@opengsd/gsd-core 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +5 -5
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +37 -0
- package/agents/gsd-planner.md +44 -46
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +12 -3
- package/bin/install.js +841 -971
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +469 -88
- package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
- package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
- package/gsd-core/bin/lib/api-coverage.cjs +3 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +876 -240
- package/gsd-core/bin/lib/broken-windows.cjs +1 -1
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +575 -101
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +495 -22
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +71 -37
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +651 -86
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +75 -0
- package/gsd-core/bin/lib/config.cjs +10 -1
- package/gsd-core/bin/lib/core-utils.cjs +127 -29
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +155 -20
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
- 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 +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init.cjs +321 -129
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +745 -258
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +134 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
- package/gsd-core/bin/lib/io.cjs +10 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +754 -70
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +444 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +125 -18
- package/gsd-core/bin/lib/phase.cjs +646 -143
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
- 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 +34 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +385 -94
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -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 +115 -3
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
- package/gsd-core/bin/lib/smart-entry.cjs +142 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +371 -117
- package/gsd-core/bin/lib/state.cjs +1794 -357
- package/gsd-core/bin/lib/surface.cjs +23 -9
- 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 +9 -3
- package/gsd-core/bin/lib/uat.cjs +399 -56
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +24 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +258 -8
- package/gsd-core/bin/lib/verify.cjs +368 -888
- 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.cjs +2 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/autonomous.md +1 -1
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +38 -12
- package/gsd-core/workflows/complete-milestone.md +141 -18
- package/gsd-core/workflows/debug.md +7 -5
- package/gsd-core/workflows/diagnose-issues.md +35 -9
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
- 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 +2 -0
- package/gsd-core/workflows/execute-phase.md +38 -50
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +5 -5
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone.md +14 -9
- package/gsd-core/workflows/new-project.md +14 -14
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase.md +41 -17
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress.md +34 -6
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
- package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
- package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
- package/gsd-core/workflows/quick.md +35 -15
- package/gsd-core/workflows/review.md +26 -5
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/spec-phase.md +30 -12
- package/gsd-core/workflows/sync-skills.md +63 -8
- package/gsd-core/workflows/transition.md +46 -11
- package/gsd-core/workflows/ui-phase.md +5 -5
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update.md +1 -1
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +9 -7
- package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +38 -24
- package/hooks/dist/gsd-statusline.js +18 -0
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/gsd-agent-isolation-guard.js +103 -14
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +77 -2
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +38 -24
- package/hooks/gsd-statusline.js +18 -0
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/package.json +21 -9
- package/pi/gsd.cjs +19 -5
- 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/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +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-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +21 -10
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +25 -0
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +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 +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -29,16 +29,22 @@ const configLoaderMod = require("./config-loader.cjs");
|
|
|
29
29
|
const { loadConfig } = configLoaderMod;
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
|
31
31
|
const coreUtilsMod = require("./core-utils.cjs");
|
|
32
|
-
|
|
32
|
+
// #2528: `extractCanonicalPlanId` used to exist here as a byte-identical second
|
|
33
|
+
// copy, and this PR had to patch BOTH with the same rewind rule — the exact
|
|
34
|
+
// generative-fix divergence CLAUDE.md warns about. Collapsed onto core-utils'
|
|
35
|
+
// copy, which was already the leaf owner, so there is no second surface left to
|
|
36
|
+
// drift and no parity test needed to police one.
|
|
37
|
+
const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, } = coreUtilsMod;
|
|
33
38
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
|
34
39
|
const phaseIdMod = require("./phase-id.cjs");
|
|
35
|
-
const {
|
|
40
|
+
const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, isSentinelPhaseId, scopeToPhase, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
|
|
41
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
36
42
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
|
37
43
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
38
|
-
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
|
|
44
|
+
const { findPhaseInternal, getArchivedPhaseDirs, listMilestonePhaseDirs } = phaseLocatorMod;
|
|
39
45
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
|
|
40
46
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
41
|
-
const { stripShippedMilestones, extractCurrentMilestone,
|
|
47
|
+
const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, findMilestoneScopeHeadingLines } = roadmapParserMod;
|
|
42
48
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
|
43
49
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
44
50
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
@@ -64,12 +70,12 @@ const verifyMod = require("./verify.cjs");
|
|
|
64
70
|
const { readVerificationStatus } = verificationMod;
|
|
65
71
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-dependency-graph.cjs is an export= CommonJS module
|
|
66
72
|
const planDependencyGraphMod = require("./plan-dependency-graph.cjs");
|
|
67
|
-
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted } = planDependencyGraphMod;
|
|
68
|
-
const { planningDir, withPlanningLock, listAvailableWorkstreams,
|
|
73
|
+
const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSummaryFileBlocked } = planDependencyGraphMod;
|
|
74
|
+
const { planningDir, withPlanningLock, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason, } = planningWorkspace;
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
|
|
76
|
+
const milestoneLockMod = require("./milestone-lock.cjs");
|
|
69
77
|
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';
|
|
78
|
+
const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncAndPreserveStateMd, withStateLock, updatePerformanceMetricsSection, } = stateMod;
|
|
73
79
|
// Any .md file with PLAN anywhere in the basename — diagnostic net
|
|
74
80
|
const PLAN_OUTLINE_RE = /-PLAN-OUTLINE\.md$/i;
|
|
75
81
|
const PLAN_PRE_BOUNCE_RE = /-PLAN.*\.pre-bounce\.md$/i;
|
|
@@ -138,27 +144,6 @@ function describeNonCanonicalPlans(dirFiles, matchedFiles) {
|
|
|
138
144
|
`. Rename to the canonical form (e.g. "01-01-PLAN.md") so the executor can detect them. ` +
|
|
139
145
|
`See agents/gsd-planner.md write_phase_prompt step for the full contract.`);
|
|
140
146
|
}
|
|
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
147
|
function cmdPhasesList(cwd, options, raw) {
|
|
163
148
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
164
149
|
const { type, phase, includeArchived } = options;
|
|
@@ -172,24 +157,56 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
172
157
|
return;
|
|
173
158
|
}
|
|
174
159
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
160
|
+
// #3185 (ADR-3180 Decision 1): only the ENUMERATION routes through the
|
|
161
|
+
// single owner. The two other modes below ask genuinely DIFFERENT
|
|
162
|
+
// questions and are exempt by documented reason, never by a file
|
|
163
|
+
// allowlist (ADR-3180 Decision 4a):
|
|
164
|
+
//
|
|
165
|
+
// --phase <n> locating ONE phase by token is phase LOCATION, a
|
|
166
|
+
// question src/phase-locator.cts already owns via
|
|
167
|
+
// findPhaseInternal/searchPhaseInDir. Scoping it to
|
|
168
|
+
// the current milestone would make an out-of-window
|
|
169
|
+
// phase report "Phase not found".
|
|
170
|
+
// --include-archived archived directories are BY DEFINITION from other
|
|
171
|
+
// milestones; filtering them through the CURRENT
|
|
172
|
+
// milestone window would return nothing at all.
|
|
173
|
+
//
|
|
174
|
+
// Generalizing #3183's rule ("a diagnostic about file NAMING wants the
|
|
175
|
+
// physical set; only a question about outstanding WORK wants the live
|
|
176
|
+
// set"): a LOOKUP wants the physical set; only "which phases belong to
|
|
177
|
+
// this milestone" wants the scoped set.
|
|
178
|
+
const archivedLabels = includeArchived
|
|
179
|
+
? getArchivedPhaseDirs(cwd).map((a) => `${a.name} [${a.milestone}]`)
|
|
180
|
+
: [];
|
|
181
|
+
let dirs;
|
|
182
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
183
|
+
// can tell a genuinely-empty milestone from one it could not scope. Only
|
|
184
|
+
// the ENUMERATION path scopes anything; the LOOKUP path below has no
|
|
185
|
+
// enumeration to report a scope for.
|
|
186
|
+
let phaseScope = null;
|
|
184
187
|
if (phase) {
|
|
188
|
+
// LOOKUP (b): search the physical set, plus archived when asked.
|
|
189
|
+
const lookupPool = [...readSubdirectories(phasesDir, true), ...archivedLabels];
|
|
185
190
|
const normalized = normalizePhaseName(phase);
|
|
186
|
-
|
|
191
|
+
// The pool is #3185's (physical set + archived); the matcher is this
|
|
192
|
+
// PR's. `dirs` is deliberately not read here: on this base it is not
|
|
193
|
+
// assigned until the branch below picks a match.
|
|
194
|
+
const { matches } = matchPhaseDirs(lookupPool, normalized);
|
|
195
|
+
const match = matches[0];
|
|
187
196
|
if (!match) {
|
|
188
197
|
output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
|
|
189
198
|
return;
|
|
190
199
|
}
|
|
191
200
|
dirs = [match];
|
|
192
201
|
}
|
|
202
|
+
else {
|
|
203
|
+
// ENUMERATION (a): milestone-scoped and sentinel-filtered, plus
|
|
204
|
+
// archived when asked (c).
|
|
205
|
+
const enumerated = listMilestonePhaseDirs(phasesDir, { cwd });
|
|
206
|
+
phaseScope = enumerated.scope;
|
|
207
|
+
dirs = [...enumerated.value, ...archivedLabels];
|
|
208
|
+
dirs.sort((a, b) => comparePhaseNum(a, b));
|
|
209
|
+
}
|
|
193
210
|
if (type) {
|
|
194
211
|
const files = [];
|
|
195
212
|
const warnings = [];
|
|
@@ -198,13 +215,31 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
198
215
|
const dirFiles = node_fs_1.default.readdirSync(dirPath);
|
|
199
216
|
let filtered;
|
|
200
217
|
if (type === 'plans') {
|
|
201
|
-
|
|
218
|
+
// #3183: this is a "what plan files physically exist" query (this
|
|
219
|
+
// IS the file-listing command), not a live-completion question, so
|
|
220
|
+
// it uses the single owner's allPlanFiles (root+nested, INCLUDING
|
|
221
|
+
// status: superseded) rather than a root-only readdirSync filter
|
|
222
|
+
// that also missed nested plans.
|
|
223
|
+
//
|
|
224
|
+
// #2893 (regression fix): `allPlanFiles` also carries
|
|
225
|
+
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately
|
|
226
|
+
// permissive for live-plan COUNTING elsewhere — see
|
|
227
|
+
// plan-count-single-owner.test.cjs). That fallback silently
|
|
228
|
+
// recognized a non-canonically-named file (e.g.
|
|
229
|
+
// `01-PLAN-01-foundation.md`) as "matched", which defeated this
|
|
230
|
+
// command's #2893 naming-convention diagnostic entirely (no
|
|
231
|
+
// warning, file listed as if valid). Intersect with the STRICT
|
|
232
|
+
// `isCanonicalPlanFile` predicate so this diagnostic — and the
|
|
233
|
+
// `files` list this command actually returns — only ever
|
|
234
|
+
// recognizes the canonical root/nested forms, exactly like the
|
|
235
|
+
// pre-#3183 behavior this feature was built and tested against.
|
|
236
|
+
filtered = scanPhasePlans(dirPath).allPlanFiles.filter(isCanonicalPlanFile);
|
|
202
237
|
const w = describeNonCanonicalPlans(dirFiles, filtered);
|
|
203
238
|
if (w)
|
|
204
239
|
warnings.push(`${dir}: ${w}`);
|
|
205
240
|
}
|
|
206
241
|
else if (type === 'summaries') {
|
|
207
|
-
filtered =
|
|
242
|
+
filtered = scanPhasePlans(dirPath).summaryFiles;
|
|
208
243
|
}
|
|
209
244
|
else {
|
|
210
245
|
filtered = dirFiles;
|
|
@@ -215,13 +250,18 @@ function cmdPhasesList(cwd, options, raw) {
|
|
|
215
250
|
files,
|
|
216
251
|
count: files.length,
|
|
217
252
|
phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)*-?/, '') : null,
|
|
253
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
254
|
+
// can tell a genuinely-empty milestone from one it could not scope.
|
|
255
|
+
phase_scope: phaseScope,
|
|
218
256
|
};
|
|
219
257
|
if (warnings.length)
|
|
220
258
|
result['warning'] = warnings.join(' | ');
|
|
221
259
|
output(result, raw, files.join('\n'));
|
|
222
260
|
return;
|
|
223
261
|
}
|
|
224
|
-
|
|
262
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
263
|
+
// can tell a genuinely-empty milestone from one it could not scope.
|
|
264
|
+
output({ directories: dirs, count: dirs.length, phase_scope: phaseScope }, raw, dirs.join('\n'));
|
|
225
265
|
}
|
|
226
266
|
catch (e) {
|
|
227
267
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -237,8 +277,8 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
|
|
|
237
277
|
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
238
278
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
239
279
|
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+)`);
|
|
280
|
+
baseExists = matchPhaseDirs(dirs, normalized).matches.length > 0;
|
|
281
|
+
const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalized)}\\.(\\d+)`);
|
|
242
282
|
for (const dir of dirs) {
|
|
243
283
|
const match = dir.match(dirPattern);
|
|
244
284
|
if (match)
|
|
@@ -351,6 +391,13 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
351
391
|
phase_name: null,
|
|
352
392
|
plans: [],
|
|
353
393
|
summaries: [],
|
|
394
|
+
// #3218: scalar counts alongside the arrays above. Left `null` (not `0`)
|
|
395
|
+
// when the phase can't be resolved at all — a fabricated `0` here would
|
|
396
|
+
// read identically to "phase exists with zero plans", which is a real,
|
|
397
|
+
// distinct answer (see the `status: superseded` case below).
|
|
398
|
+
plan_count: null,
|
|
399
|
+
summary_count: null,
|
|
400
|
+
plan_count_all: null,
|
|
354
401
|
searched_directories: [],
|
|
355
402
|
};
|
|
356
403
|
const searchDirs = [];
|
|
@@ -381,7 +428,10 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
381
428
|
// #2237: fail loud when multiple directories match the same bare phase
|
|
382
429
|
// number — prevents cross-project file writes when unrelated projects
|
|
383
430
|
// share a .planning/phases/ tree.
|
|
384
|
-
|
|
431
|
+
// #2528: selection delegates to the canonical two-pass matcher (exact
|
|
432
|
+
// token match, then the bare-integer leading-digit-run fallback) shared
|
|
433
|
+
// with the locator and the phase-plan-index scan.
|
|
434
|
+
const { matches } = matchPhaseDirs(dirs, normalized);
|
|
385
435
|
if (matches.length === 0)
|
|
386
436
|
continue;
|
|
387
437
|
if (matches.length > 1) {
|
|
@@ -398,9 +448,29 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
398
448
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
399
449
|
const phaseDir = node_path_1.default.join(searchDir, match);
|
|
400
450
|
const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
451
|
+
// #3183: canonical, live (superseded-excluded) plan/summary sets
|
|
452
|
+
// (root+nested) from the single owner, rather than a root-only
|
|
453
|
+
// isCanonicalPlanFile filter + hand-rolled summary filter.
|
|
454
|
+
//
|
|
455
|
+
// #2893 (regression fix): both `plans` and the naming-diagnostic
|
|
456
|
+
// "matched" set are further intersected with the STRICT
|
|
457
|
+
// `isCanonicalPlanFile` predicate — scanPhasePlans's own
|
|
458
|
+
// planFiles/allPlanFiles carry `isRootPlanFile`'s loose `/PLAN/i`
|
|
459
|
+
// fallback (deliberately permissive for live-plan COUNTING elsewhere),
|
|
460
|
+
// which silently recognized a non-canonically-named file (e.g.
|
|
461
|
+
// `01-PLAN-01-foundation.md`) as a valid plan here and defeated this
|
|
462
|
+
// command's #2893 naming-convention diagnostic (no warning, offender
|
|
463
|
+
// listed in `plans` as if valid).
|
|
464
|
+
const phaseScan = scanPhasePlans(phaseDir);
|
|
465
|
+
const plans = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
|
466
|
+
const summaries = phaseScan.summaryFiles.slice().sort();
|
|
467
|
+
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated
|
|
468
|
+
// to supersession — compare against allPlanFiles (every plan-shaped file
|
|
469
|
+
// the owner recognizes, canonical or not) rather than the live-only
|
|
470
|
+
// `plans`, so a superseded-but-canonically-named plan is not misreported
|
|
471
|
+
// as a naming violation.
|
|
472
|
+
const canonicalAllPlanFiles = phaseScan.allPlanFiles.filter(isCanonicalPlanFile);
|
|
473
|
+
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, canonicalAllPlanFiles);
|
|
404
474
|
const result = {
|
|
405
475
|
found: true,
|
|
406
476
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planBase), node_path_1.default.relative(planBase, searchDir), match)),
|
|
@@ -408,6 +478,20 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
408
478
|
phase_name: phaseName,
|
|
409
479
|
plans,
|
|
410
480
|
summaries,
|
|
481
|
+
// #3218: scalar counts additive alongside `plans[]`/`summaries[]`,
|
|
482
|
+
// which stay unchanged for existing consumers. Naming mirrors
|
|
483
|
+
// `roadmap.analyze`'s `plan_count`/`summary_count` (live, i.e.
|
|
484
|
+
// status:superseded EXCLUDED — same set as `plans`/`summaries`
|
|
485
|
+
// above) so the two surfaces read alike. `plan_count_all` is the
|
|
486
|
+
// PHYSICAL count — every canonically-named plan file on disk,
|
|
487
|
+
// status:superseded INCLUDED, same set `planNamingWarning` above
|
|
488
|
+
// diffs against (`canonicalAllPlanFiles`). The `_all` suffix
|
|
489
|
+
// deliberately echoes `scanPhasePlans`'s own `allPlanFiles` field so
|
|
490
|
+
// a reader can trace the name back to its source rather than guess
|
|
491
|
+
// which of two similarly-named integers is the filtered one.
|
|
492
|
+
plan_count: plans.length,
|
|
493
|
+
summary_count: summaries.length,
|
|
494
|
+
plan_count_all: canonicalAllPlanFiles.length,
|
|
411
495
|
};
|
|
412
496
|
if (planNamingWarning)
|
|
413
497
|
result['warning'] = planNamingWarning;
|
|
@@ -499,40 +583,96 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
499
583
|
const normalized = normalizePhaseName(phase);
|
|
500
584
|
let phaseDir = null;
|
|
501
585
|
let phaseDirName = null;
|
|
586
|
+
let ambiguousMatches = null;
|
|
502
587
|
try {
|
|
503
588
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
504
589
|
const dirs = entries
|
|
505
590
|
.filter((e) => e.isDirectory())
|
|
506
591
|
.map((e) => e.name)
|
|
507
592
|
.sort((a, b) => comparePhaseNum(a, b));
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
593
|
+
// #2528: selection delegates to the canonical two-pass matcher shared with
|
|
594
|
+
// the locator and the find-phase scan (this site previously first-matched
|
|
595
|
+
// with `.find()` and had no multi-match guard — the #2237 fail-loud rule
|
|
596
|
+
// now applies here too, so the three resolution paths cannot disagree).
|
|
597
|
+
const { matches } = matchPhaseDirs(dirs, normalized);
|
|
598
|
+
if (matches.length > 1) {
|
|
599
|
+
ambiguousMatches = matches;
|
|
600
|
+
}
|
|
601
|
+
else if (matches.length === 1) {
|
|
602
|
+
phaseDir = node_path_1.default.join(phasesDir, matches[0]);
|
|
603
|
+
phaseDirName = matches[0];
|
|
512
604
|
}
|
|
513
605
|
}
|
|
514
606
|
catch {
|
|
515
607
|
// phases dir doesn't exist
|
|
516
608
|
}
|
|
609
|
+
if (ambiguousMatches) {
|
|
610
|
+
output({
|
|
611
|
+
phase: normalized,
|
|
612
|
+
error: `Phase ${normalized} is ambiguous: ${ambiguousMatches.length} directories match (${ambiguousMatches.map((m) => `"${m}"`).join(', ')}).`,
|
|
613
|
+
ambiguous_matches: ambiguousMatches,
|
|
614
|
+
plans: [], waves: {}, incomplete: [], has_checkpoints: false,
|
|
615
|
+
}, raw);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
517
618
|
if (!phaseDir) {
|
|
518
619
|
output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], runnable: [], has_checkpoints: false }, raw);
|
|
519
620
|
return;
|
|
520
621
|
}
|
|
521
622
|
void phaseDirName; // used only to set phaseDir above
|
|
623
|
+
// phaseFiles stays root-only readdirSync — it feeds only
|
|
624
|
+
// describeNonCanonicalPlans's near-miss naming diagnostic below, which is
|
|
625
|
+
// advisory text, not a counted/scheduled file set.
|
|
522
626
|
const phaseFiles = node_fs_1.default.readdirSync(phaseDir);
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
627
|
+
// #3183 (highest-severity site, ADR-3180 Decision 2): canonical LIVE
|
|
628
|
+
// plan/summary sets (root+nested, status: superseded EXCLUDED) from the
|
|
629
|
+
// single owner. This fixes two real bugs in the wave/dependency index this
|
|
630
|
+
// function builds: (1) a superseded plan used to still get scheduled into
|
|
631
|
+
// an execution wave, and (2) a phase using the #3139 nested `plans/`
|
|
632
|
+
// layout used to report ZERO plans (root-only readdirSync, no `plans/`
|
|
633
|
+
// join).
|
|
634
|
+
// #2893 (regression fix): intersected with the STRICT `isCanonicalPlanFile`
|
|
635
|
+
// predicate — scanPhasePlans's own planFiles/allPlanFiles carry
|
|
636
|
+
// `isRootPlanFile`'s loose `/PLAN/i` fallback (deliberately permissive for
|
|
637
|
+
// live-plan COUNTING elsewhere), which silently scheduled a
|
|
638
|
+
// non-canonically-named file (e.g. `01-PLAN-01-foundation.md`) into a wave
|
|
639
|
+
// here and defeated this command's #2893 naming-convention diagnostic (no
|
|
640
|
+
// warning). Restores the pre-#3183, tested behavior: only canonical
|
|
641
|
+
// root/nested filenames are ever counted or scheduled by this command.
|
|
642
|
+
const phaseScan = scanPhasePlans(phaseDir);
|
|
643
|
+
const planFiles = phaseScan.planFiles.filter(isCanonicalPlanFile).sort();
|
|
644
|
+
const summaryFiles = phaseScan.summaryFiles;
|
|
645
|
+
// describeNonCanonicalPlans is a NAMING-CONVENTION diagnostic, unrelated to
|
|
646
|
+
// supersession — compare against allPlanFiles (every plan-shaped file the
|
|
647
|
+
// owner recognizes, canonical or not) rather than the live-only planFiles,
|
|
648
|
+
// so a superseded-but-canonically-named plan is not misreported as a
|
|
649
|
+
// naming violation.
|
|
650
|
+
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, phaseScan.allPlanFiles.filter(isCanonicalPlanFile));
|
|
651
|
+
// #3183: completion pairing via the canonical findUnsummarizedPlans
|
|
652
|
+
// (shares its `summaryCandidates` matching rule with countMatchedSummaries,
|
|
653
|
+
// and is layout-agnostic — it pairs a nested `plans/PLAN-01.md` with
|
|
654
|
+
// `plans/SUMMARY-01.md` correctly) instead of a bespoke ID-Set built from
|
|
655
|
+
// extractCanonicalPlanId, which only ever handled the root-canonical
|
|
656
|
+
// `-PLAN.md`/`-SUMMARY.md` naming form.
|
|
657
|
+
//
|
|
658
|
+
// #3345: the summary list is filtered through the SAME shared predicate
|
|
659
|
+
// scanPhasePlans filters its countable set with
|
|
660
|
+
// (plan-dependency-graph.cjs's isSummaryFileBlocked), so a SUMMARY declaring
|
|
661
|
+
// `status: blocked` reads as NO completion record here — has_summary false,
|
|
662
|
+
// the plan lands in `incomplete` — exactly matching the count side. Fail-open
|
|
663
|
+
// on a SUMMARY with no status key / unreadable file (filename fallback);
|
|
664
|
+
// `status: halted` stays summarized (#2830 designed stop). summaryFileByPlanId
|
|
665
|
+
// below still indexes EVERY summary on disk because the halted lookup is a
|
|
666
|
+
// file resolution for reading status, not a completion pairing.
|
|
667
|
+
const countableSummaryFiles = summaryFiles.filter((f) => !isSummaryFileBlocked(node_path_1.default.join(phaseDir, f)));
|
|
668
|
+
const unsummarizedPlanFiles = new Set(findUnsummarizedPlans(planFiles, countableSummaryFiles));
|
|
531
669
|
// #2830: reverse lookup from a completed plan's id (exact or canonical) to
|
|
532
670
|
// the actual summary filename, so a plan's own SUMMARY frontmatter can be
|
|
533
671
|
// read for its `status`. Shared builder (also used by phase-locator.cts's
|
|
534
672
|
// searchPhaseInDir) so the two can never disagree about which summary
|
|
535
|
-
// belongs to which plan.
|
|
673
|
+
// belongs to which plan. This is a FILE resolution for reading halted
|
|
674
|
+
// status, not a completion-count pairing rule, so it is unaffected by the
|
|
675
|
+
// #3183 pairing migration above.
|
|
536
676
|
const summaryFileByPlanId = buildSummaryFileIndex(summaryFiles, extractCanonicalPlanId);
|
|
537
677
|
// ── Pass 1: parse each plan file ─────────────────────────────────────────
|
|
538
678
|
const rawPlans = [];
|
|
@@ -566,7 +706,18 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
566
706
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
|
567
707
|
filesModified = Array.isArray(fmFiles) ? fmFiles.map(String) : [String(fmFiles)];
|
|
568
708
|
}
|
|
569
|
-
|
|
709
|
+
// #1689: optional per-plan specialist executor hint. Read verbatim here; the
|
|
710
|
+
// orchestrator resolves it against the active runtime's agent dir at dispatch
|
|
711
|
+
// time (execute-phase.md -> `gsd_run query resolve-agent`), falling back to
|
|
712
|
+
// gsd-executor when the field is unset or the named agent does not resolve.
|
|
713
|
+
let agentHint = null;
|
|
714
|
+
const fmAgentHint = fm['agent_hint'];
|
|
715
|
+
if (fmAgentHint !== undefined) {
|
|
716
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- FrontmatterValue scalar-to-string
|
|
717
|
+
const hintStr = String(fmAgentHint).trim();
|
|
718
|
+
agentHint = hintStr !== '' ? hintStr : null;
|
|
719
|
+
}
|
|
720
|
+
const hasSummary = !unsummarizedPlanFiles.has(planFile);
|
|
570
721
|
// #2830: a plan can have a SUMMARY (hasSummary=true) and still be halted —
|
|
571
722
|
// a designed stop still writes a completion record, just one whose status
|
|
572
723
|
// says "halted" rather than "complete". Only look up the summary file
|
|
@@ -582,6 +733,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
582
733
|
autonomous,
|
|
583
734
|
objective: extractObjective(content) || fm['objective'] || null,
|
|
584
735
|
filesModified,
|
|
736
|
+
agentHint,
|
|
585
737
|
taskCount,
|
|
586
738
|
hasSummary,
|
|
587
739
|
halted,
|
|
@@ -664,6 +816,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
664
816
|
autonomous: rawPlan.autonomous,
|
|
665
817
|
objective: rawPlan.objective,
|
|
666
818
|
files_modified: rawPlan.filesModified,
|
|
819
|
+
agent_hint: rawPlan.agentHint,
|
|
667
820
|
task_count: rawPlan.taskCount,
|
|
668
821
|
has_summary: rawPlan.hasSummary,
|
|
669
822
|
// #2830: additive fields — halted is this plan's OWN status; blocked_by
|
|
@@ -716,10 +869,54 @@ function describeGoalShapedTitle(description) {
|
|
|
716
869
|
return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
|
|
717
870
|
`as the phase title; consider a short title with the detail moved to **Goal:**.`);
|
|
718
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* #3163: compute the byte offset in `rawContent` where a new `### Phase N:`
|
|
874
|
+
* entry should be inserted — at the end of the active phase list, scoped to the
|
|
875
|
+
* CURRENT MILESTONE so the entry can never land before a trailing `---` in
|
|
876
|
+
* shipped/history/backlog material (the file's last `---` on a long roadmap
|
|
877
|
+
* sits deep in archive). When no current milestone can be resolved (no
|
|
878
|
+
* STATE.md `milestone:` and no in-progress `🚧`/`🔄` marker), fall back to the
|
|
879
|
+
* legacy whole-file lastIndexOf('\n---') so simple no-milestone roadmaps keep
|
|
880
|
+
* their existing behavior.
|
|
881
|
+
*/
|
|
882
|
+
function phaseEntryInsertOffset(rawContent, cwd) {
|
|
883
|
+
const ranges = currentMilestoneRawRanges(rawContent, cwd);
|
|
884
|
+
if (!ranges) {
|
|
885
|
+
const legacy = rawContent.lastIndexOf('\n---');
|
|
886
|
+
return legacy > 0 ? legacy : rawContent.length;
|
|
887
|
+
}
|
|
888
|
+
const window = rawContent.slice(ranges.primary.start, ranges.primary.end);
|
|
889
|
+
const lastSeparator = window.lastIndexOf('\n---');
|
|
890
|
+
return lastSeparator > 0 ? ranges.primary.start + lastSeparator : ranges.primary.end;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* #3262 (write-time milestone-scope guard): the phase-creation and
|
|
894
|
+
* phase-insertion entry templates interpolate the caller's `description`
|
|
895
|
+
* verbatim into `### Phase N: ${description}`. A description embedding a
|
|
896
|
+
* level 1-3 heading that carries a milestone marker (version token,
|
|
897
|
+
* ✅/📋/🚧/🔄, or the word "Milestone") would splice a heading that TERMINATES
|
|
898
|
+
* the current milestone window (`computeMilestoneSectionEnd`) and silently
|
|
899
|
+
* drops every later phase out of the derived milestone phase set. Reject
|
|
900
|
+
* before any write or phase-directory creation — the fail-loud sibling of
|
|
901
|
+
* the edit-phase workflow's depends_on gate. The predicate itself
|
|
902
|
+
* (`findMilestoneScopeHeadingLines`) is fence-aware and Phase-heading-exempt,
|
|
903
|
+
* so ordinary descriptions and the phase's own numbered heading never trip it.
|
|
904
|
+
*/
|
|
905
|
+
function assertDescriptionPreservesMilestoneScope(description, command) {
|
|
906
|
+
const offending = findMilestoneScopeHeadingLines(description);
|
|
907
|
+
if (offending.length === 0)
|
|
908
|
+
return;
|
|
909
|
+
error(`${command}: description contains a milestone-scoping heading line — writing it to ROADMAP.md would terminate ` +
|
|
910
|
+
`the current milestone window and silently drop later phases out of the milestone scope. ` +
|
|
911
|
+
`Offending line(s): ${offending.map((line) => JSON.stringify(line)).join(', ')}. ` +
|
|
912
|
+
`Rewrite the line so it is not a level 1-3 "#" heading carrying a milestone marker ` +
|
|
913
|
+
`(a vN.N version token, a ✅/📋/🚧/🔄 marker, or the word "Milestone").`);
|
|
914
|
+
}
|
|
719
915
|
function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
720
916
|
if (!description) {
|
|
721
917
|
error('description required for phase add');
|
|
722
918
|
}
|
|
919
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase add');
|
|
723
920
|
const config = loadConfig(cwd);
|
|
724
921
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
725
922
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
@@ -755,12 +952,14 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
755
952
|
let m;
|
|
756
953
|
while ((m = headerPattern.exec(content)) !== null) {
|
|
757
954
|
const num = parseInt(m[1], 10);
|
|
758
|
-
|
|
955
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
956
|
+
if (!isSentinelPhaseId(num))
|
|
759
957
|
usedPhaseNums.add(num);
|
|
760
958
|
}
|
|
761
959
|
while ((m = bulletPattern.exec(content)) !== null) {
|
|
762
960
|
const num = parseInt(m[1], 10);
|
|
763
|
-
|
|
961
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
962
|
+
if (!isSentinelPhaseId(num))
|
|
764
963
|
usedPhaseNums.add(num);
|
|
765
964
|
}
|
|
766
965
|
// 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
|
|
@@ -772,7 +971,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
772
971
|
if (!match)
|
|
773
972
|
continue;
|
|
774
973
|
const num = parseInt(match[1], 10);
|
|
775
|
-
|
|
974
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
975
|
+
if (!isSentinelPhaseId(num))
|
|
776
976
|
usedPhaseNums.add(num);
|
|
777
977
|
}
|
|
778
978
|
}
|
|
@@ -792,14 +992,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
792
992
|
? ''
|
|
793
993
|
: `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
|
|
794
994
|
const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_newPhaseId} to break down)\n`;
|
|
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
|
-
}
|
|
995
|
+
const insertAt = phaseEntryInsertOffset(rawContent, cwd);
|
|
996
|
+
const updatedContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
|
|
803
997
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
|
|
804
998
|
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
|
805
999
|
});
|
|
@@ -820,6 +1014,12 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
820
1014
|
if (!Array.isArray(descriptions) || descriptions.length === 0) {
|
|
821
1015
|
error('descriptions array required for phase add-batch');
|
|
822
1016
|
}
|
|
1017
|
+
// #3262: validate every description BEFORE the lock — the batch is
|
|
1018
|
+
// all-or-nothing, so one offending description must reject the whole batch
|
|
1019
|
+
// with no ROADMAP write and no phase directories created.
|
|
1020
|
+
for (const description of descriptions) {
|
|
1021
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase add-batch');
|
|
1022
|
+
}
|
|
823
1023
|
const config = loadConfig(cwd);
|
|
824
1024
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
825
1025
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
@@ -837,7 +1037,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
837
1037
|
let m;
|
|
838
1038
|
while ((m = phasePattern.exec(content)) !== null) {
|
|
839
1039
|
const num = parseInt(m[1], 10);
|
|
840
|
-
|
|
1040
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1041
|
+
if (isSentinelPhaseId(num))
|
|
841
1042
|
continue;
|
|
842
1043
|
if (num > maxPhase)
|
|
843
1044
|
maxPhase = num;
|
|
@@ -850,7 +1051,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
850
1051
|
if (!match)
|
|
851
1052
|
continue;
|
|
852
1053
|
const num = parseInt(match[1], 10);
|
|
853
|
-
|
|
1054
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1055
|
+
if (isSentinelPhaseId(num))
|
|
854
1056
|
continue;
|
|
855
1057
|
if (num > maxPhase)
|
|
856
1058
|
maxPhase = num;
|
|
@@ -878,11 +1080,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
|
878
1080
|
? ''
|
|
879
1081
|
: `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
|
880
1082
|
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${newPhaseId} to break down)\n`;
|
|
881
|
-
const
|
|
882
|
-
rawContent =
|
|
883
|
-
lastSeparator > 0
|
|
884
|
-
? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
|
|
885
|
-
: rawContent + phaseEntry;
|
|
1083
|
+
const insertAt = phaseEntryInsertOffset(rawContent, cwd);
|
|
1084
|
+
rawContent = rawContent.slice(0, insertAt) + phaseEntry + rawContent.slice(insertAt);
|
|
886
1085
|
added.push({
|
|
887
1086
|
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
|
888
1087
|
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
|
@@ -901,6 +1100,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
901
1100
|
if (!afterPhase || !description) {
|
|
902
1101
|
error('after-phase and description required for phase insert');
|
|
903
1102
|
}
|
|
1103
|
+
assertDescriptionPreservesMilestoneScope(description, 'phase insert');
|
|
904
1104
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
905
1105
|
if (!node_fs_1.default.existsSync(roadmapPath)) {
|
|
906
1106
|
error('ROADMAP.md not found');
|
|
@@ -949,7 +1149,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
949
1149
|
error(`Failed to scan phase directories for existing decimal phases: ${msg}`);
|
|
950
1150
|
}
|
|
951
1151
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
952
|
-
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
|
|
1152
|
+
const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${(0, pattern_cjs_1.escapeRegex)(normalizedBase)}\\.(\\d+)`);
|
|
953
1153
|
for (const dir of dirs) {
|
|
954
1154
|
const dm = dir.match(decimalPattern);
|
|
955
1155
|
if (dm)
|
|
@@ -977,15 +1177,30 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
977
1177
|
const phaseLabel = useBold
|
|
978
1178
|
? `**Phase ${_decimalPhase}: ${description}**`
|
|
979
1179
|
: `Phase ${_decimalPhase}: ${description}`;
|
|
1180
|
+
// #3413 review fix: bulletEntry stays hardcoded '\n'. The on-disk EOL
|
|
1181
|
+
// is decided at write time by platformWriteSync's normalizeContent /
|
|
1182
|
+
// _normalizeMd (shell-command-projection.cts), which unconditionally
|
|
1183
|
+
// converts \r\n -> \n for any .md target — so whatever terminator is
|
|
1184
|
+
// used here in memory is erased before the file is ever written, and
|
|
1185
|
+
// templating it via detectEol(rawContent) was inert dead code. '\n'
|
|
1186
|
+
// matches what platformWriteSync enforces anyway.
|
|
980
1187
|
const bulletEntry = `\n- [ ] ${phaseLabel}`;
|
|
981
|
-
|
|
1188
|
+
// #3413: was `[^\n]*`, which on CRLF content swallows the line's
|
|
1189
|
+
// trailing \r into the match, shifting bulletLineEnd to land BETWEEN
|
|
1190
|
+
// the \r and \n of the original CRLF pair — a pure splice-POSITION
|
|
1191
|
+
// bug on the not-yet-write-normalized CRLF read (independent of the
|
|
1192
|
+
// final on-disk EOL, which platformWriteSync always forces to LF for
|
|
1193
|
+
// .md targets regardless). Widening to [^\r\n]* stops the match at the
|
|
1194
|
+
// true line-content boundary so bulletLineEnd lands cleanly before the
|
|
1195
|
+
// terminator.
|
|
1196
|
+
const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*)`, 'i');
|
|
982
1197
|
const bulletMatchResult = rawContent.match(targetBulletPattern);
|
|
983
1198
|
if (!bulletMatchResult) {
|
|
984
1199
|
error(`Could not find Phase ${afterPhase} bullet line`);
|
|
985
1200
|
}
|
|
986
1201
|
const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
|
|
987
1202
|
const afterBullet = rawContent.slice(bulletLineEnd);
|
|
988
|
-
const nextBulletMatch = afterBullet.match(/\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
|
|
1203
|
+
const nextBulletMatch = afterBullet.match(/\r?\n-\s*\[[ x]\]\s*(?:\*\*)?Phase\s+\d/i);
|
|
989
1204
|
let insertIdx;
|
|
990
1205
|
if (nextBulletMatch) {
|
|
991
1206
|
insertIdx = bulletLineEnd + nextBulletMatch.index;
|
|
@@ -1005,7 +1220,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
1005
1220
|
}
|
|
1006
1221
|
const headerIdx = rawContent.indexOf(headerMatch[0]);
|
|
1007
1222
|
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
|
|
1008
|
-
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
|
|
1223
|
+
const nextPhaseMatch = afterHeader.match(/\r?\n#{2,4}\s+Phase\s+\d[\d.]*/i);
|
|
1009
1224
|
let insertIdx;
|
|
1010
1225
|
if (nextPhaseMatch) {
|
|
1011
1226
|
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
|
|
@@ -1059,9 +1274,33 @@ function renameDecimalPhases(phasesDir, baseInt, removedDecimal) {
|
|
|
1059
1274
|
}
|
|
1060
1275
|
return { renamedDirs, renamedFiles };
|
|
1061
1276
|
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Find a free name to move an occupying file aside to, on collision, so the
|
|
1279
|
+
* intended rename can proceed without destroying either file. Appends the
|
|
1280
|
+
* literal `.orphaned` suffix to the whole existing filename (never `.md`,
|
|
1281
|
+
* so no phase-directory scan predicate — all of which filter on
|
|
1282
|
+
* `.endsWith('.md')` / `.endsWith('-VERIFICATION.md')` etc — can ever pick
|
|
1283
|
+
* the displaced file back up as any phase's artifact). Falls back to a
|
|
1284
|
+
* numeric discriminator (`.orphaned.2`, `.orphaned.3`, ...) if `.orphaned`
|
|
1285
|
+
* itself is taken, bounded at 100 attempts so a pathological directory
|
|
1286
|
+
* cannot loop forever; returns null if no free name is found within that
|
|
1287
|
+
* bound, letting the caller fall back to skip-and-report.
|
|
1288
|
+
*/
|
|
1289
|
+
function findOrphanedDisplacementName(dir, fileName) {
|
|
1290
|
+
const base = `${fileName}.orphaned`;
|
|
1291
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, base)))
|
|
1292
|
+
return base;
|
|
1293
|
+
for (let n = 2; n <= 100; n++) {
|
|
1294
|
+
const candidate = `${base}.${n}`;
|
|
1295
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(dir, candidate)))
|
|
1296
|
+
return candidate;
|
|
1297
|
+
}
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1062
1300
|
function renameIntegerPhases(phasesDir, removedInt) {
|
|
1063
1301
|
const renamedDirs = [];
|
|
1064
1302
|
const renamedFiles = [];
|
|
1303
|
+
const renamedFileCollisions = [];
|
|
1065
1304
|
const dirs = readSubdirectories(phasesDir, true);
|
|
1066
1305
|
const toRename = dirs
|
|
1067
1306
|
.map((dir) => {
|
|
@@ -1069,7 +1308,8 @@ function renameIntegerPhases(phasesDir, removedInt) {
|
|
|
1069
1308
|
if (!m)
|
|
1070
1309
|
return null;
|
|
1071
1310
|
const dirInt = parseInt(m[1], 10);
|
|
1072
|
-
|
|
1311
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1312
|
+
return dirInt > removedInt && !isSentinelPhaseId(dirInt)
|
|
1073
1313
|
? {
|
|
1074
1314
|
dir,
|
|
1075
1315
|
oldInt: dirInt,
|
|
@@ -1090,21 +1330,77 @@ function renameIntegerPhases(phasesDir, removedInt) {
|
|
|
1090
1330
|
const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`;
|
|
1091
1331
|
const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`;
|
|
1092
1332
|
const newDirName = `${newPrefix}-${item.slug}`;
|
|
1333
|
+
// WARNING-3 (#3511 review): the directory match above accepts an
|
|
1334
|
+
// UNPADDED leading number (`\d+`), so a supported rename can pair a
|
|
1335
|
+
// 2-padded dir with an unpadded-numbered artifact — dir `9-slug` holding
|
|
1336
|
+
// `9-VERIFICATION.md`. Renaming files by `f.startsWith(oldPrefix)` alone
|
|
1337
|
+
// (oldPrefix always 2-padded) misses that file: it becomes desynced from
|
|
1338
|
+
// its now-renamed directory and the phase reads `missing`. Try the
|
|
1339
|
+
// UNPADDED old-prefix form as a fallback so such an artifact renames
|
|
1340
|
+
// alongside its directory. A trailing-digit boundary check keeps the
|
|
1341
|
+
// unpadded form from over-matching a DIFFERENT phase's file (unpadded
|
|
1342
|
+
// prefix "1" must not match "10-…").
|
|
1343
|
+
const oldPrefixUnpadded = `${item.oldInt}${letterSuffix}${decimalSuffix}`;
|
|
1093
1344
|
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, item.dir), node_path_1.default.join(phasesDir, newDirName));
|
|
1094
1345
|
renamedDirs.push({ from: item.dir, to: newDirName });
|
|
1095
1346
|
for (const f of node_fs_1.default.readdirSync(node_path_1.default.join(phasesDir, newDirName))) {
|
|
1347
|
+
let matchedPrefix = null;
|
|
1096
1348
|
if (f.startsWith(oldPrefix)) {
|
|
1097
|
-
|
|
1098
|
-
|
|
1349
|
+
matchedPrefix = oldPrefix;
|
|
1350
|
+
}
|
|
1351
|
+
else if (oldPrefixUnpadded !== oldPrefix &&
|
|
1352
|
+
f.startsWith(oldPrefixUnpadded) &&
|
|
1353
|
+
// Token-boundary check: the character immediately after the unpadded
|
|
1354
|
+
// prefix must be a separator (`-`, `.`) or end-of-name, not any
|
|
1355
|
+
// non-digit. A bare `!/^\d/` test (prior form) let a LETTER through
|
|
1356
|
+
// too, so unpadded prefix "2" wrongly matched "2FA-notes.md" (a
|
|
1357
|
+
// wholly unrelated file whose name merely starts with the digit).
|
|
1358
|
+
(f.length === oldPrefixUnpadded.length || /^[-.]/.test(f.slice(oldPrefixUnpadded.length)))) {
|
|
1359
|
+
matchedPrefix = oldPrefixUnpadded;
|
|
1360
|
+
}
|
|
1361
|
+
if (matchedPrefix) {
|
|
1362
|
+
const newFileName = newPrefix + f.slice(matchedPrefix.length);
|
|
1363
|
+
const destPath = node_path_1.default.join(phasesDir, newDirName, newFileName);
|
|
1364
|
+
// Collision guard: the padded and unpadded prefix forms can both
|
|
1365
|
+
// resolve to the SAME destination (e.g. `09-VERIFICATION.md` and
|
|
1366
|
+
// `9-VERIFICATION.md` in one directory both target
|
|
1367
|
+
// `08-VERIFICATION.md`), and a stray cross-phase file can already sit
|
|
1368
|
+
// at the destination name (e.g. a leftover `08-VERIFICATION.md`
|
|
1369
|
+
// belonging to a DIFFERENT phase, inside phase 9's directory).
|
|
1370
|
+
// Renaming blindly over an existing target silently destroys
|
|
1371
|
+
// whichever file loses; skipping the rename instead lets the stray
|
|
1372
|
+
// outrank the phase's own renamed artifact once it lands at the
|
|
1373
|
+
// canonical name. Neither is acceptable: move the OCCUPYING file
|
|
1374
|
+
// aside first (never overwrite, never skip the real rename), then
|
|
1375
|
+
// complete the intended rename so the phase's own artifact takes the
|
|
1376
|
+
// canonical name. This also handles a target that was already
|
|
1377
|
+
// claimed by an EARLIER file in this same pass, since that earlier
|
|
1378
|
+
// rename already created it on disk.
|
|
1379
|
+
if (node_fs_1.default.existsSync(destPath)) {
|
|
1380
|
+
const displacedName = findOrphanedDisplacementName(node_path_1.default.join(phasesDir, newDirName), newFileName);
|
|
1381
|
+
if (displacedName === null) {
|
|
1382
|
+
// No free displacement name within the bounded search — fall
|
|
1383
|
+
// back to skip-and-report rather than looping or overwriting.
|
|
1384
|
+
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: null });
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(destPath, node_path_1.default.join(phasesDir, newDirName, displacedName));
|
|
1388
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
|
|
1389
|
+
renamedFiles.push({ from: f, to: newFileName });
|
|
1390
|
+
renamedFileCollisions.push({ from: f, to: newFileName, displaced_to: displacedName });
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, newDirName, f), destPath);
|
|
1099
1394
|
renamedFiles.push({ from: f, to: newFileName });
|
|
1100
1395
|
}
|
|
1101
1396
|
}
|
|
1102
1397
|
}
|
|
1103
|
-
return { renamedDirs, renamedFiles };
|
|
1398
|
+
return { renamedDirs, renamedFiles, renamedFileCollisions };
|
|
1104
1399
|
}
|
|
1105
1400
|
function decrementRoadmapPhaseNumber(raw, removedInt) {
|
|
1106
1401
|
const num = parseInt(raw, 10);
|
|
1107
|
-
|
|
1402
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1403
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1108
1404
|
return raw;
|
|
1109
1405
|
return String(num - 1);
|
|
1110
1406
|
}
|
|
@@ -1113,13 +1409,15 @@ function decrementRoadmapPhaseToken(raw, removedInt) {
|
|
|
1113
1409
|
if (!match)
|
|
1114
1410
|
return raw;
|
|
1115
1411
|
const num = parseInt(match[1], 10);
|
|
1116
|
-
|
|
1412
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1413
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1117
1414
|
return raw;
|
|
1118
1415
|
return `${num - 1}${match[2] || ''}`;
|
|
1119
1416
|
}
|
|
1120
1417
|
function decrementRoadmapPaddedPhaseNumber(raw, removedInt) {
|
|
1121
1418
|
const num = parseInt(raw, 10);
|
|
1122
|
-
|
|
1419
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1420
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1123
1421
|
return raw;
|
|
1124
1422
|
return String(num - 1).padStart(raw.length, '0');
|
|
1125
1423
|
}
|
|
@@ -1160,7 +1458,15 @@ function findDataRowLine(sectionText, dataRowIndex) {
|
|
|
1160
1458
|
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
|
|
1161
1459
|
withPlanningLock(cwd, () => {
|
|
1162
1460
|
let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
1163
|
-
const escaped = escapeRegex(targetPhase);
|
|
1461
|
+
const escaped = (0, pattern_cjs_1.escapeRegex)(targetPhase);
|
|
1462
|
+
// #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
|
|
1463
|
+
// of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
|
|
1464
|
+
// user's remove query is usually unpadded (`1.1`) — and integer headings
|
|
1465
|
+
// legitimately appear both padded (`02`) and unpadded (`2`). A `0*` prefix
|
|
1466
|
+
// makes the token padding-insensitive in both directions without widening
|
|
1467
|
+
// to other ids: the token stays anchored between `Phase\s+`/line-start and
|
|
1468
|
+
// `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
|
|
1469
|
+
const padTolerant = `0*${escaped}`;
|
|
1164
1470
|
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
|
1165
1471
|
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
|
1166
1472
|
// (ADR-2143 §4 / markdown-sectionizer T7): it locates the target heading via
|
|
@@ -1172,9 +1478,9 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1172
1478
|
// such heading to stop at, so the lazy `[\s\S]*?` scan ran to EOF and swept
|
|
1173
1479
|
// away everything after it — including a trailing `## Progress` heading and
|
|
1174
1480
|
// its tracking table.
|
|
1175
|
-
const phaseHeadingRe = new RegExp(`^Phase\\s+${
|
|
1481
|
+
const phaseHeadingRe = new RegExp(`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
|
|
1176
1482
|
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+${
|
|
1483
|
+
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
|
|
1178
1484
|
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
|
1179
1485
|
// for a removed phase via deleteTableRow (ADR-2143 §7 row-removal sibling
|
|
1180
1486
|
// of updateTableCell). Scoped to the `## Progress` section — mirroring
|
|
@@ -1201,7 +1507,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1201
1507
|
const matchRemovedProgressRow = (row) => {
|
|
1202
1508
|
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
|
1203
1509
|
if (isDecimal) {
|
|
1204
|
-
return new RegExp(`^${
|
|
1510
|
+
return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
|
1205
1511
|
}
|
|
1206
1512
|
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
|
1207
1513
|
if (!leadingMatch || leadingMatch[2])
|
|
@@ -1216,7 +1522,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1216
1522
|
if (!isDecimal) {
|
|
1217
1523
|
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
|
1218
1524
|
// 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}`);
|
|
1525
|
+
content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\r\n]{0,200}\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
|
|
1220
1526
|
content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
|
|
1221
1527
|
// ORDINAL-RENUMBER — CELL EDIT (not row-deletion) — migrated onto
|
|
1222
1528
|
// updateTableCell (ADR-2143 §7, sibling of the deleteTableRow scoping
|
|
@@ -1278,7 +1584,8 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1278
1584
|
if (!m)
|
|
1279
1585
|
return false;
|
|
1280
1586
|
const num = parseInt(m[1], 10);
|
|
1281
|
-
|
|
1587
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
1588
|
+
if (!Number.isInteger(num) || num <= removedInt || isSentinelPhaseId(num))
|
|
1282
1589
|
return false;
|
|
1283
1590
|
processedOrdinalRows.add(index);
|
|
1284
1591
|
matchedRowIndex = index;
|
|
@@ -1291,7 +1598,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1291
1598
|
const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
|
|
1292
1599
|
const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
|
|
1293
1600
|
const padMatch = targetLine
|
|
1294
|
-
? new RegExp(`^[ \\t]*\\|(\\s*)${escapeRegex((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
1601
|
+
? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
|
|
1295
1602
|
: null;
|
|
1296
1603
|
const leadPad = padMatch ? padMatch[1] : ' ';
|
|
1297
1604
|
const trailPad = padMatch ? padMatch[2] : ' ';
|
|
@@ -1310,6 +1617,33 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
|
|
1310
1617
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, content);
|
|
1311
1618
|
});
|
|
1312
1619
|
}
|
|
1620
|
+
/**
|
|
1621
|
+
* #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
|
|
1622
|
+
* the leading frontmatter block's closing `---` fence — so a body field never
|
|
1623
|
+
* lands before the opening fence. The former whole-content prepend
|
|
1624
|
+
* (`field + content`) put the line ABOVE the opening `---`, and
|
|
1625
|
+
* syncStateFrontmatter then treated the scrambled fence structure as TWO
|
|
1626
|
+
* frontmatter blocks, rebuilding a derived one on top of the original
|
|
1627
|
+
* (milestone_name from a ROADMAP heading, total_phases counting the removed
|
|
1628
|
+
* phase, a stray 'Total Phases: 0' between fences). A file with no leading
|
|
1629
|
+
* frontmatter is all body: the field goes to content start, preserving the
|
|
1630
|
+
* former behavior for that shape.
|
|
1631
|
+
*/
|
|
1632
|
+
function insertStateBodyFieldAtTop(content, fieldLine) {
|
|
1633
|
+
// Split AND join on bare '\n' so CRLF line endings stay attached to their
|
|
1634
|
+
// own lines — each '\r' remains the tail of the line it terminated, where
|
|
1635
|
+
// the trimmed fence compare still matches it. (#3572 review: splitting on
|
|
1636
|
+
// '\n' but re-joining on a detected '\r\n' doubled every carriage return.)
|
|
1637
|
+
const lines = content.split('\n');
|
|
1638
|
+
if ((lines[0] ?? '').trim() === '---') {
|
|
1639
|
+
const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
1640
|
+
if (closeIdx !== -1) {
|
|
1641
|
+
lines.splice(closeIdx + 1, 0, '', fieldLine);
|
|
1642
|
+
return lines.join('\n');
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return fieldLine + '\n' + content;
|
|
1646
|
+
}
|
|
1313
1647
|
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
1314
1648
|
if (!targetPhase)
|
|
1315
1649
|
error('phase number required for phase remove');
|
|
@@ -1321,24 +1655,55 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1321
1655
|
const isDecimal = targetPhase.includes('.');
|
|
1322
1656
|
const force = options.force || false;
|
|
1323
1657
|
const subdirs = readSubdirectories(phasesDir, true);
|
|
1324
|
-
|
|
1658
|
+
// #2237/#2528: every other resolution path refuses to choose between multiple
|
|
1659
|
+
// directories claiming one phase number. This one is the DESTRUCTIVE path, so
|
|
1660
|
+
// taking `matches[0]` silently is strictly worse than anywhere else: it turns
|
|
1661
|
+
// "resolve nothing" into "delete one of two candidates, unrecoverably, and
|
|
1662
|
+
// renumber every phase after it". Refuse before any file is touched.
|
|
1663
|
+
const { matches: phaseDirMatches } = matchPhaseDirs(subdirs, normalized);
|
|
1664
|
+
if (phaseDirMatches.length > 1) {
|
|
1665
|
+
output({
|
|
1666
|
+
removed: null,
|
|
1667
|
+
error: `Phase ${normalized} is ambiguous: ${phaseDirMatches.length} directories match `
|
|
1668
|
+
+ `(${phaseDirMatches.map((m) => `"${m}"`).join(', ')}). Refusing to remove any of them. `
|
|
1669
|
+
+ 'Set a distinct project_code in .planning/config.json, or pass the full directory name.',
|
|
1670
|
+
ambiguous_matches: phaseDirMatches,
|
|
1671
|
+
directory_deleted: null,
|
|
1672
|
+
renamed_directories: [],
|
|
1673
|
+
renamed_files: [],
|
|
1674
|
+
roadmap_updated: false,
|
|
1675
|
+
state_updated: false,
|
|
1676
|
+
}, raw);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
const targetDir = phaseDirMatches[0] || null;
|
|
1325
1680
|
if (targetDir && !force) {
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1681
|
+
// #3183: canonical summary set (root+nested) from the single owner —
|
|
1682
|
+
// a root-only readdirSync filter left nested (#3139 layout) summaries
|
|
1683
|
+
// invisible, letting a phase with completed nested work be deleted
|
|
1684
|
+
// without --force.
|
|
1685
|
+
const summaryCount = scanPhasePlans(node_path_1.default.join(phasesDir, targetDir)).summaryFiles.length;
|
|
1686
|
+
if (summaryCount > 0) {
|
|
1687
|
+
error(`Phase ${targetPhase} has ${summaryCount} executed plan(s). Use --force to remove anyway.`);
|
|
1330
1688
|
}
|
|
1331
1689
|
}
|
|
1332
1690
|
if (targetDir)
|
|
1333
1691
|
node_fs_1.default.rmSync(node_path_1.default.join(phasesDir, targetDir), { recursive: true, force: true });
|
|
1334
1692
|
let renamedDirs = [];
|
|
1335
1693
|
let renamedFiles = [];
|
|
1694
|
+
let renamedFileCollisions = [];
|
|
1336
1695
|
try {
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1696
|
+
if (isDecimal) {
|
|
1697
|
+
const renamed = renameDecimalPhases(phasesDir, parseInt(normalized.split('.')[0], 10), parseInt(normalized.split('.')[1], 10));
|
|
1698
|
+
renamedDirs = renamed.renamedDirs;
|
|
1699
|
+
renamedFiles = renamed.renamedFiles;
|
|
1700
|
+
}
|
|
1701
|
+
else {
|
|
1702
|
+
const renamed = renameIntegerPhases(phasesDir, parseInt(normalized, 10));
|
|
1703
|
+
renamedDirs = renamed.renamedDirs;
|
|
1704
|
+
renamedFiles = renamed.renamedFiles;
|
|
1705
|
+
renamedFileCollisions = renamed.renamedFileCollisions;
|
|
1706
|
+
}
|
|
1342
1707
|
}
|
|
1343
1708
|
catch (e) {
|
|
1344
1709
|
// #2245 audit (was ERROR-HIDING): renameDecimalPhases/renameIntegerPhases
|
|
@@ -1367,13 +1732,15 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1367
1732
|
let modified = stateContent;
|
|
1368
1733
|
const totalRaw = stateExtractField(modified, 'Total Phases');
|
|
1369
1734
|
if (totalRaw) {
|
|
1735
|
+
// #3572 review: clamp at 0 — a stale 'Total Phases: 0' (e.g. written by
|
|
1736
|
+
// an earlier remove whose dir-count was 0) must not decrement to -1 on
|
|
1737
|
+
// the next removal.
|
|
1370
1738
|
modified =
|
|
1371
|
-
stateReplaceField(modified, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
|
|
1372
|
-
modified;
|
|
1739
|
+
stateReplaceField(modified, 'Total Phases', String(Math.max(0, parseInt(totalRaw, 10) - 1))) || modified;
|
|
1373
1740
|
}
|
|
1374
1741
|
const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
|
|
1375
1742
|
if (ofMatch) {
|
|
1376
|
-
modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${parseInt(ofMatch[2], 10) - 1}$3`);
|
|
1743
|
+
modified = modified.replace(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, `$1${Math.max(0, parseInt(ofMatch[2], 10) - 1)}$3`);
|
|
1377
1744
|
}
|
|
1378
1745
|
// #2640: if neither body field was found, the transform is a no-op.
|
|
1379
1746
|
// readModifyWriteStateMd's no-op guard (#948) would then skip the
|
|
@@ -1386,16 +1753,33 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1386
1753
|
if (targetDir && modified === stateContent) {
|
|
1387
1754
|
// subdirs was read before the deletion; excluding the removed target
|
|
1388
1755
|
// gives the remaining count. Renumbering changes names but not count.
|
|
1389
|
-
|
|
1756
|
+
//
|
|
1757
|
+
// #2528: exclude the directory that was ACTUALLY deleted, by identity,
|
|
1758
|
+
// rather than re-deriving "which dir was the target" from the query.
|
|
1759
|
+
// The two are not the same predicate here: `targetDir` comes from
|
|
1760
|
+
// `matchPhaseDirs`, whose bare-integer fallback resolves digit-leading
|
|
1761
|
+
// dirs (`05-80-20-cleanup` for query `5`) that `phaseTokenMatches`
|
|
1762
|
+
// reports as non-matching — so a token re-derivation would count the
|
|
1763
|
+
// just-deleted directory as still present and write a `Total Phases`
|
|
1764
|
+
// one too high. Identity is also what the comment above already
|
|
1765
|
+
// claims this filter does, and the block is gated on targetDir.
|
|
1766
|
+
// (#3572 note: this body field counts DIRECTORIES on disk; the
|
|
1767
|
+
// frontmatter progress.* block is rebuilt by syncStateFrontmatter
|
|
1768
|
+
// from the post-removal ROADMAP — the two counts legitimately differ
|
|
1769
|
+
// when phases exist in ROADMAP without directories.)
|
|
1770
|
+
const remainingPhases = Math.max(0, subdirs.filter((d) => d !== targetDir).length);
|
|
1390
1771
|
if (totalRaw) {
|
|
1391
1772
|
modified =
|
|
1392
1773
|
stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
|
|
1393
1774
|
}
|
|
1394
1775
|
else {
|
|
1395
|
-
// No 'Total Phases:' field in the body —
|
|
1396
|
-
// guard sees a diff.
|
|
1397
|
-
//
|
|
1398
|
-
|
|
1776
|
+
// No 'Total Phases:' field in the body — insert one at the start of
|
|
1777
|
+
// the BODY so the no-op guard sees a diff. #3572: the former
|
|
1778
|
+
// whole-content prepend landed the line BEFORE the opening '---'
|
|
1779
|
+
// fence and corrupted STATE.md into two frontmatter blocks.
|
|
1780
|
+
// syncStateFrontmatter will still rebuild the frontmatter
|
|
1781
|
+
// progress.* block from the real disk/ROADMAP count.
|
|
1782
|
+
modified = insertStateBodyFieldAtTop(modified, `Total Phases: ${remainingPhases}`);
|
|
1399
1783
|
}
|
|
1400
1784
|
}
|
|
1401
1785
|
return modified;
|
|
@@ -1406,6 +1790,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
1406
1790
|
directory_deleted: targetDir,
|
|
1407
1791
|
renamed_directories: renamedDirs,
|
|
1408
1792
|
renamed_files: renamedFiles,
|
|
1793
|
+
renamed_file_collisions: renamedFileCollisions,
|
|
1409
1794
|
roadmap_updated: true,
|
|
1410
1795
|
state_updated: stateUpdated,
|
|
1411
1796
|
}, raw);
|
|
@@ -1467,11 +1852,27 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1467
1852
|
// init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
|
|
1468
1853
|
// explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
|
|
1469
1854
|
const availableWorkstreams = listAvailableWorkstreams(cwd);
|
|
1470
|
-
|
|
1855
|
+
// #3579 root-cause fix: this is a check, not a consuming read — use the
|
|
1856
|
+
// non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
|
|
1857
|
+
// here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
|
|
1858
|
+
// which would misreport a present-but-bad marker as no marker at all.
|
|
1859
|
+
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
|
|
1471
1860
|
if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
|
|
1861
|
+
// #3579: getActiveWorkstream now inherits a pointer-less session's read
|
|
1862
|
+
// from the shared .planning/active-workstream marker, so reaching this
|
|
1863
|
+
// branch with a marker actually present means the marker EXISTED but
|
|
1864
|
+
// didn't resolve (invalid name, or its workstream dir is gone) — a
|
|
1865
|
+
// materially different situation from "nothing was ever set" and one
|
|
1866
|
+
// that deserves its own diagnostic instead of the generic message below.
|
|
1867
|
+
const diagnosis = diagnoseUnresolvedActiveWorkstream(cwd);
|
|
1868
|
+
if (diagnosis.present) {
|
|
1869
|
+
error(`phase.complete requires a workstream in workstream mode — the active-workstream marker names '${diagnosis.value}', but it did not resolve: ${describeUnresolvedWorkstreamReason(diagnosis.reason)}. Root STATE.md/ROADMAP.md (likely stale) would be written otherwise. ` +
|
|
1870
|
+
`Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} to point it at an existing workstream. ` +
|
|
1871
|
+
`Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_MARKER_UNRESOLVED, { marker_value: diagnosis.value, marker_reason: diagnosis.reason });
|
|
1872
|
+
}
|
|
1472
1873
|
error(`phase.complete requires a workstream in workstream mode — no active workstream is set, so root STATE.md/ROADMAP.md (likely stale) would be written. ` +
|
|
1473
1874
|
`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(', ')}
|
|
1875
|
+
`Available workstreams: ${availableWorkstreams.join(', ')}`, ERROR_REASON.WORKSTREAM_MODE_NONE_ACTIVE);
|
|
1475
1876
|
}
|
|
1476
1877
|
const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
|
|
1477
1878
|
const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
|
|
@@ -1490,6 +1891,18 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1490
1891
|
: 0;
|
|
1491
1892
|
let requirementsUpdated = false;
|
|
1492
1893
|
const warnings = [];
|
|
1894
|
+
// ADR-3408 §8.5 / D2 (#3374): "liberal but visible" — when the write-seam
|
|
1895
|
+
// composition's preservation stage restores a curated frontmatter value
|
|
1896
|
+
// over a disagreeing derived one, that divergence is surfaced here rather
|
|
1897
|
+
// than silently absorbed. Structured (field + reason), not prose, so a
|
|
1898
|
+
// caller can assert on the value rather than regex a rendered message.
|
|
1899
|
+
// Named `preservation_warnings`, NOT `warnings`: `warnings` above is
|
|
1900
|
+
// already a prose `string[]` on this exact command — reusing it for a
|
|
1901
|
+
// structured `{field, reason}[]` shape would be the "Generative Fix
|
|
1902
|
+
// Divergence" anti-pattern (two sibling fields, same name, different
|
|
1903
|
+
// element types). Mirrors `cmdMilestoneComplete`'s identical field
|
|
1904
|
+
// (milestone.cts).
|
|
1905
|
+
const preservationWarnings = [];
|
|
1493
1906
|
// #3057 B3: mirrors `verification_stale_check_indeterminate` on init.cts /
|
|
1494
1907
|
// roadmap.cts / uat-predicate.cts's outputs — set on the non-blocking path
|
|
1495
1908
|
// below (inside withPlanningLock) alongside the warnings[] entry, so a
|
|
@@ -1564,7 +1977,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1564
1977
|
}
|
|
1565
1978
|
try {
|
|
1566
1979
|
const phaseFiles = node_fs_1.default.readdirSync(phaseFullDir);
|
|
1567
|
-
|
|
1980
|
+
// #3511: scope this advisory pre-scan to THIS phase's own token so a
|
|
1981
|
+
// stray, cross-phase, or ad-hoc file cannot name a warning against a
|
|
1982
|
+
// phase it does not belong to.
|
|
1983
|
+
const phaseFullDirBaseName = node_path_1.default.basename(phaseFullDir);
|
|
1984
|
+
for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseFullDirBaseName)) {
|
|
1568
1985
|
const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
|
|
1569
1986
|
if (/result: pending/.test(content))
|
|
1570
1987
|
warnings.push(`${file}: has pending tests`);
|
|
@@ -1575,7 +1992,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1575
1992
|
if (/status: diagnosed/.test(content))
|
|
1576
1993
|
warnings.push(`${file}: has diagnosed gaps`);
|
|
1577
1994
|
}
|
|
1578
|
-
for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
|
1995
|
+
for (const file of scopeToPhase(phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md')), phaseFullDirBaseName)) {
|
|
1579
1996
|
const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
|
|
1580
1997
|
const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
|
|
1581
1998
|
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
|
|
@@ -1639,7 +2056,25 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1639
2056
|
let nextPhaseNum = null;
|
|
1640
2057
|
let nextPhaseName = null;
|
|
1641
2058
|
let isLastPhase = true;
|
|
2059
|
+
// #3311: typed conflict descriptor surfaced on the result JSON alongside the
|
|
2060
|
+
// warnings[] entry below (same parity pattern as
|
|
2061
|
+
// verification_stale_check_indeterminate).
|
|
2062
|
+
let milestoneConflict = null;
|
|
1642
2063
|
const verificationBlocked = withPlanningLock(cwd, () => {
|
|
2064
|
+
// #3311: completing a phase while a live milestone claim (phase + session)
|
|
2065
|
+
// holds a DIFFERENT phase means two sessions are working two phases against
|
|
2066
|
+
// the single Current Position slot. Warn via the established warnings[]
|
|
2067
|
+
// channel (rendered by execute-phase.md's "If has_warnings is true" step)
|
|
2068
|
+
// rather than blocking — the claim may simply be stale-but-live.
|
|
2069
|
+
milestoneConflict = milestoneLockMod.checkMilestoneConflictForPhase(cwd, phaseNum);
|
|
2070
|
+
if (milestoneConflict) {
|
|
2071
|
+
const holder = milestoneConflict.locked_session ?? 'an unknown (headless) session';
|
|
2072
|
+
const actor = milestoneConflict.session ?? 'an unknown (headless) session';
|
|
2073
|
+
warnings.push(`milestone lock conflict (#3311): ${holder} holds the milestone claim for phase ` +
|
|
2074
|
+
`${milestoneConflict.locked_phase}, but ${actor} is completing phase ${phaseNum} — ` +
|
|
2075
|
+
`STATE.md's Current Position is a single slot; verify it before trusting it`);
|
|
2076
|
+
milestoneLockMod.warnMilestoneConflict(milestoneConflict, `phase.complete ${phaseNum}`);
|
|
2077
|
+
}
|
|
1643
2078
|
// #2617: pass the project's runtime so the blocked-completion error below
|
|
1644
2079
|
// suggests the command surface this runtime actually installs
|
|
1645
2080
|
// ($gsd-… on Codex) rather than a hard-coded Claude-style string.
|
|
@@ -1786,7 +2221,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1786
2221
|
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
|
1787
2222
|
if (!planId)
|
|
1788
2223
|
continue;
|
|
1789
|
-
const planEscaped = escapeRegex(planId);
|
|
2224
|
+
const planEscaped = (0, pattern_cjs_1.escapeRegex)(planId);
|
|
1790
2225
|
const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
|
|
1791
2226
|
b = b.replace(planCheckboxPattern, '$1x$2');
|
|
1792
2227
|
}
|
|
@@ -1862,7 +2297,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1862
2297
|
.filter(Boolean)
|
|
1863
2298
|
.filter((r) => REQ_ID_SHAPE_RE.test(r));
|
|
1864
2299
|
for (const reqId of citedReqIds) {
|
|
1865
|
-
const reqEscaped = escapeRegex(reqId);
|
|
2300
|
+
const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
|
|
1866
2301
|
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**.
|
|
1867
2302
|
// #2945: the flip is CONDITIONAL (porting #2788 defect-2's rollback from
|
|
1868
2303
|
// cmdRequirementsMarkComplete). Capture the pre-flip content; if a
|
|
@@ -2069,7 +2504,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2069
2504
|
// row actually matched is registered — not a ghost — regardless of
|
|
2070
2505
|
// which section (deferred or not) it lives under.
|
|
2071
2506
|
const reqIsRegisteredAnywhere = (id) => {
|
|
2072
|
-
const reqEscaped = escapeRegex(id);
|
|
2507
|
+
const reqEscaped = (0, pattern_cjs_1.escapeRegex)(id);
|
|
2073
2508
|
// Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
|
|
2074
2509
|
// insensitive: existence check, not the write's space-only match.
|
|
2075
2510
|
if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
|
|
@@ -2106,17 +2541,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2106
2541
|
}
|
|
2107
2542
|
}
|
|
2108
2543
|
try {
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2544
|
+
// #3185 (ADR-3180 Decision 1): "which phase directories belong to
|
|
2545
|
+
// the CURRENT milestone" — routed through the canonical owner
|
|
2546
|
+
// instead of a hand-rolled readdirSync + isDirInMilestone filter
|
|
2547
|
+
// (which also never excluded sentinels on its own, unlike the
|
|
2548
|
+
// owner; the per-directory isSentinelPhaseId check below stays as a
|
|
2549
|
+
// defensive second check against the REGEX-EXTRACTED token, which
|
|
2550
|
+
// is not necessarily identical to the raw directory name).
|
|
2551
|
+
const dirs = listMilestonePhaseDirs(phasesDir, { cwd }).value;
|
|
2116
2552
|
for (const dir of dirs) {
|
|
2117
2553
|
const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
|
2118
2554
|
if (dm) {
|
|
2119
|
-
|
|
2555
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
|
|
2556
|
+
if (isSentinelPhaseId(dm[1]))
|
|
2120
2557
|
continue;
|
|
2121
2558
|
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
|
2122
2559
|
nextPhaseNum = dm[1];
|
|
@@ -2158,9 +2595,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2158
2595
|
let pm;
|
|
2159
2596
|
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
|
2160
2597
|
// #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.
|
|
2598
|
+
// already skips sentinel dirs on disk via isSentinelPhaseId (#3185);
|
|
2599
|
+
// stage 2's heading scan must not advance into backlog headings either.
|
|
2164
2600
|
if (isSentinelPhaseId(pm[1]))
|
|
2165
2601
|
continue;
|
|
2166
2602
|
if (comparePhaseNum(pm[1], phaseNum) > 0) {
|
|
@@ -2196,7 +2632,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2196
2632
|
// pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
|
|
2197
2633
|
// between the box and "Phase", a required `:`) so unrelated checklist lines
|
|
2198
2634
|
// that merely mention "Phase N" don't match.
|
|
2199
|
-
|
|
2635
|
+
// #3350: this stage answers a DIFFERENT question than stages 1-2 ("what is
|
|
2636
|
+
// the next actionable phase?" vs "is this the last phase?"), so it must not
|
|
2637
|
+
// be gated on their answer. Gating on isLastPhase let a merely-positionally
|
|
2638
|
+
// next higher heading (stage 2) permanently mask a genuinely-outstanding
|
|
2639
|
+
// lower phase — stage 2 cleared isLastPhase and this scan never ran. The
|
|
2640
|
+
// scan already refuses anything not strictly lower than the completed phase
|
|
2641
|
+
// (plus sentinels, #2949), so running it unconditionally cannot manufacture
|
|
2642
|
+
// a wrong answer: when no lower phase is outstanding it finds nothing and
|
|
2643
|
+
// stages 1-2's pick stands unchanged; in the masking case isLastPhase is
|
|
2644
|
+
// already false, so the last-phase signal has no reachable regression.
|
|
2645
|
+
if (roadmapContent !== null) {
|
|
2200
2646
|
try {
|
|
2201
2647
|
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
|
|
2202
2648
|
const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
|
|
@@ -2243,11 +2689,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2243
2689
|
// to the STATE.md Transition Module. The ~90-line inline RMW callback
|
|
2244
2690
|
// that lived here is the pure `completePhaseCore` in
|
|
2245
2691
|
// src/state-transition.cts, backed by the field-classification table.
|
|
2246
|
-
// `updatePerformanceMetricsSection`
|
|
2247
|
-
//
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
// because STATE.md is
|
|
2692
|
+
// `updatePerformanceMetricsSection` stays in this adapter: it is a
|
|
2693
|
+
// section-table / disk-scan concern, not a classified field. The
|
|
2694
|
+
// sync + post-sync preservation this transaction needs runs via the
|
|
2695
|
+
// single write-seam composition, `syncAndPreserveStateMd` (it does
|
|
2696
|
+
// NOT go through readModifyWriteStateMd because STATE.md is
|
|
2697
|
+
// committed atomically with ROADMAP/REQUIREMENTS, ADR-3408 §8.3 /
|
|
2698
|
+
// #3374 / #3469).
|
|
2251
2699
|
const nextPhaseDisplayName = phaseDisplayNameFromRoadmap(roadmapContent, nextPhaseNum) ??
|
|
2252
2700
|
phaseDisplayNameFromSlug(nextPhaseName);
|
|
2253
2701
|
const completeResult = (0, state_transition_cjs_1.transitionCore)(stateContent, {
|
|
@@ -2269,7 +2717,55 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2269
2717
|
// the intent; pass it as authoritative so the sync's prose
|
|
2270
2718
|
// re-derivation cannot rewrite current_phase_name to the name's own
|
|
2271
2719
|
// parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
|
|
2272
|
-
|
|
2720
|
+
// #3350: PAIR the override. When STATE.md's body carries no Current
|
|
2721
|
+
// Phase / Phase field to re-derive from (narrative prose), the #905
|
|
2722
|
+
// preserve guard in syncStateFrontmatter keeps the OLD frontmatter
|
|
2723
|
+
// current_phase while the authoritative current_phase_name advances —
|
|
2724
|
+
// leaving the two fields describing different phases. Pin BOTH to the
|
|
2725
|
+
// resolved next phase in that case. When the body DOES carry the field
|
|
2726
|
+
// (completePhaseCore just rewrote it), stay name-only so the body's
|
|
2727
|
+
// richer `N of T (name)` derived shape survives the sync.
|
|
2728
|
+
const fmBody = frontmatterMod.stripFrontmatter(stateContent);
|
|
2729
|
+
const bodyHasPhaseField = stateExtractField(fmBody, 'Current Phase') != null ||
|
|
2730
|
+
stateExtractField(fmBody, 'Phase') != null;
|
|
2731
|
+
const authoritativeFm = nextPhaseDisplayName
|
|
2732
|
+
? bodyHasPhaseField || !nextPhaseNum
|
|
2733
|
+
? { current_phase_name: nextPhaseDisplayName }
|
|
2734
|
+
: {
|
|
2735
|
+
current_phase: String(nextPhaseNum),
|
|
2736
|
+
current_phase_name: nextPhaseDisplayName,
|
|
2737
|
+
}
|
|
2738
|
+
: undefined;
|
|
2739
|
+
// ADR-3408 §8.3 / #3469: this deliberately bypasses
|
|
2740
|
+
// readModifyWriteStateMd (STATE.md is committed atomically with
|
|
2741
|
+
// ROADMAP/REQUIREMENTS), so it calls the single write-seam
|
|
2742
|
+
// composition (`syncAndPreserveStateMd`) directly instead of
|
|
2743
|
+
// assembling `syncStateFrontmatter` + `applyPostSyncPreservation`
|
|
2744
|
+
// itself — a call site re-assembling the pair, even with every step
|
|
2745
|
+
// calling an owner, is the exact re-derivation §8.3 forbids by name
|
|
2746
|
+
// (Phase 2 found this shape live here). The composition runs
|
|
2747
|
+
// snapshots from the on-disk pre-image (originalStateContent) and
|
|
2748
|
+
// the transformed content, table-driven applyStatePreservation, then
|
|
2749
|
+
// the #2736 authoritative re-assert (which restores the #3350
|
|
2750
|
+
// pairing override the preserve-always restore may have reverted).
|
|
2751
|
+
// resync=true is the lifecycle-transition posture (progress
|
|
2752
|
+
// recomputed from disk; only the preserve-when-unchanged deltas
|
|
2753
|
+
// apply). Fields the transition legitimately rewrote (Status, Phase,
|
|
2754
|
+
// Stopped At via completePhaseCore's #3374 continuity line) have
|
|
2755
|
+
// changed body sources, so their deltas do not fire.
|
|
2756
|
+
// ADR-3408 §8.5 / D2 (#3374): thread `divergedFields` through so this
|
|
2757
|
+
// command reports what it preserved, following `cmdMilestoneComplete`'s
|
|
2758
|
+
// shape (milestone.cts) — the same composition, the same out-param,
|
|
2759
|
+
// the same visibility contract.
|
|
2760
|
+
const divergedFields = [];
|
|
2761
|
+
stateContent = syncAndPreserveStateMd(originalStateContent, stateContent, statePath, cwd, {
|
|
2762
|
+
resync: true,
|
|
2763
|
+
authoritativeFm,
|
|
2764
|
+
divergedFields,
|
|
2765
|
+
});
|
|
2766
|
+
for (const field of divergedFields) {
|
|
2767
|
+
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
|
2768
|
+
}
|
|
2273
2769
|
writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
|
|
2274
2770
|
}
|
|
2275
2771
|
writePlanningFileSet(writes);
|
|
@@ -2280,6 +2776,11 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2280
2776
|
else {
|
|
2281
2777
|
runPhaseCompleteTransaction();
|
|
2282
2778
|
}
|
|
2779
|
+
// #3311: a successful completion of the CLAIMED phase releases the
|
|
2780
|
+
// milestone claim — regardless of which session completes it (an
|
|
2781
|
+
// orchestrator cleaning up after a dead session must not be blocked by the
|
|
2782
|
+
// dead session's own claim). No-ops when the claim names another phase.
|
|
2783
|
+
milestoneLockMod.releaseMilestonePhase(cwd, phaseNum);
|
|
2283
2784
|
return null;
|
|
2284
2785
|
});
|
|
2285
2786
|
if (verificationBlocked) {
|
|
@@ -2332,6 +2833,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
2332
2833
|
warnings,
|
|
2333
2834
|
has_warnings: warnings.length > 0,
|
|
2334
2835
|
verification_stale_check_indeterminate: staleCheckIndeterminate,
|
|
2836
|
+
milestone_conflict: milestoneConflict,
|
|
2837
|
+
preservation_warnings: preservationWarnings,
|
|
2335
2838
|
};
|
|
2336
2839
|
output(result, raw);
|
|
2337
2840
|
}
|
|
@@ -2353,7 +2856,7 @@ function cmdPhaseUatPassed(cwd, phaseNum, raw, opts = {}) {
|
|
|
2353
2856
|
// paths without re-discovering the phase directory themselves.
|
|
2354
2857
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
|
2355
2858
|
const planScanMod = require("./plan-scan.cjs");
|
|
2356
|
-
const { scanPhasePlans } = planScanMod;
|
|
2859
|
+
const { scanPhasePlans, isCanonicalPlanFile } = planScanMod;
|
|
2357
2860
|
function cmdPhaseListPlans(cwd, phaseNum, raw) {
|
|
2358
2861
|
if (!phaseNum) {
|
|
2359
2862
|
error('phase number required for phase list-plans');
|