@opengsd/gsd-core 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +5 -5
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +37 -0
- package/agents/gsd-planner.md +44 -46
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +12 -3
- package/bin/install.js +841 -971
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +469 -88
- package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
- package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
- package/gsd-core/bin/lib/api-coverage.cjs +3 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +876 -240
- package/gsd-core/bin/lib/broken-windows.cjs +1 -1
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +575 -101
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +495 -22
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +71 -37
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +651 -86
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +75 -0
- package/gsd-core/bin/lib/config.cjs +10 -1
- package/gsd-core/bin/lib/core-utils.cjs +127 -29
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +155 -20
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init.cjs +321 -129
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +745 -258
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +134 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
- package/gsd-core/bin/lib/io.cjs +10 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +754 -70
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +444 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +125 -18
- package/gsd-core/bin/lib/phase.cjs +646 -143
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +385 -94
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
- package/gsd-core/bin/lib/smart-entry.cjs +142 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +371 -117
- package/gsd-core/bin/lib/state.cjs +1794 -357
- package/gsd-core/bin/lib/surface.cjs +23 -9
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
- package/gsd-core/bin/lib/uat.cjs +399 -56
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +24 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +258 -8
- package/gsd-core/bin/lib/verify.cjs +368 -888
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/autonomous.md +1 -1
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +38 -12
- package/gsd-core/workflows/complete-milestone.md +141 -18
- package/gsd-core/workflows/debug.md +7 -5
- package/gsd-core/workflows/diagnose-issues.md +35 -9
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
- package/gsd-core/workflows/execute-phase.md +38 -50
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +5 -5
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone.md +14 -9
- package/gsd-core/workflows/new-project.md +14 -14
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase.md +41 -17
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress.md +34 -6
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
- package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
- package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
- package/gsd-core/workflows/quick.md +35 -15
- package/gsd-core/workflows/review.md +26 -5
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/spec-phase.md +30 -12
- package/gsd-core/workflows/sync-skills.md +63 -8
- package/gsd-core/workflows/transition.md +46 -11
- package/gsd-core/workflows/ui-phase.md +5 -5
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update.md +1 -1
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +9 -7
- package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +38 -24
- package/hooks/dist/gsd-statusline.js +18 -0
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/gsd-agent-isolation-guard.js +103 -14
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +77 -2
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +38 -24
- package/hooks/gsd-statusline.js +18 -0
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/package.json +21 -9
- package/pi/gsd.cjs +19 -5
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +21 -10
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +25 -0
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -43,6 +43,8 @@ exports.projectPersistentPathExportActions = projectPersistentPathExportActions;
|
|
|
43
43
|
exports.isSpawnTimeout = isSpawnTimeout;
|
|
44
44
|
exports.execGit = execGit;
|
|
45
45
|
exports.execNpm = execNpm;
|
|
46
|
+
exports.resolveExecutableBinary = resolveExecutableBinary;
|
|
47
|
+
exports.projectSpawnInvocation = projectSpawnInvocation;
|
|
46
48
|
exports.execTool = execTool;
|
|
47
49
|
exports.resolveGsdToolsPath = resolveGsdToolsPath;
|
|
48
50
|
exports.dispatchGsdCommand = dispatchGsdCommand;
|
|
@@ -58,6 +60,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
58
60
|
// can intercept calls from this seam — destructured imports capture references
|
|
59
61
|
// at load time and become un-mockable.
|
|
60
62
|
const node_child_process_1 = __importDefault(require("node:child_process"));
|
|
63
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
61
64
|
/**
|
|
62
65
|
* Convert a filesystem path to POSIX form (forward slashes) by translating the
|
|
63
66
|
* platform-native separator. Single seam for native→POSIX conversion.
|
|
@@ -306,7 +309,7 @@ function isManagedHookCommand(commandText, opts = {}) {
|
|
|
306
309
|
return false;
|
|
307
310
|
}
|
|
308
311
|
for (const basename of managedBasenames) {
|
|
309
|
-
const escapedBasename =
|
|
312
|
+
const escapedBasename = (0, pattern_cjs_1.escapeRegex)(basename);
|
|
310
313
|
const pattern = new RegExp(`(^|[\\\\/\\s"'` + '`' + `])${escapedBasename}(?=$|[\\s"'` + '`' + `])`);
|
|
311
314
|
if (pattern.test(normalizedCommand))
|
|
312
315
|
return true;
|
|
@@ -589,15 +592,283 @@ function execNpm(args, opts = {}) {
|
|
|
589
592
|
});
|
|
590
593
|
return _spawnResult(result, 'npm');
|
|
591
594
|
}
|
|
595
|
+
/**
|
|
596
|
+
* Default PATHEXT when Windows does not supply one. Matches the value
|
|
597
|
+
* `gsd-tools.cjs`'s `resolveSpawnBinary` shipped in #3275; kept identical so the
|
|
598
|
+
* delegation is a behavior-preserving move rather than a redefinition.
|
|
599
|
+
*/
|
|
600
|
+
const DEFAULT_PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
|
601
|
+
/** Windows extensions that must be mediated through cmd.exe rather than spawned. */
|
|
602
|
+
const CMD_MEDIATED_EXT = /\.(cmd|bat)$/i;
|
|
603
|
+
function _isFile(candidate, requireExecutable = false, platform = process.platform) {
|
|
604
|
+
try {
|
|
605
|
+
if (!node_fs_1.default.statSync(candidate).isFile())
|
|
606
|
+
return false;
|
|
607
|
+
if (requireExecutable && platform !== 'win32')
|
|
608
|
+
node_fs_1.default.accessSync(candidate, node_fs_1.default.constants.X_OK);
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Missing, unreadable (EACCES), a broken link, or (when requireExecutable
|
|
613
|
+
// is set) not executable — all mean "not this one".
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Read an environment variable by name, case-insensitively.
|
|
619
|
+
*
|
|
620
|
+
* Windows environment variable names are case-insensitive and conventionally
|
|
621
|
+
* cased `Path` / `ComSpec`, and `process.env` is a case-insensitive proxy that
|
|
622
|
+
* hides the difference. Spreading it (`{ ...process.env, ...opts.env }`, which
|
|
623
|
+
* `execTool` does whenever a caller supplies `opts.env`) produces a PLAIN object
|
|
624
|
+
* that keeps the OS's actual casing and loses the proxy — so an exact-case
|
|
625
|
+
* `env['PATH']` lookup returns undefined there and the PATH scan silently sees
|
|
626
|
+
* nothing. Caught by the Windows CI lane on #3617; the #3445 tests never hit it
|
|
627
|
+
* because they pass uppercase keys explicitly.
|
|
628
|
+
*
|
|
629
|
+
* Exact match wins when present, so a caller who sets the canonical name pays
|
|
630
|
+
* no scan.
|
|
631
|
+
*/
|
|
632
|
+
function _envGet(env, name) {
|
|
633
|
+
const exact = env[name];
|
|
634
|
+
if (exact !== undefined)
|
|
635
|
+
return exact;
|
|
636
|
+
const lower = name.toLowerCase();
|
|
637
|
+
for (const key of Object.keys(env)) {
|
|
638
|
+
if (key.toLowerCase() === lower)
|
|
639
|
+
return env[key];
|
|
640
|
+
}
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
/** cmd.exe's own quoting rule inside a `/c` string: a literal quote is doubled. */
|
|
644
|
+
function _cmdQuoteToken(token) {
|
|
645
|
+
return `"${token.replace(/"/g, '""')}"`;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Build a single verbatim command-line string for `cmd.exe /c` where every
|
|
649
|
+
* token is force-quoted, then wrap the whole thing in one more outer pair.
|
|
650
|
+
* cmd.exe strips exactly one outer quote pair when the string begins with a
|
|
651
|
+
* quote and contains at least two — so the outer wrap disappears and what's
|
|
652
|
+
* left is a sequence of individually-quoted tokens. Force-quoting is the
|
|
653
|
+
* point: an unquoted `a&b` is split by cmd's own metacharacter parsing, but a
|
|
654
|
+
* quoted `"a&b"` is one literal argument.
|
|
655
|
+
*/
|
|
656
|
+
function _buildVerbatimCmdLine(target, args) {
|
|
657
|
+
return `"${[target, ...args].map(_cmdQuoteToken).join(' ')}"`;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* #3411: resolve a DECLARED command name to the file a spawn can actually start.
|
|
661
|
+
* The single canonical answer to "where is this binary?" for the whole tree.
|
|
662
|
+
*
|
|
663
|
+
* This is the seam `CONTEXT.md` declares as "All OS-facing I/O; single platform
|
|
664
|
+
* seam". Four divergent implementations of this logic existed (#3411): `execNpm`'s
|
|
665
|
+
* `shell:true`, `execTool`'s absence of any handling, `gsd-tools.cjs`'s private
|
|
666
|
+
* scan, and `fallow-runner.cts`'s own candidate array. #3275 folded two of them
|
|
667
|
+
* together in `bin/`; this lifts that resolver into the seam so `bin/` delegates
|
|
668
|
+
* instead of owning a copy.
|
|
669
|
+
*
|
|
670
|
+
* **win32** tries PATHEXT entries ONLY — never the bare name. npm global installs
|
|
671
|
+
* drop an EXTENSIONLESS POSIX sh shim (`...\npm\codex`) next to `codex.CMD`; a
|
|
672
|
+
* bare-name-first scan resolves to it, the cmd.exe mediation gate sees no `.cmd`,
|
|
673
|
+
* and the ENOENT returns unchanged (field-reported on Windows 11 — see #3275).
|
|
674
|
+
* A name that ALREADY carries a PATHEXT-listed extension is tried as-is first, so
|
|
675
|
+
* `foo.exe` resolves to `foo.exe` rather than being probed as `foo.exe.EXE`; a
|
|
676
|
+
* suffix that is not in PATHEXT (`foo.txt`) is not an extension and only feeds the
|
|
677
|
+
* append loop.
|
|
678
|
+
*
|
|
679
|
+
* **POSIX** answers EXISTENCE by scanning PATH for the bare name. `execTool` does
|
|
680
|
+
* NOT consult this on POSIX — the bare name goes to spawnSync unchanged and Node's
|
|
681
|
+
* own PATH search does the work, so macOS/Linux behavior is untouched (#3275
|
|
682
|
+
* acceptance contract).
|
|
683
|
+
*
|
|
684
|
+
* Path-like names (any `/` or `\`) bypass the PATH scan: the name is already an
|
|
685
|
+
* address, so it passes through when it names an existing file.
|
|
686
|
+
*
|
|
687
|
+
* **`opts.prependPaths`** — directories searched BEFORE `env.PATH`, in array
|
|
688
|
+
* order (e.g. a project-local `node_modules/.bin`). Defaults to `[]`, so
|
|
689
|
+
* Phase 1's callers (`execTool`, and `gsd-tools.cjs`'s `resolveSpawnBinary` /
|
|
690
|
+
* `deps.spawn` / `hasBinary`), which set neither new option, are byte-identical
|
|
691
|
+
* to today. The existing per-directory candidate logic (win32 as-is-then-append-
|
|
692
|
+
* PATHEXT; POSIX bare name) applies to prepended directories exactly as it does
|
|
693
|
+
* to `PATH` segments — there is no special-casing.
|
|
694
|
+
*
|
|
695
|
+
* **`opts.requireExecutable`** — when `true` and the platform is not `win32`,
|
|
696
|
+
* a candidate must additionally pass `fs.accessSync(candidate, fs.constants.X_OK)`
|
|
697
|
+
* to count as a match. On `win32` this is a no-op (mode bits do not mean
|
|
698
|
+
* execute on Windows — the same carve-out `fallow-runner`'s prior private
|
|
699
|
+
* resolver already had). Defaults to `false`, so `accessSync` is never called
|
|
700
|
+
* unless a caller opts in. It is opt-in rather than the default because making
|
|
701
|
+
* `X_OK` unconditional would break #3445's suite: those tests stage candidates
|
|
702
|
+
* with plain `fs.writeFileSync` and never set an exec bit (the repo bans
|
|
703
|
+
* `chmod` in tests), so every one of them would resolve to `null` on POSIX.
|
|
704
|
+
*
|
|
705
|
+
* **`opts.pathOverride`** — "search THIS PATH, but read everything else —
|
|
706
|
+
* PATHEXT included — from the ambient environment." When set (`!== undefined`),
|
|
707
|
+
* the PATH search segments come from splitting `pathOverride` on `path.delimiter`
|
|
708
|
+
* instead of from `env.PATH`; `pathOverride: ''` means an explicit EMPTY search
|
|
709
|
+
* path (zero segments), never a fallback to `env.PATH` — use `!== undefined`,
|
|
710
|
+
* not truthiness, to tell "caller supplied a PATH string" apart from "caller
|
|
711
|
+
* supplied nothing". `opts.prependPaths` still comes first. PATHEXT resolution
|
|
712
|
+
* is UNAFFECTED by this option — it still reads from `env` (which defaults to
|
|
713
|
+
* `process.env`) exactly as it does when `pathOverride` is omitted. This exists
|
|
714
|
+
* so a caller that already has its own search path in hand (e.g.
|
|
715
|
+
* `resolveFallowBinary`'s `envPath`) does not have to hand-thread PATHEXT
|
|
716
|
+
* alongside it — a private PATHEXT read is the very shape
|
|
717
|
+
* `local/no-private-binary-resolution` forbids outside this seam.
|
|
718
|
+
*
|
|
719
|
+
* @returns the resolved path, or `null` when nothing matched. Callers fall back to
|
|
720
|
+
* the declared name on `null` so a genuine ENOENT still surfaces (#3086).
|
|
721
|
+
*/
|
|
722
|
+
function resolveExecutableBinary(name, opts = {}) {
|
|
723
|
+
if (!name)
|
|
724
|
+
return null;
|
|
725
|
+
const requireExecutable = opts.requireExecutable ?? false;
|
|
726
|
+
const platform = opts.platform ?? process.platform;
|
|
727
|
+
if (name.includes('/') || name.includes('\\')) {
|
|
728
|
+
return _isFile(name, requireExecutable, platform) ? name : null;
|
|
729
|
+
}
|
|
730
|
+
const env = opts.env ?? process.env;
|
|
731
|
+
const rawPath = opts.pathOverride !== undefined ? opts.pathOverride : (_envGet(env, 'PATH') || '');
|
|
732
|
+
const pathSegments = String(rawPath).split(node_path_1.default.delimiter).filter(Boolean);
|
|
733
|
+
const segments = [...(opts.prependPaths ?? []), ...pathSegments];
|
|
734
|
+
if (platform !== 'win32') {
|
|
735
|
+
for (const dir of segments) {
|
|
736
|
+
const candidate = node_path_1.default.join(dir, name);
|
|
737
|
+
if (_isFile(candidate, requireExecutable, platform))
|
|
738
|
+
return candidate;
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
const exts = String(_envGet(env, 'PATHEXT') || DEFAULT_PATHEXT).split(';').filter(Boolean);
|
|
743
|
+
// A name already ending in a PATHEXT-listed extension is an address, not a stem:
|
|
744
|
+
// probing `foo.exe` as `foo.exe.EXE` would miss the file sitting right there.
|
|
745
|
+
// Compared case-insensitively because PATHEXT casing is not guaranteed.
|
|
746
|
+
const lower = name.toLowerCase();
|
|
747
|
+
const carriesKnownExt = exts.some((ext) => lower.endsWith(ext.toLowerCase()));
|
|
748
|
+
for (const dir of segments) {
|
|
749
|
+
if (carriesKnownExt) {
|
|
750
|
+
const asIs = node_path_1.default.join(dir, name);
|
|
751
|
+
if (_isFile(asIs, requireExecutable, platform))
|
|
752
|
+
return asIs;
|
|
753
|
+
}
|
|
754
|
+
for (const ext of exts) {
|
|
755
|
+
const candidate = node_path_1.default.join(dir, name + ext);
|
|
756
|
+
if (_isFile(candidate, requireExecutable, platform))
|
|
757
|
+
return candidate;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* #3411: project a declared `(command, args)` into the pair `spawnSync` can
|
|
764
|
+
* actually execute on this platform.
|
|
765
|
+
*
|
|
766
|
+
* Resolution alone does not make Windows work: `CreateProcess` cannot execute a
|
|
767
|
+
* `.cmd`/`.bat` at all, so the cmd.exe mediation is inseparable from the lookup.
|
|
768
|
+
* Exporting only the resolver would leave every caller to re-derive that half —
|
|
769
|
+
* which is precisely how #3411's four copies accumulated.
|
|
770
|
+
*
|
|
771
|
+
* cmd.exe is invoked as an ordinary program with an EXPLICIT argv array, never via
|
|
772
|
+
* `shell: true`. `shell:true` on Windows is the mechanism behind CVE-2024-27980
|
|
773
|
+
* (argument injection through `.bat`/`.cmd`), and Node 26 deprecates it with an
|
|
774
|
+
* args array (DEP0190) because arguments are concatenated rather than escaped.
|
|
775
|
+
*
|
|
776
|
+
* Mediation fires when the target — the resolved path, or the declared name when
|
|
777
|
+
* resolution found nothing — carries a `.cmd`/`.bat` extension. A BARE name that
|
|
778
|
+
* resolved to nothing is passed through verbatim so the spawn fails with ENOENT;
|
|
779
|
+
* mediating it would turn `{exitCode:127, 'foo: not found'}` into cmd.exe's exit
|
|
780
|
+
* 9009 and silently change the not-found contract callers depend on.
|
|
781
|
+
*
|
|
782
|
+
* POSIX is a strict no-op: the declared command is returned unchanged and the
|
|
783
|
+
* environment is never consulted.
|
|
784
|
+
*
|
|
785
|
+
* The mediated command line is built VERBATIM rather than left to libuv: libuv's
|
|
786
|
+
* `quote_cmd_arg` only force-quotes an argument that contains a space, tab, or
|
|
787
|
+
* quote — it does not know about cmd.exe metacharacters (`&`, `|`, `>`, `<`,
|
|
788
|
+
* `^`, ...) at all, so an argument like `a&calc` reaches cmd.exe unquoted and
|
|
789
|
+
* gets re-parsed as two commands (the CVE-2024-27980 argument-injection class).
|
|
790
|
+
* Node's own CVE-2024-27980 escaping does not help here because it only fires
|
|
791
|
+
* when the spawned FILE itself is a `.bat`/`.cmd` — in this seam the spawned
|
|
792
|
+
* file is `cmd.exe`, not the target. Building the line ourselves and passing
|
|
793
|
+
* `windowsVerbatimArguments: true` (the shape Rust's std uses for the sibling
|
|
794
|
+
* CVE-2024-24576) means every token is force-quoted inside one outer pair, so
|
|
795
|
+
* a metacharacter inside a quoted token can never split the command line.
|
|
796
|
+
*
|
|
797
|
+
* KNOWN LIMIT: `%VAR%` still expands inside a cmd `/c` string, and there is no
|
|
798
|
+
* escape for `%` outside a batch file — an argument containing `%FOO%` is
|
|
799
|
+
* substituted with the environment value regardless of quoting. That is an
|
|
800
|
+
* information-disclosure limit, not arbitrary execution, and it's the same
|
|
801
|
+
* limit Rust's std documents for its own `CommandExt::raw_arg` escape hatch.
|
|
802
|
+
*
|
|
803
|
+
* CALLER CHOICE: this function's return value carries two independently
|
|
804
|
+
* adoptable pieces of information, and a caller may take either, both, or
|
|
805
|
+
* neither. `windowsVerbatimArguments: true` marks the cases where mediation
|
|
806
|
+
* was REQUIRED — the caller MUST adopt `command`+`args` together, since a
|
|
807
|
+
* `.cmd`/`.bat` genuinely cannot be spawned any other way. A merely-resolved
|
|
808
|
+
* `.exe` path (no mediation flag set) is only an OFFER: a caller may decline
|
|
809
|
+
* it and keep spawning the declared name instead, to hold its own observable
|
|
810
|
+
* contract stable. `execTool` (this file, below) is exactly such a caller —
|
|
811
|
+
* it adopts the mediated pair when `windowsVerbatimArguments` is set, but
|
|
812
|
+
* otherwise passes the declared `program`/`args` through untouched.
|
|
813
|
+
*/
|
|
814
|
+
function projectSpawnInvocation(command, args = [], opts = {}) {
|
|
815
|
+
const platform = opts.platform ?? process.platform;
|
|
816
|
+
if (platform !== 'win32')
|
|
817
|
+
return { command, args, resolved: null };
|
|
818
|
+
const env = opts.env ?? process.env;
|
|
819
|
+
const resolved = resolveExecutableBinary(command, { platform, env });
|
|
820
|
+
// Mediate against the resolved path when we have one, else against the declared
|
|
821
|
+
// name. An unresolved name is mediated ONLY when it already declares .cmd/.bat:
|
|
822
|
+
// PATH-only resolution misses a batch file sitting in the current directory,
|
|
823
|
+
// which `cmd.exe /c` still finds — the behavior gsd-tools.cjs shipped before
|
|
824
|
+
// this consolidation, preserved here rather than silently narrowed.
|
|
825
|
+
const target = resolved ?? command;
|
|
826
|
+
if (!CMD_MEDIATED_EXT.test(node_path_1.default.basename(target))) {
|
|
827
|
+
// A BARE name that resolved to nothing is passed through untouched so the
|
|
828
|
+
// spawn fails with ENOENT. Mediating it would turn {exitCode:127,
|
|
829
|
+
// '<name>: not found'} into cmd.exe's exit 9009 and silently change the
|
|
830
|
+
// not-found contract `_spawnResult` and its 53 dependent files rely on.
|
|
831
|
+
return resolved ? { command: resolved, args, resolved } : { command, args, resolved: null };
|
|
832
|
+
}
|
|
833
|
+
// A CR/LF cannot be represented inside a Windows command line at all — cmd.exe
|
|
834
|
+
// treats it as a line terminator, so mediating it would silently truncate the
|
|
835
|
+
// argument rather than pass it through. Fail visibly instead: fall back to the
|
|
836
|
+
// unmediated shape so the spawn either fails with ENOENT (bare unresolved name)
|
|
837
|
+
// or hands the raw string to CreateProcess, whichever the caller was already
|
|
838
|
+
// prepared to see for a non-.cmd/.bat target.
|
|
839
|
+
if (/[\r\n]/.test(target) || args.some((a) => /[\r\n]/.test(a))) {
|
|
840
|
+
return resolved ? { command: resolved, args, resolved } : { command, args, resolved: null };
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
command: String(_envGet(env, 'ComSpec') || 'cmd.exe'),
|
|
844
|
+
args: ['/d', '/s', '/c', _buildVerbatimCmdLine(target, args)],
|
|
845
|
+
resolved,
|
|
846
|
+
windowsVerbatimArguments: true,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
592
849
|
function execTool(program, args, opts = {}) {
|
|
593
|
-
|
|
850
|
+
// #3411: Windows cannot spawn a .cmd/.bat at all — CreateProcess refuses it —
|
|
851
|
+
// so those are mediated through cmd.exe. Everything else keeps the DECLARED
|
|
852
|
+
// program name: libuv's CreateProcess path already performs PATH + PATHEXT
|
|
853
|
+
// search, so resolving a .exe here would buy nothing and would change what
|
|
854
|
+
// this seam's 167 dependents observe being spawned. `tests/graphify.test.cjs`
|
|
855
|
+
// pins that contract by spying on spawnSync's first argument. POSIX never
|
|
856
|
+
// reaches the mediation branch at all.
|
|
857
|
+
const spawnEnv = opts.env ? { ...process.env, ...opts.env } : undefined;
|
|
858
|
+
const invocation = projectSpawnInvocation(program, args, { env: spawnEnv ?? process.env });
|
|
859
|
+
const mediated = invocation.windowsVerbatimArguments === true;
|
|
860
|
+
const result = node_child_process_1.default.spawnSync(mediated ? invocation.command : program, mediated ? invocation.args : args, {
|
|
594
861
|
cwd: opts.cwd,
|
|
595
|
-
env:
|
|
862
|
+
env: spawnEnv,
|
|
596
863
|
encoding: 'utf-8',
|
|
597
864
|
stdio: 'pipe',
|
|
598
865
|
timeout: opts.timeout ?? 30_000,
|
|
599
866
|
windowsHide: true,
|
|
867
|
+
...(mediated ? { windowsVerbatimArguments: true } : {}),
|
|
600
868
|
});
|
|
869
|
+
// Stamp the DECLARED name, never the resolved path: `_spawnResult` renders
|
|
870
|
+
// `${program}: not found`, and callers across 53 files match on the string they
|
|
871
|
+
// passed. Resolution must not leak an absolute path into that message.
|
|
601
872
|
return _spawnResult(result, program);
|
|
602
873
|
}
|
|
603
874
|
/**
|
|
@@ -704,6 +975,7 @@ function probeTty(opts = {}) {
|
|
|
704
975
|
const ttyPath = node_child_process_1.default.execFileSync('tty', [], {
|
|
705
976
|
encoding: 'utf-8',
|
|
706
977
|
stdio: ['inherit', 'pipe', 'ignore'],
|
|
978
|
+
timeout: 5_000,
|
|
707
979
|
}).trim();
|
|
708
980
|
if (!ttyPath || ttyPath === 'not a tty')
|
|
709
981
|
return null;
|
|
@@ -57,10 +57,13 @@ const phaseLifecycle = require("./phase-lifecycle.cjs");
|
|
|
57
57
|
const { deriveProgressFromRoadmap } = phaseLifecycle;
|
|
58
58
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
59
|
const stateDocument = require("./state-document.cjs");
|
|
60
|
-
const {
|
|
60
|
+
const { stateFieldValue } = stateDocument;
|
|
61
61
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
62
62
|
const phaseId = require("./phase-id.cjs");
|
|
63
|
-
const { comparePhaseNum, extractPhaseToken, normalizePhaseName,
|
|
63
|
+
const { comparePhaseNum, extractPhaseToken, matchPhaseDirs, normalizePhaseName, stripProjectCodePrefix } = phaseId;
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
65
|
+
const stateMod = require("./state.cjs");
|
|
66
|
+
const { readStateHeadFreshness } = stateMod;
|
|
64
67
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
65
68
|
const unusableInput = require("./unusable-input.cjs");
|
|
66
69
|
const { warnUnusableInput, UNUSABLE_REASON } = unusableInput;
|
|
@@ -86,15 +89,6 @@ exports.SITUATIONS = Object.freeze([
|
|
|
86
89
|
'unknown',
|
|
87
90
|
]);
|
|
88
91
|
// ─── Detection ─────────────────────────────────────────────────────────────────
|
|
89
|
-
/** Frontmatter scalar helper: prefer YAML frontmatter, fall back to body field. */
|
|
90
|
-
function fmScalar(fm, body, key, bodyField) {
|
|
91
|
-
const v = fm[key];
|
|
92
|
-
if (typeof v === 'string' && v.trim())
|
|
93
|
-
return v.trim();
|
|
94
|
-
if (typeof v === 'number' || typeof v === 'boolean')
|
|
95
|
-
return String(v);
|
|
96
|
-
return stateExtractField(body, bodyField);
|
|
97
|
-
}
|
|
98
92
|
/** Read a scalar value from a nested frontmatter object (e.g. progress.total_phases). */
|
|
99
93
|
function fmScalarKey(obj, key) {
|
|
100
94
|
if (!obj || typeof obj !== 'object')
|
|
@@ -115,17 +109,115 @@ function parseIntOrNull(s) {
|
|
|
115
109
|
}
|
|
116
110
|
function phaseTokenFromDirName(name) {
|
|
117
111
|
const token = extractPhaseToken(name);
|
|
118
|
-
|
|
112
|
+
// #2528: the shape probe runs on the PROJECT-CODE-STRIPPED token. A prefixed
|
|
113
|
+
// directory tokenizes to `MEM-05-80-20`, which does not start with a digit,
|
|
114
|
+
// so the unstripped probe rejected it and the entry was dropped before any
|
|
115
|
+
// resolution ran — every phase in a project-coded plan was invisible here.
|
|
116
|
+
// The full token is still what is returned: `comparePhaseNum` strips the
|
|
117
|
+
// prefix itself, so the sort is unaffected, and `matchPhaseDirs` needs the
|
|
118
|
+
// real directory name.
|
|
119
|
+
const probe = stripProjectCodePrefix(token);
|
|
120
|
+
return /^\d+(?:[A-Z])?(?:\.\d+)*(?:-|$)/i.test(probe) ? token : null;
|
|
119
121
|
}
|
|
120
122
|
/**
|
|
121
123
|
* Parse a `last_activity` value that may be an ISO date or a free-form string
|
|
122
124
|
* into an epoch-ms timestamp. Returns null when unparseable.
|
|
125
|
+
*
|
|
126
|
+
* #2570: `last_activity` routinely carries a trailing " — <description>" — the
|
|
127
|
+
* shape `templates/state.md` itself prescribes (`Last activity: [YYYY-MM-DD] —
|
|
128
|
+
* [What happened]`), which gsd-core's own STATE.md mirrors into frontmatter.
|
|
129
|
+
* `Date.parse` on the whole string returns NaN, and because `staleActivity`
|
|
130
|
+
* treats null as "not stale" (fails open), the ONLY idle/staleness detector
|
|
131
|
+
* never fired on any project whose last_activity retained its description.
|
|
132
|
+
* Be liberal in what we accept (Postel): read the leading ISO date/time token
|
|
133
|
+
* when the value carries one, so the description suffix — whatever separator
|
|
134
|
+
* (em dash or hyphen) it uses — no longer silently blinds the detector; fall
|
|
135
|
+
* back to a whole-string parse for any other shape a hand edit might use.
|
|
123
136
|
*/
|
|
137
|
+
/** Leading ISO date, with an optional time-of-day and offset. */
|
|
138
|
+
const ISO_LEADING_RE = /^(\d{4})-(\d{2})-(\d{2})((?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?)/;
|
|
139
|
+
/**
|
|
140
|
+
* A NAMED timezone designator at the start of the un-reconstructable remainder
|
|
141
|
+
* (#2571). ISO_LEADING_RE's offset group captures only `Z` / `±HH:MM`, so a
|
|
142
|
+
* named zone (GMT, EST, ...) is not in the leading token — it sits here.
|
|
143
|
+
* Reconstructing the token without it would let Date.parse read the time as
|
|
144
|
+
* LOCAL, shifting the instant by the host's offset (a wrong, host-dependent
|
|
145
|
+
* value), so a zone-shaped remainder must fail open (ADR-227).
|
|
146
|
+
*
|
|
147
|
+
* The shape is a short all-caps run (2–5 letters) that stands alone — the
|
|
148
|
+
* negative lookahead excludes the first letter of a Capitalised word like
|
|
149
|
+
* "Milestone", and an optional trailing offset is subsumed because the leading
|
|
150
|
+
* all-caps run already matches. Everything else — a lowercase or Capitalised
|
|
151
|
+
* description, a separator — is describable text and is reconstructed from the
|
|
152
|
+
* leading date.
|
|
153
|
+
*
|
|
154
|
+
* Consulted ONLY when the leading token captured a time-of-day (see the caller):
|
|
155
|
+
* a zone designator qualifies a clock time, so a BARE date can carry no zone
|
|
156
|
+
* hazard — reconstructing it is always just that date's UTC midnight, whatever
|
|
157
|
+
* trails it. Gating on the time keeps a description that merely opens with a
|
|
158
|
+
* tech acronym ("2026-06-08 CI green", "API refactor") on the reconstruct path
|
|
159
|
+
* instead of failing open. The prior "any letter" guard was too liberal — it
|
|
160
|
+
* failed open on every letter-led description and re-opened #2570.
|
|
161
|
+
*/
|
|
162
|
+
const ZONE_DESIGNATOR_RE = /^\s*[A-Z]{2,5}(?![A-Za-z])/;
|
|
163
|
+
/**
|
|
164
|
+
* True only when y/m/d name a date that actually exists on the calendar.
|
|
165
|
+
*
|
|
166
|
+
* `Date.parse` validates shape but not value: it rolls an out-of-range day
|
|
167
|
+
* FORWARD rather than rejecting it (`2026-02-30` -> `2026-03-02`,
|
|
168
|
+
* `2026-04-31` -> `2026-05-01`). Shape-only validation would therefore
|
|
169
|
+
* propagate a different, wrong instant instead of failing safe — precisely
|
|
170
|
+
* what ADR-227 ("validate shape AND value; on failure of either layer coerce
|
|
171
|
+
* to the contract's safe default, never propagate") exists to prevent. A
|
|
172
|
+
* round-trip through Date.UTC detects the rollover: any component the
|
|
173
|
+
* constructor normalised comes back changed.
|
|
174
|
+
*/
|
|
175
|
+
function isRealCalendarDate(year, month, day) {
|
|
176
|
+
if (month < 1 || month > 12 || day < 1 || day > 31)
|
|
177
|
+
return false;
|
|
178
|
+
const probe = new Date(Date.UTC(year, month - 1, day));
|
|
179
|
+
return (probe.getUTCFullYear() === year &&
|
|
180
|
+
probe.getUTCMonth() === month - 1 &&
|
|
181
|
+
probe.getUTCDate() === day);
|
|
182
|
+
}
|
|
124
183
|
function parseActivityTimestamp(raw) {
|
|
125
184
|
if (!raw)
|
|
126
185
|
return null;
|
|
127
|
-
const
|
|
128
|
-
|
|
186
|
+
const trimmed = raw.trim();
|
|
187
|
+
const iso = trimmed.match(ISO_LEADING_RE);
|
|
188
|
+
if (iso) {
|
|
189
|
+
const [, year, month, day, time] = iso;
|
|
190
|
+
// Reject an impossible calendar date outright rather than letting
|
|
191
|
+
// Date.parse substitute a rolled-forward one. null = "no activity signal",
|
|
192
|
+
// the safe default staleActivity already fails open on.
|
|
193
|
+
if (!isRealCalendarDate(Number(year), Number(month), Number(day)))
|
|
194
|
+
return null;
|
|
195
|
+
// The date is real, so stay as liberal as before (Postel): a whole-string
|
|
196
|
+
// parse still wins when the engine can make sense of the value. Reading the
|
|
197
|
+
// token first would silently DROP a trailing zone name -- "2026-06-08
|
|
198
|
+
// 12:34:56 GMT" parses whole as 12:34:56Z but as local time from the token,
|
|
199
|
+
// shifting the instant by the host's UTC offset.
|
|
200
|
+
const whole = Date.parse(trimmed);
|
|
201
|
+
if (!Number.isNaN(whole))
|
|
202
|
+
return whole;
|
|
203
|
+
// Whole-string failed: the value carries a suffix the engine can't read as
|
|
204
|
+
// one instant (#2570). Reconstruct from the leading token UNLESS the remainder
|
|
205
|
+
// is a named zone the token dropped (GMT, EST, ...): reconstructing without it
|
|
206
|
+
// reads the time as LOCAL and shifts the instant by the host's offset, so a
|
|
207
|
+
// zone-shaped remainder fails open (ADR-227: never propagate a wrong instant;
|
|
208
|
+
// null is the base's not-stale default). An ordinary description -- the #2570
|
|
209
|
+
// template's " -- description", or a hand edit's bare-space/tab/colon suffix --
|
|
210
|
+
// carries no zone and IS reconstructed. See ZONE_DESIGNATOR_RE for the shape;
|
|
211
|
+
// the earlier "any letter" guard failed open on every description and re-opened
|
|
212
|
+
// #2570 for whitespace-separated suffixes.
|
|
213
|
+
const rest = trimmed.slice(iso[0].length);
|
|
214
|
+
if (time && ZONE_DESIGNATOR_RE.test(rest))
|
|
215
|
+
return null;
|
|
216
|
+
const ms = Date.parse(`${year}-${month}-${day}${time}`);
|
|
217
|
+
return Number.isNaN(ms) ? null : ms;
|
|
218
|
+
}
|
|
219
|
+
const direct = Date.parse(trimmed);
|
|
220
|
+
return Number.isNaN(direct) ? null : direct;
|
|
129
221
|
}
|
|
130
222
|
/** Read-only git signals. Any git error is swallowed → "no git signal". */
|
|
131
223
|
function readGitSignals(cwd) {
|
|
@@ -137,6 +229,7 @@ function readGitSignals(cwd) {
|
|
|
137
229
|
maxBuffer: 4 * 1024 * 1024,
|
|
138
230
|
windowsHide: true,
|
|
139
231
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
232
|
+
timeout: 10_000,
|
|
140
233
|
});
|
|
141
234
|
}
|
|
142
235
|
catch {
|
|
@@ -186,7 +279,16 @@ function detectVerifyFailed(cwd, currentPhaseRaw) {
|
|
|
186
279
|
let targetDir;
|
|
187
280
|
if (phaseToken) {
|
|
188
281
|
const normalized = normalizePhaseName(phaseToken);
|
|
189
|
-
|
|
282
|
+
// #2528: the fourth directory-resolution site, and the one where a miss is
|
|
283
|
+
// silent — a phase whose directory cannot be found reports "not failed",
|
|
284
|
+
// which reads identically to a healthy phase. It must therefore apply the
|
|
285
|
+
// same canonical selection as the locator and the two command scans, or a
|
|
286
|
+
// dir like `05-80-20-cleanup` (phase 5 named "80/20 Cleanup") never
|
|
287
|
+
// surfaces its own failed verification. `entries` is already sorted, and
|
|
288
|
+
// `matchPhaseDirs` filters without reordering, so taking the first match
|
|
289
|
+
// preserves the previous `.find()` selection exactly.
|
|
290
|
+
const { matches } = matchPhaseDirs(entries, normalized);
|
|
291
|
+
targetDir = matches[0];
|
|
190
292
|
if (!targetDir)
|
|
191
293
|
return false;
|
|
192
294
|
}
|
|
@@ -253,6 +355,9 @@ function detectSignals(cwd, now = Date.now) {
|
|
|
253
355
|
stale_activity: false,
|
|
254
356
|
roadmap_total_phases: null,
|
|
255
357
|
roadmap_completed_phases: null,
|
|
358
|
+
// No STATE.md (or unreadable) → no stamp to compare. Unknown, not fresh.
|
|
359
|
+
state_commits_behind: null,
|
|
360
|
+
state_commit_stale: null,
|
|
256
361
|
};
|
|
257
362
|
if (!hasPlanning)
|
|
258
363
|
return empty;
|
|
@@ -266,21 +371,29 @@ function detectSignals(cwd, now = Date.now) {
|
|
|
266
371
|
// - body prose: `Phase: 3`, `**Status:** verifying`, `Total Phases: 5`
|
|
267
372
|
// Read each field across every form, scalar-first then nested then body, so
|
|
268
373
|
// the classifier works on real STATE.md files written by current GSD.
|
|
269
|
-
const statusRaw =
|
|
270
|
-
const pausedAtRaw =
|
|
271
|
-
const lastActivityRaw =
|
|
374
|
+
const statusRaw = stateFieldValue(fm, body, 'status', 'Status').value;
|
|
375
|
+
const pausedAtRaw = stateFieldValue(fm, body, 'paused_at', 'Paused At').value;
|
|
376
|
+
const lastActivityRaw = stateFieldValue(fm, body, 'last_activity', 'Last Activity').value;
|
|
272
377
|
// current_phase: scalar fm → nested (none) → body "Current Phase" → body "Phase".
|
|
273
378
|
// The body `Phase:` field is the canonical location in prose-form STATE.md
|
|
274
379
|
// (e.g. "Phase: 3" or "Phase: 3 — ui-review"); parse the leading number.
|
|
275
|
-
|
|
276
|
-
|
|
380
|
+
// #3187 / ADR-3180 Amendment 3 ("0.x split"): this read is DELIBERATELY
|
|
381
|
+
// unscoped — smart-entry classifies `gsd next` routing over the whole body,
|
|
382
|
+
// whereas state.cts's copies of this same field scope it to `## Current
|
|
383
|
+
// Position` (#1776/#2956). Those are two different questions sharing a
|
|
384
|
+
// name; folding the scoped read in here would silently change smart-entry's
|
|
385
|
+
// routing, an undisclosed Tier-2 change (design's Rejected #3). Both owner
|
|
386
|
+
// calls below intentionally pass the unscoped `body`, never a Current-
|
|
387
|
+
// Position slice.
|
|
388
|
+
const currentPhaseRaw = stateFieldValue(fm, body, 'current_phase', 'Current Phase').value ??
|
|
389
|
+
stateFieldValue(fm, body, null, 'Phase').value;
|
|
277
390
|
// total_phases & percent: nested `progress:` object takes precedence in the
|
|
278
391
|
// nested schema; scalar fm / body fields cover the flat schema.
|
|
279
392
|
const progressFm = typeof fm.progress === 'object' ? fm.progress : null;
|
|
280
393
|
const totalPhasesRaw = fmScalarKey(progressFm, 'total_phases') ??
|
|
281
|
-
|
|
394
|
+
stateFieldValue(fm, body, 'total_phases', 'Total Phases').value;
|
|
282
395
|
const progressRaw = fmScalarKey(progressFm, 'percent') ??
|
|
283
|
-
|
|
396
|
+
stateFieldValue(fm, body, 'progress', 'Progress').value;
|
|
284
397
|
// Blockers list: `- <text>` items under a `## Blockers` heading.
|
|
285
398
|
const blockers = [];
|
|
286
399
|
const blockersSection = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.level === 2 && h.text.trim().toLowerCase() === 'blockers', { levelBounded: true });
|
|
@@ -326,6 +439,11 @@ function detectSignals(cwd, now = Date.now) {
|
|
|
326
439
|
/* ROADMAP.md unreadable — leave null; isComplete falls back to legacy. */
|
|
327
440
|
}
|
|
328
441
|
}
|
|
442
|
+
// #2573: commit-age freshness proxy. Derived through state.cjs's
|
|
443
|
+
// readStateHeadFreshness so the tri-state and the hash fence stay identical
|
|
444
|
+
// to validate.health's W024 — one derivation, two surfaces.
|
|
445
|
+
const stateHeadRaw = stateFieldValue(fm, body, 'state_head', 'State Head').value;
|
|
446
|
+
const freshness = readStateHeadFreshness(cwd, stateHeadRaw);
|
|
329
447
|
return {
|
|
330
448
|
current_phase: parseIntOrNull(currentPhaseRaw),
|
|
331
449
|
total_phases: parseIntOrNull(totalPhasesRaw),
|
|
@@ -342,6 +460,8 @@ function detectSignals(cwd, now = Date.now) {
|
|
|
342
460
|
stale_activity: staleActivity,
|
|
343
461
|
roadmap_total_phases: roadmapTotalPhases,
|
|
344
462
|
roadmap_completed_phases: roadmapCompletedPhases,
|
|
463
|
+
state_commits_behind: freshness.commits_behind,
|
|
464
|
+
state_commit_stale: freshness.commit_stale,
|
|
345
465
|
};
|
|
346
466
|
}
|
|
347
467
|
// ─── Situation classification ─────────────────────────────────────────────────
|
|
@@ -144,7 +144,11 @@ function routeStateCommand({ state, args, cwd, raw, error }) {
|
|
|
144
144
|
'signal-resume': () => state.cmdSignalResume(cwd, raw),
|
|
145
145
|
'planned-phase': () => {
|
|
146
146
|
const a = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, ['phase', 'name', 'plans']);
|
|
147
|
-
|
|
147
|
+
// #3395: --name was parsed here but never forwarded (the StateModule
|
|
148
|
+
// signature had no channel for it), so the argument was silently
|
|
149
|
+
// dropped. It now persists into the Current Position `Phase:` line and
|
|
150
|
+
// the authoritative current_phase_name, mirroring begin-phase.
|
|
151
|
+
state.cmdStatePlannedPhase(cwd, strArg(a, 'phase'), strArg(a, 'name'), parsePlans(strArg(a, 'plans')), raw);
|
|
148
152
|
},
|
|
149
153
|
validate: () => state.cmdStateValidate(cwd, raw),
|
|
150
154
|
sync: () => {
|