@opengsd/gsd-core 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +12 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +22 -14
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +5 -3
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +23 -2
- package/agents/gsd-plan-checker.md +50 -53
- package/agents/gsd-planner.md +50 -50
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +63 -4
- package/agents/gsd-ui-researcher.md +41 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +13 -4
- package/bin/install.js +1448 -1103
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +9 -5
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1035 -138
- package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
- package/gsd-core/bin/lib/api-coverage.cjs +33 -14
- package/gsd-core/bin/lib/artifacts.cjs +5 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +1026 -268
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +845 -130
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +700 -40
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +216 -42
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +1172 -108
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
- package/gsd-core/bin/lib/config-loader.cjs +187 -23
- package/gsd-core/bin/lib/config.cjs +102 -3
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +208 -33
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/edge-probe.cjs +9 -1
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +899 -229
- package/gsd-core/bin/lib/gap-checker.cjs +95 -10
- package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +439 -168
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +811 -259
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
- package/gsd-core/bin/lib/install-profiles.cjs +212 -61
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +170 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +183 -22
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +842 -73
- package/gsd-core/bin/lib/model-catalog.cjs +232 -16
- package/gsd-core/bin/lib/model-resolver.cjs +193 -68
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
- package/gsd-core/bin/lib/phase-id.cjs +514 -40
- package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
- package/gsd-core/bin/lib/phase-locator.cjs +262 -34
- package/gsd-core/bin/lib/phase.cjs +1038 -214
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +98 -3
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
- package/gsd-core/bin/lib/probe-core.cjs +5 -2
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
- package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +442 -96
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
- package/gsd-core/bin/lib/smart-entry.cjs +133 -23
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +52 -19
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +338 -8
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +846 -176
- package/gsd-core/bin/lib/state.cjs +2589 -369
- package/gsd-core/bin/lib/surface.cjs +33 -11
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
- package/gsd-core/bin/lib/uat.cjs +1761 -167
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
- package/gsd-core/bin/lib/unusable-input.cjs +37 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +75 -0
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +272 -9
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +453 -918
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
- package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/references/agent-contracts.md +44 -26
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +39 -21
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +4 -6
- package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +17 -11
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +5 -4
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +61 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planner-revision.md +1 -1
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +44 -13
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/tdd.md +1 -3
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +5 -5
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -17
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +5 -4
- package/gsd-core/templates/state.md +11 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +3 -3
- package/gsd-core/workflows/add-tests.md +3 -8
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +13 -20
- package/gsd-core/workflows/audit-fix.md +12 -3
- package/gsd-core/workflows/audit-milestone.md +9 -9
- package/gsd-core/workflows/audit-uat.md +17 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +11 -27
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +64 -5
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
- package/gsd-core/workflows/code-review-fix.md +38 -11
- package/gsd-core/workflows/code-review.md +159 -52
- package/gsd-core/workflows/complete-milestone.md +151 -23
- package/gsd-core/workflows/debug.md +12 -8
- package/gsd-core/workflows/diagnose-issues.md +47 -15
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +3 -6
- package/gsd-core/workflows/docs-update.md +5 -4
- package/gsd-core/workflows/edit-phase.md +27 -2
- package/gsd-core/workflows/eval-review.md +7 -14
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase.md +72 -100
- package/gsd-core/workflows/execute-plan.md +52 -15
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +76 -10
- package/gsd-core/workflows/import.md +18 -15
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +49 -16
- package/gsd-core/workflows/insert-phase.md +5 -5
- package/gsd-core/workflows/list-seeds.md +5 -3
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +12 -23
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +8 -5
- package/gsd-core/workflows/new-milestone.md +22 -29
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +26 -40
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +14 -2
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
- package/gsd-core/workflows/plan-phase.md +162 -59
- package/gsd-core/workflows/plan-review-convergence.md +96 -11
- package/gsd-core/workflows/plant-seed.md +2 -2
- package/gsd-core/workflows/pr-branch.md +187 -51
- package/gsd-core/workflows/profile-user.md +16 -14
- package/gsd-core/workflows/progress.md +61 -18
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
- package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
- package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +55 -44
- package/gsd-core/workflows/remove-phase.md +4 -4
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +8 -12
- package/gsd-core/workflows/review.md +219 -20
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +69 -7
- package/gsd-core/workflows/ship.md +116 -50
- package/gsd-core/workflows/sketch-wrap-up.md +11 -17
- package/gsd-core/workflows/sketch.md +12 -18
- package/gsd-core/workflows/smart-entry.md +3 -5
- package/gsd-core/workflows/spec-phase.md +53 -13
- package/gsd-core/workflows/spike-wrap-up.md +7 -11
- package/gsd-core/workflows/spike.md +20 -31
- package/gsd-core/workflows/stats.md +2 -2
- package/gsd-core/workflows/sync-skills.md +64 -9
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +49 -14
- package/gsd-core/workflows/ui-phase.md +15 -21
- package/gsd-core/workflows/ui-review.md +8 -12
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +7 -11
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +66 -25
- package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +19 -10
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
- package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +76 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +37 -27
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +55 -32
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +231 -24
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-validate-commit.sh +80 -6
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +162 -46
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +158 -30
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +19 -10
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +2 -3
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +83 -3
- package/hooks/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +76 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +37 -27
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +55 -32
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +231 -24
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-validate-commit.sh +80 -6
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +162 -46
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +28 -11
- package/pi/gsd.cjs +19 -5
- package/scripts/base64-scan.sh +74 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +5 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +64 -14
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +11 -21
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +138 -25
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +308 -0
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +492 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +471 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +488 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +950 -0
- package/scripts/lint-test-file-count.allowlist.json +137 -8
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +830 -0
- package/scripts/lint-vendored-deps.cjs +297 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +100 -14
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +24 -7
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +9 -5
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -107
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -21,24 +21,34 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
21
21
|
};
|
|
22
22
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
23
|
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
const node_child_process_1 = require("node:child_process");
|
|
24
25
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module
|
|
25
26
|
const ioMod = require("./io.cjs");
|
|
26
|
-
const { output, error, ERROR_REASON } = ioMod;
|
|
27
|
+
const { output, error, ERROR_REASON, formatDiagnosticToken } = ioMod;
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
|
+
const stateContract = require("./state-contract.cjs");
|
|
30
|
+
const { publishStateContract } = stateContract;
|
|
27
31
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
|
|
28
32
|
const configLoaderMod = require("./config-loader.cjs");
|
|
29
33
|
const { loadConfig } = configLoaderMod;
|
|
30
34
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
|
31
35
|
const coreUtilsMod = require("./core-utils.cjs");
|
|
32
|
-
|
|
36
|
+
// #2528: `extractCanonicalPlanId` used to exist here as a byte-identical second
|
|
37
|
+
// copy, and this PR had to patch BOTH with the same rewind rule — the exact
|
|
38
|
+
// generative-fix divergence CLAUDE.md warns about. Collapsed onto core-utils'
|
|
39
|
+
// copy, which was already the leaf owner, so there is no second surface left to
|
|
40
|
+
// drift and no parity test needed to police one.
|
|
41
|
+
const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, normalizeLineEndings, } = coreUtilsMod;
|
|
33
42
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
34
43
|
const phaseIdMod = require("./phase-id.cjs");
|
|
35
|
-
const {
|
|
44
|
+
const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
|
|
45
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
36
46
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
|
37
47
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
38
|
-
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
|
|
48
|
+
const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocatorMod;
|
|
39
49
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
|
40
50
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
41
|
-
const { stripShippedMilestones, extractCurrentMilestone,
|
|
51
|
+
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
|
|
42
52
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
|
43
53
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
44
54
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
@@ -64,12 +74,15 @@ const verifyMod = require("./verify.cjs");
|
|
|
64
74
|
const { readVerificationStatus } = verificationMod;
|
|
65
75
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
|
|
66
76
|
const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
|
|
67
|
-
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphMod;
|
|
68
|
-
const { planningDir, withPlanningLock, listAvailableWorkstreams,
|
|
77
|
+
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
|
|
78
|
+
const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, } = planningWorkspace;
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
|
|
80
|
+
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
82
|
+
const planDocumentMod = require("./plan-document.cjs");
|
|
83
|
+
const { parsePlanDocument, planIdFromFile } = planDocumentMod;
|
|
69
84
|
const { extractFrontmatter } = frontmatterMod;
|
|
70
|
-
const { readModifyWriteStateMd, stateExtractField, stateReplaceField,
|
|
71
|
-
// #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
|
|
72
|
-
const isCanonicalPlanFile = (f) => f.endsWith('-PLAN.md') || f === 'PLAN.md';
|
|
85
|
+
const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, withStateLock, updatePerformanceMetricsSection, } = stateMod;
|
|
73
86
|
// Any .md file with PLAN anywhere in the basename — diagnostic net
|
|
74
87
|
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
|
75
88
|
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
|
@@ -138,27 +151,6 @@ function describeNonCanonicalPlans(dirFiles, matchedFiles) {
|
|
|
138
151
|
`. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
|
|
139
152
|
`See agents/gsd-planner.md write_phase_prompt step for the full contract.`);
|
|
140
153
|
}
|
|
141
|
-
function extractCanonicalPlanId(filename) {
|
|
142
|
-
const base = filename
|
|
143
|
-
.replace(/-PLAN\.md$/i, '')
|
|
144
|
-
.replace(/-SUMMARY\.md$/i, '')
|
|
145
|
-
.replace(/\.md$/i, '');
|
|
146
|
-
const parts = base.split('-').filter(Boolean);
|
|
147
|
-
// #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
|
|
148
|
-
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
|
149
|
-
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
|
150
|
-
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
|
151
|
-
// #2232: the PAIRED plan component is a zero-padded continuation segment
|
|
152
|
-
// (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
|
|
153
|
-
// bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
|
|
154
|
-
// \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
|
|
155
|
-
const planTokenRe = new RegExp(`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
|
|
156
|
-
const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
|
|
157
|
-
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
|
|
158
|
-
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
|
159
|
-
}
|
|
160
|
-
return base;
|
|
161
|
-
}
|
|
162
154
|
function cmdPhasesList(cwd, options, raw) {
|
|
163
155
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
164
156
|
const { type, phase, includeArchived } = options;
|
|
@@ -172,24 +164,56 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
172
164
|
return;
|
|
173
165
|
}
|
|
174
166
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
167
|
+
// #3185 (ADR-3180 Decision 1): only the ENUMERATION routes through the
|
|
168
|
+
// single owner. The two other modes below ask genuinely DIFFERENT
|
|
169
|
+
// questions and are exempt by documented reason, never by a file
|
|
170
|
+
// allowlist (ADR-3180 Decision 4a):
|
|
171
|
+
//
|
|
172
|
+
// --phase <n> locating ONE phase by token is phase LOCATION, a
|
|
173
|
+
// question src/phase-locator.cts already owns via
|
|
174
|
+
// findPhaseInternal/searchPhaseInDir. Scoping it to
|
|
175
|
+
// the current milestone would make an out-of-window
|
|
176
|
+
// phase report "Phase not found".
|
|
177
|
+
// --include-archived archived directories are BY DEFINITION from other
|
|
178
|
+
// milestones; filtering them through the CURRENT
|
|
179
|
+
// milestone window would return nothing at all.
|
|
180
|
+
//
|
|
181
|
+
// Generalizing #3183's rule ("a diagnostic about file NAMING wants the
|
|
182
|
+
// physical set; only a question about outstanding WORK wants the live
|
|
183
|
+
// set"): a LOOKUP wants the physical set; only "which phases belong to
|
|
184
|
+
// this milestone" wants the scoped set.
|
|
185
|
+
const archivedLabels = includeArchived
|
|
186
|
+
? getArchivedPhaseDirs(cwd).map((a) => `${a.name} [${a.milestone}]`)
|
|
187
|
+
: [];
|
|
188
|
+
let dirs;
|
|
189
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
190
|
+
// can tell a genuinely-empty milestone from one it could not scope. Only
|
|
191
|
+
// the ENUMERATION path scopes anything; the LOOKUP path below has no
|
|
192
|
+
// enumeration to report a scope for.
|
|
193
|
+
let phaseScope = null;
|
|
184
194
|
if (phase) {
|
|
195
|
+
// LOOKUP (b): search the physical set, plus archived when asked.
|
|
196
|
+
const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
|
|
185
197
|
const normalized = normalizePhaseName(phase);
|
|
186
|
-
|
|
198
|
+
// The pool is #3185's (physical set + archived); the matcher is this
|
|
199
|
+
// PR's. `dirs` is deliberately not read here: on this base it is not
|
|
200
|
+
// assigned until the branch below picks a match.
|
|
201
|
+
const { matches } = matchPhaseDirs(lookupPool, normalized);
|
|
202
|
+
const match = matches[0];
|
|
187
203
|
if (!match) {
|
|
188
204
|
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
|
|
189
205
|
return;
|
|
190
206
|
}
|
|
191
207
|
dirs = [match];
|
|
192
208
|
}
|
|
209
|
+
else {
|
|
210
|
+
// ENUMERATION (a): milestone-scoped and sentinel-filtered, plus
|
|
211
|
+
// archived when asked (c).
|
|
212
|
+
const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
|
|
213
|
+
phaseScope = enumerated.scope;
|
|
214
|
+
dirs = [...enumerated.value, ...archivedLabels];
|
|
215
|
+
dirs.sort((a, b) => comparePhaseNum(a, b));
|
|
216
|
+
}
|
|
193
217
|
if (type) {
|
|
194
218
|
const files = [];
|
|
195
219
|
const warnings = [];
|
|
@@ -198,13 +222,31 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
198
222
|
const dirFiles = node_fs_1.default.readdirSync(dirPath);
|
|
199
223
|
let filtered;
|
|
200
224
|
if (type === 'plans') {
|
|
201
|
-
|
|
225
|
+
// #3183: this is a "what plan files physically exist" query (this
|
|
226
|
+
// IS the file-listing command), not a live-completion question, so
|
|
227
|
+
// it uses the single owner's allPlanFiles (root+nested, INCLUDING
|
|
228
|
+
// status: superseded) rather than a root-only readdirSync filter
|
|
229
|
+
// that also missed nested plans.
|
|
230
|
+
//
|
|
231
|
+
// #2893 (regression fix): `allPlanFiles` also carries
|
|
232
|
+
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately
|
|
233
|
+
// permissive for live-plan COUNTING elsewhere — see
|
|
234
|
+
// plan-count-single-owner.test.cjs). That fallback silently
|
|
235
|
+
// recognized a non-canonically-named file (e.g.
|
|
236
|
+
// `01-PLAN-01-foundation.md`) as "matched", which defeated this
|
|
237
|
+
// command's #2893 naming-convention diagnostic entirely (no
|
|
238
|
+
// warning, file listed as if valid). Intersect with the STRICT
|
|
239
|
+
// `isCanonicalPlanFile` predicate so this diagnostic — and the
|
|
240
|
+
// `files` list this command actually returns — only ever
|
|
241
|
+
// recognizes the canonical root/nested forms, exactly like the
|
|
242
|
+
// pre-#3183 behavior this feature was built and tested against.
|
|
243
|
+
filtered = scanPhasePlans(dirPath).allPlanFiles.filter(isCanonicalPlanFile);
|
|
202
244
|
const w = describeNonCanonicalPlans(dirFiles, filtered);
|
|
203
245
|
if (w)
|
|
204
246
|
warnings.push(`${dir}: ${w}`);
|
|
205
247
|
}
|
|
206
248
|
else if (type === 'summaries') {
|
|
207
|
-
filtered =
|
|
249
|
+
filtered = scanPhasePlans(dirPath).summaryFiles;
|
|
208
250
|
}
|
|
209
251
|
else {
|
|
210
252
|
filtered = dirFiles;
|
|
@@ -215,13 +257,18 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
215
257
|
files,
|
|
216
258
|
count: files.length,
|
|
217
259
|
phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
|
|
260
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
261
|
+
// can tell a genuinely-empty milestone from one it could not scope.
|
|
262
|
+
phase_scope: phaseScope,
|
|
218
263
|
};
|
|
219
264
|
if (warnings.length)
|
|
220
265
|
result['warning'] = warnings.join(' | ');
|
|
221
266
|
output(result, raw, files.join('\n'));
|
|
222
267
|
return;
|
|
223
268
|
}
|
|
224
|
-
|
|
269
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
270
|
+
// can tell a genuinely-empty milestone from one it could not scope.
|
|
271
|
+
output({ directories: dirs, count: dirs.length, phase_scope: phaseScope }, raw, dirs.join('\n'));
|
|
225
272
|
}
|
|
226
273
|
catch (e) {
|
|
227
274
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -237,8 +284,8 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
|
237
284
|
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
238
285
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
239
286
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
240
|
-
baseExists = dirs
|
|
241
|
-
const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
|
|
287
|
+
baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
|
|
288
|
+
const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalized)}\\.(\\d+)`);
|
|
242
289
|
for (const dir of dirs) {
|
|
243
290
|
const match = dir.match(dirPattern);
|
|
244
291
|
if (match)
|
|
@@ -351,6 +398,13 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
351
398
|
phase_name: null,
|
|
352
399
|
plans: [],
|
|
353
400
|
summaries: [],
|
|
401
|
+
// #3218: scalar counts alongside the arrays above. Left `null` (not `0`)
|
|
402
|
+
// when the phase can't be resolved at all — a fabricated `0` here would
|
|
403
|
+
// read identically to "phase exists with zero plans", which is a real,
|
|
404
|
+
// distinct answer (see the `status: superseded` case below).
|
|
405
|
+
plan_count: null,
|
|
406
|
+
summary_count: null,
|
|
407
|
+
plan_count_all: null,
|
|
354
408
|
searched_directories: [],
|
|
355
409
|
};
|
|
356
410
|
const searchDirs = [];
|
|
@@ -381,7 +435,10 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
381
435
|
// #2237: fail loud when multiple directories match the same bare phase
|
|
382
436
|
// number — prevents cross-project file writes when unrelated projects
|
|
383
437
|
// share a .planning/phases/ tree.
|
|
384
|
-
|
|
438
|
+
// #2528: selection delegates to the canonical two-pass matcher (exact
|
|
439
|
+
// token match, then the bare-integer leading-digit-run fallback) shared
|
|
440
|
+
// with the locator and the phase-plan-index scan.
|
|
441
|
+
const { matches } = matchPhaseDirs(dirs, normalized);
|
|
385
442
|
if (matches.length === 0)
|
|
386
443
|
continue;
|
|
387
444
|
if (matches.length > 1) {
|
|
@@ -398,9 +455,29 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
398
455
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
399
456
|
const phaseDir = node_path_1.default.join(searchDir, match);
|
|
400
457
|
const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
458
|
+
// #3183: canonical, live (superseded-excluded) plan/summary sets
|
|
459
|
+
// (root+nested) from the single owner, rather than a root-only
|
|
460
|
+
// isCanonicalPlanFile filter + hand-rolled summary filter.
|
|
461
|
+
//
|
|
462
|
+
// #2893 (regression fix): both `plans` and the naming-diagnostic
|
|
463
|
+
// "matched" set are further intersected with the STRICT
|
|
464
|
+
// `isCanonicalPlanFile` predicate — scanPhasePlans's own
|
|
465
|
+
// planFiles/allPlanFiles carry `isRootPlanFile`'s loose `/PLAN/i`
|
|
466
|
+
// fallback (deliberately permissive for live-plan COUNTING elsewhere),
|
|
467
|
+
// which silently recognized a non-canonically-named file (e.g.
|
|
468
|
+
// `01-PLAN-01-foundation.md`) as a valid plan here and defeated this
|
|
469
|
+
// command's #2893 naming-convention diagnostic (no warning, offender
|
|
470
|
+
// listed in `plans` as if valid).
|
|
471
|
+
const phaseScan = scanPhasePlans(phaseDir);
|
|
472
|
+
const plans = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
|
473
|
+
const summaries = phaseScan.summaryFiles.slice().sort();
|
|
474
|
+
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated
|
|
475
|
+
// to supersession — compare against allPlanFiles (every plan-shaped file
|
|
476
|
+
// the owner recognizes, canonical or not) rather than the live-only
|
|
477
|
+
// `plans`, so a superseded-but-canonically-named plan is not misreported
|
|
478
|
+
// as a naming violation.
|
|
479
|
+
const canonicalAllPlanFiles = phaseScan.allPlanFiles.filter(isCanonicalPlanFile);
|
|
480
|
+
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, canonicalAllPlanFiles);
|
|
404
481
|
const result = {
|
|
405
482
|
found: true,
|
|
406
483
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir), match)),
|
|
@@ -408,6 +485,20 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
408
485
|
phase_name: phaseName,
|
|
409
486
|
plans,
|
|
410
487
|
summaries,
|
|
488
|
+
// #3218: scalar counts additive alongside `plans[]`/`summaries[]`,
|
|
489
|
+
// which stay unchanged for existing consumers. Naming mirrors
|
|
490
|
+
// `roadmap.analyze`'s `plan_count`/`summary_count` (live, i.e.
|
|
491
|
+
// status:superseded EXCLUDED — same set as `plans`/`summaries`
|
|
492
|
+
// above) so the two surfaces read alike. `plan_count_all` is the
|
|
493
|
+
// PHYSICAL count — every canonically-named plan file on disk,
|
|
494
|
+
// status:superseded INCLUDED, same set `planNamingWarning` above
|
|
495
|
+
// diffs against (`canonicalAllPlanFiles`). The `_all` suffix
|
|
496
|
+
// deliberately echoes `scanPhasePlans`'s own `allPlanFiles` field so
|
|
497
|
+
// a reader can trace the name back to its source rather than guess
|
|
498
|
+
// which of two similarly-named integers is the filtered one.
|
|
499
|
+
plan_count: plans.length,
|
|
500
|
+
summary_count: summaries.length,
|
|
501
|
+
plan_count_all: canonicalAllPlanFiles.length,
|
|
411
502
|
};
|
|
412
503
|
if (planNamingWarning)
|
|
413
504
|
result['warning'] = planNamingWarning;
|
|
@@ -420,23 +511,73 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
420
511
|
}
|
|
421
512
|
output(notFound, raw, '');
|
|
422
513
|
}
|
|
423
|
-
function extractObjective(content) {
|
|
424
|
-
const m = content.match(/<objective>\s*\n?\s*(.+)/);
|
|
425
|
-
return m ? m[1].trim() : null;
|
|
426
|
-
}
|
|
427
514
|
/**
|
|
428
515
|
* Resolve a raw `depends_on` token to the `RawPlan.id` it refers to
|
|
429
|
-
* (case-folded exact match, falling back to canonical-id matching
|
|
516
|
+
* (case-folded exact match, falling back to canonical-id matching, falling
|
|
517
|
+
* back to the in-phase short-form plan number — #3897 rung 4). Returns
|
|
430
518
|
* `null` when the token does not resolve to any plan in this phase (a typo
|
|
431
519
|
* or a cross-phase reference) — every call site treats that as "ignore this
|
|
432
520
|
* edge", never a throw. Shared by `computeDependencyLevels`'s DAG-edge
|
|
433
|
-
* resolution
|
|
434
|
-
*
|
|
435
|
-
*
|
|
521
|
+
* resolution and (#2830) the halt-propagation node resolution, so the two can
|
|
522
|
+
* never disagree about which token resolves to which plan. NOT used by the
|
|
523
|
+
* `depends_on` display mapping (#3785/N3) — that stays a passthrough by
|
|
524
|
+
* design; see the comment at its call site.
|
|
525
|
+
*
|
|
526
|
+
* `shortFormToId` (#3897 rung 4, ADR-3473 §8.9) is the third tier, consulted
|
|
527
|
+
* only when neither `planMap` nor `canonicalToId` resolves the token. It is
|
|
528
|
+
* optional so any caller that has not been threaded through yet (there are
|
|
529
|
+
* none left in this file) degrades to the pre-#3897 two-tier behavior rather
|
|
530
|
+
* than throwing on a missing argument.
|
|
436
531
|
*/
|
|
437
|
-
function resolveDependencyId(dep, planMap, canonicalToId) {
|
|
532
|
+
function resolveDependencyId(dep, planMap, canonicalToId, shortFormToId) {
|
|
438
533
|
const lower = dep.toLowerCase();
|
|
439
|
-
|
|
534
|
+
if (planMap.has(lower))
|
|
535
|
+
return planMap.get(lower).id;
|
|
536
|
+
if (canonicalToId.has(lower))
|
|
537
|
+
return canonicalToId.get(lower);
|
|
538
|
+
return shortFormToId?.get(lower) ?? null;
|
|
539
|
+
}
|
|
540
|
+
// #3897 rung 4 (ADR-3473 §8.9) — builds the third depends_on resolution tier:
|
|
541
|
+
// a map from an in-phase BARE PLAN NUMBER (e.g. "01") to the plan id whose
|
|
542
|
+
// canonical id ends with that number. Recovered from the retired SDK lineage
|
|
543
|
+
// (sdk/src/query/phase.ts at 11918dcc3^) with ONE deliberate narrowing: the
|
|
544
|
+
// lost implementation indexed ANY trailing dash-segment of a canonical id,
|
|
545
|
+
// with no constraint that the segment be a plan NUMBER — so a phase
|
|
546
|
+
// containing both `09-FIX-auth-PLAN.md` and `09-GAP-auth-PLAN.md` (canonical
|
|
547
|
+
// id `09-FIX-auth`, trailing segment "auth") would silently bind
|
|
548
|
+
// `depends_on: ["auth"]` to whichever sorted first, fabricating a
|
|
549
|
+
// wave-affecting DAG edge with ZERO warning — a mis-resolved edge, which is
|
|
550
|
+
// worse than a dropped one (found in isolated correctness review, #3897).
|
|
551
|
+
// `docs/reference/plan-md.md` already documents this tier as resolving "the
|
|
552
|
+
// bare plan number", so requiring `/^\d+$/` on the trailing segment is a
|
|
553
|
+
// strict narrowing onto the tier's OWN documented contract, not a behavior
|
|
554
|
+
// change for any legitimate input. Do NOT restore the unconstrained
|
|
555
|
+
// lastDash-slice "to match the recovered original" — the original was wrong
|
|
556
|
+
// here; this rung deliberately departs from it in this one respect, and only
|
|
557
|
+
// this one. Everything else — the `lastDash` bound, first-write-wins,
|
|
558
|
+
// lowercasing — is kept exactly as recovered:
|
|
559
|
+
// - first write wins, deterministic because rawPlans is passed in sorted
|
|
560
|
+
// plan-file order (D4/T44) and this loop iterates in that same order;
|
|
561
|
+
// - a canonical id with no dash (`lastDash === -1` or `lastDash === 0`,
|
|
562
|
+
// e.g. "24" or "-01") or a trailing dash (`lastDash === canonical.length
|
|
563
|
+
// - 1`, e.g. "09-") is never indexed (D5).
|
|
564
|
+
// Exported so callers can build this map once and so tests assert against
|
|
565
|
+
// this REAL implementation rather than a hand-rolled copy that could
|
|
566
|
+
// silently disagree with it after a future change here (CLAUDE.md's
|
|
567
|
+
// generative-fix-divergence rule).
|
|
568
|
+
function buildShortFormToId(rawPlans) {
|
|
569
|
+
const shortFormToId = new Map();
|
|
570
|
+
for (const p of rawPlans) {
|
|
571
|
+
const canonical = extractCanonicalPlanId(p.id);
|
|
572
|
+
const lastDash = canonical.lastIndexOf('-');
|
|
573
|
+
if (lastDash > 0 && lastDash < canonical.length - 1) {
|
|
574
|
+
const shortForm = canonical.slice(lastDash + 1).toLowerCase();
|
|
575
|
+
if (/^\d+$/.test(shortForm) && !shortFormToId.has(shortForm)) {
|
|
576
|
+
shortFormToId.set(shortForm, p.id);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return shortFormToId;
|
|
440
581
|
}
|
|
441
582
|
// O(V + E). Assigns each in-phase plan its longest-path topological level over the
|
|
442
583
|
// in-phase dependsOn DAG (Kahn's algorithm). Returns { level: Map<id,number>, visited: number,
|
|
@@ -444,19 +585,32 @@ function resolveDependencyId(dep, planMap, canonicalToId) {
|
|
|
444
585
|
// the exact dequeue order this pass already produces — a valid topological order — passed to
|
|
445
586
|
// computeHaltPropagation as `precomputedOrder` so halt propagation does not re-run Kahn's
|
|
446
587
|
// algorithm a second time over the same graph.
|
|
447
|
-
|
|
588
|
+
//
|
|
589
|
+
// `shortFormToId` (#3897 rung 4, optional — see resolveDependencyId) is threaded through so a
|
|
590
|
+
// bare in-phase plan-number token (`depends_on: ["01"]`) resolves as a real DAG edge instead of
|
|
591
|
+
// being dropped and silently collapsing the dependent plan to wave 1 (D3).
|
|
592
|
+
function computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId) {
|
|
448
593
|
const level = new Map();
|
|
449
594
|
const inDeg = new Map();
|
|
450
595
|
const adj = new Map();
|
|
596
|
+
// #3427 / ADR-3473 §8.5: a depends_on token that resolves via NONE of the
|
|
597
|
+
// three tiers (planMap, canonicalToId, shortFormToId) is a dropped edge.
|
|
598
|
+
// Naming it here (rather than silently `continue`-ing past it) lets
|
|
599
|
+
// cmdPhasePlanIndex surface the token's own warning instead of
|
|
600
|
+
// manufacturing a wave-mismatch verdict from the resulting damaged graph
|
|
601
|
+
// (#3427).
|
|
602
|
+
const unresolved = [];
|
|
451
603
|
for (const p of rawPlans) {
|
|
452
604
|
if (!inDeg.has(p.id))
|
|
453
605
|
inDeg.set(p.id, 0);
|
|
454
606
|
if (!adj.has(p.id))
|
|
455
607
|
adj.set(p.id, []);
|
|
456
608
|
for (const dep of p.dependsOn) {
|
|
457
|
-
const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId);
|
|
458
|
-
if (!resolvedDep)
|
|
609
|
+
const resolvedDep = resolveDependencyId(dep, planMap, canonicalToId, shortFormToId);
|
|
610
|
+
if (!resolvedDep) {
|
|
611
|
+
unresolved.push({ plan: p.id, token: String(dep) });
|
|
459
612
|
continue;
|
|
613
|
+
}
|
|
460
614
|
if (!adj.has(resolvedDep))
|
|
461
615
|
adj.set(resolvedDep, []);
|
|
462
616
|
adj.get(resolvedDep).push(p.id);
|
|
@@ -489,7 +643,7 @@ function computeDependencyLevels(rawPlans, planMap, canonicalToId) {
|
|
|
489
643
|
}
|
|
490
644
|
}
|
|
491
645
|
}
|
|
492
|
-
return { level, visited, order: queue };
|
|
646
|
+
return { level, visited, order: queue, unresolved };
|
|
493
647
|
}
|
|
494
648
|
function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
495
649
|
if (!phase) {
|
|
@@ -499,74 +653,109 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
499
653
|
const normalized = normalizePhaseName(phase);
|
|
500
654
|
let phaseDir = null;
|
|
501
655
|
let phaseDirName = null;
|
|
656
|
+
let ambiguousMatches = null;
|
|
502
657
|
try {
|
|
503
658
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
504
659
|
const dirs = entries
|
|
505
660
|
.filter((e) => e.isDirectory())
|
|
506
661
|
.map((e) => e.name)
|
|
507
662
|
.sort((a, b) => comparePhaseNum(a, b));
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
663
|
+
// #2528: selection delegates to the canonical two-pass matcher shared with
|
|
664
|
+
// the locator and the find-phase scan (this site previously first-matched
|
|
665
|
+
// with `.find()` and had no multi-match guard — the #2237 fail-loud rule
|
|
666
|
+
// now applies here too, so the three resolution paths cannot disagree).
|
|
667
|
+
const { matches } = matchPhaseDirs(dirs, normalized);
|
|
668
|
+
if (matches.length > 1) {
|
|
669
|
+
ambiguousMatches = matches;
|
|
670
|
+
}
|
|
671
|
+
else if (matches.length === 1) {
|
|
672
|
+
phaseDir = node_path_1.default.join(phasesDir, matches[0]);
|
|
673
|
+
phaseDirName = matches[0];
|
|
512
674
|
}
|
|
513
675
|
}
|
|
514
676
|
catch {
|
|
515
677
|
// phases dir doesn't exist
|
|
516
678
|
}
|
|
679
|
+
if (ambiguousMatches) {
|
|
680
|
+
output({
|
|
681
|
+
phase: normalized,
|
|
682
|
+
error: `Phase ${normalized} is ambiguous: ${ambiguousMatches.length} directories match (${ambiguousMatches.map((m) => `"${m}"`).join(', ')}).`,
|
|
683
|
+
ambiguous_matches: ambiguousMatches,
|
|
684
|
+
plans: [], waves: {}, incomplete: [], has_checkpoints: false,
|
|
685
|
+
}, raw);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
517
688
|
if (!phaseDir) {
|
|
518
689
|
output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false }, raw);
|
|
519
690
|
return;
|
|
520
691
|
}
|
|
521
692
|
void phaseDirName; // used only to set phaseDir above
|
|
693
|
+
// phaseFiles stays root-only readdirSync — it feeds only
|
|
694
|
+
// describeNonCanonicalPlans's near-miss naming diagnostic below, which is
|
|
695
|
+
// advisory text, not a counted/scheduled file set.
|
|
522
696
|
const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
697
|
+
// #3183 (highest-severity site, ADR-3180 Decision 2): canonical LIVE
|
|
698
|
+
// plan/summary sets (root+nested, status: superseded EXCLUDED) from the
|
|
699
|
+
// single owner. This fixes two real bugs in the wave/dependency index this
|
|
700
|
+
// function builds: (1) a superseded plan used to still get scheduled into
|
|
701
|
+
// an execution wave, and (2) a phase using the #3139 nested `plans/`
|
|
702
|
+
// layout used to report ZERO plans (root-only readdirSync, no `plans/`
|
|
703
|
+
// join).
|
|
704
|
+
// #2893 (regression fix): intersected with the STRICT `isCanonicalPlanFile`
|
|
705
|
+
// predicate — scanPhasePlans's own planFiles/allPlanFiles carry
|
|
706
|
+
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately permissive for
|
|
707
|
+
// live-plan COUNTING elsewhere), which silently scheduled a
|
|
708
|
+
// non-canonically-named file (e.g. `01-PLAN-01-foundation.md`) into a wave
|
|
709
|
+
// here and defeated this command's #2893 naming-convention diagnostic (no
|
|
710
|
+
// warning). Restores the pre-#3183, tested behavior: only canonical
|
|
711
|
+
// root/nested filenames are ever counted or scheduled by this command.
|
|
712
|
+
const phaseScan = scanPhasePlans(phaseDir);
|
|
713
|
+
const planFiles = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
|
714
|
+
const summaryFiles = phaseScan.summaryFiles;
|
|
715
|
+
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated to
|
|
716
|
+
// supersession — compare against allPlanFiles (every plan-shaped file the
|
|
717
|
+
// owner recognizes, canonical or not) rather than the live-only planFiles,
|
|
718
|
+
// so a superseded-but-canonically-named plan is not misreported as a
|
|
719
|
+
// naming violation.
|
|
720
|
+
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, phaseScan.allPlanFiles.filter(isCanonicalPlanFile));
|
|
721
|
+
// #3183: completion pairing via the canonical findUnsummarizedPlans
|
|
722
|
+
// (shares its `summaryCandidates` matching rule with countMatchedSummaries,
|
|
723
|
+
// and is layout-agnostic — it pairs a nested `plans/PLAN-01.md` with
|
|
724
|
+
// `plans/SUMMARY-01.md` correctly) instead of a bespoke ID-Set built from
|
|
725
|
+
// extractCanonicalPlanId, which only ever handled the root-canonical
|
|
726
|
+
// `-PLAN.md`/`-SUMMARY.md` naming form.
|
|
727
|
+
//
|
|
728
|
+
// #3345: the summary list is filtered through the SAME shared predicate
|
|
729
|
+
// scanPhasePlans filters its countable set with
|
|
730
|
+
// (plan-dependency-graph.cjs's isSummaryFileBlocked), so a SUMMARY declaring
|
|
731
|
+
// `status: blocked` reads as NO completion record here — has_summary false,
|
|
732
|
+
// the plan lands in `incomplete` — exactly matching the count side. Fail-open
|
|
733
|
+
// on a SUMMARY with no status key / unreadable file (filename fallback);
|
|
734
|
+
// `status: halted` stays summarized (#2830 designed stop). summaryFileByPlanId
|
|
735
|
+
// below still indexes EVERY summary on disk because the halted lookup is a
|
|
736
|
+
// file resolution for reading status, not a completion pairing.
|
|
737
|
+
const countableSummaryFiles = summaryFiles.filter((f) => !isSummaryFileBlocked(node_path_1.default.join(phaseDir, f)));
|
|
738
|
+
const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, countableSummaryFiles));
|
|
531
739
|
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
|
|
532
740
|
// the actual summary filename, so a plan's own SUMMARY frontmatter can be
|
|
533
741
|
// read for its `status`. Shared builder (also used by phase-locator.cts's
|
|
534
742
|
// searchPhaseInDir) so the two can never disagree about which summary
|
|
535
|
-
// belongs to which plan.
|
|
743
|
+
// belongs to which plan. This is a FILE resolution for reading halted
|
|
744
|
+
// status, not a completion-count pairing rule, so it is unaffected by the
|
|
745
|
+
// #3183 pairing migration above.
|
|
536
746
|
const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
|
|
537
747
|
// ── Pass 1: parse each plan file ─────────────────────────────────────────
|
|
538
748
|
const rawPlans = [];
|
|
539
749
|
for (const planFile of planFiles) {
|
|
540
|
-
const planId = planFile
|
|
750
|
+
const planId = planIdFromFile(planFile);
|
|
541
751
|
const planPath = node_path_1.default.join(phaseDir, planFile);
|
|
542
752
|
const content = node_fs_1.default.readFileSync(planPath, 'utf-8');
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
const
|
|
549
|
-
const declaredWave = Number.isNaN(parsedWave) ? null : parsedWave;
|
|
550
|
-
let dependsOn = [];
|
|
551
|
-
const fmDeps = fm['depends_on'];
|
|
552
|
-
if (Array.isArray(fmDeps)) {
|
|
553
|
-
dependsOn = fmDeps.map(String);
|
|
554
|
-
}
|
|
555
|
-
else if (typeof fmDeps === 'string' && fmDeps.trim() !== '') {
|
|
556
|
-
dependsOn = [fmDeps];
|
|
557
|
-
}
|
|
558
|
-
let autonomous = true;
|
|
559
|
-
if (fm['autonomous'] !== undefined) {
|
|
560
|
-
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue comparison
|
|
561
|
-
autonomous = fm['autonomous'] === 'true' || String(fm['autonomous']) === 'true';
|
|
562
|
-
}
|
|
563
|
-
let filesModified = [];
|
|
564
|
-
const fmFiles = fm['files_modified'] || fm['files-modified'];
|
|
565
|
-
if (fmFiles) {
|
|
566
|
-
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
|
567
|
-
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
|
|
568
|
-
}
|
|
569
|
-
const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
|
|
753
|
+
// #2790: plan-body parsing is owned by the shared Plan Document Module, so
|
|
754
|
+
// this command and the read-only `planning.inspect` query cannot drift on
|
|
755
|
+
// what a plan document says. planPath is still passed so a truncated
|
|
756
|
+
// PLAN.md names the file in the #1882 diagnostic.
|
|
757
|
+
const planDoc = parsePlanDocument(content, planPath);
|
|
758
|
+
const hasSummary = !unsummarizedPlanFiles.has(planFile);
|
|
570
759
|
// #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
|
|
571
760
|
// a designed stop still writes a completion record, just one whose status
|
|
572
761
|
// says "halted" rather than "complete". Only look up the summary file
|
|
@@ -577,12 +766,14 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
577
766
|
: false;
|
|
578
767
|
rawPlans.push({
|
|
579
768
|
id: planId,
|
|
580
|
-
declaredWave,
|
|
581
|
-
dependsOn,
|
|
582
|
-
autonomous,
|
|
583
|
-
objective:
|
|
584
|
-
filesModified,
|
|
585
|
-
|
|
769
|
+
declaredWave: planDoc.declaredWave,
|
|
770
|
+
dependsOn: planDoc.dependsOn,
|
|
771
|
+
autonomous: planDoc.autonomous,
|
|
772
|
+
objective: planDoc.objective,
|
|
773
|
+
filesModified: planDoc.filesModified,
|
|
774
|
+
filesDeleted: planDoc.filesDeleted,
|
|
775
|
+
agentHint: planDoc.agentHint,
|
|
776
|
+
taskCount: planDoc.taskCount,
|
|
586
777
|
hasSummary,
|
|
587
778
|
halted,
|
|
588
779
|
});
|
|
@@ -600,7 +791,15 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
600
791
|
}
|
|
601
792
|
const planMap = new Map(rawPlans.map((p) => [p.id.toLowerCase(), p]));
|
|
602
793
|
const canonicalToId = new Map(rawPlans.map((p) => [extractCanonicalPlanId(p.id).toLowerCase(), p.id]));
|
|
603
|
-
|
|
794
|
+
// #3897 rung 4 (ADR-3473 §8.9) — the third depends_on resolution tier.
|
|
795
|
+
// Resolves a bare in-phase plan-number short form (e.g. "01") to its owning
|
|
796
|
+
// plan id. In-phase only by construction (T49): the map is built from THIS
|
|
797
|
+
// phase's rawPlans alone, so a short form colliding with a different
|
|
798
|
+
// phase's plan can never be a candidate. See {@link buildShortFormToId}'s
|
|
799
|
+
// own comment for the numeric-only narrowing this rung applies on top of
|
|
800
|
+
// the recovered SDK-lineage algorithm.
|
|
801
|
+
const shortFormToId = buildShortFormToId(rawPlans);
|
|
802
|
+
const { level, visited, order, unresolved } = computeDependencyLevels(rawPlans, planMap, canonicalToId, shortFormToId);
|
|
604
803
|
if (visited < rawPlans.length) {
|
|
605
804
|
const cycleNodes = rawPlans.filter((p) => !level.has(p.id)).map((p) => p.id);
|
|
606
805
|
error(`depends_on cycle detected in phase ${normalized} — cycle involves: ${cycleNodes.join(', ')}`);
|
|
@@ -614,7 +813,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
614
813
|
const haltNodes = rawPlans.map((p) => ({
|
|
615
814
|
id: p.id,
|
|
616
815
|
resolvedDependsOn: p.dependsOn
|
|
617
|
-
.map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId))
|
|
816
|
+
.map((dep) => resolveDependencyId(String(dep), planMap, canonicalToId, shortFormToId))
|
|
618
817
|
.filter((id) => id !== null),
|
|
619
818
|
halted: p.halted,
|
|
620
819
|
}));
|
|
@@ -628,6 +827,17 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
628
827
|
const runnable = [];
|
|
629
828
|
let hasCheckpoints = false;
|
|
630
829
|
const warnings = [];
|
|
830
|
+
// #3427 / ADR-3473 §8.5: name every dropped depends_on edge (plan AND
|
|
831
|
+
// token) rather than letting it silently collapse the plan to a DAG root.
|
|
832
|
+
// A plan with at least one unresolved token gets ITS OWN warning here and
|
|
833
|
+
// the wave-mismatch verdict below is suppressed for that plan ONLY — a
|
|
834
|
+
// plan with no dropped edges and a genuinely wrong `wave:` still warns
|
|
835
|
+
// (N3, D6, T25).
|
|
836
|
+
const plansWithUnresolvedTokens = new Set();
|
|
837
|
+
for (const { plan, token } of unresolved) {
|
|
838
|
+
plansWithUnresolvedTokens.add(plan);
|
|
839
|
+
warnings.push(`Plan ${plan}: depends_on token ${formatDiagnosticToken(token)} does not resolve to any plan in this phase — edge dropped, wave placement for this plan may be unreliable`);
|
|
840
|
+
}
|
|
631
841
|
for (const rawPlan of rawPlans) {
|
|
632
842
|
if (!rawPlan.autonomous) {
|
|
633
843
|
hasCheckpoints = true;
|
|
@@ -644,7 +854,15 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
644
854
|
}
|
|
645
855
|
const computedWave = (level.get(rawPlan.id) ?? 0) + levelOffset;
|
|
646
856
|
const effectiveWave = computedWave;
|
|
647
|
-
|
|
857
|
+
// #3427 (D5/N3): suppress the wave-mismatch verdict for a plan that has
|
|
858
|
+
// at least one unresolved depends_on token — its own dropped-edge
|
|
859
|
+
// warning above already explains the degraded wave placement, so the
|
|
860
|
+
// mismatch here would blame the author for a DAG the tool itself
|
|
861
|
+
// couldn't build. A plan with NO unresolved tokens still gets a genuine
|
|
862
|
+
// mismatch reported (N3, T25) — the suppression is per-plan, never blanket.
|
|
863
|
+
if (rawPlan.declaredWave !== null &&
|
|
864
|
+
rawPlan.declaredWave !== computedWave &&
|
|
865
|
+
!plansWithUnresolvedTokens.has(rawPlan.id)) {
|
|
648
866
|
warnings.push(`Plan ${rawPlan.id}: declared wave: ${rawPlan.declaredWave} but depends_on DAG places it in wave ${computedWave}`);
|
|
649
867
|
}
|
|
650
868
|
const plan = {
|
|
@@ -664,6 +882,8 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
664
882
|
autonomous: rawPlan.autonomous,
|
|
665
883
|
objective: rawPlan.objective,
|
|
666
884
|
files_modified: rawPlan.filesModified,
|
|
885
|
+
files_deleted: rawPlan.filesDeleted,
|
|
886
|
+
agent_hint: rawPlan.agentHint,
|
|
667
887
|
task_count: rawPlan.taskCount,
|
|
668
888
|
has_summary: rawPlan.hasSummary,
|
|
669
889
|
// #2830: additive fields — halted is this plan's OWN status; blocked_by
|
|
@@ -716,10 +936,123 @@ function describeGoalShapedTitle(description) {
|
|
|
716
936
|
return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
|
|
717
937
|
`as the phase title; consider a short title with the detail moved to **Goal:**.`);
|
|
718
938
|
}
|
|
939
|
+
/**
|
|
940
|
+
* #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
|
|
941
|
+
* entry should be inserted — at the end of the active phase list, scoped to the
|
|
942
|
+
* CURRENT MILESTONE so the entry can never land before a trailing `---` in
|
|
943
|
+
* shipped/history/backlog material (the file's last `---` on a long roadmap
|
|
944
|
+
* sits deep in archive). When no current milestone can be resolved (no
|
|
945
|
+
* STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
|
|
946
|
+
* legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
|
|
947
|
+
* their existing behavior.
|
|
948
|
+
*/
|
|
949
|
+
function phaseEntryInsertOffset(rawContent, cwd) {
|
|
950
|
+
const ranges = currentMilestoneRawRanges(rawContent, cwd);
|
|
951
|
+
if (!ranges) {
|
|
952
|
+
const legacy = rawContent.lastIndexOf('\n---');
|
|
953
|
+
return legacy > 0 ? legacy : rawContent.length;
|
|
954
|
+
}
|
|
955
|
+
const window = rawContent.slice(ranges.primary.start, ranges.primary.end);
|
|
956
|
+
const lastSeparator = window.lastIndexOf('\n---');
|
|
957
|
+
return lastSeparator > 0 ? ranges.primary.start + lastSeparator : ranges.primary.end;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* #3262 (write-time milestone-scope guard): the phase-creation and
|
|
961
|
+
* phase-insertion entry templates interpolate the caller's `description`
|
|
962
|
+
* verbatim into `### Phase N: ${description}`. A description embedding a
|
|
963
|
+
* level 1-3 heading that carries a milestone marker (version token,
|
|
964
|
+
* ✅/📋/🚧/🔄, or the word "Milestone") would splice a heading that TERMINATES
|
|
965
|
+
* the current milestone window (`computeMilestoneSectionEnd`) and silently
|
|
966
|
+
* drops every later phase out of the derived milestone phase set. Reject
|
|
967
|
+
* before any write or phase-directory creation — the fail-loud sibling of
|
|
968
|
+
* the edit-phase workflow's depends_on gate. The predicate itself
|
|
969
|
+
* (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt,
|
|
970
|
+
* so ordinary descriptions and the phase's own numbered heading never trip it.
|
|
971
|
+
*/
|
|
972
|
+
function assertDescriptionPreservesMilestoneScope(description, command) {
|
|
973
|
+
const offending = findMilestoneScopeHeadingLines(description);
|
|
974
|
+
if (offending.length === 0)
|
|
975
|
+
return;
|
|
976
|
+
error(`${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` +
|
|
977
|
+
`the current milestone window and silently drop later phases out of the milestone scope. ` +
|
|
978
|
+
`Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` +
|
|
979
|
+
`Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
|
|
980
|
+
`(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").`);
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* #3849 — widen "used phase numbers" beyond this checkout. Every sibling git
|
|
984
|
+
* worktree carries its own `.planning/` on its own branch, so a phase minted
|
|
985
|
+
* there is invisible to the cwd-scoped sources (headers, bullets, on-disk
|
|
986
|
+
* dirs). Scan each sibling's phase-directory names (cheap — dir names alone
|
|
987
|
+
* caught the real incident) and its WHOLE ROADMAP.md headers (a row can exist
|
|
988
|
+
* before any directory does; milestone-scoping is wrong here because a number
|
|
989
|
+
* used under any milestone on another branch is still taken).
|
|
990
|
+
*
|
|
991
|
+
* Widen, never refuse: a missing `.planning/`, an unreadable sibling, a
|
|
992
|
+
* non-git cwd, or an unavailable git binary each leave `used` untouched —
|
|
993
|
+
* allocation then behaves exactly as it did before this horizon existed.
|
|
994
|
+
* Sentinels reuse the canonical `isSentinelPhaseId`; the dir pattern is the
|
|
995
|
+
* same one the on-disk scan uses, so decimal sub-phases (`411.1-foo`) are
|
|
996
|
+
* correctly not integers.
|
|
997
|
+
*/
|
|
998
|
+
function collectSiblingWorktreePhaseNums(cwd, used) {
|
|
999
|
+
let porcelain;
|
|
1000
|
+
try {
|
|
1001
|
+
porcelain = (0, node_child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], {
|
|
1002
|
+
cwd,
|
|
1003
|
+
encoding: 'utf-8',
|
|
1004
|
+
// Same subprocess band as the other git call sites (smart-entry, check-command-router):
|
|
1005
|
+
// inside the 5-30s git window, hidden console window on Windows, bounded buffer.
|
|
1006
|
+
timeout: 10_000,
|
|
1007
|
+
windowsHide: true,
|
|
1008
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
return; // not a git repo / git unavailable — unchanged behavior
|
|
1013
|
+
}
|
|
1014
|
+
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
|
|
1015
|
+
// Same header shape the allocators scan locally (#1729 tag tolerance).
|
|
1016
|
+
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
|
1017
|
+
for (const line of porcelain.split('\n')) {
|
|
1018
|
+
if (!line.startsWith('worktree '))
|
|
1019
|
+
continue;
|
|
1020
|
+
const wt = line.slice('worktree '.length).trim();
|
|
1021
|
+
if (!wt || node_path_1.default.resolve(wt) === node_path_1.default.resolve(cwd))
|
|
1022
|
+
continue;
|
|
1023
|
+
try {
|
|
1024
|
+
for (const entry of node_fs_1.default.readdirSync(node_path_1.default.join(wt, '.planning', 'phases'))) {
|
|
1025
|
+
const match = entry.match(dirNumPattern);
|
|
1026
|
+
if (!match)
|
|
1027
|
+
continue;
|
|
1028
|
+
const num = parseInt(match[1], 10);
|
|
1029
|
+
if (!isSentinelPhaseId(num))
|
|
1030
|
+
used.add(num);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
/* worktree has no .planning — normal, contributes nothing */
|
|
1035
|
+
}
|
|
1036
|
+
try {
|
|
1037
|
+
const content = node_fs_1.default.readFileSync(node_path_1.default.join(wt, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
1038
|
+
let m;
|
|
1039
|
+
headerPattern.lastIndex = 0;
|
|
1040
|
+
while ((m = headerPattern.exec(content)) !== null) {
|
|
1041
|
+
const num = parseInt(m[1], 10);
|
|
1042
|
+
if (!isSentinelPhaseId(num))
|
|
1043
|
+
used.add(num);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
1047
|
+
/* no roadmap in that worktree — normal, contributes nothing */
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
719
1051
|
function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
720
1052
|
if (!description) {
|
|
721
1053
|
error('description required for phase add');
|
|
722
1054
|
}
|
|
1055
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase add');
|
|
723
1056
|
const config = loadConfig(cwd);
|
|
724
1057
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
725
1058
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
@@ -755,12 +1088,14 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
755
1088
|
let m;
|
|
756
1089
|
while ((m = headerPattern.exec(content)) !== null) {
|
|
757
1090
|
const num = parseInt(m[1], 10);
|
|
758
|
-
|
|
1091
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1092
|
+
if (!isSentinelPhaseId(num))
|
|
759
1093
|
usedPhaseNums.add(num);
|
|
760
1094
|
}
|
|
761
1095
|
while ((m = bulletPattern.exec(content)) !== null) {
|
|
762
1096
|
const num = parseInt(m[1], 10);
|
|
763
|
-
|
|
1097
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1098
|
+
if (!isSentinelPhaseId(num))
|
|
764
1099
|
usedPhaseNums.add(num);
|
|
765
1100
|
}
|
|
766
1101
|
// 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
|
|
@@ -772,7 +1107,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
772
1107
|
if (!match)
|
|
773
1108
|
continue;
|
|
774
1109
|
const num = parseInt(match[1], 10);
|
|
775
|
-
|
|
1110
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1111
|
+
if (!isSentinelPhaseId(num))
|
|
776
1112
|
usedPhaseNums.add(num);
|
|
777
1113
|
}
|
|
778
1114
|
}
|
|
@@ -780,6 +1116,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
780
1116
|
// section headers, roadmap bullets, AND on-disk dirs above is what prevents the
|
|
781
1117
|
// #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
|
|
782
1118
|
// an existing number.
|
|
1119
|
+
// 4) Sibling git worktrees (#3849) — same max+1, wider horizon: a number
|
|
1120
|
+
// taken on another branch is still taken.
|
|
1121
|
+
collectSiblingWorktreePhaseNums(cwd, usedPhaseNums);
|
|
783
1122
|
const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
|
|
784
1123
|
_newPhaseId = maxUsed + 1;
|
|
785
1124
|
const paddedNum = String(_newPhaseId).padStart(2, '0');
|
|
@@ -792,14 +1131,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
792
1131
|
? ''
|
|
793
1132
|
: `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
|
|
794
1133
|
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`;
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
if (lastSeparator > 0) {
|
|
798
|
-
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
|
|
799
|
-
}
|
|
800
|
-
else {
|
|
801
|
-
updatedContent = rawContent + phaseEntry;
|
|
802
|
-
}
|
|
1134
|
+
const insertAt = phaseEntryInsertOffset(rawContent, cwd);
|
|
1135
|
+
const updatedContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
|
|
803
1136
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
|
|
804
1137
|
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
|
805
1138
|
});
|
|
@@ -815,11 +1148,29 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
815
1148
|
if (titleWarning)
|
|
816
1149
|
result['warning'] = titleWarning;
|
|
817
1150
|
output(result, raw, result['padded']);
|
|
1151
|
+
// #3227 (design doc §40 row 26 / "Not-corruption" rule): every
|
|
1152
|
+
// `publishStateContract` call site in this file is audited so a refreshed
|
|
1153
|
+
// state.json `updated_at` always means something on disk actually moved —
|
|
1154
|
+
// a stale-but-refreshed timestamp is worse than no refresh, because it
|
|
1155
|
+
// reads as fresh to a downstream watcher. This site is unconditional
|
|
1156
|
+
// because every reachable path either exits via `error()` (process.exit,
|
|
1157
|
+
// never reaches here) or falls through to the unconditional
|
|
1158
|
+
// `platformEnsureDir`/`platformWriteSync` pair above that always creates
|
|
1159
|
+
// the phase directory and rewrites ROADMAP.md — there is no code path that
|
|
1160
|
+
// reaches this line without having just written to disk. Best-effort —
|
|
1161
|
+
// cannot throw, cannot change this command's exit code or output.
|
|
1162
|
+
publishStateContract(cwd);
|
|
818
1163
|
}
|
|
819
1164
|
function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
820
1165
|
if (!Array.isArray(descriptions) || descriptions.length === 0) {
|
|
821
1166
|
error('descriptions array required for phase add-batch');
|
|
822
1167
|
}
|
|
1168
|
+
// #3262: validate every description BEFORE the lock — the batch is
|
|
1169
|
+
// all-or-nothing, so one offending description must reject the whole batch
|
|
1170
|
+
// with no ROADMAP write and no phase directories created.
|
|
1171
|
+
for (const description of descriptions) {
|
|
1172
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase add-batch');
|
|
1173
|
+
}
|
|
823
1174
|
const config = loadConfig(cwd);
|
|
824
1175
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
825
1176
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
@@ -832,12 +1183,24 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
832
1183
|
const content = extractCurrentMilestone(rawContent, cwd);
|
|
833
1184
|
let maxPhase = 0;
|
|
834
1185
|
if (config.phase_naming !== 'custom') {
|
|
1186
|
+
// Same three cwd-scoped sources as cmdPhaseAdd (#1229): headers, roadmap
|
|
1187
|
+
// bullets, on-disk dirs. The bullet scan was missing here — a bullet-only
|
|
1188
|
+
// `Phase N` row was invisible to batch allocation (#3849 secondary).
|
|
835
1189
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
836
1190
|
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
|
1191
|
+
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
|
837
1192
|
let m;
|
|
838
1193
|
while ((m = phasePattern.exec(content)) !== null) {
|
|
839
1194
|
const num = parseInt(m[1], 10);
|
|
840
|
-
|
|
1195
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1196
|
+
if (isSentinelPhaseId(num))
|
|
1197
|
+
continue;
|
|
1198
|
+
if (num > maxPhase)
|
|
1199
|
+
maxPhase = num;
|
|
1200
|
+
}
|
|
1201
|
+
while ((m = bulletPattern.exec(content)) !== null) {
|
|
1202
|
+
const num = parseInt(m[1], 10);
|
|
1203
|
+
if (isSentinelPhaseId(num))
|
|
841
1204
|
continue;
|
|
842
1205
|
if (num > maxPhase)
|
|
843
1206
|
maxPhase = num;
|
|
@@ -850,12 +1213,20 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
850
1213
|
if (!match)
|
|
851
1214
|
continue;
|
|
852
1215
|
const num = parseInt(match[1], 10);
|
|
853
|
-
|
|
1216
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1217
|
+
if (isSentinelPhaseId(num))
|
|
854
1218
|
continue;
|
|
855
1219
|
if (num > maxPhase)
|
|
856
1220
|
maxPhase = num;
|
|
857
1221
|
}
|
|
858
1222
|
}
|
|
1223
|
+
// 4) Sibling git worktrees (#3849) — same max+1, wider horizon.
|
|
1224
|
+
const siblingNums = new Set();
|
|
1225
|
+
collectSiblingWorktreePhaseNums(cwd, siblingNums);
|
|
1226
|
+
for (const num of siblingNums) {
|
|
1227
|
+
if (num > maxPhase)
|
|
1228
|
+
maxPhase = num;
|
|
1229
|
+
}
|
|
859
1230
|
}
|
|
860
1231
|
const added = [];
|
|
861
1232
|
for (const description of descriptions) {
|
|
@@ -878,11 +1249,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
878
1249
|
? ''
|
|
879
1250
|
: `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
|
880
1251
|
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`;
|
|
881
|
-
const
|
|
882
|
-
rawContent =
|
|
883
|
-
lastSeparator > 0
|
|
884
|
-
? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
|
|
885
|
-
: rawContent + phaseEntry;
|
|
1252
|
+
const insertAt = phaseEntryInsertOffset(rawContent, cwd);
|
|
1253
|
+
rawContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
|
|
886
1254
|
added.push({
|
|
887
1255
|
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
|
888
1256
|
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
|
@@ -896,11 +1264,17 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
896
1264
|
return added;
|
|
897
1265
|
});
|
|
898
1266
|
output({ phases: results, count: results.length }, raw);
|
|
1267
|
+
// #3227: unconditional here because `platformWriteSync(roadmapPath, rawContent)`
|
|
1268
|
+
// above always rewrites ROADMAP.md for every description in the batch before
|
|
1269
|
+
// this line is reached; the only refusal path is the `error('ROADMAP.md not
|
|
1270
|
+
// found')` above, which terminates the process and never reaches here.
|
|
1271
|
+
publishStateContract(cwd);
|
|
899
1272
|
}
|
|
900
1273
|
function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
901
1274
|
if (!afterPhase || !description) {
|
|
902
1275
|
error('after-phase and description required for phase insert');
|
|
903
1276
|
}
|
|
1277
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase insert');
|
|
904
1278
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
905
1279
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
906
1280
|
error('ROADMAP.md not found');
|
|
@@ -949,7 +1323,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
949
1323
|
error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
|
|
950
1324
|
}
|
|
951
1325
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
952
|
-
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
|
|
1326
|
+
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalizedBase)}\\.(\\d+)`);
|
|
953
1327
|
for (const dir of dirs) {
|
|
954
1328
|
const dm = dir.match(decimalPattern);
|
|
955
1329
|
if (dm)
|
|
@@ -977,15 +1351,30 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
977
1351
|
const phaseLabel = useBold
|
|
978
1352
|
? `**Phase ${_decimalPhase}: ${description}**`
|
|
979
1353
|
: `Phase ${_decimalPhase}: ${description}`;
|
|
1354
|
+
// #3413 review fix: bulletEntry stays hardcoded '\n'. The on-disk EOL
|
|
1355
|
+
// is decided at write time by platformWriteSync's normalizeContent /
|
|
1356
|
+
// _normalizeMd (shell-command-projection.cts), which unconditionally
|
|
1357
|
+
// converts \r\n -> \n for any .md target — so whatever terminator is
|
|
1358
|
+
// used here in memory is erased before the file is ever written, and
|
|
1359
|
+
// templating it via detectEol(rawContent) was inert dead code. '\n'
|
|
1360
|
+
// matches what platformWriteSync enforces anyway.
|
|
980
1361
|
const bulletEntry = `\n- [ ] ${phaseLabel}`;
|
|
981
|
-
|
|
1362
|
+
// #3413: was `[^\n]*`, which on CRLF content swallows the line's
|
|
1363
|
+
// trailing \r into the match, shifting bulletLineEnd to land BETWEEN
|
|
1364
|
+
// the \r and \n of the original CRLF pair — a pure splice-POSITION
|
|
1365
|
+
// bug on the not-yet-write-normalized CRLF read (independent of the
|
|
1366
|
+
// final on-disk EOL, which platformWriteSync always forces to LF for
|
|
1367
|
+
// .md targets regardless). Widening to [^\r\n]* stops the match at the
|
|
1368
|
+
// true line-content boundary so bulletLineEnd lands cleanly before the
|
|
1369
|
+
// terminator.
|
|
1370
|
+
const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*)`, 'i');
|
|
982
1371
|
const bulletMatchResult = rawContent.match(targetBulletPattern);
|
|
983
1372
|
if (!bulletMatchResult) {
|
|
984
1373
|
error(`Could not find Phase ${afterPhase} bullet line`);
|
|
985
1374
|
}
|
|
986
1375
|
const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
|
|
987
1376
|
const afterBullet = rawContent.slice(bulletLineEnd);
|
|
988
|
-
const nextBulletMatch = afterBullet.match(/\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
|
|
1377
|
+
const nextBulletMatch = afterBullet.match(/\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
|
|
989
1378
|
let insertIdx;
|
|
990
1379
|
if (nextBulletMatch) {
|
|
991
1380
|
insertIdx = bulletLineEnd + nextBulletMatch.index;
|
|
@@ -1005,7 +1394,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
1005
1394
|
}
|
|
1006
1395
|
const headerIdx = rawContent.indexOf(headerMatch[0]);
|
|
1007
1396
|
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
|
|
1008
|
-
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
|
|
1397
|
+
const nextPhaseMatch = afterHeader.match(/\r?\n#{2,4}\s+Phase\s+\d[\d.]*/i);
|
|
1009
1398
|
let insertIdx;
|
|
1010
1399
|
if (nextPhaseMatch) {
|
|
1011
1400
|
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
|
|
@@ -1027,6 +1416,11 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
1027
1416
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
|
|
1028
1417
|
};
|
|
1029
1418
|
output(result, raw, decimalPhase);
|
|
1419
|
+
// #3227: unconditional here because `platformWriteSync(roadmapPath, updatedContent)`
|
|
1420
|
+
// above always rewrites ROADMAP.md with the inserted phase before this line is
|
|
1421
|
+
// reached; every refusal along the way (bad args, missing ROADMAP.md, unresolved
|
|
1422
|
+
// target bullet/header) exits via `error()`, which terminates the process.
|
|
1423
|
+
publishStateContract(cwd);
|
|
1030
1424
|
}
|
|
1031
1425
|
function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
|
|
1032
1426
|
const renamedDirs = [];
|
|
@@ -1059,9 +1453,33 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
|
|
|
1059
1453
|
}
|
|
1060
1454
|
return { renamedDirs, renamedFiles };
|
|
1061
1455
|
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Find a free name to move an occupying file aside to, on collision, so the
|
|
1458
|
+
* intended rename can proceed without destroying either file. Appends the
|
|
1459
|
+
* literal `.orphaned` suffix to the whole existing filename (never `.md`,
|
|
1460
|
+
* so no phase-directory scan predicate — all of which filter on
|
|
1461
|
+
* `.endsWith('.md')` / `.endsWith('-VERIFICATION.md')` etc — can ever pick
|
|
1462
|
+
* the displaced file back up as any phase's artifact). Falls back to a
|
|
1463
|
+
* numeric discriminator (`.orphaned.2`, `.orphaned.3`, ...) if `.orphaned`
|
|
1464
|
+
* itself is taken, bounded at 100 attempts so a pathological directory
|
|
1465
|
+
* cannot loop forever; returns null if no free name is found within that
|
|
1466
|
+
* bound, letting the caller fall back to skip-and-report.
|
|
1467
|
+
*/
|
|
1468
|
+
function findOrphanedDisplacementName(dir, fileName) {
|
|
1469
|
+
const base = `${fileName}.orphaned`;
|
|
1470
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, base)))
|
|
1471
|
+
return base;
|
|
1472
|
+
for (let n = 2; n <= 100; n++) {
|
|
1473
|
+
const candidate = `${base}.${n}`;
|
|
1474
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, candidate)))
|
|
1475
|
+
return candidate;
|
|
1476
|
+
}
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1062
1479
|
function renameIntegerPhases(phasesDir, removedInt) {
|
|
1063
1480
|
const renamedDirs = [];
|
|
1064
1481
|
const renamedFiles = [];
|
|
1482
|
+
const renamedFileCollisions = [];
|
|
1065
1483
|
const dirs = readSubdirectories(phasesDir, true);
|
|
1066
1484
|
const toRename = dirs
|
|
1067
1485
|
.map((dir) => {
|
|
@@ -1069,7 +1487,8 @@ function renameIntegerPhases(phasesDir, removedInt) {
|
|
|
1069
1487
|
if (!m)
|
|
1070
1488
|
return null;
|
|
1071
1489
|
const dirInt = parseInt(m[1], 10);
|
|
1072
|
-
|
|
1490
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1491
|
+
return dirInt > removedInt && !isSentinelPhaseId(dirInt)
|
|
1073
1492
|
? {
|
|
1074
1493
|
dir,
|
|
1075
1494
|
oldInt: dirInt,
|
|
@@ -1090,21 +1509,77 @@ function renameIntegerPhases(phasesDir, removedInt) {
|
|
|
1090
1509
|
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
|
|
1091
1510
|
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
|
|
1092
1511
|
const newDirName = `${newPrefix}-${item.slug}`;
|
|
1512
|
+
// WARNING-3 (#3511 review): the directory match above accepts an
|
|
1513
|
+
// UNPADDED leading number (`\d+`), so a supported rename can pair a
|
|
1514
|
+
// 2-padded dir with an unpadded-numbered artifact — dir `9-slug` holding
|
|
1515
|
+
// `9-VERIFICATION.md`. Renaming files by `f.startsWith(oldPrefix)` alone
|
|
1516
|
+
// (oldPrefix always 2-padded) misses that file: it becomes desynced from
|
|
1517
|
+
// its now-renamed directory and the phase reads `missing`. Try the
|
|
1518
|
+
// UNPADDED old-prefix form as a fallback so such an artifact renames
|
|
1519
|
+
// alongside its directory. A trailing-digit boundary check keeps the
|
|
1520
|
+
// unpadded form from over-matching a DIFFERENT phase's file (unpadded
|
|
1521
|
+
// prefix "1" must not match "10-…").
|
|
1522
|
+
const oldPrefixUnpadded = `${item.oldInt}${letterSuffix}${decimalSuffix}`;
|
|
1093
1523
|
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
|
|
1094
1524
|
renamedDirs.push({ from: item.dir, to: newDirName });
|
|
1095
1525
|
for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
|
|
1526
|
+
let matchedPrefix = null;
|
|
1096
1527
|
if (f.startsWith(oldPrefix)) {
|
|
1097
|
-
|
|
1098
|
-
|
|
1528
|
+
matchedPrefix = oldPrefix;
|
|
1529
|
+
}
|
|
1530
|
+
else if (oldPrefixUnpadded !== oldPrefix &&
|
|
1531
|
+
f.startsWith(oldPrefixUnpadded) &&
|
|
1532
|
+
// Token-boundary check: the character immediately after the unpadded
|
|
1533
|
+
// prefix must be a separator (`-`, `.`) or end-of-name, not any
|
|
1534
|
+
// non-digit. A bare `!/^\d/` test (prior form) let a LETTER through
|
|
1535
|
+
// too, so unpadded prefix "2" wrongly matched "2FA-notes.md" (a
|
|
1536
|
+
// wholly unrelated file whose name merely starts with the digit).
|
|
1537
|
+
(f.length === oldPrefixUnpadded.length || /^[-.]/.test(f.slice(oldPrefixUnpadded.length)))) {
|
|
1538
|
+
matchedPrefix = oldPrefixUnpadded;
|
|
1539
|
+
}
|
|
1540
|
+
if (matchedPrefix) {
|
|
1541
|
+
const newFileName = newPrefix + f.slice(matchedPrefix.length);
|
|
1542
|
+
const destPath = node_path_1.default.join(phasesDir, newDirName, newFileName);
|
|
1543
|
+
// Collision guard: the padded and unpadded prefix forms can both
|
|
1544
|
+
// resolve to the SAME destination (e.g. `09-VERIFICATION.md` and
|
|
1545
|
+
// `9-VERIFICATION.md` in one directory both target
|
|
1546
|
+
// `08-VERIFICATION.md`), and a stray cross-phase file can already sit
|
|
1547
|
+
// at the destination name (e.g. a leftover `08-VERIFICATION.md`
|
|
1548
|
+
// belonging to a DIFFERENT phase, inside phase 9's directory).
|
|
1549
|
+
// Renaming blindly over an existing target silently destroys
|
|
1550
|
+
// whichever file loses; skipping the rename instead lets the stray
|
|
1551
|
+
// outrank the phase's own renamed artifact once it lands at the
|
|
1552
|
+
// canonical name. Neither is acceptable: move the OCCUPYING file
|
|
1553
|
+
// aside first (never overwrite, never skip the real rename), then
|
|
1554
|
+
// complete the intended rename so the phase's own artifact takes the
|
|
1555
|
+
// canonical name. This also handles a target that was already
|
|
1556
|
+
// claimed by an EARLIER file in this same pass, since that earlier
|
|
1557
|
+
// rename already created it on disk.
|
|
1558
|
+
if (node_fs_1.default.existsSync(destPath)) {
|
|
1559
|
+
const displacedName = findOrphanedDisplacementName(node_path_1.default.join(phasesDir, newDirName), newFileName);
|
|
1560
|
+
if (displacedName === null) {
|
|
1561
|
+
// No free displacement name within the bounded search — fall
|
|
1562
|
+
// back to skip-and-report rather than looping or overwriting.
|
|
1563
|
+
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: null });
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(destPath, node_path_1.default.join(phasesDir, newDirName, displacedName));
|
|
1567
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
|
|
1568
|
+
renamedFiles.push({ from: f, to: newFileName });
|
|
1569
|
+
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: displacedName });
|
|
1570
|
+
continue;
|
|
1571
|
+
}
|
|
1572
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
|
|
1099
1573
|
renamedFiles.push({ from: f, to: newFileName });
|
|
1100
1574
|
}
|
|
1101
1575
|
}
|
|
1102
1576
|
}
|
|
1103
|
-
return { renamedDirs, renamedFiles };
|
|
1577
|
+
return { renamedDirs, renamedFiles, renamedFileCollisions };
|
|
1104
1578
|
}
|
|
1105
1579
|
function decrementRoadmapPhaseNumber(raw, removedInt) {
|
|
1106
1580
|
const num = parseInt(raw, 10);
|
|
1107
|
-
|
|
1581
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1582
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1108
1583
|
return raw;
|
|
1109
1584
|
return String(num - 1);
|
|
1110
1585
|
}
|
|
@@ -1113,13 +1588,15 @@ function decrementRoadmapPhaseToken(raw, removedInt) {
|
|
|
1113
1588
|
if (!match)
|
|
1114
1589
|
return raw;
|
|
1115
1590
|
const num = parseInt(match[1], 10);
|
|
1116
|
-
|
|
1591
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1592
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1117
1593
|
return raw;
|
|
1118
1594
|
return `${num - 1}${match[2] || ''}`;
|
|
1119
1595
|
}
|
|
1120
1596
|
function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
|
|
1121
1597
|
const num = parseInt(raw, 10);
|
|
1122
|
-
|
|
1598
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1599
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1123
1600
|
return raw;
|
|
1124
1601
|
return String(num - 1).padStart(raw.length, '0');
|
|
1125
1602
|
}
|
|
@@ -1157,10 +1634,26 @@ function findDataRowLine(sectionText, dataRowIndex) {
|
|
|
1157
1634
|
}
|
|
1158
1635
|
return null;
|
|
1159
1636
|
}
|
|
1637
|
+
// #3685: mirror requirementsUpdated's diff-tracking contract — the caller
|
|
1638
|
+
// (cmdPhaseRemove) used to report `roadmap_updated: true` unconditionally,
|
|
1639
|
+
// hardcoded regardless of whether this transform actually changed
|
|
1640
|
+
// ROADMAP.md's content. Returning a real before/after comparison here lets
|
|
1641
|
+
// the caller report accurately, the same fix #3685 applied to
|
|
1642
|
+
// `cmdPhaseComplete` and #2640/#2974 already applied to this same function's
|
|
1643
|
+
// sibling `stateUpdated` flag a few lines below in `cmdPhaseRemove`.
|
|
1160
1644
|
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
|
|
1161
|
-
withPlanningLock(cwd, () => {
|
|
1162
|
-
|
|
1163
|
-
|
|
1645
|
+
return withPlanningLock(cwd, () => {
|
|
1646
|
+
const originalContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
1647
|
+
let content = originalContent;
|
|
1648
|
+
const escaped = (0, pattern_cjs_1.escapeRegex)(targetPhase);
|
|
1649
|
+
// #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
|
|
1650
|
+
// of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
|
|
1651
|
+
// user's remove query is usually unpadded (`1.1`) — and integer headings
|
|
1652
|
+
// legitimately appear both padded (`02`) and unpadded (`2`). A `0*` prefix
|
|
1653
|
+
// makes the token padding-insensitive in both directions without widening
|
|
1654
|
+
// to other ids: the token stays anchored between `Phase\s+`/line-start and
|
|
1655
|
+
// `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
|
|
1656
|
+
const padTolerant = `0*${escaped}`;
|
|
1164
1657
|
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
|
1165
1658
|
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
|
1166
1659
|
// (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
|
|
@@ -1172,9 +1665,9 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1172
1665
|
// such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
|
|
1173
1666
|
// away everything after it — including a trailing `## Progress` heading and
|
|
1174
1667
|
// its tracking table.
|
|
1175
|
-
const phaseHeadingRe = new RegExp(`^Phase\\s+${
|
|
1668
|
+
const phaseHeadingRe = new RegExp(`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
|
|
1176
1669
|
content = (0, markdown_sectionizer_cjs_1.deleteSection)(content, (h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text));
|
|
1177
|
-
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${
|
|
1670
|
+
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
|
|
1178
1671
|
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
|
1179
1672
|
// for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
|
|
1180
1673
|
// of updateTableCell). Scoped to the `## Progress` section — mirroring
|
|
@@ -1201,7 +1694,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1201
1694
|
const matchRemovedProgressRow = (row) => {
|
|
1202
1695
|
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
|
1203
1696
|
if (isDecimal) {
|
|
1204
|
-
return new RegExp(`^${
|
|
1697
|
+
return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
|
1205
1698
|
}
|
|
1206
1699
|
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
|
1207
1700
|
if (!leadingMatch || leadingMatch[2])
|
|
@@ -1216,7 +1709,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1216
1709
|
if (!isDecimal) {
|
|
1217
1710
|
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
|
1218
1711
|
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
|
1219
|
-
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}`);
|
|
1712
|
+
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}`);
|
|
1220
1713
|
content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
|
|
1221
1714
|
// ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
|
|
1222
1715
|
// updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
|
|
@@ -1278,7 +1771,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1278
1771
|
if (!m)
|
|
1279
1772
|
return false;
|
|
1280
1773
|
const num = parseInt(m[1], 10);
|
|
1281
|
-
|
|
1774
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1775
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1282
1776
|
return false;
|
|
1283
1777
|
processedOrdinalRows.add(index);
|
|
1284
1778
|
matchedRowIndex = index;
|
|
@@ -1291,7 +1785,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1291
1785
|
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
|
1292
1786
|
const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
|
1293
1787
|
const padMatch = targetLine
|
|
1294
|
-
? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
1788
|
+
? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
1295
1789
|
: null;
|
|
1296
1790
|
const leadPad = padMatch ? padMatch[1] : ' ';
|
|
1297
1791
|
const trailPad = padMatch ? padMatch[2] : ' ';
|
|
@@ -1308,8 +1802,43 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1308
1802
|
content = content.replace(/(Depends on:\*\*\s*Phase\s+)(\d+(?:\.\d+)?)\b/gi, (_match, prefix, num) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}`);
|
|
1309
1803
|
}
|
|
1310
1804
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
|
|
1805
|
+
// #3685 / #3691: compare NORMALIZED bytes (what platformWriteSync actually
|
|
1806
|
+
// persists), not the raw pre-normalize `content` string, against the raw
|
|
1807
|
+
// pre-mutation `originalContent` read above — a raw `!==` here reports a
|
|
1808
|
+
// false `true` whenever this transform's regenerated output takes a
|
|
1809
|
+
// different-but-equivalent shape than the already-normalized on-disk
|
|
1810
|
+
// original (same normalization-order artifact #3685 fixed at
|
|
1811
|
+
// cmdMilestoneComplete; see contentChangedAfterNormalize's own doc).
|
|
1812
|
+
return (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalContent, content);
|
|
1311
1813
|
});
|
|
1312
1814
|
}
|
|
1815
|
+
/**
|
|
1816
|
+
* #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
|
|
1817
|
+
* the leading frontmatter block's closing `---` fence — so a body field never
|
|
1818
|
+
* lands before the opening fence. The former whole-content prepend
|
|
1819
|
+
* (`field + content`) put the line ABOVE the opening `---`, and
|
|
1820
|
+
* syncStateFrontmatter then treated the scrambled fence structure as TWO
|
|
1821
|
+
* frontmatter blocks, rebuilding a derived one on top of the original
|
|
1822
|
+
* (milestone_name from a ROADMAP heading, total_phases counting the removed
|
|
1823
|
+
* phase, a stray 'Total Phases: 0' between fences). A file with no leading
|
|
1824
|
+
* frontmatter is all body: the field goes to content start, preserving the
|
|
1825
|
+
* former behavior for that shape.
|
|
1826
|
+
*/
|
|
1827
|
+
function insertStateBodyFieldAtTop(content, fieldLine) {
|
|
1828
|
+
// Split AND join on bare '\n' so CRLF line endings stay attached to their
|
|
1829
|
+
// own lines — each '\r' remains the tail of the line it terminated, where
|
|
1830
|
+
// the trimmed fence compare still matches it. (#3572 review: splitting on
|
|
1831
|
+
// '\n' but re-joining on a detected '\r\n' doubled every carriage return.)
|
|
1832
|
+
const lines = content.split('\n');
|
|
1833
|
+
if ((lines[0] ?? '').trim() === '---') {
|
|
1834
|
+
const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
1835
|
+
if (closeIdx !== -1) {
|
|
1836
|
+
lines.splice(closeIdx + 1, 0, '', fieldLine);
|
|
1837
|
+
return lines.join('\n');
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
return fieldLine + '\n' + content;
|
|
1841
|
+
}
|
|
1313
1842
|
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
1314
1843
|
if (!targetPhase)
|
|
1315
1844
|
error('phase number required for phase remove');
|
|
@@ -1321,24 +1850,55 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1321
1850
|
const isDecimal = targetPhase.includes('.');
|
|
1322
1851
|
const force = options.force || false;
|
|
1323
1852
|
const subdirs = readSubdirectories(phasesDir, true);
|
|
1324
|
-
|
|
1853
|
+
// #2237/#2528: every other resolution path refuses to choose between multiple
|
|
1854
|
+
// directories claiming one phase number. This one is the DESTRUCTIVE path, so
|
|
1855
|
+
// taking `matches[0]` silently is strictly worse than anywhere else: it turns
|
|
1856
|
+
// "resolve nothing" into "delete one of two candidates, unrecoverably, and
|
|
1857
|
+
// renumber every phase after it". Refuse before any file is touched.
|
|
1858
|
+
const { matches: phaseDirMatches } = matchPhaseDirs(subdirs, normalized);
|
|
1859
|
+
if (phaseDirMatches.length > 1) {
|
|
1860
|
+
output({
|
|
1861
|
+
removed: null,
|
|
1862
|
+
error: `Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
|
|
1863
|
+
+ `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
|
|
1864
|
+
+ 'Set a distinct project_code in .planning/config.json, or pass the full directory name.',
|
|
1865
|
+
ambiguous_matches: phaseDirMatches,
|
|
1866
|
+
directory_deleted: null,
|
|
1867
|
+
renamed_directories: [],
|
|
1868
|
+
renamed_files: [],
|
|
1869
|
+
roadmap_updated: false,
|
|
1870
|
+
state_updated: false,
|
|
1871
|
+
}, raw);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
const targetDir = phaseDirMatches[0] || null;
|
|
1325
1875
|
if (targetDir && !force) {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1876
|
+
// #3183: canonical summary set (root+nested) from the single owner —
|
|
1877
|
+
// a root-only readdirSync filter left nested (#3139 layout) summaries
|
|
1878
|
+
// invisible, letting a phase with completed nested work be deleted
|
|
1879
|
+
// without --force.
|
|
1880
|
+
const summaryCount = scanPhasePlans(node_path_1.default.join(phasesDir, targetDir)).summaryFiles.length;
|
|
1881
|
+
if (summaryCount > 0) {
|
|
1882
|
+
error(`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`);
|
|
1330
1883
|
}
|
|
1331
1884
|
}
|
|
1332
1885
|
if (targetDir)
|
|
1333
1886
|
node_fs_1.default.rmSync(node_path_1.default.join(phasesDir, targetDir), { recursive: true, force: true });
|
|
1334
1887
|
let renamedDirs = [];
|
|
1335
1888
|
let renamedFiles = [];
|
|
1889
|
+
let renamedFileCollisions = [];
|
|
1336
1890
|
try {
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1891
|
+
if (isDecimal) {
|
|
1892
|
+
const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10));
|
|
1893
|
+
renamedDirs = renamed.renamedDirs;
|
|
1894
|
+
renamedFiles = renamed.renamedFiles;
|
|
1895
|
+
}
|
|
1896
|
+
else {
|
|
1897
|
+
const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
|
|
1898
|
+
renamedDirs = renamed.renamedDirs;
|
|
1899
|
+
renamedFiles = renamed.renamedFiles;
|
|
1900
|
+
renamedFileCollisions = renamed.renamedFileCollisions;
|
|
1901
|
+
}
|
|
1342
1902
|
}
|
|
1343
1903
|
catch (e) {
|
|
1344
1904
|
// #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
|
|
@@ -1353,7 +1913,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1353
1913
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1354
1914
|
error(`Failed to renumber phase directories after removing phase ${targetPhase}: ${msg}`);
|
|
1355
1915
|
}
|
|
1356
|
-
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
|
|
1916
|
+
const roadmapUpdated = updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
|
|
1357
1917
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
1358
1918
|
let stateUpdated = false;
|
|
1359
1919
|
if (node_fs_1.default.existsSync(statePath)) {
|
|
@@ -1367,13 +1927,15 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1367
1927
|
let modified = stateContent;
|
|
1368
1928
|
const totalRaw = stateExtractField(modified, 'Total Phases');
|
|
1369
1929
|
if (totalRaw) {
|
|
1930
|
+
// #3572 review: clamp at 0 — a stale 'Total Phases: 0' (e.g. written by
|
|
1931
|
+
// an earlier remove whose dir-count was 0) must not decrement to -1 on
|
|
1932
|
+
// the next removal.
|
|
1370
1933
|
modified =
|
|
1371
|
-
stateReplaceField(modified, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
|
|
1372
|
-
modified;
|
|
1934
|
+
stateReplaceField(modified, 'Total Phases', String(Math.max(0, parseInt(totalRaw, 10) - 1))) || modified;
|
|
1373
1935
|
}
|
|
1374
1936
|
const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
|
|
1375
1937
|
if (ofMatch) {
|
|
1376
|
-
modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
|
|
1938
|
+
modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${Math.max(0, parseInt(ofMatch[2], 10) - 1)}$3`);
|
|
1377
1939
|
}
|
|
1378
1940
|
// #2640: if neither body field was found, the transform is a no-op.
|
|
1379
1941
|
// readModifyWriteStateMd's no-op guard (#948) would then skip the
|
|
@@ -1386,16 +1948,33 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1386
1948
|
if (targetDir && modified === stateContent) {
|
|
1387
1949
|
// subdirs was read before the deletion; excluding the removed target
|
|
1388
1950
|
// gives the remaining count. Renumbering changes names but not count.
|
|
1389
|
-
|
|
1951
|
+
//
|
|
1952
|
+
// #2528: exclude the directory that was ACTUALLY deleted, by identity,
|
|
1953
|
+
// rather than re-deriving "which dir was the target" from the query.
|
|
1954
|
+
// The two are not the same predicate here: `targetDir` comes from
|
|
1955
|
+
// `matchPhaseDirs`, whose bare-integer fallback resolves digit-leading
|
|
1956
|
+
// dirs (`05-80-20-cleanup` for query `5`) that `phaseTokenMatches`
|
|
1957
|
+
// reports as non-matching — so a token re-derivation would count the
|
|
1958
|
+
// just-deleted directory as still present and write a `Total Phases`
|
|
1959
|
+
// one too high. Identity is also what the comment above already
|
|
1960
|
+
// claims this filter does, and the block is gated on targetDir.
|
|
1961
|
+
// (#3572 note: this body field counts DIRECTORIES on disk; the
|
|
1962
|
+
// frontmatter progress.* block is rebuilt by syncStateFrontmatter
|
|
1963
|
+
// from the post-removal ROADMAP — the two counts legitimately differ
|
|
1964
|
+
// when phases exist in ROADMAP without directories.)
|
|
1965
|
+
const remainingPhases = Math.max(0, subdirs.filter((d) => d !== targetDir).length);
|
|
1390
1966
|
if (totalRaw) {
|
|
1391
1967
|
modified =
|
|
1392
1968
|
stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
|
|
1393
1969
|
}
|
|
1394
1970
|
else {
|
|
1395
|
-
// No 'Total Phases:' field in the body —
|
|
1396
|
-
// guard sees a diff.
|
|
1397
|
-
//
|
|
1398
|
-
|
|
1971
|
+
// No 'Total Phases:' field in the body — insert one at the start of
|
|
1972
|
+
// the BODY so the no-op guard sees a diff. #3572: the former
|
|
1973
|
+
// whole-content prepend landed the line BEFORE the opening '---'
|
|
1974
|
+
// fence and corrupted STATE.md into two frontmatter blocks.
|
|
1975
|
+
// syncStateFrontmatter will still rebuild the frontmatter
|
|
1976
|
+
// progress.* block from the real disk/ROADMAP count.
|
|
1977
|
+
modified = insertStateBodyFieldAtTop(modified, `Total Phases: ${remainingPhases}`);
|
|
1399
1978
|
}
|
|
1400
1979
|
}
|
|
1401
1980
|
return modified;
|
|
@@ -1406,10 +1985,28 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1406
1985
|
directory_deleted: targetDir,
|
|
1407
1986
|
renamed_directories: renamedDirs,
|
|
1408
1987
|
renamed_files: renamedFiles,
|
|
1409
|
-
|
|
1988
|
+
renamed_file_collisions: renamedFileCollisions,
|
|
1989
|
+
// #3685: mirror requirementsUpdated's diff-tracking contract — true only
|
|
1990
|
+
// when updateRoadmapAfterPhaseRemoval's content diff detected a real
|
|
1991
|
+
// change, not hardcoded regardless of whether ROADMAP.md's content
|
|
1992
|
+
// actually changed.
|
|
1993
|
+
roadmap_updated: roadmapUpdated,
|
|
1410
1994
|
state_updated: stateUpdated,
|
|
1411
1995
|
}, raw);
|
|
1996
|
+
// #3227: unconditional here because `updateRoadmapAfterPhaseRemoval` above
|
|
1997
|
+
// always rewrites ROADMAP.md before this line is reached; every refusal path
|
|
1998
|
+
// (bad target, missing ROADMAP.md, --force-required, renumber failure) exits
|
|
1999
|
+
// via `error()`, and the ambiguous-match case exits via an earlier `return`
|
|
2000
|
+
// before any file is touched.
|
|
2001
|
+
publishStateContract(cwd);
|
|
1412
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* #3227: returns the count of writes actually applied (entries whose
|
|
2005
|
+
* `before` differed from `after` and were therefore written to disk) — the
|
|
2006
|
+
* caller (`cmdPhaseComplete`) uses this as its publish-gate signal, since a
|
|
2007
|
+
* re-run against an already-completed phase can produce a `writes[]` array
|
|
2008
|
+
* where every entry is byte-identical to what's already on disk.
|
|
2009
|
+
*/
|
|
1413
2010
|
function writePlanningFileSet(writes) {
|
|
1414
2011
|
const applied = [];
|
|
1415
2012
|
try {
|
|
@@ -1439,6 +2036,7 @@ function writePlanningFileSet(writes) {
|
|
|
1439
2036
|
}
|
|
1440
2037
|
throw err;
|
|
1441
2038
|
}
|
|
2039
|
+
return applied.length;
|
|
1442
2040
|
}
|
|
1443
2041
|
function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
|
|
1444
2042
|
if (!roadmapContent || !phaseNum)
|
|
@@ -1467,11 +2065,27 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1467
2065
|
// init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
|
|
1468
2066
|
// explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
|
|
1469
2067
|
const availableWorkstreams = listAvailableWorkstreams(cwd);
|
|
1470
|
-
|
|
2068
|
+
// #3579 root-cause fix: this is a check, not a consuming read — use the
|
|
2069
|
+
// non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
|
|
2070
|
+
// here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
|
|
2071
|
+
// which would misreport a present-but-bad marker as no marker at all.
|
|
2072
|
+
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
|
|
1471
2073
|
if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
|
|
2074
|
+
// #3579: getActiveWorkstream now inherits a pointer-less session's read
|
|
2075
|
+
// from the shared .planning/active-workstream marker, so reaching this
|
|
2076
|
+
// branch with a marker actually present means the marker EXISTED but
|
|
2077
|
+
// didn't resolve (invalid name, or its workstream dir is gone) — a
|
|
2078
|
+
// materially different situation from "nothing was ever set" and one
|
|
2079
|
+
// that deserves its own diagnostic instead of the generic message below.
|
|
2080
|
+
const diagnosis = diagnoseUnresolvedActiveWorkstream(cwd);
|
|
2081
|
+
if (diagnosis.present) {
|
|
2082
|
+
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. ` +
|
|
2083
|
+
`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. ` +
|
|
2084
|
+
`Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_MARKER_UNRESOLVED, { marker_value: diagnosis.value, marker_reason: diagnosis.reason });
|
|
2085
|
+
}
|
|
1472
2086
|
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. ` +
|
|
1473
2087
|
`Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} first. ` +
|
|
1474
|
-
`Available workstreams: ${availableWorkstreams.join(', ')}
|
|
2088
|
+
`Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_NONE_ACTIVE);
|
|
1475
2089
|
}
|
|
1476
2090
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1477
2091
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
@@ -1489,7 +2103,25 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1489
2103
|
? phaseInfo['summaries'].length
|
|
1490
2104
|
: 0;
|
|
1491
2105
|
let requirementsUpdated = false;
|
|
2106
|
+
// #3685: mirror requirementsUpdated's diff-tracking contract at the
|
|
2107
|
+
// writes.push({filePath, before, after}) sites below, rather than
|
|
2108
|
+
// reporting via fs.existsSync (which is true whenever the file merely
|
|
2109
|
+
// exists, not when the transaction actually wrote a change).
|
|
2110
|
+
let roadmapUpdated = false;
|
|
2111
|
+
let stateUpdated = false;
|
|
1492
2112
|
const warnings = [];
|
|
2113
|
+
// ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
|
|
2114
|
+
// composition's preservation stage restores a curated frontmatter value
|
|
2115
|
+
// over a disagreeing derived one, that divergence is surfaced here rather
|
|
2116
|
+
// than silently absorbed. Structured (field + reason), not prose, so a
|
|
2117
|
+
// caller can assert on the value rather than regex a rendered message.
|
|
2118
|
+
// Named `preservation_warnings`, NOT `warnings`: `warnings` above is
|
|
2119
|
+
// already a prose `string[]` on this exact command — reusing it for a
|
|
2120
|
+
// structured `{field, reason}[]` shape would be the "Generative Fix
|
|
2121
|
+
// Divergence" anti-pattern (two sibling fields, same name, different
|
|
2122
|
+
// element types). Mirrors `cmdMilestoneComplete`'s identical field
|
|
2123
|
+
// (milestone.cts).
|
|
2124
|
+
const preservationWarnings = [];
|
|
1493
2125
|
// #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
|
|
1494
2126
|
// roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
|
|
1495
2127
|
// below (inside withPlanningLock) alongside the warnings[] entry, so a
|
|
@@ -1564,7 +2196,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1564
2196
|
}
|
|
1565
2197
|
try {
|
|
1566
2198
|
const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
|
|
1567
|
-
|
|
2199
|
+
// #3511: scope this advisory pre-scan to THIS phase's own token so a
|
|
2200
|
+
// stray, cross-phase, or ad-hoc file cannot name a warning against a
|
|
2201
|
+
// phase it does not belong to.
|
|
2202
|
+
const phaseFullDirBaseName = node_path_1.default.basename(phaseFullDir);
|
|
2203
|
+
for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseFullDirBaseName)) {
|
|
1568
2204
|
const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
|
|
1569
2205
|
if (/result: pending/.test(content))
|
|
1570
2206
|
warnings.push(`${file}: has pending tests`);
|
|
@@ -1575,9 +2211,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1575
2211
|
if (/status: diagnosed/.test(content))
|
|
1576
2212
|
warnings.push(`${file}: has diagnosed gaps`);
|
|
1577
2213
|
}
|
|
1578
|
-
for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
|
2214
|
+
for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')), phaseFullDirBaseName)) {
|
|
1579
2215
|
const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
|
|
1580
|
-
|
|
2216
|
+
// #3707-CR follow-up MINOR: normalize line endings at this read boundary
|
|
2217
|
+
// (same fix as src/verification.cts's readVerificationStatus) so a
|
|
2218
|
+
// lone-CR VERIFICATION.md's `---\r...\r---` frontmatter fence still
|
|
2219
|
+
// matches extractFrontmatter's byte-0 check instead of silently
|
|
2220
|
+
// dropping the human_needed/gaps_found advisory warning below.
|
|
2221
|
+
const content = normalizeLineEndings(node_fs_1.default.readFileSync(verificationFilePath, 'utf-8'));
|
|
1581
2222
|
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
|
|
1582
2223
|
// from historical metadata in the file body (e.g. `previous_status: gaps_found`).
|
|
1583
2224
|
// A full-text regex like /status: gaps_found/ matches the substring inside
|
|
@@ -1639,7 +2280,33 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1639
2280
|
let nextPhaseNum = null;
|
|
1640
2281
|
let nextPhaseName = null;
|
|
1641
2282
|
let isLastPhase = true;
|
|
2283
|
+
// #3311: typed conflict descriptor surfaced on the result JSON alongside the
|
|
2284
|
+
// warnings[] entry below (same parity pattern as
|
|
2285
|
+
// verification_stale_check_indeterminate).
|
|
2286
|
+
let milestoneConflict = null;
|
|
2287
|
+
// #3227: set inside `runPhaseCompleteTransaction` below from
|
|
2288
|
+
// `writePlanningFileSet`'s applied-count return — the transaction always
|
|
2289
|
+
// RUNS (verification passed, the lock was taken, `writes[]` was built),
|
|
2290
|
+
// but a re-run against a phase whose ROADMAP/STATE bytes already reflect
|
|
2291
|
+
// completion produces a `writes[]` where every entry is byte-identical to
|
|
2292
|
+
// disk, so `writePlanningFileSet` applies none of them. That must not
|
|
2293
|
+
// still refresh state.json's `updated_at` (design doc §40 row 26).
|
|
2294
|
+
let anyPlanningWrite = false;
|
|
1642
2295
|
const verificationBlocked = withPlanningLock(cwd, () => {
|
|
2296
|
+
// #3311: completing a phase while a live milestone claim (phase + session)
|
|
2297
|
+
// holds a DIFFERENT phase means two sessions are working two phases against
|
|
2298
|
+
// the single Current Position slot. Warn via the established warnings[]
|
|
2299
|
+
// channel (rendered by execute-phase.md's "If has_warnings is true" step)
|
|
2300
|
+
// rather than blocking — the claim may simply be stale-but-live.
|
|
2301
|
+
milestoneConflict = milestoneLockMod.checkMilestoneConflictForPhase(cwd, phaseNum);
|
|
2302
|
+
if (milestoneConflict) {
|
|
2303
|
+
const holder = milestoneConflict.locked_session ?? 'an unknown (headless) session';
|
|
2304
|
+
const actor = milestoneConflict.session ?? 'an unknown (headless) session';
|
|
2305
|
+
warnings.push(`milestone lock conflict (#3311): ${holder} holds the milestone claim for phase ` +
|
|
2306
|
+
`${milestoneConflict.locked_phase}, but ${actor} is completing phase ${phaseNum} — ` +
|
|
2307
|
+
`STATE.md's Current Position is a single slot; verify it before trusting it`);
|
|
2308
|
+
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `phase.complete ${phaseNum}`);
|
|
2309
|
+
}
|
|
1643
2310
|
// #2617: pass the project's runtime so the blocked-completion error below
|
|
1644
2311
|
// suggests the command surface this runtime actually installs
|
|
1645
2312
|
// ($gsd-… on Codex) rather than a hard-coded Claude-style string.
|
|
@@ -1786,7 +2453,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1786
2453
|
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
|
1787
2454
|
if (!planId)
|
|
1788
2455
|
continue;
|
|
1789
|
-
const planEscaped = escapeRegex(planId);
|
|
2456
|
+
const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
|
|
1790
2457
|
const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
|
|
1791
2458
|
b = b.replace(planCheckboxPattern, '$1x$2');
|
|
1792
2459
|
}
|
|
@@ -1816,6 +2483,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1816
2483
|
before: originalRoadmapContent,
|
|
1817
2484
|
after: roadmapContent,
|
|
1818
2485
|
});
|
|
2486
|
+
// #3685 / #3691: normalize both sides before comparing — see
|
|
2487
|
+
// contentChangedAfterNormalize's doc (shell-command-projection.cts).
|
|
2488
|
+
// A raw `!==` here false-positives whenever this phase-complete
|
|
2489
|
+
// roadmap mutation regenerates a section in a different-but-
|
|
2490
|
+
// equivalent raw shape than the already-normalized on-disk original.
|
|
2491
|
+
roadmapUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(roadmapPath, originalRoadmapContent, roadmapContent);
|
|
1819
2492
|
const reqPath = node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md');
|
|
1820
2493
|
if (node_fs_1.default.existsSync(reqPath)) {
|
|
1821
2494
|
const phaseEsc = phaseMarkdownRegexSource(phaseNum);
|
|
@@ -1862,7 +2535,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1862
2535
|
.filter(Boolean)
|
|
1863
2536
|
.filter((r) => REQ_ID_SHAPE_RE.test(r));
|
|
1864
2537
|
for (const reqId of citedReqIds) {
|
|
1865
|
-
const reqEscaped = escapeRegex(reqId);
|
|
2538
|
+
const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
|
|
1866
2539
|
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
|
|
1867
2540
|
// #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
|
|
1868
2541
|
// cmdRequirementsMarkComplete). Capture the pre-flip content; if a
|
|
@@ -2069,7 +2742,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2069
2742
|
// row actually matched is registered — not a ghost — regardless of
|
|
2070
2743
|
// which section (deferred or not) it lives under.
|
|
2071
2744
|
const reqIsRegisteredAnywhere = (id) => {
|
|
2072
|
-
const reqEscaped = escapeRegex(id);
|
|
2745
|
+
const reqEscaped = (0, pattern_cjs_1.escapeRegex)(id);
|
|
2073
2746
|
// Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
|
|
2074
2747
|
// insensitive: existence check, not the write's space-only match.
|
|
2075
2748
|
if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
|
|
@@ -2102,27 +2775,67 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2102
2775
|
// diff-tracking pattern used for the ROADMAP write above. A phase
|
|
2103
2776
|
// whose citations match nothing (ghost REQ-IDs only) must report
|
|
2104
2777
|
// `false`, not a bare "the file was present" `true`.
|
|
2105
|
-
|
|
2778
|
+
// #3685 / #3691: normalize both sides before comparing — same
|
|
2779
|
+
// false-positive shape as the sibling roadmapUpdated/stateUpdated
|
|
2780
|
+
// flags in this same transaction; all three must agree by
|
|
2781
|
+
// construction (see contentChangedAfterNormalize's doc).
|
|
2782
|
+
requirementsUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(reqPath, originalReqContent, reqContent);
|
|
2106
2783
|
}
|
|
2107
2784
|
}
|
|
2785
|
+
// #3701 — the ROADMAP decides WHICH phase is next; the disk decides only HOW it
|
|
2786
|
+
// is spelled. Both scans select the numerically lowest phase above N.
|
|
2787
|
+
//
|
|
2788
|
+
// Both scans below are unchanged in what they match; what changed is that
|
|
2789
|
+
// the roadmap is no longer gated behind "the disk found nothing". It used
|
|
2790
|
+
// to be (`if (isLastPhase && roadmapContent !== null)`), which made a wrong
|
|
2791
|
+
// disk answer uncorrectable: phase directories are created lazily, but
|
|
2792
|
+
// `phase insert` scaffolds an inserted phase's directory immediately, so an
|
|
2793
|
+
// inserted decimal is routinely the ONLY directory above N and outranked
|
|
2794
|
+
// every phase preceding it in the roadmap. Observed: roadmap `1, 2, 02.1,
|
|
2795
|
+
// 3` with directories for 01 and 02.1 only reported `next_phase: "02.1"`
|
|
2796
|
+
// after completing 1 — and PERSISTED it to STATE.md — while
|
|
2797
|
+
// `roadmap.analyze` correctly said `2`.
|
|
2798
|
+
//
|
|
2799
|
+
// #3581 fixed exactly this at `init.progress` and named the rule: "the
|
|
2800
|
+
// frontier is ROADMAP ORDER, not artifact presence". This call site was not
|
|
2801
|
+
// in that change's scope.
|
|
2802
|
+
//
|
|
2803
|
+
// Why the disk scan survives, rather than being replaced:
|
|
2804
|
+
// 1. It is the only resolver when there is no ROADMAP.md, or when its
|
|
2805
|
+
// phase rows do not parse.
|
|
2806
|
+
// 2. When both agree, it carries the SPELLING the output has always used
|
|
2807
|
+
// — the zero-padded directory token and the on-disk slug (`02`/`beta`),
|
|
2808
|
+
// where the roadmap would give `2` and a slugified title. Promoting the
|
|
2809
|
+
// roadmap without this would silently change the reported value on
|
|
2810
|
+
// every aligned project, which is the majority case.
|
|
2811
|
+
let diskNextNum = null;
|
|
2812
|
+
let diskNextName = null;
|
|
2813
|
+
let roadmapNextNum = null;
|
|
2814
|
+
let roadmapNextName = null;
|
|
2108
2815
|
try {
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2816
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories belong to
|
|
2817
|
+
// the CURRENT milestone" — routed through the canonical owner
|
|
2818
|
+
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
2819
|
+
// (which also never excluded sentinels on its own, unlike the
|
|
2820
|
+
// owner; the per-directory isSentinelPhaseId check below stays as a
|
|
2821
|
+
// defensive second check against the REGEX-EXTRACTED token, which
|
|
2822
|
+
// is not necessarily identical to the raw directory name).
|
|
2823
|
+
const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
|
|
2116
2824
|
for (const dir of dirs) {
|
|
2117
2825
|
const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
|
2118
2826
|
if (dm) {
|
|
2119
|
-
|
|
2827
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
2828
|
+
if (isSentinelPhaseId(dm[1]))
|
|
2120
2829
|
continue;
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2830
|
+
// Numeric MINIMUM above N, not "first encountered". `listMilestonePhaseDirs`
|
|
2831
|
+
// does sort by `comparePhaseNum`, so a `break` on the first hit happens to be
|
|
2832
|
+
// correct today — but that makes this scan's correctness depend on an
|
|
2833
|
+
// upstream sort nothing here states. Selecting the minimum explicitly costs
|
|
2834
|
+
// one comparison and removes the hidden coupling.
|
|
2835
|
+
if (comparePhaseNum(dm[1], phaseNum) > 0
|
|
2836
|
+
&& (diskNextNum === null || comparePhaseNum(dm[1], diskNextNum) < 0)) {
|
|
2837
|
+
diskNextNum = dm[1];
|
|
2838
|
+
diskNextName = dm[2] || null;
|
|
2126
2839
|
}
|
|
2127
2840
|
}
|
|
2128
2841
|
}
|
|
@@ -2136,7 +2849,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2136
2849
|
* derives the same information independently from ROADMAP.md content
|
|
2137
2850
|
* — not a silent data-loss path. */
|
|
2138
2851
|
}
|
|
2139
|
-
if (
|
|
2852
|
+
if (roadmapContent !== null) {
|
|
2140
2853
|
try {
|
|
2141
2854
|
const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
|
|
2142
2855
|
// #1591: match BOTH heading-style phases (`### Phase N:`) AND
|
|
@@ -2158,20 +2871,32 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2158
2871
|
let pm;
|
|
2159
2872
|
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
|
2160
2873
|
// #2786: skip sentinel phase ids (999.x backlog, 0.x drafts) — stage 1
|
|
2161
|
-
// already skips
|
|
2162
|
-
// advance into backlog headings.
|
|
2163
|
-
// stage 1 uses at line 2536, but via isSentinelPhaseId for both ranges.
|
|
2874
|
+
// already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
|
|
2875
|
+
// stage 2's heading scan must not advance into backlog headings either.
|
|
2164
2876
|
if (isSentinelPhaseId(pm[1]))
|
|
2165
2877
|
continue;
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2878
|
+
// #3701 review: the numeric MINIMUM above N, not the first row above N in
|
|
2879
|
+
// DOCUMENT order. This scan walks raw roadmap text, and one global regex
|
|
2880
|
+
// sweeps both the `## Phases` checklist and the `## Phase Details`
|
|
2881
|
+
// headings, so "first match" is a statement about where a line sits in the
|
|
2882
|
+
// file — not about which phase comes next.
|
|
2883
|
+
//
|
|
2884
|
+
// It mattered only once this scan started deciding the answer. Before, it
|
|
2885
|
+
// ran solely when the disk scan found nothing; now it outranks the disk, so
|
|
2886
|
+
// a roadmap listing rows out of numeric sequence (`1, 3, 2`) reported
|
|
2887
|
+
// `next_phase: 3` and PERSISTED it, skipping Phase 2 — on an input the
|
|
2888
|
+
// pre-#3701 code got right, because the disk scan is numerically sorted.
|
|
2889
|
+
// Phase NUMBERS define sequence here, exactly as `comparePhaseNum` does for
|
|
2890
|
+
// the disk scan and for #2028's lowest-outstanding override; the roadmap
|
|
2891
|
+
// defines which phases EXIST and which milestone they belong to.
|
|
2892
|
+
if (comparePhaseNum(pm[1], phaseNum) > 0
|
|
2893
|
+
&& (roadmapNextNum === null || comparePhaseNum(pm[1], roadmapNextNum) < 0)) {
|
|
2894
|
+
roadmapNextNum = pm[1];
|
|
2895
|
+
roadmapNextName = pm[2]
|
|
2169
2896
|
.replace(/\(INSERTED\)/i, '')
|
|
2170
2897
|
.trim()
|
|
2171
2898
|
.toLowerCase()
|
|
2172
2899
|
.replace(/\s+/g, '-');
|
|
2173
|
-
isLastPhase = false;
|
|
2174
|
-
break;
|
|
2175
2900
|
}
|
|
2176
2901
|
}
|
|
2177
2902
|
}
|
|
@@ -2182,6 +2907,25 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2182
2907
|
* regardless and provides a further, independent override. */
|
|
2183
2908
|
}
|
|
2184
2909
|
}
|
|
2910
|
+
// Resolve. The roadmap wins on identity; the disk wins on spelling when it
|
|
2911
|
+
// is talking about the same phase.
|
|
2912
|
+
if (roadmapNextNum !== null) {
|
|
2913
|
+
// Same comparator both scans already use to order phases, so "the disk
|
|
2914
|
+
// and the roadmap mean the same phase" cannot drift from "N is above the
|
|
2915
|
+
// one just completed". `02` and `2` compare equal, which is the whole
|
|
2916
|
+
// point — they are the same phase spelled two ways.
|
|
2917
|
+
const diskAgrees = diskNextNum !== null && comparePhaseNum(diskNextNum, roadmapNextNum) === 0;
|
|
2918
|
+
nextPhaseNum = diskAgrees ? diskNextNum : roadmapNextNum;
|
|
2919
|
+
nextPhaseName = diskAgrees ? diskNextName : roadmapNextName;
|
|
2920
|
+
isLastPhase = false;
|
|
2921
|
+
}
|
|
2922
|
+
else if (diskNextNum !== null) {
|
|
2923
|
+
// No usable roadmap (absent, unreadable, or no parseable phase rows) —
|
|
2924
|
+
// the disk is all there is. Unchanged from the pre-#3701 behaviour.
|
|
2925
|
+
nextPhaseNum = diskNextNum;
|
|
2926
|
+
nextPhaseName = diskNextName;
|
|
2927
|
+
isLastPhase = false;
|
|
2928
|
+
}
|
|
2185
2929
|
// #2028: don't stamp "All phases complete" when a LOWER-numbered phase is
|
|
2186
2930
|
// still outstanding. The two blocks above only clear isLastPhase when a
|
|
2187
2931
|
// HIGHER-numbered phase exists, so completing the numerically-highest phase
|
|
@@ -2196,7 +2940,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2196
2940
|
// pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
|
|
2197
2941
|
// between the box and "Phase", a required `:`) so unrelated checklist lines
|
|
2198
2942
|
// that merely mention "Phase N" don't match.
|
|
2199
|
-
|
|
2943
|
+
// #3350: this stage answers a DIFFERENT question than stages 1-2 ("what is
|
|
2944
|
+
// the next actionable phase?" vs "is this the last phase?"), so it must not
|
|
2945
|
+
// be gated on their answer. Gating on isLastPhase let a merely-positionally
|
|
2946
|
+
// next higher heading (stage 2) permanently mask a genuinely-outstanding
|
|
2947
|
+
// lower phase — stage 2 cleared isLastPhase and this scan never ran. The
|
|
2948
|
+
// scan already refuses anything not strictly lower than the completed phase
|
|
2949
|
+
// (plus sentinels, #2949), so running it unconditionally cannot manufacture
|
|
2950
|
+
// a wrong answer: when no lower phase is outstanding it finds nothing and
|
|
2951
|
+
// stages 1-2's pick stands unchanged; in the masking case isLastPhase is
|
|
2952
|
+
// already false, so the last-phase signal has no reachable regression.
|
|
2953
|
+
if (roadmapContent !== null) {
|
|
2200
2954
|
try {
|
|
2201
2955
|
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
|
|
2202
2956
|
const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
|
|
@@ -2243,11 +2997,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2243
2997
|
// to the STATE.md Transition Module. The ~90-line inline RMW callback
|
|
2244
2998
|
// that lived here is the pure `completePhaseCore` in
|
|
2245
2999
|
// src/state-transition.cts, backed by the field-classification table.
|
|
2246
|
-
// `updatePerformanceMetricsSection`
|
|
2247
|
-
//
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
// because STATE.md is
|
|
3000
|
+
// `updatePerformanceMetricsSection` stays in this adapter: it is a
|
|
3001
|
+
// section-table / disk-scan concern, not a classified field. The
|
|
3002
|
+
// sync + post-sync preservation this transaction needs runs via the
|
|
3003
|
+
// single write-seam composition, `syncAndPreserveStateMd` (it does
|
|
3004
|
+
// NOT go through readModifyWriteStateMd because STATE.md is
|
|
3005
|
+
// committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
|
|
3006
|
+
// #3374 / #3469).
|
|
2251
3007
|
const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
|
|
2252
3008
|
phaseDisplayNameFromSlug(nextPhaseName);
|
|
2253
3009
|
const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
|
|
@@ -2269,10 +3025,64 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2269
3025
|
// the intent; pass it as authoritative so the sync's prose
|
|
2270
3026
|
// re-derivation cannot rewrite current_phase_name to the name's own
|
|
2271
3027
|
// parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
|
|
2272
|
-
|
|
3028
|
+
// #3350: PAIR the override. When STATE.md's body carries no Current
|
|
3029
|
+
// Phase / Phase field to re-derive from (narrative prose), the #905
|
|
3030
|
+
// preserve guard in syncStateFrontmatter keeps the OLD frontmatter
|
|
3031
|
+
// current_phase while the authoritative current_phase_name advances —
|
|
3032
|
+
// leaving the two fields describing different phases. Pin BOTH to the
|
|
3033
|
+
// resolved next phase in that case. When the body DOES carry the field
|
|
3034
|
+
// (completePhaseCore just rewrote it), stay name-only so the body's
|
|
3035
|
+
// richer `N of T (name)` derived shape survives the sync.
|
|
3036
|
+
const fmBody = frontmatterMod.stripFrontmatter(stateContent);
|
|
3037
|
+
const bodyHasPhaseField = stateExtractField(fmBody, 'Current Phase') != null ||
|
|
3038
|
+
stateExtractField(fmBody, 'Phase') != null;
|
|
3039
|
+
const authoritativeFm = nextPhaseDisplayName
|
|
3040
|
+
? bodyHasPhaseField || !nextPhaseNum
|
|
3041
|
+
? { current_phase_name: nextPhaseDisplayName }
|
|
3042
|
+
: {
|
|
3043
|
+
current_phase: String(nextPhaseNum),
|
|
3044
|
+
current_phase_name: nextPhaseDisplayName,
|
|
3045
|
+
}
|
|
3046
|
+
: undefined;
|
|
3047
|
+
// ADR-3408 §8.3 / #3469: this deliberately bypasses
|
|
3048
|
+
// readModifyWriteStateMd (STATE.md is committed atomically with
|
|
3049
|
+
// ROADMAP/REQUIREMENTS), so it calls the single write-seam
|
|
3050
|
+
// composition (`syncAndPreserveStateMd`) directly instead of
|
|
3051
|
+
// assembling `syncStateFrontmatter` + `applyPostSyncPreservation`
|
|
3052
|
+
// itself — a call site re-assembling the pair, even with every step
|
|
3053
|
+
// calling an owner, is the exact re-derivation §8.3 forbids by name
|
|
3054
|
+
// (Phase 2 found this shape live here). The composition runs
|
|
3055
|
+
// snapshots from the on-disk pre-image (originalStateContent) and
|
|
3056
|
+
// the transformed content, table-driven applyStatePreservation, then
|
|
3057
|
+
// the #2736 authoritative re-assert (which restores the #3350
|
|
3058
|
+
// pairing override the preserve-always restore may have reverted).
|
|
3059
|
+
// resync=true is the lifecycle-transition posture (progress
|
|
3060
|
+
// recomputed from disk; only the preserve-when-unchanged deltas
|
|
3061
|
+
// apply). Fields the transition legitimately rewrote (Status, Phase,
|
|
3062
|
+
// Stopped At via completePhaseCore's #3374 continuity line) have
|
|
3063
|
+
// changed body sources, so their deltas do not fire.
|
|
3064
|
+
// ADR-3408 §8.5 / D2 (#3374): thread `divergedFields` through so this
|
|
3065
|
+
// command reports what it preserved, following `cmdMilestoneComplete`'s
|
|
3066
|
+
// shape (milestone.cts) — the same composition, the same out-param,
|
|
3067
|
+
// the same visibility contract.
|
|
3068
|
+
const divergedFields = [];
|
|
3069
|
+
stateContent = syncAndPreserveStateMd(originalStateContent, stateContent, statePath, cwd, {
|
|
3070
|
+
resync: true,
|
|
3071
|
+
authoritativeFm,
|
|
3072
|
+
divergedFields,
|
|
3073
|
+
});
|
|
3074
|
+
for (const field of divergedFields) {
|
|
3075
|
+
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
|
3076
|
+
}
|
|
2273
3077
|
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
|
|
3078
|
+
// #3685 / #3691: normalize both sides before comparing (same
|
|
3079
|
+
// transitionCore-regenerated-section artifact cmdMilestoneComplete
|
|
3080
|
+
// hit — see contentChangedAfterNormalize's doc). Reported "not
|
|
3081
|
+
// exposed" by a previous agent; the reviewer disproved that by
|
|
3082
|
+
// inspection and this branch closes it.
|
|
3083
|
+
stateUpdated = (0, shell_command_projection_cjs_1.contentChangedAfterNormalize)(statePath, originalStateContent, stateContent);
|
|
2274
3084
|
}
|
|
2275
|
-
writePlanningFileSet(writes);
|
|
3085
|
+
anyPlanningWrite = writePlanningFileSet(writes) > 0;
|
|
2276
3086
|
};
|
|
2277
3087
|
if (node_fs_1.default.existsSync(statePath)) {
|
|
2278
3088
|
withStateLock(statePath, runPhaseCompleteTransaction);
|
|
@@ -2280,6 +3090,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2280
3090
|
else {
|
|
2281
3091
|
runPhaseCompleteTransaction();
|
|
2282
3092
|
}
|
|
3093
|
+
// #3311: a successful completion of the CLAIMED phase releases the
|
|
3094
|
+
// milestone claim — regardless of which session completes it (an
|
|
3095
|
+
// orchestrator cleaning up after a dead session must not be blocked by the
|
|
3096
|
+
// dead session's own claim). No-ops when the claim names another phase.
|
|
3097
|
+
milestoneLockMod.releaseMilestonePhase(cwd, phaseNum);
|
|
2283
3098
|
return null;
|
|
2284
3099
|
});
|
|
2285
3100
|
if (verificationBlocked) {
|
|
@@ -2325,15 +3140,23 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2325
3140
|
next_phase_name: nextPhaseName,
|
|
2326
3141
|
is_last_phase: isLastPhase,
|
|
2327
3142
|
date: today,
|
|
2328
|
-
roadmap_updated:
|
|
2329
|
-
state_updated:
|
|
3143
|
+
roadmap_updated: roadmapUpdated,
|
|
3144
|
+
state_updated: stateUpdated,
|
|
2330
3145
|
requirements_updated: requirementsUpdated,
|
|
2331
3146
|
auto_pruned: autoPruned,
|
|
2332
3147
|
warnings,
|
|
2333
3148
|
has_warnings: warnings.length > 0,
|
|
2334
3149
|
verification_stale_check_indeterminate: staleCheckIndeterminate,
|
|
3150
|
+
milestone_conflict: milestoneConflict,
|
|
3151
|
+
preservation_warnings: preservationWarnings,
|
|
2335
3152
|
};
|
|
2336
3153
|
output(result, raw);
|
|
3154
|
+
// #3227: gate on `anyPlanningWrite` (whether `writePlanningFileSet`
|
|
3155
|
+
// actually wrote anything), not on reaching this line — reaching here only
|
|
3156
|
+
// means verification passed and the transaction ran, not that ROADMAP.md
|
|
3157
|
+
// or STATE.md bytes changed (see the `anyPlanningWrite` declaration above).
|
|
3158
|
+
if (anyPlanningWrite)
|
|
3159
|
+
publishStateContract(cwd);
|
|
2337
3160
|
}
|
|
2338
3161
|
function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
|
|
2339
3162
|
if (!phaseNum) {
|
|
@@ -2353,7 +3176,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
|
|
|
2353
3176
|
// paths without re-discovering the phase directory themselves.
|
|
2354
3177
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
|
2355
3178
|
const planScanMod = require("./plan-scan.cjs");
|
|
2356
|
-
const { scanPhasePlans } = planScanMod;
|
|
3179
|
+
const { scanPhasePlans, isCanonicalPlanFile } = planScanMod;
|
|
2357
3180
|
function cmdPhaseListPlans(cwd, phaseNum, raw) {
|
|
2358
3181
|
if (!phaseNum) {
|
|
2359
3182
|
error('phase number required for phase list-plans');
|
|
@@ -2390,4 +3213,5 @@ module.exports = {
|
|
|
2390
3213
|
cmdPhaseUatPassed,
|
|
2391
3214
|
cmdPhaseListPlans,
|
|
2392
3215
|
computeDependencyLevels,
|
|
3216
|
+
buildShortFormToId,
|
|
2393
3217
|
};
|