@opengsd/gsd-core 1.9.1 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +27 -3
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +12 -10
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +46 -0
- package/agents/gsd-planner.md +49 -54
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +26 -73
- package/bin/install.js +1272 -1238
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +2 -2
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/plan-phase.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1009 -115
- package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
- package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
- package/gsd-core/bin/lib/api-coverage.cjs +123 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +926 -202
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +608 -148
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +507 -24
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +114 -38
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +94 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +665 -99
- package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +76 -0
- package/gsd-core/bin/lib/config.cjs +22 -2
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +217 -40
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +239 -32
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-integration.cjs +13 -1
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1325 -169
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +805 -264
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +160 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
- package/gsd-core/bin/lib/io.cjs +38 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +821 -109
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +507 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +258 -58
- package/gsd-core/bin/lib/phase.cjs +891 -156
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +60 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +405 -84
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
- package/gsd-core/bin/lib/smart-entry.cjs +154 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +424 -105
- package/gsd-core/bin/lib/state.cjs +1927 -401
- package/gsd-core/bin/lib/surface.cjs +35 -10
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +20 -4
- package/gsd-core/bin/lib/uat.cjs +706 -64
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +33 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -20
- package/gsd-core/bin/lib/verify.cjs +368 -880
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
- package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
- package/gsd-core/bin/lib/workstream.cjs +8 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/debugger-techniques.md +255 -0
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/research-documentation-lookup.md +5 -3
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +8 -7
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/references/worktree-branch-check.md +2 -2
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +33 -70
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +74 -166
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +160 -95
- package/gsd-core/workflows/debug.md +16 -17
- package/gsd-core/workflows/diagnose-issues.md +56 -8
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +64 -7
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +103 -187
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/help/modes/full.md +6 -1
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +7 -6
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +35 -47
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +27 -240
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +89 -209
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +45 -159
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +55 -405
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +41 -13
- package/gsd-core/workflows/section-manifest.json +219 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +41 -20
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +106 -13
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +53 -31
- package/gsd-core/workflows/ui-phase.md +13 -12
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +19 -8
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +17 -65
- package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
- package/hooks/dist/gsd-check-update-worker.js +64 -12
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +607 -26
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +45 -24
- package/hooks/dist/gsd-statusline.js +90 -6
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +277 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +517 -0
- package/hooks/gsd-check-update-worker.js +64 -12
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +607 -26
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +45 -24
- package/hooks/gsd-statusline.js +90 -6
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +277 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +31 -10
- package/pi/gsd.cjs +71 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +68 -6
- package/scripts/changeset/serialize.cjs +5 -1
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-matrix.cjs +1 -1
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +439 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +150 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +15 -54
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-compiled-artifact-sync.cjs +6 -1
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-example-parser-parity.cjs +395 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +40 -3
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +52 -6
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +2 -2
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +2 -2
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +1 -2
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -577
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/gen-emitted-baseline.cjs +0 -145
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1050
|
@@ -23,21 +23,41 @@ const clock_cjs_1 = require("./clock.cjs");
|
|
|
23
23
|
const state_transition_cjs_1 = require("./state-transition.cjs");
|
|
24
24
|
const write_set_cjs_1 = require("./write-set.cjs");
|
|
25
25
|
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
26
|
+
const security_cjs_1 = require("./security.cjs");
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- audit.cjs is an export= CommonJS module
|
|
28
|
+
const auditMod = require("./audit.cjs");
|
|
29
|
+
const { resolveQuickTaskSummaryFile } = auditMod;
|
|
26
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
31
|
const ioMod = require("./io.cjs");
|
|
28
32
|
const { output, error } = ioMod;
|
|
29
33
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
30
34
|
const phaseIdMod = require("./phase-id.cjs");
|
|
31
|
-
const {
|
|
35
|
+
const { normalizePhaseName, matchPhaseDirs, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
|
|
36
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
32
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
38
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
34
|
-
const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
|
|
39
|
+
const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo, sliceMilestoneWindow, } = roadmapParserMod;
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const planningScopeMod = require("./planning-scope.cjs");
|
|
42
|
+
const { SCOPE } = planningScopeMod;
|
|
35
43
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
44
|
const coreUtilsMod = require("./core-utils.cjs");
|
|
37
|
-
const { extractOneLinerFromBody } = coreUtilsMod;
|
|
45
|
+
const { extractOneLinerFromBody, countMatchedSummaries } = coreUtilsMod;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
|
47
|
+
const planScanMod = require("./plan-scan.cjs");
|
|
48
|
+
const { scanPhasePlans } = planScanMod;
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
|
50
|
+
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
51
|
+
const { listMilestonePhaseDirs } = phaseLocatorMod;
|
|
38
52
|
const { planningPaths } = planningWorkspace;
|
|
39
53
|
const { extractFrontmatter } = frontmatterMod;
|
|
40
|
-
|
|
54
|
+
// ADR-3408 §8.3 / #3469: `writeStateMd` gets sync and NO preservation — the
|
|
55
|
+
// same #3374-shaped exposure the milestone-complete write used to carry (a
|
|
56
|
+
// stale body value silently clobbering fresher frontmatter, with no
|
|
57
|
+
// divergence signal). Routed through the single write-seam composition
|
|
58
|
+
// (`syncAndPreserveStateMd`) instead, under `withStateLock` — see
|
|
59
|
+
// `cmdMilestoneComplete`'s own STATE.md-update block for the full rationale.
|
|
60
|
+
const { syncAndPreserveStateMd, withStateLock, readModifyWriteStateMd } = stateMod;
|
|
41
61
|
// #2288 security: a milestone version label becomes a filesystem directory
|
|
42
62
|
// component (`milestones/<label>-phases/`) into which phase directories are
|
|
43
63
|
// MOVED. Any label used as a path segment must be a safe version token —
|
|
@@ -131,7 +151,7 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
|
|
|
131
151
|
// table_unmatched; this only adds the structured ADR-2143 shape on top).
|
|
132
152
|
const writeSet = [];
|
|
133
153
|
for (const reqId of reqIds) {
|
|
134
|
-
const reqEscaped = escapeRegex(reqId);
|
|
154
|
+
const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
|
|
135
155
|
// Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
|
136
156
|
// Use replace() + compare to avoid the test()+replace() global regex
|
|
137
157
|
// lastIndex bug where test() advances state and replace() misses matches.
|
|
@@ -298,17 +318,20 @@ function cmdRequirementsReadyIds(cwd, args, raw) {
|
|
|
298
318
|
return;
|
|
299
319
|
}
|
|
300
320
|
const planAbsPath = node_path_1.default.resolve(cwd, planPathArg);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
321
|
+
// #3183: `planPathArg` may point at a root plan (`<phaseDir>/<n>-PLAN.md`)
|
|
322
|
+
// or a nested plan (`<phaseDir>/plans/PLAN-<n>.md`, #3139 layout) —
|
|
323
|
+
// scanPhasePlans always operates on the PHASE dir, so a nested plan needs
|
|
324
|
+
// one extra `dirname` to reach it, and its planFiles-relative identity
|
|
325
|
+
// carries the `plans/` prefix scanPhasePlans itself applies.
|
|
326
|
+
const isNestedPlanPath = node_path_1.default.basename(node_path_1.default.dirname(planAbsPath)) === 'plans';
|
|
327
|
+
const phaseDir = isNestedPlanPath ? node_path_1.default.dirname(node_path_1.default.dirname(planAbsPath)) : node_path_1.default.dirname(planAbsPath);
|
|
328
|
+
const currentRelative = isNestedPlanPath ? `plans/${node_path_1.default.basename(planAbsPath)}` : node_path_1.default.basename(planAbsPath);
|
|
329
|
+
// #3183: canonical plan/summary sets (root+nested, superseded-excluded)
|
|
330
|
+
// from the single owner, rather than a root-only hand-rolled readdirSync
|
|
331
|
+
// filter — a superseded sibling that still declares reqId with no SUMMARY
|
|
332
|
+
// used to block the ID forever (false-block); it is now excluded upstream.
|
|
333
|
+
const phaseScan = scanPhasePlans(phaseDir);
|
|
334
|
+
const siblingPlanFiles = phaseScan.planFiles.filter((f) => f !== currentRelative);
|
|
312
335
|
const parseFrontmatterReqIds = (content, sourcePath) => {
|
|
313
336
|
const fm = extractFrontmatter(content, sourcePath);
|
|
314
337
|
const fmReq = fm.requirements;
|
|
@@ -341,9 +364,11 @@ function cmdRequirementsReadyIds(cwd, args, raw) {
|
|
|
341
364
|
if (!siblingDeclaresId)
|
|
342
365
|
continue;
|
|
343
366
|
// Sibling declares the SAME ID — it must have finished (produced a
|
|
344
|
-
// SUMMARY) before this ID is ready to mark Complete.
|
|
345
|
-
|
|
346
|
-
|
|
367
|
+
// SUMMARY) before this ID is ready to mark Complete. Canonical pairing
|
|
368
|
+
// via countMatchedSummaries (root+nested, all three naming forms)
|
|
369
|
+
// instead of a bespoke -PLAN.md→-SUMMARY.md regex swap.
|
|
370
|
+
const siblingHasSummary = countMatchedSummaries([siblingFile], phaseScan.summaryFiles) > 0;
|
|
371
|
+
if (!siblingHasSummary) {
|
|
347
372
|
blockedBySibling = true;
|
|
348
373
|
break;
|
|
349
374
|
}
|
|
@@ -388,7 +413,7 @@ function cmdRequirementsRevertPhase(cwd, reqIdsRaw, raw) {
|
|
|
388
413
|
const reverted = [];
|
|
389
414
|
const unchanged = [];
|
|
390
415
|
for (const reqId of reqIds) {
|
|
391
|
-
const reqEscaped = escapeRegex(reqId);
|
|
416
|
+
const reqEscaped = (0, pattern_cjs_1.escapeRegex)(reqId);
|
|
392
417
|
let idReverted = false;
|
|
393
418
|
// Surface 1 — checkbox: - [x] **REQ-ID** -> - [ ] **REQ-ID**
|
|
394
419
|
const checkboxPattern = new RegExp(`(-\\s*\\[)x(\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
|
@@ -424,6 +449,32 @@ function cmdRequirementsRevertPhase(cwd, reqIdsRaw, raw) {
|
|
|
424
449
|
}
|
|
425
450
|
output({ reverted, unchanged, total: reqIds.length }, raw, `${reverted.length}/${reqIds.length} requirement(s) reverted from Complete`);
|
|
426
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* #2142 (code-review FIX 4): the single owned "should the Quick Tasks
|
|
454
|
+
* Completed table be reset, and is a reset failure worth a warning" decision
|
|
455
|
+
* — shared by `cmdMilestoneComplete` (which folds this into its own
|
|
456
|
+
* `withStateLock` transform, since it already holds that lock for the
|
|
457
|
+
* closure-transition write happening in the same block) and `cmdQuickArchive`
|
|
458
|
+
* (which routes through `readModifyWriteStateMd`'s own transform instead, per
|
|
459
|
+
* the lock-reentrancy note on that function). Only the WRITE mechanics
|
|
460
|
+
* differ between the two callers — the decision itself ("skip a
|
|
461
|
+
* `QUICK_TASKS_SECTION_ABSENT` result silently; surface any other failure")
|
|
462
|
+
* was previously duplicated verbatim at both call sites.
|
|
463
|
+
*
|
|
464
|
+
* Never throws: a reset failure degrades to returning `content` unchanged
|
|
465
|
+
* with a non-null `warning`, mirroring both callers' pre-existing
|
|
466
|
+
* "liberal but visible" posture.
|
|
467
|
+
*/
|
|
468
|
+
function applyQuickTasksReset(content) {
|
|
469
|
+
const resetResult = (0, markdown_table_cjs_1.resetQuickTaskRows)(content);
|
|
470
|
+
if (resetResult.ok) {
|
|
471
|
+
return { content: resetResult.value.content, warning: null };
|
|
472
|
+
}
|
|
473
|
+
if (resetResult.reason !== markdown_table_cjs_1.QUICK_TASKS_SECTION_ABSENT) {
|
|
474
|
+
return { content, warning: { field: 'quick_tasks_table', reason: resetResult.reason } };
|
|
475
|
+
}
|
|
476
|
+
return { content, warning: null };
|
|
477
|
+
}
|
|
427
478
|
function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
428
479
|
if (!version) {
|
|
429
480
|
error('version required for milestone complete (e.g., v1.0)');
|
|
@@ -450,27 +501,78 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
450
501
|
const phasesDir = planningPaths(cwd).phases;
|
|
451
502
|
const today = clock_cjs_1.realClock.localToday();
|
|
452
503
|
const milestoneName = options.name || version;
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
504
|
+
// ADR-3408 §8.5 / #3469: "liberal but visible" — when the write-seam
|
|
505
|
+
// composition's preservation stage restores a curated frontmatter value
|
|
506
|
+
// over a disagreeing freshly-derived one, that divergence is surfaced
|
|
507
|
+
// here rather than silently absorbed (the direct answer to #3374's
|
|
508
|
+
// `warnings: []`). Structured (field + reason), not prose, so a caller can
|
|
509
|
+
// assert on the value rather than regex a rendered message.
|
|
510
|
+
//
|
|
511
|
+
// Named `preservation_warnings`, NOT `warnings`: `cmdPhaseComplete` already
|
|
512
|
+
// exposes a sibling field called `warnings` typed as prose `string[]`. Reusing
|
|
513
|
+
// that name here for a structured `{field, reason}[]` shape would be the
|
|
514
|
+
// "Generative Fix Divergence" anti-pattern — two sibling state commands
|
|
515
|
+
// sharing one field name with different element types. `warnings` stays
|
|
516
|
+
// one meaning (prose) repo-wide; this is a distinct, machine-assertable
|
|
517
|
+
// signal for ADR-3408 §8.5's "preservation is visible" rule. Check
|
|
518
|
+
// `preservation_warnings.length` rather than a companion `has_warnings`
|
|
519
|
+
// flag — that flag existed only to mirror `cmdPhaseComplete`'s channel,
|
|
520
|
+
// which this field intentionally does not claim to be.
|
|
521
|
+
const preservationWarnings = [];
|
|
457
522
|
// Scope stats and accomplishments to only the phases belonging to the
|
|
458
523
|
// current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
|
|
459
524
|
// (same logic used by cmdPhasesList and other callers).
|
|
525
|
+
// #3184 review finding: this scope computation + refusal MUST run BEFORE
|
|
526
|
+
// `platformEnsureDir(archiveDir)` below — a refused run (scope not COMPLETE,
|
|
527
|
+
// no --force) must be a true no-op on disk, and creating the archive
|
|
528
|
+
// directory first left an empty directory behind even on refusal.
|
|
460
529
|
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
|
|
461
530
|
if (isDirInMilestone.missingExplicitVersion) {
|
|
462
531
|
error(`no phases found for milestone ${version} in ROADMAP.md`);
|
|
463
532
|
}
|
|
533
|
+
// #3184/#3166: `milestone complete` is the ONE-WAY-DOOR consumer of the
|
|
534
|
+
// milestone window (ROADMAP/REQUIREMENTS archived, phase directories
|
|
535
|
+
// MOVED). #3166 is specifically the TRUNCATED case: the milestone's
|
|
536
|
+
// heading IS found but its section closes before the phase region, and the
|
|
537
|
+
// phase filter degrades to pass-all (see getMilestonePhaseFilter above) —
|
|
538
|
+
// silently archiving every phase directory on disk. UNREADABLE (no
|
|
539
|
+
// ROADMAP.md at all) and UNSCOPED (no section for this version) are
|
|
540
|
+
// pre-existing, legitimately-handled states — `missingExplicitVersion`
|
|
541
|
+
// above already errors where that matters, and a missing ROADMAP.md has
|
|
542
|
+
// its own documented graceful path — so only TRUNCATED is refused here.
|
|
543
|
+
// The read-path consumers keep the pass-all degrade for every scope
|
|
544
|
+
// (ADR-3180 Decision 3's Rejected section: deny-all there would trade one
|
|
545
|
+
// silent wrong answer for another); this write path refuses on TRUNCATED
|
|
546
|
+
// alone, positioned before `platformEnsureDir` so a refusal stays a no-op
|
|
547
|
+
// on disk.
|
|
548
|
+
if (isDirInMilestone.scope === SCOPE.TRUNCATED && !options.force) {
|
|
549
|
+
error(`Cannot mark milestone complete: the ROADMAP window for "${version}" is truncated ` +
|
|
550
|
+
`(the milestone heading was found but its section ends before reaching any phase ` +
|
|
551
|
+
`entries, even though the ROADMAP has phase entries elsewhere), so phase scoping ` +
|
|
552
|
+
`cannot be trusted for this destructive operation. Re-run with --force to override.`);
|
|
553
|
+
}
|
|
464
554
|
// Guard: prevent marking complete when ROADMAP still lists phases that have
|
|
465
555
|
// no directory on disk (disk_status: no_directory). This catches the case
|
|
466
556
|
// where the active milestone was erroneously marked complete before phases
|
|
467
|
-
// were even started.
|
|
468
|
-
//
|
|
469
|
-
//
|
|
557
|
+
// were even started. The scan scopes the ROADMAP via the `version` argument
|
|
558
|
+
// (getMilestonePhaseFilter / extractCurrentMilestone above) and runs whenever
|
|
559
|
+
// --force is absent — a fresh project with no `### Phase N:` headings in the
|
|
560
|
+
// scoped slice yields an empty `noDirectoryPhases` and the guard is a no-op,
|
|
561
|
+
// so no STATE match is required to avoid false positives.
|
|
470
562
|
// Pass --force to override this guard.
|
|
563
|
+
//
|
|
564
|
+
// #2946: the scan used to be nested inside `if (stateVersion && stateVersion
|
|
565
|
+
// === version)`, which silently disarmed the guard whenever STATE.md's
|
|
566
|
+
// `milestone:` field was desynced or absent — functionally an implicit
|
|
567
|
+
// --force on a one-way-door operation (ROADMAP/REQUIREMENTS archived, phase
|
|
568
|
+
// directories MOVED). The STATE field is not the source of truth for which
|
|
569
|
+
// phases belong to this milestone; the ROADMAP scoping is. The scan now runs
|
|
570
|
+
// unconditionally, and a present-but-mismatched STATE field emits a WARNING
|
|
571
|
+
// so the suspicious condition is visible rather than silent.
|
|
471
572
|
if (!options.force) {
|
|
472
573
|
try {
|
|
473
|
-
//
|
|
574
|
+
// Read STATE.md's milestone field only to detect a suspicious mismatch;
|
|
575
|
+
// it no longer gates the scan. (#2946)
|
|
474
576
|
let stateVersion = null;
|
|
475
577
|
try {
|
|
476
578
|
const stateRaw = node_fs_1.default.existsSync(statePath) ? node_fs_1.default.readFileSync(statePath, 'utf-8') : null;
|
|
@@ -481,58 +583,88 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
481
583
|
}
|
|
482
584
|
}
|
|
483
585
|
catch {
|
|
484
|
-
/* skip */
|
|
586
|
+
/* skip — stateVersion stays null, scan still runs */
|
|
485
587
|
}
|
|
486
|
-
if (stateVersion && stateVersion
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
588
|
+
if (stateVersion !== null && stateVersion !== version) {
|
|
589
|
+
// #2946: emit a WARNING so the suspicious STATE mismatch is visible
|
|
590
|
+
// rather than silently disarming the guard. Plain-text diagnostic on
|
|
591
|
+
// stderr, matching the existing [gsd-tools] WARNING convention
|
|
592
|
+
// (state.cts). A missing STATE.md `milestone:` field is not warned
|
|
593
|
+
// here — the scan still runs, and "no milestone declared" is a normal
|
|
594
|
+
// state for a fresh project, not a suspicious drift.
|
|
595
|
+
//
|
|
596
|
+
// `stateVersion` comes from a user-controlled file (STATE.md) and is
|
|
597
|
+
// not validated like the CLI `version` arg (ARCHIVE_VERSION_LABEL_RE).
|
|
598
|
+
// Sanitize before interpolating into stderr so ANSI escapes / control
|
|
599
|
+
// chars / secret-looking strings cannot be echoed verbatim into a CI
|
|
600
|
+
// log or terminal (CONTRIBUTING.md security: secret-looking values in
|
|
601
|
+
// stderr). `version` is already constrained to [A-Za-z0-9._-].
|
|
602
|
+
const safeStateVersion = stateVersion.replace(/[\x00-\x1f\x7f]/g, '?').slice(0, 80);
|
|
603
|
+
process.stderr.write(`[gsd-tools] WARNING: STATE.md milestone: "${safeStateVersion}" ≠ requested "${version}" — ` +
|
|
604
|
+
`running the unstarted-phase guard against the ROADMAP scoped for "${version}" anyway.\n`);
|
|
605
|
+
}
|
|
606
|
+
const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
607
|
+
// #3184/#2946: scope the unstarted-phase guard to the same `version`
|
|
608
|
+
// window `getMilestonePhaseFilter` used above, NOT to
|
|
609
|
+
// extractCurrentMilestone's own STATE.md-derived window — those two
|
|
610
|
+
// can disagree (that disagreement is exactly what the WARNING above
|
|
611
|
+
// detects), and scoping this guard to the wrong window under-detects
|
|
612
|
+
// unstarted phases on the destructive completion path. Calls the same
|
|
613
|
+
// sliceMilestoneWindow owner getMilestonePhaseFilter's versionOverride
|
|
614
|
+
// branch calls (a prior pass here re-composed locate+select+section-end
|
|
615
|
+
// locally, which review caught as a second, disagreeing derivation of
|
|
616
|
+
// the same window — ADR-3180 Decision 4(c)); falls back to
|
|
617
|
+
// extractCurrentMilestone's whole-document result only for the
|
|
618
|
+
// free-form (no versioned milestones anywhere) shape, where both
|
|
619
|
+
// windows converge to the same value regardless of which version drove
|
|
620
|
+
// the lookup.
|
|
621
|
+
const scopedContent = sliceMilestoneWindow(roadmapContent, version) ?? extractCurrentMilestone(roadmapContent, cwd);
|
|
622
|
+
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
623
|
+
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
|
|
624
|
+
const noDirectoryPhases = [];
|
|
625
|
+
let pm;
|
|
626
|
+
const phaseDirEntries = (() => {
|
|
627
|
+
try {
|
|
628
|
+
return node_fs_1.default
|
|
629
|
+
.readdirSync(phasesDir, { withFileTypes: true })
|
|
630
|
+
.filter((e) => e.isDirectory())
|
|
631
|
+
.map((e) => e.name);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return [];
|
|
523
635
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
636
|
+
})();
|
|
637
|
+
while ((pm = phasePattern.exec(scopedContent)) !== null) {
|
|
638
|
+
const phaseNum = pm[1];
|
|
639
|
+
// Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not
|
|
640
|
+
// real phases — they legitimately have no directory and must not block
|
|
641
|
+
// milestone completion. Mirrors the engine-wide sentinel convention
|
|
642
|
+
// (phase-id getMilestoneFromPhaseId, roadmap-command-router SENTINELS,
|
|
643
|
+
// the #1445 /^999/ progress filters). (#1580)
|
|
644
|
+
// #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this local check already covered both 0 and 999; now delegates to the single canonical owner.
|
|
645
|
+
if (isSentinelPhaseId(phaseNum))
|
|
646
|
+
continue;
|
|
647
|
+
const normalized = normalizePhaseName(phaseNum);
|
|
648
|
+
// A phase has disk_status: 'no_directory' when no phase directory
|
|
649
|
+
// with a matching token exists on disk. Use the same matchPhaseDirs
|
|
650
|
+
// owner that roadmap.analyze uses to avoid false positives on decimal
|
|
651
|
+
// (2.1) and letter-suffix (12A) phase IDs. (#2528)
|
|
652
|
+
const hasDirectory = matchPhaseDirs(phaseDirEntries, normalized).matches.length > 0;
|
|
653
|
+
if (!hasDirectory) {
|
|
654
|
+
noDirectoryPhases.push(phaseNum);
|
|
527
655
|
}
|
|
528
656
|
}
|
|
657
|
+
if (noDirectoryPhases.length > 0) {
|
|
658
|
+
error(`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
|
|
659
|
+
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`);
|
|
660
|
+
}
|
|
529
661
|
}
|
|
530
662
|
catch (e) {
|
|
531
663
|
// If the error came from our guard, re-throw it; otherwise skip silently.
|
|
532
664
|
const message = e instanceof Error ? e.message : String(e);
|
|
533
665
|
if (message && message.startsWith('Cannot mark milestone complete:'))
|
|
534
666
|
throw e;
|
|
535
|
-
// Phase scan failed
|
|
667
|
+
// Phase scan failed (e.g. ROADMAP unreadable) — allow completion to proceed.
|
|
536
668
|
}
|
|
537
669
|
}
|
|
538
670
|
// Gather stats from phases (scoped to current milestone only)
|
|
@@ -540,20 +672,29 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
540
672
|
let totalPlans = 0;
|
|
541
673
|
let totalTasks = 0;
|
|
542
674
|
const accomplishments = [];
|
|
675
|
+
// #3597 (ADR-3180 Decision 2): SINGLE resolution of "which phase
|
|
676
|
+
// directories belong to the current milestone" AND the SCOPE discriminator
|
|
677
|
+
// that resolution came from — shared verbatim by the read-only stats loop
|
|
678
|
+
// immediately below, the --dry-run preview, and the real archive pass, so
|
|
679
|
+
// none of the three can ever disagree. `listMilestonePhaseDirs` never
|
|
680
|
+
// throws (its own doc comment), so this is safe to call unguarded ahead of
|
|
681
|
+
// the try/catch that scopes the stats roll-up below.
|
|
682
|
+
//
|
|
683
|
+
// The stats loop's own ENUMERATION behavior is intentionally left
|
|
684
|
+
// unaffected by a non-COMPLETE scope — it reports what is actually on
|
|
685
|
+
// disk, same as before #3597. Only the destructive archive pass (and its
|
|
686
|
+
// --dry-run preview) refuses to act on a non-COMPLETE (non-answer) scope;
|
|
687
|
+
// see the guard built from `milestonePhaseScope` further down.
|
|
688
|
+
const { value: milestonePhaseDirs, scope: milestonePhaseScope } = listMilestonePhaseDirs(phasesDir, { cwd, versionOverride: version });
|
|
543
689
|
try {
|
|
544
|
-
const
|
|
545
|
-
const dirs = entries
|
|
546
|
-
.filter((e) => e.isDirectory())
|
|
547
|
-
.map((e) => e.name)
|
|
548
|
-
.sort();
|
|
549
|
-
for (const dir of dirs) {
|
|
550
|
-
if (!isDirInMilestone(dir))
|
|
551
|
-
continue;
|
|
690
|
+
for (const dir of milestonePhaseDirs) {
|
|
552
691
|
phaseCount++;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
692
|
+
// #3183: canonical plan/summary sets (root+nested, superseded-excluded)
|
|
693
|
+
// from the single owner, rather than a root-only hand-rolled readdirSync
|
|
694
|
+
// filter.
|
|
695
|
+
const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
696
|
+
const summaries = phaseScan.summaryFiles;
|
|
697
|
+
totalPlans += phaseScan.planCount;
|
|
557
698
|
// Extract one-liners from summaries
|
|
558
699
|
for (const s of summaries) {
|
|
559
700
|
try {
|
|
@@ -593,21 +734,49 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
593
734
|
* were created). Degrades stats to phaseCount/totalPlans/totalTasks=0,
|
|
594
735
|
* accomplishments=[] rather than crash `milestone complete`. */
|
|
595
736
|
}
|
|
737
|
+
// #3597 (ADR-3180 Decision 2): `SCOPE.UNREADABLE` is the ONE classification
|
|
738
|
+
// where `getMilestonePhaseFilter` throws (no workstream ROADMAP of its
|
|
739
|
+
// own), leaves `milestonePhaseNums` empty, and the window degrades to a
|
|
740
|
+
// pass-all fallback — that fallback is what silently WIDENS the archive
|
|
741
|
+
// set past the single-derivation guarantee this block exists to protect
|
|
742
|
+
// (confirmed empirically: a workstream with phase dirs but no workstream
|
|
743
|
+
// ROADMAP.md reports UNREADABLE and used to enumerate every directory on
|
|
744
|
+
// disk). `SCOPE.TRUNCATED` already refuses the WHOLE command above.
|
|
745
|
+
// `SCOPE.UNSCOPED` is a DIFFERENT, pre-existing classification — e.g. a
|
|
746
|
+
// root project with no milestone asserted in STATE.md — whose
|
|
747
|
+
// `listMilestonePhaseDirs` resolution is a real (non-degraded) answer, and
|
|
748
|
+
// its rollover archive behavior predates this branch and must not change.
|
|
749
|
+
// The guard therefore refuses ONLY on UNREADABLE, not on "not COMPLETE" —
|
|
750
|
+
// widening to every non-COMPLETE scope was itself a regression (a root
|
|
751
|
+
// project with no active workstream resolves UNSCOPED, and refusing to
|
|
752
|
+
// archive there broke the ordinary `milestone complete` -> `phases clear`
|
|
753
|
+
// rollover). Computed once here from the single
|
|
754
|
+
// `milestonePhaseDirs`/`milestonePhaseScope` resolution above, so the
|
|
755
|
+
// --dry-run preview below and the real archive pass further down can never
|
|
756
|
+
// disagree about whether (or what) to archive.
|
|
757
|
+
const phasesArchiveSkippedForScope = options.archivePhases !== false && milestonePhaseScope === SCOPE.UNREADABLE;
|
|
758
|
+
const phasesArchiveSkipReason = phasesArchiveSkippedForScope
|
|
759
|
+
? `milestone window scope is "${milestonePhaseScope}" — refusing to archive phase directories until the window can be resolved (ADR-3180)`
|
|
760
|
+
: null;
|
|
596
761
|
// #2118: --dry-run preview — compute what WOULD happen without mutating.
|
|
597
762
|
// The stats above are read-only; all mutations start at the archive section below.
|
|
598
763
|
if (options.dryRun) {
|
|
599
764
|
const phaseDirsToArchive = [];
|
|
600
|
-
if (options.archivePhases !== false) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
catch { /* phasesDir missing — nothing to archive */ }
|
|
765
|
+
if (options.archivePhases !== false && !phasesArchiveSkippedForScope) {
|
|
766
|
+
// #3185 (ADR-3180 Decision 1) / #3597: same single routed derivation as
|
|
767
|
+
// the stats loop above — the dry-run preview must list exactly what
|
|
768
|
+
// the real archive pass below would move, including refusing to list
|
|
769
|
+
// anything when the window scope is not COMPLETE.
|
|
770
|
+
phaseDirsToArchive.push(...milestonePhaseDirs);
|
|
610
771
|
}
|
|
772
|
+
// #2142 MAJOR 5 (review): dry-run preview of quick-task archival —
|
|
773
|
+
// read-only, routed through the SAME `listQuickTaskDirsForArchive`
|
|
774
|
+
// selection `archiveQuickTaskDirectories` uses for real (directory
|
|
775
|
+
// entries only, `requireSafePath`-guarded, sorted) so this preview can
|
|
776
|
+
// never disagree with what a real run actually archives. Absent
|
|
777
|
+
// --archive-quick this stays `[]` and nothing on disk is touched either
|
|
778
|
+
// way (dry-run always returns before any mutation below).
|
|
779
|
+
const quickDirsToArchive = options.archiveQuick ? listQuickTaskDirsForArchive(cwd) : [];
|
|
611
780
|
const dryRunResult = {
|
|
612
781
|
dry_run: true,
|
|
613
782
|
version,
|
|
@@ -625,6 +794,9 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
625
794
|
? { source: node_path_1.default.relative(cwd, node_path_1.default.join(planningBase, `${version}-MILESTONE-AUDIT.md`)).split(node_path_1.default.sep).join('/'), target: node_path_1.default.relative(cwd, node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)).split(node_path_1.default.sep).join('/') }
|
|
626
795
|
: null,
|
|
627
796
|
phases: phaseDirsToArchive,
|
|
797
|
+
phases_archive_skipped: phasesArchiveSkippedForScope,
|
|
798
|
+
phases_archive_skip_reason: phasesArchiveSkipReason,
|
|
799
|
+
quick: quickDirsToArchive,
|
|
628
800
|
},
|
|
629
801
|
would_update: {
|
|
630
802
|
milestones_md: node_path_1.default.relative(cwd, milestonesPath).split(node_path_1.default.sep).join('/'),
|
|
@@ -634,6 +806,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
634
806
|
output(dryRunResult, raw);
|
|
635
807
|
return;
|
|
636
808
|
}
|
|
809
|
+
// Ensure archive directory exists. Deliberately placed AFTER the dry-run
|
|
810
|
+
// early return and every refusal/guard above (missingExplicitVersion, the
|
|
811
|
+
// scope refusal, the unstarted-phase guard) — #3184 review finding: this
|
|
812
|
+
// used to run before those checks, so a refused run still left an empty
|
|
813
|
+
// archive directory behind. Reaching this point means the run is
|
|
814
|
+
// committed to mutating.
|
|
815
|
+
(0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
|
|
637
816
|
// Archive ROADMAP.md
|
|
638
817
|
if (node_fs_1.default.existsSync(roadmapPath)) {
|
|
639
818
|
const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
@@ -667,7 +846,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
667
846
|
}
|
|
668
847
|
else {
|
|
669
848
|
// Insert after the header line(s) for reverse chronological order (newest first)
|
|
670
|
-
|
|
849
|
+
// #3415: empirically verified linear-time up to 5MB adversarial input (worst-case
|
|
850
|
+
// no-newline-at-all forcing full [^\r\n]* backtrack: 0.11ms@10KB -> 6.9ms@5MB).
|
|
851
|
+
// Non-global, `^`-anchored (no /m) so this is a single match attempt at position 0
|
|
852
|
+
// only — never rescanned at every offset — with no nested repeated group, so it
|
|
853
|
+
// cannot exhibit the #2128-class catastrophic backtracking.
|
|
854
|
+
// eslint-disable-next-line local/no-unbounded-quantifier -- single ^-anchored non-global attempt at pos 0, measured linear to 5MB, no nested quantifier
|
|
855
|
+
const headerMatch = existing.match(/^(#{1,3}\s+[^\r\n]*\r?\n(?:\r?\n)?)/);
|
|
671
856
|
if (headerMatch) {
|
|
672
857
|
const header = headerMatch[1];
|
|
673
858
|
const rest = existing.slice(header.length);
|
|
@@ -682,26 +867,140 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
682
867
|
else {
|
|
683
868
|
(0, shell_command_projection_cjs_1.platformWriteSync)(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
|
684
869
|
}
|
|
870
|
+
// #2142 BLOCKER 2 (review): opt-in quick-task archival. This call MUST sit
|
|
871
|
+
// immediately adjacent to the STATE.md write block directly below it, with
|
|
872
|
+
// NO unguarded IO in between (unlike the ROADMAP/REQUIREMENTS/audit/
|
|
873
|
+
// MILESTONES.md writes above, none of which are wrapped in a try/catch).
|
|
874
|
+
// If the move ran earlier — e.g. right after `platformEnsureDir(archiveDir)`
|
|
875
|
+
// — and any one of those unguarded writes then threw, the quick-task
|
|
876
|
+
// directories would already be gone from `.planning/quick/` while the
|
|
877
|
+
// STATE.md Quick Tasks table reset (which lives inside `withStateLock`
|
|
878
|
+
// immediately below) would never be reached. That is precisely the
|
|
879
|
+
// STATE-vs-disk drift #2142 exists to eliminate: a table still describing
|
|
880
|
+
// directories that no longer exist. Keeping the move and the reset
|
|
881
|
+
// adjacent — separated only by this comment, never by IO that can throw —
|
|
882
|
+
// means either both happen or (if the move itself throws) neither does.
|
|
883
|
+
// `archiveQuick` is opt-in (default OFF); absent the flag this is `null`
|
|
884
|
+
// and every downstream read of it degrades to "no quick archival happened".
|
|
885
|
+
const quickArchiveResult = options.archiveQuick
|
|
886
|
+
? archiveQuickTaskDirectories(cwd, version)
|
|
887
|
+
: null;
|
|
685
888
|
// Update STATE.md — keep frontmatter/body semantically aligned after closure.
|
|
686
889
|
// ADR-1769 Phase 5: dispatches to the STATE.md Transition Module. The closure
|
|
687
890
|
// write (Status, Last Activity, Last Activity Description, Current Position
|
|
688
891
|
// reset, Operator Next Steps reset) is the pure `milestoneCompleteCore` in
|
|
689
892
|
// src/state-transition.cts, backed by the field-classification table. The
|
|
690
893
|
// runtime-specific next-milestone slash command is resolved here and injected
|
|
691
|
-
// via the intent so the core stays pure.
|
|
692
|
-
//
|
|
894
|
+
// via the intent so the core stays pure.
|
|
895
|
+
//
|
|
896
|
+
// ADR-3408 §8.3 / #3469: this used to write via `writeStateMd`, which gets
|
|
897
|
+
// sync and NO preservation — the identical shape #3374 reported for
|
|
898
|
+
// `phase.complete` (a stale body value silently clobbering fresher
|
|
899
|
+
// frontmatter). Routed through the single write-seam composition
|
|
900
|
+
// (`syncAndPreserveStateMd`) instead, under the same lock discipline
|
|
901
|
+
// `cmdPhaseComplete`'s atomic-commit adapter already uses: `withStateLock`
|
|
902
|
+
// wraps read + transform + sync + preserve + write so the read this
|
|
903
|
+
// transaction bases its transform on cannot be raced by a concurrent
|
|
904
|
+
// writer (closing a pre-existing TOCTOU gap `writeStateMd`'s own internal
|
|
905
|
+
// lock never covered, since the read used to happen before any lock was
|
|
906
|
+
// taken). `resync: true` mirrors `cmdPhaseComplete`'s posture (progress
|
|
907
|
+
// recomputed from disk; only the preserve-when-unchanged deltas apply) —
|
|
908
|
+
// milestone completion is the same kind of lifecycle transition.
|
|
693
909
|
if (node_fs_1.default.existsSync(statePath)) {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
910
|
+
withStateLock(statePath, () => {
|
|
911
|
+
const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
|
|
912
|
+
const result = (0, state_transition_cjs_1.transitionCore)(originalStateContent, {
|
|
913
|
+
kind: 'milestoneComplete',
|
|
914
|
+
version,
|
|
915
|
+
nextMilestoneCommand: (0, runtime_slash_cjs_1.formatGsdSlash)('new-milestone', (0, runtime_slash_cjs_1.resolveRuntime)(cwd)),
|
|
916
|
+
}, { clock: clock_cjs_1.realClock, sourcePath: statePath });
|
|
917
|
+
const divergedFields = [];
|
|
918
|
+
// #2111 (found by #3471 review): `milestoneCompleteCore` never declares
|
|
919
|
+
// `current_phase`/`current_phase_name` among the fields it touches — but
|
|
920
|
+
// its ## Current Position reset REWRITES the `Phase:` prose line to a
|
|
921
|
+
// closure message ("Milestone vX.Y complete"), which is not a number.
|
|
922
|
+
// That is an unavoidable side effect of the wholesale section reset
|
|
923
|
+
// `resetSectionVerbatim` performs, not an intent to change the phase.
|
|
924
|
+
// Downstream, `current_phase`/`current_phase_name` are
|
|
925
|
+
// `preserve-when-unchanged` rows: the #1230 delta heuristic sees the
|
|
926
|
+
// body source go from a real value to unparseable and — correctly, per
|
|
927
|
+
// ADR-3408 §8.5 Row 2 — lets the derived (empty) value win, discarding
|
|
928
|
+
// the curated phase entirely. §8.5 Row 2 governs a genuine mid-write
|
|
929
|
+
// body edit (e.g. `state.patch` deleting the Phase line); milestone
|
|
930
|
+
// closure is a different shape — the transition never intended to
|
|
931
|
+
// touch these fields at all. Re-assert them via `authoritativeFm` (the
|
|
932
|
+
// same #2736 intent-first mechanism `beginPhaseCore`/`completePhaseCore`
|
|
933
|
+
// already use to freeze a field the transition resolved out-of-band),
|
|
934
|
+
// so the closure-message side effect cannot clobber the last real
|
|
935
|
+
// phase. Scoped to non-empty strings only, mirroring #2736's own guard.
|
|
936
|
+
const authoritativeFm = {};
|
|
937
|
+
const preFm = extractFrontmatter(originalStateContent, statePath);
|
|
938
|
+
const preCurrentPhase = preFm['current_phase'];
|
|
939
|
+
const preCurrentPhaseName = preFm['current_phase_name'];
|
|
940
|
+
if (typeof preCurrentPhase === 'string' && preCurrentPhase.trim().length > 0) {
|
|
941
|
+
authoritativeFm['current_phase'] = preCurrentPhase;
|
|
942
|
+
}
|
|
943
|
+
if (typeof preCurrentPhaseName === 'string' && preCurrentPhaseName.trim().length > 0) {
|
|
944
|
+
authoritativeFm['current_phase_name'] = preCurrentPhaseName;
|
|
945
|
+
}
|
|
946
|
+
// #2142: fold the Quick Tasks table reset into this SAME
|
|
947
|
+
// `withStateLock` transform — no second lock acquisition, no second
|
|
948
|
+
// `syncAndPreserveStateMd`/`platformWriteSync` pass. Only applied when
|
|
949
|
+
// quick archival actually MOVED something (never when the flag was
|
|
950
|
+
// absent, and never for a mere dry-run preview, which never reaches
|
|
951
|
+
// here at all). A refused reset degrades to leaving the content
|
|
952
|
+
// untouched and never fails milestone completion, but the two refusal
|
|
953
|
+
// shapes are NOT equally noteworthy (design doc §40, behavior table
|
|
954
|
+
// row 5): an ABSENT "Quick Tasks Completed" section is the normal,
|
|
955
|
+
// common case — the section is created lazily by
|
|
956
|
+
// `gsd-core/workflows/quick.md` Step 7b, not by
|
|
957
|
+
// `gsd-core/templates/state.md`, so most projects simply don't have
|
|
958
|
+
// one — and is silently skipped (compared via the shared
|
|
959
|
+
// `QUICK_TASKS_SECTION_ABSENT` sentinel, never by matching on the
|
|
960
|
+
// free-form reason string). A section that EXISTS but couldn't be
|
|
961
|
+
// reset (unparseable table, or columns matching neither registered
|
|
962
|
+
// QuickTasks variant) is a genuine anomaly and IS surfaced via
|
|
963
|
+
// `preservationWarnings`, the same "liberal but visible" posture the
|
|
964
|
+
// rest of this block already uses for a disagreeing derived STATE.md
|
|
965
|
+
// value.
|
|
966
|
+
let quickTasksResetContent = result.content;
|
|
967
|
+
if (quickArchiveResult && quickArchiveResult.archived > 0) {
|
|
968
|
+
const { content: resetContent, warning } = applyQuickTasksReset(quickTasksResetContent);
|
|
969
|
+
quickTasksResetContent = resetContent;
|
|
970
|
+
if (warning)
|
|
971
|
+
preservationWarnings.push(warning);
|
|
972
|
+
}
|
|
973
|
+
const finalContent = syncAndPreserveStateMd(originalStateContent, quickTasksResetContent, statePath, cwd, {
|
|
974
|
+
resync: true,
|
|
975
|
+
authoritativeFm: Object.keys(authoritativeFm).length > 0 ? authoritativeFm : undefined,
|
|
976
|
+
divergedFields,
|
|
977
|
+
});
|
|
978
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(statePath, finalContent);
|
|
979
|
+
for (const field of divergedFields) {
|
|
980
|
+
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
|
981
|
+
}
|
|
982
|
+
// The authoritativeFm re-assert above (unlike a delta-based restore) is
|
|
983
|
+
// invisible to `divergedFields` — #2736's re-assert runs after that
|
|
984
|
+
// diff — so surface it explicitly here for "liberal but visible".
|
|
985
|
+
for (const field of Object.keys(authoritativeFm)) {
|
|
986
|
+
if (!divergedFields.includes(field)) {
|
|
987
|
+
preservationWarnings.push({ field, reason: 'preserved-over-disagreeing-derived' });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
});
|
|
700
991
|
}
|
|
701
992
|
// Archive phase directories if requested
|
|
702
993
|
let phasesArchived = false;
|
|
703
994
|
// #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
|
|
704
|
-
|
|
995
|
+
// #3597: `phasesArchiveSkippedForScope` (computed once, above, from the SAME
|
|
996
|
+
// `milestonePhaseDirs`/`milestonePhaseScope` resolution the --dry-run preview
|
|
997
|
+
// consumed) refuses this destructive rename loop entirely when the window
|
|
998
|
+
// scope is UNREADABLE — the one scope whose resolution degrades to a
|
|
999
|
+
// pass-all fallback (ADR-3180). UNSCOPED/TRUNCATED are real, pre-existing
|
|
1000
|
+
// answers and archive exactly as they did before this branch.
|
|
1001
|
+
// `phasesArchived` stays false and the refusal is surfaced on `result` below;
|
|
1002
|
+
// nothing on disk moves, and `phaseArchiveDir` is never even created.
|
|
1003
|
+
if (options.archivePhases !== false && !phasesArchiveSkippedForScope) {
|
|
705
1004
|
// #2245 audit (was ERROR-HIDING): retryRenameSync moves one phase dir at a
|
|
706
1005
|
// time — a mid-loop failure (e.g. the Nth rename) used to leave
|
|
707
1006
|
// `phasesArchived` at its `false` default even though the first N-1 dirs
|
|
@@ -713,11 +1012,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
713
1012
|
try {
|
|
714
1013
|
const phaseArchiveDir = node_path_1.default.join(archiveDir, `${version}-phases`);
|
|
715
1014
|
(0, shell_command_projection_cjs_1.platformEnsureDir)(phaseArchiveDir);
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
1015
|
+
// #3185 (ADR-3180 Decision 1) / #3597: same single routed derivation as
|
|
1016
|
+
// the stats loop and the --dry-run preview above — only the CURRENT
|
|
1017
|
+
// milestone's phase directories move, never a sentinel or an
|
|
1018
|
+
// out-of-window directory left for a later milestone, and never a
|
|
1019
|
+
// pass-all degrade from a non-COMPLETE scope (refused above).
|
|
1020
|
+
for (const dir of milestonePhaseDirs) {
|
|
721
1021
|
(0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, dir), node_path_1.default.join(phaseArchiveDir, dir));
|
|
722
1022
|
archivedCount++;
|
|
723
1023
|
}
|
|
@@ -744,9 +1044,18 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
|
|
744
1044
|
requirements: node_fs_1.default.existsSync(node_path_1.default.join(archiveDir, `${version}-REQUIREMENTS.md`)),
|
|
745
1045
|
audit: node_fs_1.default.existsSync(node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
|
|
746
1046
|
phases: phasesArchived,
|
|
1047
|
+
// #3597: machine-readable refusal signal — distinguishes "nothing to
|
|
1048
|
+
// archive because the milestone genuinely has no phase directories"
|
|
1049
|
+
// (phases: false, phases_archive_skipped: false) from "refused to
|
|
1050
|
+
// archive because the milestone window scope was not COMPLETE"
|
|
1051
|
+
// (phases: false, phases_archive_skipped: true, with a reason).
|
|
1052
|
+
phases_archive_skipped: phasesArchiveSkippedForScope,
|
|
1053
|
+
phases_archive_skip_reason: phasesArchiveSkipReason,
|
|
1054
|
+
quick: !!quickArchiveResult && quickArchiveResult.archived > 0,
|
|
747
1055
|
},
|
|
748
1056
|
milestones_updated: true,
|
|
749
1057
|
state_updated: node_fs_1.default.existsSync(statePath),
|
|
1058
|
+
preservation_warnings: preservationWarnings,
|
|
750
1059
|
};
|
|
751
1060
|
output(result, raw);
|
|
752
1061
|
}
|
|
@@ -785,7 +1094,13 @@ function cmdPhasesClear(cwd, raw, args) {
|
|
|
785
1094
|
let cleared = 0;
|
|
786
1095
|
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
787
1096
|
const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
|
|
788
|
-
|
|
1097
|
+
// #3185 (ADR-3180 Decision 1): this carried the FIFTH copy of the
|
|
1098
|
+
// sentinel rule and its THIRD regex variant — `/^999(?:\.|$)/` — which
|
|
1099
|
+
// excluded 999 but NOT 0. Because this is the DESTRUCTIVE path, that
|
|
1100
|
+
// divergence meant a `0-*` directory `roadmap analyze` preserves as a
|
|
1101
|
+
// sentinel was DELETED here. Routed through the canonical predicate so
|
|
1102
|
+
// every reader of "is this a sentinel phase" agrees by construction.
|
|
1103
|
+
const dirs = entries.filter((e) => e.isDirectory() && !isSentinelPhaseId(e.name));
|
|
789
1104
|
if (dirs.length > 0 && !confirm) {
|
|
790
1105
|
error(`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
|
|
791
1106
|
`Pass --confirm to proceed.`);
|
|
@@ -869,7 +1184,13 @@ function archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride =
|
|
|
869
1184
|
let archiveVersion = safeOverride;
|
|
870
1185
|
if (!archiveVersion) {
|
|
871
1186
|
try {
|
|
872
|
-
|
|
1187
|
+
// #3216 (ADR-3180 §7.2 Decision): getMilestoneInfo's version becomes a
|
|
1188
|
+
// DIRECTORY NAME below — only a COMPLETE scope's identity is trustworthy
|
|
1189
|
+
// enough to act on destructively. On any other scope, treat the version
|
|
1190
|
+
// as unavailable so control falls through to the dated-label fallback,
|
|
1191
|
+
// same as an unreadable ROADMAP/STATE.
|
|
1192
|
+
const info = getMilestoneInfo(cwd);
|
|
1193
|
+
const liveVersion = info.scope === SCOPE.COMPLETE ? (info.value?.version ?? null) : null;
|
|
873
1194
|
// Defense in depth (#2288 security): getMilestoneInfo reads STATE.md's
|
|
874
1195
|
// `milestone:` field, which is unvalidated file content. Only accept it
|
|
875
1196
|
// as a path component if it is a safe version label; a crafted value
|
|
@@ -900,10 +1221,401 @@ function archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride =
|
|
|
900
1221
|
}
|
|
901
1222
|
return { archiveDir: archivePhasesDir, archived };
|
|
902
1223
|
}
|
|
1224
|
+
/**
|
|
1225
|
+
* #2142 BLOCKER 1 (review): escape one directory-name span for insertion as
|
|
1226
|
+
* markdown LINK TEXT (`[...]`) — a directory name containing a literal `|`,
|
|
1227
|
+
* `[` or `]` must not be able to break the enclosing markdown. `mkdirSync`
|
|
1228
|
+
* accepts an embedded newline in a directory name on POSIX (and `isDirectory()`
|
|
1229
|
+
* still reports true for it), so an unescaped newline would let attacker-
|
|
1230
|
+
* controlled content — including a markdown HEADING — land verbatim in the
|
|
1231
|
+
* generated README.md, an indirect prompt-injection vector for any agent
|
|
1232
|
+
* workflow step that later reads that file. Mirrors `escapeCell`'s exact
|
|
1233
|
+
* convention (markdown-table.cts `escapeCell`): collapse `\r?\n+` to a single
|
|
1234
|
+
* space FIRST (so a newline can never re-enter the output as a line break),
|
|
1235
|
+
* THEN escape the escape char itself (before the rest, so a literal backslash
|
|
1236
|
+
* in the name is never mistaken for part of an escape sequence this function
|
|
1237
|
+
* introduces), THEN the markdown-syntax characters.
|
|
1238
|
+
*/
|
|
1239
|
+
function escapeMarkdownLinkText(text) {
|
|
1240
|
+
return text
|
|
1241
|
+
.replace(/\r?\n+/g, ' ')
|
|
1242
|
+
.replace(/\\/g, '\\\\')
|
|
1243
|
+
.replace(/\|/g, '\\|')
|
|
1244
|
+
.replace(/\[/g, '\\[')
|
|
1245
|
+
.replace(/\]/g, '\\]');
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* #2142 BLOCKER 1 (review): encode one path span for insertion as a markdown
|
|
1249
|
+
* link DESTINATION (`(...)`) — `relSummary` is built from a directory name
|
|
1250
|
+
* that may legally contain a space, a `(`/`)`, or a control character
|
|
1251
|
+
* (including an embedded newline) on POSIX. Per CommonMark, an unbracketed
|
|
1252
|
+
* link destination terminates at the first ASCII space/control character and
|
|
1253
|
+
* requires parens to be balanced or escaped — any of those would truncate or
|
|
1254
|
+
* corrupt the link, or let attacker-controlled content spill out of the
|
|
1255
|
+
* `(...)` span into the surrounding markdown (the same indirect
|
|
1256
|
+
* prompt-injection vector `escapeMarkdownLinkText` guards the link TEXT
|
|
1257
|
+
* against). Percent-encodes just the unsafe set (space, `(`, `)`, and C0
|
|
1258
|
+
* control chars incl. `\r`/`\n`, plus DEL) rather than switching to the
|
|
1259
|
+
* angle-bracket `<...>` destination form — percent-encoding is reversible (a
|
|
1260
|
+
* markdown viewer resolving the link still reaches the right file) and does
|
|
1261
|
+
* not introduce a new pair of syntax characters (`<`/`>`) that would in turn
|
|
1262
|
+
* need their own escaping.
|
|
1263
|
+
*/
|
|
1264
|
+
function encodeMarkdownLinkTarget(target) {
|
|
1265
|
+
return target.replace(/[\x00-\x1f\x7f ()]/g, (ch) => `%${ch.charCodeAt(0).toString(16).padStart(2, '0').toUpperCase()}`);
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* #2142 (code-review FIX 2): PURE builder — scans `archiveQuickDir` and
|
|
1269
|
+
* resolves each entry's summary link, but performs NO IO beyond the read
|
|
1270
|
+
* scan itself; never writes. Split out of the former `writeQuickArchiveReadme`
|
|
1271
|
+
* so tests can assert on the returned structured IR (`entries`) instead of
|
|
1272
|
+
* substring-matching rendered markdown (CONTRIBUTING.md "Prohibited: Raw Text
|
|
1273
|
+
* Matching on Test Outputs" — a generated archive index is a "Rendered file",
|
|
1274
|
+
* which requires a pure builder returning IR, not the `.md`-IS-the-runtime-
|
|
1275
|
+
* artifact exemption).
|
|
1276
|
+
*
|
|
1277
|
+
* (re)generates an index of every quick-task directory PHYSICALLY PRESENT in
|
|
1278
|
+
* the archive, built by scanning the ARCHIVE directory on disk. Deliberately
|
|
1279
|
+
* NOT built from STATE.md's Quick Tasks table (the issue evidenced that table
|
|
1280
|
+
* drifting — 53 rows against 49 dirs, ~22 rows pointing at absent dirs, 18
|
|
1281
|
+
* dirs missing from the table — the filesystem is the only source of truth)
|
|
1282
|
+
* and NOT from the pre-move source list either, so a RE-RUN's index includes
|
|
1283
|
+
* entries a PRIOR run already archived, not just this run's (design row 11).
|
|
1284
|
+
*
|
|
1285
|
+
* Each entry's summary link is resolved via `resolveQuickTaskSummaryFile`
|
|
1286
|
+
* (audit.cts) — the SAME rule `scanQuickTasks` uses to read a task's record
|
|
1287
|
+
* — imported rather than re-derived, so the read and write paths can never
|
|
1288
|
+
* disagree about which file is a task's summary. A task WITHOUT a summary is
|
|
1289
|
+
* still listed, just without a link — never omitted (an omission would
|
|
1290
|
+
* under-report the index, which is worse than an unlinked entry).
|
|
1291
|
+
*
|
|
1292
|
+
* Entries are sorted for deterministic output. A directory name containing
|
|
1293
|
+
* `|`, `[`, `]` or an embedded newline is neutralized via
|
|
1294
|
+
* `escapeMarkdownLinkText` (link TEXT) so it cannot break the generated
|
|
1295
|
+
* markdown or inject a heading — applied here, at build time, so `entries`
|
|
1296
|
+
* itself already carries the injection-safe name (the regression test
|
|
1297
|
+
* asserts on THIS, not on rendered output). The destination is separately
|
|
1298
|
+
* encoded via `encodeMarkdownLinkTarget` (link TARGET) at RENDER time, so a
|
|
1299
|
+
* space/paren/control char in the name cannot truncate or corrupt the
|
|
1300
|
+
* `(...)` span. The summary path is normalized to POSIX
|
|
1301
|
+
* (`.split(path.sep).join('/')`) so the link is stable across platforms.
|
|
1302
|
+
*
|
|
1303
|
+
* Throws when `archiveQuickDir` is unreadable — the caller (`writeQuickArchiveReadme`)
|
|
1304
|
+
* is the best-effort boundary, not this builder.
|
|
1305
|
+
*/
|
|
1306
|
+
function buildQuickArchiveIndex(archiveQuickDir) {
|
|
1307
|
+
const dirEntries = node_fs_1.default.readdirSync(archiveQuickDir, { withFileTypes: true });
|
|
1308
|
+
const dirNames = dirEntries
|
|
1309
|
+
.filter((e) => e.isDirectory())
|
|
1310
|
+
.map((e) => e.name)
|
|
1311
|
+
.sort();
|
|
1312
|
+
const entries = dirNames.map((dirName) => {
|
|
1313
|
+
const taskDir = node_path_1.default.join(archiveQuickDir, dirName);
|
|
1314
|
+
const summaryPath = resolveQuickTaskSummaryFile(taskDir, dirName);
|
|
1315
|
+
const escapedName = escapeMarkdownLinkText(dirName);
|
|
1316
|
+
if (summaryPath) {
|
|
1317
|
+
const relSummary = node_path_1.default.relative(archiveQuickDir, summaryPath).split(node_path_1.default.sep).join('/');
|
|
1318
|
+
return { name: escapedName, summary: relSummary };
|
|
1319
|
+
}
|
|
1320
|
+
// No summary file — list the directory, but never link into it (there
|
|
1321
|
+
// is nothing to point at). See indexListsTaskWithoutSummaryWithoutLink.
|
|
1322
|
+
return { name: escapedName, summary: null };
|
|
1323
|
+
});
|
|
1324
|
+
return {
|
|
1325
|
+
entries,
|
|
1326
|
+
render() {
|
|
1327
|
+
const lines = ['# Archived Quick Tasks', ''];
|
|
1328
|
+
for (const entry of entries) {
|
|
1329
|
+
if (entry.summary !== null) {
|
|
1330
|
+
lines.push(`- [${entry.name}](${encodeMarkdownLinkTarget(entry.summary)})`);
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
lines.push(`- ${entry.name}`);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
lines.push('');
|
|
1337
|
+
return lines.join('\n');
|
|
1338
|
+
},
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* #2142: thin writer — calls `buildQuickArchiveIndex` and writes its
|
|
1343
|
+
* `render()` output to `<archiveQuickDir>/README.md`. No-ops (writes
|
|
1344
|
+
* nothing) when `archiveQuickDir` is unreadable — this is a best-effort
|
|
1345
|
+
* index, not a gate on milestone completion. #2142 MAJOR 4 (review): the
|
|
1346
|
+
* whole body is wrapped in a try/catch so a failure of the WRITE itself
|
|
1347
|
+
* (read-only archive dir, full disk, or a quick-task directory literally
|
|
1348
|
+
* named `README.md` colliding with the file being written) degrades the same
|
|
1349
|
+
* way — this function genuinely cannot throw, matching its own
|
|
1350
|
+
* "best-effort, not a gate" contract; the directories are already safely
|
|
1351
|
+
* archived by the time this runs.
|
|
1352
|
+
*/
|
|
1353
|
+
function writeQuickArchiveReadme(archiveQuickDir) {
|
|
1354
|
+
try {
|
|
1355
|
+
const index = buildQuickArchiveIndex(archiveQuickDir);
|
|
1356
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(archiveQuickDir, 'README.md'), index.render());
|
|
1357
|
+
}
|
|
1358
|
+
catch {
|
|
1359
|
+
/* best-effort (#2142 MAJOR 4): a read-only archive dir, a full disk, or a
|
|
1360
|
+
* quick-task directory literally named `README.md` colliding with the
|
|
1361
|
+
* file this function writes must never crash `milestone complete` —
|
|
1362
|
+
* the quick-task directories are already safely archived on disk by the
|
|
1363
|
+
* time this index-generation step runs. */
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* #2142 MAJOR 5 (review): the single owned selection rule for "which
|
|
1368
|
+
* directories under `.planning/quick/` would/will move" — directory entries
|
|
1369
|
+
* only (symlinks are excluded here, per the MAJOR 3 note above), each
|
|
1370
|
+
* additionally guarded with `requireSafePath` (the same guard
|
|
1371
|
+
* `scanQuickTasks`/`archiveQuickTaskDirectories` use), sorted for
|
|
1372
|
+
* deterministic output. Extracted so `cmdMilestoneComplete`'s dry-run
|
|
1373
|
+
* preview, `cmdQuickArchive`'s dry-run preview, and the REAL selection inside
|
|
1374
|
+
* `archiveQuickTaskDirectories` all call this ONE function instead of each
|
|
1375
|
+
* re-deriving the rule — the "Generative Fix Divergence" anti-pattern this
|
|
1376
|
+
* repo explicitly guards against (a prior version of this code had the rule
|
|
1377
|
+
* written three times, and only the real-run copy applied `requireSafePath`,
|
|
1378
|
+
* so a dry-run preview could list a directory the real run would silently
|
|
1379
|
+
* skip).
|
|
1380
|
+
*/
|
|
1381
|
+
function listQuickTaskDirsForArchive(cwd) {
|
|
1382
|
+
const planningBase = planningPaths(cwd).planning;
|
|
1383
|
+
const quickDir = planningPaths(cwd).quick;
|
|
1384
|
+
let sourceEntries;
|
|
1385
|
+
try {
|
|
1386
|
+
sourceEntries = node_fs_1.default.readdirSync(quickDir, { withFileTypes: true });
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
// .planning/quick absent or unreadable — nothing to select.
|
|
1390
|
+
return [];
|
|
1391
|
+
}
|
|
1392
|
+
const names = [];
|
|
1393
|
+
for (const entry of sourceEntries) {
|
|
1394
|
+
if (!entry.isDirectory())
|
|
1395
|
+
continue; // excludes symlinks too — see MAJOR 3 note above
|
|
1396
|
+
try {
|
|
1397
|
+
(0, security_cjs_1.requireSafePath)(node_path_1.default.join(quickDir, entry.name), planningBase, 'quick task dir', { allowAbsolute: true });
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
continue; // symlink/escape attempt — never a candidate, in preview OR real run
|
|
1401
|
+
}
|
|
1402
|
+
names.push(entry.name);
|
|
1403
|
+
}
|
|
1404
|
+
return names.sort();
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* #2142: move each DIRECTORY entry under `.planning/quick/` into
|
|
1408
|
+
* `milestones/<version>-quick/` (collision-safe), then (re)write that
|
|
1409
|
+
* archive directory's README.md index. Sibling of `archivePhaseDirectories`
|
|
1410
|
+
* — extracted rather than inlined into `cmdMilestoneComplete` (already
|
|
1411
|
+
* cyclomatic 61) — mirroring its collision-safe destination-suffix loop,
|
|
1412
|
+
* `retryRenameSync`, and `platformEnsureDir` usage.
|
|
1413
|
+
*
|
|
1414
|
+
* `version` is ALREADY validated by `ARCHIVE_VERSION_LABEL_RE` at
|
|
1415
|
+
* `cmdMilestoneComplete`'s entry — this helper does not re-validate it, and
|
|
1416
|
+
* must only ever be called after that guard has run.
|
|
1417
|
+
*
|
|
1418
|
+
* #2142 MAJOR 3 (review): a symlink under `.planning/quick/` — even one that
|
|
1419
|
+
* targets a directory — is excluded by the `dirEntries` filter below
|
|
1420
|
+
* (`fs.Dirent.isDirectory()` returns FALSE for a symlink, regardless of what
|
|
1421
|
+
* it points at), so it is never a candidate `entry` in the first place and
|
|
1422
|
+
* `requireSafePath` below never runs against it. `requireSafePath` is
|
|
1423
|
+
* retained here as defense-in-depth for the NON-symlink path (a real
|
|
1424
|
+
* directory entry whose resolved path still needs re-validating against
|
|
1425
|
+
* `planningBase`) — the SAME guard `scanQuickTasks` (audit.cts) uses — so an
|
|
1426
|
+
* entry that fails it is skipped, never archived, never counted. See the
|
|
1427
|
+
* symlink regression tests in tests/milestone-archive.test.cjs
|
|
1428
|
+
* (`symlinkEscapeIsNeverArchivedByMilestoneComplete` /
|
|
1429
|
+
* `symlinkEscapeIsNeverArchivedByQuickArchive`) for a fixture proving neither
|
|
1430
|
+
* the symlink nor its external target is ever moved or altered — added
|
|
1431
|
+
* specifically so a future change to this filter cannot silently reopen the
|
|
1432
|
+
* escape with nothing to catch it.
|
|
1433
|
+
*
|
|
1434
|
+
* No-op (returns `{archived: 0, entries: []}`, creates NOTHING on disk) when
|
|
1435
|
+
* `.planning/quick/` does not exist or contains zero DIRECTORY entries — a
|
|
1436
|
+
* stray file with no sibling directory is neither an empty-dir case nor an
|
|
1437
|
+
* archive case.
|
|
1438
|
+
*
|
|
1439
|
+
* A mid-loop rename failure (or a failure to create the archive directory
|
|
1440
|
+
* itself) does not crash `milestone complete` — it degrades to whatever
|
|
1441
|
+
* `archived`/`entries` had already accumulated before the failure, mirroring
|
|
1442
|
+
* the `archivedCount` finally-pattern `cmdMilestoneComplete`'s own phase
|
|
1443
|
+
* archival uses a few hundred lines above (so a partial archive reports the
|
|
1444
|
+
* TRUE count, never a false `0`/`false`).
|
|
1445
|
+
*/
|
|
1446
|
+
function archiveQuickTaskDirectories(cwd, version) {
|
|
1447
|
+
const planningBase = planningPaths(cwd).planning;
|
|
1448
|
+
const quickDir = planningPaths(cwd).quick;
|
|
1449
|
+
const archiveQuickDir = node_path_1.default.join(planningBase, 'milestones', `${version}-quick`);
|
|
1450
|
+
// #2142 MAJOR 5 (review): dirNames is the SAME selection
|
|
1451
|
+
// `listQuickTaskDirsForArchive` hands to both dry-run previews — this is
|
|
1452
|
+
// the real run, so it cannot disagree with what a preview reported.
|
|
1453
|
+
const dirNames = listQuickTaskDirsForArchive(cwd);
|
|
1454
|
+
if (dirNames.length === 0) {
|
|
1455
|
+
// Boundary 0 (#2142): zero (safe) directory entries (empty dir, only
|
|
1456
|
+
// stray files, or every entry excluded by the selection rule) must not
|
|
1457
|
+
// create the archive directory. Also covers `.planning/quick/` being
|
|
1458
|
+
// absent/unreadable — `listQuickTaskDirsForArchive` degrades to `[]`.
|
|
1459
|
+
return { archiveDir: archiveQuickDir, archived: 0, entries: [] };
|
|
1460
|
+
}
|
|
1461
|
+
let archived = 0;
|
|
1462
|
+
const entries = [];
|
|
1463
|
+
try {
|
|
1464
|
+
(0, shell_command_projection_cjs_1.platformEnsureDir)(archiveQuickDir);
|
|
1465
|
+
for (const name of dirNames) {
|
|
1466
|
+
const src = node_path_1.default.join(quickDir, name);
|
|
1467
|
+
let safeSrc;
|
|
1468
|
+
try {
|
|
1469
|
+
// Re-validated here (not just trusted from the selection above) as
|
|
1470
|
+
// TOCTOU defense-in-depth: `listQuickTaskDirsForArchive` and this
|
|
1471
|
+
// rename are two separate filesystem observations, and an entry
|
|
1472
|
+
// that was a safe real directory at selection time could in theory
|
|
1473
|
+
// be swapped for a symlink before this loop reaches it.
|
|
1474
|
+
safeSrc = (0, security_cjs_1.requireSafePath)(src, planningBase, 'quick task dir', { allowAbsolute: true });
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
continue; // symlink/escape attempt — skip, not archived
|
|
1478
|
+
}
|
|
1479
|
+
// Collision-safe: if a same-named archive entry exists (re-run), suffix it.
|
|
1480
|
+
let dest = node_path_1.default.join(archiveQuickDir, name);
|
|
1481
|
+
let destName = name;
|
|
1482
|
+
let n = 1;
|
|
1483
|
+
while (node_fs_1.default.existsSync(dest)) {
|
|
1484
|
+
destName = `${name}.${n++}`;
|
|
1485
|
+
dest = node_path_1.default.join(archiveQuickDir, destName);
|
|
1486
|
+
}
|
|
1487
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(safeSrc, dest);
|
|
1488
|
+
archived++;
|
|
1489
|
+
entries.push(destName);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
/* best-effort: platformEnsureDir failed, or the rename loop failed
|
|
1494
|
+
* partway — `archived`/`entries` above already reflect exactly what
|
|
1495
|
+
* succeeded before the failure (accumulated incrementally, never lost
|
|
1496
|
+
* with the swallowed exception — mirrors the archivedCount pattern at
|
|
1497
|
+
* cmdMilestoneComplete's phase-archival block). */
|
|
1498
|
+
}
|
|
1499
|
+
// Regenerate the README from whatever is ACTUALLY on disk now — covers
|
|
1500
|
+
// both a clean full archive and a degraded partial one, and (on a re-run)
|
|
1501
|
+
// includes entries a PRIOR run already archived. Skipped only when the
|
|
1502
|
+
// archive directory itself was never created (ensureDir failed above).
|
|
1503
|
+
if (node_fs_1.default.existsSync(archiveQuickDir)) {
|
|
1504
|
+
writeQuickArchiveReadme(archiveQuickDir);
|
|
1505
|
+
}
|
|
1506
|
+
return { archiveDir: archiveQuickDir, archived, entries };
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* #2142 escalation: `milestone.archive-quick` (CLI: `milestone archive-quick`,
|
|
1510
|
+
* renamed from the original `quick.archive` per code-review FIX 1 — folded
|
|
1511
|
+
* under the existing `milestone` namespace rather than adding a new top-level
|
|
1512
|
+
* command) — the narrow archival helper the issue's own "Scope of changes"
|
|
1513
|
+
* anticipated ("a `quick.archive`-style routine"), for callers (chiefly
|
|
1514
|
+
* `gsd-core/workflows/cleanup.md`) that need to sweep
|
|
1515
|
+
* `.planning/quick/*` WITHOUT the full `milestone complete` close-out.
|
|
1516
|
+
*
|
|
1517
|
+
* `milestone complete --archive-quick` cannot be reused for this: it
|
|
1518
|
+
* hard-errors via `missingExplicitVersion` for an already-completed
|
|
1519
|
+
* milestone (no `### Phase N:` headings left in its ROADMAP window),
|
|
1520
|
+
* re-archives ROADMAP.md over the very snapshot cleanup depends on, and
|
|
1521
|
+
* appends a duplicate MILESTONES.md entry on every re-run.
|
|
1522
|
+
*
|
|
1523
|
+
* This command performs ONLY the two things `archiveQuickTaskDirectories`
|
|
1524
|
+
* already does (move `.planning/quick/*` dirs into
|
|
1525
|
+
* `milestones/<version>-quick/` + (re)write that archive's README index —
|
|
1526
|
+
* the SAME helper `cmdMilestoneComplete` calls, so the two entry points can
|
|
1527
|
+
* never diverge on step 1) plus a Quick Tasks Completed table reset. It
|
|
1528
|
+
* NEVER touches ROADMAP.md, REQUIREMENTS.md, or MILESTONES.md, and runs
|
|
1529
|
+
* NEITHER the unstarted-phase guard NOR the milestone-window/TRUNCATED
|
|
1530
|
+
* refusal — those remain `milestone complete`'s alone.
|
|
1531
|
+
*
|
|
1532
|
+
* #2142 MAJOR 6 (review): the STATE.md write now routes through
|
|
1533
|
+
* `readModifyWriteStateMd` — the same owned read-transform-write composition
|
|
1534
|
+
* `gsd-tools.cjs`'s `quick-tasks-append` handler uses (ADR-3408 §8.3 / #3469:
|
|
1535
|
+
* "the single owned composition ... so the composition cannot diverge"). A
|
|
1536
|
+
* prior version of this function called `platformWriteSync` directly with
|
|
1537
|
+
* the reset result, bypassing `syncAndPreserveStateMd` entirely — the exact
|
|
1538
|
+
* bypass shape that ADR closed. Per `src/state.cts:3289-3330`,
|
|
1539
|
+
* `readModifyWriteStateMd` ALREADY acquires its own exclusive lock
|
|
1540
|
+
* (`acquireStateLock`/`releaseStateLock`, a real `O_CREAT|O_EXCL` file lock,
|
|
1541
|
+
* not reentrant) across its own read -> transform -> write cycle — so, unlike
|
|
1542
|
+
* `cmdMilestoneComplete` (which folds the table reset into its own
|
|
1543
|
+
* pre-existing `withStateLock` transform because it ALSO needs that lock for
|
|
1544
|
+
* the closure-transition write happening in the same block), this function
|
|
1545
|
+
* must NOT wrap the call in its own `withStateLock`: doing so would acquire
|
|
1546
|
+
* the same lock file twice in the same process, and the second acquire would
|
|
1547
|
+
* spin against a lock this same call already holds until it times out.
|
|
1548
|
+
*/
|
|
1549
|
+
function cmdQuickArchive(cwd, version, options, raw) {
|
|
1550
|
+
if (!version) {
|
|
1551
|
+
error('version required for milestone.archive-quick (e.g., v1.0)');
|
|
1552
|
+
}
|
|
1553
|
+
// #2288-class security: `version` becomes a filesystem directory component
|
|
1554
|
+
// (`milestones/<version>-quick/`) that directories are MOVED into — same
|
|
1555
|
+
// guard + wording shape `cmdMilestoneComplete` uses for its own version arg.
|
|
1556
|
+
if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
|
|
1557
|
+
error(`milestone.archive-quick: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
|
|
1558
|
+
}
|
|
1559
|
+
const statePath = planningPaths(cwd).state;
|
|
1560
|
+
const planningBase = planningPaths(cwd).planning;
|
|
1561
|
+
const toPosixRel = (p) => node_path_1.default.relative(cwd, p).split(node_path_1.default.sep).join('/');
|
|
1562
|
+
// --dry-run: preview only, mutates nothing. #2142 MAJOR 5 (review): routed
|
|
1563
|
+
// through the SAME `listQuickTaskDirsForArchive` selection
|
|
1564
|
+
// `cmdMilestoneComplete`'s own dry-run preview and the real
|
|
1565
|
+
// `archiveQuickTaskDirectories` both use, so all three can never disagree.
|
|
1566
|
+
if (options.dryRun) {
|
|
1567
|
+
const quickDirsToArchive = listQuickTaskDirsForArchive(cwd);
|
|
1568
|
+
output({
|
|
1569
|
+
dry_run: true,
|
|
1570
|
+
version,
|
|
1571
|
+
would_archive: quickDirsToArchive,
|
|
1572
|
+
archive_dir: toPosixRel(node_path_1.default.join(planningBase, 'milestones', `${version}-quick`)),
|
|
1573
|
+
}, raw);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
const quickArchiveResult = archiveQuickTaskDirectories(cwd, version);
|
|
1577
|
+
const warnings = [];
|
|
1578
|
+
let stateUpdated = false;
|
|
1579
|
+
// Same silent/surfaced rule `cmdMilestoneComplete` applies: only attempt
|
|
1580
|
+
// the reset when something actually moved, and treat the
|
|
1581
|
+
// QUICK_TASKS_SECTION_ABSENT sentinel as a silent no-op (the section is
|
|
1582
|
+
// created lazily by quick.md Step 7b and absent from templates/state.md,
|
|
1583
|
+
// so absence is the common case, not an anomaly). Any other reset failure
|
|
1584
|
+
// is surfaced via `warnings`, never thrown.
|
|
1585
|
+
//
|
|
1586
|
+
// #2142 MAJOR 6 (review): routed through `readModifyWriteStateMd` (see the
|
|
1587
|
+
// docstring above) instead of a bare `platformWriteSync` — the transform
|
|
1588
|
+
// returns the ORIGINAL content unchanged whenever the reset did not apply
|
|
1589
|
+
// (sentinel-absent or a genuine failure), so `readModifyWriteStateMd`'s own
|
|
1590
|
+
// no-op guard (#948, state.cts:3304) skips the write and its `false`
|
|
1591
|
+
// return accurately reports "nothing was written" — the same "state_updated
|
|
1592
|
+
// must report accurately" contract the prior direct-write version upheld.
|
|
1593
|
+
if (quickArchiveResult.archived > 0 && node_fs_1.default.existsSync(statePath)) {
|
|
1594
|
+
let resetWarning = null;
|
|
1595
|
+
stateUpdated = readModifyWriteStateMd(statePath, (content) => {
|
|
1596
|
+
const { content: nextContent, warning } = applyQuickTasksReset(content);
|
|
1597
|
+
resetWarning = warning;
|
|
1598
|
+
return nextContent;
|
|
1599
|
+
}, cwd);
|
|
1600
|
+
if (resetWarning) {
|
|
1601
|
+
warnings.push(resetWarning);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
output({
|
|
1605
|
+
version,
|
|
1606
|
+
archived: quickArchiveResult.archived,
|
|
1607
|
+
entries: quickArchiveResult.entries,
|
|
1608
|
+
archive_dir: toPosixRel(quickArchiveResult.archiveDir),
|
|
1609
|
+
state_updated: stateUpdated,
|
|
1610
|
+
warnings,
|
|
1611
|
+
}, raw, `${quickArchiveResult.archived} quick task director${quickArchiveResult.archived === 1 ? 'y' : 'ies'} archived`);
|
|
1612
|
+
}
|
|
903
1613
|
module.exports = {
|
|
904
1614
|
cmdRequirementsMarkComplete,
|
|
905
1615
|
cmdRequirementsReadyIds,
|
|
906
1616
|
cmdRequirementsRevertPhase,
|
|
907
1617
|
cmdMilestoneComplete,
|
|
908
1618
|
cmdPhasesClear,
|
|
1619
|
+
cmdQuickArchive,
|
|
1620
|
+
buildQuickArchiveIndex,
|
|
909
1621
|
};
|