chore(#191): retire sdk package seam
This commit is contained in:
5
.changeset/191-retire-sdk-package-seam.md
Normal file
5
.changeset/191-retire-sdk-package-seam.md
Normal file
@@ -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.
|
||||
@@ -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 <args>` behaves identically to
|
||||
* `node <packageDir>/sdk/dist/cli.js <args>`.
|
||||
*
|
||||
* 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
21
package.json
21
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",
|
||||
|
||||
@@ -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 <path>`~~ `[--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 <path>`~~ | `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 <file>`~~ | `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.*
|
||||
@@ -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.*
|
||||
@@ -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 <workflow>` | 3 | Safety gates (continue-here, error state, verification debt). |
|
||||
| 3.3 | `check config-gates <workflow>` | **1** | Batch `workflow.*` config for orchestration (replaces many `config-get`s). |
|
||||
| 3.4 | `check phase-ready <phase>` | **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 <phase>` | 2 | Structured UI/schema detection (replaces fragile grep). |
|
||||
| 3.7 | `check completion <scope>` | 2 | Phase or milestone completion rollup. |
|
||||
| 3.8 | `check verification-status <phase>` | 3 | VERIFICATION.md parsing for routing. |
|
||||
| 3.9 | `check ship-ready <phase>` | 3 | Ship preflight (`ship.md`). |
|
||||
| 3.10 | `route workflow-steps <workflow>` | ❌ **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.*
|
||||
@@ -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) |
|
||||
@@ -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)
|
||||
2502
sdk/package-lock.json
generated
2502
sdk/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
# PROJECT.md Template
|
||||
|
||||
Template for `.planning/PROJECT.md` — the living project context document.
|
||||
|
||||
<template>
|
||||
|
||||
```markdown
|
||||
# [Project Name]
|
||||
|
||||
## What This Is
|
||||
|
||||
[Current accurate description — 2-3 sentences. What does this product do and who is it for?
|
||||
Use the user's language and framing. Update whenever reality drifts from this description.]
|
||||
|
||||
## Core Value
|
||||
|
||||
[The ONE thing that matters most. If everything else fails, this must work.
|
||||
One sentence that drives prioritization when tradeoffs arise.]
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
<!-- Shipped and confirmed valuable. -->
|
||||
|
||||
(None yet — ship to validate)
|
||||
|
||||
### Active
|
||||
|
||||
<!-- Current scope. Building toward these. -->
|
||||
|
||||
- [ ] [Requirement 1]
|
||||
- [ ] [Requirement 2]
|
||||
- [ ] [Requirement 3]
|
||||
|
||||
### Out of Scope
|
||||
|
||||
<!-- Explicit boundaries. Includes reasoning to prevent re-adding. -->
|
||||
|
||||
- [Exclusion 1] — [why]
|
||||
- [Exclusion 2] — [why]
|
||||
|
||||
## Context
|
||||
|
||||
[Background information that informs implementation:
|
||||
- Technical environment or ecosystem
|
||||
- Relevant prior work or experience
|
||||
- User research or feedback themes
|
||||
- Known issues to address]
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[Type]**: [What] — [Why]
|
||||
- **[Type]**: [What] — [Why]
|
||||
|
||||
Common types: Tech stack, Timeline, Budget, Dependencies, Compatibility, Performance, Security
|
||||
|
||||
## Key Decisions
|
||||
|
||||
<!-- Decisions that constrain future work. Add throughout project lifecycle. -->
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| [Choice] | [Why] | [✓ Good / ⚠️ Revisit / — Pending] |
|
||||
|
||||
---
|
||||
*Last updated: [date] after [trigger]*
|
||||
```
|
||||
|
||||
</template>
|
||||
|
||||
<guidelines>
|
||||
|
||||
**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
|
||||
|
||||
</guidelines>
|
||||
|
||||
<evolution>
|
||||
|
||||
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)
|
||||
|
||||
</evolution>
|
||||
|
||||
<brownfield>
|
||||
|
||||
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
|
||||
|
||||
</brownfield>
|
||||
|
||||
<state_reference>
|
||||
|
||||
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.
|
||||
|
||||
</state_reference>
|
||||
@@ -1,231 +0,0 @@
|
||||
# Requirements Template
|
||||
|
||||
Template for `.planning/REQUIREMENTS.md` — checkable requirements that define "done."
|
||||
|
||||
<template>
|
||||
|
||||
```markdown
|
||||
# Requirements: [Project Name]
|
||||
|
||||
**Defined:** [date]
|
||||
**Core Value:** [from PROJECT.md]
|
||||
|
||||
## v1 Requirements
|
||||
|
||||
Requirements for initial release. Each maps to roadmap phases.
|
||||
|
||||
### 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
|
||||
|
||||
### [Category 2]
|
||||
|
||||
- [ ] **[CAT]-01**: [Requirement description]
|
||||
- [ ] **[CAT]-02**: [Requirement description]
|
||||
- [ ] **[CAT]-03**: [Requirement description]
|
||||
|
||||
### [Category 3]
|
||||
|
||||
- [ ] **[CAT]-01**: [Requirement description]
|
||||
- [ ] **[CAT]-02**: [Requirement description]
|
||||
|
||||
## v2 Requirements
|
||||
|
||||
Deferred to future release. Tracked but not in current roadmap.
|
||||
|
||||
### [Category]
|
||||
|
||||
- **[CAT]-01**: [Requirement description]
|
||||
- **[CAT]-02**: [Requirement description]
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Explicitly excluded. Documented to prevent scope creep.
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| [Feature] | [Why excluded] |
|
||||
| [Feature] | [Why excluded] |
|
||||
|
||||
## Traceability
|
||||
|
||||
Which phases cover which requirements. Updated during roadmap creation.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| AUTH-01 | Phase 1 | Pending |
|
||||
| AUTH-02 | Phase 1 | Pending |
|
||||
| AUTH-03 | Phase 1 | Pending |
|
||||
| AUTH-04 | Phase 1 | Pending |
|
||||
| [REQ-ID] | Phase [N] | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1 requirements: [X] total
|
||||
- Mapped to phases: [Y]
|
||||
- Unmapped: [Z] ⚠️
|
||||
|
||||
---
|
||||
*Requirements defined: [date]*
|
||||
*Last updated: [date] after [trigger]*
|
||||
```
|
||||
|
||||
</template>
|
||||
|
||||
<guidelines>
|
||||
|
||||
**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
|
||||
|
||||
</guidelines>
|
||||
|
||||
<evolution>
|
||||
|
||||
**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
|
||||
|
||||
</evolution>
|
||||
|
||||
<example>
|
||||
|
||||
```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*
|
||||
```
|
||||
|
||||
</example>
|
||||
@@ -1,204 +0,0 @@
|
||||
# Architecture Research Template
|
||||
|
||||
Template for `.planning/research/ARCHITECTURE.md` — system structure patterns for the project domain.
|
||||
|
||||
<template>
|
||||
|
||||
```markdown
|
||||
# Architecture Research
|
||||
|
||||
**Domain:** [domain type]
|
||||
**Researched:** [date]
|
||||
**Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
## Standard Architecture
|
||||
|
||||
### System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [Layer Name] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ [Comp] │ │ [Comp] │ │ [Comp] │ │ [Comp] │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │ │
|
||||
├───────┴────────────┴────────────┴────────────┴──────────────┤
|
||||
│ [Layer Name] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [Component] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ [Layer Name] │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ [Store] │ │ [Store] │ │ [Store] │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Responsibility | Typical Implementation |
|
||||
|-----------|----------------|------------------------|
|
||||
| [name] | [what it owns] | [how it's usually built] |
|
||||
| [name] | [what it owns] | [how it's usually built] |
|
||||
| [name] | [what it owns] | [how it's usually built] |
|
||||
|
||||
## Recommended Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── [folder]/ # [purpose]
|
||||
│ ├── [subfolder]/ # [purpose]
|
||||
│ └── [file].ts # [purpose]
|
||||
├── [folder]/ # [purpose]
|
||||
│ ├── [subfolder]/ # [purpose]
|
||||
│ └── [file].ts # [purpose]
|
||||
├── [folder]/ # [purpose]
|
||||
└── [folder]/ # [purpose]
|
||||
```
|
||||
|
||||
### Structure Rationale
|
||||
|
||||
- **[folder]/:** [why organized this way]
|
||||
- **[folder]/:** [why organized this way]
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Pattern 1: [Pattern Name]
|
||||
|
||||
**What:** [description]
|
||||
**When to use:** [conditions]
|
||||
**Trade-offs:** [pros and cons]
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// [Brief code example showing the pattern]
|
||||
```
|
||||
|
||||
### Pattern 2: [Pattern Name]
|
||||
|
||||
**What:** [description]
|
||||
**When to use:** [conditions]
|
||||
**Trade-offs:** [pros and cons]
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// [Brief code example showing the pattern]
|
||||
```
|
||||
|
||||
### Pattern 3: [Pattern Name]
|
||||
|
||||
**What:** [description]
|
||||
**When to use:** [conditions]
|
||||
**Trade-offs:** [pros and cons]
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
[User Action]
|
||||
↓
|
||||
[Component] → [Handler] → [Service] → [Data Store]
|
||||
↓ ↓ ↓ ↓
|
||||
[Response] ← [Transform] ← [Query] ← [Database]
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```
|
||||
[State Store]
|
||||
↓ (subscribe)
|
||||
[Components] ←→ [Actions] → [Reducers/Mutations] → [State Store]
|
||||
```
|
||||
|
||||
### Key Data Flows
|
||||
|
||||
1. **[Flow name]:** [description of how data moves]
|
||||
2. **[Flow name]:** [description of how data moves]
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
| Scale | Architecture Adjustments |
|
||||
|-------|--------------------------|
|
||||
| 0-1k users | [approach — usually monolith is fine] |
|
||||
| 1k-100k users | [approach — what to optimize first] |
|
||||
| 100k+ users | [approach — when to consider splitting] |
|
||||
|
||||
### Scaling Priorities
|
||||
|
||||
1. **First bottleneck:** [what breaks first, how to fix]
|
||||
2. **Second bottleneck:** [what breaks next, how to fix]
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Anti-Pattern 1: [Name]
|
||||
|
||||
**What people do:** [the mistake]
|
||||
**Why it's wrong:** [the problem it causes]
|
||||
**Do this instead:** [the correct approach]
|
||||
|
||||
### Anti-Pattern 2: [Name]
|
||||
|
||||
**What people do:** [the mistake]
|
||||
**Why it's wrong:** [the problem it causes]
|
||||
**Do this instead:** [the correct approach]
|
||||
|
||||
## Integration Points
|
||||
|
||||
### External Services
|
||||
|
||||
| Service | Integration Pattern | Notes |
|
||||
|---------|---------------------|-------|
|
||||
| [service] | [how to connect] | [gotchas] |
|
||||
| [service] | [how to connect] | [gotchas] |
|
||||
|
||||
### Internal Boundaries
|
||||
|
||||
| Boundary | Communication | Notes |
|
||||
|----------|---------------|-------|
|
||||
| [module A ↔ module B] | [API/events/direct] | [considerations] |
|
||||
|
||||
## Sources
|
||||
|
||||
- [Architecture references]
|
||||
- [Official documentation]
|
||||
- [Case studies]
|
||||
|
||||
---
|
||||
*Architecture research for: [domain]*
|
||||
*Researched: [date]*
|
||||
```
|
||||
|
||||
</template>
|
||||
|
||||
<guidelines>
|
||||
|
||||
**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
|
||||
|
||||
</guidelines>
|
||||
@@ -1,147 +0,0 @@
|
||||
# Features Research Template
|
||||
|
||||
Template for `.planning/research/FEATURES.md` — feature landscape for the project domain.
|
||||
|
||||
<template>
|
||||
|
||||
```markdown
|
||||
# Feature Research
|
||||
|
||||
**Domain:** [domain type]
|
||||
**Researched:** [date]
|
||||
**Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
## Feature Landscape
|
||||
|
||||
### Table Stakes (Users Expect These)
|
||||
|
||||
Features users assume exist. Missing these = product feels incomplete.
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| [feature] | [user expectation] | LOW/MEDIUM/HIGH | [implementation notes] |
|
||||
| [feature] | [user expectation] | LOW/MEDIUM/HIGH | [implementation notes] |
|
||||
| [feature] | [user expectation] | LOW/MEDIUM/HIGH | [implementation notes] |
|
||||
|
||||
### Differentiators (Competitive Advantage)
|
||||
|
||||
Features that set the product apart. Not required, but valuable.
|
||||
|
||||
| Feature | Value Proposition | Complexity | Notes |
|
||||
|---------|-------------------|------------|-------|
|
||||
| [feature] | [why it matters] | LOW/MEDIUM/HIGH | [implementation notes] |
|
||||
| [feature] | [why it matters] | LOW/MEDIUM/HIGH | [implementation notes] |
|
||||
| [feature] | [why it matters] | LOW/MEDIUM/HIGH | [implementation notes] |
|
||||
|
||||
### Anti-Features (Commonly Requested, Often Problematic)
|
||||
|
||||
Features that seem good but create problems.
|
||||
|
||||
| Feature | Why Requested | Why Problematic | Alternative |
|
||||
|---------|---------------|-----------------|-------------|
|
||||
| [feature] | [surface appeal] | [actual problems] | [better approach] |
|
||||
| [feature] | [surface appeal] | [actual problems] | [better approach] |
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
[Feature A]
|
||||
└──requires──> [Feature B]
|
||||
└──requires──> [Feature C]
|
||||
|
||||
[Feature D] ──enhances──> [Feature A]
|
||||
|
||||
[Feature E] ──conflicts──> [Feature F]
|
||||
```
|
||||
|
||||
### Dependency Notes
|
||||
|
||||
- **[Feature A] requires [Feature B]:** [why the dependency exists]
|
||||
- **[Feature D] enhances [Feature A]:** [how they work together]
|
||||
- **[Feature E] conflicts with [Feature F]:** [why they're incompatible]
|
||||
|
||||
## MVP Definition
|
||||
|
||||
### Launch With (v1)
|
||||
|
||||
Minimum viable product — what's needed to validate the concept.
|
||||
|
||||
- [ ] [Feature] — [why essential]
|
||||
- [ ] [Feature] — [why essential]
|
||||
- [ ] [Feature] — [why essential]
|
||||
|
||||
### Add After Validation (v1.x)
|
||||
|
||||
Features to add once core is working.
|
||||
|
||||
- [ ] [Feature] — [trigger for adding]
|
||||
- [ ] [Feature] — [trigger for adding]
|
||||
|
||||
### Future Consideration (v2+)
|
||||
|
||||
Features to defer until product-market fit is established.
|
||||
|
||||
- [ ] [Feature] — [why defer]
|
||||
- [ ] [Feature] — [why defer]
|
||||
|
||||
## Feature Prioritization Matrix
|
||||
|
||||
| Feature | User Value | Implementation Cost | Priority |
|
||||
|---------|------------|---------------------|----------|
|
||||
| [feature] | HIGH/MEDIUM/LOW | HIGH/MEDIUM/LOW | P1/P2/P3 |
|
||||
| [feature] | HIGH/MEDIUM/LOW | HIGH/MEDIUM/LOW | P1/P2/P3 |
|
||||
| [feature] | HIGH/MEDIUM/LOW | HIGH/MEDIUM/LOW | P1/P2/P3 |
|
||||
|
||||
**Priority key:**
|
||||
- P1: Must have for launch
|
||||
- P2: Should have, add when possible
|
||||
- P3: Nice to have, future consideration
|
||||
|
||||
## Competitor Feature Analysis
|
||||
|
||||
| Feature | Competitor A | Competitor B | Our Approach |
|
||||
|---------|--------------|--------------|--------------|
|
||||
| [feature] | [how they do it] | [how they do it] | [our plan] |
|
||||
| [feature] | [how they do it] | [how they do it] | [our plan] |
|
||||
|
||||
## Sources
|
||||
|
||||
- [Competitor products analyzed]
|
||||
- [User research or feedback sources]
|
||||
- [Industry standards referenced]
|
||||
|
||||
---
|
||||
*Feature research for: [domain]*
|
||||
*Researched: [date]*
|
||||
```
|
||||
|
||||
</template>
|
||||
|
||||
<guidelines>
|
||||
|
||||
**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
|
||||
|
||||
</guidelines>
|
||||
@@ -1,200 +0,0 @@
|
||||
# Pitfalls Research Template
|
||||
|
||||
Template for `.planning/research/PITFALLS.md` — common mistakes to avoid in the project domain.
|
||||
|
||||
<template>
|
||||
|
||||
```markdown
|
||||
# Pitfalls Research
|
||||
|
||||
**Domain:** [domain type]
|
||||
**Researched:** [date]
|
||||
**Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### Pitfall 1: [Name]
|
||||
|
||||
**What goes wrong:**
|
||||
[Description of the failure mode]
|
||||
|
||||
**Why it happens:**
|
||||
[Root cause — why developers make this mistake]
|
||||
|
||||
**How to avoid:**
|
||||
[Specific prevention strategy]
|
||||
|
||||
**Warning signs:**
|
||||
[How to detect this early before it becomes a problem]
|
||||
|
||||
**Phase to address:**
|
||||
[Which roadmap phase should prevent this]
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 2: [Name]
|
||||
|
||||
**What goes wrong:**
|
||||
[Description of the failure mode]
|
||||
|
||||
**Why it happens:**
|
||||
[Root cause — why developers make this mistake]
|
||||
|
||||
**How to avoid:**
|
||||
[Specific prevention strategy]
|
||||
|
||||
**Warning signs:**
|
||||
[How to detect this early before it becomes a problem]
|
||||
|
||||
**Phase to address:**
|
||||
[Which roadmap phase should prevent this]
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 3: [Name]
|
||||
|
||||
**What goes wrong:**
|
||||
[Description of the failure mode]
|
||||
|
||||
**Why it happens:**
|
||||
[Root cause — why developers make this mistake]
|
||||
|
||||
**How to avoid:**
|
||||
[Specific prevention strategy]
|
||||
|
||||
**Warning signs:**
|
||||
[How to detect this early before it becomes a problem]
|
||||
|
||||
**Phase to address:**
|
||||
[Which roadmap phase should prevent this]
|
||||
|
||||
---
|
||||
|
||||
[Continue for all critical pitfalls...]
|
||||
|
||||
## Technical Debt Patterns
|
||||
|
||||
Shortcuts that seem reasonable but create long-term problems.
|
||||
|
||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||
|----------|-------------------|----------------|-----------------|
|
||||
| [shortcut] | [benefit] | [cost] | [conditions, or "never"] |
|
||||
| [shortcut] | [benefit] | [cost] | [conditions, or "never"] |
|
||||
| [shortcut] | [benefit] | [cost] | [conditions, or "never"] |
|
||||
|
||||
## Integration Gotchas
|
||||
|
||||
Common mistakes when connecting to external services.
|
||||
|
||||
| Integration | Common Mistake | Correct Approach |
|
||||
|-------------|----------------|------------------|
|
||||
| [service] | [what people do wrong] | [what to do instead] |
|
||||
| [service] | [what people do wrong] | [what to do instead] |
|
||||
| [service] | [what people do wrong] | [what to do instead] |
|
||||
|
||||
## Performance Traps
|
||||
|
||||
Patterns that work at small scale but fail as usage grows.
|
||||
|
||||
| Trap | Symptoms | Prevention | When It Breaks |
|
||||
|------|----------|------------|----------------|
|
||||
| [trap] | [how you notice] | [how to avoid] | [scale threshold] |
|
||||
| [trap] | [how you notice] | [how to avoid] | [scale threshold] |
|
||||
| [trap] | [how you notice] | [how to avoid] | [scale threshold] |
|
||||
|
||||
## Security Mistakes
|
||||
|
||||
Domain-specific security issues beyond general web security.
|
||||
|
||||
| Mistake | Risk | Prevention |
|
||||
|---------|------|------------|
|
||||
| [mistake] | [what could happen] | [how to avoid] |
|
||||
| [mistake] | [what could happen] | [how to avoid] |
|
||||
| [mistake] | [what could happen] | [how to avoid] |
|
||||
|
||||
## UX Pitfalls
|
||||
|
||||
Common user experience mistakes in this domain.
|
||||
|
||||
| Pitfall | User Impact | Better Approach |
|
||||
|---------|-------------|-----------------|
|
||||
| [pitfall] | [how users suffer] | [what to do instead] |
|
||||
| [pitfall] | [how users suffer] | [what to do instead] |
|
||||
| [pitfall] | [how users suffer] | [what to do instead] |
|
||||
|
||||
## "Looks Done But Isn't" Checklist
|
||||
|
||||
Things that appear complete but are missing critical pieces.
|
||||
|
||||
- [ ] **[Feature]:** Often missing [thing] — verify [check]
|
||||
- [ ] **[Feature]:** Often missing [thing] — verify [check]
|
||||
- [ ] **[Feature]:** Often missing [thing] — verify [check]
|
||||
- [ ] **[Feature]:** Often missing [thing] — verify [check]
|
||||
|
||||
## Recovery Strategies
|
||||
|
||||
When pitfalls occur despite prevention, how to recover.
|
||||
|
||||
| Pitfall | Recovery Cost | Recovery Steps |
|
||||
|---------|---------------|----------------|
|
||||
| [pitfall] | LOW/MEDIUM/HIGH | [what to do] |
|
||||
| [pitfall] | LOW/MEDIUM/HIGH | [what to do] |
|
||||
| [pitfall] | LOW/MEDIUM/HIGH | [what to do] |
|
||||
|
||||
## Pitfall-to-Phase Mapping
|
||||
|
||||
How roadmap phases should address these pitfalls.
|
||||
|
||||
| Pitfall | Prevention Phase | Verification |
|
||||
|---------|------------------|--------------|
|
||||
| [pitfall] | Phase [X] | [how to verify prevention worked] |
|
||||
| [pitfall] | Phase [X] | [how to verify prevention worked] |
|
||||
| [pitfall] | Phase [X] | [how to verify prevention worked] |
|
||||
|
||||
## Sources
|
||||
|
||||
- [Post-mortems referenced]
|
||||
- [Community discussions]
|
||||
- [Official "gotchas" documentation]
|
||||
- [Personal experience / known issues]
|
||||
|
||||
---
|
||||
*Pitfalls research for: [domain]*
|
||||
*Researched: [date]*
|
||||
```
|
||||
|
||||
</template>
|
||||
|
||||
<guidelines>
|
||||
|
||||
**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
|
||||
|
||||
</guidelines>
|
||||
@@ -1,120 +0,0 @@
|
||||
# Stack Research Template
|
||||
|
||||
Template for `.planning/research/STACK.md` — recommended technologies for the project domain.
|
||||
|
||||
<template>
|
||||
|
||||
```markdown
|
||||
# Stack Research
|
||||
|
||||
**Domain:** [domain type]
|
||||
**Researched:** [date]
|
||||
**Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
## Recommended Stack
|
||||
|
||||
### Core Technologies
|
||||
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| [name] | [version] | [what it does] | [why experts use it for this domain] |
|
||||
| [name] | [version] | [what it does] | [why experts use it for this domain] |
|
||||
| [name] | [version] | [what it does] | [why experts use it for this domain] |
|
||||
|
||||
### Supporting Libraries
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| [name] | [version] | [what it does] | [specific use case] |
|
||||
| [name] | [version] | [what it does] | [specific use case] |
|
||||
| [name] | [version] | [what it does] | [specific use case] |
|
||||
|
||||
### Development Tools
|
||||
|
||||
| Tool | Purpose | Notes |
|
||||
|------|---------|-------|
|
||||
| [name] | [what it does] | [configuration tips] |
|
||||
| [name] | [what it does] | [configuration tips] |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Core
|
||||
npm install [packages]
|
||||
|
||||
# Supporting
|
||||
npm install [packages]
|
||||
|
||||
# Dev dependencies
|
||||
npm install -D [packages]
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Recommended | Alternative | When to Use Alternative |
|
||||
|-------------|-------------|-------------------------|
|
||||
| [our choice] | [other option] | [conditions where alternative is better] |
|
||||
| [our choice] | [other option] | [conditions where alternative is better] |
|
||||
|
||||
## What NOT to Use
|
||||
|
||||
| Avoid | Why | Use Instead |
|
||||
|-------|-----|-------------|
|
||||
| [technology] | [specific problem] | [recommended alternative] |
|
||||
| [technology] | [specific problem] | [recommended alternative] |
|
||||
|
||||
## Stack Patterns by Variant
|
||||
|
||||
**If [condition]:**
|
||||
- Use [variation]
|
||||
- Because [reason]
|
||||
|
||||
**If [condition]:**
|
||||
- Use [variation]
|
||||
- Because [reason]
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
| Package A | Compatible With | Notes |
|
||||
|-----------|-----------------|-------|
|
||||
| [package@version] | [package@version] | [compatibility notes] |
|
||||
|
||||
## Sources
|
||||
|
||||
- [Context7 library ID] — [topics fetched]
|
||||
- [Official docs URL] — [what was verified]
|
||||
- [Other source] — [confidence level]
|
||||
|
||||
---
|
||||
*Stack research for: [domain]*
|
||||
*Researched: [date]*
|
||||
```
|
||||
|
||||
</template>
|
||||
|
||||
<guidelines>
|
||||
|
||||
**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
|
||||
|
||||
</guidelines>
|
||||
@@ -1,170 +0,0 @@
|
||||
# Research Summary Template
|
||||
|
||||
Template for `.planning/research/SUMMARY.md` — executive summary of project research with roadmap implications.
|
||||
|
||||
<template>
|
||||
|
||||
```markdown
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** [name from PROJECT.md]
|
||||
**Domain:** [inferred domain type]
|
||||
**Researched:** [date]
|
||||
**Confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
## Executive Summary
|
||||
|
||||
[2-3 paragraph overview of research findings]
|
||||
|
||||
- What type of product this is and how experts build it
|
||||
- The recommended approach based on research
|
||||
- Key risks and how to mitigate them
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
[Summary from STACK.md — 1-2 paragraphs]
|
||||
|
||||
**Core technologies:**
|
||||
- [Technology]: [purpose] — [why recommended]
|
||||
- [Technology]: [purpose] — [why recommended]
|
||||
- [Technology]: [purpose] — [why recommended]
|
||||
|
||||
### Expected Features
|
||||
|
||||
[Summary from FEATURES.md]
|
||||
|
||||
**Must have (table stakes):**
|
||||
- [Feature] — users expect this
|
||||
- [Feature] — users expect this
|
||||
|
||||
**Should have (competitive):**
|
||||
- [Feature] — differentiator
|
||||
- [Feature] — differentiator
|
||||
|
||||
**Defer (v2+):**
|
||||
- [Feature] — not essential for launch
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
[Summary from ARCHITECTURE.md — 1 paragraph]
|
||||
|
||||
**Major components:**
|
||||
1. [Component] — [responsibility]
|
||||
2. [Component] — [responsibility]
|
||||
3. [Component] — [responsibility]
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
[Top 3-5 from PITFALLS.md]
|
||||
|
||||
1. **[Pitfall]** — [how to avoid]
|
||||
2. **[Pitfall]** — [how to avoid]
|
||||
3. **[Pitfall]** — [how to avoid]
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Based on research, suggested phase structure:
|
||||
|
||||
### Phase 1: [Name]
|
||||
**Rationale:** [why this comes first based on research]
|
||||
**Delivers:** [what this phase produces]
|
||||
**Addresses:** [features from FEATURES.md]
|
||||
**Avoids:** [pitfall from PITFALLS.md]
|
||||
|
||||
### Phase 2: [Name]
|
||||
**Rationale:** [why this order]
|
||||
**Delivers:** [what this phase produces]
|
||||
**Uses:** [stack elements from STACK.md]
|
||||
**Implements:** [architecture component]
|
||||
|
||||
### Phase 3: [Name]
|
||||
**Rationale:** [why this order]
|
||||
**Delivers:** [what this phase produces]
|
||||
|
||||
[Continue for suggested phases...]
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- [Why this order based on dependencies discovered]
|
||||
- [Why this grouping based on architecture patterns]
|
||||
- [How this avoids pitfalls from research]
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases likely needing deeper research during planning:
|
||||
- **Phase [X]:** [reason — e.g., "complex integration, needs API research"]
|
||||
- **Phase [Y]:** [reason — e.g., "niche domain, sparse documentation"]
|
||||
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase [X]:** [reason — e.g., "well-documented, established patterns"]
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | [HIGH/MEDIUM/LOW] | [reason] |
|
||||
| Features | [HIGH/MEDIUM/LOW] | [reason] |
|
||||
| Architecture | [HIGH/MEDIUM/LOW] | [reason] |
|
||||
| Pitfalls | [HIGH/MEDIUM/LOW] | [reason] |
|
||||
|
||||
**Overall confidence:** [HIGH/MEDIUM/LOW]
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
[Any areas where research was inconclusive or needs validation during implementation]
|
||||
|
||||
- [Gap]: [how to handle during planning/execution]
|
||||
- [Gap]: [how to handle during planning/execution]
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [Context7 library ID] — [topics]
|
||||
- [Official docs URL] — [what was checked]
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Source] — [finding]
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- [Source] — [finding, needs validation]
|
||||
|
||||
---
|
||||
*Research completed: [date]*
|
||||
*Ready for roadmap: yes*
|
||||
```
|
||||
|
||||
</template>
|
||||
|
||||
<guidelines>
|
||||
|
||||
**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
|
||||
|
||||
</guidelines>
|
||||
@@ -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] <!-- brackets optional, parser handles both formats -->
|
||||
**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 | - |
|
||||
```
|
||||
|
||||
<guidelines>
|
||||
**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 `<details>` tags
|
||||
- Add new milestone sections for upcoming work
|
||||
- Keep continuous phase numbering (never restart at 01)
|
||||
</guidelines>
|
||||
|
||||
<status_values>
|
||||
- `Not started` - Haven't begun
|
||||
- `In progress` - Currently working
|
||||
- `Complete` - Done (add completion date)
|
||||
- `Deferred` - Pushed to later (with reason)
|
||||
</status_values>
|
||||
|
||||
## 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
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.0 MVP (Phases 1-4) - SHIPPED YYYY-MM-DD</summary>
|
||||
|
||||
### 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 ...]
|
||||
|
||||
</details>
|
||||
|
||||
### 🚧 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 `<details>` for readability
|
||||
- Current/future milestones expanded
|
||||
- Continuous phase numbering (01-99)
|
||||
- Progress table includes milestone column
|
||||
@@ -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"]
|
||||
```
|
||||
|
||||
<purpose>
|
||||
|
||||
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
|
||||
|
||||
</purpose>
|
||||
|
||||
<lifecycle>
|
||||
|
||||
**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
|
||||
|
||||
</lifecycle>
|
||||
|
||||
<sections>
|
||||
|
||||
### 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
|
||||
|
||||
</sections>
|
||||
|
||||
<size_constraint>
|
||||
|
||||
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.
|
||||
|
||||
</size_constraint>
|
||||
@@ -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<string, Record<string, string>> = ${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);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/<name>`
|
||||
* 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/<name> 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');
|
||||
});
|
||||
});
|
||||
@@ -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/<name>` 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<unknown>)
|
||||
| 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<unknown>)
|
||||
| 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');
|
||||
});
|
||||
});
|
||||
@@ -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<GSDEventBase> = {}): Omit<GSDEventBase, 'type'> {
|
||||
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, '');
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <prompt> 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 <subcommand>` 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>): 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');
|
||||
});
|
||||
});
|
||||
589
sdk/src/cli.ts
589
sdk/src/cli.ts
@@ -1,589 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI entry point for gsd-sdk.
|
||||
*
|
||||
* Usage: gsd-sdk run "<prompt>" [--project-dir <dir>] [--ws-port <port>]
|
||||
* [--model <model>] [--max-budget <n>]
|
||||
*/
|
||||
|
||||
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/<name>/. */
|
||||
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 <command> [args] [options]
|
||||
|
||||
Commands:
|
||||
run <prompt> 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 <argv...> Registered query handlers only (longest-prefix argv match; see QUERY-HANDLERS.md)
|
||||
Use --pick <field> to extract a specific field from JSON output
|
||||
|
||||
Options:
|
||||
--init <input> Bootstrap from a PRD before running (auto only)
|
||||
Accepts @path/to/prd.md or "description text"
|
||||
--project-dir <dir> Project directory (default: cwd)
|
||||
--ws <name> Route .planning/ to .planning/workstreams/<name>/
|
||||
--ws-port <port> Enable WebSocket transport on <port>
|
||||
--model <model> Override LLM model
|
||||
--max-budget <n> 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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string>((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<void> {
|
||||
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 <prompt>", "gsd-sdk auto", "gsd-sdk init [input]", or "gsd-sdk query <command>"');
|
||||
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();
|
||||
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
@@ -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 type="checkpoint:human-verify">` task emission; the planner
|
||||
* embeds verification details into the relevant `auto` task's
|
||||
* `<verify><human-check>` 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 `<decisions>` 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<string, unknown>;
|
||||
/** 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<GSDConfig> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>): GSDConfig {
|
||||
return canonicalMergeDefaults(parsed) as unknown as GSDConfig;
|
||||
}
|
||||
@@ -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<string, unknown>)?.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<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | 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<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | 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<string, unknown>).granularity
|
||||
?? (config.planning as Record<string, unknown> | 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<string, unknown>).branching_strategy).toBeUndefined();
|
||||
expect((parsed as Record<string, Record<string, unknown>>).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<string, unknown>).sub_repos).toBeUndefined();
|
||||
expect((parsed as Record<string, Record<string, unknown>>).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<string, unknown>).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<string, unknown>).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<string, unknown>).granularity
|
||||
?? (parsed as Record<string, Record<string, unknown>>).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<string, unknown>).granularity
|
||||
?? (parsed as Record<string, Record<string, unknown>>).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<string, Record<string, unknown>>).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<string, unknown>);
|
||||
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<string, Record<string, unknown>>).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<string, Record<string, unknown>>).sub_repos).toBeUndefined();
|
||||
// canonical nested wins even when explicit null is used to unset
|
||||
expect((parsed as Record<string, Record<string, unknown>>).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<string, unknown>).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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = 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<string> = new Set(_schemaManifest.validKeys);
|
||||
export const RUNTIME_STATE_KEYS: ReadonlySet<string> = 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<string, unknown>;
|
||||
|
||||
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/<name>/config.json */
|
||||
workstream?: string;
|
||||
/** Optional callback to observe normalizations applied during load */
|
||||
onNormalizations?: (normalizations: Normalization[]) => void;
|
||||
}
|
||||
|
||||
// ─── Depth → Granularity mapping ─────────────────────────────────────────────
|
||||
|
||||
const DEPTH_TO_GRANULARITY: Record<string, string> = {
|
||||
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<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...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<string, unknown>, ov as Record<string, unknown>);
|
||||
} else {
|
||||
result[key] = deepMergeConfig({}, ov as Record<string, unknown>);
|
||||
}
|
||||
} 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<string, unknown>): NormalizationResult {
|
||||
const result: Record<string, unknown> = { ...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<string, unknown> | 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<string, unknown> | 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<string, unknown>): MergedConfig {
|
||||
// Start with a deep clone of defaults, then overlay parsed
|
||||
const defaults = JSON.parse(JSON.stringify(CONFIG_DEFAULTS)) as Record<string, unknown>;
|
||||
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<MergedConfig> {
|
||||
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<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} 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<MigrationReport> {
|
||||
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<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} 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<string, unknown> | 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 };
|
||||
}
|
||||
@@ -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<string> {
|
||||
return mkdtemp(join(tmpdir(), 'gsd-ctx-'));
|
||||
}
|
||||
|
||||
async function createPlanningDir(projectDir: string, files: Record<string, string>): Promise<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, FileSpec[]> = {
|
||||
[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<TruncationOptions>, 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<ContextFiles> {
|
||||
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<string | undefined> {
|
||||
try {
|
||||
await access(filePath, constants.R_OK);
|
||||
return await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { PHASE_FILE_MANIFEST };
|
||||
export type { FileSpec };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<QueryResult>;
|
||||
/** @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<typeof validationError> | ReturnType<typeof unknownCommandError>, stderr: string[] = []): QueryDispatchResult {
|
||||
return toDispatchFailure(error, stderr);
|
||||
}
|
||||
|
||||
export async function runQueryDispatch(deps: QueryDispatchDeps, queryArgv: string[]): Promise<QueryDispatchResult> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from './errors/index.js';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<TransportHandler> = 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, 'type'>,
|
||||
): 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, 'type'>,
|
||||
): 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<string, unknown> } =>
|
||||
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<string, unknown>,
|
||||
} 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, 'type'>,
|
||||
): 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<GSDEvent, 'type'>,
|
||||
): 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<GSDEvent, 'type'>,
|
||||
): GSDToolUseSummaryEvent {
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.ToolUseSummary,
|
||||
summary: msg.summary,
|
||||
toolUseIds: msg.preceding_tool_use_ids,
|
||||
} as GSDToolUseSummaryEvent;
|
||||
}
|
||||
|
||||
private mapRateLimitMessage(
|
||||
msg: SDKRateLimitEvent,
|
||||
base: Omit<GSDEvent, 'type'>,
|
||||
): 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<GSDEvent, 'type'>,
|
||||
): GSDStreamEvent {
|
||||
return {
|
||||
...base,
|
||||
type: GSDEventType.StreamEvent,
|
||||
event: msg.event,
|
||||
} as GSDStreamEvent;
|
||||
}
|
||||
}
|
||||
@@ -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<unknown> {
|
||||
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 <command> [...args]` in `projectDir` and parse stdout as JSON.
|
||||
*/
|
||||
export async function captureGsdToolsOutput(
|
||||
command: string,
|
||||
args: string[],
|
||||
projectDir: string,
|
||||
): Promise<unknown> {
|
||||
const { stdout } = await execGsdTools(projectDir, command, args);
|
||||
return parseGsdToolsJson(stdout, projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `node gsd-tools.cjs <command> [...args]` and return raw stdout (no JSON parse).
|
||||
*/
|
||||
export async function captureGsdToolsStdout(
|
||||
command: string,
|
||||
args: string[],
|
||||
projectDir: string,
|
||||
): Promise<string> {
|
||||
const { stdout } = await execGsdTools(projectDir, command, args);
|
||||
return stdout;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"slug":"my-phase"}
|
||||
@@ -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"}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
'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 + <behavior> 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<string> {
|
||||
return new Set<string>([
|
||||
...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<string, string> = buildGoldenParityExceptions();
|
||||
|
||||
function buildGoldenParityExceptions(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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')}`);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
const o = { ...data };
|
||||
for (const k of INIT_QUICK_VOLATILE_KEYS) {
|
||||
delete o[k];
|
||||
}
|
||||
return o;
|
||||
}
|
||||
@@ -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<string> {
|
||||
const s = new Set<string>(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;
|
||||
}
|
||||
@@ -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<string, unknown> => {
|
||||
const o = { ...(d as Record<string, unknown>) };
|
||||
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<string, unknown> => {
|
||||
const o = { ...(d as Record<string, unknown>) };
|
||||
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<string, unknown>)?.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 <field>`', async ({ skip }) => {
|
||||
const registry = createRegistry();
|
||||
const sdkResult = await registry.dispatch('state.get', ['milestone'], REPO_ROOT);
|
||||
if ((sdkResult.data as Record<string, unknown>)?.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);
|
||||
});
|
||||
});
|
||||
@@ -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<QueryHandler, string[]>();
|
||||
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));
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string> {
|
||||
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 <msg> --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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<unknown> {
|
||||
return this.executeWithToolsError(legacyCommand, legacyArgs, () =>
|
||||
this.bridge.dispatchHotpath(
|
||||
legacyCommand,
|
||||
legacyArgs,
|
||||
registryCommand,
|
||||
registryArgs,
|
||||
mode,
|
||||
));
|
||||
}
|
||||
|
||||
private async executeWithToolsError<T>(command: string, args: string[], work: () => Promise<T>): Promise<T> {
|
||||
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<unknown> {
|
||||
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<string> {
|
||||
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<unknown> {
|
||||
return this.exec('state', ['load']);
|
||||
}
|
||||
|
||||
async roadmapAnalyze(): Promise<RoadmapAnalysis> {
|
||||
return this.exec('roadmap', ['analyze']) as Promise<RoadmapAnalysis>;
|
||||
}
|
||||
|
||||
async phaseComplete(phase: string): Promise<string> {
|
||||
return this.hotpathMethods.phaseComplete(phase);
|
||||
}
|
||||
|
||||
async commit(message: string, files?: string[]): Promise<string> {
|
||||
return this.hotpathMethods.commit(message, files);
|
||||
}
|
||||
|
||||
async verifySummary(path: string): Promise<string> {
|
||||
return this.execRaw('verify-summary', [path]);
|
||||
}
|
||||
|
||||
async initExecutePhase(phase: string): Promise<string> {
|
||||
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<PhaseOpInfo> {
|
||||
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<string | null> {
|
||||
return this.hotpathMethods.configGet(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin phase state tracking in gsd-tools.cjs.
|
||||
*/
|
||||
async stateBeginPhase(phaseNumber: string): Promise<string> {
|
||||
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<PhasePlanIndex> {
|
||||
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<InitNewProjectInfo> {
|
||||
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<string> {
|
||||
return this.hotpathMethods.configSet(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export { resolveGsdToolsPath } from './query-gsd-tools-path.js';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, Partial<TransportPolicy>> = Object.fromEntries(
|
||||
TRANSPORT_RAW_COMMANDS.map((command) => [command, { outputMode: 'raw' as const }]),
|
||||
);
|
||||
|
||||
const COMMAND_POLICY_OVERRIDES: Record<string, Partial<TransportPolicy>> = {};
|
||||
|
||||
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<TransportPolicy>): 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];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<QueryResult>;
|
||||
execSubprocessJson: (legacyCommand: string, legacyArgs: string[]) => Promise<unknown>;
|
||||
execSubprocessRaw: (legacyCommand: string, legacyArgs: string[]) => Promise<string>;
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
const result = await resolveModel([agentType], projectDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
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<string[]> {
|
||||
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<string, boolean> {
|
||||
const states = new Map<string, boolean>();
|
||||
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<string> {
|
||||
const terminal = new Set<string>();
|
||||
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<string, boolean>,
|
||||
): '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<string, unknown> = {
|
||||
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<string, unknown>) };
|
||||
};
|
||||
|
||||
// ─── 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<string, unknown>[] = [];
|
||||
let currentPhase: Record<string, unknown> | null = null;
|
||||
let nextPhase: Record<string, unknown> | null = null;
|
||||
|
||||
// Build set of phases from ROADMAP for the current milestone
|
||||
const roadmapPhaseNames = new Map<string, string>();
|
||||
const seenPhaseNums = new Set<string>();
|
||||
let checkboxStates = new Map<string, boolean>();
|
||||
let terminalLabels = new Set<string>();
|
||||
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>) };
|
||||
};
|
||||
|
||||
// ─── 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<string, unknown>[] = [];
|
||||
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<string>()): 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<string, unknown>[] = [];
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>).manager as Record<string, Record<string, string>> | 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<string, unknown> = {
|
||||
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<string, unknown>) };
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Record<string, QueryHandler>> = {
|
||||
'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,
|
||||
};
|
||||
@@ -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<Record<string, QueryHandler>> = {
|
||||
'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,
|
||||
};
|
||||
@@ -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<Record<string, QueryHandler>> = {
|
||||
'phases.list': phasesList,
|
||||
'phases.clear': phasesClear,
|
||||
'phases.archive': phasesArchive,
|
||||
};
|
||||
@@ -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<Record<string, QueryHandler>> = {
|
||||
'roadmap.analyze': roadmapAnalyze,
|
||||
'roadmap.get-phase': roadmapGetPhase,
|
||||
'roadmap.update-plan-progress': roadmapUpdatePlanProgress,
|
||||
'roadmap.annotate-dependencies': roadmapAnnotateDependencies,
|
||||
};
|
||||
@@ -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<Record<string, QueryHandler>> = {
|
||||
'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,
|
||||
};
|
||||
@@ -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<Record<string, QueryHandler>> = {
|
||||
'validate.consistency': validateConsistency,
|
||||
'validate.health': validateHealth,
|
||||
'validate.agents': validateAgents,
|
||||
'validate.context': validateContext,
|
||||
};
|
||||
@@ -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<Record<string, QueryHandler>> = {
|
||||
'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.
|
||||
};
|
||||
366
sdk/src/index.ts
366
sdk/src/index.ts
@@ -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<PlanResult> {
|
||||
// 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<PhaseRunnerResult> {
|
||||
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<MilestoneRunnerResult> {
|
||||
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<string | undefined> {
|
||||
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';
|
||||
@@ -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
|
||||
});
|
||||
@@ -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> = {}): PlanResult {
|
||||
return {
|
||||
success: true,
|
||||
sessionId: `sess-${Date.now()}`,
|
||||
totalCostUsd: 0.05,
|
||||
durationMs: 2000,
|
||||
usage: makeUsage(),
|
||||
numTurns: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeErrorResult(overrides: Partial<PlanResult> = {}): 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> = {}): 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<string, unknown> = {}) {
|
||||
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<InitRunnerDeps> & { 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<string, unknown> = {}, configOverrides?: Partial<InitRunnerDeps['config']>) {
|
||||
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<string, unknown> = {},
|
||||
configOverrides?: Partial<InitRunnerDeps['config']>,
|
||||
) {
|
||||
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+/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ResearchType, InitStepName> = {
|
||||
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<InitConfig>;
|
||||
/** 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<InitResult> {
|
||||
const startTime = Date.now();
|
||||
const steps: InitStepResult[] = [];
|
||||
const artifacts: string[] = [];
|
||||
|
||||
this.emitEvent<GSDInitStartEvent>({
|
||||
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<T>(
|
||||
step: InitStepName,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<{ stepResult: InitStepResult; value?: T }> {
|
||||
const stepStart = Date.now();
|
||||
|
||||
this.emitEvent<GSDInitStepStartEvent>({
|
||||
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<GSDInitStepCompleteEvent>({
|
||||
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<GSDInitStepCompleteEvent>({
|
||||
type: GSDEventType.InitStepComplete,
|
||||
step,
|
||||
success: false,
|
||||
durationMs,
|
||||
costUsd: 0,
|
||||
error: errorMsg,
|
||||
});
|
||||
|
||||
return { stepResult };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Parallel research ─────────────────────────────────────────────────────
|
||||
|
||||
private async runParallelResearch(
|
||||
input: string,
|
||||
projectInfo: InitNewProjectInfo,
|
||||
): Promise<InitStepResult[]> {
|
||||
this.emitEvent<GSDInitResearchSpawnEvent>({
|
||||
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<string> {
|
||||
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.',
|
||||
'',
|
||||
'<project_template>',
|
||||
template,
|
||||
'</project_template>',
|
||||
'',
|
||||
'<user_input>',
|
||||
input,
|
||||
'</user_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<string> {
|
||||
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([
|
||||
'<agent_definition>',
|
||||
agentDef,
|
||||
'</agent_definition>',
|
||||
'',
|
||||
`You are researching the ${researchType} aspect of this project.`,
|
||||
`Write your findings to .planning/research/${researchType}.md`,
|
||||
'',
|
||||
'<files_to_read>',
|
||||
'.planning/PROJECT.md',
|
||||
'</files_to_read>',
|
||||
'',
|
||||
'<project_context>',
|
||||
projectContent,
|
||||
'</project_context>',
|
||||
'',
|
||||
'<research_template>',
|
||||
template,
|
||||
'</research_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<string> {
|
||||
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(`<research_${rt.toLowerCase()}>\n${content}\n</research_${rt.toLowerCase()}>`);
|
||||
} catch {
|
||||
researchContent.push(`<research_${rt.toLowerCase()}>\n(Not available)\n</research_${rt.toLowerCase()}>`);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizePrompt([
|
||||
'<agent_definition>',
|
||||
agentDef,
|
||||
'</agent_definition>',
|
||||
'',
|
||||
'<files_to_read>',
|
||||
'.planning/research/STACK.md',
|
||||
'.planning/research/FEATURES.md',
|
||||
'.planning/research/ARCHITECTURE.md',
|
||||
'.planning/research/PITFALLS.md',
|
||||
'</files_to_read>',
|
||||
'',
|
||||
'Synthesize the research files below into .planning/research/SUMMARY.md',
|
||||
'',
|
||||
...researchContent,
|
||||
'',
|
||||
'<summary_template>',
|
||||
summaryTemplate,
|
||||
'</summary_template>',
|
||||
'',
|
||||
'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<string> {
|
||||
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.).',
|
||||
'',
|
||||
'<project_context>',
|
||||
projectContent,
|
||||
'</project_context>',
|
||||
'',
|
||||
'<features_research>',
|
||||
featuresContent || '(Not available)',
|
||||
'</features_research>',
|
||||
'',
|
||||
'<requirements_template>',
|
||||
reqTemplate,
|
||||
'</requirements_template>',
|
||||
'',
|
||||
'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<string> {
|
||||
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(`<file path="${fp}">\n${content}\n</file>`);
|
||||
} catch {
|
||||
fileContents.push(`<file path="${fp}">\n(Not available)\n</file>`);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizePrompt([
|
||||
'<agent_definition>',
|
||||
agentDef,
|
||||
'</agent_definition>',
|
||||
'',
|
||||
'<files_to_read>',
|
||||
...filesToRead,
|
||||
'</files_to_read>',
|
||||
'',
|
||||
...fileContents,
|
||||
'',
|
||||
'<roadmap_template>',
|
||||
roadmapTemplate,
|
||||
'</roadmap_template>',
|
||||
'',
|
||||
'<state_template>',
|
||||
stateTemplate,
|
||||
'</state_template>',
|
||||
'',
|
||||
'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<PlanResult> {
|
||||
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<string> {
|
||||
// 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<string> {
|
||||
// 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<string> {
|
||||
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<T extends { type: GSDEventType }>(
|
||||
partial: Omit<T, 'timestamp' | 'sessionId'> & { 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<GSDInitCompleteEvent>({
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
[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<GSDEvent, { type: GSDEventType.PhaseStepStart }> =>
|
||||
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<GSDEvent, { type: GSDEventType.PhaseStepComplete }> =>
|
||||
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)
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<LogLevel, number> = {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── 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<string, unknown>): void {
|
||||
this.log('debug', message, data);
|
||||
}
|
||||
|
||||
info(message: string, data?: Record<string, unknown>): void {
|
||||
this.log('info', message, data);
|
||||
}
|
||||
|
||||
warn(message: string, data?: Record<string, unknown>): void {
|
||||
this.log('warn', message, data);
|
||||
}
|
||||
|
||||
error(message: string, data?: Record<string, unknown>): void {
|
||||
this.log('error', message, data);
|
||||
}
|
||||
|
||||
private log(level: LogLevel, message: string, data?: Record<string, unknown>): 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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> = {}): RoadmapPhaseInfo {
|
||||
return {
|
||||
number: '1',
|
||||
disk_status: 'not_started',
|
||||
roadmap_complete: false,
|
||||
phase_name: 'Auth',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePhaseResult(overrides: Partial<PhaseRunnerResult> = {}): 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<typeof vi.fn>;
|
||||
let events: GSDEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
gsd = new GSD({ projectDir: '/tmp/test-project' });
|
||||
events = [];
|
||||
|
||||
// Capture emitted events
|
||||
(gsd.eventStream.emitEvent as ReturnType<typeof vi.fn>).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);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
interface RuntimeTierEntry {
|
||||
model: string;
|
||||
reasoning_effort?: string;
|
||||
}
|
||||
|
||||
type RuntimeTierTable = Record<string, Record<string, RuntimeTierEntry | null>>;
|
||||
|
||||
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<string, AgentCatalogEntry>;
|
||||
}
|
||||
|
||||
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<string, Record<string, string>> = 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<string, string> = Object.fromEntries(
|
||||
Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.phaseType])
|
||||
);
|
||||
|
||||
export const AGENT_DEFAULT_TIERS: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.routingTier])
|
||||
);
|
||||
|
||||
export function getAgentToModelMapForProfile(normalizedProfile: string): Record<string, string> {
|
||||
const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced';
|
||||
const out: Record<string, string> = {};
|
||||
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<string> {
|
||||
return new Set(
|
||||
Object.entries(catalog.runtimeTierDefaults)
|
||||
.filter(([, tiers]) => Object.values(tiers).some((entry) => entry && entry.reasoning_effort))
|
||||
.map(([runtime]) => runtime)
|
||||
);
|
||||
}
|
||||
@@ -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 (<role>, <purpose>, <process>), 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, string> = {
|
||||
[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., <purpose>...</purpose>).
|
||||
* 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 <step> blocks from a workflow's <process> 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 = /<step\s+name="([^"]*)"[^>]*>([\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<string> {
|
||||
// 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 <role> block. The real agents have critical instructions in sections
|
||||
// like <philosophy>, <task_breakdown>, <plan_format>, <execution_flow>,
|
||||
// <scope_estimation>, <context_fidelity>, <checkpoints>, 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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<keyof ContextFiles, string> = {
|
||||
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 };
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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}
|
||||
|
||||
<task type="code" name="Create ${id}" files="src/${id}.ts">
|
||||
<read_first>none</read_first>
|
||||
<action>Create ${id}</action>
|
||||
<verify>File exists</verify>
|
||||
<acceptance_criteria>
|
||||
- File exists
|
||||
</acceptance_criteria>
|
||||
<done>Done</done>
|
||||
</task>
|
||||
`;
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
|
||||
# Only include SUMMARY refs if genuinely needed
|
||||
@src/relevant/source.ts
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create User model</name>
|
||||
<files>src/models/user.ts</files>
|
||||
<read_first>src/existing/types.ts, src/config/db.ts</read_first>
|
||||
<action>Define User type with id, email, name, createdAt. Export TypeScript interface.</action>
|
||||
<verify>tsc --noEmit passes</verify>
|
||||
<acceptance_criteria>
|
||||
- User type is exported from src/models/user.ts
|
||||
- Type includes id, email, name, createdAt fields
|
||||
</acceptance_criteria>
|
||||
<done>User type exported and usable</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create User API endpoints</name>
|
||||
<files>src/api/users.ts, src/api/middleware.ts</files>
|
||||
<action>GET /users (list), GET /users/:id (single), POST /users (create). Use User type from model.</action>
|
||||
<verify>fetch tests pass for all endpoints</verify>
|
||||
<done>All CRUD operations work</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Verify UI visually</name>
|
||||
<files>src/components/UserList.tsx</files>
|
||||
<action>Start dev server and present for review.</action>
|
||||
<verify>User confirms layout is correct</verify>
|
||||
<done>Visual verification passed</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- [ ] npm run build succeeds
|
||||
- [ ] API endpoints respond correctly
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All tasks completed
|
||||
- User feature works end-to-end
|
||||
</success_criteria>
|
||||
`;
|
||||
|
||||
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: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
Minimal test plan.
|
||||
</objective>
|
||||
|
||||
<tasks>
|
||||
<task type="auto">
|
||||
<name>Single task</name>
|
||||
<files>output.txt</files>
|
||||
<action>Create output.txt</action>
|
||||
<verify>test -f output.txt</verify>
|
||||
<done>File exists</done>
|
||||
</task>
|
||||
</tasks>
|
||||
`;
|
||||
|
||||
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: []
|
||||
---
|
||||
|
||||
<tasks>
|
||||
<task type="auto">
|
||||
<name>Build server with config</name>
|
||||
<files>src/server.ts</files>
|
||||
<action>
|
||||
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 <script> or XML-like content.
|
||||
</action>
|
||||
<verify>npm run build && curl localhost:3000/health</verify>
|
||||
<done>Server starts and health endpoint returns 200</done>
|
||||
</task>
|
||||
</tasks>
|
||||
`;
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('extractFrontmatter', () => {
|
||||
it('extracts basic key-value pairs', () => {
|
||||
const result = extractFrontmatter(FULL_PLAN);
|
||||
expect(result.phase).toBe('03-features');
|
||||
expect(result.plan).toBe('01');
|
||||
expect(result.type).toBe('execute');
|
||||
});
|
||||
|
||||
it('coerces numeric values', () => {
|
||||
const result = extractFrontmatter(FULL_PLAN);
|
||||
expect(result.wave).toBe(2);
|
||||
});
|
||||
|
||||
it('coerces boolean values', () => {
|
||||
const result = extractFrontmatter(FULL_PLAN);
|
||||
expect(result.autonomous).toBe(true);
|
||||
});
|
||||
|
||||
it('parses inline arrays', () => {
|
||||
const result = extractFrontmatter(FULL_PLAN);
|
||||
expect(result.depends_on).toEqual(['01-01', '01-02']);
|
||||
expect(result.files_modified).toEqual([
|
||||
'src/models/user.ts',
|
||||
'src/api/users.ts',
|
||||
'src/components/UserList.tsx',
|
||||
]);
|
||||
expect(result.requirements).toEqual(['R001', 'R003']);
|
||||
});
|
||||
|
||||
it('parses empty inline arrays', () => {
|
||||
const result = extractFrontmatter(MINIMAL_PLAN);
|
||||
expect(result.depends_on).toEqual([]);
|
||||
expect(result.files_modified).toEqual([]);
|
||||
expect(result.requirements).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty object for content without frontmatter', () => {
|
||||
const result = extractFrontmatter('# Just a heading\nSome content');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object for empty string', () => {
|
||||
const result = extractFrontmatter('');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns the LEADING block when body contains markdown horizontal rules', () => {
|
||||
// Regression: LAST-block semantics picked up body separators as frontmatter (#3240)
|
||||
const content = [
|
||||
'---',
|
||||
'wave: 3',
|
||||
'autonomous: false',
|
||||
'phase: 05-hardening',
|
||||
'---',
|
||||
'',
|
||||
'## Section One',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## Section Two',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'body text',
|
||||
].join('\n');
|
||||
const result = extractFrontmatter(content);
|
||||
expect(result.wave).toBe(3);
|
||||
expect(result.autonomous).toBe(false);
|
||||
expect(result.phase).toBe('05-hardening');
|
||||
});
|
||||
|
||||
it('returns the LEADING block when body contains embedded YAML in fenced code block', () => {
|
||||
// Regression: LAST-block semantics matched YAML inside ```yaml fences (#3240)
|
||||
const content = [
|
||||
'---',
|
||||
'wave: 2',
|
||||
'autonomous: true',
|
||||
'phase: 04-polish',
|
||||
'---',
|
||||
'',
|
||||
'## Example',
|
||||
'',
|
||||
'```yaml',
|
||||
'---',
|
||||
'name: example',
|
||||
'value: 99',
|
||||
'---',
|
||||
'```',
|
||||
'',
|
||||
'More body text.',
|
||||
].join('\n');
|
||||
const result = extractFrontmatter(content);
|
||||
expect(result.wave).toBe(2);
|
||||
expect(result.autonomous).toBe(true);
|
||||
expect(result.phase).toBe('04-polish');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlan — frontmatter', () => {
|
||||
it('parses all typed frontmatter fields', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
const fm = result.frontmatter;
|
||||
|
||||
expect(fm.phase).toBe('03-features');
|
||||
expect(fm.plan).toBe('01');
|
||||
expect(fm.type).toBe('execute');
|
||||
expect(fm.wave).toBe(2);
|
||||
expect(fm.depends_on).toEqual(['01-01', '01-02']);
|
||||
expect(fm.files_modified).toEqual([
|
||||
'src/models/user.ts',
|
||||
'src/api/users.ts',
|
||||
'src/components/UserList.tsx',
|
||||
]);
|
||||
expect(fm.autonomous).toBe(true);
|
||||
expect(fm.requirements).toEqual(['R001', 'R003']);
|
||||
});
|
||||
|
||||
it('parses must_haves.truths', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
expect(result.frontmatter.must_haves.truths).toEqual([
|
||||
'User can see existing messages',
|
||||
'User can send a message',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses must_haves.artifacts', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
const artifacts = result.frontmatter.must_haves.artifacts;
|
||||
expect(artifacts).toHaveLength(2);
|
||||
expect(artifacts[0]).toMatchObject({
|
||||
path: 'src/components/Chat.tsx',
|
||||
provides: 'Message list rendering',
|
||||
min_lines: 30,
|
||||
});
|
||||
expect(artifacts[1]).toMatchObject({
|
||||
path: 'src/app/api/chat/route.ts',
|
||||
provides: 'Message CRUD operations',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses must_haves.key_links', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
const links = result.frontmatter.must_haves.key_links;
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toMatchObject({
|
||||
from: 'src/components/Chat.tsx',
|
||||
to: '/api/chat',
|
||||
via: 'fetch in useEffect',
|
||||
pattern: 'fetch.*api/chat',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses empty must_haves', () => {
|
||||
const result = parsePlan(MINIMAL_PLAN);
|
||||
expect(result.frontmatter.must_haves).toEqual({
|
||||
truths: [],
|
||||
artifacts: [],
|
||||
key_links: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('provides defaults for missing frontmatter', () => {
|
||||
const result = parsePlan('<tasks></tasks>');
|
||||
expect(result.frontmatter.phase).toBe('');
|
||||
expect(result.frontmatter.wave).toBe(1);
|
||||
expect(result.frontmatter.depends_on).toEqual([]);
|
||||
expect(result.frontmatter.autonomous).toBe(true);
|
||||
expect(result.frontmatter.must_haves).toEqual({
|
||||
truths: [],
|
||||
artifacts: [],
|
||||
key_links: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlan — XML tasks', () => {
|
||||
it('parses auto tasks', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
expect(result.tasks).toHaveLength(3);
|
||||
|
||||
const task1 = result.tasks[0];
|
||||
expect(task1.type).toBe('auto');
|
||||
expect(task1.name).toBe('Task 1: Create User model');
|
||||
expect(task1.files).toEqual(['src/models/user.ts']);
|
||||
expect(task1.read_first).toEqual(['src/existing/types.ts', 'src/config/db.ts']);
|
||||
expect(task1.action).toBe(
|
||||
'Define User type with id, email, name, createdAt. Export TypeScript interface.',
|
||||
);
|
||||
expect(task1.verify).toBe('tsc --noEmit passes');
|
||||
expect(task1.done).toBe('User type exported and usable');
|
||||
});
|
||||
|
||||
it('parses checkpoint tasks', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
const checkpoint = result.tasks[2];
|
||||
expect(checkpoint.type).toBe('checkpoint:human-verify');
|
||||
expect(checkpoint.name).toBe('Verify UI visually');
|
||||
});
|
||||
|
||||
it('parses acceptance_criteria list', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
expect(result.tasks[0].acceptance_criteria).toEqual([
|
||||
'User type is exported from src/models/user.ts',
|
||||
'Type includes id, email, name, createdAt fields',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses multiple files from comma-separated list', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
const task2 = result.tasks[1];
|
||||
expect(task2.files).toEqual(['src/api/users.ts', 'src/api/middleware.ts']);
|
||||
});
|
||||
|
||||
it('handles missing optional elements', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
const task2 = result.tasks[1];
|
||||
// Task 2 has no read_first or acceptance_criteria
|
||||
expect(task2.read_first).toEqual([]);
|
||||
expect(task2.acceptance_criteria).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles multiline action blocks', () => {
|
||||
const result = parsePlan(MULTILINE_ACTION_PLAN);
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
|
||||
const task = result.tasks[0];
|
||||
expect(task.action).toContain('Create the Express server');
|
||||
expect(task.action).toContain('ValidationError => 400');
|
||||
expect(task.action).toContain('app.get');
|
||||
// The angle brackets inside action should be preserved
|
||||
expect(task.action).toContain('angle brackets like <script>');
|
||||
});
|
||||
|
||||
it('returns empty array for no tasks', () => {
|
||||
const result = parsePlan('---\nphase: test\n---\n\nNo tasks here.');
|
||||
expect(result.tasks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlan — sections', () => {
|
||||
it('extracts objective', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
expect(result.objective).toContain('Implement complete User feature');
|
||||
expect(result.objective).toContain('Self-contained user management');
|
||||
});
|
||||
|
||||
it('extracts execution_context references', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
expect(result.execution_context).toEqual([
|
||||
'~/.claude/get-shit-done/workflows/execute-plan.md',
|
||||
'~/.claude/get-shit-done/templates/summary.md',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts context references (skipping comments)', () => {
|
||||
const result = parsePlan(FULL_PLAN);
|
||||
expect(result.context_refs).toEqual([
|
||||
'.planning/PROJECT.md',
|
||||
'.planning/ROADMAP.md',
|
||||
'.planning/STATE.md',
|
||||
'src/relevant/source.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty sections for missing blocks', () => {
|
||||
const result = parsePlan(MINIMAL_PLAN);
|
||||
expect(result.execution_context).toEqual([]);
|
||||
// context_refs should be empty when no <context> block
|
||||
expect(result.context_refs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlan — edge cases', () => {
|
||||
it('handles empty string input', () => {
|
||||
const result = parsePlan('');
|
||||
expect(result.frontmatter.phase).toBe('');
|
||||
expect(result.tasks).toEqual([]);
|
||||
expect(result.raw).toBe('');
|
||||
});
|
||||
|
||||
it('handles null-ish input without crashing', () => {
|
||||
// @ts-expect-error — testing runtime guard
|
||||
const result = parsePlan(null);
|
||||
expect(result.tasks).toEqual([]);
|
||||
expect(result.raw).toBe('');
|
||||
});
|
||||
|
||||
it('handles undefined input without crashing', () => {
|
||||
// @ts-expect-error — testing runtime guard
|
||||
const result = parsePlan(undefined);
|
||||
expect(result.tasks).toEqual([]);
|
||||
expect(result.raw).toBe('');
|
||||
});
|
||||
|
||||
it('preserves raw content', () => {
|
||||
const result = parsePlan(MINIMAL_PLAN);
|
||||
expect(result.raw).toBe(MINIMAL_PLAN);
|
||||
});
|
||||
|
||||
it('handles malformed XML gracefully (unclosed tags)', () => {
|
||||
const content = `---
|
||||
phase: test
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
requirements: []
|
||||
must_haves:
|
||||
truths: []
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
|
||||
<tasks>
|
||||
<task type="auto">
|
||||
<name>Broken task</name>
|
||||
<action>This action is never closed
|
||||
</tasks>
|
||||
`;
|
||||
// Should not throw — just parse what it can
|
||||
const result = parsePlan(content);
|
||||
expect(result.tasks).toEqual([]); // Can't match <task>...</task> if malformed
|
||||
expect(result.frontmatter.phase).toBe('test');
|
||||
});
|
||||
|
||||
it('handles content with only frontmatter', () => {
|
||||
const content = `---
|
||||
phase: 01-solo
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
requirements: [R001]
|
||||
must_haves:
|
||||
truths: []
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
`;
|
||||
const result = parsePlan(content);
|
||||
expect(result.frontmatter.phase).toBe('01-solo');
|
||||
expect(result.frontmatter.requirements).toEqual(['R001']);
|
||||
expect(result.tasks).toEqual([]);
|
||||
expect(result.objective).toBe('');
|
||||
});
|
||||
|
||||
it('handles code snippets with angle brackets inside action', () => {
|
||||
const result = parsePlan(MULTILINE_ACTION_PLAN);
|
||||
const action = result.tasks[0].action;
|
||||
// The <script> inside the action text should be preserved (it's between <action>...</action>)
|
||||
expect(action).toContain('<script>');
|
||||
// TypeScript code block with angle brackets should be preserved
|
||||
expect(action).toContain("res.json({ status: 'ok' })");
|
||||
});
|
||||
|
||||
it('handles plan with boolean autonomous=false', () => {
|
||||
const content = `---
|
||||
phase: test
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: []
|
||||
autonomous: false
|
||||
requirements: []
|
||||
must_haves:
|
||||
truths: []
|
||||
artifacts: []
|
||||
key_links: []
|
||||
---
|
||||
`;
|
||||
const result = parsePlan(content);
|
||||
expect(result.frontmatter.autonomous).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTasks — standalone', () => {
|
||||
it('extracts tasks from raw task XML', () => {
|
||||
const xml = `
|
||||
<tasks>
|
||||
<task type="auto">
|
||||
<name>Do something</name>
|
||||
<files>a.ts</files>
|
||||
<action>Build the thing</action>
|
||||
<verify>npm test</verify>
|
||||
<done>It works</done>
|
||||
</task>
|
||||
</tasks>
|
||||
`;
|
||||
const tasks = parseTasks(xml);
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].name).toBe('Do something');
|
||||
expect(tasks[0].type).toBe('auto');
|
||||
});
|
||||
|
||||
it('defaults task type to auto when attribute missing', () => {
|
||||
const xml = `<tasks><task><name>No type</name><action>Do it</action></task></tasks>`;
|
||||
const tasks = parseTasks(xml);
|
||||
expect(tasks[0].type).toBe('auto');
|
||||
});
|
||||
});
|
||||
@@ -1,431 +0,0 @@
|
||||
/**
|
||||
* plan-parser.ts — Parse GSD-1 PLAN.md files into structured data.
|
||||
*
|
||||
* Extracts YAML frontmatter, XML task bodies, and markdown sections
|
||||
* (<objective>, <execution_context>, <context>) from plan files.
|
||||
*
|
||||
* Ported from get-shit-done/bin/lib/frontmatter.cjs with TypeScript types.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type {
|
||||
PlanFrontmatter,
|
||||
PlanTask,
|
||||
ParsedPlan,
|
||||
MustHaves,
|
||||
MustHaveArtifact,
|
||||
MustHaveKeyLink,
|
||||
} from './types.js';
|
||||
|
||||
// ─── YAML frontmatter extraction ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract frontmatter from a PLAN.md content string.
|
||||
*
|
||||
* Uses a stack-based parser that handles nested objects, inline arrays,
|
||||
* multi-line arrays, and boolean/numeric coercion. Ported from the CJS
|
||||
* reference implementation with the same edge-case coverage.
|
||||
*
|
||||
* Anchored at the start of the file — only the leading `---...---` block is
|
||||
* considered canonical frontmatter. Body `---` separators and embedded YAML
|
||||
* inside fenced code blocks are never picked up.
|
||||
*/
|
||||
export function extractFrontmatter(content: string): Record<string, unknown> {
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
|
||||
// Anchored at file start — only the leading ---...--- block is canonical frontmatter.
|
||||
// Body `---` separators and embedded YAML inside fenced code blocks are not matched.
|
||||
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
||||
if (!match) return frontmatter;
|
||||
|
||||
const yaml = match[1];
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
|
||||
// Stack tracks nested objects: [{obj, key, indent}]
|
||||
const stack: Array<{ obj: Record<string, unknown> | unknown[]; key: string | null; indent: number }> = [
|
||||
{ obj: frontmatter, key: null, indent: -1 },
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
const indentMatch = line.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[1].length : 0;
|
||||
|
||||
// Pop stack back to appropriate level
|
||||
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
const current = stack[stack.length - 1];
|
||||
const currentObj = current.obj as Record<string, unknown>;
|
||||
|
||||
// Key: value pattern
|
||||
const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
|
||||
if (keyMatch) {
|
||||
const key = keyMatch[2];
|
||||
const value = keyMatch[3].trim();
|
||||
|
||||
if (value === '' || value === '[') {
|
||||
// Key with no value or opening bracket — nested object or array (TBD)
|
||||
currentObj[key] = value === '[' ? [] : {};
|
||||
current.key = null;
|
||||
stack.push({ obj: currentObj[key] as Record<string, unknown>, key: null, indent });
|
||||
} else if (value.startsWith('[') && value.endsWith(']')) {
|
||||
// Inline array: key: [a, b, c]
|
||||
currentObj[key] = value
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(Boolean);
|
||||
current.key = null;
|
||||
} else {
|
||||
// Simple key: value — coerce booleans and numbers
|
||||
const cleanValue = value.replace(/^["']|["']$/g, '');
|
||||
currentObj[key] = coerceValue(cleanValue);
|
||||
current.key = null;
|
||||
}
|
||||
} else if (line.trim().startsWith('- ')) {
|
||||
// Array item — could be a plain string or "- key: value" (start of mapping item)
|
||||
const afterDash = line.trim().slice(2);
|
||||
const dashKvMatch = afterDash.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
|
||||
|
||||
// Determine the value to push
|
||||
let itemToPush: unknown;
|
||||
if (dashKvMatch) {
|
||||
// "- key: value" → start of a mapping item (object in array)
|
||||
const obj: Record<string, unknown> = {};
|
||||
const val = dashKvMatch[2].trim().replace(/^["']|["']$/g, '');
|
||||
obj[dashKvMatch[1]] = coerceValue(val);
|
||||
itemToPush = obj;
|
||||
} else {
|
||||
const itemValue = afterDash.replace(/^["']|["']$/g, '');
|
||||
itemToPush = coerceValue(itemValue);
|
||||
}
|
||||
|
||||
// If current context is an empty object, convert to array
|
||||
if (
|
||||
typeof current.obj === 'object' &&
|
||||
!Array.isArray(current.obj) &&
|
||||
Object.keys(current.obj).length === 0
|
||||
) {
|
||||
const parent = stack.length > 1 ? stack[stack.length - 2] : null;
|
||||
if (parent && typeof parent.obj === 'object' && !Array.isArray(parent.obj)) {
|
||||
const parentObj = parent.obj as Record<string, unknown>;
|
||||
for (const k of Object.keys(parentObj)) {
|
||||
if (parentObj[k] === current.obj) {
|
||||
parentObj[k] = [itemToPush];
|
||||
current.obj = parentObj[k] as unknown[];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(current.obj)) {
|
||||
current.obj.push(itemToPush);
|
||||
}
|
||||
|
||||
// If we pushed a mapping object, push it onto the stack so subsequent
|
||||
// indented key-value lines populate the same object
|
||||
if (dashKvMatch && typeof itemToPush === 'object') {
|
||||
stack.push({
|
||||
obj: itemToPush as Record<string, unknown>,
|
||||
key: null,
|
||||
indent, // use dash indent so sub-keys (more indented) populate this object
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce string values to appropriate JS types.
|
||||
* Preserves leading-zero strings (e.g., "01") as strings.
|
||||
*/
|
||||
function coerceValue(value: string): unknown {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
// Only coerce numbers without leading zeros (01, 007 stay as strings)
|
||||
if (/^[1-9]\d*$/.test(value) || value === '0') return parseInt(value, 10);
|
||||
if (/^\d+\.\d+$/.test(value) && !value.startsWith('0')) return parseFloat(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
// ─── must_haves block parsing ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the must_haves nested structure from raw frontmatter.
|
||||
*
|
||||
* The must_haves field has three sub-keys: truths (string[]),
|
||||
* artifacts (object[]), and key_links (object[]).
|
||||
* The stack-based parser above produces these as nested objects
|
||||
* which need further normalization.
|
||||
*/
|
||||
function parseMustHaves(raw: unknown): MustHaves {
|
||||
const defaults: MustHaves = { truths: [], artifacts: [], key_links: [] };
|
||||
if (!raw || typeof raw !== 'object') return defaults;
|
||||
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
truths: normalizeStringArray(obj.truths),
|
||||
artifacts: normalizeArtifacts(obj.artifacts),
|
||||
key_links: normalizeKeyLinks(obj.key_links),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringArray(val: unknown): string[] {
|
||||
if (Array.isArray(val)) return val.map(String);
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeArtifacts(val: unknown): MustHaveArtifact[] {
|
||||
if (!Array.isArray(val)) return [];
|
||||
return val
|
||||
.filter((item) => typeof item === 'object' && item !== null)
|
||||
.map((item) => {
|
||||
const obj = item as Record<string, unknown>;
|
||||
return {
|
||||
path: String(obj.path ?? ''),
|
||||
provides: String(obj.provides ?? ''),
|
||||
...(obj.min_lines !== undefined ? { min_lines: Number(obj.min_lines) } : {}),
|
||||
...(obj.exports !== undefined ? { exports: normalizeStringArray(obj.exports) } : {}),
|
||||
...(obj.contains !== undefined ? { contains: String(obj.contains) } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeKeyLinks(val: unknown): MustHaveKeyLink[] {
|
||||
if (!Array.isArray(val)) return [];
|
||||
return val
|
||||
.filter((item) => typeof item === 'object' && item !== null)
|
||||
.map((item) => {
|
||||
const obj = item as Record<string, unknown>;
|
||||
return {
|
||||
from: String(obj.from ?? ''),
|
||||
to: String(obj.to ?? ''),
|
||||
via: String(obj.via ?? ''),
|
||||
...(obj.pattern !== undefined ? { pattern: String(obj.pattern) } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── XML task extraction ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract inner text of an XML element from a task body.
|
||||
* Handles multiline content and trims whitespace.
|
||||
*/
|
||||
function extractElement(taskBody: string, tagName: string): string {
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i');
|
||||
const match = taskBody.match(regex);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the type attribute from a <task> opening tag.
|
||||
*/
|
||||
function extractTaskType(taskTag: string): string {
|
||||
const match = taskTag.match(/type\s*=\s*["']([^"']+)["']/);
|
||||
return match ? match[1] : 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse XML task blocks from the <tasks> section.
|
||||
*
|
||||
* Uses a regex to match <task ...>...</task> blocks, then extracts
|
||||
* inner elements (name, files, read_first, action, verify,
|
||||
* acceptance_criteria, done).
|
||||
*
|
||||
* Handles:
|
||||
* - Multiline <action> blocks (including code snippets with angle brackets)
|
||||
* - Optional elements (missing elements → empty string/array)
|
||||
* - Both auto and checkpoint task types
|
||||
*/
|
||||
export function parseTasks(content: string): PlanTask[] {
|
||||
const tasks: PlanTask[] = [];
|
||||
|
||||
// Extract the <tasks>...</tasks> section first
|
||||
const tasksSection = content.match(/<tasks>([\s\S]*?)<\/tasks>/i);
|
||||
const taskContent = tasksSection ? tasksSection[1] : content;
|
||||
|
||||
// Match individual task blocks — use a greedy-enough approach
|
||||
// that handles nested angle brackets in action blocks
|
||||
const taskRegex = /<task\b([^>]*)>([\s\S]*?)<\/task>/gi;
|
||||
let taskMatch: RegExpExecArray | null;
|
||||
|
||||
while ((taskMatch = taskRegex.exec(taskContent)) !== null) {
|
||||
const attrs = taskMatch[1];
|
||||
const body = taskMatch[2];
|
||||
|
||||
const type = extractTaskType(attrs);
|
||||
const name = extractElement(body, 'name');
|
||||
const filesStr = extractElement(body, 'files');
|
||||
const readFirstStr = extractElement(body, 'read_first');
|
||||
const action = extractElement(body, 'action');
|
||||
const verify = extractElement(body, 'verify');
|
||||
const done = extractElement(body, 'done');
|
||||
|
||||
// Parse acceptance_criteria — can be a block with "- " list items
|
||||
const acRaw = extractElement(body, 'acceptance_criteria');
|
||||
const acceptance_criteria = acRaw
|
||||
? acRaw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('- '))
|
||||
.map((line) => line.slice(2).trim())
|
||||
: [];
|
||||
|
||||
// Parse file lists (comma-separated)
|
||||
const files = filesStr
|
||||
? filesStr
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const read_first = readFirstStr
|
||||
? readFirstStr
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
tasks.push({
|
||||
type,
|
||||
name,
|
||||
files,
|
||||
read_first,
|
||||
action,
|
||||
verify,
|
||||
acceptance_criteria,
|
||||
done,
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
// ─── Section extraction ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract content of a named XML section (e.g., <objective>...</objective>).
|
||||
*/
|
||||
function extractSection(content: string, sectionName: string): string {
|
||||
const regex = new RegExp(`<${sectionName}>([\\s\\S]*?)</${sectionName}>`, 'i');
|
||||
const match = content.match(regex);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract context references from the <context> block.
|
||||
* Returns an array of file paths (lines starting with @).
|
||||
*/
|
||||
function extractContextRefs(content: string): string[] {
|
||||
const contextBlock = extractSection(content, 'context');
|
||||
if (!contextBlock) return [];
|
||||
|
||||
return contextBlock
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('@'))
|
||||
.map((line) => line.slice(1).trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract execution_context references.
|
||||
* Returns an array of file paths (lines starting with @).
|
||||
*/
|
||||
function extractExecutionContext(content: string): string[] {
|
||||
const block = extractSection(content, 'execution_context');
|
||||
if (!block) return [];
|
||||
|
||||
return block
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('@'))
|
||||
.map((line) => line.slice(1).trim());
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a GSD-1 PLAN.md content string into a structured ParsedPlan.
|
||||
*
|
||||
* Extracts:
|
||||
* - YAML frontmatter (phase, wave, depends_on, must_haves, etc.)
|
||||
* - <objective> section
|
||||
* - <execution_context> references
|
||||
* - <context> file references
|
||||
* - <task> blocks with all inner elements
|
||||
*
|
||||
* Handles edge cases:
|
||||
* - Empty input → empty frontmatter, no tasks
|
||||
* - Missing frontmatter → empty object with defaults
|
||||
* - Malformed XML → partial extraction, no crash
|
||||
*/
|
||||
export function parsePlan(content: string): ParsedPlan {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return {
|
||||
frontmatter: createDefaultFrontmatter(),
|
||||
objective: '',
|
||||
execution_context: [],
|
||||
context_refs: [],
|
||||
tasks: [],
|
||||
raw: content ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const rawFrontmatter = extractFrontmatter(content);
|
||||
|
||||
// Build typed frontmatter with defaults
|
||||
const frontmatter: PlanFrontmatter = {
|
||||
phase: String(rawFrontmatter.phase ?? ''),
|
||||
plan: String(rawFrontmatter.plan ?? ''),
|
||||
type: String(rawFrontmatter.type ?? 'execute'),
|
||||
wave: Number(rawFrontmatter.wave ?? 1),
|
||||
depends_on: normalizeStringArray(rawFrontmatter.depends_on),
|
||||
files_modified: normalizeStringArray(rawFrontmatter.files_modified),
|
||||
autonomous: rawFrontmatter.autonomous !== false,
|
||||
requirements: normalizeStringArray(rawFrontmatter.requirements),
|
||||
must_haves: parseMustHaves(rawFrontmatter.must_haves),
|
||||
};
|
||||
|
||||
// Preserve any extra frontmatter keys
|
||||
for (const [key, value] of Object.entries(rawFrontmatter)) {
|
||||
if (!(key in frontmatter)) {
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
objective: extractSection(content, 'objective'),
|
||||
execution_context: extractExecutionContext(content),
|
||||
context_refs: extractContextRefs(content),
|
||||
tasks: parseTasks(content),
|
||||
raw: content,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultFrontmatter(): PlanFrontmatter {
|
||||
return {
|
||||
phase: '',
|
||||
plan: '',
|
||||
type: 'execute',
|
||||
wave: 1,
|
||||
depends_on: [],
|
||||
files_modified: [],
|
||||
autonomous: true,
|
||||
requirements: [],
|
||||
must_haves: { truths: [], artifacts: [], key_links: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper — reads a PLAN.md file from disk and parses it.
|
||||
*/
|
||||
export async function parsePlanFile(filePath: string): Promise<ParsedPlan> {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return parsePlan(content);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { mkdtemp, readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PlanningJournal } from './planning-journal.js';
|
||||
|
||||
describe('PlanningJournal', () => {
|
||||
it('appends events with monotonic source sequence numbers', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gsd-journal-'));
|
||||
const journal = new PlanningJournal({ projectDir: dir, sourceId: 'daemon-1', runId: 'run-1' });
|
||||
|
||||
const first = await journal.append({
|
||||
projectId: 'project-1',
|
||||
type: 'plan.next',
|
||||
actor: { type: 'agent', id: 'agent-1' },
|
||||
payload: { itemId: 'item-1' },
|
||||
idempotencyKey: 'next-1',
|
||||
});
|
||||
const second = await journal.append({
|
||||
projectId: 'project-1',
|
||||
type: 'plan.done',
|
||||
actor: { type: 'agent', id: 'agent-1' },
|
||||
payload: { itemId: 'item-1' },
|
||||
idempotencyKey: 'done-1',
|
||||
});
|
||||
|
||||
expect(first.source.seq).toBe(1);
|
||||
expect(second.source.seq).toBe(2);
|
||||
expect(await journal.readAll()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('replays an existing event for duplicate idempotency keys', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gsd-journal-'));
|
||||
const journal = new PlanningJournal({ projectDir: dir, sourceId: 'sdk-1', runId: 'run-1' });
|
||||
|
||||
const first = await journal.append({
|
||||
projectId: 'project-1',
|
||||
type: 'plan.checkpoint',
|
||||
actor: { type: 'agent', id: 'agent-1' },
|
||||
payload: { summary: 'Progress' },
|
||||
idempotencyKey: 'checkpoint-1',
|
||||
});
|
||||
const replay = await journal.append({
|
||||
projectId: 'project-1',
|
||||
type: 'plan.checkpoint',
|
||||
actor: { type: 'agent', id: 'agent-1' },
|
||||
payload: { summary: 'Progress' },
|
||||
idempotencyKey: 'checkpoint-1',
|
||||
});
|
||||
|
||||
expect(replay.id).toBe(first.id);
|
||||
expect(await journal.readAll()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('writes jsonl under .gsd/journal.jsonl', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gsd-journal-'));
|
||||
const journal = new PlanningJournal({ projectDir: dir, sourceId: 'sdk-1', runId: 'run-1' });
|
||||
await journal.append({
|
||||
projectId: 'project-1',
|
||||
type: 'plan.status',
|
||||
actor: { type: 'agent', id: 'agent-1' },
|
||||
payload: {},
|
||||
idempotencyKey: 'status-1',
|
||||
});
|
||||
|
||||
const raw = await readFile(join(dir, '.gsd', 'journal.jsonl'), 'utf8');
|
||||
expect(raw.trim().split('\n')).toHaveLength(1);
|
||||
expect(JSON.parse(raw).schemaVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type PlanningEventActor = {
|
||||
type: 'human' | 'agent' | 'runtime' | 'verifier' | 'system';
|
||||
id: string;
|
||||
role?: string;
|
||||
sessionId?: string;
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
export type PlanningEvent = {
|
||||
id: string;
|
||||
schemaVersion: 1;
|
||||
projectionVersion: number;
|
||||
projectId: string;
|
||||
source: { id: string; kind: 'sdk' | 'daemon' | 'cloud' | 'import'; seq: number; cursor?: string };
|
||||
runId: string;
|
||||
workstreamId?: string;
|
||||
planId?: string;
|
||||
itemId?: string;
|
||||
actor: PlanningEventActor;
|
||||
authority: 'local' | 'cloud' | 'human_approved' | 'system';
|
||||
type: string;
|
||||
idempotencyKey: string;
|
||||
causationId?: string;
|
||||
occurredAt: string;
|
||||
payload: Record<string, unknown>;
|
||||
evidenceIds: string[];
|
||||
parentEventIds: string[];
|
||||
trace: Record<string, unknown>;
|
||||
requestHash: string;
|
||||
};
|
||||
|
||||
export type PlanningJournalAppendInput = {
|
||||
projectId: string;
|
||||
type: string;
|
||||
actor: PlanningEventActor;
|
||||
payload: Record<string, unknown>;
|
||||
idempotencyKey: string;
|
||||
planId?: string;
|
||||
itemId?: string;
|
||||
workstreamId?: string;
|
||||
evidenceIds?: string[];
|
||||
parentEventIds?: string[];
|
||||
causationId?: string;
|
||||
trace?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class PlanningJournal {
|
||||
private readonly path: string;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
projectDir: string;
|
||||
sourceId: string;
|
||||
runId: string;
|
||||
sourceKind?: 'sdk' | 'daemon' | 'cloud' | 'import';
|
||||
projectionVersion?: number;
|
||||
},
|
||||
) {
|
||||
this.path = join(options.projectDir, '.gsd', 'journal.jsonl');
|
||||
}
|
||||
|
||||
async append(input: PlanningJournalAppendInput): Promise<PlanningEvent> {
|
||||
const existing = await this.findByIdempotency(input.idempotencyKey);
|
||||
const requestHash = hashRequest(input);
|
||||
if (existing) {
|
||||
if (existing.requestHash !== requestHash) {
|
||||
throw new Error(`conflicting idempotency key: ${input.idempotencyKey}`);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const events = await this.readAll();
|
||||
const event: PlanningEvent = {
|
||||
id: randomUUID(),
|
||||
schemaVersion: 1,
|
||||
projectionVersion: this.options.projectionVersion ?? 1,
|
||||
projectId: input.projectId,
|
||||
source: {
|
||||
id: this.options.sourceId,
|
||||
kind: this.options.sourceKind ?? 'sdk',
|
||||
seq: events.filter((candidate) => candidate.source.id === this.options.sourceId).length + 1,
|
||||
},
|
||||
runId: this.options.runId,
|
||||
workstreamId: input.workstreamId,
|
||||
planId: input.planId,
|
||||
itemId: input.itemId,
|
||||
actor: input.actor,
|
||||
authority: 'local',
|
||||
type: input.type,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
causationId: input.causationId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload: input.payload,
|
||||
evidenceIds: input.evidenceIds ?? [],
|
||||
parentEventIds: input.parentEventIds ?? [],
|
||||
trace: input.trace ?? {},
|
||||
requestHash,
|
||||
};
|
||||
|
||||
await mkdir(join(this.options.projectDir, '.gsd'), { recursive: true });
|
||||
await appendFile(this.path, `${JSON.stringify(event)}\n`, 'utf8');
|
||||
return event;
|
||||
}
|
||||
|
||||
async readAll(): Promise<PlanningEvent[]> {
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await readFile(this.path, 'utf8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as PlanningEvent);
|
||||
}
|
||||
|
||||
async compact(events: PlanningEvent[]): Promise<void> {
|
||||
await mkdir(join(this.options.projectDir, '.gsd'), { recursive: true });
|
||||
const tmp = `${this.path}.tmp`;
|
||||
await writeFile(
|
||||
tmp,
|
||||
events.map((event) => JSON.stringify(event)).join('\n') + (events.length ? '\n' : ''),
|
||||
'utf8',
|
||||
);
|
||||
await rename(tmp, this.path);
|
||||
}
|
||||
|
||||
private async findByIdempotency(idempotencyKey: string): Promise<PlanningEvent | null> {
|
||||
const events = await this.readAll();
|
||||
return events.find((event) => event.idempotencyKey === idempotencyKey) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function hashRequest(input: PlanningJournalAppendInput): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
projectId: input.projectId,
|
||||
type: input.type,
|
||||
payload: input.payload,
|
||||
planId: input.planId,
|
||||
itemId: input.itemId,
|
||||
actor: input.actor,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PlanningRuntime } from './planning-runtime.js';
|
||||
|
||||
describe('PlanningRuntime', () => {
|
||||
it('records intent events through the durable journal', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gsd-runtime-'));
|
||||
const runtime = new PlanningRuntime({
|
||||
projectDir: dir,
|
||||
projectId: 'project-1',
|
||||
runId: 'run-1',
|
||||
sourceId: 'sdk-1',
|
||||
actor: { type: 'agent', id: 'agent-1', role: 'executor' },
|
||||
});
|
||||
|
||||
await runtime.status({ idempotencyKey: 'status-1' });
|
||||
await runtime.next({ idempotencyKey: 'next-1', createPlan: { title: 'Plan', items: [{ title: 'Item' }] } });
|
||||
await runtime.checkpoint({ idempotencyKey: 'checkpoint-1', summary: 'Progress' });
|
||||
|
||||
const events = await runtime.journal.readAll();
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'plan.status',
|
||||
'plan.next',
|
||||
'plan.checkpoint',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { PlanningJournal, type PlanningEventActor } from './planning-journal.js';
|
||||
|
||||
type RuntimeOptions = {
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
runId: string;
|
||||
sourceId: string;
|
||||
actor: PlanningEventActor;
|
||||
};
|
||||
|
||||
type RuntimeMeta = {
|
||||
idempotencyKey: string;
|
||||
planId?: string;
|
||||
itemId?: string;
|
||||
};
|
||||
|
||||
type NextInput = RuntimeMeta & {
|
||||
selector?: { itemId?: string; titleIncludes?: string };
|
||||
createPlan?: { title: string; items: Array<{ title: string; description?: string; dependsOn?: string[] }> };
|
||||
};
|
||||
|
||||
type CheckpointInput = RuntimeMeta & {
|
||||
summary?: string;
|
||||
subTasks?: Array<{ id?: string; text: string }>;
|
||||
agentCriteria?: Array<{ id?: string; text: string }>;
|
||||
criteriaMet?: string[];
|
||||
blocked?: { reason: string; nextAction?: string };
|
||||
};
|
||||
|
||||
type DoneInput = RuntimeMeta & {
|
||||
summary: string;
|
||||
blockers?: string[];
|
||||
criteriaMet?: string[];
|
||||
evidenceRefs?: string[];
|
||||
evidencePolicy?: 'auto' | 'explicit' | 'waive';
|
||||
evidenceWaiverReason?: string;
|
||||
advance?: boolean;
|
||||
};
|
||||
|
||||
export class PlanningRuntime {
|
||||
readonly journal: PlanningJournal;
|
||||
|
||||
constructor(private readonly options: RuntimeOptions) {
|
||||
this.journal = new PlanningJournal({
|
||||
projectDir: options.projectDir,
|
||||
sourceId: options.sourceId,
|
||||
runId: options.runId,
|
||||
sourceKind: 'sdk',
|
||||
});
|
||||
}
|
||||
|
||||
status(input: RuntimeMeta) {
|
||||
return this.record('plan.status', input, {});
|
||||
}
|
||||
|
||||
next(input: NextInput) {
|
||||
return this.record('plan.next', input, {
|
||||
selector: input.selector,
|
||||
createPlan: input.createPlan,
|
||||
});
|
||||
}
|
||||
|
||||
checkpoint(input: CheckpointInput) {
|
||||
return this.record('plan.checkpoint', input, {
|
||||
summary: input.summary,
|
||||
subTasks: input.subTasks,
|
||||
agentCriteria: input.agentCriteria,
|
||||
criteriaMet: input.criteriaMet,
|
||||
blocked: input.blocked,
|
||||
});
|
||||
}
|
||||
|
||||
sync(input: RuntimeMeta & { cursor?: string }) {
|
||||
return this.record('plan.sync', input, { cursor: input.cursor });
|
||||
}
|
||||
|
||||
done(input: DoneInput) {
|
||||
return this.record('plan.done', input, {
|
||||
summary: input.summary,
|
||||
blockers: input.blockers,
|
||||
criteriaMet: input.criteriaMet,
|
||||
evidenceRefs: input.evidenceRefs,
|
||||
evidencePolicy: input.evidencePolicy ?? 'auto',
|
||||
evidenceWaiverReason: input.evidenceWaiverReason,
|
||||
advance: input.advance ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
private record(type: string, input: RuntimeMeta, payload: Record<string, unknown>) {
|
||||
return this.journal.append({
|
||||
projectId: this.options.projectId,
|
||||
type,
|
||||
actor: this.options.actor,
|
||||
planId: input.planId,
|
||||
itemId: input.itemId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
/**
|
||||
* Unit tests for prompt-builder.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildExecutorPrompt,
|
||||
parseAgentTools,
|
||||
parseAgentRole,
|
||||
DEFAULT_ALLOWED_TOOLS,
|
||||
} from './prompt-builder.js';
|
||||
import type { ParsedPlan, PlanFrontmatter, MustHaves } from './types.js';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePlan(overrides: Partial<ParsedPlan> = {}): ParsedPlan {
|
||||
const defaultFrontmatter: PlanFrontmatter = {
|
||||
phase: '01-auth',
|
||||
plan: '01',
|
||||
type: 'execute',
|
||||
wave: 1,
|
||||
depends_on: [],
|
||||
files_modified: [],
|
||||
autonomous: true,
|
||||
requirements: ['AUTH-01'],
|
||||
must_haves: { truths: [], artifacts: [], key_links: [] },
|
||||
};
|
||||
|
||||
return {
|
||||
frontmatter: { ...defaultFrontmatter, ...overrides.frontmatter },
|
||||
objective: overrides.objective ?? 'Implement JWT authentication with refresh tokens',
|
||||
execution_context: overrides.execution_context ?? [],
|
||||
context_refs: overrides.context_refs ?? [],
|
||||
tasks: overrides.tasks ?? [
|
||||
{
|
||||
type: 'auto',
|
||||
name: 'Create auth module',
|
||||
files: ['src/auth.ts'],
|
||||
read_first: ['src/types.ts'],
|
||||
action: 'Create the auth module with login and refresh endpoints',
|
||||
verify: 'npm test -- --filter auth',
|
||||
acceptance_criteria: ['JWT tokens issued on login', 'Refresh tokens rotate correctly'],
|
||||
done: 'Auth module created and tests pass',
|
||||
},
|
||||
{
|
||||
type: 'auto',
|
||||
name: 'Add middleware',
|
||||
files: ['src/middleware.ts'],
|
||||
read_first: [],
|
||||
action: 'Create auth middleware for protected routes',
|
||||
verify: 'npm test -- --filter middleware',
|
||||
acceptance_criteria: [],
|
||||
done: 'Middleware validates JWT on protected routes',
|
||||
},
|
||||
],
|
||||
raw: '',
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_AGENT_DEF = `---
|
||||
name: gsd-executor
|
||||
description: Executes GSD plans
|
||||
tools: Read, Write, Edit, Bash, Grep, Glob
|
||||
permissionMode: acceptEdits
|
||||
---
|
||||
|
||||
<role>
|
||||
You are a GSD plan executor. You execute PLAN.md files atomically.
|
||||
</role>
|
||||
|
||||
<execution_flow>
|
||||
Some flow content
|
||||
</execution_flow>`;
|
||||
|
||||
// ─── parseAgentTools ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseAgentTools', () => {
|
||||
it('extracts tools from agent definition frontmatter', () => {
|
||||
const tools = parseAgentTools(SAMPLE_AGENT_DEF);
|
||||
expect(tools).toEqual(['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob']);
|
||||
});
|
||||
|
||||
it('returns defaults when no frontmatter found', () => {
|
||||
const tools = parseAgentTools('Just some text without frontmatter');
|
||||
expect(tools).toEqual(DEFAULT_ALLOWED_TOOLS);
|
||||
});
|
||||
|
||||
it('returns defaults when frontmatter has no tools key', () => {
|
||||
const def = `---\nname: test\n---\nContent`;
|
||||
const tools = parseAgentTools(def);
|
||||
expect(tools).toEqual(DEFAULT_ALLOWED_TOOLS);
|
||||
});
|
||||
|
||||
it('handles empty tools value', () => {
|
||||
const def = `---\ntools: \n---`;
|
||||
const tools = parseAgentTools(def);
|
||||
expect(tools).toEqual(DEFAULT_ALLOWED_TOOLS);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseAgentRole ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseAgentRole', () => {
|
||||
it('extracts role content from agent definition', () => {
|
||||
const role = parseAgentRole(SAMPLE_AGENT_DEF);
|
||||
expect(role).toContain('GSD plan executor');
|
||||
expect(role).toContain('PLAN.md files atomically');
|
||||
});
|
||||
|
||||
it('returns empty string when no role block', () => {
|
||||
expect(parseAgentRole('No role block here')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildExecutorPrompt ─────────────────────────────────────────────────────
|
||||
|
||||
describe('buildExecutorPrompt', () => {
|
||||
it('includes the objective text', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('Implement JWT authentication with refresh tokens');
|
||||
});
|
||||
|
||||
it('includes all task names', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('Create auth module');
|
||||
expect(prompt).toContain('Add middleware');
|
||||
});
|
||||
|
||||
it('includes task actions', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('Create the auth module with login and refresh endpoints');
|
||||
expect(prompt).toContain('Create auth middleware for protected routes');
|
||||
});
|
||||
|
||||
it('includes task verification commands', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('npm test -- --filter auth');
|
||||
expect(prompt).toContain('npm test -- --filter middleware');
|
||||
});
|
||||
|
||||
it('includes task file references', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('src/auth.ts');
|
||||
expect(prompt).toContain('src/types.ts');
|
||||
});
|
||||
|
||||
it('includes acceptance criteria', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('JWT tokens issued on login');
|
||||
expect(prompt).toContain('Refresh tokens rotate correctly');
|
||||
});
|
||||
|
||||
it('includes SUMMARY.md creation instruction with derived filename', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('01-01-SUMMARY.md');
|
||||
});
|
||||
|
||||
it('includes phaseDir in SUMMARY path when provided', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan, { phaseDir: '.planning/phases/01-auth' });
|
||||
expect(prompt).toContain('.planning/phases/01-auth/01-01-SUMMARY.md');
|
||||
});
|
||||
|
||||
it('uses bare SUMMARY.md when phase/plan numbers missing', () => {
|
||||
const plan = makePlan({ frontmatter: { phase: '', plan: '', type: 'execute', wave: 1, depends_on: [], files_modified: [], autonomous: true, requirements: [], must_haves: { truths: [], artifacts: [], key_links: [] } } });
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('SUMMARY.md');
|
||||
expect(prompt).not.toContain('/SUMMARY.md');
|
||||
});
|
||||
|
||||
it('includes sequential execution instruction', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('Execute these tasks sequentially');
|
||||
});
|
||||
|
||||
it('handles plan with no tasks gracefully', () => {
|
||||
const plan = makePlan({ tasks: [] });
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('No tasks defined');
|
||||
expect(prompt).toContain('SUMMARY.md');
|
||||
// Should not throw
|
||||
expect(prompt.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes context references when present', () => {
|
||||
const plan = makePlan({
|
||||
context_refs: ['src/config.ts', 'docs/architecture.md'],
|
||||
});
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('@src/config.ts');
|
||||
expect(prompt).toContain('@docs/architecture.md');
|
||||
expect(prompt).toContain('Read these files for context');
|
||||
});
|
||||
|
||||
it('omits context section when no refs', () => {
|
||||
const plan = makePlan({ context_refs: [] });
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).not.toContain('Context Files');
|
||||
});
|
||||
|
||||
it('includes plan metadata', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('Phase: 01-auth');
|
||||
expect(prompt).toContain('Plan: 01');
|
||||
});
|
||||
|
||||
it('includes must-have truths when present', () => {
|
||||
const plan = makePlan({
|
||||
frontmatter: {
|
||||
phase: '01',
|
||||
plan: '01',
|
||||
type: 'execute',
|
||||
wave: 1,
|
||||
depends_on: [],
|
||||
files_modified: [],
|
||||
autonomous: true,
|
||||
requirements: [],
|
||||
must_haves: {
|
||||
truths: ['All endpoints require JWT auth', 'Tokens expire after 15 minutes'],
|
||||
artifacts: [],
|
||||
key_links: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('All endpoints require JWT auth');
|
||||
expect(prompt).toContain('Tokens expire after 15 minutes');
|
||||
});
|
||||
|
||||
it('includes must-have artifacts', () => {
|
||||
const plan = makePlan({
|
||||
frontmatter: {
|
||||
phase: '01',
|
||||
plan: '01',
|
||||
type: 'execute',
|
||||
wave: 1,
|
||||
depends_on: [],
|
||||
files_modified: [],
|
||||
autonomous: true,
|
||||
requirements: [],
|
||||
must_haves: {
|
||||
truths: [],
|
||||
artifacts: [{ path: 'src/auth.ts', provides: 'JWT auth module' }],
|
||||
key_links: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('`src/auth.ts`');
|
||||
expect(prompt).toContain('JWT auth module');
|
||||
});
|
||||
|
||||
it('includes must-have key_links', () => {
|
||||
const plan = makePlan({
|
||||
frontmatter: {
|
||||
phase: '01',
|
||||
plan: '01',
|
||||
type: 'execute',
|
||||
wave: 1,
|
||||
depends_on: [],
|
||||
files_modified: [],
|
||||
autonomous: true,
|
||||
requirements: [],
|
||||
must_haves: {
|
||||
truths: [],
|
||||
artifacts: [],
|
||||
key_links: [{ from: 'auth.ts', to: 'middleware.ts', via: 'import' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('auth.ts → middleware.ts via import');
|
||||
});
|
||||
|
||||
it('includes role from agent definition when provided', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan, SAMPLE_AGENT_DEF);
|
||||
expect(prompt).toContain('## Role');
|
||||
expect(prompt).toContain('GSD plan executor');
|
||||
});
|
||||
|
||||
it('works without agent definition', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
// Should still produce a valid prompt without role section
|
||||
expect(prompt).toContain('## Objective');
|
||||
expect(prompt).toContain('## Tasks');
|
||||
expect(prompt).not.toContain('## Role');
|
||||
});
|
||||
|
||||
it('provides fallback objective when plan has empty objective', () => {
|
||||
const plan = makePlan({ objective: '' });
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('Execute plan: 01');
|
||||
});
|
||||
|
||||
it('includes done criteria for tasks', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('Auth module created and tests pass');
|
||||
expect(prompt).toContain('Middleware validates JWT on protected routes');
|
||||
});
|
||||
|
||||
it('includes commit instruction in completion section', () => {
|
||||
const plan = makePlan();
|
||||
const prompt = buildExecutorPrompt(plan);
|
||||
expect(prompt).toContain('Commit the SUMMARY.md');
|
||||
});
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
/**
|
||||
* Prompt builder — assembles executor prompts from parsed plans.
|
||||
*
|
||||
* Converts a ParsedPlan into a structured prompt that tells the
|
||||
* executor agent exactly what to do: follow the tasks sequentially,
|
||||
* verify each one, and produce a SUMMARY.md at the end.
|
||||
*/
|
||||
|
||||
import type { ParsedPlan, PlanTask } from './types.js';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_ALLOWED_TOOLS = ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'];
|
||||
|
||||
// ─── Agent definition parsing ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the tools list from a gsd-executor.md agent definition.
|
||||
* Falls back to DEFAULT_ALLOWED_TOOLS if parsing fails.
|
||||
*/
|
||||
export function parseAgentTools(agentDef: string): string[] {
|
||||
// Look for "tools:" in the YAML frontmatter
|
||||
const frontmatterMatch = agentDef.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) return DEFAULT_ALLOWED_TOOLS;
|
||||
|
||||
const toolsMatch = frontmatterMatch[1].match(/^tools:\s*(.+)$/m);
|
||||
if (!toolsMatch) return DEFAULT_ALLOWED_TOOLS;
|
||||
|
||||
const tools = toolsMatch[1]
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return tools.length > 0 ? tools : DEFAULT_ALLOWED_TOOLS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the role instructions from a gsd-executor.md agent definition.
|
||||
* Returns the <role>...</role> block content, or empty string.
|
||||
*/
|
||||
export function parseAgentRole(agentDef: string): string {
|
||||
const match = agentDef.match(/<role>([\s\S]*?)<\/role>/i);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
// ─── Prompt assembly ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format a single task into a prompt block.
|
||||
*/
|
||||
function formatTask(task: PlanTask, index: number): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`### Task ${index + 1}: ${task.name}`);
|
||||
|
||||
if (task.files.length > 0) {
|
||||
lines.push(`**Files:** ${task.files.join(', ')}`);
|
||||
}
|
||||
|
||||
if (task.read_first.length > 0) {
|
||||
lines.push(`**Read first:** ${task.read_first.join(', ')}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('**Action:**');
|
||||
lines.push(task.action);
|
||||
|
||||
if (task.verify) {
|
||||
lines.push('');
|
||||
lines.push('**Verify:**');
|
||||
lines.push(task.verify);
|
||||
}
|
||||
|
||||
if (task.done) {
|
||||
lines.push('');
|
||||
lines.push('**Done when:**');
|
||||
lines.push(task.done);
|
||||
}
|
||||
|
||||
if (task.acceptance_criteria.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('**Acceptance criteria:**');
|
||||
for (const criterion of task.acceptance_criteria) {
|
||||
lines.push(`- ${criterion}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for buildExecutorPrompt beyond the required plan.
|
||||
*/
|
||||
export interface ExecutorPromptOptions {
|
||||
/** Raw content of gsd-executor.md agent definition. */
|
||||
agentDef?: string;
|
||||
/** Phase directory relative to project root (e.g. `.planning/phases/01-auth`). */
|
||||
phaseDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the executor prompt from a parsed plan and optional agent definition.
|
||||
*
|
||||
* The prompt instructs the executor to:
|
||||
* 1. Follow the plan tasks sequentially
|
||||
* 2. Run verification for each task
|
||||
* 3. Commit each task individually
|
||||
* 4. Produce a SUMMARY.md file on completion
|
||||
*
|
||||
* @param plan - Parsed plan structure from plan-parser
|
||||
* @param agentDefOrOpts - Raw agent definition string (legacy) or ExecutorPromptOptions
|
||||
* @returns Assembled prompt string
|
||||
*/
|
||||
export function buildExecutorPrompt(plan: ParsedPlan, agentDefOrOpts?: string | ExecutorPromptOptions): string {
|
||||
const opts: ExecutorPromptOptions = typeof agentDefOrOpts === 'string'
|
||||
? { agentDef: agentDefOrOpts }
|
||||
: agentDefOrOpts ?? {};
|
||||
const { agentDef, phaseDir } = opts;
|
||||
const sections: string[] = [];
|
||||
|
||||
// ── Role instructions from agent definition ──
|
||||
if (agentDef) {
|
||||
const role = parseAgentRole(agentDef);
|
||||
if (role) {
|
||||
sections.push(`## Role\n\n${role}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Objective ──
|
||||
if (plan.objective) {
|
||||
sections.push(`## Objective\n\n${plan.objective}`);
|
||||
} else {
|
||||
sections.push(`## Objective\n\nExecute plan: ${plan.frontmatter.plan || plan.frontmatter.phase || 'unnamed'}`);
|
||||
}
|
||||
|
||||
// ── Plan metadata ──
|
||||
const meta: string[] = [];
|
||||
if (plan.frontmatter.phase) meta.push(`Phase: ${plan.frontmatter.phase}`);
|
||||
if (plan.frontmatter.plan) meta.push(`Plan: ${plan.frontmatter.plan}`);
|
||||
if (plan.frontmatter.type) meta.push(`Type: ${plan.frontmatter.type}`);
|
||||
if (meta.length > 0) {
|
||||
sections.push(`## Plan Info\n\n${meta.join('\n')}`);
|
||||
}
|
||||
|
||||
// ── Context references ──
|
||||
if (plan.context_refs.length > 0) {
|
||||
const refs = plan.context_refs.map((r) => `- @${r}`).join('\n');
|
||||
sections.push(`## Context Files\n\nRead these files for context before starting:\n${refs}`);
|
||||
}
|
||||
|
||||
// ── Tasks ──
|
||||
if (plan.tasks.length > 0) {
|
||||
const taskBlocks = plan.tasks.map((t, i) => formatTask(t, i)).join('\n\n---\n\n');
|
||||
sections.push(`## Tasks\n\nExecute these tasks sequentially. For each task: read any referenced files, execute the action, run verification, confirm done criteria, then commit.\n\n${taskBlocks}`);
|
||||
} else {
|
||||
sections.push(`## Tasks\n\nNo tasks defined in this plan. Review the objective and determine if any actions are needed.`);
|
||||
}
|
||||
|
||||
// ── Must-haves ──
|
||||
if (plan.frontmatter.must_haves) {
|
||||
const mh = plan.frontmatter.must_haves;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (mh.truths.length > 0) {
|
||||
parts.push('**Truths (invariants):**');
|
||||
for (const t of mh.truths) {
|
||||
parts.push(`- ${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mh.artifacts.length > 0) {
|
||||
parts.push('**Required artifacts:**');
|
||||
for (const a of mh.artifacts) {
|
||||
parts.push(`- \`${a.path}\`: ${a.provides}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mh.key_links.length > 0) {
|
||||
parts.push('**Key links:**');
|
||||
for (const l of mh.key_links) {
|
||||
parts.push(`- ${l.from} → ${l.to} via ${l.via}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
sections.push(`## Must-Haves\n\n${parts.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Completion instructions ──
|
||||
// Derive the SUMMARY filename from plan frontmatter (e.g. "01-01-SUMMARY.md")
|
||||
// Phase may be "01-auth" or "01" — extract leading number, zero-pad to 2 digits.
|
||||
const phaseNum = (plan.frontmatter.phase || '').match(/^(\d+)/)?.[1] || '';
|
||||
const planNum = (plan.frontmatter.plan || '').match(/^(\d+)/)?.[1] || '';
|
||||
const summaryName = phaseNum && planNum
|
||||
? `${phaseNum.padStart(2, '0')}-${planNum.padStart(2, '0')}-SUMMARY.md`
|
||||
: 'SUMMARY.md';
|
||||
const summaryPath = phaseDir
|
||||
? `${phaseDir}/${summaryName}`
|
||||
: summaryName;
|
||||
|
||||
sections.push(
|
||||
`## Completion\n\n` +
|
||||
`After all tasks are complete:\n` +
|
||||
`1. Run any overall verification or success criteria checks\n` +
|
||||
`2. Create \`${summaryPath}\` documenting:\n` +
|
||||
` - One-line summary of what was accomplished\n` +
|
||||
` - Tasks completed with commit hashes\n` +
|
||||
` - Any deviations from the plan\n` +
|
||||
` - Files created or modified\n` +
|
||||
` - Known issues (if any)\n` +
|
||||
`3. Commit the SUMMARY.md\n` +
|
||||
`4. Report completion`,
|
||||
);
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
export { DEFAULT_ALLOWED_TOOLS };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user