@opengsd/gsd-core 1.9.1 → 1.11.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 +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +27 -3
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +12 -10
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +46 -0
- package/agents/gsd-planner.md +49 -54
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +26 -73
- package/bin/install.js +1272 -1238
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +2 -2
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/plan-phase.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1009 -115
- package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
- package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
- package/gsd-core/bin/lib/api-coverage.cjs +123 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +926 -202
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +608 -148
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +507 -24
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +114 -38
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +94 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +665 -99
- package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +76 -0
- package/gsd-core/bin/lib/config.cjs +22 -2
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +217 -40
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +239 -32
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-integration.cjs +13 -1
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1325 -169
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +805 -264
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +160 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
- package/gsd-core/bin/lib/io.cjs +38 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +821 -109
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +507 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +258 -58
- package/gsd-core/bin/lib/phase.cjs +891 -156
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +405 -84
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
- package/gsd-core/bin/lib/smart-entry.cjs +154 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +424 -105
- package/gsd-core/bin/lib/state.cjs +1927 -401
- package/gsd-core/bin/lib/surface.cjs +35 -10
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
- package/gsd-core/bin/lib/uat.cjs +706 -64
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +33 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -20
- package/gsd-core/bin/lib/verify.cjs +368 -880
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
- package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
- package/gsd-core/bin/lib/workstream.cjs +8 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/debugger-techniques.md +255 -0
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/research-documentation-lookup.md +5 -3
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +8 -7
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/references/worktree-branch-check.md +2 -2
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +33 -70
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +74 -166
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +160 -95
- package/gsd-core/workflows/debug.md +16 -17
- package/gsd-core/workflows/diagnose-issues.md +56 -8
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +103 -187
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/help/modes/full.md +6 -1
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +7 -6
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +35 -47
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +27 -240
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +89 -209
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +45 -159
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +55 -405
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +41 -13
- package/gsd-core/workflows/section-manifest.json +219 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +41 -20
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +106 -13
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +53 -31
- package/gsd-core/workflows/ui-phase.md +13 -12
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +19 -8
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +17 -65
- package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
- package/hooks/dist/gsd-check-update-worker.js +64 -12
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +45 -24
- package/hooks/dist/gsd-statusline.js +90 -6
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +277 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +517 -0
- package/hooks/gsd-check-update-worker.js +64 -12
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +607 -26
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +45 -24
- package/hooks/gsd-statusline.js +90 -6
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +277 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +31 -10
- package/pi/gsd.cjs +71 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +68 -6
- package/scripts/changeset/serialize.cjs +5 -1
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-matrix.cjs +1 -1
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +439 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +150 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-compiled-artifact-sync.cjs +6 -1
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-example-parser-parity.cjs +395 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +40 -3
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +52 -6
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +2 -2
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +2 -2
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +1 -2
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -577
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/gen-emitted-baseline.cjs +0 -145
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1050
|
@@ -29,16 +29,22 @@ const configLoaderMod = require("./config-loader.cjs");
|
|
|
29
29
|
const { loadConfig } = configLoaderMod;
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
|
31
31
|
const coreUtilsMod = require("./core-utils.cjs");
|
|
32
|
-
|
|
32
|
+
// #2528: `extractCanonicalPlanId` used to exist here as a byte-identical second
|
|
33
|
+
// copy, and this PR had to patch BOTH with the same rewind rule — the exact
|
|
34
|
+
// generative-fix divergence CLAUDE.md warns about. Collapsed onto core-utils'
|
|
35
|
+
// copy, which was already the leaf owner, so there is no second surface left to
|
|
36
|
+
// drift and no parity test needed to police one.
|
|
37
|
+
const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, } = coreUtilsMod;
|
|
33
38
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
34
39
|
const phaseIdMod = require("./phase-id.cjs");
|
|
35
|
-
const {
|
|
40
|
+
const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
|
|
41
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
36
42
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
|
37
43
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
38
|
-
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
|
|
44
|
+
const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocatorMod;
|
|
39
45
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
|
40
46
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
41
|
-
const { stripShippedMilestones, extractCurrentMilestone,
|
|
47
|
+
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
|
|
42
48
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
|
43
49
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
44
50
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
@@ -62,11 +68,14 @@ const verificationMod = require("./verification.cjs");
|
|
|
62
68
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verify.cjs is an export= CommonJS module
|
|
63
69
|
const verifyMod = require("./verify.cjs");
|
|
64
70
|
const { readVerificationStatus } = verificationMod;
|
|
65
|
-
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
|
|
72
|
+
const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
|
|
73
|
+
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
|
|
74
|
+
const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, } = planningWorkspace;
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
|
|
76
|
+
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
66
77
|
const { extractFrontmatter } = frontmatterMod;
|
|
67
|
-
const { readModifyWriteStateMd, stateExtractField, stateReplaceField,
|
|
68
|
-
// #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
|
|
69
|
-
const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
|
|
78
|
+
const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, withStateLock, updatePerformanceMetricsSection, } = stateMod;
|
|
70
79
|
// Any .md file with PLAN anywhere in the basename — diagnostic net
|
|
71
80
|
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
|
72
81
|
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
|
@@ -135,27 +144,6 @@ function describeNonCanonicalPlans(dirFiles, matchedFiles) {
|
|
|
135
144
|
`. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
|
|
136
145
|
`See agents/gsd-planner.md write_phase_prompt step for the full contract.`);
|
|
137
146
|
}
|
|
138
|
-
function extractCanonicalPlanId(filename) {
|
|
139
|
-
const base = filename
|
|
140
|
-
.replace(/-PLAN\.md$/i, '')
|
|
141
|
-
.replace(/-SUMMARY\.md$/i, '')
|
|
142
|
-
.replace(/\.md$/i, '');
|
|
143
|
-
const parts = base.split('-').filter(Boolean);
|
|
144
|
-
// #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
|
|
145
|
-
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
|
146
|
-
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
|
147
|
-
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
|
148
|
-
// #2232: the PAIRED plan component is a zero-padded continuation segment
|
|
149
|
-
// (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
|
|
150
|
-
// bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
|
|
151
|
-
// \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
|
|
152
|
-
const planTokenRe = new RegExp(`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
|
|
153
|
-
const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
|
|
154
|
-
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
|
|
155
|
-
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
|
156
|
-
}
|
|
157
|
-
return base;
|
|
158
|
-
}
|
|
159
147
|
function cmdPhasesList(cwd, options, raw) {
|
|
160
148
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
161
149
|
const { type, phase, includeArchived } = options;
|
|
@@ -169,24 +157,56 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
169
157
|
return;
|
|
170
158
|
}
|
|
171
159
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
160
|
+
// #3185 (ADR-3180 Decision 1): only the ENUMERATION routes through the
|
|
161
|
+
// single owner. The two other modes below ask genuinely DIFFERENT
|
|
162
|
+
// questions and are exempt by documented reason, never by a file
|
|
163
|
+
// allowlist (ADR-3180 Decision 4a):
|
|
164
|
+
//
|
|
165
|
+
// --phase <n> locating ONE phase by token is phase LOCATION, a
|
|
166
|
+
// question src/phase-locator.cts already owns via
|
|
167
|
+
// findPhaseInternal/searchPhaseInDir. Scoping it to
|
|
168
|
+
// the current milestone would make an out-of-window
|
|
169
|
+
// phase report "Phase not found".
|
|
170
|
+
// --include-archived archived directories are BY DEFINITION from other
|
|
171
|
+
// milestones; filtering them through the CURRENT
|
|
172
|
+
// milestone window would return nothing at all.
|
|
173
|
+
//
|
|
174
|
+
// Generalizing #3183's rule ("a diagnostic about file NAMING wants the
|
|
175
|
+
// physical set; only a question about outstanding WORK wants the live
|
|
176
|
+
// set"): a LOOKUP wants the physical set; only "which phases belong to
|
|
177
|
+
// this milestone" wants the scoped set.
|
|
178
|
+
const archivedLabels = includeArchived
|
|
179
|
+
? getArchivedPhaseDirs(cwd).map((a) => `${a.name} [${a.milestone}]`)
|
|
180
|
+
: [];
|
|
181
|
+
let dirs;
|
|
182
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
183
|
+
// can tell a genuinely-empty milestone from one it could not scope. Only
|
|
184
|
+
// the ENUMERATION path scopes anything; the LOOKUP path below has no
|
|
185
|
+
// enumeration to report a scope for.
|
|
186
|
+
let phaseScope = null;
|
|
181
187
|
if (phase) {
|
|
188
|
+
// LOOKUP (b): search the physical set, plus archived when asked.
|
|
189
|
+
const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
|
|
182
190
|
const normalized = normalizePhaseName(phase);
|
|
183
|
-
|
|
191
|
+
// The pool is #3185's (physical set + archived); the matcher is this
|
|
192
|
+
// PR's. `dirs` is deliberately not read here: on this base it is not
|
|
193
|
+
// assigned until the branch below picks a match.
|
|
194
|
+
const { matches } = matchPhaseDirs(lookupPool, normalized);
|
|
195
|
+
const match = matches[0];
|
|
184
196
|
if (!match) {
|
|
185
197
|
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
|
|
186
198
|
return;
|
|
187
199
|
}
|
|
188
200
|
dirs = [match];
|
|
189
201
|
}
|
|
202
|
+
else {
|
|
203
|
+
// ENUMERATION (a): milestone-scoped and sentinel-filtered, plus
|
|
204
|
+
// archived when asked (c).
|
|
205
|
+
const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
|
|
206
|
+
phaseScope = enumerated.scope;
|
|
207
|
+
dirs = [...enumerated.value, ...archivedLabels];
|
|
208
|
+
dirs.sort((a, b) => comparePhaseNum(a, b));
|
|
209
|
+
}
|
|
190
210
|
if (type) {
|
|
191
211
|
const files = [];
|
|
192
212
|
const warnings = [];
|
|
@@ -195,13 +215,31 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
195
215
|
const dirFiles = node_fs_1.default.readdirSync(dirPath);
|
|
196
216
|
let filtered;
|
|
197
217
|
if (type === 'plans') {
|
|
198
|
-
|
|
218
|
+
// #3183: this is a "what plan files physically exist" query (this
|
|
219
|
+
// IS the file-listing command), not a live-completion question, so
|
|
220
|
+
// it uses the single owner's allPlanFiles (root+nested, INCLUDING
|
|
221
|
+
// status: superseded) rather than a root-only readdirSync filter
|
|
222
|
+
// that also missed nested plans.
|
|
223
|
+
//
|
|
224
|
+
// #2893 (regression fix): `allPlanFiles` also carries
|
|
225
|
+
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately
|
|
226
|
+
// permissive for live-plan COUNTING elsewhere — see
|
|
227
|
+
// plan-count-single-owner.test.cjs). That fallback silently
|
|
228
|
+
// recognized a non-canonically-named file (e.g.
|
|
229
|
+
// `01-PLAN-01-foundation.md`) as "matched", which defeated this
|
|
230
|
+
// command's #2893 naming-convention diagnostic entirely (no
|
|
231
|
+
// warning, file listed as if valid). Intersect with the STRICT
|
|
232
|
+
// `isCanonicalPlanFile` predicate so this diagnostic — and the
|
|
233
|
+
// `files` list this command actually returns — only ever
|
|
234
|
+
// recognizes the canonical root/nested forms, exactly like the
|
|
235
|
+
// pre-#3183 behavior this feature was built and tested against.
|
|
236
|
+
filtered = scanPhasePlans(dirPath).allPlanFiles.filter(isCanonicalPlanFile);
|
|
199
237
|
const w = describeNonCanonicalPlans(dirFiles, filtered);
|
|
200
238
|
if (w)
|
|
201
239
|
warnings.push(`${dir}: ${w}`);
|
|
202
240
|
}
|
|
203
241
|
else if (type === 'summaries') {
|
|
204
|
-
filtered =
|
|
242
|
+
filtered = scanPhasePlans(dirPath).summaryFiles;
|
|
205
243
|
}
|
|
206
244
|
else {
|
|
207
245
|
filtered = dirFiles;
|
|
@@ -212,13 +250,18 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
212
250
|
files,
|
|
213
251
|
count: files.length,
|
|
214
252
|
phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
|
|
253
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
254
|
+
// can tell a genuinely-empty milestone from one it could not scope.
|
|
255
|
+
phase_scope: phaseScope,
|
|
215
256
|
};
|
|
216
257
|
if (warnings.length)
|
|
217
258
|
result['warning'] = warnings.join(' | ');
|
|
218
259
|
output(result, raw, files.join('\n'));
|
|
219
260
|
return;
|
|
220
261
|
}
|
|
221
|
-
|
|
262
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
263
|
+
// can tell a genuinely-empty milestone from one it could not scope.
|
|
264
|
+
output({ directories: dirs, count: dirs.length, phase_scope: phaseScope }, raw, dirs.join('\n'));
|
|
222
265
|
}
|
|
223
266
|
catch (e) {
|
|
224
267
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -234,8 +277,8 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
|
234
277
|
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
235
278
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
236
279
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
237
|
-
baseExists = dirs
|
|
238
|
-
const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
|
|
280
|
+
baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
|
|
281
|
+
const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalized)}\\.(\\d+)`);
|
|
239
282
|
for (const dir of dirs) {
|
|
240
283
|
const match = dir.match(dirPattern);
|
|
241
284
|
if (match)
|
|
@@ -348,6 +391,13 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
348
391
|
phase_name: null,
|
|
349
392
|
plans: [],
|
|
350
393
|
summaries: [],
|
|
394
|
+
// #3218: scalar counts alongside the arrays above. Left `null` (not `0`)
|
|
395
|
+
// when the phase can't be resolved at all — a fabricated `0` here would
|
|
396
|
+
// read identically to "phase exists with zero plans", which is a real,
|
|
397
|
+
// distinct answer (see the `status: superseded` case below).
|
|
398
|
+
plan_count: null,
|
|
399
|
+
summary_count: null,
|
|
400
|
+
plan_count_all: null,
|
|
351
401
|
searched_directories: [],
|
|
352
402
|
};
|
|
353
403
|
const searchDirs = [];
|
|
@@ -378,7 +428,10 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
378
428
|
// #2237: fail loud when multiple directories match the same bare phase
|
|
379
429
|
// number — prevents cross-project file writes when unrelated projects
|
|
380
430
|
// share a .planning/phases/ tree.
|
|
381
|
-
|
|
431
|
+
// #2528: selection delegates to the canonical two-pass matcher (exact
|
|
432
|
+
// token match, then the bare-integer leading-digit-run fallback) shared
|
|
433
|
+
// with the locator and the phase-plan-index scan.
|
|
434
|
+
const { matches } = matchPhaseDirs(dirs, normalized);
|
|
382
435
|
if (matches.length === 0)
|
|
383
436
|
continue;
|
|
384
437
|
if (matches.length > 1) {
|
|
@@ -395,9 +448,29 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
395
448
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
396
449
|
const phaseDir = node_path_1.default.join(searchDir, match);
|
|
397
450
|
const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
451
|
+
// #3183: canonical, live (superseded-excluded) plan/summary sets
|
|
452
|
+
// (root+nested) from the single owner, rather than a root-only
|
|
453
|
+
// isCanonicalPlanFile filter + hand-rolled summary filter.
|
|
454
|
+
//
|
|
455
|
+
// #2893 (regression fix): both `plans` and the naming-diagnostic
|
|
456
|
+
// "matched" set are further intersected with the STRICT
|
|
457
|
+
// `isCanonicalPlanFile` predicate — scanPhasePlans's own
|
|
458
|
+
// planFiles/allPlanFiles carry `isRootPlanFile`'s loose `/PLAN/i`
|
|
459
|
+
// fallback (deliberately permissive for live-plan COUNTING elsewhere),
|
|
460
|
+
// which silently recognized a non-canonically-named file (e.g.
|
|
461
|
+
// `01-PLAN-01-foundation.md`) as a valid plan here and defeated this
|
|
462
|
+
// command's #2893 naming-convention diagnostic (no warning, offender
|
|
463
|
+
// listed in `plans` as if valid).
|
|
464
|
+
const phaseScan = scanPhasePlans(phaseDir);
|
|
465
|
+
const plans = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
|
466
|
+
const summaries = phaseScan.summaryFiles.slice().sort();
|
|
467
|
+
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated
|
|
468
|
+
// to supersession — compare against allPlanFiles (every plan-shaped file
|
|
469
|
+
// the owner recognizes, canonical or not) rather than the live-only
|
|
470
|
+
// `plans`, so a superseded-but-canonically-named plan is not misreported
|
|
471
|
+
// as a naming violation.
|
|
472
|
+
const canonicalAllPlanFiles = phaseScan.allPlanFiles.filter(isCanonicalPlanFile);
|
|
473
|
+
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, canonicalAllPlanFiles);
|
|
401
474
|
const result = {
|
|
402
475
|
found: true,
|
|
403
476
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir), match)),
|
|
@@ -405,6 +478,20 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
405
478
|
phase_name: phaseName,
|
|
406
479
|
plans,
|
|
407
480
|
summaries,
|
|
481
|
+
// #3218: scalar counts additive alongside `plans[]`/`summaries[]`,
|
|
482
|
+
// which stay unchanged for existing consumers. Naming mirrors
|
|
483
|
+
// `roadmap.analyze`'s `plan_count`/`summary_count` (live, i.e.
|
|
484
|
+
// status:superseded EXCLUDED — same set as `plans`/`summaries`
|
|
485
|
+
// above) so the two surfaces read alike. `plan_count_all` is the
|
|
486
|
+
// PHYSICAL count — every canonically-named plan file on disk,
|
|
487
|
+
// status:superseded INCLUDED, same set `planNamingWarning` above
|
|
488
|
+
// diffs against (`canonicalAllPlanFiles`). The `_all` suffix
|
|
489
|
+
// deliberately echoes `scanPhasePlans`'s own `allPlanFiles` field so
|
|
490
|
+
// a reader can trace the name back to its source rather than guess
|
|
491
|
+
// which of two similarly-named integers is the filtered one.
|
|
492
|
+
plan_count: plans.length,
|
|
493
|
+
summary_count: summaries.length,
|
|
494
|
+
plan_count_all: canonicalAllPlanFiles.length,
|
|
408
495
|
};
|
|
409
496
|
if (planNamingWarning)
|
|
410
497
|
result['warning'] = planNamingWarning;
|
|
@@ -421,9 +508,26 @@ function extractObjective(content) {
|
|
|
421
508
|
const m = content.match(/<objective>\s*\n?\s*(.+)/);
|
|
422
509
|
return m ? m[1].trim() : null;
|
|
423
510
|
}
|
|
511
|
+
/**
|
|
512
|
+
* Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
|
|
513
|
+
* (case-folded exact match, falling back to canonical-id matching). Returns
|
|
514
|
+
* `null` when the token does not resolve to any plan in this phase (a typo
|
|
515
|
+
* or a cross-phase reference) — every call site treats that as "ignore this
|
|
516
|
+
* edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
|
|
517
|
+
* resolution, the `depends_on` display mapping, and (#2830) the
|
|
518
|
+
* halt-propagation node resolution, so the three can never disagree about
|
|
519
|
+
* which token resolves to which plan.
|
|
520
|
+
*/
|
|
521
|
+
function resolveDependencyId(dep, planMap, canonicalToId) {
|
|
522
|
+
const lower = dep.toLowerCase();
|
|
523
|
+
return planMap.has(lower) ? planMap.get(lower).id : (canonicalToId.get(lower) ?? null);
|
|
524
|
+
}
|
|
424
525
|
// O(V + E). Assigns each in-phase plan its longest-path topological level over the
|
|
425
|
-
// in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number
|
|
426
|
-
// visited < rawPlans.length signals a dependency cycle.
|
|
526
|
+
// in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
|
|
527
|
+
// order: string[] }. visited < rawPlans.length signals a dependency cycle. `order` (#2830) is
|
|
528
|
+
// the exact dequeue order this pass already produces — a valid topological order — passed to
|
|
529
|
+
// computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
|
|
530
|
+
// algorithm a second time over the same graph.
|
|
427
531
|
function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
|
|
428
532
|
const level = new Map();
|
|
429
533
|
const inDeg = new Map();
|
|
@@ -434,10 +538,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
|
|
|
434
538
|
if (!adj.has(p.id))
|
|
435
539
|
adj.set(p.id, []);
|
|
436
540
|
for (const dep of p.dependsOn) {
|
|
437
|
-
const
|
|
438
|
-
const resolvedDep = planMap.has(depLower)
|
|
439
|
-
? planMap.get(depLower).id
|
|
440
|
-
: canonicalToId.get(depLower);
|
|
541
|
+
const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
|
|
441
542
|
if (!resolvedDep)
|
|
442
543
|
continue;
|
|
443
544
|
if (!adj.has(resolvedDep))
|
|
@@ -472,7 +573,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
|
|
|
472
573
|
}
|
|
473
574
|
}
|
|
474
575
|
}
|
|
475
|
-
return { level, visited };
|
|
576
|
+
return { level, visited, order: queue };
|
|
476
577
|
}
|
|
477
578
|
function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
478
579
|
if (!phase) {
|
|
@@ -482,35 +583,97 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
482
583
|
const normalized = normalizePhaseName(phase);
|
|
483
584
|
let phaseDir = null;
|
|
484
585
|
let phaseDirName = null;
|
|
586
|
+
let ambiguousMatches = null;
|
|
485
587
|
try {
|
|
486
588
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
487
589
|
const dirs = entries
|
|
488
590
|
.filter((e) => e.isDirectory())
|
|
489
591
|
.map((e) => e.name)
|
|
490
592
|
.sort((a, b) => comparePhaseNum(a, b));
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
593
|
+
// #2528: selection delegates to the canonical two-pass matcher shared with
|
|
594
|
+
// the locator and the find-phase scan (this site previously first-matched
|
|
595
|
+
// with `.find()` and had no multi-match guard — the #2237 fail-loud rule
|
|
596
|
+
// now applies here too, so the three resolution paths cannot disagree).
|
|
597
|
+
const { matches } = matchPhaseDirs(dirs, normalized);
|
|
598
|
+
if (matches.length > 1) {
|
|
599
|
+
ambiguousMatches = matches;
|
|
600
|
+
}
|
|
601
|
+
else if (matches.length === 1) {
|
|
602
|
+
phaseDir = node_path_1.default.join(phasesDir, matches[0]);
|
|
603
|
+
phaseDirName = matches[0];
|
|
495
604
|
}
|
|
496
605
|
}
|
|
497
606
|
catch {
|
|
498
607
|
// phases dir doesn't exist
|
|
499
608
|
}
|
|
609
|
+
if (ambiguousMatches) {
|
|
610
|
+
output({
|
|
611
|
+
phase: normalized,
|
|
612
|
+
error: `Phase ${normalized} is ambiguous: ${ambiguousMatches.length} directories match (${ambiguousMatches.map((m) => `"${m}"`).join(', ')}).`,
|
|
613
|
+
ambiguous_matches: ambiguousMatches,
|
|
614
|
+
plans: [], waves: {}, incomplete: [], has_checkpoints: false,
|
|
615
|
+
}, raw);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
500
618
|
if (!phaseDir) {
|
|
501
|
-
output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
|
|
619
|
+
output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false }, raw);
|
|
502
620
|
return;
|
|
503
621
|
}
|
|
504
622
|
void phaseDirName; // used only to set phaseDir above
|
|
623
|
+
// phaseFiles stays root-only readdirSync — it feeds only
|
|
624
|
+
// describeNonCanonicalPlans's near-miss naming diagnostic below, which is
|
|
625
|
+
// advisory text, not a counted/scheduled file set.
|
|
505
626
|
const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
627
|
+
// #3183 (highest-severity site, ADR-3180 Decision 2): canonical LIVE
|
|
628
|
+
// plan/summary sets (root+nested, status: superseded EXCLUDED) from the
|
|
629
|
+
// single owner. This fixes two real bugs in the wave/dependency index this
|
|
630
|
+
// function builds: (1) a superseded plan used to still get scheduled into
|
|
631
|
+
// an execution wave, and (2) a phase using the #3139 nested `plans/`
|
|
632
|
+
// layout used to report ZERO plans (root-only readdirSync, no `plans/`
|
|
633
|
+
// join).
|
|
634
|
+
// #2893 (regression fix): intersected with the STRICT `isCanonicalPlanFile`
|
|
635
|
+
// predicate — scanPhasePlans's own planFiles/allPlanFiles carry
|
|
636
|
+
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately permissive for
|
|
637
|
+
// live-plan COUNTING elsewhere), which silently scheduled a
|
|
638
|
+
// non-canonically-named file (e.g. `01-PLAN-01-foundation.md`) into a wave
|
|
639
|
+
// here and defeated this command's #2893 naming-convention diagnostic (no
|
|
640
|
+
// warning). Restores the pre-#3183, tested behavior: only canonical
|
|
641
|
+
// root/nested filenames are ever counted or scheduled by this command.
|
|
642
|
+
const phaseScan = scanPhasePlans(phaseDir);
|
|
643
|
+
const planFiles = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
|
644
|
+
const summaryFiles = phaseScan.summaryFiles;
|
|
645
|
+
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated to
|
|
646
|
+
// supersession — compare against allPlanFiles (every plan-shaped file the
|
|
647
|
+
// owner recognizes, canonical or not) rather than the live-only planFiles,
|
|
648
|
+
// so a superseded-but-canonically-named plan is not misreported as a
|
|
649
|
+
// naming violation.
|
|
650
|
+
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, phaseScan.allPlanFiles.filter(isCanonicalPlanFile));
|
|
651
|
+
// #3183: completion pairing via the canonical findUnsummarizedPlans
|
|
652
|
+
// (shares its `summaryCandidates` matching rule with countMatchedSummaries,
|
|
653
|
+
// and is layout-agnostic — it pairs a nested `plans/PLAN-01.md` with
|
|
654
|
+
// `plans/SUMMARY-01.md` correctly) instead of a bespoke ID-Set built from
|
|
655
|
+
// extractCanonicalPlanId, which only ever handled the root-canonical
|
|
656
|
+
// `-PLAN.md`/`-SUMMARY.md` naming form.
|
|
657
|
+
//
|
|
658
|
+
// #3345: the summary list is filtered through the SAME shared predicate
|
|
659
|
+
// scanPhasePlans filters its countable set with
|
|
660
|
+
// (plan-dependency-graph.cjs's isSummaryFileBlocked), so a SUMMARY declaring
|
|
661
|
+
// `status: blocked` reads as NO completion record here — has_summary false,
|
|
662
|
+
// the plan lands in `incomplete` — exactly matching the count side. Fail-open
|
|
663
|
+
// on a SUMMARY with no status key / unreadable file (filename fallback);
|
|
664
|
+
// `status: halted` stays summarized (#2830 designed stop). summaryFileByPlanId
|
|
665
|
+
// below still indexes EVERY summary on disk because the halted lookup is a
|
|
666
|
+
// file resolution for reading status, not a completion pairing.
|
|
667
|
+
const countableSummaryFiles = summaryFiles.filter((f) => !isSummaryFileBlocked(node_path_1.default.join(phaseDir, f)));
|
|
668
|
+
const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, countableSummaryFiles));
|
|
669
|
+
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
|
|
670
|
+
// the actual summary filename, so a plan's own SUMMARY frontmatter can be
|
|
671
|
+
// read for its `status`. Shared builder (also used by phase-locator.cts's
|
|
672
|
+
// searchPhaseInDir) so the two can never disagree about which summary
|
|
673
|
+
// belongs to which plan. This is a FILE resolution for reading halted
|
|
674
|
+
// status, not a completion-count pairing rule, so it is unaffected by the
|
|
675
|
+
// #3183 pairing migration above.
|
|
676
|
+
const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
|
|
514
677
|
// ── Pass 1: parse each plan file ─────────────────────────────────────────
|
|
515
678
|
const rawPlans = [];
|
|
516
679
|
for (const planFile of planFiles) {
|
|
@@ -543,7 +706,26 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
543
706
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
|
544
707
|
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
|
|
545
708
|
}
|
|
546
|
-
|
|
709
|
+
// #1689: optional per-plan specialist executor hint. Read verbatim here; the
|
|
710
|
+
// orchestrator resolves it against the active runtime's agent dir at dispatch
|
|
711
|
+
// time (execute-phase.md -> `gsd_run query resolve-agent`), falling back to
|
|
712
|
+
// gsd-executor when the field is unset or the named agent does not resolve.
|
|
713
|
+
let agentHint = null;
|
|
714
|
+
const fmAgentHint = fm['agent_hint'];
|
|
715
|
+
if (fmAgentHint !== undefined) {
|
|
716
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
|
717
|
+
const hintStr = String(fmAgentHint).trim();
|
|
718
|
+
agentHint = hintStr !== '' ? hintStr : null;
|
|
719
|
+
}
|
|
720
|
+
const hasSummary = !unsummarizedPlanFiles.has(planFile);
|
|
721
|
+
// #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
|
|
722
|
+
// a designed stop still writes a completion record, just one whose status
|
|
723
|
+
// says "halted" rather than "complete". Only look up the summary file
|
|
724
|
+
// when one exists; there is nothing to read otherwise.
|
|
725
|
+
const summaryFile = summaryFileByPlanId.get(planId) ?? summaryFileByPlanId.get(extractCanonicalPlanId(planFile));
|
|
726
|
+
const halted = hasSummary && summaryFile !== undefined
|
|
727
|
+
? isSummaryFileHalted(node_path_1.default.join(phaseDir, summaryFile))
|
|
728
|
+
: false;
|
|
547
729
|
rawPlans.push({
|
|
548
730
|
id: planId,
|
|
549
731
|
declaredWave,
|
|
@@ -551,8 +733,10 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
551
733
|
autonomous,
|
|
552
734
|
objective: extractObjective(content) || fm['objective'] || null,
|
|
553
735
|
filesModified,
|
|
736
|
+
agentHint,
|
|
554
737
|
taskCount,
|
|
555
738
|
hasSummary,
|
|
739
|
+
halted,
|
|
556
740
|
});
|
|
557
741
|
}
|
|
558
742
|
// ── Pass 2: topological level assignment via depends_on DAG ──────────────
|
|
@@ -568,26 +752,47 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
568
752
|
}
|
|
569
753
|
const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
|
|
570
754
|
const canonicalToId = new Map(rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
|
|
571
|
-
const { level, visited } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
|
|
755
|
+
const { level, visited, order } = computeDependencyLevels(rawPlans, planMap, canonicalToId);
|
|
572
756
|
if (visited < rawPlans.length) {
|
|
573
757
|
const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
|
|
574
758
|
error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
|
|
575
759
|
return;
|
|
576
760
|
}
|
|
761
|
+
// #2830: single shared halt-propagation pass, reusing the SAME id
|
|
762
|
+
// resolution (planMap/canonicalToId) AND the SAME topological order
|
|
763
|
+
// (`order`, computeDependencyLevels's own Kahn's-algorithm dequeue
|
|
764
|
+
// sequence) — passed as `precomputedOrder` so computeHaltPropagation does
|
|
765
|
+
// NOT run Kahn's algorithm a second time over this graph.
|
|
766
|
+
const haltNodes = rawPlans.map((p) => ({
|
|
767
|
+
id: p.id,
|
|
768
|
+
resolvedDependsOn: p.dependsOn
|
|
769
|
+
.map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
|
|
770
|
+
.filter((id) => id !== null),
|
|
771
|
+
halted: p.halted,
|
|
772
|
+
}));
|
|
773
|
+
const { blockedBy } = computeHaltPropagation(haltNodes, order);
|
|
577
774
|
// ── Pass 3: determine lowest bucket key and build output ─────────────────
|
|
578
775
|
const anyWaveZero = rawPlans.some((p) => p.declaredWave === 0);
|
|
579
776
|
const levelOffset = anyWaveZero ? 0 : 1;
|
|
580
777
|
const plans = [];
|
|
581
778
|
const waves = {};
|
|
582
779
|
const incomplete = [];
|
|
780
|
+
const runnable = [];
|
|
583
781
|
let hasCheckpoints = false;
|
|
584
782
|
const warnings = [];
|
|
585
783
|
for (const rawPlan of rawPlans) {
|
|
586
784
|
if (!rawPlan.autonomous) {
|
|
587
785
|
hasCheckpoints = true;
|
|
588
786
|
}
|
|
787
|
+
const blockedByIds = blockedBy.get(rawPlan.id) ?? [];
|
|
589
788
|
if (!rawPlan.hasSummary) {
|
|
590
789
|
incomplete.push(rawPlan.id);
|
|
790
|
+
// #2830: the runnable-only view — incomplete AND not transitively
|
|
791
|
+
// blocked by a halted upstream plan. Additive alongside `incomplete`,
|
|
792
|
+
// which keeps its existing "no SUMMARY yet" meaning unchanged.
|
|
793
|
+
if (blockedByIds.length === 0) {
|
|
794
|
+
runnable.push(rawPlan.id);
|
|
795
|
+
}
|
|
591
796
|
}
|
|
592
797
|
const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
|
|
593
798
|
const effectiveWave = computedWave;
|
|
@@ -597,6 +802,13 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
597
802
|
const plan = {
|
|
598
803
|
id: rawPlan.id,
|
|
599
804
|
wave: effectiveWave,
|
|
805
|
+
// DELIBERATELY not `resolveDependencyId`: the emitted field is a DISPLAY
|
|
806
|
+
// mapping, not the DAG resolution. It rewrites a dep only when it names a
|
|
807
|
+
// plan directly (planMap) and otherwise passes it through verbatim — a
|
|
808
|
+
// short canonical prefix like `24-01` stays `24-01` rather than becoming
|
|
809
|
+
// `24-01-auth-hardening`. #3785 pins that contract. Full resolution via
|
|
810
|
+
// canonicalToId is used for the wave DAG and #2830 halt propagation only;
|
|
811
|
+
// routing this line through it too silently changed the output shape.
|
|
600
812
|
depends_on: rawPlan.dependsOn.map((dep) => {
|
|
601
813
|
const lower = String(dep).toLowerCase();
|
|
602
814
|
return planMap.has(lower) ? planMap.get(lower).id : dep;
|
|
@@ -604,8 +816,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
604
816
|
autonomous: rawPlan.autonomous,
|
|
605
817
|
objective: rawPlan.objective,
|
|
606
818
|
files_modified: rawPlan.filesModified,
|
|
819
|
+
agent_hint: rawPlan.agentHint,
|
|
607
820
|
task_count: rawPlan.taskCount,
|
|
608
821
|
has_summary: rawPlan.hasSummary,
|
|
822
|
+
// #2830: additive fields — halted is this plan's OWN status; blocked_by
|
|
823
|
+
// names the halted plan(s) transitively upstream of it (empty when not
|
|
824
|
+
// blocked). Neither mutates has_summary/incomplete's existing meaning.
|
|
825
|
+
halted: rawPlan.halted,
|
|
826
|
+
blocked_by: blockedByIds,
|
|
609
827
|
};
|
|
610
828
|
plans.push(plan);
|
|
611
829
|
const waveKey = String(effectiveWave);
|
|
@@ -619,6 +837,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
619
837
|
plans,
|
|
620
838
|
waves,
|
|
621
839
|
incomplete,
|
|
840
|
+
runnable,
|
|
622
841
|
has_checkpoints: hasCheckpoints,
|
|
623
842
|
};
|
|
624
843
|
if (planNamingWarning)
|
|
@@ -650,10 +869,54 @@ function describeGoalShapedTitle(description) {
|
|
|
650
869
|
return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
|
|
651
870
|
`as the phase title; consider a short title with the detail moved to **Goal:**.`);
|
|
652
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
|
|
874
|
+
* entry should be inserted — at the end of the active phase list, scoped to the
|
|
875
|
+
* CURRENT MILESTONE so the entry can never land before a trailing `---` in
|
|
876
|
+
* shipped/history/backlog material (the file's last `---` on a long roadmap
|
|
877
|
+
* sits deep in archive). When no current milestone can be resolved (no
|
|
878
|
+
* STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
|
|
879
|
+
* legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
|
|
880
|
+
* their existing behavior.
|
|
881
|
+
*/
|
|
882
|
+
function phaseEntryInsertOffset(rawContent, cwd) {
|
|
883
|
+
const ranges = currentMilestoneRawRanges(rawContent, cwd);
|
|
884
|
+
if (!ranges) {
|
|
885
|
+
const legacy = rawContent.lastIndexOf('\n---');
|
|
886
|
+
return legacy > 0 ? legacy : rawContent.length;
|
|
887
|
+
}
|
|
888
|
+
const window = rawContent.slice(ranges.primary.start, ranges.primary.end);
|
|
889
|
+
const lastSeparator = window.lastIndexOf('\n---');
|
|
890
|
+
return lastSeparator > 0 ? ranges.primary.start + lastSeparator : ranges.primary.end;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* #3262 (write-time milestone-scope guard): the phase-creation and
|
|
894
|
+
* phase-insertion entry templates interpolate the caller's `description`
|
|
895
|
+
* verbatim into `### Phase N: ${description}`. A description embedding a
|
|
896
|
+
* level 1-3 heading that carries a milestone marker (version token,
|
|
897
|
+
* ✅/📋/🚧/🔄, or the word "Milestone") would splice a heading that TERMINATES
|
|
898
|
+
* the current milestone window (`computeMilestoneSectionEnd`) and silently
|
|
899
|
+
* drops every later phase out of the derived milestone phase set. Reject
|
|
900
|
+
* before any write or phase-directory creation — the fail-loud sibling of
|
|
901
|
+
* the edit-phase workflow's depends_on gate. The predicate itself
|
|
902
|
+
* (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt,
|
|
903
|
+
* so ordinary descriptions and the phase's own numbered heading never trip it.
|
|
904
|
+
*/
|
|
905
|
+
function assertDescriptionPreservesMilestoneScope(description, command) {
|
|
906
|
+
const offending = findMilestoneScopeHeadingLines(description);
|
|
907
|
+
if (offending.length === 0)
|
|
908
|
+
return;
|
|
909
|
+
error(`${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` +
|
|
910
|
+
`the current milestone window and silently drop later phases out of the milestone scope. ` +
|
|
911
|
+
`Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` +
|
|
912
|
+
`Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
|
|
913
|
+
`(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").`);
|
|
914
|
+
}
|
|
653
915
|
function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
654
916
|
if (!description) {
|
|
655
917
|
error('description required for phase add');
|
|
656
918
|
}
|
|
919
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase add');
|
|
657
920
|
const config = loadConfig(cwd);
|
|
658
921
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
659
922
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
@@ -689,12 +952,14 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
689
952
|
let m;
|
|
690
953
|
while ((m = headerPattern.exec(content)) !== null) {
|
|
691
954
|
const num = parseInt(m[1], 10);
|
|
692
|
-
|
|
955
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
956
|
+
if (!isSentinelPhaseId(num))
|
|
693
957
|
usedPhaseNums.add(num);
|
|
694
958
|
}
|
|
695
959
|
while ((m = bulletPattern.exec(content)) !== null) {
|
|
696
960
|
const num = parseInt(m[1], 10);
|
|
697
|
-
|
|
961
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
962
|
+
if (!isSentinelPhaseId(num))
|
|
698
963
|
usedPhaseNums.add(num);
|
|
699
964
|
}
|
|
700
965
|
// 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
|
|
@@ -706,7 +971,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
706
971
|
if (!match)
|
|
707
972
|
continue;
|
|
708
973
|
const num = parseInt(match[1], 10);
|
|
709
|
-
|
|
974
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
975
|
+
if (!isSentinelPhaseId(num))
|
|
710
976
|
usedPhaseNums.add(num);
|
|
711
977
|
}
|
|
712
978
|
}
|
|
@@ -726,14 +992,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
726
992
|
? ''
|
|
727
993
|
: `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
|
|
728
994
|
const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_newPhaseId} to break down)\n`;
|
|
729
|
-
|
|
730
|
-
const
|
|
731
|
-
if (lastSeparator > 0) {
|
|
732
|
-
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
|
|
733
|
-
}
|
|
734
|
-
else {
|
|
735
|
-
updatedContent = rawContent + phaseEntry;
|
|
736
|
-
}
|
|
995
|
+
const insertAt = phaseEntryInsertOffset(rawContent, cwd);
|
|
996
|
+
const updatedContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
|
|
737
997
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
|
|
738
998
|
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
|
739
999
|
});
|
|
@@ -754,6 +1014,12 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
754
1014
|
if (!Array.isArray(descriptions) || descriptions.length === 0) {
|
|
755
1015
|
error('descriptions array required for phase add-batch');
|
|
756
1016
|
}
|
|
1017
|
+
// #3262: validate every description BEFORE the lock — the batch is
|
|
1018
|
+
// all-or-nothing, so one offending description must reject the whole batch
|
|
1019
|
+
// with no ROADMAP write and no phase directories created.
|
|
1020
|
+
for (const description of descriptions) {
|
|
1021
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase add-batch');
|
|
1022
|
+
}
|
|
757
1023
|
const config = loadConfig(cwd);
|
|
758
1024
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
759
1025
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
@@ -771,7 +1037,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
771
1037
|
let m;
|
|
772
1038
|
while ((m = phasePattern.exec(content)) !== null) {
|
|
773
1039
|
const num = parseInt(m[1], 10);
|
|
774
|
-
|
|
1040
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1041
|
+
if (isSentinelPhaseId(num))
|
|
775
1042
|
continue;
|
|
776
1043
|
if (num > maxPhase)
|
|
777
1044
|
maxPhase = num;
|
|
@@ -784,7 +1051,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
784
1051
|
if (!match)
|
|
785
1052
|
continue;
|
|
786
1053
|
const num = parseInt(match[1], 10);
|
|
787
|
-
|
|
1054
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1055
|
+
if (isSentinelPhaseId(num))
|
|
788
1056
|
continue;
|
|
789
1057
|
if (num > maxPhase)
|
|
790
1058
|
maxPhase = num;
|
|
@@ -812,11 +1080,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
812
1080
|
? ''
|
|
813
1081
|
: `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
|
814
1082
|
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${newPhaseId} to break down)\n`;
|
|
815
|
-
const
|
|
816
|
-
rawContent =
|
|
817
|
-
lastSeparator > 0
|
|
818
|
-
? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
|
|
819
|
-
: rawContent + phaseEntry;
|
|
1083
|
+
const insertAt = phaseEntryInsertOffset(rawContent, cwd);
|
|
1084
|
+
rawContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
|
|
820
1085
|
added.push({
|
|
821
1086
|
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
|
822
1087
|
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
|
@@ -835,6 +1100,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
835
1100
|
if (!afterPhase || !description) {
|
|
836
1101
|
error('after-phase and description required for phase insert');
|
|
837
1102
|
}
|
|
1103
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase insert');
|
|
838
1104
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
839
1105
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
840
1106
|
error('ROADMAP.md not found');
|
|
@@ -883,7 +1149,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
883
1149
|
error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
|
|
884
1150
|
}
|
|
885
1151
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
886
|
-
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
|
|
1152
|
+
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalizedBase)}\\.(\\d+)`);
|
|
887
1153
|
for (const dir of dirs) {
|
|
888
1154
|
const dm = dir.match(decimalPattern);
|
|
889
1155
|
if (dm)
|
|
@@ -911,15 +1177,30 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
911
1177
|
const phaseLabel = useBold
|
|
912
1178
|
? `**Phase ${_decimalPhase}: ${description}**`
|
|
913
1179
|
: `Phase ${_decimalPhase}: ${description}`;
|
|
1180
|
+
// #3413 review fix: bulletEntry stays hardcoded '\n'. The on-disk EOL
|
|
1181
|
+
// is decided at write time by platformWriteSync's normalizeContent /
|
|
1182
|
+
// _normalizeMd (shell-command-projection.cts), which unconditionally
|
|
1183
|
+
// converts \r\n -> \n for any .md target — so whatever terminator is
|
|
1184
|
+
// used here in memory is erased before the file is ever written, and
|
|
1185
|
+
// templating it via detectEol(rawContent) was inert dead code. '\n'
|
|
1186
|
+
// matches what platformWriteSync enforces anyway.
|
|
914
1187
|
const bulletEntry = `\n- [ ] ${phaseLabel}`;
|
|
915
|
-
|
|
1188
|
+
// #3413: was `[^\n]*`, which on CRLF content swallows the line's
|
|
1189
|
+
// trailing \r into the match, shifting bulletLineEnd to land BETWEEN
|
|
1190
|
+
// the \r and \n of the original CRLF pair — a pure splice-POSITION
|
|
1191
|
+
// bug on the not-yet-write-normalized CRLF read (independent of the
|
|
1192
|
+
// final on-disk EOL, which platformWriteSync always forces to LF for
|
|
1193
|
+
// .md targets regardless). Widening to [^\r\n]* stops the match at the
|
|
1194
|
+
// true line-content boundary so bulletLineEnd lands cleanly before the
|
|
1195
|
+
// terminator.
|
|
1196
|
+
const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*)`, 'i');
|
|
916
1197
|
const bulletMatchResult = rawContent.match(targetBulletPattern);
|
|
917
1198
|
if (!bulletMatchResult) {
|
|
918
1199
|
error(`Could not find Phase ${afterPhase} bullet line`);
|
|
919
1200
|
}
|
|
920
1201
|
const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
|
|
921
1202
|
const afterBullet = rawContent.slice(bulletLineEnd);
|
|
922
|
-
const nextBulletMatch = afterBullet.match(/\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
|
|
1203
|
+
const nextBulletMatch = afterBullet.match(/\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
|
|
923
1204
|
let insertIdx;
|
|
924
1205
|
if (nextBulletMatch) {
|
|
925
1206
|
insertIdx = bulletLineEnd + nextBulletMatch.index;
|
|
@@ -939,7 +1220,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
939
1220
|
}
|
|
940
1221
|
const headerIdx = rawContent.indexOf(headerMatch[0]);
|
|
941
1222
|
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
|
|
942
|
-
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
|
|
1223
|
+
const nextPhaseMatch = afterHeader.match(/\r?\n#{2,4}\s+Phase\s+\d[\d.]*/i);
|
|
943
1224
|
let insertIdx;
|
|
944
1225
|
if (nextPhaseMatch) {
|
|
945
1226
|
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
|
|
@@ -993,9 +1274,33 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
|
|
|
993
1274
|
}
|
|
994
1275
|
return { renamedDirs, renamedFiles };
|
|
995
1276
|
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Find a free name to move an occupying file aside to, on collision, so the
|
|
1279
|
+
* intended rename can proceed without destroying either file. Appends the
|
|
1280
|
+
* literal `.orphaned` suffix to the whole existing filename (never `.md`,
|
|
1281
|
+
* so no phase-directory scan predicate — all of which filter on
|
|
1282
|
+
* `.endsWith('.md')` / `.endsWith('-VERIFICATION.md')` etc — can ever pick
|
|
1283
|
+
* the displaced file back up as any phase's artifact). Falls back to a
|
|
1284
|
+
* numeric discriminator (`.orphaned.2`, `.orphaned.3`, ...) if `.orphaned`
|
|
1285
|
+
* itself is taken, bounded at 100 attempts so a pathological directory
|
|
1286
|
+
* cannot loop forever; returns null if no free name is found within that
|
|
1287
|
+
* bound, letting the caller fall back to skip-and-report.
|
|
1288
|
+
*/
|
|
1289
|
+
function findOrphanedDisplacementName(dir, fileName) {
|
|
1290
|
+
const base = `${fileName}.orphaned`;
|
|
1291
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, base)))
|
|
1292
|
+
return base;
|
|
1293
|
+
for (let n = 2; n <= 100; n++) {
|
|
1294
|
+
const candidate = `${base}.${n}`;
|
|
1295
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, candidate)))
|
|
1296
|
+
return candidate;
|
|
1297
|
+
}
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
996
1300
|
function renameIntegerPhases(phasesDir, removedInt) {
|
|
997
1301
|
const renamedDirs = [];
|
|
998
1302
|
const renamedFiles = [];
|
|
1303
|
+
const renamedFileCollisions = [];
|
|
999
1304
|
const dirs = readSubdirectories(phasesDir, true);
|
|
1000
1305
|
const toRename = dirs
|
|
1001
1306
|
.map((dir) => {
|
|
@@ -1003,7 +1308,8 @@ function renameIntegerPhases(phasesDir, removedInt) {
|
|
|
1003
1308
|
if (!m)
|
|
1004
1309
|
return null;
|
|
1005
1310
|
const dirInt = parseInt(m[1], 10);
|
|
1006
|
-
|
|
1311
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1312
|
+
return dirInt > removedInt && !isSentinelPhaseId(dirInt)
|
|
1007
1313
|
? {
|
|
1008
1314
|
dir,
|
|
1009
1315
|
oldInt: dirInt,
|
|
@@ -1024,21 +1330,77 @@ function renameIntegerPhases(phasesDir, removedInt) {
|
|
|
1024
1330
|
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
|
|
1025
1331
|
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
|
|
1026
1332
|
const newDirName = `${newPrefix}-${item.slug}`;
|
|
1333
|
+
// WARNING-3 (#3511 review): the directory match above accepts an
|
|
1334
|
+
// UNPADDED leading number (`\d+`), so a supported rename can pair a
|
|
1335
|
+
// 2-padded dir with an unpadded-numbered artifact — dir `9-slug` holding
|
|
1336
|
+
// `9-VERIFICATION.md`. Renaming files by `f.startsWith(oldPrefix)` alone
|
|
1337
|
+
// (oldPrefix always 2-padded) misses that file: it becomes desynced from
|
|
1338
|
+
// its now-renamed directory and the phase reads `missing`. Try the
|
|
1339
|
+
// UNPADDED old-prefix form as a fallback so such an artifact renames
|
|
1340
|
+
// alongside its directory. A trailing-digit boundary check keeps the
|
|
1341
|
+
// unpadded form from over-matching a DIFFERENT phase's file (unpadded
|
|
1342
|
+
// prefix "1" must not match "10-…").
|
|
1343
|
+
const oldPrefixUnpadded = `${item.oldInt}${letterSuffix}${decimalSuffix}`;
|
|
1027
1344
|
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
|
|
1028
1345
|
renamedDirs.push({ from: item.dir, to: newDirName });
|
|
1029
1346
|
for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
|
|
1347
|
+
let matchedPrefix = null;
|
|
1030
1348
|
if (f.startsWith(oldPrefix)) {
|
|
1031
|
-
|
|
1032
|
-
|
|
1349
|
+
matchedPrefix = oldPrefix;
|
|
1350
|
+
}
|
|
1351
|
+
else if (oldPrefixUnpadded !== oldPrefix &&
|
|
1352
|
+
f.startsWith(oldPrefixUnpadded) &&
|
|
1353
|
+
// Token-boundary check: the character immediately after the unpadded
|
|
1354
|
+
// prefix must be a separator (`-`, `.`) or end-of-name, not any
|
|
1355
|
+
// non-digit. A bare `!/^\d/` test (prior form) let a LETTER through
|
|
1356
|
+
// too, so unpadded prefix "2" wrongly matched "2FA-notes.md" (a
|
|
1357
|
+
// wholly unrelated file whose name merely starts with the digit).
|
|
1358
|
+
(f.length === oldPrefixUnpadded.length || /^[-.]/.test(f.slice(oldPrefixUnpadded.length)))) {
|
|
1359
|
+
matchedPrefix = oldPrefixUnpadded;
|
|
1360
|
+
}
|
|
1361
|
+
if (matchedPrefix) {
|
|
1362
|
+
const newFileName = newPrefix + f.slice(matchedPrefix.length);
|
|
1363
|
+
const destPath = node_path_1.default.join(phasesDir, newDirName, newFileName);
|
|
1364
|
+
// Collision guard: the padded and unpadded prefix forms can both
|
|
1365
|
+
// resolve to the SAME destination (e.g. `09-VERIFICATION.md` and
|
|
1366
|
+
// `9-VERIFICATION.md` in one directory both target
|
|
1367
|
+
// `08-VERIFICATION.md`), and a stray cross-phase file can already sit
|
|
1368
|
+
// at the destination name (e.g. a leftover `08-VERIFICATION.md`
|
|
1369
|
+
// belonging to a DIFFERENT phase, inside phase 9's directory).
|
|
1370
|
+
// Renaming blindly over an existing target silently destroys
|
|
1371
|
+
// whichever file loses; skipping the rename instead lets the stray
|
|
1372
|
+
// outrank the phase's own renamed artifact once it lands at the
|
|
1373
|
+
// canonical name. Neither is acceptable: move the OCCUPYING file
|
|
1374
|
+
// aside first (never overwrite, never skip the real rename), then
|
|
1375
|
+
// complete the intended rename so the phase's own artifact takes the
|
|
1376
|
+
// canonical name. This also handles a target that was already
|
|
1377
|
+
// claimed by an EARLIER file in this same pass, since that earlier
|
|
1378
|
+
// rename already created it on disk.
|
|
1379
|
+
if (node_fs_1.default.existsSync(destPath)) {
|
|
1380
|
+
const displacedName = findOrphanedDisplacementName(node_path_1.default.join(phasesDir, newDirName), newFileName);
|
|
1381
|
+
if (displacedName === null) {
|
|
1382
|
+
// No free displacement name within the bounded search — fall
|
|
1383
|
+
// back to skip-and-report rather than looping or overwriting.
|
|
1384
|
+
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: null });
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(destPath, node_path_1.default.join(phasesDir, newDirName, displacedName));
|
|
1388
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
|
|
1389
|
+
renamedFiles.push({ from: f, to: newFileName });
|
|
1390
|
+
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: displacedName });
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
|
|
1033
1394
|
renamedFiles.push({ from: f, to: newFileName });
|
|
1034
1395
|
}
|
|
1035
1396
|
}
|
|
1036
1397
|
}
|
|
1037
|
-
return { renamedDirs, renamedFiles };
|
|
1398
|
+
return { renamedDirs, renamedFiles, renamedFileCollisions };
|
|
1038
1399
|
}
|
|
1039
1400
|
function decrementRoadmapPhaseNumber(raw, removedInt) {
|
|
1040
1401
|
const num = parseInt(raw, 10);
|
|
1041
|
-
|
|
1402
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1403
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1042
1404
|
return raw;
|
|
1043
1405
|
return String(num - 1);
|
|
1044
1406
|
}
|
|
@@ -1047,13 +1409,15 @@ function decrementRoadmapPhaseToken(raw, removedInt) {
|
|
|
1047
1409
|
if (!match)
|
|
1048
1410
|
return raw;
|
|
1049
1411
|
const num = parseInt(match[1], 10);
|
|
1050
|
-
|
|
1412
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1413
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1051
1414
|
return raw;
|
|
1052
1415
|
return `${num - 1}${match[2] || ''}`;
|
|
1053
1416
|
}
|
|
1054
1417
|
function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
|
|
1055
1418
|
const num = parseInt(raw, 10);
|
|
1056
|
-
|
|
1419
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1420
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1057
1421
|
return raw;
|
|
1058
1422
|
return String(num - 1).padStart(raw.length, '0');
|
|
1059
1423
|
}
|
|
@@ -1094,7 +1458,15 @@ function findDataRowLine(sectionText, dataRowIndex) {
|
|
|
1094
1458
|
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
|
|
1095
1459
|
withPlanningLock(cwd, () => {
|
|
1096
1460
|
let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
1097
|
-
const escaped = escapeRegex(targetPhase);
|
|
1461
|
+
const escaped = (0, pattern_cjs_1.escapeRegex)(targetPhase);
|
|
1462
|
+
// #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
|
|
1463
|
+
// of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
|
|
1464
|
+
// user's remove query is usually unpadded (`1.1`) — and integer headings
|
|
1465
|
+
// legitimately appear both padded (`02`) and unpadded (`2`). A `0*` prefix
|
|
1466
|
+
// makes the token padding-insensitive in both directions without widening
|
|
1467
|
+
// to other ids: the token stays anchored between `Phase\s+`/line-start and
|
|
1468
|
+
// `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
|
|
1469
|
+
const padTolerant = `0*${escaped}`;
|
|
1098
1470
|
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
|
1099
1471
|
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
|
1100
1472
|
// (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
|
|
@@ -1106,9 +1478,9 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1106
1478
|
// such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
|
|
1107
1479
|
// away everything after it — including a trailing `## Progress` heading and
|
|
1108
1480
|
// its tracking table.
|
|
1109
|
-
const phaseHeadingRe = new RegExp(`^Phase\\s+${
|
|
1481
|
+
const phaseHeadingRe = new RegExp(`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
|
|
1110
1482
|
content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
|
|
1111
|
-
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${
|
|
1483
|
+
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
|
|
1112
1484
|
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
|
1113
1485
|
// for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
|
|
1114
1486
|
// of updateTableCell). Scoped to the `## Progress` section — mirroring
|
|
@@ -1135,7 +1507,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1135
1507
|
const matchRemovedProgressRow = (row) => {
|
|
1136
1508
|
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
|
1137
1509
|
if (isDecimal) {
|
|
1138
|
-
return new RegExp(`^${
|
|
1510
|
+
return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
|
1139
1511
|
}
|
|
1140
1512
|
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
|
1141
1513
|
if (!leadingMatch || leadingMatch[2])
|
|
@@ -1150,7 +1522,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1150
1522
|
if (!isDecimal) {
|
|
1151
1523
|
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
|
1152
1524
|
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
|
1153
|
-
content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
|
|
1525
|
+
content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\r\n]{0,200}\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
|
|
1154
1526
|
content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
|
|
1155
1527
|
// ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
|
|
1156
1528
|
// updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
|
|
@@ -1212,7 +1584,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1212
1584
|
if (!m)
|
|
1213
1585
|
return false;
|
|
1214
1586
|
const num = parseInt(m[1], 10);
|
|
1215
|
-
|
|
1587
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1588
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1216
1589
|
return false;
|
|
1217
1590
|
processedOrdinalRows.add(index);
|
|
1218
1591
|
matchedRowIndex = index;
|
|
@@ -1225,7 +1598,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1225
1598
|
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
|
1226
1599
|
const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
|
1227
1600
|
const padMatch = targetLine
|
|
1228
|
-
? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
1601
|
+
? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
1229
1602
|
: null;
|
|
1230
1603
|
const leadPad = padMatch ? padMatch[1] : ' ';
|
|
1231
1604
|
const trailPad = padMatch ? padMatch[2] : ' ';
|
|
@@ -1244,6 +1617,33 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1244
1617
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
|
|
1245
1618
|
});
|
|
1246
1619
|
}
|
|
1620
|
+
/**
|
|
1621
|
+
* #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
|
|
1622
|
+
* the leading frontmatter block's closing `---` fence — so a body field never
|
|
1623
|
+
* lands before the opening fence. The former whole-content prepend
|
|
1624
|
+
* (`field + content`) put the line ABOVE the opening `---`, and
|
|
1625
|
+
* syncStateFrontmatter then treated the scrambled fence structure as TWO
|
|
1626
|
+
* frontmatter blocks, rebuilding a derived one on top of the original
|
|
1627
|
+
* (milestone_name from a ROADMAP heading, total_phases counting the removed
|
|
1628
|
+
* phase, a stray 'Total Phases: 0' between fences). A file with no leading
|
|
1629
|
+
* frontmatter is all body: the field goes to content start, preserving the
|
|
1630
|
+
* former behavior for that shape.
|
|
1631
|
+
*/
|
|
1632
|
+
function insertStateBodyFieldAtTop(content, fieldLine) {
|
|
1633
|
+
// Split AND join on bare '\n' so CRLF line endings stay attached to their
|
|
1634
|
+
// own lines — each '\r' remains the tail of the line it terminated, where
|
|
1635
|
+
// the trimmed fence compare still matches it. (#3572 review: splitting on
|
|
1636
|
+
// '\n' but re-joining on a detected '\r\n' doubled every carriage return.)
|
|
1637
|
+
const lines = content.split('\n');
|
|
1638
|
+
if ((lines[0] ?? '').trim() === '---') {
|
|
1639
|
+
const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
1640
|
+
if (closeIdx !== -1) {
|
|
1641
|
+
lines.splice(closeIdx + 1, 0, '', fieldLine);
|
|
1642
|
+
return lines.join('\n');
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return fieldLine + '\n' + content;
|
|
1646
|
+
}
|
|
1247
1647
|
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
1248
1648
|
if (!targetPhase)
|
|
1249
1649
|
error('phase number required for phase remove');
|
|
@@ -1255,24 +1655,55 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1255
1655
|
const isDecimal = targetPhase.includes('.');
|
|
1256
1656
|
const force = options.force || false;
|
|
1257
1657
|
const subdirs = readSubdirectories(phasesDir, true);
|
|
1258
|
-
|
|
1658
|
+
// #2237/#2528: every other resolution path refuses to choose between multiple
|
|
1659
|
+
// directories claiming one phase number. This one is the DESTRUCTIVE path, so
|
|
1660
|
+
// taking `matches[0]` silently is strictly worse than anywhere else: it turns
|
|
1661
|
+
// "resolve nothing" into "delete one of two candidates, unrecoverably, and
|
|
1662
|
+
// renumber every phase after it". Refuse before any file is touched.
|
|
1663
|
+
const { matches: phaseDirMatches } = matchPhaseDirs(subdirs, normalized);
|
|
1664
|
+
if (phaseDirMatches.length > 1) {
|
|
1665
|
+
output({
|
|
1666
|
+
removed: null,
|
|
1667
|
+
error: `Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
|
|
1668
|
+
+ `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
|
|
1669
|
+
+ 'Set a distinct project_code in .planning/config.json, or pass the full directory name.',
|
|
1670
|
+
ambiguous_matches: phaseDirMatches,
|
|
1671
|
+
directory_deleted: null,
|
|
1672
|
+
renamed_directories: [],
|
|
1673
|
+
renamed_files: [],
|
|
1674
|
+
roadmap_updated: false,
|
|
1675
|
+
state_updated: false,
|
|
1676
|
+
}, raw);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
const targetDir = phaseDirMatches[0] || null;
|
|
1259
1680
|
if (targetDir && !force) {
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1681
|
+
// #3183: canonical summary set (root+nested) from the single owner —
|
|
1682
|
+
// a root-only readdirSync filter left nested (#3139 layout) summaries
|
|
1683
|
+
// invisible, letting a phase with completed nested work be deleted
|
|
1684
|
+
// without --force.
|
|
1685
|
+
const summaryCount = scanPhasePlans(node_path_1.default.join(phasesDir, targetDir)).summaryFiles.length;
|
|
1686
|
+
if (summaryCount > 0) {
|
|
1687
|
+
error(`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`);
|
|
1264
1688
|
}
|
|
1265
1689
|
}
|
|
1266
1690
|
if (targetDir)
|
|
1267
1691
|
node_fs_1.default.rmSync(node_path_1.default.join(phasesDir, targetDir), { recursive: true, force: true });
|
|
1268
1692
|
let renamedDirs = [];
|
|
1269
1693
|
let renamedFiles = [];
|
|
1694
|
+
let renamedFileCollisions = [];
|
|
1270
1695
|
try {
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1696
|
+
if (isDecimal) {
|
|
1697
|
+
const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10));
|
|
1698
|
+
renamedDirs = renamed.renamedDirs;
|
|
1699
|
+
renamedFiles = renamed.renamedFiles;
|
|
1700
|
+
}
|
|
1701
|
+
else {
|
|
1702
|
+
const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
|
|
1703
|
+
renamedDirs = renamed.renamedDirs;
|
|
1704
|
+
renamedFiles = renamed.renamedFiles;
|
|
1705
|
+
renamedFileCollisions = renamed.renamedFileCollisions;
|
|
1706
|
+
}
|
|
1276
1707
|
}
|
|
1277
1708
|
catch (e) {
|
|
1278
1709
|
// #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
|
|
@@ -1289,19 +1720,69 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1289
1720
|
}
|
|
1290
1721
|
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
|
|
1291
1722
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
1723
|
+
let stateUpdated = false;
|
|
1292
1724
|
if (node_fs_1.default.existsSync(statePath)) {
|
|
1293
|
-
|
|
1294
|
-
|
|
1725
|
+
// #2640: report whether STATE.md content actually changed, not just file
|
|
1726
|
+
// existence (fs.existsSync was trivially true). Also ensure the body
|
|
1727
|
+
// transform produces a diff so readModifyWriteStateMd's no-op guard
|
|
1728
|
+
// (#948) doesn't skip the frontmatter resync — without that, the
|
|
1729
|
+
// progress.* frontmatter block stays stale when the body has no
|
|
1730
|
+
// 'Total Phases:' or 'of N' phrase.
|
|
1731
|
+
stateUpdated = readModifyWriteStateMd(statePath, (stateContent) => {
|
|
1732
|
+
let modified = stateContent;
|
|
1733
|
+
const totalRaw = stateExtractField(modified, 'Total Phases');
|
|
1295
1734
|
if (totalRaw) {
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1735
|
+
// #3572 review: clamp at 0 — a stale 'Total Phases: 0' (e.g. written by
|
|
1736
|
+
// an earlier remove whose dir-count was 0) must not decrement to -1 on
|
|
1737
|
+
// the next removal.
|
|
1738
|
+
modified =
|
|
1739
|
+
stateReplaceField(modified, 'Total Phases', String(Math.max(0, parseInt(totalRaw, 10) - 1))) || modified;
|
|
1299
1740
|
}
|
|
1300
|
-
const ofMatch =
|
|
1741
|
+
const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
|
|
1301
1742
|
if (ofMatch) {
|
|
1302
|
-
|
|
1743
|
+
modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${Math.max(0, parseInt(ofMatch[2], 10) - 1)}$3`);
|
|
1303
1744
|
}
|
|
1304
|
-
|
|
1745
|
+
// #2640: if neither body field was found, the transform is a no-op.
|
|
1746
|
+
// readModifyWriteStateMd's no-op guard (#948) would then skip the
|
|
1747
|
+
// frontmatter resync, leaving progress.* stale. Force a body diff
|
|
1748
|
+
// ONLY when a phase directory was actually removed (targetDir !== null)
|
|
1749
|
+
// so the guard passes and syncStateFrontmatter rebuilds the frontmatter
|
|
1750
|
+
// from the post-deletion disk/ROADMAP state. Without the targetDir gate,
|
|
1751
|
+
// a no-op removal (ROADMAP-only phase, no directory) would inject a
|
|
1752
|
+
// spurious 'Total Phases:' line into a body that intentionally lacked one.
|
|
1753
|
+
if (targetDir && modified === stateContent) {
|
|
1754
|
+
// subdirs was read before the deletion; excluding the removed target
|
|
1755
|
+
// gives the remaining count. Renumbering changes names but not count.
|
|
1756
|
+
//
|
|
1757
|
+
// #2528: exclude the directory that was ACTUALLY deleted, by identity,
|
|
1758
|
+
// rather than re-deriving "which dir was the target" from the query.
|
|
1759
|
+
// The two are not the same predicate here: `targetDir` comes from
|
|
1760
|
+
// `matchPhaseDirs`, whose bare-integer fallback resolves digit-leading
|
|
1761
|
+
// dirs (`05-80-20-cleanup` for query `5`) that `phaseTokenMatches`
|
|
1762
|
+
// reports as non-matching — so a token re-derivation would count the
|
|
1763
|
+
// just-deleted directory as still present and write a `Total Phases`
|
|
1764
|
+
// one too high. Identity is also what the comment above already
|
|
1765
|
+
// claims this filter does, and the block is gated on targetDir.
|
|
1766
|
+
// (#3572 note: this body field counts DIRECTORIES on disk; the
|
|
1767
|
+
// frontmatter progress.* block is rebuilt by syncStateFrontmatter
|
|
1768
|
+
// from the post-removal ROADMAP — the two counts legitimately differ
|
|
1769
|
+
// when phases exist in ROADMAP without directories.)
|
|
1770
|
+
const remainingPhases = Math.max(0, subdirs.filter((d) => d !== targetDir).length);
|
|
1771
|
+
if (totalRaw) {
|
|
1772
|
+
modified =
|
|
1773
|
+
stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
|
|
1774
|
+
}
|
|
1775
|
+
else {
|
|
1776
|
+
// No 'Total Phases:' field in the body — insert one at the start of
|
|
1777
|
+
// the BODY so the no-op guard sees a diff. #3572: the former
|
|
1778
|
+
// whole-content prepend landed the line BEFORE the opening '---'
|
|
1779
|
+
// fence and corrupted STATE.md into two frontmatter blocks.
|
|
1780
|
+
// syncStateFrontmatter will still rebuild the frontmatter
|
|
1781
|
+
// progress.* block from the real disk/ROADMAP count.
|
|
1782
|
+
modified = insertStateBodyFieldAtTop(modified, `Total Phases: ${remainingPhases}`);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
return modified;
|
|
1305
1786
|
}, cwd);
|
|
1306
1787
|
}
|
|
1307
1788
|
output({
|
|
@@ -1309,8 +1790,9 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1309
1790
|
directory_deleted: targetDir,
|
|
1310
1791
|
renamed_directories: renamedDirs,
|
|
1311
1792
|
renamed_files: renamedFiles,
|
|
1793
|
+
renamed_file_collisions: renamedFileCollisions,
|
|
1312
1794
|
roadmap_updated: true,
|
|
1313
|
-
state_updated:
|
|
1795
|
+
state_updated: stateUpdated,
|
|
1314
1796
|
}, raw);
|
|
1315
1797
|
}
|
|
1316
1798
|
function writePlanningFileSet(writes) {
|
|
@@ -1370,11 +1852,27 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1370
1852
|
// init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
|
|
1371
1853
|
// explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
|
|
1372
1854
|
const availableWorkstreams = listAvailableWorkstreams(cwd);
|
|
1373
|
-
|
|
1855
|
+
// #3579 root-cause fix: this is a check, not a consuming read — use the
|
|
1856
|
+
// non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
|
|
1857
|
+
// here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
|
|
1858
|
+
// which would misreport a present-but-bad marker as no marker at all.
|
|
1859
|
+
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
|
|
1374
1860
|
if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
|
|
1861
|
+
// #3579: getActiveWorkstream now inherits a pointer-less session's read
|
|
1862
|
+
// from the shared .planning/active-workstream marker, so reaching this
|
|
1863
|
+
// branch with a marker actually present means the marker EXISTED but
|
|
1864
|
+
// didn't resolve (invalid name, or its workstream dir is gone) — a
|
|
1865
|
+
// materially different situation from "nothing was ever set" and one
|
|
1866
|
+
// that deserves its own diagnostic instead of the generic message below.
|
|
1867
|
+
const diagnosis = diagnoseUnresolvedActiveWorkstream(cwd);
|
|
1868
|
+
if (diagnosis.present) {
|
|
1869
|
+
error(`phase.complete requires a workstream in workstream mode — the active-workstream marker names '${diagnosis.value}', but it did not resolve: ${describeUnresolvedWorkstreamReason(diagnosis.reason)}. Root STATE.md/ROADMAP.md (likely stale) would be written otherwise. ` +
|
|
1870
|
+
`Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} to point it at an existing workstream. ` +
|
|
1871
|
+
`Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_MARKER_UNRESOLVED, { marker_value: diagnosis.value, marker_reason: diagnosis.reason });
|
|
1872
|
+
}
|
|
1375
1873
|
error(`phase.complete requires a workstream in workstream mode — no active workstream is set, so root STATE.md/ROADMAP.md (likely stale) would be written. ` +
|
|
1376
1874
|
`Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} first. ` +
|
|
1377
|
-
`Available workstreams: ${availableWorkstreams.join(', ')}
|
|
1875
|
+
`Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_NONE_ACTIVE);
|
|
1378
1876
|
}
|
|
1379
1877
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1380
1878
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
@@ -1393,10 +1891,97 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1393
1891
|
: 0;
|
|
1394
1892
|
let requirementsUpdated = false;
|
|
1395
1893
|
const warnings = [];
|
|
1894
|
+
// ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
|
|
1895
|
+
// composition's preservation stage restores a curated frontmatter value
|
|
1896
|
+
// over a disagreeing derived one, that divergence is surfaced here rather
|
|
1897
|
+
// than silently absorbed. Structured (field + reason), not prose, so a
|
|
1898
|
+
// caller can assert on the value rather than regex a rendered message.
|
|
1899
|
+
// Named `preservation_warnings`, NOT `warnings`: `warnings` above is
|
|
1900
|
+
// already a prose `string[]` on this exact command — reusing it for a
|
|
1901
|
+
// structured `{field, reason}[]` shape would be the "Generative Fix
|
|
1902
|
+
// Divergence" anti-pattern (two sibling fields, same name, different
|
|
1903
|
+
// element types). Mirrors `cmdMilestoneComplete`'s identical field
|
|
1904
|
+
// (milestone.cts).
|
|
1905
|
+
const preservationWarnings = [];
|
|
1906
|
+
// #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
|
|
1907
|
+
// roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
|
|
1908
|
+
// below (inside withPlanningLock) alongside the warnings[] entry, so a
|
|
1909
|
+
// caller can assert on the typed field instead of the warning's prose.
|
|
1910
|
+
let staleCheckIndeterminate = false;
|
|
1396
1911
|
const phaseFullDir = node_path_1.default.join(cwd, phaseInfo['directory']);
|
|
1912
|
+
// #2648: fail-closed plan-coverage gate. phase.complete used to gate ONLY on a
|
|
1913
|
+
// single *-VERIFICATION.md status, so a phase could close "complete" while an
|
|
1914
|
+
// arbitrary number of its plans — including plans a lock/recovery decision
|
|
1915
|
+
// silently dropped — had no completion record (a confirmed production incident
|
|
1916
|
+
// closed a phase with 6/30 plans unexecuted, including its entire final UI
|
|
1917
|
+
// scope, with every tool-reported signal green). Now refuse completion when any
|
|
1918
|
+
// plan lacks a matching *-SUMMARY.md, UNLESS that plan is explicitly retired
|
|
1919
|
+
// via machine-readable `status: superseded` frontmatter (the #2349 marker).
|
|
1920
|
+
//
|
|
1921
|
+
// scanPhasePlans is the superseded-AWARE counter (it drops status: superseded
|
|
1922
|
+
// plans from planFiles before returning), so a deliberately-retired plan never
|
|
1923
|
+
// appears in the unsummarized set and never blocks completion — closing the
|
|
1924
|
+
// Goodhart hole (delete a SUMMARY to raise the %) without regressing the
|
|
1925
|
+
// legitimate lock/recovery pattern (retire a plan instead of executing it).
|
|
1926
|
+
// This is evaluated BEFORE the verification-gate transaction below so a
|
|
1927
|
+
// plan-coverage refusal fails fast without mutating ROADMAP/STATE. The count
|
|
1928
|
+
// path (cmdPhaseComplete's own planCount/summaryCount above) is NOT superseded-
|
|
1929
|
+
// aware (it comes from findPhaseInternal/phase-locator.cts); that is fine for
|
|
1930
|
+
// DISPLAY (the X/Y cell) but must not be the gate — the gate needs the
|
|
1931
|
+
// superseded-adjusted set so retired plans don't re-block the very phases the
|
|
1932
|
+
// marker exists to unblock. Matches roadmap.cts's already-correct-but-unenforced
|
|
1933
|
+
// `summaryCount >= planCount` predicate, now enforced at the completion seam.
|
|
1934
|
+
const coverageScan = scanPhasePlans(phaseFullDir);
|
|
1935
|
+
// #2648 security: fail CLOSED when the phase directory cannot be read.
|
|
1936
|
+
// scanPhasePlans deliberately swallows readdirSync errors and returns an empty
|
|
1937
|
+
// plan set ({planFiles: []}), which is indistinguishable from a readable empty
|
|
1938
|
+
// phase. For a COVERAGE gate that is the wrong posture: "I could not read the
|
|
1939
|
+
// plans" must mean "I cannot prove coverage," not "all plans are summarized" —
|
|
1940
|
+
// otherwise any I/O failure (permissions, ENOTDIR, EBUSY on Windows, a dir
|
|
1941
|
+
// present in ROADMAP.md but missing/unreadable on disk) silently re-opens the
|
|
1942
|
+
// exact hole this gate exists to close. Distinguish the two: a readable
|
|
1943
|
+
// directory with zero plans is a legitimately complete empty phase; an
|
|
1944
|
+
// UNREADABLE directory is a fail-closed refusal. Mirrors cmdPhaseInsert's own
|
|
1945
|
+
// readdirSync-fail-closed posture (a swallow there used to risk writing a
|
|
1946
|
+
// colliding phase number).
|
|
1947
|
+
try {
|
|
1948
|
+
node_fs_1.default.readdirSync(phaseFullDir);
|
|
1949
|
+
}
|
|
1950
|
+
catch (readErr) {
|
|
1951
|
+
error(`Phase ${phaseNum} cannot be completed: its plan directory is unreadable (${phaseInfo['directory']}: ${readErr.code || readErr.message}), so plan coverage cannot be verified. Restore read access and retry — a coverage gate that passes when it cannot read the plans is no gate at all (#2648).`, ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE);
|
|
1952
|
+
}
|
|
1953
|
+
const unsummarizedPlans = findUnsummarizedPlans(coverageScan.planFiles, coverageScan.summaryFiles);
|
|
1954
|
+
if (unsummarizedPlans.length > 0) {
|
|
1955
|
+
// Sanitize plan filenames before interpolation: they come raw from
|
|
1956
|
+
// readdirSync and could carry C0 control chars / DEL (a committable filename
|
|
1957
|
+
// could spoof the terminal in plain-error mode). Strip them so the message is
|
|
1958
|
+
// safe to print regardless of --json-errors. Path traversal sequences are not
|
|
1959
|
+
// a code-execution vector here (printed only, never reopened from the message).
|
|
1960
|
+
const sanitize = (name) => name.replace(/[\u0000-\u001f\u007f]/g, '?');
|
|
1961
|
+
const listed = unsummarizedPlans.slice(0, 20).map(sanitize).join(', ');
|
|
1962
|
+
const more = unsummarizedPlans.length > 20 ? ` (and ${unsummarizedPlans.length - 20} more)` : '';
|
|
1963
|
+
// Audit surface (#2648 review M1): name how many plans were excluded as
|
|
1964
|
+
// superseded so a reviewer can see WHICH work was declared retired, not just
|
|
1965
|
+
// that some plans are missing summaries. The status: superseded marker is a
|
|
1966
|
+
// committable, review-time-trusted bypass; surfacing its count keeps that
|
|
1967
|
+
// bypass visible rather than silent.
|
|
1968
|
+
const phaseInfoPlanCount = Array.isArray(phaseInfo['plans']) ? phaseInfo['plans'].length : 0;
|
|
1969
|
+
const supersededCount = coverageScan.planFiles.length === 0 ? 0 : Math.max(0, phaseInfoPlanCount - coverageScan.planFiles.length);
|
|
1970
|
+
const supersededNote = supersededCount > 0
|
|
1971
|
+
? ` ${supersededCount} plan(s) excluded as status: superseded (retired).`
|
|
1972
|
+
: '';
|
|
1973
|
+
error(`Phase ${phaseNum} cannot be completed: ${unsummarizedPlans.length} plan(s) have no completion record (*-SUMMARY.md): ${listed}${more}.` +
|
|
1974
|
+
supersededNote +
|
|
1975
|
+
` Execute the plans and write their summaries, or retire a plan with machine-readable \`status: superseded\` frontmatter (#2349) if it was deliberately dropped — a retired plan is excluded from this gate. ` +
|
|
1976
|
+
`Completing a phase with unexecuted plans is what lost an entire promised deliverable silently (#2648).`, ERROR_REASON.PHASE_PLAN_COVERAGE_INCOMPLETE);
|
|
1977
|
+
}
|
|
1397
1978
|
try {
|
|
1398
1979
|
const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
|
|
1399
|
-
|
|
1980
|
+
// #3511: scope this advisory pre-scan to THIS phase's own token so a
|
|
1981
|
+
// stray, cross-phase, or ad-hoc file cannot name a warning against a
|
|
1982
|
+
// phase it does not belong to.
|
|
1983
|
+
const phaseFullDirBaseName = node_path_1.default.basename(phaseFullDir);
|
|
1984
|
+
for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseFullDirBaseName)) {
|
|
1400
1985
|
const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
|
|
1401
1986
|
if (/result: pending/.test(content))
|
|
1402
1987
|
warnings.push(`${file}: has pending tests`);
|
|
@@ -1407,7 +1992,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1407
1992
|
if (/status: diagnosed/.test(content))
|
|
1408
1993
|
warnings.push(`${file}: has diagnosed gaps`);
|
|
1409
1994
|
}
|
|
1410
|
-
for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
|
1995
|
+
for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')), phaseFullDirBaseName)) {
|
|
1411
1996
|
const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
|
|
1412
1997
|
const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
|
|
1413
1998
|
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
|
|
@@ -1471,11 +2056,42 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1471
2056
|
let nextPhaseNum = null;
|
|
1472
2057
|
let nextPhaseName = null;
|
|
1473
2058
|
let isLastPhase = true;
|
|
2059
|
+
// #3311: typed conflict descriptor surfaced on the result JSON alongside the
|
|
2060
|
+
// warnings[] entry below (same parity pattern as
|
|
2061
|
+
// verification_stale_check_indeterminate).
|
|
2062
|
+
let milestoneConflict = null;
|
|
1474
2063
|
const verificationBlocked = withPlanningLock(cwd, () => {
|
|
2064
|
+
// #3311: completing a phase while a live milestone claim (phase + session)
|
|
2065
|
+
// holds a DIFFERENT phase means two sessions are working two phases against
|
|
2066
|
+
// the single Current Position slot. Warn via the established warnings[]
|
|
2067
|
+
// channel (rendered by execute-phase.md's "If has_warnings is true" step)
|
|
2068
|
+
// rather than blocking — the claim may simply be stale-but-live.
|
|
2069
|
+
milestoneConflict = milestoneLockMod.checkMilestoneConflictForPhase(cwd, phaseNum);
|
|
2070
|
+
if (milestoneConflict) {
|
|
2071
|
+
const holder = milestoneConflict.locked_session ?? 'an unknown (headless) session';
|
|
2072
|
+
const actor = milestoneConflict.session ?? 'an unknown (headless) session';
|
|
2073
|
+
warnings.push(`milestone lock conflict (#3311): ${holder} holds the milestone claim for phase ` +
|
|
2074
|
+
`${milestoneConflict.locked_phase}, but ${actor} is completing phase ${phaseNum} — ` +
|
|
2075
|
+
`STATE.md's Current Position is a single slot; verify it before trusting it`);
|
|
2076
|
+
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `phase.complete ${phaseNum}`);
|
|
2077
|
+
}
|
|
1475
2078
|
// #2617: pass the project's runtime so the blocked-completion error below
|
|
1476
2079
|
// suggests the command surface this runtime actually installs
|
|
1477
2080
|
// ($gsd-… on Codex) rather than a hard-coded Claude-style string.
|
|
1478
2081
|
const verificationStatus = readVerificationStatus(phaseFullDir, { runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd) });
|
|
2082
|
+
// #3057 B3: the staleness check inside readVerificationStatus can itself
|
|
2083
|
+
// fail (fs / scanPhasePlans / clock error), in which case `status` above
|
|
2084
|
+
// was routed as if nothing were stale (unchanged fail-open routing) — but
|
|
2085
|
+
// that must not be silently identical to a check that actually ran and
|
|
2086
|
+
// found nothing stale. Join the SAME advisory channel the UAT/VERIFICATION
|
|
2087
|
+
// pre-scan above already uses (`warnings[]`, rendered by execute-phase.md's
|
|
2088
|
+
// "If has_warnings is true" step) rather than inventing a new one. This
|
|
2089
|
+
// only fires on the non-blocking path (status resolves to 'passed' despite
|
|
2090
|
+
// the indeterminate check) — the blocked path below carries its own note.
|
|
2091
|
+
if (verificationStatus.staleCheckIndeterminate) {
|
|
2092
|
+
staleCheckIndeterminate = true;
|
|
2093
|
+
warnings.push(`verification staleness check could not complete for phase ${phaseNum} — routed as not-stale, but this was not actually verified (#3057)`);
|
|
2094
|
+
}
|
|
1479
2095
|
if (verificationStatus.status !== 'passed') {
|
|
1480
2096
|
return verificationStatus;
|
|
1481
2097
|
}
|
|
@@ -1605,7 +2221,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1605
2221
|
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
|
1606
2222
|
if (!planId)
|
|
1607
2223
|
continue;
|
|
1608
|
-
const planEscaped = escapeRegex(planId);
|
|
2224
|
+
const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
|
|
1609
2225
|
const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
|
|
1610
2226
|
b = b.replace(planCheckboxPattern, '$1x$2');
|
|
1611
2227
|
}
|
|
@@ -1681,8 +2297,18 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1681
2297
|
.filter(Boolean)
|
|
1682
2298
|
.filter((r) => REQ_ID_SHAPE_RE.test(r));
|
|
1683
2299
|
for (const reqId of citedReqIds) {
|
|
1684
|
-
const reqEscaped = escapeRegex(reqId);
|
|
1685
|
-
|
|
2300
|
+
const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
|
|
2301
|
+
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
|
|
2302
|
+
// #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
|
|
2303
|
+
// cmdRequirementsMarkComplete). Capture the pre-flip content; if a
|
|
2304
|
+
// traceability row EXISTS for this ID below but its Status write is rejected
|
|
2305
|
+
// (Out/Deferred/Blocked), the checkbox is rolled back so the two surfaces
|
|
2306
|
+
// cannot silently diverge. A requirement recorded as deferred must not read
|
|
2307
|
+
// as shipped.
|
|
2308
|
+
const checkboxRe = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
|
2309
|
+
const beforeCheckbox = reqContent;
|
|
2310
|
+
reqContent = reqContent.replace(checkboxRe, '$1x$2');
|
|
2311
|
+
const checkboxFlipped = reqContent !== beforeCheckbox;
|
|
1686
2312
|
// Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
|
|
1687
2313
|
// ... Complete | via the markdown-table seam (ADR-2143 §7). Match the
|
|
1688
2314
|
// row by its FIRST cell's value (the requirement-ID column) regardless
|
|
@@ -1698,16 +2324,33 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1698
2324
|
// requirement's write. The "only flip Pending/In Progress ->
|
|
1699
2325
|
// Complete" gate is folded into the newValue callback so one
|
|
1700
2326
|
// updateTableCell call both probes and writes.
|
|
1701
|
-
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
|
|
2327
|
+
// #2945: track tableHit (did the callback actually CHANGE the value?) so the
|
|
2328
|
+
// checkbox rollback below can distinguish "row existed and accepted" from
|
|
2329
|
+
// "row existed and rejected".
|
|
2330
|
+
let tableHit = false;
|
|
2331
|
+
const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => {
|
|
2332
|
+
// #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
|
|
2333
|
+
// gaps_found response) can complete without hand-editing the table.
|
|
2334
|
+
if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
|
|
2335
|
+
tableHit = true;
|
|
2336
|
+
return ' Complete ';
|
|
2337
|
+
}
|
|
2338
|
+
return current;
|
|
2339
|
+
});
|
|
1705
2340
|
if (reqUpdate.ok) {
|
|
1706
2341
|
reqContent = reqUpdate.value;
|
|
1707
2342
|
}
|
|
1708
2343
|
else if (!isPlaceholderReqId(reqId)) {
|
|
1709
2344
|
traceabilityWriteMisses.push(reqId);
|
|
1710
2345
|
}
|
|
2346
|
+
// #2945 defect-2 (port of milestone.cts:200-210): if a row EXISTS for this
|
|
2347
|
+
// ID but its Status write was rejected (row reads Out/Deferred/Blocked,
|
|
2348
|
+
// which the callback returned unchanged), roll the checkbox back so the
|
|
2349
|
+
// checkbox and the row cannot silently diverge. reqUpdate.ok === a row
|
|
2350
|
+
// matched (existence probe); !tableHit === the callback did not advance it.
|
|
2351
|
+
if (checkboxFlipped && reqUpdate.ok && !tableHit) {
|
|
2352
|
+
reqContent = beforeCheckbox;
|
|
2353
|
+
}
|
|
1711
2354
|
}
|
|
1712
2355
|
}
|
|
1713
2356
|
// #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
|
|
@@ -1861,7 +2504,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1861
2504
|
// row actually matched is registered — not a ghost — regardless of
|
|
1862
2505
|
// which section (deferred or not) it lives under.
|
|
1863
2506
|
const reqIsRegisteredAnywhere = (id) => {
|
|
1864
|
-
const reqEscaped = escapeRegex(id);
|
|
2507
|
+
const reqEscaped = (0, pattern_cjs_1.escapeRegex)(id);
|
|
1865
2508
|
// Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
|
|
1866
2509
|
// insensitive: existence check, not the write's space-only match.
|
|
1867
2510
|
if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
|
|
@@ -1898,17 +2541,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1898
2541
|
}
|
|
1899
2542
|
}
|
|
1900
2543
|
try {
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
2544
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories belong to
|
|
2545
|
+
// the CURRENT milestone" — routed through the canonical owner
|
|
2546
|
+
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
2547
|
+
// (which also never excluded sentinels on its own, unlike the
|
|
2548
|
+
// owner; the per-directory isSentinelPhaseId check below stays as a
|
|
2549
|
+
// defensive second check against the REGEX-EXTRACTED token, which
|
|
2550
|
+
// is not necessarily identical to the raw directory name).
|
|
2551
|
+
const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
|
|
1908
2552
|
for (const dir of dirs) {
|
|
1909
2553
|
const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
|
1910
2554
|
if (dm) {
|
|
1911
|
-
|
|
2555
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
2556
|
+
if (isSentinelPhaseId(dm[1]))
|
|
1912
2557
|
continue;
|
|
1913
2558
|
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
|
1914
2559
|
nextPhaseNum = dm[1];
|
|
@@ -1949,6 +2594,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1949
2594
|
const phasePattern = new RegExp(`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
|
|
1950
2595
|
let pm;
|
|
1951
2596
|
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
|
2597
|
+
// #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
|
|
2598
|
+
// already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
|
|
2599
|
+
// stage 2's heading scan must not advance into backlog headings either.
|
|
2600
|
+
if (isSentinelPhaseId(pm[1]))
|
|
2601
|
+
continue;
|
|
1952
2602
|
if (comparePhaseNum(pm[1], phaseNum) > 0) {
|
|
1953
2603
|
nextPhaseNum = pm[1];
|
|
1954
2604
|
nextPhaseName = pm[2]
|
|
@@ -1982,7 +2632,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1982
2632
|
// pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
|
|
1983
2633
|
// between the box and "Phase", a required `:`) so unrelated checklist lines
|
|
1984
2634
|
// that merely mention "Phase N" don't match.
|
|
1985
|
-
|
|
2635
|
+
// #3350: this stage answers a DIFFERENT question than stages 1-2 ("what is
|
|
2636
|
+
// the next actionable phase?" vs "is this the last phase?"), so it must not
|
|
2637
|
+
// be gated on their answer. Gating on isLastPhase let a merely-positionally
|
|
2638
|
+
// next higher heading (stage 2) permanently mask a genuinely-outstanding
|
|
2639
|
+
// lower phase — stage 2 cleared isLastPhase and this scan never ran. The
|
|
2640
|
+
// scan already refuses anything not strictly lower than the completed phase
|
|
2641
|
+
// (plus sentinels, #2949), so running it unconditionally cannot manufacture
|
|
2642
|
+
// a wrong answer: when no lower phase is outstanding it finds nothing and
|
|
2643
|
+
// stages 1-2's pick stands unchanged; in the masking case isLastPhase is
|
|
2644
|
+
// already false, so the last-phase signal has no reachable regression.
|
|
2645
|
+
if (roadmapContent !== null) {
|
|
1986
2646
|
try {
|
|
1987
2647
|
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
|
|
1988
2648
|
const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
|
|
@@ -1990,7 +2650,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1990
2650
|
let lowestOutstanding = null;
|
|
1991
2651
|
while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
|
|
1992
2652
|
const isChecked = cbm[1].toLowerCase() === 'x';
|
|
1993
|
-
|
|
2653
|
+
// #2949: exclude sentinel-range phase ids (0.x backlog, 999.x) from candidacy.
|
|
2654
|
+
// comparePhaseNum("0.1","12") === -12, so without this guard an unchecked 0.x
|
|
2655
|
+
// backlog row sorts below every real phase and is wrongly selected as next_phase,
|
|
2656
|
+
// corrupting STATE.md and desyncing current_phase from current_phase_name.
|
|
2657
|
+
// isSentinelPhaseId covers both sentinel ranges (SENTINEL_RANGES = [0, 999]); a
|
|
2658
|
+
// real lower-numbered outstanding phase (e.g. Phase 9) is NOT a sentinel and is
|
|
2659
|
+
// still selected, preserving #2028's out-of-order-completion behavior.
|
|
2660
|
+
if (!isChecked && !isSentinelPhaseId(cbm[2]) && comparePhaseNum(cbm[2], phaseNum) < 0) {
|
|
1994
2661
|
if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
|
|
1995
2662
|
lowestOutstanding = {
|
|
1996
2663
|
num: cbm[2],
|
|
@@ -2022,11 +2689,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2022
2689
|
// to the STATE.md Transition Module. The ~90-line inline RMW callback
|
|
2023
2690
|
// that lived here is the pure `completePhaseCore` in
|
|
2024
2691
|
// src/state-transition.cts, backed by the field-classification table.
|
|
2025
|
-
// `updatePerformanceMetricsSection`
|
|
2026
|
-
//
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
// because STATE.md is
|
|
2692
|
+
// `updatePerformanceMetricsSection` stays in this adapter: it is a
|
|
2693
|
+
// section-table / disk-scan concern, not a classified field. The
|
|
2694
|
+
// sync + post-sync preservation this transaction needs runs via the
|
|
2695
|
+
// single write-seam composition, `syncAndPreserveStateMd` (it does
|
|
2696
|
+
// NOT go through readModifyWriteStateMd because STATE.md is
|
|
2697
|
+
// committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
|
|
2698
|
+
// #3374 / #3469).
|
|
2030
2699
|
const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
|
|
2031
2700
|
phaseDisplayNameFromSlug(nextPhaseName);
|
|
2032
2701
|
const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
|
|
@@ -2039,7 +2708,6 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2039
2708
|
summaryCount,
|
|
2040
2709
|
}, {
|
|
2041
2710
|
clock: clock_cjs_1.realClock,
|
|
2042
|
-
progressProvider: () => null, // completePhase derives progress from the roadmap, not disk
|
|
2043
2711
|
roadmapProvider: () => roadmapContent,
|
|
2044
2712
|
sourcePath: statePath,
|
|
2045
2713
|
});
|
|
@@ -2049,7 +2717,55 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2049
2717
|
// the intent; pass it as authoritative so the sync's prose
|
|
2050
2718
|
// re-derivation cannot rewrite current_phase_name to the name's own
|
|
2051
2719
|
// parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
|
|
2052
|
-
|
|
2720
|
+
// #3350: PAIR the override. When STATE.md's body carries no Current
|
|
2721
|
+
// Phase / Phase field to re-derive from (narrative prose), the #905
|
|
2722
|
+
// preserve guard in syncStateFrontmatter keeps the OLD frontmatter
|
|
2723
|
+
// current_phase while the authoritative current_phase_name advances —
|
|
2724
|
+
// leaving the two fields describing different phases. Pin BOTH to the
|
|
2725
|
+
// resolved next phase in that case. When the body DOES carry the field
|
|
2726
|
+
// (completePhaseCore just rewrote it), stay name-only so the body's
|
|
2727
|
+
// richer `N of T (name)` derived shape survives the sync.
|
|
2728
|
+
const fmBody = frontmatterMod.stripFrontmatter(stateContent);
|
|
2729
|
+
const bodyHasPhaseField = stateExtractField(fmBody, 'Current Phase') != null ||
|
|
2730
|
+
stateExtractField(fmBody, 'Phase') != null;
|
|
2731
|
+
const authoritativeFm = nextPhaseDisplayName
|
|
2732
|
+
? bodyHasPhaseField || !nextPhaseNum
|
|
2733
|
+
? { current_phase_name: nextPhaseDisplayName }
|
|
2734
|
+
: {
|
|
2735
|
+
current_phase: String(nextPhaseNum),
|
|
2736
|
+
current_phase_name: nextPhaseDisplayName,
|
|
2737
|
+
}
|
|
2738
|
+
: undefined;
|
|
2739
|
+
// ADR-3408 §8.3 / #3469: this deliberately bypasses
|
|
2740
|
+
// readModifyWriteStateMd (STATE.md is committed atomically with
|
|
2741
|
+
// ROADMAP/REQUIREMENTS), so it calls the single write-seam
|
|
2742
|
+
// composition (`syncAndPreserveStateMd`) directly instead of
|
|
2743
|
+
// assembling `syncStateFrontmatter` + `applyPostSyncPreservation`
|
|
2744
|
+
// itself — a call site re-assembling the pair, even with every step
|
|
2745
|
+
// calling an owner, is the exact re-derivation §8.3 forbids by name
|
|
2746
|
+
// (Phase 2 found this shape live here). The composition runs
|
|
2747
|
+
// snapshots from the on-disk pre-image (originalStateContent) and
|
|
2748
|
+
// the transformed content, table-driven applyStatePreservation, then
|
|
2749
|
+
// the #2736 authoritative re-assert (which restores the #3350
|
|
2750
|
+
// pairing override the preserve-always restore may have reverted).
|
|
2751
|
+
// resync=true is the lifecycle-transition posture (progress
|
|
2752
|
+
// recomputed from disk; only the preserve-when-unchanged deltas
|
|
2753
|
+
// apply). Fields the transition legitimately rewrote (Status, Phase,
|
|
2754
|
+
// Stopped At via completePhaseCore's #3374 continuity line) have
|
|
2755
|
+
// changed body sources, so their deltas do not fire.
|
|
2756
|
+
// ADR-3408 §8.5 / D2 (#3374): thread `divergedFields` through so this
|
|
2757
|
+
// command reports what it preserved, following `cmdMilestoneComplete`'s
|
|
2758
|
+
// shape (milestone.cts) — the same composition, the same out-param,
|
|
2759
|
+
// the same visibility contract.
|
|
2760
|
+
const divergedFields = [];
|
|
2761
|
+
stateContent = syncAndPreserveStateMd(originalStateContent, stateContent, statePath, cwd, {
|
|
2762
|
+
resync: true,
|
|
2763
|
+
authoritativeFm,
|
|
2764
|
+
divergedFields,
|
|
2765
|
+
});
|
|
2766
|
+
for (const field of divergedFields) {
|
|
2767
|
+
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
|
2768
|
+
}
|
|
2053
2769
|
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
|
|
2054
2770
|
}
|
|
2055
2771
|
writePlanningFileSet(writes);
|
|
@@ -2060,13 +2776,29 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2060
2776
|
else {
|
|
2061
2777
|
runPhaseCompleteTransaction();
|
|
2062
2778
|
}
|
|
2779
|
+
// #3311: a successful completion of the CLAIMED phase releases the
|
|
2780
|
+
// milestone claim — regardless of which session completes it (an
|
|
2781
|
+
// orchestrator cleaning up after a dead session must not be blocked by the
|
|
2782
|
+
// dead session's own claim). No-ops when the claim names another phase.
|
|
2783
|
+
milestoneLockMod.releaseMilestonePhase(cwd, phaseNum);
|
|
2063
2784
|
return null;
|
|
2064
2785
|
});
|
|
2065
2786
|
if (verificationBlocked) {
|
|
2066
2787
|
const nextStep = verificationBlocked.next_command
|
|
2067
2788
|
? ` Next: ${verificationBlocked.next_command}`
|
|
2068
2789
|
: '';
|
|
2069
|
-
|
|
2790
|
+
// #3057 B3: purely additive to the message text — does not change WHETHER
|
|
2791
|
+
// this blocks (verificationBlocked was already truthy) or the
|
|
2792
|
+
// ERROR_REASON, only whether the operator can see the staleness check
|
|
2793
|
+
// itself did not complete. The same fact is also attached as a typed
|
|
2794
|
+
// field (`verification_stale_check_indeterminate`) on the JSON-error-mode
|
|
2795
|
+
// payload so a test can assert on it by value instead of regexing this
|
|
2796
|
+
// human-readable note.
|
|
2797
|
+
const staleCheckIndeterminate = verificationBlocked.staleCheckIndeterminate === true;
|
|
2798
|
+
const indeterminateNote = staleCheckIndeterminate
|
|
2799
|
+
? ' (staleness check could not complete — see #3057)'
|
|
2800
|
+
: '';
|
|
2801
|
+
error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}${indeterminateNote}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE, { verification_stale_check_indeterminate: staleCheckIndeterminate });
|
|
2070
2802
|
}
|
|
2071
2803
|
let autoPruned = false;
|
|
2072
2804
|
try {
|
|
@@ -2100,6 +2832,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2100
2832
|
auto_pruned: autoPruned,
|
|
2101
2833
|
warnings,
|
|
2102
2834
|
has_warnings: warnings.length > 0,
|
|
2835
|
+
verification_stale_check_indeterminate: staleCheckIndeterminate,
|
|
2836
|
+
milestone_conflict: milestoneConflict,
|
|
2837
|
+
preservation_warnings: preservationWarnings,
|
|
2103
2838
|
};
|
|
2104
2839
|
output(result, raw);
|
|
2105
2840
|
}
|
|
@@ -2121,7 +2856,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
|
|
|
2121
2856
|
// paths without re-discovering the phase directory themselves.
|
|
2122
2857
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
|
2123
2858
|
const planScanMod = require("./plan-scan.cjs");
|
|
2124
|
-
const { scanPhasePlans } = planScanMod;
|
|
2859
|
+
const { scanPhasePlans, isCanonicalPlanFile } = planScanMod;
|
|
2125
2860
|
function cmdPhaseListPlans(cwd, phaseNum, raw) {
|
|
2126
2861
|
if (!phaseNum) {
|
|
2127
2862
|
error('phase number required for phase list-plans');
|