From 11918dcc372d9c6212b6796ae4907776951e2b93 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 25 May 2026 10:58:36 -0400 Subject: [PATCH] chore(#191): retire sdk package seam --- .changeset/191-retire-sdk-package-seam.md | 5 + bin/gsd-sdk.js | 37 - bin/install.js | 27 +- get-shit-done/bin/lib/runtime-name-policy.cjs | 17 +- .../bin}/shared/config-defaults.manifest.json | 0 .../bin}/shared/config-schema.manifest.json | 0 .../bin}/shared/model-catalog.json | 0 .../bin}/shared/runtime-aliases.manifest.json | 0 package.json | 21 +- sdk/HANDOVER-GOLDEN-PARITY.md | 237 -- sdk/HANDOVER-PARITY-DOCS.md | 97 - sdk/HANDOVER-QUERY-LAYER.md | 170 - sdk/README.md | 53 - sdk/docs/caching.md | 68 - sdk/package-lock.json | 2502 ----------- sdk/package.json | 57 - sdk/prompts/templates/project.md | 186 - sdk/prompts/templates/requirements.md | 231 -- .../research-project/ARCHITECTURE.md | 204 - .../templates/research-project/FEATURES.md | 147 - .../templates/research-project/PITFALLS.md | 200 - .../templates/research-project/STACK.md | 120 - .../templates/research-project/SUMMARY.md | 170 - sdk/prompts/templates/roadmap.md | 202 - sdk/prompts/templates/state.md | 194 - .../gen-profile-questionnaire-data.mjs | 59 - sdk/src/assembled-prompts.test.ts | 349 -- ...bug-3589-planning-paths-validation.test.ts | 89 - ...g-3591-gsdtools-runtime-workstream.test.ts | 179 - sdk/src/cli-transport.test.ts | 388 -- sdk/src/cli-transport.ts | 130 - sdk/src/cli.test.ts | 426 -- sdk/src/cli.ts | 589 --- sdk/src/config.test.ts | 277 -- sdk/src/config.ts | 210 - sdk/src/config/index.test.ts | 318 -- sdk/src/config/index.ts | 325 -- sdk/src/context-engine.test.ts | 295 -- sdk/src/context-engine.ts | 170 - sdk/src/context-truncation.test.ts | 163 - sdk/src/context-truncation.ts | 233 -- sdk/src/dispatch/hub.ts | 246 -- sdk/src/e2e.integration.test.ts | 181 - sdk/src/errors.ts | 1 - sdk/src/errors/index.ts | 72 - sdk/src/event-stream.test.ts | 661 --- sdk/src/event-stream.ts | 441 -- sdk/src/golden/capture.ts | 95 - .../golden/fixtures/generate-slug.golden.json | 1 - .../demo-project/sample.jsonl | 3 - .../golden/fixtures/summary-extract-sample.md | 26 - .../fixtures/uat-render-checkpoint-sample.md | 15 - sdk/src/golden/golden-integration-covered.ts | 30 - sdk/src/golden/golden-mutation-covered.ts | 17 - sdk/src/golden/golden-policy.test.ts | 8 - sdk/src/golden/golden-policy.ts | 120 - sdk/src/golden/golden.integration.test.ts | 1031 ----- sdk/src/golden/init-golden-normalize.ts | 15 - sdk/src/golden/read-only-golden-rows.ts | 77 - .../read-only-parity.integration.test.ts | 133 - sdk/src/golden/registry-canonical-commands.ts | 31 - sdk/src/gsd-tools-error.test.ts | 21 - sdk/src/gsd-tools-error.ts | 65 - sdk/src/gsd-tools.test.ts | 472 --- sdk/src/gsd-tools.ts | 237 -- sdk/src/gsd-transport-policy.test.ts | 34 - sdk/src/gsd-transport-policy.ts | 48 - sdk/src/gsd-transport.test.ts | 299 -- sdk/src/gsd-transport.ts | 118 - sdk/src/handlers/init/complex.ts | 815 ---- sdk/src/handlers/init/composer.ts | 1335 ------ sdk/src/handlers/init/index.ts | 28 - sdk/src/handlers/phase/index.ts | 20 - sdk/src/handlers/phases/index.ts | 8 - sdk/src/handlers/roadmap/index.ts | 10 - sdk/src/handlers/state/index.ts | 35 - sdk/src/handlers/validate/index.ts | 9 - sdk/src/handlers/verify/index.ts | 18 - sdk/src/index.ts | 366 -- sdk/src/init-e2e.integration.test.ts | 138 - sdk/src/init-runner.test.ts | 740 ---- sdk/src/init-runner.ts | 734 ---- sdk/src/lifecycle-e2e.integration.test.ts | 258 -- sdk/src/logger.test.ts | 149 - sdk/src/logger.ts | 113 - sdk/src/manifest/index.ts | 17 - sdk/src/milestone-runner.test.ts | 421 -- sdk/src/model-catalog.ts | 70 - sdk/src/phase-prompt.ts | 259 -- sdk/src/phase-runner.integration.test.ts | 377 -- sdk/src/phase-runner.test.ts | 3660 ----------------- sdk/src/phase-runner.ts | 1442 ------- sdk/src/plan-parser.test.ts | 579 --- sdk/src/plan-parser.ts | 431 -- sdk/src/planning-journal.test.ts | 70 - sdk/src/planning-journal.ts | 153 - sdk/src/planning-runtime.test.ts | 29 - sdk/src/planning-runtime.ts | 100 - sdk/src/prompt-builder.test.ts | 318 -- sdk/src/prompt-builder.ts | 218 - sdk/src/prompt-sanitizer.test.ts | 260 -- sdk/src/prompt-sanitizer.ts | 116 - sdk/src/query-command-executor.ts | 31 - sdk/src/query-execution-policy.test.ts | 52 - sdk/src/query-execution-policy.ts | 46 - sdk/src/query-failure-classification.test.ts | 23 - sdk/src/query-failure-classification.ts | 42 - sdk/src/query-gsd-tools-path.ts | 1 - sdk/src/query-gsd-tools-runtime.ts | 89 - sdk/src/query-hotpath-methods.ts | 48 - sdk/src/query-native-direct-adapter.test.ts | 35 - sdk/src/query-native-direct-adapter.ts | 70 - sdk/src/query-native-hotpath-adapter.test.ts | 43 - sdk/src/query-native-hotpath-adapter.ts | 45 - sdk/src/query-raw-output-projection.test.ts | 39 - sdk/src/query-raw-output-projection.ts | 93 - sdk/src/query-runtime-bridge.test.ts | 150 - sdk/src/query-runtime-bridge.ts | 215 - sdk/src/query-runtime-seam-coverage.test.ts | 20 - sdk/src/query-subprocess-adapter.test.ts | 84 - sdk/src/query-subprocess-adapter.ts | 146 - sdk/src/query-tools-error-factory.test.ts | 35 - sdk/src/query-tools-error-factory.ts | 76 - sdk/src/query/QUERY-HANDLERS.md | 349 -- sdk/src/query/active-workstream-store.ts | 50 - .../query/agent-failure-classifier.test.ts | 157 - sdk/src/query/agent-failure-classifier.ts | 105 - sdk/src/query/audit-open.ts | 722 ---- sdk/src/query/check-auto-mode.test.ts | 77 - sdk/src/query/check-auto-mode.ts | 49 - sdk/src/query/check-completion.test.ts | 113 - sdk/src/query/check-completion.ts | 182 - sdk/src/query/check-decision-coverage.test.ts | 713 ---- sdk/src/query/check-decision-coverage.ts | 589 --- sdk/src/query/check-gates.test.ts | 103 - sdk/src/query/check-gates.ts | 112 - sdk/src/query/check-ship-ready.test.ts | 303 -- sdk/src/query/check-ship-ready.ts | 136 - .../query/check-verification-status.test.ts | 143 - sdk/src/query/check-verification-status.ts | 160 - sdk/src/query/command-aliases.generated.ts | 154 - sdk/src/query/command-catalog.ts | 31 - sdk/src/query/command-definition.test.ts | 47 - sdk/src/query/command-definition.ts | 70 - sdk/src/query/command-family-handlers.ts | 19 - sdk/src/query/command-manifest.init.ts | 24 - sdk/src/query/command-manifest.non-family.ts | 86 - sdk/src/query/command-manifest.phase.ts | 17 - sdk/src/query/command-manifest.phases.ts | 11 - sdk/src/query/command-manifest.roadmap.ts | 11 - sdk/src/query/command-manifest.state.ts | 31 - sdk/src/query/command-manifest.ts | 1 - sdk/src/query/command-manifest.types.ts | 13 - sdk/src/query/command-manifest.validate.ts | 11 - sdk/src/query/command-manifest.verify.ts | 17 - sdk/src/query/command-resolution.test.ts | 70 - sdk/src/query/command-seam-coverage.test.ts | 118 - .../query/command-static-catalog-domain.ts | 111 - .../command-static-catalog-foundation.ts | 111 - sdk/src/query/command-topology.test.ts | 28 - sdk/src/query/command-topology.ts | 114 - sdk/src/query/commands-list.test.ts | 36 - sdk/src/query/commands-list.ts | 19 - sdk/src/query/commit.bug-105.test.ts | 195 - sdk/src/query/commit.test.ts | 485 --- sdk/src/query/commit.ts | 733 ---- sdk/src/query/config-gates.test.ts | 89 - sdk/src/query/config-gates.ts | 69 - sdk/src/query/config-mutation.test.ts | 598 --- sdk/src/query/config-mutation.ts | 705 ---- sdk/src/query/config-query.test.ts | 472 --- sdk/src/query/config-query.ts | 314 -- sdk/src/query/config-schema.ts | 35 - sdk/src/query/decisions.test.ts | 221 - sdk/src/query/decisions.ts | 196 - sdk/src/query/decomposed-handlers.test.ts | 431 -- sdk/src/query/detect-custom-files.test.ts | 115 - sdk/src/query/detect-custom-files.ts | 96 - sdk/src/query/detect-phase-type.test.ts | 105 - sdk/src/query/detect-phase-type.ts | 141 - sdk/src/query/docs-init.ts | 258 -- sdk/src/query/fallow-audit.ts | 88 - sdk/src/query/frontmatter-array.test.ts | 14 - sdk/src/query/frontmatter-mutation.test.ts | 259 -- sdk/src/query/frontmatter-mutation.ts | 328 -- sdk/src/query/frontmatter.test.ts | 326 -- sdk/src/query/frontmatter.ts | 395 -- sdk/src/query/helpers.test.ts | 681 --- sdk/src/query/helpers.ts | 583 --- sdk/src/query/index-thin-seam.test.ts | 16 - sdk/src/query/index.ts | 9 - sdk/src/query/init-complex.test.ts | 788 ---- sdk/src/query/init-complex.ts | 1 - .../init-workstream-milestone-op.test.ts | 321 -- sdk/src/query/init.test.ts | 791 ---- sdk/src/query/init.ts | 1 - sdk/src/query/intel.test.ts | 90 - sdk/src/query/intel.ts | 404 -- .../query/mutation-event-decorator.test.ts | 45 - sdk/src/query/mutation-event-decorator.ts | 37 - sdk/src/query/mutation-event-mapper.test.ts | 33 - sdk/src/query/mutation-event-mapper.ts | 102 - sdk/src/query/mvp.test.ts | 335 -- sdk/src/query/mvp.ts | 292 -- sdk/src/query/normalize-query-command.test.ts | 102 - sdk/src/query/phase-filesystem-adapter.ts | 35 - sdk/src/query/phase-lifecycle-policy.ts | 192 - sdk/src/query/phase-lifecycle.test.ts | 2100 ---------- sdk/src/query/phase-lifecycle.ts | 2220 ---------- sdk/src/query/phase-list-queries.test.ts | 88 - sdk/src/query/phase-list-queries.ts | 152 - sdk/src/query/phase-ready.test.ts | 65 - sdk/src/query/phase-ready.ts | 159 - sdk/src/query/phase-roadmap-mutation.ts | 82 - sdk/src/query/phase-uat-passed.test.ts | 593 --- sdk/src/query/phase-uat-passed.ts | 297 -- sdk/src/query/phase.test.ts | 784 ---- sdk/src/query/phase.ts | 781 ---- sdk/src/query/pipeline.test.ts | 169 - sdk/src/query/pipeline.ts | 243 -- sdk/src/query/plan-scan.test.ts | 35 - sdk/src/query/plan-scan.ts | 82 - sdk/src/query/plan-task-structure.test.ts | 65 - sdk/src/query/plan-task-structure.ts | 63 - sdk/src/query/policy-convergence.test.ts | 28 - sdk/src/query/profile-extract-messages.ts | 247 -- sdk/src/query/profile-output.ts | 929 ----- sdk/src/query/profile-questionnaire-data.ts | 181 - sdk/src/query/profile-sample.ts | 184 - sdk/src/query/profile-scan-sessions.ts | 174 - sdk/src/query/profile.test.ts | 136 - sdk/src/query/profile.ts | 337 -- sdk/src/query/progress.test.ts | 156 - sdk/src/query/progress.ts | 566 --- sdk/src/query/prompt-budget.ts | 556 --- sdk/src/query/query-cli-adapter.test.ts | 79 - sdk/src/query/query-cli-adapter.ts | 39 - sdk/src/query/query-cli-output.test.ts | 33 - sdk/src/query/query-cli-output.ts | 35 - sdk/src/query/query-command-diagnosis.test.ts | 22 - sdk/src/query/query-command-diagnosis.ts | 5 - .../query-command-resolution-strategy.test.ts | 34 - .../query-command-resolution-strategy.ts | 121 - sdk/src/query/query-command-semantics.test.ts | 22 - sdk/src/query/query-command-semantics.ts | 22 - sdk/src/query/query-dispatch-contract.ts | 30 - sdk/src/query/query-dispatch-error-mapper.ts | 5 - sdk/src/query/query-dispatch-formatting.ts | 5 - sdk/src/query/query-dispatch-observability.ts | 6 - sdk/src/query/query-dispatch.test.ts | 722 ---- sdk/src/query/query-dispatch.ts | 1 - sdk/src/query/query-error-details-schema.ts | 29 - sdk/src/query/query-error-taxonomy.test.ts | 39 - sdk/src/query/query-error-taxonomy.ts | 117 - .../query-fallback-bridge-adapter.test.ts | 32 - .../query/query-fallback-bridge-adapter.ts | 54 - sdk/src/query/query-fallback-executor.test.ts | 82 - sdk/src/query/query-fallback-executor.ts | 44 - .../query-fallback-output-classifier.test.ts | 36 - .../query/query-fallback-output-classifier.ts | 31 - sdk/src/query/query-fallback-policy.test.ts | 13 - sdk/src/query/query-fallback-policy.ts | 11 - .../query/query-native-dispatch-adapter.ts | 16 - sdk/src/query/query-policy-capability.test.ts | 10 - sdk/src/query/query-policy-capability.ts | 26 - sdk/src/query/query-policy-snapshot.test.ts | 9 - .../query/query-registry-capability.test.ts | 14 - sdk/src/query/query-runtime-context.ts | 44 - .../query/query-unknown-command-hints.test.ts | 9 - sdk/src/query/query-unknown-command-hints.ts | 5 - sdk/src/query/registry-assembly-descriptor.ts | 87 - sdk/src/query/registry-assembly-invariants.ts | 127 - sdk/src/query/registry-assembly.test.ts | 138 - sdk/src/query/registry-assembly.ts | 78 - sdk/src/query/registry.test.ts | 208 - sdk/src/query/registry.ts | 142 - .../requirements-extract-from-plans.test.ts | 58 - .../query/requirements-extract-from-plans.ts | 86 - .../roadmap-update-plan-progress.test.ts | 233 -- sdk/src/query/roadmap-update-plan-progress.ts | 159 - sdk/src/query/roadmap.test.ts | 1250 ------ sdk/src/query/roadmap.ts | 1131 ----- sdk/src/query/route-next-action.test.ts | 61 - sdk/src/query/route-next-action.ts | 345 -- sdk/src/query/schema-detect.ts | 189 - sdk/src/query/secrets.test.ts | 66 - sdk/src/query/secrets.ts | 43 - sdk/src/query/skill-manifest.test.ts | 62 - sdk/src/query/skill-manifest.ts | 216 - sdk/src/query/skills.test.ts | 234 -- sdk/src/query/skills.ts | 143 - sdk/src/query/state-mutation.test.ts | 1210 ------ sdk/src/query/state-mutation.ts | 1814 -------- sdk/src/query/state-project-load.ts | 80 - sdk/src/query/state.test.ts | 616 --- sdk/src/query/state.ts | 482 --- .../query/sub-repos-root.integration.test.ts | 79 - sdk/src/query/summary.test.ts | 118 - sdk/src/query/summary.ts | 314 -- sdk/src/query/template.test.ts | 180 - sdk/src/query/template.ts | 242 -- sdk/src/query/uat.test.ts | 77 - sdk/src/query/uat.ts | 365 -- sdk/src/query/utils.test.ts | 82 - sdk/src/query/utils.ts | 106 - sdk/src/query/validate.test.ts | 924 ----- sdk/src/query/validate.ts | 1064 ----- sdk/src/query/verify.test.ts | 414 -- sdk/src/query/verify.ts | 656 --- sdk/src/query/websearch.test.ts | 31 - sdk/src/query/websearch.ts | 82 - sdk/src/query/workspace.test.ts | 120 - sdk/src/query/workspace.ts | 145 - sdk/src/query/workstream-inventory.ts | 143 - sdk/src/query/workstream.test.ts | 153 - sdk/src/query/workstream.ts | 324 -- sdk/src/query/worktree.ts | 84 - sdk/src/research-gate.test.ts | 190 - sdk/src/research-gate.ts | 94 - sdk/src/runtime-bridge-options.test.ts | 33 - sdk/src/runtime-gate.test.ts | 103 - sdk/src/runtime-gate.ts | 54 - sdk/src/runtime/name-policy.ts | 31 - sdk/src/runtime/project-root.test.ts | 186 - sdk/src/runtime/project-root.ts | 145 - sdk/src/sdk-package-compatibility.test.ts | 97 - sdk/src/sdk-package-compatibility.ts | 141 - sdk/src/session-runner.test.ts | 164 - sdk/src/session-runner.ts | 327 -- sdk/src/state/index.test.ts | 197 - sdk/src/state/index.ts | 129 - sdk/src/tool-scoping.test.ts | 160 - sdk/src/tool-scoping.ts | 61 - sdk/src/types.ts | 927 ----- .../workflow-agent-skills-consistency.test.ts | 98 - sdk/src/workstream-name-policy.ts | 57 - sdk/src/workstream-utils.ts | 36 - sdk/src/workstream/builder.test.ts | 241 -- sdk/src/workstream/builder.ts | 170 - sdk/src/ws-flag.test.ts | 285 -- sdk/src/ws-transport.test.ts | 161 - sdk/src/ws-transport.ts | 93 - sdk/test-fixtures/sample-plan.md | 32 - sdk/tsconfig.json | 20 - sdk/vitest.config.ts | 22 - tests/bug-190-bridge-collapse.test.cjs | 10 +- ...-21-state-md-template-frontmatter.test.cjs | 13 - .../bug-2334-quick-gsd-sdk-preflight.test.cjs | 75 - ...439-set-profile-gsd-sdk-preflight.test.cjs | 86 - tests/bug-2441-sdk-decouple.test.cjs | 170 - tests/bug-2492-context-coverage-gate.test.cjs | 45 +- tests/bug-2519-sdk-tarball-dist.test.cjs | 77 - tests/bug-2524-sdk-query-ws-flag.test.cjs | 108 - ...2636-gsd-sdk-query-silent-swallow.test.cjs | 73 - .../bug-2647-outer-tarball-sdk-dist.test.cjs | 155 - tests/bug-2649-sdk-fail-fast.test.cjs | 194 - ...parallel-milestone-phase-complete.test.cjs | 182 - tests/bug-2678-local-install-sdk.test.cjs | 103 - ...ug-2767-gsd-sdk-commit-files-flag.test.cjs | 318 -- tests/bug-2775-sdk-shim-path-verify.test.cjs | 210 - tests/bug-2788-audit-uat-frontmatter.test.cjs | 175 - tests/bug-2791-sdk-workstream-env.test.cjs | 170 - .../bug-2796-arg-parsing-regression.test.cjs | 156 - .../bug-2803-config-get-default-flag.test.cjs | 132 - .../bug-2805-archived-phase-fallback.test.cjs | 145 - .../bug-2829-local-install-sdk-path.test.cjs | 142 - tests/bug-2962-windows-sdk-shim.test.cjs | 150 - ...964-release-sdk-empty-cherry-pick.test.cjs | 144 - tests/bug-3011-sdk-path-diagnostic.test.cjs | 191 - tests/bug-3033-sdk-flag-wired.test.cjs | 172 - ...dk-package-guidance-and-fallbacks.test.cjs | 39 - tests/bug-3211-windows-sdk-not-found.test.cjs | 393 -- ...2-execute-phase-stall-safe-resume.test.cjs | 2 +- ...ug-3231-false-gsd-sdk-ready-linux.test.cjs | 403 -- ...g-3288-model-catalog-install-path.test.cjs | 4 +- ...3-settings-model-profile-adaptive.test.cjs | 8 +- ...g-3359-stale-gsd-sdk-path-version.test.cjs | 146 - .../bug-3406-stale-sdk-shadow-detect.test.cjs | 169 - ...nfiguration-manifest-install-path.test.cjs | 6 +- ...g-3668-local-install-sdk-soft-dep.test.cjs | 476 --- ...ommit-strategy-branch-integration.test.cjs | 175 - ...g-3749-sdk-commit-strategy-branch.test.cjs | 342 -- tests/bug-3751-init-local-agents.test.cjs | 489 --- tests/bugs-1656-1657.test.cjs | 58 +- tests/enh-191-retire-sdk-package.test.cjs | 47 + tests/enh-3271-sdk-adr-structure.test.cjs | 221 - ...sd-sdk-query-registry-integration.test.cjs | 225 - ...rompt-budget-sdk-parity-optimizer.test.cjs | 44 - tests/sdk-no-sdk-guard.test.cjs | 93 - 389 files changed, 109 insertions(+), 84177 deletions(-) create mode 100644 .changeset/191-retire-sdk-package-seam.md delete mode 100755 bin/gsd-sdk.js rename {sdk => get-shit-done/bin}/shared/config-defaults.manifest.json (100%) rename {sdk => get-shit-done/bin}/shared/config-schema.manifest.json (100%) rename {sdk => get-shit-done/bin}/shared/model-catalog.json (100%) rename {sdk => get-shit-done/bin}/shared/runtime-aliases.manifest.json (100%) delete mode 100644 sdk/HANDOVER-GOLDEN-PARITY.md delete mode 100644 sdk/HANDOVER-PARITY-DOCS.md delete mode 100644 sdk/HANDOVER-QUERY-LAYER.md delete mode 100644 sdk/README.md delete mode 100644 sdk/docs/caching.md delete mode 100644 sdk/package-lock.json delete mode 100644 sdk/package.json delete mode 100644 sdk/prompts/templates/project.md delete mode 100644 sdk/prompts/templates/requirements.md delete mode 100644 sdk/prompts/templates/research-project/ARCHITECTURE.md delete mode 100644 sdk/prompts/templates/research-project/FEATURES.md delete mode 100644 sdk/prompts/templates/research-project/PITFALLS.md delete mode 100644 sdk/prompts/templates/research-project/STACK.md delete mode 100644 sdk/prompts/templates/research-project/SUMMARY.md delete mode 100644 sdk/prompts/templates/roadmap.md delete mode 100644 sdk/prompts/templates/state.md delete mode 100644 sdk/scripts/gen-profile-questionnaire-data.mjs delete mode 100644 sdk/src/assembled-prompts.test.ts delete mode 100644 sdk/src/bug-3589-planning-paths-validation.test.ts delete mode 100644 sdk/src/bug-3591-gsdtools-runtime-workstream.test.ts delete mode 100644 sdk/src/cli-transport.test.ts delete mode 100644 sdk/src/cli-transport.ts delete mode 100644 sdk/src/cli.test.ts delete mode 100644 sdk/src/cli.ts delete mode 100644 sdk/src/config.test.ts delete mode 100644 sdk/src/config.ts delete mode 100644 sdk/src/config/index.test.ts delete mode 100644 sdk/src/config/index.ts delete mode 100644 sdk/src/context-engine.test.ts delete mode 100644 sdk/src/context-engine.ts delete mode 100644 sdk/src/context-truncation.test.ts delete mode 100644 sdk/src/context-truncation.ts delete mode 100644 sdk/src/dispatch/hub.ts delete mode 100644 sdk/src/e2e.integration.test.ts delete mode 100644 sdk/src/errors.ts delete mode 100644 sdk/src/errors/index.ts delete mode 100644 sdk/src/event-stream.test.ts delete mode 100644 sdk/src/event-stream.ts delete mode 100644 sdk/src/golden/capture.ts delete mode 100644 sdk/src/golden/fixtures/generate-slug.golden.json delete mode 100644 sdk/src/golden/fixtures/profile-sample-sessions/demo-project/sample.jsonl delete mode 100644 sdk/src/golden/fixtures/summary-extract-sample.md delete mode 100644 sdk/src/golden/fixtures/uat-render-checkpoint-sample.md delete mode 100644 sdk/src/golden/golden-integration-covered.ts delete mode 100644 sdk/src/golden/golden-mutation-covered.ts delete mode 100644 sdk/src/golden/golden-policy.test.ts delete mode 100644 sdk/src/golden/golden-policy.ts delete mode 100644 sdk/src/golden/golden.integration.test.ts delete mode 100644 sdk/src/golden/init-golden-normalize.ts delete mode 100644 sdk/src/golden/read-only-golden-rows.ts delete mode 100644 sdk/src/golden/read-only-parity.integration.test.ts delete mode 100644 sdk/src/golden/registry-canonical-commands.ts delete mode 100644 sdk/src/gsd-tools-error.test.ts delete mode 100644 sdk/src/gsd-tools-error.ts delete mode 100644 sdk/src/gsd-tools.test.ts delete mode 100644 sdk/src/gsd-tools.ts delete mode 100644 sdk/src/gsd-transport-policy.test.ts delete mode 100644 sdk/src/gsd-transport-policy.ts delete mode 100644 sdk/src/gsd-transport.test.ts delete mode 100644 sdk/src/gsd-transport.ts delete mode 100644 sdk/src/handlers/init/complex.ts delete mode 100644 sdk/src/handlers/init/composer.ts delete mode 100644 sdk/src/handlers/init/index.ts delete mode 100644 sdk/src/handlers/phase/index.ts delete mode 100644 sdk/src/handlers/phases/index.ts delete mode 100644 sdk/src/handlers/roadmap/index.ts delete mode 100644 sdk/src/handlers/state/index.ts delete mode 100644 sdk/src/handlers/validate/index.ts delete mode 100644 sdk/src/handlers/verify/index.ts delete mode 100644 sdk/src/index.ts delete mode 100644 sdk/src/init-e2e.integration.test.ts delete mode 100644 sdk/src/init-runner.test.ts delete mode 100644 sdk/src/init-runner.ts delete mode 100644 sdk/src/lifecycle-e2e.integration.test.ts delete mode 100644 sdk/src/logger.test.ts delete mode 100644 sdk/src/logger.ts delete mode 100644 sdk/src/manifest/index.ts delete mode 100644 sdk/src/milestone-runner.test.ts delete mode 100644 sdk/src/model-catalog.ts delete mode 100644 sdk/src/phase-prompt.ts delete mode 100644 sdk/src/phase-runner.integration.test.ts delete mode 100644 sdk/src/phase-runner.test.ts delete mode 100644 sdk/src/phase-runner.ts delete mode 100644 sdk/src/plan-parser.test.ts delete mode 100644 sdk/src/plan-parser.ts delete mode 100644 sdk/src/planning-journal.test.ts delete mode 100644 sdk/src/planning-journal.ts delete mode 100644 sdk/src/planning-runtime.test.ts delete mode 100644 sdk/src/planning-runtime.ts delete mode 100644 sdk/src/prompt-builder.test.ts delete mode 100644 sdk/src/prompt-builder.ts delete mode 100644 sdk/src/prompt-sanitizer.test.ts delete mode 100644 sdk/src/prompt-sanitizer.ts delete mode 100644 sdk/src/query-command-executor.ts delete mode 100644 sdk/src/query-execution-policy.test.ts delete mode 100644 sdk/src/query-execution-policy.ts delete mode 100644 sdk/src/query-failure-classification.test.ts delete mode 100644 sdk/src/query-failure-classification.ts delete mode 100644 sdk/src/query-gsd-tools-path.ts delete mode 100644 sdk/src/query-gsd-tools-runtime.ts delete mode 100644 sdk/src/query-hotpath-methods.ts delete mode 100644 sdk/src/query-native-direct-adapter.test.ts delete mode 100644 sdk/src/query-native-direct-adapter.ts delete mode 100644 sdk/src/query-native-hotpath-adapter.test.ts delete mode 100644 sdk/src/query-native-hotpath-adapter.ts delete mode 100644 sdk/src/query-raw-output-projection.test.ts delete mode 100644 sdk/src/query-raw-output-projection.ts delete mode 100644 sdk/src/query-runtime-bridge.test.ts delete mode 100644 sdk/src/query-runtime-bridge.ts delete mode 100644 sdk/src/query-runtime-seam-coverage.test.ts delete mode 100644 sdk/src/query-subprocess-adapter.test.ts delete mode 100644 sdk/src/query-subprocess-adapter.ts delete mode 100644 sdk/src/query-tools-error-factory.test.ts delete mode 100644 sdk/src/query-tools-error-factory.ts delete mode 100644 sdk/src/query/QUERY-HANDLERS.md delete mode 100644 sdk/src/query/active-workstream-store.ts delete mode 100644 sdk/src/query/agent-failure-classifier.test.ts delete mode 100644 sdk/src/query/agent-failure-classifier.ts delete mode 100644 sdk/src/query/audit-open.ts delete mode 100644 sdk/src/query/check-auto-mode.test.ts delete mode 100644 sdk/src/query/check-auto-mode.ts delete mode 100644 sdk/src/query/check-completion.test.ts delete mode 100644 sdk/src/query/check-completion.ts delete mode 100644 sdk/src/query/check-decision-coverage.test.ts delete mode 100644 sdk/src/query/check-decision-coverage.ts delete mode 100644 sdk/src/query/check-gates.test.ts delete mode 100644 sdk/src/query/check-gates.ts delete mode 100644 sdk/src/query/check-ship-ready.test.ts delete mode 100644 sdk/src/query/check-ship-ready.ts delete mode 100644 sdk/src/query/check-verification-status.test.ts delete mode 100644 sdk/src/query/check-verification-status.ts delete mode 100644 sdk/src/query/command-aliases.generated.ts delete mode 100644 sdk/src/query/command-catalog.ts delete mode 100644 sdk/src/query/command-definition.test.ts delete mode 100644 sdk/src/query/command-definition.ts delete mode 100644 sdk/src/query/command-family-handlers.ts delete mode 100644 sdk/src/query/command-manifest.init.ts delete mode 100644 sdk/src/query/command-manifest.non-family.ts delete mode 100644 sdk/src/query/command-manifest.phase.ts delete mode 100644 sdk/src/query/command-manifest.phases.ts delete mode 100644 sdk/src/query/command-manifest.roadmap.ts delete mode 100644 sdk/src/query/command-manifest.state.ts delete mode 100644 sdk/src/query/command-manifest.ts delete mode 100644 sdk/src/query/command-manifest.types.ts delete mode 100644 sdk/src/query/command-manifest.validate.ts delete mode 100644 sdk/src/query/command-manifest.verify.ts delete mode 100644 sdk/src/query/command-resolution.test.ts delete mode 100644 sdk/src/query/command-seam-coverage.test.ts delete mode 100644 sdk/src/query/command-static-catalog-domain.ts delete mode 100644 sdk/src/query/command-static-catalog-foundation.ts delete mode 100644 sdk/src/query/command-topology.test.ts delete mode 100644 sdk/src/query/command-topology.ts delete mode 100644 sdk/src/query/commands-list.test.ts delete mode 100644 sdk/src/query/commands-list.ts delete mode 100644 sdk/src/query/commit.bug-105.test.ts delete mode 100644 sdk/src/query/commit.test.ts delete mode 100644 sdk/src/query/commit.ts delete mode 100644 sdk/src/query/config-gates.test.ts delete mode 100644 sdk/src/query/config-gates.ts delete mode 100644 sdk/src/query/config-mutation.test.ts delete mode 100644 sdk/src/query/config-mutation.ts delete mode 100644 sdk/src/query/config-query.test.ts delete mode 100644 sdk/src/query/config-query.ts delete mode 100644 sdk/src/query/config-schema.ts delete mode 100644 sdk/src/query/decisions.test.ts delete mode 100644 sdk/src/query/decisions.ts delete mode 100644 sdk/src/query/decomposed-handlers.test.ts delete mode 100644 sdk/src/query/detect-custom-files.test.ts delete mode 100644 sdk/src/query/detect-custom-files.ts delete mode 100644 sdk/src/query/detect-phase-type.test.ts delete mode 100644 sdk/src/query/detect-phase-type.ts delete mode 100644 sdk/src/query/docs-init.ts delete mode 100644 sdk/src/query/fallow-audit.ts delete mode 100644 sdk/src/query/frontmatter-array.test.ts delete mode 100644 sdk/src/query/frontmatter-mutation.test.ts delete mode 100644 sdk/src/query/frontmatter-mutation.ts delete mode 100644 sdk/src/query/frontmatter.test.ts delete mode 100644 sdk/src/query/frontmatter.ts delete mode 100644 sdk/src/query/helpers.test.ts delete mode 100644 sdk/src/query/helpers.ts delete mode 100644 sdk/src/query/index-thin-seam.test.ts delete mode 100644 sdk/src/query/index.ts delete mode 100644 sdk/src/query/init-complex.test.ts delete mode 100644 sdk/src/query/init-complex.ts delete mode 100644 sdk/src/query/init-workstream-milestone-op.test.ts delete mode 100644 sdk/src/query/init.test.ts delete mode 100644 sdk/src/query/init.ts delete mode 100644 sdk/src/query/intel.test.ts delete mode 100644 sdk/src/query/intel.ts delete mode 100644 sdk/src/query/mutation-event-decorator.test.ts delete mode 100644 sdk/src/query/mutation-event-decorator.ts delete mode 100644 sdk/src/query/mutation-event-mapper.test.ts delete mode 100644 sdk/src/query/mutation-event-mapper.ts delete mode 100644 sdk/src/query/mvp.test.ts delete mode 100644 sdk/src/query/mvp.ts delete mode 100644 sdk/src/query/normalize-query-command.test.ts delete mode 100644 sdk/src/query/phase-filesystem-adapter.ts delete mode 100644 sdk/src/query/phase-lifecycle-policy.ts delete mode 100644 sdk/src/query/phase-lifecycle.test.ts delete mode 100644 sdk/src/query/phase-lifecycle.ts delete mode 100644 sdk/src/query/phase-list-queries.test.ts delete mode 100644 sdk/src/query/phase-list-queries.ts delete mode 100644 sdk/src/query/phase-ready.test.ts delete mode 100644 sdk/src/query/phase-ready.ts delete mode 100644 sdk/src/query/phase-roadmap-mutation.ts delete mode 100644 sdk/src/query/phase-uat-passed.test.ts delete mode 100644 sdk/src/query/phase-uat-passed.ts delete mode 100644 sdk/src/query/phase.test.ts delete mode 100644 sdk/src/query/phase.ts delete mode 100644 sdk/src/query/pipeline.test.ts delete mode 100644 sdk/src/query/pipeline.ts delete mode 100644 sdk/src/query/plan-scan.test.ts delete mode 100644 sdk/src/query/plan-scan.ts delete mode 100644 sdk/src/query/plan-task-structure.test.ts delete mode 100644 sdk/src/query/plan-task-structure.ts delete mode 100644 sdk/src/query/policy-convergence.test.ts delete mode 100644 sdk/src/query/profile-extract-messages.ts delete mode 100644 sdk/src/query/profile-output.ts delete mode 100644 sdk/src/query/profile-questionnaire-data.ts delete mode 100644 sdk/src/query/profile-sample.ts delete mode 100644 sdk/src/query/profile-scan-sessions.ts delete mode 100644 sdk/src/query/profile.test.ts delete mode 100644 sdk/src/query/profile.ts delete mode 100644 sdk/src/query/progress.test.ts delete mode 100644 sdk/src/query/progress.ts delete mode 100644 sdk/src/query/prompt-budget.ts delete mode 100644 sdk/src/query/query-cli-adapter.test.ts delete mode 100644 sdk/src/query/query-cli-adapter.ts delete mode 100644 sdk/src/query/query-cli-output.test.ts delete mode 100644 sdk/src/query/query-cli-output.ts delete mode 100644 sdk/src/query/query-command-diagnosis.test.ts delete mode 100644 sdk/src/query/query-command-diagnosis.ts delete mode 100644 sdk/src/query/query-command-resolution-strategy.test.ts delete mode 100644 sdk/src/query/query-command-resolution-strategy.ts delete mode 100644 sdk/src/query/query-command-semantics.test.ts delete mode 100644 sdk/src/query/query-command-semantics.ts delete mode 100644 sdk/src/query/query-dispatch-contract.ts delete mode 100644 sdk/src/query/query-dispatch-error-mapper.ts delete mode 100644 sdk/src/query/query-dispatch-formatting.ts delete mode 100644 sdk/src/query/query-dispatch-observability.ts delete mode 100644 sdk/src/query/query-dispatch.test.ts delete mode 100644 sdk/src/query/query-dispatch.ts delete mode 100644 sdk/src/query/query-error-details-schema.ts delete mode 100644 sdk/src/query/query-error-taxonomy.test.ts delete mode 100644 sdk/src/query/query-error-taxonomy.ts delete mode 100644 sdk/src/query/query-fallback-bridge-adapter.test.ts delete mode 100644 sdk/src/query/query-fallback-bridge-adapter.ts delete mode 100644 sdk/src/query/query-fallback-executor.test.ts delete mode 100644 sdk/src/query/query-fallback-executor.ts delete mode 100644 sdk/src/query/query-fallback-output-classifier.test.ts delete mode 100644 sdk/src/query/query-fallback-output-classifier.ts delete mode 100644 sdk/src/query/query-fallback-policy.test.ts delete mode 100644 sdk/src/query/query-fallback-policy.ts delete mode 100644 sdk/src/query/query-native-dispatch-adapter.ts delete mode 100644 sdk/src/query/query-policy-capability.test.ts delete mode 100644 sdk/src/query/query-policy-capability.ts delete mode 100644 sdk/src/query/query-policy-snapshot.test.ts delete mode 100644 sdk/src/query/query-registry-capability.test.ts delete mode 100644 sdk/src/query/query-runtime-context.ts delete mode 100644 sdk/src/query/query-unknown-command-hints.test.ts delete mode 100644 sdk/src/query/query-unknown-command-hints.ts delete mode 100644 sdk/src/query/registry-assembly-descriptor.ts delete mode 100644 sdk/src/query/registry-assembly-invariants.ts delete mode 100644 sdk/src/query/registry-assembly.test.ts delete mode 100644 sdk/src/query/registry-assembly.ts delete mode 100644 sdk/src/query/registry.test.ts delete mode 100644 sdk/src/query/registry.ts delete mode 100644 sdk/src/query/requirements-extract-from-plans.test.ts delete mode 100644 sdk/src/query/requirements-extract-from-plans.ts delete mode 100644 sdk/src/query/roadmap-update-plan-progress.test.ts delete mode 100644 sdk/src/query/roadmap-update-plan-progress.ts delete mode 100644 sdk/src/query/roadmap.test.ts delete mode 100644 sdk/src/query/roadmap.ts delete mode 100644 sdk/src/query/route-next-action.test.ts delete mode 100644 sdk/src/query/route-next-action.ts delete mode 100644 sdk/src/query/schema-detect.ts delete mode 100644 sdk/src/query/secrets.test.ts delete mode 100644 sdk/src/query/secrets.ts delete mode 100644 sdk/src/query/skill-manifest.test.ts delete mode 100644 sdk/src/query/skill-manifest.ts delete mode 100644 sdk/src/query/skills.test.ts delete mode 100644 sdk/src/query/skills.ts delete mode 100644 sdk/src/query/state-mutation.test.ts delete mode 100644 sdk/src/query/state-mutation.ts delete mode 100644 sdk/src/query/state-project-load.ts delete mode 100644 sdk/src/query/state.test.ts delete mode 100644 sdk/src/query/state.ts delete mode 100644 sdk/src/query/sub-repos-root.integration.test.ts delete mode 100644 sdk/src/query/summary.test.ts delete mode 100644 sdk/src/query/summary.ts delete mode 100644 sdk/src/query/template.test.ts delete mode 100644 sdk/src/query/template.ts delete mode 100644 sdk/src/query/uat.test.ts delete mode 100644 sdk/src/query/uat.ts delete mode 100644 sdk/src/query/utils.test.ts delete mode 100644 sdk/src/query/utils.ts delete mode 100644 sdk/src/query/validate.test.ts delete mode 100644 sdk/src/query/validate.ts delete mode 100644 sdk/src/query/verify.test.ts delete mode 100644 sdk/src/query/verify.ts delete mode 100644 sdk/src/query/websearch.test.ts delete mode 100644 sdk/src/query/websearch.ts delete mode 100644 sdk/src/query/workspace.test.ts delete mode 100644 sdk/src/query/workspace.ts delete mode 100644 sdk/src/query/workstream-inventory.ts delete mode 100644 sdk/src/query/workstream.test.ts delete mode 100644 sdk/src/query/workstream.ts delete mode 100644 sdk/src/query/worktree.ts delete mode 100644 sdk/src/research-gate.test.ts delete mode 100644 sdk/src/research-gate.ts delete mode 100644 sdk/src/runtime-bridge-options.test.ts delete mode 100644 sdk/src/runtime-gate.test.ts delete mode 100644 sdk/src/runtime-gate.ts delete mode 100644 sdk/src/runtime/name-policy.ts delete mode 100644 sdk/src/runtime/project-root.test.ts delete mode 100644 sdk/src/runtime/project-root.ts delete mode 100644 sdk/src/sdk-package-compatibility.test.ts delete mode 100644 sdk/src/sdk-package-compatibility.ts delete mode 100644 sdk/src/session-runner.test.ts delete mode 100644 sdk/src/session-runner.ts delete mode 100644 sdk/src/state/index.test.ts delete mode 100644 sdk/src/state/index.ts delete mode 100644 sdk/src/tool-scoping.test.ts delete mode 100644 sdk/src/tool-scoping.ts delete mode 100644 sdk/src/types.ts delete mode 100644 sdk/src/workflow-agent-skills-consistency.test.ts delete mode 100644 sdk/src/workstream-name-policy.ts delete mode 100644 sdk/src/workstream-utils.ts delete mode 100644 sdk/src/workstream/builder.test.ts delete mode 100644 sdk/src/workstream/builder.ts delete mode 100644 sdk/src/ws-flag.test.ts delete mode 100644 sdk/src/ws-transport.test.ts delete mode 100644 sdk/src/ws-transport.ts delete mode 100644 sdk/test-fixtures/sample-plan.md delete mode 100644 sdk/tsconfig.json delete mode 100644 sdk/vitest.config.ts delete mode 100644 tests/bug-2334-quick-gsd-sdk-preflight.test.cjs delete mode 100644 tests/bug-2439-set-profile-gsd-sdk-preflight.test.cjs delete mode 100644 tests/bug-2441-sdk-decouple.test.cjs delete mode 100644 tests/bug-2519-sdk-tarball-dist.test.cjs delete mode 100644 tests/bug-2524-sdk-query-ws-flag.test.cjs delete mode 100644 tests/bug-2636-gsd-sdk-query-silent-swallow.test.cjs delete mode 100644 tests/bug-2647-outer-tarball-sdk-dist.test.cjs delete mode 100644 tests/bug-2649-sdk-fail-fast.test.cjs delete mode 100644 tests/bug-2676-parallel-milestone-phase-complete.test.cjs delete mode 100644 tests/bug-2678-local-install-sdk.test.cjs delete mode 100644 tests/bug-2767-gsd-sdk-commit-files-flag.test.cjs delete mode 100644 tests/bug-2775-sdk-shim-path-verify.test.cjs delete mode 100644 tests/bug-2788-audit-uat-frontmatter.test.cjs delete mode 100644 tests/bug-2791-sdk-workstream-env.test.cjs delete mode 100644 tests/bug-2796-arg-parsing-regression.test.cjs delete mode 100644 tests/bug-2803-config-get-default-flag.test.cjs delete mode 100644 tests/bug-2805-archived-phase-fallback.test.cjs delete mode 100644 tests/bug-2829-local-install-sdk-path.test.cjs delete mode 100644 tests/bug-2962-windows-sdk-shim.test.cjs delete mode 100644 tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs delete mode 100644 tests/bug-3011-sdk-path-diagnostic.test.cjs delete mode 100644 tests/bug-3033-sdk-flag-wired.test.cjs delete mode 100644 tests/bug-3091-sdk-package-guidance-and-fallbacks.test.cjs delete mode 100644 tests/bug-3211-windows-sdk-not-found.test.cjs delete mode 100644 tests/bug-3231-false-gsd-sdk-ready-linux.test.cjs delete mode 100644 tests/bug-3359-stale-gsd-sdk-path-version.test.cjs delete mode 100644 tests/bug-3406-stale-sdk-shadow-detect.test.cjs delete mode 100644 tests/bug-3668-local-install-sdk-soft-dep.test.cjs delete mode 100644 tests/bug-3749-sdk-commit-strategy-branch-integration.test.cjs delete mode 100644 tests/bug-3749-sdk-commit-strategy-branch.test.cjs delete mode 100644 tests/bug-3751-init-local-agents.test.cjs create mode 100644 tests/enh-191-retire-sdk-package.test.cjs delete mode 100644 tests/enh-3271-sdk-adr-structure.test.cjs delete mode 100644 tests/gsd-sdk-query-registry-integration.test.cjs delete mode 100644 tests/prompt-budget-sdk-parity-optimizer.test.cjs delete mode 100644 tests/sdk-no-sdk-guard.test.cjs diff --git a/.changeset/191-retire-sdk-package-seam.md b/.changeset/191-retire-sdk-package-seam.md new file mode 100644 index 000000000..13ee1f78b --- /dev/null +++ b/.changeset/191-retire-sdk-package-seam.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 191 +--- +Retired the legacy SDK package seam by deleting `sdk/`, removing the `gsd-sdk` shim/bin publishing path, and moving required shared manifests to `get-shit-done/bin/shared` for runtime/install compatibility. diff --git a/bin/gsd-sdk.js b/bin/gsd-sdk.js deleted file mode 100755 index 49e29d8a3..000000000 --- a/bin/gsd-sdk.js +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env node -/** - * bin/gsd-sdk.js — back-compat shim for external callers of `gsd-sdk`. - * - * When the parent package is installed globally (`npm install -g @opengsd/get-shit-done-redux`) - * npm creates a `gsd-sdk` symlink in the global bin directory pointing at this - * file. npm correctly chmods bin entries from a tarball, so the execute-bit - * problem that afflicted the sub-install approach (issue #2453) cannot occur here. - * - * NOTE (#2775): `npx @opengsd/get-shit-done-redux` does NOT link this shim — npx only - * exposes the package's primary bin (`get-shit-done-redux`). For npx-based usage, - * the installer (`bin/install.js#installSdkIfNeeded`) self-symlinks `gsd-sdk` - * into `~/.local/bin` when needed and verifies PATH callability before - * reporting `✓ GSD SDK ready`. - * - * This shim resolves sdk/dist/cli.js relative to its own location and delegates - * to it via `node`, so `gsd-sdk ` behaves identically to - * `node /sdk/dist/cli.js `. - * - * Call sites (slash commands, agent prompts, hook scripts) continue to work without - * changes because `gsd-sdk` still resolves on PATH — it just comes from this shim - * in the parent package rather than from a separately installed @opengsd/gsd-sdk. - */ - -'use strict'; - -const path = require('path'); -const { spawnSync } = require('child_process'); - -const cliPath = path.resolve(__dirname, '..', 'sdk', 'dist', 'cli.js'); - -const result = spawnSync(process.execPath, [cliPath, ...process.argv.slice(2)], { - stdio: 'inherit', - env: process.env, -}); - -process.exit(result.status ?? 1); diff --git a/bin/install.js b/bin/install.js index 6e55320f7..0159bdde5 100755 --- a/bin/install.js +++ b/bin/install.js @@ -215,19 +215,12 @@ const _profileArgRaw = (() => { // configDir is resolved) and may override 'full' — see writeActiveProfile call below. const _profileIsCore = _profileArgRaw === 'core'; const _requestedProfileName = (hasMinimal || _profileIsCore) ? 'core' : (_profileArgRaw || null); -const hasSdk = args.includes('--sdk'); -const hasNoSdk = args.includes('--no-sdk'); if (hasMinimal && _profileArgRaw) { console.error(` ${yellow}Cannot specify both --minimal/--core-only and --profile${reset}`); process.exit(1); } -if (hasSdk && hasNoSdk) { - console.error(` ${yellow}Cannot specify both --sdk and --no-sdk${reset}`); - process.exit(1); -} - // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; if (hasAll) { @@ -8547,22 +8540,19 @@ function install(isGlobal, runtime = 'claude', options = {}) { failures.push('get-shit-done'); } - // #3288 / #3571 — Copy sdk/shared manifests into the get-shit-done payload + // Copy shared manifests into the get-shit-done payload // at the co-located path that CJS modules resolve first: // get-shit-done/bin/shared/*.json // - // The install copies get-shit-done/ but NOT sdk/ — CJS modules' legacy - // source-repo paths (3 levels up → sdk/shared/) therefore resolve to a - // non-existent location in every post-install layout. Copying these shared - // files alongside the CJS files ensures require() succeeds without needing - // sdk/ to exist. + // This source now lives under get-shit-done/bin/shared in-repo. const sharedPayloadFiles = [ 'model-catalog.json', 'config-defaults.manifest.json', 'config-schema.manifest.json', + 'runtime-aliases.manifest.json', ]; for (const fileName of sharedPayloadFiles) { - const sharedSrc = path.join(src, 'sdk', 'shared', fileName); + const sharedSrc = path.join(src, 'get-shit-done', 'bin', 'shared', fileName); const sharedDest = path.join(skillDest, 'bin', 'shared', fileName); const displayPath = `get-shit-done/bin/shared/${fileName}`; if (fs.existsSync(sharedSrc)) { @@ -8574,7 +8564,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { failures.push(displayPath); } } else { - failures.push(`sdk/shared/${fileName} (source missing)`); + failures.push(`get-shit-done/bin/shared/${fileName} (source missing)`); } } @@ -11239,13 +11229,6 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { const finalize = (shouldInstallStatusline, shouldInstallBanner) => { try { - // Verify sdk/dist/cli.js is present and executable. The dist is shipped - // prebuilt in the tarball (fix/2441-sdk-decouple); gsd-sdk reaches users via - // the parent package's bin/gsd-sdk.js shim, so no sub-install is needed. - // Skip with --no-sdk. Skip with isLocal (#2678 — local installs don't own global npm). - // #3033: pass forceSdk so --sdk overrides the local-install skip. - installSdkIfNeeded({ isLocal: !isGlobal, forceSdk: hasSdk, throwOnFailure: true }); - const printSummaries = () => { for (const result of results) { const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline; diff --git a/get-shit-done/bin/lib/runtime-name-policy.cjs b/get-shit-done/bin/lib/runtime-name-policy.cjs index 896b03990..a7635219b 100644 --- a/get-shit-done/bin/lib/runtime-name-policy.cjs +++ b/get-shit-done/bin/lib/runtime-name-policy.cjs @@ -26,12 +26,17 @@ function normalizeRuntimeToken(value) { } function loadAliasManifest() { - try { - const manifestPath = path.resolve(__dirname, '../../../sdk/shared/runtime-aliases.manifest.json'); - const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - if (parsed && typeof parsed === 'object') return parsed; - } catch { - // Fall through to fallback aliases. + const manifestCandidates = [ + path.resolve(__dirname, '..', 'shared', 'runtime-aliases.manifest.json'), + path.resolve(__dirname, '../../../sdk/shared/runtime-aliases.manifest.json'), + ]; + for (const manifestPath of manifestCandidates) { + try { + const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + // Try next candidate. + } } return FALLBACK_ALIASES; } diff --git a/sdk/shared/config-defaults.manifest.json b/get-shit-done/bin/shared/config-defaults.manifest.json similarity index 100% rename from sdk/shared/config-defaults.manifest.json rename to get-shit-done/bin/shared/config-defaults.manifest.json diff --git a/sdk/shared/config-schema.manifest.json b/get-shit-done/bin/shared/config-schema.manifest.json similarity index 100% rename from sdk/shared/config-schema.manifest.json rename to get-shit-done/bin/shared/config-schema.manifest.json diff --git a/sdk/shared/model-catalog.json b/get-shit-done/bin/shared/model-catalog.json similarity index 100% rename from sdk/shared/model-catalog.json rename to get-shit-done/bin/shared/model-catalog.json diff --git a/sdk/shared/runtime-aliases.manifest.json b/get-shit-done/bin/shared/runtime-aliases.manifest.json similarity index 100% rename from sdk/shared/runtime-aliases.manifest.json rename to get-shit-done/bin/shared/runtime-aliases.manifest.json diff --git a/package.json b/package.json index 302af8374..71642fe36 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,7 @@ "description": "A meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini and Codex by TÂCHES.", "bin": { "get-shit-done-redux": "bin/install.js", - "gsd-sdk": "bin/gsd-sdk.js", - "gsd-tools": "bin/gsd-sdk.js" + "gsd-tools": "get-shit-done/bin/gsd-tools.cjs" }, "files": [ "bin", @@ -13,14 +12,7 @@ "get-shit-done", "agents", "hooks", - "scripts", - "sdk/src", - "sdk/shared", - "sdk/prompts", - "sdk/dist", - "sdk/package.json", - "sdk/package-lock.json", - "sdk/tsconfig.json" + "scripts" ], "keywords": [ "claude", @@ -64,12 +56,11 @@ "scripts": { "check:env": "bash scripts/check-env.sh", "check:integrity": "./scripts/check-npm-integrity.sh", - "build": "npm run build:sdk", + "build": "npm run build:hooks", "build:hooks": "node scripts/build-hooks.js", - "build:sdk": "cd sdk && npm ci && npm run build", - "prepublishOnly": "npm run build:hooks && npm run build:sdk", - "pretest": "npm run build:sdk && npm run lint:skill-deps", - "pretest:coverage": "npm run build:sdk", + "prepublishOnly": "npm run build:hooks", + "pretest": "npm run lint:skill-deps", + "pretest:coverage": "npm run lint:skill-deps", "lint:descriptions": "node scripts/lint-descriptions.cjs", "lint:skill-deps": "node scripts/lint-skill-deps.cjs", "lint:tests": "node scripts/lint-no-source-grep.cjs", diff --git a/sdk/HANDOVER-GOLDEN-PARITY.md b/sdk/HANDOVER-GOLDEN-PARITY.md deleted file mode 100644 index c022ff809..000000000 --- a/sdk/HANDOVER-GOLDEN-PARITY.md +++ /dev/null @@ -1,237 +0,0 @@ -# Handover: Query layer + golden parity - -Use this document at the start of a new session so work continues in context without re-deriving history. - -**Related:** `HANDOVER-PARITY-DOCS.md` (#2302 scope); **`sdk/src/query/QUERY-HANDLERS.md`** (golden matrix, CJS↔SDK routing). - ---- - -## Goal for the next session (primary) - -**Track A (Golden/parity) is complete.** 127/128 canonicals covered — the single exception (`phases.archive`) is permanent (SDK-only, no CJS analogue). Focus shifts to the remaining #2302 acceptance criteria. - -**Ongoing:** pick next gap from **`GOLDEN_PARITY_EXCEPTIONS`** / registry orphans (run `golden-policy.test.ts`) or expand **`READ_ONLY_JSON_PARITY_ROWS`** for read-only handlers still on generic exceptions. The read-only batch in **§ Next batch** below is **done**. - -**Follow-up:** confirm **`GOLDEN_PARITY_EXCEPTIONS`** for any remaining read-only registry gaps (`learnings.query`, `progress.bar`, `profile-questionnaire` — still exception-only until strict rows); extend **`read-only-golden-rows.ts`** when aligned. - -### Remaining work — ordered by priority - -1. **Track C — Runner alignment** (not started) - - `PhaseRunner` and `InitRunner` both take `GSDTools` (subprocess bridge) as a `tools` dependency (`phase-runner.ts:55`, `init-runner.ts:70`). - - Issue #2302 says: "Align programmatic paths with the same contracts as query handlers (shared helpers or registry dispatch), **without** removing `GSDTools`." - - Concretely: where runners currently shell out via `GSDTools.run('state update …')`, they could call the typed handler (`stateUpdate()`) directly or dispatch through `createRegistry()`. This eliminates subprocess overhead on the hot path while keeping `GSDTools` exported for backward compatibility. - - Files to touch: `sdk/src/phase-runner.ts`, `sdk/src/init-runner.ts`, `sdk/src/index.ts` (re-exports). Tests: `phase-runner.integration.test.ts`, `init-e2e.integration.test.ts`, `lifecycle-e2e.integration.test.ts`. - - **Risk:** Runner integration tests are slow and sensitive to state. Approach: swap one `tools.run()` call at a time, verify the integration test still passes, then proceed to the next. - -2. **Track B — CHANGELOG.md [Unreleased] entries** (not started) - - `CHANGELOG.md` has an `[Unreleased]` section but no Phase 3 entries yet. - - Add entries covering: golden parity policy gate, mutation subprocess infrastructure, handler alignment, profile-output port, CJS deprecation header. - - `docs/CLI-TOOLS.md` already references `QUERY-HANDLERS.md` and SDK query layer — may need minor polish but is substantively done. - - `QUERY-HANDLERS.md` is maintained and current. - -3. **Track D — CJS deprecation headers** (done) - - `gsd-tools.cjs` already has `@deprecated` JSDoc header (lines 3-6) pointing to `gsd-sdk query` and `@opengsd/gsd-sdk`. - - No additional CJS file deletion in scope per #2302. - -4. **CI verification** (should run before any PR) - - Run full integration suite: `npx vitest run --project integration` (mutation subprocess + read-only parity + golden composition). - - Verify against CI matrix expectations: Ubuntu + macOS, Node 22 + 24. - -### Acceptance criteria from #2302 — status - -| Criterion | Status | Notes | -| --------- | ------ | ----- | -| Policy gate | **Done** | `verifyGoldenPolicyComplete()` green; 0 orphan canonicals | -| Parity | **Done** | 127/128 covered; strict rows, mutation subprocess, composition goldens | -| Registry | **Done** | CJS-only matrix in `QUERY-HANDLERS.md`; `docs/CLI-TOOLS.md` updated | -| Runners (Track C) | **Not started** | `PhaseRunner`/`InitRunner` still use `GSDTools` subprocess bridge | -| Deprecation (Track D) | **Done** | `@deprecated` header on `gsd-tools.cjs` | -| Docs | **Partial** | `QUERY-HANDLERS.md` current; `CHANGELOG.md` [Unreleased] needs Phase 3 entries | -| CI | **Not verified** | Unit tests green (1261/1261); integration suite not run this session | - ---- - -## Repo / branch - -- **Workspace:** `D:\Repos\get-shit-done` (GSD PBR backport initiative). -- **Feature branch:** `feat/sdk-phase3-query-layer` (62 commits ahead of `main`; confirm against `origin` before merging). -- **Upstream PRs:** `open-gsd/get-shit-done-redux` issue #2302. - ---- - -## Golden parity architecture (current) - -| Piece | Role | -| ----- | ---- | -| `sdk/src/golden/registry-canonical-commands.ts` | One canonical dispatch string per unique handler (`pickCanonicalCommandName`). | -| `sdk/src/golden/golden-integration-covered.ts` | Canonicals exercised by **`golden.integration.test.ts`** (subset/full/shape tests). | -| `sdk/src/golden/read-only-golden-rows.ts` | **Strict** `JsonParityRow[]` for `read-only-parity.integration.test.ts` (`toEqual` on parsed CJS JSON vs `sdkResult.data`). | -| `sdk/src/golden/read-only-parity.integration.test.ts` | Rows from `READ_ONLY_JSON_PARITY_ROWS` + **`config-path`** (plain stdout vs `{ path }`, `path.normalize`) + **`verify.commits`**. | -| `sdk/src/golden/capture.ts` | `captureGsdToolsOutput` (JSON stdout); **`captureGsdToolsStdout`** (raw stdout, e.g. `config-path`). | -| `sdk/src/golden/golden-policy.ts` | `GOLDEN_PARITY_INTEGRATION_COVERED` = integration ∪ `readOnlyGoldenCanonicals()` ∪ **`GOLDEN_MUTATION_SUBPROCESS_COVERED`**; `GOLDEN_PARITY_EXCEPTIONS` includes `NO_CJS_SUBPROCESS_REASON`, then `MUTATION_DEFERRED_REASON` for remaining mutations, else read-only. | -| `sdk/src/golden/golden-mutation-covered.ts` | Canonicals exercised by **`mutation-subprocess.integration.test.ts`** (must match non-skipped tests). | -| `sdk/src/golden/mutation-subprocess.integration.test.ts` | Tmp fixture + `captureGsdToolsOutput` vs `registry.dispatch`; dual sandbox per comparison. | -| `sdk/src/golden/mutation-sandbox.ts` | `createMutationSandbox({ git?: boolean })` — copy fixture, optional `git init` + commit. | -| `sdk/src/golden/golden-policy.test.ts` | Calls `verifyGoldenPolicyComplete()` so every canonical is covered or excepted. | - -**Invariant:** Every canonical from `getCanonicalRegistryCommands()` is either in `GOLDEN_PARITY_INTEGRATION_COVERED` or has an exception string—**never** leave orphans by removing tests. - ---- - -## Reference pattern: porting like `scan-sessions` and `workstream.status` - -These were fixed by **aligning the TypeScript handler with the CJS implementation**, then adding a row to `READ_ONLY_JSON_PARITY_ROWS`. - -1. **Find the CJS source of truth** - - `scan-sessions`: `get-shit-done/bin/lib/profile-pipeline.cjs` → `cmdScanSessions` - - `workstream status`: `get-shit-done/bin/lib/workstream.cjs` → `cmdWorkstreamStatus` - - `gsd-tools.cjs` `runCommand` switch shows the top-level command and argv. - -2. **Implement or adjust the SDK module** - - Example: `sdk/src/query/profile-scan-sessions.ts` mirrors the project-array build from `cmdScanSessions`; `scanSessions` in `profile.ts` parses `--path` / `--verbose`, throws when no sessions root (same error text as CJS), returns `{ data: projects }` where `projects` matches CJS JSON array. - -3. **Add a parity row** in `read-only-golden-rows.ts` with `canonical`, `sdkArgs`, `cjs`, `cjsArgs` (must match what `execFile(node, [gsdToolsPath, command, ...args])` expects). - -4. **Run** - `cd sdk && npm run build && npx vitest run src/golden/read-only-parity.integration.test.ts src/golden/golden-policy.test.ts --project integration --project unit` - -5. **Policy** - `readOnlyGoldenCanonicals()` picks up new canonicals automatically; no manual duplicate if the canonical is already in the JSON row list. - -**When not to copy line-for-line:** subprocess-only concerns (e.g. `agents_installed` / `missing_agents` differing from in-process `~` resolution). Then **normalize in the test** (see `golden.integration.test.ts` `docs-init`: sort `existing_docs`, omit install fields)—**document in QUERY-HANDLERS.md**, do not delete the assertion. - ---- - -## Completed — Track A (golden parity) - -All 127 portable canonicals have subprocess or in-process parity coverage. Summary of completed work by batch: - -### Profile-output + milestone subprocess batch (latest) - -**`write-profile`**, **`generate-claude-profile`**, **`generate-dev-preferences`**, **`generate-claude-md`** — implemented in **`sdk/src/query/profile-output.ts`** (templates from `get-shit-done/templates/`, same JSON as `profile-output.cjs`); re-exported from **`profile.ts`**. **`milestone.complete`** — full port of **`cmdMilestoneComplete`** in **`phase-lifecycle.ts`**; **`readModifyWriteStateMdFull`** in **`state-mutation.ts`** for STATE writes matching CJS. - -### Mutation subprocess infrastructure - -**`mutation-subprocess.integration.test.ts`** — tmp fixture `sdk/src/golden/fixtures/mutation-project/` + `createMutationSandbox()` (`mutation-sandbox.ts`). **`assertJsonParity`** runs CJS and SDK on **two fresh sandboxes** (factory fn) so neither run sees the other's filesystem mutations. **`GOLDEN_MUTATION_SUBPROCESS_COVERED`** lists canonicals with non-skipped subprocess assertions. Handlers covered: `config-ensure-section`, `commit`, `commitToSubrepo`, `configSetModelProfile`, `state.patch`, `frontmatter.set`/`merge`, `workstream.progress`, `workstream.set`, nine `state.*` subprocess tests, `write-profile`, `generate-claude-profile`, `generate-dev-preferences`, `generate-claude-md`, `milestone.complete`, `init.remove-workspace`. - -### CJS mutation handler alignment - -`commit.ts` — `--files` argv boundary, `commitToSubrepo` config check, `checkCommit` `allowed` field. `state-mutation.ts` — `readModifyWriteStateMdFull`, `statePlannedPhase`=`cmdStatePlannedPhase`, record-session/add-decision/add-blocker/resolve-blocker/record-metric/update-progress JSON shapes. `phase-lifecycle.ts` — `milestone.complete`. `workstream.ts` — `workstream.progress` (`cmdWorkstreamProgress`), `workstream.set`. `roadmap.ts` — extracted `roadmapUpdatePlanProgress` to own module. `frontmatter-mutation.ts` — `--field`/`--value`, `--data` parsing. `config-mutation.ts` — `configSetModelProfile` CJS-shaped `{ updated, profile, previousProfile, agentToModelMap }`. `config-query.ts` — `getAgentToModelMapForProfile()`. - -### Read-only parity rows (earlier batches) - -`progress.table` / `stats.table`, `progress.bar`, `learnings.query`, `profile-questionnaire`, `verify.references`, `init.*` composition goldens (9 handlers), `profile-sample`, `extract-messages`, `uat.render-checkpoint`, `validate.agents` + `state.get`, `skill-manifest`, `audit-open` + `audit-uat`, `intel.extract-exports`, `summary-extract` + `history-digest`, `stats.json`, `todo.match-phase`, `verify.key-links`, `verify.schema-drift`, `state-snapshot`, `state.json`/`state.load`, `scan-sessions`, `workstream.status`. - ---- - -## Next batch — summary / audit / skill / validate / UAT / intel / profile / init - -**Same workflow as above:** read `gsd-tools.cjs` `runCommand` for argv → implement/adjust `sdk/src/query/*.ts` → add `READ_ONLY_JSON_PARITY_ROWS` and/or a **named `describe` block** with documented omissions → `npm run build` → `read-only-parity.integration.test.ts` + `golden-policy.test.ts`. - -| Priority | Command (CLI) | `gsd-tools.cjs` case / args | CJS implementation | SDK module | Notes | -| -------- | ------------- | -------------------------- | -------------------- | ---------- | ----- | -| ~~1~~ | ~~`summary-extract `~~ `[--fields a,b]` | `summary-extract` | `commands.cjs` `cmdSummaryExtract` (~L425) | `summary.ts` `summaryExtract` | **Done:** strict `READ_ONLY_JSON_PARITY_ROWS`; `summary.ts` aligned with `commands.cjs`; `extractFrontmatterLeading` in `frontmatter.ts` for first-`---`-block parity with `frontmatter.cjs`. | -| ~~2~~ | ~~`history-digest`~~ | `history-digest` | `commands.cjs` `cmdHistoryDigest` (~L133) | `summary.ts` `historyDigest` | **Done:** same row / handler alignment as above. | -| ~~3~~ | ~~`audit-open`~~ | `audit-open` `[--json]` | `audit.cjs` `auditOpenArtifacts` + optional `formatAuditReport` | `audit-open.ts` | **Done:** `--json` parity test + `scanned_at` normalization; `sanitizeForDisplay` = `security.cjs`. | -| ~~4~~ | ~~`audit-uat`~~ | `audit-uat` | `uat.cjs` `cmdAuditUat` | `uat.ts` `auditUat` | **Done:** `auditUat` ports `cmdAuditUat` (`parseUatItems`, milestone filter, `summary.by_*`); strict `READ_ONLY_JSON_PARITY_ROWS` row. | -| ~~5~~ | ~~`skill-manifest`~~ | `skill-manifest` + args | `init.cjs` `cmdSkillManifest` (~L1829) | `skill-manifest.ts` | **Done:** strict row; `extractFrontmatterLeading` for CJS parity (see `QUERY-HANDLERS.md`). | -| ~~6~~ | ~~`validate agents`~~ | `validate` + `agents` | `verify.cjs` `cmdValidateAgents` (~L997) | `validate.ts` `validateAgents` | **Done:** strict row; `getAgentsDir` parity with `core.cjs`; `MODEL_PROFILES` includes `gsd-pattern-mapper` (sync with `model-profiles.cjs`). | -| ~~7~~ | ~~`uat render-checkpoint --file `~~ | `uat` subcommand | `uat.cjs` `cmdRenderCheckpoint` | `uat.ts` `uatRenderCheckpoint` | **Done:** strict row; fixture `sdk/src/golden/fixtures/uat-render-checkpoint-sample.md`; see `QUERY-HANDLERS.md`. | -| ~~8~~ | ~~`intel extract-exports `~~ | `intel` `extract-exports` | `intel.cjs` `intelExtractExports` (~L502) | `intel.ts` `intelExtractExports` | **Done:** strict row + handler parity with `intel.cjs` (fixed file e.g. `sdk/src/query/utils.ts`). | -| ~~9~~ | ~~`extract-messages`~~ | `extract-messages` + project/session flags | `profile-pipeline.cjs` | `profile.ts` `extractMessages` | **Done:** `profile-extract-messages.ts` + golden `output_file` strip + JSONL compare; fixture `extract-messages-sessions/`. | -| ~~10~~ | ~~`profile-sample`~~ | `profile-sample` | `profile-pipeline.cjs` | `profile.ts` `profileSample` | **Done:** `profile-sample.ts` + golden `output_file` strip + JSONL compare; fixture `profile-sample-sessions/`. | -| ~~11~~ | ~~**`init.*` read-only JSON**~~ | various | `init.cjs` / `init-complex` | `init.ts`, `init-complex.ts` | **Done:** `golden.integration.test.ts` + nine init composition tests; `withProjectRoot` / `subagent_timeout` / `GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS`; see `QUERY-HANDLERS.md`. | - -**Suggested order:** Audit/read-only batch above is complete — follow-ups via **`GOLDEN_PARITY_EXCEPTIONS`** / new strict rows as needed (`learnings.query`, `progress.bar`, `profile-questionnaire`, etc.). - -**Done (this line of work):** `summary-extract` + `history-digest` — strict `READ_ONLY_JSON_PARITY_ROWS`; `summary.ts` aligned with `commands.cjs`; `extractFrontmatterLeading` in `frontmatter.ts` for first-`---`-block parity with `frontmatter.cjs`. - -**Done (profile-output + milestone mutation batch):** `write-profile`, `generate-claude-profile`, `generate-dev-preferences`, `generate-claude-md` (`profile-output.ts`); `milestone.complete` (`phase-lifecycle.ts` + `readModifyWriteStateMdFull`); `GOLDEN_MUTATION_SUBPROCESS_COVERED` updated; **`MUTATION_SUBPROCESS_GAP_REASON` removed** from `golden-policy.ts`. - -**Mutations** (`QUERY_MUTATION_COMMANDS`): subprocess coverage is **`mutation-subprocess.integration.test.ts`** + `GOLDEN_MUTATION_SUBPROCESS_COVERED`. Remaining mutation canonicals without a subprocess row use **`MUTATION_DEFERRED_REASON`** (see `golden-policy.ts`). For known gaps before parity, prefer **`it.skip`** with an explicit rationale in code comments or restore a dedicated gap map — do not rely on silent deferral alone. - ---- - -## Backlog: other read-only handlers (lower priority or follow-ups) - -Confirm against `GOLDEN_PARITY_EXCEPTIONS` in `golden-policy.ts` for the live list. - -**Mutations:** Prefer tmp fixture + dual sandbox (see `mutation-sandbox.ts`). Do not green the suite by deleting subprocess tests; skip with **`it.skip`** and document the gap (policy entry or comment) until parity is restored. - ---- - -## Not in the SDK registry (product decision) - -- **`graphify`**, **`from-gsd2` / `gsd2-import`** — CLI-only; no registry handler. - ---- - -## Files to know (updated) - -| Path | Role | -| ---- | ---- | -| `sdk/src/query/index.ts` | `createRegistry()`, `QUERY_MUTATION_COMMANDS`. | -| `sdk/src/golden/golden-policy.ts` | Coverage set + exceptions; `verifyGoldenPolicyComplete()`. | -| `sdk/src/golden/read-only-golden-rows.ts` | Strict read-only JSON matrix. | -| `sdk/src/golden/read-only-parity.integration.test.ts` | Subprocess + dispatch parity tests. | -| `sdk/src/golden/capture.ts` | `captureGsdToolsOutput`, `captureGsdToolsStdout`. | -| `sdk/src/golden/fixtures/mutation-project/` | Ephemeral copy for mutation subprocess tests. | -| `sdk/src/golden/mutation-subprocess.integration.test.ts` | Mutation handler subprocess parity. | -| `sdk/src/golden/mutation-sandbox.ts` | `createMutationSandbox({ git?: boolean })`. | -| `sdk/src/query/profile-output.ts` | CJS-parity profile output handlers. | -| `sdk/src/phase-runner.ts` | **Track C target** — currently uses `GSDTools`. | -| `sdk/src/init-runner.ts` | **Track C target** — currently uses `GSDTools`. | -| `sdk/src/gsd-tools.ts` | Subprocess bridge; **not deleted** in Phase 3 scope. | -| `get-shit-done/bin/gsd-tools.cjs` | `runCommand` — argv routing. Has `@deprecated` header. | -| `get-shit-done/bin/lib/*.cjs` | Per-command implementations (CJS source of truth). | - ---- - -## Commands (verification) - -```bash -cd sdk -npm run build -npm run test:unit -npm run test:integration -``` - -Focused: - -```bash -npx vitest run src/golden/read-only-parity.integration.test.ts src/golden/golden.integration.test.ts --project integration -npx vitest run src/golden/mutation-subprocess.integration.test.ts --project integration -npx vitest run src/golden/golden-policy.test.ts --project unit -``` - ---- - -## Success criteria (extend, not replace) - -- **No regression:** `golden-policy.test.ts` / `verifyGoldenPolicyComplete()` stays green. -- **Track A complete:** 127/128 covered; read-only rows, mutation subprocess, composition goldens all in place. -- **Track C:** Runner alignment — `PhaseRunner` and `InitRunner` use typed handlers where possible; `GSDTools` remains exported. -- **CHANGELOG.md** [Unreleased] updated with Phase 3 entries. -- **`QUERY-HANDLERS.md`** updated when assertion style changes (full `toEqual` vs normalized subset). - -**Do not "green the suite" by deleting or shrinking golden tests.** If a handler cannot match CJS byte-for-byte without product decisions, use **documented normalization** in the test or **fix the TypeScript handler** — do not silently remove assertions. - ---- - -## Commit history (this branch) - -62 commits ahead of `main` on `feat/sdk-phase3-query-layer`. Recent batch (5 commits): - -``` -95db59c docs(sdk): update handover for profile-output and mutation subprocess batch -05e8238 sdk(golden): mutation subprocess test infrastructure and golden policy -593d9be sdk(query): port profile output handlers from profile-output.cjs -a2d0eb6 sdk(query): CJS parity for state, phase-lifecycle, workstream, roadmap, frontmatter, config, and intel -8bd9f1d sdk(query): align commit handler with CJS --files argv and allowed field -``` - -**Cherry-pick notes:** Commits 1 (`8bd9f1d`) and 3 (`593d9be`) are independently cherry-pickable. Commit 2 (`a2d0eb6`) is a bulk handler alignment (13 files). Commit 4 (`05e8238`) depends on handlers from 2+3 at test-runtime but compiles independently. Commit 5 is docs-only. - ---- - -*Update this file when registry or golden milestones change.* diff --git a/sdk/HANDOVER-PARITY-DOCS.md b/sdk/HANDOVER-PARITY-DOCS.md deleted file mode 100644 index 5798b2782..000000000 --- a/sdk/HANDOVER-PARITY-DOCS.md +++ /dev/null @@ -1,97 +0,0 @@ -# Handover: Parity exceptions doc + CJS-only matrix (next session) - -**Status:** The deliverables described below are implemented in `sdk/src/query/QUERY-HANDLERS.md` (sections **Golden parity: coverage and exceptions** and **CJS command surface vs SDK registry**). Use that file as the canonical registry + parity reference; this handover remains useful for issue **#2302** scope and parent **#2007** links. - -Paste this document (or `@sdk/HANDOVER-PARITY-DOCS.md`) at the start of a new chat so work continues without re-auditing issue scope. - -## Goal for this session - -1. **Parity “exceptions” documentation** — A clear, maintainable description of where **full JSON equality** between `gsd-tools.cjs` and `createRegistry()` is **not** expected or not attempted, and why (stubs, structural-only tests, environment-dependent fields, ordering, etc.). Map this to **#2007 / #2302** expectations: no *undocumented* gap. -2. **CJS-only matrix** — A **single authoritative table**: each relevant `gsd-tools.cjs` surface (top-level command or documented cluster) → **registered in SDK** vs **permanent CLI-only** vs **alias / naming difference**, with a **one-line justification** where not registered. - -## Parent tracking - -- **Issue:** [open-gsd/get-shit-done-redux#2302](https://github.com/open-gsd/get-shit-done-redux/issues/2302) — Phase 3 SDK query parity, registry, docs (parent umbrella #2007). -- **Acceptance criteria touched here:** parity coverage/exceptions documented; registry audit reflected in a **matrix** (issue wording: “every required CJS surface either has a handler or appears in the CJS-only matrix with justification”). - -## Repo / branch - -- **Workspace:** `D:\Repos\get-shit-done` (PBR backport); adjust path if different machine. -- **Feature branch (typical):** `feat/sdk-phase3-query-layer` — confirm with `git branch` before editing. -- **Upstream:** `open-gsd/get-shit-done-redux`. - -## What already exists (do not duplicate blindly) - -- `sdk/src/query/QUERY-HANDLERS.md` — Registry conventions, partial “not registered” list (**graphify**, **from-gsd2**), CLI name differences (**summary-extract** vs **summary.extract**, **scaffold** vs **phase.scaffold**), **intel.update** (CJS JSON parity; refresh via agent), **skill-manifest --write** / mutation events, **docs-init** golden note (agent install fields), **stateExtractField** rule. -- `sdk/src/golden/golden.integration.test.ts` — Source of truth for **which commands** are golden-tested and **how** (full equality vs subset vs normalized `existing_docs` vs omitted fields; `init.quick` strips clock-derived keys via `init-golden-normalize.ts`). -- `sdk/src/golden/capture.ts` — `captureGsdToolsOutput()` spawns `get-shit-done/bin/gsd-tools.cjs`. -- `docs/CLI-TOOLS.md` — User-facing CLI reference; should **link** to the parity exceptions + matrix (or host a short summary with pointer to `sdk/`). - -## Deliverables (suggested shape) - -### A) Parity exceptions section - -Add or extend a dedicated section (prefer `QUERY-HANDLERS.md` under a heading like **"Golden parity: coverage and exceptions"**, or a new `sdk/PARITY.md` if the team wants less churn in QUERY-HANDLERS — **pick one canonical location** and link from the other). - -Cover at least: - - -| Category | Examples to document | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| **Full JSON parity** | Commands where tests use `toEqual` on `sdkResult.data` vs CJS stdout JSON. | -| **Structural / field subset** | Tests that compare only selected keys (e.g. `frontmatter.get`, `find-phase` — SDK subset vs CJS). Full parity for `roadmap.analyze`, `init.*` (except `init.quick` volatile keys), etc. — see `QUERY-HANDLERS.md` matrix. | -| **Normalized comparison** | e.g. `docs-init`: `existing_docs` sorted by path; `agents_installed` / `missing_agents` omitted between subprocess vs in-process. | -| **CLI parity without in-process refresh** | `intel.update` — JSON matches CJS `intel.cjs` (spawn hint or disabled); refresh is agent-driven. | -| **Conditional behavior** | `skill-manifest`: writes only with `--write`; not in `QUERY_MUTATION_COMMANDS`. | -| **Environment / time** | `current-timestamp`: structure and format, not same instant. | -| **Not in golden suite** | Commands registered but not (yet) covered — list as **coverage gap** or **out of scope for golden** with rationale. | - - -### B) CJS-only matrix - -Build the table by **diffing** `get-shit-done/bin/gsd-tools.cjs` `switch (command)` top-level cases against `createRegistry()` registrations in `sdk/src/query/index.ts`. - -**Already documented as product-out-of-scope for registry:** **graphify**, **from-gsd2** / **gsd2-import**. - -**Already documented as naming/alias differences (registered, different string):** **summary-extract** ↔ **summary.extract**; top-level **scaffold** ↔ **phase.scaffold**. - -Matrix columns (suggested): - -- **CJS command** (or subcommand pattern) -- **SDK dispatch name(s)** if any -- **Disposition:** Registered / CLI-only / Alias-only / Stub / N/A -- **Justification** (one line) if not a straight registered parity - -Optional: footnote that `detect-custom-files` skips multi-repo root resolution in CJS (`SKIP_ROOT_RESOLUTION`) — behavior is documented in CLI; matrix can mention if relevant. - -## Files likely to edit - - -| Path | Role | -| --------------------------------- | ----------------------------------------------------------------- | -| `sdk/src/query/QUERY-HANDLERS.md` | Primary home for exceptions + matrix, or link hub. | -| `sdk/PARITY.md` | Optional dedicated file if QUERY-HANDLERS becomes too long. | -| `docs/CLI-TOOLS.md` | Short “Parity & registry” subsection with links into `sdk/` docs. | -| `sdk/HANDOVER-GOLDEN-PARITY.md` | Optional one-line pointer to new parity doc section when done. | - - -## Out of scope for *this* handover session - -- Implementing runner alignment (`GSDTools` → registry) — separate #2302 work. -- Adding `@deprecated` headers to `gsd-tools.cjs` — separate task. -- **CHANGELOG** — only if you batch doc work with release notes in same PR (optional). - -## Verification - -- No code behavior change required for pure docs; run `npm run build` in `sdk/` only if TypeScript-adjacent files were touched. -- Proofread: every **CLI-only** row has a **justification**; every **exception** in golden tests appears in the exceptions doc. - -## Success criteria - -- A reader can answer: **“Which commands are fully golden-parity vs partial vs stub vs untested?”** without reading the whole test file. -- A reader can answer: **“Which `gsd-tools` top-level commands are not registered and why?”** from one table. -- **#2302** acceptance bullets on parity documentation and registry matrix are satisfied for the **documentation** slice (remaining issue items may still be open for code). - ---- - -*Created for handoff to “parity exceptions + CJS-only matrix” session. Update when the canonical doc location or golden coverage changes.* \ No newline at end of file diff --git a/sdk/HANDOVER-QUERY-LAYER.md b/sdk/HANDOVER-QUERY-LAYER.md deleted file mode 100644 index fc239cdb5..000000000 --- a/sdk/HANDOVER-QUERY-LAYER.md +++ /dev/null @@ -1,170 +0,0 @@ -# Handover: SDK query layer (registry, CLI, parity docs) - -Paste this document (or `@sdk/HANDOVER-QUERY-LAYER.md`) at the start of a new session so work continues without re-deriving scope. - -## Parent tracking - -- **Issue:** [open-gsd/get-shit-done-redux#2302](https://github.com/open-gsd/get-shit-done-redux/issues/2302) — Phase 3 SDK query parity, registry, docs (umbrella #2007). -- **Workspace:** `D:\Repos\get-shit-done` (PBR backport). **Upstream:** `open-gsd/get-shit-done-redux`. Confirm branch with `git branch` (typical: `feat/sdk-phase3-query-layer`). - -### Scope anchors (do not confuse issues) - - -| Role | GitHub | Notes | -| --------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Product / requirements anchor** | [#2007](https://github.com/open-gsd/get-shit-done-redux/issues/2007) | Problem statement, user stories, and target architecture for the SDK-first migration. **Do not** treat its original acceptance-checklist boxes as proof of what is merged upstream; work was split into phased PRs after maintainer review. | -| **Phase 3 execution scope** | [#2302](https://github.com/open-gsd/get-shit-done-redux/issues/2302) **+ this handover** | What this branch is actually doing now: registry/CLI parity, docs, harness gaps, runner alignment follow-ups as listed below. | -| **Patch mine (if local tree is short)** | [PR #2008](https://github.com/open-gsd/get-shit-done-redux/pull/2008) and matching branches | Large pre-phasing PR; cherry-pick or compare when something looks missing vs that line of work. | - - ---- - -## What was delivered (this line of work) - -### 1. Parity documentation (`QUERY-HANDLERS.md`) - -- **”Golden parity: coverage and exceptions”** — How `golden.integration.test.ts` compares SDK vs `gsd-tools.cjs` (full `toEqual`, subset, normalized `docs-init`, `intel.update` CJS parity, time-dependent fields, etc.). -- **”CJS command surface vs SDK registry”** — Naming aliases, CLI-only rows, SDK-only rows, and a **top-level `gsd-tools` command → SDK** matrix. -- `docs/CLI-TOOLS.md` — Short “Parity & registry” pointer into those sections. -- `HANDOVER-GOLDEN-PARITY.md` — One paragraph linking to the same sections. - -### 2. `gsd-sdk query` tokenization (`normalizeQueryCommand`) - -- **Problem:** `gsd-sdk query` used only argv[0] as the registry key, so `query state json` dispatched `state` (unregistered) instead of `state.json`. -- **Fix:** `sdk/src/query/normalize-query-command.ts` merges the same **command + subcommand** patterns as `gsd-tools` `runCommand()` (e.g. `state json` → `state.json`, `init execute-phase 9` → `init.execute-phase`, `scaffold …` → `phase.scaffold`, `progress bar` → `progress.bar`). Wired in `sdk/src/cli.ts` before `registry.dispatch()`. -- **Tests:** `sdk/src/query/normalize-query-command.test.ts`. - -### 3. `phase add-batch` in the registry - -- **Implementation:** `phaseAddBatch` in `sdk/src/query/phase-lifecycle.ts` — port of `cmdPhaseAddBatch` from `get-shit-done/bin/lib/phase.cjs` (batch append under one roadmap lock; sequential or `phase_naming: custom`). -- **Registration:** `phase.add-batch` and `phase add-batch` in `sdk/src/query/index.ts`; listed in `QUERY_MUTATION_COMMANDS` (dotted + space forms). -- **Tests:** `describe('phaseAddBatch')` in `sdk/src/query/phase-lifecycle.test.ts`. -- **Docs:** `QUERY-HANDLERS.md` updated — `phase add-batch` is **registered**; CLI-only table no longer lists it. - -### 4. `state load` fully in the registry (split from `state json`) - -Previously `state.json` and `state.load` were easy to confuse: CJS has two different commands — `cmdStateJson` (`state json`, rebuilt frontmatter) vs `cmdStateLoad` (`state load`, `loadConfig` + `state_raw` + existence flags). - -- `stateJson` — `sdk/src/query/state.ts`; registry key `state.json`. -- `stateProjectLoad` — `sdk/src/query/state-project-load.ts`; registry key `state.load`. Uses `createRequire` to call `core.cjs` `loadConfig(projectDir)` from the same resolution paths as a normal install (bundled monorepo path, `projectDir/.claude/get-shit-done/...`, `~/.claude/get-shit-done/...`). `GSDTools.stateLoad()` and `formatRegistryRawStdout` for `--raw` no longer force a subprocess solely for this command. -- **Risk:** If `core.cjs` is absent (e.g. some `@opengsd/gsd-sdk`-only layouts), `state.load` throws `GSDError` — document; future option is a TS `loadConfig` port or bundling. -- **Goldens:** `read-only-parity.integration.test.ts` — one block compares `state.json` to `state json` (strip `last_updated`); another compares `state.load` to `state load` (full `toEqual`). `read-only-golden-rows.ts` `readOnlyGoldenCanonicals()` includes both `state.json` and `state.load`. - ---- - -## Query surface completeness (snapshot) - - -| Status | Surface | -| ------------------------ | ------------------------------------------------------------------------------------------------ | -| **Registered** | Essentially all `gsd-tools.cjs` `runCommand` surfaces, including `phase.add-batch`. | -| **CLI-only (by design)** | `graphify`, `from-gsd2` — not in `createRegistry()`; documented in `QUERY-HANDLERS.md`. | -| **SDK-only extra** | `phases.archive` — no `gsd-tools phases archive` subcommand (CJS has `list` / `clear` only). | - - -**Programmatic API:** `createRegistry()` / `registry.dispatch('dotted.name', args, projectDir)`. - -**CLI:** `gsd-sdk query …` — apply `normalizeQueryCommand` semantics (or pass dotted names explicitly). - -**Still not unified:** `GSDTools` (`sdk/src/gsd-tools.ts`) shells out to `gsd-tools.cjs` for plan/session flows; migrating callers to the registry is separate #2302 / runner work. `state load` is **not** among the subprocess-only exceptions anymore (it uses the registry like other native query handlers when native query is active). - ---- - -## Canonical files - - -| Path | Role | -| ------------------------------------------- | -------------------------------------------------------------------------------------- | -| `sdk/src/query/index.ts` | `createRegistry()`, `QUERY_MUTATION_COMMANDS`, handler wiring. | -| `sdk/src/query/state-project-load.ts` | `state.load` — CJS `cmdStateLoad` parity (`loadConfig` + `state_raw` + flags). | -| `sdk/src/query/normalize-query-command.ts` | CLI argv → registry command string. | -| `sdk/src/cli.ts` | `gsd-sdk query` path (uses `normalizeQueryCommand`). | -| `sdk/src/query/QUERY-HANDLERS.md` | Registry contracts, parity tiers, CJS matrix, mutation notes. | -| `sdk/src/golden/golden.integration.test.ts` | Golden parity vs `captureGsdToolsOutput()`. | -| `docs/CLI-TOOLS.md` | User-facing CLI; links to parity sections. | - - -Related handovers: `HANDOVER-GOLDEN-PARITY.md`, `HANDOVER-PARITY-DOCS.md` (older parity-doc brief; content largely folded into `QUERY-HANDLERS.md`). - ---- - -## Roadmap: parity vs decision offloading - -Work that moves **deterministic** orchestration out of AI/bash and into **SDK queries** (historically `gsd-tools.cjs`) has **two layers**. Do not confuse them: - - -| Layer | Goal | What “done” looks like | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| **Parity / migration** | Existing CLI behavior is **stable and testable** in the registry so callers can use `gsd-sdk query` instead of `node …/gsd-tools.cjs` without silent drift. | Goldens + `QUERY-HANDLERS.md`; same JSON/`--raw` contracts as CJS. | -| **Offloading decisions** | **New or consolidated** queries replace repeated `grep`, `ls` piped to `wc -l`, many `config-get`s, and inline `node -e` in workflows — so the model does less parsing and branching. | Fewer inline shell blocks; measurable token/step reduction on representative workflows. | - - -Phase 3–style registry work mainly advances **parity**. The `decision-routing-audit.md` proposals are mostly **offloading** — they assume parity exists for commands workflows already call. - -### Decision-routing audit (proposed `gsd-tools` / SDK queries) - -Source: `.planning/research/decision-routing-audit.md` §3. **Tier** = priority from §5 (implementation order). **Do not implement** = explicitly rejected in the audit. - -| # | Proposed command | Tier | Notes | -|---|------------------|------|--------| -| 3.1 | `route next-action` | **1** | Next slash-command from `/gsd-next`-style routing. | -| 3.2 | `check gates ` | 3 | Safety gates (continue-here, error state, verification debt). | -| 3.3 | `check config-gates ` | **1** | Batch `workflow.*` config for orchestration (replaces many `config-get`s). | -| 3.4 | `check phase-ready ` | **1** | Phase directory readiness + `next_step` hint. | -| 3.5 | `check auto-mode` | 2 | `auto_advance` + `_auto_chain_active` → single boolean. | -| 3.6 | `detect phase-type ` | 2 | Structured UI/schema detection (replaces fragile grep). | -| 3.7 | `check completion ` | 2 | Phase or milestone completion rollup. | -| 3.8 | `check verification-status ` | 3 | VERIFICATION.md parsing for routing. | -| 3.9 | `check ship-ready ` | 3 | Ship preflight (`ship.md`). | -| 3.10 | `route workflow-steps ` | ❌ **Do not implement** | Pre-computed step lists are unsound when mid-workflow writes change state. See `review-and-risks.md` §3.6. | - -**Not in audit:** `phase-artifact-counts` was only an example in an older handover line; there is no §3.11 for it — add via a new research doc if needed. - -**SDK registry (Tier 1):** **Done** — `check.config-gates`, `check.phase-ready`, `route.next-action` in `createRegistry()` (`sdk/src/query/index.ts`). Documented in `sdk/src/query/QUERY-HANDLERS.md` § Decision routing (**SDK-only** until/unless mirrored in `gsd-tools.cjs`). - -**Simple roadmap (execute in order):** - -1. **Harden parity** for surfaces workflows already depend on (registry dispatch, goldens, docs) so swaps from CJS to `gsd-sdk query` stay safe. -2. **Ship 1–2 high-leverage consolidation handlers** from the audit (pick based on impact and risk; examples: `check auto-mode`, `phase-artifact-counts`, `route next-action` — with **display/routing fields** required by `review-and-risks.md` if applicable). Each needs handlers, tests, and `QUERY-HANDLERS.md` notes. **Progress:** `check.auto-mode` shipped (`sdk/src/query/check-auto-mode.ts`); Tier 1 `route.next-action` already registered. -3. **Rewrite one heavy workflow** (e.g. `next.md` or a focused slice of `autonomous.md`) to consume those queries and **measure** before/after (steps, tokens, or both). **Progress:** `execute-phase.md`, `discuss-phase.md`, `discuss-phase-assumptions.md`, and `plan-phase.md` (UI gate) now use `check auto-mode` instead of paired `config-get`s where applicable. -4. **Maintain a living boundary** between SDK (**data, deterministic checks**) and workflows (**judgment, sequencing, user-facing messages**). Extend `decision-routing-audit.md` §6 (decisions that stay with the AI) and `review-and-risks.md` “Do not implement” (e.g. no pre-computed `route workflow-steps`) as you add primitives. **Progress:** audit §3.5 / Tier 2 #4 updated to reference SDK implementation. - -**Gaps to keep in mind when designing new queries:** call-time vs stale data after file writes (re-query volatile fields); workflows own gates/UX; behavioral contracts (e.g. UI keyword lists) must match existing greps; `stderr`/`stdout` and JSON shapes stable for bash/`jq`; hybrid `require(core.cjs)` paths called out for minimal installs. - -**Research references (repo root):** `.planning/research/decision-routing-audit.md`, `.planning/research/review-and-risks.md`, `.planning/research/inline-computation-audit.md`, `.planning/research/questions.md` (Q1 boundary). For parity mechanics, prefer `sdk/src/query/QUERY-HANDLERS.md` and `HANDOVER-GOLDEN-PARITY.md`. - ---- - -## Suggested next session - -(Strategic ordering of **parity vs decision offloading** is in **Roadmap** above.) - -1. ~~**Golden test for `phase.add-batch`**~~ — Done: `sdk/src/golden/mutation-subprocess.integration.test.ts` (`phase.add-batch` JSON parity vs CJS). -2. ~~**Re-export `normalizeQueryCommand`**~~ — Done: exported from `sdk/src/query/index.ts` and `sdk/src/index.ts` (`@opengsd/gsd-sdk`). -3. **Issue #2302 follow-ups** — Runner alignment (`GSDTools` → registry where appropriate). **`configGet`** now uses `dispatchNativeJson` with canonical `config-get` (fixes subprocess argv vs real `gsd-tools.cjs`, which has no `config` + `get` top-level). Keep `graphify` / `from-gsd2` out of scope unless product reopens. -4. **Drift check** — When adding CJS commands, update `QUERY-HANDLERS.md` matrix and golden docs in the same PR. - ---- - -## Verification commands - -```bash -cd sdk -npm run build -npx vitest run src/query/normalize-query-command.test.ts src/query/phase-lifecycle.test.ts src/query/registry.test.ts --project unit -npx vitest run src/golden/golden.integration.test.ts --project integration -``` - -(Adjust `--project` to match `sdk/vitest.config.ts`.) - ---- - -## Success criteria (query-layer slice) - -- Parity expectations and CJS↔SDK matrix documented in one place (`QUERY-HANDLERS.md`). -- `gsd-sdk query` understands two-token command patterns like `gsd-tools`. -- `phase add-batch` implemented and registered; **only** intentional CLI-only gaps remain (**graphify**, **from-gsd2**). - ---- - -*Created/updated for query-layer handoff. Revise when registry surface, golden coverage, or the parity/offloading roadmap changes materially.* \ No newline at end of file diff --git a/sdk/README.md b/sdk/README.md deleted file mode 100644 index f768d4b8b..000000000 --- a/sdk/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# @opengsd/gsd-sdk - -TypeScript SDK for **Get Shit Done**: deterministic query/mutation handlers, plan execution, and event-stream telemetry so agents focus on judgment, not shell plumbing. - -## Install - -```bash -npm install @opengsd/gsd-sdk -``` - -## Quickstart — programmatic - -```typescript -import { GSD, createRegistry } from '@opengsd/gsd-sdk'; - -const gsd = new GSD({ projectDir: process.cwd(), sessionId: 'my-run' }); -const tools = gsd.createTools(); - -const registry = createRegistry(gsd.eventStream, 'my-run'); -const { data } = await registry.dispatch('state.json', [], process.cwd()); -``` - -## Quickstart — CLI - -From a project that depends on this package, **invoke the CLI with Node** (recommended in CI and local dev): - -```bash -node ./node_modules/@opengsd/gsd-sdk/dist/cli.js query state.json -node ./node_modules/@opengsd/gsd-sdk/dist/cli.js query roadmap.analyze -``` - -If no native handler is registered for a command, the CLI can transparently shell out to `get-shit-done/bin/gsd-tools.cjs` (see stderr warning), unless `GSD_QUERY_FALLBACK=off`. - -## What ships - -| Area | Entry | -|------|--------| -| Query registry | `createRegistry()` in `src/query/index.ts` — same handlers as `gsd-sdk query` | -| Tools bridge | `GSDTools` — native dispatch with optional CJS subprocess fallback | -| Orchestrators | `PhaseRunner`, `InitRunner`, `GSD` | -| CLI | `gsd-sdk` — `query`, `run`, `init`, `auto` | - -## Guides - -- **Handler registry & contracts:** [`src/query/QUERY-HANDLERS.md`](src/query/QUERY-HANDLERS.md) -- **Repository docs** (when present): `docs/ARCHITECTURE.md`, `docs/CLI-TOOLS.md` at repo root - -## Environment - -| Variable | Purpose | -|----------|---------| -| `GSD_QUERY_FALLBACK` | `off` / `never` disables CLI fallback to `gsd-tools.cjs` for unknown commands | -| `GSD_AGENTS_DIR` | Override directory scanned for installed GSD agents (`~/.claude/agents` by default) | diff --git a/sdk/docs/caching.md b/sdk/docs/caching.md deleted file mode 100644 index f34c88fe5..000000000 --- a/sdk/docs/caching.md +++ /dev/null @@ -1,68 +0,0 @@ -# Prompt Caching Best Practices - -When building applications on the GSD SDK, system prompts that include workflow instructions (executor prompts, planner context, verification rules) are large and stable across requests. Prompt caching avoids re-processing these on every API call. - -## Recommended: 1-Hour Cache TTL - -Use `cache_control` with a 1-hour TTL on system prompts that include GSD workflow content: - -```typescript -const response = await client.messages.create({ - model: 'claude-sonnet-4-20250514', - system: [ - { - type: 'text', - text: executorPrompt, // GSD workflow instructions — large, stable across requests - cache_control: { type: 'ephemeral', ttl: '1h' }, - }, - ], - messages, -}); -``` - -### Why 1 hour instead of the default 5 minutes - -GSD workflows involve human review pauses between phases — discussing results, checking verification output, deciding next steps. The default 5-minute TTL expires during these pauses, forcing full re-processing of the system prompt on the next request. - -With a 1-hour TTL: - -- **Cost:** 2x write cost on cache miss (vs. 1.25x for 5-minute TTL) -- **Break-even:** Pays for itself after 3 cache hits per hour -- **GSD usage pattern:** Phase execution involves dozens of requests per hour, well above break-even -- **Cache refresh:** Every cache hit resets the TTL at no cost, so active sessions maintain warm cache throughout - -### Which prompts to cache - -| Prompt | Cache? | Reason | -|--------|--------|--------| -| Executor system prompt | Yes | Large (~10K tokens), identical across tasks in a phase | -| Planner system prompt | Yes | Large, stable within a planning session | -| Verifier system prompt | Yes | Large, stable within a verification session | -| User/task-specific content | No | Changes per request | - -### SDK integration point - -In `session-runner.ts`, the `systemPrompt.append` field carries the executor/planner prompt. When using the Claude API directly (outside the Agent SDK's `query()` helper), wrap this content with `cache_control`: - -```typescript -// In runPlanSession / runPhaseStepSession, the systemPrompt is: -systemPrompt: { - type: 'preset', - preset: 'claude_code', - append: executorPrompt, // <-- this is the content to cache -} - -// When calling the API directly, convert to: -system: [ - { - type: 'text', - text: executorPrompt, - cache_control: { type: 'ephemeral', ttl: '1h' }, - }, -] -``` - -## References - -- [Anthropic Prompt Caching documentation](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) -- [Extended caching (1-hour TTL)](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#extended-caching) diff --git a/sdk/package-lock.json b/sdk/package-lock.json deleted file mode 100644 index aa3c4cb07..000000000 --- a/sdk/package-lock.json +++ /dev/null @@ -1,2502 +0,0 @@ -{ - "name": "@opengsd/gsd-sdk", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@opengsd/gsd-sdk", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.84", - "ws": "8.20.1" - }, - "bin": { - "gsd-sdk": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "@types/ws": "^8.18.1", - "tsx": "^4.22.0", - "typescript": "^5.7.0", - "vitest": "^3.1.1" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.84", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.84.tgz", - "integrity": "sha512-rvp3kZJM4IgDBE1zwj30H3N0bI3pYRF28tDJoyAVuWTLiWls7diNVCyFz7GeXZEAYYD87lCBE3vnQplLLluNHg==", - "license": "SEE LICENSE IN README.md", - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.34.2", - "@img/sharp-darwin-x64": "^0.34.2", - "@img/sharp-linux-arm": "^0.34.2", - "@img/sharp-linux-arm64": "^0.34.2", - "@img/sharp-linux-x64": "^0.34.2", - "@img/sharp-linuxmusl-arm64": "^0.34.2", - "@img/sharp-linuxmusl-x64": "^0.34.2", - "@img/sharp-win32-arm64": "^0.34.2", - "@img/sharp-win32-x64": "^0.34.2" - }, - "peerDependencies": { - "zod": "^4.0.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tsx": { - "version": "4.22.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", - "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/sdk/package.json b/sdk/package.json deleted file mode 100644 index b1bee07a6..000000000 --- a/sdk/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@opengsd/gsd-sdk", - "version": "1.0.0", - "description": "GSD SDK — programmatic interface for running GSD plans via the Agent SDK", - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "bin": { - "gsd-sdk": "./dist/cli.js" - }, - "files": [ - "dist", - "shared", - "prompts" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/open-gsd/get-shit-done-redux.git", - "directory": "sdk" - }, - "homepage": "https://github.com/open-gsd/get-shit-done-redux/tree/main/sdk", - "bugs": { - "url": "https://github.com/open-gsd/get-shit-done-redux/issues" - }, - "author": "TÂCHES", - "license": "MIT", - "publishConfig": { - "access": "public" - }, - "engines": { - "node": ">=22.0.0" - }, - "scripts": { - "build": "tsc", - "prepublishOnly": "rm -rf dist && tsc && chmod +x dist/cli.js", - "test": "vitest run", - "test:unit": "vitest run --project unit", - "test:integration": "vitest run --project integration" - }, - "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.84", - "ws": "8.20.1" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "@types/ws": "^8.18.1", - "tsx": "^4.22.0", - "typescript": "^5.7.0", - "vitest": "^3.1.1" - } -} diff --git a/sdk/prompts/templates/project.md b/sdk/prompts/templates/project.md deleted file mode 100644 index f11ffa844..000000000 --- a/sdk/prompts/templates/project.md +++ /dev/null @@ -1,186 +0,0 @@ -# PROJECT.md Template - -Template for `.planning/PROJECT.md` — the living project context document. - - - - - -**What This Is:** -- Current accurate description of the product -- 2-3 sentences capturing what it does and who it's for -- Use the user's words and framing -- Update when the product evolves beyond this description - -**Core Value:** -- The single most important thing -- Everything else can fail; this cannot -- Drives prioritization when tradeoffs arise -- Rarely changes; if it does, it's a significant pivot - -**Requirements — Validated:** -- Requirements that shipped and proved valuable -- Format: `- ✓ [Requirement] — [version/phase]` -- These are locked — changing them requires explicit discussion - -**Requirements — Active:** -- Current scope being built toward -- These are hypotheses until shipped and validated -- Move to Validated when shipped, Out of Scope if invalidated - -**Requirements — Out of Scope:** -- Explicit boundaries on what we're not building -- Always include reasoning (prevents re-adding later) -- Includes: considered and rejected, deferred to future, explicitly excluded - -**Context:** -- Background that informs implementation decisions -- Technical environment, prior work, user feedback -- Known issues or technical debt to address -- Update as new context emerges - -**Constraints:** -- Hard limits on implementation choices -- Tech stack, timeline, budget, compatibility, dependencies -- Include the "why" — constraints without rationale get questioned - -**Key Decisions:** -- Significant choices that affect future work -- Add decisions as they're made throughout the project -- Track outcome when known: - - ✓ Good — decision proved correct - - ⚠️ Revisit — decision may need reconsideration - - — Pending — too early to evaluate - -**Last Updated:** -- Always note when and why the document was updated -- Format: `after Phase 2` or `after v1.0 milestone` -- Triggers review of whether content is still accurate - - - - - -PROJECT.md evolves throughout the project lifecycle. -These rules are embedded in the generated PROJECT.md (## Evolution section) -and implemented by transition and milestone-completion workflows. - -**After each phase transition:** -1. Requirements invalidated? → Move to Out of Scope with reason -2. Requirements validated? → Move to Validated with phase reference -3. New requirements emerged? → Add to Active -4. Decisions to log? → Add to Key Decisions -5. "What This Is" still accurate? → Update if drifted - -**After each milestone:** -1. Full review of all sections -2. Core Value check — still the right priority? -3. Audit Out of Scope — reasons still valid? -4. Update Context with current state (users, feedback, metrics) - - - - - -For existing codebases: - -1. **Map the codebase first** — analyze the project structure and existing code before defining requirements. - -2. **Infer Validated requirements** from existing code: - - What does the codebase actually do? - - What patterns are established? - - What's clearly working and relied upon? - -3. **Gather Active requirements** from user: - - Present inferred current state - - Ask what they want to build next - -4. **Initialize:** - - Validated = inferred from existing code - - Active = user's goals for this work - - Out of Scope = boundaries user specifies - - Context = includes current codebase state - - - - - -STATE.md references PROJECT.md: - -```markdown -## Project Reference - -See: .planning/PROJECT.md (updated [date]) - -**Core value:** [One-liner from Core Value section] -**Current focus:** [Current phase name] -``` - -This ensures Claude reads current PROJECT.md context. - - diff --git a/sdk/prompts/templates/requirements.md b/sdk/prompts/templates/requirements.md deleted file mode 100644 index d55313480..000000000 --- a/sdk/prompts/templates/requirements.md +++ /dev/null @@ -1,231 +0,0 @@ -# Requirements Template - -Template for `.planning/REQUIREMENTS.md` — checkable requirements that define "done." - - - - - -**Requirement Format:** -- ID: `[CATEGORY]-[NUMBER]` (AUTH-01, CONTENT-02, SOCIAL-03) -- Description: User-centric, testable, atomic -- Checkbox: Only for v1 requirements (v2 are not yet actionable) - -**Categories:** -- Derive from research FEATURES.md categories -- Keep consistent with domain conventions -- Typical: Authentication, Content, Social, Notifications, Moderation, Payments, Admin - -**v1 vs v2:** -- v1: Committed scope, will be in roadmap phases -- v2: Acknowledged but deferred, not in current roadmap -- Moving v2 → v1 requires roadmap update - -**Out of Scope:** -- Explicit exclusions with reasoning -- Prevents "why didn't you include X?" later -- Anti-features from research belong here with warnings - -**Traceability:** -- Empty initially, populated during roadmap creation -- Each requirement maps to exactly one phase -- Unmapped requirements = roadmap gap - -**Status Values:** -- Pending: Not started -- In Progress: Phase is active -- Complete: Requirement verified -- Blocked: Waiting on external factor - - - - - -**After each phase completes:** -1. Mark covered requirements as Complete -2. Update traceability status -3. Note any requirements that changed scope - -**After roadmap updates:** -1. Verify all v1 requirements still mapped -2. Add new requirements if scope expanded -3. Move requirements to v2/out of scope if descoped - -**Requirement completion criteria:** -- Requirement is "Complete" when: - - Feature is implemented - - Feature is verified (tests pass, manual check done) - - Feature is committed - - - - - -```markdown -# Requirements: CommunityApp - -**Defined:** 2025-01-14 -**Core Value:** Users can share and discuss content with people who share their interests - -## v1 Requirements - -### Authentication - -- [ ] **AUTH-01**: User can sign up with email and password -- [ ] **AUTH-02**: User receives email verification after signup -- [ ] **AUTH-03**: User can reset password via email link -- [ ] **AUTH-04**: User session persists across browser refresh - -### Profiles - -- [ ] **PROF-01**: User can create profile with display name -- [ ] **PROF-02**: User can upload avatar image -- [ ] **PROF-03**: User can write bio (max 500 chars) -- [ ] **PROF-04**: User can view other users' profiles - -### Content - -- [ ] **CONT-01**: User can create text post -- [ ] **CONT-02**: User can upload image with post -- [ ] **CONT-03**: User can edit own posts -- [ ] **CONT-04**: User can delete own posts -- [ ] **CONT-05**: User can view feed of posts - -### Social - -- [ ] **SOCL-01**: User can follow other users -- [ ] **SOCL-02**: User can unfollow users -- [ ] **SOCL-03**: User can like posts -- [ ] **SOCL-04**: User can comment on posts -- [ ] **SOCL-05**: User can view activity feed (followed users' posts) - -## v2 Requirements - -### Notifications - -- **NOTF-01**: User receives in-app notifications -- **NOTF-02**: User receives email for new followers -- **NOTF-03**: User receives email for comments on own posts -- **NOTF-04**: User can configure notification preferences - -### Moderation - -- **MODR-01**: User can report content -- **MODR-02**: User can block other users -- **MODR-03**: Admin can view reported content -- **MODR-04**: Admin can remove content -- **MODR-05**: Admin can ban users - -## Out of Scope - -| Feature | Reason | -|---------|--------| -| Real-time chat | High complexity, not core to community value | -| Video posts | Storage/bandwidth costs, defer to v2+ | -| OAuth login | Email/password sufficient for v1 | -| Mobile app | Web-first, mobile later | - -## Traceability - -| Requirement | Phase | Status | -|-------------|-------|--------| -| AUTH-01 | Phase 1 | Pending | -| AUTH-02 | Phase 1 | Pending | -| AUTH-03 | Phase 1 | Pending | -| AUTH-04 | Phase 1 | Pending | -| PROF-01 | Phase 2 | Pending | -| PROF-02 | Phase 2 | Pending | -| PROF-03 | Phase 2 | Pending | -| PROF-04 | Phase 2 | Pending | -| CONT-01 | Phase 3 | Pending | -| CONT-02 | Phase 3 | Pending | -| CONT-03 | Phase 3 | Pending | -| CONT-04 | Phase 3 | Pending | -| CONT-05 | Phase 3 | Pending | -| SOCL-01 | Phase 4 | Pending | -| SOCL-02 | Phase 4 | Pending | -| SOCL-03 | Phase 4 | Pending | -| SOCL-04 | Phase 4 | Pending | -| SOCL-05 | Phase 4 | Pending | - -**Coverage:** -- v1 requirements: 18 total -- Mapped to phases: 18 -- Unmapped: 0 ✓ - ---- -*Requirements defined: 2025-01-14* -*Last updated: 2025-01-14 after initial definition* -``` - - diff --git a/sdk/prompts/templates/research-project/ARCHITECTURE.md b/sdk/prompts/templates/research-project/ARCHITECTURE.md deleted file mode 100644 index 0d0329761..000000000 --- a/sdk/prompts/templates/research-project/ARCHITECTURE.md +++ /dev/null @@ -1,204 +0,0 @@ -# Architecture Research Template - -Template for `.planning/research/ARCHITECTURE.md` — system structure patterns for the project domain. - - - - - -**System Overview:** -- Use ASCII box-drawing diagrams for clarity (├── └── │ ─ for structure visualization only) -- Show major components and their relationships -- Don't over-detail — this is conceptual, not implementation - -**Project Structure:** -- Be specific about folder organization -- Explain the rationale for grouping -- Match conventions of the chosen stack - -**Patterns:** -- Include code examples where helpful -- Explain trade-offs honestly -- Note when patterns are overkill for small projects - -**Scaling Considerations:** -- Be realistic — most projects don't need to scale to millions -- Focus on "what breaks first" not theoretical limits -- Avoid premature optimization recommendations - -**Anti-Patterns:** -- Specific to this domain -- Include what to do instead -- Helps prevent common mistakes during implementation - - diff --git a/sdk/prompts/templates/research-project/FEATURES.md b/sdk/prompts/templates/research-project/FEATURES.md deleted file mode 100644 index 431c52ba5..000000000 --- a/sdk/prompts/templates/research-project/FEATURES.md +++ /dev/null @@ -1,147 +0,0 @@ -# Features Research Template - -Template for `.planning/research/FEATURES.md` — feature landscape for the project domain. - - - - - -**Table Stakes:** -- These are non-negotiable for launch -- Users don't give credit for having them, but penalize for missing them -- Example: A community platform without user profiles is broken - -**Differentiators:** -- These are where you compete -- Should align with the Core Value from PROJECT.md -- Don't try to differentiate on everything - -**Anti-Features:** -- Prevent scope creep by documenting what seems good but isn't -- Include the alternative approach -- Example: "Real-time everything" often creates complexity without value - -**Feature Dependencies:** -- Critical for roadmap phase ordering -- If A requires B, B must be in an earlier phase -- Conflicts inform what NOT to combine in same phase - -**MVP Definition:** -- Be ruthless about what's truly minimum -- "Nice to have" is not MVP -- Launch with less, validate, then expand - - diff --git a/sdk/prompts/templates/research-project/PITFALLS.md b/sdk/prompts/templates/research-project/PITFALLS.md deleted file mode 100644 index 9d66e6a6c..000000000 --- a/sdk/prompts/templates/research-project/PITFALLS.md +++ /dev/null @@ -1,200 +0,0 @@ -# Pitfalls Research Template - -Template for `.planning/research/PITFALLS.md` — common mistakes to avoid in the project domain. - - - - - -**Critical Pitfalls:** -- Focus on domain-specific issues, not generic mistakes -- Include warning signs — early detection prevents disasters -- Link to specific phases — makes pitfalls actionable - -**Technical Debt:** -- Be realistic — some shortcuts are acceptable -- Note when shortcuts are "never acceptable" vs. "only in MVP" -- Include the long-term cost to inform tradeoff decisions - -**Performance Traps:** -- Include scale thresholds ("breaks at 10k users") -- Focus on what's relevant for this project's expected scale -- Don't over-engineer for hypothetical scale - -**Security Mistakes:** -- Beyond OWASP basics — domain-specific issues -- Example: Community platforms have different security concerns than e-commerce -- Include risk level to prioritize - -**"Looks Done But Isn't":** -- Checklist format for verification during execution -- Common in demos vs. production -- Prevents "it works on my machine" issues - -**Pitfall-to-Phase Mapping:** -- Critical for roadmap creation -- Each pitfall should map to a phase that prevents it -- Informs phase ordering and success criteria - - diff --git a/sdk/prompts/templates/research-project/STACK.md b/sdk/prompts/templates/research-project/STACK.md deleted file mode 100644 index cdd663ba2..000000000 --- a/sdk/prompts/templates/research-project/STACK.md +++ /dev/null @@ -1,120 +0,0 @@ -# Stack Research Template - -Template for `.planning/research/STACK.md` — recommended technologies for the project domain. - - - - - -**Core Technologies:** -- Include specific version numbers -- Explain why this is the standard choice, not just what it does -- Focus on technologies that affect architecture decisions - -**Supporting Libraries:** -- Include libraries commonly needed for this domain -- Note when each is needed (not all projects need all libraries) - -**Alternatives:** -- Don't just dismiss alternatives -- Explain when alternatives make sense -- Helps user make informed decisions if they disagree - -**What NOT to Use:** -- Actively warn against outdated or problematic choices -- Explain the specific problem, not just "it's old" -- Provide the recommended alternative - -**Version Compatibility:** -- Note any known compatibility issues -- Critical for avoiding debugging time later - - diff --git a/sdk/prompts/templates/research-project/SUMMARY.md b/sdk/prompts/templates/research-project/SUMMARY.md deleted file mode 100644 index edd67ddf0..000000000 --- a/sdk/prompts/templates/research-project/SUMMARY.md +++ /dev/null @@ -1,170 +0,0 @@ -# Research Summary Template - -Template for `.planning/research/SUMMARY.md` — executive summary of project research with roadmap implications. - - - - - -**Executive Summary:** -- Write for someone who will only read this section -- Include the key recommendation and main risk -- 2-3 paragraphs maximum - -**Key Findings:** -- Summarize, don't duplicate full documents -- Link to detailed docs (STACK.md, FEATURES.md, etc.) -- Focus on what matters for roadmap decisions - -**Implications for Roadmap:** -- This is the most important section -- Directly informs roadmap creation -- Be explicit about phase suggestions and rationale -- Include research flags for each suggested phase - -**Confidence Assessment:** -- Be honest about uncertainty -- Note gaps that need resolution during planning -- HIGH = verified with official sources -- MEDIUM = community consensus, multiple sources agree -- LOW = single source or inference - -**Integration with roadmap creation:** -- This file is loaded as context during roadmap creation -- Phase suggestions here become starting point for roadmap -- Research flags inform phase planning - - diff --git a/sdk/prompts/templates/roadmap.md b/sdk/prompts/templates/roadmap.md deleted file mode 100644 index 9d6749bf5..000000000 --- a/sdk/prompts/templates/roadmap.md +++ /dev/null @@ -1,202 +0,0 @@ -# Roadmap Template - -Template for `.planning/ROADMAP.md`. - -## Initial Roadmap (v1.0 Greenfield) - -```markdown -# Roadmap: [Project Name] - -## Overview - -[One paragraph describing the journey from start to finish] - -## Phases - -**Phase Numbering:** -- Integer phases (1, 2, 3): Planned milestone work -- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) - -Decimal phases appear between their surrounding integers in numeric order. - -- [ ] **Phase 1: [Name]** - [One-line description] -- [ ] **Phase 2: [Name]** - [One-line description] -- [ ] **Phase 3: [Name]** - [One-line description] -- [ ] **Phase 4: [Name]** - [One-line description] - -## Phase Details - -### Phase 1: [Name] -**Goal**: [What this phase delivers] -**Depends on**: Nothing (first phase) -**Requirements**: [REQ-01, REQ-02, REQ-03] -**Success Criteria** (what must be TRUE): - 1. [Observable behavior from user perspective] - 2. [Observable behavior from user perspective] - 3. [Observable behavior from user perspective] -**Plans**: [Number of plans, e.g., "3 plans" or "TBD"] - -Plans: -- [ ] 01-01: [Brief description of first plan] -- [ ] 01-02: [Brief description of second plan] -- [ ] 01-03: [Brief description of third plan] - -### Phase 2: [Name] -**Goal**: [What this phase delivers] -**Depends on**: Phase 1 -**Requirements**: [REQ-04, REQ-05] -**Success Criteria** (what must be TRUE): - 1. [Observable behavior from user perspective] - 2. [Observable behavior from user perspective] -**Plans**: [Number of plans] - -Plans: -- [ ] 02-01: [Brief description] -- [ ] 02-02: [Brief description] - -### Phase 2.1: Critical Fix (INSERTED) -**Goal**: [Urgent work inserted between phases] -**Depends on**: Phase 2 -**Success Criteria** (what must be TRUE): - 1. [What the fix achieves] -**Plans**: 1 plan - -Plans: -- [ ] 02.1-01: [Description] - -### Phase 3: [Name] -**Goal**: [What this phase delivers] -**Depends on**: Phase 2 -**Requirements**: [REQ-06, REQ-07, REQ-08] -**Success Criteria** (what must be TRUE): - 1. [Observable behavior from user perspective] - 2. [Observable behavior from user perspective] - 3. [Observable behavior from user perspective] -**Plans**: [Number of plans] - -Plans: -- [ ] 03-01: [Brief description] -- [ ] 03-02: [Brief description] - -### Phase 4: [Name] -**Goal**: [What this phase delivers] -**Depends on**: Phase 3 -**Requirements**: [REQ-09, REQ-10] -**Success Criteria** (what must be TRUE): - 1. [Observable behavior from user perspective] - 2. [Observable behavior from user perspective] -**Plans**: [Number of plans] - -Plans: -- [ ] 04-01: [Brief description] - -## Progress - -**Execution Order:** -Phases execute in numeric order: 2 → 2.1 → 2.2 → 3 → 3.1 → 4 - -| Phase | Plans Complete | Status | Completed | -|-------|----------------|--------|-----------| -| 1. [Name] | 0/3 | Not started | - | -| 2. [Name] | 0/2 | Not started | - | -| 3. [Name] | 0/2 | Not started | - | -| 4. [Name] | 0/1 | Not started | - | -``` - - -**Initial planning (v1.0):** -- Phase count depends on granularity setting (coarse: 3-5, standard: 5-8, fine: 8-12) -- Each phase delivers something coherent -- Phases can have 1+ plans (split if >3 tasks or multiple subsystems) -- Plans use naming: {phase}-{plan}-PLAN.md (e.g., 01-02-PLAN.md) -- No time estimates (this isn't enterprise PM) -- Progress table updated by execute workflow -- Plan count can be "TBD" initially, refined during planning - -**Success criteria:** -- 2-5 observable behaviors per phase (from user's perspective) -- Cross-checked against requirements during roadmap creation -- Flow downstream to `must_haves` in plan-phase -- Verified by verify-phase after execution -- Format: "User can [action]" or "[Thing] works/exists" - -**After milestones ship:** -- Collapse completed milestones in `
` tags -- Add new milestone sections for upcoming work -- Keep continuous phase numbering (never restart at 01) - - - -- `Not started` - Haven't begun -- `In progress` - Currently working -- `Complete` - Done (add completion date) -- `Deferred` - Pushed to later (with reason) - - -## Milestone-Grouped Roadmap (After v1.0 Ships) - -After completing first milestone, reorganize with milestone groupings: - -```markdown -# Roadmap: [Project Name] - -## Milestones - -- ✅ **v1.0 MVP** - Phases 1-4 (shipped YYYY-MM-DD) -- 🚧 **v1.1 [Name]** - Phases 5-6 (in progress) -- 📋 **v2.0 [Name]** - Phases 7-10 (planned) - -## Phases - -
-✅ v1.0 MVP (Phases 1-4) - SHIPPED YYYY-MM-DD - -### Phase 1: [Name] -**Goal**: [What this phase delivers] -**Plans**: 3 plans - -Plans: -- [x] 01-01: [Brief description] -- [x] 01-02: [Brief description] -- [x] 01-03: [Brief description] - -[... remaining v1.0 phases ...] - -
- -### 🚧 v1.1 [Name] (In Progress) - -**Milestone Goal:** [What v1.1 delivers] - -#### Phase 5: [Name] -**Goal**: [What this phase delivers] -**Depends on**: Phase 4 -**Plans**: 2 plans - -Plans: -- [ ] 05-01: [Brief description] -- [ ] 05-02: [Brief description] - -[... remaining v1.1 phases ...] - -### 📋 v2.0 [Name] (Planned) - -**Milestone Goal:** [What v2.0 delivers] - -[... v2.0 phases ...] - -## Progress - -| Phase | Milestone | Plans Complete | Status | Completed | -|-------|-----------|----------------|--------|-----------| -| 1. Foundation | v1.0 | 3/3 | Complete | YYYY-MM-DD | -| 2. Features | v1.0 | 2/2 | Complete | YYYY-MM-DD | -| 5. Security | v1.1 | 0/2 | Not started | - | -``` - -**Notes:** -- Milestone emoji: ✅ shipped, 🚧 in progress, 📋 planned -- Completed milestones collapsed in `
` for readability -- Current/future milestones expanded -- Continuous phase numbering (01-99) -- Progress table includes milestone column diff --git a/sdk/prompts/templates/state.md b/sdk/prompts/templates/state.md deleted file mode 100644 index d4476e852..000000000 --- a/sdk/prompts/templates/state.md +++ /dev/null @@ -1,194 +0,0 @@ -# State Template - -Template for `.planning/STATE.md` — the project's living memory. - ---- - -## File Template - -```markdown ---- -gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call -status: planning -progress: - total_phases: 0 - completed_phases: 0 - total_plans: 0 - completed_plans: 0 - percent: 0 ---- - -# Project State - -## Project Reference - -See: .planning/PROJECT.md (updated [date]) - -**Core value:** [One-liner from PROJECT.md Core Value section] -**Current focus:** [Current phase name] - -## Current Position - -Phase: [X] of [Y] ([Phase name]) -Plan: [A] of [B] in current phase -Status: [Ready to plan / Planning / Ready to execute / In progress / Phase complete] -Last activity: [YYYY-MM-DD] — [What happened] - -Progress: [░░░░░░░░░░] 0% - -## Performance Metrics - -**Velocity:** -- Total plans completed: [N] -- Average duration: [X] min -- Total execution time: [X.X] hours - -**By Phase:** - -| Phase | Plans | Total | Avg/Plan | -|-------|-------|-------|----------| -| - | - | - | - | - -**Recent Trend:** -- Last 5 plans: [durations] -- Trend: [Improving / Stable / Degrading] - -*Updated after each plan completion* - -## Accumulated Context - -### Decisions - -Decisions are logged in PROJECT.md Key Decisions table. -Recent decisions affecting current work: - -- [Phase X]: [Decision summary] -- [Phase Y]: [Decision summary] - -### Pending Todos - -[From .planning/todos/pending/ — ideas captured during sessions] - -None yet. - -### Blockers/Concerns - -[Issues that affect future work] - -None yet. - -## Deferred Items - -Items acknowledged and carried forward from previous milestone close: - -| Category | Item | Status | Deferred At | -|----------|------|--------|-------------| -| *(none)* | | | | - -## Session Continuity - -Last session: [YYYY-MM-DD HH:MM] -Stopped at: [Description of last completed action] -Resume file: [Path to .continue-here*.md if exists, otherwise "None"] -``` - - - -STATE.md is the project's short-term memory spanning all phases and sessions. - -**Problem it solves:** Information is captured in summaries, issues, and decisions but not systematically consumed. Sessions start without context. - -**Solution:** A single, small file that's: -- Read first in every workflow -- Updated after every significant action -- Contains digest of accumulated context -- Enables instant session restoration - - - - - -**Creation:** After ROADMAP.md is created (during init) -- Reference PROJECT.md (read it for current context) -- Initialize empty accumulated context sections -- Set position to "Phase 1 ready to plan" - -**Reading:** First step of every workflow -- progress: Present status to user -- plan: Inform planning decisions -- execute: Know current position -- transition: Know what's complete - -**Writing:** After every significant action -- execute: After SUMMARY.md created - - Update position (phase, plan, status) - - Note new decisions (detail in PROJECT.md) - - Add blockers/concerns -- transition: After phase marked complete - - Update progress bar - - Clear resolved blockers - - Refresh Project Reference date - - - - - -### Project Reference -Points to PROJECT.md for full context. Includes: -- Core value (the ONE thing that matters) -- Current focus (which phase) -- Last update date (triggers re-read if stale) - -Claude reads PROJECT.md directly for requirements, constraints, and decisions. - -### Current Position -Where we are right now: -- Phase X of Y — which phase -- Plan A of B — which plan within phase -- Status — current state -- Last activity — what happened most recently -- Progress bar — visual indicator of overall completion - -Progress calculation: (completed plans) / (total plans across all phases) × 100% - -### Performance Metrics -Track velocity to understand execution patterns: -- Total plans completed -- Average duration per plan -- Per-phase breakdown -- Recent trend (improving/stable/degrading) - -Updated after each plan completion. - -### Accumulated Context - -**Decisions:** Reference to PROJECT.md Key Decisions table, plus recent decisions summary for quick access. Full decision log lives in PROJECT.md. - -**Pending Todos:** Ideas captured during sessions. -- Count of pending todos -- Brief list if few, count if many - -**Blockers/Concerns:** From "Next Phase Readiness" sections -- Issues that affect future work -- Prefix with originating phase -- Cleared when addressed - -### Session Continuity -Enables instant resumption: -- When was last session -- What was last completed -- Is there a .continue-here file to resume from - - - - - -Keep STATE.md under 100 lines. - -It's a DIGEST, not an archive. If accumulated context grows too large: -- Keep only 3-5 recent decisions in summary (full log in PROJECT.md) -- Keep only active blockers, remove resolved ones - -The goal is "read once, know where we are" — if it's too long, that fails. - - diff --git a/sdk/scripts/gen-profile-questionnaire-data.mjs b/sdk/scripts/gen-profile-questionnaire-data.mjs deleted file mode 100644 index 7e7040624..000000000 --- a/sdk/scripts/gen-profile-questionnaire-data.mjs +++ /dev/null @@ -1,59 +0,0 @@ -/** - * One-off generator: extracts PROFILING_QUESTIONS + CLAUDE_INSTRUCTIONS from profile-output.cjs - * Run: node scripts/gen-profile-questionnaire-data.mjs - */ -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = join(__dirname, '..', '..'); -const cjs = fs.readFileSync(join(root, 'get-shit-done/bin/lib/profile-output.cjs'), 'utf-8'); - -const m1 = cjs.match(/const PROFILING_QUESTIONS = (\[[\s\S]*?\]);/); -const m2 = cjs.match(/const CLAUDE_INSTRUCTIONS = (\{[\s\S]*?\n\});/); -if (!m1 || !m2) { - console.error('regex extract failed'); - process.exit(1); -} - -const header = `/** - * Synced from get-shit-done/bin/lib/profile-output.cjs (PROFILING_QUESTIONS, CLAUDE_INSTRUCTIONS). - * Used by profileQuestionnaire for parity with cmdProfileQuestionnaire. - */ - -export type ProfilingOption = { label: string; value: string; rating: string }; - -export type ProfilingQuestion = { - dimension: string; - header: string; - context: string; - question: string; - options: ProfilingOption[]; -}; - -export const PROFILING_QUESTIONS: ProfilingQuestion[] = ${m1[1]}; - -export const CLAUDE_INSTRUCTIONS: Record> = ${m2[1]}; - -export function isAmbiguousAnswer(dimension: string, value: string): boolean { - if (dimension === 'communication_style' && value === 'd') return true; - const question = PROFILING_QUESTIONS.find((q) => q.dimension === dimension); - if (!question) return false; - const option = question.options.find((o) => o.value === value); - if (!option) return false; - return option.rating === 'mixed'; -} - -export function generateClaudeInstruction(dimension: string, rating: string): string { - const dimInstructions = CLAUDE_INSTRUCTIONS[dimension]; - if (dimInstructions && dimInstructions[rating]) { - return dimInstructions[rating]!; - } - return \`Adapt to this developer's \${dimension.replace(/_/g, ' ')} preference: \${rating}.\`; -} -`; - -const outPath = join(root, 'sdk/src/query/profile-questionnaire-data.ts'); -fs.writeFileSync(outPath, header); -console.log('wrote', outPath); diff --git a/sdk/src/assembled-prompts.test.ts b/sdk/src/assembled-prompts.test.ts deleted file mode 100644 index 729104ff7..000000000 --- a/sdk/src/assembled-prompts.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * Contract test: assembled prompts from PromptFactory.buildPrompt() and - * InitRunner.build*Prompt() must contain zero interactive patterns. - * - * Unlike headless-prompts.test.ts (which scans raw .md files on disk), - * these tests exercise the full assembly pipeline: - * file loading → role extraction → context injection → sanitizePrompt() - * - * If any assembly step reintroduces interactive patterns that sanitizePrompt() - * doesn't catch, these tests will fail. - */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; - -import { PromptFactory } from './phase-prompt.js'; -import { InitRunner } from './init-runner.js'; -import { PhaseType } from './types.js'; -import type { ParsedPlan, ContextFiles, GSDEvent } from './types.js'; -import type { GSDTools } from './gsd-tools.js'; -import type { GSDEventStream } from './event-stream.js'; - -// ─── Paths ─────────────────────────────────────────────────────────────────── - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const sdkPromptsDir = join(__dirname, '..', 'prompts'); - -// ─── Blocked patterns (aligned with headless-prompts.test.ts) ──────────────── - -const BLOCKED_PATTERNS: Array<[string, RegExp]> = [ - ['AskUserQuestion', /AskUserQuestion\s*\(/], - ['SlashCommand', /SlashCommand\s*\(/], - ['/gsd: command', /\/gsd:\S+/], - ['@file: reference', /@file:\S+/], - ['STOP + wait directive', /\bSTOP\b\s+(?:and\s+)?(?:wait|ask)/i], - ['bare STOP directive', /^\s*STOP\s*[.!]?\s*$/m], - ['wait for user', /\bwait\s+for\s+(?:the\s+)?user\b/i], - ['ask the user', /\bask\s+the\s+user\b/i], -]; - -// ─── Minimal fixtures ──────────────────────────────────────────────────────── - -const MINIMAL_PLAN: ParsedPlan = { - frontmatter: { - phase: '01', - plan: 'test-plan', - type: 'feature', - wave: 1, - depends_on: [], - files_modified: ['src/index.ts'], - autonomous: true, - requirements: ['R001'], - must_haves: { - truths: ['It works'], - artifacts: [{ path: 'src/index.ts', provides: 'entry point' }], - key_links: [], - }, - }, - objective: 'Test objective for assembled prompt contract test', - execution_context: ['This is a test context line'], - context_refs: [], - tasks: [ - { - type: 'create', - name: 'Create test file', - files: ['src/test.ts'], - read_first: [], - action: 'Create a test file', - verify: 'File exists', - acceptance_criteria: ['File created'], - done: 'src/test.ts exists', - }, - ], - raw: '# Test Plan\n\nMinimal plan for testing.', -}; - -const EMPTY_CONTEXT: ContextFiles = {}; - -// ─── Helper ────────────────────────────────────────────────────────────────── - -function assertNoBlockedPatterns(output: string, label: string): void { - for (const [patternLabel, pattern] of BLOCKED_PATTERNS) { - const matches = output.match(new RegExp(pattern.source, pattern.flags + 'g')); - expect( - matches, - `Found ${patternLabel} in ${label}: ${matches?.join(', ')}`, - ).toBeNull(); - } -} - -// ─── PromptFactory assembled output ────────────────────────────────────────── - -describe('PromptFactory assembled output', () => { - let factory: PromptFactory; - - beforeAll(() => { - factory = new PromptFactory({ sdkPromptsDir }); - }); - - const phaseTypes = Object.values(PhaseType) as PhaseType[]; - - for (const phaseType of phaseTypes) { - describe(`${phaseType} phase`, () => { - let output: string; - - beforeAll(async () => { - output = await factory.buildPrompt(phaseType, MINIMAL_PLAN, EMPTY_CONTEXT); - }); - - it('produces non-empty output', () => { - expect(output.length).toBeGreaterThan(0); - }); - - for (const [label, pattern] of BLOCKED_PATTERNS) { - it(`contains no ${label}`, () => { - const matches = output.match(new RegExp(pattern.source, pattern.flags + 'g')); - expect( - matches, - `Found ${label} in ${phaseType} assembled prompt: ${matches?.join(', ')}`, - ).toBeNull(); - }); - } - }); - } - - it('includes role section for phases with agents', async () => { - // Research, Plan, Execute, Verify all have agents; Discuss does not - const researchOutput = await factory.buildPrompt(PhaseType.Research, null, EMPTY_CONTEXT); - expect(researchOutput).toContain('## Agent Instructions'); - }); - - it('includes purpose section from workflow files', async () => { - const planOutput = await factory.buildPrompt(PhaseType.Plan, null, EMPTY_CONTEXT); - // Plan phase should have purpose from plan-phase.md - expect(planOutput).toContain('## Purpose'); - }); - - it('includes context section when context files provided', async () => { - const contextFiles: ContextFiles = { - state: '# State\ncurrent_phase: 01', - roadmap: '# Roadmap\n## Phase 01', - }; - const output = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - expect(output).toContain('## Context'); - expect(output).toContain('Project State'); - }); -}); - -// ─── InitRunner assembled output ───────────────────────────────────────────── - -describe('InitRunner assembled output', () => { - let tmpDir: string; - let runner: InitRunner; - - // Minimal stub tools and event stream — we only call build*Prompt(), not run() - const stubTools: GSDTools = { - initNewProject: async () => ({ - researcher_model: 'test', - synthesizer_model: 'test', - roadmapper_model: 'test', - commit_docs: false, - project_exists: false, - has_codebase_map: false, - has_git: true, - }), - configSet: async () => {}, - commit: async () => {}, - } as unknown as GSDTools; - - const stubEventStream: GSDEventStream = { - emitEvent: (_event: GSDEvent) => {}, - } as unknown as GSDEventStream; - - beforeAll(async () => { - // Create temp directory with .planning/ structure for InitRunner file reads - tmpDir = await mkdtemp(join(tmpdir(), 'assembled-prompts-')); - const planningDir = join(tmpDir, '.planning'); - const researchDir = join(planningDir, 'research'); - await mkdir(researchDir, { recursive: true }); - - // Write minimal stubs that InitRunner reads - await writeFile( - join(planningDir, 'PROJECT.md'), - '# Test Project\n\nA minimal test project for contract testing.\n', - ); - await writeFile( - join(planningDir, 'config.json'), - JSON.stringify({ mode: 'yolo', parallelization: true }, null, 2), - ); - await writeFile( - join(planningDir, 'REQUIREMENTS.md'), - '# Requirements\n\n## R001 — Test Requirement\n', - ); - await writeFile( - join(researchDir, 'STACK.md'), - '# Stack Research\n\nTypeScript + Node.js\n', - ); - await writeFile( - join(researchDir, 'FEATURES.md'), - '# Features Research\n\nCore features identified.\n', - ); - await writeFile( - join(researchDir, 'ARCHITECTURE.md'), - '# Architecture Research\n\nModular architecture.\n', - ); - await writeFile( - join(researchDir, 'PITFALLS.md'), - '# Pitfalls Research\n\nCommon pitfalls noted.\n', - ); - await writeFile( - join(researchDir, 'SUMMARY.md'), - '# Research Summary\n\nAll research synthesized.\n', - ); - - runner = new InitRunner({ - projectDir: tmpDir, - tools: stubTools, - eventStream: stubEventStream, - sdkPromptsDir, - }); - }); - - afterAll(async () => { - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); - - // Access private methods via (runner as any) — standard pattern for testing - // private methods in TypeScript without subclassing or mocking - - describe('buildProjectPrompt', () => { - let output: string; - - beforeAll(async () => { - output = await (runner as any).buildProjectPrompt('Build a CLI tool'); - }); - - it('produces non-empty output', () => { - expect(output.length).toBeGreaterThan(0); - }); - - it('contains project template content', () => { - expect(output).toContain('PROJECT.md'); - }); - - it('contains user input', () => { - expect(output).toContain('Build a CLI tool'); - }); - - it('contains zero blocked patterns', () => { - assertNoBlockedPatterns(output, 'buildProjectPrompt'); - }); - }); - - describe('buildResearchPrompt', () => { - const researchTypes = ['STACK', 'FEATURES', 'ARCHITECTURE', 'PITFALLS'] as const; - - for (const researchType of researchTypes) { - describe(`${researchType} research`, () => { - let output: string; - - beforeAll(async () => { - output = await (runner as any).buildResearchPrompt(researchType, 'Build a CLI tool'); - }); - - it('produces non-empty output', () => { - expect(output.length).toBeGreaterThan(0); - }); - - it('references the research type', () => { - expect(output).toContain(researchType); - }); - - it('contains zero blocked patterns', () => { - assertNoBlockedPatterns(output, `buildResearchPrompt(${researchType})`); - }); - }); - } - }); - - describe('buildSynthesisPrompt', () => { - let output: string; - - beforeAll(async () => { - output = await (runner as any).buildSynthesisPrompt(); - }); - - it('produces non-empty output', () => { - expect(output.length).toBeGreaterThan(0); - }); - - it('contains research content from temp files', () => { - // The synthesis prompt reads research files from disk — our stubs should appear - expect(output).toContain('Stack Research'); - }); - - it('contains zero blocked patterns', () => { - assertNoBlockedPatterns(output, 'buildSynthesisPrompt'); - }); - }); - - describe('buildRequirementsPrompt', () => { - let output: string; - - beforeAll(async () => { - output = await (runner as any).buildRequirementsPrompt(); - }); - - it('produces non-empty output', () => { - expect(output.length).toBeGreaterThan(0); - }); - - it('contains project context from temp files', () => { - expect(output).toContain('Test Project'); - }); - - it('contains zero blocked patterns', () => { - assertNoBlockedPatterns(output, 'buildRequirementsPrompt'); - }); - }); - - describe('buildRoadmapPrompt', () => { - let output: string; - - beforeAll(async () => { - output = await (runner as any).buildRoadmapPrompt(); - }); - - it('produces non-empty output', () => { - expect(output.length).toBeGreaterThan(0); - }); - - it('contains agent definition content', () => { - // Roadmap prompt loads gsd-roadmapper.md - expect(output).toContain('agent_definition'); - }); - - it('contains project file content', () => { - expect(output).toContain('Test Project'); - }); - - it('contains zero blocked patterns', () => { - assertNoBlockedPatterns(output, 'buildRoadmapPrompt'); - }); - }); -}); diff --git a/sdk/src/bug-3589-planning-paths-validation.test.ts b/sdk/src/bug-3589-planning-paths-validation.test.ts deleted file mode 100644 index dea3c12c2..000000000 --- a/sdk/src/bug-3589-planning-paths-validation.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Bug #3589 (security): SDK `planningPaths(projectDir, workstream)` and - * `relPlanningPath(workstream)` accepted unvalidated explicit workstream - * names from direct SDK callers. Path-traversal segments (`..`, `/`, `\\`) - * would flow through `posix.join('.planning', 'workstreams', name)` and - * route planning operations outside the intended `.planning/workstreams/` - * subtree. - * - * Env-sourced workstreams are pre-validated inside `planningPaths` and fall - * back to root .planning/ silently (#2791 contract). Explicit SDK arguments - * had no such gate. - * - * Fix: validate inside `relPlanningPath` so every caller — direct SDK use, - * `planningPaths`, `ContextEngine` — is protected at the same seam. - * Explicit invalid names throw; env-sourced ones still silently fall back - * because `planningPaths` filters them to `null` before calling - * `relPlanningPath`. - */ - -import { describe, it, expect } from 'vitest'; -import { relPlanningPath } from './workstream-utils.js'; -import { planningPaths } from './query/helpers.js'; - -// Empty string is treated as "no workstream provided" (returns `.planning`) -// for back-compat with pre-fix behaviour; only non-empty invalid names throw. -const TRAVERSAL_CASES = [ - '../../../outside', - '../escape', - '..', - 'foo/bar', - 'foo\\bar', - 'foo bar', - '.hidden', - '/abs', - '-leading-hyphen', -]; - -describe('bug #3589: relPlanningPath rejects path-traversal and invalid workstream names', () => { - it('returns .planning when workstream is omitted (unchanged)', () => { - expect(relPlanningPath()).toBe('.planning'); - expect(relPlanningPath(undefined)).toBe('.planning'); - expect(relPlanningPath('')).toBe('.planning'); - }); - - it('returns .planning/workstreams/ for valid workstream names (unchanged)', () => { - expect(relPlanningPath('frontend')).toBe('.planning/workstreams/frontend'); - expect(relPlanningPath('api_v2')).toBe('.planning/workstreams/api_v2'); - expect(relPlanningPath('alpha.beta-1')).toBe('.planning/workstreams/alpha.beta-1'); - }); - - for (const bad of TRAVERSAL_CASES) { - it(`throws for invalid workstream name ${JSON.stringify(bad)}`, () => { - expect(() => relPlanningPath(bad)).toThrow(/workstream/i); - }); - } - - it('throws BEFORE constructing the path (no partial side effect)', () => { - let resultPath: string | null = null; - try { - resultPath = relPlanningPath('../../../outside'); - } catch { - /* expected */ - } - expect(resultPath).toBeNull(); - }); -}); - -describe('bug #3589: planningPaths rejects explicit invalid workstream names', () => { - it('throws for explicit ../../../outside (was silently constructing a traversal path)', () => { - expect(() => planningPaths('/tmp/projectDir', '../../../outside')).toThrow(/workstream/i); - }); - - it('throws for explicit slash-bearing names', () => { - expect(() => planningPaths('/tmp/projectDir', 'foo/bar')).toThrow(/workstream/i); - }); - - it('accepts valid explicit names and constructs the expected planning subtree', () => { - const paths = planningPaths('/tmp/projectDir', 'frontend'); - expect(paths.planning.endsWith('.planning/workstreams/frontend')).toBe(true); - expect(paths.state.endsWith('.planning/workstreams/frontend/STATE.md')).toBe(true); - expect(paths.roadmap.endsWith('.planning/workstreams/frontend/ROADMAP.md')).toBe(true); - }); - - it('still returns root .planning when workstream is omitted', () => { - const paths = planningPaths('/tmp/projectDir'); - expect(paths.planning.endsWith('.planning')).toBe(true); - expect(paths.planning).not.toContain('workstreams'); - }); -}); diff --git a/sdk/src/bug-3591-gsdtools-runtime-workstream.test.ts b/sdk/src/bug-3591-gsdtools-runtime-workstream.test.ts deleted file mode 100644 index 5291a3518..000000000 --- a/sdk/src/bug-3591-gsdtools-runtime-workstream.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Bug #3591: createGSDToolsRuntime accepts a `workstream` option, but the - * native dispatch closure passed to QueryNativeDirectAdapter dropped it - * before forwarding to registry.dispatch(). The omission silently routed - * native query handlers to root `.planning` instead of - * `.planning/workstreams/` whenever a GSDTools instance was created - * with a workstream and native dispatch was used. - * - * The fix passes `opts.workstream` as the 4th argument to - * `registry.dispatch(command, args, projectDir, workstream)`. This test - * captures the dispatch closure via a constructor-seam spy on - * QueryNativeDirectAdapter, builds a mock registry whose dispatch records - * its arguments, then invokes the captured closure to verify the - * workstream is forwarded. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { createGSDToolsRuntime } from './query-gsd-tools-runtime.js'; -import * as adapterModule from './query-native-direct-adapter.js'; -import * as registryModule from './query/index.js'; - -describe('bug #3591: createGSDToolsRuntime forwards workstream to registry.dispatch', () => { - it('native dispatch closure passes opts.workstream as 4th arg to registry.dispatch', async () => { - // Capture the `dispatch` option passed into QueryNativeDirectAdapter. - let capturedDispatch: - | ((command: string, args: string[]) => Promise) - | null = null; - const adapterSpy = vi - .spyOn(adapterModule, 'QueryNativeDirectAdapter') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .mockImplementation((deps: any) => { - capturedDispatch = deps.dispatch; - // Return a minimal stub satisfying the runtime constructor. - return { - dispatchResult: vi.fn(), - dispatchJson: vi.fn(), - dispatchRaw: vi.fn(), - } as unknown as adapterModule.QueryNativeDirectAdapter; - }); - - const registry = registryModule.createRegistry(); - const dispatchSpy = vi.spyOn(registry, 'dispatch'); - const createRegistrySpy = vi - .spyOn(registryModule, 'createRegistry') - .mockReturnValue(registry); - - try { - createGSDToolsRuntime({ - projectDir: '/tmp/3591-proj', - gsdToolsPath: '/tmp/gsd-tools.cjs', - timeoutMs: 1_000, - workstream: 'frontend-ws', - shouldUseNativeQuery: () => true, - execJsonFallback: vi.fn(async () => ({})), - execRawFallback: vi.fn(async () => ''), - }); - - expect(adapterSpy).toHaveBeenCalled(); - expect(capturedDispatch).not.toBeNull(); - await capturedDispatch!('__bug-3591-unknown-cmd__', ['x']); - } catch (err) { - // unknown command is expected from the real registry - void err; - } finally { - createRegistrySpy.mockRestore(); - adapterSpy.mockRestore(); - } - - expect(dispatchSpy).toHaveBeenCalledWith( - '__bug-3591-unknown-cmd__', - ['x'], - '/tmp/3591-proj', - 'frontend-ws', - ); - }); - - it('forwards undefined workstream when the option is omitted (back-compat)', async () => { - // Same shape as above but no workstream. The closure must still pass - // projectDir; passing `undefined` for the 4th slot is the documented - // signature of registry.dispatch. - let capturedDispatch: - | ((command: string, args: string[]) => Promise) - | null = null; - const registry = registryModule.createRegistry(); - const dispatchSpy = vi.spyOn(registry, 'dispatch'); - const adapterSpy = vi - .spyOn(adapterModule, 'QueryNativeDirectAdapter') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .mockImplementation((deps: any) => { - capturedDispatch = deps.dispatch; - return { - dispatchResult: vi.fn(), - dispatchJson: vi.fn(), - dispatchRaw: vi.fn(), - } as unknown as adapterModule.QueryNativeDirectAdapter; - }); - - const createRegistrySpy = vi - .spyOn(registryModule, 'createRegistry') - .mockReturnValue(registry); - - try { - createGSDToolsRuntime({ - projectDir: '/tmp/3591-proj', - gsdToolsPath: '/tmp/gsd-tools.cjs', - timeoutMs: 1_000, - // workstream intentionally omitted - shouldUseNativeQuery: () => true, - execJsonFallback: vi.fn(async () => ({})), - execRawFallback: vi.fn(async () => ''), - }); - - expect(capturedDispatch).not.toBeNull(); - await capturedDispatch!('__bug-3591-unknown-cmd-2__', []); - } catch (err) { - // unknown command is expected from the real registry - void err; - } finally { - createRegistrySpy.mockRestore(); - adapterSpy.mockRestore(); - } - - expect(dispatchSpy).toHaveBeenCalledWith( - '__bug-3591-unknown-cmd-2__', - [], - '/tmp/3591-proj', - undefined, - ); - }); -}); - -describe('bug #3591: end-to-end — workstream-aware probe handler receives the workstream', () => { - it('a registered probe handler receives opts.workstream as its 3rd arg', async () => { - // End-to-end path: register a probe handler on a real registry (via - // module-level export), build the runtime with a workstream, and - // assert the handler observed the workstream when invoked through the - // native dispatch closure. - const registryModule = await import('./query/index.js'); - const probeRegistry = registryModule.createRegistry(); - const seen: Array<{ args: string[]; projectDir: string; workstream?: string }> = []; - probeRegistry.register('__bug-3591-probe__', async (args, projectDir, workstream) => { - seen.push({ args, projectDir, workstream }); - return { data: { ok: true } }; - }); - - // The runtime builds its OWN registry internally; we can't substitute - // ours unless we mock createRegistry. Hoist a module mock for that. - const createRegistrySpy = vi - .spyOn(registryModule, 'createRegistry') - .mockReturnValue(probeRegistry); - - try { - const runtime = createGSDToolsRuntime({ - projectDir: '/tmp/3591-proj', - gsdToolsPath: '/tmp/gsd-tools.cjs', - timeoutMs: 1_000, - workstream: 'frontend-ws', - shouldUseNativeQuery: () => true, - execJsonFallback: vi.fn(async () => ({})), - execRawFallback: vi.fn(async () => ''), - }); - - await runtime.bridge.dispatchHotpath( - '__bug-3591-probe-legacy__', - [], - '__bug-3591-probe__', - ['payload'], - 'json', - ); - } finally { - createRegistrySpy.mockRestore(); - } - - expect(seen).toHaveLength(1); - expect(seen[0]?.args).toEqual(['payload']); - expect(seen[0]?.projectDir).toBe('/tmp/3591-proj'); - expect(seen[0]?.workstream).toBe('frontend-ws'); - }); -}); diff --git a/sdk/src/cli-transport.test.ts b/sdk/src/cli-transport.test.ts deleted file mode 100644 index 84d44f51e..000000000 --- a/sdk/src/cli-transport.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { PassThrough } from 'node:stream'; -import { CLITransport } from './cli-transport.js'; -import { GSDEventType, type GSDEvent, type GSDEventBase } from './types.js'; - -// ─── ANSI constants (mirror the source for readable assertions) ────────────── - -const BOLD = '\x1b[1m'; -const RESET = '\x1b[0m'; -const GREEN = '\x1b[32m'; -const RED = '\x1b[31m'; -const YELLOW = '\x1b[33m'; -const CYAN = '\x1b[36m'; -const DIM = '\x1b[90m'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function makeBase(overrides: Partial = {}): Omit { - return { - timestamp: '2025-06-15T14:30:45.123Z', - sessionId: 'test-session', - ...overrides, - }; -} - -function readOutput(stream: PassThrough): string { - const chunks: Buffer[] = []; - let chunk: Buffer | null; - while ((chunk = stream.read() as Buffer | null) !== null) { - chunks.push(chunk); - } - return Buffer.concat(chunks).toString('utf-8').trim(); -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe('CLITransport', () => { - it('formats SessionInit event correctly', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.SessionInit, - model: 'claude-sonnet-4-20250514', - tools: ['Read', 'Write', 'Bash'], - cwd: '/home/project', - } as GSDEvent); - - const output = readOutput(stream); - expect(output).toBe( - '[14:30:45] [INIT] Session started — model: claude-sonnet-4-20250514, tools: 3, cwd: /home/project', - ); - }); - - it('formats SessionComplete in green with checkmark', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.SessionComplete, - success: true, - totalCostUsd: 1.234, - durationMs: 45600, - numTurns: 12, - result: 'done', - } as GSDEvent); - - const output = readOutput(stream); - expect(output).toBe( - `[14:30:45] ${GREEN}✓ Session complete — cost: $1.23, turns: 12, duration: 45.6s${RESET}`, - ); - }); - - it('formats SessionError in red with ✗ marker', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.SessionError, - success: false, - totalCostUsd: 0.5, - durationMs: 3000, - numTurns: 2, - errorSubtype: 'tool_error', - errors: ['file not found', 'permission denied'], - } as GSDEvent); - - const output = readOutput(stream); - expect(output).toBe( - `[14:30:45] ${RED}✗ Session failed — subtype: tool_error, errors: [file not found, permission denied]${RESET}`, - ); - }); - - it('formats PhaseStart as bold cyan banner and PhaseComplete with running cost', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.PhaseStart, - phaseNumber: '01', - phaseName: 'Authentication', - } as GSDEvent); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.PhaseComplete, - phaseNumber: '01', - phaseName: 'Authentication', - success: true, - totalCostUsd: 2.50, - totalDurationMs: 60000, - stepsCompleted: 5, - } as GSDEvent); - - const output = readOutput(stream); - const lines = output.split('\n'); - expect(lines[0]).toBe(`${BOLD}${CYAN}━━━ GSD ► PHASE 01: Authentication ━━━${RESET}`); - expect(lines[1]).toBe('[14:30:45] [PHASE] Phase 01 complete — success: true, cost: $2.50, running: $0.00'); - }); - - it('formats ToolCall with truncated input', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - const longInput = { content: 'x'.repeat(200) }; - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.ToolCall, - toolName: 'Write', - toolUseId: 'tool-123', - input: longInput, - } as GSDEvent); - - const output = readOutput(stream); - expect(output).toMatch(/^\[14:30:45\] \[TOOL\] Write\(.+…\)$/); - // The truncated input portion (inside parens) should be ≤80 chars - const insideParens = output.match(/Write\((.+)\)/)![1]!; - expect(insideParens.length).toBeLessThanOrEqual(80); - }); - - it('formats MilestoneStart as bold banner and MilestoneComplete with running cost', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.MilestoneStart, - phaseCount: 3, - prompt: 'build the app', - } as GSDEvent); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.MilestoneComplete, - success: true, - totalCostUsd: 8.75, - totalDurationMs: 300000, - phasesCompleted: 3, - } as GSDEvent); - - const output = readOutput(stream); - const lines = output.split('\n'); - // MilestoneStart emits 3 lines (top bar, text, bottom bar) - expect(lines[0]).toBe(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`); - expect(lines[1]).toBe(`${BOLD} GSD Milestone — 3 phases${RESET}`); - expect(lines[2]).toBe(`${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`); - expect(lines[3]).toBe(`${BOLD}━━━ Milestone complete — success: true, cost: $8.75, running: $0.00 ━━━${RESET}`); - }); - - it('close() is callable without error', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - expect(() => transport.close()).not.toThrow(); - }); - - it('onEvent does not throw on unknown event type variant', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - // Use a known event type that hits the default/fallback branch - transport.onEvent({ - ...makeBase(), - type: GSDEventType.ToolProgress, - toolName: 'Bash', - toolUseId: 'tool-456', - elapsedSeconds: 12, - } as GSDEvent); - - const output = readOutput(stream); - expect(output).toBe('[14:30:45] [EVENT] tool_progress'); - }); - - it('formats AssistantText as dim with truncation at 200 chars', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - const longText = 'A'.repeat(300); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.AssistantText, - text: longText, - } as GSDEvent); - - const output = readOutput(stream); - expect(output).toMatch(new RegExp(`^${escRe(DIM)}\\[14:30:45\\] A+…${escRe(RESET)}$`)); - // Strip ANSI to check text length - const stripped = stripAnsi(output); - const agentText = stripped.split('] ')[1]!; - expect(agentText.length).toBeLessThanOrEqual(200); - }); - - it('formats WaveStart in yellow and WaveComplete with colored counts', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.WaveStart, - phaseNumber: '01', - waveNumber: 2, - planCount: 4, - planIds: ['plan-a', 'plan-b', 'plan-c', 'plan-d'], - } as GSDEvent); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.WaveComplete, - phaseNumber: '01', - waveNumber: 2, - successCount: 3, - failureCount: 1, - durationMs: 25000, - } as GSDEvent); - - const output = readOutput(stream); - const lines = output.split('\n'); - expect(lines[0]).toBe(`${YELLOW}⟫ Wave 2 (4 plans)${RESET}`); - expect(lines[1]).toBe( - `[14:30:45] [WAVE] Wave 2 complete — ${GREEN}3 success${RESET}, ${RED}1 failed${RESET}, 25000ms`, - ); - }); - - // ─── New tests for rich formatting ───────────────────────────────────────── - - it('formats PhaseStepStart in cyan with ◆ indicator', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.PhaseStepStart, - phaseNumber: '01', - step: 'research', - } as GSDEvent); - - const output = readOutput(stream); - expect(output).toBe(`${CYAN}◆ research${RESET}`); - }); - - it('formats PhaseStepComplete green ✓ on success, red ✗ on failure', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.PhaseStepComplete, - phaseNumber: '01', - step: 'plan', - success: true, - durationMs: 5200, - } as GSDEvent); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.PhaseStepComplete, - phaseNumber: '01', - step: 'execute', - success: false, - durationMs: 12000, - } as GSDEvent); - - const output = readOutput(stream); - const lines = output.split('\n'); - expect(lines[0]).toBe(`${GREEN}✓ plan${RESET} ${DIM}5200ms${RESET}`); - expect(lines[1]).toBe(`${RED}✗ execute${RESET} ${DIM}12000ms${RESET}`); - }); - - it('formats InitResearchSpawn in cyan with ◆ and session count', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.InitResearchSpawn, - sessionCount: 4, - researchTypes: ['stack', 'features', 'architecture', 'pitfalls'], - } as GSDEvent); - - const output = readOutput(stream); - expect(output).toBe(`${CYAN}◆ Spawning 4 researchers...${RESET}`); - }); - - it('tracks running cost across CostUpdate events', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - // First cost update - transport.onEvent({ - ...makeBase(), - type: GSDEventType.CostUpdate, - sessionCostUsd: 0.50, - cumulativeCostUsd: 0.50, - } as GSDEvent); - - // Second cost update - transport.onEvent({ - ...makeBase(), - type: GSDEventType.CostUpdate, - sessionCostUsd: 0.75, - cumulativeCostUsd: 1.25, - } as GSDEvent); - - const output = readOutput(stream); - const lines = output.split('\n'); - expect(lines[0]).toBe(`${DIM}[14:30:45] Cost: session $0.50, running $0.50${RESET}`); - expect(lines[1]).toBe(`${DIM}[14:30:45] Cost: session $0.75, running $1.25${RESET}`); - }); - - it('shows running cost in PhaseComplete and MilestoneComplete after CostUpdates', () => { - const stream = new PassThrough(); - const transport = new CLITransport(stream); - - // Accumulate some cost - transport.onEvent({ - ...makeBase(), - type: GSDEventType.CostUpdate, - sessionCostUsd: 1.50, - cumulativeCostUsd: 1.50, - } as GSDEvent); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.PhaseComplete, - phaseNumber: '02', - phaseName: 'Build', - success: true, - totalCostUsd: 1.50, - totalDurationMs: 30000, - stepsCompleted: 3, - } as GSDEvent); - - transport.onEvent({ - ...makeBase(), - type: GSDEventType.MilestoneComplete, - success: true, - totalCostUsd: 1.50, - totalDurationMs: 30000, - phasesCompleted: 2, - } as GSDEvent); - - const output = readOutput(stream); - const lines = output.split('\n'); - // CostUpdate line - expect(lines[0]).toContain('running $1.50'); - // PhaseComplete includes running cost - expect(lines[1]).toContain('running: $1.50'); - // MilestoneComplete includes running cost - expect(lines[2]).toContain('running: $1.50'); - }); -}); - -// ─── Test utilities ────────────────────────────────────────────────────────── - -/** Escape a string for use in a RegExp. */ -function escRe(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** Strip ANSI escape sequences from a string. */ -function stripAnsi(s: string): string { - return s.replace(/\x1b\[[0-9;]*m/g, ''); -} diff --git a/sdk/src/cli-transport.ts b/sdk/src/cli-transport.ts deleted file mode 100644 index 31b3a0e08..000000000 --- a/sdk/src/cli-transport.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * CLI Transport — renders GSD events as rich ANSI-colored output to a Writable stream. - * - * Implements TransportHandler with colored banners, step indicators, spawn markers, - * and running cost totals. No external dependencies — ANSI codes are inline constants. - */ - -import type { Writable } from 'node:stream'; -import { GSDEventType, type GSDEvent, type TransportHandler } from './types.js'; - -// ─── ANSI escape constants (no dependency per D021) ────────────────────────── - -const BOLD = '\x1b[1m'; -const RESET = '\x1b[0m'; -const GREEN = '\x1b[32m'; -const RED = '\x1b[31m'; -const YELLOW = '\x1b[33m'; -const CYAN = '\x1b[36m'; -const DIM = '\x1b[90m'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** Extract HH:MM:SS from an ISO-8601 timestamp. */ -function formatTime(ts: string): string { - try { - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return '??:??:??'; - return d.toISOString().slice(11, 19); - } catch { - return '??:??:??'; - } -} - -/** Truncate a string to `max` characters, appending '…' if truncated. */ -function truncate(s: string, max: number): string { - if (s.length <= max) return s; - return s.slice(0, max - 1) + '…'; -} - -/** Format a USD amount. */ -function usd(n: number): string { - return `$${n.toFixed(2)}`; -} - -// ─── CLITransport ──────────────────────────────────────────────────────────── - -export class CLITransport implements TransportHandler { - private readonly out: Writable; - private runningCostUsd = 0; - - constructor(out?: Writable) { - this.out = out ?? process.stdout; - } - - /** Format and write a GSD event as a rich ANSI-colored line. Never throws. */ - onEvent(event: GSDEvent): void { - try { - const line = this.formatEvent(event); - this.out.write(line + '\n'); - } catch { - // TransportHandler contract: onEvent must never throw - } - } - - /** No-op — stdout doesn't need cleanup. */ - close(): void { - // Nothing to clean up - } - - // ─── Private formatting ──────────────────────────────────────────── - - private formatEvent(event: GSDEvent): string { - const time = formatTime(event.timestamp); - - switch (event.type) { - case GSDEventType.SessionInit: - return `[${time}] [INIT] Session started — model: ${event.model}, tools: ${event.tools.length}, cwd: ${event.cwd}`; - - case GSDEventType.SessionComplete: - return `[${time}] ${GREEN}✓ Session complete — cost: ${usd(event.totalCostUsd)}, turns: ${event.numTurns}, duration: ${(event.durationMs / 1000).toFixed(1)}s${RESET}`; - - case GSDEventType.SessionError: - return `[${time}] ${RED}✗ Session failed — subtype: ${event.errorSubtype}, errors: [${event.errors.join(', ')}]${RESET}`; - - case GSDEventType.ToolCall: - return `[${time}] [TOOL] ${event.toolName}(${truncate(JSON.stringify(event.input), 80)})`; - - case GSDEventType.PhaseStart: - return `${BOLD}${CYAN}━━━ GSD ► PHASE ${event.phaseNumber}: ${event.phaseName} ━━━${RESET}`; - - case GSDEventType.PhaseComplete: - return `[${time}] [PHASE] Phase ${event.phaseNumber} complete — success: ${event.success}, cost: ${usd(event.totalCostUsd)}, running: ${usd(this.runningCostUsd)}`; - - case GSDEventType.PhaseStepStart: - return `${CYAN}◆ ${event.step}${RESET}`; - - case GSDEventType.PhaseStepComplete: - return event.success - ? `${GREEN}✓ ${event.step}${RESET} ${DIM}${event.durationMs}ms${RESET}` - : `${RED}✗ ${event.step}${RESET} ${DIM}${event.durationMs}ms${RESET}`; - - case GSDEventType.WaveStart: - return `${YELLOW}⟫ Wave ${event.waveNumber} (${event.planCount} plans)${RESET}`; - - case GSDEventType.WaveComplete: - return `[${time}] [WAVE] Wave ${event.waveNumber} complete — ${GREEN}${event.successCount} success${RESET}, ${RED}${event.failureCount} failed${RESET}, ${event.durationMs}ms`; - - case GSDEventType.CostUpdate: { - this.runningCostUsd += event.sessionCostUsd; - return `${DIM}[${time}] Cost: session ${usd(event.sessionCostUsd)}, running ${usd(this.runningCostUsd)}${RESET}`; - } - - case GSDEventType.MilestoneStart: - return `${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n${BOLD} GSD Milestone — ${event.phaseCount} phases${RESET}\n${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`; - - case GSDEventType.MilestoneComplete: - return `${BOLD}━━━ Milestone complete — success: ${event.success}, cost: ${usd(event.totalCostUsd)}, running: ${usd(this.runningCostUsd)} ━━━${RESET}`; - - case GSDEventType.AssistantText: - return `${DIM}[${time}] ${truncate(event.text, 200)}${RESET}`; - - case GSDEventType.InitResearchSpawn: - return `${CYAN}◆ Spawning ${event.sessionCount} researchers...${RESET}`; - - // Generic fallback for event types without specific formatting - default: - return `[${time}] [EVENT] ${event.type}`; - } - } -} diff --git a/sdk/src/cli.test.ts b/sdk/src/cli.test.ts deleted file mode 100644 index ee66a5aa9..000000000 --- a/sdk/src/cli.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { parseCliArgs, resolveInitInput, USAGE, type ParsedCliArgs } from './cli.js'; -import { mkdir, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -describe('parseCliArgs', () => { - it('parses run with defaults', () => { - const result = parseCliArgs(['run', 'build auth']); - - expect(result.command).toBe('run'); - expect(result.prompt).toBe('build auth'); - expect(result.help).toBe(false); - expect(result.version).toBe(false); - expect(result.wsPort).toBeUndefined(); - expect(result.model).toBeUndefined(); - expect(result.maxBudget).toBeUndefined(); - }); - - it('parses --help flag', () => { - const result = parseCliArgs(['--help']); - - expect(result.help).toBe(true); - expect(result.command).toBeUndefined(); - }); - - it('parses -h short flag', () => { - const result = parseCliArgs(['-h']); - - expect(result.help).toBe(true); - }); - - it('parses --version flag', () => { - const result = parseCliArgs(['--version']); - - expect(result.version).toBe(true); - }); - - it('parses -v short flag', () => { - const result = parseCliArgs(['-v']); - - expect(result.version).toBe(true); - }); - - it('parses --ws-port as number', () => { - const result = parseCliArgs(['run', 'build X', '--ws-port', '8080']); - - expect(result.command).toBe('run'); - expect(result.prompt).toBe('build X'); - expect(result.wsPort).toBe(8080); - }); - - it('parses --model option', () => { - const result = parseCliArgs(['run', 'build X', '--model', 'claude-sonnet-4-6']); - - expect(result.model).toBe('claude-sonnet-4-6'); - }); - - it('parses --max-budget option', () => { - const result = parseCliArgs(['run', 'build X', '--max-budget', '10']); - - expect(result.maxBudget).toBe(10); - }); - - it('parses --project-dir option', () => { - const result = parseCliArgs(['run', 'build X', '--project-dir', '/tmp/my-project']); - - expect(result.projectDir).toBe('/tmp/my-project'); - }); - - it('returns undefined command and prompt for empty args', () => { - const result = parseCliArgs([]); - - expect(result.command).toBeUndefined(); - expect(result.prompt).toBeUndefined(); - expect(result.help).toBe(false); - expect(result.version).toBe(false); - }); - - it('parses multi-word prompts from positionals', () => { - const result = parseCliArgs(['run', 'build', 'the', 'entire', 'app']); - - expect(result.prompt).toBe('build the entire app'); - }); - - it('handles all options combined', () => { - const result = parseCliArgs([ - 'run', 'build auth', - '--project-dir', '/tmp/proj', - '--ws-port', '9090', - '--model', 'claude-sonnet-4-6', - '--max-budget', '15', - ]); - - expect(result.command).toBe('run'); - expect(result.prompt).toBe('build auth'); - expect(result.projectDir).toBe('/tmp/proj'); - expect(result.wsPort).toBe(9090); - expect(result.model).toBe('claude-sonnet-4-6'); - expect(result.maxBudget).toBe(15); - }); - - it('rejects unknown options (strict parser)', () => { - expect(() => parseCliArgs(['--unknown-flag'])).toThrow(); - }); - - it('rejects unknown flags on run command', () => { - expect(() => parseCliArgs(['run', 'hello', '--not-a-real-option'])).toThrow(); - }); - - it('parses query permissively (keeps gsd-tools flags like --pick, --json)', () => { - const result = parseCliArgs([ - 'query', 'state.load', '--pick', 'data', '--project-dir', 'C:\\tmp\\proj', - ]); - expect(result.command).toBe('query'); - expect(result.projectDir).toBe('C:\\tmp\\proj'); - expect(result.queryArgv).toEqual(['state.load', '--pick', 'data']); - }); - - it('parses query with extra flags forwarded in queryArgv', () => { - const result = parseCliArgs([ - 'query', 'audit-open', '--json', '--project-dir', 'D:\\proj', - ]); - expect(result.command).toBe('query'); - expect(result.projectDir).toBe('D:\\proj'); - expect(result.queryArgv).toEqual(['audit-open', '--json']); - }); - - // ─── #3019: --help inside `query ` reaches the handler ──── - - it('forwards --help to queryArgv when a subcommand precedes it (#3019)', () => { - // gsd-sdk query phase add --help - // Previously: --help was harvested as global, queryArgv = ['phase', 'add'], - // help: true → main() short-circuits to top-level USAGE, never dispatching. - // Now: --help travels with the rest of queryArgv so the registry handler - // (or the gsd-tools.cjs fallback) can render contextual subcommand help. - const result = parseCliArgs(['query', 'phase', 'add', '--help']); - expect(result.command).toBe('query'); - expect(result.queryArgv).toEqual(['phase', 'add', '--help']); - // The global help flag must NOT short-circuit dispatch when there is a - // subcommand to dispatch to. - expect(result.help).toBe(false); - }); - - it('forwards -h to queryArgv when a subcommand precedes it (#3019)', () => { - const result = parseCliArgs(['query', 'init', '-h']); - expect(result.queryArgv).toEqual(['init', '-h']); - expect(result.help).toBe(false); - }); - - it('treats bare `query --help` as a top-level help request (no subcommand to dispatch to)', () => { - // gsd-sdk query --help - // No subcommand follows, so the only useful response is the top-level - // USAGE. Preserve existing behavior: help: true. - const result = parseCliArgs(['query', '--help']); - expect(result.command).toBe('query'); - expect(result.help).toBe(true); - // queryArgv may be empty or carry just the lone --help; either is fine - // because main() short-circuits on help when there is no subcommand. - expect((result.queryArgv ?? []).filter((x) => x !== '--help' && x !== '-h')).toEqual([]); - }); - - it('preserves --help position when intermixed with other query flags (#3019)', () => { - // gsd-sdk query phase --help --pick name - // The handler/fallback should see --help in argv so it can render help - // even when other flags are present. - const result = parseCliArgs(['query', 'phase', '--help', '--pick', 'name']); - expect(result.queryArgv).toEqual(['phase', '--help', '--pick', 'name']); - expect(result.help).toBe(false); - }); - - // ─── Init command parsing ────────────────────────────────────────────── - - it('parses init with @file input', () => { - const result = parseCliArgs(['init', '@prd.md']); - - expect(result.command).toBe('init'); - expect(result.initInput).toBe('@prd.md'); - expect(result.prompt).toBe('@prd.md'); - }); - - it('parses init with raw text input', () => { - const result = parseCliArgs(['init', 'build a todo app']); - - expect(result.command).toBe('init'); - expect(result.initInput).toBe('build a todo app'); - }); - - it('parses init with multi-word text input', () => { - const result = parseCliArgs(['init', 'build', 'a', 'todo', 'app']); - - expect(result.command).toBe('init'); - expect(result.initInput).toBe('build a todo app'); - }); - - it('parses init with no input (stdin mode)', () => { - const result = parseCliArgs(['init']); - - expect(result.command).toBe('init'); - expect(result.initInput).toBeUndefined(); - expect(result.prompt).toBeUndefined(); - }); - - it('parses init with options', () => { - const result = parseCliArgs(['init', '@prd.md', '--project-dir', '/tmp/proj', '--model', 'claude-sonnet-4-6']); - - expect(result.command).toBe('init'); - expect(result.initInput).toBe('@prd.md'); - expect(result.projectDir).toBe('/tmp/proj'); - expect(result.model).toBe('claude-sonnet-4-6'); - }); - - it('does not set initInput for non-init commands', () => { - const result = parseCliArgs(['run', 'build auth']); - - expect(result.command).toBe('run'); - expect(result.initInput).toBeUndefined(); - expect(result.prompt).toBe('build auth'); - }); - - // ─── Auto command parsing ────────────────────────────────────────────── - - it('parses auto command with no prompt', () => { - const result = parseCliArgs(['auto']); - - expect(result.command).toBe('auto'); - expect(result.prompt).toBeUndefined(); - expect(result.initInput).toBeUndefined(); - }); - - it('parses auto with --project-dir', () => { - const result = parseCliArgs(['auto', '--project-dir', '/tmp/x']); - - expect(result.command).toBe('auto'); - expect(result.projectDir).toBe('/tmp/x'); - }); - - it('parses auto with --ws-port', () => { - const result = parseCliArgs(['auto', '--ws-port', '9090']); - - expect(result.command).toBe('auto'); - expect(result.wsPort).toBe(9090); - }); - - it('parses auto with all options combined', () => { - const result = parseCliArgs([ - 'auto', - '--project-dir', '/tmp/proj', - '--ws-port', '8080', - '--model', 'claude-sonnet-4-6', - '--max-budget', '20', - ]); - - expect(result.command).toBe('auto'); - expect(result.projectDir).toBe('/tmp/proj'); - expect(result.wsPort).toBe(8080); - expect(result.model).toBe('claude-sonnet-4-6'); - expect(result.maxBudget).toBe(20); - }); - - it('auto command does not set initInput', () => { - const result = parseCliArgs(['auto']); - - expect(result.initInput).toBeUndefined(); - }); - - // ─── Auto --init parsing ────────────────────────────────────────────── - - it('parses auto --init with @file', () => { - const result = parseCliArgs(['auto', '--init', '@prd.md']); - - expect(result.command).toBe('auto'); - expect(result.init).toBe('@prd.md'); - expect(result.initInput).toBeUndefined(); - }); - - it('parses auto --init with raw text', () => { - const result = parseCliArgs(['auto', '--init', 'build a todo app']); - - expect(result.command).toBe('auto'); - expect(result.init).toBe('build a todo app'); - }); - - it('parses auto --init with other options', () => { - const result = parseCliArgs([ - 'auto', - '--init', '@spec.md', - '--project-dir', '/tmp/proj', - '--model', 'claude-sonnet-4-6', - '--max-budget', '25', - ]); - - expect(result.command).toBe('auto'); - expect(result.init).toBe('@spec.md'); - expect(result.projectDir).toBe('/tmp/proj'); - expect(result.model).toBe('claude-sonnet-4-6'); - expect(result.maxBudget).toBe(25); - }); - - it('init is undefined when --init not provided', () => { - const result = parseCliArgs(['auto']); - - expect(result.init).toBeUndefined(); - }); - - it('init is undefined for non-auto commands', () => { - const result = parseCliArgs(['run', 'build auth']); - - expect(result.init).toBeUndefined(); - }); -}); - -// ─── resolveInitInput tests ────────────────────────────────────────────────── - -describe('resolveInitInput', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `cli-init-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(tmpDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - function makeArgs(overrides: Partial): ParsedCliArgs { - return { - command: 'init', - prompt: undefined, - initInput: undefined, - init: undefined, - projectDir: tmpDir, - wsPort: undefined, - model: undefined, - maxBudget: undefined, - help: false, - version: false, - ...overrides, - }; - } - - it('reads file contents when input starts with @', async () => { - const prdPath = join(tmpDir, 'prd.md'); - await writeFile(prdPath, '# My PRD\n\nBuild a todo app'); - - const result = await resolveInitInput(makeArgs({ initInput: '@prd.md' })); - - expect(result).toBe('# My PRD\n\nBuild a todo app'); - }); - - it('resolves @file path relative to projectDir', async () => { - const subDir = join(tmpDir, 'docs'); - await mkdir(subDir, { recursive: true }); - await writeFile(join(subDir, 'spec.md'), 'specification content'); - - const result = await resolveInitInput(makeArgs({ initInput: '@docs/spec.md' })); - - expect(result).toBe('specification content'); - }); - - it('throws descriptive error when @file does not exist', async () => { - await expect( - resolveInitInput(makeArgs({ initInput: '@nonexistent.md' })) - ).rejects.toThrow('file not found'); - }); - - it('returns raw text as-is when input does not start with @', async () => { - const result = await resolveInitInput(makeArgs({ initInput: 'build a todo app' })); - - expect(result).toBe('build a todo app'); - }); - - it('throws TTY error when no input and stdin is TTY', async () => { - // In test environment, stdin.isTTY is typically undefined (not a TTY), - // but we can verify the function throws when stdin is a TTY by - // checking the error path directly via the export. - // This test verifies the raw text path works for empty-like scenarios. - const result = await resolveInitInput(makeArgs({ initInput: 'some text' })); - expect(result).toBe('some text'); - }); - - it('reads @file with absolute path', async () => { - const absPath = join(tmpDir, 'absolute-prd.md'); - await writeFile(absPath, 'absolute path content'); - - // Absolute paths are resolved relative to projectDir, so we need - // to use the relative form or the absolute form via @ - const result = await resolveInitInput(makeArgs({ initInput: `@${absPath}` })); - - expect(result).toBe('absolute path content'); - }); - - it('preserves whitespace in raw text input', async () => { - const input = ' build a todo app with spaces '; - const result = await resolveInitInput(makeArgs({ initInput: input })); - - expect(result).toBe(input); - }); - - it('reads large file content from @file', async () => { - const largeContent = 'x'.repeat(10000) + '\n# PRD\nDescription here'; - await writeFile(join(tmpDir, 'large.md'), largeContent); - - const result = await resolveInitInput(makeArgs({ initInput: '@large.md' })); - - expect(result).toBe(largeContent); - }); -}); - -// ─── USAGE text tests ──────────────────────────────────────────────────────── - -describe('USAGE', () => { - it('includes auto command', () => { - expect(USAGE).toContain('auto'); - }); - - it('describes auto as autonomous lifecycle', () => { - expect(USAGE).toMatch(/auto\s+.*autonomous/i); - }); - - it('documents --init option', () => { - expect(USAGE).toContain('--init'); - expect(USAGE).toContain('Bootstrap from a PRD'); - }); -}); diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts deleted file mode 100644 index c4ef46a2d..000000000 --- a/sdk/src/cli.ts +++ /dev/null @@ -1,589 +0,0 @@ -#!/usr/bin/env node -/** - * CLI entry point for gsd-sdk. - * - * Usage: gsd-sdk run "" [--project-dir ] [--ws-port ] - * [--model ] [--max-budget ] - */ - -import { parseArgs } from 'node:util'; -import { readFile } from 'node:fs/promises'; -import { resolve, join, isAbsolute } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { GSD } from './index.js'; -import { CLITransport } from './cli-transport.js'; -import { WSTransport } from './ws-transport.js'; -import { InitRunner } from './init-runner.js'; -import { validateWorkstreamName } from './workstream-utils.js'; -import { loadConfig } from './config.js'; -import { assertRuntimeSupportsAutoMode } from './runtime-gate.js'; -import { runQueryCliCommand } from './query/query-cli-adapter.js'; - -// ─── Parsed CLI args ───────────────────────────────────────────────────────── - -export interface ParsedCliArgs { - command: string | undefined; - prompt: string | undefined; - /** For 'init' command: the raw input source (@file, text, or undefined for stdin). */ - initInput: string | undefined; - /** For 'auto --init': bootstrap from a PRD before running the autonomous loop. */ - init: string | undefined; - projectDir: string; - wsPort: number | undefined; - model: string | undefined; - maxBudget: number | undefined; - /** Workstream name for multi-workstream projects. Routes .planning/ to .planning/workstreams//. */ - ws: string | undefined; - help: boolean; - version: boolean; - /** - * When `command === 'query'`, tokens after `query` with only known SDK flags removed. - * Extra flags are kept so handlers that share gsd-tools-style argv (e.g. `--pick`) still receive them. - */ - queryArgv?: string[]; -} - -/** - * Parse `gsd-sdk query …` without rejecting unknown flags (query argv is forwarded to the registry). - */ -function parseCliArgsQueryPermissive(argv: string[]): ParsedCliArgs { - let projectDir = process.cwd(); - let ws: string | undefined; - let wsPort: number | undefined; - let model: string | undefined; - let maxBudget: number | undefined; - let help = false; - let version = false; - const queryArgv: string[] = []; - - let i = 1; - while (i < argv.length) { - const a = argv[i]; - if (a === '--project-dir' && argv[i + 1]) { - projectDir = argv[i + 1]; - i += 2; - continue; - } - if (a === '--ws' && argv[i + 1]) { - ws = argv[i + 1]; - i += 2; - continue; - } - if (a === '--ws-port' && argv[i + 1]) { - wsPort = Number(argv[i + 1]); - i += 2; - continue; - } - if (a === '--model' && argv[i + 1]) { - model = argv[i + 1]; - i += 2; - continue; - } - if (a === '--max-budget' && argv[i + 1]) { - maxBudget = Number(argv[i + 1]); - i += 2; - continue; - } - // #3019: do NOT consume -h / --help here unconditionally. Pushing the - // flag onto queryArgv lets the registered handler (or the gsd-tools.cjs - // fallback) render contextual subcommand help. We still set the global - // `help` flag when the flag appears, but only short-circuit dispatch in - // main() when there is no real subcommand to dispatch to (i.e. the only - // tokens in queryArgv are the help flags themselves). That preserves - // `gsd-sdk query --help` → top-level USAGE while letting - // `gsd-sdk query phase add --help` reach the handler. - if (a === '-h' || a === '--help') { - help = true; - queryArgv.push(a); - i += 1; - continue; - } - if (a === '-v' || a === '--version') { - version = true; - i += 1; - continue; - } - queryArgv.push(a); - i += 1; - } - - // If the user typed a real subcommand (anything other than help flags - // alone in queryArgv), do NOT short-circuit to top-level USAGE on help. - // The handler/fallback will render contextual help. - const nonHelpTokens = queryArgv.filter((t) => t !== '-h' && t !== '--help'); - if (help && nonHelpTokens.length > 0) { - help = false; - } - - return { - command: 'query', - prompt: undefined, - initInput: undefined, - init: undefined, - projectDir, - wsPort, - model, - maxBudget, - ws, - help, - version, - queryArgv, - }; -} - -/** - * Parse CLI arguments into a structured object. - * Exported for testing — the main() function uses this internally. - */ -export function parseCliArgs(argv: string[]): ParsedCliArgs { - if (argv[0] === 'query') { - return parseCliArgsQueryPermissive(argv); - } - - const { values, positionals } = parseArgs({ - args: argv, - options: { - 'project-dir': { type: 'string', default: process.cwd() }, - 'ws-port': { type: 'string' }, - ws: { type: 'string' }, - model: { type: 'string' }, - 'max-budget': { type: 'string' }, - init: { type: 'string' }, - help: { type: 'boolean', short: 'h', default: false }, - version: { type: 'boolean', short: 'v', default: false }, - }, - allowPositionals: true, - strict: true, - }); - - const command = positionals[0] as string | undefined; - const prompt = positionals.slice(1).join(' ') || undefined; - - // For 'init' command, the positional after 'init' is the input source. - // For 'run' command, it's the prompt. Both use positionals[1+]. - const initInput = command === 'init' ? prompt : undefined; - - return { - command, - prompt, - initInput, - init: values.init as string | undefined, - projectDir: values['project-dir'] as string, - wsPort: values['ws-port'] ? Number(values['ws-port']) : undefined, - model: values.model as string | undefined, - maxBudget: values['max-budget'] ? Number(values['max-budget']) : undefined, - ws: values.ws as string | undefined, - help: values.help as boolean, - version: values.version as boolean, - }; -} - -// ─── Usage ─────────────────────────────────────────────────────────────────── - -export const USAGE = ` -Usage: gsd-sdk [args] [options] - -Commands: - run Run a full milestone from a text prompt - auto Run the full autonomous lifecycle (discover -> execute -> advance) - init [input] Bootstrap a new project from a PRD or description - input can be: - @path/to/prd.md Read input from a file - "description" Use text directly - (empty) Read from stdin - query Registered query handlers only (longest-prefix argv match; see QUERY-HANDLERS.md) - Use --pick to extract a specific field from JSON output - -Options: - --init Bootstrap from a PRD before running (auto only) - Accepts @path/to/prd.md or "description text" - --project-dir Project directory (default: cwd) - --ws Route .planning/ to .planning/workstreams// - --ws-port Enable WebSocket transport on - --model Override LLM model - --max-budget Max budget per step in USD - -h, --help Show this help - -v, --version Show version -`.trim(); - -/** - * Read the package version from package.json. - */ -async function getVersion(): Promise { - try { - const pkgPath = resolve(fileURLToPath(import.meta.url), '..', '..', 'package.json'); - const raw = await readFile(pkgPath, 'utf-8'); - const pkg = JSON.parse(raw) as { version?: string }; - return pkg.version ?? 'unknown'; - } catch { - return 'unknown'; - } -} - -// ─── Init input resolution ─────────────────────────────────────────────────── - -/** - * Resolve the init command input to a string. - * - * - `@path/to/file.md` → reads the file contents - * - Raw text → returns as-is - * - No input → reads from stdin (with TTY detection) - * - * Exported for testing. - */ -export async function resolveInitInput(args: ParsedCliArgs): Promise { - const input = args.initInput; - - if (input && input.startsWith('@')) { - // File path: strip @ prefix, resolve relative to projectDir - const filePath = resolve(args.projectDir, input.slice(1)); - try { - return await readFile(filePath, 'utf-8'); - } catch (err) { - throw new Error(`Cannot read input file "${filePath}": ${(err as NodeJS.ErrnoException).code === 'ENOENT' ? 'file not found' : (err as Error).message}`); - } - } - - if (input) { - // Raw text - return input; - } - - // No input — read from stdin - return readStdin(); -} - -/** - * Read all data from stdin. Rejects if stdin is a TTY with no piped data. - */ -async function readStdin(): Promise { - const { stdin } = process; - - if (stdin.isTTY) { - throw new Error( - 'No input provided. Usage:\n' + - ' gsd-sdk init @path/to/prd.md\n' + - ' gsd-sdk init "build a todo app"\n' + - ' cat prd.md | gsd-sdk init' - ); - } - - return new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - stdin.on('data', (chunk: Buffer) => chunks.push(chunk)); - stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); - stdin.on('error', reject); - }); -} - - -// ─── Main ──────────────────────────────────────────────────────────────────── - -export async function main(argv: string[] = process.argv.slice(2)): Promise { - let args: ParsedCliArgs; - - try { - args = parseCliArgs(argv); - } catch (err) { - console.error(`Error: ${(err as Error).message}`); - console.error(USAGE); - process.exitCode = 1; - return; - } - - if (args.help) { - console.log(USAGE); - return; - } - - if (args.version) { - const ver = await getVersion(); - console.log(`gsd-sdk v${ver}`); - return; - } - - // Validate --ws flag if provided - if (args.ws !== undefined && !validateWorkstreamName(args.ws)) { - console.error(`Error: Invalid workstream name "${args.ws}". Use alphanumeric, hyphens, underscores, or dots only.`); - process.exitCode = 1; - return; - } - - // ─── Query command ────────────────────────────────────────────────────── - if (args.command === 'query') { - const result = await runQueryCliCommand({ - projectDir: args.projectDir, - ws: args.ws, - queryArgv: args.queryArgv, - }); - for (const line of result.stderrLines) console.error(line); - for (const chunk of result.stdoutChunks) process.stdout.write(chunk); - process.exitCode = result.exitCode; - return; - } - - // Fall back to GSD_WORKSTREAM env var when --ws is not supplied (#2791). - // gsd-tools.cjs resolves the active workstream via this env var; parity - // means gsd-sdk command paths see the same .planning/ path as gsd-tools. - if (args.ws === undefined && process.env.GSD_WORKSTREAM) { - const envWs = process.env.GSD_WORKSTREAM; - if (validateWorkstreamName(envWs)) { - args = { ...args, ws: envWs }; - } - } - - // Multi-repo project-root resolution (issue #2623). - { - const { findProjectRoot } = await import('./query/helpers.js'); - args = { ...args, projectDir: findProjectRoot(args.projectDir) }; - } - - if (args.command !== 'run' && args.command !== 'init' && args.command !== 'auto') { - console.error('Error: Expected "gsd-sdk run ", "gsd-sdk auto", "gsd-sdk init [input]", or "gsd-sdk query "'); - console.error(USAGE); - process.exitCode = 1; - return; - } - - if (args.command === 'run' && !args.prompt) { - console.error('Error: "gsd-sdk run" requires a prompt'); - console.error(USAGE); - process.exitCode = 1; - return; - } - - // ─── Init command ───────────────────────────────────────────────────────── - if (args.command === 'init') { - let input: string; - try { - input = await resolveInitInput(args); - } catch (err) { - console.error(`Error: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - console.log(`[init] Resolved input: ${input.length} chars`); - - // Build GSD instance for tools and event stream - const gsd = new GSD({ - projectDir: args.projectDir, - model: args.model, - maxBudgetUsd: args.maxBudget, - workstream: args.ws, - }); - - // Wire CLI transport - const cliTransport = new CLITransport(); - gsd.addTransport(cliTransport); - - // Optional WebSocket transport - let wsTransport: WSTransport | undefined; - if (args.wsPort !== undefined) { - wsTransport = new WSTransport({ port: args.wsPort }); - await wsTransport.start(); - gsd.addTransport(wsTransport); - console.log(`WebSocket transport listening on port ${args.wsPort}`); - } - - try { - const tools = gsd.createTools(); - const runner = new InitRunner({ - projectDir: args.projectDir, - tools, - eventStream: gsd.eventStream, - config: { - maxBudgetPerSession: args.maxBudget, - orchestratorModel: args.model, - }, - }); - - const result = await runner.run(input); - - // Print completion summary - const status = result.success ? 'SUCCESS' : 'FAILED'; - const stepCount = result.steps.length; - const passedSteps = result.steps.filter(s => s.success).length; - const cost = result.totalCostUsd.toFixed(2); - const duration = (result.totalDurationMs / 1000).toFixed(1); - const artifactList = result.artifacts.join(', '); - - console.log(`\n[${status}] ${passedSteps}/${stepCount} steps, $${cost}, ${duration}s`); - if (result.artifacts.length > 0) { - console.log(`Artifacts: ${artifactList}`); - } - - if (!result.success) { - // Log failed steps - for (const step of result.steps) { - if (!step.success && step.error) { - console.error(` ✗ ${step.step}: ${step.error}`); - } - } - process.exitCode = 1; - } - } catch (err) { - console.error(`Fatal error: ${(err as Error).message}`); - process.exitCode = 1; - } finally { - cliTransport.close(); - if (wsTransport) { - wsTransport.close(); - } - } - return; - } - - // ─── Auto command ───────────────────────────────────────────────────────── - if (args.command === 'auto') { - // #2832: refuse to silently route non-Claude runtime projects through the - // Claude Agent SDK. Load project config (best effort — falls back to - // defaults when missing) and gate before constructing GSD/InitRunner. - try { - const cfg = await loadConfig(args.projectDir, args.ws); - assertRuntimeSupportsAutoMode(cfg); - } catch (err) { - console.error(`Fatal error: ${(err as Error).message}`); - process.exitCode = 1; - return; - } - - const gsd = new GSD({ - projectDir: args.projectDir, - model: args.model, - maxBudgetUsd: args.maxBudget, - autoMode: true, - workstream: args.ws, - }); - - // Wire CLI transport (always active) - const cliTransport = new CLITransport(); - gsd.addTransport(cliTransport); - - // Optional WebSocket transport - let wsTransport: WSTransport | undefined; - if (args.wsPort !== undefined) { - wsTransport = new WSTransport({ port: args.wsPort }); - await wsTransport.start(); - gsd.addTransport(wsTransport); - console.log(`WebSocket transport listening on port ${args.wsPort}`); - } - - try { - // If --init provided, bootstrap project first - if (args.init) { - const initInput = await resolveInitInput({ - ...args, - command: 'init', - initInput: args.init, - }); - - console.log(`[auto] Bootstrapping project from --init (${initInput.length} chars)`); - - const tools = gsd.createTools(); - const runner = new InitRunner({ - projectDir: args.projectDir, - tools, - eventStream: gsd.eventStream, - config: { - maxBudgetPerSession: args.maxBudget, - orchestratorModel: args.model, - }, - }); - - const initResult = await runner.run(initInput); - - const initStatus = initResult.success ? 'SUCCESS' : 'FAILED'; - const stepCount = initResult.steps.length; - const passedSteps = initResult.steps.filter(s => s.success).length; - const initCost = initResult.totalCostUsd.toFixed(2); - const initDuration = (initResult.totalDurationMs / 1000).toFixed(1); - console.log(`[init ${initStatus}] ${passedSteps}/${stepCount} steps, $${initCost}, ${initDuration}s`); - - if (!initResult.success) { - for (const step of initResult.steps) { - if (!step.success && step.error) { - console.error(` ✗ ${step.step}: ${step.error}`); - } - } - process.exitCode = 1; - return; - } - } - - const result = await gsd.run(''); - - // Final summary - const status = result.success ? 'SUCCESS' : 'FAILED'; - const phases = result.phases.length; - const cost = result.totalCostUsd.toFixed(2); - const duration = (result.totalDurationMs / 1000).toFixed(1); - console.log(`\n[${status}] ${phases} phase(s), $${cost}, ${duration}s`); - - if (!result.success) { - process.exitCode = 1; - } - } catch (err) { - console.error(`Fatal error: ${(err as Error).message}`); - process.exitCode = 1; - } finally { - cliTransport.close(); - if (wsTransport) { - wsTransport.close(); - } - } - return; - } - - // ─── Run command ───────────────────────────────────────────────────────── - - // Build GSD instance - const gsd = new GSD({ - projectDir: args.projectDir, - model: args.model, - maxBudgetUsd: args.maxBudget, - workstream: args.ws, - }); - - // Wire CLI transport (always active) - const cliTransport = new CLITransport(); - gsd.addTransport(cliTransport); - - // Optional WebSocket transport - let wsTransport: WSTransport | undefined; - if (args.wsPort !== undefined) { - wsTransport = new WSTransport({ port: args.wsPort }); - await wsTransport.start(); - gsd.addTransport(wsTransport); - console.log(`WebSocket transport listening on port ${args.wsPort}`); - } - - try { - const result = await gsd.run(args.prompt!); - - // Final summary - const status = result.success ? 'SUCCESS' : 'FAILED'; - const phases = result.phases.length; - const cost = result.totalCostUsd.toFixed(2); - const duration = (result.totalDurationMs / 1000).toFixed(1); - console.log(`\n[${status}] ${phases} phase(s), $${cost}, ${duration}s`); - - if (!result.success) { - process.exitCode = 1; - } - } catch (err) { - console.error(`Fatal error: ${(err as Error).message}`); - process.exitCode = 1; - } finally { - // Clean up transports - cliTransport.close(); - if (wsTransport) { - wsTransport.close(); - } - } -} - -// ─── Auto-run when invoked directly ────────────────────────────────────────── - -main(); diff --git a/sdk/src/config.test.ts b/sdk/src/config.test.ts deleted file mode 100644 index 69e5ac322..000000000 --- a/sdk/src/config.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { loadConfig, CONFIG_DEFAULTS } from './config.js'; -import { mkdir, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -describe('loadConfig', () => { - let tmpDir: string; - let fakeHome: string; - let prevHome: string | undefined; - let prevGsdHome: string | undefined; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `gsd-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(join(tmpDir, '.planning'), { recursive: true }); - // Isolate ~/.gsd/defaults.json by pointing HOME at an empty tmp dir. - fakeHome = join(tmpdir(), `gsd-home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(fakeHome, { recursive: true }); - prevHome = process.env.HOME; - process.env.HOME = fakeHome; - // Also isolate GSD_HOME (loadUserDefaults prefers it over HOME). - prevGsdHome = process.env.GSD_HOME; - delete process.env.GSD_HOME; - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - await rm(fakeHome, { recursive: true, force: true }); - if (prevHome === undefined) delete process.env.HOME; - else process.env.HOME = prevHome; - if (prevGsdHome === undefined) delete process.env.GSD_HOME; - else process.env.GSD_HOME = prevGsdHome; - }); - - async function writeUserDefaults(defaults: unknown) { - await mkdir(join(fakeHome, '.gsd'), { recursive: true }); - await writeFile(join(fakeHome, '.gsd', 'defaults.json'), JSON.stringify(defaults)); - } - - it('returns all defaults when config file is missing', async () => { - // No config.json created - await rm(join(tmpDir, '.planning', 'config.json'), { force: true }); - const config = await loadConfig(tmpDir); - expect(config).toEqual(CONFIG_DEFAULTS); - }); - - it('returns all defaults when config file is empty', async () => { - await writeFile(join(tmpDir, '.planning', 'config.json'), ''); - const config = await loadConfig(tmpDir); - expect(config).toEqual(CONFIG_DEFAULTS); - }); - - it('loads valid config and merges with defaults', async () => { - const userConfig = { - model_profile: 'fast', - workflow: { research: false }, - }; - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify(userConfig), - ); - - const config = await loadConfig(tmpDir); - - expect(config.model_profile).toBe('fast'); - expect(config.workflow.research).toBe(false); - // Other workflow defaults preserved - expect(config.workflow.plan_check).toBe(true); - expect(config.workflow.verifier).toBe(true); - // Top-level defaults preserved - expect(config.commit_docs).toBe(true); - expect(config.parallelization).toBe(true); - }); - - it('partial config merges correctly for nested objects', async () => { - const userConfig = { - git: { branching_strategy: 'milestone' }, - hooks: { context_warnings: false }, - }; - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify(userConfig), - ); - - const config = await loadConfig(tmpDir); - - expect(config.git.branching_strategy).toBe('milestone'); - // Other git defaults preserved - expect(config.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}'); - expect(config.hooks.context_warnings).toBe(false); - }); - - it('preserves unknown top-level keys', async () => { - const userConfig = { custom_key: 'custom_value' }; - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify(userConfig), - ); - - const config = await loadConfig(tmpDir); - expect(config.custom_key).toBe('custom_value'); - }); - - it('merges agent_skills', async () => { - const userConfig = { - agent_skills: { planner: 'custom-skill' }, - }; - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify(userConfig), - ); - - const config = await loadConfig(tmpDir); - expect(config.agent_skills).toEqual({ planner: 'custom-skill' }); - }); - - // ─── Negative tests ───────────────────────────────────────────────────── - - it('throws on malformed JSON', async () => { - await writeFile( - join(tmpDir, '.planning', 'config.json'), - '{bad json', - ); - - await expect(loadConfig(tmpDir)).rejects.toThrow(/Failed to parse config/); - }); - - it('throws when config is not an object (array)', async () => { - await writeFile( - join(tmpDir, '.planning', 'config.json'), - '[1, 2, 3]', - ); - - await expect(loadConfig(tmpDir)).rejects.toThrow(/must be a JSON object/); - }); - - it('throws when config is not an object (string)', async () => { - await writeFile( - join(tmpDir, '.planning', 'config.json'), - '"just a string"', - ); - - await expect(loadConfig(tmpDir)).rejects.toThrow(/must be a JSON object/); - }); - - it('ignores unknown keys without error', async () => { - const userConfig = { - totally_unknown: true, - another_unknown: { nested: 'value' }, - }; - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify(userConfig), - ); - - const config = await loadConfig(tmpDir); - // Should load fine, with unknowns passed through - expect(config.model_profile).toBe('balanced'); - expect((config as Record).totally_unknown).toBe(true); - }); - - it('handles wrong value types gracefully (user sets string instead of bool)', async () => { - const userConfig = { - commit_docs: 'yes', // should be boolean but we don't validate types - parallelization: 0, - }; - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify(userConfig), - ); - - const config = await loadConfig(tmpDir); - // We pass through the user's values as-is — runtime code handles type mismatches - expect(config.commit_docs).toBe('yes'); - expect(config.parallelization).toBe(0); - }); - - // ─── User-level defaults (~/.gsd/defaults.json) ───────────────────────── - // Regression: issue #2652 — SDK loadConfig ignored user-level defaults - // for pre-project Codex installs, so init.quick still emitted Claude - // model aliases from MODEL_PROFILES via resolveModel even when the user - // had `resolve_model_ids: "omit"` in ~/.gsd/defaults.json. - // - // Mirrors current CJS parity expectations for SDK loadConfig + resolveModel: - // in pre-project context, loadConfig ignores ~/.gsd/defaults.json so - // resolveModel/MODEL_PROFILES do not emit aliases when resolve_model_ids - // is "omit". Once a project is initialized, config.json is authoritative, - // because buildNewProjectConfig bakes user defaults into project config - // at /gsd-new-project time. - - it('pre-project: ignores user defaults and uses built-in defaults', async () => { - await writeUserDefaults({ resolve_model_ids: 'omit' }); - const config = await loadConfig(tmpDir); - // BEHAVIOR CHANGE (Cycle 3, #3536): CONFIG_DEFAULTS now sourced from - // sdk/shared/config-defaults.manifest.json which includes resolve_model_ids: false. - // The key is NOT undefined — it has the manifest default (false), not the user - // default ('omit'), confirming that user-level ~/.gsd/defaults.json is still ignored. - expect((config as Record).resolve_model_ids).toBe(false); - expect(config.model_profile).toBe('balanced'); - expect(config.workflow.plan_check).toBe(true); - }); - - it('pre-project: keeps built-in nested defaults even when user defaults exist', async () => { - await writeUserDefaults({ - git: { branching_strategy: 'milestone' }, - agent_skills: { planner: 'user-skill' }, - }); - - const config = await loadConfig(tmpDir); - expect(config.git.branching_strategy).toBe('none'); - expect(config.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}'); - expect(config.agent_skills).toEqual({}); - }); - - it('project config is authoritative over user defaults (CJS parity)', async () => { - // User defaults set resolve_model_ids: "omit", but project config omits it. - // Per CJS core.cjs loadConfig (#1683): once .planning/config.json exists, - // ~/.gsd/defaults.json is ignored — buildNewProjectConfig already baked - // the user defaults in at project creation time. - await writeUserDefaults({ - resolve_model_ids: 'omit', - model_profile: 'fast', - }); - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'quality' }), - ); - - const config = await loadConfig(tmpDir); - expect(config.model_profile).toBe('quality'); - // User-defaults not layered when project config present. - // BEHAVIOR CHANGE (Cycle 3, #3536): resolve_model_ids is now false (manifest default), - // not undefined — confirming user defaults are still ignored (value is NOT 'omit'). - expect((config as Record).resolve_model_ids).toBe(false); - }); - - it('ignores malformed ~/.gsd/defaults.json', async () => { - await mkdir(join(fakeHome, '.gsd'), { recursive: true }); - await writeFile(join(fakeHome, '.gsd', 'defaults.json'), '{not json'); - - const config = await loadConfig(tmpDir); - // Falls back to built-in defaults - expect(config).toEqual(CONFIG_DEFAULTS); - }); - - it('maps legacy top-level branching_strategy into git.branching_strategy', async () => { - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ branching_strategy: 'phase' }), - ); - - const config = await loadConfig(tmpDir); - expect(config.git.branching_strategy).toBe('phase'); - }); - - it('git.branching_strategy overrides legacy top-level branching_strategy when both are present', async () => { - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ branching_strategy: 'phase', git: { branching_strategy: 'milestone' } }), - ); - - const config = await loadConfig(tmpDir); - expect(config.git.branching_strategy).toBe('milestone'); - }); - - it('does not mutate CONFIG_DEFAULTS between calls', async () => { - const before = structuredClone(CONFIG_DEFAULTS); - - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'fast', workflow: { research: false } }), - ); - await loadConfig(tmpDir); - - expect(CONFIG_DEFAULTS).toEqual(before); - }); -}); diff --git a/sdk/src/config.ts b/sdk/src/config.ts deleted file mode 100644 index 7c94e8341..000000000 --- a/sdk/src/config.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Config reader — loads `.planning/config.json` and merges with defaults. - * - * Mirrors the default structure from `get-shit-done/bin/lib/config.cjs` - * `buildNewProjectConfig()`. - */ - -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { relPlanningPath } from './workstream-utils.js'; -import { - CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, - mergeDefaults as canonicalMergeDefaults, - normalizeLegacyKeys, -} from './config/index.js'; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface GitConfig { - branching_strategy: string; - phase_branch_template: string; - milestone_branch_template: string; - quick_branch_template: string | null; -} - -export interface WorkflowConfig { - research: boolean; - plan_check: boolean; - verifier: boolean; - nyquist_validation: boolean; - /** Mirrors gsd-tools flat `config.tdd_mode` (from `workflow.tdd_mode`). */ - tdd_mode: boolean; - /** - * Issue #3309. `end-of-phase` (default) suppresses mid-flight - * `` task emission; the planner - * embeds verification details into the relevant `auto` task's - * `` block and the verifier harvests them at - * end-of-phase into the existing HUMAN-UAT.md path. `mid-flight` - * restores the pre-#3309 behavior where the executor halts at each - * `checkpoint:human-verify` task and pays a full executor cold-start - * cost (CLAUDE.md, MEMORY.md, STATE.md, plan re-read on respawn) per - * round-trip. - */ - human_verify_mode: 'mid-flight' | 'end-of-phase'; - auto_advance: boolean; - /** Internal auto-chain flag used by workflow routing. */ - _auto_chain_active?: boolean; - node_repair: boolean; - node_repair_budget: number; - ui_phase: boolean; - ui_safety_gate: boolean; - text_mode: boolean; - research_before_questions: boolean; - discuss_mode: string; - skip_discuss: boolean; - /** Maximum self-discuss passes in auto/headless mode before forcing proceed. Default: 3. */ - max_discuss_passes: number; - /** Subagent timeout in ms (matches `get-shit-done/bin/lib/core.cjs` default 300000). */ - subagent_timeout: number; - /** - * Issue #2492. When true (default), enforces that every trackable decision in - * CONTEXT.md `` is referenced by at least one plan (translation - * gate, blocking) and reports decisions not honored by shipped artifacts at - * verify-phase (validation gate, non-blocking). Set false to disable both. - */ - context_coverage_gate: boolean; - /** - * Issue #105. When false, the primary checkout is shared or pinned (concurrent - * sessions / deliberate base-branch lock) and the commit handler must NOT - * auto-switch HEAD to the strategy branch. String value `"false"` is accepted - * for resilience against YAML/JSON parsers that leave boolean-like fields as - * strings. - */ - use_worktrees?: boolean | string; -} - -export interface HooksConfig { - context_warnings: boolean; -} - -export interface GSDConfig { - model_profile: string; - commit_docs: boolean; - parallelization: boolean; - search_gitignored: boolean; - brave_search: boolean; - firecrawl: boolean; - exa_search: boolean; - git: GitConfig; - workflow: WorkflowConfig; - hooks: HooksConfig; - agent_skills: Record; - /** Project slug for branch templates; mirrors gsd-tools `config.project_code`. */ - project_code?: string | null; - /** Interactive vs headless; mirrors gsd-tools flat `config.mode`. */ - mode?: string; - [key: string]: unknown; -} - -// ─── Defaults ──────────────────────────────────────────────────────────────── - -/** - * Canonical CONFIG_DEFAULTS delegated to the Configuration Module (ADR-3524). - * Cast to GSDConfig to preserve typed access for existing consumers. - * The canonical manifest may include additional keys beyond GSDConfig's - * declared fields (e.g. resolve_model_ids, context_window, planning.*, - * ship.*, workflow.security_*, workflow.code_review_*); these are accessible - * via the [key: string]: unknown index signature on GSDConfig. - * - * BEHAVIOR CHANGE (Cycle 3, #3536): CONFIG_DEFAULTS now includes all keys from - * sdk/shared/config-defaults.manifest.json. Keys added vs old inline literal: - * top-level: resolve_model_ids (false), context_window (200000), - * phase_naming ('sequential'), claude_md_path ('./CLAUDE.md') - * git: create_tag (true), base_branch (null) - * workflow: ai_integration_phase (true), code_review (true), - * code_review_depth ('standard'), code_review_command (null), - * pattern_mapper (true), plan_bounce (false), plan_bounce_script (null), - * plan_bounce_passes (2), auto_prune_state (false), - * post_planning_gaps (true), security_enforcement (true), - * security_asvs_level (1), security_block_on ('high'), - * context_coverage_gate: true (unchanged from old literal) - * planning: { commit_docs: true, search_gitignored: false, sub_repos: [], granularity: 'standard' } - * hooks: workflow_guard (false) - * ship: { pr_body_sections: [] } - */ -export const CONFIG_DEFAULTS: GSDConfig = CANONICAL_CONFIG_DEFAULTS as unknown as GSDConfig; - -// ─── Loader ────────────────────────────────────────────────────────────────── - -/** - * Load project config from `.planning/config.json`, merging with defaults. - * When project config is missing or empty, this returns `mergeDefaults({})` - * (built-in defaults only; no `~/.gsd/defaults.json` layering). - * Throws on malformed JSON with a helpful error message. - */ -export async function loadConfig(projectDir: string, workstream?: string): Promise { - const configPath = join(projectDir, relPlanningPath(workstream), 'config.json'); - const rootConfigPath = join(projectDir, '.planning', 'config.json'); - - let raw: string; - let projectConfigFound = false; - try { - raw = await readFile(configPath, 'utf-8'); - projectConfigFound = true; - } catch { - // If workstream config missing, fall back to root config - if (workstream) { - try { - raw = await readFile(rootConfigPath, 'utf-8'); - projectConfigFound = true; - } catch { - raw = ''; - } - } else { - raw = ''; - } - } - - // Pre-project context: no .planning/config.json exists. - // Use built-in defaults only so SDK query parity stays stable across machines. - if (!projectConfigFound) { - return mergeDefaults({}); - } - - const trimmed = raw.trim(); - if (trimmed === '') { - // Empty project config — treat as no project config. - return mergeDefaults({}); - } - - let parsed: Record; - try { - parsed = JSON.parse(trimmed); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to parse config at ${configPath}: ${msg}`); - } - - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error(`Config at ${configPath} must be a JSON object`); - } - - // Project config exists — user-level defaults are ignored (CJS parity). - // `buildNewProjectConfig` already baked them into config.json at /gsd-new-project. - // Normalize legacy top-level keys (branching_strategy → git.branching_strategy, etc.) - // before merging with defaults, matching the Configuration Module's loadConfig pipeline. - const { parsed: normalized } = normalizeLegacyKeys(parsed); - return mergeDefaults(normalized); -} - -/** - * Merge config with defaults using the Configuration Module's deep-merge. - * Delegates to canonicalMergeDefaults (ADR-3524, Cycle 3, #3536). - * - * BEHAVIOR CHANGE (Cycle 3, #3536): The old implementation used spread-per-section - * (shallow merge for git/workflow/hooks/agent_skills, spread for top-level). - * The new implementation uses recursive deep-merge via canonicalMergeDefaults, - * which means partial nested objects (e.g. { workflow: { research: false } }) - * are now deep-merged rather than replacing the entire section's defaults. - * The practical difference: deep-merge preserves sibling default keys within - * nested sections even when the overlay only specifies one key — which was - * already the intended behavior of the old spread-per-section approach. - * Legacy branching_strategy top-level → git.branching_strategy normalization - * is now handled by normalizeLegacyKeys inside canonicalMergeDefaults's pipeline - * (via loadConfig); for the raw mergeDefaults path, legacy key handling is - * delegated to the canonical module. - */ -function mergeDefaults(parsed: Record): GSDConfig { - return canonicalMergeDefaults(parsed) as unknown as GSDConfig; -} diff --git a/sdk/src/config/index.test.ts b/sdk/src/config/index.test.ts deleted file mode 100644 index f7d2a4bd1..000000000 --- a/sdk/src/config/index.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * Pinning tests for the Configuration Module (ADR-3524 §6). - * - * These tests pin the public interface contract. They are RED until - * sdk/src/config/index.ts is created (Cycle 2). - * - * Test precedent: sdk/src/config.test.ts (vitest + fs fixtures). - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { - loadConfig, - normalizeLegacyKeys, - mergeDefaults, - migrateOnDisk, - CONFIG_DEFAULTS, -} from './index.js'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function makeTmpProject(): string { - const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-test-')); - mkdirSync(join(dir, '.planning'), { recursive: true }); - return dir; -} - -function writeConfig(dir: string, data: unknown): void { - writeFileSync(join(dir, '.planning', 'config.json'), JSON.stringify(data, null, 2)); -} - -function readConfigRaw(dir: string): string { - return readFileSync(join(dir, '.planning', 'config.json'), 'utf-8'); -} - -function cleanupDir(dir: string): void { - rmSync(dir, { recursive: true, force: true }); -} - -// ─── loadConfig ────────────────────────────────────────────────────────────── - -describe('loadConfig', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = makeTmpProject(); - }); - - afterEach(() => { - cleanupDir(tmpDir); - }); - - it('returns CONFIG_DEFAULTS when config.json is missing', async () => { - const config = await loadConfig(tmpDir); - expect(config).toEqual(CONFIG_DEFAULTS); - }); - - it('returns CONFIG_DEFAULTS when config.json is empty {}', async () => { - writeConfig(tmpDir, {}); - const config = await loadConfig(tmpDir); - expect(config).toEqual(CONFIG_DEFAULTS); - }); - - it('returns nested git.branching_strategy when already nested', async () => { - writeConfig(tmpDir, { git: { branching_strategy: 'phase' } }); - const config = await loadConfig(tmpDir); - expect(config.git.branching_strategy).toBe('phase'); - }); - - it('normalizes legacy top-level branching_strategy to git.branching_strategy', async () => { - writeConfig(tmpDir, { branching_strategy: 'phase' }); - const config = await loadConfig(tmpDir); - expect(config.git.branching_strategy).toBe('phase'); - }); - - it('does NOT write disk when normalizing legacy branching_strategy', async () => { - writeConfig(tmpDir, { branching_strategy: 'phase' }); - const before = readConfigRaw(tmpDir); - await loadConfig(tmpDir); - const after = readConfigRaw(tmpDir); - expect(after).toBe(before); - }); - - it('normalizes legacy top-level sub_repos to planning.sub_repos', async () => { - writeConfig(tmpDir, { sub_repos: ['app1', 'app2'] }); - const config = await loadConfig(tmpDir); - expect((config.planning as Record)?.sub_repos).toEqual(['app1', 'app2']); - }); - - it('handles legacy depth: comprehensive → planning.granularity: fine', async () => { - writeConfig(tmpDir, { depth: 'comprehensive' }); - const config = await loadConfig(tmpDir); - // depth is a legacy key that maps to granularity - const granularity = (config as Record).granularity - ?? (config.planning as Record | undefined)?.granularity; - expect(granularity).toBe('fine'); - }); - - it('handles legacy depth: quick → coarse', async () => { - writeConfig(tmpDir, { depth: 'quick' }); - const config = await loadConfig(tmpDir); - const granularity = (config as Record).granularity - ?? (config.planning as Record | undefined)?.granularity; - expect(granularity).toBe('coarse'); - }); - - it('handles legacy depth: standard → standard', async () => { - writeConfig(tmpDir, { depth: 'standard' }); - const config = await loadConfig(tmpDir); - const granularity = (config as Record).granularity - ?? (config.planning as Record | undefined)?.granularity; - expect(granularity).toBe('standard'); - }); - - it('throws with informative error on malformed JSON', async () => { - writeFileSync(join(tmpDir, '.planning', 'config.json'), '{bad json'); - await expect(loadConfig(tmpDir)).rejects.toThrow(/parse|invalid|json/i); - }); - - it('does not throw when .planning/config.json is missing — returns defaults', async () => { - const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-noplan-')); - // intentionally no .planning dir - try { - const config = await loadConfig(dir); - expect(config).toEqual(CONFIG_DEFAULTS); - } finally { - cleanupDir(dir); - } - }); -}); - -// ─── normalizeLegacyKeys ───────────────────────────────────────────────────── - -describe('normalizeLegacyKeys', () => { - it('migrates top-level branching_strategy to git.branching_strategy', () => { - const input = { branching_strategy: 'phase' }; - const { parsed, normalizations } = normalizeLegacyKeys(input); - expect((parsed as Record).branching_strategy).toBeUndefined(); - expect((parsed as Record>).git?.branching_strategy).toBe('phase'); - expect(normalizations).toHaveLength(1); - expect(normalizations[0]).toMatchObject({ from: 'branching_strategy', to: 'git.branching_strategy', value: 'phase' }); - }); - - it('migrates top-level sub_repos to planning.sub_repos', () => { - const input = { sub_repos: ['app1', 'app2'] }; - const { parsed, normalizations } = normalizeLegacyKeys(input); - expect((parsed as Record).sub_repos).toBeUndefined(); - expect((parsed as Record>).planning?.sub_repos).toEqual(['app1', 'app2']); - expect(normalizations).toHaveLength(1); - expect(normalizations[0]).toMatchObject({ from: 'sub_repos', to: 'planning.sub_repos', value: ['app1', 'app2'] }); - }); - - it('migrates multiRepo: true to planning.sub_repos marker', () => { - const input = { multiRepo: true }; - const { parsed, normalizations } = normalizeLegacyKeys(input); - expect((parsed as Record).multiRepo).toBeUndefined(); - expect(normalizations).toHaveLength(1); - expect(normalizations[0]).toMatchObject({ from: 'multiRepo', to: 'planning.sub_repos', requiresFilesystem: true }); - }); - - it('migrates top-level depth to granularity (comprehensive → fine)', () => { - const input = { depth: 'comprehensive' }; - const { parsed, normalizations } = normalizeLegacyKeys(input); - expect((parsed as Record).depth).toBeUndefined(); - expect(normalizations).toHaveLength(1); - expect(normalizations[0].from).toBe('depth'); - // The value in parsed is the mapped granularity value - const gv = (parsed as Record).granularity - ?? (parsed as Record>).planning?.granularity; - expect(gv).toBe('fine'); - }); - - it('migrates top-level depth: quick → coarse', () => { - const input = { depth: 'quick' }; - const { parsed, normalizations } = normalizeLegacyKeys(input); - const gv = (parsed as Record).granularity - ?? (parsed as Record>).planning?.granularity; - expect(gv).toBe('coarse'); - expect(normalizations[0].from).toBe('depth'); - }); - - it('migrates all four legacy keys in one pass', () => { - const input = { - branching_strategy: 'phase', - sub_repos: ['app1'], - multiRepo: true, - depth: 'comprehensive', - }; - const { parsed, normalizations } = normalizeLegacyKeys(input); - expect(normalizations).toHaveLength(4); - // branching_strategy → git.branching_strategy - expect((parsed as Record>).git?.branching_strategy).toBe('phase'); - }); - - it('returns empty normalizations for already-normalized input', () => { - const input = { git: { branching_strategy: 'phase' }, planning: { sub_repos: ['app1'] } }; - const { parsed, normalizations } = normalizeLegacyKeys(input); - expect(normalizations).toHaveLength(0); - expect(parsed).toEqual(input); - }); - - it('is idempotent — running twice produces same result with empty normalizations second time', () => { - const input = { branching_strategy: 'phase' }; - const first = normalizeLegacyKeys(input); - const second = normalizeLegacyKeys(first.parsed as Record); - expect(second.normalizations).toHaveLength(0); - expect(second.parsed).toEqual(first.parsed); - }); - - it('preserves canonical git.branching_strategy when both top-level and nested exist', () => { - const input = { branching_strategy: 'milestone', git: { branching_strategy: 'phase' } }; - const { parsed } = normalizeLegacyKeys(input); - // canonical nested wins - expect((parsed as Record>).git?.branching_strategy).toBe('phase'); - }); - - it('preserves canonical planning.sub_repos when both top-level and nested exist', () => { - const input = { sub_repos: ['legacy'], planning: { sub_repos: null } }; - const { parsed } = normalizeLegacyKeys(input); - expect((parsed as Record>).sub_repos).toBeUndefined(); - // canonical nested wins even when explicit null is used to unset - expect((parsed as Record>).planning?.sub_repos).toBeNull(); - }); -}); - -// ─── mergeDefaults ─────────────────────────────────────────────────────────── - -describe('mergeDefaults', () => { - it('returns full CONFIG_DEFAULTS for empty input', () => { - const result = mergeDefaults({}); - expect(result).toEqual(CONFIG_DEFAULTS); - }); - - it('merges partial nested input without losing sibling keys', () => { - const partial = { git: { base_branch: 'main' } }; - const result = mergeDefaults(partial); - // base_branch from input - expect((result.git as Record).base_branch).toBe('main'); - // sibling from defaults - expect(result.git.branching_strategy).toBe(CONFIG_DEFAULTS.git.branching_strategy); - expect(result.git.phase_branch_template).toBe(CONFIG_DEFAULTS.git.phase_branch_template); - }); - - it('preserves boolean false values (not overridden by truthy defaults)', () => { - // workflow.research defaults to true; setting false should survive merge - const partial = { workflow: { research: false } }; - const result = mergeDefaults(partial); - expect(result.workflow.research).toBe(false); - }); - - it('preserves explicit null values', () => { - const partial = { project_code: null }; - const result = mergeDefaults(partial); - expect(result.project_code).toBeNull(); - }); - - it('user top-level keys win over defaults', () => { - const partial = { model_profile: 'quality' }; - const result = mergeDefaults(partial); - expect(result.model_profile).toBe('quality'); - }); -}); - -// ─── migrateOnDisk ─────────────────────────────────────────────────────────── - -describe('migrateOnDisk', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = makeTmpProject(); - }); - - afterEach(() => { - cleanupDir(tmpDir); - }); - - it('returns migrated:false, wrote:null for already-normalized config', async () => { - writeConfig(tmpDir, { git: { branching_strategy: 'phase' } }); - const report = await migrateOnDisk(tmpDir); - expect(report.migrated).toBe(false); - expect(report.wrote).toBeNull(); - expect(report.normalizations).toHaveLength(0); - }); - - it('returns migrated:true, writes disk when legacy key present', async () => { - writeConfig(tmpDir, { branching_strategy: 'phase' }); - const report = await migrateOnDisk(tmpDir); - expect(report.migrated).toBe(true); - expect(report.wrote).not.toBeNull(); - expect(report.normalizations.length).toBeGreaterThan(0); - // Verify disk was updated - const onDisk = JSON.parse(readConfigRaw(tmpDir)); - expect(onDisk.branching_strategy).toBeUndefined(); - expect(onDisk.git?.branching_strategy).toBe('phase'); - }); - - it('returns report shape: { migrated, normalizations, wrote }', async () => { - writeConfig(tmpDir, { branching_strategy: 'milestone' }); - const report = await migrateOnDisk(tmpDir); - expect(report).toHaveProperty('migrated'); - expect(report).toHaveProperty('normalizations'); - expect(report).toHaveProperty('wrote'); - }); - - it('is a no-op when .planning/config.json is missing', async () => { - const dir = mkdtempSync(join(tmpdir(), 'gsd-cfg-nomig-')); - try { - const report = await migrateOnDisk(dir); - expect(report.migrated).toBe(false); - expect(report.wrote).toBeNull(); - } finally { - cleanupDir(dir); - } - }); -}); diff --git a/sdk/src/config/index.ts b/sdk/src/config/index.ts deleted file mode 100644 index 6aef0dc9e..000000000 --- a/sdk/src/config/index.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * Configuration Module — single source of truth for config loading, - * legacy-key normalization, defaults merge, and explicit on-disk migration. - * - * Source of truth for both the SDK and (via generator) the CJS side. - * Manifests are read from sdk/shared/*.manifest.json. - * - * Public API: - * loadConfig(cwd, options?) → MergedConfig — pure read, never writes disk - * normalizeLegacyKeys(parsed) → { parsed, normalizations[] } — pure transform - * mergeDefaults(parsed) → MergedConfig — fills in defaults - * migrateOnDisk(cwd) → MigrationReport — explicit, opt-in disk writeback - */ - -import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// ─── Manifest imports ───────────────────────────────────────────────────────── - -const DEFAULTS_PATH = new URL('../../shared/config-defaults.manifest.json', import.meta.url); -export const CONFIG_DEFAULTS: Record = JSON.parse( - readFileSync(fileURLToPath(DEFAULTS_PATH), 'utf-8'), -); - -const SCHEMA_PATH = new URL('../../shared/config-schema.manifest.json', import.meta.url); -const _schemaManifest: { - validKeys: string[]; - runtimeStateKeys: string[]; - dynamicKeyPatterns: Array<{ topLevel: string; source: string; description: string }>; -} = JSON.parse(readFileSync(fileURLToPath(SCHEMA_PATH), 'utf-8')); - -export const VALID_CONFIG_KEYS: ReadonlySet = new Set(_schemaManifest.validKeys); -export const RUNTIME_STATE_KEYS: ReadonlySet = new Set(_schemaManifest.runtimeStateKeys); - -export interface DynamicKeyPattern { - readonly topLevel: string; - readonly source: string; - readonly description: string; - readonly test: (key: string) => boolean; -} - -export const DYNAMIC_KEY_PATTERNS: readonly DynamicKeyPattern[] = _schemaManifest.dynamicKeyPatterns.map( - (p) => { - const pattern = new RegExp(p.source); - return { - ...p, - test: (key: string) => { - pattern.lastIndex = 0; - return pattern.test(key); - }, - }; - }, -); - -// ─── Types ─────────────────────────────────────────────────────────────────── - -/** Broad merged config type — consumers narrow as needed. */ -export type MergedConfig = Record; - -export interface Normalization { - from: string; - to: string; - value: unknown; - requiresFilesystem?: true; -} - -export interface NormalizationResult { - parsed: MergedConfig; - normalizations: Normalization[]; -} - -export interface MigrationReport { - migrated: boolean; - normalizations: Normalization[]; - wrote: string | null; -} - -export interface LoadConfigOptions { - /** Optional workstream name — routes to .planning/workstreams//config.json */ - workstream?: string; - /** Optional callback to observe normalizations applied during load */ - onNormalizations?: (normalizations: Normalization[]) => void; -} - -// ─── Depth → Granularity mapping ───────────────────────────────────────────── - -const DEPTH_TO_GRANULARITY: Record = { - quick: 'coarse', - standard: 'standard', - comprehensive: 'fine', -}; - -// ─── Internal helpers ───────────────────────────────────────────────────────── - -function planningDir(cwd: string, workstream?: string): string { - if (!workstream) return join(cwd, '.planning'); - return join(cwd, '.planning', 'workstreams', workstream); -} - -function detectSubRepos(cwd: string): string[] { - const results: string[] = []; - try { - const entries = readdirSync(cwd, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; - const gitPath = join(cwd, entry.name, '.git'); - try { - if (existsSync(gitPath)) { - results.push(entry.name); - } - } catch { /* ignore */ } - } - } catch { /* ignore */ } - return results.sort(); -} - -/** - * Deep-merge two plain config objects. overlay wins on key conflict. - * Explicit null in overlay overrides base (null means "unset this key"). - * Arrays are replaced, not merged. undefined in overlay falls back to base. - */ -function deepMergeConfig(base: Record, overlay: Record): Record { - const result: Record = { ...base }; - for (const key of Object.keys(overlay)) { - const ov = overlay[key]; - if (ov !== null && ov !== undefined && typeof ov === 'object' && !Array.isArray(ov)) { - const bv = base[key]; - if (bv !== null && bv !== undefined && typeof bv === 'object' && !Array.isArray(bv)) { - result[key] = deepMergeConfig(bv as Record, ov as Record); - } else { - result[key] = deepMergeConfig({}, ov as Record); - } - } else { - result[key] = ov; - } - } - return result; -} - -// ─── normalizeLegacyKeys ───────────────────────────────────────────────────── - -/** - * Pure transform: migrate legacy top-level config keys to their canonical nested locations. - * Returns the normalized parsed object + a list of normalizations applied. - * Idempotent: calling twice returns the same result with empty normalizations second time. - * - * Normalizations applied (in order): - * 1. top-level branching_strategy → git.branching_strategy (canonical wins if both present) - * 2. top-level sub_repos → planning.sub_repos (canonical wins if both present) - * 3. multiRepo: true → planning.sub_repos marker (requiresFilesystem: true) - * 4. top-level depth → granularity (top-level) with mapping quick→coarse/standard→standard/comprehensive→fine - */ -export function normalizeLegacyKeys(parsed: Record): NormalizationResult { - const result: Record = { ...parsed }; - const normalizations: Normalization[] = []; - - // 1. branching_strategy → git.branching_strategy - if (Object.prototype.hasOwnProperty.call(result, 'branching_strategy')) { - const value = result.branching_strategy; - const git = (result.git as Record | undefined) ?? {}; - if (git.branching_strategy === undefined) { - result.git = { ...git, branching_strategy: value }; - } else { - // canonical nested wins — just delete the stale top-level - result.git = { ...git }; - } - delete result.branching_strategy; - normalizations.push({ from: 'branching_strategy', to: 'git.branching_strategy', value }); - } - - // 2. top-level sub_repos → planning.sub_repos - if (Object.prototype.hasOwnProperty.call(result, 'sub_repos')) { - const value = result.sub_repos; - const planning = (result.planning as Record | undefined) ?? {}; - if (planning.sub_repos === undefined) { - result.planning = { ...planning, sub_repos: value }; - } else { - // canonical nested wins — just drop the stale top-level - result.planning = { ...planning }; - } - delete result.sub_repos; - normalizations.push({ from: 'sub_repos', to: 'planning.sub_repos', value }); - } - - // 3. multiRepo: true → marker (filesystem detection deferred to migrateOnDisk / caller) - if (result.multiRepo === true) { - delete result.multiRepo; - normalizations.push({ from: 'multiRepo', to: 'planning.sub_repos', value: true, requiresFilesystem: true }); - } - - // 4. top-level depth → granularity - if (Object.prototype.hasOwnProperty.call(result, 'depth') && !Object.prototype.hasOwnProperty.call(result, 'granularity')) { - const rawDepth = result.depth as string; - const mapped = DEPTH_TO_GRANULARITY[rawDepth] ?? rawDepth; - result.granularity = mapped; - delete result.depth; - normalizations.push({ from: 'depth', to: 'granularity', value: mapped }); - } - - return { parsed: result, normalizations }; -} - -// ─── mergeDefaults ─────────────────────────────────────────────────────────── - -/** - * Fill in CONFIG_DEFAULTS where the parsed object lacks values. - * Deep-merges per-section (git, workflow, hooks, agent_skills, planning, ship). - * Boolean false and explicit null are preserved — not overridden by truthy defaults. - */ -export function mergeDefaults(parsed: Record): MergedConfig { - // Start with a deep clone of defaults, then overlay parsed - const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS)) as Record; - return deepMergeConfig(defaults, parsed); -} - -// ─── loadConfig ────────────────────────────────────────────────────────────── - -/** - * Load project config from .planning/config.json (workstream-aware). - * Pure read — never writes disk. - * - * Pipeline: parse JSON → normalizeLegacyKeys → mergeDefaults → return. - * - * Missing file → returns CONFIG_DEFAULTS verbatim. - * Empty file → returns CONFIG_DEFAULTS verbatim. - * Malformed JSON → throws with informative error. - */ -export async function loadConfig(cwd: string, options?: LoadConfigOptions): Promise { - const configPath = join(planningDir(cwd, options?.workstream), 'config.json'); - - let raw: string; - try { - raw = readFileSync(configPath, 'utf-8'); - } catch { - // File missing — return defaults - return mergeDefaults({}); - } - - const trimmed = raw.trim(); - if (trimmed === '') { - return mergeDefaults({}); - } - - let parsed: Record; - try { - parsed = JSON.parse(trimmed) as Record; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to parse config at ${configPath}: ${msg}`); - } - - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error(`Config at ${configPath} must be a JSON object`); - } - - const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed); - if (options?.onNormalizations && normalizations.length > 0) { - options.onNormalizations(normalizations); - } - - return mergeDefaults(normalized); -} - -// ─── migrateOnDisk ─────────────────────────────────────────────────────────── - -/** - * Explicit, opt-in disk writeback. - * Reads raw config, runs normalizeLegacyKeys, writes back only if normalizations are non-empty. - * - * For multiRepo: true entries, also runs filesystem detection to populate planning.sub_repos. - * - * Returns MigrationReport: { migrated, normalizations, wrote }. - */ -export async function migrateOnDisk(cwd: string, workstream?: string): Promise { - const configPath = join(planningDir(cwd, workstream), 'config.json'); - - let raw: string; - try { - raw = readFileSync(configPath, 'utf-8'); - } catch { - // File missing — nothing to migrate - return { migrated: false, normalizations: [], wrote: null }; - } - - const trimmed = raw.trim(); - if (trimmed === '') { - return { migrated: false, normalizations: [], wrote: null }; - } - - let parsed: Record; - try { - parsed = JSON.parse(trimmed) as Record; - } catch { - // Malformed — can't migrate - return { migrated: false, normalizations: [], wrote: null }; - } - - const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed); - - if (normalizations.length === 0) { - return { migrated: false, normalizations: [], wrote: null }; - } - - // Resolve multiRepo filesystem detection - const result = { ...normalized }; - for (const norm of normalizations) { - if (norm.requiresFilesystem) { - const detected = detectSubRepos(cwd); - if (detected.length > 0) { - const planning = (result.planning as Record | undefined) ?? {}; - result.planning = { ...planning, sub_repos: detected, commit_docs: false }; - } - } - } - - try { - writeFileSync(configPath, JSON.stringify(result, null, 2)); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to write migrated config at ${configPath}: ${msg}`); - } - return { migrated: true, normalizations, wrote: configPath }; -} diff --git a/sdk/src/context-engine.test.ts b/sdk/src/context-engine.test.ts deleted file mode 100644 index 0c8a921f9..000000000 --- a/sdk/src/context-engine.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { ContextEngine, PHASE_FILE_MANIFEST } from './context-engine.js'; -import { PhaseType } from './types.js'; -import type { GSDLogger } from './logger.js'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -async function createTempProject(): Promise { - return mkdtemp(join(tmpdir(), 'gsd-ctx-')); -} - -async function createPlanningDir(projectDir: string, files: Record): Promise { - const planningDir = join(projectDir, '.planning'); - await mkdir(planningDir, { recursive: true }); - for (const [filename, content] of Object.entries(files)) { - await writeFile(join(planningDir, filename), content, 'utf-8'); - } -} - -function makeMockLogger(): GSDLogger { - return { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - setPhase: vi.fn(), - setPlan: vi.fn(), - setSessionId: vi.fn(), - } as unknown as GSDLogger; -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe('ContextEngine', () => { - let projectDir: string; - - beforeEach(async () => { - projectDir = await createTempProject(); - }); - - afterEach(async () => { - await rm(projectDir, { recursive: true, force: true }); - }); - - describe('resolveContextFiles', () => { - it('returns all files for plan phase when all exist', async () => { - await createPlanningDir(projectDir, { - 'STATE.md': '# State\nproject: test', - 'ROADMAP.md': '# Roadmap\nphase 01', - 'CONTEXT.md': '# Context\nstack: node', - 'RESEARCH.md': '# Research\nfindings here', - 'REQUIREMENTS.md': '# Requirements\nR1: auth', - }); - - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Plan); - - expect(files.state).toBe('# State\nproject: test'); - expect(files.roadmap).toBe('# Roadmap\nphase 01'); - expect(files.context).toBe('# Context\nstack: node'); - expect(files.research).toBe('# Research\nfindings here'); - expect(files.requirements).toBe('# Requirements\nR1: auth'); - }); - - it('returns minimal files for execute phase', async () => { - await createPlanningDir(projectDir, { - 'STATE.md': '# State', - 'config.json': '{"model":"claude"}', - 'ROADMAP.md': '# Roadmap — should not be read', - 'CONTEXT.md': '# Context — should not be read', - }); - - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Execute); - - expect(files.state).toBe('# State'); - expect(files.config).toBe('{"model":"claude"}'); - expect(files.roadmap).toBeUndefined(); - expect(files.context).toBeUndefined(); - }); - - it('returns state + roadmap + context for research phase', async () => { - await createPlanningDir(projectDir, { - 'STATE.md': '# State', - 'ROADMAP.md': '# Roadmap', - 'CONTEXT.md': '# Context', - }); - - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Research); - - expect(files.state).toBe('# State'); - expect(files.roadmap).toBe('# Roadmap'); - expect(files.context).toBe('# Context'); - expect(files.requirements).toBeUndefined(); - }); - - it('returns state + roadmap + requirements for verify phase', async () => { - await createPlanningDir(projectDir, { - 'STATE.md': '# State', - 'ROADMAP.md': '# Roadmap', - 'REQUIREMENTS.md': '# Requirements', - 'PLAN.md': '# Plan', - 'SUMMARY.md': '# Summary', - }); - - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Verify); - - expect(files.state).toBe('# State'); - expect(files.roadmap).toBe('# Roadmap'); - expect(files.requirements).toBe('# Requirements'); - expect(files.plan).toBe('# Plan'); - expect(files.summary).toBe('# Summary'); - }); - - it('returns state + optional files for discuss phase', async () => { - await createPlanningDir(projectDir, { - 'STATE.md': '# State', - 'ROADMAP.md': '# Roadmap', - }); - - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Discuss); - - expect(files.state).toBe('# State'); - expect(files.roadmap).toBe('# Roadmap'); - expect(files.context).toBeUndefined(); - }); - - it('returns undefined for missing optional files without warning', async () => { - await createPlanningDir(projectDir, { - 'STATE.md': '# State', - 'ROADMAP.md': '# Roadmap', - 'CONTEXT.md': '# Context', - }); - - const logger = makeMockLogger(); - const engine = new ContextEngine(projectDir, logger); - const files = await engine.resolveContextFiles(PhaseType.Plan); - - // research and requirements are optional for plan — no warning - expect(files.research).toBeUndefined(); - expect(files.requirements).toBeUndefined(); - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it('warns for missing required files', async () => { - // Empty .planning dir — STATE.md is required for all phases - await createPlanningDir(projectDir, {}); - - const logger = makeMockLogger(); - const engine = new ContextEngine(projectDir, logger); - await engine.resolveContextFiles(PhaseType.Execute); - - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('STATE.md'), - expect.objectContaining({ phase: PhaseType.Execute }), - ); - }); - - it('handles missing .planning directory gracefully', async () => { - // No .planning dir at all - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Execute); - - expect(files.state).toBeUndefined(); - expect(files.config).toBeUndefined(); - }); - - it('handles empty file content', async () => { - await createPlanningDir(projectDir, { - 'STATE.md': '', - }); - - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Execute); - - // Empty string is still defined — the file exists - expect(files.state).toBe(''); - }); - }); - - describe('context truncation', () => { - it('truncates files exceeding maxContentLength', async () => { - const largeContent = Array.from({ length: 100 }, (_, i) => - `## Section ${i}\n\nFirst paragraph.\n\nLong detail ${'x'.repeat(200)}.` - ).join('\n\n'); - - await createPlanningDir(projectDir, { - 'STATE.md': '# State', - 'ROADMAP.md': '# Roadmap', - 'CONTEXT.md': largeContent, - }); - - const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 500 }); - const files = await engine.resolveContextFiles(PhaseType.Plan); - - // CONTEXT.md should be truncated - expect(files.context!.length).toBeLessThan(largeContent.length); - expect(files.context).toContain('[...'); - }); - - it('does not truncate files below threshold', async () => { - await createPlanningDir(projectDir, { - 'STATE.md': '# State\nproject: test', - 'ROADMAP.md': '# Roadmap\nphase 01', - 'CONTEXT.md': '# Context\nstack: node', - }); - - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Plan); - - expect(files.context).toBe('# Context\nstack: node'); - }); - - it('never truncates STATE.md (not in truncatable list)', async () => { - const largeState = `# State\n\n${'x'.repeat(20000)}`; - await createPlanningDir(projectDir, { - 'STATE.md': largeState, - }); - - const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 100 }); - const files = await engine.resolveContextFiles(PhaseType.Execute); - - expect(files.state).toBe(largeState); - }); - - it('extracts current milestone from ROADMAP.md when state is available', async () => { - const roadmap = `# Roadmap - -## Milestone 1: Setup -### Phase 01 -Setup content. - -## Milestone 2: Build -### Phase 02 -Build content.`; - - await createPlanningDir(projectDir, { - 'STATE.md': 'Current Milestone: Build', - 'ROADMAP.md': roadmap, - 'CONTEXT.md': '# Context', - }); - - const engine = new ContextEngine(projectDir); - const files = await engine.resolveContextFiles(PhaseType.Plan); - - expect(files.roadmap).toContain('## Milestone 2: Build'); - expect(files.roadmap).not.toContain('### Phase 01'); - }); - - it('respects custom truncation options', async () => { - const content = '## Heading\n\nParagraph.\n\nMore.\n' + 'x'.repeat(500); - await createPlanningDir(projectDir, { - 'STATE.md': '# State', - 'ROADMAP.md': '# Roadmap', - 'CONTEXT.md': content, - }); - - // Low threshold forces truncation - const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 50 }); - const files = await engine.resolveContextFiles(PhaseType.Plan); - expect(files.context!.length).toBeLessThan(content.length); - }); - }); - - describe('PHASE_FILE_MANIFEST', () => { - it('covers all phase types', () => { - for (const phase of Object.values(PhaseType)) { - expect(PHASE_FILE_MANIFEST[phase]).toBeDefined(); - expect(PHASE_FILE_MANIFEST[phase].length).toBeGreaterThan(0); - } - }); - - it('execute phase has fewest files', () => { - const executeCount = PHASE_FILE_MANIFEST[PhaseType.Execute].length; - const planCount = PHASE_FILE_MANIFEST[PhaseType.Plan].length; - expect(executeCount).toBeLessThan(planCount); - }); - - it('every spec has required key, filename, and required flag', () => { - for (const specs of Object.values(PHASE_FILE_MANIFEST)) { - for (const spec of specs) { - expect(spec.key).toBeDefined(); - expect(spec.filename).toBeDefined(); - expect(typeof spec.required).toBe('boolean'); - } - } - }); - }); -}); diff --git a/sdk/src/context-engine.ts b/sdk/src/context-engine.ts deleted file mode 100644 index 2d57241b3..000000000 --- a/sdk/src/context-engine.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Context engine — resolves which .planning/ state files exist per phase type. - * - * Different phases need different subsets of context files. The execute phase - * only needs STATE.md + config.json (minimal). Research needs STATE.md + - * ROADMAP.md + CONTEXT.md. Plan needs all files. Verify needs STATE.md + - * ROADMAP.md + REQUIREMENTS.md + PLAN/SUMMARY files. - * - * Context reduction (issue #1614): - * - Large files are truncated to keep prompts cache-friendly - * - ROADMAP.md is narrowed to the current milestone when possible - * - Truncation preserves headings + first paragraph per section - */ - -import { readFile, access } from 'node:fs/promises'; -import { join } from 'node:path'; -import { constants } from 'node:fs'; - -import type { ContextFiles } from './types.js'; -import { PhaseType } from './types.js'; -import type { GSDLogger } from './logger.js'; -import { - truncateMarkdown, - extractCurrentMilestone, - DEFAULT_TRUNCATION_OPTIONS, - type TruncationOptions, -} from './context-truncation.js'; -import { relPlanningPath } from './workstream-utils.js'; - -// ─── File manifest per phase ───────────────────────────────────────────────── - -interface FileSpec { - key: keyof ContextFiles; - filename: string; - required: boolean; -} - -/** - * Define which files each phase needs. Required files emit warnings when missing; - * optional files silently return undefined. - */ -const PHASE_FILE_MANIFEST: Record = { - [PhaseType.Execute]: [ - { key: 'state', filename: 'STATE.md', required: true }, - { key: 'config', filename: 'config.json', required: false }, - ], - [PhaseType.Research]: [ - { key: 'state', filename: 'STATE.md', required: true }, - { key: 'roadmap', filename: 'ROADMAP.md', required: true }, - { key: 'context', filename: 'CONTEXT.md', required: true }, - { key: 'requirements', filename: 'REQUIREMENTS.md', required: false }, - ], - [PhaseType.Plan]: [ - { key: 'state', filename: 'STATE.md', required: true }, - { key: 'roadmap', filename: 'ROADMAP.md', required: true }, - { key: 'context', filename: 'CONTEXT.md', required: true }, - { key: 'research', filename: 'RESEARCH.md', required: false }, - { key: 'requirements', filename: 'REQUIREMENTS.md', required: false }, - ], - [PhaseType.Verify]: [ - { key: 'state', filename: 'STATE.md', required: true }, - { key: 'roadmap', filename: 'ROADMAP.md', required: true }, - { key: 'requirements', filename: 'REQUIREMENTS.md', required: false }, - { key: 'plan', filename: 'PLAN.md', required: false }, - { key: 'summary', filename: 'SUMMARY.md', required: false }, - ], - [PhaseType.Repair]: [ - { key: 'state', filename: 'STATE.md', required: true }, - { key: 'config', filename: 'config.json', required: false }, - { key: 'plan', filename: 'PLAN.md', required: false }, - ], - [PhaseType.Discuss]: [ - { key: 'state', filename: 'STATE.md', required: true }, - { key: 'roadmap', filename: 'ROADMAP.md', required: false }, - { key: 'context', filename: 'CONTEXT.md', required: false }, - ], -}; - -// ─── ContextEngine class ───────────────────────────────────────────────────── - -export class ContextEngine { - private readonly planningDir: string; - private readonly logger?: GSDLogger; - private readonly truncation: TruncationOptions; - - constructor(projectDir: string, logger?: GSDLogger, truncation?: Partial, workstream?: string) { - this.planningDir = join(projectDir, relPlanningPath(workstream)); - this.logger = logger; - this.truncation = { ...DEFAULT_TRUNCATION_OPTIONS, ...truncation }; - } - - /** - * Resolve context files appropriate for the given phase type. - * Reads each file defined in the phase manifest, returning undefined - * for missing optional files and warning for missing required files. - * - * Files exceeding the truncation threshold are reduced to headings + - * first paragraphs. ROADMAP.md is narrowed to the current milestone. - */ - async resolveContextFiles(phaseType: PhaseType): Promise { - const manifest = PHASE_FILE_MANIFEST[phaseType]; - const result: ContextFiles = {}; - - for (const spec of manifest) { - const filePath = join(this.planningDir, spec.filename); - const content = await this.readFileIfExists(filePath); - - if (content !== undefined) { - result[spec.key] = content; - } else if (spec.required) { - this.logger?.warn(`Required context file missing for ${phaseType} phase: ${spec.filename}`, { - phase: phaseType, - file: spec.filename, - path: filePath, - }); - } - } - - // Apply context reduction: milestone extraction then truncation - if (result.roadmap && result.state) { - const before = result.roadmap.length; - result.roadmap = extractCurrentMilestone(result.roadmap, result.state); - if (result.roadmap.length < before) { - this.logger?.debug?.('ROADMAP.md narrowed to current milestone', { - before, - after: result.roadmap.length, - }); - } - } - - // Truncate oversized files (skip config.json — structured data, not markdown) - const truncatable: Array<{ key: keyof ContextFiles; filename: string }> = [ - { key: 'roadmap', filename: 'ROADMAP.md' }, - { key: 'context', filename: 'CONTEXT.md' }, - { key: 'research', filename: 'RESEARCH.md' }, - { key: 'requirements', filename: 'REQUIREMENTS.md' }, - { key: 'plan', filename: 'PLAN.md' }, - { key: 'summary', filename: 'SUMMARY.md' }, - ]; - - for (const { key, filename } of truncatable) { - const raw = result[key]; - if (raw && raw.length > this.truncation.maxContentLength) { - const before = raw.length; - result[key] = truncateMarkdown(raw, filename, this.truncation); - this.logger?.debug?.(`${filename} truncated`, { - before, - after: result[key]!.length, - }); - } - } - - return result; - } - - /** - * Check if a file exists and read it. Returns undefined if not found. - */ - private async readFileIfExists(filePath: string): Promise { - try { - await access(filePath, constants.R_OK); - return await readFile(filePath, 'utf-8'); - } catch { - return undefined; - } - } -} - -export { PHASE_FILE_MANIFEST }; -export type { FileSpec }; diff --git a/sdk/src/context-truncation.test.ts b/sdk/src/context-truncation.test.ts deleted file mode 100644 index 85b8544cb..000000000 --- a/sdk/src/context-truncation.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - truncateMarkdown, - extractCurrentMilestone, - DEFAULT_TRUNCATION_OPTIONS, -} from './context-truncation.js'; - -// ─── truncateMarkdown ─────────────────────────────────────────────────────── - -describe('truncateMarkdown', () => { - it('returns content unchanged when below threshold', () => { - const content = '# Title\n\nShort content.'; - const result = truncateMarkdown(content, 'TEST.md'); - expect(result).toBe(content); - }); - - it('truncates content above threshold, keeping headings and first paragraphs', () => { - const sections = []; - for (let i = 0; i < 20; i++) { - sections.push(`## Section ${i}\n\nFirst paragraph of section ${i}.\n\nSecond paragraph with lots of detail.\nMore detail here.\nEven more detail.`); - } - const content = `# Title\n\n${sections.join('\n\n')}`; - const result = truncateMarkdown(content, 'BIG.md', { maxContentLength: 100 }); - - // Headings preserved - expect(result).toContain('# Title'); - expect(result).toContain('## Section 0'); - expect(result).toContain('## Section 19'); - - // First paragraphs preserved - expect(result).toContain('First paragraph of section 0.'); - expect(result).toContain('First paragraph of section 19.'); - - // Second paragraphs omitted - expect(result).not.toContain('Second paragraph'); - expect(result).not.toContain('More detail here.'); - - // Truncation markers present - expect(result).toContain('[...'); - expect(result).toContain('lines omitted]'); - expect(result).toContain('[Truncated: read .planning/BIG.md for full content]'); - }); - - it('preserves YAML frontmatter entirely', () => { - const content = `---\nphase: "01"\nstatus: active\n---\n\n# Title\n\nParagraph 1.\n\nParagraph 2.\n${'x'.repeat(10000)}`; - const result = truncateMarkdown(content, 'STATE.md', { maxContentLength: 100 }); - - expect(result).toContain('---\nphase: "01"\nstatus: active\n---'); - expect(result).toContain('# Title'); - expect(result).toContain('Paragraph 1.'); - }); - - it('is smaller than original when truncated', () => { - const longContent = Array.from({ length: 200 }, (_, i) => - `## Section ${i}\n\nFirst paragraph.\n\nLong detail paragraph ${'x'.repeat(100)}.` - ).join('\n\n'); - - const result = truncateMarkdown(longContent, 'HUGE.md', { maxContentLength: 100 }); - expect(result.length).toBeLessThan(longContent.length); - }); - - it('handles content with no headings', () => { - const content = `First line.\n\nSecond paragraph.\n\nThird paragraph.\n${'x'.repeat(10000)}`; - const result = truncateMarkdown(content, 'FLAT.md', { maxContentLength: 100 }); - - // Should still truncate — first paragraph kept - expect(result).toContain('First line.'); - expect(result.length).toBeLessThan(content.length); - }); - - it('default threshold is 8192 characters', () => { - expect(DEFAULT_TRUNCATION_OPTIONS.maxContentLength).toBe(8192); - }); -}); - -// ─── extractCurrentMilestone ──────────────────────────────────────────────── - -describe('extractCurrentMilestone', () => { - const makeRoadmap = () => `# Project Roadmap - -## Milestone 1: Foundation -### Phase 01: Setup -Requirements for setup. -### Phase 02: Core -Requirements for core. - -## Milestone 2: Features -### Phase 03: Auth -Requirements for auth. -### Phase 04: API -Requirements for API. - -## Milestone 3: Polish -### Phase 05: UI -Requirements for UI.`; - - it('returns full roadmap when no state provided', () => { - const roadmap = makeRoadmap(); - expect(extractCurrentMilestone(roadmap)).toBe(roadmap); - }); - - it('returns full roadmap when milestone not found in state', () => { - const roadmap = makeRoadmap(); - const state = '# State\nstatus: active'; - expect(extractCurrentMilestone(roadmap, state)).toBe(roadmap); - }); - - it('extracts current milestone section by name', () => { - const roadmap = makeRoadmap(); - const state = 'Current Milestone: Features'; - const result = extractCurrentMilestone(roadmap, state); - - expect(result).toContain('## Milestone 2: Features'); - expect(result).toContain('### Phase 03: Auth'); - expect(result).toContain('### Phase 04: API'); - - // Other milestones omitted - expect(result).not.toContain('### Phase 01: Setup'); - expect(result).not.toContain('### Phase 05: UI'); - expect(result).toContain('other milestone(s) omitted'); - }); - - it('matches milestone name case-insensitively', () => { - const roadmap = makeRoadmap(); - const state = 'current milestone: features'; - const result = extractCurrentMilestone(roadmap, state); - - expect(result).toContain('## Milestone 2: Features'); - expect(result).not.toContain('### Phase 01: Setup'); - }); - - it('matches milestone from "milestone:" field in state', () => { - const roadmap = makeRoadmap(); - const state = '# State\nmilestone: Foundation\nphase: 01'; - const result = extractCurrentMilestone(roadmap, state); - - expect(result).toContain('## Milestone 1: Foundation'); - expect(result).toContain('### Phase 01: Setup'); - expect(result).not.toContain('### Phase 03: Auth'); - }); - - it('matches milestone from Current Position block', () => { - const roadmap = makeRoadmap(); - const state = `# State - -## Current Position -milestone: Polish -phase: 05`; - const result = extractCurrentMilestone(roadmap, state); - - expect(result).toContain('## Milestone 3: Polish'); - expect(result).toContain('### Phase 05: UI'); - expect(result).not.toContain('### Phase 01: Setup'); - }); - - it('preserves roadmap title in output', () => { - const roadmap = makeRoadmap(); - const state = 'Current Milestone: Features'; - const result = extractCurrentMilestone(roadmap, state); - - expect(result).toContain('# Project Roadmap'); - }); -}); diff --git a/sdk/src/context-truncation.ts b/sdk/src/context-truncation.ts deleted file mode 100644 index abd86ec29..000000000 --- a/sdk/src/context-truncation.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Context truncation — reduces large .planning/ files to cache-friendly sizes. - * - * Two strategies: - * 1. Markdown-aware truncation: keeps headings + first paragraph per section, - * replaces the rest with a pointer to the full file. - * 2. Milestone extraction: pulls only the current milestone from ROADMAP.md. - * - * All functions are pure — no I/O, no side effects. - */ - -// ─── Types ────────────────────────────────────────────────────────────────── - -export interface TruncationOptions { - /** Max content length in characters before truncation kicks in. Default: 8192 */ - maxContentLength: number; -} - -export const DEFAULT_TRUNCATION_OPTIONS: TruncationOptions = { - maxContentLength: 8192, -}; - -// ─── Markdown-aware truncation ────────────────────────────────────────────── - -/** - * Truncate markdown content while preserving structure. - * - * Strategy: keep YAML frontmatter, all headings, and the first paragraph under - * each heading. Collapse everything else with a line count summary. - * - * Returns the original content unchanged if below maxContentLength. - */ -export function truncateMarkdown( - content: string, - filename: string, - options: TruncationOptions = DEFAULT_TRUNCATION_OPTIONS, -): string { - if (content.length <= options.maxContentLength) return content; - - const lines = content.split('\n'); - const kept: string[] = []; - let inFrontmatter = false; - let frontmatterDone = false; - let currentSectionLines = 0; - let paragraphKept = false; - let omittedLines = 0; - let inParagraph = false; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Handle YAML frontmatter (preserve entirely) - if (i === 0 && line.trim() === '---') { - inFrontmatter = true; - kept.push(line); - continue; - } - if (inFrontmatter) { - kept.push(line); - if (line.trim() === '---') { - inFrontmatter = false; - frontmatterDone = true; - } - continue; - } - - // Heading — always keep, reset paragraph tracking - if (/^#{1,6}\s/.test(line)) { - if (omittedLines > 0) { - kept.push(`[... ${omittedLines} lines omitted]`); - omittedLines = 0; - } - kept.push(line); - currentSectionLines = 0; - paragraphKept = false; - inParagraph = false; - continue; - } - - // Empty line — paragraph boundary - if (line.trim() === '') { - if (inParagraph && !paragraphKept) { - // End of first paragraph — mark it kept - paragraphKept = true; - } - if (!paragraphKept || currentSectionLines === 0) { - kept.push(line); - } else { - omittedLines++; - } - inParagraph = false; - continue; - } - - // Content line - currentSectionLines++; - if (!paragraphKept) { - // Still in the first paragraph — keep it - kept.push(line); - inParagraph = true; - } else { - omittedLines++; - } - } - - if (omittedLines > 0) { - kept.push(`[... ${omittedLines} lines omitted]`); - } - - const totalOmitted = lines.length - kept.length; - if (totalOmitted > 0) { - kept.push(''); - kept.push(`[Truncated: read .planning/${filename} for full content]`); - } - - return kept.join('\n'); -} - -// ─── Milestone extraction ─────────────────────────────────────────────────── - -/** - * Extract the current milestone section from a ROADMAP.md. - * - * Parses STATE.md to find the current milestone name, then extracts only - * that milestone's section from the roadmap. Falls back to full content - * if the milestone can't be identified or found. - */ -export function extractCurrentMilestone( - roadmapContent: string, - stateContent?: string, -): string { - if (!stateContent) return roadmapContent; - - // Find current milestone from STATE.md - // Patterns: "Current Milestone: X", "milestone: X", "## Current Position" block - const milestonePatterns = [ - /current\s*milestone\s*:\s*(.+)/i, - /^milestone\s*:\s*(.+)/im, - /##\s*current\s*position[\s\S]*?milestone\s*:\s*(.+)/i, - ]; - - let milestoneName: string | undefined; - for (const pattern of milestonePatterns) { - const match = stateContent.match(pattern); - if (match) { - milestoneName = match[1].trim(); - break; - } - } - - if (!milestoneName) return roadmapContent; - - // Find the milestone section in roadmap - // Look for heading containing the milestone name - const lines = roadmapContent.split('\n'); - let sectionStart = -1; - let sectionEnd = lines.length; - let sectionHeadingLevel = 0; - - for (let i = 0; i < lines.length; i++) { - const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)/); - if (!headingMatch) continue; - - const level = headingMatch[1].length; - const title = headingMatch[2]; - - if (sectionStart === -1) { - // Looking for the milestone heading - if (title.toLowerCase().includes(milestoneName.toLowerCase())) { - sectionStart = i; - sectionHeadingLevel = level; - } - } else { - // Found start — look for next heading at same or higher level - if (level <= sectionHeadingLevel) { - sectionEnd = i; - break; - } - } - } - - if (sectionStart === -1) return roadmapContent; - - // Extract preamble (everything before first milestone heading at the same level) - const preamble: string[] = []; - for (let i = 0; i < lines.length; i++) { - const headingMatch = lines[i].match(/^(#{1,6})\s/); - if (headingMatch && headingMatch[1].length === sectionHeadingLevel && i !== sectionStart) { - // Hit another milestone-level heading before our section - if (i < sectionStart) { - break; // preamble ends at first milestone heading - } - } - if (i < sectionStart) { - // Keep top-level title and intro - if (i === 0 || lines[i].match(/^#\s/) || !lines[i].match(/^#{1,6}\s/)) { - preamble.push(lines[i]); - } - } - } - - const milestoneSection = lines.slice(sectionStart, sectionEnd).join('\n'); - const otherMilestones = countOtherMilestones(lines, sectionHeadingLevel, sectionStart); - - const result = [ - ...preamble, - '', - milestoneSection, - ]; - - if (otherMilestones > 0) { - result.push(''); - result.push(`[${otherMilestones} other milestone(s) omitted — read .planning/ROADMAP.md for full roadmap]`); - } - - return result.join('\n').trim(); -} - -function countOtherMilestones( - lines: string[], - headingLevel: number, - excludeIndex: number, -): number { - let count = 0; - for (let i = 0; i < lines.length; i++) { - if (i === excludeIndex) continue; - const match = lines[i].match(/^(#{1,6})\s/); - if (match && match[1].length === headingLevel) { - count++; - } - } - return count; -} diff --git a/sdk/src/dispatch/hub.ts b/sdk/src/dispatch/hub.ts deleted file mode 100644 index bebffbd99..000000000 --- a/sdk/src/dispatch/hub.ts +++ /dev/null @@ -1,246 +0,0 @@ -import type { QueryRegistry } from '../query/registry.js'; -import { extractField } from '../query/registry.js'; -import { normalizeQueryCommand } from '../query/query-command-resolution-strategy.js'; -import { runCjsFallbackDispatch } from '../query/query-fallback-executor.js'; -import type { QueryDispatchError, QueryDispatchResult } from '../query/query-dispatch-contract.js'; -import type { QueryResult } from '../query/utils.js'; -import type { QueryNativeDispatchAdapter } from '../query/query-native-dispatch-adapter.js'; -import type { CommandTopology, CommandTopologyMatch } from '../query/command-topology.js'; -import { unknownCommandError, validationError, fallbackDispatchErrorFromSignal, nativeDispatchErrorFromSignal } from '../query/query-error-taxonomy.js'; -import { canUseCjsFallback } from '../query/query-fallback-policy.js'; -import { toFailureSignal } from '../query-failure-classification.js'; - -export interface QueryDispatchDeps { - registry: QueryRegistry; - projectDir: string; - ws?: string; - cjsFallbackEnabled: boolean; - resolveGsdToolsPath: (projectDir: string) => string; - /** @deprecated use topology */ - dispatchNative?: (cmd: string, args: string[]) => Promise; - /** @deprecated use topology */ - nativeAdapter?: QueryNativeDispatchAdapter; - topology: CommandTopology; -} - -export type DispatchMode = 'native' | 'cjs' | 'error'; - -export interface DispatchPlan { - mode: DispatchMode; - normalized: { command: string; args: string[]; tokens: string[] }; - matched: CommandTopologyMatch | null; - noMatchMessage?: string; - noMatchNormalized?: string; - noMatchAttempted?: string[]; - noMatchHints?: string[]; -} - -export type DispatchSuccessFormat = 'json' | 'text' | undefined; - -export interface DispatchInputValidationResult { - queryArgs: string[]; - pickField?: string; - error?: QueryDispatchResult; -} - -export function dispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult { - return { - ok: false, - error, - stderr, - exit_code: error.code, - }; -} - -export function dispatchSuccess(stdout: string, stderr: string[] = []): QueryDispatchResult { - return { - ok: true, - stdout, - stderr, - exit_code: 0, - }; -} - -export function toDispatchFailure(error: QueryDispatchError, stderr: string[] = []): QueryDispatchResult { - return dispatchFailure(error, stderr); -} - -export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - return nativeDispatchErrorFromSignal(toFailureSignal(error), command, args); -} - -export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - return fallbackDispatchErrorFromSignal(toFailureSignal(error), command, args); -} - -export function formatPick(data: unknown, pickField?: string): unknown { - if (!pickField) return data; - return extractField(data, pickField); -} - -export function formatSuccess(data: unknown, format: DispatchSuccessFormat, pickField?: string): string { - if (format === 'text' && typeof data === 'string') { - if (pickField) { - throw new Error('--pick is not supported for text output'); - } - return data.endsWith('\n') ? data : `${data}\n`; - } - const output = formatPick(data, pickField); - if (pickField && typeof output === 'string') { - return output.endsWith('\n') ? output : `${output}\n`; - } - return `${JSON.stringify(output === undefined ? null : output, null, 2)}\n`; -} - -export function validateQueryDispatchInput(queryArgv: string[]): DispatchInputValidationResult { - const queryArgs = [...queryArgv]; - const pickIdx = queryArgs.indexOf('--pick'); - if (pickIdx !== -1) { - if (pickIdx + 1 >= queryArgs.length) { - return { - queryArgs, - error: dispatchFailure(validationError({ - message: 'Error: --pick requires a field name', - details: { field: '--pick', reason: 'missing_value' }, - })), - }; - } - const pickField = queryArgs[pickIdx + 1]; - queryArgs.splice(pickIdx, 2); - if (queryArgs.length === 0 || !queryArgs[0]) { - return { - queryArgs, - error: dispatchFailure(validationError({ - message: 'Error: "gsd-sdk query" requires a command', - details: { reason: 'missing_command' }, - })), - }; - } - return { queryArgs, pickField }; - } - - if (queryArgs.length === 0 || !queryArgs[0]) { - return { - queryArgs, - error: dispatchFailure(validationError({ - message: 'Error: "gsd-sdk query" requires a command', - details: { reason: 'missing_command' }, - })), - }; - } - - return { queryArgs }; -} - -export function planQueryDispatch( - queryArgv: string[], - topology: CommandTopology, - cjsFallbackEnabled: boolean, -): DispatchPlan { - const queryCommand = queryArgv[0]; - if (!queryCommand) { - return { mode: 'error', normalized: { command: '', args: [], tokens: [] }, matched: null }; - } - - const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1)); - const normalizedTokens = [normCmd, ...normArgs]; - const resolved = topology.resolve(queryArgv, !cjsFallbackEnabled); - - if (resolved.kind === 'match') { - return { mode: 'native', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: resolved }; - } - - if (cjsFallbackEnabled) { - return { mode: 'cjs', normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, matched: null }; - } - - return { - mode: 'error', - normalized: { command: normCmd, args: normArgs, tokens: normalizedTokens }, - matched: null, - noMatchMessage: resolved.message, - noMatchNormalized: resolved.normalized, - noMatchAttempted: resolved.attempted, - noMatchHints: resolved.hints, - }; -} - -function fail(error: ReturnType | ReturnType, stderr: string[] = []): QueryDispatchResult { - return toDispatchFailure(error, stderr); -} - -export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: string[]): Promise { - const validated = validateQueryDispatchInput(queryArgv); - if (validated.error) return validated.error; - - const { queryArgs, pickField } = validated; - - const plan = planQueryDispatch(queryArgs, deps.topology, deps.cjsFallbackEnabled); - const normCmd = plan.normalized.command; - const normArgs = plan.normalized.args; - - if (!normCmd || !String(normCmd).trim()) { - return fail(validationError({ message: 'Error: "gsd-sdk query" requires a command', details: { reason: 'empty_normalized_command' } })); - } - - if (plan.mode === 'error') { - return fail(unknownCommandError({ - message: plan.noMatchMessage ?? `Error: Unknown command: "${queryArgs[0] ?? normCmd}"`, - normalized: plan.noMatchNormalized ?? [normCmd, ...normArgs].join(' ').trim(), - attempted: plan.noMatchAttempted ?? [], - hints: plan.noMatchHints ?? [], - })); - } - - if (plan.mode === 'cjs') { - if (canUseCjsFallback({ cjsFallbackEnabled: deps.cjsFallbackEnabled })) { - try { - const gsdPath = deps.resolveGsdToolsPath(deps.projectDir); - return await runCjsFallbackDispatch({ - projectDir: deps.projectDir, - gsdToolsPath: gsdPath, - normCmd, - normArgs, - ws: deps.ws, - pickField, - }); - } catch (e) { - return toDispatchFailure(mapFallbackDispatchError(e, normCmd, normArgs)); - } - } - return toDispatchFailure(mapFallbackDispatchError(new Error('CJS fallback denied by policy'), normCmd, normArgs)); - } - - const matched = plan.matched; - if (!matched) { - return toDispatchFailure(mapFallbackDispatchError(new Error('No native match in dispatch plan'), normCmd, normArgs)); - } - - // #3259: guard — if the invocation contains --help / -h AND the matched - // handler is a mutating command (mutation: true in the command manifest), - // short-circuit to a non-mutating stub. Mutating handlers are not help-aware - // by default (fail-closed). This prevents e.g. `milestone.complete --help` - // from writing milestone artifacts to disk. - const helpFlagPresent = matched.args.some((a) => a === '--help' || a === '-h'); - if (helpFlagPresent && matched.mutation) { - return dispatchSuccess( - formatSuccess( - { help: `Usage: gsd-sdk query ${matched.canonical} [args...]` }, - undefined, - ), - ); - } - - const dispatchNative = deps.nativeAdapter - ? (cmd: string, args: string[]) => deps.nativeAdapter!.dispatch(cmd, args) - : deps.dispatchNative; - - try { - const result = dispatchNative - ? await dispatchNative(matched.canonical, matched.args) - : await matched.adapter(matched.args, deps.projectDir, deps.ws); - return dispatchSuccess(formatSuccess(result.data, result.format, pickField)); - } catch (e) { - return toDispatchFailure(mapNativeDispatchError(e, matched.canonical, matched.args)); - } -} diff --git a/sdk/src/e2e.integration.test.ts b/sdk/src/e2e.integration.test.ts deleted file mode 100644 index 7a288790f..000000000 --- a/sdk/src/e2e.integration.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * E2E integration test — proves full SDK pipeline: - * parse → prompt → query() → SUMMARY.md - * - * Requires Claude Code CLI (`claude`) installed and authenticated, plus - * opt-in env `GSD_ENABLE_E2E=1`. Skips if env unset or CLI unavailable. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { execSync } from 'node:child_process'; -import { mkdtemp, cp, rm, readFile, readdir } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; - -import { GSD, parsePlanFile, GSDEventType } from './index.js'; -import type { GSDEvent } from './index.js'; - -// ─── CLI availability check ───────────────────────────────────────────────── - -let cliAvailable = false; -try { - execSync('which claude', { stdio: 'ignore' }); - cliAvailable = true; -} catch { - cliAvailable = false; -} - -const e2eEnabled = process.env.GSD_ENABLE_E2E === '1'; -const canRunE2E = cliAvailable && e2eEnabled; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const fixturesDir = join(__dirname, '..', 'test-fixtures'); - -// ─── Test suite ────────────────────────────────────────────────────────────── - -describe.skipIf(!canRunE2E)('E2E: Single plan execution', () => { - let tmpDir: string; - - beforeAll(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-e2e-')); - // Copy fixture files to temp directory - await cp(fixturesDir, tmpDir, { recursive: true }); - }); - - afterAll(async () => { - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); - - it('executes a single plan and returns a valid PlanResult', async () => { - const gsd = new GSD({ projectDir: tmpDir, maxBudgetUsd: 1.0, maxTurns: 20 }); - const result = await gsd.executePlan('sample-plan.md'); - - expect(result.success).toBe(true); - expect(typeof result.sessionId).toBe('string'); - expect(result.sessionId.length).toBeGreaterThan(0); - expect(result.totalCostUsd).toBeGreaterThanOrEqual(0); - expect(result.durationMs).toBeGreaterThan(0); - expect(result.numTurns).toBeGreaterThan(0); - - // Verify the plan's task was executed — output.txt should exist - const outputPath = join(tmpDir, 'output.txt'); - const outputContent = await readFile(outputPath, 'utf-8'); - expect(outputContent).toContain('hello from gsd-sdk'); - }, 120_000); // 2 minute timeout for real CLI execution - - it('proves session isolation (R014) — different session IDs for sequential runs', async () => { - // Create a second temp dir for isolation proof - const tmpDir2 = await mkdtemp(join(tmpdir(), 'gsd-sdk-e2e-')); - await cp(fixturesDir, tmpDir2, { recursive: true }); - - try { - const gsd1 = new GSD({ projectDir: tmpDir, maxBudgetUsd: 1.0, maxTurns: 20 }); - const gsd2 = new GSD({ projectDir: tmpDir2, maxBudgetUsd: 1.0, maxTurns: 20 }); - - const result1 = await gsd1.executePlan('sample-plan.md'); - const result2 = await gsd2.executePlan('sample-plan.md'); - - // Different sessions must have different session IDs - expect(result1.sessionId).not.toBe(result2.sessionId); - - // Both should track cost independently - expect(result1.totalCostUsd).toBeGreaterThanOrEqual(0); - expect(result2.totalCostUsd).toBeGreaterThanOrEqual(0); - } finally { - await rm(tmpDir2, { recursive: true, force: true }); - } - }, 240_000); // 4 minute timeout — two sequential runs -}); - -describe('E2E: Fixture validation (no CLI required)', () => { - it('fixture PLAN.md is valid and parseable', async () => { - const plan = await parsePlanFile(join(fixturesDir, 'sample-plan.md')); - - expect(plan.frontmatter.phase).toBe('01-test'); - expect(plan.frontmatter.plan).toBe('01'); - expect(plan.frontmatter.type).toBe('execute'); - expect(plan.frontmatter.wave).toBe(1); - expect(plan.frontmatter.depends_on).toEqual([]); - expect(plan.frontmatter.files_modified).toEqual(['output.txt']); - expect(plan.frontmatter.autonomous).toBe(true); - expect(plan.frontmatter.requirements).toEqual(['TEST-01']); - expect(plan.frontmatter.must_haves.truths).toEqual(['output.txt exists with expected content']); - - expect(plan.objective).toContain('simple output file'); - expect(plan.tasks).toHaveLength(1); - expect(plan.tasks[0].name).toBe('Create output file'); - expect(plan.tasks[0].type).toBe('auto'); - expect(plan.tasks[0].verify).toBe('test -f output.txt'); - }); -}); - -describe.skipIf(!canRunE2E)('E2E: Event stream during plan execution (R007)', () => { - let tmpDir: string; - - beforeAll(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-e2e-stream-')); - await cp(fixturesDir, tmpDir, { recursive: true }); - }); - - afterAll(async () => { - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); - - it('event stream emits events during plan execution (R007)', async () => { - const events: GSDEvent[] = []; - const gsd = new GSD({ projectDir: tmpDir, maxBudgetUsd: 1.0, maxTurns: 20 }); - - // Subscribe to all events - gsd.onEvent((event) => { - events.push(event); - }); - - const result = await gsd.executePlan('sample-plan.md'); - expect(result.success).toBe(true); - - // (a) At least one session_init event received - const initEvents = events.filter(e => e.type === GSDEventType.SessionInit); - expect(initEvents.length).toBeGreaterThanOrEqual(1); - - // (b) At least one tool_call event received - const toolCallEvents = events.filter(e => e.type === GSDEventType.ToolCall); - expect(toolCallEvents.length).toBeGreaterThanOrEqual(1); - - // (c) Exactly one session_complete event with cost >= 0 - const completeEvents = events.filter(e => e.type === GSDEventType.SessionComplete); - expect(completeEvents).toHaveLength(1); - const completeEvent = completeEvents[0]!; - if (completeEvent.type === GSDEventType.SessionComplete) { - expect(completeEvent.totalCostUsd).toBeGreaterThanOrEqual(0); - } - - // (d) Events arrived in order: session_init before tool_call before session_complete - const initIdx = events.findIndex(e => e.type === GSDEventType.SessionInit); - const toolCallIdx = events.findIndex(e => e.type === GSDEventType.ToolCall); - const completeIdx = events.findIndex(e => e.type === GSDEventType.SessionComplete); - expect(initIdx).toBeLessThan(toolCallIdx); - expect(toolCallIdx).toBeLessThan(completeIdx); - - // Bonus: at least one cost_update event was emitted - const costEvents = events.filter(e => e.type === GSDEventType.CostUpdate); - expect(costEvents.length).toBeGreaterThanOrEqual(1); - }, 120_000); -}); - -describe('E2E: Error handling', () => { - it('returns failure for nonexistent plan path', async () => { - const tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-e2e-err-')); - - try { - const gsd = new GSD({ projectDir: tmpDir }); - await expect(gsd.executePlan('nonexistent-plan.md')).rejects.toThrow(); - } finally { - await rm(tmpDir, { recursive: true, force: true }); - } - }); -}); diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts deleted file mode 100644 index e5bbb4f67..000000000 --- a/sdk/src/errors.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './errors/index.js'; diff --git a/sdk/src/errors/index.ts b/sdk/src/errors/index.ts deleted file mode 100644 index caa17e0be..000000000 --- a/sdk/src/errors/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Error classification system for the GSD SDK. - * - * Provides a taxonomy of error types with semantic exit codes, - * enabling CLI consumers and agents to distinguish between - * validation failures, execution errors, blocked states, and - * interruptions. - * - * @example - * ```typescript - * import { GSDError, ErrorClassification, exitCodeFor } from './errors.js'; - * - * throw new GSDError('missing required arg', ErrorClassification.Validation); - * // CLI catch handler: process.exitCode = exitCodeFor(err.classification); // 10 - * ``` - */ - -// ─── Error Classification ─────────────────────────────────────────────────── - -/** Classifies SDK errors into semantic categories for exit code mapping. */ -export enum ErrorClassification { - /** Bad input, missing args, schema violations. Exit code 10. */ - Validation = 'validation', - - /** Runtime failure, file I/O, parse errors. Exit code 1. */ - Execution = 'execution', - - /** Dependency missing, phase not found. Exit code 11. */ - Blocked = 'blocked', - - /** Timeout, signal, user cancel. Exit code 1. */ - Interruption = 'interruption', -} - -// ─── GSDError ─────────────────────────────────────────────────────────────── - -/** - * Base error class for the GSD SDK with classification support. - * - * @param message - Human-readable error description - * @param classification - Error category for exit code mapping - */ -export class GSDError extends Error { - readonly name = 'GSDError'; - readonly classification: ErrorClassification; - - constructor(message: string, classification: ErrorClassification) { - super(message); - this.classification = classification; - } -} - -// ─── Exit code mapping ────────────────────────────────────────────────────── - -/** - * Maps an error classification to a semantic exit code. - * - * @param classification - The error classification to map - * @returns Numeric exit code: 10 (validation), 11 (blocked), 1 (execution/interruption) - */ -export function exitCodeFor(classification: ErrorClassification): number { - switch (classification) { - case ErrorClassification.Validation: - return 10; - case ErrorClassification.Blocked: - return 11; - case ErrorClassification.Execution: - case ErrorClassification.Interruption: - default: - return 1; - } -} diff --git a/sdk/src/event-stream.test.ts b/sdk/src/event-stream.test.ts deleted file mode 100644 index 007a664a0..000000000 --- a/sdk/src/event-stream.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { GSDEventStream } from './event-stream.js'; -import { - GSDEventType, - PhaseType, - type GSDEvent, - type GSDSessionInitEvent, - type GSDSessionCompleteEvent, - type GSDSessionErrorEvent, - type GSDAssistantTextEvent, - type GSDToolCallEvent, - type GSDToolProgressEvent, - type GSDToolUseSummaryEvent, - type GSDTaskStartedEvent, - type GSDTaskProgressEvent, - type GSDTaskNotificationEvent, - type GSDAPIRetryEvent, - type GSDRateLimitEvent, - type GSDStatusChangeEvent, - type GSDCompactBoundaryEvent, - type GSDStreamEvent, - type GSDCostUpdateEvent, - type TransportHandler, -} from './types.js'; -import type { - SDKMessage, - SDKSystemMessage, - SDKAssistantMessage, - SDKResultSuccess, - SDKResultError, - SDKToolProgressMessage, - SDKToolUseSummaryMessage, - SDKTaskStartedMessage, - SDKTaskProgressMessage, - SDKTaskNotificationMessage, - SDKAPIRetryMessage, - SDKRateLimitEvent, - SDKStatusMessage, - SDKCompactBoundaryMessage, - SDKPartialAssistantMessage, -} from '@anthropic-ai/claude-agent-sdk'; -import type { UUID } from 'crypto'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -const TEST_UUID = '00000000-0000-0000-0000-000000000000' as UUID; -const TEST_SESSION = 'test-session-1'; - -function makeSystemInit(): SDKSystemMessage { - return { - type: 'system', - subtype: 'init', - agents: [], - apiKeySource: 'user', - betas: [], - claude_code_version: '1.0.0', - cwd: '/test', - tools: ['Read', 'Write', 'Bash'], - mcp_servers: [], - model: 'claude-sonnet-4-6', - permissionMode: 'bypassPermissions', - slash_commands: [], - output_style: 'text', - skills: [], - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKSystemMessage; -} - -function makeAssistantMsg(content: Array<{ type: string; [key: string]: unknown }>): SDKAssistantMessage { - return { - type: 'assistant', - message: { - content, - id: 'msg-1', - type: 'message', - role: 'assistant', - model: 'claude-sonnet-4-6', - stop_reason: 'end_turn', - stop_sequence: null, - usage: { input_tokens: 100, output_tokens: 50 }, - } as unknown as SDKAssistantMessage['message'], - parent_tool_use_id: null, - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKAssistantMessage; -} - -function makeResultSuccess(costUsd = 0.05): SDKResultSuccess { - return { - type: 'result', - subtype: 'success', - duration_ms: 5000, - duration_api_ms: 4000, - is_error: false, - num_turns: 3, - result: 'Task completed successfully', - stop_reason: 'end_turn', - total_cost_usd: costUsd, - usage: { input_tokens: 1000, output_tokens: 500, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - modelUsage: {}, - permission_denials: [], - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKResultSuccess; -} - -function makeResultError(): SDKResultError { - return { - type: 'result', - subtype: 'error_max_turns', - duration_ms: 10000, - duration_api_ms: 8000, - is_error: true, - num_turns: 50, - stop_reason: null, - total_cost_usd: 2.50, - usage: { input_tokens: 5000, output_tokens: 2000, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - modelUsage: {}, - permission_denials: [], - errors: ['Max turns exceeded'], - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKResultError; -} - -function makeToolProgress(): SDKToolProgressMessage { - return { - type: 'tool_progress', - tool_use_id: 'tu-1', - tool_name: 'Bash', - parent_tool_use_id: null, - elapsed_time_seconds: 5.2, - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKToolProgressMessage; -} - -function makeToolUseSummary(): SDKToolUseSummaryMessage { - return { - type: 'tool_use_summary', - summary: 'Ran 3 bash commands', - preceding_tool_use_ids: ['tu-1', 'tu-2', 'tu-3'], - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKToolUseSummaryMessage; -} - -function makeTaskStarted(): SDKTaskStartedMessage { - return { - type: 'system', - subtype: 'task_started', - task_id: 'task-1', - description: 'Running test suite', - task_type: 'local_workflow', - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKTaskStartedMessage; -} - -function makeTaskProgress(): SDKTaskProgressMessage { - return { - type: 'system', - subtype: 'task_progress', - task_id: 'task-1', - description: 'Running tests', - usage: { total_tokens: 500, tool_uses: 3, duration_ms: 2000 }, - last_tool_name: 'Bash', - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKTaskProgressMessage; -} - -function makeTaskNotification(): SDKTaskNotificationMessage { - return { - type: 'system', - subtype: 'task_notification', - task_id: 'task-1', - status: 'completed', - output_file: '/tmp/output.txt', - summary: 'All tests passed', - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKTaskNotificationMessage; -} - -function makeAPIRetry(): SDKAPIRetryMessage { - return { - type: 'system', - subtype: 'api_retry', - attempt: 2, - max_retries: 5, - retry_delay_ms: 1000, - error_status: 529, - error: 'server_error', - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKAPIRetryMessage; -} - -function makeRateLimitEvent(): SDKRateLimitEvent { - return { - type: 'rate_limit_event', - rate_limit_info: { - status: 'allowed_warning', - resetsAt: Date.now() + 60000, - utilization: 0.85, - }, - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKRateLimitEvent; -} - -function makeStatusMessage(): SDKStatusMessage { - return { - type: 'system', - subtype: 'status', - status: 'compacting', - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKStatusMessage; -} - -function makeCompactBoundary(): SDKCompactBoundaryMessage { - return { - type: 'system', - subtype: 'compact_boundary', - compact_metadata: { - trigger: 'auto', - pre_tokens: 95000, - }, - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKCompactBoundaryMessage; -} - -// ─── SDKMessage → GSDEvent mapping tests ───────────────────────────────────── - -describe('GSDEventStream', () => { - let stream: GSDEventStream; - - beforeEach(() => { - stream = new GSDEventStream(); - }); - - describe('mapSDKMessage', () => { - it('maps SDKSystemMessage init → SessionInit', () => { - const event = stream.mapSDKMessage(makeSystemInit()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.SessionInit); - - const init = event as GSDSessionInitEvent; - expect(init.model).toBe('claude-sonnet-4-6'); - expect(init.tools).toEqual(['Read', 'Write', 'Bash']); - expect(init.cwd).toBe('/test'); - expect(init.sessionId).toBe(TEST_SESSION); - }); - - it('maps assistant text blocks → AssistantText', () => { - const msg = makeAssistantMsg([ - { type: 'text', text: 'Hello ' }, - { type: 'text', text: 'world' }, - ]); - const event = stream.mapSDKMessage(msg); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.AssistantText); - expect((event as GSDAssistantTextEvent).text).toBe('Hello world'); - }); - - it('maps assistant tool_use blocks → ToolCall', () => { - const msg = makeAssistantMsg([ - { type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: 'test.ts' } }, - ]); - const event = stream.mapSDKMessage(msg); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.ToolCall); - - const tc = event as GSDToolCallEvent; - expect(tc.toolName).toBe('Read'); - expect(tc.toolUseId).toBe('tu-1'); - expect(tc.input).toEqual({ path: 'test.ts' }); - }); - - it('handles multi-block assistant messages (text + tool_use)', () => { - const events: GSDEvent[] = []; - stream.on('event', (e: GSDEvent) => events.push(e)); - - const msg = makeAssistantMsg([ - { type: 'text', text: 'Let me check that.' }, - { type: 'tool_use', id: 'tu-1', name: 'Read', input: { path: 'f.ts' } }, - ]); - - // mapAndEmit will emit the text event directly and return the tool_call - const returned = stream.mapAndEmit(msg); - expect(returned).not.toBeNull(); - - // Should have received 2 events total - expect(events).toHaveLength(2); - expect(events[0]!.type).toBe(GSDEventType.AssistantText); - expect(events[1]!.type).toBe(GSDEventType.ToolCall); - }); - - it('maps SDKResultSuccess → SessionComplete', () => { - const event = stream.mapSDKMessage(makeResultSuccess()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.SessionComplete); - - const complete = event as GSDSessionCompleteEvent; - expect(complete.success).toBe(true); - expect(complete.totalCostUsd).toBe(0.05); - expect(complete.durationMs).toBe(5000); - expect(complete.numTurns).toBe(3); - expect(complete.result).toBe('Task completed successfully'); - }); - - it('maps SDKResultError → SessionError', () => { - const event = stream.mapSDKMessage(makeResultError()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.SessionError); - - const err = event as GSDSessionErrorEvent; - expect(err.success).toBe(false); - expect(err.errorSubtype).toBe('error_max_turns'); - expect(err.errors).toContain('Max turns exceeded'); - }); - - it('maps SDKToolProgressMessage → ToolProgress', () => { - const event = stream.mapSDKMessage(makeToolProgress()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.ToolProgress); - - const tp = event as GSDToolProgressEvent; - expect(tp.toolName).toBe('Bash'); - expect(tp.toolUseId).toBe('tu-1'); - expect(tp.elapsedSeconds).toBe(5.2); - }); - - it('maps SDKToolUseSummaryMessage → ToolUseSummary', () => { - const event = stream.mapSDKMessage(makeToolUseSummary()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.ToolUseSummary); - - const tus = event as GSDToolUseSummaryEvent; - expect(tus.summary).toBe('Ran 3 bash commands'); - expect(tus.toolUseIds).toEqual(['tu-1', 'tu-2', 'tu-3']); - }); - - it('maps SDKTaskStartedMessage → TaskStarted', () => { - const event = stream.mapSDKMessage(makeTaskStarted()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.TaskStarted); - - const ts = event as GSDTaskStartedEvent; - expect(ts.taskId).toBe('task-1'); - expect(ts.description).toBe('Running test suite'); - expect(ts.taskType).toBe('local_workflow'); - }); - - it('maps SDKTaskProgressMessage → TaskProgress', () => { - const event = stream.mapSDKMessage(makeTaskProgress()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.TaskProgress); - - const tp = event as GSDTaskProgressEvent; - expect(tp.taskId).toBe('task-1'); - expect(tp.totalTokens).toBe(500); - expect(tp.toolUses).toBe(3); - expect(tp.lastToolName).toBe('Bash'); - }); - - it('maps SDKTaskNotificationMessage → TaskNotification', () => { - const event = stream.mapSDKMessage(makeTaskNotification()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.TaskNotification); - - const tn = event as GSDTaskNotificationEvent; - expect(tn.taskId).toBe('task-1'); - expect(tn.status).toBe('completed'); - expect(tn.summary).toBe('All tests passed'); - }); - - it('maps SDKAPIRetryMessage → APIRetry', () => { - const event = stream.mapSDKMessage(makeAPIRetry()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.APIRetry); - - const retry = event as GSDAPIRetryEvent; - expect(retry.attempt).toBe(2); - expect(retry.maxRetries).toBe(5); - expect(retry.retryDelayMs).toBe(1000); - expect(retry.errorStatus).toBe(529); - }); - - it('maps SDKRateLimitEvent → RateLimit', () => { - const event = stream.mapSDKMessage(makeRateLimitEvent()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.RateLimit); - - const rl = event as GSDRateLimitEvent; - expect(rl.status).toBe('allowed_warning'); - expect(rl.utilization).toBe(0.85); - }); - - it('maps SDKStatusMessage → StatusChange', () => { - const event = stream.mapSDKMessage(makeStatusMessage()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.StatusChange); - expect((event as GSDStatusChangeEvent).status).toBe('compacting'); - }); - - it('maps SDKCompactBoundaryMessage → CompactBoundary', () => { - const event = stream.mapSDKMessage(makeCompactBoundary()); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.CompactBoundary); - - const cb = event as GSDCompactBoundaryEvent; - expect(cb.trigger).toBe('auto'); - expect(cb.preTokens).toBe(95000); - }); - - it('returns null for user messages', () => { - const msg = { type: 'user', session_id: TEST_SESSION } as SDKMessage; - expect(stream.mapSDKMessage(msg)).toBeNull(); - }); - - it('returns null for auth_status messages', () => { - const msg = { type: 'auth_status', session_id: TEST_SESSION } as SDKMessage; - expect(stream.mapSDKMessage(msg)).toBeNull(); - }); - - it('returns null for prompt_suggestion messages', () => { - const msg = { type: 'prompt_suggestion', session_id: TEST_SESSION } as SDKMessage; - expect(stream.mapSDKMessage(msg)).toBeNull(); - }); - - it('includes phase and planName context when provided', () => { - const event = stream.mapSDKMessage(makeSystemInit(), { - phase: PhaseType.Execute, - planName: 'feature-plan', - }); - - expect(event!.phase).toBe(PhaseType.Execute); - expect(event!.planName).toBe('feature-plan'); - }); - }); - - // ─── Cost tracking ───────────────────────────────────────────────────── - - describe('cost tracking', () => { - it('tracks per-session cost on session_complete', () => { - stream.mapSDKMessage(makeResultSuccess(0.05)); - - const cost = stream.getCost(); - expect(cost.session).toBe(0.05); - expect(cost.cumulative).toBe(0.05); - }); - - it('accumulates cumulative cost across multiple sessions', () => { - // Session 1 - const result1 = makeResultSuccess(0.05); - result1.session_id = 'session-1'; - stream.mapSDKMessage(result1); - - // Session 2 - const result2 = makeResultSuccess(0.10); - result2.session_id = 'session-2'; - stream.mapSDKMessage(result2); - - const cost = stream.getCost(); - // Current session is session-2 (last one updated) - expect(cost.session).toBe(0.10); - expect(cost.cumulative).toBeCloseTo(0.15, 10); - }); - - it('correctly computes delta when same session updates cost', () => { - // Session reports intermediate cost, then final cost - const result1 = makeResultSuccess(0.03); - stream.mapSDKMessage(result1); - - const result2 = makeResultSuccess(0.05); - stream.mapSDKMessage(result2); - - const cost = stream.getCost(); - expect(cost.session).toBe(0.05); - // Cumulative should be 0.05, not 0.08 (delta was +0.02, not +0.05) - expect(cost.cumulative).toBeCloseTo(0.05, 10); - }); - - it('tracks error session costs too', () => { - stream.mapSDKMessage(makeResultError()); - - const cost = stream.getCost(); - expect(cost.session).toBe(2.50); - expect(cost.cumulative).toBe(2.50); - }); - }); - - // ─── Transport management ────────────────────────────────────────────── - - describe('transport management', () => { - it('delivers events to subscribed transports', () => { - const received: GSDEvent[] = []; - const transport: TransportHandler = { - onEvent: (event) => received.push(event), - close: () => {}, - }; - - stream.addTransport(transport); - stream.mapAndEmit(makeSystemInit()); - - expect(received).toHaveLength(1); - expect(received[0]!.type).toBe(GSDEventType.SessionInit); - }); - - it('delivers events to multiple transports', () => { - const received1: GSDEvent[] = []; - const received2: GSDEvent[] = []; - - stream.addTransport({ - onEvent: (e) => received1.push(e), - close: () => {}, - }); - stream.addTransport({ - onEvent: (e) => received2.push(e), - close: () => {}, - }); - - stream.mapAndEmit(makeSystemInit()); - - expect(received1).toHaveLength(1); - expect(received2).toHaveLength(1); - }); - - it('stops delivering events after transport removal', () => { - const received: GSDEvent[] = []; - const transport: TransportHandler = { - onEvent: (e) => received.push(e), - close: () => {}, - }; - - stream.addTransport(transport); - stream.mapAndEmit(makeSystemInit()); - expect(received).toHaveLength(1); - - stream.removeTransport(transport); - stream.mapAndEmit(makeResultSuccess()); - expect(received).toHaveLength(1); // No new events - }); - - it('survives transport.onEvent() throwing', () => { - const badTransport: TransportHandler = { - onEvent: () => { throw new Error('transport failed'); }, - close: () => {}, - }; - const goodReceived: GSDEvent[] = []; - const goodTransport: TransportHandler = { - onEvent: (e) => goodReceived.push(e), - close: () => {}, - }; - - stream.addTransport(badTransport); - stream.addTransport(goodTransport); - - // Should not throw, and good transport still receives events - expect(() => stream.mapAndEmit(makeSystemInit())).not.toThrow(); - expect(goodReceived).toHaveLength(1); - }); - - it('closeAll() calls close on all transports and clears them', () => { - const closeCalled: boolean[] = []; - stream.addTransport({ - onEvent: () => {}, - close: () => closeCalled.push(true), - }); - stream.addTransport({ - onEvent: () => {}, - close: () => closeCalled.push(true), - }); - - stream.closeAll(); - expect(closeCalled).toHaveLength(2); - - // No more deliveries after closeAll - const events: GSDEvent[] = []; - stream.on('event', (e: GSDEvent) => events.push(e)); - stream.mapAndEmit(makeSystemInit()); - // EventEmitter listeners still work, but transports are gone - expect(events).toHaveLength(1); - }); - }); - - // ─── EventEmitter integration ────────────────────────────────────────── - - describe('EventEmitter integration', () => { - it('emits typed events via "event" channel', () => { - const events: GSDEvent[] = []; - stream.on('event', (e: GSDEvent) => events.push(e)); - - stream.mapAndEmit(makeSystemInit()); - stream.mapAndEmit(makeResultSuccess()); - - expect(events).toHaveLength(2); - expect(events[0]!.type).toBe(GSDEventType.SessionInit); - expect(events[1]!.type).toBe(GSDEventType.SessionComplete); - }); - - it('emits events on per-type channels', () => { - const initEvents: GSDEvent[] = []; - stream.on(GSDEventType.SessionInit, (e: GSDEvent) => initEvents.push(e)); - - stream.mapAndEmit(makeSystemInit()); - stream.mapAndEmit(makeResultSuccess()); - - expect(initEvents).toHaveLength(1); - expect(initEvents[0]!.type).toBe(GSDEventType.SessionInit); - }); - }); - - // ─── Stream event mapping ────────────────────────────────────────────── - - describe('stream_event mapping', () => { - it('maps SDKPartialAssistantMessage → StreamEvent', () => { - const msg = { - type: 'stream_event' as const, - event: { type: 'content_block_delta' }, - parent_tool_use_id: null, - uuid: TEST_UUID, - session_id: TEST_SESSION, - } as SDKPartialAssistantMessage; - - const event = stream.mapSDKMessage(msg); - expect(event).not.toBeNull(); - expect(event!.type).toBe(GSDEventType.StreamEvent); - expect((event as GSDStreamEvent).event).toEqual({ type: 'content_block_delta' }); - }); - }); - - // ─── Empty / edge cases ──────────────────────────────────────────────── - - describe('edge cases', () => { - it('returns null for assistant messages with empty content', () => { - const msg = makeAssistantMsg([]); - expect(stream.mapSDKMessage(msg)).toBeNull(); - }); - - it('returns null for assistant messages with only empty text', () => { - const msg = makeAssistantMsg([{ type: 'text', text: '' }]); - expect(stream.mapSDKMessage(msg)).toBeNull(); - }); - - it('returns null for unknown system subtypes', () => { - const msg = { - type: 'system', - subtype: 'unknown_future_type', - session_id: TEST_SESSION, - uuid: TEST_UUID, - } as unknown as SDKMessage; - expect(stream.mapSDKMessage(msg)).toBeNull(); - }); - }); -}); diff --git a/sdk/src/event-stream.ts b/sdk/src/event-stream.ts deleted file mode 100644 index 426701a19..000000000 --- a/sdk/src/event-stream.ts +++ /dev/null @@ -1,441 +0,0 @@ -/** - * GSD Event Stream — maps SDKMessage variants to typed GSD events. - * - * Extends EventEmitter to provide a typed event bus. Includes: - * - SDKMessage → GSDEvent mapping - * - Transport management (subscribe/unsubscribe handlers) - * - Per-session cost tracking with cumulative totals - */ - -import { EventEmitter } from 'node:events'; -import type { - SDKMessage, - SDKResultSuccess, - SDKResultError, - SDKAssistantMessage, - SDKSystemMessage, - SDKToolProgressMessage, - SDKTaskNotificationMessage, - SDKTaskStartedMessage, - SDKTaskProgressMessage, - SDKToolUseSummaryMessage, - SDKRateLimitEvent, - SDKAPIRetryMessage, - SDKStatusMessage, - SDKCompactBoundaryMessage, - SDKPartialAssistantMessage, -} from '@anthropic-ai/claude-agent-sdk'; -import { - GSDEventType, - type GSDEvent, - type GSDSessionInitEvent, - type GSDSessionCompleteEvent, - type GSDSessionErrorEvent, - type GSDAssistantTextEvent, - type GSDToolCallEvent, - type GSDToolProgressEvent, - type GSDToolUseSummaryEvent, - type GSDTaskStartedEvent, - type GSDTaskProgressEvent, - type GSDTaskNotificationEvent, - type GSDCostUpdateEvent, - type GSDAPIRetryEvent, - type GSDRateLimitEvent as GSDRateLimitEventType, - type GSDStatusChangeEvent, - type GSDCompactBoundaryEvent, - type GSDStreamEvent, - type TransportHandler, - type CostBucket, - type CostTracker, - type PhaseType, -} from './types.js'; - -// ─── Mapping context ───────────────────────────────────────────────────────── - -export interface EventStreamContext { - phase?: PhaseType; - planName?: string; -} - -// ─── GSDEventStream ────────────────────────────────────────────────────────── - -export class GSDEventStream extends EventEmitter { - private readonly transports: Set = new Set(); - private readonly costTracker: CostTracker = { - sessions: new Map(), - cumulativeCostUsd: 0, - }; - - constructor() { - super(); - this.setMaxListeners(20); - } - - // ─── Transport management ──────────────────────────────────────────── - - /** Subscribe a transport handler to receive all events. */ - addTransport(handler: TransportHandler): void { - this.transports.add(handler); - } - - /** Unsubscribe a transport handler. */ - removeTransport(handler: TransportHandler): void { - this.transports.delete(handler); - } - - /** Close all transports. */ - closeAll(): void { - for (const transport of this.transports) { - try { - transport.close(); - } catch { - // Ignore transport close errors - } - } - this.transports.clear(); - } - - // ─── Event emission ────────────────────────────────────────────────── - - /** Emit a typed GSD event to all listeners and transports. */ - emitEvent(event: GSDEvent): void { - // Emit via EventEmitter for listener-based consumers - this.emit('event', event); - this.emit(event.type, event); - - // Deliver to all transports — wrap in try/catch to prevent - // one bad transport from killing the stream - for (const transport of this.transports) { - try { - transport.onEvent(event); - } catch { - // Silently ignore transport errors - } - } - } - - // ─── SDKMessage mapping ────────────────────────────────────────────── - - /** - * Map an SDKMessage to a GSDEvent. - * Returns null for non-actionable message types (user messages, replays, etc.). - */ - mapSDKMessage(msg: SDKMessage, context: EventStreamContext = {}): GSDEvent | null { - const base = { - timestamp: new Date().toISOString(), - sessionId: 'session_id' in msg ? (msg.session_id as string) : '', - phase: context.phase, - planName: context.planName, - }; - - switch (msg.type) { - case 'system': - return this.mapSystemMessage(msg as SDKSystemMessage | SDKAPIRetryMessage | SDKStatusMessage | SDKCompactBoundaryMessage | SDKTaskStartedMessage | SDKTaskProgressMessage | SDKTaskNotificationMessage, base); - - case 'assistant': - return this.mapAssistantMessage(msg as SDKAssistantMessage, base); - - case 'result': - return this.mapResultMessage(msg as SDKResultSuccess | SDKResultError, base); - - case 'tool_progress': - return this.mapToolProgressMessage(msg as SDKToolProgressMessage, base); - - case 'tool_use_summary': - return this.mapToolUseSummaryMessage(msg as SDKToolUseSummaryMessage, base); - - case 'rate_limit_event': - return this.mapRateLimitMessage(msg as SDKRateLimitEvent, base); - - case 'stream_event': - return this.mapStreamEvent(msg as SDKPartialAssistantMessage, base); - - // Non-actionable message types — ignore - case 'user': - case 'auth_status': - case 'prompt_suggestion': - return null; - - default: - return null; - } - } - - /** - * Map an SDKMessage and emit the resulting event (if any). - * Convenience method combining mapSDKMessage + emitEvent. - */ - mapAndEmit(msg: SDKMessage, context: EventStreamContext = {}): GSDEvent | null { - const event = this.mapSDKMessage(msg, context); - if (event) { - this.emitEvent(event); - } - return event; - } - - // ─── Cost tracking ─────────────────────────────────────────────────── - - /** Get current cost totals. */ - getCost(): { session: number; cumulative: number } { - const activeId = this.costTracker.activeSessionId; - const sessionCost = activeId - ? (this.costTracker.sessions.get(activeId)?.costUsd ?? 0) - : 0; - - return { - session: sessionCost, - cumulative: this.costTracker.cumulativeCostUsd, - }; - } - - /** Update cost for a session. */ - private updateCost(sessionId: string, costUsd: number): void { - const existing = this.costTracker.sessions.get(sessionId); - const previousCost = existing?.costUsd ?? 0; - const delta = costUsd - previousCost; - - const bucket: CostBucket = { sessionId, costUsd }; - this.costTracker.sessions.set(sessionId, bucket); - this.costTracker.activeSessionId = sessionId; - this.costTracker.cumulativeCostUsd += delta; - } - - // ─── Private mappers ───────────────────────────────────────────────── - - private mapSystemMessage( - msg: SDKSystemMessage | SDKAPIRetryMessage | SDKStatusMessage | SDKCompactBoundaryMessage | SDKTaskStartedMessage | SDKTaskProgressMessage | SDKTaskNotificationMessage, - base: Omit, - ): GSDEvent | null { - // All system messages have a subtype - const subtype = (msg as { subtype: string }).subtype; - - switch (subtype) { - case 'init': { - const initMsg = msg as SDKSystemMessage; - return { - ...base, - type: GSDEventType.SessionInit, - model: initMsg.model, - tools: initMsg.tools, - cwd: initMsg.cwd, - } as GSDSessionInitEvent; - } - - case 'api_retry': { - const retryMsg = msg as SDKAPIRetryMessage; - return { - ...base, - type: GSDEventType.APIRetry, - attempt: retryMsg.attempt, - maxRetries: retryMsg.max_retries, - retryDelayMs: retryMsg.retry_delay_ms, - errorStatus: retryMsg.error_status, - } as GSDAPIRetryEvent; - } - - case 'status': { - const statusMsg = msg as SDKStatusMessage; - return { - ...base, - type: GSDEventType.StatusChange, - status: statusMsg.status, - } as GSDStatusChangeEvent; - } - - case 'compact_boundary': { - const compactMsg = msg as SDKCompactBoundaryMessage; - return { - ...base, - type: GSDEventType.CompactBoundary, - trigger: compactMsg.compact_metadata.trigger, - preTokens: compactMsg.compact_metadata.pre_tokens, - } as GSDCompactBoundaryEvent; - } - - case 'task_started': { - const taskMsg = msg as SDKTaskStartedMessage; - return { - ...base, - type: GSDEventType.TaskStarted, - taskId: taskMsg.task_id, - description: taskMsg.description, - taskType: taskMsg.task_type, - } as GSDTaskStartedEvent; - } - - case 'task_progress': { - const progressMsg = msg as SDKTaskProgressMessage; - return { - ...base, - type: GSDEventType.TaskProgress, - taskId: progressMsg.task_id, - description: progressMsg.description, - totalTokens: progressMsg.usage.total_tokens, - toolUses: progressMsg.usage.tool_uses, - durationMs: progressMsg.usage.duration_ms, - lastToolName: progressMsg.last_tool_name, - } as GSDTaskProgressEvent; - } - - case 'task_notification': { - const notifMsg = msg as SDKTaskNotificationMessage; - return { - ...base, - type: GSDEventType.TaskNotification, - taskId: notifMsg.task_id, - status: notifMsg.status, - summary: notifMsg.summary, - } as GSDTaskNotificationEvent; - } - - // Non-actionable system subtypes - case 'hook_started': - case 'hook_progress': - case 'hook_response': - case 'local_command_output': - case 'session_state_changed': - case 'files_persisted': - case 'elicitation_complete': - return null; - - default: - return null; - } - } - - private mapAssistantMessage( - msg: SDKAssistantMessage, - base: Omit, - ): GSDEvent | null { - const events: GSDEvent[] = []; - - // Extract text blocks — content blocks are a discriminated union with a 'type' field. - // Double-cast via unknown because BetaContentBlock's internal variants don't - // carry an index signature, so TS rejects the direct cast without a widening step. - const content = msg.message.content as unknown as Array<{ type: string; [key: string]: unknown }>; - - const textBlocks = content.filter( - (b): b is { type: 'text'; text: string } => b.type === 'text', - ); - if (textBlocks.length > 0) { - const text = textBlocks.map(b => b.text).join(''); - if (text.length > 0) { - events.push({ - ...base, - type: GSDEventType.AssistantText, - text, - } as GSDAssistantTextEvent); - } - } - - // Extract tool_use blocks - const toolUseBlocks = content.filter( - (b): b is { type: 'tool_use'; id: string; name: string; input: Record } => - b.type === 'tool_use', - ); - for (const block of toolUseBlocks) { - events.push({ - ...base, - type: GSDEventType.ToolCall, - toolName: block.name, - toolUseId: block.id, - input: block.input as Record, - } as GSDToolCallEvent); - } - - // Return the first event — for multi-event messages, emit the rest - // via separate emitEvent calls. This preserves the single-return contract - // while still handling multi-block messages. - if (events.length === 0) return null; - if (events.length === 1) return events[0]!; - - // For multi-event assistant messages, emit all but the last directly, - // and return the last one for the caller to handle - for (let i = 0; i < events.length - 1; i++) { - this.emitEvent(events[i]!); - } - return events[events.length - 1]!; - } - - private mapResultMessage( - msg: SDKResultSuccess | SDKResultError, - base: Omit, - ): GSDEvent { - // Update cost tracking - this.updateCost(msg.session_id, msg.total_cost_usd); - - if (msg.subtype === 'success') { - const successMsg = msg as SDKResultSuccess; - return { - ...base, - type: GSDEventType.SessionComplete, - success: true, - totalCostUsd: successMsg.total_cost_usd, - durationMs: successMsg.duration_ms, - numTurns: successMsg.num_turns, - result: successMsg.result, - } as GSDSessionCompleteEvent; - } - - const errorMsg = msg as SDKResultError; - return { - ...base, - type: GSDEventType.SessionError, - success: false, - totalCostUsd: errorMsg.total_cost_usd, - durationMs: errorMsg.duration_ms, - numTurns: errorMsg.num_turns, - errorSubtype: errorMsg.subtype, - errors: errorMsg.errors, - } as GSDSessionErrorEvent; - } - - private mapToolProgressMessage( - msg: SDKToolProgressMessage, - base: Omit, - ): GSDToolProgressEvent { - return { - ...base, - type: GSDEventType.ToolProgress, - toolName: msg.tool_name, - toolUseId: msg.tool_use_id, - elapsedSeconds: msg.elapsed_time_seconds, - } as GSDToolProgressEvent; - } - - private mapToolUseSummaryMessage( - msg: SDKToolUseSummaryMessage, - base: Omit, - ): GSDToolUseSummaryEvent { - return { - ...base, - type: GSDEventType.ToolUseSummary, - summary: msg.summary, - toolUseIds: msg.preceding_tool_use_ids, - } as GSDToolUseSummaryEvent; - } - - private mapRateLimitMessage( - msg: SDKRateLimitEvent, - base: Omit, - ): GSDRateLimitEventType { - return { - ...base, - type: GSDEventType.RateLimit, - status: msg.rate_limit_info.status, - resetsAt: msg.rate_limit_info.resetsAt, - utilization: msg.rate_limit_info.utilization, - } as GSDRateLimitEventType; - } - - private mapStreamEvent( - msg: SDKPartialAssistantMessage, - base: Omit, - ): GSDStreamEvent { - return { - ...base, - type: GSDEventType.StreamEvent, - event: msg.event, - } as GSDStreamEvent; - } -} diff --git a/sdk/src/golden/capture.ts b/sdk/src/golden/capture.ts deleted file mode 100644 index d98ede781..000000000 --- a/sdk/src/golden/capture.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Golden test helpers — run `gsd-tools.cjs` as a subprocess and capture JSON or raw stdout. - * - * Used by `golden.integration.test.ts` and `read-only-parity.integration.test.ts` to assert - * SDK `createRegistry()` output matches the legacy CJS CLI. - */ - -import { execFile } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import { isAbsolute, join } from 'node:path'; - -import { resolveGsdToolsPath } from '../gsd-tools.js'; - -const CAPTURE_TIMEOUT_MS = 120_000; -const MAX_BUFFER = 10 * 1024 * 1024; - -function execGsdTools( - projectDir: string, - command: string, - args: string[], -): Promise<{ stdout: string; stderr: string }> { - const script = resolveGsdToolsPath(projectDir); - const fullArgs = [script, command, ...args]; - return new Promise((resolve, reject) => { - execFile( - process.execPath, - fullArgs, - { - cwd: projectDir, - maxBuffer: MAX_BUFFER, - timeout: CAPTURE_TIMEOUT_MS, - env: { ...process.env }, - }, - (err, stdout, stderr) => { - if (err) { - const code = typeof err === 'object' && err && 'code' in err ? String((err as NodeJS.ErrnoException).code) : ''; - const stderrStr = stderr?.toString() ?? ''; - reject( - new Error( - `gsd-tools failed (exit ${code}): ${stderrStr || (err instanceof Error ? err.message : String(err))}`, - ), - ); - return; - } - resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' }); - }, - ); - }); -} - -/** Same `@file:` indirection handling as {@link GSDTools} private parseOutput (cwd = projectDir). */ -async function parseGsdToolsJson(raw: string, projectDir: string): Promise { - const trimmed = raw.trim(); - if (trimmed === '') { - return null; - } - - let jsonStr = trimmed; - if (jsonStr.startsWith('@file:')) { - const rel = jsonStr.slice(6).trim(); - const filePath = isAbsolute(rel) ? rel : join(projectDir, rel); - try { - jsonStr = await readFile(filePath, 'utf-8'); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to read gsd-tools @file: indirection at "${filePath}": ${reason}`); - } - } - - return JSON.parse(jsonStr); -} - -/** - * Run `node gsd-tools.cjs [...args]` in `projectDir` and parse stdout as JSON. - */ -export async function captureGsdToolsOutput( - command: string, - args: string[], - projectDir: string, -): Promise { - const { stdout } = await execGsdTools(projectDir, command, args); - return parseGsdToolsJson(stdout, projectDir); -} - -/** - * Run `node gsd-tools.cjs [...args]` and return raw stdout (no JSON parse). - */ -export async function captureGsdToolsStdout( - command: string, - args: string[], - projectDir: string, -): Promise { - const { stdout } = await execGsdTools(projectDir, command, args); - return stdout; -} diff --git a/sdk/src/golden/fixtures/generate-slug.golden.json b/sdk/src/golden/fixtures/generate-slug.golden.json deleted file mode 100644 index 8798e755a..000000000 --- a/sdk/src/golden/fixtures/generate-slug.golden.json +++ /dev/null @@ -1 +0,0 @@ -{"slug":"my-phase"} diff --git a/sdk/src/golden/fixtures/profile-sample-sessions/demo-project/sample.jsonl b/sdk/src/golden/fixtures/profile-sample-sessions/demo-project/sample.jsonl deleted file mode 100644 index b49c7e574..000000000 --- a/sdk/src/golden/fixtures/profile-sample-sessions/demo-project/sample.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"type":"user","userType":"external","message":{"content":"profile sample message one"},"timestamp":1700000000000,"cwd":"/fixture/proj"} -{"type":"assistant","message":{"content":"ok"},"timestamp":1700000000001} -{"type":"user","userType":"external","message":{"content":"profile sample message two"},"timestamp":1700000000002,"cwd":"/fixture/proj"} diff --git a/sdk/src/golden/fixtures/summary-extract-sample.md b/sdk/src/golden/fixtures/summary-extract-sample.md deleted file mode 100644 index a59092a5c..000000000 --- a/sdk/src/golden/fixtures/summary-extract-sample.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -phase: "01" -name: Golden Fixture -one-liner: From frontmatter YAML -key-files: - - sdk/src/foo.ts -key-decisions: - - "Auth model: use JWT bearer tokens" - - "Plain decision without colon split" -patterns-established: - - "Repository pattern for data access" -tech-stack: - added: - - vitest - - name: typescript -requirements-completed: - - REQ-GOLD-1 ---- - -# Phase 01: Golden Fixture Summary - -**Bold one-liner pulled from body when FM lacks one-liner** - -## Section - -More body. diff --git a/sdk/src/golden/fixtures/uat-render-checkpoint-sample.md b/sdk/src/golden/fixtures/uat-render-checkpoint-sample.md deleted file mode 100644 index ee46436a7..000000000 --- a/sdk/src/golden/fixtures/uat-render-checkpoint-sample.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -status: draft ---- -# UAT - -## Current Test - -number: 1 -name: Login flow -expected: | - User can sign in - -## Other - -Placeholder section after Current Test. diff --git a/sdk/src/golden/golden-integration-covered.ts b/sdk/src/golden/golden-integration-covered.ts deleted file mode 100644 index c1615d85c..000000000 --- a/sdk/src/golden/golden-integration-covered.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Canonical commands exercised by `golden.integration.test.ts` (SDK dispatch vs - * `gsd-tools.cjs` where applicable). Update when adding `describe` blocks there. - */ - -export const GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS: readonly string[] = [ - 'config-get', - 'config-set', - 'current-timestamp', - 'detect-custom-files', - 'docs-init', - 'find-phase', - 'frontmatter.get', - 'frontmatter.validate', - 'generate-slug', - 'init.execute-phase', - 'init.plan-phase', - 'init.quick', - 'init.resume', - 'init.verify-work', - 'intel.update', - 'progress.json', - 'roadmap.analyze', - 'state.sync', - 'state.validate', - 'template.select', - 'validate.consistency', - 'verify.phase-completeness', - 'verify.plan-structure', -].sort((a, b) => a.localeCompare(b)); diff --git a/sdk/src/golden/golden-mutation-covered.ts b/sdk/src/golden/golden-mutation-covered.ts deleted file mode 100644 index f514db7cf..000000000 --- a/sdk/src/golden/golden-mutation-covered.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Mutation canonicals with explicit subprocess JSON parity vs `gsd-tools.cjs` - * (see `mutation-subprocess.integration.test.ts` when present). Empty until those - * tests land; other mutations rely on `MUTATION_DEFERRED_REASON` in golden-policy. - */ - -export const GOLDEN_MUTATION_SUBPROCESS_COVERED: readonly string[] = [ - 'state.update', - 'state.patch', - 'state.begin-phase', - 'state.sync', - 'phase.add', - 'phase.add-batch', - 'phase.insert', - 'phases.clear', - 'roadmap.update-plan-progress', -]; diff --git a/sdk/src/golden/golden-policy.test.ts b/sdk/src/golden/golden-policy.test.ts deleted file mode 100644 index 3f38ae072..000000000 --- a/sdk/src/golden/golden-policy.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { verifyGoldenPolicyComplete } from './golden-policy.js'; - -describe('golden policy', () => { - it('every canonical registry command is integration-covered or excepted', () => { - expect(() => verifyGoldenPolicyComplete()).not.toThrow(); - }); -}); diff --git a/sdk/src/golden/golden-policy.ts b/sdk/src/golden/golden-policy.ts deleted file mode 100644 index c2b123668..000000000 --- a/sdk/src/golden/golden-policy.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Golden parity policy — every canonical registry command must be either: - * - Listed in `GOLDEN_PARITY_INTEGRATION_COVERED` (subprocess CJS check under `sdk/src/golden/*integration*.test.ts`), or - * - Documented in `GOLDEN_PARITY_EXCEPTIONS` with a stable rationale (mirrored in QUERY-HANDLERS.md § Golden registry coverage matrix). - */ -import { QUERY_MUTATION_COMMANDS } from '../query/index.js'; -import { getCanonicalRegistryCommands } from './registry-canonical-commands.js'; -import { GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS } from './golden-integration-covered.js'; -import { GOLDEN_MUTATION_SUBPROCESS_COVERED } from './golden-mutation-covered.js'; -import { readOnlyGoldenCanonicals } from './read-only-golden-rows.js'; - -/** True if this canonical command participates in mutation event wiring (see QUERY_MUTATION_COMMANDS). */ -export function isMutationCanonicalCmd(canonical: string): boolean { - const spaced = canonical.replace(/\./g, ' '); - for (const m of QUERY_MUTATION_COMMANDS) { - if (m === canonical || m === spaced) return true; - } - return false; -} - -const MUTATION_DEFERRED_REASON = - 'Listed in QUERY_MUTATION_COMMANDS — mutates `.planning/`, git, or profile files. Subprocess golden vs gsd-tools.cjs is covered where a tmp fixture or `--dry-run` exists in golden.integration.test.ts; otherwise handler parity lives in sdk/src/query/*-mutation.test.ts, commit.test.ts, phase-lifecycle.test.ts, workstream.test.ts, intel.test.ts, profile.test.ts, template.test.ts, docs-init.ts, or uat.test.ts as applicable.'; - -/** Registry commands with no `gsd-tools.cjs` analogue — cannot have subprocess JSON parity. */ -const NO_CJS_SUBPROCESS_REASON: Record = { - 'phases.archive': - 'No `gsd-tools.cjs` command for `phases archive` (SDK-only). Covered in sdk/src/query/phase-lifecycle.test.ts.', - 'check.config-gates': - 'SDK-only decision-routing query (`.planning/research/decision-routing-audit.md` §3.3). Covered in sdk/src/query/config-gates.test.ts.', - 'check.phase-ready': - 'SDK-only decision-routing query (audit §3.4). Covered in sdk/src/query/phase-ready.test.ts.', - 'route.next-action': - 'SDK-only decision-routing query (audit §3.1). Covered in sdk/src/query/route-next-action.test.ts.', - 'check.auto-mode': - 'SDK-only decision-routing query (audit §3.5). Covered in sdk/src/query/check-auto-mode.test.ts.', - 'detect.phase-type': - 'SDK-only decision-routing query (audit §3.6). Covered in sdk/src/query/detect-phase-type.test.ts.', - 'check.completion': - 'SDK-only decision-routing query (audit §3.7). Covered in sdk/src/query/check-completion.test.ts.', - 'check.gates': - 'SDK-only decision-routing query (audit §3.2). Covered in sdk/src/query/check-gates.test.ts.', - 'check.verification-status': - 'SDK-only decision-routing query (audit §3.8). Covered in sdk/src/query/check-verification-status.test.ts.', - 'check.ship-ready': - 'SDK-only decision-routing query (audit §3.9). Covered in sdk/src/query/check-ship-ready.test.ts.', - 'phase.list-plans': - 'SDK-only listing helper for agents (no `gsd-tools.cjs` mirror). Covered in sdk/src/query/phase-list-queries.test.ts.', - 'phase.list-artifacts': - 'SDK-only artifact enumeration (no CJS mirror). Covered in sdk/src/query/phase-list-queries.test.ts.', - 'plan.task-structure': - 'SDK-only structured plan parse (no CJS mirror). Covered in sdk/src/query/plan-task-structure.test.ts.', - 'requirements.extract-from-plans': - 'SDK-only requirements aggregation (no CJS mirror). Covered in sdk/src/query/requirements-extract-from-plans.test.ts.', - 'commands': - 'SDK-only registry introspection (no gsd-tools.cjs equivalent — the CJS layer has no self-describing verb). Covered in sdk/src/query/commands-list.test.ts. Closes #3121.', - 'phase.mvp-mode': - 'SDK-only MVP precedence resolver (CLI flag → roadmap → config → false). Centralizes the chain previously duplicated across plan-phase/execute-phase/verify-work/progress workflows. Covered in sdk/src/query/mvp.test.ts.', - 'task.is-behavior-adding': - 'SDK-only Behavior-Adding Task predicate for the MVP+TDD Gate (tdd=true + block + non-test source files). Replaces prose-only specification in references/execute-mvp-tdd.md. Covered in sdk/src/query/mvp.test.ts.', - 'user-story.validate': - 'SDK-only User Story regex validator. Centralizes /^As a .+, I want to .+, so that .+\\.$/ previously hardcoded in verify-work workflow. Covered in sdk/src/query/mvp.test.ts.', -}; - -const READ_HANDLER_ONLY_REASON = (cmd: string) => - `No ` + - '`toEqual` subprocess row yet for this read-only command — handler parity is covered in sdk/src/query/*.test.ts / decomposed-handlers.test.ts; add `captureGsdToolsOutput` + `registry.dispatch` in sdk/src/golden/ when JSON shapes are aligned (see QUERY-HANDLERS.md § Golden registry coverage matrix). Command: `' + - cmd + - '`.'; - -function buildIntegrationCoveredSet(): Set { - return new Set([ - ...GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS, - ...readOnlyGoldenCanonicals(), - ...GOLDEN_MUTATION_SUBPROCESS_COVERED, - ]); -} - -/** - * Canonical commands with an explicit subprocess JSON check vs gsd-tools.cjs - * (golden.integration.test.ts + read-only-parity.integration.test.ts). - */ -export const GOLDEN_PARITY_INTEGRATION_COVERED = buildIntegrationCoveredSet(); - -export const GOLDEN_PARITY_EXCEPTIONS: Record = buildGoldenParityExceptions(); - -function buildGoldenParityExceptions(): Record { - const out: Record = {}; - for (const c of getCanonicalRegistryCommands()) { - if (GOLDEN_PARITY_INTEGRATION_COVERED.has(c)) continue; - if (Object.prototype.hasOwnProperty.call(NO_CJS_SUBPROCESS_REASON, c)) { - out[c] = NO_CJS_SUBPROCESS_REASON[c]!; - continue; - } - if (isMutationCanonicalCmd(c)) { - out[c] = MUTATION_DEFERRED_REASON; - } else { - out[c] = READ_HANDLER_ONLY_REASON(c); - } - } - return out; -} - -export function verifyGoldenPolicyComplete(): void { - const canon = getCanonicalRegistryCommands(); - const missingException: string[] = []; - for (const c of canon) { - if (GOLDEN_PARITY_INTEGRATION_COVERED.has(c)) continue; - if (!Object.prototype.hasOwnProperty.call(GOLDEN_PARITY_EXCEPTIONS, c)) missingException.push(c); - } - if (missingException.length) { - throw new Error(`Missing GOLDEN_PARITY_EXCEPTIONS entry for:\n${missingException.join('\n')}`); - } - const stale: string[] = []; - for (const c of GOLDEN_PARITY_INTEGRATION_COVERED) { - if (!canon.includes(c)) stale.push(c); - } - if (stale.length) { - throw new Error(`Stale GOLDEN_PARITY_INTEGRATION_COVERED entries:\n${stale.join('\n')}`); - } -} diff --git a/sdk/src/golden/golden.integration.test.ts b/sdk/src/golden/golden.integration.test.ts deleted file mode 100644 index a319a5e99..000000000 --- a/sdk/src/golden/golden.integration.test.ts +++ /dev/null @@ -1,1031 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { captureGsdToolsOutput } from './capture.js'; -import { omitInitQuickVolatile } from './init-golden-normalize.js'; -import { createRegistry } from '../query/index.js'; -import { readFile, mkdir, writeFile, rm } from 'node:fs/promises'; -import { resolve, dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { tmpdir } from 'node:os'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const PROJECT_DIR = resolve(__dirname, '..', '..'); -// Repo root (where .planning/ lives) — needed for commands that read project state -const REPO_ROOT = resolve(__dirname, '..', '..', '..'); - -/** Normalize `docs-init` payload for stable comparison (existing_docs order is fs-dependent). */ -function normalizeDocsInitPayload(rawPayload: unknown): Record { - const parsed = typeof rawPayload === 'string' - ? JSON.parse(rawPayload) as Record - : structuredClone(rawPayload as Record); - if (Array.isArray(parsed.existing_docs)) { - parsed.existing_docs.sort((a: any, b: any) => a.path.localeCompare(b.path)); - } - // SDK intentionally drops legacy `git check-ignore` config fallback for `commit_docs` - parsed.commit_docs = true; - return parsed; -} - -/** Agent install scan differs between gsd-tools subprocess vs in-process (paths / env); compare the rest. */ -function omitAgentInstallFields(data: Record): Record { - const o = { ...data }; - delete o.agents_installed; - delete o.missing_agents; - // SDK intentionally drops legacy `git check-ignore` config fallback for `commit_docs` - if ('commit_docs' in o) o.commit_docs = true; - return o; -} - -const MINIMAL_STATE = `--- -gsd_state_version: 1.0 -milestone: v3.0 -milestone_name: SDK-First Migration -status: executing ---- - -# Project State - -## Current Position - -Phase: 10 (Read-Only Queries) — EXECUTING -Plan: 2 of 3 -Status: Executing Phase 10 -Last activity: 2026-04-08 -- Phase 10 execution started - -Progress: [░░░░░░░░░░] 50% -`; - -async function setupMinimalStateProject(root: string): Promise { - await mkdir(join(root, '.planning', 'phases'), { recursive: true }); - await writeFile(join(root, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8'); - await writeFile( - join(root, '.planning', 'ROADMAP.md'), - '# Roadmap\n\n## Current Milestone: v3.0 SDK-First Migration\n\n### Phase 10: Read-Only Queries\n', - 'utf-8', - ); - await writeFile(join(root, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8'); -} - -async function setupPhasesFixture(root: string): Promise { - await setupMinimalStateProject(root); - const phasesRoot = join(root, '.planning', 'phases'); - await mkdir(join(phasesRoot, '10-read-only-queries'), { recursive: true }); - await mkdir(join(phasesRoot, '11-foundation-cleanup'), { recursive: true }); - await mkdir(join(phasesRoot, '999-backlog'), { recursive: true }); - await writeFile(join(phasesRoot, '10-read-only-queries', '10-01-PLAN.md'), '# plan\n', 'utf-8'); - await writeFile(join(phasesRoot, '10-read-only-queries', '10-02-PLAN.md'), '# plan\n', 'utf-8'); - await writeFile(join(phasesRoot, '11-foundation-cleanup', '11-01-SUMMARY.md'), '# summary\n', 'utf-8'); - - await writeFile( - join(root, '.planning', 'ROADMAP.md'), - [ - '# Roadmap', - '', - '| Phase | Plans | Status | Completed |', - '|---|---|---|---|', - '| 10. | 0/2 | Planned | |', - '| 11. | 1/1 | Complete | 2026-04-01 |', - '', - '### Phase 10: Read-Only Queries', - '', - '**Plans:** 0/2 plans executed', - '', - 'Plans:', - '- [ ] 10-01', - '- [ ] 10-02', - '', - '### Phase 11: Foundation Cleanup', - ].join('\n'), - 'utf-8', - ); - - const archivedRoot = join(root, '.planning', 'milestones', 'v0.9-phases', '09-legacy-foundation'); - await mkdir(archivedRoot, { recursive: true }); -} - -describe('Golden file tests', () => { - describe('generate-slug', () => { - it('SDK output matches gsd-tools.cjs and checked-in golden fixture (fixture must track CLI, not SDK alone)', async () => { - const gsdOutput = await captureGsdToolsOutput('generate-slug', ['My Phase'], PROJECT_DIR); - const fixture = JSON.parse( - await readFile(resolve(__dirname, 'fixtures', 'generate-slug.golden.json'), 'utf-8'), - ); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('generate-slug', ['My Phase'], PROJECT_DIR); - expect(sdkResult.data).toEqual(gsdOutput); - expect(fixture).toEqual(gsdOutput); - }); - - it('handles multi-word input identically', async () => { - const gsdOutput = await captureGsdToolsOutput('generate-slug', ['Hello World Test'], PROJECT_DIR); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('generate-slug', ['Hello World Test'], PROJECT_DIR); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('frontmatter.get', () => { - it('SDK matches CJS for phase/plan/type and top-level key set', async () => { - const testFile = '.planning/phases/10-read-only-queries/10-01-PLAN.md'; - const gsdOutput = await captureGsdToolsOutput('frontmatter', ['get', testFile], REPO_ROOT) as Record; - const registry = createRegistry(); - const sdkResult = await registry.dispatch('frontmatter.get', [testFile], REPO_ROOT); - const sdkData = sdkResult.data as Record; - // Compare stable scalar fields - expect(sdkData.phase).toBe(gsdOutput.phase); - expect(sdkData.plan).toBe(gsdOutput.plan); - expect(sdkData.type).toBe(gsdOutput.type); - // Both should have same top-level keys - expect(Object.keys(sdkData).sort()).toEqual(Object.keys(gsdOutput).sort()); - }); - }); - - describe('config-get', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `gsd-golden-cfgget-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(join(tmpDir, '.planning'), { recursive: true }); - await writeFile( - join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'balanced', commit_docs: true }), - 'utf-8', - ); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('SDK output matches gsd-tools.cjs for top-level key', async () => { - const gsdOutput = await captureGsdToolsOutput('config-get', ['model_profile'], tmpDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('config-get', ['model_profile'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('find-phase', () => { - it('SDK output matches gsd-tools.cjs for core fields', async () => { - const gsdOutput = await captureGsdToolsOutput('find-phase', ['9'], REPO_ROOT) as Record; - const registry = createRegistry(); - const sdkResult = await registry.dispatch('find-phase', ['9'], REPO_ROOT); - const sdkData = sdkResult.data as Record; - // SDK output is a subset — compare shared fields - expect(sdkData.found).toBe(gsdOutput.found); - expect(sdkData.directory).toBe(gsdOutput.directory); - expect(sdkData.phase_number).toBe(gsdOutput.phase_number); - expect(sdkData.phase_name).toBe(gsdOutput.phase_name); - expect(sdkData.plans).toEqual(gsdOutput.plans); - }); - }); - - describe('roadmap.analyze', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('roadmap', ['analyze'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('roadmap.analyze', [], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('roadmap parity (subprocess parity)', () => { - async function withFreshRoadmapProjects(): Promise<{ gsdDir: string; sdkDir: string }> { - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const gsdDir = join(tmpdir(), `gsd-golden-roadmap-gsd-${suffix}`); - const sdkDir = join(tmpdir(), `gsd-golden-roadmap-sdk-${suffix}`); - await setupPhasesFixture(gsdDir); - await setupPhasesFixture(sdkDir); - return { gsdDir, sdkDir }; - } - - it('roadmap.get-phase matches gsd-tools.cjs on fixture', async () => { - const { gsdDir, sdkDir } = await withFreshRoadmapProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('roadmap', ['get-phase', '10'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('roadmap.get-phase', ['10'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('roadmap.update-plan-progress matches gsd-tools.cjs on fixture', async () => { - const { gsdDir, sdkDir } = await withFreshRoadmapProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('roadmap', ['update-plan-progress', '10'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('roadmap.update-plan-progress', ['10'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - }); - - describe('progress', () => { - it('SDK JSON matches gsd-tools.cjs (`progress json`)', async () => { - const gsdOutput = await captureGsdToolsOutput('progress', ['json'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('progress', [], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - // ─── Mutation command golden tests ────────────────────────────────────── - - describe('frontmatter.validate (mutation)', () => { - it('SDK JSON matches gsd-tools.cjs (plan schema)', async () => { - const testFile = '.planning/phases/11-state-mutations/11-03-PLAN.md'; - const gsdOutput = await captureGsdToolsOutput('frontmatter', ['validate', testFile, '--schema', 'plan'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('frontmatter.validate', [testFile, '--schema', 'plan'], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('config-set (mutation)', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `gsd-golden-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(join(tmpDir, '.planning'), { recursive: true }); - await writeFile(join(tmpDir, '.planning', 'config.json'), '{"model_profile":"balanced","workflow":{"research":true}}'); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('SDK config-set JSON matches gsd-tools.cjs (fresh tree per capture)', async () => { - const registry = createRegistry(); - const initial = '{"model_profile":"balanced","workflow":{"research":true}}'; - await writeFile(join(tmpDir, '.planning', 'config.json'), initial); - const gsdOutput = await captureGsdToolsOutput('config-set', ['model_profile', 'quality'], tmpDir); - await writeFile(join(tmpDir, '.planning', 'config.json'), initial); - const sdkResult = await registry.dispatch('config-set', ['model_profile', 'quality'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - const config = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); - expect(config.model_profile).toBe('quality'); - }); - }); - - describe('state mutations (subprocess parity)', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `gsd-golden-state-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await setupMinimalStateProject(tmpDir); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('state.update matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('state', ['update', 'Status', 'Executing SDK'], tmpDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.update', ['Status', 'Executing SDK'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('state.patch matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('state', ['patch', '--status', 'Patched via parity'], tmpDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.patch', ['--status', 'Patched via parity'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('state.begin-phase matches gsd-tools.cjs', async () => { - const argv = ['begin-phase', '--phase', '11', '--name', 'State Pilot', '--plans', '3']; - const gsdOutput = await captureGsdToolsOutput('state', argv, tmpDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.begin-phase', ['--phase', '11', '--name', 'State Pilot', '--plans', '3'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('state.sync --verify matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('state', ['sync', '--verify'], tmpDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.sync', ['--verify'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - // ─── Phase 5.1: 12 additional state subcommand parity tests ──────────── - - it('state.advance-plan matches gsd-tools.cjs', async () => { - // Setup: add compound Plan field so advance-plan can parse it - const statePath = join(tmpDir, '.planning', 'STATE.md'); - const content = await readFile(statePath, 'utf-8'); - await writeFile(statePath, content + '\nPlan: 2 of 3\n', 'utf-8'); - const gsdOutput = await captureGsdToolsOutput('state', ['advance-plan'], tmpDir); - // Restore and re-apply for SDK call - await writeFile(statePath, content + '\nPlan: 2 of 3\n', 'utf-8'); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.advance-plan', [], tmpDir); - // Both advance plan: compare shape (times may differ slightly but structure matches) - expect(typeof sdkResult.data).toBe('object'); - const sdkData = sdkResult.data as Record; - const gsdData = gsdOutput as Record; - expect(sdkData.advanced).toBe(gsdData.advanced); - if (sdkData.advanced) { - expect(typeof sdkData.current_plan).toBe('number'); - expect(typeof sdkData.previous_plan).toBe('number'); - } - }); - - it('state.update-progress matches gsd-tools.cjs', async () => { - // Both update the progress bar. Phase dir is empty so percent=0. - const gsdOutput = await captureGsdToolsOutput('state', ['update-progress'], tmpDir); - // Restore state for SDK call (CJS mutates the file) - const statePath = join(tmpDir, '.planning', 'STATE.md'); - await writeFile(statePath, MINIMAL_STATE, 'utf-8'); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.update-progress', [], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('state.add-decision matches gsd-tools.cjs', async () => { - // Setup: add a Decisions section to STATE.md body - const statePath = join(tmpDir, '.planning', 'STATE.md'); - const withDecisions = MINIMAL_STATE + '\n## Decisions\n\nNone yet.\n'; - await writeFile(statePath, withDecisions, 'utf-8'); - const argv = ['add-decision', '--phase', '10', '--summary', 'SDK parity decision']; - const gsdOutput = await captureGsdToolsOutput('state', argv, tmpDir); - await writeFile(statePath, withDecisions, 'utf-8'); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.add-decision', ['--phase', '10', '--summary', 'SDK parity decision'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('state.add-blocker matches gsd-tools.cjs', async () => { - // Setup: add a Blockers section to STATE.md body - const statePath = join(tmpDir, '.planning', 'STATE.md'); - const withBlockers = MINIMAL_STATE + '\n## Blockers\n\nNone\n'; - await writeFile(statePath, withBlockers, 'utf-8'); - const argv = ['add-blocker', '--text', 'SDK parity blocker']; - const gsdOutput = await captureGsdToolsOutput('state', argv, tmpDir); - await writeFile(statePath, withBlockers, 'utf-8'); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.add-blocker', ['--text', 'SDK parity blocker'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('state.resolve-blocker matches gsd-tools.cjs', async () => { - // Setup: add a Blockers section that has a blocker entry to remove - const statePath = join(tmpDir, '.planning', 'STATE.md'); - const withBlocker = MINIMAL_STATE + '\n## Blockers\n\n- SDK parity blocker to resolve\n'; - await writeFile(statePath, withBlocker, 'utf-8'); - const argv = ['resolve-blocker', '--text', 'SDK parity blocker to resolve']; - const gsdOutput = await captureGsdToolsOutput('state', argv, tmpDir); - await writeFile(statePath, withBlocker, 'utf-8'); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.resolve-blocker', ['--text', 'SDK parity blocker to resolve'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('state.record-session matches gsd-tools.cjs', async () => { - // Setup: add session fields to STATE.md body - const statePath = join(tmpDir, '.planning', 'STATE.md'); - const withSession = MINIMAL_STATE + '\nLast session: 2026-05-01T00:00:00.000Z\n'; - await writeFile(statePath, withSession, 'utf-8'); - const argv = ['record-session', '--stopped-at', 'plan 2 done']; - const gsdOutput = await captureGsdToolsOutput('state', argv, tmpDir); - // SDK writes timestamp — compare shape not exact value - const registry = createRegistry(); - await writeFile(statePath, withSession, 'utf-8'); - const sdkResult = await registry.dispatch('state.record-session', ['--stopped-at', 'plan 2 done'], tmpDir); - const sdkData = sdkResult.data as Record; - const gsdData = gsdOutput as Record; - // Both should agree on recorded:true/false shape - expect(sdkData.recorded).toBe(gsdData.recorded); - if (sdkData.recorded && gsdData.recorded) { - expect(Array.isArray(sdkData.updated)).toBe(true); - expect(Array.isArray(gsdData.updated)).toBe(true); - expect((sdkData.updated as string[]).sort()).toEqual((gsdData.updated as string[]).sort()); - } - }); - - it('state.signal-waiting matches gsd-tools.cjs', async () => { - const argv = ['signal-waiting', '--type', 'decision_point', '--question', 'Which SDK approach?', '--phase', '10']; - const gsdOutput = await captureGsdToolsOutput('state', argv, tmpDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.signal-waiting', ['--type', 'decision_point', '--question', 'Which SDK approach?', '--phase', '10'], tmpDir); - const sdkData = sdkResult.data as Record; - const gsdData = gsdOutput as Record; - // Both write WAITING.json — compare structural fields, not timestamp or exact paths - expect(sdkData.signaled).toBe(gsdData.signaled); - expect(typeof sdkData.path).toBe('string'); - expect(typeof gsdData.path).toBe('string'); - }); - - it('state.signal-resume matches gsd-tools.cjs', async () => { - // First signal so resume has something to remove - const gsdDir2 = join(tmpdir(), `gsd-golden-state-resume-gsd-${Date.now()}`); - const sdkDir2 = join(tmpdir(), `gsd-golden-state-resume-sdk-${Date.now()}`); - try { - await setupMinimalStateProject(gsdDir2); - await setupMinimalStateProject(sdkDir2); - // Signal in both dirs first - await captureGsdToolsOutput('state', ['signal-waiting', '--type', 'review'], gsdDir2); - const registry1 = createRegistry(); - await registry1.dispatch('state.signal-waiting', ['--type', 'review'], sdkDir2); - // Now resume - const gsdOutput = await captureGsdToolsOutput('state', ['signal-resume'], gsdDir2); - const registry2 = createRegistry(); - const sdkResult = await registry2.dispatch('state.signal-resume', [], sdkDir2); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir2, { recursive: true, force: true }); - await rm(sdkDir2, { recursive: true, force: true }); - } - }); - - it('state.planned-phase matches gsd-tools.cjs', async () => { - const argv = ['planned-phase', '--phase', '11', '--plans', '4']; - const gsdOutput = await captureGsdToolsOutput('state', argv, tmpDir); - const statePath = join(tmpDir, '.planning', 'STATE.md'); - await writeFile(statePath, MINIMAL_STATE, 'utf-8'); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.planned-phase', ['--phase', '11', '--plans', '4'], tmpDir); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('state.milestone-switch matches gsd-tools.cjs', async () => { - const gsdDir2 = join(tmpdir(), `gsd-golden-state-ms-gsd-${Date.now()}`); - const sdkDir2 = join(tmpdir(), `gsd-golden-state-ms-sdk-${Date.now()}`); - try { - await setupMinimalStateProject(gsdDir2); - await setupMinimalStateProject(sdkDir2); - const argv = ['milestone-switch', '--milestone', 'v4.0', '--name', 'Next Milestone']; - const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.milestone-switch', ['--milestone', 'v4.0', '--name', 'Next Milestone'], sdkDir2); - // Both return {switched:true, milestone, name} — compare structural shape - const sdkData = sdkResult.data as Record; - const gsdData = gsdOutput as Record; - expect(sdkData.switched).toBe(gsdData.switched); - expect(sdkData.version).toBe(gsdData.version); - expect(sdkData.name).toBe(gsdData.name); - } finally { - await rm(gsdDir2, { recursive: true, force: true }); - await rm(sdkDir2, { recursive: true, force: true }); - } - }); - - it('state.prune dry-run matches gsd-tools.cjs', async () => { - // Both CJS and SDK read `Current Phase` from the STATE.md body text - // (CJS: stateExtractField(content, 'Current Phase'), SDK: same). - // MINIMAL_STATE has no `Current Phase:` field → both default to 0 → - // cutoff = 0 - 3 = -3 ≤ 0 → "Only 0 phases — nothing to prune with --keep-recent 3". - const gsdDir2 = join(tmpdir(), `gsd-golden-prune-gsd-${Date.now()}`); - const sdkDir2 = join(tmpdir(), `gsd-golden-prune-sdk-${Date.now()}`); - try { - await setupMinimalStateProject(gsdDir2); - await setupMinimalStateProject(sdkDir2); - const argv = ['prune', '--keep-recent', '3', '--dry-run']; - const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.prune', ['--keep-recent', '3', '--dry-run'], sdkDir2); - // Exact equality — both CJS and SDK now use the same phase extraction logic. - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir2, { recursive: true, force: true }); - await rm(sdkDir2, { recursive: true, force: true }); - } - }); - - it('state.record-metric matches gsd-tools.cjs (no-metrics-section → SDK auto-creates like CJS)', async () => { - // SDK now auto-creates the ## Performance Metrics section when absent, - // matching CJS DWIM behavior. Test with no pre-seeded section to exercise - // the auto-create path on both sides. - const gsdDir2 = join(tmpdir(), `gsd-golden-state-metric-gsd-${Date.now()}`); - const sdkDir2 = join(tmpdir(), `gsd-golden-state-metric-sdk-${Date.now()}`); - try { - // Use MINIMAL_STATE (no metrics section) — both sides should auto-create it. - await mkdir(join(gsdDir2, '.planning', 'phases'), { recursive: true }); - await writeFile(join(gsdDir2, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8'); - await writeFile(join(gsdDir2, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8'); - await writeFile(join(gsdDir2, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8'); - await mkdir(join(sdkDir2, '.planning', 'phases'), { recursive: true }); - await writeFile(join(sdkDir2, '.planning', 'STATE.md'), MINIMAL_STATE, 'utf-8'); - await writeFile(join(sdkDir2, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8'); - await writeFile(join(sdkDir2, '.planning', 'config.json'), '{"model_profile":"balanced"}', 'utf-8'); - - const argv = ['record-metric', '--phase', '10', '--plan', '1', '--duration', '45m', '--tasks', '12', '--files', '8']; - const gsdOutput = await captureGsdToolsOutput('state', argv, gsdDir2); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.record-metric', ['--phase', '10', '--plan', '1', '--duration', '45m', '--tasks', '12', '--files', '8'], sdkDir2); - // Exact equality — SDK now auto-creates Performance Metrics section like CJS. - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir2, { recursive: true, force: true }); - await rm(sdkDir2, { recursive: true, force: true }); - } - }); - }); - - describe('phase mutations (subprocess parity)', () => { - async function withFreshPhaseProjects(): Promise<{ gsdDir: string; sdkDir: string }> { - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const gsdDir = join(tmpdir(), `gsd-golden-phase-gsd-${suffix}`); - const sdkDir = join(tmpdir(), `gsd-golden-phase-sdk-${suffix}`); - await setupMinimalStateProject(gsdDir); - await setupMinimalStateProject(sdkDir); - return { gsdDir, sdkDir }; - } - - it('phase.add matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhaseProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('phase', ['add', 'Phase parity add'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phase.add', ['Phase parity add'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('phase.add-batch matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhaseProjects(); - try { - const argv = ['add-batch', '--descriptions', '["Batch A","Batch B"]']; - const gsdOutput = await captureGsdToolsOutput('phase', argv, gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phase.add-batch', ['--descriptions', '["Batch A","Batch B"]'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('phase.insert matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhaseProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('phase', ['insert', '10', 'Inserted parity phase'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phase.insert', ['10', 'Inserted parity phase'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - }); - - describe('phases parity (subprocess parity)', () => { - async function withFreshPhasesProjects(): Promise<{ gsdDir: string; sdkDir: string }> { - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const gsdDir = join(tmpdir(), `gsd-golden-phases-gsd-${suffix}`); - const sdkDir = join(tmpdir(), `gsd-golden-phases-sdk-${suffix}`); - await setupPhasesFixture(gsdDir); - await setupPhasesFixture(sdkDir); - return { gsdDir, sdkDir }; - } - - it('phases.list matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhasesProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('phases', ['list'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phases.list', [], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('phases.list --type plans matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhasesProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('phases', ['list', '--type', 'plans'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phases.list', ['--type', 'plans'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('phases.list --type summaries matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhasesProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('phases', ['list', '--type', 'summaries'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phases.list', ['--type', 'summaries'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('phases.list --phase 10 matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhasesProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('phases', ['list', '--phase', '10'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phases.list', ['--phase', '10'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('phases.list --include-archived matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhasesProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('phases', ['list', '--include-archived'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phases.list', ['--include-archived'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('phases.clear --confirm matches gsd-tools.cjs', async () => { - const { gsdDir, sdkDir } = await withFreshPhasesProjects(); - try { - const gsdOutput = await captureGsdToolsOutput('phases', ['clear', '--confirm'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phases.clear', ['--confirm'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - }); - - describe('current-timestamp', () => { - it('SDK full format matches gsd-tools.cjs output structure', async () => { - const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['full'], PROJECT_DIR) as { timestamp: string }; - const registry = createRegistry(); - const sdkResult = await registry.dispatch('current-timestamp', ['full'], PROJECT_DIR); - const sdkData = sdkResult.data as { timestamp: string }; - - // Both produce { timestamp: } — compare structure and format, not exact value - expect(sdkData).toHaveProperty('timestamp'); - expect(gsdOutput).toHaveProperty('timestamp'); - // Both should be valid ISO timestamps - expect(new Date(sdkData.timestamp).toISOString()).toBe(sdkData.timestamp); - expect(new Date(gsdOutput.timestamp).toISOString()).toBe(gsdOutput.timestamp); - }); - - it('SDK date format matches gsd-tools.cjs output structure', async () => { - const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['date'], PROJECT_DIR) as { timestamp: string }; - const registry = createRegistry(); - const sdkResult = await registry.dispatch('current-timestamp', ['date'], PROJECT_DIR); - const sdkData = sdkResult.data as { timestamp: string }; - - // Both should match YYYY-MM-DD format - expect(sdkData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(gsdOutput.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}$/); - // Same date (unless test runs exactly at midnight — acceptable flake) - expect(sdkData.timestamp).toBe(gsdOutput.timestamp); - }); - - it('SDK filename format matches gsd-tools.cjs (same subprocess round-trip)', async () => { - const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['filename'], PROJECT_DIR); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('current-timestamp', ['filename'], PROJECT_DIR); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - // ─── Verification handler golden tests ────────────────────────────────── - - describe('verify.plan-structure', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const testFile = '.planning/phases/09-foundation-and-test-infrastructure/09-01-PLAN.md'; - const gsdOutput = await captureGsdToolsOutput('verify', ['plan-structure', testFile], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.plan-structure', [testFile], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - /** Normalize init.* payloads where legacy CJS injects commit_docs: false dynamically */ - const verifyInitParity = (sdk: unknown, cjs: unknown) => { - const s = structuredClone(sdk as Record); - const c = structuredClone(cjs as Record); - if (s && 'commit_docs' in s) s.commit_docs = true; - if (c && 'commit_docs' in c) c.commit_docs = true; - expect(s).toEqual(c); - }; - - describe('validate.consistency', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('validate', ['consistency'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('validate.consistency', [], REPO_ROOT); - - // Patch expected output to account for array-of-objects frontmatter parsing fix - // The old parser caused Phase 15 missing errors and missed frontmatter errors. - const patchedGsd = JSON.parse(JSON.stringify(gsdOutput)); - patchedGsd.warnings = (sdkResult.data as Record).warnings; - patchedGsd.warning_count = (sdkResult.data as Record).warning_count; - - expect(sdkResult.data).toEqual(patchedGsd); - }); - }); - - describe('validate.health', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('validate', ['health'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('validate.health', [], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('validate.agents', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('validate', ['agents'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('validate.agents', [], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - // ─── Init composition handler golden tests ───────────────────────────── - - describe('init.execute-phase', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('init', ['execute-phase', '9'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('init.execute-phase', ['9'], REPO_ROOT); - verifyInitParity(sdkResult.data, gsdOutput); - }); - }); - - describe('init.plan-phase', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('init', ['plan-phase', '9'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('init.plan-phase', ['9'], REPO_ROOT); - verifyInitParity(sdkResult.data, gsdOutput); - }); - }); - - describe('init.quick', () => { - it('SDK JSON matches gsd-tools.cjs except clock-derived quick fields', async () => { - const gsdOutput = await captureGsdToolsOutput('init', ['quick', 'test-task'], REPO_ROOT) as Record; - const registry = createRegistry(); - const sdkResult = await registry.dispatch('init.quick', ['test-task'], REPO_ROOT); - verifyInitParity( - omitInitQuickVolatile(sdkResult.data as Record), - omitInitQuickVolatile(gsdOutput), - ); - }); - }); - - describe('init.resume', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('init', ['resume'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('init.resume', [], REPO_ROOT); - verifyInitParity(sdkResult.data, gsdOutput); - }); - }); - - describe('init.verify-work', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('init', ['verify-work', '9'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('init.verify-work', ['9'], REPO_ROOT); - verifyInitParity(sdkResult.data, gsdOutput); - }); - }); - - describe('verify.phase-completeness', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('verify', ['phase-completeness', '9'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.phase-completeness', ['9'], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - // ─── State validate / sync (read + dry-run mutation parity) ───────────── - - describe('state.validate', () => { - it('SDK output matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('state', ['validate'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.validate', [], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('state.sync --verify', () => { - it('SDK dry-run output matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('state', ['sync', '--verify'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.sync', ['--verify'], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - // ─── detect-custom-files (temp config dir) ───────────────────────────── - - describe('detect-custom-files', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `gsd-golden-dcf-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(join(tmpDir, 'agents'), { recursive: true }); - await writeFile(join(tmpDir, 'gsd-file-manifest.json'), JSON.stringify({ version: 1, files: {} }), 'utf-8'); - await writeFile(join(tmpDir, 'agents', 'user-added.md'), '# custom\n', 'utf-8'); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - it('SDK output matches gsd-tools.cjs for manifest + custom file', async () => { - const args = ['--config-dir', tmpDir]; - const gsdOutput = await captureGsdToolsOutput('detect-custom-files', args, PROJECT_DIR); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('detect-custom-files', args, PROJECT_DIR); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - // ─── docs-init ───────────────────────────────────────────────────────── - - describe('docs-init', () => { - it('SDK output matches gsd-tools.cjs (normalized existing_docs order)', async () => { - const gsdOutput = await captureGsdToolsOutput('docs-init', [], REPO_ROOT) as Record; - const registry = createRegistry(); - const sdkResult = await registry.dispatch('docs-init', [], REPO_ROOT); - expect( - omitAgentInstallFields(normalizeDocsInitPayload(sdkResult.data as Record)), - ).toEqual( - omitAgentInstallFields(normalizeDocsInitPayload(gsdOutput)), - ); - }); - }); - - // ─── intel.update (JSON parity with `intel.cjs` — spawn message when enabled; disabled payload otherwise) ── - - describe('intel.update', () => { - it('SDK JSON matches gsd-tools.cjs (`intel update`)', async () => { - const gsdOutput = await captureGsdToolsOutput('intel', ['update'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('intel.update', [], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - // ─── Phase 6: verify.* parity tests ──────────────────────────────────────── - - describe('verify.references', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const testPhase = '9'; - const gsdOutput = await captureGsdToolsOutput('verify', ['references', testPhase], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.references', [testPhase], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('verify.commits', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const testPhase = '9'; - const gsdOutput = await captureGsdToolsOutput('verify', ['commits', testPhase], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.commits', [testPhase], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('verify.artifacts', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const testPhase = '9'; - const gsdOutput = await captureGsdToolsOutput('verify', ['artifacts', testPhase], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.artifacts', [testPhase], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('verify.key-links', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const testPhase = '9'; - const gsdOutput = await captureGsdToolsOutput('verify', ['key-links', testPhase], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.key-links', [testPhase], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('verify.schema-drift', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const testPhase = '9'; - const gsdOutput = await captureGsdToolsOutput('verify', ['schema-drift', testPhase], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.schema-drift', [testPhase], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - describe('verify.codebase-drift', () => { - it('SDK JSON matches gsd-tools.cjs', async () => { - const gsdOutput = await captureGsdToolsOutput('verify', ['codebase-drift'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.codebase-drift', [], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - }); - - // ─── Phase 6: roadmap.* parity tests ─────────────────────────────────────── - - describe('roadmap.annotate-dependencies', () => { - it('roadmap.annotate-dependencies matches gsd-tools.cjs on fixture', async () => { - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const gsdDir = join(tmpdir(), `gsd-golden-roadmap-annotate-gsd-${suffix}`); - const sdkDir = join(tmpdir(), `gsd-golden-roadmap-annotate-sdk-${suffix}`); - try { - await setupPhasesFixture(gsdDir); - await setupPhasesFixture(sdkDir); - const gsdOutput = await captureGsdToolsOutput('roadmap', ['annotate-dependencies', '10'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('roadmap.annotate-dependencies', ['10'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - }); - - // ─── Phase 6: phase.* parity tests ──────────────────────────────────────── - - describe('phase.next-decimal', () => { - it('phase.next-decimal matches gsd-tools.cjs on fixture', async () => { - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const gsdDir = join(tmpdir(), `gsd-golden-phase-nd-gsd-${suffix}`); - const sdkDir = join(tmpdir(), `gsd-golden-phase-nd-sdk-${suffix}`); - try { - await setupMinimalStateProject(gsdDir); - await setupMinimalStateProject(sdkDir); - const gsdOutput = await captureGsdToolsOutput('phase', ['next-decimal', '10'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phase.next-decimal', ['10'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - }); - - describe('phase.remove and phase.complete', () => { - it('phase.remove matches gsd-tools.cjs on fixture', async () => { - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const gsdDir = join(tmpdir(), `gsd-golden-phase-rm-gsd-${suffix}`); - const sdkDir = join(tmpdir(), `gsd-golden-phase-rm-sdk-${suffix}`); - try { - await setupPhasesFixture(gsdDir); - await setupPhasesFixture(sdkDir); - // Both remove phase 11 (complete in fixture, safe to remove with --force) - const gsdOutput = await captureGsdToolsOutput('phase', ['remove', '11', '--force'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phase.remove', ['11', '--force'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - - it('phase.complete matches gsd-tools.cjs on fixture', async () => { - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - const gsdDir = join(tmpdir(), `gsd-golden-phase-complete-gsd-${suffix}`); - const sdkDir = join(tmpdir(), `gsd-golden-phase-complete-sdk-${suffix}`); - try { - await setupPhasesFixture(gsdDir); - await setupPhasesFixture(sdkDir); - // Both complete phase 10 (which is in the fixture ROADMAP) - const gsdOutput = await captureGsdToolsOutput('phase', ['complete', '10'], gsdDir); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('phase.complete', ['10'], sdkDir); - expect(sdkResult.data).toEqual(gsdOutput); - } finally { - await rm(gsdDir, { recursive: true, force: true }); - await rm(sdkDir, { recursive: true, force: true }); - } - }); - }); -}); diff --git a/sdk/src/golden/init-golden-normalize.ts b/sdk/src/golden/init-golden-normalize.ts deleted file mode 100644 index c08475be2..000000000 --- a/sdk/src/golden/init-golden-normalize.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Normalize `init quick` payloads for golden parity: CJS runs in a subprocess with a - * different clock than the in-process SDK, so time-derived fields cannot match exactly. - */ - -/** Keys derived from `Date` / `quick_id` generation (init.cjs cmdInitQuick). */ -export const INIT_QUICK_VOLATILE_KEYS = ['quick_id', 'timestamp', 'branch_name', 'task_dir'] as const; - -export function omitInitQuickVolatile(data: Record): Record { - const o = { ...data }; - for (const k of INIT_QUICK_VOLATILE_KEYS) { - delete o[k]; - } - return o; -} diff --git a/sdk/src/golden/read-only-golden-rows.ts b/sdk/src/golden/read-only-golden-rows.ts deleted file mode 100644 index c0abbb0ce..000000000 --- a/sdk/src/golden/read-only-golden-rows.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Read-only subprocess golden rows: SDK `registry.dispatch` vs `gsd-tools.cjs` JSON on stdout. - * Imported by `read-only-parity.integration.test.ts` and `golden-policy.ts` coverage accounting. - */ - -export type JsonParityRow = { - canonical: string; - sdkArgs: string[]; - cjs: string; - cjsArgs: string[]; -}; - -/** Repo-relative fixtures (cwd = get-shit-done repo root). */ -export const GOLDEN_PLAN = '.planning/phases/09-foundation-and-test-infrastructure/09-01-PLAN.md'; - -/** - * Strict `toEqual` JSON parity rows verified on this repository. - * (Expand as more handlers are aligned with `gsd-tools.cjs`.) - */ -export const READ_ONLY_JSON_PARITY_ROWS: JsonParityRow[] = [ - { canonical: 'resolve-model', sdkArgs: ['gsd-planner'], cjs: 'resolve-model', cjsArgs: ['gsd-planner'] }, - { canonical: 'phase-plan-index', sdkArgs: ['9'], cjs: 'phase-plan-index', cjsArgs: ['9'] }, - { canonical: 'roadmap.get-phase', sdkArgs: ['9'], cjs: 'roadmap', cjsArgs: ['get-phase', '9'] }, - { canonical: 'list.todos', sdkArgs: [], cjs: 'list-todos', cjsArgs: [] }, - { canonical: 'phase.next-decimal', sdkArgs: ['9'], cjs: 'phase', cjsArgs: ['next-decimal', '9'] }, - { canonical: 'phases.list', sdkArgs: [], cjs: 'phases', cjsArgs: ['list'] }, - { canonical: 'verify.summary', sdkArgs: [GOLDEN_PLAN], cjs: 'verify-summary', cjsArgs: [GOLDEN_PLAN] }, - { canonical: 'verify.path-exists', sdkArgs: ['.planning/STATE.md'], cjs: 'verify-path-exists', cjsArgs: ['.planning/STATE.md'] }, - { canonical: 'verify.artifacts', sdkArgs: [GOLDEN_PLAN], cjs: 'verify', cjsArgs: ['artifacts', GOLDEN_PLAN] }, - { canonical: 'websearch', sdkArgs: ['typescript', '--limit', '1'], cjs: 'websearch', cjsArgs: ['typescript', '--limit', '1'] }, - { canonical: 'workstream.get', sdkArgs: ['default'], cjs: 'workstream', cjsArgs: ['get', 'default'] }, - { canonical: 'workstream.list', sdkArgs: [], cjs: 'workstream', cjsArgs: ['list'] }, - { canonical: 'workstream.status', sdkArgs: ['default'], cjs: 'workstream', cjsArgs: ['status', 'default'] }, - { canonical: 'learnings.list', sdkArgs: [], cjs: 'learnings', cjsArgs: ['list'] }, - { canonical: 'intel.status', sdkArgs: [], cjs: 'intel', cjsArgs: ['status'] }, - { canonical: 'intel.diff', sdkArgs: [], cjs: 'intel', cjsArgs: ['diff'] }, - { canonical: 'intel.validate', sdkArgs: [], cjs: 'intel', cjsArgs: ['validate'] }, - { canonical: 'intel.query', sdkArgs: ['gsd'], cjs: 'intel', cjsArgs: ['query', 'gsd'] }, - { - canonical: 'intel.extract-exports', - sdkArgs: ['sdk/src/query/utils.ts'], - cjs: 'intel', - cjsArgs: ['extract-exports', 'sdk/src/query/utils.ts'], - }, - { canonical: 'init.list-workspaces', sdkArgs: [], cjs: 'init', cjsArgs: ['list-workspaces'] }, - { canonical: 'agent-skills', sdkArgs: [], cjs: 'agent-skills', cjsArgs: [] }, - { canonical: 'scan-sessions', sdkArgs: ['--json'], cjs: 'scan-sessions', cjsArgs: ['--json'] }, - { canonical: 'stats.json', sdkArgs: [], cjs: 'stats', cjsArgs: ['json'] }, - { canonical: 'todo.match-phase', sdkArgs: ['9'], cjs: 'todo', cjsArgs: ['match-phase', '9'] }, - { canonical: 'verify.key-links', sdkArgs: [GOLDEN_PLAN], cjs: 'verify', cjsArgs: ['key-links', GOLDEN_PLAN] }, - { canonical: 'verify.schema-drift', sdkArgs: ['9'], cjs: 'verify', cjsArgs: ['schema-drift', '9'] }, - { canonical: 'state-snapshot', sdkArgs: [], cjs: 'state-snapshot', cjsArgs: [] }, - - { canonical: 'history.digest', sdkArgs: [], cjs: 'history-digest', cjsArgs: [] }, - { canonical: 'audit-uat', sdkArgs: [], cjs: 'audit-uat', cjsArgs: [] }, - { canonical: 'skill-manifest', sdkArgs: [], cjs: 'skill-manifest', cjsArgs: [] }, - { canonical: 'validate.agents', sdkArgs: [], cjs: 'validate', cjsArgs: ['agents'] }, - { - canonical: 'uat.render-checkpoint', - sdkArgs: ['--file', 'sdk/src/golden/fixtures/uat-render-checkpoint-sample.md'], - cjs: 'uat', - cjsArgs: ['render-checkpoint', '--file', 'sdk/src/golden/fixtures/uat-render-checkpoint-sample.md'], - }, -]; - -/** Canonicals from JSON rows plus special-case subprocess tests in read-only-parity integration. */ -export function readOnlyGoldenCanonicals(): Set { - const s = new Set(READ_ONLY_JSON_PARITY_ROWS.map((r) => r.canonical)); - s.add('verify.commits'); - s.add('config-path'); - s.add('state.json'); - s.add('state.load'); - s.add('audit-open'); - s.add('state.get'); - s.add('summary.extract'); - return s; -} diff --git a/sdk/src/golden/read-only-parity.integration.test.ts b/sdk/src/golden/read-only-parity.integration.test.ts deleted file mode 100644 index 68689cf9b..000000000 --- a/sdk/src/golden/read-only-parity.integration.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Read-only subprocess golden checks (SDK vs gsd-tools.cjs JSON). - * Row data: `read-only-golden-rows.ts`. Policy: `golden-policy.ts`, `QUERY-HANDLERS.md`. - */ -import { describe, it, expect } from 'vitest'; -import { captureGsdToolsOutput, captureGsdToolsStdout } from './capture.js'; -import { createRegistry } from '../query/index.js'; -import { resolve, dirname, normalize } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { execSync } from 'node:child_process'; -import { READ_ONLY_JSON_PARITY_ROWS } from './read-only-golden-rows.js'; - -const STABLE_JSON_PARITY_ROWS = READ_ONLY_JSON_PARITY_ROWS.filter( - (row) => row.canonical !== 'scan-sessions' && row.canonical !== 'audit-uat', -); - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, '..', '..', '..'); - -describe('Read-only golden parity (JSON toEqual)', () => { - it.each(STABLE_JSON_PARITY_ROWS)('$canonical matches gsd-tools.cjs JSON', async (row) => { - - const gsdOutput = await captureGsdToolsOutput(row.cjs, row.cjsArgs, REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch(row.canonical, row.sdkArgs, REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); -}); - -describe('config-path (plain stdout vs SDK { path })', () => { - it('SDK path matches gsd-tools.cjs plain-text stdout', async () => { - const out = await captureGsdToolsStdout('config-path', [], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('config-path', [], REPO_ROOT); - const data = sdkResult.data as { path?: string }; - expect(data.path).toBeDefined(); - expect(normalize(data.path!.trim())).toBe(normalize(out.trim())); - }); -}); - -describe('audit-open golden parity (excluding scanned_at)', () => { - it('SDK JSON matches gsd-tools.cjs except volatile scanned_at', async () => { - const gsdOutput = await captureGsdToolsOutput('audit-open', ['--json'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('audit-open', ['--json'], REPO_ROOT); - const strip = (d: unknown): Record => { - const o = { ...(d as Record) }; - delete o.scanned_at; - delete o.has_scan_errors; - return o; - }; - expect(strip(sdkResult.data)).toEqual(strip(gsdOutput)); - }); -}); - -describe('state.json golden parity (excluding last_updated)', () => { - it('SDK rebuilt frontmatter matches gsd-tools.cjs except volatile last_updated', async () => { - const gsdOutput = await captureGsdToolsOutput('state', ['json'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.json', [], REPO_ROOT); - const strip = (d: unknown): Record => { - const o = { ...(d as Record) }; - delete o.last_updated; - return o; - }; - expect(strip(sdkResult.data)).toEqual(strip(gsdOutput)); - }); -}); - -describe('summary.extract golden parity (with array-of-objects fix)', () => { - it('SDK JSON matches gsd-tools.cjs except for intentional array-of-objects parsing fix', async () => { - const gsdOutput = await captureGsdToolsOutput('summary-extract', ['sdk/src/golden/fixtures/summary-extract-sample.md'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('summary.extract', ['sdk/src/golden/fixtures/summary-extract-sample.md'], REPO_ROOT); - - // The SDK correctly parses array-of-objects, whereas CJS parses them as strings. - // Patch the CJS output to reflect the CodeRabbit bugfix. - const patchedGsd = JSON.parse(JSON.stringify(gsdOutput)); - if (patchedGsd.tech_added && Array.isArray(patchedGsd.tech_added)) { - patchedGsd.tech_added = patchedGsd.tech_added.map((t: any) => - t === 'name: typescript' ? { name: 'typescript' } : t - ); - } - - expect(sdkResult.data).toEqual(patchedGsd); - }); -}); - -describe('state.load golden parity', () => { - it('SDK load payload matches gsd-tools.cjs state load', async () => { - const gsdOutput = await captureGsdToolsOutput('state', ['load'], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.load', [], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); -}); - -describe('state.get golden parity', () => { - it('matches full STATE.md when no field (same as `state get` with no section)', async ({ skip }) => { - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.get', [], REPO_ROOT); - // Repo may not have .planning/STATE.md; skip parity in that case. - if ((sdkResult.data as Record)?.error === 'STATE.md not found') skip(); - const gsdOutput = await captureGsdToolsOutput('state', ['get'], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); - - it('matches single frontmatter field when `state get `', async ({ skip }) => { - const registry = createRegistry(); - const sdkResult = await registry.dispatch('state.get', ['milestone'], REPO_ROOT); - if ((sdkResult.data as Record)?.error === 'STATE.md not found') skip(); - const gsdOutput = await captureGsdToolsOutput('state', ['get', 'milestone'], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); -}); - -describe('verify.commits golden parity', () => { - it('SDK output matches gsd-tools.cjs for two SHAs', async () => { - const revs = execSync('git rev-list --max-count=2 HEAD', { cwd: REPO_ROOT, encoding: 'utf-8' }) - .trim() - .split('\n') - .filter(Boolean); - if (revs.length < 2) { - throw new Error('verify.commits parity requires at least 2 commits in checkout history'); - } - const b = revs[0]; - const a = revs[1]; - const gsdOutput = await captureGsdToolsOutput('verify', ['commits', a, b], REPO_ROOT); - const registry = createRegistry(); - const sdkResult = await registry.dispatch('verify.commits', [a, b], REPO_ROOT); - expect(sdkResult.data).toEqual(gsdOutput); - }); -}); diff --git a/sdk/src/golden/registry-canonical-commands.ts b/sdk/src/golden/registry-canonical-commands.ts deleted file mode 100644 index edcd722a0..000000000 --- a/sdk/src/golden/registry-canonical-commands.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Canonical registry command strings for golden parity — one primary name per unique - * native handler (dedupes dotted vs space-delimited aliases on the same function). - */ - -import { createRegistry } from '../query/index.js'; -import type { QueryHandler } from '../query/utils.js'; - -export function getCanonicalRegistryCommands(): string[] { - const registry = createRegistry(); - const byHandler = new Map(); - for (const cmd of registry.commands()) { - const h = registry.getHandler(cmd); - if (!h) continue; - const list = byHandler.get(h) ?? []; - list.push(cmd); - byHandler.set(h, list); - } - const out: string[] = []; - for (const cmds of byHandler.values()) { - cmds.sort((a, b) => a.localeCompare(b)); - const dotted = cmds.find((c) => c.includes('.')); - if (dotted) { - out.push(dotted); - continue; - } - const kebab = cmds.find((c) => c.includes('-')); - out.push(kebab ?? cmds[0]!); - } - return out.sort((a, b) => a.localeCompare(b)); -} diff --git a/sdk/src/gsd-tools-error.test.ts b/sdk/src/gsd-tools-error.test.ts deleted file mode 100644 index 2e1973198..000000000 --- a/sdk/src/gsd-tools-error.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { GSDToolsError } from './gsd-tools-error.js'; - -describe('GSDToolsError constructors', () => { - it('builds timeout-classified errors', () => { - const err = GSDToolsError.timeout('timeout', 'state', ['load'], '', 1000); - expect(err.classification).toEqual({ kind: 'timeout', timeoutMs: 1000 }); - expect(err.exitCode).toBeNull(); - }); - - it('builds failure-classified errors', () => { - const err = GSDToolsError.failure('boom', 'state', ['load'], 1); - expect(err.classification).toEqual({ kind: 'failure' }); - expect(err.exitCode).toBe(1); - }); - - it('defaults direct constructor to failure classification', () => { - const err = new GSDToolsError('boom', 'state', ['load'], 1, 'stderr'); - expect(err.classification).toEqual({ kind: 'failure' }); - }); -}); diff --git a/sdk/src/gsd-tools-error.ts b/sdk/src/gsd-tools-error.ts deleted file mode 100644 index 713476d1b..000000000 --- a/sdk/src/gsd-tools-error.ts +++ /dev/null @@ -1,65 +0,0 @@ -export interface GSDToolsErrorClassification { - kind: 'timeout' | 'failure'; - timeoutMs?: number; -} - -function timeoutClassification(timeoutMs?: number): GSDToolsErrorClassification { - return timeoutMs === undefined ? { kind: 'timeout' } : { kind: 'timeout', timeoutMs }; -} - -function failureClassification(): GSDToolsErrorClassification { - return { kind: 'failure' }; -} - -export class GSDToolsError extends Error { - constructor( - message: string, - public readonly command: string, - public readonly args: string[], - public readonly exitCode: number | null, - public readonly stderr: string, - options?: { cause?: unknown; classification?: GSDToolsErrorClassification }, - ) { - super(message, options); - this.name = 'GSDToolsError'; - this.classification = options?.classification ?? failureClassification(); - } - - static timeout( - message: string, - command: string, - args: string[], - stderr = '', - timeoutMs?: number, - options?: { cause?: unknown; exitCode?: number | null }, - ): GSDToolsError { - return new GSDToolsError( - message, - command, - args, - options?.exitCode ?? null, - stderr, - { cause: options?.cause, classification: timeoutClassification(timeoutMs) }, - ); - } - - static failure( - message: string, - command: string, - args: string[], - exitCode: number | null, - stderr = '', - options?: { cause?: unknown }, - ): GSDToolsError { - return new GSDToolsError( - message, - command, - args, - exitCode, - stderr, - { cause: options?.cause, classification: failureClassification() }, - ); - } - - public readonly classification: GSDToolsErrorClassification; -} diff --git a/sdk/src/gsd-tools.test.ts b/sdk/src/gsd-tools.test.ts deleted file mode 100644 index 035fdbe50..000000000 --- a/sdk/src/gsd-tools.test.ts +++ /dev/null @@ -1,472 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { GSDTools, GSDToolsError, resolveGsdToolsPath } from './gsd-tools.js'; -import { setTransportPolicy, clearTransportPolicy } from './gsd-transport-policy.js'; -import { mkdir, writeFile, rm } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir, homedir } from 'node:os'; -import { fileURLToPath } from 'node:url'; - -const BUNDLED_GSD_TOOLS_PATH = fileURLToPath( - new URL('../../get-shit-done/bin/gsd-tools.cjs', import.meta.url), -); - -describe('GSDTools', () => { - let tmpDir: string; - let fixtureDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `gsd-tools-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fixtureDir = join(tmpDir, 'fixtures'); - await mkdir(fixtureDir, { recursive: true }); - await mkdir(join(tmpDir, '.planning'), { recursive: true }); - }); - - afterEach(async () => { - clearTransportPolicy(); - await rm(tmpDir, { recursive: true, force: true }); - }); - - // ─── Helper: create a Node script that outputs something ──────────────── - - async function createScript(name: string, code: string): Promise { - const scriptPath = join(fixtureDir, name); - await writeFile(scriptPath, code, { mode: 0o755 }); - return scriptPath; - } - - // ─── exec() tests ────────────────────────────────────────────────────── - - describe('exec()', () => { - it('parses valid JSON output', async () => { - // Create a script that ignores args and outputs JSON - const scriptPath = await createScript( - 'echo-json.cjs', - `process.stdout.write(JSON.stringify({ status: "ok", count: 42 }));`, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.exec('state', ['load']); - - expect(result).toEqual({ status: 'ok', count: 42 }); - }); - - it('handles @file: prefix by reading referenced file', async () => { - // Write a large JSON result to a file - const resultFile = join(fixtureDir, 'big-result.json'); - const bigData = { items: Array.from({ length: 100 }, (_, i) => ({ id: i })) }; - await writeFile(resultFile, JSON.stringify(bigData)); - - // Script outputs @file: prefix - const scriptPath = await createScript( - 'file-ref.cjs', - `process.stdout.write('@file:${resultFile.replace(/\\/g, '\\\\')}');`, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.exec('state', ['load']); - - expect(result).toEqual(bigData); - }); - - it('returns null for empty stdout', async () => { - const scriptPath = await createScript( - 'empty-output.cjs', - `// outputs nothing`, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.exec('state', ['load']); - - expect(result).toBeNull(); - }); - - it('throws GSDToolsError on non-zero exit code', async () => { - const scriptPath = await createScript( - 'fail.cjs', - `process.stderr.write('something went wrong\\n'); process.exit(1);`, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - - try { - await tools.exec('state', ['load']); - expect.fail('Should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(GSDToolsError); - const gsdErr = err as GSDToolsError; - expect(gsdErr.command).toBe('state'); - expect(gsdErr.args).toEqual(['load']); - expect(gsdErr.stderr).toContain('something went wrong'); - expect(gsdErr.exitCode).toBeGreaterThan(0); - } - }); - - it('throws GSDToolsError with context when gsd-tools.cjs not found', async () => { - const tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: '/nonexistent/path/gsd-tools.cjs', - preferNativeQuery: false, - }); - - await expect(tools.exec('state', ['load'])).rejects.toThrow(GSDToolsError); - }); - - it('throws parse error when stdout is non-JSON', async () => { - const scriptPath = await createScript( - 'bad-json.cjs', - `process.stdout.write('Not JSON at all');`, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - - try { - await tools.exec('state', ['load']); - expect.fail('Should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(GSDToolsError); - const gsdErr = err as GSDToolsError; - expect(gsdErr.message).toContain('Failed to parse'); - expect(gsdErr.message).toContain('Not JSON at all'); - } - }); - - it('throws when @file: points to nonexistent file', async () => { - const scriptPath = await createScript( - 'bad-file-ref.cjs', - `process.stdout.write('@file:/tmp/does-not-exist-${Date.now()}.json');`, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - - await expect(tools.exec('state', ['load'])).rejects.toThrow(GSDToolsError); - }); - - it('handles timeout by killing child process', async () => { - const scriptPath = await createScript( - 'hang.cjs', - `setTimeout(() => {}, 60000); // hang for 60s`, - ); - - const tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: scriptPath, - timeoutMs: 500, - preferNativeQuery: false, - }); - - try { - await tools.exec('state', ['load']); - expect.fail('Should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(GSDToolsError); - const gsdErr = err as GSDToolsError; - expect(gsdErr.message).toContain('timed out'); - } - }, 10_000); - - it('uses subprocess fallback when native handler throws and policy allows fallback', async () => { - const scriptPath = await createScript( - 'fallback-ok.cjs', - `process.stdout.write(JSON.stringify({ from: 'subprocess-fallback' }));`, - ); - - const tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: scriptPath, - allowFallbackToSubprocess: true, - }); - setTransportPolicy('verify.path-exists', { allowFallbackToSubprocess: true }); - - const result = await tools.exec('verify.path-exists', []); - expect(result).toEqual({ from: 'subprocess-fallback' }); - }); - - it('fails fast in strictSdk mode when command has no native adapter', async () => { - const scriptPath = await createScript( - 'strict-should-not-run.cjs', - `process.stdout.write(JSON.stringify({ should: 'not-run' }));`, - ); - - const tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: scriptPath, - strictSdk: true, - allowFallbackToSubprocess: true, - }); - - await expect(tools.exec('graphify', [])).rejects.toThrow( - "Strict SDK mode: command 'graphify' has no native adapter", - ); - }); - - it('preserves GSDToolsError contract when native handler throws and fallback disabled', async () => { - const scriptPath = await createScript( - 'should-not-run.cjs', - `process.stdout.write(JSON.stringify({ should: 'not-run' }));`, - ); - - const tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: scriptPath, - allowFallbackToSubprocess: false, - }); - setTransportPolicy('verify.path-exists', { allowFallbackToSubprocess: false }); - - try { - await tools.exec('verify.path-exists', []); - expect.fail('Should have thrown'); - } catch (err) { - expect(err).toBeInstanceOf(GSDToolsError); - const gsdErr = err as GSDToolsError; - expect(gsdErr.command).toBe('verify.path-exists'); - expect(gsdErr.args).toEqual([]); - expect(gsdErr.stderr).toBe(''); - expect(typeof gsdErr.exitCode === 'number').toBe(true); - } - }); - }); - - // ─── Typed method tests ──────────────────────────────────────────────── - - describe('typed methods', () => { - it('stateLoad() calls exec with correct args', async () => { - const scriptPath = await createScript( - 'state-load.cjs', - ` - const args = process.argv.slice(2); - // Script receives: state load (no --raw when policy is json) - if (args[0] === 'state' && args[1] === 'load') { - process.stdout.write(JSON.stringify({ phase: '3', status: 'executing' })); - } else { - process.stderr.write('unexpected args: ' + args.join(' ')); - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.stateLoad(); - - expect(result).toEqual({ phase: '3', status: 'executing' }); - }); - - it('commit() passes message and optional files', async () => { - const scriptPath = await createScript( - 'commit.cjs', - ` - const args = process.argv.slice(2); - // commit --files f1 f2 --raw — returns a git SHA - process.stdout.write('f89ae07'); - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.commit('test message', ['file1.md', 'file2.md']); - - expect(result).toBe('f89ae07'); - }); - - it('roadmapAnalyze() calls roadmap analyze', async () => { - const scriptPath = await createScript( - 'roadmap.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'roadmap' && args[1] === 'analyze') { - process.stdout.write(JSON.stringify({ phases: [] })); - } else { - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.roadmapAnalyze(); - - expect(result).toEqual({ phases: [] }); - }); - - it('verifySummary() passes path argument', async () => { - const scriptPath = await createScript( - 'verify.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'verify-summary' && args[1] === '/path/to/SUMMARY.md') { - process.stdout.write('passed'); - } else { - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.verifySummary('/path/to/SUMMARY.md'); - - expect(result).toBe('passed'); - }); - }); - - // ─── Integration-style test ──────────────────────────────────────────── - - describe('integration', () => { - it('handles large JSON output (>100KB)', async () => { - const largeArray = Array.from({ length: 5000 }, (_, i) => ({ - id: i, - name: `item-${i}`, - data: 'x'.repeat(20), - })); - const largeJson = JSON.stringify(largeArray); - - const scriptPath = await createScript( - 'large-output.cjs', - `process.stdout.write(${JSON.stringify(largeJson)});`, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.exec('state', ['load']); - - expect(Array.isArray(result)).toBe(true); - expect((result as unknown[]).length).toBe(5000); - }); - }); - - // ─── initNewProject() tests ──────────────────────────────────────────── - - describe('initNewProject()', () => { - it('calls init new-project and returns typed result', async () => { - const mockResult = { - researcher_model: 'claude-sonnet-4-6', - synthesizer_model: 'claude-sonnet-4-6', - roadmapper_model: 'claude-sonnet-4-6', - commit_docs: true, - project_exists: false, - has_codebase_map: false, - planning_exists: false, - has_existing_code: false, - has_package_file: false, - is_brownfield: false, - needs_codebase_map: false, - has_git: true, - brave_search_available: false, - firecrawl_available: false, - exa_search_available: false, - project_path: '.planning/PROJECT.md', - project_root: '/tmp/test', - }; - - const scriptPath = await createScript( - 'init-new-project.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'init' && args[1] === 'new-project') { - process.stdout.write(JSON.stringify(${JSON.stringify(mockResult)})); - } else { - process.stderr.write('unexpected args: ' + args.join(' ')); - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.initNewProject(); - - expect(result.researcher_model).toBe('claude-sonnet-4-6'); - expect(result.project_exists).toBe(false); - expect(result.has_git).toBe(true); - expect(result.is_brownfield).toBe(false); - expect(result.project_path).toBe('.planning/PROJECT.md'); - }); - - it('propagates errors from gsd-tools', async () => { - const scriptPath = await createScript( - 'init-fail.cjs', - `process.stderr.write('init failed\\n'); process.exit(1);`, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - - await expect(tools.initNewProject()).rejects.toThrow(GSDToolsError); - }); - }); - - // ─── resolveGsdToolsPath() tests ──────────────────────────────────────── - - describe('resolveGsdToolsPath()', () => { - it('prefers bundled gsd-tools over project .claude when the bundled file exists', async () => { - const localBinDir = join(tmpDir, '.claude', 'get-shit-done', 'bin'); - await mkdir(localBinDir, { recursive: true }); - await writeFile(join(localBinDir, 'gsd-tools.cjs'), '// stub'); - - const result = resolveGsdToolsPath(tmpDir); - if (existsSync(BUNDLED_GSD_TOOLS_PATH)) { - expect(result).toBe(BUNDLED_GSD_TOOLS_PATH); - } else { - expect(result).toBe(join(localBinDir, 'gsd-tools.cjs')); - } - }); - - it('falls back to bundled repo path when repo-local does not exist', () => { - const result = resolveGsdToolsPath(tmpDir); - const expected = existsSync(BUNDLED_GSD_TOOLS_PATH) - ? BUNDLED_GSD_TOOLS_PATH - : join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs'); - - expect(result).toBe(expected); - }); - - it('uses explicit gsdToolsPath when provided (overrides bundled / .claude resolution)', async () => { - const localBinDir = join(tmpDir, '.claude', 'get-shit-done', 'bin'); - await mkdir(localBinDir, { recursive: true }); - const scriptPath = join(localBinDir, 'gsd-tools.cjs'); - await writeFile( - scriptPath, - `process.stdout.write(JSON.stringify({ source: "local" }));`, - { mode: 0o755 }, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.exec('test', []); - expect(result).toEqual({ source: 'local' }); - }); - }); - - // ─── configSet() tests ───────────────────────────────────────────────── - - describe('configSet()', () => { - it('calls config-set with key and value args', async () => { - const scriptPath = await createScript( - 'config-set.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'config-set' && args[1] === 'workflow.auto_advance' && args[2] === 'true' && args.includes('--raw')) { - process.stdout.write('workflow.auto_advance=true'); - } else { - process.stderr.write('unexpected args: ' + args.join(' ')); - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.configSet('workflow.auto_advance', 'true'); - - expect(result).toBe('workflow.auto_advance=true'); - }); - - it('passes string values without coercion', async () => { - const scriptPath = await createScript( - 'config-set-str.cjs', - ` - const args = process.argv.slice(2); - // config-set mode yolo --raw - process.stdout.write(args[1] + '=' + args[2]); - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.configSet('mode', 'yolo'); - - expect(result).toBe('mode=yolo'); - }); - }); -}); diff --git a/sdk/src/gsd-tools.ts b/sdk/src/gsd-tools.ts deleted file mode 100644 index be721e17f..000000000 --- a/sdk/src/gsd-tools.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * GSD Tools Bridge — programmatic access to GSD planning operations. - * - * By default routes commands through the SDK **query registry** (same handlers as - * `gsd-sdk query`) so `PhaseRunner`, `InitRunner`, and `GSD` share contracts with - * the typed CLI. Runner hot-path helpers (`initPhaseOp`, `phasePlanIndex`, - * `phaseComplete`, `initNewProject`, `configSet`, `commit`) call - * `registry.dispatch()` with canonical keys when native query is active, avoiding - * repeated argv resolution. When a workstream is set, dispatches to `gsd-tools.cjs` so - * workstream env stays aligned with CJS. - */ - - -import type { InitNewProjectInfo, PhaseOpInfo, PhasePlanIndex, RoadmapAnalysis } from './types.js'; -import type { GSDEventStream } from './event-stream.js'; -import { toToolsErrorFromUnknown } from './query-tools-error-factory.js'; -import { GSDToolsError } from './gsd-tools-error.js'; -import type { QueryCommandResolution } from './query/query-command-resolution-strategy.js'; -import { resolveGsdToolsPath } from './query-gsd-tools-path.js'; -import { createGSDToolsRuntime } from './query-gsd-tools-runtime.js'; -import { QueryCommandExecutor } from './query-command-executor.js'; -import { QueryHotpathMethods } from './query-hotpath-methods.js'; -import { QueryRuntimeBridge, type RuntimeBridgeOptions } from './query-runtime-bridge.js'; - -export { GSDToolsError } from './gsd-tools-error.js'; - -// ─── GSDTools class ────────────────────────────────────────────────────────── - -const DEFAULT_TIMEOUT_MS = 30_000; - - -export class GSDTools { - private readonly projectDir: string; - private readonly gsdToolsPath: string; - private readonly timeoutMs: number; - private readonly workstream?: string; - private readonly bridge: QueryRuntimeBridge; - private readonly preferNativeQuery: boolean; - private readonly commandExecutor: QueryCommandExecutor; - private readonly hotpathMethods: QueryHotpathMethods; - - constructor(opts: { - projectDir: string; - gsdToolsPath?: string; - timeoutMs?: number; - workstream?: string; - /** When set, mutation handlers emit the same events as `gsd-sdk query`. */ - eventStream?: GSDEventStream; - /** Correlation id for mutation events when `eventStream` is set. */ - sessionId?: string; - /** - * When true (default), route known commands through the SDK query registry. - * Set false in tests that substitute a mock `gsdToolsPath` script. - */ - preferNativeQuery?: boolean; - /** When true, fail if a command has no native registry adapter. */ - strictSdk?: boolean; - /** Explicit subprocess bridge policy. Default false for SDK-native mode. */ - allowFallbackToSubprocess?: boolean; - /** Structured runtime bridge dispatch observability callback. */ - onDispatchEvent?: RuntimeBridgeOptions['onDispatchEvent']; - }) { - this.projectDir = opts.projectDir; - this.gsdToolsPath = - opts.gsdToolsPath ?? resolveGsdToolsPath(opts.projectDir); - this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - this.workstream = opts.workstream; - this.preferNativeQuery = opts.preferNativeQuery ?? true; - - const runtime = createGSDToolsRuntime({ - projectDir: this.projectDir, - gsdToolsPath: this.gsdToolsPath, - timeoutMs: this.timeoutMs, - workstream: this.workstream, - eventStream: opts.eventStream, - sessionId: opts.sessionId, - shouldUseNativeQuery: () => this.shouldUseNativeQuery(), - execJsonFallback: (legacyCommand, legacyArgs) => this.exec(legacyCommand, legacyArgs), - execRawFallback: (legacyCommand, legacyArgs) => this.execRaw(legacyCommand, legacyArgs), - strictSdk: opts.strictSdk, - allowFallbackToSubprocess: opts.allowFallbackToSubprocess, - onDispatchEvent: opts.onDispatchEvent, - }); - - this.bridge = runtime.bridge; - this.commandExecutor = new QueryCommandExecutor({ - nativeMatch: (command, args) => this.nativeMatch(command, args), - execute: async (input) => this.bridge.execute({ - legacyCommand: input.legacyCommand, - legacyArgs: input.legacyArgs, - registryCommand: input.registryCommand, - registryArgs: input.registryArgs, - mode: input.mode, - projectDir: this.projectDir, - workstream: this.workstream, - }), - }); - - this.hotpathMethods = new QueryHotpathMethods({ - dispatchNativeHotpath: (legacyCommand, legacyArgs, registryCommand, registryArgs, mode) => - this.dispatchNativeHotpath(legacyCommand, legacyArgs, registryCommand, registryArgs, mode), - }); - } - - private shouldUseNativeQuery(): boolean { - return this.preferNativeQuery && !this.workstream; - } - - private nativeMatch(command: string, args: string[]): QueryCommandResolution | null { - return this.bridge.resolve(command, args); - } - - private async dispatchNativeHotpath( - legacyCommand: string, - legacyArgs: string[], - registryCommand: string, - registryArgs: string[], - mode: 'json' | 'raw', - ): Promise { - return this.executeWithToolsError(legacyCommand, legacyArgs, () => - this.bridge.dispatchHotpath( - legacyCommand, - legacyArgs, - registryCommand, - registryArgs, - mode, - )); - } - - private async executeWithToolsError(command: string, args: string[], work: () => Promise): Promise { - try { - return await work(); - } catch (err) { - if (err instanceof GSDToolsError) throw err; - throw toToolsErrorFromUnknown(command, args, err); - } - } - - // ─── Core exec ─────────────────────────────────────────────────────────── - - /** - * Execute a gsd-tools command and return parsed JSON output. - * Handles the `@file:` prefix pattern for large results. - */ - async exec(command: string, args: string[] = []): Promise { - return this.executeWithToolsError(command, args, () => this.commandExecutor.exec(command, args, 'json')); - } - - // ─── Raw exec (no JSON parsing) ─────────────────────────────────────── - - /** - * Execute a gsd-tools command and return raw stdout without JSON parsing. - * Use for commands like `config-set` that return plain text, not JSON. - */ - async execRaw(command: string, args: string[] = []): Promise { - return this.executeWithToolsError(command, args, async () => { - const out = await this.commandExecutor.exec(command, args, 'raw'); - return typeof out === 'string' ? out : String(out ?? ''); - }); - } - - - // ─── Typed convenience methods ───────────────────────────────────────── - - async stateLoad(): Promise { - return this.exec('state', ['load']); - } - - async roadmapAnalyze(): Promise { - return this.exec('roadmap', ['analyze']) as Promise; - } - - async phaseComplete(phase: string): Promise { - return this.hotpathMethods.phaseComplete(phase); - } - - async commit(message: string, files?: string[]): Promise { - return this.hotpathMethods.commit(message, files); - } - - async verifySummary(path: string): Promise { - return this.execRaw('verify-summary', [path]); - } - - async initExecutePhase(phase: string): Promise { - return this.execRaw('state', ['begin-phase', '--phase', phase]); - } - - /** - * Query phase state from gsd-tools.cjs `init phase-op`. - * Returns a typed PhaseOpInfo describing what exists on disk for this phase. - */ - async initPhaseOp(phaseNumber: string): Promise { - return this.hotpathMethods.initPhaseOp(phaseNumber); - } - - /** - * Get a config value via the `config-get` surface (CJS and registry use the same key path). - */ - async configGet(key: string): Promise { - return this.hotpathMethods.configGet(key); - } - - /** - * Begin phase state tracking in gsd-tools.cjs. - */ - async stateBeginPhase(phaseNumber: string): Promise { - return this.execRaw('state', ['begin-phase', '--phase', phaseNumber]); - } - - /** - * Get the plan index for a phase, grouping plans into dependency waves. - * Returns typed PhasePlanIndex with wave assignments and completion status. - */ - async phasePlanIndex(phaseNumber: string): Promise { - return this.hotpathMethods.phasePlanIndex(phaseNumber); - } - - /** - * Query new-project init state from gsd-tools.cjs `init new-project`. - * Returns project metadata, model configs, brownfield detection, etc. - */ - async initNewProject(): Promise { - return this.hotpathMethods.initNewProject(); - } - - /** - * Set a config value via gsd-tools.cjs `config-set`. - * Handles type coercion (booleans, numbers, JSON) on the gsd-tools side. - * Note: config-set returns `key=value` text, not JSON, so we use execRaw. - */ - async configSet(key: string, value: string): Promise { - return this.hotpathMethods.configSet(key, value); - } -} - -export { resolveGsdToolsPath } from './query-gsd-tools-path.js'; diff --git a/sdk/src/gsd-transport-policy.test.ts b/sdk/src/gsd-transport-policy.test.ts deleted file mode 100644 index c49f376e5..000000000 --- a/sdk/src/gsd-transport-policy.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { resolveTransportPolicy, setTransportPolicy, clearTransportPolicy } from './gsd-transport-policy.js'; - -describe('gsd-transport-policy', () => { - afterEach(() => { - clearTransportPolicy(); - }); - - it('uses legacy-safe defaults for unknown command', () => { - const policy = resolveTransportPolicy('unknown-cmd'); - expect(policy.preferNative).toBe(true); - expect(policy.allowFallbackToSubprocess).toBe(true); - expect(policy.outputMode).toBe('json'); - }); - - it('applies built-in raw output override', () => { - const policy = resolveTransportPolicy('config-set'); - expect(policy.outputMode).toBe('raw'); - expect(policy.allowFallbackToSubprocess).toBe(true); - }); - - it('applies verify-summary alias raw overrides', () => { - expect(resolveTransportPolicy('verify-summary').outputMode).toBe('raw'); - expect(resolveTransportPolicy('verify.summary').outputMode).toBe('raw'); - expect(resolveTransportPolicy('verify summary').outputMode).toBe('raw'); - }); - - it('supports per-command override updates', () => { - setTransportPolicy('state', { allowFallbackToSubprocess: false, outputMode: 'raw' }); - const policy = resolveTransportPolicy('state'); - expect(policy.allowFallbackToSubprocess).toBe(false); - expect(policy.outputMode).toBe('raw'); - }); -}); diff --git a/sdk/src/gsd-transport-policy.ts b/sdk/src/gsd-transport-policy.ts deleted file mode 100644 index 3cc6bff93..000000000 --- a/sdk/src/gsd-transport-policy.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { TRANSPORT_RAW_COMMANDS } from './query/query-policy-capability.js'; - -export type TransportMode = 'json' | 'raw'; - -export interface TransportPolicy { - preferNative: boolean; - allowFallbackToSubprocess: boolean; - outputMode: TransportMode; -} - -const DEFAULT_POLICY: TransportPolicy = { - preferNative: true, - allowFallbackToSubprocess: true, - outputMode: 'json', -}; - -const BUILTIN_COMMAND_POLICY: Record> = Object.fromEntries( - TRANSPORT_RAW_COMMANDS.map((command) => [command, { outputMode: 'raw' as const }]), -); - -const COMMAND_POLICY_OVERRIDES: Record> = {}; - -export function resolveTransportPolicy(command: string): TransportPolicy { - const override = { - ...(BUILTIN_COMMAND_POLICY[command] ?? {}), - ...(COMMAND_POLICY_OVERRIDES[command] ?? {}), - }; - return { - preferNative: override.preferNative ?? DEFAULT_POLICY.preferNative, - allowFallbackToSubprocess: - override.allowFallbackToSubprocess ?? DEFAULT_POLICY.allowFallbackToSubprocess, - outputMode: override.outputMode ?? DEFAULT_POLICY.outputMode, - }; -} - -export function setTransportPolicy(command: string, override: Partial): void { - COMMAND_POLICY_OVERRIDES[command] = { ...(COMMAND_POLICY_OVERRIDES[command] ?? {}), ...override }; -} - -export function clearTransportPolicy(command?: string): void { - if (command) { - delete COMMAND_POLICY_OVERRIDES[command]; - return; - } - for (const key of Object.keys(COMMAND_POLICY_OVERRIDES)) { - delete COMMAND_POLICY_OVERRIDES[key]; - } -} diff --git a/sdk/src/gsd-transport.test.ts b/sdk/src/gsd-transport.test.ts deleted file mode 100644 index 2d6095198..000000000 --- a/sdk/src/gsd-transport.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { GSDToolsError } from './gsd-tools-error.js'; -import { QueryRegistry } from './query/registry.js'; -import { GSDTransport } from './gsd-transport.js'; - -describe('GSDTransport', () => { - it('uses native adapter when command registered and policy prefers native', async () => { - const registry = new QueryRegistry(); - registry.register('state.load', async () => ({ data: { ok: true } })); - - const adapters = { - dispatchNative: vi.fn(async () => ({ data: { ok: true } })), - execSubprocessJson: vi.fn(async () => ({ ok: false })), - execSubprocessRaw: vi.fn(async () => 'subprocess'), - }; - - const transport = new GSDTransport(registry, adapters); - const result = await transport.run({ - legacyCommand: 'state', - legacyArgs: ['load'], - registryCommand: 'state.load', - registryArgs: [], - mode: 'json', - projectDir: '/tmp', - }, { - preferNative: true, - allowFallbackToSubprocess: true, - }); - - expect(result).toEqual({ ok: true }); - expect(adapters.dispatchNative).toHaveBeenCalledOnce(); - expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); - }); - - it('falls back to subprocess when native throws and policy allows fallback', async () => { - const registry = new QueryRegistry(); - registry.register('state.load', async () => ({ data: { ok: true } })); - - const adapters = { - dispatchNative: vi.fn(async () => { - throw new Error('native failed'); - }), - execSubprocessJson: vi.fn(async () => ({ ok: 'fallback' })), - execSubprocessRaw: vi.fn(async () => 'fallback-raw'), - }; - - const transport = new GSDTransport(registry, adapters); - const result = await transport.run({ - legacyCommand: 'state', - legacyArgs: ['load'], - registryCommand: 'state.load', - registryArgs: [], - mode: 'json', - projectDir: '/tmp', - }, { - preferNative: true, - allowFallbackToSubprocess: true, - }); - - expect(result).toEqual({ ok: 'fallback' }); - expect(adapters.dispatchNative).toHaveBeenCalledOnce(); - expect(adapters.execSubprocessJson).toHaveBeenCalledOnce(); - }); - - it('hard-fails when native throws and fallback disabled', async () => { - const registry = new QueryRegistry(); - registry.register('state.load', async () => ({ data: { ok: true } })); - - const adapters = { - dispatchNative: vi.fn(async () => { - throw new Error('native failed'); - }), - execSubprocessJson: vi.fn(async () => ({ ok: 'fallback' })), - execSubprocessRaw: vi.fn(async () => 'fallback-raw'), - }; - - const transport = new GSDTransport(registry, adapters); - - await expect(transport.run({ - legacyCommand: 'state', - legacyArgs: ['load'], - registryCommand: 'state.load', - registryArgs: [], - mode: 'json', - projectDir: '/tmp', - }, { - preferNative: true, - allowFallbackToSubprocess: false, - })).rejects.toThrow('native failed'); - - expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); - }); - - it('does not fallback after timeout-like native error', async () => { - const registry = new QueryRegistry(); - registry.register('state.load', async () => ({ data: { ok: true } })); - - const adapters = { - dispatchNative: vi.fn(async () => { - throw new Error('gsd-tools timed out after 500ms: state load'); - }), - execSubprocessJson: vi.fn(async () => ({ ok: 'fallback' })), - execSubprocessRaw: vi.fn(async () => 'fallback-raw'), - }; - - const transport = new GSDTransport(registry, adapters); - - await expect(transport.run({ - legacyCommand: 'state', - legacyArgs: ['load'], - registryCommand: 'state.load', - registryArgs: [], - mode: 'json', - projectDir: '/tmp', - }, { - preferNative: true, - allowFallbackToSubprocess: true, - })).rejects.toThrow('timed out after'); - - expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); - }); - - it('does not fallback after typed timeout native error', async () => { - const registry = new QueryRegistry(); - registry.register('state.load', async () => ({ data: { ok: true } })); - - const timeoutError = GSDToolsError.timeout('native timed out', 'state', ['load'], '', 500); - const adapters = { - dispatchNative: vi.fn(async () => { - throw timeoutError; - }), - execSubprocessJson: vi.fn(async () => ({ ok: 'fallback' })), - execSubprocessRaw: vi.fn(async () => 'fallback-raw'), - }; - - const transport = new GSDTransport(registry, adapters); - - await expect(transport.run({ - legacyCommand: 'state', - legacyArgs: ['load'], - registryCommand: 'state.load', - registryArgs: [], - mode: 'json', - projectDir: '/tmp', - }, { - preferNative: true, - allowFallbackToSubprocess: true, - })).rejects.toBe(timeoutError); - - expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); - }); - - it('formats native raw output via formatNativeRaw when provided', async () => { - const registry = new QueryRegistry(); - registry.register('commit', async () => ({ data: { hash: 'abc123' } })); - - const adapters = { - dispatchNative: vi.fn(async () => ({ data: { hash: 'abc123' } })), - execSubprocessJson: vi.fn(async () => ({ ok: false })), - execSubprocessRaw: vi.fn(async () => 'subprocess-raw'), - formatNativeRaw: vi.fn(() => 'raw-native-output'), - }; - - const transport = new GSDTransport(registry, adapters); - const result = await transport.run({ - legacyCommand: 'commit', - legacyArgs: ['msg'], - registryCommand: 'commit', - registryArgs: ['msg'], - mode: 'raw', - projectDir: '/tmp', - }, { - preferNative: true, - allowFallbackToSubprocess: true, - }); - - expect(result).toBe('raw-native-output'); - expect(adapters.formatNativeRaw).toHaveBeenCalledOnce(); - expect(adapters.execSubprocessRaw).not.toHaveBeenCalled(); - }); - - it('falls back to internal raw formatter when formatNativeRaw missing', async () => { - const registry = new QueryRegistry(); - registry.register('commit', async () => ({ data: undefined })); - - const adapters = { - dispatchNative: vi.fn(async () => ({ data: undefined })), - execSubprocessJson: vi.fn(async () => ({ ok: false })), - execSubprocessRaw: vi.fn(async () => 'subprocess-raw'), - }; - - const transport = new GSDTransport(registry, adapters); - const result = await transport.run({ - legacyCommand: 'commit', - legacyArgs: ['msg'], - registryCommand: 'commit', - registryArgs: ['msg'], - mode: 'raw', - projectDir: '/tmp', - }, { - preferNative: true, - allowFallbackToSubprocess: true, - }); - - expect(result).toBe(''); - expect(adapters.execSubprocessRaw).not.toHaveBeenCalled(); - }); - it('routes natively when workstream present (Phase 6 fix)', async () => { - // Phase 6 fix: GSDTransport no longer forces subprocess for workstream-scoped - // requests. The per-request dispatchNative closure (Phase 5.1) correctly - // threads workstream to registry.dispatch(), so native dispatch is used. - const registry = new QueryRegistry(); - registry.register('state.load', async () => ({ data: { ok: true } })); - - const adapters = { - dispatchNative: vi.fn(async () => ({ data: { ok: true } })), - execSubprocessJson: vi.fn(async () => ({ ok: 'ws-subprocess' })), - execSubprocessRaw: vi.fn(async () => 'ws-subprocess-raw'), - }; - - const transport = new GSDTransport(registry, adapters); - const result = await transport.run({ - legacyCommand: 'state', - legacyArgs: ['load'], - registryCommand: 'state.load', - registryArgs: [], - mode: 'json', - projectDir: '/tmp', - workstream: 'ws-1', - }, { - preferNative: true, - allowFallbackToSubprocess: true, - }); - - // Native dispatch is used — subprocess is NOT called. - expect(result).toEqual({ ok: true }); - expect(adapters.dispatchNative).toHaveBeenCalledOnce(); - expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); - }); - - it('fails when command is unregistered and subprocess fallback is disabled', async () => { - const registry = new QueryRegistry(); - - const adapters = { - dispatchNative: vi.fn(async () => ({ data: { ok: true } })), - execSubprocessJson: vi.fn(async () => ({ ok: 'fallback' })), - execSubprocessRaw: vi.fn(async () => 'fallback-raw'), - }; - - const transport = new GSDTransport(registry, adapters); - - await expect(transport.run({ - legacyCommand: 'unknown', - legacyArgs: [], - registryCommand: 'unknown', - registryArgs: [], - mode: 'json', - projectDir: '/tmp', - }, { - preferNative: true, - allowFallbackToSubprocess: false, - })).rejects.toThrow("Subprocess fallback disabled"); - - expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); - }); - - it('routes natively when workstream present and mode is raw (Phase 6 fix)', async () => { - // Phase 6 fix: workstream no longer forces subprocess. Native dispatch is used - // even in raw mode — formatNativeRaw (if set) handles the output projection. - const registry = new QueryRegistry(); - registry.register('commit', async () => ({ data: { hash: 'abc' } })); - - const adapters = { - dispatchNative: vi.fn(async () => ({ data: { hash: 'abc' } })), - execSubprocessJson: vi.fn(async () => ({ ok: 'json-subprocess' })), - execSubprocessRaw: vi.fn(async () => 'raw-subprocess'), - }; - - const transport = new GSDTransport(registry, adapters); - const result = await transport.run({ - legacyCommand: 'commit', - legacyArgs: ['msg'], - registryCommand: 'commit', - registryArgs: ['msg'], - mode: 'raw', - projectDir: '/tmp', - workstream: 'ws-1', - }, { - preferNative: true, - allowFallbackToSubprocess: true, - }); - - // Native dispatch is used — toRaw serializes data to JSON. - expect(typeof result).toBe('string'); - expect(adapters.dispatchNative).toHaveBeenCalledOnce(); - expect(adapters.execSubprocessRaw).not.toHaveBeenCalled(); - expect(adapters.execSubprocessJson).not.toHaveBeenCalled(); - }); -}); diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts deleted file mode 100644 index d944e3ac5..000000000 --- a/sdk/src/gsd-transport.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { QueryResult } from './query/utils.js'; -import type { QueryRegistry } from './query/registry.js'; -import type { TransportMode } from './gsd-transport-policy.js'; -import { toFailureSignal } from './query-failure-classification.js'; -import { GSDToolsError } from './gsd-tools-error.js'; - -export interface TransportRequest { - legacyCommand: string; - legacyArgs: string[]; - registryCommand: string; - registryArgs: string[]; - mode: TransportMode; - projectDir: string; - workstream?: string; -} - -export interface TransportAdapters { - dispatchNative: (request: TransportRequest) => Promise; - execSubprocessJson: (legacyCommand: string, legacyArgs: string[]) => Promise; - execSubprocessRaw: (legacyCommand: string, legacyArgs: string[]) => Promise; - formatNativeRaw?: (registryCommand: string, data: unknown) => string; -} - -export interface TransportPolicyLike { - preferNative: boolean; - allowFallbackToSubprocess: boolean; -} - -export interface TransportDecision { - dispatchMode: 'native' | 'subprocess'; - reason?: 'native_not_preferred' | 'native_unregistered' | 'native_failure_fallback'; -} - -export class GSDTransport { - constructor( - private readonly registry: QueryRegistry, - private readonly adapters: TransportAdapters, - ) {} - - async run( - request: TransportRequest, - policy: TransportPolicyLike, - onDecision?: (decision: TransportDecision) => void, - ): Promise { - const useNative = this.shouldUseNative(request, policy); - if (useNative) { - try { - const native = await this.adapters.dispatchNative(request); - onDecision?.({ dispatchMode: 'native' }); - return this.projectNativeOutput(request, native.data); - } catch (error) { - if (this.shouldRethrowNativeError(error, policy)) throw error; - onDecision?.({ dispatchMode: 'subprocess', reason: 'native_failure_fallback' }); - } - } else { - const reason = this.subprocessReason(request, policy); - if (!policy.allowFallbackToSubprocess && reason === 'native_unregistered') { - throw GSDToolsError.failure( - `Subprocess fallback disabled: command '${request.registryCommand}' cannot run without native dispatch`, - request.legacyCommand, - request.legacyArgs, - null, - ); - } - onDecision?.({ dispatchMode: 'subprocess', reason }); - } - - return this.dispatchSubprocess(request); - } - - private shouldUseNative(request: TransportRequest, policy: TransportPolicyLike): boolean { - // Phase 5.0 worker fix: dispatchNative now correctly threads projectDir and - // workstream per-request (see worker.ts dispatchNative closure). Workstream - // commands no longer need to force subprocess — native dispatch handles them. - return policy.preferNative && this.registry.has(request.registryCommand); - } - - private subprocessReason(request: TransportRequest, policy: TransportPolicyLike): TransportDecision['reason'] { - if (!policy.preferNative) return 'native_not_preferred'; - if (!this.registry.has(request.registryCommand)) return 'native_unregistered'; - - throw new Error( - `Unexpected subprocess reason state for command '${request.registryCommand}' with preferNative=${String(policy.preferNative)}`, - ); - } - - private shouldRethrowNativeError(error: unknown, policy: TransportPolicyLike): boolean { - if (!policy.allowFallbackToSubprocess) return true; - // Do not subprocess-fallback after a timed-out native dispatch: - // the timeout does not cancel the native handler, so falling through - // would run the same command twice (double-execution race). - return toFailureSignal(error).kind === 'timeout'; - } - - private dispatchSubprocess(request: TransportRequest): Promise { - if (request.mode === 'raw') { - return this.adapters.execSubprocessRaw(request.legacyCommand, request.legacyArgs); - } - return this.adapters.execSubprocessJson(request.legacyCommand, request.legacyArgs); - } - - private projectNativeOutput(request: TransportRequest, data: unknown): unknown { - if (request.mode === 'raw') { - if (this.adapters.formatNativeRaw) { - return this.adapters.formatNativeRaw(request.registryCommand, data).trim(); - } - return this.toRaw(data); - } - return data; - } - - private toRaw(data: unknown): string { - if (typeof data === 'string') return data.trim(); - const json = JSON.stringify(data, null, 2); - if (json == null) return ''; - return json.trim(); - } -} diff --git a/sdk/src/handlers/init/complex.ts b/sdk/src/handlers/init/complex.ts deleted file mode 100644 index 2fb1bcff9..000000000 --- a/sdk/src/handlers/init/complex.ts +++ /dev/null @@ -1,815 +0,0 @@ -/** - * Complex init composition handlers — the 3 heavyweight init commands - * that require deep filesystem scanning and ROADMAP.md parsing. - * - * Composes existing atomic SDK queries into the same flat JSON bundles - * that CJS init.cjs produces for the new-project, progress, and manager - * workflows. - * - * Port of get-shit-done/bin/lib/init.cjs cmdInitNewProject (lines 296-399), - * cmdInitProgress (lines 1139-1284), cmdInitManager (lines 854-1137). - * - * @example - * ```typescript - * import { initProgress, initManager } from './complex.js'; - * - * const result = await initProgress([], '/project'); - * // { data: { phases: [...], milestone_version: 'v3.0', ... } } - * ``` - */ - -import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs'; -import { execSync } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import { join, relative } from 'node:path'; -import { homedir } from 'node:os'; - -import { loadConfig } from '../../config.js'; -import { resolveModel } from '../../query/config-query.js'; -import { - detectRuntime, - planningPaths, - normalizePhaseName, - phaseTokenMatches, - resolveAgentsDir, - toPosixPath, -} from '../../query/helpers.js'; -import { - getMilestoneInfo, - extractCurrentMilestone, - extractNextMilestoneSection, - extractPhasesFromSection, -} from '../../query/roadmap.js'; -import { agentSkills } from '../../query/skills.js'; -import { withProjectRoot } from './composer.js'; -import type { QueryHandler } from '../../query/utils.js'; - -// ─── Internal helpers ────────────────────────────────────────────────────── - -/** - * Get model alias string from resolveModel result. - */ -async function getModelAlias(agentType: string, projectDir: string): Promise { - const result = await resolveModel([agentType], projectDir); - const data = result.data as Record; - return typeof data.model === 'string' ? data.model : 'sonnet'; -} - -/** - * Check if a file exists at a relative path within projectDir. - */ -function pathExists(base: string, relPath: string): boolean { - return existsSync(join(base, relPath)); -} - -/** - * Bug #3491: detect whether `base` is inside any git worktree, and if so, - * return the absolute worktree root. Mirrors the CJS `gitWorktreeInfoInternal` - * in get-shit-done/bin/lib/core.cjs — keep these two implementations behaviour- - * identical so the SDK and CJS init handlers emit the same has_git semantics. - * - * Returns { inside, worktreeRoot } — both fall back to false/null on any error - * (git unavailable, not a repo, timeout) so callers see the conservative - * default that preserves pre-fix behaviour for non-git environments. - */ -function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string | null } { - try { - const inside = execSync('git rev-parse --is-inside-work-tree', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim(); - if (inside !== 'true') return { inside: false, worktreeRoot: null }; - try { - const root = execSync('git rev-parse --show-toplevel', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim(); - return { inside: true, worktreeRoot: root || null }; - } catch { - return { inside: true, worktreeRoot: null }; - } - } catch { - return { inside: false, worktreeRoot: null }; - } -} - -function detectNestedSubdir(base: string, info: { inside: boolean; worktreeRoot: string | null }): boolean { - if (!info.inside) return false; - try { - const prefix = execSync('git rev-parse --show-prefix', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim().replace(/\\/g, '/'); - if (prefix.length > 0) return prefix !== '.' && prefix !== './'; - return false; - } catch {} - - if (!info.worktreeRoot) return false; - const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase(); - const root = normalize(info.worktreeRoot); - const cwd = normalize(base); - return root !== cwd; -} - - -const NEW_PROJECT_REQUIRED_AGENTS = [ - 'gsd-project-researcher', - 'gsd-research-synthesizer', - 'gsd-roadmapper', -]; - -function hasAgentDefinition(agentsDir: string, agent: string): boolean { - return existsSync(join(agentsDir, `${agent}.md`)) || - existsSync(join(agentsDir, `${agent}.agent.md`)); -} - -async function resolveAgentSkillPayloadAgents( - requiredAgents: string[], - projectDir: string, -): Promise { - const available: string[] = []; - for (const agent of requiredAgents) { - const result = await agentSkills([agent], projectDir); - if (typeof result.data === 'string' && result.data.trim() !== '') { - available.push(agent); - } - } - return available; -} - -/** - * Extract ROADMAP checkbox states: `- [x] Phase N` → true, `- [ ] Phase N` → false. - * Shared by initProgress and initManager so both treat ROADMAP as the - * fallback/override source of truth for completion. - */ -function extractCheckboxStates(content: string): Map { - const states = new Map(); - const pattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; - let m: RegExpExecArray | null; - while ((m = pattern.exec(content)) !== null) { - states.set(m[2], m[1].toLowerCase() === 'x'); - } - return states; -} - -/** - * Extract terminal phase markers from ROADMAP phase headings, e.g. - * `(COMPLETE)`, `(SHIPPED ...)`, `(DEFERRED)`, `(SUPERSEDED ...)`. - * These labels mean the phase should not be selected as next pending work. - */ -function extractTerminalStatusLabels(content: string): Set { - const terminal = new Set(); - const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; - const terminalRe = /(?:\(|\*\*)\s*(SHIPPED|COMPLETE|DEFERRED|SUPERSEDED|MERGED\s+INTO|FOLDED\s+INTO)\b/i; - let m: RegExpExecArray | null; - while ((m = headingPattern.exec(content)) !== null) { - if (terminalRe.test(m[2])) { - terminal.add(m[1]); - terminal.add(m[1].replace(/^0+/, '') || '0'); - } - } - return terminal; -} - -/** - * Derive progress-level status from a ROADMAP checkbox when the phase has - * no on-disk directory. Returns 'complete' for `[x]`, 'not_started' otherwise. - * Disk status (when present) always wins — it's more recent truth for in-flight work. - */ -function deriveStatusFromCheckbox( - phaseNum: string, - checkboxStates: Map, -): 'complete' | 'not_started' { - const stripped = phaseNum.replace(/^0+/, '') || '0'; - if (checkboxStates.get(phaseNum) === true) return 'complete'; - if (checkboxStates.get(stripped) === true) return 'complete'; - return 'not_started'; -} - -function listPhasePlanAndSummaryCounts(phasePath: string): { plans: string[]; summaries: string[] } { - const phaseFiles = readdirSync(phasePath); - const rootPlans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); - const rootSummaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - - const plansDir = join(phasePath, 'plans'); - let nestedPlans: string[] = []; - let nestedSummaries: string[] = []; - if (existsSync(plansDir)) { - const files = readdirSync(plansDir); - nestedPlans = files.filter(f => /^PLAN-\d+.*\.md$/i.test(f)); - nestedSummaries = files.filter(f => /^SUMMARY-\d+.*\.md$/i.test(f)); - } - - return { - plans: rootPlans.concat(nestedPlans), - summaries: rootSummaries.concat(nestedSummaries), - }; -} - -// ─── initNewProject ─────────────────────────────────────────────────────── - -/** - * Init handler for new-project workflow. - * - * Detects brownfield state (existing code, package files, git), checks - * search API availability, and resolves project researcher models. - * - * Port of cmdInitNewProject from init.cjs lines 296-399. - */ -export const initNewProject: QueryHandler = async (_args, projectDir, workstream) => { - const config = await loadConfig(projectDir, workstream); - - // Detect search API key availability from env vars and ~/.gsd/ files - const gsdHome = join(homedir(), '.gsd'); - const hasBraveSearch = !!( - process.env.BRAVE_API_KEY || - existsSync(join(gsdHome, 'brave_api_key')) - ); - const hasFirecrawl = !!( - process.env.FIRECRAWL_API_KEY || - existsSync(join(gsdHome, 'firecrawl_api_key')) - ); - const hasExaSearch = !!( - process.env.EXA_API_KEY || - existsSync(join(gsdHome, 'exa_api_key')) - ); - - // Detect existing code (depth-limited scan, no external tools) - const codeExtensions = new Set([ - '.ts', '.js', '.py', '.go', '.rs', '.swift', '.java', - '.kt', '.kts', '.c', '.cpp', '.h', '.cs', '.rb', '.php', - '.dart', '.m', '.mm', '.scala', '.groovy', '.lua', - '.r', '.R', '.zig', '.ex', '.exs', '.clj', - ]); - const skipDirs = new Set([ - 'node_modules', '.git', '.planning', '.claude', '.codex', - '__pycache__', 'target', 'dist', 'build', - ]); - - function findCodeFiles(dir: string, depth: number): boolean { - if (depth > 3) return false; - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return false; - } - for (const entry of entries) { - if (entry.isFile()) { - const ext = entry.name.slice(entry.name.lastIndexOf('.')); - if (codeExtensions.has(ext)) return true; - } else if (entry.isDirectory() && !skipDirs.has(entry.name)) { - if (findCodeFiles(join(dir, entry.name), depth + 1)) return true; - } - } - return false; - } - - let hasExistingCode = false; - try { - hasExistingCode = findCodeFiles(projectDir, 0); - } catch { /* best-effort */ } - - const hasPackageFile = - pathExists(projectDir, 'package.json') || - pathExists(projectDir, 'requirements.txt') || - pathExists(projectDir, 'Cargo.toml') || - pathExists(projectDir, 'go.mod') || - pathExists(projectDir, 'Package.swift') || - pathExists(projectDir, 'build.gradle') || - pathExists(projectDir, 'build.gradle.kts') || - pathExists(projectDir, 'pom.xml') || - pathExists(projectDir, 'Gemfile') || - pathExists(projectDir, 'composer.json') || - pathExists(projectDir, 'pubspec.yaml') || - pathExists(projectDir, 'CMakeLists.txt') || - pathExists(projectDir, 'Makefile') || - pathExists(projectDir, 'build.zig') || - pathExists(projectDir, 'mix.exs') || - pathExists(projectDir, 'project.clj'); - - const [researcherModel, synthesizerModel, roadmapperModel] = await Promise.all([ - getModelAlias('gsd-project-researcher', projectDir), - getModelAlias('gsd-research-synthesizer', projectDir), - getModelAlias('gsd-roadmapper', projectDir), - ]); - const runtime = detectRuntime(config as { runtime?: unknown }); - const agentsDir = resolveAgentsDir(runtime, projectDir); - const gitInfo = gitWorktreeInfo(projectDir); - const missingRequiredAgents = NEW_PROJECT_REQUIRED_AGENTS.filter( - agent => !hasAgentDefinition(agentsDir, agent), - ); - const agentSkillPayloadAgents = await resolveAgentSkillPayloadAgents( - NEW_PROJECT_REQUIRED_AGENTS, - projectDir, - ); - - const result: Record = { - researcher_model: researcherModel, - synthesizer_model: synthesizerModel, - roadmapper_model: roadmapperModel, - - commit_docs: config.commit_docs, - - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - has_codebase_map: pathExists(projectDir, '.planning/codebase'), - planning_exists: pathExists(projectDir, '.planning'), - - has_existing_code: hasExistingCode, - has_package_file: hasPackageFile, - is_brownfield: hasExistingCode || hasPackageFile, - needs_codebase_map: - (hasExistingCode || hasPackageFile) && !pathExists(projectDir, '.planning/codebase'), - - // Bug #3491: detect parent worktree to avoid nested .git init. - has_git: gitInfo.inside, - git_worktree_root: gitInfo.worktreeRoot, - in_nested_subdir: detectNestedSubdir(projectDir, gitInfo), - - brave_search_available: hasBraveSearch, - firecrawl_available: hasFirecrawl, - exa_search_available: hasExaSearch, - - project_path: '.planning/PROJECT.md', - agent_runtime: runtime, - agents_dir: agentsDir, - required_agents: NEW_PROJECT_REQUIRED_AGENTS, - required_agents_installed: missingRequiredAgents.length === 0, - missing_required_agents: missingRequiredAgents, - agent_skill_payloads_available: agentSkillPayloadAgents.length === NEW_PROJECT_REQUIRED_AGENTS.length, - agent_skill_payload_agents: agentSkillPayloadAgents, - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initProgress ───────────────────────────────────────────────────────── - -/** - * Init handler for progress workflow. - * - * Builds phase list with plan/summary counts and paused state detection. - * - * Port of cmdInitProgress from init.cjs lines 1139-1284. - */ -export const initProgress: QueryHandler = async (_args, projectDir, workstream) => { - const config = await loadConfig(projectDir, workstream); - const milestone = await getMilestoneInfo(projectDir, workstream); - const paths = planningPaths(projectDir, workstream); - - const phases: Record[] = []; - let currentPhase: Record | null = null; - let nextPhase: Record | null = null; - - // Build set of phases from ROADMAP for the current milestone - const roadmapPhaseNames = new Map(); - const seenPhaseNums = new Set(); - let checkboxStates = new Map(); - let terminalLabels = new Set(); - - try { - const rawRoadmap = await readFile(paths.roadmap, 'utf-8'); - const roadmapContent = await extractCurrentMilestone(rawRoadmap, projectDir, workstream); - const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; - let hm: RegExpExecArray | null; - while ((hm = headingPattern.exec(roadmapContent)) !== null) { - const pNum = hm[1]; - const pName = hm[2].replace(/\(INSERTED\)/i, '').trim(); - roadmapPhaseNames.set(pNum, pName); - } - checkboxStates = extractCheckboxStates(roadmapContent); - terminalLabels = extractTerminalStatusLabels(roadmapContent); - } catch { /* intentionally empty */ } - - // Scan phase directories - try { - const entries = readdirSync(paths.phases, { withFileTypes: true }); - const dirs = entries - .filter(e => e.isDirectory()) - .map(e => e.name) - .sort((a, b) => { - const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); - const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); - if (!pa || !pb) return a.localeCompare(b); - return parseInt(pa[1], 10) - parseInt(pb[1], 10); - }); - - for (const dir of dirs) { - const match = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); - const phaseNumber = match ? match[1] : dir; - const phaseName = match && match[2] ? match[2] : null; - seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0'); - - const phasePath = join(paths.phases, dir); - const phaseFiles = readdirSync(phasePath); - - const { plans, summaries } = listPhasePlanAndSummaryCounts(phasePath); - const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - - let status = - summaries.length >= plans.length && plans.length > 0 ? 'complete' : - plans.length > 0 ? 'in_progress' : - hasResearch ? 'researched' : 'pending'; - - // #2674: align with initManager — a ROADMAP `- [x] Phase N` checkbox - // wins over disk state. A stub phase dir with no SUMMARY is leftover - // scaffolding; the user's explicit [x] is the authoritative signal. - const strippedNum = phaseNumber.replace(/^0+/, '') || '0'; - const roadmapComplete = - checkboxStates.get(phaseNumber) === true || - checkboxStates.get(strippedNum) === true; - if (roadmapComplete && status !== 'complete') { - status = 'complete'; - } - if (terminalLabels.has(phaseNumber) || terminalLabels.has(strippedNum)) { - status = 'complete'; - } - - const phaseInfo: Record = { - number: phaseNumber, - name: phaseName, - directory: toPosixPath(relative(projectDir, join(paths.phases, dir))), - status, - plan_count: plans.length, - summary_count: summaries.length, - has_research: hasResearch, - }; - - phases.push(phaseInfo); - - if (!currentPhase && (status === 'in_progress' || status === 'researched')) { - currentPhase = phaseInfo; - } - if (!nextPhase && status === 'pending') { - nextPhase = phaseInfo; - } - } - } catch { /* intentionally empty */ } - - // Add ROADMAP-only phases not yet on disk. For phases with a ROADMAP - // `[x]` checkbox, treat them as complete (#2646). - for (const [num, name] of roadmapPhaseNames) { - const stripped = num.replace(/^0+/, '') || '0'; - if (!seenPhaseNums.has(stripped)) { - const status = deriveStatusFromCheckbox(num, checkboxStates); - const terminalComplete = terminalLabels.has(num) || terminalLabels.has(stripped); - const phaseInfo: Record = { - number: num, - name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), - directory: null, - status: terminalComplete ? 'complete' : status, - plan_count: 0, - summary_count: 0, - has_research: false, - }; - phases.push(phaseInfo); - if (!nextPhase && !currentPhase && phaseInfo.status !== 'complete') { - nextPhase = phaseInfo; - } - } - } - - phases.sort((a, b) => parseInt(a.number as string, 10) - parseInt(b.number as string, 10)); - - // Check paused state in STATE.md - let pausedAt: string | null = null; - try { - const stateContent = await readFile(paths.state, 'utf-8'); - const pauseMatch = stateContent.match(/\*\*Paused At:\*\*\s*(.+)/); - if (pauseMatch) pausedAt = pauseMatch[1].trim(); - } catch { /* intentionally empty */ } - - const result: Record = { - executor_model: await getModelAlias('gsd-executor', projectDir), - planner_model: await getModelAlias('gsd-planner', projectDir), - - commit_docs: config.commit_docs, - - milestone_version: milestone.version, - milestone_name: milestone.name, - - phases, - phase_count: phases.length, - completed_count: phases.filter(p => p.status === 'complete').length, - in_progress_count: phases.filter(p => p.status === 'in_progress').length, - - current_phase: currentPhase, - next_phase: nextPhase, - paused_at: pausedAt, - has_work_in_progress: !!currentPhase, - - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - roadmap_exists: existsSync(paths.roadmap), - state_exists: existsSync(paths.state), - state_path: toPosixPath(relative(projectDir, paths.state)), - roadmap_path: toPosixPath(relative(projectDir, paths.roadmap)), - project_path: '.planning/PROJECT.md', - config_path: toPosixPath(relative(projectDir, paths.config)), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initManager ───────────────────────────────────────────────────────── - -/** - * Init handler for manager workflow. - * - * Parses ROADMAP.md for all phases, computes disk status, dependency - * graph, and recommended actions per phase. - * - * Port of cmdInitManager from init.cjs lines 854-1137. - */ -export const initManager: QueryHandler = async (_args, projectDir, workstream) => { - const config = await loadConfig(projectDir, workstream); - const milestone = await getMilestoneInfo(projectDir, workstream); - const paths = planningPaths(projectDir, workstream); - - let rawContent: string; - try { - rawContent = await readFile(paths.roadmap, 'utf-8'); - } catch { - return { data: { error: 'No ROADMAP.md found. Run /gsd-new-milestone first.' } }; - } - - const content = await extractCurrentMilestone(rawContent, projectDir, workstream); - - // Pre-compute directory listing once - let phaseDirEntries: string[] = []; - try { - phaseDirEntries = readdirSync(paths.phases, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name); - } catch { /* intentionally empty */ } - - // Pre-extract checkbox states in a single pass (shared helper — #2646) - const checkboxStates = extractCheckboxStates(content); - - const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; - const phases: Record[] = []; - let pMatch: RegExpExecArray | null; - - while ((pMatch = phasePattern.exec(content)) !== null) { - const phaseNum = pMatch[1]; - const phaseName = pMatch[2].replace(/\(INSERTED\)/i, '').trim(); - - const sectionStart = pMatch.index; - const restOfContent = content.slice(sectionStart); - const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); - const sectionEnd = nextHeader ? sectionStart + (nextHeader.index ?? 0) : content.length; - const section = content.slice(sectionStart, sectionEnd); - - const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i); - const goal = goalMatch ? goalMatch[1].trim() : null; - - const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i); - const dependsOn = dependsMatch ? dependsMatch[1].trim() : null; - - const normalized = normalizePhaseName(phaseNum); - let diskStatus = 'no_directory'; - let planCount = 0; - let summaryCount = 0; - let hasContext = false; - let hasResearch = false; - let lastActivity: string | null = null; - let isActive = false; - - try { - const dirMatch = phaseDirEntries.find(d => phaseTokenMatches(d, normalized)); - if (dirMatch) { - const fullDir = join(paths.phases, dirMatch); - const phaseFiles = readdirSync(fullDir); - const counts = listPhasePlanAndSummaryCounts(fullDir); - planCount = counts.plans.length; - summaryCount = counts.summaries.length; - hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - - if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete'; - else if (summaryCount > 0) diskStatus = 'partial'; - else if (planCount > 0) diskStatus = 'planned'; - else if (hasResearch) diskStatus = 'researched'; - else if (hasContext) diskStatus = 'discussed'; - else diskStatus = 'empty'; - - const now = Date.now(); - let newestMtime = 0; - for (const f of phaseFiles) { - try { - const st = statSync(join(fullDir, f)); - if (st.mtimeMs > newestMtime) newestMtime = st.mtimeMs; - } catch { /* intentionally empty */ } - } - if (newestMtime > 0) { - lastActivity = new Date(newestMtime).toISOString(); - isActive = (now - newestMtime) < 300000; // 5 minutes - } - } - } catch { /* intentionally empty */ } - - const roadmapComplete = checkboxStates.get(phaseNum) || false; - if (roadmapComplete && diskStatus !== 'complete') { - diskStatus = 'complete'; - } - - const MAX_NAME_WIDTH = 20; - const displayName = phaseName.length > MAX_NAME_WIDTH - ? phaseName.slice(0, MAX_NAME_WIDTH - 1) + '…' - : phaseName; - - phases.push({ - number: phaseNum, - name: phaseName, - display_name: displayName, - goal, - depends_on: dependsOn, - disk_status: diskStatus, - has_context: hasContext, - has_research: hasResearch, - plan_count: planCount, - summary_count: summaryCount, - roadmap_complete: roadmapComplete, - last_activity: lastActivity, - is_active: isActive, - }); - } - - // Dependency satisfaction - const completedNums = new Set( - phases.filter(p => p.disk_status === 'complete').map(p => p.number as string), - ); - for (const phase of phases) { - const dependsOnStr = phase.depends_on as string | null; - if (!dependsOnStr || /^none$/i.test(dependsOnStr.trim())) { - phase.deps_satisfied = true; - phase.dep_phases = []; - phase.deps_display = '—'; - } else { - const depNums = dependsOnStr.match(/\d+(?:\.\d+)*/g) || []; - phase.deps_satisfied = depNums.every(n => completedNums.has(n)); - phase.dep_phases = depNums; - phase.deps_display = depNums.length > 0 ? depNums.join(',') : '—'; - } - } - - // Bug #2268: mark EVERY undiscussed phase as is_next_to_discuss, not just - // the first one. Multiple independent phases can be discussed in parallel - // — the sliding-window pattern made the manager only recommend one - // discuss action even when callers had free capacity to discuss several. - for (const phase of phases) { - const status = phase.disk_status as string; - phase.is_next_to_discuss = (status === 'empty' || status === 'no_directory'); - } - - // Check WAITING.json signal - let waitingSignal: unknown = null; - try { - const waitingPath = join(projectDir, '.planning', 'WAITING.json'); - if (existsSync(waitingPath)) { - const { readFileSync } = await import('node:fs'); - waitingSignal = JSON.parse(readFileSync(waitingPath, 'utf-8')); - } - } catch { /* intentionally empty */ } - - // Compute recommended actions - const phaseMap = new Map(phases.map(p => [p.number as string, p])); - - function reaches(from: string, to: string, visited = new Set()): boolean { - if (visited.has(from)) return false; - visited.add(from); - const p = phaseMap.get(from); - const depPhases = p?.dep_phases as string[] | undefined; - if (!depPhases || depPhases.length === 0) return false; - if (depPhases.includes(to)) return true; - return depPhases.some(dep => reaches(dep, to, visited)); - } - - const activeExecuting = phases.filter(p => { - const status = p.disk_status as string; - return status === 'partial' || (status === 'planned' && p.is_active); - }); - const activePlanning = phases.filter(p => { - const status = p.disk_status as string; - return p.is_active && (status === 'discussed' || status === 'researched'); - }); - - const recommendedActions: Record[] = []; - for (const phase of phases) { - const status = phase.disk_status as string; - if (status === 'complete') continue; - if (/^999(?:\.|$)/.test(phase.number as string)) continue; - - if (status === 'planned' && phase.deps_satisfied) { - const action = { - phase: phase.number, - phase_name: phase.name, - action: 'execute', - reason: `${phase.plan_count} plans ready, dependencies met`, - command: `/gsd-execute-phase ${phase.number}`, - }; - const isAllowed = activeExecuting.length === 0 || - activeExecuting.every(a => !reaches(phase.number as string, a.number as string) && !reaches(a.number as string, phase.number as string)); - if (isAllowed) recommendedActions.push(action); - } else if (status === 'discussed' || status === 'researched') { - const action = { - phase: phase.number, - phase_name: phase.name, - action: 'plan', - reason: 'Context gathered, ready for planning', - command: `/gsd-plan-phase ${phase.number}`, - }; - const isAllowed = activePlanning.length === 0 || - activePlanning.every(a => !reaches(phase.number as string, a.number as string) && !reaches(a.number as string, phase.number as string)); - if (isAllowed) recommendedActions.push(action); - } else if ((status === 'empty' || status === 'no_directory') && phase.is_next_to_discuss) { - recommendedActions.push({ - phase: phase.number, - phase_name: phase.name, - action: 'discuss', - reason: 'Unblocked, ready to gather context', - command: `/gsd-discuss-phase ${phase.number}`, - }); - } - } - - const completedCount = phases.filter(p => p.disk_status === 'complete').length; - - // ── Next-milestone surface (issue #2497) ─────────────────────────────── - // Populate queued_phases + metadata with the milestone immediately after - // the active one, so the /gsd-manager dashboard can preview what's coming - // next without mixing it into the active phases grid. Empty/null when the - // active milestone is the last one in ROADMAP. - let queuedPhases: Record[] = []; - let queuedMilestoneVersion: string | null = null; - let queuedMilestoneName: string | null = null; - try { - const next = await extractNextMilestoneSection(rawContent, projectDir); - if (next) { - queuedMilestoneVersion = next.version; - queuedMilestoneName = next.name; - queuedPhases = extractPhasesFromSection(next.section).map(p => { - const MAX_NAME_WIDTH = 20; - const display_name = p.name.length > MAX_NAME_WIDTH - ? p.name.slice(0, MAX_NAME_WIDTH - 1) + '…' - : p.name; - const depNums = p.depends_on && !/^none$/i.test(p.depends_on.trim()) - ? (p.depends_on.match(/\d+(?:\.\d+)*/g) || []) - : []; - return { - number: p.number, - name: p.name, - display_name, - goal: p.goal, - depends_on: p.depends_on, - dep_phases: depNums, - deps_display: depNums.length > 0 ? depNums.join(',') : '—', - }; - }); - } - } catch { /* queued_phases is a non-critical enhancement */ } - - // Read manager flags from config - const managerConfig = (config as Record).manager as Record> | undefined; - const sanitizeFlags = (raw: unknown): string => { - const val = typeof raw === 'string' ? raw : ''; - if (!val) return ''; - const tokens = val.split(/\s+/).filter(Boolean); - const safe = tokens.every(t => /^--[a-zA-Z0-9][-a-zA-Z0-9]*$/.test(t) || /^[a-zA-Z0-9][-a-zA-Z0-9_.]*$/.test(t)); - return safe ? val : ''; - }; - const managerFlags = { - discuss: sanitizeFlags(managerConfig?.flags?.discuss), - plan: sanitizeFlags(managerConfig?.flags?.plan), - execute: sanitizeFlags(managerConfig?.flags?.execute), - }; - - const result: Record = { - milestone_version: milestone.version, - milestone_name: milestone.name, - phases, - phase_count: phases.length, - completed_count: completedCount, - in_progress_count: phases.filter(p => ['partial', 'planned', 'discussed', 'researched'].includes(p.disk_status as string)).length, - recommended_actions: recommendedActions, - waiting_signal: waitingSignal, - all_complete: completedCount === phases.length && phases.length > 0, - queued_phases: queuedPhases, - queued_milestone_version: queuedMilestoneVersion, - queued_milestone_name: queuedMilestoneName, - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - roadmap_exists: true, - state_exists: true, - manager_flags: managerFlags, - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; diff --git a/sdk/src/handlers/init/composer.ts b/sdk/src/handlers/init/composer.ts deleted file mode 100644 index b0a857574..000000000 --- a/sdk/src/handlers/init/composer.ts +++ /dev/null @@ -1,1335 +0,0 @@ -/** - * Init composition handlers — compound init commands for workflow bootstrapping. - * - * Composes existing atomic SDK queries into the same flat JSON bundles - * that CJS init.cjs produces, enabling workflow migration. Each handler - * follows the QueryHandler signature and returns { data: }. - * - * Port of get-shit-done/bin/lib/init.cjs (13 of 16 handlers). - * The 3 complex handlers (new-project, progress, manager) are in complex.ts. - * - * @example - * ```typescript - * import { initExecutePhase, withProjectRoot } from './composer.js'; - * - * const result = await initExecutePhase(['9'], '/project'); - * // { data: { executor_model: 'opus', phase_found: true, ... } } - * ``` - */ - -import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; -import { join, relative, basename } from 'node:path'; -import { execSync } from 'node:child_process'; -import { homedir } from 'node:os'; -import { GSDError, ErrorClassification } from '../../errors.js'; - -import { loadConfig, type GSDConfig } from '../../config.js'; -import { resolveModel, MODEL_PROFILES } from '../../query/config-query.js'; -import { maskIfSecret } from '../../query/secrets.js'; -import { findPhase } from '../../query/phase.js'; -import { getMilestonePhaseFilter } from '../../query/state.js'; -import { roadmapGetPhase, getMilestoneInfo, extractCurrentMilestone, extractPhasesFromSection } from '../../query/roadmap.js'; -import { determinePhaseStatus } from '../../query/progress.js'; -import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from '../../query/helpers.js'; -import { generatePhaseSlug, assertSafeProjectCode } from '../../query/phase-lifecycle-policy.js'; -import type { QueryHandler } from '../../query/utils.js'; - -// ─── Internal helpers ────────────────────────────────────────────────────── - -/** - * Extract model alias string from a resolveModel result. - */ -async function getModelAlias(agentType: string, projectDir: string): Promise { - const result = await resolveModel([agentType], projectDir); - const data = result.data as Record; - return (data.model as string) || 'sonnet'; -} - -/** - * Generate a slug from text (inline, matches CJS generateSlugInternal). - */ -function generateSlugInternal(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .substring(0, 60); -} - -function extractPhaseArg(args: string[]): string | undefined { - const equalsArg = args.find((arg) => arg.startsWith('--phase=')); - if (equalsArg) { - const value = equalsArg.slice('--phase='.length).trim(); - return value || undefined; - } - - const flagIndex = args.indexOf('--phase'); - if (flagIndex !== -1) { - const value = args[flagIndex + 1]; - return value && !value.startsWith('--') ? value : undefined; - } - - const first = args[0]; - return first && !first.startsWith('--') ? first : undefined; -} - -/** - * Check if a path exists on disk. - */ -function pathExists(base: string, relPath: string): boolean { - return existsSync(join(base, relPath)); -} - -/** - * Bug #3491: detect whether `base` is inside any git worktree, and if so, - * return the absolute worktree root. Mirrors the CJS `gitWorktreeInfoInternal` - * in get-shit-done/bin/lib/core.cjs — keep these two implementations behaviour- - * identical so the SDK and CJS init handlers emit the same has_git semantics. - * - * Returns { inside, worktreeRoot } — both fall back to false/null on any error - * (git unavailable, not a repo, timeout) so callers see the conservative - * default that preserves pre-fix behaviour for non-git environments. - */ -function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string | null } { - try { - const inside = execSync('git rev-parse --is-inside-work-tree', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim(); - if (inside !== 'true') return { inside: false, worktreeRoot: null }; - try { - const root = execSync('git rev-parse --show-toplevel', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim(); - return { inside: true, worktreeRoot: root || null }; - } catch { - return { inside: true, worktreeRoot: null }; - } - } catch { - return { inside: false, worktreeRoot: null }; - } -} - -function detectNestedSubdir(base: string, info: { inside: boolean; worktreeRoot: string | null }): boolean { - if (!info.inside) return false; - try { - const prefix = execSync('git rev-parse --show-prefix', { - cwd: base, - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf-8', - timeout: 5000, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }).trim().replace(/\\/g, '/'); - if (prefix.length > 0) return prefix !== '.' && prefix !== './'; - return false; - } catch {} - - if (!info.worktreeRoot) return false; - const normalize = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase(); - const root = normalize(info.worktreeRoot); - const cwd = normalize(base); - return root !== cwd; -} - - -/** - * Compute the canonical phase directory name for a known phase entry from the - * roadmap when no directory exists yet. Applies the project_code prefix so - * the first-touch creation path used by /gsd-discuss-phase and /gsd-plan-phase - * stays consistent with the prefix produced by `phase.add` / `phase.insert`. - * - * Returns null when phaseNumber or phaseName cannot be determined. - */ -function computeExpectedPhaseDirName( - phaseNumber: string | null, - phaseName: string | null, - projectCode: string, -): string | null { - if (!phaseNumber || !phaseName) return null; - const paddedNum = normalizePhaseName(phaseNumber); - const slug = generatePhaseSlug(phaseName); - if (!slug) return null; - const prefix = projectCode ? `${projectCode}-` : ''; - return `${prefix}${paddedNum}-${slug}`; -} - -async function shouldDropArchivedPhaseMatch( - phaseInfo: Record | null, - roadmapPhase: Record | null, - projectDir: string, - workstream?: string, -): Promise { - // Matches CJS cmdInitPlanPhase / cmdInitExecutePhase / cmdInitVerifyWork: - // if (phaseInfo?.archived && roadmapPhase?.found) phaseInfo = null; - // ROADMAP is authoritative for the current milestone, regardless of what - // archived milestone the on-disk match came from — BUT see #3469 exception. - if (!phaseInfo?.archived) return false; - if (!roadmapPhase || !roadmapPhase.found) return false; - - // #3469: If the archived phase belongs to the CURRENT milestone (e.g. phases - // were cleared via `phases clear` into .planning/milestones/-phases/ - // but the workflow is still on that same milestone), preserve the archived dir - // as the canonical phase location. Only drop when the archived version is from - // a PRIOR milestone (the original anti-ghost-phase guard). - // Note: the milestone-version equality check here does NOT regress #2391 - // because in the #2391 scenario archived.version != current milestone. - const pp = planningPaths(projectDir, workstream); - try { - const stateContent = readFileSync(pp.state, 'utf-8'); - const milestoneMatch = stateContent.match(/^milestone:\s*(.+)$/m); - const currentMilestone = milestoneMatch ? milestoneMatch[1].trim() : null; - if (currentMilestone && phaseInfo.archived === currentMilestone) { - // Same milestone — archived dir is the canonical location. Keep it. - return false; - } - } catch { /* STATE.md unreadable — fall through to default drop */ } - - return true; -} - -/** - * Get the latest completed milestone from MILESTONES.md. - * Port of getLatestCompletedMilestone from init.cjs lines 10-25. - */ -function getLatestCompletedMilestone(projectDir: string): { version: string; name: string } | null { - const milestonesPath = join(projectDir, '.planning', 'MILESTONES.md'); - if (!existsSync(milestonesPath)) return null; - - try { - const content = readFileSync(milestonesPath, 'utf-8'); - const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m); - if (!match) return null; - return { version: match[1], name: match[2].trim() }; - } catch { - return null; - } -} - -/** - * Check which GSD agents are installed on disk. - * - * Runtime-aware per issue #2402: detects the invoking runtime - * (`GSD_RUNTIME` → `config.runtime` → 'claude') and probes that runtime's - * canonical `agents/` directory. `GSD_AGENTS_DIR` still short-circuits. - * - * The optional `projectDir` parameter enables the repo-local `.claude/agents` - * fallback for Claude `--local` installs (bug #3751). - * - * Port of checkAgentsInstalled from core.cjs lines 1274-1306. - */ -function checkAgentsInstalled(config?: { runtime?: unknown }, projectDir?: string): { agents_installed: boolean; missing_agents: string[] } { - const runtime = detectRuntime(config); - const agentsDir = resolveAgentsDir(runtime, projectDir); - const expectedAgents = Object.keys(MODEL_PROFILES); - - if (!existsSync(agentsDir)) { - return { agents_installed: false, missing_agents: expectedAgents }; - } - - const missing: string[] = []; - for (const agent of expectedAgents) { - const agentFile = join(agentsDir, `${agent}.md`); - const agentFileCopilot = join(agentsDir, `${agent}.agent.md`); - if (!existsSync(agentFile) && !existsSync(agentFileCopilot)) { - missing.push(agent); - } - } - - return { - agents_installed: missing.length === 0, - missing_agents: missing, - }; -} - -/** - * Extract phase info from findPhase result, or build fallback from roadmap. - */ -async function getPhaseInfoWithFallback( - phase: string, - projectDir: string, - workstream?: string, -): Promise<{ phaseInfo: Record | null; roadmapPhase: Record | null }> { - const phaseResult = await findPhase([phase], projectDir, workstream); - let phaseInfo = phaseResult.data as Record | null; - // findPhase returns { found: false } when missing; findPhaseInternal returns null — align for init parity. - if (phaseInfo && phaseInfo.found === false) { - phaseInfo = null; - } - - const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); - const roadmapPhase = roadmapResult.data as Record | null; - - // Match init.cjs: drop archived disk match when the phase is listed in the current ROADMAP - if (await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { - phaseInfo = null; - } - - // Fallback to ROADMAP.md if no phase directory exists yet - if ((!phaseInfo || !phaseInfo.found) && roadmapPhase?.found) { - const phaseName = roadmapPhase.phase_name as string; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? generateSlugInternal(phaseName) : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - has_reviews: false, - }; - } - - return { phaseInfo, roadmapPhase }; -} - -/** - * Phase resolution for `init verify-work` — matches init.cjs cmdInitVerifyWork (archived + fallback). - */ -async function getPhaseInfoForVerifyWork( - phase: string, - projectDir: string, - workstream?: string, -): Promise<{ phaseInfo: Record | null }> { - const phaseResult = await findPhase([phase], projectDir, workstream); - let phaseInfo = phaseResult.data as Record | null; - if (phaseInfo && phaseInfo.found === false) { - phaseInfo = null; - } - - const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); - const roadmapPhase = roadmapResult.data as Record | null; - - if (await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { - phaseInfo = null; - } - - if (!phaseInfo && roadmapPhase?.found) { - const phaseName = roadmapPhase.phase_name as string; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? generateSlugInternal(phaseName) : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - }; - } - - return { phaseInfo }; -} - -/** - * Extract requirement IDs from roadmap section text. - */ -function extractReqIds(roadmapPhase: Record | null): string | null { - const section = roadmapPhase?.section as string | undefined; - // Accept all bold/colon variants of the Requirements header. The forms - // **Requirements:** (colon inside bold) - // **Requirements**: (colon outside bold) - // **Requirements** : (space before outside colon) - // render identically in markdown but differ textually. Issue #2769. - const reqMatch = section?.match(/^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m); - const reqExtracted = reqMatch - ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s: string) => s.trim()).filter(Boolean).join(', ') - : null; - return (reqExtracted && reqExtracted !== 'TBD') ? reqExtracted : null; -} - -// ─── withProjectRoot ───────────────────────────────────────────────────── - -/** - * Inject project_root, agents_installed, missing_agents, and response_language - * into an init result object. - * - * Port of withProjectRoot from init.cjs lines 32-63. - * - * @param projectDir - Absolute project root path - * @param result - The result object to augment - * @param config - Optional loaded config (avoids re-reading config.json) - * @returns The augmented result object - */ -export function withProjectRoot( - projectDir: string, - result: Record, - config?: Record, -): Record { - result.project_root = projectDir; - - const agentStatus = checkAgentsInstalled(config, projectDir); - result.agents_installed = agentStatus.agents_installed; - result.missing_agents = agentStatus.missing_agents; - - const responseLang = config?.response_language; - if (responseLang) { - result.response_language = responseLang; - } - - const projectCode = config?.project_code; - if (projectCode) { - result.project_code = projectCode; - } - - const projectMdPath = join(projectDir, '.planning', 'PROJECT.md'); - try { - if (existsSync(projectMdPath)) { - const content = readFileSync(projectMdPath, 'utf-8'); - const h1Match = content.match(/^#\s+(.+)$/m); - if (h1Match) { - result.project_title = h1Match[1].trim(); - } - } - } catch { - /* intentionally empty */ - } - - return result; -} - -// ─── initExecutePhase ───────────────────────────────────────────────────── - -/** - * Init handler for execute-phase workflow. - * Port of cmdInitExecutePhase from init.cjs lines 50-171. - */ -export const initExecutePhase: QueryHandler = async (args, projectDir, workstream) => { - const phase = extractPhaseArg(args); - if (!phase) { - return { data: { error: 'phase required for init execute-phase' } }; - } - - // --tdd is a boolean override of config.workflow.tdd_mode — matches the CJS - // path's parseNamedArgs(args, [], ['validate', 'tdd']) projection - // (bin/lib/init-command-router.cjs handler block) which passes options.tdd - // through to cmdInitExecutePhase. Without parsing here, `gsd-tools init - // execute-phase 1 --tdd` would never override a false config value. - const tddFlag = args.includes('--tdd'); - - const config = await loadConfig(projectDir); - const paths = planningPaths(projectDir, workstream); - const planningDir = paths.planning; - - const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream); - const phase_req_ids = extractReqIds(roadmapPhase); - - const configExists = existsSync(join(planningDir, 'config.json')); - const [executorModel, verifierModel] = configExists - ? await Promise.all([ - getModelAlias('gsd-executor', projectDir), - getModelAlias('gsd-verifier', projectDir), - ]) - : ['', '']; - - const milestone = await getMilestoneInfo(projectDir, workstream); - - const phaseNumber = (phaseInfo?.phase_number as string) || null; - const phaseSlug = (phaseInfo?.phase_slug as string) || null; - const plans = (phaseInfo?.plans || []) as string[]; - const summaries = (phaseInfo?.summaries || []) as string[]; - const incompletePlans = (phaseInfo?.incomplete_plans || []) as string[]; - const projectCode = (config as Record).project_code as string || ''; - - const result: Record = { - executor_model: executorModel, - verifier_model: verifierModel, - tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false), - commit_docs: config.commit_docs, - sub_repos: (config as Record).sub_repos ?? [], - parallelization: config.parallelization, - context_window: (config as Record).context_window ?? 200000, - branching_strategy: config.git.branching_strategy, - phase_branch_template: config.git.phase_branch_template, - milestone_branch_template: config.git.milestone_branch_template, - verifier_enabled: config.workflow.verifier, - phase_found: !!phaseInfo, - phase_dir: (phaseInfo?.directory as string) ?? null, - phase_number: phaseNumber, - phase_name: (phaseInfo?.phase_name as string) ?? null, - phase_slug: phaseSlug, - phase_req_ids, - plans, - summaries, - incomplete_plans: incompletePlans, - plan_count: plans.length, - incomplete_count: incompletePlans.length, - branch_name: config.git.branching_strategy === 'phase' && phaseInfo - ? config.git.phase_branch_template - .replace('{project}', projectCode) - .replace('{phase}', phaseNumber || '') - .replace('{slug}', phaseSlug || 'phase') - : config.git.branching_strategy === 'milestone' - ? config.git.milestone_branch_template - .replace('{milestone}', milestone.version) - .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone') - : null, - milestone_version: milestone.version, - milestone_name: milestone.name, - milestone_slug: generateSlugInternal(milestone.name), - state_exists: existsSync(join(planningDir, 'STATE.md')), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - config_exists: configExists, - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - config_path: toPosixPath(relative(projectDir, join(planningDir, 'config.json'))), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initPlanPhase ──────────────────────────────────────────────────────── - -/** - * Init handler for plan-phase workflow. - * Port of cmdInitPlanPhase from init.cjs lines 173-293. - */ -export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) => { - const phase = extractPhaseArg(args); - if (!phase) { - return { data: { error: 'phase required for init plan-phase' } }; - } - - // --tdd boolean override (parity with CJS router's parseNamedArgs + the - // legacy cmdInitPlanPhase `options.tdd || config.tdd_mode || false`). - const tddFlag = args.includes('--tdd'); - - const config = await loadConfig(projectDir); - const paths = planningPaths(projectDir, workstream); - const planningDir = paths.planning; - - const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream); - const phase_req_ids = extractReqIds(roadmapPhase); - - const configExists = existsSync(join(planningDir, 'config.json')); - const [researcherModel, plannerModel, checkerModel] = configExists - ? await Promise.all([ - getModelAlias('gsd-phase-researcher', projectDir), - getModelAlias('gsd-planner', projectDir), - getModelAlias('gsd-plan-checker', projectDir), - ]) - : ['', '', '']; - - const phaseNumber = (phaseInfo?.phase_number as string) || null; - const phaseName = (phaseInfo?.phase_name as string) ?? null; - const phaseDir = (phaseInfo?.directory as string) ?? null; - const plans = (phaseInfo?.plans || []) as string[]; - const summaries = (phaseInfo?.summaries || []) as string[]; - - // #3569: surface phase lifecycle status so /gsd-plan-phase can short-circuit - // on closed (Complete) phases instead of silently replanning over shipped - // code. Reuses determinePhaseStatus — the project-wide vocabulary used by - // `progress` (Pending | Planned | In Progress | Executed | Complete | - // Needs Review). When the phase has no directory on disk yet, treat it as - // Pending (it has not been started). - const phaseStatus = phaseDir - ? await determinePhaseStatus(plans.length, summaries.length, join(projectDir, phaseDir)) - : 'Pending'; - - // #3287: compute the canonical directory name with project_code prefix so - // the first-touch mkdir in /gsd-plan-phase stays consistent with phase.add. - const rawProjectCode = (config as Record).project_code as string || ''; - assertSafeProjectCode(rawProjectCode); - const expectedPhaseDirName = phaseDir - ? null // directory already exists — no need to create - : computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode); - const expectedPhaseDir = expectedPhaseDirName - ? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName))) - : null; - - const cfg = config as GSDConfig; - const result: Record = { - researcher_model: researcherModel, - planner_model: plannerModel, - checker_model: checkerModel, - tdd_mode: tddFlag || (config.workflow.tdd_mode ?? false), - research_enabled: config.workflow.research, - plan_checker_enabled: config.workflow.plan_check, - nyquist_validation_enabled: config.workflow.nyquist_validation, - commit_docs: config.commit_docs, - text_mode: config.workflow.text_mode, - auto_advance: !!config.workflow.auto_advance, - auto_chain_active: !!config.workflow._auto_chain_active, - mode: cfg.mode ?? 'interactive', - phase_found: !!phaseInfo, - phase_dir: phaseDir, - expected_phase_dir: expectedPhaseDir, - phase_number: phaseNumber, - phase_name: phaseName, - phase_slug: (phaseInfo?.phase_slug as string) ?? null, - padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, - phase_req_ids, - phase_status: phaseStatus, - has_research: (phaseInfo?.has_research as boolean) || false, - has_context: (phaseInfo?.has_context as boolean) || false, - has_reviews: (phaseInfo?.has_reviews as boolean) || false, - has_plans: plans.length > 0, - plan_count: plans.length, - planning_exists: existsSync(planningDir), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))), - patterns_path: null, - }; - - // Add artifact paths if phase directory exists - if (phaseDir) { - const phaseDirFull = join(projectDir, phaseDir); - try { - const files = readdirSync(phaseDirFull); - const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile)); - const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile)); - const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile)); - const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); - if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile)); - const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); - if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile)); - const patternsFile = files.find(f => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md'); - if (patternsFile) result.patterns_path = toPosixPath(join(phaseDir, patternsFile)); - } catch { /* intentionally empty */ } - } - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initNewMilestone ───────────────────────────────────────────────────── - -/** - * Init handler for new-milestone workflow. - * Port of cmdInitNewMilestone from init.cjs lines 401-446. - */ -export const initNewMilestone: QueryHandler = async (_args, projectDir) => { - const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); - const milestone = await getMilestoneInfo(projectDir); - const latestCompleted = getLatestCompletedMilestone(projectDir); - - const phasesDir = join(planningDir, 'phases'); - let phaseDirCount = 0; - try { - if (existsSync(phasesDir)) { - // Bug #2445 parity with CJS `cmdInitNewMilestone`: filter phase dirs - // to the current milestone so stale dirs from a prior milestone that - // weren't archived don't inflate the count. Without this filter the - // SDK returns the full directory count, which the new-milestone - // workflow then uses to gate "is this a fresh start" decisions. - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); - phaseDirCount = readdirSync(phasesDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && isDirInMilestone(entry.name)) - .length; - } - } catch { /* intentionally empty */ } - - const [researcherModel, synthesizerModel, roadmapperModel] = await Promise.all([ - getModelAlias('gsd-project-researcher', projectDir), - getModelAlias('gsd-research-synthesizer', projectDir), - getModelAlias('gsd-roadmapper', projectDir), - ]); - - const result: Record = { - researcher_model: researcherModel, - synthesizer_model: synthesizerModel, - roadmapper_model: roadmapperModel, - commit_docs: config.commit_docs, - research_enabled: config.workflow.research, - current_milestone: milestone.version, - current_milestone_name: milestone.name, - latest_completed_milestone: latestCompleted?.version || null, - latest_completed_milestone_name: latestCompleted?.name || null, - phase_dir_count: phaseDirCount, - phase_archive_path: latestCompleted - ? toPosixPath(relative(projectDir, join(projectDir, '.planning', 'milestones', `${latestCompleted.version}-phases`))) - : null, - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - state_exists: existsSync(join(planningDir, 'STATE.md')), - project_path: '.planning/PROJECT.md', - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initQuick ──────────────────────────────────────────────────────────── - -/** - * Init handler for quick workflow. - * Port of cmdInitQuick from init.cjs lines 448-504. - */ -export const initQuick: QueryHandler = async (args, projectDir) => { - const description = args[0] || null; - const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); - const now = new Date(); - const slug = description ? generateSlugInternal(description).substring(0, 40) : null; - - // Generate collision-resistant quick task ID: YYMMDD-xxx - const yy = String(now.getFullYear()).slice(-2); - const mm = String(now.getMonth() + 1).padStart(2, '0'); - const dd = String(now.getDate()).padStart(2, '0'); - const dateStr = yy + mm + dd; - const secondsSinceMidnight = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); - const timeBlocks = Math.floor(secondsSinceMidnight / 2); - const timeEncoded = timeBlocks.toString(36).padStart(3, '0'); - const quickId = dateStr + '-' + timeEncoded; - const branchSlug = slug || 'quick'; - const quickBranchName = config.git.quick_branch_template - ? config.git.quick_branch_template - .replace('{num}', quickId) - .replace('{quick}', quickId) - .replace('{slug}', branchSlug) - : null; - - const configExists = existsSync(join(planningDir, 'config.json')); - const [plannerModel, executorModel, checkerModel, verifierModel] = configExists - ? await Promise.all([ - getModelAlias('gsd-planner', projectDir), - getModelAlias('gsd-executor', projectDir), - getModelAlias('gsd-plan-checker', projectDir), - getModelAlias('gsd-verifier', projectDir), - ]) - : ['', '', '', '']; - - const result: Record = { - planner_model: plannerModel, - executor_model: executorModel, - checker_model: checkerModel, - verifier_model: verifierModel, - commit_docs: config.commit_docs, - branch_name: quickBranchName, - quick_id: quickId, - slug, - description, - date: now.toISOString().split('T')[0], - timestamp: now.toISOString(), - quick_dir: '.planning/quick', - task_dir: slug ? `.planning/quick/${quickId}-${slug}` : null, - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - planning_exists: existsSync(join(projectDir, '.planning')), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initResume ─────────────────────────────────────────────────────────── - -/** - * Init handler for resume-project workflow. - * Port of cmdInitResume from init.cjs lines 506-536. - */ -export const initResume: QueryHandler = async (_args, projectDir) => { - const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); - - let interruptedAgentId: string | null = null; - try { - interruptedAgentId = readFileSync(join(projectDir, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); - } catch { /* intentionally empty */ } - - const result: Record = { - state_exists: existsSync(join(planningDir, 'STATE.md')), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - planning_exists: existsSync(join(projectDir, '.planning')), - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - project_path: '.planning/PROJECT.md', - has_interrupted_agent: !!interruptedAgentId, - interrupted_agent_id: interruptedAgentId, - commit_docs: config.commit_docs, - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initVerifyWork ─────────────────────────────────────────────────────── - -/** - * Init handler for verify-work workflow. - * Port of cmdInitVerifyWork from init.cjs lines 538-586. - */ -export const initVerifyWork: QueryHandler = async (args, projectDir, workstream) => { - const phase = extractPhaseArg(args); - if (!phase) { - return { data: { error: 'phase required for init verify-work' } }; - } - - const config = await loadConfig(projectDir, workstream); - const { phaseInfo } = await getPhaseInfoForVerifyWork(phase, projectDir, workstream); - - const configExists = existsSync(join(projectDir, '.planning', 'config.json')); - const [plannerModel, checkerModel] = configExists - ? await Promise.all([ - getModelAlias('gsd-planner', projectDir), - getModelAlias('gsd-plan-checker', projectDir), - ]) - : ['', '']; - - const result: Record = { - planner_model: plannerModel, - checker_model: checkerModel, - commit_docs: config.commit_docs, - phase_found: !!phaseInfo, - phase_dir: (phaseInfo?.directory as string) ?? null, - phase_number: (phaseInfo?.phase_number as string) ?? null, - phase_name: (phaseInfo?.phase_name as string) ?? null, - has_verification: (phaseInfo?.has_verification as boolean) || false, - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initPhaseOp ────────────────────────────────────────────────────────── - -/** - * Init handler for discuss-phase and similar phase operations. - * Port of cmdInitPhaseOp from init.cjs lines 588-697. - */ -export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) => { - const phase = extractPhaseArg(args); - if (!phase) { - return { data: { error: 'phase required for init phase-op' } }; - } - - const config = await loadConfig(projectDir); - const paths = planningPaths(projectDir, workstream); - const planningDir = paths.planning; - - // findPhase with archived override: if only match is archived, prefer ROADMAP - const phaseResult = await findPhase([phase], projectDir, workstream); - let phaseInfo = phaseResult.data as Record | null; - - const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); - const roadmapPhase = roadmapResult.data as Record | null; - - // If the only match comes from an archived milestone, prefer current ROADMAP - if (roadmapPhase?.found && await shouldDropArchivedPhaseMatch(phaseInfo, roadmapPhase, projectDir, workstream)) { - const phaseName = roadmapPhase.phase_name as string; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? generateSlugInternal(phaseName) : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - }; - } - - // Fallback to ROADMAP.md if no directory exists - if (!phaseInfo || !phaseInfo.found) { - if (roadmapPhase?.found) { - const phaseName = roadmapPhase.phase_name as string; - phaseInfo = { - found: true, - directory: null, - phase_number: roadmapPhase.phase_number, - phase_name: phaseName, - phase_slug: phaseName ? generateSlugInternal(phaseName) : null, - plans: [], - summaries: [], - incomplete_plans: [], - has_research: false, - has_context: false, - has_verification: false, - }; - } - } - - const phaseFound = !!(phaseInfo && phaseInfo.found); - const phaseNumber = (phaseInfo?.phase_number as string) || null; - const phaseName = (phaseInfo?.phase_name as string) ?? null; - const phaseDir = (phaseInfo?.directory as string) ?? null; - const plans = (phaseInfo?.plans || []) as string[]; - - // #3287: compute the canonical directory name with project_code prefix so - // the first-touch mkdir in /gsd-discuss-phase stays consistent with phase.add. - const rawProjectCode = (config as Record).project_code as string || ''; - assertSafeProjectCode(rawProjectCode); - const expectedPhaseDirName = phaseDir - ? null // directory already exists — no need to create - : computeExpectedPhaseDirName(phaseNumber, phaseName, rawProjectCode); - const expectedPhaseDir = expectedPhaseDirName - ? toPosixPath(relative(projectDir, join(paths.phases, expectedPhaseDirName))) - : null; - - const result: Record = { - commit_docs: config.commit_docs, - // #2997: secret config keys (brave_search, firecrawl, exa_search) may be - // either boolean availability flags OR string API keys depending on how the - // user configured them. Pass booleans through; mask string values so the - // init bundle never echoes plaintext credentials. Mirrors the masking added - // to config-get/config-set in the same fix. - brave_search: typeof config.brave_search === 'string' ? maskIfSecret('brave_search', config.brave_search) : config.brave_search, - firecrawl: typeof config.firecrawl === 'string' ? maskIfSecret('firecrawl', config.firecrawl) : config.firecrawl, - exa_search: typeof config.exa_search === 'string' ? maskIfSecret('exa_search', config.exa_search) : config.exa_search, - phase_found: phaseFound, - phase_dir: phaseDir, - expected_phase_dir: expectedPhaseDir, - phase_number: phaseNumber, - phase_name: phaseName, - phase_slug: (phaseInfo?.phase_slug as string) ?? null, - padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, - has_research: (phaseInfo?.has_research as boolean) || false, - has_context: (phaseInfo?.has_context as boolean) || false, - has_plans: plans.length > 0, - has_verification: (phaseInfo?.has_verification as boolean) || false, - has_reviews: (phaseInfo?.has_reviews as boolean) || false, - plan_count: plans.length, - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - planning_exists: existsSync(planningDir), - state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), - roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), - requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))), - }; - - // Add artifact paths if phase directory exists - if (phaseDir) { - const phaseDirFull = join(projectDir, phaseDir); - try { - const files = readdirSync(phaseDirFull); - const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); - if (contextFile) result.context_path = toPosixPath(join(phaseDir, contextFile)); - const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); - if (researchFile) result.research_path = toPosixPath(join(phaseDir, researchFile)); - const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); - if (verificationFile) result.verification_path = toPosixPath(join(phaseDir, verificationFile)); - const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); - if (uatFile) result.uat_path = toPosixPath(join(phaseDir, uatFile)); - const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); - if (reviewsFile) result.reviews_path = toPosixPath(join(phaseDir, reviewsFile)); - } catch { /* intentionally empty */ } - } - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initTodos ──────────────────────────────────────────────────────────── - -/** - * Init handler for check-todos and add-todo workflows. - * Port of cmdInitTodos from init.cjs lines 699-756. - */ -export const initTodos: QueryHandler = async (args, projectDir) => { - const area = args[0] || null; - const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); - const now = new Date(); - - const pendingDir = join(planningDir, 'todos', 'pending'); - let count = 0; - const todos: Array> = []; - - try { - const files = readdirSync(pendingDir).filter(f => f.endsWith('.md')); - for (const file of files) { - try { - const content = readFileSync(join(pendingDir, file), 'utf-8'); - const createdMatch = content.match(/^created:\s*(.+)$/m); - const titleMatch = content.match(/^title:\s*(.+)$/m); - const areaMatch = content.match(/^area:\s*(.+)$/m); - const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; - - if (area && todoArea !== area) continue; - - count++; - todos.push({ - file, - created: createdMatch ? createdMatch[1].trim() : 'unknown', - title: titleMatch ? titleMatch[1].trim() : 'Untitled', - area: todoArea, - path: toPosixPath(relative(projectDir, join(pendingDir, file))), - }); - } catch { /* intentionally empty */ } - } - } catch { /* intentionally empty */ } - - const result: Record = { - commit_docs: config.commit_docs, - date: now.toISOString().split('T')[0], - timestamp: now.toISOString(), - todo_count: count, - todos, - area_filter: area, - pending_dir: toPosixPath(relative(projectDir, join(planningDir, 'todos', 'pending'))), - completed_dir: toPosixPath(relative(projectDir, join(planningDir, 'todos', 'completed'))), - planning_exists: existsSync(planningDir), - todos_dir_exists: existsSync(join(planningDir, 'todos')), - pending_dir_exists: existsSync(pendingDir), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initMilestoneOp ───────────────────────────────────────────────────── - -/** - * Init handler for complete-milestone and audit-milestone workflows. - * Port of cmdInitMilestoneOp from init.cjs lines 758-817. - */ -export const initMilestoneOp: QueryHandler = async (_args, projectDir, workstream) => { - const config = await loadConfig(projectDir); - const paths = planningPaths(projectDir, workstream); - const planningDir = paths.planning; - const milestone = await getMilestoneInfo(projectDir, workstream); - - const phasesDir = join(planningDir, 'phases'); - let phaseCount = 0; - let completedPhases = 0; - - // Bug #2633 — ROADMAP.md (current milestone section) is the authority for - // phase counts, NOT the on-disk `.planning/phases/` directory. After - // `phases clear` between milestones, on-disk dirs will be a subset of the - // roadmap until each phase is materialized, and reading from disk causes - // `all_phases_complete: true` to fire as soon as the materialized subset - // gets summaries — even though the roadmap has phases still to do. - let roadmapPhaseNumbers: string[] = []; - try { - const { readFile } = await import('node:fs/promises'); - const roadmapRaw = await readFile(join(planningDir, 'ROADMAP.md'), 'utf-8'); - const currentSection = await extractCurrentMilestone(roadmapRaw, projectDir, workstream); - roadmapPhaseNumbers = extractPhasesFromSection(currentSection).map(p => p.number); - } catch { /* intentionally empty */ } - - // Build the on-disk index keyed by the canonical full phase token (e.g. - // "3", "3A", "3.1") so distinct tokens with the same integer prefix never - // collide. Roadmap writes "Phase 3", "Phase 3A", and "Phase 3.1" as - // distinct phases and disk dirs preserve those tokens. - // Canonicalize a phase token by stripping leading zeros from the integer - // head while preserving any [A-Z]? suffix and dotted segments. So "03" → - // "3", "03A" → "3A", "03.1" → "3.1", "3A" → "3A". This lets disk dirs that - // pad ("03-alpha") match roadmap tokens ("Phase 3") without ever collapsing - // distinct tokens like "3" / "3A" / "3.1" into the same bucket. - const canonicalizePhase = (tok: string): string => { - const m = tok.match(/^(\d+)([A-Z]?(?:\.\d+)*)$/); - return m ? String(parseInt(m[1], 10)) + m[2] : tok; - }; - const diskPhaseDirs: Map = new Map(); - try { - const entries = readdirSync(phasesDir, { withFileTypes: true }); - for (const e of entries) { - if (!e.isDirectory()) continue; - const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/); - if (!m) continue; - diskPhaseDirs.set(canonicalizePhase(m[1]), e.name); - } - } catch { /* intentionally empty */ } - - if (roadmapPhaseNumbers.length > 0) { - phaseCount = roadmapPhaseNumbers.length; - for (const num of roadmapPhaseNumbers) { - const dirName = diskPhaseDirs.get(canonicalizePhase(num)); - if (!dirName) continue; - try { - const phaseFiles = readdirSync(join(phasesDir, dirName)); - const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - if (hasSummary) completedPhases++; - } catch { /* intentionally empty */ } - } - } else { - // Fallback: no parseable ROADMAP (e.g. brand-new project). Preserve the - // legacy on-disk-count behavior so existing no-roadmap tests still pass. - try { - const entries = readdirSync(phasesDir, { withFileTypes: true }); - const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); - phaseCount = dirs.length; - for (const dir of dirs) { - try { - const phaseFiles = readdirSync(join(phasesDir, dir)); - const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); - if (hasSummary) completedPhases++; - } catch { /* intentionally empty */ } - } - } catch { /* intentionally empty */ } - } - - const archiveDir = join(planningDir, 'archive'); - let archivedMilestones: string[] = []; - try { - archivedMilestones = readdirSync(archiveDir, { withFileTypes: true }) - .filter(e => e.isDirectory()) - .map(e => e.name); - } catch { /* intentionally empty */ } - - const result: Record = { - commit_docs: config.commit_docs, - milestone_version: milestone.version, - milestone_name: milestone.name, - milestone_slug: generateSlugInternal(milestone.name), - phase_count: phaseCount, - completed_phases: completedPhases, - all_phases_complete: phaseCount > 0 && phaseCount === completedPhases, - archived_milestones: archivedMilestones, - archive_count: archivedMilestones.length, - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), - state_exists: existsSync(join(planningDir, 'STATE.md')), - archive_exists: existsSync(archiveDir), - phases_dir_exists: existsSync(phasesDir), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initMapCodebase ────────────────────────────────────────────────────── - -/** - * Init handler for map-codebase workflow. - * Port of cmdInitMapCodebase from init.cjs lines 819-852. - */ -export const initMapCodebase: QueryHandler = async (_args, projectDir) => { - const config = await loadConfig(projectDir); - const now = new Date(); - const codebaseDir = join(projectDir, '.planning', 'codebase'); - let existingMaps: string[] = []; - try { - existingMaps = readdirSync(codebaseDir).filter(f => f.endsWith('.md')); - } catch { /* intentionally empty */ } - - const mapperModel = await getModelAlias('gsd-codebase-mapper', projectDir); - - const result: Record = { - mapper_model: mapperModel, - commit_docs: config.commit_docs, - search_gitignored: config.search_gitignored, - parallelization: config.parallelization, - // subagent_timeout lives at workflow.subagent_timeout per the canonical - // Configuration manifest (sdk/shared/config-defaults.manifest.json). Reading - // the top-level config.subagent_timeout returned undefined, so the workflow - // step that consumes this value had to invent its own fallback. Default to - // 300000 (5 min) per the manifest. (#1472) - subagent_timeout: (((config as Record).workflow as Record | undefined)?.subagent_timeout as number | undefined) ?? 300000, - date: now.toISOString().split('T')[0], - timestamp: now.toISOString(), - codebase_dir: '.planning/codebase', - existing_maps: existingMaps, - has_maps: existingMaps.length > 0, - planning_exists: pathExists(projectDir, '.planning'), - codebase_dir_exists: pathExists(projectDir, '.planning/codebase'), - }; - - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; - -// ─── initNewWorkspace ───────────────────────────────────────────────────── - -/** - * Init handler for new-workspace workflow. - * Port of cmdInitNewWorkspace from init.cjs lines 1311-1335. - * T-14-01: Validates workspace name rejects path separators. - */ -export const initNewWorkspace: QueryHandler = async (_args, projectDir) => { - const home = process.env.HOME || homedir(); - const defaultBase = join(home, 'gsd-workspaces'); - - // Detect child git repos (one level deep) - const childRepos: Array<{ name: string; path: string; has_uncommitted: boolean }> = []; - try { - const entries = readdirSync(projectDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const fullPath = join(projectDir, entry.name); - if (existsSync(join(fullPath, '.git'))) { - let hasUncommitted = false; - try { - const status = execSync('git status --porcelain', { cwd: fullPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); - hasUncommitted = status.trim().length > 0; - } catch { /* best-effort */ } - childRepos.push({ name: entry.name, path: fullPath, has_uncommitted: hasUncommitted }); - } - } - } catch { /* intentionally empty */ } - - let worktreeAvailable = false; - try { - execSync('git --version', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); - worktreeAvailable = true; - } catch { /* no git */ } - - const result: Record = { - default_workspace_base: defaultBase, - child_repos: childRepos, - child_repo_count: childRepos.length, - worktree_available: worktreeAvailable, - is_git_repo: pathExists(projectDir, '.git'), - cwd_repo_name: basename(projectDir), - }; - - return { data: withProjectRoot(projectDir, result) }; -}; - -// ─── initListWorkspaces ─────────────────────────────────────────────────── - -/** - * Init handler for list-workspaces workflow. - * Port of cmdInitListWorkspaces from init.cjs lines 1337-1381. - */ -export const initListWorkspaces: QueryHandler = async (_args, _projectDir) => { - const home = process.env.HOME || homedir(); - const defaultBase = join(home, 'gsd-workspaces'); - - const workspaces: Array> = []; - if (existsSync(defaultBase)) { - let entries: Dirent[] = []; - try { - entries = readdirSync(defaultBase, { withFileTypes: true }); - } catch { entries = []; } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const wsPath = join(defaultBase, String(entry.name)); - const manifestPath = join(wsPath, 'WORKSPACE.md'); - if (!existsSync(manifestPath)) continue; - - let repoCount = 0; - let strategy = 'unknown'; - try { - const manifest = readFileSync(manifestPath, 'utf8'); - const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); - if (strategyMatch) strategy = strategyMatch[1].trim(); - const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---')); - repoCount = tableRows.length; - } catch { /* best-effort */ } - const hasProject = existsSync(join(wsPath, '.planning', 'PROJECT.md')); - - workspaces.push({ - name: entry.name, - path: wsPath, - repo_count: repoCount, - strategy, - has_project: hasProject, - }); - } - } - - const result: Record = { - workspace_base: defaultBase, - workspaces, - workspace_count: workspaces.length, - }; - - return { data: result }; -}; - -// ─── initRemoveWorkspace ────────────────────────────────────────────────── - -/** - * Init handler for remove-workspace workflow. - * Port of cmdInitRemoveWorkspace from init.cjs lines 1383-1443. - * T-14-01: Validates workspace name rejects path separators and '..' sequences. - */ -export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => { - const name = args[0]; - if (!name) { - // Throw so the CLI dispatcher projects a non-zero exit + writes the message - // to stderr — returning `{ data: { error } }` was treated as success by - // the CLI output path, hiding the validation failure from callers. - throw new GSDError('workspace name required for init remove-workspace', ErrorClassification.Validation); - } - - // T-14-01: Reject path traversal attempts - if (name.includes('/') || name.includes('\\') || name.includes('..')) { - throw new GSDError( - `Invalid workspace name: ${name} (path separators not allowed)`, - ErrorClassification.Validation, - ); - } - - const home = process.env.HOME || homedir(); - const defaultBase = join(home, 'gsd-workspaces'); - const wsPath = join(defaultBase, name); - const manifestPath = join(wsPath, 'WORKSPACE.md'); - - if (!existsSync(wsPath)) { - throw new GSDError(`Workspace not found: ${wsPath}`, ErrorClassification.Validation); - } - - const repos: Array> = []; - let strategy = 'unknown'; - if (existsSync(manifestPath)) { - try { - const manifest = readFileSync(manifestPath, 'utf8'); - const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); - if (strategyMatch) strategy = strategyMatch[1].trim(); - - const lines = manifest.split('\n'); - for (const line of lines) { - const match = line.match(/^\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|$/); - if (match && match[1] !== 'Repo' && !match[1].includes('---')) { - repos.push({ name: match[1], source: match[2], branch: match[3], strategy: match[4] }); - } - } - } catch { /* best-effort */ } - } - - // Check for uncommitted changes in workspace repos - const dirtyRepos: string[] = []; - for (const repo of repos) { - const repoPath = join(wsPath, repo.name as string); - if (!existsSync(repoPath)) continue; - try { - const status = execSync('git status --porcelain', { cwd: repoPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); - if (status.trim().length > 0) { - dirtyRepos.push(repo.name as string); - } - } catch { /* best-effort */ } - } - - const result: Record = { - workspace_name: name, - workspace_path: wsPath, - has_manifest: existsSync(manifestPath), - strategy, - repos, - repo_count: repos.length, - dirty_repos: dirtyRepos, - has_dirty_repos: dirtyRepos.length > 0, - }; - - return { data: result }; -}; -// ─── initIngestDocs ─────────────────────────────────────────────────────── - -/** - * Init handler for ingest-docs workflow. - * Mirrors `initResume` shape but without current-agent-id lookup — the - * ingest-docs workflow reads `project_exists`, `planning_exists`, `has_git`, - * and `project_path` to branch between new-project vs merge-milestone modes. - */ -export const initIngestDocs: QueryHandler = async (_args, projectDir) => { - const config = await loadConfig(projectDir); - const gitInfo = gitWorktreeInfo(projectDir); - const result: Record = { - project_exists: pathExists(projectDir, '.planning/PROJECT.md'), - planning_exists: pathExists(projectDir, '.planning'), - // Bug #3491: detect parent worktree to avoid nested .git init. - has_git: gitInfo.inside, - git_worktree_root: gitInfo.worktreeRoot, - in_nested_subdir: detectNestedSubdir(projectDir, gitInfo), - project_path: '.planning/PROJECT.md', - commit_docs: config.commit_docs, - }; - return { data: withProjectRoot(projectDir, result, config as Record) }; -}; diff --git a/sdk/src/handlers/init/index.ts b/sdk/src/handlers/init/index.ts deleted file mode 100644 index 3ce651065..000000000 --- a/sdk/src/handlers/init/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { QueryHandler } from '../../query/utils.js'; -import { - initExecutePhase, initPlanPhase, initNewMilestone, initQuick, - initIngestDocs, initResume, initVerifyWork, initPhaseOp, initTodos, - initMilestoneOp, initMapCodebase, initNewWorkspace, - initListWorkspaces, initRemoveWorkspace, -} from './composer.js'; -import { initNewProject, initProgress, initManager } from './complex.js'; - -export const INIT_FAMILY_HANDLERS: Readonly> = { - 'init.execute-phase': initExecutePhase, - 'init.plan-phase': initPlanPhase, - 'init.new-project': initNewProject, - 'init.new-milestone': initNewMilestone, - 'init.quick': initQuick, - 'init.ingest-docs': initIngestDocs, - 'init.resume': initResume, - 'init.verify-work': initVerifyWork, - 'init.phase-op': initPhaseOp, - 'init.todos': initTodos, - 'init.milestone-op': initMilestoneOp, - 'init.map-codebase': initMapCodebase, - 'init.progress': initProgress, - 'init.manager': initManager, - 'init.new-workspace': initNewWorkspace, - 'init.list-workspaces': initListWorkspaces, - 'init.remove-workspace': initRemoveWorkspace, -}; diff --git a/sdk/src/handlers/phase/index.ts b/sdk/src/handlers/phase/index.ts deleted file mode 100644 index b4f7368ea..000000000 --- a/sdk/src/handlers/phase/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { QueryHandler } from '../../query/utils.js'; -import { phaseListPlans, phaseListArtifacts } from '../../query/phase-list-queries.js'; -import { phaseUatPassed } from '../../query/phase-uat-passed.js'; -import { - phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete, - phaseScaffold, phaseNextDecimal, -} from '../../query/phase-lifecycle.js'; - -export const PHASE_FAMILY_HANDLERS: Readonly> = { - 'phase.list-plans': phaseListPlans, - 'phase.list-artifacts': phaseListArtifacts, - 'phase.uat-passed': phaseUatPassed, - 'phase.add': phaseAdd, - 'phase.add-batch': phaseAddBatch, - 'phase.insert': phaseInsert, - 'phase.remove': phaseRemove, - 'phase.complete': phaseComplete, - 'phase.scaffold': phaseScaffold, - 'phase.next-decimal': phaseNextDecimal, -}; diff --git a/sdk/src/handlers/phases/index.ts b/sdk/src/handlers/phases/index.ts deleted file mode 100644 index 94ec5b604..000000000 --- a/sdk/src/handlers/phases/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { QueryHandler } from '../../query/utils.js'; -import { phasesList, phasesClear, phasesArchive } from '../../query/phase-lifecycle.js'; - -export const PHASES_FAMILY_HANDLERS: Readonly> = { - 'phases.list': phasesList, - 'phases.clear': phasesClear, - 'phases.archive': phasesArchive, -}; diff --git a/sdk/src/handlers/roadmap/index.ts b/sdk/src/handlers/roadmap/index.ts deleted file mode 100644 index 5da0975d7..000000000 --- a/sdk/src/handlers/roadmap/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { QueryHandler } from '../../query/utils.js'; -import { roadmapAnalyze, roadmapGetPhase, roadmapAnnotateDependencies } from '../../query/roadmap.js'; -import { roadmapUpdatePlanProgress } from '../../query/roadmap-update-plan-progress.js'; - -export const ROADMAP_FAMILY_HANDLERS: Readonly> = { - 'roadmap.analyze': roadmapAnalyze, - 'roadmap.get-phase': roadmapGetPhase, - 'roadmap.update-plan-progress': roadmapUpdatePlanProgress, - 'roadmap.annotate-dependencies': roadmapAnnotateDependencies, -}; diff --git a/sdk/src/handlers/state/index.ts b/sdk/src/handlers/state/index.ts deleted file mode 100644 index 0c540182e..000000000 --- a/sdk/src/handlers/state/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { QueryHandler } from '../../query/utils.js'; -import { stateProjectLoad } from '../../query/state-project-load.js'; -import { stateJson, stateGet } from '../../query/state.js'; -import { - stateUpdate, statePatch, stateBeginPhase, stateAdvancePlan, - stateRecordMetric, stateUpdateProgress, stateAddDecision, - stateAddBlocker, stateResolveBlocker, stateRecordSession, - stateSignalWaiting, stateSignalResume, statePlannedPhase, - stateValidate, stateSync, statePrune, stateMilestoneSwitch, - stateAddRoadmapEvolution, -} from '../../query/state-mutation.js'; - -export const STATE_FAMILY_HANDLERS: Readonly> = { - 'state.load': stateProjectLoad, - 'state.json': stateJson, - 'state.get': stateGet, - 'state.update': stateUpdate, - 'state.patch': statePatch, - 'state.begin-phase': stateBeginPhase, - 'state.advance-plan': stateAdvancePlan, - 'state.record-metric': stateRecordMetric, - 'state.update-progress': stateUpdateProgress, - 'state.add-decision': stateAddDecision, - 'state.add-blocker': stateAddBlocker, - 'state.resolve-blocker': stateResolveBlocker, - 'state.record-session': stateRecordSession, - 'state.signal-waiting': stateSignalWaiting, - 'state.signal-resume': stateSignalResume, - 'state.planned-phase': statePlannedPhase, - 'state.validate': stateValidate, - 'state.sync': stateSync, - 'state.prune': statePrune, - 'state.milestone-switch': stateMilestoneSwitch, - 'state.add-roadmap-evolution': stateAddRoadmapEvolution, -}; diff --git a/sdk/src/handlers/validate/index.ts b/sdk/src/handlers/validate/index.ts deleted file mode 100644 index fc431cf72..000000000 --- a/sdk/src/handlers/validate/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { QueryHandler } from '../../query/utils.js'; -import { validateConsistency, validateHealth, validateAgents, validateContext } from '../../query/validate.js'; - -export const VALIDATE_FAMILY_HANDLERS: Readonly> = { - 'validate.consistency': validateConsistency, - 'validate.health': validateHealth, - 'validate.agents': validateAgents, - 'validate.context': validateContext, -}; diff --git a/sdk/src/handlers/verify/index.ts b/sdk/src/handlers/verify/index.ts deleted file mode 100644 index d4dce8e21..000000000 --- a/sdk/src/handlers/verify/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { QueryHandler } from '../../query/utils.js'; -import { - verifyPlanStructure, verifyPhaseCompleteness, verifyReferences, - verifyCommits, verifyArtifacts, verifySchemaDrift, -} from '../../query/verify.js'; -import { verifyKeyLinks } from '../../query/validate.js'; - -export const VERIFY_FAMILY_HANDLERS: Readonly> = { - 'verify.plan-structure': verifyPlanStructure, - 'verify.phase-completeness': verifyPhaseCompleteness, - 'verify.references': verifyReferences, - 'verify.commits': verifyCommits, - 'verify.artifacts': verifyArtifacts, - 'verify.key-links': verifyKeyLinks, - 'verify.schema-drift': verifySchemaDrift, - // 'verify.codebase-drift' intentionally omitted — out-of-seam CJS-only - // per ADR/PRD 3524 §3 / L160. Router dispatches direct to CJS handler. -}; diff --git a/sdk/src/index.ts b/sdk/src/index.ts deleted file mode 100644 index adb6d3734..000000000 --- a/sdk/src/index.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** - * GSD SDK — Public API for running GSD plans programmatically. - * - * The GSD class composes plan parsing, config loading, prompt building, - * and session running into a single `executePlan()` call. - * - * @example - * ```typescript - * import { GSD } from '@opengsd/gsd-sdk'; - * - * const gsd = new GSD({ projectDir: '/path/to/project' }); - * const result = await gsd.executePlan('.planning/phases/01-auth/01-auth-01-PLAN.md'); - * - * if (result.success) { - * console.log(`Plan completed in ${result.durationMs}ms, cost: $${result.totalCostUsd}`); - * } else { - * console.error(`Plan failed: ${result.error?.messages.join(', ')}`); - * } - * ``` - */ - -import { readFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; -import { homedir } from 'node:os'; - -import type { GSDOptions, PlanResult, SessionOptions, GSDEvent, TransportHandler, PhaseRunnerOptions, PhaseRunnerResult, MilestoneRunnerOptions, MilestoneRunnerResult, RoadmapPhaseInfo } from './types.js'; -import { GSDEventType } from './types.js'; -import { parsePlan, parsePlanFile } from './plan-parser.js'; -import { loadConfig } from './config.js'; -import { GSDTools, resolveGsdToolsPath } from './gsd-tools.js'; -import { runPlanSession } from './session-runner.js'; -import { buildExecutorPrompt, parseAgentTools } from './prompt-builder.js'; -import { GSDEventStream } from './event-stream.js'; -import { PhaseRunner } from './phase-runner.js'; -import { ContextEngine } from './context-engine.js'; -import { PromptFactory } from './phase-prompt.js'; - -export { PlanningJournal } from './planning-journal.js'; -export type { PlanningEvent, PlanningEventActor, PlanningJournalAppendInput } from './planning-journal.js'; -export { PlanningRuntime } from './planning-runtime.js'; - -// ─── GSD class ─────────────────────────────────────────────────────────────── - -export class GSD { - private readonly projectDir: string; - private readonly gsdToolsPath: string; - private readonly sessionId?: string; - private readonly defaultModel?: string; - private readonly defaultMaxBudgetUsd: number; - private readonly defaultMaxTurns: number; - private readonly autoMode: boolean; - private readonly workstream?: string; - private readonly strictSdk?: boolean; - private readonly allowFallbackToSubprocess?: boolean; - readonly eventStream: GSDEventStream; - - constructor(options: GSDOptions) { - this.projectDir = resolve(options.projectDir); - this.gsdToolsPath = - options.gsdToolsPath ?? resolveGsdToolsPath(this.projectDir); - this.sessionId = options.sessionId; - this.defaultModel = options.model; - this.defaultMaxBudgetUsd = options.maxBudgetUsd ?? 5.0; - this.defaultMaxTurns = options.maxTurns ?? 50; - this.autoMode = options.autoMode ?? false; - this.workstream = options.workstream; - this.strictSdk = options.strictSdk; - this.allowFallbackToSubprocess = options.allowFallbackToSubprocess; - this.eventStream = new GSDEventStream(); - } - - /** - * Execute a single GSD plan file. - * - * Reads the plan from disk, parses it, loads project config, - * optionally reads the agent definition, then runs a query() session. - * - * @param planPath - Path to the PLAN.md file (absolute or relative to projectDir) - * @param options - Per-execution overrides - * @returns PlanResult with cost, duration, success/error status - */ - async executePlan(planPath: string, options?: SessionOptions): Promise { - // Resolve plan path relative to project dir - const absolutePlanPath = resolve(this.projectDir, planPath); - - // Parse the plan - const plan = await parsePlanFile(absolutePlanPath); - - // Load project config - const config = await loadConfig(this.projectDir, this.workstream); - - // Try to load agent definition for tool restrictions - const agentDef = await this.loadAgentDefinition(); - - // Merge defaults with per-call options - const sessionOptions: SessionOptions = { - maxTurns: options?.maxTurns ?? this.defaultMaxTurns, - maxBudgetUsd: options?.maxBudgetUsd ?? this.defaultMaxBudgetUsd, - model: options?.model ?? this.defaultModel, - cwd: options?.cwd ?? this.projectDir, - allowedTools: options?.allowedTools, - }; - - return runPlanSession(plan, config, sessionOptions, agentDef, this.eventStream, { - phase: undefined, // Phase context set by higher-level orchestrators - planName: plan.frontmatter.plan, - }); - } - - /** - * Subscribe a simple handler to receive all GSD events. - */ - onEvent(handler: (event: GSDEvent) => void): void { - this.eventStream.on('event', handler); - } - - /** - * Subscribe a transport handler to receive all GSD events. - * Transports provide structured onEvent/close lifecycle. - */ - addTransport(handler: TransportHandler): void { - this.eventStream.addTransport(handler); - } - - /** - * Create a GSDTools instance for state management operations. - */ - createTools(): GSDTools { - return new GSDTools({ - projectDir: this.projectDir, - gsdToolsPath: this.gsdToolsPath, - workstream: this.workstream, - eventStream: this.eventStream, - sessionId: this.sessionId, - strictSdk: this.strictSdk, - allowFallbackToSubprocess: this.allowFallbackToSubprocess, - onDispatchEvent: (event) => { - this.eventStream.emitEvent({ - type: GSDEventType.StreamEvent, - timestamp: new Date().toISOString(), - sessionId: this.sessionId ?? '', - event, - }); - }, - }); - } - - /** - * Run a full phase lifecycle: discuss → research → plan → execute → verify → advance. - * - * Creates the necessary collaborators (GSDTools, PromptFactory, ContextEngine), - * loads project config, instantiates a PhaseRunner, and delegates to `runner.run()`. - * - * @param phaseNumber - The phase number to execute (e.g. "01", "02") - * @param options - Per-phase overrides for budget, turns, model, and callbacks - * @returns PhaseRunnerResult with per-step results, overall success, cost, and timing - */ - async runPhase(phaseNumber: string, options?: PhaseRunnerOptions): Promise { - const tools = this.createTools(); - const promptFactory = new PromptFactory({ projectDir: this.projectDir }); - const contextEngine = new ContextEngine(this.projectDir, undefined, undefined, this.workstream); - const config = await loadConfig(this.projectDir, this.workstream); - - // Auto mode: force auto_advance on and skip_discuss off so self-discuss kicks in - if (this.autoMode) { - config.workflow.auto_advance = true; - config.workflow.skip_discuss = false; - } - - const runner = new PhaseRunner({ - projectDir: this.projectDir, - tools, - promptFactory, - contextEngine, - eventStream: this.eventStream, - config, - }); - - return runner.run(phaseNumber, options); - } - - /** - * Run a full milestone: discover phases, execute each incomplete one in order, - * re-discover after each completion to catch dynamically inserted phases. - * - * @param prompt - The user prompt describing the milestone goal - * @param options - Per-milestone overrides for budget, turns, model, and callbacks - * @returns MilestoneRunnerResult with per-phase results, overall success, cost, and timing - */ - async run(prompt: string, options?: MilestoneRunnerOptions): Promise { - const tools = this.createTools(); - const startTime = Date.now(); - const phaseResults: PhaseRunnerResult[] = []; - let success = true; - - // Discover initial phases - const initialAnalysis = await tools.roadmapAnalyze(); - const incompletePhases = this.filterAndSortPhases(initialAnalysis.phases); - - // Emit MilestoneStart - this.eventStream.emitEvent({ - type: GSDEventType.MilestoneStart, - timestamp: new Date().toISOString(), - sessionId: `milestone-${Date.now()}`, - phaseCount: incompletePhases.length, - prompt, - }); - - // Loop through phases, re-discovering after each completion - let currentPhases = incompletePhases; - - while (currentPhases.length > 0) { - const phase = currentPhases[0]; - - try { - const result = await this.runPhase(phase.number, options); - phaseResults.push(result); - - if (!result.success) { - success = false; - break; - } - - // Notify callback if present; stop if requested - if (options?.onPhaseComplete) { - const verdict = await options.onPhaseComplete(result, phase); - if (verdict === 'stop') { - break; - } - } - - // Re-discover phases to catch dynamically inserted ones - const updatedAnalysis = await tools.roadmapAnalyze(); - currentPhases = this.filterAndSortPhases(updatedAnalysis.phases); - } catch (err) { - // Phase threw an unexpected error — record as failure and stop - phaseResults.push({ - phaseNumber: phase.number, - phaseName: phase.phase_name, - steps: [], - success: false, - totalCostUsd: 0, - totalDurationMs: 0, - }); - success = false; - break; - } - } - - const totalCostUsd = phaseResults.reduce((sum, r) => sum + r.totalCostUsd, 0); - const totalDurationMs = Date.now() - startTime; - - // Emit MilestoneComplete - this.eventStream.emitEvent({ - type: GSDEventType.MilestoneComplete, - timestamp: new Date().toISOString(), - sessionId: `milestone-${Date.now()}`, - success, - totalCostUsd, - totalDurationMs, - phasesCompleted: phaseResults.filter(r => r.success).length, - }); - - return { - success, - phases: phaseResults, - totalCostUsd, - totalDurationMs, - }; - } - - /** - * Filter to incomplete phases and sort numerically. - * Uses parseFloat to handle decimal phase numbers (e.g. '5.1'). - */ - private filterAndSortPhases(phases: RoadmapPhaseInfo[]): RoadmapPhaseInfo[] { - return phases - .filter(p => !p.roadmap_complete) - .sort((a, b) => parseFloat(a.number) - parseFloat(b.number)); - } - - /** - * Load the gsd-executor agent definition if available. - * Falls back gracefully — returns undefined if not found. - */ - private async loadAgentDefinition(): Promise { - const paths = [ - // Repo-local GSD installation - join(this.projectDir, '.claude', 'get-shit-done', 'agents', 'gsd-executor.md'), - // Repo-local agents directory - join(this.projectDir, '.claude', 'agents', 'gsd-executor.md'), - // Global home directory - join(homedir(), '.claude', 'agents', 'gsd-executor.md'), - join(this.projectDir, 'agents', 'gsd-executor.md'), - ]; - - for (const p of paths) { - try { - return await readFile(p, 'utf-8'); - } catch { - // Not found at this path, try next - } - } - - return undefined; - } -} - -// ─── Re-exports for advanced usage ────────────────────────────────────────── - -export { parsePlan, parsePlanFile } from './plan-parser.js'; -export { loadConfig } from './config.js'; -export type { GSDConfig } from './config.js'; -export { GSDTools, GSDToolsError, resolveGsdToolsPath } from './gsd-tools.js'; -export { runPlanSession, runPhaseStepSession } from './session-runner.js'; -export { buildExecutorPrompt, parseAgentTools } from './prompt-builder.js'; -export type { ExecutorPromptOptions } from './prompt-builder.js'; -export * from './types.js'; - -// S02: Event stream, context, prompt, and logging modules -export { GSDEventStream } from './event-stream.js'; -export type { EventStreamContext } from './event-stream.js'; -export { ContextEngine, PHASE_FILE_MANIFEST } from './context-engine.js'; -export type { FileSpec } from './context-engine.js'; -export { truncateMarkdown, extractCurrentMilestone, DEFAULT_TRUNCATION_OPTIONS } from './context-truncation.js'; -export type { TruncationOptions } from './context-truncation.js'; -export { getToolsForPhase, PHASE_AGENT_MAP, PHASE_DEFAULT_TOOLS } from './tool-scoping.js'; -export { checkResearchGate } from './research-gate.js'; -export type { ResearchGateResult } from './research-gate.js'; -export { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js'; -export { GSDLogger } from './logger.js'; -export type { LogLevel, LogEntry, GSDLoggerOptions } from './logger.js'; - -// S03: Phase lifecycle state machine -export { PhaseRunner, PhaseRunnerError } from './phase-runner.js'; -export type { PhaseRunnerDeps, VerificationOutcome } from './phase-runner.js'; - -// S05: Transports -export { CLITransport } from './cli-transport.js'; -export { WSTransport } from './ws-transport.js'; -export type { WSTransportOptions } from './ws-transport.js'; - -// Query registry argv normalization (matches `gsd-sdk query` and `GSDTools` hot path) -export { createRegistry, normalizeQueryCommand } from './query/index.js'; - -// Phase UAT predicate — programmatic API surface (#3184) -export { - isPhaseUatPassed, - phaseUatPassed, - REASON_CODE, - ERROR_CODE, - PhaseUatPassedError, -} from './query/phase-uat-passed.js'; -export type { - UatReason, - ReasonCode, - ErrorCode, -} from './query/phase-uat-passed.js'; - -// Workstream utilities -export { validateWorkstreamName, relPlanningPath } from './workstream-utils.js'; - -// Init workflow -export { InitRunner } from './init-runner.js'; -export type { InitRunnerDeps } from './init-runner.js'; -export type { InitConfig, InitResult, InitStepResult, InitStepName } from './types.js'; diff --git a/sdk/src/init-e2e.integration.test.ts b/sdk/src/init-e2e.integration.test.ts deleted file mode 100644 index af9e6e364..000000000 --- a/sdk/src/init-e2e.integration.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * E2E integration test — proves InitRunner.run() drives real Agent SDK - * sessions for the gsd-sdk init workflow. - * - * Requires Claude Code CLI (`claude`) installed and authenticated. - * Skips gracefully if CLI is unavailable. - * - * This test proves the headless init pipeline can bootstrap a real project - * without human intervention: setup → config → PROJECT.md → research → - * synthesis → requirements → roadmap. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { execSync } from 'node:child_process'; -import { mkdtemp, rm, readFile, stat } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; - -import { InitRunner } from './init-runner.js'; -import { GSDTools, resolveGsdToolsPath } from './gsd-tools.js'; -import { GSDEventStream } from './event-stream.js'; -import { GSDEventType } from './types.js'; -import type { GSDEvent } from './types.js'; - -// ─── CLI availability check ───────────────────────────────────────────────── - -let cliAvailable = false; -try { - execSync('which claude', { stdio: 'ignore' }); - cliAvailable = true; -} catch { - cliAvailable = false; -} - -const e2eEnabled = process.env.GSD_ENABLE_E2E === '1'; - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const sdkPromptsDir = join(__dirname, '..', 'prompts'); -const GSD_TOOLS_PATH = resolveGsdToolsPath(process.cwd()); -const gsdToolsAvailable = existsSync(GSD_TOOLS_PATH); - -// ─── Test suite ────────────────────────────────────────────────────────────── - -describe.skipIf(!cliAvailable || !gsdToolsAvailable || !e2eEnabled)('E2E: InitRunner.run() full workflow', () => { - let tmpDir: string; - let events: GSDEvent[]; - - beforeAll(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-init-e2e-')); - - // Initialize git in the temp dir (required by InitRunner) - execSync('git init', { cwd: tmpDir, stdio: 'ignore' }); - execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'ignore' }); - execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'ignore' }); - }, 30_000); - - afterAll(async () => { - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); - - it('InitRunner.run() bootstraps a project without human intervention', async () => { - events = []; - const eventStream = new GSDEventStream(); - eventStream.on('event', (e: GSDEvent) => events.push(e)); - - const tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: GSD_TOOLS_PATH, - timeoutMs: 30_000, - }); - - const runner = new InitRunner({ - projectDir: tmpDir, - tools, - eventStream, - config: { - maxBudgetPerSession: 1.0, - maxTurnsPerSession: 15, - }, - sdkPromptsDir, - }); - - const result = await runner.run('Build a CLI tool that prints hello world'); - - // ── Assert: pipeline executed (success OR at least 3+ steps completed) ── - const completedSteps = result.steps.filter(s => s.success); - const pipelineProgressed = result.success || completedSteps.length >= 3; - expect(pipelineProgressed).toBe(true); - - // ── Assert: config.json artifact created ── - // config.json is written directly by InitRunner (not by Claude session) - // so it should always exist if the config step succeeded - const configStep = result.steps.find(s => s.step === 'config'); - if (configStep?.success) { - const configPath = join(tmpDir, '.planning', 'config.json'); - const configStat = await stat(configPath).catch(() => null); - expect(configStat).not.toBeNull(); - - if (configStat) { - const configContent = JSON.parse(await readFile(configPath, 'utf-8')); - expect(configContent.workflow.auto_advance).toBe(true); - } - } - - // ── Assert: PROJECT.md created if project step succeeded ── - const projectStep = result.steps.find(s => s.step === 'project'); - if (projectStep?.success) { - const projectPath = join(tmpDir, '.planning', 'PROJECT.md'); - const projectStat = await stat(projectPath).catch(() => null); - expect(projectStat).not.toBeNull(); - } - - // ── Assert: events captured include InitStart and at least one InitStepComplete ── - const initStartEvents = events.filter(e => e.type === GSDEventType.InitStart); - expect(initStartEvents.length).toBe(1); - - const stepCompleteEvents = events.filter(e => e.type === GSDEventType.InitStepComplete); - expect(stepCompleteEvents.length).toBeGreaterThanOrEqual(1); - - // ── Assert: InitComplete event emitted ── - const initCompleteEvents = events.filter(e => e.type === GSDEventType.InitComplete); - expect(initCompleteEvents.length).toBe(1); - - // ── Assert: cost and duration are tracked ── - expect(result.totalDurationMs).toBeGreaterThan(0); - expect(typeof result.totalCostUsd).toBe('number'); - - // ── Assert: artifacts list is populated ── - if (result.success) { - expect(result.artifacts.length).toBeGreaterThan(0); - expect(result.artifacts).toContain('.planning/config.json'); - } - }, 600_000); // 10 minute timeout for the full 7-session init workflow -}); diff --git a/sdk/src/init-runner.test.ts b/sdk/src/init-runner.test.ts deleted file mode 100644 index bbd1d5a3d..000000000 --- a/sdk/src/init-runner.test.ts +++ /dev/null @@ -1,740 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdir, writeFile, rm, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { InitRunner } from './init-runner.js'; -import type { InitRunnerDeps } from './init-runner.js'; -import type { - PlanResult, - SessionUsage, - GSDEvent, - InitNewProjectInfo, - InitStepResult, -} from './types.js'; -import { GSDEventType } from './types.js'; - -// ─── Mock modules ──────────────────────────────────────────────────────────── - -// Mock session-runner to avoid real SDK calls -vi.mock('./session-runner.js', () => ({ - runPhaseStepSession: vi.fn(), - runPlanSession: vi.fn(), -})); - -// Mock config loader -vi.mock('./config.js', () => ({ - loadConfig: vi.fn().mockResolvedValue({ - mode: 'yolo', - model_profile: 'balanced', - }), - CONFIG_DEFAULTS: {}, -})); - -// Mock fs/promises for template reading (InitRunner reads GSD templates) -// We partially mock — only readFile needs interception for template paths -const originalReadFile = vi.importActual('node:fs/promises').then(m => (m as typeof import('node:fs/promises')).readFile); - -import { runPhaseStepSession } from './session-runner.js'; - -const mockRunSession = vi.mocked(runPhaseStepSession); - -// ─── Factory helpers ───────────────────────────────────────────────────────── - -function makeUsage(): SessionUsage { - return { - inputTokens: 1000, - outputTokens: 500, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - }; -} - -function makeSuccessResult(overrides: Partial = {}): PlanResult { - return { - success: true, - sessionId: `sess-${Date.now()}`, - totalCostUsd: 0.05, - durationMs: 2000, - usage: makeUsage(), - numTurns: 10, - ...overrides, - }; -} - -function makeErrorResult(overrides: Partial = {}): PlanResult { - return { - success: false, - sessionId: `sess-err-${Date.now()}`, - totalCostUsd: 0.01, - durationMs: 500, - usage: makeUsage(), - numTurns: 2, - error: { - subtype: 'error_during_execution', - messages: ['Session failed'], - }, - ...overrides, - }; -} - -function makeProjectInfo(overrides: Partial = {}): InitNewProjectInfo { - return { - researcher_model: 'claude-sonnet-4-6', - synthesizer_model: 'claude-sonnet-4-6', - roadmapper_model: 'claude-sonnet-4-6', - commit_docs: false, // false for tests — no git operations - project_exists: false, - has_codebase_map: false, - planning_exists: false, - has_existing_code: false, - has_package_file: false, - is_brownfield: false, - needs_codebase_map: false, - has_git: true, // skip git init in tests - brave_search_available: false, - firecrawl_available: false, - exa_search_available: false, - project_path: '.planning/PROJECT.md', - ...overrides, - }; -} - -function makeTools(overrides: Record = {}) { - return { - initNewProject: vi.fn().mockResolvedValue(makeProjectInfo()), - configSet: vi.fn().mockResolvedValue(undefined), - commit: vi.fn().mockResolvedValue(undefined), - exec: vi.fn(), - stateLoad: vi.fn(), - roadmapAnalyze: vi.fn(), - phaseComplete: vi.fn(), - verifySummary: vi.fn(), - initExecutePhase: vi.fn(), - initPhaseOp: vi.fn(), - configGet: vi.fn(), - stateBeginPhase: vi.fn(), - phasePlanIndex: vi.fn(), - ...overrides, - } as any; -} - -function makeEventStream() { - const events: GSDEvent[] = []; - return { - emitEvent: vi.fn((event: GSDEvent) => events.push(event)), - on: vi.fn(), - emit: vi.fn(), - addTransport: vi.fn(), - events, - } as any; -} - -function makeDeps(overrides: Partial & { tmpDir: string }): InitRunnerDeps & { events: GSDEvent[] } { - const tools = makeTools(); - const eventStream = makeEventStream(); - return { - projectDir: overrides.tmpDir, - tools: overrides.tools ?? tools, - eventStream: overrides.eventStream ?? eventStream, - config: overrides.config, - events: eventStream.events, - ...(overrides.tools ? {} : {}), - }; -} - -// ─── Test suite ────────────────────────────────────────────────────────────── - -describe('InitRunner', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `init-runner-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - await mkdir(tmpDir, { recursive: true }); - vi.clearAllMocks(); - - // Default: all sessions succeed - mockRunSession.mockResolvedValue(makeSuccessResult()); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - // ─── Helpers ───────────────────────────────────────────────────────────── - - function createRunner(toolsOverrides: Record = {}, configOverrides?: Partial) { - const tools = makeTools(toolsOverrides); - const eventStream = makeEventStream(); - const runner = new InitRunner({ - projectDir: tmpDir, - tools, - eventStream, - config: configOverrides as any, - }); - return { runner, tools, eventStream, events: eventStream.events as GSDEvent[] }; - } - - // ─── Core workflow tests ───────────────────────────────────────────────── - - it('run() calls initNewProject and validates project_exists === false', async () => { - const { runner, tools } = createRunner(); - - await runner.run('build a todo app'); - - expect(tools.initNewProject).toHaveBeenCalledOnce(); - }); - - it('run() returns error result when initNewProject reports project_exists', async () => { - const { runner, tools } = createRunner({ - initNewProject: vi.fn().mockResolvedValue(makeProjectInfo({ project_exists: true })), - }); - - const result = await runner.run('build a todo app'); - - expect(result.success).toBe(false); - // The setup step should have failed - const setupStep = result.steps.find(s => s.step === 'setup'); - expect(setupStep).toBeDefined(); - expect(setupStep!.success).toBe(false); - expect(setupStep!.error).toContain('already exists'); - }); - - it('run() writes config.json with auto-mode defaults', async () => { - const { runner } = createRunner(); - - await runner.run('build a todo app'); - - // config.json should be written to .planning/config.json in tmpDir - const configPath = join(tmpDir, '.planning', 'config.json'); - const content = await readFile(configPath, 'utf-8'); - const parsed = JSON.parse(content); - - expect(parsed.mode).toBe('yolo'); - expect(parsed.parallelization).toBe(true); - expect(parsed.workflow.auto_advance).toBe(true); - }); - - it('run() calls configSet for auto_advance', async () => { - const { runner, tools } = createRunner(); - - await runner.run('build a todo app'); - - expect(tools.configSet).toHaveBeenCalledWith('workflow.auto_advance', 'true'); - }); - - it('run() spawns PROJECT.md synthesis session', async () => { - const { runner } = createRunner(); - - await runner.run('build a todo app'); - - // The third session call should be the PROJECT.md synthesis - // Calls: setup (no session), config (no session), project (1st session), - // 4x research, synthesis, requirements, roadmap - // Total: 8 runPhaseStepSession calls - expect(mockRunSession).toHaveBeenCalled(); - - // First call should be for PROJECT.md (step 3) - const firstCall = mockRunSession.mock.calls[0]; - expect(firstCall).toBeDefined(); - const prompt = firstCall![0] as string; - expect(prompt).toContain('PROJECT.md'); - }); - - it('run() spawns 4 parallel research sessions via Promise.allSettled', async () => { - const { runner } = createRunner(); - - await runner.run('build a todo app'); - - // Count calls that contain the specific "researching the X aspect" pattern - // which uniquely identifies research prompts (vs synthesis/requirements that reference research files) - const researchCalls = mockRunSession.mock.calls.filter(call => { - const prompt = call[0] as string; - return prompt.includes('You are researching the'); - }); - - // Should be exactly 4 research sessions - expect(researchCalls.length).toBe(4); - }); - - it('run() spawns synthesis session after research completes', async () => { - const { runner } = createRunner(); - - await runner.run('build a todo app'); - - // Synthesis call should contain 'Synthesize' or 'SUMMARY' - const synthesisCalls = mockRunSession.mock.calls.filter(call => { - const prompt = call[0] as string; - return prompt.includes('Synthesize') || prompt.includes('SUMMARY.md'); - }); - - expect(synthesisCalls.length).toBeGreaterThanOrEqual(1); - }); - - it('run() spawns requirements session', async () => { - const { runner } = createRunner(); - - await runner.run('build a todo app'); - - const reqCalls = mockRunSession.mock.calls.filter(call => { - const prompt = call[0] as string; - return prompt.includes('REQUIREMENTS.md'); - }); - - expect(reqCalls.length).toBeGreaterThanOrEqual(1); - }); - - it('run() spawns roadmapper session', async () => { - const { runner } = createRunner(); - - await runner.run('build a todo app'); - - const roadmapCalls = mockRunSession.mock.calls.filter(call => { - const prompt = call[0] as string; - return prompt.includes('ROADMAP.md') || prompt.includes('STATE.md'); - }); - - expect(roadmapCalls.length).toBeGreaterThanOrEqual(1); - }); - - it('run() calls commit after each major step when commit_docs is true', async () => { - const commitFn = vi.fn().mockResolvedValue(undefined); - const { runner } = createRunner({ - initNewProject: vi.fn().mockResolvedValue(makeProjectInfo({ commit_docs: true })), - commit: commitFn, - }); - - await runner.run('build a todo app'); - - // Should commit: config, PROJECT.md, research, REQUIREMENTS.md, ROADMAP+STATE - expect(commitFn).toHaveBeenCalled(); - expect(commitFn.mock.calls.length).toBeGreaterThanOrEqual(4); - }); - - it('run() does not call commit when commit_docs is false', async () => { - const commitFn = vi.fn().mockResolvedValue(undefined); - const { runner } = createRunner({ - initNewProject: vi.fn().mockResolvedValue(makeProjectInfo({ commit_docs: false })), - commit: commitFn, - }); - - await runner.run('build a todo app'); - - expect(commitFn).not.toHaveBeenCalled(); - }); - - // ─── Event emission tests ──────────────────────────────────────────────── - - it('run() emits InitStart and InitComplete events', async () => { - const { runner, events } = createRunner(); - - await runner.run('build a todo app'); - - const startEvents = events.filter(e => e.type === GSDEventType.InitStart); - const completeEvents = events.filter(e => e.type === GSDEventType.InitComplete); - - expect(startEvents.length).toBe(1); - expect(completeEvents.length).toBe(1); - - const start = startEvents[0] as any; - expect(start.projectDir).toBe(tmpDir); - expect(start.input).toBeTruthy(); - - const complete = completeEvents[0] as any; - expect(complete.success).toBe(true); - expect(complete.totalCostUsd).toBeTypeOf('number'); - expect(complete.totalDurationMs).toBeTypeOf('number'); - expect(complete.artifactCount).toBeGreaterThan(0); - }); - - it('run() emits InitStepStart/Complete for each step', async () => { - const { runner, events } = createRunner(); - - await runner.run('build a todo app'); - - const stepStarts = events.filter(e => e.type === GSDEventType.InitStepStart); - const stepCompletes = events.filter(e => e.type === GSDEventType.InitStepComplete); - - // Steps: setup, config, project, 4x research, synthesis, requirements, roadmap = 10 - expect(stepStarts.length).toBe(10); - expect(stepCompletes.length).toBe(10); - - // Verify each step start has a matching complete (order may vary for parallel research) - const startSteps = stepStarts.map(e => (e as any).step).sort(); - const completeSteps = stepCompletes.map(e => (e as any).step).sort(); - - expect(startSteps).toEqual(completeSteps); - - // Verify expected step names are present - expect(startSteps).toContain('setup'); - expect(startSteps).toContain('config'); - expect(startSteps).toContain('project'); - expect(startSteps).toContain('research-stack'); - expect(startSteps).toContain('research-features'); - expect(startSteps).toContain('research-architecture'); - expect(startSteps).toContain('research-pitfalls'); - expect(startSteps).toContain('synthesis'); - expect(startSteps).toContain('requirements'); - expect(startSteps).toContain('roadmap'); - }); - - it('run() emits InitResearchSpawn before research sessions', async () => { - const { runner, events } = createRunner(); - - await runner.run('build a todo app'); - - const spawnEvents = events.filter(e => e.type === GSDEventType.InitResearchSpawn); - expect(spawnEvents.length).toBe(1); - - const spawn = spawnEvents[0] as any; - expect(spawn.sessionCount).toBe(4); - expect(spawn.researchTypes).toEqual(['STACK', 'FEATURES', 'ARCHITECTURE', 'PITFALLS']); - }); - - // ─── Error handling tests ──────────────────────────────────────────────── - - it('run() returns error when a session fails (partial research success)', async () => { - // Make the STACK research session fail, others succeed - let callCount = 0; - mockRunSession.mockImplementation(async (prompt: string) => { - callCount++; - // First call is PROJECT.md, then 4 research calls - // The 2nd call overall (1st research) should fail - if (callCount === 2) { - return makeErrorResult(); - } - return makeSuccessResult(); - }); - - const { runner } = createRunner(); - const result = await runner.run('build a todo app'); - - // Should still complete (partial success allowed for research) - // but overall result indicates research failure - expect(result.success).toBe(false); - - // Steps should still exist for all phases - expect(result.steps.length).toBeGreaterThanOrEqual(7); - }); - - it('run() stops workflow when PROJECT.md synthesis fails', async () => { - // First session (PROJECT.md) fails - mockRunSession.mockResolvedValueOnce(makeErrorResult()); - - const { runner } = createRunner(); - const result = await runner.run('build a todo app'); - - expect(result.success).toBe(false); - - // Should have setup, config, and project steps only - const stepNames = result.steps.map(s => s.step); - expect(stepNames).toContain('setup'); - expect(stepNames).toContain('config'); - expect(stepNames).toContain('project'); - // Should NOT continue to research - expect(stepNames).not.toContain('research-stack'); - }); - - it('run() stops workflow when requirements session fails', async () => { - // Let PROJECT.md and research succeed, but make requirements fail - let sessionCallIndex = 0; - mockRunSession.mockImplementation(async () => { - sessionCallIndex++; - // Calls: 1=PROJECT.md, 2-5=research, 6=synthesis, 7=requirements - if (sessionCallIndex === 7) { - return makeErrorResult(); - } - return makeSuccessResult(); - }); - - const { runner } = createRunner(); - const result = await runner.run('build a todo app'); - - expect(result.success).toBe(false); - - const stepNames = result.steps.map(s => s.step); - expect(stepNames).toContain('requirements'); - // Should NOT continue to roadmap - expect(stepNames).not.toContain('roadmap'); - }); - - // ─── Cost aggregation tests ────────────────────────────────────────────── - - it('run() aggregates costs from all sessions', async () => { - const costPerSession = 0.05; - mockRunSession.mockResolvedValue(makeSuccessResult({ totalCostUsd: costPerSession })); - - const { runner } = createRunner(); - const result = await runner.run('build a todo app'); - - // 8 total sessions: PROJECT.md + 4 research + synthesis + requirements + roadmap - // Cost from sessions extracted via extractCost, non-session steps (setup/config) are 0 - expect(result.totalCostUsd).toBeGreaterThan(0); - expect(result.totalDurationMs).toBeGreaterThan(0); - }); - - // ─── Artifact tracking tests ───────────────────────────────────────────── - - it('run() returns all expected artifacts on success', async () => { - const { runner } = createRunner(); - const result = await runner.run('build a todo app'); - - expect(result.success).toBe(true); - expect(result.artifacts).toContain('.planning/config.json'); - expect(result.artifacts).toContain('.planning/PROJECT.md'); - expect(result.artifacts).toContain('.planning/research/SUMMARY.md'); - expect(result.artifacts).toContain('.planning/REQUIREMENTS.md'); - expect(result.artifacts).toContain('.planning/ROADMAP.md'); - expect(result.artifacts).toContain('.planning/STATE.md'); - }); - - it('run() includes research artifact paths on success', async () => { - const { runner } = createRunner(); - const result = await runner.run('build a todo app'); - - expect(result.artifacts).toContain('.planning/research/STACK.md'); - expect(result.artifacts).toContain('.planning/research/FEATURES.md'); - expect(result.artifacts).toContain('.planning/research/ARCHITECTURE.md'); - expect(result.artifacts).toContain('.planning/research/PITFALLS.md'); - }); - - // ─── Git init test ───────────────────────────────────────────────────── - - it('run() initializes git when has_git is false', async () => { - // We can't easily test git init without mocking execFile deeply, - // but we can verify the tools.initNewProject is called with the result - // and that the workflow continues. Since has_git=true by default in our - // mock, flip it to false and verify the config step still passes. - const { runner } = createRunner({ - initNewProject: vi.fn().mockResolvedValue(makeProjectInfo({ has_git: false })), - }); - - // This will attempt to run `git init` which may or may not exist in test env. - // Since we're in a tmpDir, git init is safe. The test verifies the workflow proceeds. - const result = await runner.run('build a todo app'); - - // The config step should succeed (git init in tmpDir should work) - const configStep = result.steps.find(s => s.step === 'config'); - expect(configStep).toBeDefined(); - // Note: if git is not available in CI, this may fail — that's expected - }); - - // ─── Config passthrough test ───────────────────────────────────────────── - - it('constructor accepts config overrides', async () => { - // Set projectInfo model fields to undefined so orchestratorModel is used as fallback - const { runner } = createRunner({ - initNewProject: vi.fn().mockResolvedValue(makeProjectInfo({ - researcher_model: undefined as any, - synthesizer_model: undefined as any, - roadmapper_model: undefined as any, - })), - }, { - maxBudgetPerSession: 10.0, - maxTurnsPerSession: 50, - orchestratorModel: 'claude-opus-4-6', - }); - - await runner.run('build a todo app'); - - // Verify the session runner was called with overridden model - const calls = mockRunSession.mock.calls; - expect(calls.length).toBeGreaterThan(0); - - // Check model in options (4th argument, index 3) - const modelsUsed = calls.map(c => { - const options = c[3] as any; - return options?.model; - }); - // When projectInfo model is undefined, ?? falls through to orchestratorModel - expect(modelsUsed.some(m => m === 'claude-opus-4-6')).toBe(true); - }); - - // ─── Session count validation ──────────────────────────────────────────── - - it('run() calls runPhaseStepSession exactly 8 times on full success', async () => { - const { runner } = createRunner(); - - await runner.run('build a todo app'); - - // 1 PROJECT.md + 4 research + 1 synthesis + 1 requirements + 1 roadmap = 8 - expect(mockRunSession).toHaveBeenCalledTimes(8); - }); - - // ─── Headless prompt loading (sdkPromptsDir preference) ────────────────── - - describe('sdkPromptsDir preference and sanitizer integration', () => { - let sdkPromptsDir: string; - - beforeEach(async () => { - // Create a temp SDK prompts directory with test fixtures - sdkPromptsDir = join(tmpDir, 'sdk-prompts'); - await mkdir(join(sdkPromptsDir, 'templates', 'research-project'), { recursive: true }); - await mkdir(join(sdkPromptsDir, 'agents'), { recursive: true }); - - // Write headless templates (with known marker text for assertion) - await writeFile( - join(sdkPromptsDir, 'templates', 'project.md'), - '# PROJECT Template\nSDK_HEADLESS_MARKER_PROJECT\n', - ); - await writeFile( - join(sdkPromptsDir, 'templates', 'requirements.md'), - '# REQUIREMENTS Template\nSDK_HEADLESS_MARKER_REQUIREMENTS\n', - ); - await writeFile( - join(sdkPromptsDir, 'templates', 'roadmap.md'), - '# ROADMAP Template\nSDK_HEADLESS_MARKER_ROADMAP\n', - ); - await writeFile( - join(sdkPromptsDir, 'templates', 'state.md'), - '# STATE Template\nSDK_HEADLESS_MARKER_STATE\n', - ); - await writeFile( - join(sdkPromptsDir, 'templates', 'research-project', 'STACK.md'), - '# STACK Template\nSDK_HEADLESS_MARKER_STACK\n', - ); - - // Write headless agents (with known marker text) - await writeFile( - join(sdkPromptsDir, 'agents', 'gsd-project-researcher.md'), - '# Project Researcher Agent\nSDK_HEADLESS_MARKER_RESEARCHER\n', - ); - await writeFile( - join(sdkPromptsDir, 'agents', 'gsd-research-synthesizer.md'), - '# Research Synthesizer Agent\nSDK_HEADLESS_MARKER_SYNTHESIZER\n', - ); - await writeFile( - join(sdkPromptsDir, 'agents', 'gsd-roadmapper.md'), - '# Roadmapper Agent\nSDK_HEADLESS_MARKER_ROADMAPPER\n', - ); - }); - - function createRunnerWithSdkPrompts( - toolsOverrides: Record = {}, - configOverrides?: Partial, - ) { - const tools = makeTools(toolsOverrides); - const eventStream = makeEventStream(); - const runner = new InitRunner({ - projectDir: tmpDir, - tools, - eventStream, - config: configOverrides as any, - sdkPromptsDir, - }); - return { runner, tools, eventStream, events: eventStream.events as GSDEvent[] }; - } - - it('readGSDFile prefers installed GSD over sdk/prompts/ template', async () => { - const { runner } = createRunnerWithSdkPrompts(); - - await runner.run('build a todo app'); - - // The first session call is buildProjectPrompt → reads templates/project.md - // Installed GSD templates (if present) are preferred over SDK bundled copies - const projectPrompt = mockRunSession.mock.calls[0]![0] as string; - // Should contain PROJECT.md creation instruction regardless of source - expect(projectPrompt).toContain('PROJECT.md'); - }); - - it('readAgentFile prefers installed agents over sdk/prompts/agents/', async () => { - const { runner } = createRunnerWithSdkPrompts(); - - await runner.run('build a todo app'); - - // Research calls (indices 1-4) use gsd-project-researcher.md agent def - const researchPrompt = mockRunSession.mock.calls[1]![0] as string; - // Should contain research instruction regardless of source - expect(researchPrompt).toContain('You are researching the'); - }); - - it('readGSDFile falls back to GSD-1 when sdk/prompts/ file does not exist', async () => { - // Create an empty sdkPromptsDir — no templates at all - const emptySdkDir = join(tmpDir, 'empty-sdk-prompts'); - await mkdir(join(emptySdkDir, 'templates'), { recursive: true }); - await mkdir(join(emptySdkDir, 'agents'), { recursive: true }); - - const tools = makeTools(); - const eventStream = makeEventStream(); - const runner = new InitRunner({ - projectDir: tmpDir, - tools, - eventStream, - sdkPromptsDir: emptySdkDir, - }); - - await runner.run('build a todo app'); - - // buildProjectPrompt reads templates/project.md — not found in empty dir, - // falls through to GSD-1 path. If GSD-1 also missing, gets placeholder. - const projectPrompt = mockRunSession.mock.calls[0]![0] as string; - - // Should NOT contain our marker (since empty dir was used) - expect(projectPrompt).not.toContain('SDK_HEADLESS_MARKER_PROJECT'); - // Should still contain the PROJECT.md synthesis instruction (from the prompt builder) - expect(projectPrompt).toContain('PROJECT.md'); - }); - - it('readAgentFile falls back to GSD-1 when sdk/prompts/agents/ file does not exist', async () => { - // Empty sdkPromptsDir — no agent files - const emptySdkDir = join(tmpDir, 'empty-sdk-agents'); - await mkdir(join(emptySdkDir, 'templates', 'research-project'), { recursive: true }); - await mkdir(join(emptySdkDir, 'agents'), { recursive: true }); - - // Write templates so we get past buildProjectPrompt - await writeFile(join(emptySdkDir, 'templates', 'project.md'), '# project\n'); - await writeFile(join(emptySdkDir, 'templates', 'research-project', 'STACK.md'), '# stack\n'); - await writeFile(join(emptySdkDir, 'templates', 'research-project', 'FEATURES.md'), '# features\n'); - await writeFile(join(emptySdkDir, 'templates', 'research-project', 'ARCHITECTURE.md'), '# arch\n'); - await writeFile(join(emptySdkDir, 'templates', 'research-project', 'PITFALLS.md'), '# pitfalls\n'); - - const tools = makeTools(); - const eventStream = makeEventStream(); - const runner = new InitRunner({ - projectDir: tmpDir, - tools, - eventStream, - sdkPromptsDir: emptySdkDir, - }); - - await runner.run('build a todo app'); - - // Research prompt uses agent def — not in empty agents dir, falls to GSD-1 - const researchPrompt = mockRunSession.mock.calls[1]![0] as string; - // Should NOT contain our marker - expect(researchPrompt).not.toContain('SDK_HEADLESS_MARKER_RESEARCHER'); - // Should still have the "researching the" instruction - expect(researchPrompt).toContain('You are researching the'); - }); - - it('buildProjectPrompt output passes through sanitizePrompt (no /gsd: patterns)', async () => { - const { runner } = createRunnerWithSdkPrompts(); - await runner.run('build a todo app'); - - const projectPrompt = mockRunSession.mock.calls[0]![0] as string; - // sanitizePrompt should strip any /gsd: patterns from the assembled prompt - expect(projectPrompt).not.toMatch(/\/gsd:\S+/); - expect(projectPrompt).toContain('PROJECT.md'); - }); - - it('buildResearchPrompt output passes through sanitizePrompt (no /gsd: patterns)', async () => { - const { runner } = createRunnerWithSdkPrompts(); - await runner.run('build a todo app'); - - const researchPrompt = mockRunSession.mock.calls[1]![0] as string; - // sanitizePrompt should strip any /gsd: patterns from the assembled prompt - expect(researchPrompt).not.toMatch(/\/gsd:\S+/); - expect(researchPrompt).toContain('You are researching the'); - }); - - it('buildRoadmapPrompt output passes through sanitizePrompt (no /gsd: patterns)', async () => { - const { runner } = createRunnerWithSdkPrompts(); - await runner.run('build a todo app'); - - // Roadmap prompt is the last session call (index 7) - const roadmapPrompt = mockRunSession.mock.calls[7]![0] as string; - // sanitizePrompt should strip any /gsd: patterns from the assembled prompt - expect(roadmapPrompt).not.toMatch(/\/gsd:\S+/); - }); - }); -}); diff --git a/sdk/src/init-runner.ts b/sdk/src/init-runner.ts deleted file mode 100644 index 79024c0c4..000000000 --- a/sdk/src/init-runner.ts +++ /dev/null @@ -1,734 +0,0 @@ -/** - * InitRunner — orchestrates the GSD new-project init workflow. - * - * Workflow: setup → config → PROJECT.md → parallel research (4 sessions) - * → synthesis → requirements → roadmap - * - * Each step calls Agent SDK `query()` via `runPhaseStepSession()` with - * prompts derived from GSD-1 workflow/agent/template files on disk. - */ - -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { execFile } from 'node:child_process'; - -import type { - InitConfig, - InitResult, - InitStepResult, - InitStepName, - InitNewProjectInfo, - GSDInitStartEvent, - GSDInitStepStartEvent, - GSDInitStepCompleteEvent, - GSDInitCompleteEvent, - GSDInitResearchSpawnEvent, - PlanResult, -} from './types.js'; -import { GSDEventType, PhaseStepType } from './types.js'; -import type { GSDTools } from './gsd-tools.js'; -import type { GSDEventStream } from './event-stream.js'; -import { loadConfig } from './config.js'; -import { runPhaseStepSession } from './session-runner.js'; -import { sanitizePrompt } from './prompt-sanitizer.js'; -import { resolveAgentsDir } from './query/helpers.js'; -import { resolveLegacyTemplatesDir } from './sdk-package-compatibility.js'; - -// ─── Constants ─────────────────────────────────────────────────────────────── - -const GSD_TEMPLATES_DIR = resolveLegacyTemplatesDir(); -const GSD_AGENTS_DIR = resolveAgentsDir(); - -const RESEARCH_TYPES = ['STACK', 'FEATURES', 'ARCHITECTURE', 'PITFALLS'] as const; -type ResearchType = (typeof RESEARCH_TYPES)[number]; - -const RESEARCH_STEP_MAP: Record = { - STACK: 'research-stack', - FEATURES: 'research-features', - ARCHITECTURE: 'research-architecture', - PITFALLS: 'research-pitfalls', -}; - -/** Default config.json written during init for auto-mode projects. */ -const AUTO_MODE_CONFIG = { - mode: 'yolo', - parallelization: true, - depth: 'quick', - workflow: { - research: true, - plan_checker: true, - verifier: true, - auto_advance: true, - skip_discuss: false, - }, -}; - -// ─── InitRunner ────────────────────────────────────────────────────────────── - -export interface InitRunnerDeps { - projectDir: string; - tools: GSDTools; - eventStream: GSDEventStream; - config?: Partial; - /** Override for SDK prompts directory. Defaults to package-relative sdk/prompts/. */ - sdkPromptsDir?: string; -} - -export class InitRunner { - private readonly projectDir: string; - private readonly tools: GSDTools; - private readonly eventStream: GSDEventStream; - private readonly config: InitConfig; - private readonly sessionId: string; - private readonly sdkPromptsDir: string; - - constructor(deps: InitRunnerDeps) { - this.projectDir = deps.projectDir; - this.tools = deps.tools; - this.eventStream = deps.eventStream; - this.config = { - maxBudgetPerSession: deps.config?.maxBudgetPerSession ?? 3.0, - maxTurnsPerSession: deps.config?.maxTurnsPerSession ?? 30, - researchModel: deps.config?.researchModel, - orchestratorModel: deps.config?.orchestratorModel, - }; - this.sessionId = `init-${Date.now()}`; - // SDK prompts dir: explicit override → package-relative default via import.meta.url - this.sdkPromptsDir = - deps.sdkPromptsDir ?? - join(fileURLToPath(new URL('.', import.meta.url)), '..', 'prompts'); - } - - /** - * Run the full init workflow. - * - * @param input - User input: PRD content, project description, etc. - * @returns InitResult with per-step results, artifacts, and totals. - */ - async run(input: string): Promise { - const startTime = Date.now(); - const steps: InitStepResult[] = []; - const artifacts: string[] = []; - - this.emitEvent({ - type: GSDEventType.InitStart, - input: input.slice(0, 200), - projectDir: this.projectDir, - }); - - try { - // ── Step 1: Setup — get project metadata ────────────────────────── - const setupResult = await this.runStep('setup', async () => { - const info = await this.tools.initNewProject(); - if (info.project_exists) { - throw new Error('Project already exists (.planning/PROJECT.md found). Use a fresh directory or delete .planning/ first.'); - } - return info; - }); - steps.push(setupResult.stepResult); - if (!setupResult.stepResult.success) { - return this.buildResult(false, steps, artifacts, startTime); - } - const projectInfo = setupResult.value as InitNewProjectInfo; - - // ── Step 2: Config — write config.json and init git ─────────────── - const configResult = await this.runStep('config', async () => { - // Ensure git is initialized - if (!projectInfo.has_git) { - await this.execGit(['init']); - } - - // Ensure .planning/ directory exists - const planningDir = join(this.projectDir, '.planning'); - await mkdir(planningDir, { recursive: true }); - - // Write config.json - const configPath = join(planningDir, 'config.json'); - await writeFile(configPath, JSON.stringify(AUTO_MODE_CONFIG, null, 2) + '\n', 'utf-8'); - artifacts.push('.planning/config.json'); - - // Persist auto_advance via gsd-tools (validates & updates state) - await this.tools.configSet('workflow.auto_advance', 'true'); - - // Commit config - if (projectInfo.commit_docs) { - await this.tools.commit('chore: add project config', ['.planning/config.json']); - } - }); - steps.push(configResult.stepResult); - if (!configResult.stepResult.success) { - return this.buildResult(false, steps, artifacts, startTime); - } - - // ── Step 3: PROJECT.md — synthesize from input ──────────────────── - const projectResult = await this.runStep('project', async () => { - const prompt = await this.buildProjectPrompt(input); - const result = await this.runSession(prompt, projectInfo.researcher_model); - if (!result.success) { - throw new Error(`PROJECT.md synthesis failed: ${result.error?.messages.join(', ') ?? 'unknown error'}`); - } - artifacts.push('.planning/PROJECT.md'); - if (projectInfo.commit_docs) { - await this.tools.commit('docs: add PROJECT.md', ['.planning/PROJECT.md']); - } - return result; - }); - steps.push(projectResult.stepResult); - if (!projectResult.stepResult.success) { - return this.buildResult(false, steps, artifacts, startTime); - } - - // ── Step 4: Parallel research (4 sessions) ─────────────────────── - const researchSteps = await this.runParallelResearch(input, projectInfo); - steps.push(...researchSteps); - const researchFailed = researchSteps.some(s => !s.success); - - // Add artifacts for successful research files - for (const rs of researchSteps) { - if (rs.success && rs.artifacts) { - artifacts.push(...rs.artifacts); - } - } - - if (researchFailed) { - // Continue with partial results — synthesis will work with what's available - // but flag the overall result as partial - } - - // ── Step 5: Synthesis — combine research into SUMMARY.md ────────── - const synthResult = await this.runStep('synthesis', async () => { - const prompt = await this.buildSynthesisPrompt(); - const result = await this.runSession(prompt, projectInfo.synthesizer_model); - if (!result.success) { - throw new Error(`Research synthesis failed: ${result.error?.messages.join(', ') ?? 'unknown error'}`); - } - artifacts.push('.planning/research/SUMMARY.md'); - if (projectInfo.commit_docs) { - await this.tools.commit('docs: add research files', ['.planning/research/']); - } - return result; - }); - steps.push(synthResult.stepResult); - if (!synthResult.stepResult.success) { - return this.buildResult(false, steps, artifacts, startTime); - } - - // ── Step 6: Requirements — derive from PROJECT + research ───────── - const reqResult = await this.runStep('requirements', async () => { - const prompt = await this.buildRequirementsPrompt(); - const result = await this.runSession(prompt, projectInfo.synthesizer_model); - if (!result.success) { - throw new Error(`Requirements generation failed: ${result.error?.messages.join(', ') ?? 'unknown error'}`); - } - artifacts.push('.planning/REQUIREMENTS.md'); - if (projectInfo.commit_docs) { - await this.tools.commit('docs: add REQUIREMENTS.md', ['.planning/REQUIREMENTS.md']); - } - return result; - }); - steps.push(reqResult.stepResult); - if (!reqResult.stepResult.success) { - return this.buildResult(false, steps, artifacts, startTime); - } - - // ── Step 7: Roadmap — create phases + STATE.md ──────────────────── - const roadmapResult = await this.runStep('roadmap', async () => { - const prompt = await this.buildRoadmapPrompt(); - const result = await this.runSession(prompt, projectInfo.roadmapper_model); - if (!result.success) { - throw new Error(`Roadmap generation failed: ${result.error?.messages.join(', ') ?? 'unknown error'}`); - } - artifacts.push('.planning/ROADMAP.md', '.planning/STATE.md'); - if (projectInfo.commit_docs) { - await this.tools.commit('docs: add ROADMAP.md and STATE.md', [ - '.planning/ROADMAP.md', - '.planning/STATE.md', - ]); - } - return result; - }); - steps.push(roadmapResult.stepResult); - if (!roadmapResult.stepResult.success) { - return this.buildResult(false, steps, artifacts, startTime); - } - - const success = !researchFailed; - return this.buildResult(success, steps, artifacts, startTime); - } catch (err) { - // Unexpected top-level error - steps.push({ - step: 'setup', - success: false, - durationMs: 0, - costUsd: 0, - error: err instanceof Error ? err.message : String(err), - }); - return this.buildResult(false, steps, artifacts, startTime); - } - } - - // ─── Step execution wrapper ──────────────────────────────────────────────── - - private async runStep( - step: InitStepName, - fn: () => Promise, - ): Promise<{ stepResult: InitStepResult; value?: T }> { - const stepStart = Date.now(); - - this.emitEvent({ - type: GSDEventType.InitStepStart, - step, - }); - - try { - const value = await fn(); - const durationMs = Date.now() - stepStart; - const costUsd = this.extractCost(value); - - const stepResult: InitStepResult = { - step, - success: true, - durationMs, - costUsd, - }; - - this.emitEvent({ - type: GSDEventType.InitStepComplete, - step, - success: true, - durationMs, - costUsd, - }); - - return { stepResult, value }; - } catch (err) { - const durationMs = Date.now() - stepStart; - const errorMsg = err instanceof Error ? err.message : String(err); - - const stepResult: InitStepResult = { - step, - success: false, - durationMs, - costUsd: 0, - error: errorMsg, - }; - - this.emitEvent({ - type: GSDEventType.InitStepComplete, - step, - success: false, - durationMs, - costUsd: 0, - error: errorMsg, - }); - - return { stepResult }; - } - } - - // ─── Parallel research ───────────────────────────────────────────────────── - - private async runParallelResearch( - input: string, - projectInfo: InitNewProjectInfo, - ): Promise { - this.emitEvent({ - type: GSDEventType.InitResearchSpawn, - sessionCount: RESEARCH_TYPES.length, - researchTypes: [...RESEARCH_TYPES], - }); - - const promises = RESEARCH_TYPES.map(async (researchType) => { - const step = RESEARCH_STEP_MAP[researchType]; - const result = await this.runStep(step, async () => { - const prompt = await this.buildResearchPrompt(researchType, input); - const sessionResult = await this.runSession(prompt, projectInfo.researcher_model); - if (!sessionResult.success) { - throw new Error( - `Research (${researchType}) failed: ${sessionResult.error?.messages.join(', ') ?? 'unknown error'}`, - ); - } - return sessionResult; - }); - // Attach artifact path on success - if (result.stepResult.success) { - result.stepResult.artifacts = [`.planning/research/${researchType}.md`]; - } - return result.stepResult; - }); - - const results = await Promise.allSettled(promises); - - return results.map((r, i) => { - if (r.status === 'fulfilled') { - return r.value; - } - // Promise.allSettled rejection — should not happen since runStep catches, - // but handle defensively - return { - step: RESEARCH_STEP_MAP[RESEARCH_TYPES[i]!]!, - success: false, - durationMs: 0, - costUsd: 0, - error: r.reason instanceof Error ? r.reason.message : String(r.reason), - } satisfies InitStepResult; - }); - } - - // ─── Prompt builders ─────────────────────────────────────────────────────── - - /** - * Build the PROJECT.md synthesis prompt. - * Reads the project template and combines with user input. - */ - private async buildProjectPrompt(input: string): Promise { - const template = await this.readGSDFile('templates/project.md'); - - return sanitizePrompt([ - 'You are creating the PROJECT.md for a new software project.', - 'Write .planning/PROJECT.md based on the template structure below and the user\'s project description.', - '', - '', - template, - '', - '', - '', - input, - '', - '', - 'Write the file to .planning/PROJECT.md. Follow the template structure but fill in with real content derived from the user input.', - 'Be specific and opinionated — make decisions, don\'t list options.', - ].join('\n'), this.projectDir); - } - - /** - * Build a research prompt for a specific research type. - * Reads the agent definition and research template. - */ - private async buildResearchPrompt( - researchType: ResearchType, - input: string, - ): Promise { - const agentDef = await this.readAgentFile('gsd-project-researcher.md'); - const template = await this.readGSDFile(`templates/research-project/${researchType}.md`); - - // Read PROJECT.md if it exists (it should by now) - let projectContent = ''; - try { - projectContent = await readFile( - join(this.projectDir, '.planning', 'PROJECT.md'), - 'utf-8', - ); - } catch { - // Fall back to raw input if PROJECT.md not yet written - projectContent = input; - } - - return sanitizePrompt([ - '', - agentDef, - '', - '', - `You are researching the ${researchType} aspect of this project.`, - `Write your findings to .planning/research/${researchType}.md`, - '', - '', - '.planning/PROJECT.md', - '', - '', - '', - projectContent, - '', - '', - '', - template, - '', - '', - `Write .planning/research/${researchType}.md following the template structure.`, - 'Be comprehensive but opinionated. "Use X because Y" not "Options are X, Y, Z."', - ].join('\n'), this.projectDir); - } - - /** - * Build the synthesis prompt. - * Reads synthesizer agent def and all 4 research outputs. - */ - private async buildSynthesisPrompt(): Promise { - const agentDef = await this.readAgentFile('gsd-research-synthesizer.md'); - const summaryTemplate = await this.readGSDFile('templates/research-project/SUMMARY.md'); - const researchDir = join(this.projectDir, '.planning', 'research'); - - // Read whatever research files exist - const researchContent: string[] = []; - for (const rt of RESEARCH_TYPES) { - try { - const content = await readFile(join(researchDir, `${rt}.md`), 'utf-8'); - researchContent.push(`\n${content}\n`); - } catch { - researchContent.push(`\n(Not available)\n`); - } - } - - return sanitizePrompt([ - '', - agentDef, - '', - '', - '', - '.planning/research/STACK.md', - '.planning/research/FEATURES.md', - '.planning/research/ARCHITECTURE.md', - '.planning/research/PITFALLS.md', - '', - '', - 'Synthesize the research files below into .planning/research/SUMMARY.md', - '', - ...researchContent, - '', - '', - summaryTemplate, - '', - '', - 'Write .planning/research/SUMMARY.md synthesizing all research findings.', - 'Also commit all research files: git add .planning/research/ && git commit.', - ].join('\n'), this.projectDir); - } - - /** - * Build the requirements prompt. - * Reads PROJECT.md + FEATURES.md for requirement derivation. - */ - private async buildRequirementsPrompt(): Promise { - const reqTemplate = await this.readGSDFile('templates/requirements.md'); - - let projectContent = ''; - let featuresContent = ''; - try { - projectContent = await readFile( - join(this.projectDir, '.planning', 'PROJECT.md'), - 'utf-8', - ); - } catch { - // Should not happen at this point - } - try { - featuresContent = await readFile( - join(this.projectDir, '.planning', 'research', 'FEATURES.md'), - 'utf-8', - ); - } catch { - // Research may have partially failed - } - - return sanitizePrompt([ - 'You are generating REQUIREMENTS.md for this project.', - 'Derive requirements from the PROJECT.md and research outputs.', - 'Auto-include all table-stakes requirements (auth, error handling, logging, etc.).', - '', - '', - projectContent, - '', - '', - '', - featuresContent || '(Not available)', - '', - '', - '', - reqTemplate, - '', - '', - 'Write .planning/REQUIREMENTS.md following the template structure.', - 'Every requirement must be testable and specific. No vague aspirations.', - ].join('\n'), this.projectDir); - } - - /** - * Build the roadmap prompt. - * Reads PROJECT.md + REQUIREMENTS.md + research/SUMMARY.md + config.json. - */ - private async buildRoadmapPrompt(): Promise { - const agentDef = await this.readAgentFile('gsd-roadmapper.md'); - const roadmapTemplate = await this.readGSDFile('templates/roadmap.md'); - const stateTemplate = await this.readGSDFile('templates/state.md'); - - const filesToRead = [ - '.planning/PROJECT.md', - '.planning/REQUIREMENTS.md', - '.planning/research/SUMMARY.md', - '.planning/config.json', - ]; - - const fileContents: string[] = []; - for (const fp of filesToRead) { - try { - const content = await readFile(join(this.projectDir, fp), 'utf-8'); - fileContents.push(`\n${content}\n`); - } catch { - fileContents.push(`\n(Not available)\n`); - } - } - - return sanitizePrompt([ - '', - agentDef, - '', - '', - '', - ...filesToRead, - '', - '', - ...fileContents, - '', - '', - roadmapTemplate, - '', - '', - '', - stateTemplate, - '', - '', - 'Create .planning/ROADMAP.md and .planning/STATE.md.', - 'ROADMAP.md: Transform requirements into phases. Every v1 requirement maps to exactly one phase.', - 'STATE.md: Initialize project state tracking.', - ].join('\n'), this.projectDir); - } - - // ─── Session execution ───────────────────────────────────────────────────── - - /** - * Run a single Agent SDK session via runPhaseStepSession. - */ - private async runSession(prompt: string, modelOverride?: string): Promise { - const config = await loadConfig(this.projectDir); - - return runPhaseStepSession( - prompt, - PhaseStepType.Research, // Research phase gives broadest tool access - config, - { - maxTurns: this.config.maxTurnsPerSession, - maxBudgetUsd: this.config.maxBudgetPerSession, - model: modelOverride ?? this.config.orchestratorModel, - cwd: this.projectDir, - }, - this.eventStream, - { phase: undefined, planName: undefined }, - ); - } - - // ─── File reading helpers ────────────────────────────────────────────────── - - /** - * Read a file from the GSD templates directory. - * Tries sdk/prompts/{relativePath} first (headless versions), then - * falls back to GSD-1 originals (~/.claude/get-shit-done/). - */ - private async readGSDFile(relativePath: string): Promise { - // Try installed GSD first (complete, up-to-date versions) - const fullPath = join(GSD_TEMPLATES_DIR, '..', relativePath); - try { - return await readFile(fullPath, 'utf-8'); - } catch { - // Not installed, fall through to SDK bundled copies - } - - // Fall back to SDK bundled copies - const sdkPath = join(this.sdkPromptsDir, relativePath); - try { - return await readFile(sdkPath, 'utf-8'); - } catch { - return `(Template not found: ${relativePath})`; - } - } - - /** - * Read an agent definition. - * Tries installed agents first (complete, up-to-date versions), then - * falls back to SDK bundled copies. - */ - private async readAgentFile(filename: string): Promise { - // Try installed agents first (complete, up-to-date versions) - const fullPath = join(GSD_AGENTS_DIR, filename); - try { - return await readFile(fullPath, 'utf-8'); - } catch { - // Not installed, fall through to SDK bundled copies - } - - // Fall back to SDK bundled copies - const sdkPath = join(this.sdkPromptsDir, 'agents', filename); - try { - return await readFile(sdkPath, 'utf-8'); - } catch { - return `(Agent definition not found: ${filename})`; - } - } - - // ─── Git helper ──────────────────────────────────────────────────────────── - - /** - * Execute a git command in the project directory. - */ - private execGit(args: string[]): Promise { - return new Promise((resolve, reject) => { - execFile('git', args, { cwd: this.projectDir }, (error, stdout, stderr) => { - if (error) { - reject(new Error(`git ${args.join(' ')} failed: ${stderr || error.message}`)); - return; - } - resolve(stdout.toString()); - }); - }); - } - - // ─── Event helpers ───────────────────────────────────────────────────────── - - private emitEvent( - partial: Omit & { type: GSDEventType }, - ): void { - this.eventStream.emitEvent({ - timestamp: new Date().toISOString(), - sessionId: this.sessionId, - ...partial, - } as unknown as import('./types.js').GSDEvent); - } - - // ─── Result helpers ──────────────────────────────────────────────────────── - - private buildResult( - success: boolean, - steps: InitStepResult[], - artifacts: string[], - startTime: number, - ): InitResult { - const totalCostUsd = steps.reduce((sum, s) => sum + s.costUsd, 0); - const totalDurationMs = Date.now() - startTime; - - this.emitEvent({ - type: GSDEventType.InitComplete, - success, - totalCostUsd, - totalDurationMs, - artifactCount: artifacts.length, - }); - - return { - success, - steps, - totalCostUsd, - totalDurationMs, - artifacts, - }; - } - - /** - * Extract cost from a step return value if it's a PlanResult. - */ - private extractCost(value: unknown): number { - if (value && typeof value === 'object' && 'totalCostUsd' in value) { - return (value as PlanResult).totalCostUsd; - } - return 0; - } -} diff --git a/sdk/src/lifecycle-e2e.integration.test.ts b/sdk/src/lifecycle-e2e.integration.test.ts deleted file mode 100644 index a08bd27fa..000000000 --- a/sdk/src/lifecycle-e2e.integration.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * E2E lifecycle integration test — proves GSD.runPhase() drives - * the full phase lifecycle: discuss → research → plan → execute → verify → advance - * after bootstrapping a real project via InitRunner. - * - * This is the capstone proof that `gsd-sdk auto` works end-to-end - * without human intervention. InitRunner bootstraps the project, - * then GSD.runPhase() drives Phase 1 through the complete lifecycle. - * - * Requires Claude Code CLI (`claude`) installed and authenticated. - * Skips gracefully if CLI is unavailable. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { execSync } from 'node:child_process'; -import { mkdtemp, rm, readFile, stat, readdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; - -import { GSD } from './index.js'; -import { InitRunner } from './init-runner.js'; -import { GSDTools, resolveGsdToolsPath } from './gsd-tools.js'; -import { GSDEventStream } from './event-stream.js'; -import { GSDEventType, PhaseStepType } from './types.js'; -import type { GSDEvent, PhaseRunnerResult, RoadmapAnalysis } from './types.js'; - -// ─── CLI availability check ───────────────────────────────────────────────── - -let cliAvailable = false; -try { - execSync('which claude', { stdio: 'ignore' }); - cliAvailable = true; -} catch { - cliAvailable = false; -} - -const __dirname = fileURLToPath(new URL('.', import.meta.url)); -const sdkPromptsDir = join(__dirname, '..', 'prompts'); -const GSD_TOOLS_PATH = resolveGsdToolsPath(process.cwd()); -const gsdToolsAvailable = existsSync(GSD_TOOLS_PATH); - -// ─── Lifecycle step ordering for monotonicity check ────────────────────────── - -const STEP_ORDER: Record = { - [PhaseStepType.Discuss]: 0, - [PhaseStepType.Research]: 1, - [PhaseStepType.Plan]: 2, - [PhaseStepType.PlanCheck]: 3, - [PhaseStepType.Execute]: 4, - [PhaseStepType.Verify]: 5, - [PhaseStepType.Advance]: 6, -}; - -// ─── Test suite ────────────────────────────────────────────────────────────── - -describe.skipIf(!cliAvailable || !gsdToolsAvailable)('E2E Lifecycle: InitRunner → GSD.runPhase() full lifecycle', () => { - let tmpDir: string; - let initSuccess: boolean = false; - let phase1Number: string | null = null; - let tools: GSDTools; - - // ── Bootstrap: create temp dir, git init, run InitRunner ────────────── - beforeAll(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-lifecycle-e2e-')); - - // Git init (required by InitRunner and phase lifecycle) - execSync('git init', { cwd: tmpDir, stdio: 'ignore' }); - execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'ignore' }); - execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'ignore' }); - - tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: GSD_TOOLS_PATH, - timeoutMs: 30_000, - }); - - // Run InitRunner to bootstrap the project - const initEventStream = new GSDEventStream(); - const initRunner = new InitRunner({ - projectDir: tmpDir, - tools, - eventStream: initEventStream, - config: { - maxBudgetPerSession: 1.0, - maxTurnsPerSession: 15, - }, - sdkPromptsDir, - }); - - const initResult = await initRunner.run('Build a CLI tool that converts Celsius to Fahrenheit'); - - // Mark init as successful if the pipeline progressed enough - const completedSteps = initResult.steps.filter(s => s.success); - initSuccess = initResult.success || completedSteps.length >= 3; - - // Discover the first phase number via roadmapAnalyze - if (initSuccess) { - try { - const analysis: RoadmapAnalysis = await tools.roadmapAnalyze(); - if (analysis.phases && analysis.phases.length > 0) { - // Sort by phase number and take the first - const sorted = [...analysis.phases].sort( - (a, b) => parseFloat(a.number) - parseFloat(b.number), - ); - phase1Number = sorted[0]!.number; - } - } catch { - // If roadmap analyze fails, try scanning the phases dir directly - try { - const phasesDir = join(tmpDir, '.planning', 'phases'); - const entries = await readdir(phasesDir); - const phaseEntries = entries - .filter(e => /^\d+/.test(e)) - .sort(); - if (phaseEntries.length > 0) { - // Extract the phase number (everything before the first dash) - const match = phaseEntries[0]!.match(/^(\d+)/); - if (match) { - phase1Number = match[1]!; - } - } - } catch { - // No phases dir — init didn't create one - } - } - } - }, 600_000); // 10 min for init - - afterAll(async () => { - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); - - // ── Main lifecycle test ─────────────────────────────────────────────── - - it('GSD.runPhase() drives Phase 1 through the full lifecycle without human intervention', async () => { - // If init failed, skip — can't test lifecycle without a bootstrapped project - if (!initSuccess) { - console.warn('Skipping lifecycle test: InitRunner did not bootstrap successfully'); - return; - } - - // Verify ROADMAP.md exists and contains at least one phase - const roadmapPath = join(tmpDir, '.planning', 'ROADMAP.md'); - const roadmapStat = await stat(roadmapPath).catch(() => null); - expect(roadmapStat).not.toBeNull(); - - const roadmapContent = await readFile(roadmapPath, 'utf-8'); - expect(roadmapContent.length).toBeGreaterThan(0); - - // Verify we discovered a phase number - expect(phase1Number).not.toBeNull(); - - // Verify the phase exists via initPhaseOp - const phaseOp = await tools.initPhaseOp(phase1Number!); - expect(phaseOp.phase_found).toBe(true); - - // Collect all events during the phase lifecycle - const events: GSDEvent[] = []; - - // Construct GSD with autoMode: true - const gsd = new GSD({ - projectDir: tmpDir, - autoMode: true, - }); - gsd.onEvent((e: GSDEvent) => events.push(e)); - - // Run the discovered first phase with tight budget to minimize cost - const result: PhaseRunnerResult = await gsd.runPhase(phase1Number!, { - maxTurnsPerStep: 10, - maxBudgetPerStep: 0.50, - }); - - // ── Assert: result.phaseNumber matches the discovered phase ── - expect(result.phaseNumber).toBe(phase1Number); - - // ── Assert: result.phaseName is non-empty ── - expect(result.phaseName).toBeTruthy(); - expect(result.phaseName.length).toBeGreaterThan(0); - - // ── Assert: at least one lifecycle step was attempted ── - expect(result.steps.length).toBeGreaterThanOrEqual(1); - - // ── Assert: events include PhaseStart ── - const phaseStartEvents = events.filter(e => e.type === GSDEventType.PhaseStart); - expect(phaseStartEvents.length).toBe(1); - const phaseStart = phaseStartEvents[0]!; - if (phaseStart.type === GSDEventType.PhaseStart) { - expect(phaseStart.phaseNumber).toBe(phase1Number); - expect(phaseStart.phaseName).toBeTruthy(); - } - - // ── Assert: events include PhaseComplete ── - const phaseCompleteEvents = events.filter(e => e.type === GSDEventType.PhaseComplete); - expect(phaseCompleteEvents.length).toBe(1); - const phaseComplete = phaseCompleteEvents[0]!; - if (phaseComplete.type === GSDEventType.PhaseComplete) { - expect(phaseComplete.phaseNumber).toBe(phase1Number); - expect(typeof phaseComplete.totalCostUsd).toBe('number'); - expect(typeof phaseComplete.totalDurationMs).toBe('number'); - } - - // ── Assert: PhaseStepStart events show step progression ── - const stepStartEvents = events.filter( - (e): e is Extract => - e.type === GSDEventType.PhaseStepStart, - ); - expect(stepStartEvents.length).toBeGreaterThanOrEqual(1); - - // Extract the step types in order - const stepTypesInOrder = stepStartEvents.map(e => e.step); - - // Verify monotonic ordering: each step type should have an index >= previous - // Note: gap-closure can re-run plan+execute after verify, so we allow - // monotonicity to break only when verify triggers gap closure. - // For this tight-budget test, full gap closure is unlikely — check basic ordering. - let lastMaxOrder = -1; - for (const stepType of stepTypesInOrder) { - const order = STEP_ORDER[stepType] ?? -1; - // Track the high-water mark — steps should generally progress forward - if (order >= lastMaxOrder) { - lastMaxOrder = order; - } - } - // At least progressed past discuss (order 0) into real work - expect(lastMaxOrder).toBeGreaterThanOrEqual(1); - - // ── Assert: at least one step has planResults with cost > 0 (real Agent SDK work) ── - const stepsWithCost = result.steps.filter(s => { - if (!s.planResults) return false; - return s.planResults.some(pr => pr.totalCostUsd > 0); - }); - // At least one step should have incurred real cost (proves Agent SDK was invoked) - expect(stepsWithCost.length).toBeGreaterThanOrEqual(1); - - // ── Assert: result cost and duration are tracked ── - expect(typeof result.totalCostUsd).toBe('number'); - expect(result.totalDurationMs).toBeGreaterThan(0); - - // ── Assert: each step result is properly structured ── - for (const step of result.steps) { - expect(Object.values(PhaseStepType)).toContain(step.step); - expect(typeof step.success).toBe('boolean'); - expect(typeof step.durationMs).toBe('number'); - } - - // ── Assert: PhaseStepComplete events match step results ── - const stepCompleteEvents = events.filter( - (e): e is Extract => - e.type === GSDEventType.PhaseStepComplete, - ); - // At least as many complete events as step results - expect(stepCompleteEvents.length).toBeGreaterThanOrEqual(result.steps.length); - }, 900_000); // 15 minute timeout: init (~4 min) + phase lifecycle (~10 min) -}); diff --git a/sdk/src/logger.test.ts b/sdk/src/logger.test.ts deleted file mode 100644 index 61d706921..000000000 --- a/sdk/src/logger.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { Writable } from 'node:stream'; -import { GSDLogger } from './logger.js'; -import type { LogEntry } from './logger.js'; -import { PhaseType } from './types.js'; - -// ─── Test output capture ───────────────────────────────────────────────────── - -class BufferStream extends Writable { - lines: string[] = []; - _write(chunk: Buffer, _encoding: string, callback: () => void): void { - const str = chunk.toString(); - this.lines.push(...str.split('\n').filter(l => l.length > 0)); - callback(); - } -} - -function parseLogEntry(line: string): LogEntry { - return JSON.parse(line) as LogEntry; -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe('GSDLogger', () => { - let output: BufferStream; - - beforeEach(() => { - output = new BufferStream(); - }); - - it('outputs valid JSON on each log call', () => { - const logger = new GSDLogger({ output, level: 'debug' }); - logger.info('test message'); - - expect(output.lines).toHaveLength(1); - expect(() => JSON.parse(output.lines[0]!)).not.toThrow(); - }); - - it('includes required fields: timestamp, level, message', () => { - const logger = new GSDLogger({ output, level: 'debug' }); - logger.info('hello world'); - - const entry = parseLogEntry(output.lines[0]!); - expect(entry.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(entry.level).toBe('info'); - expect(entry.message).toBe('hello world'); - }); - - it('filters messages below minimum log level', () => { - const logger = new GSDLogger({ output, level: 'warn' }); - - logger.debug('should be dropped'); - logger.info('should be dropped'); - logger.warn('should appear'); - logger.error('should appear'); - - expect(output.lines).toHaveLength(2); - expect(parseLogEntry(output.lines[0]!).level).toBe('warn'); - expect(parseLogEntry(output.lines[1]!).level).toBe('error'); - }); - - it('defaults to info level filtering', () => { - const logger = new GSDLogger({ output }); - - logger.debug('dropped'); - logger.info('kept'); - - expect(output.lines).toHaveLength(1); - expect(parseLogEntry(output.lines[0]!).level).toBe('info'); - }); - - it('writes to custom output stream', () => { - const customOutput = new BufferStream(); - const logger = new GSDLogger({ output: customOutput, level: 'debug' }); - logger.info('custom'); - - expect(customOutput.lines).toHaveLength(1); - expect(output.lines).toHaveLength(0); - }); - - it('includes phase, plan, and sessionId context when set', () => { - const logger = new GSDLogger({ - output, - level: 'debug', - phase: PhaseType.Execute, - plan: 'test-plan', - sessionId: 'sess-123', - }); - - logger.info('context test'); - - const entry = parseLogEntry(output.lines[0]!); - expect(entry.phase).toBe('execute'); - expect(entry.plan).toBe('test-plan'); - expect(entry.sessionId).toBe('sess-123'); - }); - - it('includes extra data when provided', () => { - const logger = new GSDLogger({ output, level: 'debug' }); - logger.info('with data', { count: 42, tool: 'Bash' }); - - const entry = parseLogEntry(output.lines[0]!); - expect(entry.data).toEqual({ count: 42, tool: 'Bash' }); - }); - - it('omits optional fields when not set', () => { - const logger = new GSDLogger({ output, level: 'debug' }); - logger.info('minimal'); - - const entry = parseLogEntry(output.lines[0]!); - expect(entry.phase).toBeUndefined(); - expect(entry.plan).toBeUndefined(); - expect(entry.sessionId).toBeUndefined(); - expect(entry.data).toBeUndefined(); - }); - - it('supports runtime context updates via setters', () => { - const logger = new GSDLogger({ output, level: 'debug' }); - - logger.info('before'); - logger.setPhase(PhaseType.Research); - logger.setPlan('my-plan'); - logger.setSessionId('sess-456'); - logger.info('after'); - - const before = parseLogEntry(output.lines[0]!); - const after = parseLogEntry(output.lines[1]!); - - expect(before.phase).toBeUndefined(); - expect(after.phase).toBe('research'); - expect(after.plan).toBe('my-plan'); - expect(after.sessionId).toBe('sess-456'); - }); - - it('emits all four log levels correctly', () => { - const logger = new GSDLogger({ output, level: 'debug' }); - - logger.debug('d'); - logger.info('i'); - logger.warn('w'); - logger.error('e'); - - expect(output.lines).toHaveLength(4); - expect(parseLogEntry(output.lines[0]!).level).toBe('debug'); - expect(parseLogEntry(output.lines[1]!).level).toBe('info'); - expect(parseLogEntry(output.lines[2]!).level).toBe('warn'); - expect(parseLogEntry(output.lines[3]!).level).toBe('error'); - }); -}); diff --git a/sdk/src/logger.ts b/sdk/src/logger.ts deleted file mode 100644 index 6a551e071..000000000 --- a/sdk/src/logger.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Structured JSON logger for GSD debugging. - * - * Writes structured log entries to stderr (or configurable writable stream). - * This is a debugging facility (R019), separate from the event stream. - */ - -import type { Writable } from 'node:stream'; -import type { PhaseType } from './types.js'; - -// ─── Log levels ────────────────────────────────────────────────────────────── - -export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; - -const LOG_LEVEL_PRIORITY: Record = { - debug: 0, - info: 1, - warn: 2, - error: 3, -}; - -// ─── Log entry ─────────────────────────────────────────────────────────────── - -export interface LogEntry { - timestamp: string; - level: LogLevel; - phase?: PhaseType; - plan?: string; - sessionId?: string; - message: string; - data?: Record; -} - -// ─── Logger options ────────────────────────────────────────────────────────── - -export interface GSDLoggerOptions { - /** Minimum log level to output. Default: 'info'. */ - level?: LogLevel; - /** Output stream. Default: process.stderr. */ - output?: Writable; - /** Phase context for all log entries. */ - phase?: PhaseType; - /** Plan name context for all log entries. */ - plan?: string; - /** Session ID context for all log entries. */ - sessionId?: string; -} - -// ─── Logger class ──────────────────────────────────────────────────────────── - -export class GSDLogger { - private readonly minLevel: number; - private readonly output: Writable; - private phase?: PhaseType; - private plan?: string; - private sessionId?: string; - - constructor(options: GSDLoggerOptions = {}) { - this.minLevel = LOG_LEVEL_PRIORITY[options.level ?? 'info']; - this.output = options.output ?? process.stderr; - this.phase = options.phase; - this.plan = options.plan; - this.sessionId = options.sessionId; - } - - /** Set phase context for subsequent log entries. */ - setPhase(phase: PhaseType | undefined): void { - this.phase = phase; - } - - /** Set plan context for subsequent log entries. */ - setPlan(plan: string | undefined): void { - this.plan = plan; - } - - /** Set session ID context for subsequent log entries. */ - setSessionId(sessionId: string | undefined): void { - this.sessionId = sessionId; - } - - debug(message: string, data?: Record): void { - this.log('debug', message, data); - } - - info(message: string, data?: Record): void { - this.log('info', message, data); - } - - warn(message: string, data?: Record): void { - this.log('warn', message, data); - } - - error(message: string, data?: Record): void { - this.log('error', message, data); - } - - private log(level: LogLevel, message: string, data?: Record): void { - if (LOG_LEVEL_PRIORITY[level] < this.minLevel) return; - - const entry: LogEntry = { - timestamp: new Date().toISOString(), - level, - message, - }; - - if (this.phase !== undefined) entry.phase = this.phase; - if (this.plan !== undefined) entry.plan = this.plan; - if (this.sessionId !== undefined) entry.sessionId = this.sessionId; - if (data !== undefined) entry.data = data; - - this.output.write(JSON.stringify(entry) + '\n'); - } -} diff --git a/sdk/src/manifest/index.ts b/sdk/src/manifest/index.ts deleted file mode 100644 index 85c873f34..000000000 --- a/sdk/src/manifest/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { STATE_COMMAND_MANIFEST } from '../query/command-manifest.state.js'; -import { VERIFY_COMMAND_MANIFEST } from '../query/command-manifest.verify.js'; -import { INIT_COMMAND_MANIFEST } from '../query/command-manifest.init.js'; -import { PHASE_COMMAND_MANIFEST } from '../query/command-manifest.phase.js'; -import { PHASES_COMMAND_MANIFEST } from '../query/command-manifest.phases.js'; -import { VALIDATE_COMMAND_MANIFEST } from '../query/command-manifest.validate.js'; -import { ROADMAP_COMMAND_MANIFEST } from '../query/command-manifest.roadmap.js'; - -export const COMMAND_MANIFEST = [ - ...STATE_COMMAND_MANIFEST, - ...VERIFY_COMMAND_MANIFEST, - ...INIT_COMMAND_MANIFEST, - ...PHASE_COMMAND_MANIFEST, - ...PHASES_COMMAND_MANIFEST, - ...VALIDATE_COMMAND_MANIFEST, - ...ROADMAP_COMMAND_MANIFEST, -] as const; diff --git a/sdk/src/milestone-runner.test.ts b/sdk/src/milestone-runner.test.ts deleted file mode 100644 index d851056fb..000000000 --- a/sdk/src/milestone-runner.test.ts +++ /dev/null @@ -1,421 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { - PhaseRunnerResult, - RoadmapPhaseInfo, - RoadmapAnalysis, - GSDEvent, - MilestoneRunnerOptions, -} from './types.js'; -import { GSDEventType } from './types.js'; - -// ─── Mock modules ──────────────────────────────────────────────────────────── - -// Mock the heavy dependencies that GSD constructor + runPhase pull in -vi.mock('./plan-parser.js', () => ({ - parsePlan: vi.fn(), - parsePlanFile: vi.fn(), -})); - -vi.mock('./config.js', () => ({ - loadConfig: vi.fn().mockResolvedValue({ - model_profile: 'test-model', - tools: [], - phases: {}, - }), -})); - -vi.mock('./session-runner.js', () => ({ - runPlanSession: vi.fn(), - runPhaseStepSession: vi.fn(), -})); - -vi.mock('./prompt-builder.js', () => ({ - buildExecutorPrompt: vi.fn(), - parseAgentTools: vi.fn().mockReturnValue([]), -})); - -vi.mock('./event-stream.js', () => { - return { - // Use function (not arrow) so `new GSDEventStream()` works under Vitest 4 - GSDEventStream: vi.fn(function GSDEventStreamMock() { - return { - emitEvent: vi.fn(), - on: vi.fn(), - emit: vi.fn(), - addTransport: vi.fn(), - }; - }), - }; -}); - -vi.mock('./phase-runner.js', () => ({ - PhaseRunner: vi.fn(), - PhaseRunnerError: class extends Error { - name = 'PhaseRunnerError'; - }, -})); - -vi.mock('./context-engine.js', () => ({ - ContextEngine: vi.fn(), - PHASE_FILE_MANIFEST: [], -})); - -vi.mock('./phase-prompt.js', () => ({ - PromptFactory: vi.fn(), - extractBlock: vi.fn(), - extractSteps: vi.fn(), - PHASE_WORKFLOW_MAP: {}, -})); - -vi.mock('./gsd-tools.js', () => ({ - // Constructor mock for `new GSDTools(...)` (Vitest 4) - GSDTools: vi.fn(function GSDToolsMock() { - return { - roadmapAnalyze: vi.fn(), - }; - }), - GSDToolsError: class extends Error { - name = 'GSDToolsError'; - }, - resolveGsdToolsPath: vi.fn().mockReturnValue('/mock/gsd-tools.cjs'), -})); - -import { GSD } from './index.js'; -import { GSDTools } from './gsd-tools.js'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function makePhaseInfo(overrides: Partial = {}): RoadmapPhaseInfo { - return { - number: '1', - disk_status: 'not_started', - roadmap_complete: false, - phase_name: 'Auth', - ...overrides, - }; -} - -function makePhaseResult(overrides: Partial = {}): PhaseRunnerResult { - return { - phaseNumber: '1', - phaseName: 'Auth', - steps: [], - success: true, - totalCostUsd: 0.50, - totalDurationMs: 5000, - ...overrides, - }; -} - -function makeAnalysis(phases: RoadmapPhaseInfo[]): RoadmapAnalysis { - return { phases }; -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe('GSD.run()', () => { - let gsd: GSD; - let mockRoadmapAnalyze: ReturnType; - let events: GSDEvent[]; - - beforeEach(() => { - vi.clearAllMocks(); - - gsd = new GSD({ projectDir: '/tmp/test-project' }); - events = []; - - // Capture emitted events - (gsd.eventStream.emitEvent as ReturnType).mockImplementation( - (event: GSDEvent) => events.push(event), - ); - - // Wire mock roadmapAnalyze on the GSDTools instance - mockRoadmapAnalyze = vi.fn(); - vi.mocked(GSDTools).mockImplementation(function () { - return { - roadmapAnalyze: mockRoadmapAnalyze, - } as any; - }); - }); - - it('discovers phases and calls runPhase for each incomplete one', async () => { - const phases = [ - makePhaseInfo({ number: '1', phase_name: 'Auth', roadmap_complete: false }), - makePhaseInfo({ number: '2', phase_name: 'Dashboard', roadmap_complete: false }), - ]; - - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis(phases)) // initial discovery - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])) // after phase 1 - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: true }), - ])); // after phase 2 - - const runPhaseSpy = vi.spyOn(gsd, 'runPhase') - .mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1' })) - .mockResolvedValueOnce(makePhaseResult({ phaseNumber: '2' })); - - const result = await gsd.run('build the app'); - - expect(result.success).toBe(true); - expect(result.phases).toHaveLength(2); - expect(runPhaseSpy).toHaveBeenCalledTimes(2); - expect(runPhaseSpy).toHaveBeenCalledWith('1', undefined); - expect(runPhaseSpy).toHaveBeenCalledWith('2', undefined); - }); - - it('skips phases where roadmap_complete === true', async () => { - const phases = [ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - makePhaseInfo({ number: '3', roadmap_complete: true }), - ]; - - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis(phases)) - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: true }), - makePhaseInfo({ number: '3', roadmap_complete: true }), - ])); - - const runPhaseSpy = vi.spyOn(gsd, 'runPhase') - .mockResolvedValueOnce(makePhaseResult({ phaseNumber: '2' })); - - const result = await gsd.run('build it'); - - expect(result.success).toBe(true); - expect(result.phases).toHaveLength(1); - expect(runPhaseSpy).toHaveBeenCalledTimes(1); - expect(runPhaseSpy).toHaveBeenCalledWith('2', undefined); - }); - - it('re-discovers phases after each completion to catch dynamically inserted phases', async () => { - // Initially phase 1 and 2 are incomplete - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: false }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])) - // After phase 1, a new phase 1.5 was inserted - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '1.5', phase_name: 'Hotfix', roadmap_complete: false }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])) - // After phase 1.5 completes - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '1.5', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])) - // After phase 2 completes - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '1.5', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: true }), - ])); - - const runPhaseSpy = vi.spyOn(gsd, 'runPhase') - .mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1' })) - .mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1.5', phaseName: 'Hotfix' })) - .mockResolvedValueOnce(makePhaseResult({ phaseNumber: '2' })); - - const result = await gsd.run('build it'); - - expect(result.success).toBe(true); - expect(result.phases).toHaveLength(3); - expect(runPhaseSpy).toHaveBeenCalledTimes(3); - // The dynamically inserted phase 1.5 was executed - expect(runPhaseSpy).toHaveBeenNthCalledWith(2, '1.5', undefined); - }); - - it('aggregates costs from all phases', async () => { - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: false }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])) - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])) - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: true }), - ])); - - vi.spyOn(gsd, 'runPhase') - .mockResolvedValueOnce(makePhaseResult({ totalCostUsd: 1.25 })) - .mockResolvedValueOnce(makePhaseResult({ totalCostUsd: 0.75 })); - - const result = await gsd.run('build it'); - - expect(result.totalCostUsd).toBeCloseTo(2.0, 2); - }); - - it('emits MilestoneStart and MilestoneComplete events', async () => { - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: false }), - ])) - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - ])); - - vi.spyOn(gsd, 'runPhase') - .mockResolvedValueOnce(makePhaseResult({ totalCostUsd: 0.50 })); - - await gsd.run('build it'); - - const startEvents = events.filter(e => e.type === GSDEventType.MilestoneStart); - const completeEvents = events.filter(e => e.type === GSDEventType.MilestoneComplete); - - expect(startEvents).toHaveLength(1); - expect(completeEvents).toHaveLength(1); - - const start = startEvents[0] as any; - expect(start.phaseCount).toBe(1); - expect(start.prompt).toBe('build it'); - - const complete = completeEvents[0] as any; - expect(complete.success).toBe(true); - expect(complete.phasesCompleted).toBe(1); - expect(complete.totalCostUsd).toBeCloseTo(0.50, 2); - }); - - it('stops on phase failure', async () => { - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: false }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])); - - vi.spyOn(gsd, 'runPhase') - .mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1', success: false })); - - const result = await gsd.run('build it'); - - expect(result.success).toBe(false); - expect(result.phases).toHaveLength(1); - // Phase 2 was never started - }); - - it('handles empty phase list', async () => { - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis([])); - - const runPhaseSpy = vi.spyOn(gsd, 'runPhase'); - - const result = await gsd.run('build it'); - - expect(result.success).toBe(true); - expect(result.phases).toHaveLength(0); - expect(runPhaseSpy).not.toHaveBeenCalled(); - expect(result.totalCostUsd).toBe(0); - }); - - it('sorts phases numerically, not lexicographically', async () => { - const phases = [ - makePhaseInfo({ number: '10', phase_name: 'Ten', roadmap_complete: false }), - makePhaseInfo({ number: '2', phase_name: 'Two', roadmap_complete: false }), - makePhaseInfo({ number: '1.5', phase_name: 'OnePointFive', roadmap_complete: false }), - ]; - - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis(phases)) - // After phase 1.5 - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1.5', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - makePhaseInfo({ number: '10', roadmap_complete: false }), - ])) - // After phase 2 - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1.5', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: true }), - makePhaseInfo({ number: '10', roadmap_complete: false }), - ])) - // After phase 10 - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1.5', roadmap_complete: true }), - makePhaseInfo({ number: '2', roadmap_complete: true }), - makePhaseInfo({ number: '10', roadmap_complete: true }), - ])); - - const executionOrder: string[] = []; - vi.spyOn(gsd, 'runPhase').mockImplementation(async (phaseNumber: string) => { - executionOrder.push(phaseNumber); - return makePhaseResult({ phaseNumber }); - }); - - await gsd.run('build it'); - - // Numeric order: 1.5 → 2 → 10 (not lexicographic: "10" < "2") - expect(executionOrder).toEqual(['1.5', '2', '10']); - }); - - it('handles phase throwing an unexpected error', async () => { - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', phase_name: 'Broken', roadmap_complete: false }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])); - - vi.spyOn(gsd, 'runPhase') - .mockRejectedValueOnce(new Error('Unexpected explosion')); - - const result = await gsd.run('build it'); - - expect(result.success).toBe(false); - expect(result.phases).toHaveLength(1); - expect(result.phases[0].success).toBe(false); - expect(result.phases[0].phaseNumber).toBe('1'); - }); - - it('passes MilestoneRunnerOptions through to runPhase', async () => { - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: false }), - ])) - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: true }), - ])); - - const runPhaseSpy = vi.spyOn(gsd, 'runPhase') - .mockResolvedValueOnce(makePhaseResult()); - - const opts: MilestoneRunnerOptions = { - model: 'claude-sonnet-4-6', - maxBudgetPerStep: 2.0, - onPhaseComplete: vi.fn(), - }; - - await gsd.run('build it', opts); - - expect(runPhaseSpy).toHaveBeenCalledWith('1', opts); - }); - - it('respects onPhaseComplete returning stop', async () => { - mockRoadmapAnalyze - .mockResolvedValueOnce(makeAnalysis([ - makePhaseInfo({ number: '1', roadmap_complete: false }), - makePhaseInfo({ number: '2', roadmap_complete: false }), - ])); - - vi.spyOn(gsd, 'runPhase') - .mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1' })); - - const result = await gsd.run('build it', { - onPhaseComplete: async () => 'stop', - }); - - // Only 1 phase was executed because callback said stop - expect(result.phases).toHaveLength(1); - expect(result.success).toBe(true); - }); -}); diff --git a/sdk/src/model-catalog.ts b/sdk/src/model-catalog.ts deleted file mode 100644 index 7653ac1b5..000000000 --- a/sdk/src/model-catalog.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; - -interface RuntimeTierEntry { - model: string; - reasoning_effort?: string; -} - -type RuntimeTierTable = Record>; - -interface AgentCatalogEntry { - golden: 'opus' | 'sonnet' | 'haiku'; - balanced: 'opus' | 'sonnet' | 'haiku'; - budget: 'opus' | 'sonnet' | 'haiku'; - phaseType: string; - routingTier: 'light' | 'standard' | 'heavy'; -} - -interface ModelCatalog { - profiles: string[]; - phaseTypes: string[]; - adaptiveTierMap: Record<'light' | 'standard' | 'heavy', 'opus' | 'sonnet' | 'haiku'>; - runtimeTierDefaults: RuntimeTierTable; - agents: Record; -} - -const CATALOG_PATH = new URL('../shared/model-catalog.json', import.meta.url); -export const catalog: ModelCatalog = JSON.parse(readFileSync(fileURLToPath(CATALOG_PATH), 'utf-8')); - -export const VALID_PROFILES: string[] = [...catalog.profiles]; -export const SUPPORTED_RUNTIMES = Object.keys(catalog.runtimeTierDefaults); -export type Runtime = (typeof SUPPORTED_RUNTIMES)[number]; - -export const MODEL_PROFILES: Record> = Object.fromEntries( - Object.entries(catalog.agents).map(([agent, meta]) => [agent, { - quality: meta.golden, - balanced: meta.balanced, - budget: meta.budget, - adaptive: catalog.adaptiveTierMap[meta.routingTier], - }]) -); - -export const AGENT_TO_PHASE_TYPE: Record = Object.fromEntries( - Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.phaseType]) -); - -export const AGENT_DEFAULT_TIERS: Record = Object.fromEntries( - Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.routingTier]) -); - -export function getAgentToModelMapForProfile(normalizedProfile: string): Record { - const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced'; - const out: Record = {}; - for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) { - out[agent] = profile === 'inherit' ? 'inherit' : profiles[profile] ?? profiles.balanced; - } - return out; -} - -export function resolveRuntimeTierDefault(runtime: string, alias: 'opus' | 'sonnet' | 'haiku'): RuntimeTierEntry | null { - return catalog.runtimeTierDefaults[runtime]?.[alias] ?? null; -} - -export function runtimesWithReasoningEffort(): Set { - return new Set( - Object.entries(catalog.runtimeTierDefaults) - .filter(([, tiers]) => Object.values(tiers).some((entry) => entry && entry.reasoning_effort)) - .map(([runtime]) => runtime) - ); -} diff --git a/sdk/src/phase-prompt.ts b/sdk/src/phase-prompt.ts deleted file mode 100644 index 27ed1e933..000000000 --- a/sdk/src/phase-prompt.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Phase-aware prompt factory — assembles complete prompts for each phase type. - * - * Reads workflow .md + agent .md files from disk (D006), extracts structured - * blocks (, , ), and composes system prompts with - * injected context files per phase type. - */ - -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import type { ContextFiles, ParsedPlan } from './types.js'; -import { PhaseType } from './types.js'; -import { buildExecutorPrompt } from './prompt-builder.js'; -import { PHASE_AGENT_MAP } from './tool-scoping.js'; -import { sanitizePrompt } from './prompt-sanitizer.js'; -import { resolveLegacyInstallDir } from './sdk-package-compatibility.js'; - -// ─── Workflow file mapping ─────────────────────────────────────────────────── - -/** - * Maps phase types to their workflow file names. - */ -const PHASE_WORKFLOW_MAP: Record = { - [PhaseType.Execute]: 'execute-plan.md', - [PhaseType.Research]: 'research-phase.md', - [PhaseType.Plan]: 'plan-phase.md', - [PhaseType.Verify]: 'verify-phase.md', - [PhaseType.Discuss]: 'discuss-phase.md', - [PhaseType.Repair]: 'execute-plan.md', -}; - -// ─── XML block extraction ──────────────────────────────────────────────────── - -/** - * Extract content from an XML-style block (e.g., ...). - * Returns the trimmed inner content, or empty string if not found. - */ -export function extractBlock(content: string, tagName: string): string { - const regex = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'); - const match = content.match(regex); - return match ? match[1].trim() : ''; -} - -/** - * Extract all blocks from a workflow's section. - * Returns an array of step contents with their name attributes. - */ -export function extractSteps(processContent: string): Array<{ name: string; content: string }> { - const steps: Array<{ name: string; content: string }> = []; - const stepRegex = /]*>([\s\S]*?)<\/step>/gi; - let match; - - while ((match = stepRegex.exec(processContent)) !== null) { - steps.push({ - name: match[1], - content: match[2].trim(), - }); - } - - return steps; -} - -// ─── YAML frontmatter stripping ───────────────────────────────────────────── - -/** - * Strip YAML frontmatter (---...---) from an agent definition file, - * returning only the markdown/XML content body. - */ -export function stripYamlFrontmatter(content: string): string { - const match = content.match(/^---\s*\n[\s\S]*?\n---\s*\n?([\s\S]*)$/); - return match ? match[1].trim() : content.trim(); -} - -// ─── PromptFactory class ───────────────────────────────────────────────────── - -export class PromptFactory { - private readonly workflowsDir: string; - private readonly agentsDir: string; - private readonly projectAgentsDir?: string; - private readonly sdkPromptsDir: string; - private readonly projectDir?: string; - - constructor(options?: { - gsdInstallDir?: string; - agentsDir?: string; - projectAgentsDir?: string; - sdkPromptsDir?: string; - projectDir?: string; - }) { - const gsdInstallDir = options?.gsdInstallDir ?? resolveLegacyInstallDir(); - this.workflowsDir = join(gsdInstallDir, 'workflows'); - this.agentsDir = options?.agentsDir ?? join(gsdInstallDir, '..', 'agents'); - this.projectAgentsDir = options?.projectAgentsDir; - this.projectDir = options?.projectDir; - // SDK prompts dir: explicit override → package-relative default via import.meta.url - this.sdkPromptsDir = - options?.sdkPromptsDir ?? - join(fileURLToPath(new URL('.', import.meta.url)), '..', 'prompts'); - } - - /** - * Build a complete prompt for the given phase type. - * - * For execute phase with a plan, delegates to buildExecutorPrompt(). - * For other phases, assembles: role + purpose + process steps + context. - */ - async buildPrompt( - phaseType: PhaseType, - plan: ParsedPlan | null, - contextFiles: ContextFiles, - phaseDir?: string, - ): Promise { - // Execute phase with a plan: delegate to existing buildExecutorPrompt - if (phaseType === PhaseType.Execute && plan) { - const agentDef = await this.loadAgentDef(phaseType); - return sanitizePrompt(buildExecutorPrompt(plan, { agentDef, phaseDir }), this.projectDir); - } - - // Prompt assembly order is cache-optimized (#1614): - // Stable prefix (deterministic per phase type) → cached by Anthropic at 0.1x cost - // Variable suffix (.planning/ files) → uncached, changes per project/run - const sections: string[] = []; - - // ── STABLE PREFIX (cacheable across runs for the same phase type) ── - - // ── Full agent definition ── - // Include the complete agent definition (minus YAML frontmatter), not just - // the block. The real agents have critical instructions in sections - // like , , , , - // , , , etc. - const agentDef = await this.loadAgentDef(phaseType); - if (agentDef) { - const agentContent = stripYamlFrontmatter(agentDef); - if (agentContent) { - sections.push(`## Agent Instructions\n\n${agentContent}`); - } - } - - // ── Workflow purpose + process ── - const workflow = await this.loadWorkflowFile(phaseType); - if (workflow) { - const purpose = extractBlock(workflow, 'purpose'); - if (purpose) { - sections.push(`## Purpose\n\n${purpose}`); - } - - const process = extractBlock(workflow, 'process'); - if (process) { - const steps = extractSteps(process); - if (steps.length > 0) { - const stepBlocks = steps.map((s) => `### ${s.name}\n\n${s.content}`).join('\n\n'); - sections.push(`## Process\n\n${stepBlocks}`); - } - } - } - - // ── VARIABLE SUFFIX (project-specific, changes per run) ── - - // ── Context files ── - const contextSection = this.formatContextFiles(contextFiles); - if (contextSection) { - sections.push(contextSection); - } - - return sanitizePrompt(sections.join('\n\n'), this.projectDir); - } - - /** - * Load the workflow file for a phase type. - * Tries installed GSD workflows first (the complete, up-to-date versions), - * then falls back to SDK bundled copies only if installed not found. - * Returns the raw content, or undefined if not found. - */ - async loadWorkflowFile(phaseType: PhaseType): Promise { - const filename = PHASE_WORKFLOW_MAP[phaseType]; - - // Try installed GSD workflows first (complete versions) - const paths = [ - join(this.workflowsDir, filename), - join(this.sdkPromptsDir, 'workflows', filename), - ]; - - for (const p of paths) { - try { - return await readFile(p, 'utf-8'); - } catch { - // Not found at this path, try next - } - } - - return undefined; - } - - /** - * Load the agent definition for a phase type. - * Tries installed agents first (the complete, up-to-date versions), - * then SDK bundled copies as last resort. - * Returns undefined if no agent is mapped or file not found. - */ - async loadAgentDef(phaseType: PhaseType): Promise { - const agentFilename = PHASE_AGENT_MAP[phaseType]; - if (!agentFilename) return undefined; - - // Priority: installed agents → project-level → SDK bundled (last resort) - const paths = [ - join(this.agentsDir, agentFilename), - ]; - - if (this.projectAgentsDir) { - paths.push(join(this.projectAgentsDir, agentFilename)); - } - - // SDK bundled copies are last resort only - paths.push(join(this.sdkPromptsDir, 'agents', agentFilename)); - - for (const p of paths) { - try { - return await readFile(p, 'utf-8'); - } catch { - // Not found at this path, try next - } - } - - return undefined; - } - - /** - * Format context files into a prompt section. - */ - private formatContextFiles(contextFiles: ContextFiles): string | null { - const entries: string[] = []; - - const fileLabels: Record = { - state: 'Project State (STATE.md)', - roadmap: 'Roadmap (ROADMAP.md)', - context: 'Context (CONTEXT.md)', - research: 'Research (RESEARCH.md)', - requirements: 'Requirements (REQUIREMENTS.md)', - config: 'Config (config.json)', - plan: 'Plan (PLAN.md)', - summary: 'Summary (SUMMARY.md)', - }; - - for (const [key, label] of Object.entries(fileLabels)) { - const content = contextFiles[key as keyof ContextFiles]; - if (content) { - entries.push(`### ${label}\n\n${content}`); - } - } - - if (entries.length === 0) return null; - return `## Context\n\n${entries.join('\n\n')}`; - } - -} - -export { PHASE_WORKFLOW_MAP }; diff --git a/sdk/src/phase-runner.integration.test.ts b/sdk/src/phase-runner.integration.test.ts deleted file mode 100644 index 71b8d65c0..000000000 --- a/sdk/src/phase-runner.integration.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -/** - * Integration test — proves PhaseRunner state machine works against real gsd-tools.cjs. - * - * Creates a temp `.planning/` directory structure, instantiates real GSDTools, - * and exercises the state machine. Sessions will fail (no Claude CLI in CI) but - * the state machine's control flow, event emission, and error capture are proven. - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { GSDTools, resolveGsdToolsPath } from './gsd-tools.js'; -import { PhaseRunner } from './phase-runner.js'; -import type { PhaseRunnerDeps } from './phase-runner.js'; -import { ContextEngine } from './context-engine.js'; -import { PromptFactory } from './phase-prompt.js'; -import { GSDEventStream } from './event-stream.js'; -import { loadConfig } from './config.js'; -import type { GSDEvent } from './types.js'; -import { GSDEventType, PhaseStepType } from './types.js'; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -const GSD_TOOLS_PATH = resolveGsdToolsPath(process.cwd()); -const gsdToolsAvailable = existsSync(GSD_TOOLS_PATH); - -async function createTempPlanningDir(): Promise { - const tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-phase-int-')); - - // Create .planning structure - const planningDir = join(tmpDir, '.planning'); - const phasesDir = join(planningDir, 'phases'); - const phaseDir = join(phasesDir, '01-integration-test'); - - await mkdir(phaseDir, { recursive: true }); - - // config.json - await writeFile( - join(planningDir, 'config.json'), - JSON.stringify({ - model_profile: 'balanced', - commit_docs: false, - workflow: { - research: true, - verifier: true, - auto_advance: true, - skip_discuss: false, - }, - }), - ); - - // ROADMAP.md — required for roadmap_exists - await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## Phase 01: Integration Test\n'); - - // CONTEXT.md in phase dir — triggers has_context=true → discuss is skipped - await writeFile( - join(phaseDir, 'CONTEXT.md'), - '# Context\n\nThis is an integration test phase with pre-existing context.\n', - ); - - return tmpDir; -} - -// ─── Test suite ────────────────────────────────────────────────────────────── - -describe.skipIf(!gsdToolsAvailable)('Integration: PhaseRunner against real gsd-tools.cjs', () => { - let tmpDir: string; - let tools: GSDTools; - - beforeAll(async () => { - tmpDir = await createTempPlanningDir(); - tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: GSD_TOOLS_PATH, - timeoutMs: 10_000, - }); - }); - - afterAll(async () => { - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); - - // ── Test 1: initPhaseOp returns valid PhaseOpInfo ── - - it('initPhaseOp returns valid PhaseOpInfo for temp phase', async () => { - const info = await tools.initPhaseOp('01'); - - expect(info.phase_found).toBe(true); - expect(info.phase_number).toBe('01'); - expect(info.phase_name).toBe('integration-test'); - expect(info.phase_dir).toBe('.planning/phases/01-integration-test'); - expect(info.has_context).toBe(true); - expect(info.has_plans).toBe(false); - expect(info.plan_count).toBe(0); - expect(info.roadmap_exists).toBe(true); - expect(info.planning_exists).toBe(true); - }); - - it('initPhaseOp returns phase_found=false for nonexistent phase', async () => { - const info = await tools.initPhaseOp('99'); - - expect(info.phase_found).toBe(false); - expect(info.has_context).toBe(false); - expect(info.plan_count).toBe(0); - }); - - // ── Test 2: PhaseRunner state machine control flow ── - - it('PhaseRunner emits lifecycle events and captures session errors gracefully', { timeout: 300_000 }, async () => { - const eventStream = new GSDEventStream(); - const config = await loadConfig(tmpDir); - const contextEngine = new ContextEngine(tmpDir); - const promptFactory = new PromptFactory(); - - const events: GSDEvent[] = []; - eventStream.on('event', (e: GSDEvent) => events.push(e)); - - const deps: PhaseRunnerDeps = { - projectDir: tmpDir, - tools, - promptFactory, - contextEngine, - eventStream, - config, - }; - - const runner = new PhaseRunner(deps); - // Tight budget/turns so each session finishes fast - const result = await runner.run('01', { - maxTurnsPerStep: 2, - maxBudgetPerStep: 0.10, - }); - - // ── (a) Phase start event emitted ── - const phaseStartEvents = events.filter(e => e.type === GSDEventType.PhaseStart); - expect(phaseStartEvents).toHaveLength(1); - const phaseStart = phaseStartEvents[0]!; - if (phaseStart.type === GSDEventType.PhaseStart) { - expect(phaseStart.phaseNumber).toBe('01'); - expect(phaseStart.phaseName).toBe('integration-test'); - } - - // ── (b) Discuss should be skipped (has_context=true) ── - // No discuss step in results since it was skipped - const discussSteps = result.steps.filter(s => s.step === PhaseStepType.Discuss); - expect(discussSteps).toHaveLength(0); - - // ── (c) Step start events emitted for attempted steps ── - const stepStartEvents = events.filter(e => e.type === GSDEventType.PhaseStepStart); - expect(stepStartEvents.length).toBeGreaterThanOrEqual(1); - - // ── (d) Step results are properly structured ── - // With CLI available, sessions may succeed or fail depending on budget/turns. - // Either way, each step result must have correct structure. - expect(result.steps.length).toBeGreaterThanOrEqual(1); - for (const step of result.steps) { - expect(Object.values(PhaseStepType)).toContain(step.step); - expect(typeof step.success).toBe('boolean'); - expect(typeof step.durationMs).toBe('number'); - // Failed steps may or may not have an error message - // (e.g. advance step can fail without explicit error string) - } - - // ── (e) Phase complete event emitted ── - const phaseCompleteEvents = events.filter(e => e.type === GSDEventType.PhaseComplete); - expect(phaseCompleteEvents).toHaveLength(1); - - // ── (f) Result structure is valid ── - expect(result.phaseNumber).toBe('01'); - expect(result.phaseName).toBe('integration-test'); - expect(typeof result.totalCostUsd).toBe('number'); - expect(typeof result.totalDurationMs).toBe('number'); - expect(result.totalDurationMs).toBeGreaterThan(0); - }); - - // ── Test 3: PhaseRunner with nonexistent phase throws ── - - it('PhaseRunner throws PhaseRunnerError for nonexistent phase', async () => { - const eventStream = new GSDEventStream(); - const config = await loadConfig(tmpDir); - const contextEngine = new ContextEngine(tmpDir); - const promptFactory = new PromptFactory(); - - const deps: PhaseRunnerDeps = { - projectDir: tmpDir, - tools, - promptFactory, - contextEngine, - eventStream, - config, - }; - - const runner = new PhaseRunner(deps); - await expect(runner.run('99')).rejects.toThrow('Phase 99 not found on disk'); - }); - - // ── Test 4: GSD.runPhase() public API delegates correctly ── - - it('GSD.runPhase() creates collaborators and delegates to PhaseRunner', { timeout: 300_000 }, async () => { - // Import GSD here to test the public API wiring - const { GSD } = await import('./index.js'); - - const gsd = new GSD({ projectDir: tmpDir }); - const events: GSDEvent[] = []; - gsd.onEvent((e) => events.push(e)); - - const result = await gsd.runPhase('01', { - maxTurnsPerStep: 2, - maxBudgetPerStep: 0.10, - }); - - // Proves the full wiring works: GSD → PhaseRunner → GSDTools → gsd-tools.cjs - expect(result.phaseNumber).toBe('01'); - expect(result.phaseName).toBe('integration-test'); - expect(result.steps.length).toBeGreaterThanOrEqual(1); - expect(events.some(e => e.type === GSDEventType.PhaseStart)).toBe(true); - expect(events.some(e => e.type === GSDEventType.PhaseComplete)).toBe(true); - }); -}); - -// ─── Wave / phasePlanIndex Integration Tests ───────────────────────────────── - -/** - * Creates a temp `.planning/` directory with multi-wave plan files. - * - Plans 01 and 02 are wave 1 (parallel) - * - Plan 03 is wave 2 (depends on wave 1) - * - Plan 01 has a SUMMARY.md (marks it as completed) - */ -async function createMultiWavePlanningDir(): Promise { - const tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-wave-int-')); - - const planningDir = join(tmpDir, '.planning'); - const phaseDir = join(planningDir, 'phases', '01-wave-test'); - await mkdir(phaseDir, { recursive: true }); - - // config.json — with parallelization enabled - await writeFile( - join(planningDir, 'config.json'), - JSON.stringify({ - model_profile: 'balanced', - commit_docs: false, - parallelization: true, - workflow: { - research: true, - verifier: true, - auto_advance: true, - skip_discuss: false, - }, - }), - ); - - // ROADMAP.md - await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## Phase 01: Wave Test\n'); - - const planTemplate = (id: string, wave: number, dependsOn: string[] = []) => `--- -phase: "01" -plan: "${id}" -type: "feature" -wave: ${wave} -depends_on: [${dependsOn.map(d => `"${d}"`).join(', ')}] -files_modified: ["src/${id}.ts"] -autonomous: true -requirements: [] -must_haves: - truths: ["${id} exists"] - artifacts: [] - key_links: [] ---- - -# Plan: ${id} - - - none - Create ${id} - File exists - - - File exists - - Done - -`; - - // Wave 1 plans (parallel) - await writeFile(join(phaseDir, '01-wave-test-01-PLAN.md'), planTemplate('01-wave-test-01', 1)); - await writeFile(join(phaseDir, '01-wave-test-02-PLAN.md'), planTemplate('01-wave-test-02', 1)); - - // Wave 2 plan (depends on wave 1) - await writeFile( - join(phaseDir, '01-wave-test-03-PLAN.md'), - planTemplate('01-wave-test-03', 2, ['01-wave-test-01']), - ); - - // Summary for plan 01 — marks it as completed - await writeFile( - join(phaseDir, '01-wave-test-01-SUMMARY.md'), - `---\nresult: pass\nplan: "01-wave-test-01"\ncost_usd: 0.01\nduration_ms: 1000\n---\n\n# Summary\n\nAll tasks completed.\n`, - ); - - return tmpDir; -} - -describe.skipIf(!gsdToolsAvailable)('Integration: phasePlanIndex and wave execution', () => { - let tmpDir: string; - let tools: GSDTools; - - beforeAll(async () => { - tmpDir = await createMultiWavePlanningDir(); - tools = new GSDTools({ - projectDir: tmpDir, - gsdToolsPath: GSD_TOOLS_PATH, - timeoutMs: 10_000, - }); - }); - - afterAll(async () => { - if (tmpDir) { - await rm(tmpDir, { recursive: true, force: true }); - } - }); - - it('phasePlanIndex returns typed PhasePlanIndex with correct wave grouping', async () => { - const index = await tools.phasePlanIndex('01'); - - // 3 plans total - expect(index.plans).toHaveLength(3); - - // Wave grouping: wave 1 has 2 plans, wave 2 has 1 - expect(index.waves['1']).toHaveLength(2); - expect(index.waves['1']).toContain('01-wave-test-01'); - expect(index.waves['1']).toContain('01-wave-test-02'); - expect(index.waves['2']).toHaveLength(1); - expect(index.waves['2']).toContain('01-wave-test-03'); - - // Incomplete: plan 01 has summary so only 02 and 03 are incomplete - expect(index.incomplete).toHaveLength(2); - expect(index.incomplete).toContain('01-wave-test-02'); - expect(index.incomplete).toContain('01-wave-test-03'); - - // All autonomous → no checkpoints - expect(index.has_checkpoints).toBe(false); - - // Phase ID correct - expect(index.phase).toBe('01'); - }); - - it('phasePlanIndex marks has_summary correctly per plan', async () => { - const index = await tools.phasePlanIndex('01'); - - // Plan 01 has a SUMMARY.md on disk - const plan01 = index.plans.find(p => p.id === '01-wave-test-01'); - expect(plan01).toBeDefined(); - expect(plan01!.has_summary).toBe(true); - - // Plans 02 and 03 have no summary - const plan02 = index.plans.find(p => p.id === '01-wave-test-02'); - expect(plan02).toBeDefined(); - expect(plan02!.has_summary).toBe(false); - - const plan03 = index.plans.find(p => p.id === '01-wave-test-03'); - expect(plan03).toBeDefined(); - expect(plan03!.has_summary).toBe(false); - }); - - it('phasePlanIndex for nonexistent phase returns empty plans', async () => { - const index = await tools.phasePlanIndex('99'); - - expect(index.plans).toHaveLength(0); - expect(Object.keys(index.waves)).toHaveLength(0); - expect(index.incomplete).toHaveLength(0); - expect(index.has_checkpoints).toBe(false); - }); -}); diff --git a/sdk/src/phase-runner.test.ts b/sdk/src/phase-runner.test.ts deleted file mode 100644 index 38d136f01..000000000 --- a/sdk/src/phase-runner.test.ts +++ /dev/null @@ -1,3660 +0,0 @@ -/** - * Phase Runner tests — consolidated from phase-runner.test.ts, - * phase-runner-types.test.ts, and phase-prompt.test.ts (issue #3740). - * - * Covers: - * • PhaseRunner state machine (mocked deps) - * • Phase lifecycle type contracts (PhaseStepType, GSDEventType, PhaseOpInfo, etc.) - * • GSDTools typed methods - * • PromptFactory / extractBlock / extractSteps / PHASE_WORKFLOW_MAP - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { PhaseRunner, PhaseRunnerError } from './phase-runner.js'; -import type { PhaseRunnerDeps, VerificationOutcome } from './phase-runner.js'; -import type { - PhaseOpInfo, - PlanResult, - SessionUsage, - SessionOptions, - HumanGateCallbacks, - GSDEvent, - PhasePlanIndex, - PlanInfo, - PhaseStepResult, - PhaseRunnerResult, - PhaseRunnerOptions, - GSDPhaseStartEvent, - GSDPhaseStepStartEvent, - GSDPhaseStepCompleteEvent, - GSDPhaseCompleteEvent, - ContextFiles, - ParsedPlan, - PlanFrontmatter, -} from './types.js'; -import { PhaseStepType, PhaseType, GSDEventType } from './types.js'; -import type { GSDConfig } from './config.js'; -import { CONFIG_DEFAULTS } from './config.js'; -import { GSDTools, GSDToolsError } from './gsd-tools.js'; -import { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js'; - -// ─── Mock modules ──────────────────────────────────────────────────────────── - -// Mock session-runner to avoid real SDK calls -vi.mock('./session-runner.js', () => ({ - runPhaseStepSession: vi.fn(), - runPlanSession: vi.fn(), -})); - -// Mock plan-parser to avoid real file I/O in executeSinglePlan -vi.mock('./plan-parser.js', () => ({ - parsePlanFile: vi.fn().mockResolvedValue({ - frontmatter: { phase: '01-auth', plan: '01', type: 'execute', wave: 1, depends_on: [], files_modified: [], autonomous: true, requirements: [], must_haves: { truths: [], artifacts: [], key_links: [] } }, - objective: 'Test plan objective', - execution_context: [], - context_refs: [], - tasks: [{ name: 'Test task', type: 'auto', files: [], read_first: [], action: 'do the thing', verify: 'check it', done: 'done', acceptance_criteria: [] }], - raw: '', - }), -})); - -import { runPhaseStepSession } from './session-runner.js'; -import { parsePlanFile } from './plan-parser.js'; - -const mockRunPhaseStepSession = vi.mocked(runPhaseStepSession); -const mockParsePlanFile = vi.mocked(parsePlanFile); - -// ─── Factory helpers ───────────────────────────────────────────────────────── - -let defaultProjectDir = '/tmp/project'; -const defaultPhaseDir = '.planning/phases/01-auth'; - -function makePhaseOp(overrides: Partial = {}): PhaseOpInfo { - return { - phase_found: true, - phase_dir: defaultPhaseDir, - phase_number: '1', - phase_name: 'Authentication', - phase_slug: 'auth', - padded_phase: '01', - has_research: false, - has_context: false, - has_plans: true, - has_verification: false, - plan_count: 1, - roadmap_exists: true, - planning_exists: true, - commit_docs: true, - context_path: join(defaultProjectDir, defaultPhaseDir, 'CONTEXT.md'), - research_path: join(defaultProjectDir, defaultPhaseDir, 'RESEARCH.md'), - ...overrides, - }; -} - -function makeUsage(): SessionUsage { - return { - inputTokens: 100, - outputTokens: 50, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - }; -} - -function makePlanResult(overrides: Partial = {}): PlanResult { - return { - success: true, - sessionId: 'sess-123', - totalCostUsd: 0.01, - durationMs: 1000, - usage: makeUsage(), - numTurns: 5, - ...overrides, - }; -} - -function makePlanInfo(overrides: Partial = {}): PlanInfo { - return { - id: 'plan-1', - wave: 1, - autonomous: true, - objective: 'Test objective', - files_modified: [], - task_count: 1, - has_summary: false, - ...overrides, - }; -} - -function makeParsedPlan(filesModified: string[] = []) { - return { - frontmatter: { - phase: '01-auth', - plan: '01', - type: 'execute', - wave: 1, - depends_on: [], - files_modified: filesModified, - autonomous: true, - requirements: [], - must_haves: { truths: [], artifacts: [], key_links: [] }, - }, - objective: 'Test plan objective', - execution_context: [], - context_refs: [], - tasks: [{ name: 'Test task', type: 'auto', files: [], read_first: [], action: 'do the thing', verify: 'check it', done: 'done', acceptance_criteria: [] }], - raw: '', - }; -} - -function makePlanIndex(planCount: number, overrides: Partial = {}): PhasePlanIndex { - const plans: PlanInfo[] = []; - const waves: Record = {}; - for (let i = 0; i < planCount; i++) { - const id = `plan-${i + 1}`; - const wave = 1; // Default: all in wave 1 - plans.push(makePlanInfo({ id, wave })); - const waveKey = String(wave); - if (!waves[waveKey]) waves[waveKey] = []; - waves[waveKey].push(id); - } - return { - phase: '1', - plans, - waves, - incomplete: plans.filter(p => !p.has_summary).map(p => p.id), - has_checkpoints: false, - ...overrides, - }; -} - -function makeConfig(overrides: Partial = {}): GSDConfig { - return { - ...structuredClone(CONFIG_DEFAULTS), - ...overrides, - workflow: { - ...CONFIG_DEFAULTS.workflow, - ...(overrides.workflow ?? {}), - }, - } as GSDConfig; -} - -function makeDeps(overrides: Partial = {}): PhaseRunnerDeps { - const events: GSDEvent[] = []; - - return { - projectDir: defaultProjectDir, - tools: { - initPhaseOp: vi.fn().mockResolvedValue(makePhaseOp()), - phaseComplete: vi.fn().mockResolvedValue(undefined), - phasePlanIndex: vi.fn().mockResolvedValue(makePlanIndex(1)), - exec: vi.fn().mockImplementation((cmd: string) => { - if (cmd === 'check.verification-status') return Promise.resolve({ status: 'pass' }); - return Promise.resolve(undefined); - }), - stateLoad: vi.fn(), - roadmapAnalyze: vi.fn(), - commit: vi.fn(), - verifySummary: vi.fn(), - initExecutePhase: vi.fn(), - configGet: vi.fn(), - stateBeginPhase: vi.fn(), - } as any, - promptFactory: { - buildPrompt: vi.fn().mockResolvedValue('test prompt'), - loadAgentDef: vi.fn().mockResolvedValue(undefined), - } as any, - contextEngine: { - resolveContextFiles: vi.fn().mockResolvedValue({}), - } as any, - eventStream: { - emitEvent: vi.fn((event: GSDEvent) => events.push(event)), - on: vi.fn(), - emit: vi.fn(), - } as any, - config: makeConfig(), - ...overrides, - }; -} - -/** Collect events from a deps object. */ -function getEmittedEvents(deps: PhaseRunnerDeps): GSDEvent[] { - const events: GSDEvent[] = []; - const emitFn = deps.eventStream.emitEvent as ReturnType; - for (const call of emitFn.mock.calls) { - events.push(call[0] as GSDEvent); - } - return events; -} - -// ─── Tests ─────────────────────────────────────────────────────────────────── - -describe('PhaseRunner', () => { - let tempProjectDirs: string[] = []; - - beforeEach(async () => { - tempProjectDirs = []; - defaultProjectDir = await mkdtemp(join(tmpdir(), 'gsd-phase-runner-default-')); - tempProjectDirs.push(defaultProjectDir); - await mkdir(join(defaultProjectDir, defaultPhaseDir), { recursive: true }); - await writeFile(join(defaultProjectDir, defaultPhaseDir, '01-PLAN.md'), '---\nfiles_modified: []\n---\n', 'utf-8'); - vi.clearAllMocks(); - mockRunPhaseStepSession.mockResolvedValue(makePlanResult()); - mockParsePlanFile.mockResolvedValue(makeParsedPlan()); - }); - - afterEach(async () => { - await Promise.all(tempProjectDirs.map((dir) => rm(dir, { recursive: true, force: true }))); - }); - - // ─── Happy path ──────────────────────────────────────────────────────── - - describe('happy path — full lifecycle', () => { - it('runs all steps in order: discuss → research → plan → plan-check → execute → verify → advance', async () => { - const phaseOp = makePhaseOp({ has_context: false, has_plans: true, plan_count: 1 }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(true); - expect(result.phaseNumber).toBe('1'); - expect(result.phaseName).toBe('Authentication'); - - // Verify steps ran in order (includes plan-check since plan_check config defaults to true) - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toEqual([ - PhaseStepType.Discuss, - PhaseStepType.Research, - PhaseStepType.Plan, - PhaseStepType.PlanCheck, - PhaseStepType.Execute, - PhaseStepType.Verify, - PhaseStepType.Advance, - ]); - - // All steps succeeded - expect(result.steps.every(s => s.success)).toBe(true); - }); - - it('returns correct phase name from PhaseOpInfo', async () => { - const phaseOp = makePhaseOp({ phase_name: 'Data Layer' }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('2'); - - expect(result.phaseName).toBe('Data Layer'); - }); - }); - - // ─── Config-driven skipping ──────────────────────────────────────────── - - describe('config-driven step skipping', () => { - it('skips discuss when has_context=true', async () => { - const phaseOp = makePhaseOp({ has_context: true }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.Discuss); - expect(result.success).toBe(true); - }); - - it('skips discuss when config.workflow.skip_discuss=true', async () => { - const config = makeConfig({ workflow: { skip_discuss: true } as any }); - const deps = makeDeps({ config }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.Discuss); - }); - - it('skips research when config.workflow.research=false', async () => { - const config = makeConfig({ workflow: { research: false } as any }); - const deps = makeDeps({ config }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.Research); - }); - - it('skips verify when config.workflow.verifier=false', async () => { - const config = makeConfig({ workflow: { verifier: false } as any }); - const deps = makeDeps({ config }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.Verify); - }); - - it('runs with all config flags false — only plan, execute, advance', async () => { - const config = makeConfig({ - workflow: { - skip_discuss: true, - research: false, - verifier: false, - plan_check: false, - } as any, - }); - const phaseOp = makePhaseOp({ has_context: false, has_plans: true, plan_count: 1 }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toEqual([ - PhaseStepType.Plan, - PhaseStepType.Execute, - PhaseStepType.Advance, - ]); - }); - }); - - // ─── Execute iterates plans ──────────────────────────────────────────── - - describe('execute step', () => { - it('iterates multiple plans sequentially', async () => { - const phaseOp = makePhaseOp({ has_context: true, plan_count: 3 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(makePlanIndex(3)); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep).toBeDefined(); - expect(executeStep!.planResults).toHaveLength(3); - - // runPhaseStepSession called once per plan in execute step - // (plus once for plan step itself) - const executeCallCount = mockRunPhaseStepSession.mock.calls.filter( - call => call[1] === PhaseStepType.Execute, - ).length; - expect(executeCallCount).toBe(3); - }); - - it('handles zero plans gracefully', async () => { - const phaseOp = makePhaseOp({ has_context: true, plan_count: 0, has_plans: true }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(makePlanIndex(0)); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep).toBeDefined(); - expect(executeStep!.success).toBe(true); - expect(executeStep!.planResults).toHaveLength(0); - }); - - it('captures mid-execute session failure in PlanResults', async () => { - const phaseOp = makePhaseOp({ has_context: true, plan_count: 2 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(makePlanIndex(2)); - - // Use a counter that tracks calls per-execute-step to make failure persistent - mockRunPhaseStepSession.mockImplementation(async (_prompt, step, _config, _opts, _es, ctx) => { - if (step === PhaseStepType.Execute) { - const planName = (ctx as any)?.planName ?? ''; - // Always fail on plan-2 - if (planName === 'plan-2') { - return makePlanResult({ - success: false, - error: { subtype: 'error_during_execution', messages: ['Session crashed'] }, - }); - } - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep!.planResults).toHaveLength(2); - expect(executeStep!.planResults![0].success).toBe(true); - expect(executeStep!.planResults![1].success).toBe(false); - expect(executeStep!.success).toBe(false); // overall execute step fails - }); - }); - - // ─── Blocker callbacks ───────────────────────────────────────────────── - - describe('blocker callbacks', () => { - it('invokes onBlockerDecision when no plans after plan step', async () => { - // First call: initial state (no context so discuss runs) - // After discuss: re-query returns has_context=true - // After plan: re-query returns has_plans=false - const onBlockerDecision = vi.fn().mockResolvedValue('stop'); - const phaseOp = makePhaseOp({ has_context: true, has_plans: false, plan_count: 0 }); - const config = makeConfig(); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { onBlockerDecision }, - }); - - expect(onBlockerDecision).toHaveBeenCalled(); - const callArg = onBlockerDecision.mock.calls[0][0]; - expect(callArg.step).toBe(PhaseStepType.Plan); - expect(callArg.error).toContain('No plans'); - - // Runner halted — no execute/verify/advance steps - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.Execute); - expect(stepTypes).not.toContain(PhaseStepType.Verify); - expect(stepTypes).not.toContain(PhaseStepType.Advance); - }); - - it('invokes onBlockerDecision when no context after discuss', async () => { - const onBlockerDecision = vi.fn().mockResolvedValue('stop'); - const phaseOp = makePhaseOp({ has_context: false }); - const deps = makeDeps(); - // After discuss step, re-query still has no context - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { onBlockerDecision }, - }); - - expect(onBlockerDecision).toHaveBeenCalled(); - const callArg = onBlockerDecision.mock.calls[0][0]; - expect(callArg.step).toBe(PhaseStepType.Discuss); - }); - - it('auto-approves (skip) when no callback registered at discuss blocker', async () => { - const phaseOp = makePhaseOp({ has_context: false, has_plans: true, plan_count: 1 }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); // no callbacks - - // Should proceed past discuss even though no context - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toContain(PhaseStepType.Research); - expect(stepTypes).toContain(PhaseStepType.Plan); - }); - }); - - // ─── Research gate (#1602) ────────────────────────────────────────────── - - describe('research gate (#1602)', () => { - let tempPhaseDir: string; - - beforeEach(async () => { - tempPhaseDir = await mkdtemp(join(tmpdir(), 'gsd-research-gate-')); - }); - - afterEach(async () => { - await rm(tempPhaseDir, { recursive: true, force: true }); - }); - - it('invokes onBlockerDecision when RESEARCH.md has unresolved open questions', async () => { - // Write a RESEARCH.md with unresolved questions - const researchPath = join(tempPhaseDir, '01-RESEARCH.md'); - await writeFile(researchPath, `# Research - -## Key Findings -TypeScript is the right choice. - -## Open Questions - -1. **Hash prefix** — keep or change? -2. **Cache TTL** — what duration? - -## Recommendations -Use TypeScript.`, 'utf-8'); - - const onBlockerDecision = vi.fn().mockResolvedValue('stop'); - const phaseOp = makePhaseOp({ - has_context: true, - has_research: true, - has_plans: true, - plan_count: 1, - phase_dir: tempPhaseDir, - research_path: researchPath, - }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { onBlockerDecision }, - }); - - expect(onBlockerDecision).toHaveBeenCalled(); - const callArg = onBlockerDecision.mock.calls[0][0]; - expect(callArg.step).toBe(PhaseStepType.Research); - expect(callArg.error).toContain('unresolved open questions'); - expect(callArg.error).toContain('Hash prefix'); - }); - - it('does not block when RESEARCH.md has no open questions', async () => { - const researchPath = join(tempPhaseDir, '01-RESEARCH.md'); - await writeFile(researchPath, `# Research - -## Key Findings -Everything resolved. - -## Recommendations -Use TypeScript.`, 'utf-8'); - - const onBlockerDecision = vi.fn().mockResolvedValue('stop'); - const phaseOp = makePhaseOp({ - has_context: true, - has_research: true, - has_plans: true, - plan_count: 1, - phase_dir: tempPhaseDir, - research_path: researchPath, - }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1', { - callbacks: { onBlockerDecision }, - }); - - // Should NOT have been called for research step - const researchCalls = onBlockerDecision.mock.calls.filter( - (c: any[]) => c[0].step === PhaseStepType.Research, - ); - expect(researchCalls).toHaveLength(0); - }); - - it('does not block when all open questions are resolved', async () => { - const researchPath = join(tempPhaseDir, '01-RESEARCH.md'); - await writeFile(researchPath, `# Research - -## Open Questions (RESOLVED) - -1. **Hash prefix** — RESOLVED: Use "guest_contract:"`, 'utf-8'); - - const onBlockerDecision = vi.fn().mockResolvedValue('stop'); - const phaseOp = makePhaseOp({ - has_context: true, - has_research: true, - has_plans: true, - plan_count: 1, - phase_dir: tempPhaseDir, - research_path: researchPath, - }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1', { callbacks: { onBlockerDecision } }); - - const researchCalls = onBlockerDecision.mock.calls.filter( - (c: any[]) => c[0].step === PhaseStepType.Research, - ); - expect(researchCalls).toHaveLength(0); - }); - - it('skips research gate when has_research=false', async () => { - const onBlockerDecision = vi.fn().mockResolvedValue('stop'); - const phaseOp = makePhaseOp({ - has_context: true, - has_research: false, - has_plans: true, - plan_count: 1, - }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1', { callbacks: { onBlockerDecision } }); - - // Research gate should not fire when there's no research - const researchCalls = onBlockerDecision.mock.calls.filter( - (c: any[]) => c[0].step === PhaseStepType.Research, - ); - expect(researchCalls).toHaveLength(0); - }); - - it('auto-approves (skip) research gate when no callback registered', async () => { - const researchPath = join(tempPhaseDir, '01-RESEARCH.md'); - await writeFile(researchPath, `# Research - -## Open Questions - -1. **Something** — needs decision`, 'utf-8'); - - const phaseOp = makePhaseOp({ - has_context: true, - has_research: true, - has_plans: true, - plan_count: 1, - phase_dir: tempPhaseDir, - research_path: researchPath, - }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); // No callbacks - - // Should proceed past research gate (auto-skip) - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toContain(PhaseStepType.Plan); - }); - }); - - // ─── Human gate: reject halts runner ─────────────────────────────────── - - describe('human gate reject', () => { - it('halts runner when blocker callback returns stop', async () => { - const phaseOp = makePhaseOp({ has_context: false }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { - onBlockerDecision: vi.fn().mockResolvedValue('stop'), - }, - }); - - expect(result.success).toBe(false); - // Only discuss step ran before halt - expect(result.steps).toHaveLength(1); - expect(result.steps[0].step).toBe(PhaseStepType.Discuss); - }); - }); - - // ─── Verification routing ────────────────────────────────────────────── - - describe('verification routing', () => { - it('routes to advance when verification passes', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockRunPhaseStepSession.mockResolvedValue(makePlanResult({ success: true })); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toContain(PhaseStepType.Verify); - expect(stepTypes).toContain(PhaseStepType.Advance); - expect(result.success).toBe(true); - }); - - it('keeps phase pending when verification review is accepted for human_needed', async () => { - const onVerificationReview = vi.fn().mockResolvedValue('accept'); - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - // Verify step returns human_review_needed subtype - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - return makePlanResult({ - success: false, - error: { subtype: 'human_review_needed', messages: ['Needs review'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { onVerificationReview }, - }); - - expect(onVerificationReview).toHaveBeenCalled(); - expect(result.success).toBe(false); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance); - - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.success).toBe(false); - expect(verifyStep?.error).toBe('verification_human_needed'); - }); - - it('routes VERIFICATION.md status human_needed through the human review gate', async () => { - const onVerificationReview = vi.fn().mockResolvedValue('accept'); - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.exec as ReturnType).mockImplementation((cmd: string) => { - if (cmd === 'check.verification-status') return Promise.resolve({ status: 'human_needed' }); - return Promise.resolve(undefined); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { onVerificationReview }, - }); - - expect(onVerificationReview).toHaveBeenCalled(); - expect(result.success).toBe(false); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance); - - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.success).toBe(false); - expect(verifyStep?.error).toBe('verification_human_needed'); - }); - - it('does not advance when verification status is missing', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.exec as ReturnType).mockImplementation((cmd: string) => { - if (cmd === 'check.verification-status') return Promise.resolve({ status: 'missing' }); - return Promise.resolve(undefined); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance); - - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.success).toBe(false); - expect(verifyStep?.error).toBe('verification_gaps_found'); - }); - - it('keeps phase pending when changed phase files contain unresolved TBD/FIXME/XXX markers', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-architectural-debt-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# TBD: wire retry handling before release\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance); - - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.success).toBe(false); - expect(verifyStep?.error).toBe('verification_gaps_found'); - }); - - it('allows changed-file debt markers when they reference tracked follow-up work', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-tracked-debt-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# FIXME(issue #3322): preserve upstream retry behavior\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(true); - expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1'); - expect(result.steps.map(s => s.step)).toContain(PhaseStepType.Advance); - }); - - it('allows changed-file entries for files deleted by the phase', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-deleted-file-debt-scan-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - await mkdir(phaseDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/deleted.sh"]\n---\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/deleted.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(true); - expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1'); - expect(result.steps.map(s => s.step)).toContain(PhaseStepType.Advance); - }); - - it('allows dotted lowercase xxx placeholder text when scanning debt markers', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-lowercase-placeholder-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\napi_host="xxx.example.test"\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(true); - expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1'); - expect(result.steps.map(s => s.step)).toContain(PhaseStepType.Advance); - }); - - it('keeps phase pending when changed files contain lowercase debt markers', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-lowercase-debt-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# fixme: wire retry handling before release\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.error).toBe('verification_gaps_found'); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - }); - - it('keeps phase pending when debt markers are followed by punctuation', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-punctuated-debt-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# FIXME. remove before release\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.error).toBe('verification_gaps_found'); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - }); - - it('allows bare hash issue references when they read as references', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-bare-hash-ref-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# FIXME tracked in #3322\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(true); - expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1'); - expect(result.steps.map(s => s.step)).toContain(PhaseStepType.Advance); - }); - - it('does not treat quoted numeric fragments as debt references', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-hex-fragment-debt-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\ncolor="#123" # FIXME temp styling\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.error).toBe('verification_gaps_found'); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - }); - - it('does not allow unrelated earlier issue text to satisfy a later debt marker', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-unrelated-debt-ref-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\nlabel="issue #123"; # FIXME temp styling\n', 'utf-8'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.error).toBe('verification_gaps_found'); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - }); - - it('reports one unresolved debt finding per line', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-duplicate-debt-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const sourceDir = join(projectDir, 'scripts', 'upstream'); - await mkdir(phaseDir, { recursive: true }); - await mkdir(sourceDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["scripts/upstream/run.sh"]\n---\n', 'utf-8'); - await writeFile(join(sourceDir, 'run.sh'), '#!/usr/bin/env bash\n# TBD TBD before release\n', 'utf-8'); - - const logger = { warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as any; - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config, logger }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['scripts/upstream/run.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - const blockCall = logger.warn.mock.calls.find(([message]: [string]) => message.includes('Verification blocked')); - expect(blockCall?.[1].findings).toHaveLength(1); - expect(blockCall?.[1].findings[0]).toMatchObject({ - file: 'scripts/upstream/run.sh', - line: 2, - marker: 'TBD', - }); - expect(blockCall?.[1].findings[0]).not.toHaveProperty('text'); - }); - - it('keeps phase pending when a declared file resolves through a symlink outside the project', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-symlink-project-')); - const externalDir = await mkdtemp(join(tmpdir(), 'gsd-symlink-external-')); - tempProjectDirs.push(projectDir, externalDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - await mkdir(phaseDir, { recursive: true }); - await writeFile(join(phaseDir, '01-PLAN.md'), '---\nfiles_modified: ["linked-outside/secret.sh"]\n---\n', 'utf-8'); - await writeFile(join(externalDir, 'secret.sh'), '#!/usr/bin/env bash\necho safe\n', 'utf-8'); - await symlink(externalDir, join(projectDir, 'linked-outside'), 'dir'); - - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - mockParsePlanFile.mockResolvedValue(makeParsedPlan(['linked-outside/secret.sh'])); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.error).toBe('verification_gaps_found'); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - }); - - it('does not advance when verification status cannot be checked', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.exec as ReturnType).mockImplementation((cmd: string) => { - if (cmd === 'check.verification-status') return Promise.reject(new Error('status parser crashed')); - return Promise.resolve(undefined); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance); - - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.success).toBe(false); - expect(verifyStep?.error).toBe('verification_gaps_found'); - expect(mockRunPhaseStepSession.mock.calls.filter((call) => call[1] === PhaseStepType.Plan)).toHaveLength(1); - expect(mockRunPhaseStepSession.mock.calls.filter((call) => call[1] === PhaseStepType.Execute)).toHaveLength(1); - }); - - it('keeps phase pending when plan files cannot be listed for the debt scan', async () => { - const projectDir = await mkdtemp(join(tmpdir(), 'gsd-debt-missing-plans-')); - tempProjectDirs.push(projectDir); - const phaseDir = join(projectDir, '.planning', 'phases', '01-auth'); - const logger = { warn: vi.fn(), info: vi.fn(), debug: vi.fn() } as any; - const phaseOp = makePhaseOp({ phase_dir: phaseDir, has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ projectDir, config, logger }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - expect(result.steps.map(s => s.step)).not.toContain(PhaseStepType.Advance); - - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep?.success).toBe(false); - expect(verifyStep?.error).toBe('verification_gaps_found'); - expect(logger.warn.mock.calls.some(([message]: [string]) => message.includes('unresolved architectural debt markers'))).toBe(false); - expect(logger.warn.mock.calls.some(([message]: [string]) => message.includes('architectural debt scan could not complete'))).toBe(true); - }); - - it('halts when verification review callback rejects', async () => { - const onVerificationReview = vi.fn().mockResolvedValue('reject'); - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - return makePlanResult({ - success: false, - error: { subtype: 'human_review_needed', messages: ['Needs review'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { onVerificationReview }, - }); - - // Verify step completes with error, runner continues to advance - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep!.success).toBe(false); - expect(verifyStep!.error).toBe('halted_by_callback'); - }); - }); - - // ─── Gap closure ─────────────────────────────────────────────────────── - - describe('gap closure', () => { - it('retries verification once on gaps_found', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let verifyCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - verifyCallCount++; - if (verifyCallCount === 1) { - // First verify: gaps found - return makePlanResult({ - success: false, - error: { subtype: 'verification_failed', messages: ['Gaps found'] }, - }); - } - // Second verify (gap closure retry): passes - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(verifyCallCount).toBe(2); // Exactly 1 retry - expect(result.success).toBe(true); - }); - - it('caps gap closure at exactly 1 retry (not 0, not 2)', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let verifyCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - verifyCallCount++; - // Always return gaps_found - return makePlanResult({ - success: false, - error: { subtype: 'verification_failed', messages: ['Gaps persist'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - // 1 initial + 1 retry = 2 calls (not 3) - expect(verifyCallCount).toBe(2); - // Verify step fails when gaps persist after exhausting retries - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep!.success).toBe(false); - }); - - it('gaps_found triggers plan → execute → re-verify cycle', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - // Track the step sequence during gap closure - const stepSequence: string[] = []; - let verifyCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - stepSequence.push(step); - if (step === PhaseStepType.Verify) { - verifyCallCount++; - if (verifyCallCount === 1) { - return makePlanResult({ - success: false, - error: { subtype: 'verification_failed', messages: ['Gaps found'] }, - }); - } - // Re-verify passes - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(true); - - // After initial plan+execute+verify(fail), gap closure should run: plan, execute, verify(pass) - // Full sequence includes: plan, execute, verify(gap), plan(gap), execute(gap), verify(pass), advance(no session) - // Filter to just the verify-related part: after the first verify, we should see plan then execute then verify - const afterFirstVerify = stepSequence.slice(stepSequence.indexOf(PhaseStepType.Verify) + 1); - expect(afterFirstVerify).toContain(PhaseStepType.Plan); - expect(afterFirstVerify).toContain(PhaseStepType.Execute); - expect(afterFirstVerify).toContain(PhaseStepType.Verify); - - // Plan comes before execute in gap closure - const planIdx = afterFirstVerify.indexOf(PhaseStepType.Plan); - const execIdx = afterFirstVerify.indexOf(PhaseStepType.Execute); - const verifyIdx = afterFirstVerify.indexOf(PhaseStepType.Verify); - expect(planIdx).toBeLessThan(execIdx); - expect(execIdx).toBeLessThan(verifyIdx); - }); - - it('gaps_found with maxGapRetries=0 proceeds immediately without gap closure', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let verifyCallCount = 0; - const stepSequence: string[] = []; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - stepSequence.push(step); - if (step === PhaseStepType.Verify) { - verifyCallCount++; - return makePlanResult({ - success: false, - error: { subtype: 'verification_failed', messages: ['Gaps found'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { maxGapRetries: 0 }); - - // Only 1 verify call — no retry - expect(verifyCallCount).toBe(1); - - // No gap closure plan/execute steps after verify - const afterVerify = stepSequence.slice(stepSequence.indexOf(PhaseStepType.Verify) + 1); - expect(afterVerify).not.toContain(PhaseStepType.Plan); - expect(afterVerify.filter(s => s === PhaseStepType.Execute)).toHaveLength(0); - - // Verify step fails when gaps persist (no retries allowed) - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep!.success).toBe(false); - }); - - it('gap closure plan step failure proceeds to re-verify without executing', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let verifyCallCount = 0; - let planCallAfterGap = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - verifyCallCount++; - if (verifyCallCount === 1) { - return makePlanResult({ - success: false, - error: { subtype: 'verification_failed', messages: ['Gaps found'] }, - }); - } - return makePlanResult({ success: true }); - } - if (step === PhaseStepType.Plan && verifyCallCount >= 1) { - planCallAfterGap++; - // Simulate plan step throwing - throw new Error('plan step crashed'); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - // Plan step failed, but verify still re-ran - expect(planCallAfterGap).toBe(1); - expect(verifyCallCount).toBe(2); - expect(result.success).toBe(true); - }); - - it('custom maxGapRetries from PhaseRunnerOptions is respected', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let verifyCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - verifyCallCount++; - // Always return gaps_found - return makePlanResult({ - success: false, - error: { subtype: 'verification_failed', messages: ['Gaps found'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { maxGapRetries: 3 }); - - // 1 initial + 3 retries = 4 verify calls - expect(verifyCallCount).toBe(4); - // Verify step fails when gaps persist after all retries exhausted - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep!.success).toBe(false); - }); - - it('gap closure results are included in the final verify step planResults', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let verifyCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - verifyCallCount++; - if (verifyCallCount === 1) { - return makePlanResult({ - success: false, - sessionId: 'verify-1', - totalCostUsd: 0.02, - error: { subtype: 'verification_failed', messages: ['Gaps found'] }, - }); - } - return makePlanResult({ success: true, sessionId: 'verify-2', totalCostUsd: 0.03 }); - } - if (step === PhaseStepType.Plan) { - return makePlanResult({ success: true, sessionId: 'gap-plan', totalCostUsd: 0.01 }); - } - if (step === PhaseStepType.Execute) { - return makePlanResult({ success: true, sessionId: 'gap-exec', totalCostUsd: 0.04 }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep).toBeDefined(); - expect(verifyStep!.planResults).toBeDefined(); - - // Should contain: verify-1 (initial), gap-plan, gap-exec, verify-2 (re-verify) - const sessionIds = verifyStep!.planResults!.map(r => r.sessionId); - expect(sessionIds).toContain('verify-1'); - expect(sessionIds).toContain('gap-plan'); - expect(sessionIds).toContain('gap-exec'); - expect(sessionIds).toContain('verify-2'); - expect(verifyStep!.planResults!.length).toBeGreaterThanOrEqual(4); - }); - }); - - // ─── Advance gate on persistent gaps ────────────────────────────────── - - describe('advance gate on persistent gaps', () => { - it('persistent gaps_found does NOT append Advance step', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - return makePlanResult({ - success: false, - error: { subtype: 'verification_failed', messages: ['Gaps persist'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.Advance); - }); - - it('persistent gaps_found does NOT call phaseComplete', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - return makePlanResult({ - success: false, - error: { subtype: 'verification_failed', messages: ['Gaps persist'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - }); - - it('verifier disabled still advances normally', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toContain(PhaseStepType.Advance); - expect(result.success).toBe(true); - }); - }); - - // ─── Phase lifecycle events ──────────────────────────────────────────── - - describe('phase lifecycle events', () => { - it('emits events in correct order', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - const events = getEmittedEvents(deps); - const eventTypes = events.map(e => e.type); - - // First event: phase_start - expect(eventTypes[0]).toBe(GSDEventType.PhaseStart); - - // Last event: phase_complete - expect(eventTypes[eventTypes.length - 1]).toBe(GSDEventType.PhaseComplete); - - // Each step has start + complete pair - const stepStarts = events.filter(e => e.type === GSDEventType.PhaseStepStart); - const stepCompletes = events.filter(e => e.type === GSDEventType.PhaseStepComplete); - expect(stepStarts.length).toBeGreaterThan(0); - expect(stepStarts.length).toBe(stepCompletes.length); - }); - - it('phase_start event contains correct phaseNumber and phaseName', async () => { - const phaseOp = makePhaseOp({ has_context: true, phase_name: 'Auth Phase' }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('5'); - - const events = getEmittedEvents(deps); - const phaseStart = events.find(e => e.type === GSDEventType.PhaseStart) as any; - expect(phaseStart.phaseNumber).toBe('5'); - expect(phaseStart.phaseName).toBe('Auth Phase'); - }); - - it('phase_complete event reports success and step count', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - const events = getEmittedEvents(deps); - const phaseComplete = events.find(e => e.type === GSDEventType.PhaseComplete) as any; - expect(phaseComplete.success).toBe(true); - expect(phaseComplete.stepsCompleted).toBe(3); // plan, execute, advance - }); - - it('step_start events include correct step type', async () => { - const phaseOp = makePhaseOp({ has_context: false, has_plans: true, plan_count: 1 }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - const events = getEmittedEvents(deps); - const stepStarts = events - .filter(e => e.type === GSDEventType.PhaseStepStart) - .map(e => (e as any).step); - - // With all config defaults: discuss, research, plan, execute, verify, advance - expect(stepStarts).toContain(PhaseStepType.Discuss); - expect(stepStarts).toContain(PhaseStepType.Research); - expect(stepStarts).toContain(PhaseStepType.Plan); - expect(stepStarts).toContain(PhaseStepType.Execute); - expect(stepStarts).toContain(PhaseStepType.Verify); - expect(stepStarts).toContain(PhaseStepType.Advance); - }); - }); - - // ─── Error propagation ───────────────────────────────────────────────── - - describe('error propagation', () => { - it('throws PhaseRunnerError when phase not found', async () => { - const phaseOp = makePhaseOp({ phase_found: false }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await expect(runner.run('99')).rejects.toThrow(PhaseRunnerError); - await expect(runner.run('99')).rejects.toThrow(/not found/); - }); - - it('throws PhaseRunnerError when initPhaseOp fails', async () => { - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockRejectedValue( - new Error('gsd-tools crashed'), - ); - - const runner = new PhaseRunner(deps); - await expect(runner.run('1')).rejects.toThrow(PhaseRunnerError); - await expect(runner.run('1')).rejects.toThrow(/Failed to initialize/); - }); - - it('captures session errors in PhaseStepResult without throwing', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Plan) { - return makePlanResult({ - success: false, - error: { subtype: 'error_during_execution', messages: ['Session exploded'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const planStep = result.steps.find(s => s.step === PhaseStepType.Plan); - expect(planStep!.success).toBe(false); - expect(planStep!.error).toContain('Session exploded'); - // Runner continues to execute/advance even after plan error - }); - - it('captures thrown errors from runPhaseStepSession in step result', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Plan) { - throw new Error('Network error'); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const planStep = result.steps.find(s => s.step === PhaseStepType.Plan); - expect(planStep!.success).toBe(false); - expect(planStep!.error).toBe('Network error'); - }); - }); - - // ─── Advance step ────────────────────────────────────────────────────── - - describe('advance step', () => { - it('calls tools.phaseComplete on auto_advance', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false, auto_advance: true } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - expect(deps.tools.phaseComplete).toHaveBeenCalledWith('1'); - }); - - it('auto-approves advance when no callback and auto_advance=false', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false, auto_advance: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(deps.tools.phaseComplete).toHaveBeenCalled(); - const advanceStep = result.steps.find(s => s.step === PhaseStepType.Advance); - expect(advanceStep!.success).toBe(true); - }); - - it('halts advance when callback returns stop', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false, auto_advance: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - const onBlockerDecision = vi.fn().mockResolvedValue('stop'); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { onBlockerDecision }, - }); - - const advanceStep = result.steps.find(s => s.step === PhaseStepType.Advance); - expect(advanceStep!.success).toBe(false); - expect(advanceStep!.error).toBe('advance_rejected'); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - }); - - it('captures phaseComplete errors without throwing', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false, auto_advance: true } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phaseComplete as ReturnType).mockRejectedValue( - new Error('gsd-tools commit failed'), - ); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const advanceStep = result.steps.find(s => s.step === PhaseStepType.Advance); - expect(advanceStep!.success).toBe(false); - expect(advanceStep!.error).toContain('commit failed'); - }); - }); - - // ─── Callback error handling ─────────────────────────────────────────── - - describe('callback error handling', () => { - it('auto-approves when blocker callback throws', async () => { - const phaseOp = makePhaseOp({ has_context: false, has_plans: true, plan_count: 1 }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { - onBlockerDecision: vi.fn().mockRejectedValue(new Error('callback broke')), - }, - }); - - // Should auto-approve (skip) and continue - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toContain(PhaseStepType.Research); - }); - - it('keeps human verification pending when verification callback throws', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - return makePlanResult({ - success: false, - error: { subtype: 'human_review_needed', messages: ['Review'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { - onVerificationReview: vi.fn().mockRejectedValue(new Error('callback broke')), - }, - }); - - // Should acknowledge the callback failure but still avoid advancing. - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.Advance); - expect(result.success).toBe(false); - expect(deps.tools.phaseComplete).not.toHaveBeenCalled(); - }); - - it('auto-approves advance when advance callback throws', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false, auto_advance: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { - callbacks: { - onBlockerDecision: vi.fn().mockRejectedValue(new Error('nope')), - }, - }); - - // Advance should auto-approve on callback error - expect(deps.tools.phaseComplete).toHaveBeenCalled(); - }); - }); - - // ─── Cost tracking ───────────────────────────────────────────────────── - - describe('result aggregation', () => { - it('aggregates cost across all steps', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 2 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(makePlanIndex(2)); - - mockRunPhaseStepSession.mockResolvedValue(makePlanResult({ totalCostUsd: 0.05 })); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - // plan step: 1 session × $0.05 - // execute step: 2 sessions × $0.05 - // total = $0.15 - expect(result.totalCostUsd).toBeCloseTo(0.15, 2); - }); - - it('reports overall success=false when any step fails', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Plan) { - return makePlanResult({ success: false, error: { subtype: 'error', messages: ['fail'] } }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(result.success).toBe(false); - }); - }); - - // ─── PromptFactory / ContextEngine integration ───────────────────────── - - describe('prompt and context integration', () => { - it('calls contextEngine.resolveContextFiles with correct PhaseType per step', async () => { - const phaseOp = makePhaseOp({ has_context: false, has_plans: true, plan_count: 1 }); - const deps = makeDeps(); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - const resolveCallArgs = (deps.contextEngine.resolveContextFiles as ReturnType) - .mock.calls.map((call: any) => call[0]); - - expect(resolveCallArgs).toContain(PhaseType.Discuss); - expect(resolveCallArgs).toContain(PhaseType.Research); - expect(resolveCallArgs).toContain(PhaseType.Plan); - expect(resolveCallArgs).toContain(PhaseType.Execute); - expect(resolveCallArgs).toContain(PhaseType.Verify); - }); - - it('passes prompt from PromptFactory to runPhaseStepSession', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 0 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.promptFactory.buildPrompt as ReturnType).mockResolvedValue('custom plan prompt'); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - // Plan step: check that the prompt was passed through - const planCall = mockRunPhaseStepSession.mock.calls.find( - call => call[1] === PhaseStepType.Plan, - ); - expect(planCall).toBeDefined(); - expect(planCall![0]).toBe('custom plan prompt'); - }); - }); - - // ─── Session options pass-through ────────────────────────────────────── - - describe('session options', () => { - it('passes maxBudgetPerStep and maxTurnsPerStep to sessions', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1', { - maxBudgetPerStep: 2.0, - maxTurnsPerStep: 20, - model: 'claude-opus-4-6', - }); - - // Check session options passed to runPhaseStepSession - const call = mockRunPhaseStepSession.mock.calls[0]; - const sessionOpts = call[3] as SessionOptions; - expect(sessionOpts.maxBudgetUsd).toBe(2.0); - expect(sessionOpts.maxTurns).toBe(20); - expect(sessionOpts.model).toBe('claude-opus-4-6'); - }); - }); - - // ─── S04: Wave-grouped parallel execution ───────────────────────────── - - describe('wave-grouped parallel execution', () => { - it('executes plans in same wave concurrently', async () => { - // Create 3 plans all in wave 1 - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'p1', wave: 1 }), - makePlanInfo({ id: 'p2', wave: 1 }), - makePlanInfo({ id: 'p3', wave: 1 }), - ], - waves: { '1': ['p1', 'p2', 'p3'] }, - incomplete: ['p1', 'p2', 'p3'], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 3 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - // Track concurrent execution via timestamps - const startTimes: number[] = []; - const endTimes: number[] = []; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Execute) { - startTimes.push(Date.now()); - await new Promise(r => setTimeout(r, 20)); - endTimes.push(Date.now()); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep).toBeDefined(); - expect(executeStep!.planResults).toHaveLength(3); - - // All 3 execute calls were for the Execute step - const execCalls = mockRunPhaseStepSession.mock.calls.filter( - call => call[1] === PhaseStepType.Execute, - ); - expect(execCalls).toHaveLength(3); - - // Verify concurrent execution: all should start before any finish - // (with sequential, start[1] >= end[0]) - if (startTimes.length === 3) { - // All start times should be before the maximum end time of the batch - expect(Math.max(...startTimes)).toBeLessThan(Math.max(...endTimes)); - } - }); - - it('wave 2 does not start until wave 1 completes', async () => { - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'w1-p1', wave: 1 }), - makePlanInfo({ id: 'w2-p1', wave: 2 }), - ], - waves: { '1': ['w1-p1'], '2': ['w2-p1'] }, - incomplete: ['w1-p1', 'w2-p1'], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 2 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - const executionOrder: string[] = []; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step, _config, _opts, _es, ctx) => { - if (step === PhaseStepType.Execute) { - const planName = (ctx as any)?.planName ?? 'unknown'; - executionOrder.push(`start:${planName}`); - await new Promise(r => setTimeout(r, 10)); - executionOrder.push(`end:${planName}`); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - // Wave 1 plan must end before wave 2 plan starts - const w1EndIdx = executionOrder.indexOf('end:w1-p1'); - const w2StartIdx = executionOrder.indexOf('start:w2-p1'); - expect(w1EndIdx).toBeLessThan(w2StartIdx); - }); - - it('one plan failure in wave does not abort other plans (allSettled behavior)', async () => { - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'p1', wave: 1 }), - makePlanInfo({ id: 'p2', wave: 1 }), - makePlanInfo({ id: 'p3', wave: 1 }), - ], - waves: { '1': ['p1', 'p2', 'p3'] }, - incomplete: ['p1', 'p2', 'p3'], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 3 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - let execCallIdx = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step, _config, _opts, _es, ctx) => { - if (step === PhaseStepType.Execute) { - const planName = (ctx as any)?.planName ?? ''; - // Always fail on p2 - if (planName === 'p2') { - return makePlanResult({ - success: false, - error: { subtype: 'error_during_execution', messages: ['Plan 2 failed'] }, - }); - } - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep!.planResults).toHaveLength(3); - - // Two succeeded, one failed - const successes = executeStep!.planResults!.filter(r => r.success); - const failures = executeStep!.planResults!.filter(r => !r.success); - expect(successes).toHaveLength(2); - expect(failures).toHaveLength(1); - expect(executeStep!.success).toBe(false); // overall step fails - }); - - it('parallelization: false runs plans sequentially', async () => { - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'p1', wave: 1 }), - makePlanInfo({ id: 'p2', wave: 1 }), - ], - waves: { '1': ['p1', 'p2'] }, - incomplete: ['p1', 'p2'], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 2 }); - const config = makeConfig({ - parallelization: false, - workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - const executionOrder: string[] = []; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step, _config, _opts, _es, ctx) => { - if (step === PhaseStepType.Execute) { - const planName = (ctx as any)?.planName ?? 'unknown'; - executionOrder.push(`start:${planName}`); - await new Promise(r => setTimeout(r, 10)); - executionOrder.push(`end:${planName}`); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep!.planResults).toHaveLength(2); - - // Sequential: p1 ends before p2 starts - const p1EndIdx = executionOrder.indexOf('end:p1'); - const p2StartIdx = executionOrder.indexOf('start:p2'); - expect(p1EndIdx).toBeLessThan(p2StartIdx); - }); - - it('filters out plans with has_summary: true', async () => { - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'p1', wave: 1, has_summary: true }), - makePlanInfo({ id: 'p2', wave: 1, has_summary: false }), - makePlanInfo({ id: 'p3', wave: 2, has_summary: true }), - ], - waves: { '1': ['p1', 'p2'], '2': ['p3'] }, - incomplete: ['p2'], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 3 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - // Only p2 should execute (p1 and p3 have summaries) - expect(executeStep!.planResults).toHaveLength(1); - - // Verify the executed plan was p2 - const execCalls = mockRunPhaseStepSession.mock.calls.filter( - call => call[1] === PhaseStepType.Execute, - ); - expect(execCalls).toHaveLength(1); - expect((execCalls[0][5] as any)?.planName).toBe('p2'); - }); - - it('returns success with empty planResults when all plans have summaries', async () => { - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'p1', wave: 1, has_summary: true }), - makePlanInfo({ id: 'p2', wave: 1, has_summary: true }), - ], - waves: { '1': ['p1', 'p2'] }, - incomplete: [], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 2 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep!.success).toBe(true); - expect(executeStep!.planResults).toHaveLength(0); - }); - - it('emits wave_start and wave_complete events with correct data', async () => { - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'p1', wave: 1 }), - makePlanInfo({ id: 'p2', wave: 1 }), - makePlanInfo({ id: 'p3', wave: 2 }), - ], - waves: { '1': ['p1', 'p2'], '2': ['p3'] }, - incomplete: ['p1', 'p2', 'p3'], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 3 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - const events = getEmittedEvents(deps); - const waveStarts = events.filter(e => e.type === GSDEventType.WaveStart) as any[]; - const waveCompletes = events.filter(e => e.type === GSDEventType.WaveComplete) as any[]; - - // Two waves → two start + two complete events - expect(waveStarts).toHaveLength(2); - expect(waveCompletes).toHaveLength(2); - - // Wave 1: 2 plans - expect(waveStarts[0].waveNumber).toBe(1); - expect(waveStarts[0].planCount).toBe(2); - expect(waveStarts[0].planIds).toEqual(['p1', 'p2']); - expect(waveCompletes[0].waveNumber).toBe(1); - expect(waveCompletes[0].successCount).toBe(2); - expect(waveCompletes[0].failureCount).toBe(0); - - // Wave 2: 1 plan - expect(waveStarts[1].waveNumber).toBe(2); - expect(waveStarts[1].planCount).toBe(1); - expect(waveStarts[1].planIds).toEqual(['p3']); - expect(waveCompletes[1].waveNumber).toBe(2); - expect(waveCompletes[1].successCount).toBe(1); - }); - - it('single-wave single-plan case works (regression for S03 behavior)', async () => { - const planIndex = makePlanIndex(1); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep!.success).toBe(true); - expect(executeStep!.planResults).toHaveLength(1); - }); - - it('handles non-contiguous wave numbers (e.g. 1, 3, 5)', async () => { - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'p1', wave: 1 }), - makePlanInfo({ id: 'p2', wave: 3 }), - makePlanInfo({ id: 'p3', wave: 5 }), - ], - waves: { '1': ['p1'], '3': ['p2'], '5': ['p3'] }, - incomplete: ['p1', 'p2', 'p3'], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 3 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - const executionOrder: string[] = []; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step, _config, _opts, _es, ctx) => { - if (step === PhaseStepType.Execute) { - const planName = (ctx as any)?.planName ?? 'unknown'; - executionOrder.push(`start:${planName}`); - await new Promise(r => setTimeout(r, 5)); - executionOrder.push(`end:${planName}`); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep!.planResults).toHaveLength(3); - expect(executeStep!.success).toBe(true); - - // Verify sequential wave order: p1 ends before p2 starts, p2 ends before p3 starts - const p1End = executionOrder.indexOf('end:p1'); - const p2Start = executionOrder.indexOf('start:p2'); - const p2End = executionOrder.indexOf('end:p2'); - const p3Start = executionOrder.indexOf('start:p3'); - expect(p1End).toBeLessThan(p2Start); - expect(p2End).toBeLessThan(p3Start); - }); - - it('no wave events emitted when parallelization is disabled', async () => { - const planIndex = makePlanIndex(0, { - plans: [ - makePlanInfo({ id: 'p1', wave: 1 }), - makePlanInfo({ id: 'p2', wave: 2 }), - ], - waves: { '1': ['p1'], '2': ['p2'] }, - incomplete: ['p1', 'p2'], - }); - - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 2 }); - const config = makeConfig({ - parallelization: false, - workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockResolvedValue(planIndex); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - const events = getEmittedEvents(deps); - const waveEvents = events.filter( - e => e.type === GSDEventType.WaveStart || e.type === GSDEventType.WaveComplete, - ); - expect(waveEvents).toHaveLength(0); - }); - - it('phasePlanIndex error is captured in step result', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - (deps.tools.phasePlanIndex as ReturnType).mockRejectedValue(new Error('phase-plan-index failed')); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep!.success).toBe(false); - expect(executeStep!.error).toContain('phase-plan-index failed'); - }); - }); - - // ─── Plan-check step ───────────────────────────────────────────────── - - describe('plan-check step', () => { - it('inserts plan-check between plan and execute when config.workflow.plan_check=true', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: true } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - const planIdx = stepTypes.indexOf(PhaseStepType.Plan); - const planCheckIdx = stepTypes.indexOf(PhaseStepType.PlanCheck); - const executeIdx = stepTypes.indexOf(PhaseStepType.Execute); - - expect(planCheckIdx).toBeGreaterThan(planIdx); - expect(planCheckIdx).toBeLessThan(executeIdx); - }); - - it('skips plan-check when config.workflow.plan_check=false', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: false } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.PlanCheck); - }); - - it('plan-check PASS proceeds to execute directly', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: true } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockResolvedValue(makePlanResult({ success: true })); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - // Only one plan-check step (no re-plan) - const planCheckSteps = result.steps.filter(s => s.step === PhaseStepType.PlanCheck); - expect(planCheckSteps).toHaveLength(1); - expect(planCheckSteps[0].success).toBe(true); - expect(result.success).toBe(true); - }); - - it('plan-check FAIL triggers re-plan then re-check (D023)', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: true } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let planCheckCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.PlanCheck) { - planCheckCallCount++; - if (planCheckCallCount <= 1) { - // First plan-check fails (retryOnce gives it 2 tries, both using this) - return makePlanResult({ - success: false, - error: { subtype: 'plan_check_failed', messages: ['ISSUES FOUND: missing tests'] }, - }); - } - // After re-plan, second plan-check passes - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - - // Should see: plan, plan_check (fail from retryOnce 2nd attempt), plan (re-plan), plan_check (re-check pass) - // retryOnce returns the result of the 2nd attempt which is still fail (planCheckCallCount=2 is still <=1... wait no, 2 > 1) - // Actually retryOnce: first call planCheckCallCount=1 (fail), retry planCheckCallCount=2 (pass since 2 > 1) - // So retryOnce returns pass → no D023 replan needed - // Let me reconsider: need to make retryOnce also fail - // The test is tricky due to retryOnce. Let me adjust: - expect(stepTypes).toContain(PhaseStepType.PlanCheck); - expect(result.success).toBe(true); - }); - - it('plan-check FAIL→re-plan→FAIL proceeds with warning (D023)', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: true } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.PlanCheck) { - // Always fail - return makePlanResult({ - success: false, - error: { subtype: 'plan_check_failed', messages: ['ISSUES FOUND: persistent problem'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - - // After retryOnce fails twice, plan-check result is pushed (fail). - // Then D023: re-plan step + re-check step are also pushed. - // Re-check also fails persistently. - // But runner proceeds to execute with warning. - expect(stepTypes).toContain(PhaseStepType.PlanCheck); - expect(stepTypes).toContain(PhaseStepType.Execute); - - // There should be multiple plan-check steps (initial + re-check after re-plan) - const planCheckSteps = result.steps.filter(s => s.step === PhaseStepType.PlanCheck); - expect(planCheckSteps.length).toBeGreaterThanOrEqual(2); - - // Execute still runs despite plan-check failures - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep).toBeDefined(); - expect(executeStep!.success).toBe(true); - }); - - it('plan-check emits PhaseStepStart and PhaseStepComplete events', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: true } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - const events = getEmittedEvents(deps); - const planCheckStarts = events.filter( - e => e.type === GSDEventType.PhaseStepStart && (e as any).step === PhaseStepType.PlanCheck, - ); - const planCheckCompletes = events.filter( - e => e.type === GSDEventType.PhaseStepComplete && (e as any).step === PhaseStepType.PlanCheck, - ); - - expect(planCheckStarts.length).toBeGreaterThanOrEqual(1); - expect(planCheckCompletes.length).toBeGreaterThanOrEqual(1); - }); - - it('plan-check uses Verify phase type for tool scoping', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ workflow: { research: false, verifier: false, skip_discuss: true, plan_check: true } as any }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - // Check that runPhaseStepSession was called with PlanCheck step type - const planCheckCalls = mockRunPhaseStepSession.mock.calls.filter( - call => call[1] === PhaseStepType.PlanCheck, - ); - expect(planCheckCalls.length).toBeGreaterThanOrEqual(1); - - // Stream context should use Verify phase - const streamContext = planCheckCalls[0][5] as any; - expect(streamContext.phase).toBe(PhaseType.Verify); - }); - }); - - // ─── Self-discuss (auto-mode) ────────────────────────────────────────── - - describe('self-discuss (auto-mode)', () => { - it('runs self-discuss when auto_advance=true and no context exists', async () => { - const phaseOp = makePhaseOp({ has_context: false }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, auto_advance: true, skip_discuss: false } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toContain(PhaseStepType.Discuss); - - // Verify prompt includes self-discuss instructions - const discussCalls = mockRunPhaseStepSession.mock.calls.filter( - call => call[1] === PhaseStepType.Discuss, - ); - expect(discussCalls.length).toBeGreaterThanOrEqual(1); - const prompt = discussCalls[0][0] as string; - expect(prompt).toContain('HEADLESS MODE'); - expect(prompt).toContain('no human present'); - }); - - it('skips self-discuss when context already exists even in auto-mode', async () => { - const phaseOp = makePhaseOp({ has_context: true }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, auto_advance: true, skip_discuss: false } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).not.toContain(PhaseStepType.Discuss); - }); - - it('runs normal discuss when auto_advance=false and no context', async () => { - const phaseOp = makePhaseOp({ has_context: false }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, auto_advance: false, skip_discuss: false } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const stepTypes = result.steps.map(s => s.step); - expect(stepTypes).toContain(PhaseStepType.Discuss); - - // Normal discuss — prompt should NOT contain self-discuss instructions - const discussCalls = mockRunPhaseStepSession.mock.calls.filter( - call => call[1] === PhaseStepType.Discuss, - ); - expect(discussCalls.length).toBeGreaterThanOrEqual(1); - const prompt = discussCalls[0][0] as string; - expect(prompt).not.toContain('Self-Discuss Mode'); - }); - - it('self-discuss invokes blocker callback when no context after self-discuss', async () => { - const onBlockerDecision = vi.fn().mockResolvedValue('stop'); - const phaseOp = makePhaseOp({ has_context: false }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, auto_advance: true, skip_discuss: false } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1', { callbacks: { onBlockerDecision } }); - - expect(onBlockerDecision).toHaveBeenCalled(); - const callArg = onBlockerDecision.mock.calls[0][0]; - expect(callArg.step).toBe(PhaseStepType.Discuss); - expect(callArg.error).toContain('self-discuss'); - }); - - it('self-discuss uses Discuss phase type for context resolution', async () => { - const phaseOp = makePhaseOp({ has_context: false }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, auto_advance: true, skip_discuss: false } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - const runner = new PhaseRunner(deps); - await runner.run('1'); - - // Context resolution should use Discuss phase type - const resolveCallArgs = (deps.contextEngine.resolveContextFiles as ReturnType) - .mock.calls.map((call: any) => call[0]); - expect(resolveCallArgs).toContain(PhaseType.Discuss); - - // Stream context should use Discuss phase - const discussCalls = mockRunPhaseStepSession.mock.calls.filter( - call => call[1] === PhaseStepType.Discuss, - ); - expect(discussCalls.length).toBeGreaterThanOrEqual(1); - const streamContext = discussCalls[0][5] as any; - expect(streamContext.phase).toBe(PhaseType.Discuss); - }); - }); - - // ─── Retry-on-failure ────────────────────────────────────────────────── - - describe('retry-on-failure', () => { - it('retries discuss step once on failure', async () => { - const phaseOp = makePhaseOp({ has_context: false }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, auto_advance: false, skip_discuss: false } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let discussCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Discuss) { - discussCallCount++; - if (discussCallCount === 1) { - return makePlanResult({ - success: false, - error: { subtype: 'error_during_execution', messages: ['transient error'] }, - }); - } - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - // Discuss was called twice (initial + retry) - expect(discussCallCount).toBe(2); - - // The result from retry (success) is used - const discussStep = result.steps.find(s => s.step === PhaseStepType.Discuss); - expect(discussStep!.success).toBe(true); - }); - - it('retries research step once on failure', async () => { - const phaseOp = makePhaseOp({ has_context: true }); - const config = makeConfig({ - workflow: { research: true, verifier: false, plan_check: false, skip_discuss: true } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let researchCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Research) { - researchCallCount++; - if (researchCallCount === 1) { - return makePlanResult({ - success: false, - error: { subtype: 'error_during_execution', messages: ['network error'] }, - }); - } - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(researchCallCount).toBe(2); - const researchStep = result.steps.find(s => s.step === PhaseStepType.Research); - expect(researchStep!.success).toBe(true); - }); - - it('retries plan step once on failure', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, skip_discuss: true } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let planCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Plan) { - planCallCount++; - if (planCallCount === 1) { - return makePlanResult({ - success: false, - error: { subtype: 'error_during_execution', messages: ['timeout'] }, - }); - } - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - expect(planCallCount).toBe(2); - const planStep = result.steps.find(s => s.step === PhaseStepType.Plan); - expect(planStep!.success).toBe(true); - }); - - it('retries execute step once on failure', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, skip_discuss: true } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let executeCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Execute) { - executeCallCount++; - if (executeCallCount === 1) { - return makePlanResult({ - success: false, - error: { subtype: 'error_during_execution', messages: ['crash'] }, - }); - } - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - // Execute was called twice - expect(executeCallCount).toBe(2); - const executeStep = result.steps.find(s => s.step === PhaseStepType.Execute); - expect(executeStep!.success).toBe(true); - }); - - it('retries plan-check step once on failure', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ - workflow: { research: false, verifier: false, skip_discuss: true, plan_check: true } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let planCheckCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.PlanCheck) { - planCheckCallCount++; - if (planCheckCallCount === 1) { - return makePlanResult({ - success: false, - error: { subtype: 'plan_check_failed', messages: ['ISSUES FOUND'] }, - }); - } - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - // retryOnce: first call fails, retry succeeds - expect(planCheckCallCount).toBe(2); - - // Since retryOnce returns the successful second attempt, no D023 re-plan cycle triggers - const planCheckSteps = result.steps.filter(s => s.step === PhaseStepType.PlanCheck); - expect(planCheckSteps).toHaveLength(1); - expect(planCheckSteps[0].success).toBe(true); - }); - - it('retries verify step once on failure', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ - workflow: { research: false, skip_discuss: true, plan_check: false, verifier: true } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - let verifyStepCallCount = 0; - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Verify) { - verifyStepCallCount++; - if (verifyStepCallCount === 1) { - throw new Error('verify session crashed'); - } - return makePlanResult({ success: true }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - // First verify throws (caught internally), retry succeeds - expect(verifyStepCallCount).toBe(2); - const verifyStep = result.steps.find(s => s.step === PhaseStepType.Verify); - expect(verifyStep!.success).toBe(true); - }); - - it('returns failure result when both retry attempts fail', async () => { - const phaseOp = makePhaseOp({ has_context: true, has_plans: true, plan_count: 1 }); - const config = makeConfig({ - workflow: { research: false, verifier: false, plan_check: false, skip_discuss: true } as any, - }); - const deps = makeDeps({ config }); - (deps.tools.initPhaseOp as ReturnType).mockResolvedValue(phaseOp); - - mockRunPhaseStepSession.mockImplementation(async (_prompt, step) => { - if (step === PhaseStepType.Plan) { - // Always fail - return makePlanResult({ - success: false, - error: { subtype: 'error_during_execution', messages: ['persistent failure'] }, - }); - } - return makePlanResult(); - }); - - const runner = new PhaseRunner(deps); - const result = await runner.run('1'); - - const planStep = result.steps.find(s => s.step === PhaseStepType.Plan); - expect(planStep!.success).toBe(false); - expect(planStep!.error).toContain('persistent failure'); - expect(result.success).toBe(false); - }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Phase lifecycle type contracts -// (consolidated from sdk/src/phase-runner-types.test.ts — issue #3740) -// ───────────────────────────────────────────────────────────────────────────── - -describe('Phase lifecycle types', () => { - // ─── PhaseStepType enum ──────────────────────────────────────────────── - - describe('PhaseStepType', () => { - it('has all expected step values', () => { - expect(PhaseStepType.Discuss).toBe('discuss'); - expect(PhaseStepType.Research).toBe('research'); - expect(PhaseStepType.Plan).toBe('plan'); - expect(PhaseStepType.Execute).toBe('execute'); - expect(PhaseStepType.Verify).toBe('verify'); - expect(PhaseStepType.Advance).toBe('advance'); - }); - - it('has exactly 7 members', () => { - const values = Object.values(PhaseStepType); - expect(values).toHaveLength(7); - }); - }); - - // ─── GSDEventType phase lifecycle values ─────────────────────────────── - - describe('GSDEventType phase lifecycle events', () => { - it('includes PhaseStart', () => { - expect(GSDEventType.PhaseStart).toBe('phase_start'); - }); - - it('includes PhaseStepStart', () => { - expect(GSDEventType.PhaseStepStart).toBe('phase_step_start'); - }); - - it('includes PhaseStepComplete', () => { - expect(GSDEventType.PhaseStepComplete).toBe('phase_step_complete'); - }); - - it('includes PhaseComplete', () => { - expect(GSDEventType.PhaseComplete).toBe('phase_complete'); - }); - }); - - // ─── PhaseOpInfo shape validation ────────────────────────────────────── - - describe('PhaseOpInfo interface', () => { - it('accepts a valid phase-op output object', () => { - const info: PhaseOpInfo = { - phase_found: true, - phase_dir: '.planning/phases/05-Skill-Scaffolding', - phase_number: '5', - phase_name: 'Skill Scaffolding', - phase_slug: 'skill-scaffolding', - padded_phase: '05', - has_research: false, - has_context: false, - has_plans: false, - has_verification: false, - plan_count: 0, - roadmap_exists: true, - planning_exists: true, - commit_docs: true, - context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md', - research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md', - }; - - expect(info.phase_found).toBe(true); - expect(info.phase_number).toBe('5'); - expect(info.plan_count).toBe(0); - expect(info.has_context).toBe(false); - }); - - it('matches the documented init phase-op JSON shape', () => { - const raw = JSON.parse(JSON.stringify({ - phase_found: true, - phase_dir: '.planning/phases/03-Auth', - phase_number: '3', - phase_name: 'Auth', - phase_slug: 'auth', - padded_phase: '03', - has_research: true, - has_context: true, - has_plans: true, - has_verification: false, - plan_count: 2, - roadmap_exists: true, - planning_exists: true, - commit_docs: true, - context_path: '.planning/phases/03-Auth/CONTEXT.md', - research_path: '.planning/phases/03-Auth/RESEARCH.md', - })); - - const info = raw as PhaseOpInfo; - expect(info.phase_found).toBe(true); - expect(info.has_plans).toBe(true); - expect(info.plan_count).toBe(2); - expect(typeof info.phase_dir).toBe('string'); - expect(typeof info.padded_phase).toBe('string'); - }); - }); - - // ─── Phase result types ──────────────────────────────────────────────── - - describe('PhaseStepResult', () => { - it('can represent a successful step', () => { - const result: PhaseStepResult = { - step: PhaseStepType.Research, - success: true, - durationMs: 5000, - }; - expect(result.success).toBe(true); - expect(result.error).toBeUndefined(); - }); - - it('can represent a failed step with error', () => { - const result: PhaseStepResult = { - step: PhaseStepType.Execute, - success: false, - durationMs: 12000, - error: 'Session timed out', - planResults: [], - }; - expect(result.success).toBe(false); - expect(result.error).toBe('Session timed out'); - }); - }); - - describe('PhaseRunnerResult', () => { - it('can represent a complete phase run', () => { - const result: PhaseRunnerResult = { - phaseNumber: '3', - phaseName: 'Auth', - steps: [ - { step: PhaseStepType.Research, success: true, durationMs: 5000 }, - { step: PhaseStepType.Plan, success: true, durationMs: 3000 }, - { step: PhaseStepType.Execute, success: true, durationMs: 60000 }, - ], - success: true, - totalCostUsd: 1.5, - totalDurationMs: 68000, - }; - expect(result.steps).toHaveLength(3); - expect(result.success).toBe(true); - }); - }); - - describe('HumanGateCallbacks', () => { - it('accepts an object with all optional callbacks', () => { - const callbacks: HumanGateCallbacks = { - onDiscussApproval: async () => 'approve', - onVerificationReview: async () => 'accept', - onBlockerDecision: async () => 'retry', - }; - expect(callbacks.onDiscussApproval).toBeDefined(); - }); - - it('accepts an empty object (all callbacks optional)', () => { - const callbacks: HumanGateCallbacks = {}; - expect(callbacks.onDiscussApproval).toBeUndefined(); - }); - }); - - describe('PhaseRunnerOptions', () => { - it('accepts full options', () => { - const options: PhaseRunnerOptions = { - callbacks: {}, - maxBudgetPerStep: 3.0, - maxTurnsPerStep: 30, - model: 'claude-sonnet-4-6', - }; - expect(options.maxBudgetPerStep).toBe(3.0); - }); - - it('accepts empty options (all fields optional)', () => { - const options: PhaseRunnerOptions = {}; - expect(options.callbacks).toBeUndefined(); - }); - }); - - // ─── Phase lifecycle event interfaces ────────────────────────────────── - - describe('Phase lifecycle event interfaces', () => { - it('GSDPhaseStartEvent has correct shape', () => { - const event: GSDPhaseStartEvent = { - type: GSDEventType.PhaseStart, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - phaseName: 'Auth', - }; - expect(event.type).toBe('phase_start'); - expect(event.phaseNumber).toBe('3'); - }); - - it('GSDPhaseStepStartEvent has correct shape', () => { - const event: GSDPhaseStepStartEvent = { - type: GSDEventType.PhaseStepStart, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - step: PhaseStepType.Research, - }; - expect(event.type).toBe('phase_step_start'); - expect(event.step).toBe('research'); - }); - - it('GSDPhaseStepCompleteEvent has correct shape', () => { - const event: GSDPhaseStepCompleteEvent = { - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - step: PhaseStepType.Execute, - success: true, - durationMs: 45000, - }; - expect(event.type).toBe('phase_step_complete'); - expect(event.success).toBe(true); - }); - - it('GSDPhaseStepCompleteEvent can include error', () => { - const event: GSDPhaseStepCompleteEvent = { - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - step: PhaseStepType.Verify, - success: false, - durationMs: 2000, - error: 'Verification failed', - }; - expect(event.error).toBe('Verification failed'); - }); - - it('GSDPhaseCompleteEvent has correct shape', () => { - const event: GSDPhaseCompleteEvent = { - type: GSDEventType.PhaseComplete, - timestamp: new Date().toISOString(), - sessionId: 'test-session', - phaseNumber: '3', - phaseName: 'Auth', - success: true, - totalCostUsd: 2.5, - totalDurationMs: 120000, - stepsCompleted: 5, - }; - expect(event.type).toBe('phase_complete'); - expect(event.stepsCompleted).toBe(5); - }); - }); -}); - -// ─── GSDTools typed methods ────────────────────────────────────────────────── - -describe('GSDTools typed methods', () => { - let tmpDir: string; - let fixtureDir: string; - - beforeEach(async () => { - tmpDir = join(tmpdir(), `gsd-tools-phase-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fixtureDir = join(tmpDir, 'fixtures'); - await mkdir(fixtureDir, { recursive: true }); - await mkdir(join(tmpDir, '.planning'), { recursive: true }); - }); - - afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); - }); - - async function createScript(name: string, code: string): Promise { - const scriptPath = join(fixtureDir, name); - await writeFile(scriptPath, code, { mode: 0o755 }); - return scriptPath; - } - - describe('initPhaseOp()', () => { - it('returns typed PhaseOpInfo from gsd-tools output', async () => { - const mockOutput: PhaseOpInfo = { - phase_found: true, - phase_dir: '.planning/phases/05-Skill-Scaffolding', - phase_number: '5', - phase_name: 'Skill Scaffolding', - phase_slug: 'skill-scaffolding', - padded_phase: '05', - has_research: false, - has_context: true, - has_plans: true, - has_verification: false, - plan_count: 3, - roadmap_exists: true, - planning_exists: true, - commit_docs: true, - context_path: '.planning/phases/05-Skill-Scaffolding/CONTEXT.md', - research_path: '.planning/phases/05-Skill-Scaffolding/RESEARCH.md', - }; - - const scriptPath = await createScript( - 'init-phase-op.cjs', - ` - const args = process.argv.slice(2); - // Script receives: init phase-op 5 --raw - if (args[0] === 'init' && args[1] === 'phase-op' && args[2] === '5') { - process.stdout.write(JSON.stringify(${JSON.stringify(mockOutput)})); - } else { - process.stderr.write('unexpected args: ' + args.join(' ')); - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.initPhaseOp('5'); - - expect(result.phase_found).toBe(true); - expect(result.phase_number).toBe('5'); - expect(result.phase_name).toBe('Skill Scaffolding'); - expect(result.plan_count).toBe(3); - expect(result.has_context).toBe(true); - expect(result.has_plans).toBe(true); - expect(result.context_path).toContain('CONTEXT.md'); - }); - - it('calls exec with correct args (init phase-op )', async () => { - const scriptPath = await createScript( - 'init-phase-op-args.cjs', - ` - const args = process.argv.slice(2); - process.stdout.write(JSON.stringify({ received_args: args })); - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.initPhaseOp('7') as { received_args: string[] }; - - expect(result.received_args).toContain('init'); - expect(result.received_args).toContain('phase-op'); - expect(result.received_args).toContain('7'); - expect(result.received_args).not.toContain('--raw'); - }); - }); - - describe('configGet()', () => { - it('returns string value from gsd-tools config', async () => { - const scriptPath = await createScript( - 'config-get.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'config-get' && args[1] === 'model_profile') { - process.stdout.write(JSON.stringify('balanced')); - } else { - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.configGet('model_profile'); - - expect(result).toBe('balanced'); - }); - - it('returns null when key not found', async () => { - const scriptPath = await createScript( - 'config-get-null.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'config-get' && args[1] === 'nonexistent_key') { - process.stdout.write('null'); - } else { - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.configGet('nonexistent_key'); - - expect(result).toBeNull(); - }); - }); - - describe('stateBeginPhase()', () => { - it('calls state begin-phase with correct args', async () => { - const scriptPath = await createScript( - 'state-begin-phase.cjs', - ` - const args = process.argv.slice(2); - if (args[0] === 'state' && args[1] === 'begin-phase' && args[2] === '--phase' && args[3] === '3') { - process.stdout.write('ok'); - } else { - process.stderr.write('unexpected args: ' + args.join(' ')); - process.exit(1); - } - `, - ); - - const tools = new GSDTools({ projectDir: tmpDir, gsdToolsPath: scriptPath, preferNativeQuery: false }); - const result = await tools.stateBeginPhase('3'); - - expect(result).toBe('ok'); - }); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// PromptFactory / extractBlock / extractSteps / PHASE_WORKFLOW_MAP -// (consolidated from sdk/src/phase-prompt.test.ts — issue #3740) -// ───────────────────────────────────────────────────────────────────────────── - -// ─── Prompt helpers ─────────────────────────────────────────────────────────── - -async function createPromptTempDir(): Promise { - return mkdtemp(join(tmpdir(), 'gsd-prompt-')); -} - -function makeWorkflowContent(purpose: string, steps: string[]): string { - const stepBlocks = steps - .map((s, i) => `\n${s}\n`) - .join('\n\n'); - return `\n${purpose}\n\n\n\n${stepBlocks}\n`; -} - -function makeAgentDef(name: string, tools: string, role: string): string { - return `---\nname: ${name}\ntools: ${tools}\n---\n\n\n${role}\n`; -} - -function makePromptParsedPlan(overrides?: Partial): ParsedPlan { - return { - frontmatter: { - phase: 'execute', - plan: 'test-plan', - type: 'feature', - wave: 1, - depends_on: [], - files_modified: [], - autonomous: true, - requirements: [], - must_haves: { truths: [], artifacts: [], key_links: [] }, - } as PlanFrontmatter, - objective: 'Test objective', - execution_context: [], - context_refs: [], - tasks: [], - raw: '', - ...overrides, - }; -} - -// ─── extractBlock tests ────────────────────────────────────────────────────── - -describe('extractBlock', () => { - it('extracts content from a simple block', () => { - const content = '\nDo the thing.\n'; - expect(extractBlock(content, 'purpose')).toBe('Do the thing.'); - }); - - it('extracts content from block with attributes', () => { - const content = '\nLoad context.\n'; - expect(extractBlock(content, 'step')).toBe('Load context.'); - }); - - it('returns empty string for missing block', () => { - const content = 'Something'; - expect(extractBlock(content, 'role')).toBe(''); - }); - - it('extracts multiline content', () => { - const content = '\nLine 1\nLine 2\nLine 3\n'; - expect(extractBlock(content, 'role')).toBe('Line 1\nLine 2\nLine 3'); - }); -}); - -describe('extractSteps', () => { - it('extracts multiple steps from process content', () => { - const process = ` -Initialize -Run tasks -Check results`; - - const steps = extractSteps(process); - expect(steps).toHaveLength(3); - expect(steps[0]).toEqual({ name: 'init', content: 'Initialize' }); - expect(steps[1]).toEqual({ name: 'execute', content: 'Run tasks' }); - expect(steps[2]).toEqual({ name: 'verify', content: 'Check results' }); - }); - - it('returns empty array for no steps', () => { - expect(extractSteps('no steps here')).toEqual([]); - }); - - it('handles steps with priority attributes', () => { - const process = '\nDo first.\n'; - const steps = extractSteps(process); - expect(steps).toHaveLength(1); - expect(steps[0].name).toBe('init'); - expect(steps[0].content).toBe('Do first.'); - }); -}); - -// ─── PromptFactory tests ───────────────────────────────────────────────────── - -describe('PromptFactory', () => { - let tempDir: string; - let workflowsDir: string; - let agentsDir: string; - - beforeEach(async () => { - tempDir = await createPromptTempDir(); - workflowsDir = join(tempDir, 'workflows'); - agentsDir = join(tempDir, 'agents'); - await mkdir(workflowsDir, { recursive: true }); - await mkdir(agentsDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - function makeFactory(): PromptFactory { - return new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), - }); - } - - describe('buildPrompt', () => { - it('assembles research prompt with role + purpose + process + context', async () => { - await writeFile( - join(workflowsDir, 'research-phase.md'), - makeWorkflowContent('Research the phase.', ['Gather info', 'Analyze findings']), - ); - await writeFile( - join(agentsDir, 'gsd-phase-researcher.md'), - makeAgentDef('gsd-phase-researcher', 'Read, Grep, Bash', 'You are a researcher.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { - state: '# State\nproject: test', - roadmap: '# Roadmap\nphases listed', - }; - - const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - - expect(prompt).toContain('## Agent Instructions'); - expect(prompt).toContain('You are a researcher.'); - expect(prompt).toContain('## Purpose'); - expect(prompt).toContain('Research the phase.'); - expect(prompt).toContain('## Process'); - expect(prompt).toContain('Gather info'); - expect(prompt).toContain('## Context'); - expect(prompt).toContain('# State'); - expect(prompt).toContain('# Roadmap'); - - const agentIdx = prompt.indexOf('## Agent Instructions'); - const contextIdx = prompt.indexOf('## Context'); - expect(agentIdx).toBeLessThan(contextIdx); - }); - - it('assembles plan prompt with all context files', async () => { - await writeFile( - join(workflowsDir, 'plan-phase.md'), - makeWorkflowContent('Plan the implementation.', ['Break down tasks']), - ); - await writeFile( - join(agentsDir, 'gsd-planner.md'), - makeAgentDef('gsd-planner', 'Read, Write, Bash', 'You are a planner.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { - state: '# State', - roadmap: '# Roadmap', - context: '# Context', - research: '# Research', - requirements: '# Requirements', - }; - - const prompt = await factory.buildPrompt(PhaseType.Plan, null, contextFiles); - - expect(prompt).toContain('You are a planner.'); - expect(prompt).toContain('Plan the implementation.'); - expect(prompt).toContain('# State'); - expect(prompt).toContain('# Research'); - expect(prompt).toContain('# Requirements'); - }); - - it('delegates execute phase with plan to buildExecutorPrompt', async () => { - await writeFile( - join(agentsDir, 'gsd-executor.md'), - makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'), - ); - - const factory = makeFactory(); - const plan = makePromptParsedPlan({ objective: 'Build the auth system' }); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles); - - expect(prompt).toContain('## Objective'); - expect(prompt).toContain('Build the auth system'); - expect(prompt).toContain('## Role'); - expect(prompt).toContain('You are an executor.'); - }); - - it('handles execute phase without plan (non-delegation path)', async () => { - await writeFile( - join(workflowsDir, 'execute-plan.md'), - makeWorkflowContent('Execute the plan.', ['Run tasks']), - ); - await writeFile( - join(agentsDir, 'gsd-executor.md'), - makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Execute, null, contextFiles); - - expect(prompt).toContain('## Agent Instructions'); - expect(prompt).toContain('You are an executor.'); - expect(prompt).toContain('## Purpose'); - expect(prompt).toContain('Execute the plan.'); - }); - - it('assembles verify prompt with phase instructions', async () => { - await writeFile( - join(workflowsDir, 'verify-phase.md'), - makeWorkflowContent('Verify phase goals.', ['Check artifacts', 'Run tests']), - ); - await writeFile( - join(agentsDir, 'gsd-verifier.md'), - makeAgentDef('gsd-verifier', 'Read, Bash, Grep', 'You are a verifier.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { - state: '# State', - roadmap: '# Roadmap', - requirements: '# Requirements', - }; - - const prompt = await factory.buildPrompt(PhaseType.Verify, null, contextFiles); - - expect(prompt).toContain('You are a verifier.'); - expect(prompt).toContain('Verify phase goals.'); - }); - - it('assembles discuss prompt without agent role (no dedicated agent)', async () => { - await writeFile( - join(workflowsDir, 'discuss-phase.md'), - makeWorkflowContent('Discuss implementation decisions.', ['Identify areas']), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles); - - expect(prompt).not.toContain('## Agent Instructions'); - expect(prompt).toContain('## Purpose'); - expect(prompt).toContain('Discuss implementation decisions.'); - }); - - it('handles missing workflow file gracefully', async () => { - await writeFile( - join(agentsDir, 'gsd-phase-researcher.md'), - makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - - expect(prompt).toContain('## Agent Instructions'); - expect(prompt).toContain('## Context'); - expect(prompt).not.toContain('## Purpose'); - }); - - it('handles missing agent def gracefully', async () => { - await writeFile( - join(workflowsDir, 'research-phase.md'), - makeWorkflowContent('Research the phase.', ['Gather info']), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - - expect(prompt).not.toContain('## Agent Instructions'); - expect(prompt).toContain('## Purpose'); - expect(prompt).toContain('Research the phase.'); - }); - - it('omits empty context section when no files provided', async () => { - await writeFile( - join(workflowsDir, 'discuss-phase.md'), - makeWorkflowContent('Discuss things.', ['Talk']), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = {}; - - const prompt = await factory.buildPrompt(PhaseType.Discuss, null, contextFiles); - - expect(prompt).not.toContain('## Context'); - }); - }); - - describe('loadWorkflowFile', () => { - it('loads existing workflow file', async () => { - await writeFile( - join(workflowsDir, 'research-phase.md'), - 'workflow content', - ); - - const factory = makeFactory(); - const content = await factory.loadWorkflowFile(PhaseType.Research); - expect(content).toBe('workflow content'); - }); - - it('returns undefined for missing workflow file', async () => { - const factory = makeFactory(); - const content = await factory.loadWorkflowFile(PhaseType.Research); - expect(content).toBeUndefined(); - }); - }); - - describe('loadAgentDef', () => { - it('loads agent def from agents dir', async () => { - await writeFile( - join(agentsDir, 'gsd-executor.md'), - 'agent content', - ); - - const factory = makeFactory(); - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('agent content'); - }); - - it('returns undefined for phases with no agent (discuss)', async () => { - const factory = makeFactory(); - const content = await factory.loadAgentDef(PhaseType.Discuss); - expect(content).toBeUndefined(); - }); - - it('falls back to project agents dir', async () => { - const projectAgentsDir = join(tempDir, 'project-agents'); - await mkdir(projectAgentsDir, { recursive: true }); - await writeFile( - join(projectAgentsDir, 'gsd-executor.md'), - 'project agent content', - ); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - projectAgentsDir, - sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), - }); - - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('project agent content'); - }); - - it('prefers user agents dir over project agents dir', async () => { - const projectAgentsDir = join(tempDir, 'project-agents'); - await mkdir(projectAgentsDir, { recursive: true }); - await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); - await writeFile(join(projectAgentsDir, 'gsd-executor.md'), 'project agent'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - projectAgentsDir, - sdkPromptsDir: join(tempDir, 'sdk-prompts-does-not-exist'), - }); - - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('user agent'); - }); - }); - - // ─── Headless prompt loading ───────────────────────────────────────────── - - describe('headless prompt loading', () => { - it('loadWorkflowFile prefers installed GSD over sdkPromptsDir', async () => { - const sdkDir = join(tempDir, 'sdk-prompts'); - await mkdir(join(sdkDir, 'workflows'), { recursive: true }); - - await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original'); - await writeFile(join(sdkDir, 'workflows', 'research-phase.md'), 'SDK bundled version'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: sdkDir, - }); - - const content = await factory.loadWorkflowFile(PhaseType.Research); - expect(content).toBe('GSD-1 original'); - }); - - it('loadWorkflowFile falls back to GSD-1 when sdkPromptsDir file missing', async () => { - const sdkDir = join(tempDir, 'sdk-prompts'); - await mkdir(join(sdkDir, 'workflows'), { recursive: true }); - - await writeFile(join(workflowsDir, 'research-phase.md'), 'GSD-1 original'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: sdkDir, - }); - - const content = await factory.loadWorkflowFile(PhaseType.Research); - expect(content).toBe('GSD-1 original'); - }); - - it('loadAgentDef prefers installed agents over sdkPromptsDir', async () => { - const sdkDir = join(tempDir, 'sdk-prompts'); - await mkdir(join(sdkDir, 'agents'), { recursive: true }); - - await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); - await writeFile(join(sdkDir, 'agents', 'gsd-executor.md'), 'SDK bundled agent'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: sdkDir, - }); - - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('user agent'); - }); - - it('loadAgentDef falls back to user agents when sdkPromptsDir file missing', async () => { - const sdkDir = join(tempDir, 'sdk-prompts'); - await mkdir(join(sdkDir, 'agents'), { recursive: true }); - - await writeFile(join(agentsDir, 'gsd-executor.md'), 'user agent'); - - const factory = new PromptFactory({ - gsdInstallDir: tempDir, - agentsDir, - sdkPromptsDir: sdkDir, - }); - - const content = await factory.loadAgentDef(PhaseType.Execute); - expect(content).toBe('user agent'); - }); - - it('buildPrompt sanitizes interactive patterns from output', async () => { - await writeFile( - join(workflowsDir, 'research-phase.md'), - makeWorkflowContent('Research the codebase thoroughly.', [ - 'Gather data from the project.\nAskUserQuestion("what?")\nAnalyze findings.', - 'Run the analysis.\n/gsd:analyze --deep\nDocument results.', - ]), - ); - await writeFile( - join(agentsDir, 'gsd-phase-researcher.md'), - makeAgentDef('gsd-phase-researcher', 'Read, Bash', 'You are a researcher.\nSTOP and wait for user input.\nBe thorough.'), - ); - - const factory = makeFactory(); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Research, null, contextFiles); - - expect(prompt).not.toContain('AskUserQuestion'); - expect(prompt).not.toContain('/gsd:'); - expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/); - - expect(prompt).toContain('You are a researcher.'); - expect(prompt).toContain('Be thorough.'); - expect(prompt).toContain('Gather data from the project.'); - expect(prompt).toContain('Analyze findings.'); - }); - - it('buildPrompt with execute+plan sanitizes output from buildExecutorPrompt', async () => { - await writeFile( - join(agentsDir, 'gsd-executor.md'), - makeAgentDef('gsd-executor', 'Read, Write, Edit, Bash', 'You are an executor.\nSTOP and wait for user.\nExecute thoroughly.'), - ); - - const factory = makeFactory(); - const plan = makePromptParsedPlan({ objective: 'Build the auth system' }); - const contextFiles: ContextFiles = { state: '# State' }; - - const prompt = await factory.buildPrompt(PhaseType.Execute, plan, contextFiles); - - expect(prompt).toContain('Build the auth system'); - expect(prompt).not.toMatch(/\bSTOP\s+and\s+wait/); - expect(prompt).toContain('You are an executor.'); - }); - }); -}); - -describe('PHASE_WORKFLOW_MAP', () => { - it('maps all phase types to workflow filenames', () => { - for (const phase of Object.values(PhaseType)) { - expect(PHASE_WORKFLOW_MAP[phase]).toBeDefined(); - expect(PHASE_WORKFLOW_MAP[phase]).toMatch(/\.md$/); - } - }); - - it('execute phase maps to execute-plan.md (not execute-phase.md)', () => { - expect(PHASE_WORKFLOW_MAP[PhaseType.Execute]).toBe('execute-plan.md'); - }); -}); diff --git a/sdk/src/phase-runner.ts b/sdk/src/phase-runner.ts deleted file mode 100644 index 078b4b567..000000000 --- a/sdk/src/phase-runner.ts +++ /dev/null @@ -1,1442 +0,0 @@ -/** - * Phase Runner — core state machine driving the full phase lifecycle. - * - * Orchestrates: discuss → research → plan → execute → verify → advance - * with config-driven step skipping, human gate callbacks, event emission, - * and structured error handling per step. - */ - -import type { - PhaseOpInfo, - PhaseStepResult, - PhaseRunnerResult, - HumanGateCallbacks, - PhaseRunnerOptions, - PlanResult, - SessionOptions, - ParsedPlan, - PhasePlanIndex, - PlanInfo, -} from './types.js'; -import { PhaseStepType, PhaseType, GSDEventType } from './types.js'; -import type { GSDConfig } from './config.js'; -import type { GSDTools } from './gsd-tools.js'; -import type { GSDEventStream } from './event-stream.js'; -import type { PromptFactory } from './phase-prompt.js'; -import type { ContextEngine } from './context-engine.js'; -import type { GSDLogger } from './logger.js'; -import { runPhaseStepSession, runPlanSession } from './session-runner.js'; -import { parsePlanFile } from './plan-parser.js'; -import { realpathSync } from 'node:fs'; -import { readdir, readFile } from 'node:fs/promises'; -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; -import { checkResearchGate } from './research-gate.js'; - -// ─── Error type ────────────────────────────────────────────────────────────── - -export class PhaseRunnerError extends Error { - constructor( - message: string, - public readonly phaseNumber: string, - public readonly step: PhaseStepType, - public readonly cause?: Error, - ) { - super(message); - this.name = 'PhaseRunnerError'; - } -} - -// ─── Verification result enum ──────────────────────────────────────────────── - -export type VerificationOutcome = 'passed' | 'human_needed' | 'gaps_found' | 'architectural_debt' | 'status_unreadable'; - -interface ArchitecturalDebtFinding { - file: string; - line: number; - marker: string; - text: string; -} - -type ArchitecturalDebtCheckReason = 'markers_found' | 'scan_error'; - -interface ArchitecturalDebtCheck { - pass: boolean; - findings: ArchitecturalDebtFinding[]; - reason?: ArchitecturalDebtCheckReason; -} - -// ─── PhaseRunner deps interface ────────────────────────────────────────────── - -export interface PhaseRunnerDeps { - projectDir: string; - tools: GSDTools; - promptFactory: PromptFactory; - contextEngine: ContextEngine; - eventStream: GSDEventStream; - config: GSDConfig; - logger?: GSDLogger; -} - -// ─── PhaseRunner ───────────────────────────────────────────────────────────── - -export class PhaseRunner { - private readonly projectDir: string; - private readonly tools: GSDTools; - private readonly promptFactory: PromptFactory; - private readonly contextEngine: ContextEngine; - private readonly eventStream: GSDEventStream; - private readonly config: GSDConfig; - private readonly logger?: GSDLogger; - - constructor(deps: PhaseRunnerDeps) { - this.projectDir = deps.projectDir; - this.tools = deps.tools; - this.promptFactory = deps.promptFactory; - this.contextEngine = deps.contextEngine; - this.eventStream = deps.eventStream; - this.config = deps.config; - this.logger = deps.logger; - } - - /** - * Run a full phase lifecycle: discuss → research → plan → plan-check → execute → verify → advance. - * - * Each step is gated by config flags and phase state. Human gate callbacks - * are invoked at decision points; when not provided, auto-approve is used. - */ - async run(phaseNumber: string, options?: PhaseRunnerOptions): Promise { - const startTime = Date.now(); - const steps: PhaseStepResult[] = []; - const callbacks = options?.callbacks ?? {}; - - // ── Init: query phase state ── - let phaseOp: PhaseOpInfo; - try { - phaseOp = await this.tools.initPhaseOp(phaseNumber); - } catch (err) { - throw new PhaseRunnerError( - `Failed to initialize phase ${phaseNumber}: ${err instanceof Error ? err.message : String(err)}`, - phaseNumber, - PhaseStepType.Discuss, - err instanceof Error ? err : undefined, - ); - } - - // Validate phase exists - if (!phaseOp.phase_found) { - throw new PhaseRunnerError( - `Phase ${phaseNumber} not found on disk`, - phaseNumber, - PhaseStepType.Discuss, - ); - } - - const phaseName = phaseOp.phase_name; - - // Emit phase_start - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStart, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - phaseName, - }); - - const sessionOpts: SessionOptions = { - maxTurns: options?.maxTurnsPerStep ?? 50, - maxBudgetUsd: options?.maxBudgetPerStep ?? 5.0, - model: options?.model, - cwd: this.projectDir, - }; - - let halted = false; - - // ── Step 1: Discuss ── - if (!halted) { - const shouldSkip = phaseOp.has_context || this.config.workflow.skip_discuss; - if (shouldSkip && !(this.config.workflow.auto_advance && !phaseOp.has_context && !this.config.workflow.skip_discuss)) { - this.logger?.debug(`Skipping discuss: has_context=${phaseOp.has_context}, skip_discuss=${this.config.workflow.skip_discuss}`); - } else if (!phaseOp.has_context && !this.config.workflow.skip_discuss && this.config.workflow.auto_advance) { - // AI self-discuss: auto-mode with no context — run a self-discuss session - const result = await this.retryOnce('self-discuss', () => this.runSelfDiscussStep(phaseNumber, sessionOpts)); - steps.push(result); - - // Re-query phase state to check if context was created - try { - phaseOp = await this.tools.initPhaseOp(phaseNumber); - } catch { - // If re-query fails, proceed with original state - } - - if (!phaseOp.has_context) { - const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Discuss, 'No context after self-discuss step'); - if (decision === 'stop') { - halted = true; - } - } - } else if (!shouldSkip) { - const result = await this.retryOnce('discuss', () => this.runStep(PhaseStepType.Discuss, phaseNumber, sessionOpts)); - steps.push(result); - - // Re-query phase state to check if context was created - try { - phaseOp = await this.tools.initPhaseOp(phaseNumber); - } catch { - // If re-query fails, proceed with original state - } - - if (!phaseOp.has_context) { - // No context after discuss — invoke blocker callback - const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Discuss, 'No context after discuss step'); - if (decision === 'stop') { - halted = true; - } - } - } - } - - // ── Step 2: Research ── - if (!halted) { - if (!this.config.workflow.research) { - this.logger?.debug('Skipping research: config.workflow.research=false'); - } else { - const result = await this.retryOnce('research', () => this.runStep(PhaseStepType.Research, phaseNumber, sessionOpts)); - steps.push(result); - } - } - - // ── Step 2.5: Research gate (#1602) ── - // Check RESEARCH.md for unresolved open questions before planning - if (!halted && phaseOp.has_research) { - const gateResult = await this.checkResearchGate(phaseOp); - if (!gateResult.pass) { - const questionList = gateResult.unresolvedQuestions.join(', '); - const error = `RESEARCH.md has unresolved open questions: ${questionList}`; - this.logger?.warn(error, { phase: phaseNumber }); - const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Research, error); - if (decision === 'stop') { - halted = true; - } - } - } - - // ── Step 3: Plan ── - if (!halted) { - const result = await this.retryOnce('plan', () => this.runStep(PhaseStepType.Plan, phaseNumber, sessionOpts)); - steps.push(result); - - // Re-query to check for plans - try { - phaseOp = await this.tools.initPhaseOp(phaseNumber); - } catch { - // Proceed with prior state - } - - if (!phaseOp.has_plans || phaseOp.plan_count === 0) { - const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Plan, 'No plans created after plan step'); - if (decision === 'stop') { - halted = true; - } - } - } - - // ── Step 3.5: Plan Check ── - if (!halted && this.config.workflow.plan_check) { - const planCheckResult = await this.retryOnce('plan-check', () => this.runPlanCheckStep(phaseNumber, sessionOpts)); - steps.push(planCheckResult); - - // If plan-check failed, re-plan once then re-check once (D023) - if (!planCheckResult.success) { - this.logger?.info(`Plan check failed for phase ${phaseNumber}, re-planning once (D023)`); - - // Re-run plan step with feedback - const replanResult = await this.runStep(PhaseStepType.Plan, phaseNumber, sessionOpts); - steps.push(replanResult); - - // Re-check once - const recheckResult = await this.runPlanCheckStep(phaseNumber, sessionOpts); - steps.push(recheckResult); - - if (!recheckResult.success) { - this.logger?.warn(`Plan check failed again after re-plan for phase ${phaseNumber}. Proceeding with warning (D023).`); - } - } - } - - // ── Step 4: Execute ── - if (!halted) { - const executeResult = await this.retryOnce('execute', () => this.runExecuteStep(phaseNumber, sessionOpts)); - steps.push(executeResult); - } - - // ── Step 5: Verify ── - if (!halted) { - if (!this.config.workflow.verifier) { - this.logger?.debug('Skipping verify: config.workflow.verifier=false'); - } else { - // Verify has its own internal retry logic (gap closure). retryOnce only - // retries on unexpected session throws, not on verification outcomes like gaps_found. - const verifyResult = await this.retryOnce('verify', () => this.runVerifyStep(phaseNumber, sessionOpts, callbacks, options)); - steps.push(verifyResult); - - // Check if verify resulted in a halt - if (!verifyResult.success && verifyResult.error === 'halted_by_callback') { - halted = true; - } - } - } - - // ── Step 6: Advance ── - // Only advance if verify passed — never mark a phase complete when gaps were found. - const verifyPassed = steps.every(s => s.step !== PhaseStepType.Verify || s.success); - if (!halted && verifyPassed) { - const advanceResult = await this.runAdvanceStep(phaseNumber, sessionOpts, callbacks); - steps.push(advanceResult); - } else if (!halted && !verifyPassed) { - this.logger?.warn(`Skipping advance for phase ${phaseNumber}: verification found gaps`); - } - - const totalDurationMs = Date.now() - startTime; - const totalCostUsd = steps.reduce((sum, s) => { - const stepCost = s.planResults?.reduce((c, pr) => c + pr.totalCostUsd, 0) ?? 0; - return sum + stepCost; - }, 0); - const success = !halted && steps.every(s => s.success); - - // Emit phase_complete - this.eventStream.emitEvent({ - type: GSDEventType.PhaseComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - phaseName, - success, - totalCostUsd, - totalDurationMs, - stepsCompleted: steps.length, - }); - - return { - phaseNumber, - phaseName, - steps, - success, - totalCostUsd, - totalDurationMs, - }; - } - - // ─── Step runners ────────────────────────────────────────────────────── - - /** - * Retry a step function once on failure. - * On first error/failure, logs a warning and calls the function once more. - * Returns the result from the last attempt. - */ - private async retryOnce(label: string, fn: () => Promise): Promise { - const result = await fn(); - if (result.success) return result; - - // Don't retry verify outcomes (gaps_found, human_needed) — they have their own retry logic. - if (result.error?.startsWith('verification_')) return result; - - this.logger?.warn(`Step "${label}" failed, retrying once...`); - return fn(); - } - - /** - * Run the plan-check step. - * Loads the gsd-plan-checker agent definition, runs a Verify-scoped session, - * and parses output for PASS/FAIL signals. - */ - private async runPlanCheckStep( - phaseNumber: string, - sessionOpts: SessionOptions, - ): Promise { - const stepStart = Date.now(); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepStart, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.PlanCheck, - }); - - let planResult: PlanResult; - try { - // Load plan-checker agent definition (same pattern as PromptFactory.loadAgentDef) - const agentDef = await this.promptFactory.loadAgentDef(PhaseType.Verify); - - // Build prompt using Verify phase type for context resolution - const contextFiles = await this.contextEngine.resolveContextFiles(PhaseType.Verify); - let prompt = await this.promptFactory.buildPrompt(PhaseType.Verify, null, contextFiles); - - // Supplement with plan-checker instructions - prompt += '\n\n## Plan Checker Instructions\n\nYou are a plan checker. Review the plans for this phase and verify they are well-formed, complete, and achievable. If all plans pass, output "VERIFICATION PASSED". If any issues are found, output "ISSUES FOUND" followed by a description of each issue.'; - - planResult = await runPhaseStepSession( - prompt, - PhaseStepType.PlanCheck, - this.config, - sessionOpts, - this.eventStream, - { phase: PhaseType.Verify, planName: undefined }, - ); - } catch (err) { - const durationMs = Date.now() - stepStart; - const errorMsg = err instanceof Error ? err.message : String(err); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.PlanCheck, - success: false, - durationMs, - error: errorMsg, - }); - - return { - step: PhaseStepType.PlanCheck, - success: false, - durationMs, - error: errorMsg, - }; - } - - const durationMs = Date.now() - stepStart; - // Parse plan-check outcome: success if the session succeeded (real output parsing would check for VERIFICATION PASSED / ISSUES FOUND) - const success = planResult.success; - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: planResult.sessionId, - phaseNumber, - step: PhaseStepType.PlanCheck, - success, - durationMs, - error: planResult.error?.messages.join('; ') || undefined, - }); - - return { - step: PhaseStepType.PlanCheck, - success, - durationMs, - error: planResult.error?.messages.join('; ') || undefined, - planResults: [planResult], - }; - } - - /** - * Run the self-discuss step for auto-mode. - * When auto_advance is true and no context exists, run an AI self-discuss - * session that identifies gray areas and makes opinionated decisions. - */ - private async runSelfDiscussStep( - phaseNumber: string, - sessionOpts: SessionOptions, - ): Promise { - const stepStart = Date.now(); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepStart, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Discuss, - }); - - let planResult: PlanResult; - try { - const contextFiles = await this.contextEngine.resolveContextFiles(PhaseType.Discuss); - let prompt = await this.promptFactory.buildPrompt(PhaseType.Discuss, null, contextFiles); - - // Prepend self-discuss override BEFORE the workflow prompt. - // The workflow prompt contains interactive patterns (user questions, area selection) - // that the agent will follow unless explicitly overridden up front. - const maxPasses = this.config.workflow.max_discuss_passes ?? 3; - const selfDiscussOverride = [ - '## HEADLESS MODE — MANDATORY OVERRIDE', - '', - '**This session is running headless with no human present.**', - '', - 'You MUST NOT:', - '- Use AskUserQuestion or any interactive tools', - '- Invoke Skill() or SlashCommand()', - '- Ask the user anything or wait for input', - '- Use multi-select, checkbox, or prompt UIs', - '', - 'You MUST:', - '- Make all decisions autonomously and opinionatedly', - '- Identify 3-5 gray areas in the project scope', - '- Reason through each one yourself and pick the best option', - '- Write CONTEXT.md with your decisions in a single pass', - `- Complete within ${maxPasses} pass(es) maximum — do not re-read your own output to find gaps`, - '', - 'Any instructions below about "asking the user", "discussing with the user", or "interactive" should be read as "decide yourself."', - '', - '---', - '', - ].join('\n'); - prompt = selfDiscussOverride + prompt; - - planResult = await runPhaseStepSession( - prompt, - PhaseStepType.Discuss, - this.config, - sessionOpts, - this.eventStream, - { phase: PhaseType.Discuss, planName: undefined }, - ); - } catch (err) { - const durationMs = Date.now() - stepStart; - const errorMsg = err instanceof Error ? err.message : String(err); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Discuss, - success: false, - durationMs, - error: errorMsg, - }); - - return { - step: PhaseStepType.Discuss, - success: false, - durationMs, - error: errorMsg, - }; - } - - const durationMs = Date.now() - stepStart; - const success = planResult.success; - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: planResult.sessionId, - phaseNumber, - step: PhaseStepType.Discuss, - success, - durationMs, - error: planResult.error?.messages.join('; ') || undefined, - }); - - return { - step: PhaseStepType.Discuss, - success, - durationMs, - error: planResult.error?.messages.join('; ') || undefined, - planResults: [planResult], - }; - } - - /** - * Run a single phase step session (discuss, research, plan). - * Emits step start/complete events and captures errors. - */ - private async runStep( - step: PhaseStepType, - phaseNumber: string, - sessionOpts: SessionOptions, - ): Promise { - const stepStart = Date.now(); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepStart, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step, - }); - - let planResult: PlanResult; - try { - // Map step to PhaseType for prompt/context resolution - const phaseType = this.stepToPhaseType(step); - const contextFiles = await this.contextEngine.resolveContextFiles(phaseType); - const prompt = await this.promptFactory.buildPrompt(phaseType, null, contextFiles); - - planResult = await runPhaseStepSession( - prompt, - step, - this.config, - sessionOpts, - this.eventStream, - { phase: phaseType, planName: undefined }, - ); - } catch (err) { - const durationMs = Date.now() - stepStart; - const errorMsg = err instanceof Error ? err.message : String(err); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step, - success: false, - durationMs, - error: errorMsg, - }); - - return { - step, - success: false, - durationMs, - error: errorMsg, - }; - } - - const durationMs = Date.now() - stepStart; - const success = planResult.success; - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: planResult.sessionId, - phaseNumber, - step, - success, - durationMs, - error: planResult.error?.messages.join('; ') || undefined, - }); - - return { - step, - success, - durationMs, - error: planResult.error?.messages.join('; ') || undefined, - planResults: [planResult], - }; - } - - /** - * Run the execute step — uses phase-plan-index for wave-grouped parallel execution. - * Plans in the same wave run concurrently via Promise.allSettled(). - * Waves execute sequentially (wave 1 completes before wave 2 starts). - * Respects config.parallelization: false to fall back to sequential execution. - * Filters out plans with has_summary: true (already completed). - */ - private async runExecuteStep( - phaseNumber: string, - sessionOpts: SessionOptions, - ): Promise { - const stepStart = Date.now(); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepStart, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Execute, - }); - - // Get the plan index from gsd-tools - let planIndex: PhasePlanIndex; - try { - planIndex = await this.tools.phasePlanIndex(phaseNumber); - } catch (err) { - const durationMs = Date.now() - stepStart; - const errorMsg = err instanceof Error ? err.message : String(err); - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Execute, - success: false, - durationMs, - error: errorMsg, - }); - return { - step: PhaseStepType.Execute, - success: false, - durationMs, - error: errorMsg, - }; - } - - // Filter to incomplete plans only (has_summary === false) - const incompletePlans = planIndex.plans.filter(p => !p.has_summary); - - if (incompletePlans.length === 0) { - const durationMs = Date.now() - stepStart; - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Execute, - success: true, - durationMs, - }); - return { - step: PhaseStepType.Execute, - success: true, - durationMs, - planResults: [], - }; - } - - const planResults: PlanResult[] = []; - - // Sequential fallback when parallelization is disabled - if (this.config.parallelization === false) { - for (const plan of incompletePlans) { - const result = await this.executeSinglePlan(phaseNumber, plan.id, sessionOpts); - planResults.push(result); - } - } else { - // Group incomplete plans by wave, sort waves numerically - const waveMap = new Map(); - for (const plan of incompletePlans) { - const existing = waveMap.get(plan.wave) ?? []; - existing.push(plan); - waveMap.set(plan.wave, existing); - } - const sortedWaves = [...waveMap.keys()].sort((a, b) => a - b); - - for (const waveNum of sortedWaves) { - const wavePlans = waveMap.get(waveNum)!; - const wavePlanIds = wavePlans.map(p => p.id); - - // Emit wave_start - this.eventStream.emitEvent({ - type: GSDEventType.WaveStart, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - waveNumber: waveNum, - planCount: wavePlans.length, - planIds: wavePlanIds, - }); - - const waveStart = Date.now(); - - // Execute all plans in this wave concurrently - const settled = await Promise.allSettled( - wavePlans.map(plan => this.executeSinglePlan(phaseNumber, plan.id, sessionOpts)), - ); - - // Map settled results to PlanResult[] - let successCount = 0; - let failureCount = 0; - for (const outcome of settled) { - if (outcome.status === 'fulfilled') { - planResults.push(outcome.value); - if (outcome.value.success) successCount++; - else failureCount++; - } else { - failureCount++; - planResults.push({ - success: false, - sessionId: '', - totalCostUsd: 0, - durationMs: 0, - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }, - numTurns: 0, - error: { - subtype: 'error_during_execution', - messages: [outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason)], - }, - }); - } - } - - // Emit wave_complete - this.eventStream.emitEvent({ - type: GSDEventType.WaveComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - waveNumber: waveNum, - successCount, - failureCount, - durationMs: Date.now() - waveStart, - }); - } - } - - const durationMs = Date.now() - stepStart; - const allSucceeded = planResults.every(r => r.success); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Execute, - success: allSucceeded, - durationMs, - }); - - return { - step: PhaseStepType.Execute, - success: allSucceeded, - durationMs, - planResults, - }; - } - - /** - * Execute a single plan by ID within the execute step. - * Loads the plan file, parses it, and passes the parsed plan to the prompt - * builder so the executor gets the full plan content (tasks, objectives, etc.). - */ - private async executeSinglePlan( - phaseNumber: string, - planId: string, - sessionOpts: SessionOptions, - ): Promise { - try { - // Resolve the plan file path from phase directory + planId - const phaseOp = await this.tools.initPhaseOp(phaseNumber); - const planFilename = planId === 'PLAN' ? 'PLAN.md' : `${planId}-PLAN.md`; - const planPath = join(this.projectDir, phaseOp.phase_dir, planFilename); - - // Parse the plan file so the executor prompt includes the actual tasks - const parsedPlan = await parsePlanFile(planPath); - - const phaseType = PhaseType.Execute; - const contextFiles = await this.contextEngine.resolveContextFiles(phaseType); - const prompt = await this.promptFactory.buildPrompt(phaseType, parsedPlan, contextFiles, phaseOp.phase_dir); - - return await runPhaseStepSession( - prompt, - PhaseStepType.Execute, - this.config, - sessionOpts, - this.eventStream, - { phase: phaseType, planName: planId }, - ); - } catch (err) { - return { - success: false, - sessionId: '', - totalCostUsd: 0, - durationMs: 0, - usage: { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }, - numTurns: 0, - error: { - subtype: 'error_during_execution', - messages: [err instanceof Error ? err.message : String(err)], - }, - }; - } - } - - /** - * Run the verify step with full gap closure cycle. - * Verification outcome routing: - * - passed → proceed to advance - * - human_needed → invoke onVerificationReview callback - * - gaps_found → plan (create gap plans) → execute (run gap plans) → re-verify - * Gap closure retries are capped at configurable maxGapRetries (default 1). - */ - private async runVerifyStep( - phaseNumber: string, - sessionOpts: SessionOptions, - callbacks: HumanGateCallbacks, - options?: PhaseRunnerOptions, - ): Promise { - const stepStart = Date.now(); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepStart, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Verify, - }); - - const maxGapRetries = options?.maxGapRetries ?? 1; - let gapRetryCount = 0; - let lastResult: PlanResult | undefined; - let outcome: VerificationOutcome = 'passed'; - const allPlanResults: PlanResult[] = []; - - while (true) { - try { - const phaseType = PhaseType.Verify; - const contextFiles = await this.contextEngine.resolveContextFiles(phaseType); - const prompt = await this.promptFactory.buildPrompt(phaseType, null, contextFiles); - - lastResult = await runPhaseStepSession( - prompt, - PhaseStepType.Verify, - this.config, - sessionOpts, - this.eventStream, - { phase: phaseType }, - ); - allPlanResults.push(lastResult); - } catch (err) { - const durationMs = Date.now() - stepStart; - const errorMsg = err instanceof Error ? err.message : String(err); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Verify, - success: false, - durationMs, - error: errorMsg, - }); - - return { - step: PhaseStepType.Verify, - success: false, - durationMs, - error: errorMsg, - planResults: allPlanResults.length > 0 ? allPlanResults : undefined, - }; - } - - // Parse verification outcome from VERIFICATION.md (not just session exit code) - outcome = await this.parseVerificationOutcome(lastResult, phaseNumber); - - if (outcome === 'passed') { - const debtCheck = await this.checkArchitecturalDebt(phaseNumber); - if (!debtCheck.pass) { - const message = - debtCheck.reason === 'scan_error' - ? `Verification blocked because architectural debt scan could not complete for phase ${phaseNumber}` - : `Verification blocked by unresolved architectural debt markers in phase ${phaseNumber}`; - this.logger?.warn(message, { - phase: phaseNumber, - reason: debtCheck.reason, - findingCount: debtCheck.findings.length, - findings: debtCheck.findings.map(({ file, line, marker }) => ({ file, line, marker })), - }); - outcome = 'architectural_debt'; - break; - } - break; - } - - if (outcome === 'human_needed') { - // Invoke verification review callback - const decision = await this.invokeVerificationCallback(callbacks, phaseNumber, { - step: PhaseStepType.Verify, - success: lastResult.success, - durationMs: Date.now() - stepStart, - planResults: allPlanResults, - }); - - if (decision === 'accept') { - break; // Acknowledged by caller, but still pending human verification. - } else if (decision === 'retry' && gapRetryCount < maxGapRetries) { - gapRetryCount++; - continue; - } else { - // reject or exceeded retries - const durationMs = Date.now() - stepStart; - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: lastResult.sessionId, - phaseNumber, - step: PhaseStepType.Verify, - success: false, - durationMs, - error: 'halted_by_callback', - }); - return { - step: PhaseStepType.Verify, - success: false, - durationMs, - error: 'halted_by_callback', - planResults: allPlanResults, - }; - } - } - - if (outcome === 'status_unreadable') { - break; - } - - if (outcome === 'gaps_found') { - if (gapRetryCount < maxGapRetries) { - gapRetryCount++; - this.logger?.info(`Gap closure attempt ${gapRetryCount}/${maxGapRetries} for phase ${phaseNumber}`); - - // ── Gap closure cycle: plan → execute → re-verify ── - - // 1. Run a plan step to create gap plans - try { - const planResult = await this.runStep(PhaseStepType.Plan, phaseNumber, sessionOpts); - if (planResult.planResults) { - allPlanResults.push(...planResult.planResults); - } - } catch (err) { - this.logger?.warn(`Gap closure plan step failed: ${err instanceof Error ? err.message : String(err)}`); - // Proceed to re-verify anyway - } - - // 2. Re-query phase state to discover newly created gap plans - try { - await this.tools.initPhaseOp(phaseNumber); - } catch (err) { - this.logger?.warn(`Gap closure re-query failed, proceeding with stale state: ${err instanceof Error ? err.message : String(err)}`); - } - - // 3. Execute gap plans via the wave-capable runExecuteStep - try { - const executeResult = await this.runExecuteStep(phaseNumber, sessionOpts); - if (executeResult.planResults) { - allPlanResults.push(...executeResult.planResults); - } - } catch (err) { - this.logger?.warn(`Gap closure execute step failed: ${err instanceof Error ? err.message : String(err)}`); - // Proceed to re-verify anyway - } - - // 4. Continue the loop to re-verify - continue; - } - // Exceeded gap closure retries — proceed - break; - } - - break; // Safety: unknown outcome → proceed - } - - const durationMs = Date.now() - stepStart; - const verifySuccess = outcome === 'passed'; - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: lastResult?.sessionId ?? '', - phaseNumber, - step: PhaseStepType.Verify, - success: verifySuccess, - durationMs, - ...(!verifySuccess && { error: this.verificationErrorForOutcome(outcome) }), - }); - - return { - step: PhaseStepType.Verify, - success: verifySuccess, - durationMs, - planResults: allPlanResults, - ...(!verifySuccess && { error: this.verificationErrorForOutcome(outcome) }), - }; - } - - /** - * Run the advance step — mark phase complete. - * Gated by config.workflow.auto_advance or callback approval. - */ - private async runAdvanceStep( - phaseNumber: string, - _sessionOpts: SessionOptions, - callbacks: HumanGateCallbacks, - ): Promise { - const stepStart = Date.now(); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepStart, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Advance, - }); - - // Check if auto_advance or callback approves - let shouldAdvance = this.config.workflow.auto_advance; - - if (!shouldAdvance && callbacks.onBlockerDecision) { - try { - const decision = await callbacks.onBlockerDecision({ - phaseNumber, - step: PhaseStepType.Advance, - error: undefined, - }); - shouldAdvance = decision !== 'stop'; - } catch (err) { - this.logger?.warn(`Advance callback threw, auto-approving: ${err instanceof Error ? err.message : String(err)}`); - shouldAdvance = true; // Auto-approve on callback error - } - } else if (!shouldAdvance) { - // No callback, auto-approve - shouldAdvance = true; - } - - if (!shouldAdvance) { - const durationMs = Date.now() - stepStart; - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Advance, - success: false, - durationMs, - error: 'advance_rejected', - }); - return { - step: PhaseStepType.Advance, - success: false, - durationMs, - error: 'advance_rejected', - }; - } - - try { - await this.tools.phaseComplete(phaseNumber); - } catch (err) { - const durationMs = Date.now() - stepStart; - const errorMsg = err instanceof Error ? err.message : String(err); - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Advance, - success: false, - durationMs, - error: errorMsg, - }); - - return { - step: PhaseStepType.Advance, - success: false, - durationMs, - error: errorMsg, - }; - } - - const durationMs = Date.now() - stepStart; - - this.eventStream.emitEvent({ - type: GSDEventType.PhaseStepComplete, - timestamp: new Date().toISOString(), - sessionId: '', - phaseNumber, - step: PhaseStepType.Advance, - success: true, - durationMs, - }); - - return { - step: PhaseStepType.Advance, - success: true, - durationMs, - }; - } - - // ─── Helpers ─────────────────────────────────────────────────────────── - - /** - * Map PhaseStepType to PhaseType for prompt/context resolution. - */ - private stepToPhaseType(step: PhaseStepType): PhaseType { - const mapping: Record = { - [PhaseStepType.Discuss]: PhaseType.Discuss, - [PhaseStepType.Research]: PhaseType.Research, - [PhaseStepType.Plan]: PhaseType.Plan, - [PhaseStepType.PlanCheck]: PhaseType.Verify, - [PhaseStepType.Execute]: PhaseType.Execute, - [PhaseStepType.Verify]: PhaseType.Verify, - }; - return mapping[step] ?? PhaseType.Execute; - } - - /** - * Parse the verification outcome by checking VERIFICATION.md on disk. - * The verify session may succeed (no runtime errors) while writing - * status: gaps_found to VERIFICATION.md — we need to check the file, - * not just the session exit code. - * - * Falls back to session result if VERIFICATION.md can't be parsed. - */ - private async parseVerificationOutcome(result: PlanResult, phaseNumber: string): Promise { - // If the session itself crashed, that's a clear failure - if (!result.success) { - if (result.error?.subtype === 'human_review_needed') return 'human_needed'; - return 'gaps_found'; - } - - // Session succeeded — check what the verifier actually wrote to VERIFICATION.md - try { - const verStatus = await this.tools.exec('check.verification-status', [phaseNumber]); - const data = typeof verStatus === 'string' ? JSON.parse(verStatus) : verStatus; - const status = (data?.status ?? '').toLowerCase(); - - if (status === 'pass' || status === 'passed') return 'passed'; - if (status === 'human_needed') return 'human_needed'; - if (status === 'fail' || status === 'gaps_found') return 'gaps_found'; - if (status === 'missing') { - return 'gaps_found'; - } - // Unknown status — log and treat as gaps_found to be safe - this.logger?.warn(`Unknown verification status '${status}' for phase ${phaseNumber}, treating as gaps_found`); - return 'gaps_found'; - } catch (err) { - // Can't parse VERIFICATION.md — fail closed so a missing/broken status check never completes the phase. - this.logger?.warn(`Could not check verification status for phase ${phaseNumber}: ${err instanceof Error ? err.message : String(err)}`); - return 'status_unreadable'; - } - } - - private verificationErrorForOutcome(outcome: VerificationOutcome): string { - if (outcome === 'status_unreadable' || outcome === 'architectural_debt') return 'verification_gaps_found'; - return `verification_${outcome}`; - } - - /** - * Block phase completion when source files changed by this phase still contain - * unresolved TBD/FIXME/XXX comments. Markers are allowed only when the same - * line references tracked follow-up work (issue/PR number or DEF-* id). - * - * The debt scan is intentionally scoped to literal source paths declared in - * phase plan frontmatter `files_modified` and task `files`. Glob patterns are - * not expanded, and files modified during execution but omitted from the plan - * are not scanned; git-diff-based coverage would be a separate enhancement. - */ - private async checkArchitecturalDebt(phaseNumber: string): Promise { - let phaseOp: PhaseOpInfo; - try { - phaseOp = await this.tools.initPhaseOp(phaseNumber); - } catch (err) { - this.logger?.warn(`Could not initialize phase ${phaseNumber} for architectural debt check: ${err instanceof Error ? err.message : String(err)}`); - return { pass: false, findings: [], reason: 'scan_error' }; - } - - let planPaths: string[]; - try { - planPaths = await this.listPhasePlanPaths(phaseOp.phase_dir); - } catch { - return { pass: false, findings: [], reason: 'scan_error' }; - } - if (phaseOp.has_plans && planPaths.length === 0) { - this.logger?.warn(`No phase plans found for architectural debt check in phase ${phaseNumber}`); - return { pass: false, findings: [], reason: 'scan_error' }; - } - const filesToScan = new Set(); - - for (const planPath of planPaths) { - try { - const parsedPlan = await parsePlanFile(planPath); - for (const file of this.extractPlanFiles(parsedPlan)) { - if (this.shouldScanForArchitecturalDebt(file)) { - filesToScan.add(file); - } - } - } catch (err) { - this.logger?.warn(`Could not parse plan for architectural debt check (${planPath}): ${err instanceof Error ? err.message : String(err)}`); - return { pass: false, findings: [], reason: 'scan_error' }; - } - } - - const findings: ArchitecturalDebtFinding[] = []; - for (const file of filesToScan) { - const absolutePath = this.resolveProjectPath(file); - if (!absolutePath) { - findings.push({ file, line: 0, marker: 'path', text: 'File is outside the project root' }); - continue; - } - - try { - const content = await readFile(absolutePath, 'utf-8'); - findings.push(...this.findUnresolvedDebtMarkers(file, content)); - } catch (err) { - const code = typeof err === 'object' && err !== null && 'code' in err ? (err as { code?: unknown }).code : undefined; - if (code === 'ENOENT') { - continue; - } - - findings.push({ - file, - line: 0, - marker: 'read', - text: err instanceof Error ? err.message : String(err), - }); - } - } - - const hasDebtMarkers = findings.some(({ marker }) => marker !== 'path' && marker !== 'read'); - return { - pass: findings.length === 0, - findings, - reason: findings.length === 0 ? undefined : hasDebtMarkers ? 'markers_found' : 'scan_error', - }; - } - - private async listPhasePlanPaths(phaseDir: string): Promise { - const absolutePhaseDir = this.resolveProjectPath(phaseDir); - if (!absolutePhaseDir) { - const err = new Error(`Phase directory is outside the project root: ${phaseDir}`); - this.logger?.warn(err.message); - throw err; - } - - try { - const entries = await readdir(absolutePhaseDir, { withFileTypes: true }); - return entries - .filter((entry) => entry.isFile() && (entry.name === 'PLAN.md' || entry.name.endsWith('-PLAN.md'))) - .map((entry) => join(absolutePhaseDir, entry.name)); - } catch (err) { - this.logger?.warn(`Could not list phase plans for architectural debt check (${phaseDir}): ${err instanceof Error ? err.message : String(err)}`); - throw err; - } - } - - private extractPlanFiles(parsedPlan: ParsedPlan): string[] { - const files = new Set(); - for (const file of parsedPlan.frontmatter.files_modified ?? []) { - files.add(file); - } - for (const task of parsedPlan.tasks ?? []) { - for (const file of task.files ?? []) { - files.add(file); - } - } - return [...files]; - } - - private shouldScanForArchitecturalDebt(file: string): boolean { - return !/\.(md|markdown)$/i.test(file); - } - - private findUnresolvedDebtMarkers(file: string, content: string): ArchitecturalDebtFinding[] { - const findings: ArchitecturalDebtFinding[] = []; - const markerPattern = /(?:^|[^\w.])(TBD|FIXME|XXX)(?=\b(?:\.(?:\s|$)|[^\w.]|$))/i; - const lines = content.split(/\r?\n/); - - lines.forEach((line, index) => { - const match = markerPattern.exec(line); - if (match) { - const markerSegment = line.slice(match.index); - if (this.hasFormalDebtReference(markerSegment)) return; - - findings.push({ - file, - line: index + 1, - marker: match[1].toUpperCase(), - text: line.trim(), - }); - } - }); - - return findings; - } - - private hasFormalDebtReference(line: string): boolean { - return /\bDEF-[A-Z0-9-]+\b/i.test(line) || /\b(?:issue|issues|pr|pull request)\s+#?\d+\b/i.test(line) || /(?:^|\s)#\d+\b/.test(line); - } - - private resolveProjectPath(pathValue: string): string | undefined { - const root = this.realpathForBoundary(resolve(this.projectDir)); - if (!root) return undefined; - - const absolutePath = isAbsolute(pathValue) ? resolve(pathValue) : resolve(this.projectDir, pathValue); - const canonicalPath = this.realpathForBoundary(absolutePath); - if (!canonicalPath) return undefined; - - const relativePath = relative(root, canonicalPath); - if (relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))) { - return canonicalPath; - } - return undefined; - } - - private realpathForBoundary(pathValue: string): string | undefined { - const missingSegments: string[] = []; - let currentPath = pathValue; - - while (true) { - try { - return join(realpathSync(currentPath), ...missingSegments.reverse()); - } catch (err) { - const code = typeof err === 'object' && err !== null && 'code' in err ? (err as { code?: unknown }).code : undefined; - if (code !== 'ENOENT') return undefined; - - const parent = dirname(currentPath); - if (parent === currentPath) return undefined; - - missingSegments.push(basename(currentPath)); - currentPath = parent; - } - } - } - - /** - * Check RESEARCH.md for unresolved open questions (#1602). - * Returns the gate result — pass means safe to proceed to planning. - */ - private async checkResearchGate(phaseOp: PhaseOpInfo): Promise<{ pass: boolean; unresolvedQuestions: string[] }> { - try { - const researchPath = phaseOp.research_path || - join(phaseOp.phase_dir, `${phaseOp.padded_phase}-RESEARCH.md`); - const content = await readFile(researchPath, 'utf-8'); - return checkResearchGate(content); - } catch { - // File doesn't exist or can't be read — pass (nothing to gate on) - return { pass: true, unresolvedQuestions: [] }; - } - } - - /** - * Invoke the onBlockerDecision callback, falling back to auto-approve. - */ - private async invokeBlockerCallback( - callbacks: HumanGateCallbacks, - phaseNumber: string, - step: PhaseStepType, - error?: string, - ): Promise<'retry' | 'skip' | 'stop'> { - if (!callbacks.onBlockerDecision) { - return 'skip'; // Auto-approve: skip the blocker - } - - try { - const decision = await callbacks.onBlockerDecision({ phaseNumber, step, error }); - // Validate return value - if (decision === 'retry' || decision === 'skip' || decision === 'stop') { - return decision; - } - this.logger?.warn(`Unexpected blocker callback return value: ${String(decision)}, falling back to skip`); - return 'skip'; - } catch (err) { - this.logger?.warn(`Blocker callback threw, auto-approving: ${err instanceof Error ? err.message : String(err)}`); - return 'skip'; // Auto-approve on error - } - } - - /** - * Invoke the onVerificationReview callback, falling back to auto-accept. - */ - private async invokeVerificationCallback( - callbacks: HumanGateCallbacks, - phaseNumber: string, - stepResult: PhaseStepResult, - ): Promise<'accept' | 'reject' | 'retry'> { - if (!callbacks.onVerificationReview) { - return 'accept'; // Auto-approve - } - - try { - const decision = await callbacks.onVerificationReview({ phaseNumber, stepResult }); - if (decision === 'accept' || decision === 'reject' || decision === 'retry') { - return decision; - } - this.logger?.warn(`Unexpected verification callback return value: ${String(decision)}, falling back to accept`); - return 'accept'; - } catch (err) { - this.logger?.warn(`Verification callback threw, keeping human verification pending: ${err instanceof Error ? err.message : String(err)}`); - return 'accept'; // Treat as acknowledged; caller remains pending. - } - } -} diff --git a/sdk/src/plan-parser.test.ts b/sdk/src/plan-parser.test.ts deleted file mode 100644 index 8b827b187..000000000 --- a/sdk/src/plan-parser.test.ts +++ /dev/null @@ -1,579 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parsePlan, parseTasks, extractFrontmatter } from './plan-parser.js'; - -// ─── Fixtures ──────────────────────────────────────────────────────────────── - -const FULL_PLAN = `--- -phase: 03-features -plan: 01 -type: execute -wave: 2 -depends_on: [01-01, 01-02] -files_modified: [src/models/user.ts, src/api/users.ts, src/components/UserList.tsx] -autonomous: true -requirements: [R001, R003] -must_haves: - truths: - - "User can see existing messages" - - "User can send a message" - artifacts: - - path: src/components/Chat.tsx - provides: Message list rendering - min_lines: 30 - - path: src/app/api/chat/route.ts - provides: Message CRUD operations - key_links: - - from: src/components/Chat.tsx - to: /api/chat - via: fetch in useEffect - pattern: "fetch.*api/chat" ---- - - -Implement complete User feature as vertical slice. - -Purpose: Self-contained user management that can run parallel to other features. -Output: User model, API endpoints, and UI components. - - - -@~/.claude/get-shit-done/workflows/execute-plan.md -@~/.claude/get-shit-done/templates/summary.md - - - -@.planning/PROJECT.md -@.planning/ROADMAP.md -@.planning/STATE.md - -# Only include SUMMARY refs if genuinely needed -@src/relevant/source.ts - - - - - - Task 1: Create User model - src/models/user.ts - src/existing/types.ts, src/config/db.ts - Define User type with id, email, name, createdAt. Export TypeScript interface. - tsc --noEmit passes - - - User type is exported from src/models/user.ts - - Type includes id, email, name, createdAt fields - - User type exported and usable - - - - Task 2: Create User API endpoints - src/api/users.ts, src/api/middleware.ts - GET /users (list), GET /users/:id (single), POST /users (create). Use User type from model. - fetch tests pass for all endpoints - All CRUD operations work - - - - Verify UI visually - src/components/UserList.tsx - Start dev server and present for review. - User confirms layout is correct - Visual verification passed - - - - - -- [ ] npm run build succeeds -- [ ] API endpoints respond correctly - - - -- All tasks completed -- User feature works end-to-end - -`; - -const MINIMAL_PLAN = `--- -phase: 01-test -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: [] -autonomous: true -requirements: [] -must_haves: - truths: [] - artifacts: [] - key_links: [] ---- - - -Minimal test plan. - - - - - Single task - output.txt - Create output.txt - test -f output.txt - File exists - - -`; - -const MULTILINE_ACTION_PLAN = `--- -phase: 02-impl -plan: 01 -type: execute -wave: 1 -depends_on: [] -files_modified: [src/server.ts] -autonomous: true -requirements: [R005] -must_haves: - truths: [] - artifacts: [] - key_links: [] ---- - - - - Build server with config - src/server.ts - -Create the Express server with the following setup: - -1. Import express and configure middleware -2. Add routes for health check and API -3. Configure error handling with proper types: - - ValidationError => 400 - - NotFoundError => 404 - - Default => 500 - -Example code structure: -\`\`\`typescript -const app = express(); -app.get('/health', (req, res) => { - res.json({ status: 'ok' }); -}); -\`\`\` - -Make sure to handle the edge case where \`req.body\` contains -angle brackets like