diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d24836c..de5761189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`@gsd-build/sdk` — Phase 1 typed query foundation** — Registry-based `gsd-sdk query` command, classified errors (`GSDQueryError`), and unit-tested handlers under `sdk/src/query/` (state, roadmap, phase lifecycle, init, config, validation, and related domains). Implements incremental SDK-first migration scope approved in #2083; builds on validated work from #2007 / `feat/sdk-foundation` without migrating workflows or removing `gsd-tools.cjs` in this phase. + ## [1.35.0] - 2026-04-10 ### Added diff --git a/get-shit-done/workflows/execute-phase.md b/get-shit-done/workflows/execute-phase.md index 030caad98..c79f1c807 100644 --- a/get-shit-done/workflows/execute-phase.md +++ b/get-shit-done/workflows/execute-phase.md @@ -82,6 +82,15 @@ Read worktree config: USE_WORKTREES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get workflow.use_worktrees 2>/dev/null || echo "true") ``` +If the project uses git submodules, worktree isolation is skipped regardless of the `workflow.use_worktrees` config — the executor commit protocol cannot correctly handle submodule commits inside isolated worktrees. Sequential execution handles submodules transparently. + +```bash +if [ -f .gitmodules ]; then + echo "[worktree] Submodule project detected (.gitmodules exists) — falling back to sequential execution" + USE_WORKTREES=false +fi +``` + When `USE_WORKTREES` is `false`, all executor agents run without `isolation="worktree"` — they execute sequentially on the main working tree instead of in parallel worktrees. Read context window size for adaptive prompt enrichment: diff --git a/get-shit-done/workflows/quick.md b/get-shit-done/workflows/quick.md index 16627496b..d71f55ea2 100644 --- a/get-shit-done/workflows/quick.md +++ b/get-shit-done/workflows/quick.md @@ -146,6 +146,15 @@ Parse JSON for: `planner_model`, `executor_model`, `checker_model`, `verifier_mo USE_WORKTREES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" config-get workflow.use_worktrees 2>/dev/null || echo "true") ``` +If the project uses git submodules, worktree isolation is skipped: + +```bash +if [ -f .gitmodules ]; then + echo "[worktree] Submodule project detected (.gitmodules exists) — falling back to sequential execution" + USE_WORKTREES=false +fi +``` + **If `roadmap_exists` is false:** Error — Quick mode requires an active project with ROADMAP.md. Run `/gsd-new-project` first. Quick tasks can run mid-phase - validation only checks ROADMAP.md exists, not phase status. diff --git a/sdk/src/cli.test.ts b/sdk/src/cli.test.ts index fcb1ccc5f..c0cc70a60 100644 --- a/sdk/src/cli.test.ts +++ b/sdk/src/cli.test.ts @@ -100,8 +100,10 @@ describe('parseCliArgs', () => { expect(result.maxBudget).toBe(15); }); - it('throws on unknown options (strict mode)', () => { - expect(() => parseCliArgs(['--unknown-flag'])).toThrow(); + it('ignores unknown options (non-strict for --pick support)', () => { + // strict: false allows --pick and other query-specific flags + const result = parseCliArgs(['--unknown-flag']); + expect(result.command).toBeUndefined(); }); // ─── Init command parsing ────────────────────────────────────────────── diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts index 561af20e8..bc242fde9 100644 --- a/sdk/src/cli.ts +++ b/sdk/src/cli.ts @@ -54,7 +54,7 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs { version: { type: 'boolean', short: 'v', default: false }, }, allowPositionals: true, - strict: true, + strict: false, }); const command = positionals[0] as string | undefined; @@ -86,12 +86,14 @@ Usage: gsd-sdk [args] [options] Commands: run Run a full milestone from a text prompt - auto Run the full autonomous lifecycle (discover → execute → advance) + 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 Execute a registered native query command (registry: sdk/src/query/index.ts) + Use --pick to extract a specific field Options: --init Bootstrap from a PRD before running (auto only) @@ -207,8 +209,58 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise= queryArgs.length) { + console.error('Error: --pick requires a field name'); + process.exitCode = 10; + return; + } + pickField = queryArgs[pickIdx + 1]; + queryArgs.splice(pickIdx, 2); + } + + try { + const registry = createRegistry(); + const result = await registry.dispatch(queryCommand, queryArgs.slice(1), args.projectDir); + let output: unknown = result.data; + + if (pickField) { + output = extractField(output, pickField); + } + + console.log(JSON.stringify(output, null, 2)); + } catch (err) { + if (err instanceof GSDError) { + console.error(`Error: ${err.message}`); + process.exitCode = exitCodeFor(err.classification); + } else { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + } + } + return; + } + if (args.command !== 'run' && args.command !== 'init' && args.command !== 'auto') { - console.error('Error: Expected "gsd-sdk run ", "gsd-sdk auto", or "gsd-sdk init [input]"'); + console.error('Error: Expected "gsd-sdk run ", "gsd-sdk auto", "gsd-sdk init [input]", or "gsd-sdk query "'); console.error(USAGE); process.exitCode = 1; return; diff --git a/sdk/src/context-engine.ts b/sdk/src/context-engine.ts index b772cfca3..2d57241b3 100644 --- a/sdk/src/context-engine.ts +++ b/sdk/src/context-engine.ts @@ -64,6 +64,11 @@ const PHASE_FILE_MANIFEST: Record = { { 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 }, diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts new file mode 100644 index 000000000..caa17e0be --- /dev/null +++ b/sdk/src/errors.ts @@ -0,0 +1,72 @@ +/** + * Error classification system for the GSD SDK. + * + * Provides a taxonomy of error types with semantic exit codes, + * enabling CLI consumers and agents to distinguish between + * validation failures, execution errors, blocked states, and + * interruptions. + * + * @example + * ```typescript + * import { GSDError, ErrorClassification, exitCodeFor } from './errors.js'; + * + * throw new GSDError('missing required arg', ErrorClassification.Validation); + * // CLI catch handler: process.exitCode = exitCodeFor(err.classification); // 10 + * ``` + */ + +// ─── Error Classification ─────────────────────────────────────────────────── + +/** Classifies SDK errors into semantic categories for exit code mapping. */ +export enum ErrorClassification { + /** Bad input, missing args, schema violations. Exit code 10. */ + Validation = 'validation', + + /** Runtime failure, file I/O, parse errors. Exit code 1. */ + Execution = 'execution', + + /** Dependency missing, phase not found. Exit code 11. */ + Blocked = 'blocked', + + /** Timeout, signal, user cancel. Exit code 1. */ + Interruption = 'interruption', +} + +// ─── GSDError ─────────────────────────────────────────────────────────────── + +/** + * Base error class for the GSD SDK with classification support. + * + * @param message - Human-readable error description + * @param classification - Error category for exit code mapping + */ +export class GSDError extends Error { + readonly name = 'GSDError'; + readonly classification: ErrorClassification; + + constructor(message: string, classification: ErrorClassification) { + super(message); + this.classification = classification; + } +} + +// ─── Exit code mapping ────────────────────────────────────────────────────── + +/** + * Maps an error classification to a semantic exit code. + * + * @param classification - The error classification to map + * @returns Numeric exit code: 10 (validation), 11 (blocked), 1 (execution/interruption) + */ +export function exitCodeFor(classification: ErrorClassification): number { + switch (classification) { + case ErrorClassification.Validation: + return 10; + case ErrorClassification.Blocked: + return 11; + case ErrorClassification.Execution: + case ErrorClassification.Interruption: + default: + return 1; + } +} diff --git a/sdk/src/milestone-runner.test.ts b/sdk/src/milestone-runner.test.ts index 1e0a41492..d851056fb 100644 --- a/sdk/src/milestone-runner.test.ts +++ b/sdk/src/milestone-runner.test.ts @@ -36,12 +36,15 @@ vi.mock('./prompt-builder.js', () => ({ vi.mock('./event-stream.js', () => { return { - GSDEventStream: vi.fn().mockImplementation(() => ({ - emitEvent: vi.fn(), - on: vi.fn(), - emit: vi.fn(), - addTransport: vi.fn(), - })), + // 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(), + }; + }), }; }); @@ -65,9 +68,12 @@ vi.mock('./phase-prompt.js', () => ({ })); vi.mock('./gsd-tools.js', () => ({ - GSDTools: vi.fn().mockImplementation(() => ({ - roadmapAnalyze: vi.fn(), - })), + // Constructor mock for `new GSDTools(...)` (Vitest 4) + GSDTools: vi.fn(function GSDToolsMock() { + return { + roadmapAnalyze: vi.fn(), + }; + }), GSDToolsError: class extends Error { name = 'GSDToolsError'; }, @@ -125,12 +131,11 @@ describe('GSD.run()', () => { // Wire mock roadmapAnalyze on the GSDTools instance mockRoadmapAnalyze = vi.fn(); - vi.mocked(GSDTools).mockImplementation( - () => - ({ - roadmapAnalyze: mockRoadmapAnalyze, - }) as any, - ); + vi.mocked(GSDTools).mockImplementation(function () { + return { + roadmapAnalyze: mockRoadmapAnalyze, + } as any; + }); }); it('discovers phases and calls runPhase for each incomplete one', async () => { diff --git a/sdk/src/phase-prompt.ts b/sdk/src/phase-prompt.ts index 97f52e213..f5192c53d 100644 --- a/sdk/src/phase-prompt.ts +++ b/sdk/src/phase-prompt.ts @@ -28,6 +28,7 @@ const PHASE_WORKFLOW_MAP: Record = { [PhaseType.Plan]: 'plan-phase.md', [PhaseType.Verify]: 'verify-phase.md', [PhaseType.Discuss]: 'discuss-phase.md', + [PhaseType.Repair]: 'execute-plan.md', }; // ─── XML block extraction ──────────────────────────────────────────────────── diff --git a/sdk/src/query/commit.test.ts b/sdk/src/query/commit.test.ts new file mode 100644 index 000000000..535a13a02 --- /dev/null +++ b/sdk/src/query/commit.test.ts @@ -0,0 +1,202 @@ +/** + * Unit tests for git commit and check-commit query handlers. + * + * Tests: execGit, sanitizeCommitMessage, commit, checkCommit. + * Uses real git repos in temp directories. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; + +// ─── Test setup ───────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-commit-')); + // Initialize a git repo + execSync('git init', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git config user.name "Test User"', { cwd: tmpDir, stdio: 'pipe' }); + // Create .planning directory + await mkdir(join(tmpDir, '.planning'), { recursive: true }); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── execGit ─────────────────────────────────────────────────────────────── + +describe('execGit', () => { + it('returns exitCode 0 for successful command', async () => { + const { execGit } = await import('./commit.js'); + const result = execGit(tmpDir, ['status']); + expect(result.exitCode).toBe(0); + }); + + it('returns non-zero exitCode for failed command', async () => { + const { execGit } = await import('./commit.js'); + const result = execGit(tmpDir, ['log', '--oneline']); + // git log fails in empty repo with no commits + expect(result.exitCode).not.toBe(0); + }); + + it('captures stdout from git command', async () => { + const { execGit } = await import('./commit.js'); + const result = execGit(tmpDir, ['rev-parse', '--git-dir']); + expect(result.stdout).toBe('.git'); + }); +}); + +// ─── sanitizeCommitMessage ───────────────────────────────────────────────── + +describe('sanitizeCommitMessage', () => { + it('strips null bytes and zero-width characters', async () => { + const { sanitizeCommitMessage } = await import('./commit.js'); + const result = sanitizeCommitMessage('hello\u0000\u200Bworld'); + expect(result).toBe('helloworld'); + }); + + it('neutralizes injection markers', async () => { + const { sanitizeCommitMessage } = await import('./commit.js'); + const result = sanitizeCommitMessage('fix: update prompt [SYSTEM] test'); + expect(result).not.toContain(''); + expect(result).not.toContain('[SYSTEM]'); + }); + + it('preserves normal commit messages', async () => { + const { sanitizeCommitMessage } = await import('./commit.js'); + const result = sanitizeCommitMessage('feat(auth): add login endpoint'); + expect(result).toBe('feat(auth): add login endpoint'); + }); + + it('returns input unchanged for non-string', async () => { + const { sanitizeCommitMessage } = await import('./commit.js'); + expect(sanitizeCommitMessage('')).toBe(''); + }); +}); + +// ─── commit ──────────────────────────────────────────────────────────────── + +describe('commit', () => { + it('returns committed:false when commit_docs is false and no --force', async () => { + const { commit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: false }), + ); + const result = await commit(['test commit message'], tmpDir); + expect((result.data as { committed: boolean }).committed).toBe(false); + expect((result.data as { reason: string }).reason).toContain('commit_docs'); + }); + + it('creates commit with --force even when commit_docs is false', async () => { + const { commit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: false }), + ); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), '# State\n'); + const result = await commit(['test commit', '--force'], tmpDir); + expect((result.data as { committed: boolean }).committed).toBe(true); + expect((result.data as { hash: string }).hash).toBeTruthy(); + }); + + it('stages files and creates commit with correct message', async () => { + const { commit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: true }), + ); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), '# State\n'); + const result = await commit(['docs: update state'], tmpDir); + expect((result.data as { committed: boolean }).committed).toBe(true); + expect((result.data as { hash: string }).hash).toBeTruthy(); + + // Verify commit message in git log + const log = execSync('git log -1 --format=%s', { cwd: tmpDir, encoding: 'utf-8' }).trim(); + expect(log).toBe('docs: update state'); + }); + + it('returns nothing staged when no files match', async () => { + const { commit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: true }), + ); + // Stage config.json first then commit it so .planning/ has no unstaged changes + execSync('git add .planning/config.json', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' }); + // Now commit with specific nonexistent file + const result = await commit(['test msg', 'nonexistent-file.txt'], tmpDir); + expect((result.data as { committed: boolean }).committed).toBe(false); + expect((result.data as { reason: string }).reason).toContain('nothing'); + }); + + it('commits specific files when provided', async () => { + const { commit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: true }), + ); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), '# State\n'); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n'); + const result = await commit(['docs: state only', '.planning/STATE.md'], tmpDir); + expect((result.data as { committed: boolean }).committed).toBe(true); + + // Verify only STATE.md was committed + const files = execSync('git show --name-only --format=', { cwd: tmpDir, encoding: 'utf-8' }).trim(); + expect(files).toContain('STATE.md'); + expect(files).not.toContain('ROADMAP.md'); + }); +}); + +// ─── checkCommit ─────────────────────────────────────────────────────────── + +describe('checkCommit', () => { + it('returns can_commit:true when commit_docs is enabled', async () => { + const { checkCommit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: true }), + ); + const result = await checkCommit([], tmpDir); + expect((result.data as { can_commit: boolean }).can_commit).toBe(true); + }); + + it('returns can_commit:true when commit_docs is not set', async () => { + const { checkCommit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + const result = await checkCommit([], tmpDir); + expect((result.data as { can_commit: boolean }).can_commit).toBe(true); + }); + + it('returns can_commit:false when commit_docs is false and planning files staged', async () => { + const { checkCommit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: false }), + ); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), '# State\n'); + execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' }); + const result = await checkCommit([], tmpDir); + expect((result.data as { can_commit: boolean }).can_commit).toBe(false); + }); + + it('returns can_commit:true when commit_docs is false but no planning files staged', async () => { + const { checkCommit } = await import('./commit.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ commit_docs: false }), + ); + const result = await checkCommit([], tmpDir); + expect((result.data as { can_commit: boolean }).can_commit).toBe(true); + }); +}); diff --git a/sdk/src/query/commit.ts b/sdk/src/query/commit.ts new file mode 100644 index 000000000..762c7a705 --- /dev/null +++ b/sdk/src/query/commit.ts @@ -0,0 +1,258 @@ +/** + * Git commit and check-commit query handlers. + * + * Ported from get-shit-done/bin/lib/commands.cjs (cmdCommit, cmdCheckCommit) + * and core.cjs (execGit). Provides commit creation with message sanitization + * and pre-commit validation. + * + * @example + * ```typescript + * import { commit, checkCommit } from './commit.js'; + * + * await commit(['docs: update state', '.planning/STATE.md'], '/project'); + * // { data: { committed: true, hash: 'abc1234', message: 'docs: update state', files: [...] } } + * + * await checkCommit([], '/project'); + * // { data: { can_commit: true, reason: 'commit_docs_enabled', ... } } + * ``` + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { planningPaths } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── execGit ────────────────────────────────────────────────────────────── + +/** + * Run a git command in the given working directory. + * + * Ported from core.cjs lines 531-542. + * + * @param cwd - Working directory for the git command + * @param args - Git command arguments (e.g., ['commit', '-m', 'msg']) + * @returns Object with exitCode, stdout, and stderr + */ +export function execGit(cwd: string, args: string[]): { exitCode: number; stdout: string; stderr: string } { + const result = spawnSync('git', args, { + cwd, + stdio: 'pipe', + encoding: 'utf-8', + }); + return { + exitCode: result.status ?? 1, + stdout: (result.stdout ?? '').toString().trim(), + stderr: (result.stderr ?? '').toString().trim(), + }; +} + +// ─── sanitizeCommitMessage ──────────────────────────────────────────────── + +/** + * Sanitize a commit message to prevent prompt injection. + * + * Ported from security.cjs sanitizeForPrompt. + * Strips zero-width characters, null bytes, and neutralizes + * known injection markers that could hijack agent context. + * + * @param text - Raw commit message + * @returns Sanitized message safe for git commit + */ +export function sanitizeCommitMessage(text: string): string { + if (!text || typeof text !== 'string') return ''; + + let sanitized = text; + + // Strip null bytes + sanitized = sanitized.replace(/\0/g, ''); + + // Strip zero-width characters that could hide instructions + sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD]/g, ''); + + // Neutralize XML/HTML tags that mimic system boundaries + sanitized = sanitized.replace(/<(\/?)?(?:system|assistant|human)>/gi, + (_match, slash) => `\uFF1C${slash || ''}system-text\uFF1E`); + + // Neutralize [SYSTEM] / [INST] markers + sanitized = sanitized.replace(/\[(SYSTEM|INST)\]/gi, '[$1-TEXT]'); + + // Neutralize <> markers + sanitized = sanitized.replace(/<<\s*SYS\s*>>/gi, '\u00ABSYS-TEXT\u00BB'); + + return sanitized; +} + +// ─── commit ─────────────────────────────────────────────────────────────── + +/** + * Stage files and create a git commit. + * + * Checks commit_docs config (unless --force), sanitizes message, + * stages specified files (or all .planning/), and commits. + * + * @param args - args[0]=message, remaining=file paths or flags (--force, --amend, --no-verify) + * @param projectDir - Project root directory + * @returns QueryResult with commit result + */ +export const commit: QueryHandler = async (args, projectDir) => { + const allArgs = [...args]; + + // Extract flags + const hasForce = allArgs.includes('--force'); + const hasAmend = allArgs.includes('--amend'); + const hasNoVerify = allArgs.includes('--no-verify'); + const nonFlagArgs = allArgs.filter(a => !a.startsWith('--')); + + const message = nonFlagArgs[0]; + const filePaths = nonFlagArgs.slice(1); + + if (!message && !hasAmend) { + return { data: { committed: false, reason: 'commit message required' } }; + } + + // Check commit_docs config unless --force + if (!hasForce) { + const paths = planningPaths(projectDir); + try { + const raw = await readFile(paths.config, 'utf-8'); + const config = JSON.parse(raw) as Record; + if (config.commit_docs === false) { + return { data: { committed: false, reason: 'commit_docs disabled' } }; + } + } catch { + // No config or malformed — allow commit + } + } + + // Sanitize message + const sanitized = message ? sanitizeCommitMessage(message) : message; + + // Stage files + const filesToStage = filePaths.length > 0 ? filePaths : ['.planning/']; + for (const file of filesToStage) { + execGit(projectDir, ['add', file]); + } + + // Check if anything is staged + const diffResult = execGit(projectDir, ['diff', '--cached', '--name-only']); + const stagedFiles = diffResult.stdout ? diffResult.stdout.split('\n').filter(Boolean) : []; + if (stagedFiles.length === 0) { + return { data: { committed: false, reason: 'nothing staged' } }; + } + + // Build commit command + const commitArgs = hasAmend + ? ['commit', '--amend', '--no-edit'] + : ['commit', '-m', sanitized]; + if (hasNoVerify) commitArgs.push('--no-verify'); + + const commitResult = execGit(projectDir, commitArgs); + if (commitResult.exitCode !== 0) { + if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) { + return { data: { committed: false, reason: 'nothing to commit' } }; + } + return { data: { committed: false, reason: commitResult.stderr || 'commit failed', exitCode: commitResult.exitCode } }; + } + + // Get short hash + const hashResult = execGit(projectDir, ['rev-parse', '--short', 'HEAD']); + const hash = hashResult.exitCode === 0 ? hashResult.stdout : null; + + return { data: { committed: true, hash, message: sanitized, files: stagedFiles } }; +}; + +// ─── checkCommit ────────────────────────────────────────────────────────── + +/** + * Validate whether a commit can proceed. + * + * Checks commit_docs config and staged file state. + * + * @param _args - Unused + * @param projectDir - Project root directory + * @returns QueryResult with { can_commit, reason, commit_docs, staged_files } + */ +export const checkCommit: QueryHandler = async (_args, projectDir) => { + const paths = planningPaths(projectDir); + + let commitDocs = true; + try { + const raw = await readFile(paths.config, 'utf-8'); + const config = JSON.parse(raw) as Record; + if (config.commit_docs === false) { + commitDocs = false; + } + } catch { + // No config — default to allowing commits + } + + // Check staged files + const diffResult = execGit(projectDir, ['diff', '--cached', '--name-only']); + const stagedFiles = diffResult.stdout ? diffResult.stdout.split('\n').filter(Boolean) : []; + + if (!commitDocs) { + // If commit_docs is false, check if any .planning/ files are staged + const planningFiles = stagedFiles.filter(f => f.startsWith('.planning/') || f.startsWith('.planning\\')); + if (planningFiles.length > 0) { + return { + data: { + can_commit: false, + reason: `commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged`, + commit_docs: false, + staged_files: planningFiles, + }, + }; + } + } + + return { + data: { + can_commit: true, + reason: commitDocs ? 'commit_docs_enabled' : 'no_planning_files_staged', + commit_docs: commitDocs, + staged_files: stagedFiles, + }, + }; +}; + +// ─── commitToSubrepo ───────────────────────────────────────────────────── + +export const commitToSubrepo: QueryHandler = async (args, projectDir) => { + const message = args[0]; + const filesIdx = args.indexOf('--files'); + const files = filesIdx >= 0 ? args.slice(filesIdx + 1) : []; + + if (!message) { + return { data: { committed: false, reason: 'commit message required' } }; + } + + try { + for (const file of files) { + const resolved = join(projectDir, file); + if (!resolved.startsWith(projectDir)) { + return { data: { committed: false, reason: `file path escapes project: ${file}` } }; + } + } + + const fileArgs = files.length > 0 ? files : ['.']; + spawnSync('git', ['-C', projectDir, 'add', ...fileArgs], { stdio: 'pipe' }); + + const commitResult = spawnSync( + 'git', ['-C', projectDir, 'commit', '-m', message], + { stdio: 'pipe', encoding: 'utf-8' }, + ); + if (commitResult.status !== 0) { + return { data: { committed: false, reason: commitResult.stderr || 'commit failed' } }; + } + + const hashResult = spawnSync( + 'git', ['-C', projectDir, 'rev-parse', '--short', 'HEAD'], + { encoding: 'utf-8' }, + ); + const hash = hashResult.stdout.trim(); + return { data: { committed: true, hash, message } }; + } catch (err) { + return { data: { committed: false, reason: String(err) } }; + } +}; diff --git a/sdk/src/query/config-mutation.test.ts b/sdk/src/query/config-mutation.test.ts new file mode 100644 index 000000000..2b1f7f0fc --- /dev/null +++ b/sdk/src/query/config-mutation.test.ts @@ -0,0 +1,356 @@ +/** + * Unit tests for config mutation handlers. + * + * Tests: isValidConfigKey, parseConfigValue, configSet, + * configSetModelProfile, configNewProject, configEnsureSection. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, readFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { GSDError } from '../errors.js'; + +// ─── Test setup ───────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-cfgmut-')); + await mkdir(join(tmpDir, '.planning'), { recursive: true }); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── isValidConfigKey ────────────────────────────────────────────────────── + +describe('isValidConfigKey', () => { + it('accepts known exact keys', async () => { + const { isValidConfigKey } = await import('./config-mutation.js'); + expect(isValidConfigKey('model_profile').valid).toBe(true); + expect(isValidConfigKey('commit_docs').valid).toBe(true); + expect(isValidConfigKey('workflow.auto_advance').valid).toBe(true); + }); + + it('accepts wildcard agent_skills.* patterns', async () => { + const { isValidConfigKey } = await import('./config-mutation.js'); + expect(isValidConfigKey('agent_skills.gsd-planner').valid).toBe(true); + expect(isValidConfigKey('agent_skills.custom_agent').valid).toBe(true); + }); + + it('accepts wildcard features.* patterns', async () => { + const { isValidConfigKey } = await import('./config-mutation.js'); + expect(isValidConfigKey('features.global_learnings').valid).toBe(true); + expect(isValidConfigKey('features.thinking_partner').valid).toBe(true); + }); + + it('rejects unknown keys with suggestion', async () => { + const { isValidConfigKey } = await import('./config-mutation.js'); + const result = isValidConfigKey('model_profle'); + expect(result.valid).toBe(false); + expect(result.suggestion).toBeDefined(); + }); + + it('rejects completely invalid keys', async () => { + const { isValidConfigKey } = await import('./config-mutation.js'); + const result = isValidConfigKey('totally_unknown_key'); + expect(result.valid).toBe(false); + }); + + it('accepts learnings.max_inject as valid key (D7)', async () => { + const { isValidConfigKey } = await import('./config-mutation.js'); + expect(isValidConfigKey('learnings.max_inject').valid).toBe(true); + }); + + it('accepts features.global_learnings as valid key (D7)', async () => { + const { isValidConfigKey } = await import('./config-mutation.js'); + expect(isValidConfigKey('features.global_learnings').valid).toBe(true); + }); + + it('returns curated suggestion for known typos before LCP fallback (D9)', async () => { + const { isValidConfigKey } = await import('./config-mutation.js'); + const r1 = isValidConfigKey('workflow.codereview'); + expect(r1.valid).toBe(false); + expect(r1.suggestion).toBe('workflow.code_review'); + + const r2 = isValidConfigKey('agents.nyquist_validation_enabled'); + expect(r2.valid).toBe(false); + expect(r2.suggestion).toBe('workflow.nyquist_validation'); + }); +}); + +// ─── parseConfigValue ────────────────────────────────────────────────────── + +describe('parseConfigValue', () => { + it('converts "true" to boolean true', async () => { + const { parseConfigValue } = await import('./config-mutation.js'); + expect(parseConfigValue('true')).toBe(true); + }); + + it('converts "false" to boolean false', async () => { + const { parseConfigValue } = await import('./config-mutation.js'); + expect(parseConfigValue('false')).toBe(false); + }); + + it('converts numeric strings to numbers', async () => { + const { parseConfigValue } = await import('./config-mutation.js'); + expect(parseConfigValue('42')).toBe(42); + expect(parseConfigValue('3.14')).toBe(3.14); + }); + + it('parses JSON arrays', async () => { + const { parseConfigValue } = await import('./config-mutation.js'); + expect(parseConfigValue('["a","b"]')).toEqual(['a', 'b']); + }); + + it('parses JSON objects', async () => { + const { parseConfigValue } = await import('./config-mutation.js'); + expect(parseConfigValue('{"key":"val"}')).toEqual({ key: 'val' }); + }); + + it('preserves plain strings', async () => { + const { parseConfigValue } = await import('./config-mutation.js'); + expect(parseConfigValue('hello')).toBe('hello'); + }); + + it('preserves empty string as empty string', async () => { + const { parseConfigValue } = await import('./config-mutation.js'); + expect(parseConfigValue('')).toBe(''); + }); +}); + +// ─── atomicWriteConfig behavior ─────────────────────────────────────────── + +describe('atomicWriteConfig internals (via configSet)', () => { + it('uses PID-qualified temp file name (D4)', async () => { + const { configSet } = await import('./config-mutation.js'); + await writeFile(join(tmpDir, '.planning', 'config.json'), '{}'); + + await configSet(['model_profile', 'quality'], tmpDir); + + // Verify the config was written (temp file should be cleaned up) + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.model_profile).toBe('quality'); + }); + + it('falls back to direct write when rename fails (D5)', async () => { + const { configSet } = await import('./config-mutation.js'); + await writeFile(join(tmpDir, '.planning', 'config.json'), '{}'); + + // Even if rename would fail, config-set should still succeed via fallback + await configSet(['model_profile', 'balanced'], tmpDir); + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.model_profile).toBe('balanced'); + }); +}); + +// ─── configSet lock protection ──────────────────────────────────────────── + +describe('configSet lock protection (D6)', () => { + it('acquires and releases lock around read-modify-write', async () => { + const { configSet } = await import('./config-mutation.js'); + await writeFile(join(tmpDir, '.planning', 'config.json'), '{}'); + + // Run two concurrent config-set operations — both should succeed without corruption + const [r1, r2] = await Promise.all([ + configSet(['commit_docs', 'true'], tmpDir), + configSet(['model_profile', 'quality'], tmpDir), + ]); + expect((r1.data as { set: boolean }).set).toBe(true); + expect((r2.data as { set: boolean }).set).toBe(true); + + // Both values should be present (no lost updates) + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.commit_docs).toBe(true); + expect(raw.model_profile).toBe('quality'); + }); +}); + +// ─── configSet context validation ───────────────────────────────────────── + +describe('configSet context validation (D8)', () => { + it('rejects invalid context values', async () => { + const { configSet } = await import('./config-mutation.js'); + await writeFile(join(tmpDir, '.planning', 'config.json'), '{}'); + + await expect(configSet(['context', 'invalid'], tmpDir)).rejects.toThrow(/Invalid context value/); + }); + + it('accepts valid context values (dev, research, review)', async () => { + const { configSet } = await import('./config-mutation.js'); + + for (const ctx of ['dev', 'research', 'review']) { + await writeFile(join(tmpDir, '.planning', 'config.json'), '{}'); + const result = await configSet(['context', ctx], tmpDir); + expect((result.data as { set: boolean }).set).toBe(true); + } + }); +}); + +// ─── configNewProject global defaults ───────────────────────────────────── + +describe('configNewProject global defaults (D11)', () => { + it('creates config with standard defaults when no global defaults exist', async () => { + const { configNewProject } = await import('./config-mutation.js'); + const result = await configNewProject([], tmpDir); + expect((result.data as { created: boolean }).created).toBe(true); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.model_profile).toBe('balanced'); + }); +}); + +// ─── configSet ───────────────────────────────────────────────────────────── + +describe('configSet', () => { + it('writes value and round-trips through reading config.json', async () => { + const { configSet } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + const result = await configSet(['model_profile', 'quality'], tmpDir); + expect(result.data).toEqual({ set: true, key: 'model_profile', value: 'quality' }); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.model_profile).toBe('quality'); + }); + + it('sets nested dot-notation keys', async () => { + const { configSet } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ workflow: { research: true } }), + ); + const result = await configSet(['workflow.auto_advance', 'true'], tmpDir); + expect(result.data).toEqual({ set: true, key: 'workflow.auto_advance', value: true }); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.workflow.auto_advance).toBe(true); + expect(raw.workflow.research).toBe(true); + }); + + it('rejects invalid key with GSDError', async () => { + const { configSet } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + await expect(configSet(['totally_bogus_key', 'value'], tmpDir)).rejects.toThrow(GSDError); + }); + + it('coerces values through parseConfigValue', async () => { + const { configSet } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + await configSet(['commit_docs', 'true'], tmpDir); + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.commit_docs).toBe(true); + }); +}); + +// ─── configSetModelProfile ───────────────────────────────────────────────── + +describe('configSetModelProfile', () => { + it('writes valid profile', async () => { + const { configSetModelProfile } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + const result = await configSetModelProfile(['quality'], tmpDir); + expect((result.data as { set: boolean }).set).toBe(true); + expect((result.data as { profile: string }).profile).toBe('quality'); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.model_profile).toBe('quality'); + }); + + it('rejects invalid profile with GSDError', async () => { + const { configSetModelProfile } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + await expect(configSetModelProfile(['invalid_profile'], tmpDir)).rejects.toThrow(GSDError); + }); + + it('normalizes profile name to lowercase', async () => { + const { configSetModelProfile } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({}), + ); + const result = await configSetModelProfile(['Quality'], tmpDir); + expect((result.data as { profile: string }).profile).toBe('quality'); + }); +}); + +// ─── configNewProject ────────────────────────────────────────────────────── + +describe('configNewProject', () => { + it('creates config.json with defaults', async () => { + const { configNewProject } = await import('./config-mutation.js'); + const result = await configNewProject([], tmpDir); + expect((result.data as { created: boolean }).created).toBe(true); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.model_profile).toBe('balanced'); + expect(raw.commit_docs).toBe(false); + }); + + it('merges user choices', async () => { + const { configNewProject } = await import('./config-mutation.js'); + const choices = JSON.stringify({ model_profile: 'quality', commit_docs: true }); + const result = await configNewProject([choices], tmpDir); + expect((result.data as { created: boolean }).created).toBe(true); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.model_profile).toBe('quality'); + expect(raw.commit_docs).toBe(true); + }); + + it('does not overwrite existing config', async () => { + const { configNewProject } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + const result = await configNewProject([], tmpDir); + expect((result.data as { created: boolean }).created).toBe(false); + }); +}); + +// ─── configEnsureSection ─────────────────────────────────────────────────── + +describe('configEnsureSection', () => { + it('creates section if not present', async () => { + const { configEnsureSection } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + const result = await configEnsureSection(['workflow'], tmpDir); + expect((result.data as { ensured: boolean }).ensured).toBe(true); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.workflow).toEqual({}); + }); + + it('is idempotent on existing section', async () => { + const { configEnsureSection } = await import('./config-mutation.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ workflow: { research: true } }), + ); + const result = await configEnsureSection(['workflow'], tmpDir); + expect((result.data as { ensured: boolean }).ensured).toBe(true); + + const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(raw.workflow).toEqual({ research: true }); + }); +}); diff --git a/sdk/src/query/config-mutation.ts b/sdk/src/query/config-mutation.ts new file mode 100644 index 000000000..731a4a0e4 --- /dev/null +++ b/sdk/src/query/config-mutation.ts @@ -0,0 +1,462 @@ +/** + * Config mutation handlers — write operations for .planning/config.json. + * + * Ported from get-shit-done/bin/lib/config.cjs. + * Provides config-set (with key validation and value coercion), + * config-set-model-profile, config-new-project, and config-ensure-section. + * + * @example + * ```typescript + * import { configSet, configNewProject } from './config-mutation.js'; + * + * await configSet(['model_profile', 'quality'], '/project'); + * // { data: { set: true, key: 'model_profile', value: 'quality' } } + * + * await configNewProject([], '/project'); + * // { data: { created: true, path: '.planning/config.json' } } + * ``` + */ + +import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { MODEL_PROFILES, VALID_PROFILES } from './config-query.js'; +import { planningPaths } from './helpers.js'; +import { acquireStateLock, releaseStateLock } from './state-mutation.js'; +import type { QueryHandler } from './utils.js'; + +/** + * Write config JSON atomically via temp file + rename to prevent + * partial writes on process interruption. + */ +async function atomicWriteConfig(configPath: string, config: Record): Promise { + const tmpPath = configPath + '.tmp.' + process.pid; + const content = JSON.stringify(config, null, 2) + '\n'; + try { + await writeFile(tmpPath, content, 'utf-8'); + await rename(tmpPath, configPath); + } catch { + // D5: Rename-failure fallback — clean up temp, fall back to direct write + try { await unlink(tmpPath); } catch { /* already gone */ } + await writeFile(configPath, content, 'utf-8'); + } +} + +// ─── VALID_CONFIG_KEYS ──────────────────────────────────────────────────── + +/** + * Allowlist of valid config key paths. + * + * Ported from config.cjs lines 14-37. + * Dynamic patterns (agent_skills.*, features.*) are handled + * separately in isValidConfigKey. + */ +const VALID_CONFIG_KEYS = new Set([ + 'mode', 'granularity', 'parallelization', 'commit_docs', 'model_profile', + 'search_gitignored', 'brave_search', 'firecrawl', 'exa_search', + 'workflow.research', 'workflow.plan_check', 'workflow.verifier', + 'workflow.nyquist_validation', 'workflow.ui_phase', 'workflow.ui_safety_gate', + 'workflow.auto_advance', 'workflow.node_repair', 'workflow.node_repair_budget', + 'workflow.text_mode', + 'workflow.research_before_questions', + 'workflow.discuss_mode', + 'workflow.skip_discuss', + 'workflow._auto_chain_active', + 'workflow.use_worktrees', + 'workflow.code_review', + 'workflow.code_review_depth', + 'git.branching_strategy', 'git.base_branch', 'git.phase_branch_template', + 'git.milestone_branch_template', 'git.quick_branch_template', + 'planning.commit_docs', 'planning.search_gitignored', + 'workflow.subagent_timeout', + 'hooks.context_warnings', + 'features.thinking_partner', + 'features.global_learnings', + 'learnings.max_inject', + 'context', + 'project_code', 'phase_naming', + 'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute', + 'response_language', +]); + +// ─── CONFIG_KEY_SUGGESTIONS (D9 — match CJS config.cjs:57-67) ──────────── + +/** + * Curated typo correction map for known config key mistakes. + * Checked before the general LCP fallback for more precise suggestions. + */ +const CONFIG_KEY_SUGGESTIONS: Record = { + 'workflow.nyquist_validation_enabled': 'workflow.nyquist_validation', + 'agents.nyquist_validation_enabled': 'workflow.nyquist_validation', + 'nyquist.validation_enabled': 'workflow.nyquist_validation', + 'hooks.research_questions': 'workflow.research_before_questions', + 'workflow.research_questions': 'workflow.research_before_questions', + 'workflow.codereview': 'workflow.code_review', + 'workflow.review': 'workflow.code_review', + 'workflow.code_review_level': 'workflow.code_review_depth', + 'workflow.review_depth': 'workflow.code_review_depth', +}; + +// ─── isValidConfigKey ───────────────────────────────────────────────────── + +/** + * Check whether a config key path is valid. + * + * Supports exact matches from VALID_CONFIG_KEYS plus dynamic patterns + * like `agent_skills.` and `features.`. + * Uses curated CONFIG_KEY_SUGGESTIONS before LCP fallback for typo correction. + * + * @param keyPath - Dot-notation config key path + * @returns Object with valid flag and optional suggestion for typos + */ +export function isValidConfigKey(keyPath: string): { valid: boolean; suggestion?: string } { + if (VALID_CONFIG_KEYS.has(keyPath)) return { valid: true }; + + // Dynamic patterns: agent_skills. + if (/^agent_skills\.[a-zA-Z0-9_-]+$/.test(keyPath)) return { valid: true }; + + // Dynamic patterns: features. + if (/^features\.[a-zA-Z0-9_]+$/.test(keyPath)) return { valid: true }; + + // D9: Check curated suggestions before LCP fallback + if (CONFIG_KEY_SUGGESTIONS[keyPath]) { + return { valid: false, suggestion: CONFIG_KEY_SUGGESTIONS[keyPath] }; + } + + // Find closest suggestion using longest common prefix + const keys = [...VALID_CONFIG_KEYS]; + let bestMatch = ''; + let bestScore = 0; + + for (const candidate of keys) { + let shared = 0; + const maxLen = Math.min(keyPath.length, candidate.length); + for (let i = 0; i < maxLen; i++) { + if (keyPath[i] === candidate[i]) shared++; + else break; + } + if (shared > bestScore) { + bestScore = shared; + bestMatch = candidate; + } + } + + return { valid: false, suggestion: bestScore > 2 ? bestMatch : undefined }; +} + +// ─── parseConfigValue ───────────────────────────────────────────────────── + +/** + * Coerce a CLI string value to its native type. + * + * Ported from config.cjs lines 344-351. + * + * @param value - String value from CLI + * @returns Coerced value: boolean, number, parsed JSON, or original string + */ +export function parseConfigValue(value: string): unknown { + if (value === 'true') return true; + if (value === 'false') return false; + if (value !== '' && !isNaN(Number(value))) return Number(value); + if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) { + try { return JSON.parse(value); } catch { /* keep as string */ } + } + return value; +} + +// ─── setConfigValue ─────────────────────────────────────────────────────── + +/** + * Set a value at a dot-notation path in a config object. + * + * Creates nested objects as needed along the path. + * + * @param obj - Config object to mutate + * @param dotPath - Dot-notation key path (e.g., 'workflow.auto_advance') + * @param value - Value to set + */ +function setConfigValue(obj: Record, dotPath: string, value: unknown): void { + const keys = dotPath.split('.'); + let current: Record = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (current[key] === undefined || typeof current[key] !== 'object' || current[key] === null) { + current[key] = {}; + } + current = current[key] as Record; + } + current[keys[keys.length - 1]] = value; +} + +// ─── configSet ──────────────────────────────────────────────────────────── + +/** + * Write a validated key-value pair to config.json. + * + * Validates key against VALID_CONFIG_KEYS allowlist, coerces value + * from CLI string to native type, and writes config.json. + * + * @param args - args[0]=key, args[1]=value + * @param projectDir - Project root directory + * @returns QueryResult with { set: true, key, value } + * @throws GSDError with Validation if key is invalid or args missing + */ +export const configSet: QueryHandler = async (args, projectDir) => { + const keyPath = args[0]; + const rawValue = args[1]; + if (!keyPath) { + throw new GSDError('Usage: config-set ', ErrorClassification.Validation); + } + + const validation = isValidConfigKey(keyPath); + if (!validation.valid) { + const suggestion = validation.suggestion ? `. Did you mean: ${validation.suggestion}?` : ''; + throw new GSDError( + `Unknown config key: "${keyPath}"${suggestion}`, + ErrorClassification.Validation, + ); + } + + const parsedValue = rawValue !== undefined ? parseConfigValue(rawValue) : rawValue; + + // D8: Context value validation (match CJS config.cjs:357-359) + const VALID_CONTEXT_VALUES = ['dev', 'research', 'review']; + if (keyPath === 'context' && !VALID_CONTEXT_VALUES.includes(String(parsedValue))) { + throw new GSDError( + `Invalid context value '${rawValue}'. Valid values: ${VALID_CONTEXT_VALUES.join(', ')}`, + ErrorClassification.Validation, + ); + } + + // D6: Lock protection for read-modify-write (match CJS config.cjs:296) + const paths = planningPaths(projectDir); + const lockPath = await acquireStateLock(paths.config); + try { + let config: Record = {}; + try { + const raw = await readFile(paths.config, 'utf-8'); + config = JSON.parse(raw) as Record; + } catch { + // Start with empty config if file doesn't exist or is malformed + } + + setConfigValue(config, keyPath, parsedValue); + await atomicWriteConfig(paths.config, config); + } finally { + await releaseStateLock(lockPath); + } + + return { data: { set: true, key: keyPath, value: parsedValue } }; +}; + +// ─── configSetModelProfile ──────────────────────────────────────────────── + +/** + * Validate and set the model profile in config.json. + * + * @param args - args[0]=profileName + * @param projectDir - Project root directory + * @returns QueryResult with { set: true, profile, agents } + * @throws GSDError with Validation if profile is invalid + */ +export const configSetModelProfile: QueryHandler = async (args, projectDir) => { + const profileName = args[0]; + if (!profileName) { + throw new GSDError( + `Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`, + ErrorClassification.Validation, + ); + } + + const normalized = profileName.toLowerCase().trim(); + if (!VALID_PROFILES.includes(normalized)) { + throw new GSDError( + `Invalid profile '${profileName}'. Valid profiles: ${VALID_PROFILES.join(', ')}`, + ErrorClassification.Validation, + ); + } + + // D6: Lock protection for read-modify-write + const paths = planningPaths(projectDir); + const lockPath = await acquireStateLock(paths.config); + try { + let config: Record = {}; + try { + const raw = await readFile(paths.config, 'utf-8'); + config = JSON.parse(raw) as Record; + } catch { + // Start with empty config + } + + config.model_profile = normalized; + await atomicWriteConfig(paths.config, config); + } finally { + await releaseStateLock(lockPath); + } + + return { data: { set: true, profile: normalized, agents: MODEL_PROFILES } }; +}; + +// ─── configNewProject ───────────────────────────────────────────────────── + +/** + * Create config.json with defaults and optional user choices. + * + * Idempotent: if config.json already exists, returns { created: false }. + * Detects API key availability from environment variables. + * + * @param args - args[0]=optional JSON string of user choices + * @param projectDir - Project root directory + * @returns QueryResult with { created: true, path } or { created: false, reason } + */ +export const configNewProject: QueryHandler = async (args, projectDir) => { + const paths = planningPaths(projectDir); + + // Idempotent: don't overwrite existing config + if (existsSync(paths.config)) { + return { data: { created: false, reason: 'already_exists' } }; + } + + // Parse user choices + let userChoices: Record = {}; + if (args[0] && args[0].trim() !== '') { + try { + userChoices = JSON.parse(args[0]) as Record; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new GSDError(`Invalid JSON for config-new-project: ${msg}`, ErrorClassification.Validation); + } + } + + // Ensure .planning directory exists + const planningDir = paths.planning; + if (!existsSync(planningDir)) { + await mkdir(planningDir, { recursive: true }); + } + + // D11: Load global defaults from ~/.gsd/defaults.json if present + const homeDir = homedir(); + let globalDefaults: Record = {}; + try { + const defaultsPath = join(homeDir, '.gsd', 'defaults.json'); + const defaultsRaw = await readFile(defaultsPath, 'utf-8'); + globalDefaults = JSON.parse(defaultsRaw) as Record; + } catch { + // No global defaults — continue with hardcoded defaults only + } + + // Detect API key availability (boolean only, never store keys) + const hasBraveSearch = !!(process.env.BRAVE_API_KEY || existsSync(join(homeDir, '.gsd', 'brave_api_key'))); + const hasFirecrawl = !!(process.env.FIRECRAWL_API_KEY || existsSync(join(homeDir, '.gsd', 'firecrawl_api_key'))); + const hasExaSearch = !!(process.env.EXA_API_KEY || existsSync(join(homeDir, '.gsd', 'exa_api_key'))); + + // Build default config + const defaults: Record = { + model_profile: 'balanced', + commit_docs: false, + parallelization: 1, + search_gitignored: false, + brave_search: hasBraveSearch, + firecrawl: hasFirecrawl, + exa_search: hasExaSearch, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + workflow: { + research: true, + plan_check: true, + verifier: true, + nyquist_validation: true, + auto_advance: false, + node_repair: true, + node_repair_budget: 2, + ui_phase: true, + ui_safety_gate: true, + text_mode: false, + research_before_questions: false, + discuss_mode: 'discuss', + skip_discuss: false, + code_review: true, + code_review_depth: 'standard', + }, + hooks: { + context_warnings: true, + }, + project_code: null, + phase_naming: 'sequential', + agent_skills: {}, + features: {}, + }; + + // Deep merge: hardcoded <- globalDefaults <- userChoices (D11) + const config: Record = { + ...defaults, + ...globalDefaults, + ...userChoices, + git: { + ...(defaults.git as Record), + ...((userChoices.git as Record) || {}), + }, + workflow: { + ...(defaults.workflow as Record), + ...((userChoices.workflow as Record) || {}), + }, + hooks: { + ...(defaults.hooks as Record), + ...((userChoices.hooks as Record) || {}), + }, + agent_skills: { + ...((defaults.agent_skills as Record) || {}), + ...((userChoices.agent_skills as Record) || {}), + }, + features: { + ...((defaults.features as Record) || {}), + ...((userChoices.features as Record) || {}), + }, + }; + + await atomicWriteConfig(paths.config, config); + + return { data: { created: true, path: paths.config } }; +}; + +// ─── configEnsureSection ────────────────────────────────────────────────── + +/** + * Idempotently ensure a top-level section exists in config.json. + * + * If the section key doesn't exist, creates it as an empty object. + * If it already exists, preserves its contents. + * + * @param args - args[0]=sectionName + * @param projectDir - Project root directory + * @returns QueryResult with { ensured: true, section } + */ +export const configEnsureSection: QueryHandler = async (args, projectDir) => { + const sectionName = args[0]; + if (!sectionName) { + throw new GSDError('Usage: config-ensure-section
', ErrorClassification.Validation); + } + + const paths = planningPaths(projectDir); + let config: Record = {}; + try { + const raw = await readFile(paths.config, 'utf-8'); + config = JSON.parse(raw) as Record; + } catch { + // Start with empty config + } + + if (!(sectionName in config)) { + config[sectionName] = {}; + } + + await atomicWriteConfig(paths.config, config); + + return { data: { ensured: true, section: sectionName } }; +}; diff --git a/sdk/src/query/config-query.test.ts b/sdk/src/query/config-query.test.ts new file mode 100644 index 000000000..03bdbd612 --- /dev/null +++ b/sdk/src/query/config-query.test.ts @@ -0,0 +1,161 @@ +/** + * Unit tests for config-get and resolve-model query handlers. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { GSDError } from '../errors.js'; + +// ─── Test setup ───────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-cfg-')); + await mkdir(join(tmpDir, '.planning'), { recursive: true }); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── configGet ────────────────────────────────────────────────────────────── + +describe('configGet', () => { + it('returns raw config value for top-level key', async () => { + const { configGet } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + const result = await configGet(['model_profile'], tmpDir); + expect(result.data).toBe('quality'); + }); + + it('traverses dot-notation for nested keys', async () => { + const { configGet } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ workflow: { auto_advance: true } }), + ); + const result = await configGet(['workflow.auto_advance'], tmpDir); + expect(result.data).toBe(true); + }); + + it('throws GSDError when no key provided', async () => { + const { configGet } = await import('./config-query.js'); + await expect(configGet([], tmpDir)).rejects.toThrow(GSDError); + }); + + it('throws GSDError for nonexistent key', async () => { + const { configGet } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + await expect(configGet(['nonexistent.key'], tmpDir)).rejects.toThrow(GSDError); + }); + + it('reads raw config without merging defaults', async () => { + const { configGet } = await import('./config-query.js'); + // Write config with only model_profile -- no workflow section + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + // Accessing workflow should fail (not merged with defaults) + await expect(configGet(['workflow.auto_advance'], tmpDir)).rejects.toThrow(GSDError); + }); +}); + +// ─── resolveModel ─────────────────────────────────────────────────────────── + +describe('resolveModel', () => { + it('returns model and profile for known agent', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + const result = await resolveModel(['gsd-planner'], tmpDir); + const data = result.data as Record; + expect(data).toHaveProperty('model'); + expect(data).toHaveProperty('profile', 'balanced'); + expect(data).not.toHaveProperty('unknown_agent'); + }); + + it('returns unknown_agent flag for unknown agent', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + const result = await resolveModel(['unknown-agent'], tmpDir); + const data = result.data as Record; + expect(data).toHaveProperty('model', 'sonnet'); + expect(data).toHaveProperty('unknown_agent', true); + }); + + it('throws GSDError when no agent type provided', async () => { + const { resolveModel } = await import('./config-query.js'); + await expect(resolveModel([], tmpDir)).rejects.toThrow(GSDError); + }); + + it('respects model_overrides from config', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + model_overrides: { 'gsd-planner': 'openai/gpt-5.4' }, + }), + ); + const result = await resolveModel(['gsd-planner'], tmpDir); + const data = result.data as Record; + expect(data).toHaveProperty('model', 'openai/gpt-5.4'); + }); + + it('returns empty model when resolve_model_ids is omit', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + model_profile: 'balanced', + resolve_model_ids: 'omit', + }), + ); + const result = await resolveModel(['gsd-planner'], tmpDir); + const data = result.data as Record; + expect(data).toHaveProperty('model', ''); + }); +}); + +// ─── MODEL_PROFILES ───────────────────────────────────────────────────────── + +describe('MODEL_PROFILES', () => { + it('contains all 17 agent entries', async () => { + const { MODEL_PROFILES } = await import('./config-query.js'); + expect(Object.keys(MODEL_PROFILES)).toHaveLength(17); + }); + + it('has quality/balanced/budget/adaptive for each agent', async () => { + const { MODEL_PROFILES } = await import('./config-query.js'); + for (const agent of Object.keys(MODEL_PROFILES)) { + expect(MODEL_PROFILES[agent]).toHaveProperty('quality'); + expect(MODEL_PROFILES[agent]).toHaveProperty('balanced'); + expect(MODEL_PROFILES[agent]).toHaveProperty('budget'); + expect(MODEL_PROFILES[agent]).toHaveProperty('adaptive'); + } + }); +}); + +// ─── VALID_PROFILES ───────────────────────────────────────────────────────── + +describe('VALID_PROFILES', () => { + it('contains the four profile names', async () => { + const { VALID_PROFILES } = await import('./config-query.js'); + expect(VALID_PROFILES).toEqual(['quality', 'balanced', 'budget', 'adaptive']); + }); +}); diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts new file mode 100644 index 000000000..91f6a837b --- /dev/null +++ b/sdk/src/query/config-query.ts @@ -0,0 +1,159 @@ +/** + * Config-get and resolve-model query handlers. + * + * Ported from get-shit-done/bin/lib/config.cjs and commands.cjs. + * Provides raw config.json traversal and model profile resolution. + * + * @example + * ```typescript + * import { configGet, resolveModel } from './config-query.js'; + * + * const result = await configGet(['workflow.auto_advance'], '/project'); + * // { data: true } + * + * const model = await resolveModel(['gsd-planner'], '/project'); + * // { data: { model: 'opus', profile: 'balanced' } } + * ``` + */ + +import { readFile } from 'node:fs/promises'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { loadConfig } from '../config.js'; +import { planningPaths } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── MODEL_PROFILES ───────────────────────────────────────────────────────── + +/** + * Mapping of GSD agent type to model alias for each profile tier. + * + * Ported from get-shit-done/bin/lib/model-profiles.cjs. + */ +export const MODEL_PROFILES: Record> = { + 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet', adaptive: 'opus' }, + 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'sonnet' }, + 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'sonnet' }, + 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, + 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, + 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, + 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'opus' }, + 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku', adaptive: 'haiku' }, + 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, + 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, + 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, + 'gsd-nyquist-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, + 'gsd-ui-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, + 'gsd-ui-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, + 'gsd-ui-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, + 'gsd-doc-writer': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, + 'gsd-doc-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, +}; + +/** Valid model profile names. */ +export const VALID_PROFILES: string[] = Object.keys(MODEL_PROFILES['gsd-planner']); + +// ─── configGet ────────────────────────────────────────────────────────────── + +/** + * Query handler for config-get command. + * + * Reads raw .planning/config.json and traverses dot-notation key paths. + * Does NOT merge with defaults (matches gsd-tools.cjs behavior). + * + * @param args - args[0] is the dot-notation key path (e.g., 'workflow.auto_advance') + * @param projectDir - Project root directory + * @returns QueryResult with the config value at the given path + * @throws GSDError with Validation classification if key missing or not found + */ +export const configGet: QueryHandler = async (args, projectDir) => { + const keyPath = args[0]; + if (!keyPath) { + throw new GSDError('Usage: config-get ', ErrorClassification.Validation); + } + + const paths = planningPaths(projectDir); + let raw: string; + try { + raw = await readFile(paths.config, 'utf-8'); + } catch { + throw new GSDError(`No config.json found at ${paths.config}`, ErrorClassification.Validation); + } + + let config: Record; + try { + config = JSON.parse(raw) as Record; + } catch { + throw new GSDError(`Malformed config.json at ${paths.config}`, ErrorClassification.Validation); + } + + const keys = keyPath.split('.'); + let current: unknown = config; + for (const key of keys) { + if (current === undefined || current === null || typeof current !== 'object') { + throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Validation); + } + current = (current as Record)[key]; + } + if (current === undefined) { + throw new GSDError(`Key not found: ${keyPath}`, ErrorClassification.Validation); + } + + return { data: current }; +}; + +// ─── resolveModel ─────────────────────────────────────────────────────────── + +/** + * Query handler for resolve-model command. + * + * Resolves the model alias for a given agent type based on the current profile. + * Uses loadConfig (with defaults) and MODEL_PROFILES for lookup. + * + * @param args - args[0] is the agent type (e.g., 'gsd-planner') + * @param projectDir - Project root directory + * @returns QueryResult with { model, profile } or { model, profile, unknown_agent: true } + * @throws GSDError with Validation classification if agent type not provided + */ +export const resolveModel: QueryHandler = async (args, projectDir) => { + const agentType = args[0]; + if (!agentType) { + throw new GSDError('agent-type required', ErrorClassification.Validation); + } + + const config = await loadConfig(projectDir); + const profile = String(config.model_profile || 'balanced').toLowerCase(); + + // Check per-agent override first + const overrides = (config as Record).model_overrides as Record | undefined; + const override = overrides?.[agentType]; + if (override) { + const agentModels = MODEL_PROFILES[agentType]; + const result = agentModels + ? { model: override, profile } + : { model: override, profile, unknown_agent: true }; + return { data: result }; + } + + // resolve_model_ids: "omit" -- return empty string + const resolveModelIds = (config as Record).resolve_model_ids; + if (resolveModelIds === 'omit') { + const agentModels = MODEL_PROFILES[agentType]; + const result = agentModels + ? { model: '', profile } + : { model: '', profile, unknown_agent: true }; + return { data: result }; + } + + // Fall back to profile lookup + const agentModels = MODEL_PROFILES[agentType]; + if (!agentModels) { + return { data: { model: 'sonnet', profile, unknown_agent: true } }; + } + + if (profile === 'inherit') { + return { data: { model: 'inherit', profile } }; + } + + const alias = agentModels[profile] || agentModels['balanced'] || 'sonnet'; + return { data: { model: alias, profile } }; +}; diff --git a/sdk/src/query/frontmatter-mutation.test.ts b/sdk/src/query/frontmatter-mutation.test.ts new file mode 100644 index 000000000..51116faa1 --- /dev/null +++ b/sdk/src/query/frontmatter-mutation.test.ts @@ -0,0 +1,234 @@ +/** + * Unit tests for frontmatter mutation handlers. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + reconstructFrontmatter, + spliceFrontmatter, + frontmatterSet, + frontmatterMerge, + frontmatterValidate, + FRONTMATTER_SCHEMAS, +} from './frontmatter-mutation.js'; +import { extractFrontmatter } from './frontmatter.js'; + +// ─── reconstructFrontmatter ───────────────────────────────────────────────── + +describe('reconstructFrontmatter', () => { + it('serializes flat key-value pairs', () => { + const result = reconstructFrontmatter({ phase: '10', plan: '01' }); + expect(result).toContain('phase: 10'); + expect(result).toContain('plan: 01'); + }); + + it('serializes short arrays inline', () => { + const result = reconstructFrontmatter({ tags: ['a', 'b', 'c'] }); + expect(result).toBe('tags: [a, b, c]'); + }); + + it('serializes long arrays as dash items', () => { + const result = reconstructFrontmatter({ + items: ['alpha', 'bravo', 'charlie', 'delta'], + }); + expect(result).toContain('items:'); + expect(result).toContain(' - alpha'); + expect(result).toContain(' - delta'); + }); + + it('serializes empty arrays as []', () => { + const result = reconstructFrontmatter({ depends_on: [] }); + expect(result).toBe('depends_on: []'); + }); + + it('serializes nested objects with 2-space indent', () => { + const result = reconstructFrontmatter({ progress: { total: 5, done: 3 } }); + expect(result).toContain('progress:'); + expect(result).toContain(' total: 5'); + expect(result).toContain(' done: 3'); + }); + + it('skips null and undefined values', () => { + const result = reconstructFrontmatter({ a: 'yes', b: null, c: undefined }); + expect(result).toBe('a: yes'); + }); + + it('quotes strings containing colons', () => { + const result = reconstructFrontmatter({ label: 'key: value' }); + expect(result).toContain('"key: value"'); + }); + + it('quotes strings containing hash', () => { + const result = reconstructFrontmatter({ label: 'color #red' }); + expect(result).toContain('"color #red"'); + }); + + it('quotes strings starting with [ or {', () => { + const result = reconstructFrontmatter({ data: '[1,2,3]' }); + expect(result).toContain('"[1,2,3]"'); + }); +}); + +// ─── spliceFrontmatter ────────────────────────────────────────────────────── + +describe('spliceFrontmatter', () => { + it('replaces existing frontmatter block', () => { + const content = '---\nphase: 10\n---\n\n# Body'; + const result = spliceFrontmatter(content, { phase: '11', plan: '01' }); + expect(result).toMatch(/^---\nphase: 11\nplan: 01\n---/); + expect(result).toContain('# Body'); + }); + + it('prepends frontmatter when none exists', () => { + const content = '# Just a body'; + const result = spliceFrontmatter(content, { phase: '10' }); + expect(result).toMatch(/^---\nphase: 10\n---\n\n# Just a body/); + }); +}); + +// ─── frontmatterSet ───────────────────────────────────────────────────────── + +describe('frontmatterSet', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-fm-set-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('writes a single field and round-trips through extractFrontmatter', async () => { + const filePath = join(tmpDir, 'test.md'); + await writeFile(filePath, '---\nphase: 10\nplan: 01\n---\n\n# Body\n'); + + await frontmatterSet([filePath, 'status', 'executing'], tmpDir); + + const content = await readFile(filePath, 'utf-8'); + const fm = extractFrontmatter(content); + expect(fm.status).toBe('executing'); + expect(fm.phase).toBe('10'); + }); + + it('converts boolean string values', async () => { + const filePath = join(tmpDir, 'test.md'); + await writeFile(filePath, '---\nphase: 10\n---\n\n# Body\n'); + + await frontmatterSet([filePath, 'autonomous', 'true'], tmpDir); + + const content = await readFile(filePath, 'utf-8'); + const fm = extractFrontmatter(content); + expect(fm.autonomous).toBe('true'); + }); + + it('handles numeric string values', async () => { + const filePath = join(tmpDir, 'test.md'); + await writeFile(filePath, '---\nphase: 10\n---\n\n# Body\n'); + + await frontmatterSet([filePath, 'wave', '3'], tmpDir); + + const content = await readFile(filePath, 'utf-8'); + const fm = extractFrontmatter(content); + // reconstructFrontmatter outputs the number, extractFrontmatter reads it back as string + expect(String(fm.wave)).toBe('3'); + }); + + it('rejects null bytes in file path', async () => { + await expect( + frontmatterSet(['/path/with\0null', 'key', 'val'], tmpDir) + ).rejects.toThrow(/null bytes/); + }); +}); + +// ─── frontmatterMerge ─────────────────────────────────────────────────────── + +describe('frontmatterMerge', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-fm-merge-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('deep merges JSON into existing frontmatter', async () => { + const filePath = join(tmpDir, 'test.md'); + await writeFile(filePath, '---\nphase: 10\nplan: 01\n---\n\n# Body\n'); + + const result = await frontmatterMerge( + [filePath, JSON.stringify({ status: 'done', wave: 2 })], + tmpDir + ); + + const content = await readFile(filePath, 'utf-8'); + const fm = extractFrontmatter(content); + expect(fm.phase).toBe('10'); + expect(fm.status).toBe('done'); + expect((result.data as Record).merged).toBe(true); + }); + + it('rejects invalid JSON', async () => { + const filePath = join(tmpDir, 'test.md'); + await writeFile(filePath, '---\nphase: 10\n---\n\n# Body\n'); + + await expect( + frontmatterMerge([filePath, 'not-json'], tmpDir) + ).rejects.toThrow(); + }); +}); + +// ─── frontmatterValidate ──────────────────────────────────────────────────── + +describe('frontmatterValidate', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-fm-validate-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('validates a valid plan file', async () => { + const filePath = join(tmpDir, 'plan.md'); + const fm = '---\nphase: 10\nplan: 01\ntype: execute\nwave: 1\ndepends_on: []\nfiles_modified: []\nautonomous: true\nmust_haves:\n truths:\n - foo\n---\n\n# Plan\n'; + await writeFile(filePath, fm); + + const result = await frontmatterValidate([filePath, '--schema', 'plan'], tmpDir); + const data = result.data as Record; + expect(data.valid).toBe(true); + expect((data.missing as string[]).length).toBe(0); + }); + + it('detects missing fields', async () => { + const filePath = join(tmpDir, 'plan.md'); + await writeFile(filePath, '---\nphase: 10\n---\n\n# Plan\n'); + + const result = await frontmatterValidate([filePath, '--schema', 'plan'], tmpDir); + const data = result.data as Record; + expect(data.valid).toBe(false); + expect((data.missing as string[]).length).toBeGreaterThan(0); + }); + + it('rejects unknown schema', async () => { + const filePath = join(tmpDir, 'test.md'); + await writeFile(filePath, '---\nphase: 10\n---\n\n# Body\n'); + + await expect( + frontmatterValidate([filePath, '--schema', 'unknown'], tmpDir) + ).rejects.toThrow(/Unknown schema/); + }); + + it('has plan, summary, and verification schemas', () => { + expect(FRONTMATTER_SCHEMAS).toHaveProperty('plan'); + expect(FRONTMATTER_SCHEMAS).toHaveProperty('summary'); + expect(FRONTMATTER_SCHEMAS).toHaveProperty('verification'); + }); +}); diff --git a/sdk/src/query/frontmatter-mutation.ts b/sdk/src/query/frontmatter-mutation.ts new file mode 100644 index 000000000..073cb5984 --- /dev/null +++ b/sdk/src/query/frontmatter-mutation.ts @@ -0,0 +1,302 @@ +/** + * Frontmatter mutation handlers — write operations for YAML frontmatter. + * + * Ported from get-shit-done/bin/lib/frontmatter.cjs. + * Provides reconstructFrontmatter (serialization), spliceFrontmatter (replacement), + * and query handlers for frontmatter.set, frontmatter.merge, frontmatter.validate. + * + * @example + * ```typescript + * import { reconstructFrontmatter, spliceFrontmatter } from './frontmatter-mutation.js'; + * + * const yaml = reconstructFrontmatter({ phase: '10', tags: ['a', 'b'] }); + * // 'phase: 10\ntags: [a, b]' + * + * const updated = spliceFrontmatter('---\nold: val\n---\nbody', { new: 'val' }); + * // '---\nnew: val\n---\nbody' + * ``` + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { join, isAbsolute } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { extractFrontmatter } from './frontmatter.js'; +import { normalizeMd } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── FRONTMATTER_SCHEMAS ────────────────────────────────────────────────── + +/** Schema definitions for frontmatter validation. */ +export const FRONTMATTER_SCHEMAS: Record = { + plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] }, + summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] }, + verification: { required: ['phase', 'verified', 'status', 'score'] }, +}; + +// ─── reconstructFrontmatter ──────────────────────────────────────────────── + +/** + * Serialize a flat/nested object into YAML frontmatter lines. + * + * Port of `reconstructFrontmatter` from frontmatter.cjs lines 122-183. + * Handles arrays (inline/dash), nested objects (2 levels), and quoting. + * + * @param obj - Object to serialize + * @returns YAML string (without --- delimiters) + */ +export function reconstructFrontmatter(obj: Record): string { + const lines: string[] = []; + + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) continue; + + if (Array.isArray(value)) { + serializeArray(lines, key, value, ''); + } else if (typeof value === 'object') { + lines.push(`${key}:`); + for (const [subkey, subval] of Object.entries(value as Record)) { + if (subval === null || subval === undefined) continue; + if (Array.isArray(subval)) { + serializeArray(lines, subkey, subval, ' '); + } else if (typeof subval === 'object') { + lines.push(` ${subkey}:`); + for (const [subsubkey, subsubval] of Object.entries(subval as Record)) { + if (subsubval === null || subsubval === undefined) continue; + if (Array.isArray(subsubval)) { + if (subsubval.length === 0) { + lines.push(` ${subsubkey}: []`); + } else { + lines.push(` ${subsubkey}:`); + for (const item of subsubval) { + lines.push(` - ${item}`); + } + } + } else { + lines.push(` ${subsubkey}: ${subsubval}`); + } + } + } else { + const sv = String(subval); + lines.push(` ${subkey}: ${needsQuoting(sv) ? `"${sv}"` : sv}`); + } + } + } else { + const sv = String(value); + if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) { + lines.push(`${key}: "${sv}"`); + } else { + lines.push(`${key}: ${sv}`); + } + } + } + + return lines.join('\n'); +} + +/** Serialize an array at the given indent level. */ +function serializeArray(lines: string[], key: string, arr: unknown[], indent: string): void { + if (arr.length === 0) { + lines.push(`${indent}${key}: []`); + } else if ( + arr.every(v => typeof v === 'string') && + arr.length <= 3 && + (arr as string[]).join(', ').length < 60 + ) { + lines.push(`${indent}${key}: [${(arr as string[]).join(', ')}]`); + } else { + lines.push(`${indent}${key}:`); + for (const item of arr) { + const s = String(item); + lines.push(`${indent} - ${typeof item === 'string' && needsQuoting(s) ? `"${s}"` : s}`); + } + } +} + +/** Check if a string value needs quoting in YAML. */ +function needsQuoting(s: string): boolean { + return s.includes(':') || s.includes('#'); +} + +// ─── spliceFrontmatter ───────────────────────────────────────────────────── + +/** + * Replace or prepend frontmatter in content. + * + * Port of `spliceFrontmatter` from frontmatter.cjs lines 186-193. + * + * @param content - File content with potential existing frontmatter + * @param newObj - New frontmatter object to serialize + * @returns Content with updated frontmatter + */ +export function spliceFrontmatter(content: string, newObj: Record): string { + const yamlStr = reconstructFrontmatter(newObj); + const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/); + if (match) { + return `---\n${yamlStr}\n---` + content.slice(match[0].length); + } + return `---\n${yamlStr}\n---\n\n` + content; +} + +// ─── parseSimpleValue ────────────────────────────────────────────────────── + +/** + * Parse a simple CLI value string into a typed value. + * Tries JSON.parse first (handles booleans, numbers, arrays, objects). + * Falls back to raw string. + */ +function parseSimpleValue(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +// ─── frontmatterSet ──────────────────────────────────────────────────────── + +/** + * Query handler for frontmatter.set command. + * + * Reads a file, sets a single frontmatter field, writes back with normalization. + * Port of `cmdFrontmatterSet` from frontmatter.cjs lines 328-342. + * + * @param args - args[0]: file path, args[1]: field name, args[2]: value + * @param projectDir - Project root directory + * @returns QueryResult with { updated: true, field, value } + */ +export const frontmatterSet: QueryHandler = async (args, projectDir) => { + const filePath = args[0]; + const field = args[1]; + const value = args[2]; + + if (!filePath || !field || value === undefined) { + throw new GSDError('file, field, and value required', ErrorClassification.Validation); + } + + // Path traversal guard: reject null bytes + if (filePath.includes('\0')) { + throw new GSDError('file path contains null bytes', ErrorClassification.Validation); + } + + const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + + let content: string; + try { + content = await readFile(fullPath, 'utf-8'); + } catch { + return { data: { error: 'File not found', path: filePath } }; + } + + const fm = extractFrontmatter(content); + fm[field] = parseSimpleValue(value); + const newContent = spliceFrontmatter(content, fm); + await writeFile(fullPath, normalizeMd(newContent), 'utf-8'); + + return { data: { updated: true, field, value: fm[field] } }; +}; + +// ─── frontmatterMerge ────────────────────────────────────────────────────── + +/** + * Query handler for frontmatter.merge command. + * + * Reads a file, merges JSON object into existing frontmatter, writes back. + * Port of `cmdFrontmatterMerge` from frontmatter.cjs lines 344-356. + * + * @param args - args[0]: file path, args[1]: JSON string + * @param projectDir - Project root directory + * @returns QueryResult with { merged: true, fields: [...] } + */ +export const frontmatterMerge: QueryHandler = async (args, projectDir) => { + const filePath = args[0]; + const jsonString = args[1]; + + if (!filePath || !jsonString) { + throw new GSDError('file and data required', ErrorClassification.Validation); + } + + // Path traversal guard: reject null bytes (consistent with frontmatterSet) + if (filePath.includes('\0')) { + throw new GSDError('file path contains null bytes', ErrorClassification.Validation); + } + + const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + + let content: string; + try { + content = await readFile(fullPath, 'utf-8'); + } catch { + return { data: { error: 'File not found', path: filePath } }; + } + + let mergeData: Record; + try { + mergeData = JSON.parse(jsonString) as Record; + } catch { + throw new GSDError('Invalid JSON for merge data', ErrorClassification.Validation); + } + + const fm = extractFrontmatter(content); + Object.assign(fm, mergeData); + const newContent = spliceFrontmatter(content, fm); + await writeFile(fullPath, normalizeMd(newContent), 'utf-8'); + + return { data: { merged: true, fields: Object.keys(mergeData) } }; +}; + +// ─── frontmatterValidate ─────────────────────────────────────────────────── + +/** + * Query handler for frontmatter.validate command. + * + * Reads a file and checks its frontmatter against a known schema. + * Port of `cmdFrontmatterValidate` from frontmatter.cjs lines 358-369. + * + * @param args - args[0]: file path, args[1]: '--schema', args[2]: schema name + * @param projectDir - Project root directory + * @returns QueryResult with { valid, missing, present, schema } + */ +export const frontmatterValidate: QueryHandler = async (args, projectDir) => { + const filePath = args[0]; + + // Parse --schema flag from args + let schemaName: string | undefined; + for (let i = 1; i < args.length; i++) { + if (args[i] === '--schema' && args[i + 1]) { + schemaName = args[i + 1]; + break; + } + } + + if (!filePath || !schemaName) { + throw new GSDError('file and schema required', ErrorClassification.Validation); + } + + // Path traversal guard: reject null bytes (consistent with frontmatterSet) + if (filePath.includes('\0')) { + throw new GSDError('file path contains null bytes', ErrorClassification.Validation); + } + + const schema = FRONTMATTER_SCHEMAS[schemaName]; + if (!schema) { + throw new GSDError( + `Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`, + ErrorClassification.Validation + ); + } + + const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + + let content: string; + try { + content = await readFile(fullPath, 'utf-8'); + } catch { + return { data: { error: 'File not found', path: filePath } }; + } + + const fm = extractFrontmatter(content); + const missing = schema.required.filter(f => fm[f] === undefined); + const present = schema.required.filter(f => fm[f] !== undefined); + + return { data: { valid: missing.length === 0, missing, present, schema: schemaName } }; +}; diff --git a/sdk/src/query/frontmatter.test.ts b/sdk/src/query/frontmatter.test.ts new file mode 100644 index 000000000..9c969afef --- /dev/null +++ b/sdk/src/query/frontmatter.test.ts @@ -0,0 +1,266 @@ +/** + * Unit tests for frontmatter parser and query handler. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + splitInlineArray, + extractFrontmatter, + stripFrontmatter, + frontmatterGet, + parseMustHavesBlock, +} from './frontmatter.js'; + +// ─── splitInlineArray ─────────────────────────────────────────────────────── + +describe('splitInlineArray', () => { + it('splits simple CSV', () => { + expect(splitInlineArray('a, b, c')).toEqual(['a', 'b', 'c']); + }); + + it('handles quoted strings with commas', () => { + expect(splitInlineArray('"a, b", c')).toEqual(['a, b', 'c']); + }); + + it('handles single-quoted strings', () => { + expect(splitInlineArray("'a, b', c")).toEqual(['a, b', 'c']); + }); + + it('trims whitespace', () => { + expect(splitInlineArray(' a , b ')).toEqual(['a', 'b']); + }); + + it('returns empty array for empty string', () => { + expect(splitInlineArray('')).toEqual([]); + }); +}); + +// ─── extractFrontmatter ───────────────────────────────────────────────────── + +describe('extractFrontmatter', () => { + it('parses simple key-value pairs', () => { + const content = '---\nkey: value\n---\nbody'; + const result = extractFrontmatter(content); + expect(result).toEqual({ key: 'value' }); + }); + + it('parses nested objects', () => { + const content = '---\nparent:\n child: value\n---\n'; + const result = extractFrontmatter(content); + expect(result).toEqual({ parent: { child: 'value' } }); + }); + + it('parses inline arrays', () => { + const content = '---\ntags: [a, b, c]\n---\n'; + const result = extractFrontmatter(content); + expect(result).toEqual({ tags: ['a', 'b', 'c'] }); + }); + + it('parses dash arrays', () => { + const content = '---\nitems:\n - one\n - two\n---\n'; + const result = extractFrontmatter(content); + expect(result).toEqual({ items: ['one', 'two'] }); + }); + + it('uses the LAST block when multiple stacked blocks exist', () => { + const content = '---\nold: data\n---\n---\nnew: data\n---\nbody'; + const result = extractFrontmatter(content); + expect(result).toEqual({ new: 'data' }); + }); + + it('handles empty-object-to-array conversion', () => { + const content = '---\nlist:\n - item1\n - item2\n---\n'; + const result = extractFrontmatter(content); + expect(result).toEqual({ list: ['item1', 'item2'] }); + }); + + it('returns empty object when no frontmatter', () => { + const result = extractFrontmatter('no frontmatter here'); + expect(result).toEqual({}); + }); + + it('strips surrounding quotes from values', () => { + const content = '---\nkey: "quoted"\n---\n'; + const result = extractFrontmatter(content); + expect(result).toEqual({ key: 'quoted' }); + }); + + it('handles CRLF line endings', () => { + const content = '---\r\nkey: value\r\n---\r\nbody'; + const result = extractFrontmatter(content); + expect(result).toEqual({ key: 'value' }); + }); +}); + +// ─── stripFrontmatter ─────────────────────────────────────────────────────── + +describe('stripFrontmatter', () => { + it('strips single frontmatter block', () => { + const result = stripFrontmatter('---\nk: v\n---\nbody'); + expect(result).toBe('body'); + }); + + it('strips multiple stacked blocks', () => { + const result = stripFrontmatter('---\na: 1\n---\n---\nb: 2\n---\nbody'); + expect(result).toBe('body'); + }); + + it('returns content unchanged when no frontmatter', () => { + expect(stripFrontmatter('just body')).toBe('just body'); + }); + + it('handles leading whitespace after strip', () => { + const result = stripFrontmatter('---\nk: v\n---\n\nbody'); + // After stripping, leading whitespace/newlines may remain + expect(result.trim()).toBe('body'); + }); +}); + +// ─── frontmatterGet ───────────────────────────────────────────────────────── + +describe('frontmatterGet', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-fm-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns parsed frontmatter from a file', async () => { + await writeFile(join(tmpDir, 'test.md'), '---\nkey: value\n---\nbody'); + const result = await frontmatterGet(['test.md'], tmpDir); + expect(result.data).toEqual({ key: 'value' }); + }); + + it('returns single field when field arg provided', async () => { + await writeFile(join(tmpDir, 'test.md'), '---\nkey: value\n---\nbody'); + const result = await frontmatterGet(['test.md', 'key'], tmpDir); + expect(result.data).toEqual({ key: 'value' }); + }); + + it('returns error for missing file', async () => { + const result = await frontmatterGet(['missing.md'], tmpDir); + expect(result.data).toEqual({ error: 'File not found', path: 'missing.md' }); + }); + + it('throws GSDError for null bytes in path', async () => { + const { GSDError } = await import('../errors.js'); + await expect(frontmatterGet(['bad\0path.md'], tmpDir)).rejects.toThrow(GSDError); + }); +}); + +// ─── parseMustHavesBlock ─────────────────────────────────────────────────── + +describe('parseMustHavesBlock', () => { + it('parses artifacts block with path, provides, min_lines, contains, exports', () => { + const content = `--- +phase: 12 +must_haves: + artifacts: + - path: sdk/src/foo.ts + provides: Foo handler + min_lines: 50 + contains: export function foo + exports: + - foo + - bar +--- +body`; + const result = parseMustHavesBlock(content, 'artifacts'); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toEqual({ + path: 'sdk/src/foo.ts', + provides: 'Foo handler', + min_lines: 50, + contains: 'export function foo', + exports: ['foo', 'bar'], + }); + }); + + it('parses key_links block with from, to, via, pattern', () => { + const content = `--- +phase: 12 +must_haves: + key_links: + - from: src/a.ts + to: src/b.ts + via: import something + pattern: import.*something.*from.*b +--- +body`; + const result = parseMustHavesBlock(content, 'key_links'); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toEqual({ + from: 'src/a.ts', + to: 'src/b.ts', + via: 'import something', + pattern: 'import.*something.*from.*b', + }); + }); + + it('parses simple string items (truths)', () => { + const content = `--- +phase: 12 +must_haves: + truths: + - Running verify returns valid + - Running check returns true +--- +body`; + const result = parseMustHavesBlock(content, 'truths'); + expect(result.items).toHaveLength(2); + expect(result.items[0]).toBe('Running verify returns valid'); + expect(result.items[1]).toBe('Running check returns true'); + }); + + it('preserves nested array values (exports: [a, b])', () => { + const content = `--- +must_haves: + artifacts: + - path: foo.ts + exports: + - alpha + - beta +--- +`; + const result = parseMustHavesBlock(content, 'artifacts'); + expect(result.items[0]).toMatchObject({ exports: ['alpha', 'beta'] }); + }); + + it('returns empty items for missing block', () => { + const content = `--- +must_haves: + truths: + - something +--- +`; + const result = parseMustHavesBlock(content, 'artifacts'); + expect(result.items).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('returns empty items for no frontmatter', () => { + const result = parseMustHavesBlock('no frontmatter here', 'artifacts'); + expect(result.items).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('emits diagnostic warning when content lines exist but 0 items parsed', () => { + const content = `--- +must_haves: + artifacts: + some badly formatted content +--- +`; + const result = parseMustHavesBlock(content, 'artifacts'); + expect(result.items).toEqual([]); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('artifacts'); + }); +}); diff --git a/sdk/src/query/frontmatter.ts b/sdk/src/query/frontmatter.ts new file mode 100644 index 000000000..5ed9915c2 --- /dev/null +++ b/sdk/src/query/frontmatter.ts @@ -0,0 +1,353 @@ +/** + * Frontmatter parser and query handler. + * + * Ported from get-shit-done/bin/lib/frontmatter.cjs and state.cjs. + * Provides YAML frontmatter extraction from .planning/ artifacts. + * + * @example + * ```typescript + * import { extractFrontmatter, frontmatterGet } from './frontmatter.js'; + * + * const fm = extractFrontmatter('---\nphase: 10\nplan: 01\n---\nbody'); + * // { phase: '10', plan: '01' } + * + * const result = await frontmatterGet(['STATE.md'], '/project'); + * // { data: { gsd_state_version: '1.0', milestone: 'v3.0', ... } } + * ``` + */ + +import { readFile } from 'node:fs/promises'; +import { join, isAbsolute } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import type { QueryHandler } from './utils.js'; +import { escapeRegex } from './helpers.js'; + +// ─── splitInlineArray ─────────────────────────────────────────────────────── + +/** + * Quote-aware CSV splitting for inline YAML arrays. + * + * Handles both single and double quotes, preserving commas inside quotes. + * + * @param body - The content inside brackets, e.g. 'a, "b, c", d' + * @returns Array of trimmed values + */ +export function splitInlineArray(body: string): string[] { + const items: string[] = []; + let current = ''; + let inQuote: string | null = null; + + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (inQuote) { + if (ch === inQuote) { + inQuote = null; + } else { + current += ch; + } + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === ',') { + const trimmed = current.trim(); + if (trimmed) items.push(trimmed); + current = ''; + } else { + current += ch; + } + } + const trimmed = current.trim(); + if (trimmed) items.push(trimmed); + return items; +} + +// ─── extractFrontmatter ───────────────────────────────────────────────────── + +/** + * Parse YAML frontmatter from file content. + * + * Full stack-based parser supporting: + * - Simple key: value pairs + * - Nested objects via indentation + * - Inline arrays: key: [a, b, c] + * - Dash arrays with auto-conversion from empty objects + * - Multiple stacked blocks (uses the LAST match) + * - CRLF line endings + * - Quoted value stripping + * + * @param content - File content potentially containing frontmatter + * @returns Parsed frontmatter as a record, or empty object if none found + */ +export function extractFrontmatter(content: string): Record { + const frontmatter: Record = {}; + // Find ALL frontmatter blocks. Use the LAST one (corruption recovery). + const allBlocks = [...content.matchAll(/(?:^|\n)\s*---\r?\n([\s\S]+?)\r?\n---/g)]; + const match = allBlocks.length > 0 ? allBlocks[allBlocks.length - 1] : null; + if (!match) return frontmatter; + + const yaml = match[1]; + const lines = yaml.split(/\r?\n/); + + // Stack to track nested objects: [{obj, key, indent}] + const stack: Array<{ obj: Record | unknown[]; key: string | null; indent: number }> = [ + { obj: frontmatter, key: null, indent: -1 }, + ]; + + for (const line of lines) { + // Skip empty lines + if (line.trim() === '') continue; + + // Calculate indentation (number of leading spaces) + 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]; + + // Check for 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 -- could be nested object or array + (current.obj as Record)[key] = value === '[' ? [] : {}; + current.key = null; + // Push new context for potential nested content + stack.push({ obj: (current.obj as Record)[key] as Record, key: null, indent }); + } else if (value.startsWith('[') && value.endsWith(']')) { + // Inline array: key: [a, b, c] + (current.obj as Record)[key] = splitInlineArray(value.slice(1, -1)); + current.key = null; + } else { + // Simple key: value -- strip surrounding quotes + (current.obj as Record)[key] = value.replace(/^["']|["']$/g, ''); + current.key = null; + } + } else if (line.trim().startsWith('- ')) { + // Array item + const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, ''); + + // 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) { + // Find the key in parent that points to this object and convert it + const parent = stack.length > 1 ? stack[stack.length - 2] : null; + if (parent && !Array.isArray(parent.obj)) { + for (const k of Object.keys(parent.obj as Record)) { + if ((parent.obj as Record)[k] === current.obj) { + (parent.obj as Record)[k] = [itemValue]; + current.obj = (parent.obj as Record)[k] as unknown[]; + break; + } + } + } + } else if (Array.isArray(current.obj)) { + current.obj.push(itemValue); + } + } + } + + return frontmatter; +} + +// ─── stripFrontmatter ─────────────────────────────────────────────────────── + +/** + * Strip all frontmatter blocks from the start of content. + * + * Handles CRLF line endings and multiple stacked blocks (corruption recovery). + * Greedy: keeps stripping ---...--- blocks separated by optional whitespace. + * + * @param content - File content with potential frontmatter + * @returns Content with frontmatter removed + */ +export function stripFrontmatter(content: string): string { + let result = content; + // eslint-disable-next-line no-constant-condition + while (true) { + const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, ''); + if (stripped === result) break; + result = stripped; + } + return result; +} + +// ─── parseMustHavesBlock ──────────────────────────────────────────────────── + +/** + * Result of parsing a must_haves block from frontmatter. + */ +export interface MustHavesBlockResult { + items: unknown[]; + warnings: string[]; +} + +/** + * Parse a named block from must_haves in raw frontmatter YAML. + * + * Port of `parseMustHavesBlock` from `get-shit-done/bin/lib/frontmatter.cjs` lines 195-301. + * Handles 3-level nesting: `must_haves > blockName > [{key: value, ...}]`. + * Supports simple string items, structured objects with key-value pairs, + * and nested arrays within items. + * + * @param content - File content with frontmatter + * @param blockName - Block name under must_haves (e.g. 'artifacts', 'key_links', 'truths') + * @returns Structured result with items array and warnings + */ +export function parseMustHavesBlock(content: string, blockName: string): MustHavesBlockResult { + const warnings: string[] = []; + + // Extract raw YAML from first ---\n...\n--- block + const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/); + if (!fmMatch) return { items: [], warnings }; + + const yaml = fmMatch[1]; + + // Find must_haves: at its indentation level + const mustHavesMatch = yaml.match(/^(\s*)must_haves:\s*$/m); + if (!mustHavesMatch) return { items: [], warnings }; + const mustHavesIndent = mustHavesMatch[1].length; + + // Find the block (e.g., "artifacts:", "key_links:") under must_haves + const blockPattern = new RegExp(`^(\\s+)${escapeRegex(blockName)}:\\s*$`, 'm'); + const blockMatch = yaml.match(blockPattern); + if (!blockMatch) return { items: [], warnings }; + + const blockIndent = blockMatch[1].length; + // The block must be nested under must_haves (more indented) + if (blockIndent <= mustHavesIndent) return { items: [], warnings }; + + // Find where the block starts in the yaml string + const blockStart = yaml.indexOf(blockMatch[0]); + if (blockStart === -1) return { items: [], warnings }; + + const afterBlock = yaml.slice(blockStart); + const blockLines = afterBlock.split(/\r?\n/).slice(1); // skip the header line + + // List items are indented one level deeper than blockIndent + // Continuation KVs are indented one level deeper than list items + const items: unknown[] = []; + let current: Record | string | null = null; + let listItemIndent = -1; // detected from first "- " line + + for (const line of blockLines) { + // Skip empty lines + if (line.trim() === '') continue; + const indentMatch = line.match(/^(\s*)/); + const indent = indentMatch ? indentMatch[1].length : 0; + // Stop at same or lower indent level than the block header + if (indent <= blockIndent && line.trim() !== '') break; + + const trimmed = line.trim(); + + if (trimmed.startsWith('- ')) { + // Detect list item indent from the first occurrence + if (listItemIndent === -1) listItemIndent = indent; + + // Only treat as a top-level list item if at the expected indent + if (indent === listItemIndent) { + if (current !== null) items.push(current); + const afterDash = trimmed.slice(2); + // Check if it's a simple string item (no colon means not a key-value) + if (!afterDash.includes(':')) { + current = afterDash.replace(/^["']|["']$/g, ''); + } else { + // Key-value on same line as dash: "- path: value" + const kvMatch = afterDash.match(/^(\w+):\s*"?([^"]*)"?\s*$/); + if (kvMatch) { + current = {} as Record; + current[kvMatch[1]] = kvMatch[2]; + } else { + current = {} as Record; + } + } + continue; + } + } + + if (current !== null && typeof current === 'object' && indent > listItemIndent) { + // Continuation key-value or nested array item + if (trimmed.startsWith('- ')) { + // Array item under a key + const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, ''); + const keys = Object.keys(current); + const lastKey = keys[keys.length - 1]; + if (lastKey && !Array.isArray(current[lastKey])) { + current[lastKey] = current[lastKey] ? [current[lastKey]] : []; + } + if (lastKey) (current[lastKey] as unknown[]).push(arrVal); + } else { + const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/); + if (kvMatch) { + const val = kvMatch[2]; + // Try to parse as number + current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val; + } + } + } + } + if (current !== null) items.push(current); + + // Diagnostic warning when block has content lines but parsed 0 items + if (items.length === 0 && blockLines.length > 0) { + const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length; + if (nonEmptyLines > 0) { + warnings.push( + `must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` + + `Possible YAML formatting issue.` + ); + } + } + + return { items, warnings }; +} + +// ─── frontmatterGet ───────────────────────────────────────────────────────── + +/** + * Query handler for frontmatter.get command. + * + * Reads a file, extracts frontmatter, and optionally returns a single field. + * Rejects null bytes in path (security: path traversal guard). + * + * @param args - args[0]: file path, args[1]: optional field name + * @param projectDir - Project root directory + * @returns QueryResult with parsed frontmatter or single field value + */ +export const frontmatterGet: QueryHandler = async (args, projectDir) => { + const filePath = args[0]; + if (!filePath) { + throw new GSDError('file path required', ErrorClassification.Validation); + } + + // Path traversal guard: reject null bytes + if (filePath.includes('\0')) { + throw new GSDError('file path contains null bytes', ErrorClassification.Validation); + } + + const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + + let content: string; + try { + content = await readFile(fullPath, 'utf-8'); + } catch { + return { data: { error: 'File not found', path: filePath } }; + } + + const fm = extractFrontmatter(content); + const field = args[1]; + + if (field) { + const value = fm[field]; + if (value === undefined) { + return { data: { error: 'Field not found', field } }; + } + return { data: { [field]: value } }; + } + + return { data: fm }; +}; diff --git a/sdk/src/query/helpers.test.ts b/sdk/src/query/helpers.test.ts new file mode 100644 index 000000000..159cb84c2 --- /dev/null +++ b/sdk/src/query/helpers.test.ts @@ -0,0 +1,225 @@ +/** + * Unit tests for shared query helpers. + */ + +import { describe, it, expect } from 'vitest'; +import { + escapeRegex, + normalizePhaseName, + comparePhaseNum, + extractPhaseToken, + phaseTokenMatches, + toPosixPath, + stateExtractField, + planningPaths, + normalizeMd, +} from './helpers.js'; + +// ─── escapeRegex ──────────────────────────────────────────────────────────── + +describe('escapeRegex', () => { + it('escapes dots', () => { + expect(escapeRegex('foo.bar')).toBe('foo\\.bar'); + }); + + it('escapes brackets', () => { + expect(escapeRegex('test[0]')).toBe('test\\[0\\]'); + }); + + it('escapes all regex special characters', () => { + expect(escapeRegex('a.*+?^${}()|[]\\')).toBe('a\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\'); + }); + + it('returns plain strings unchanged', () => { + expect(escapeRegex('hello')).toBe('hello'); + }); +}); + +// ─── normalizePhaseName ───────────────────────────────────────────────────── + +describe('normalizePhaseName', () => { + it('pads single digit to 2 digits', () => { + expect(normalizePhaseName('9')).toBe('09'); + }); + + it('strips project code prefix', () => { + expect(normalizePhaseName('CK-01')).toBe('01'); + }); + + it('preserves letter suffix', () => { + expect(normalizePhaseName('12A')).toBe('12A'); + }); + + it('preserves decimal parts', () => { + expect(normalizePhaseName('12.1')).toBe('12.1'); + }); + + it('strips project code and normalizes numeric part', () => { + // PROJ-42 -> strip PROJ- prefix -> 42 -> pad to 42 + expect(normalizePhaseName('PROJ-42')).toBe('42'); + }); + + it('handles already-padded numbers', () => { + expect(normalizePhaseName('01')).toBe('01'); + }); +}); + +// ─── comparePhaseNum ──────────────────────────────────────────────────────── + +describe('comparePhaseNum', () => { + it('compares numeric phases', () => { + expect(comparePhaseNum('01-foo', '02-bar')).toBeLessThan(0); + }); + + it('compares letter suffixes', () => { + expect(comparePhaseNum('12A-foo', '12B-bar')).toBeLessThan(0); + }); + + it('sorts no-decimal before decimal', () => { + expect(comparePhaseNum('12-foo', '12.1-bar')).toBeLessThan(0); + }); + + it('returns 0 for equal phases', () => { + expect(comparePhaseNum('01-name', '01-other')).toBe(0); + }); + + it('falls back to string comparison for custom IDs', () => { + const result = comparePhaseNum('AUTH-name', 'PROJ-name'); + expect(typeof result).toBe('number'); + }); +}); + +// ─── extractPhaseToken ────────────────────────────────────────────────────── + +describe('extractPhaseToken', () => { + it('extracts plain numeric token', () => { + expect(extractPhaseToken('01-foundation')).toBe('01'); + }); + + it('extracts project-code-prefixed token', () => { + expect(extractPhaseToken('CK-01-name')).toBe('CK-01'); + }); + + it('extracts letter suffix token', () => { + expect(extractPhaseToken('12A-name')).toBe('12A'); + }); + + it('extracts decimal token', () => { + expect(extractPhaseToken('999.6-name')).toBe('999.6'); + }); +}); + +// ─── phaseTokenMatches ────────────────────────────────────────────────────── + +describe('phaseTokenMatches', () => { + it('matches normalized numeric phase', () => { + expect(phaseTokenMatches('09-foundation', '09')).toBe(true); + }); + + it('matches after stripping project code', () => { + expect(phaseTokenMatches('CK-01-name', '01')).toBe(true); + }); + + it('does not match different phases', () => { + expect(phaseTokenMatches('09-foundation', '10')).toBe(false); + }); +}); + +// ─── toPosixPath ──────────────────────────────────────────────────────────── + +describe('toPosixPath', () => { + it('converts backslashes to forward slashes', () => { + expect(toPosixPath('a\\b\\c')).toBe('a/b/c'); + }); + + it('preserves already-posix paths', () => { + expect(toPosixPath('a/b/c')).toBe('a/b/c'); + }); +}); + +// ─── stateExtractField ────────────────────────────────────────────────────── + +describe('stateExtractField', () => { + it('extracts bold field value', () => { + const content = '**Phase:** 10\n**Plan:** 1'; + expect(stateExtractField(content, 'Phase')).toBe('10'); + }); + + it('extracts plain field value', () => { + const content = 'Status: executing\nPlan: 1'; + expect(stateExtractField(content, 'Status')).toBe('executing'); + }); + + it('returns null for missing field', () => { + expect(stateExtractField('no fields here', 'Missing')).toBeNull(); + }); + + it('is case-insensitive', () => { + const content = '**phase:** 10'; + expect(stateExtractField(content, 'Phase')).toBe('10'); + }); +}); + +// ─── planningPaths ────────────────────────────────────────────────────────── + +describe('planningPaths', () => { + it('returns all expected keys', () => { + const paths = planningPaths('/proj'); + expect(paths).toHaveProperty('planning'); + expect(paths).toHaveProperty('state'); + expect(paths).toHaveProperty('roadmap'); + expect(paths).toHaveProperty('project'); + expect(paths).toHaveProperty('config'); + expect(paths).toHaveProperty('phases'); + expect(paths).toHaveProperty('requirements'); + }); + + it('uses posix paths', () => { + const paths = planningPaths('/proj'); + expect(paths.state).toContain('.planning/STATE.md'); + expect(paths.config).toContain('.planning/config.json'); + }); +}); + +// ─── normalizeMd ─────────────────────────────────────────────────────────── + +describe('normalizeMd', () => { + it('converts CRLF to LF', () => { + const result = normalizeMd('line1\r\nline2\r\n'); + expect(result).not.toContain('\r'); + expect(result).toContain('line1\nline2'); + }); + + it('ensures terminal newline', () => { + const result = normalizeMd('no trailing newline'); + expect(result).toMatch(/\n$/); + }); + + it('collapses 3+ consecutive blank lines to 2', () => { + const result = normalizeMd('a\n\n\n\nb'); + // Should have at most 2 consecutive newlines (1 blank line between) + expect(result).not.toContain('\n\n\n'); + }); + + it('preserves content inside code fences', () => { + const input = '```\n code with trailing spaces \n```\n'; + const result = normalizeMd(input); + expect(result).toContain(' code with trailing spaces '); + }); + + it('adds blank line before headings when missing', () => { + const result = normalizeMd('some text\n# Heading\n'); + expect(result).toContain('some text\n\n# Heading'); + }); + + it('returns empty-ish content unchanged', () => { + expect(normalizeMd('')).toBe(''); + expect(normalizeMd(null as unknown as string)).toBe(null); + }); + + it('handles normal markdown without changes', () => { + const input = '# Title\n\nSome text.\n\n## Section\n\nMore text.\n'; + const result = normalizeMd(input); + expect(result).toBe(input); + }); +}); diff --git a/sdk/src/query/helpers.ts b/sdk/src/query/helpers.ts new file mode 100644 index 000000000..802ce2dcd --- /dev/null +++ b/sdk/src/query/helpers.ts @@ -0,0 +1,324 @@ +/** + * Shared query helpers — cross-cutting utility functions used across query modules. + * + * Ported from get-shit-done/bin/lib/core.cjs and state.cjs. + * Provides phase name normalization, path handling, regex escaping, + * and STATE.md field extraction. + * + * @example + * ```typescript + * import { normalizePhaseName, planningPaths } from './helpers.js'; + * + * normalizePhaseName('9'); // '09' + * normalizePhaseName('CK-01'); // '01' + * + * const paths = planningPaths('/project'); + * // { planning: '/project/.planning', state: '/project/.planning/STATE.md', ... } + * ``` + */ + +import { join } from 'node:path'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +/** Paths to common .planning files. */ +export interface PlanningPaths { + planning: string; + state: string; + roadmap: string; + project: string; + config: string; + phases: string; + requirements: string; +} + +// ─── escapeRegex ──────────────────────────────────────────────────────────── + +/** + * Escape regex special characters in a string. + * + * @param value - String to escape + * @returns String with regex special characters escaped + */ +export function escapeRegex(value: string): string { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// ─── normalizePhaseName ───────────────────────────────────────────────────── + +/** + * Normalize a phase identifier to a canonical form. + * + * Strips optional project code prefix (e.g., 'CK-01' -> '01'), + * pads numeric part to 2 digits, preserves letter suffix and decimal parts. + * + * @param phase - Phase identifier string + * @returns Normalized phase name + */ +export function normalizePhaseName(phase: string): string { + const str = String(phase); + // Strip optional project_code prefix (e.g., 'CK-01' -> '01') + const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, ''); + // Standard numeric phases: 1, 01, 12A, 12.1 + const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); + if (match) { + const padded = match[1].padStart(2, '0'); + const letter = match[2] ? match[2].toUpperCase() : ''; + const decimal = match[3] || ''; + return padded + letter + decimal; + } + // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is + return str; +} + +// ─── comparePhaseNum ──────────────────────────────────────────────────────── + +/** + * Compare two phase directory names for sorting. + * + * Handles numeric, letter-suffixed, and decimal phases. + * Falls back to string comparison for custom IDs. + * + * @param a - First phase directory name + * @param b - Second phase directory name + * @returns Negative if a < b, positive if a > b, 0 if equal + */ +export function comparePhaseNum(a: string, b: string): number { + // Strip optional project_code prefix before comparing + const sa = String(a).replace(/^[A-Z]{1,6}-/, ''); + const sb = String(b).replace(/^[A-Z]{1,6}-/, ''); + const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); + const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i); + // If either is non-numeric (custom ID), fall back to string comparison + if (!pa || !pb) return String(a).localeCompare(String(b)); + const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10); + if (intDiff !== 0) return intDiff; + // No letter sorts before letter: 12 < 12A < 12B + const la = (pa[2] || '').toUpperCase(); + const lb = (pb[2] || '').toUpperCase(); + if (la !== lb) { + if (!la) return -1; + if (!lb) return 1; + return la < lb ? -1 : 1; + } + // Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2 + const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; + const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : []; + const maxLen = Math.max(aDecParts.length, bDecParts.length); + if (aDecParts.length === 0 && bDecParts.length > 0) return -1; + if (bDecParts.length === 0 && aDecParts.length > 0) return 1; + for (let i = 0; i < maxLen; i++) { + const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0; + const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0; + if (av !== bv) return av - bv; + } + return 0; +} + +// ─── extractPhaseToken ────────────────────────────────────────────────────── + +/** + * Extract the phase token from a directory name. + * + * Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'. + * + * @param dirName - Directory name to extract token from + * @returns The token portion (e.g. '01', '1009A', '999.6', 'PROJ-42') + */ +export function extractPhaseToken(dirName: string): string { + // Try project-code-prefixed numeric: CK-01-name -> CK-01 + const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i); + if (codePrefixed) return codePrefixed[1]; + // Try plain numeric: 01-name, 1009A-name, 999.6-name + const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i); + if (numeric) return numeric[1]; + // Custom IDs: PROJ-42-name -> everything before the last segment that looks like a name + const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i); + if (custom) return custom[1]; + return dirName; +} + +// ─── phaseTokenMatches ────────────────────────────────────────────────────── + +/** + * Check if a directory name's phase token matches the normalized phase exactly. + * + * Case-insensitive comparison for the token portion. + * + * @param dirName - Directory name to check + * @param normalized - Normalized phase name to match against + * @returns True if the directory matches the phase + */ +export function phaseTokenMatches(dirName: string, normalized: string): boolean { + const token = extractPhaseToken(dirName); + if (token.toUpperCase() === normalized.toUpperCase()) return true; + // Strip optional project_code prefix from dir and retry + const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, ''); + if (stripped !== dirName) { + const strippedToken = extractPhaseToken(stripped); + if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true; + } + return false; +} + +// ─── toPosixPath ──────────────────────────────────────────────────────────── + +/** + * Convert a path to POSIX format (forward slashes). + * + * @param p - Path to convert + * @returns Path with all separators as forward slashes + */ +export function toPosixPath(p: string): string { + return p.split('\\').join('/'); +} + +// ─── stateExtractField ────────────────────────────────────────────────────── + +/** + * Extract a field value from STATE.md content. + * + * Supports both **bold:** and plain: formats, case-insensitive. + * + * @param content - STATE.md content string + * @param fieldName - Field name to extract + * @returns The field value, or null if not found + */ +export function stateExtractField(content: string, fieldName: string): string | null { + const escaped = escapeRegex(fieldName); + const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i'); + const boldMatch = content.match(boldPattern); + if (boldMatch) return boldMatch[1].trim(); + const plainPattern = new RegExp(`^${escaped}:\\s*(.+)`, 'im'); + const plainMatch = content.match(plainPattern); + return plainMatch ? plainMatch[1].trim() : null; +} + +// ─── normalizeMd ─────────────────────────────────────────────────────────── + +/** + * Normalize markdown content for consistent formatting. + * + * Port of `normalizeMd` from core.cjs lines 434-529. + * Applies: CRLF normalization, blank lines around headings/fences/lists, + * blank line collapsing (3+ to 2), terminal newline. + * + * @param content - Markdown content to normalize + * @returns Normalized markdown string + */ +export function normalizeMd(content: string): string { + if (!content || typeof content !== 'string') return content; + + // Normalize line endings to LF + let text = content.replace(/\r\n/g, '\n'); + + const lines = text.split('\n'); + const result: string[] = []; + + // Pre-compute fence state in a single O(n) pass + const fenceRegex = /^```/; + const insideFence = new Array(lines.length); + let fenceOpen = false; + for (let i = 0; i < lines.length; i++) { + if (fenceRegex.test(lines[i].trimEnd())) { + if (fenceOpen) { + insideFence[i] = false; + fenceOpen = false; + } else { + insideFence[i] = false; + fenceOpen = true; + } + } else { + insideFence[i] = fenceOpen; + } + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const prev = i > 0 ? lines[i - 1] : ''; + const prevTrimmed = prev.trimEnd(); + const trimmed = line.trimEnd(); + const isFenceLine = fenceRegex.test(trimmed); + + // MD022: Blank line before headings (skip first line and frontmatter delimiters) + if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') { + result.push(''); + } + + // MD031: Blank line before fenced code blocks (opening fences only) + if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) { + if (i === 0 || !insideFence[i - 1]) { + result.push(''); + } + } + + // MD032: Blank line before lists + if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && + prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) && + prevTrimmed !== '---') { + result.push(''); + } + + result.push(line); + + // MD022: Blank line after headings + if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) { + const next = lines[i + 1]; + if (next !== undefined && next.trimEnd() !== '') { + result.push(''); + } + } + + // MD031: Blank line after closing fenced code blocks + if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) { + const next = lines[i + 1]; + if (next !== undefined && next.trimEnd() !== '') { + result.push(''); + } + } + + // MD032: Blank line after last list item in a block + if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) { + const next = lines[i + 1]; + if (next !== undefined && next.trimEnd() !== '' && + !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) && + !/^\s/.test(next)) { + result.push(''); + } + } + } + + text = result.join('\n'); + + // MD012: Collapse 3+ consecutive blank lines to 2 + text = text.replace(/\n{3,}/g, '\n\n'); + + // MD047: Ensure file ends with exactly one newline + text = text.replace(/\n*$/, '\n'); + + return text; +} + +// ─── planningPaths ────────────────────────────────────────────────────────── + +/** + * Get common .planning file paths for a project directory. + * + * Simplified version (no workstream/project env vars). + * All paths returned in POSIX format. + * + * @param projectDir - Root project directory + * @returns Object with paths to common .planning files + */ +export function planningPaths(projectDir: string): PlanningPaths { + const base = join(projectDir, '.planning'); + return { + planning: toPosixPath(base), + state: toPosixPath(join(base, 'STATE.md')), + roadmap: toPosixPath(join(base, 'ROADMAP.md')), + project: toPosixPath(join(base, 'PROJECT.md')), + config: toPosixPath(join(base, 'config.json')), + phases: toPosixPath(join(base, 'phases')), + requirements: toPosixPath(join(base, 'REQUIREMENTS.md')), + }; +} diff --git a/sdk/src/query/index.ts b/sdk/src/query/index.ts new file mode 100644 index 000000000..94fdc47f2 --- /dev/null +++ b/sdk/src/query/index.ts @@ -0,0 +1,429 @@ +/** + * Query module entry point — factory and re-exports. + * + * The `createRegistry()` factory creates a fully-wired `QueryRegistry` + * with all native handlers registered. New handlers are added here + * as they are migrated from gsd-tools.cjs. + * + * @example + * ```typescript + * import { createRegistry } from './query/index.js'; + * + * const registry = createRegistry(); + * const result = await registry.dispatch('generate-slug', ['My Phase'], projectDir); + * ``` + */ + +import { QueryRegistry } from './registry.js'; +import { generateSlug, currentTimestamp } from './utils.js'; +import { frontmatterGet } from './frontmatter.js'; +import { configGet, resolveModel } from './config-query.js'; +import { stateLoad, stateGet, stateSnapshot } from './state.js'; +import { findPhase, phasePlanIndex } from './phase.js'; +import { roadmapAnalyze, roadmapGetPhase } from './roadmap.js'; +import { progressJson } from './progress.js'; +import { frontmatterSet, frontmatterMerge, frontmatterValidate } from './frontmatter-mutation.js'; +import { + stateUpdate, statePatch, stateBeginPhase, stateAdvancePlan, + stateRecordMetric, stateUpdateProgress, stateAddDecision, + stateAddBlocker, stateResolveBlocker, stateRecordSession, +} from './state-mutation.js'; +import { + configSet, configSetModelProfile, configNewProject, configEnsureSection, +} from './config-mutation.js'; +import { commit, checkCommit } from './commit.js'; +import { templateFill, templateSelect } from './template.js'; +import { verifyPlanStructure, verifyPhaseCompleteness, verifyArtifacts, verifyCommits, verifyReferences, verifySummary, verifyPathExists } from './verify.js'; +import { verifyKeyLinks, validateConsistency, validateHealth } from './validate.js'; +import { + phaseAdd, phaseInsert, phaseRemove, phaseComplete, + phaseScaffold, phasesClear, phasesArchive, + phasesList, phaseNextDecimal, +} from './phase-lifecycle.js'; +import { + initExecutePhase, initPlanPhase, initNewMilestone, initQuick, + initResume, initVerifyWork, initPhaseOp, initTodos, initMilestoneOp, + initMapCodebase, initNewWorkspace, initListWorkspaces, initRemoveWorkspace, +} from './init.js'; +import { initNewProject, initProgress, initManager } from './init-complex.js'; +import { agentSkills } from './skills.js'; +import { roadmapUpdatePlanProgress, requirementsMarkComplete } from './roadmap.js'; +import { statePlannedPhase } from './state-mutation.js'; +import { verifySchemaDrift } from './verify.js'; +import { todoMatchPhase, statsJson, progressBar, listTodos, todoComplete } from './progress.js'; +import { milestoneComplete } from './phase-lifecycle.js'; +import { summaryExtract, historyDigest } from './summary.js'; +import { commitToSubrepo } from './commit.js'; +import { + workstreamList, workstreamCreate, workstreamSet, workstreamStatus, + workstreamComplete, workstreamProgress, +} from './workstream.js'; +import { docsInit } from './init.js'; +import { uatRenderCheckpoint, auditUat } from './uat.js'; +import { websearch } from './websearch.js'; +import { + intelStatus, intelDiff, intelSnapshot, intelValidate, intelQuery, + intelExtractExports, intelPatchMeta, +} from './intel.js'; +import { + learningsCopy, learningsQuery, extractMessages, scanSessions, profileSample, profileQuestionnaire, + writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd, +} from './profile.js'; +import { GSDEventStream } from '../event-stream.js'; +import { + GSDEventType, + type GSDEvent, + type GSDStateMutationEvent, + type GSDConfigMutationEvent, + type GSDFrontmatterMutationEvent, + type GSDGitCommitEvent, + type GSDTemplateFillEvent, +} from '../types.js'; +import type { QueryHandler, QueryResult } from './utils.js'; + +// ─── Re-exports ──────────────────────────────────────────────────────────── + +export type { QueryResult, QueryHandler } from './utils.js'; +export { extractField } from './registry.js'; + +// ─── Mutation commands set ──────────────────────────────────────────────── + +/** + * Set of command names that represent mutation operations. + * Used to wire event emission after successful dispatch. + */ +const MUTATION_COMMANDS = new Set([ + 'state.update', 'state.patch', 'state.begin-phase', 'state.advance-plan', + 'state.record-metric', 'state.update-progress', 'state.add-decision', + 'state.add-blocker', 'state.resolve-blocker', 'state.record-session', + 'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', + 'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section', + 'commit', 'check-commit', + 'template.fill', 'template.select', + 'validate.health', 'validate health', + 'phase.add', 'phase.insert', 'phase.remove', 'phase.complete', + 'phase.scaffold', 'phases.clear', 'phases.archive', + 'phase add', 'phase insert', 'phase remove', 'phase complete', + 'phase scaffold', 'phases clear', 'phases archive', +]); + +// ─── Event builder ──────────────────────────────────────────────────────── + +/** + * Build a mutation event based on the command prefix and result. + */ +function buildMutationEvent(cmd: string, args: string[], result: QueryResult): GSDEvent { + const base = { + timestamp: new Date().toISOString(), + sessionId: '', + }; + + if (cmd.startsWith('state.')) { + return { + ...base, + type: GSDEventType.StateMutation, + command: cmd, + fields: args.slice(0, 2), + success: true, + } as GSDStateMutationEvent; + } + + if (cmd.startsWith('config-')) { + return { + ...base, + type: GSDEventType.ConfigMutation, + command: cmd, + key: args[0] ?? '', + success: true, + } as GSDConfigMutationEvent; + } + + if (cmd.startsWith('frontmatter.')) { + return { + ...base, + type: GSDEventType.FrontmatterMutation, + command: cmd, + file: args[0] ?? '', + fields: args.slice(1), + success: true, + } as GSDFrontmatterMutationEvent; + } + + if (cmd === 'commit' || cmd === 'check-commit') { + const data = result.data as Record | null; + return { + ...base, + type: GSDEventType.GitCommit, + hash: (data?.hash as string) ?? null, + committed: (data?.committed as boolean) ?? false, + reason: (data?.reason as string) ?? '', + } as GSDGitCommitEvent; + } + + if (cmd.startsWith('phase.') || cmd.startsWith('phase ') || cmd.startsWith('phases.') || cmd.startsWith('phases ')) { + return { + ...base, + type: GSDEventType.StateMutation, + command: cmd, + fields: args.slice(0, 2), + success: true, + } as GSDStateMutationEvent; + } + + if (cmd.startsWith('validate.') || cmd.startsWith('validate ')) { + return { + ...base, + type: GSDEventType.ConfigMutation, + command: cmd, + key: args[0] ?? '', + success: true, + } as GSDConfigMutationEvent; + } + + // template.fill / template.select + const data = result.data as Record | null; + return { + ...base, + type: GSDEventType.TemplateFill, + templateType: (data?.template as string) ?? args[0] ?? '', + path: (data?.path as string) ?? args[1] ?? '', + created: (data?.created as boolean) ?? false, + } as GSDTemplateFillEvent; +} + +// ─── Factory ─────────────────────────────────────────────────────────────── + +/** + * Create a fully-wired QueryRegistry with all native handlers registered. + * + * @param eventStream - Optional event stream for mutation event emission + * @returns A QueryRegistry instance with all handlers registered + */ +export function createRegistry(eventStream?: GSDEventStream): QueryRegistry { + const registry = new QueryRegistry(); + + registry.register('generate-slug', generateSlug); + registry.register('current-timestamp', currentTimestamp); + registry.register('frontmatter.get', frontmatterGet); + registry.register('config-get', configGet); + registry.register('resolve-model', resolveModel); + registry.register('state.load', stateLoad); + registry.register('state.json', stateLoad); + registry.register('state.get', stateGet); + registry.register('state-snapshot', stateSnapshot); + registry.register('find-phase', findPhase); + registry.register('phase-plan-index', phasePlanIndex); + registry.register('roadmap.analyze', roadmapAnalyze); + registry.register('roadmap.get-phase', roadmapGetPhase); + registry.register('progress', progressJson); + registry.register('progress.json', progressJson); + + // Frontmatter mutation handlers + registry.register('frontmatter.set', frontmatterSet); + registry.register('frontmatter.merge', frontmatterMerge); + registry.register('frontmatter.validate', frontmatterValidate); + registry.register('frontmatter validate', frontmatterValidate); + + // State mutation handlers + registry.register('state.update', stateUpdate); + registry.register('state.patch', statePatch); + registry.register('state.begin-phase', stateBeginPhase); + registry.register('state.advance-plan', stateAdvancePlan); + registry.register('state.record-metric', stateRecordMetric); + registry.register('state.update-progress', stateUpdateProgress); + registry.register('state.add-decision', stateAddDecision); + registry.register('state.add-blocker', stateAddBlocker); + registry.register('state.resolve-blocker', stateResolveBlocker); + registry.register('state.record-session', stateRecordSession); + + // Config mutation handlers + registry.register('config-set', configSet); + registry.register('config-set-model-profile', configSetModelProfile); + registry.register('config-new-project', configNewProject); + registry.register('config-ensure-section', configEnsureSection); + + // Git commit handlers + registry.register('commit', commit); + registry.register('check-commit', checkCommit); + + // Template handlers + registry.register('template.fill', templateFill); + registry.register('template.select', templateSelect); + registry.register('template select', templateSelect); + + // Verification handlers + registry.register('verify.plan-structure', verifyPlanStructure); + registry.register('verify plan-structure', verifyPlanStructure); + registry.register('verify.phase-completeness', verifyPhaseCompleteness); + registry.register('verify phase-completeness', verifyPhaseCompleteness); + registry.register('verify.artifacts', verifyArtifacts); + registry.register('verify artifacts', verifyArtifacts); + registry.register('verify.key-links', verifyKeyLinks); + registry.register('verify key-links', verifyKeyLinks); + registry.register('verify.commits', verifyCommits); + registry.register('verify commits', verifyCommits); + registry.register('verify.references', verifyReferences); + registry.register('verify references', verifyReferences); + registry.register('verify-summary', verifySummary); + registry.register('verify.summary', verifySummary); + registry.register('verify summary', verifySummary); + registry.register('verify-path-exists', verifyPathExists); + registry.register('verify.path-exists', verifyPathExists); + registry.register('verify path-exists', verifyPathExists); + registry.register('validate.consistency', validateConsistency); + registry.register('validate consistency', validateConsistency); + registry.register('validate.health', validateHealth); + registry.register('validate health', validateHealth); + + // Phase lifecycle handlers + registry.register('phase.add', phaseAdd); + registry.register('phase.insert', phaseInsert); + registry.register('phase.remove', phaseRemove); + registry.register('phase.complete', phaseComplete); + registry.register('phase.scaffold', phaseScaffold); + registry.register('phases.clear', phasesClear); + registry.register('phases.archive', phasesArchive); + registry.register('phases.list', phasesList); + registry.register('phase.next-decimal', phaseNextDecimal); + // Space-delimited aliases for CJS compatibility + registry.register('phase add', phaseAdd); + registry.register('phase insert', phaseInsert); + registry.register('phase remove', phaseRemove); + registry.register('phase complete', phaseComplete); + registry.register('phase scaffold', phaseScaffold); + registry.register('phases clear', phasesClear); + registry.register('phases archive', phasesArchive); + registry.register('phases list', phasesList); + registry.register('phase next-decimal', phaseNextDecimal); + + // Init composition handlers + registry.register('init.execute-phase', initExecutePhase); + registry.register('init.plan-phase', initPlanPhase); + registry.register('init.new-milestone', initNewMilestone); + registry.register('init.quick', initQuick); + registry.register('init.resume', initResume); + registry.register('init.verify-work', initVerifyWork); + registry.register('init.phase-op', initPhaseOp); + registry.register('init.todos', initTodos); + registry.register('init.milestone-op', initMilestoneOp); + registry.register('init.map-codebase', initMapCodebase); + registry.register('init.new-workspace', initNewWorkspace); + registry.register('init.list-workspaces', initListWorkspaces); + registry.register('init.remove-workspace', initRemoveWorkspace); + // Space-delimited aliases for CJS compatibility + registry.register('init execute-phase', initExecutePhase); + registry.register('init plan-phase', initPlanPhase); + registry.register('init new-milestone', initNewMilestone); + registry.register('init quick', initQuick); + registry.register('init resume', initResume); + registry.register('init verify-work', initVerifyWork); + registry.register('init phase-op', initPhaseOp); + registry.register('init todos', initTodos); + registry.register('init milestone-op', initMilestoneOp); + registry.register('init map-codebase', initMapCodebase); + registry.register('init new-workspace', initNewWorkspace); + registry.register('init list-workspaces', initListWorkspaces); + registry.register('init remove-workspace', initRemoveWorkspace); + + // Complex init handlers + registry.register('init.new-project', initNewProject); + registry.register('init.progress', initProgress); + registry.register('init.manager', initManager); + registry.register('init new-project', initNewProject); + registry.register('init progress', initProgress); + registry.register('init manager', initManager); + + // Domain-specific handlers (fully implemented) + registry.register('agent-skills', agentSkills); + registry.register('roadmap.update-plan-progress', roadmapUpdatePlanProgress); + registry.register('roadmap update-plan-progress', roadmapUpdatePlanProgress); + registry.register('requirements.mark-complete', requirementsMarkComplete); + registry.register('requirements mark-complete', requirementsMarkComplete); + registry.register('state.planned-phase', statePlannedPhase); + registry.register('state planned-phase', statePlannedPhase); + registry.register('verify.schema-drift', verifySchemaDrift); + registry.register('verify schema-drift', verifySchemaDrift); + registry.register('todo.match-phase', todoMatchPhase); + registry.register('todo match-phase', todoMatchPhase); + registry.register('list-todos', listTodos); + registry.register('list.todos', listTodos); + registry.register('todo.complete', todoComplete); + registry.register('todo complete', todoComplete); + registry.register('milestone.complete', milestoneComplete); + registry.register('milestone complete', milestoneComplete); + registry.register('summary.extract', summaryExtract); + registry.register('summary extract', summaryExtract); + registry.register('history.digest', historyDigest); + registry.register('history digest', historyDigest); + registry.register('history-digest', historyDigest); + registry.register('stats.json', statsJson); + registry.register('stats json', statsJson); + registry.register('commit-to-subrepo', commitToSubrepo); + registry.register('progress.bar', progressBar); + registry.register('progress bar', progressBar); + registry.register('workstream.list', workstreamList); + registry.register('workstream list', workstreamList); + registry.register('workstream.create', workstreamCreate); + registry.register('workstream create', workstreamCreate); + registry.register('workstream.set', workstreamSet); + registry.register('workstream set', workstreamSet); + registry.register('workstream.status', workstreamStatus); + registry.register('workstream status', workstreamStatus); + registry.register('workstream.complete', workstreamComplete); + registry.register('workstream complete', workstreamComplete); + registry.register('workstream.progress', workstreamProgress); + registry.register('workstream progress', workstreamProgress); + registry.register('docs-init', docsInit); + registry.register('websearch', websearch); + registry.register('learnings.copy', learningsCopy); + registry.register('learnings copy', learningsCopy); + registry.register('learnings.query', learningsQuery); + registry.register('learnings query', learningsQuery); + registry.register('extract-messages', extractMessages); + registry.register('extract.messages', extractMessages); + registry.register('audit-uat', auditUat); + registry.register('uat.render-checkpoint', uatRenderCheckpoint); + registry.register('uat render-checkpoint', uatRenderCheckpoint); + registry.register('intel.diff', intelDiff); + registry.register('intel diff', intelDiff); + registry.register('intel.snapshot', intelSnapshot); + registry.register('intel snapshot', intelSnapshot); + registry.register('intel.validate', intelValidate); + registry.register('intel validate', intelValidate); + registry.register('intel.status', intelStatus); + registry.register('intel status', intelStatus); + registry.register('intel.query', intelQuery); + registry.register('intel query', intelQuery); + registry.register('intel.extract-exports', intelExtractExports); + registry.register('intel extract-exports', intelExtractExports); + registry.register('intel.patch-meta', intelPatchMeta); + registry.register('intel patch-meta', intelPatchMeta); + registry.register('generate-claude-profile', generateClaudeProfile); + registry.register('generate-dev-preferences', generateDevPreferences); + registry.register('write-profile', writeProfile); + registry.register('profile-questionnaire', profileQuestionnaire); + registry.register('profile-sample', profileSample); + registry.register('scan-sessions', scanSessions); + registry.register('generate-claude-md', generateClaudeMd); + + // Wire event emission for mutation commands + if (eventStream) { + for (const cmd of MUTATION_COMMANDS) { + const original = registry.getHandler(cmd); + if (original) { + registry.register(cmd, async (args: string[], projectDir: string) => { + const result = await original(args, projectDir); + try { + const event = buildMutationEvent(cmd, args, result); + eventStream.emitEvent(event); + } catch { + // T-11-12: Event emission is fire-and-forget; never block mutation success + } + return result; + }); + } + } + } + + return registry; +} diff --git a/sdk/src/query/init-complex.test.ts b/sdk/src/query/init-complex.test.ts new file mode 100644 index 000000000..e45c7d104 --- /dev/null +++ b/sdk/src/query/init-complex.test.ts @@ -0,0 +1,232 @@ +/** + * Unit tests for complex init composition handlers. + * + * Tests the 3 complex handlers: initNewProject, initProgress, initManager. + * Uses mkdtemp temp directories to simulate .planning/ layout. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { initNewProject, initProgress, initManager } from './init-complex.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-init-complex-')); + + // Create minimal .planning structure + await mkdir(join(tmpDir, '.planning', 'phases', '09-foundation'), { recursive: true }); + await mkdir(join(tmpDir, '.planning', 'phases', '10-queries'), { recursive: true }); + + // config.json + await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + commit_docs: false, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, + })); + + // STATE.md + await writeFile(join(tmpDir, '.planning', 'STATE.md'), [ + '---', + 'milestone: v3.0', + 'status: executing', + '---', + '', + '# Project State', + ].join('\n')); + + // ROADMAP.md + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '## v3.0: SDK-First Migration', + '', + '### Phase 9: Foundation', + '', + '**Goal:** Build foundation', + '', + '**Depends on:** None', + '', + '### Phase 10: Read-Only Queries', + '', + '**Goal:** Implement queries', + '', + '**Depends on:** Phase 9', + '', + ].join('\n')); + + // Phase 09: has plan + summary (complete) + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-PLAN.md'), [ + '---', + 'phase: 09-foundation', + 'plan: 01', + '---', + ].join('\n')); + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md'), '# Done'); + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-RESEARCH.md'), '# Research'); + + // Phase 10: only plan, no summary (in_progress) + await writeFile(join(tmpDir, '.planning', 'phases', '10-queries', '10-01-PLAN.md'), [ + '---', + 'phase: 10-queries', + 'plan: 01', + '---', + ].join('\n')); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('initNewProject', () => { + it('returns flat JSON with expected shape', async () => { + const result = await initNewProject([], tmpDir); + const data = result.data as Record; + expect(data.researcher_model).toBeDefined(); + expect(data.synthesizer_model).toBeDefined(); + expect(data.roadmapper_model).toBeDefined(); + expect(typeof data.is_brownfield).toBe('boolean'); + expect(typeof data.has_existing_code).toBe('boolean'); + expect(typeof data.has_package_file).toBe('boolean'); + expect(typeof data.has_git).toBe('boolean'); + expect(typeof data.brave_search_available).toBe('boolean'); + expect(typeof data.firecrawl_available).toBe('boolean'); + expect(typeof data.exa_search_available).toBe('boolean'); + expect(data.project_path).toBe('.planning/PROJECT.md'); + expect(data.project_root).toBe(tmpDir); + expect(typeof data.agents_installed).toBe('boolean'); + expect(Array.isArray(data.missing_agents)).toBe(true); + }); + + it('detects brownfield when package.json exists', async () => { + await writeFile(join(tmpDir, 'package.json'), '{"name":"test"}'); + const result = await initNewProject([], tmpDir); + const data = result.data as Record; + expect(data.has_package_file).toBe(true); + expect(data.is_brownfield).toBe(true); + }); + + it('detects planning_exists when .planning exists', async () => { + const result = await initNewProject([], tmpDir); + const data = result.data as Record; + expect(data.planning_exists).toBe(true); + }); +}); + +describe('initProgress', () => { + it('returns flat JSON with phases array', async () => { + const result = await initProgress([], tmpDir); + const data = result.data as Record; + expect(Array.isArray(data.phases)).toBe(true); + expect(data.milestone_version).toBeDefined(); + expect(data.milestone_name).toBeDefined(); + expect(typeof data.phase_count).toBe('number'); + expect(typeof data.completed_count).toBe('number'); + expect(data.project_root).toBe(tmpDir); + }); + + it('correctly identifies complete vs in_progress phases', async () => { + const result = await initProgress([], tmpDir); + const data = result.data as Record; + const phases = data.phases as Record[]; + + const phase9 = phases.find(p => p.number === '9' || (p.number as string).startsWith('09')); + const phase10 = phases.find(p => p.number === '10' || (p.number as string).startsWith('10')); + + // Phase 09 has plan+summary → complete + expect(phase9?.status).toBe('complete'); + // Phase 10 has plan but no summary → in_progress + expect(phase10?.status).toBe('in_progress'); + }); + + it('returns null paused_at when STATE.md has no pause', async () => { + const result = await initProgress([], tmpDir); + const data = result.data as Record; + expect(data.paused_at).toBeNull(); + }); + + it('extracts paused_at when STATE.md has pause marker', async () => { + await writeFile(join(tmpDir, '.planning', 'STATE.md'), [ + '---', + 'milestone: v3.0', + '---', + '**Paused At:** Phase 10, Plan 2', + ].join('\n')); + const result = await initProgress([], tmpDir); + const data = result.data as Record; + expect(data.paused_at).toBe('Phase 10, Plan 2'); + }); + + it('includes state/roadmap path fields', async () => { + const result = await initProgress([], tmpDir); + const data = result.data as Record; + expect(typeof data.state_path).toBe('string'); + expect(typeof data.roadmap_path).toBe('string'); + expect(typeof data.config_path).toBe('string'); + }); +}); + +describe('initManager', () => { + it('returns flat JSON with phases and recommended_actions', async () => { + const result = await initManager([], tmpDir); + const data = result.data as Record; + expect(Array.isArray(data.phases)).toBe(true); + expect(Array.isArray(data.recommended_actions)).toBe(true); + expect(data.milestone_version).toBeDefined(); + expect(data.milestone_name).toBeDefined(); + expect(typeof data.phase_count).toBe('number'); + expect(typeof data.completed_count).toBe('number'); + expect(typeof data.all_complete).toBe('boolean'); + expect(data.project_root).toBe(tmpDir); + }); + + it('includes disk_status for each phase', async () => { + const result = await initManager([], tmpDir); + const data = result.data as Record; + const phases = data.phases as Record[]; + expect(phases.length).toBeGreaterThan(0); + for (const p of phases) { + expect(typeof p.disk_status).toBe('string'); + expect(typeof p.deps_satisfied).toBe('boolean'); + } + }); + + it('returns error when ROADMAP.md missing', async () => { + await rm(join(tmpDir, '.planning', 'ROADMAP.md')); + const result = await initManager([], tmpDir); + const data = result.data as Record; + expect(data.error).toBeDefined(); + }); + + it('includes display_name truncated to 20 chars', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '## v3.0: Test', + '### Phase 9: A Very Long Phase Name That Should Be Truncated', + '**Goal:** Something', + ].join('\n')); + const result = await initManager([], tmpDir); + const data = result.data as Record; + const phases = data.phases as Record[]; + const phase9 = phases.find(p => p.number === '9'); + expect(phase9).toBeDefined(); + expect((phase9!.display_name as string).length).toBeLessThanOrEqual(20); + }); + + it('includes manager_flags in result', async () => { + const result = await initManager([], tmpDir); + const data = result.data as Record; + const flags = data.manager_flags as Record; + expect(typeof flags.discuss).toBe('string'); + expect(typeof flags.plan).toBe('string'); + expect(typeof flags.execute).toBe('string'); + }); +}); diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts new file mode 100644 index 000000000..1fbf0f184 --- /dev/null +++ b/sdk/src/query/init-complex.ts @@ -0,0 +1,578 @@ +/** + * 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 './init-complex.js'; + * + * const result = await initProgress([], '/project'); + * // { data: { phases: [...], milestone_version: 'v3.0', ... } } + * ``` + */ + +import { existsSync, readdirSync, statSync } from 'node:fs'; +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 './config-query.js'; +import { planningPaths, normalizePhaseName, phaseTokenMatches, toPosixPath } from './helpers.js'; +import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js'; +import { withProjectRoot } from './init.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Internal helpers ────────────────────────────────────────────────────── + +/** + * Get model alias string from resolveModel result. + */ +async function getModelAlias(agentType: string, projectDir: string): Promise { + const result = await resolveModel([agentType], projectDir); + const data = result.data as Record; + return (data.model as string) || 'sonnet'; +} + +/** + * Check if a file exists at a relative path within projectDir. + */ +function pathExists(base: string, relPath: string): boolean { + return existsSync(join(base, relPath)); +} + +// ─── 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) => { + const config = await loadConfig(projectDir); + + // 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: Array<{ isDirectory(): boolean; isFile(): boolean; name: string }>; + try { + entries = readdirSync(dir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; isFile(): boolean; name: string }>; + } 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 result: Record = { + researcher_model: researcherModel, + synthesizer_model: synthesizerModel, + roadmapper_model: roadmapperModel, + + commit_docs: config.commit_docs, + + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + has_codebase_map: pathExists(projectDir, '.planning/codebase'), + planning_exists: pathExists(projectDir, '.planning'), + + has_existing_code: hasExistingCode, + has_package_file: hasPackageFile, + is_brownfield: hasExistingCode || hasPackageFile, + needs_codebase_map: + (hasExistingCode || hasPackageFile) && !pathExists(projectDir, '.planning/codebase'), + + has_git: pathExists(projectDir, '.git'), + + brave_search_available: hasBraveSearch, + firecrawl_available: hasFirecrawl, + exa_search_available: hasExaSearch, + + project_path: '.planning/PROJECT.md', + }; + + return { data: withProjectRoot(projectDir, result) }; +}; + +// ─── 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) => { + const config = await loadConfig(projectDir); + const milestone = await getMilestoneInfo(projectDir); + const paths = planningPaths(projectDir); + + const phases: Record[] = []; + let currentPhase: Record | null = null; + let nextPhase: Record | null = null; + + // Build set of phases from ROADMAP for the current milestone + const roadmapPhaseNames = new Map(); + const seenPhaseNums = new Set(); + + try { + const rawRoadmap = await readFile(paths.roadmap, 'utf-8'); + const roadmapContent = await extractCurrentMilestone(rawRoadmap, projectDir); + 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); + } + } catch { /* intentionally empty */ } + + // Scan phase directories + try { + const entries = readdirSync(paths.phases, { withFileTypes: true }); + const dirs = (entries as unknown as Array<{ isDirectory(): boolean; name: string }>) + .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 = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + + const status = + summaries.length >= plans.length && plans.length > 0 ? 'complete' : + plans.length > 0 ? 'in_progress' : + hasResearch ? 'researched' : 'pending'; + + const phaseInfo: Record = { + number: phaseNumber, + name: phaseName, + directory: toPosixPath(relative(projectDir, join(paths.phases, dir))), + status, + plan_count: plans.length, + summary_count: summaries.length, + has_research: hasResearch, + }; + + phases.push(phaseInfo); + + if (!currentPhase && (status === 'in_progress' || status === 'researched')) { + currentPhase = phaseInfo; + } + if (!nextPhase && status === 'pending') { + nextPhase = phaseInfo; + } + } + } catch { /* intentionally empty */ } + + // Add ROADMAP-only phases not yet on disk + for (const [num, name] of roadmapPhaseNames) { + const stripped = num.replace(/^0+/, '') || '0'; + if (!seenPhaseNums.has(stripped)) { + const phaseInfo: Record = { + number: num, + name: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''), + directory: null, + status: 'not_started', + plan_count: 0, + summary_count: 0, + has_research: false, + }; + phases.push(phaseInfo); + if (!nextPhase && !currentPhase) { + nextPhase = phaseInfo; + } + } + } + + phases.sort((a, b) => parseInt(a.number as string, 10) - parseInt(b.number as string, 10)); + + // Check paused state in STATE.md + let pausedAt: string | null = null; + try { + const stateContent = await readFile(paths.state, 'utf-8'); + const pauseMatch = stateContent.match(/\*\*Paused At:\*\*\s*(.+)/); + if (pauseMatch) pausedAt = pauseMatch[1].trim(); + } catch { /* intentionally empty */ } + + const result: Record = { + executor_model: await getModelAlias('gsd-executor', projectDir), + planner_model: await getModelAlias('gsd-planner', projectDir), + + commit_docs: config.commit_docs, + + milestone_version: milestone.version, + milestone_name: milestone.name, + + phases, + phase_count: phases.length, + completed_count: phases.filter(p => p.status === 'complete').length, + in_progress_count: phases.filter(p => p.status === 'in_progress').length, + + current_phase: currentPhase, + next_phase: nextPhase, + paused_at: pausedAt, + has_work_in_progress: !!currentPhase, + + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + roadmap_exists: existsSync(paths.roadmap), + state_exists: existsSync(paths.state), + state_path: toPosixPath(relative(projectDir, paths.state)), + roadmap_path: toPosixPath(relative(projectDir, paths.roadmap)), + project_path: '.planning/PROJECT.md', + config_path: toPosixPath(relative(projectDir, paths.config)), + }; + + return { data: withProjectRoot(projectDir, result) }; +}; + +// ─── 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) => { + const config = await loadConfig(projectDir); + const milestone = await getMilestoneInfo(projectDir); + const paths = planningPaths(projectDir); + + 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); + + // Pre-compute directory listing once + let phaseDirEntries: string[] = []; + try { + phaseDirEntries = (readdirSync(paths.phases, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { /* intentionally empty */ } + + // Pre-extract checkbox states in a single pass + const checkboxStates = new Map(); + const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi; + let cbMatch: RegExpExecArray | null; + while ((cbMatch = cbPattern.exec(content)) !== null) { + checkboxStates.set(cbMatch[2], cbMatch[1].toLowerCase() === 'x'); + } + + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + const phases: Record[] = []; + let pMatch: RegExpExecArray | null; + + while ((pMatch = phasePattern.exec(content)) !== null) { + const phaseNum = pMatch[1]; + const phaseName = pMatch[2].replace(/\(INSERTED\)/i, '').trim(); + + const sectionStart = pMatch.index; + const restOfContent = content.slice(sectionStart); + const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); + const sectionEnd = nextHeader ? sectionStart + (nextHeader.index ?? 0) : content.length; + const section = content.slice(sectionStart, sectionEnd); + + const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i); + const goal = goalMatch ? goalMatch[1].trim() : null; + + const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i); + const dependsOn = dependsMatch ? dependsMatch[1].trim() : null; + + const normalized = normalizePhaseName(phaseNum); + let diskStatus = 'no_directory'; + let planCount = 0; + let summaryCount = 0; + let hasContext = false; + let hasResearch = false; + let lastActivity: string | null = null; + let isActive = false; + + try { + const dirMatch = phaseDirEntries.find(d => phaseTokenMatches(d, normalized)); + if (dirMatch) { + const fullDir = join(paths.phases, dirMatch); + const phaseFiles = readdirSync(fullDir); + planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; + summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').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(',') : '—'; + } + } + + // Sliding window: only first undiscussed phase is available to discuss + let foundNextToDiscuss = false; + for (const phase of phases) { + const status = phase.disk_status as string; + if (!foundNextToDiscuss && (status === 'empty' || status === 'no_directory')) { + phase.is_next_to_discuss = true; + foundNextToDiscuss = true; + } else { + phase.is_next_to_discuss = false; + } + } + + // Check WAITING.json signal + let waitingSignal: unknown = null; + try { + const waitingPath = join(projectDir, '.planning', 'WAITING.json'); + if (existsSync(waitingPath)) { + const { readFileSync } = await import('node:fs'); + waitingSignal = JSON.parse(readFileSync(waitingPath, 'utf-8')); + } + } catch { /* intentionally empty */ } + + // Compute recommended actions + const phaseMap = new Map(phases.map(p => [p.number as string, p])); + + function reaches(from: string, to: string, visited = new Set()): boolean { + if (visited.has(from)) return false; + visited.add(from); + const p = phaseMap.get(from); + const depPhases = p?.dep_phases as string[] | undefined; + if (!depPhases || depPhases.length === 0) return false; + if (depPhases.includes(to)) return true; + return depPhases.some(dep => reaches(dep, to, visited)); + } + + const activeExecuting = phases.filter(p => { + const status = p.disk_status as string; + return status === 'partial' || (status === 'planned' && p.is_active); + }); + const activePlanning = phases.filter(p => { + const status = p.disk_status as string; + return p.is_active && (status === 'discussed' || status === 'researched'); + }); + + const recommendedActions: Record[] = []; + for (const phase of phases) { + const status = phase.disk_status as string; + if (status === 'complete') continue; + if (/^999(?:\.|$)/.test(phase.number as string)) continue; + + if (status === 'planned' && phase.deps_satisfied) { + const action = { + phase: phase.number, + phase_name: phase.name, + action: 'execute', + reason: `${phase.plan_count} plans ready, dependencies met`, + command: `/gsd-execute-phase ${phase.number}`, + }; + const isAllowed = activeExecuting.length === 0 || + activeExecuting.every(a => !reaches(phase.number as string, a.number as string) && !reaches(a.number as string, phase.number as string)); + if (isAllowed) recommendedActions.push(action); + } else if (status === 'discussed' || status === 'researched') { + const action = { + phase: phase.number, + phase_name: phase.name, + action: 'plan', + reason: 'Context gathered, ready for planning', + command: `/gsd-plan-phase ${phase.number}`, + }; + const isAllowed = activePlanning.length === 0 || + activePlanning.every(a => !reaches(phase.number as string, a.number as string) && !reaches(a.number as string, phase.number as string)); + if (isAllowed) recommendedActions.push(action); + } else if ((status === 'empty' || status === 'no_directory') && phase.is_next_to_discuss) { + recommendedActions.push({ + phase: phase.number, + phase_name: phase.name, + action: 'discuss', + reason: 'Unblocked, ready to gather context', + command: `/gsd-discuss-phase ${phase.number}`, + }); + } + } + + const completedCount = phases.filter(p => p.disk_status === 'complete').length; + + // Read manager flags from config + const managerConfig = (config as Record).manager as Record> | undefined; + const sanitizeFlags = (raw: unknown): string => { + const val = typeof raw === 'string' ? raw : ''; + if (!val) return ''; + const tokens = val.split(/\s+/).filter(Boolean); + const safe = tokens.every(t => /^--[a-zA-Z0-9][-a-zA-Z0-9]*$/.test(t) || /^[a-zA-Z0-9][-a-zA-Z0-9_.]*$/.test(t)); + return safe ? val : ''; + }; + const managerFlags = { + discuss: sanitizeFlags(managerConfig?.flags?.discuss), + plan: sanitizeFlags(managerConfig?.flags?.plan), + execute: sanitizeFlags(managerConfig?.flags?.execute), + }; + + const result: Record = { + milestone_version: milestone.version, + milestone_name: milestone.name, + phases, + phase_count: phases.length, + completed_count: completedCount, + in_progress_count: phases.filter(p => ['partial', 'planned', 'discussed', 'researched'].includes(p.disk_status as string)).length, + recommended_actions: recommendedActions, + waiting_signal: waitingSignal, + all_complete: completedCount === phases.length && phases.length > 0, + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + roadmap_exists: true, + state_exists: true, + manager_flags: managerFlags, + }; + + return { data: withProjectRoot(projectDir, result) }; +}; diff --git a/sdk/src/query/init.test.ts b/sdk/src/query/init.test.ts new file mode 100644 index 000000000..70c14dddd --- /dev/null +++ b/sdk/src/query/init.test.ts @@ -0,0 +1,308 @@ +/** + * Unit tests for init composition handlers. + * + * Tests all 13 init handlers plus the withProjectRoot helper. + * Uses mkdtemp temp directories to simulate .planning/ layout. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + withProjectRoot, + initExecutePhase, + initPlanPhase, + initNewMilestone, + initQuick, + initResume, + initVerifyWork, + initPhaseOp, + initTodos, + initMilestoneOp, + initMapCodebase, + initNewWorkspace, + initListWorkspaces, + initRemoveWorkspace, +} from './init.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-init-')); + // Create minimal .planning structure + await mkdir(join(tmpDir, '.planning', 'phases', '09-foundation'), { recursive: true }); + await mkdir(join(tmpDir, '.planning', 'phases', '10-read-only-queries'), { recursive: true }); + // Create config.json + await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + commit_docs: false, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, + })); + // Create STATE.md + await writeFile(join(tmpDir, '.planning', 'STATE.md'), [ + '---', + 'milestone: v3.0', + 'status: executing', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Phase: 9 (foundation)', + 'Plan: 1 of 3', + 'Status: Executing', + '', + ].join('\n')); + // Create ROADMAP.md with phase sections + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '## v3.0: SDK-First Migration', + '', + '### Phase 9: Foundation', + '', + '**Goal:** Build foundation', + '', + '### Phase 10: Read-Only Queries', + '', + '**Goal:** Implement queries', + '', + ].join('\n')); + // Create plan and summary files in phase 09 + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-PLAN.md'), [ + '---', + 'phase: 09-foundation', + 'plan: 01', + 'wave: 1', + '---', + 'Test plan', + ].join('\n')); + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md'), '# Summary'); + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-CONTEXT.md'), '# Context'); + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-RESEARCH.md'), '# Research'); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('withProjectRoot', () => { + it('injects project_root, agents_installed, missing_agents into result', () => { + const result: Record = { foo: 'bar' }; + const enriched = withProjectRoot(tmpDir, result); + expect(enriched.project_root).toBe(tmpDir); + expect(typeof enriched.agents_installed).toBe('boolean'); + expect(Array.isArray(enriched.missing_agents)).toBe(true); + // Original field preserved + expect(enriched.foo).toBe('bar'); + }); + + it('injects response_language when config has it', () => { + const result: Record = {}; + const enriched = withProjectRoot(tmpDir, result, { response_language: 'ja' }); + expect(enriched.response_language).toBe('ja'); + }); + + it('does not inject response_language when not in config', () => { + const result: Record = {}; + const enriched = withProjectRoot(tmpDir, result, {}); + expect(enriched.response_language).toBeUndefined(); + }); +}); + +describe('initExecutePhase', () => { + it('returns flat JSON with expected keys for existing phase', async () => { + const result = await initExecutePhase(['9'], tmpDir); + const data = result.data as Record; + expect(data.phase_found).toBe(true); + expect(data.phase_number).toBe('09'); + expect(data.executor_model).toBeDefined(); + expect(data.commit_docs).toBeDefined(); + expect(data.project_root).toBe(tmpDir); + expect(data.plans).toBeDefined(); + expect(data.summaries).toBeDefined(); + expect(data.milestone_version).toBeDefined(); + }); + + it('returns error when phase arg missing', async () => { + const result = await initExecutePhase([], tmpDir); + const data = result.data as Record; + expect(data.error).toBeDefined(); + }); +}); + +describe('initPlanPhase', () => { + it('returns flat JSON with expected keys', async () => { + const result = await initPlanPhase(['9'], tmpDir); + const data = result.data as Record; + expect(data.phase_found).toBe(true); + expect(data.researcher_model).toBeDefined(); + expect(data.planner_model).toBeDefined(); + expect(data.checker_model).toBeDefined(); + expect(data.research_enabled).toBeDefined(); + expect(data.has_research).toBe(true); + expect(data.has_context).toBe(true); + expect(data.project_root).toBe(tmpDir); + }); + + it('returns error when phase arg missing', async () => { + const result = await initPlanPhase([], tmpDir); + const data = result.data as Record; + expect(data.error).toBeDefined(); + }); +}); + +describe('initNewMilestone', () => { + it('returns flat JSON with milestone info', async () => { + const result = await initNewMilestone([], tmpDir); + const data = result.data as Record; + expect(data.current_milestone).toBeDefined(); + expect(data.current_milestone_name).toBeDefined(); + expect(data.phase_dir_count).toBeGreaterThanOrEqual(0); + expect(data.project_root).toBe(tmpDir); + }); +}); + +describe('initQuick', () => { + it('returns flat JSON with task info', async () => { + const result = await initQuick(['my-task'], tmpDir); + const data = result.data as Record; + expect(data.quick_id).toBeDefined(); + expect(data.slug).toBe('my-task'); + expect(data.description).toBe('my-task'); + expect(data.planner_model).toBeDefined(); + expect(data.executor_model).toBeDefined(); + expect(data.quick_dir).toBe('.planning/quick'); + expect(data.project_root).toBe(tmpDir); + }); +}); + +describe('initResume', () => { + it('returns flat JSON with state info', async () => { + const result = await initResume([], tmpDir); + const data = result.data as Record; + expect(data.state_exists).toBe(true); + expect(data.roadmap_exists).toBe(true); + expect(data.project_root).toBe(tmpDir); + expect(data.commit_docs).toBeDefined(); + }); +}); + +describe('initVerifyWork', () => { + it('returns flat JSON with expected keys', async () => { + const result = await initVerifyWork(['9'], tmpDir); + const data = result.data as Record; + expect(data.phase_found).toBe(true); + expect(data.phase_number).toBe('09'); + expect(data.planner_model).toBeDefined(); + expect(data.checker_model).toBeDefined(); + expect(data.project_root).toBe(tmpDir); + }); + + it('returns error when phase arg missing', async () => { + const result = await initVerifyWork([], tmpDir); + const data = result.data as Record; + expect(data.error).toBeDefined(); + }); +}); + +describe('initPhaseOp', () => { + it('returns flat JSON with phase artifacts', async () => { + const result = await initPhaseOp(['9'], tmpDir); + const data = result.data as Record; + expect(data.phase_found).toBe(true); + expect(data.phase_number).toBe('09'); + expect(data.has_research).toBe(true); + expect(data.has_context).toBe(true); + expect(data.plan_count).toBeGreaterThanOrEqual(1); + expect(data.project_root).toBe(tmpDir); + }); +}); + +describe('initTodos', () => { + it('returns flat JSON with todo inventory', async () => { + const result = await initTodos([], tmpDir); + const data = result.data as Record; + expect(data.todo_count).toBe(0); + expect(Array.isArray(data.todos)).toBe(true); + expect(data.area_filter).toBeNull(); + expect(data.project_root).toBe(tmpDir); + }); + + it('filters by area when provided', async () => { + const result = await initTodos(['code'], tmpDir); + const data = result.data as Record; + expect(data.area_filter).toBe('code'); + }); +}); + +describe('initMilestoneOp', () => { + it('returns flat JSON with milestone info', async () => { + const result = await initMilestoneOp([], tmpDir); + const data = result.data as Record; + expect(data.milestone_version).toBeDefined(); + expect(data.milestone_name).toBeDefined(); + expect(data.phase_count).toBeGreaterThanOrEqual(0); + expect(data.completed_phases).toBeGreaterThanOrEqual(0); + expect(data.project_root).toBe(tmpDir); + }); +}); + +describe('initMapCodebase', () => { + it('returns flat JSON with mapper info', async () => { + const result = await initMapCodebase([], tmpDir); + const data = result.data as Record; + expect(data.mapper_model).toBeDefined(); + expect(Array.isArray(data.existing_maps)).toBe(true); + expect(data.codebase_dir).toBe('.planning/codebase'); + expect(data.project_root).toBe(tmpDir); + }); +}); + +describe('initNewWorkspace', () => { + it('returns flat JSON with workspace info', async () => { + const result = await initNewWorkspace([], tmpDir); + const data = result.data as Record; + expect(data.default_workspace_base).toBeDefined(); + expect(typeof data.worktree_available).toBe('boolean'); + expect(data.project_root).toBe(tmpDir); + }); + + it('detects git availability', async () => { + const result = await initNewWorkspace([], tmpDir); + const data = result.data as Record; + // worktree_available depends on whether git is installed + expect(typeof data.worktree_available).toBe('boolean'); + }); +}); + +describe('initListWorkspaces', () => { + it('returns flat JSON with workspaces array', async () => { + const result = await initListWorkspaces([], tmpDir); + const data = result.data as Record; + expect(Array.isArray(data.workspaces)).toBe(true); + expect(data.workspace_count).toBeGreaterThanOrEqual(0); + }); +}); + +describe('initRemoveWorkspace', () => { + it('returns error when name arg missing', async () => { + const result = await initRemoveWorkspace([], tmpDir); + const data = result.data as Record; + expect(data.error).toBeDefined(); + }); + + it('rejects path separator in workspace name (T-14-01)', async () => { + const result = await initRemoveWorkspace(['../../bad'], tmpDir); + const data = result.data as Record; + expect(data.error).toBeDefined(); + }); +}); diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts new file mode 100644 index 000000000..507e5a461 --- /dev/null +++ b/sdk/src/query/init.ts @@ -0,0 +1,956 @@ +/** + * Init composition handlers — compound init commands for workflow bootstrapping. + * + * Composes existing atomic SDK queries into the same flat JSON bundles + * that CJS init.cjs produces, enabling workflow migration. Each handler + * follows the QueryHandler signature and returns { data: }. + * + * Port of get-shit-done/bin/lib/init.cjs (13 of 16 handlers). + * The 3 complex handlers (new-project, progress, manager) are in init-complex.ts. + * + * @example + * ```typescript + * import { initExecutePhase, withProjectRoot } from './init.js'; + * + * const result = await initExecutePhase(['9'], '/project'); + * // { data: { executor_model: 'opus', phase_found: true, ... } } + * ``` + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join, relative, basename } from 'node:path'; +import { execSync } from 'node:child_process'; +import { homedir } from 'node:os'; + +import { loadConfig } from '../config.js'; +import { resolveModel, MODEL_PROFILES } from './config-query.js'; +import { findPhase } from './phase.js'; +import { roadmapGetPhase, getMilestoneInfo } from './roadmap.js'; +import { planningPaths, normalizePhaseName, toPosixPath } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Internal helpers ────────────────────────────────────────────────────── + +/** + * Extract model alias string from a resolveModel result. + */ +async function getModelAlias(agentType: string, projectDir: string): Promise { + const result = await resolveModel([agentType], projectDir); + const data = result.data as Record; + return (data.model as string) || 'sonnet'; +} + +/** + * Generate a slug from text (inline, matches CJS generateSlugInternal). + */ +function generateSlugInternal(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 60); +} + +/** + * Check if a path exists on disk. + */ +function pathExists(base: string, relPath: string): boolean { + return existsSync(join(base, relPath)); +} + +/** + * Get the latest completed milestone from MILESTONES.md. + * Port of getLatestCompletedMilestone from init.cjs lines 10-25. + */ +function getLatestCompletedMilestone(projectDir: string): { version: string; name: string } | null { + const milestonesPath = join(projectDir, '.planning', 'MILESTONES.md'); + if (!existsSync(milestonesPath)) return null; + + try { + const content = readFileSync(milestonesPath, 'utf-8'); + const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m); + if (!match) return null; + return { version: match[1], name: match[2].trim() }; + } catch { + return null; + } +} + +/** + * Check which GSD agents are installed on disk. + * Port of checkAgentsInstalled from core.cjs lines 1274-1306. + */ +function checkAgentsInstalled(): { agents_installed: boolean; missing_agents: string[] } { + const agentsDir = process.env.GSD_AGENTS_DIR + || join(homedir(), '.claude', 'get-shit-done', 'agents'); + const expectedAgents = Object.keys(MODEL_PROFILES); + + if (!existsSync(agentsDir)) { + return { agents_installed: false, missing_agents: expectedAgents }; + } + + const missing: string[] = []; + for (const agent of expectedAgents) { + const agentFile = join(agentsDir, `${agent}.md`); + const agentFileCopilot = join(agentsDir, `${agent}.agent.md`); + if (!existsSync(agentFile) && !existsSync(agentFileCopilot)) { + missing.push(agent); + } + } + + return { + agents_installed: missing.length === 0, + missing_agents: missing, + }; +} + +/** + * Extract phase info from findPhase result, or build fallback from roadmap. + */ +async function getPhaseInfoWithFallback( + phase: string, + projectDir: string, +): Promise<{ phaseInfo: Record | null; roadmapPhase: Record | null }> { + const phaseResult = await findPhase([phase], projectDir); + let phaseInfo = phaseResult.data as Record | null; + + const roadmapResult = await roadmapGetPhase([phase], projectDir); + const roadmapPhase = roadmapResult.data as Record | null; + + // Fallback to ROADMAP.md if no phase directory exists yet + if ((!phaseInfo || !phaseInfo.found) && roadmapPhase?.found) { + const phaseName = roadmapPhase.phase_name as string; + phaseInfo = { + found: true, + directory: null, + phase_number: roadmapPhase.phase_number, + phase_name: phaseName, + phase_slug: phaseName ? generateSlugInternal(phaseName) : null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + has_reviews: false, + }; + } + + return { phaseInfo, roadmapPhase }; +} + +/** + * Extract requirement IDs from roadmap section text. + */ +function extractReqIds(roadmapPhase: Record | null): string | null { + const section = roadmapPhase?.section as string | undefined; + const reqMatch = section?.match(/^\*\*Requirements\*\*:[^\S\n]*([^\n]*)$/m); + const reqExtracted = reqMatch + ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s: string) => s.trim()).filter(Boolean).join(', ') + : null; + return (reqExtracted && reqExtracted !== 'TBD') ? reqExtracted : null; +} + +// ─── withProjectRoot ───────────────────────────────────────────────────── + +/** + * Inject project_root, agents_installed, missing_agents, and response_language + * into an init result object. + * + * Port of withProjectRoot from init.cjs lines 32-48. + * + * @param projectDir - Absolute project root path + * @param result - The result object to augment + * @param config - Optional loaded config (avoids re-reading config.json) + * @returns The augmented result object + */ +export function withProjectRoot( + projectDir: string, + result: Record, + config?: Record, +): Record { + result.project_root = projectDir; + + const agentStatus = checkAgentsInstalled(); + result.agents_installed = agentStatus.agents_installed; + result.missing_agents = agentStatus.missing_agents; + + const responseLang = config?.response_language; + if (responseLang) { + result.response_language = responseLang; + } + + return result; +} + +// ─── initExecutePhase ───────────────────────────────────────────────────── + +/** + * Init handler for execute-phase workflow. + * Port of cmdInitExecutePhase from init.cjs lines 50-171. + */ +export const initExecutePhase: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + if (!phase) { + return { data: { error: 'phase required for init execute-phase' } }; + } + + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + + const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir); + const phase_req_ids = extractReqIds(roadmapPhase); + + const [executorModel, verifierModel] = await Promise.all([ + getModelAlias('gsd-executor', projectDir), + getModelAlias('gsd-verifier', projectDir), + ]); + + const milestone = await getMilestoneInfo(projectDir); + + const phaseFound = !!(phaseInfo && phaseInfo.found); + const phaseNumber = (phaseInfo?.phase_number as string) || null; + const phaseSlug = (phaseInfo?.phase_slug as string) || null; + const plans = (phaseInfo?.plans || []) as string[]; + const summaries = (phaseInfo?.summaries || []) as string[]; + const incompletePlans = (phaseInfo?.incomplete_plans || []) as string[]; + const projectCode = (config as Record).project_code as string || ''; + + const result: Record = { + executor_model: executorModel, + verifier_model: verifierModel, + commit_docs: config.commit_docs, + sub_repos: (config as Record).sub_repos ?? [], + parallelization: config.parallelization, + context_window: (config as Record).context_window ?? 200000, + branching_strategy: config.git.branching_strategy, + phase_branch_template: config.git.phase_branch_template, + milestone_branch_template: config.git.milestone_branch_template, + verifier_enabled: config.workflow.verifier, + phase_found: phaseFound, + phase_dir: (phaseInfo?.directory as string) ?? null, + phase_number: phaseNumber, + phase_name: (phaseInfo?.phase_name as string) ?? null, + phase_slug: phaseSlug, + phase_req_ids, + plans, + summaries, + incomplete_plans: incompletePlans, + plan_count: plans.length, + incomplete_count: incompletePlans.length, + branch_name: config.git.branching_strategy === 'phase' && phaseInfo + ? config.git.phase_branch_template + .replace('{project}', projectCode) + .replace('{phase}', phaseNumber || '') + .replace('{slug}', phaseSlug || 'phase') + : config.git.branching_strategy === 'milestone' + ? config.git.milestone_branch_template + .replace('{milestone}', milestone.version) + .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone') + : null, + milestone_version: milestone.version, + milestone_name: milestone.name, + milestone_slug: generateSlugInternal(milestone.name), + state_exists: existsSync(join(planningDir, 'STATE.md')), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + config_exists: existsSync(join(planningDir, 'config.json')), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + config_path: toPosixPath(relative(projectDir, join(planningDir, 'config.json'))), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initPlanPhase ──────────────────────────────────────────────────────── + +/** + * Init handler for plan-phase workflow. + * Port of cmdInitPlanPhase from init.cjs lines 173-293. + */ +export const initPlanPhase: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + if (!phase) { + return { data: { error: 'phase required for init plan-phase' } }; + } + + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + + const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir); + const phase_req_ids = extractReqIds(roadmapPhase); + + const [researcherModel, plannerModel, checkerModel] = await Promise.all([ + getModelAlias('gsd-phase-researcher', projectDir), + getModelAlias('gsd-planner', projectDir), + getModelAlias('gsd-plan-checker', projectDir), + ]); + + const phaseFound = !!(phaseInfo && phaseInfo.found); + const phaseNumber = (phaseInfo?.phase_number as string) || null; + const plans = (phaseInfo?.plans || []) as string[]; + + const result: Record = { + researcher_model: researcherModel, + planner_model: plannerModel, + checker_model: checkerModel, + research_enabled: config.workflow.research, + plan_checker_enabled: config.workflow.plan_check, + nyquist_validation_enabled: config.workflow.nyquist_validation, + commit_docs: config.commit_docs, + text_mode: config.workflow.text_mode, + phase_found: phaseFound, + phase_dir: (phaseInfo?.directory as string) ?? null, + phase_number: phaseNumber, + phase_name: (phaseInfo?.phase_name as string) ?? null, + phase_slug: (phaseInfo?.phase_slug as string) ?? null, + padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, + phase_req_ids, + has_research: (phaseInfo?.has_research as boolean) || false, + has_context: (phaseInfo?.has_context as boolean) || false, + has_reviews: (phaseInfo?.has_reviews as boolean) || false, + has_plans: plans.length > 0, + plan_count: plans.length, + planning_exists: existsSync(planningDir), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))), + }; + + // Add artifact paths if phase directory exists + if (phaseInfo?.directory) { + const phaseDirFull = join(projectDir, phaseInfo.directory as string); + try { + const files = readdirSync(phaseDirFull); + const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); + if (contextFile) result.context_path = toPosixPath(join(phaseInfo.directory as string, contextFile)); + const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + if (researchFile) result.research_path = toPosixPath(join(phaseInfo.directory as string, researchFile)); + const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); + if (verificationFile) result.verification_path = toPosixPath(join(phaseInfo.directory as string, verificationFile)); + const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); + if (uatFile) result.uat_path = toPosixPath(join(phaseInfo.directory as string, uatFile)); + const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); + if (reviewsFile) result.reviews_path = toPosixPath(join(phaseInfo.directory as string, reviewsFile)); + } catch { /* intentionally empty */ } + } + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initNewMilestone ───────────────────────────────────────────────────── + +/** + * Init handler for new-milestone workflow. + * Port of cmdInitNewMilestone from init.cjs lines 401-446. + */ +export const initNewMilestone: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + const milestone = await getMilestoneInfo(projectDir); + const latestCompleted = getLatestCompletedMilestone(projectDir); + + const phasesDir = join(planningDir, 'phases'); + let phaseDirCount = 0; + try { + if (existsSync(phasesDir)) { + phaseDirCount = readdirSync(phasesDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .length; + } + } catch { /* intentionally empty */ } + + const [researcherModel, synthesizerModel, roadmapperModel] = await Promise.all([ + getModelAlias('gsd-project-researcher', projectDir), + getModelAlias('gsd-research-synthesizer', projectDir), + getModelAlias('gsd-roadmapper', projectDir), + ]); + + const result: Record = { + researcher_model: researcherModel, + synthesizer_model: synthesizerModel, + roadmapper_model: roadmapperModel, + commit_docs: config.commit_docs, + research_enabled: config.workflow.research, + current_milestone: milestone.version, + current_milestone_name: milestone.name, + latest_completed_milestone: latestCompleted?.version || null, + latest_completed_milestone_name: latestCompleted?.name || null, + phase_dir_count: phaseDirCount, + phase_archive_path: latestCompleted + ? toPosixPath(relative(projectDir, join(projectDir, '.planning', 'milestones', `${latestCompleted.version}-phases`))) + : null, + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + state_exists: existsSync(join(planningDir, 'STATE.md')), + project_path: '.planning/PROJECT.md', + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initQuick ──────────────────────────────────────────────────────────── + +/** + * Init handler for quick workflow. + * Port of cmdInitQuick from init.cjs lines 448-504. + */ +export const initQuick: QueryHandler = async (args, projectDir) => { + const description = args[0] || null; + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + const now = new Date(); + const slug = description ? generateSlugInternal(description).substring(0, 40) : null; + + // Generate collision-resistant quick task ID: YYMMDD-xxx + const yy = String(now.getFullYear()).slice(-2); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + const dateStr = yy + mm + dd; + const secondsSinceMidnight = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); + const timeBlocks = Math.floor(secondsSinceMidnight / 2); + const timeEncoded = timeBlocks.toString(36).padStart(3, '0'); + const quickId = dateStr + '-' + timeEncoded; + const branchSlug = slug || 'quick'; + const quickBranchName = config.git.quick_branch_template + ? config.git.quick_branch_template + .replace('{num}', quickId) + .replace('{quick}', quickId) + .replace('{slug}', branchSlug) + : null; + + const [plannerModel, executorModel, checkerModel, verifierModel] = await Promise.all([ + getModelAlias('gsd-planner', projectDir), + getModelAlias('gsd-executor', projectDir), + getModelAlias('gsd-plan-checker', projectDir), + getModelAlias('gsd-verifier', projectDir), + ]); + + const result: Record = { + planner_model: plannerModel, + executor_model: executorModel, + checker_model: checkerModel, + verifier_model: verifierModel, + commit_docs: config.commit_docs, + branch_name: quickBranchName, + quick_id: quickId, + slug, + description, + date: now.toISOString().split('T')[0], + timestamp: now.toISOString(), + quick_dir: '.planning/quick', + task_dir: slug ? `.planning/quick/${quickId}-${slug}` : null, + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + planning_exists: existsSync(join(projectDir, '.planning')), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initResume ─────────────────────────────────────────────────────────── + +/** + * Init handler for resume-project workflow. + * Port of cmdInitResume from init.cjs lines 506-536. + */ +export const initResume: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + + let interruptedAgentId: string | null = null; + try { + interruptedAgentId = readFileSync(join(projectDir, '.planning', 'current-agent-id.txt'), 'utf-8').trim(); + } catch { /* intentionally empty */ } + + const result: Record = { + state_exists: existsSync(join(planningDir, 'STATE.md')), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + planning_exists: existsSync(join(projectDir, '.planning')), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + project_path: '.planning/PROJECT.md', + has_interrupted_agent: !!interruptedAgentId, + interrupted_agent_id: interruptedAgentId, + commit_docs: config.commit_docs, + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initVerifyWork ─────────────────────────────────────────────────────── + +/** + * Init handler for verify-work workflow. + * Port of cmdInitVerifyWork from init.cjs lines 538-586. + */ +export const initVerifyWork: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + if (!phase) { + return { data: { error: 'phase required for init verify-work' } }; + } + + const config = await loadConfig(projectDir); + const { phaseInfo } = await getPhaseInfoWithFallback(phase, projectDir); + + const [plannerModel, checkerModel] = await Promise.all([ + getModelAlias('gsd-planner', projectDir), + getModelAlias('gsd-plan-checker', projectDir), + ]); + + const result: Record = { + planner_model: plannerModel, + checker_model: checkerModel, + commit_docs: config.commit_docs, + phase_found: !!(phaseInfo && phaseInfo.found), + phase_dir: (phaseInfo?.directory as string) ?? null, + phase_number: (phaseInfo?.phase_number as string) ?? null, + phase_name: (phaseInfo?.phase_name as string) ?? null, + has_verification: (phaseInfo?.has_verification as boolean) || false, + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initPhaseOp ────────────────────────────────────────────────────────── + +/** + * Init handler for discuss-phase and similar phase operations. + * Port of cmdInitPhaseOp from init.cjs lines 588-697. + */ +export const initPhaseOp: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + if (!phase) { + return { data: { error: 'phase required for init phase-op' } }; + } + + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + + // findPhase with archived override: if only match is archived, prefer ROADMAP + const phaseResult = await findPhase([phase], projectDir); + let phaseInfo = phaseResult.data as Record | null; + + const roadmapResult = await roadmapGetPhase([phase], projectDir); + const roadmapPhase = roadmapResult.data as Record | null; + + // If the only match comes from an archived milestone, prefer current ROADMAP + if (phaseInfo?.archived && roadmapPhase?.found) { + const phaseName = roadmapPhase.phase_name as string; + phaseInfo = { + found: true, + directory: null, + phase_number: roadmapPhase.phase_number, + phase_name: phaseName, + phase_slug: phaseName ? generateSlugInternal(phaseName) : null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + }; + } + + // Fallback to ROADMAP.md if no directory exists + if (!phaseInfo || !phaseInfo.found) { + if (roadmapPhase?.found) { + const phaseName = roadmapPhase.phase_name as string; + phaseInfo = { + found: true, + directory: null, + phase_number: roadmapPhase.phase_number, + phase_name: phaseName, + phase_slug: phaseName ? generateSlugInternal(phaseName) : null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + }; + } + } + + const phaseFound = !!(phaseInfo && phaseInfo.found); + const phaseNumber = (phaseInfo?.phase_number as string) || null; + const plans = (phaseInfo?.plans || []) as string[]; + + const result: Record = { + commit_docs: config.commit_docs, + brave_search: config.brave_search, + firecrawl: config.firecrawl, + exa_search: config.exa_search, + phase_found: phaseFound, + phase_dir: (phaseInfo?.directory as string) ?? null, + phase_number: phaseNumber, + phase_name: (phaseInfo?.phase_name as string) ?? null, + phase_slug: (phaseInfo?.phase_slug as string) ?? null, + padded_phase: phaseNumber ? normalizePhaseName(phaseNumber) : null, + has_research: (phaseInfo?.has_research as boolean) || false, + has_context: (phaseInfo?.has_context as boolean) || false, + has_plans: plans.length > 0, + has_verification: (phaseInfo?.has_verification as boolean) || false, + has_reviews: (phaseInfo?.has_reviews as boolean) || false, + plan_count: plans.length, + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + planning_exists: existsSync(planningDir), + state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))), + roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))), + requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))), + }; + + // Add artifact paths if phase directory exists + if (phaseInfo?.directory) { + const phaseDirFull = join(projectDir, phaseInfo.directory as string); + try { + const files = readdirSync(phaseDirFull); + const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'); + if (contextFile) result.context_path = toPosixPath(join(phaseInfo.directory as string, contextFile)); + const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'); + if (researchFile) result.research_path = toPosixPath(join(phaseInfo.directory as string, researchFile)); + const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'); + if (verificationFile) result.verification_path = toPosixPath(join(phaseInfo.directory as string, verificationFile)); + const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md'); + if (uatFile) result.uat_path = toPosixPath(join(phaseInfo.directory as string, uatFile)); + const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'); + if (reviewsFile) result.reviews_path = toPosixPath(join(phaseInfo.directory as string, reviewsFile)); + } catch { /* intentionally empty */ } + } + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initTodos ──────────────────────────────────────────────────────────── + +/** + * Init handler for check-todos and add-todo workflows. + * Port of cmdInitTodos from init.cjs lines 699-756. + */ +export const initTodos: QueryHandler = async (args, projectDir) => { + const area = args[0] || null; + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + const now = new Date(); + + const pendingDir = join(planningDir, 'todos', 'pending'); + let count = 0; + const todos: Array> = []; + + try { + const files = readdirSync(pendingDir).filter(f => f.endsWith('.md')); + for (const file of files) { + try { + const content = readFileSync(join(pendingDir, file), 'utf-8'); + const createdMatch = content.match(/^created:\s*(.+)$/m); + const titleMatch = content.match(/^title:\s*(.+)$/m); + const areaMatch = content.match(/^area:\s*(.+)$/m); + const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; + + if (area && todoArea !== area) continue; + + count++; + todos.push({ + file, + created: createdMatch ? createdMatch[1].trim() : 'unknown', + title: titleMatch ? titleMatch[1].trim() : 'Untitled', + area: todoArea, + path: toPosixPath(relative(projectDir, join(pendingDir, file))), + }); + } catch { /* intentionally empty */ } + } + } catch { /* intentionally empty */ } + + const result: Record = { + commit_docs: config.commit_docs, + date: now.toISOString().split('T')[0], + timestamp: now.toISOString(), + todo_count: count, + todos, + area_filter: area, + pending_dir: toPosixPath(relative(projectDir, join(planningDir, 'todos', 'pending'))), + completed_dir: toPosixPath(relative(projectDir, join(planningDir, 'todos', 'completed'))), + planning_exists: existsSync(planningDir), + todos_dir_exists: existsSync(join(planningDir, 'todos')), + pending_dir_exists: existsSync(pendingDir), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initMilestoneOp ───────────────────────────────────────────────────── + +/** + * Init handler for complete-milestone and audit-milestone workflows. + * Port of cmdInitMilestoneOp from init.cjs lines 758-817. + */ +export const initMilestoneOp: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const planningDir = join(projectDir, '.planning'); + const milestone = await getMilestoneInfo(projectDir); + + const phasesDir = join(planningDir, 'phases'); + let phaseCount = 0; + let completedPhases = 0; + + try { + const entries = readdirSync(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + phaseCount = dirs.length; + + for (const dir of dirs) { + try { + const phaseFiles = readdirSync(join(phasesDir, dir)); + const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + if (hasSummary) completedPhases++; + } catch { /* intentionally empty */ } + } + } catch { /* intentionally empty */ } + + const archiveDir = join(projectDir, '.planning', 'archive'); + let archivedMilestones: string[] = []; + try { + archivedMilestones = readdirSync(archiveDir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name); + } catch { /* intentionally empty */ } + + const result: Record = { + commit_docs: config.commit_docs, + milestone_version: milestone.version, + milestone_name: milestone.name, + milestone_slug: generateSlugInternal(milestone.name), + phase_count: phaseCount, + completed_phases: completedPhases, + all_phases_complete: phaseCount > 0 && phaseCount === completedPhases, + archived_milestones: archivedMilestones, + archive_count: archivedMilestones.length, + project_exists: pathExists(projectDir, '.planning/PROJECT.md'), + roadmap_exists: existsSync(join(planningDir, 'ROADMAP.md')), + state_exists: existsSync(join(planningDir, 'STATE.md')), + archive_exists: existsSync(archiveDir), + phases_dir_exists: existsSync(phasesDir), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initMapCodebase ────────────────────────────────────────────────────── + +/** + * Init handler for map-codebase workflow. + * Port of cmdInitMapCodebase from init.cjs lines 819-852. + */ +export const initMapCodebase: QueryHandler = async (_args, projectDir) => { + const config = await loadConfig(projectDir); + const codebaseDir = join(projectDir, '.planning', 'codebase'); + let existingMaps: string[] = []; + try { + existingMaps = readdirSync(codebaseDir).filter(f => f.endsWith('.md')); + } catch { /* intentionally empty */ } + + const mapperModel = await getModelAlias('gsd-codebase-mapper', projectDir); + + const result: Record = { + mapper_model: mapperModel, + commit_docs: config.commit_docs, + search_gitignored: config.search_gitignored, + parallelization: config.parallelization, + subagent_timeout: (config as Record).subagent_timeout ?? undefined, + codebase_dir: '.planning/codebase', + existing_maps: existingMaps, + has_maps: existingMaps.length > 0, + planning_exists: pathExists(projectDir, '.planning'), + codebase_dir_exists: pathExists(projectDir, '.planning/codebase'), + }; + + return { data: withProjectRoot(projectDir, result, config as Record) }; +}; + +// ─── initNewWorkspace ───────────────────────────────────────────────────── + +/** + * Init handler for new-workspace workflow. + * Port of cmdInitNewWorkspace from init.cjs lines 1311-1335. + * T-14-01: Validates workspace name rejects path separators. + */ +export const initNewWorkspace: QueryHandler = async (_args, projectDir) => { + const home = process.env.HOME || homedir(); + const defaultBase = join(home, 'gsd-workspaces'); + + // Detect child git repos (one level deep) + const childRepos: Array<{ name: string; path: string; has_uncommitted: boolean }> = []; + try { + const entries = readdirSync(projectDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const fullPath = join(projectDir, entry.name); + if (existsSync(join(fullPath, '.git'))) { + let hasUncommitted = false; + try { + const status = execSync('git status --porcelain', { cwd: fullPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); + hasUncommitted = status.trim().length > 0; + } catch { /* best-effort */ } + childRepos.push({ name: entry.name, path: fullPath, has_uncommitted: hasUncommitted }); + } + } + } catch { /* intentionally empty */ } + + let worktreeAvailable = false; + try { + execSync('git --version', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); + worktreeAvailable = true; + } catch { /* no git */ } + + const result: Record = { + default_workspace_base: defaultBase, + child_repos: childRepos, + child_repo_count: childRepos.length, + worktree_available: worktreeAvailable, + is_git_repo: pathExists(projectDir, '.git'), + cwd_repo_name: basename(projectDir), + }; + + return { data: withProjectRoot(projectDir, result) }; +}; + +// ─── initListWorkspaces ─────────────────────────────────────────────────── + +/** + * Init handler for list-workspaces workflow. + * Port of cmdInitListWorkspaces from init.cjs lines 1337-1381. + */ +export const initListWorkspaces: QueryHandler = async (_args, _projectDir) => { + const home = process.env.HOME || homedir(); + const defaultBase = join(home, 'gsd-workspaces'); + + const workspaces: Array> = []; + if (existsSync(defaultBase)) { + let entries: Array<{ isDirectory(): boolean; name: string }> = []; + try { + entries = readdirSync(defaultBase, { withFileTypes: true }) as unknown as typeof entries; + } catch { entries = []; } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const wsPath = join(defaultBase, String(entry.name)); + const manifestPath = join(wsPath, 'WORKSPACE.md'); + if (!existsSync(manifestPath)) continue; + + let repoCount = 0; + let strategy = 'unknown'; + try { + const manifest = readFileSync(manifestPath, 'utf8'); + const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); + if (strategyMatch) strategy = strategyMatch[1].trim(); + const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---')); + repoCount = tableRows.length; + } catch { /* best-effort */ } + const hasProject = existsSync(join(wsPath, '.planning', 'PROJECT.md')); + + workspaces.push({ + name: entry.name, + path: wsPath, + repo_count: repoCount, + strategy, + has_project: hasProject, + }); + } + } + + const result: Record = { + workspace_base: defaultBase, + workspaces, + workspace_count: workspaces.length, + }; + + return { data: result }; +}; + +// ─── initRemoveWorkspace ────────────────────────────────────────────────── + +/** + * Init handler for remove-workspace workflow. + * Port of cmdInitRemoveWorkspace from init.cjs lines 1383-1443. + * T-14-01: Validates workspace name rejects path separators and '..' sequences. + */ +export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => { + const name = args[0]; + if (!name) { + return { data: { error: 'workspace name required for init remove-workspace' } }; + } + + // T-14-01: Reject path traversal attempts + if (name.includes('/') || name.includes('\\') || name.includes('..')) { + return { data: { error: `Invalid workspace name: ${name} (path separators not allowed)` } }; + } + + const home = process.env.HOME || homedir(); + const defaultBase = join(home, 'gsd-workspaces'); + const wsPath = join(defaultBase, name); + const manifestPath = join(wsPath, 'WORKSPACE.md'); + + if (!existsSync(wsPath)) { + return { data: { error: `Workspace not found: ${wsPath}` } }; + } + + const repos: Array> = []; + let strategy = 'unknown'; + if (existsSync(manifestPath)) { + try { + const manifest = readFileSync(manifestPath, 'utf8'); + const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m); + if (strategyMatch) strategy = strategyMatch[1].trim(); + + const lines = manifest.split('\n'); + for (const line of lines) { + const match = line.match(/^\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|\s*(\S+)\s*\|$/); + if (match && match[1] !== 'Repo' && !match[1].includes('---')) { + repos.push({ name: match[1], source: match[2], branch: match[3], strategy: match[4] }); + } + } + } catch { /* best-effort */ } + } + + // Check for uncommitted changes in workspace repos + const dirtyRepos: string[] = []; + for (const repo of repos) { + const repoPath = join(wsPath, repo.name as string); + if (!existsSync(repoPath)) continue; + try { + const status = execSync('git status --porcelain', { cwd: repoPath, encoding: 'utf8', timeout: 5000, stdio: 'pipe' }); + if (status.trim().length > 0) { + dirtyRepos.push(repo.name as string); + } + } catch { /* best-effort */ } + } + + const result: Record = { + workspace_name: name, + workspace_path: wsPath, + has_manifest: existsSync(manifestPath), + strategy, + repos, + repo_count: repos.length, + dirty_repos: dirtyRepos, + has_dirty_repos: dirtyRepos.length > 0, + }; + + return { data: result }; +}; + +// ─── docsInit ──────────────────────────────────────────────────────────── + +export const docsInit: QueryHandler = async (_args, projectDir) => { + return { + data: { + project_exists: existsSync(join(projectDir, '.planning', 'PROJECT.md')), + roadmap_exists: existsSync(join(projectDir, '.planning', 'ROADMAP.md')), + docs_dir: '.planning/docs', + project_root: projectDir, + }, + }; +}; diff --git a/sdk/src/query/intel.ts b/sdk/src/query/intel.ts new file mode 100644 index 000000000..a347b629c --- /dev/null +++ b/sdk/src/query/intel.ts @@ -0,0 +1,311 @@ +/** + * Intel query handlers — .planning/intel/ file management. + * + * Ported from get-shit-done/bin/lib/intel.cjs. + * Provides intel status, diff, snapshot, validate, query, extract-exports, + * and patch-meta operations for the project intelligence system. + * + * @example + * ```typescript + * import { intelStatus, intelQuery } from './intel.js'; + * + * await intelStatus([], '/project'); + * // { data: { files: { ... }, overall_stale: false } } + * + * await intelQuery(['AuthService'], '/project'); + * // { data: { matches: [...], term: 'AuthService', total: 3 } } + * ``` + */ + +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; + +import { planningPaths } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Constants ─────────────────────────────────────────────────────────── + +const INTEL_FILES: Record = { + files: 'files.json', + apis: 'apis.json', + deps: 'deps.json', + arch: 'arch.md', + stack: 'stack.json', +}; + +const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours + +// ─── Internal helpers ──────────────────────────────────────────────────── + +function intelDir(projectDir: string): string { + return join(projectDir, '.planning', 'intel'); +} + +function isIntelEnabled(projectDir: string): boolean { + try { + const cfg = JSON.parse(readFileSync(planningPaths(projectDir).config, 'utf-8')); + return cfg?.intel?.enabled === true; + } catch { + return false; + } +} + +function intelFilePath(projectDir: string, filename: string): string { + return join(intelDir(projectDir), filename); +} + +function safeReadJson(filePath: string): unknown { + try { + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} + +function hashFile(filePath: string): string | null { + try { + if (!existsSync(filePath)) return null; + const content = readFileSync(filePath); + return createHash('sha256').update(content).digest('hex'); + } catch { + return null; + } +} + +function searchJsonEntries(data: unknown, term: string): unknown[] { + const lowerTerm = term.toLowerCase(); + const results: unknown[] = []; + if (!data || typeof data !== 'object') return results; + + function matchesInValue(value: unknown): boolean { + if (typeof value === 'string') return value.toLowerCase().includes(lowerTerm); + if (Array.isArray(value)) return value.some(v => matchesInValue(v)); + if (value && typeof value === 'object') return Object.values(value as object).some(v => matchesInValue(v)); + return false; + } + + if (Array.isArray(data)) { + for (const entry of data) { + if (matchesInValue(entry)) results.push(entry); + } + } else { + for (const [, value] of Object.entries(data as object)) { + if (Array.isArray(value)) { + for (const entry of value) { + if (matchesInValue(entry)) results.push(entry); + } + } + } + } + return results; +} + +function searchArchMd(filePath: string, term: string): string[] { + if (!existsSync(filePath)) return []; + const lowerTerm = term.toLowerCase(); + const content = readFileSync(filePath, 'utf-8'); + return content.split('\n').filter(line => line.toLowerCase().includes(lowerTerm)); +} + +// ─── Handlers ──────────────────────────────────────────────────────────── + +export const intelStatus: QueryHandler = async (_args, projectDir) => { + if (!isIntelEnabled(projectDir)) { + return { data: { disabled: true, message: 'Intel system disabled. Set intel.enabled=true in config.json to activate.' } }; + } + const now = Date.now(); + const files: Record = {}; + let overallStale = false; + + for (const [, filename] of Object.entries(INTEL_FILES)) { + const filePath = intelFilePath(projectDir, filename); + if (!existsSync(filePath)) { + files[filename] = { exists: false, updated_at: null, stale: true }; + overallStale = true; + continue; + } + let updatedAt: string | null = null; + if (filename.endsWith('.md')) { + try { updatedAt = statSync(filePath).mtime.toISOString(); } catch { /* skip */ } + } else { + const data = safeReadJson(filePath) as Record | null; + if (data?._meta) { + updatedAt = (data._meta as Record).updated_at as string | null; + } + } + const stale = !updatedAt || (now - new Date(updatedAt).getTime()) > STALE_MS; + if (stale) overallStale = true; + files[filename] = { exists: true, updated_at: updatedAt, stale }; + } + return { data: { files, overall_stale: overallStale } }; +}; + +export const intelDiff: QueryHandler = async (_args, projectDir) => { + if (!isIntelEnabled(projectDir)) { + return { data: { disabled: true, message: 'Intel system disabled.' } }; + } + const snapshotPath = intelFilePath(projectDir, '.last-refresh.json'); + const snapshot = safeReadJson(snapshotPath) as Record | null; + if (!snapshot) return { data: { no_baseline: true } }; + + const prevHashes = (snapshot.hashes as Record) || {}; + const changed: string[] = []; + const added: string[] = []; + const removed: string[] = []; + + for (const [, filename] of Object.entries(INTEL_FILES)) { + const filePath = intelFilePath(projectDir, filename); + const currentHash = hashFile(filePath); + if (currentHash && !prevHashes[filename]) added.push(filename); + else if (currentHash && prevHashes[filename] && currentHash !== prevHashes[filename]) changed.push(filename); + else if (!currentHash && prevHashes[filename]) removed.push(filename); + } + return { data: { changed, added, removed } }; +}; + +export const intelSnapshot: QueryHandler = async (_args, projectDir) => { + if (!isIntelEnabled(projectDir)) { + return { data: { disabled: true, message: 'Intel system disabled.' } }; + } + const dir = intelDir(projectDir); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + const hashes: Record = {}; + let fileCount = 0; + for (const [, filename] of Object.entries(INTEL_FILES)) { + const filePath = join(dir, filename); + const hash = hashFile(filePath); + if (hash) { hashes[filename] = hash; fileCount++; } + } + + const timestamp = new Date().toISOString(); + writeFileSync(join(dir, '.last-refresh.json'), JSON.stringify({ hashes, timestamp, version: 1 }, null, 2), 'utf-8'); + return { data: { saved: true, timestamp, files: fileCount } }; +}; + +export const intelValidate: QueryHandler = async (_args, projectDir) => { + if (!isIntelEnabled(projectDir)) { + return { data: { disabled: true, message: 'Intel system disabled.' } }; + } + const errors: string[] = []; + const warnings: string[] = []; + + for (const [, filename] of Object.entries(INTEL_FILES)) { + const filePath = intelFilePath(projectDir, filename); + if (!existsSync(filePath)) { + errors.push(`Missing intel file: ${filename}`); + continue; + } + if (!filename.endsWith('.md')) { + const data = safeReadJson(filePath) as Record | null; + if (!data) { errors.push(`Invalid JSON in: ${filename}`); continue; } + const meta = data._meta as Record | undefined; + if (!meta?.updated_at) warnings.push(`${filename}: missing _meta.updated_at`); + else { + const age = Date.now() - new Date(meta.updated_at as string).getTime(); + if (age > STALE_MS) warnings.push(`${filename}: stale (${Math.round(age / 3600000)}h old)`); + } + } + } + return { data: { valid: errors.length === 0, errors, warnings } }; +}; + +export const intelQuery: QueryHandler = async (args, projectDir) => { + const term = args[0] || ''; + if (!isIntelEnabled(projectDir)) { + return { data: { disabled: true, message: 'Intel system disabled.' } }; + } + const matches: unknown[] = []; + let total = 0; + + for (const [, filename] of Object.entries(INTEL_FILES)) { + if (filename.endsWith('.md')) { + const filePath = intelFilePath(projectDir, filename); + const archMatches = searchArchMd(filePath, term); + if (archMatches.length > 0) { matches.push({ source: filename, entries: archMatches }); total += archMatches.length; } + } else { + const filePath = intelFilePath(projectDir, filename); + const data = safeReadJson(filePath); + if (!data) continue; + const found = searchJsonEntries(data, term); + if (found.length > 0) { matches.push({ source: filename, entries: found }); total += found.length; } + } + } + return { data: { matches, term, total } }; +}; + +export const intelExtractExports: QueryHandler = async (args, projectDir) => { + const filePath = args[0] ? resolve(projectDir, args[0]) : ''; + if (!filePath || !existsSync(filePath)) { + return { data: { file: filePath, exports: [], method: 'none' } }; + } + + const content = readFileSync(filePath, 'utf-8'); + const exports: string[] = []; + let method = 'none'; + + const allMatches = [...content.matchAll(/module\.exports\s*=\s*\{/g)]; + if (allMatches.length > 0) { + const lastMatch = allMatches[allMatches.length - 1]; + const startIdx = lastMatch.index! + lastMatch[0].length; + let depth = 1; let endIdx = startIdx; + while (endIdx < content.length && depth > 0) { + if (content[endIdx] === '{') depth++; + else if (content[endIdx] === '}') depth--; + if (depth > 0) endIdx++; + } + const block = content.substring(startIdx, endIdx); + method = 'module.exports'; + for (const line of block.split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('//') || t.startsWith('*')) continue; + const k = t.match(/^(\w+)\s*[,}:]/) || t.match(/^(\w+)$/); + if (k) exports.push(k[1]); + } + } + for (const m of content.matchAll(/^exports\.(\w+)\s*=/gm)) { + if (!exports.includes(m[1])) { exports.push(m[1]); if (method === 'none') method = 'exports.X'; } + } + const esmExports: string[] = []; + for (const m of content.matchAll(/^export\s+(?:default\s+)?(?:async\s+)?(?:function|class)\s+(\w+)/gm)) { + if (!esmExports.includes(m[1])) esmExports.push(m[1]); + } + for (const m of content.matchAll(/^export\s+(?:const|let|var)\s+(\w+)\s*=/gm)) { + if (!esmExports.includes(m[1])) esmExports.push(m[1]); + } + for (const m of content.matchAll(/^export\s*\{([^}]+)\}/gm)) { + for (const item of m[1].split(',')) { + const name = item.trim().split(/\s+as\s+/)[0].trim(); + if (name && !esmExports.includes(name)) esmExports.push(name); + } + } + for (const e of esmExports) { + if (!exports.includes(e)) exports.push(e); + } + if (esmExports.length > 0 && exports.length > esmExports.length) method = 'mixed'; + else if (esmExports.length > 0 && method === 'none') method = 'esm'; + + return { data: { file: args[0], exports, method } }; +}; + +export const intelPatchMeta: QueryHandler = async (args, projectDir) => { + const filePath = args[0] ? resolve(projectDir, args[0]) : ''; + if (!filePath || !existsSync(filePath)) { + return { data: { patched: false, error: `File not found: ${filePath}` } }; + } + try { + const raw = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as Record; + if (!data._meta) data._meta = {}; + const meta = data._meta as Record; + const timestamp = new Date().toISOString(); + meta.updated_at = timestamp; + meta.version = ((meta.version as number) || 0) + 1; + writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); + return { data: { patched: true, file: args[0], timestamp } }; + } catch (err) { + return { data: { patched: false, error: String(err) } }; + } +}; diff --git a/sdk/src/query/phase-lifecycle.test.ts b/sdk/src/query/phase-lifecycle.test.ts new file mode 100644 index 000000000..2a77fbe5c --- /dev/null +++ b/sdk/src/query/phase-lifecycle.test.ts @@ -0,0 +1,1079 @@ +/** + * Unit tests for phase lifecycle handlers. + * + * Tests phaseAdd, phaseInsert, phaseScaffold, replaceInCurrentMilestone, + * and readModifyWriteRoadmapMd. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, readFile, rm, mkdir, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { existsSync } from 'node:fs'; + +// ─── Fixtures ───────────────────────────────────────────────────────────── + +const MINIMAL_ROADMAP = `# Roadmap + +## Current Milestone: v3.0 SDK-First Migration + +### Phase 9: Foundation + +**Goal:** Build foundation +**Requirements**: TBD +**Depends on:** Phase 8 +**Plans:** 3 plans + +Plans: +- [x] 09-01 (Foundation setup) + +### Phase 10: Read-Only Queries + +**Goal:** Port queries. +**Requirements**: TBD +**Depends on:** Phase 9 +**Plans:** 3 plans + +Plans: +- [x] 10-01 (Query setup) + +--- +*Last updated: 2026-04-08* +`; + +const ROADMAP_WITH_DETAILS = `# Roadmap + +
+v1.0 (shipped) + +### Phase 1: Old Phase + +**Goal:** Shipped already +**Plans:** 2 plans + +
+ +## Current Milestone: v3.0 SDK-First Migration + +### Phase 9: Foundation + +**Goal:** Build foundation +**Requirements**: TBD +**Plans:** 3 plans + +### Phase 10: Read-Only Queries + +**Goal:** Port queries. +**Requirements**: TBD +**Plans:** 3 plans + +--- +*Last updated: 2026-04-08* +`; + +const MINIMAL_STATE = `--- +gsd_state_version: 1.0 +milestone: v3.0 +milestone_name: SDK-First Migration +status: executing +--- + +# Project State + +## Current Position + +Phase: 10 (Read-Only Queries) — EXECUTING +Plan: 2 of 3 +Status: Executing Phase 10 + +## Session Continuity + +Last session: 2026-04-07T10:00:00.000Z +Stopped at: Completed 10-02-PLAN.md +`; + +/** Create a test project with .planning structure. */ +async function setupTestProject( + tmpDir: string, + opts?: { roadmap?: string; state?: string; config?: Record; phases?: string[] } +): Promise { + const planningDir = join(tmpDir, '.planning'); + await mkdir(planningDir, { recursive: true }); + const phasesDir = join(planningDir, 'phases'); + await mkdir(phasesDir, { recursive: true }); + await writeFile(join(planningDir, 'ROADMAP.md'), opts?.roadmap || MINIMAL_ROADMAP, 'utf-8'); + await writeFile(join(planningDir, 'STATE.md'), opts?.state || MINIMAL_STATE, 'utf-8'); + await writeFile( + join(planningDir, 'config.json'), + JSON.stringify(opts?.config || { model_profile: 'balanced', phase_naming: 'sequential' }), + 'utf-8' + ); + // Create phase directories if requested + if (opts?.phases) { + for (const phase of opts.phases) { + await mkdir(join(phasesDir, phase), { recursive: true }); + await writeFile(join(phasesDir, phase, '.gitkeep'), '', 'utf-8'); + } + } + return tmpDir; +} + +// ─── Tests ──────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-lifecycle-')); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── replaceInCurrentMilestone ────────────────────────────────────────── + +describe('replaceInCurrentMilestone', () => { + it('replaces in full content when no details blocks', async () => { + const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js'); + const content = '### Phase 9: Foundation\n**Plans:** 3 plans\n'; + const result = replaceInCurrentMilestone(content, /3 plans/, '4 plans'); + expect(result).toContain('4 plans'); + }); + + it('only replaces after last block', async () => { + const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js'); + const content = '
\n### Phase 1: Old\n**Plans:** 3 plans\n
\n\n### Phase 9: Current\n**Plans:** 3 plans\n'; + const result = replaceInCurrentMilestone(content, /3 plans/, '4 plans'); + // Should only replace in the current milestone section (after ) + const before = result.slice(0, result.indexOf('') + ''.length); + const after = result.slice(result.indexOf('') + ''.length); + expect(before).toContain('3 plans'); // old milestone untouched + expect(after).toContain('4 plans'); // current milestone updated + }); +}); + +// ─── readModifyWriteRoadmapMd ─────────────────────────────────────────── + +describe('readModifyWriteRoadmapMd', () => { + it('reads, modifies, and writes ROADMAP.md atomically', async () => { + const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir); + const result = await readModifyWriteRoadmapMd(tmpDir, (content) => { + return content.replace('Port queries.', 'Port all queries.'); + }); + expect(result).toContain('Port all queries.'); + const ondisk = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + expect(ondisk).toContain('Port all queries.'); + }); + + it('creates and releases lockfile', async () => { + const { readModifyWriteRoadmapMd } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir); + await readModifyWriteRoadmapMd(tmpDir, (c) => c); + // Lock should be released after operation + const lockPath = join(tmpDir, '.planning', 'ROADMAP.md.lock'); + expect(existsSync(lockPath)).toBe(false); + }); +}); + +// ─── phaseAdd ────────────────────────────────────────────────────────── + +describe('phaseAdd', () => { + it('creates directory and updates ROADMAP.md for sequential phase', async () => { + const { phaseAdd } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation', '10-read-only-queries'], + }); + + const result = await phaseAdd(['New Feature'], tmpDir); + const data = result.data as Record; + + expect(data.phase_number).toBe(11); + expect(data.padded).toBe('11'); + expect(data.name).toBe('New Feature'); + expect(data.slug).toBe('new-feature'); + expect(data.naming_mode).toBe('sequential'); + + // Verify directory was created + const dir = data.directory as string; + expect(dir).toContain('11-new-feature'); + const phasesDir = join(tmpDir, '.planning', 'phases'); + const entries = await readdir(phasesDir, { withFileTypes: true }); + const newDir = entries.find(e => e.isDirectory() && e.name.includes('11-new-feature')); + expect(newDir).toBeTruthy(); + + // Verify .gitkeep + expect(existsSync(join(phasesDir, newDir!.name, '.gitkeep'))).toBe(true); + + // Verify ROADMAP.md updated + const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + expect(roadmap).toContain('### Phase 11: New Feature'); + expect(roadmap).toContain('**Goal:** [To be planned]'); + }); + + it('skips phases >= 999 when calculating next number (backlog exclusion)', async () => { + const { phaseAdd } = await import('./phase-lifecycle.js'); + const roadmapWith999 = MINIMAL_ROADMAP.replace( + '---\n*Last updated', + '### Phase 999: Backlog\n\n**Goal:** Backlog items\n**Plans:** 0 plans\n\n---\n*Last updated' + ); + await setupTestProject(tmpDir, { roadmap: roadmapWith999 }); + + const result = await phaseAdd(['After Ten'], tmpDir); + const data = result.data as Record; + // Should be 11, not 1000 + expect(data.phase_number).toBe(11); + }); + + it('throws GSDError with Validation for empty description', async () => { + const { phaseAdd } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir); + + await expect(phaseAdd([], tmpDir)).rejects.toThrow('description required'); + }); + + it('inserts phase entry before last --- separator', async () => { + const { phaseAdd } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir); + + await phaseAdd(['Inserted Phase'], tmpDir); + const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + + // The new phase should appear before the trailing --- + const phaseIdx = roadmap.indexOf('### Phase 11: Inserted Phase'); + const sepIdx = roadmap.lastIndexOf('\n---'); + expect(phaseIdx).toBeLessThan(sepIdx); + expect(phaseIdx).toBeGreaterThan(0); + }); +}); + +// ─── phaseInsert ──────────────────────────────────────────────────────── + +describe('phaseInsert', () => { + it('creates decimal phase directory after target phase', async () => { + const { phaseInsert } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation', '10-read-only-queries'], + }); + + const result = await phaseInsert(['10', 'Urgent Fix'], tmpDir); + const data = result.data as Record; + + expect(data.phase_number).toBe('10.1'); + expect(data.after_phase).toBe('10'); + expect(data.name).toBe('Urgent Fix'); + expect(data.slug).toBe('urgent-fix'); + + // Verify directory created + const dir = data.directory as string; + expect(dir).toContain('10.1-urgent-fix'); + const phasesDir = join(tmpDir, '.planning', 'phases'); + const entries = await readdir(phasesDir, { withFileTypes: true }); + const newDir = entries.find(e => e.isDirectory() && e.name.includes('10.1-urgent-fix')); + expect(newDir).toBeTruthy(); + }); + + it('scans both directories and ROADMAP.md for existing decimals to avoid collisions', async () => { + const { phaseInsert } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation', '10-read-only-queries', '10.1-hotfix'], + }); + + const result = await phaseInsert(['10', 'Another Fix'], tmpDir); + const data = result.data as Record; + // Should be 10.2 since 10.1 already exists on disk + expect(data.phase_number).toBe('10.2'); + }); + + it('inserts section in ROADMAP.md after target phase', async () => { + const { phaseInsert } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir); + + await phaseInsert(['10', 'Urgent Fix'], tmpDir); + const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + + expect(roadmap).toContain('### Phase 10.1: Urgent Fix (INSERTED)'); + // Should appear after Phase 10 section + const phase10Idx = roadmap.indexOf('### Phase 10:'); + const insertedIdx = roadmap.indexOf('### Phase 10.1:'); + expect(insertedIdx).toBeGreaterThan(phase10Idx); + }); + + it('throws GSDError for missing target phase', async () => { + const { phaseInsert } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir); + + await expect(phaseInsert(['99', 'Missing'], tmpDir)).rejects.toThrow('Phase 99 not found'); + }); + + it('throws GSDError with Validation for missing args', async () => { + const { phaseInsert } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir); + + await expect(phaseInsert([], tmpDir)).rejects.toThrow('after-phase and description required'); + }); +}); + +// ─── phaseScaffold ────────────────────────────────────────────────────── + +describe('phaseScaffold', () => { + it('creates context template for a phase', async () => { + const { phaseScaffold } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation'], + }); + + const result = await phaseScaffold(['context', '9'], tmpDir); + const data = result.data as Record; + + expect(data.created).toBe(true); + const filePath = data.path as string; + expect(filePath).toContain('09-CONTEXT.md'); + + // Check content + const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-CONTEXT.md'); + expect(existsSync(fullPath)).toBe(true); + const content = await readFile(fullPath, 'utf-8'); + expect(content).toContain('phase: "09"'); + expect(content).toContain('Context'); + }); + + it('creates uat template', async () => { + const { phaseScaffold } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation'], + }); + + const result = await phaseScaffold(['uat', '9'], tmpDir); + const data = result.data as Record; + + expect(data.created).toBe(true); + const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-UAT.md'); + expect(existsSync(fullPath)).toBe(true); + const content = await readFile(fullPath, 'utf-8'); + expect(content).toContain('User Acceptance Testing'); + }); + + it('creates verification template', async () => { + const { phaseScaffold } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation'], + }); + + const result = await phaseScaffold(['verification', '9'], tmpDir); + const data = result.data as Record; + + expect(data.created).toBe(true); + const fullPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-VERIFICATION.md'); + expect(existsSync(fullPath)).toBe(true); + const content = await readFile(fullPath, 'utf-8'); + expect(content).toContain('Verification'); + }); + + it('creates phase-dir under phases/', async () => { + const { phaseScaffold } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir); + + const result = await phaseScaffold(['phase-dir', '15', 'New Module'], tmpDir); + const data = result.data as Record; + + expect(data.created).toBe(true); + const dir = data.directory as string; + expect(dir).toContain('15-new-module'); + }); + + it('returns already_exists for existing file', async () => { + const { phaseScaffold } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation'], + }); + + // Create first + await phaseScaffold(['context', '9'], tmpDir); + // Second call should return already_exists + const result = await phaseScaffold(['context', '9'], tmpDir); + const data = result.data as Record; + expect(data.created).toBe(false); + expect(data.reason).toBe('already_exists'); + }); + + it('throws GSDError for unknown type', async () => { + const { phaseScaffold } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation'], + }); + + await expect(phaseScaffold(['badtype', '9'], tmpDir)).rejects.toThrow('Unknown scaffold type'); + }); +}); + +// ─── phaseRemove ───────────────────────────────────────────────────────── + +const ROADMAP_FOR_REMOVE = `# Roadmap + +## Current Milestone: v3.0 SDK-First Migration + +### Phase 5: Auth + +**Goal:** Build authentication +**Requirements**: TBD +**Depends on:** Phase 4 +**Plans:** 2 plans + +Plans: +- [x] 05-01 (Auth setup) +- [x] 05-02 (Auth complete) + +### Phase 6: Dashboard + +**Goal:** Build dashboard +**Requirements**: TBD +**Depends on:** Phase 5 +**Plans:** 3 plans + +Plans: +- [ ] 06-01 (Dashboard setup) + +### Phase 7: API + +**Goal:** Build API layer +**Requirements**: TBD +**Depends on:** Phase 6 +**Plans:** 2 plans + +Plans: +- [ ] 07-01 (API setup) + +--- +*Last updated: 2026-04-08* +`; + +const STATE_FOR_REMOVE = `--- +gsd_state_version: 1.0 +milestone: v3.0 +milestone_name: SDK-First Migration +status: executing +progress: + total_phases: 7 + completed_phases: 4 + total_plans: 15 + completed_plans: 12 + percent: 80 +--- + +# Project State + +## Current Position + +Phase: 6 (Dashboard) — EXECUTING +Plan: 1 of 3 +Status: Executing Phase 6 + +## Session Continuity + +Last session: 2026-04-08T10:00:00.000Z +Stopped at: Started +`; + +describe('phaseRemove', () => { + it('removes integer phase directory and renumbers subsequent phases', async () => { + const { phaseRemove } = await import('./phase-lifecycle.js'); + const phasesDir = join(tmpDir, '.planning', 'phases'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_REMOVE, + state: STATE_FOR_REMOVE, + phases: ['05-auth', '06-dashboard', '07-api'], + }); + // Create files inside directories to verify file renaming + await writeFile(join(phasesDir, '06-dashboard', '06-01-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(phasesDir, '07-api', '07-01-PLAN.md'), 'plan', 'utf-8'); + + const result = await phaseRemove(['6'], tmpDir); + const data = result.data as Record; + + expect(data.removed).toBe('6'); + expect(data.directory_deleted).toBeTruthy(); + expect(data.roadmap_updated).toBe(true); + expect(data.state_updated).toBe(true); + + // Phase 6 dir should be gone + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name); + expect(dirNames.find(d => d.includes('06-dashboard'))).toBeUndefined(); + + // Phase 7 should have been renamed to 06 + const renamedDir = dirNames.find(d => d.includes('06-api')); + expect(renamedDir).toBeTruthy(); + + // Files inside renamed dir should also be renamed + const files = await readdir(join(phasesDir, renamedDir!)); + expect(files.some(f => f.includes('06-01'))).toBe(true); + expect(files.some(f => f.includes('07-01'))).toBe(false); + }); + + it('removes decimal phase and renumbers sibling decimals', async () => { + const { phaseRemove } = await import('./phase-lifecycle.js'); + const decimalRoadmap = ROADMAP_FOR_REMOVE.replace( + '### Phase 7: API', + '### Phase 6.1: Hotfix A\n\n**Goal:** Fix A\n**Plans:** 1 plans\n\n### Phase 6.2: Hotfix B\n\n**Goal:** Fix B\n**Plans:** 1 plans\n\n### Phase 6.3: Hotfix C\n\n**Goal:** Fix C\n**Plans:** 1 plans\n\n### Phase 7: API' + ); + const phasesDir = join(tmpDir, '.planning', 'phases'); + await setupTestProject(tmpDir, { + roadmap: decimalRoadmap, + state: STATE_FOR_REMOVE, + phases: ['05-auth', '06-dashboard', '06.1-hotfix-a', '06.2-hotfix-b', '06.3-hotfix-c', '07-api'], + }); + // Create files with phase ID in name + await writeFile(join(phasesDir, '06.2-hotfix-b', '06.2-01-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(phasesDir, '06.3-hotfix-c', '06.3-01-PLAN.md'), 'plan', 'utf-8'); + + const result = await phaseRemove(['6.1'], tmpDir); + const data = result.data as Record; + + expect(data.removed).toBe('6.1'); + + // 06.1 should be gone + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name); + expect(dirNames.find(d => d.includes('06.1-hotfix-a'))).toBeUndefined(); + + // 06.2 should become 06.1, 06.3 should become 06.2 + expect(dirNames.find(d => d.includes('06.1-hotfix-b'))).toBeTruthy(); + expect(dirNames.find(d => d.includes('06.2-hotfix-c'))).toBeTruthy(); + expect(dirNames.find(d => d.includes('06.3'))).toBeUndefined(); + + // Files inside renamed dirs should be renamed + const dir1Files = await readdir(join(phasesDir, '06.1-hotfix-b')); + expect(dir1Files.some(f => f.includes('06.1-01'))).toBe(true); + const dir2Files = await readdir(join(phasesDir, '06.2-hotfix-c')); + expect(dir2Files.some(f => f.includes('06.2-01'))).toBe(true); + }); + + it('requires --force to remove phase with SUMMARY files', async () => { + const { phaseRemove } = await import('./phase-lifecycle.js'); + const phasesDir = join(tmpDir, '.planning', 'phases'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_REMOVE, + state: STATE_FOR_REMOVE, + phases: ['05-auth', '06-dashboard', '07-api'], + }); + // Create a SUMMARY file to simulate executed work + await writeFile(join(phasesDir, '06-dashboard', '06-01-SUMMARY.md'), 'summary', 'utf-8'); + + await expect(phaseRemove(['6'], tmpDir)).rejects.toThrow('--force'); + }); + + it('allows removal with --force even when SUMMARY files exist', async () => { + const { phaseRemove } = await import('./phase-lifecycle.js'); + const phasesDir = join(tmpDir, '.planning', 'phases'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_REMOVE, + state: STATE_FOR_REMOVE, + phases: ['05-auth', '06-dashboard', '07-api'], + }); + await writeFile(join(phasesDir, '06-dashboard', '06-01-SUMMARY.md'), 'summary', 'utf-8'); + + const result = await phaseRemove(['6', '--force'], tmpDir); + const data = result.data as Record; + expect(data.removed).toBe('6'); + expect(data.directory_deleted).toBeTruthy(); + }); + + it('throws GSDError when ROADMAP.md is missing', async () => { + const { phaseRemove } = await import('./phase-lifecycle.js'); + // Set up without ROADMAP.md + const planningDir = join(tmpDir, '.planning'); + await mkdir(planningDir, { recursive: true }); + const phasesDir = join(planningDir, 'phases'); + await mkdir(phasesDir, { recursive: true }); + await writeFile(join(planningDir, 'STATE.md'), STATE_FOR_REMOVE, 'utf-8'); + + await expect(phaseRemove(['6'], tmpDir)).rejects.toThrow('ROADMAP.md not found'); + }); + + it('throws GSDError when phase number is missing', async () => { + const { phaseRemove } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_REMOVE, + state: STATE_FOR_REMOVE, + }); + + await expect(phaseRemove([], tmpDir)).rejects.toThrow('phase number required'); + }); + + it('updates ROADMAP.md by removing phase section and renumbering', async () => { + const { phaseRemove } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_REMOVE, + state: STATE_FOR_REMOVE, + phases: ['05-auth', '06-dashboard', '07-api'], + }); + + await phaseRemove(['6'], tmpDir); + + const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + // Phase 6 section should be removed + expect(roadmap).not.toContain('### Phase 6: Dashboard'); + // Phase 7 should be renumbered to 6 + expect(roadmap).toContain('### Phase 6: API'); + // Plan references should be renumbered + expect(roadmap).toContain('06-01'); + expect(roadmap).not.toContain('07-01'); + }); + + it('decrements total_phases in STATE.md frontmatter', async () => { + const { phaseRemove } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_REMOVE, + state: STATE_FOR_REMOVE, + phases: ['05-auth', '06-dashboard', '07-api'], + }); + + await phaseRemove(['6'], tmpDir); + + const stateContent = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + // total_phases should be decremented from 7 to 6 + expect(stateContent).toMatch(/total_phases:\s*6/); + }); +}); + +// ─── phaseComplete ───────────────────────────────────────────────────────── + +const ROADMAP_FOR_COMPLETE = `# Roadmap + +
+v1.0 (shipped) + +### Phase 1: Old Phase + +**Goal:** Shipped already +**Plans:** 2 plans + +
+ +## Current Milestone: v3.0 SDK-First Migration + +| Phase | Plans | Status | Completed | +|-------|-------|--------|-----------| +| 9. | 3/3 | Complete | 2026-04-01 | +| 10. | 0/3 | In Progress | | +| 11. | 0/2 | Not Started | | + +- [x] Phase 9: Foundation (completed 2026-04-01) +- [ ] Phase 10: Read-Only Queries +- [ ] Phase 11: Final Phase + +### Phase 9: Foundation + +**Goal:** Build foundation +**Requirements**: FOUND-01, FOUND-02 +**Depends on:** Phase 8 +**Plans:** 3/3 plans complete + +Plans: +- [x] 09-01 (Foundation setup) +- [x] 09-02 (Foundation core) +- [x] 09-03 (Foundation tests) + +### Phase 10: Read-Only Queries + +**Goal:** Port queries +**Requirements**: QUERY-01 +**Depends on:** Phase 9 +**Plans:** 3 plans + +Plans: +- [x] 10-01 (Query setup) +- [x] 10-02 (Query core) +- [ ] 10-03 (Query tests) + +### Phase 11: Final Phase + +**Goal:** Final work +**Requirements**: FINAL-01 +**Depends on:** Phase 10 +**Plans:** 2 plans + +Plans: +- [ ] 11-01 (Final setup) +- [ ] 11-02 (Final complete) + +--- +*Last updated: 2026-04-08* +`; + +const STATE_FOR_COMPLETE = `--- +gsd_state_version: 1.0 +milestone: v3.0 +milestone_name: SDK-First Migration +status: executing +progress: + total_phases: 3 + completed_phases: 1 + total_plans: 8 + completed_plans: 5 + percent: 33 +--- + +# Project State + +## Current Position + +Phase: 10 of 3 (Read-Only Queries) — EXECUTING +Plan: 3 of 3 +Status: Executing Phase 10 +Last activity: 2026-04-08 + +## Performance Metrics + +**Velocity:** + +- Total plans completed: 3 +- Average duration: -- +- Total execution time: 0 hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| 9 | 3 | - | - | + +## Session Continuity + +Last session: 2026-04-08T10:00:00.000Z +Stopped at: Completed 10-03-PLAN.md +`; + +const REQUIREMENTS_FOR_COMPLETE = `# Requirements + +## Checklist + +- [x] **FOUND-01** Foundation setup +- [x] **FOUND-02** Foundation core +- [ ] **QUERY-01** Query implementation +- [ ] **FINAL-01** Final work + +## Traceability + +| Requirement | Phase | Status | +|-------------|-------|--------| +| FOUND-01 | Phase 9 | Complete | +| FOUND-02 | Phase 9 | Complete | +| QUERY-01 | Phase 10 | In Progress | +| FINAL-01 | Phase 11 | Pending | +`; + +describe('phaseComplete', () => { + it('marks phase checkbox, updates progress table, and plan count in ROADMAP.md', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_COMPLETE, + state: STATE_FOR_COMPLETE, + phases: ['09-foundation', '10-read-only-queries', '11-final-phase'], + }); + // Create PLAN and SUMMARY files for phase 10 + const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries'); + await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan1', 'utf-8'); + await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan2', 'utf-8'); + await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan3', 'utf-8'); + await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary1', 'utf-8'); + await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary2', 'utf-8'); + await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary3', 'utf-8'); + // Create REQUIREMENTS.md + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8'); + + const result = await phaseComplete(['10'], tmpDir); + const data = result.data as Record; + + expect(data.completed_phase).toBe('10'); + expect(data.plans_executed).toBe('3/3'); + expect(data.is_last_phase).toBe(false); + expect(data.next_phase).toBeTruthy(); + expect(data.roadmap_updated).toBe(true); + + // Check ROADMAP.md updates + const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'); + // Checkbox should be marked + expect(roadmap).toMatch(/\[x\].*Phase 10/); + // Progress table should show Complete + expect(roadmap).toMatch(/10\.?\s*\|.*3\/3.*\|.*Complete/i); + // Plan count in section should be updated + expect(roadmap).toContain('3/3 plans complete'); + // Plan checkboxes should be [x] + expect(roadmap).toMatch(/\[x\] 10-01/); + expect(roadmap).toMatch(/\[x\] 10-02/); + expect(roadmap).toMatch(/\[x\] 10-03/); + }); + + it('updates REQUIREMENTS.md checkboxes and traceability table', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_COMPLETE, + state: STATE_FOR_COMPLETE, + phases: ['09-foundation', '10-read-only-queries', '11-final-phase'], + }); + const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries'); + await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan1', 'utf-8'); + await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan2', 'utf-8'); + await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan3', 'utf-8'); + await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary1', 'utf-8'); + await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary2', 'utf-8'); + await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary3', 'utf-8'); + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8'); + + await phaseComplete(['10'], tmpDir); + + const req = await readFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8'); + // QUERY-01 checkbox should be marked + expect(req).toMatch(/\[x\].*\*\*QUERY-01\*\*/); + // Traceability should show Complete for QUERY-01 + expect(req).toMatch(/QUERY-01\s*\|.*\|\s*Complete\s*\|/); + // FINAL-01 should remain Pending + expect(req).toMatch(/FINAL-01\s*\|.*\|\s*Pending\s*\|/); + }); + + it('updates STATE.md fields: current phase, status, completed phases, percent', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_COMPLETE, + state: STATE_FOR_COMPLETE, + phases: ['09-foundation', '10-read-only-queries', '11-final-phase'], + }); + const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries'); + await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8'); + await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary', 'utf-8'); + await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary', 'utf-8'); + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8'); + + await phaseComplete(['10'], tmpDir); + + const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + // Phase should advance to 11 + expect(state).toMatch(/Phase:\s*11/); + // Status should indicate ready to plan + expect(state).toMatch(/Status:\s*Ready to plan/); + // Completed phases should be incremented from 1 to 2 + expect(state).toMatch(/completed_phases:\s*2/); + // Percent should be recalculated (2/3 = 67%) + expect(state).toMatch(/percent:\s*67/); + }); + + it('detects next phase from filesystem, falls back to ROADMAP.md', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_COMPLETE, + state: STATE_FOR_COMPLETE, + phases: ['09-foundation', '10-read-only-queries', '11-final-phase'], + }); + const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries'); + await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8'); + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8'); + + const result = await phaseComplete(['10'], tmpDir); + const data = result.data as Record; + + // Next phase should be 11 (from filesystem) + expect(data.next_phase).toBe('11'); + expect(data.is_last_phase).toBe(false); + }); + + it('sets is_last_phase when completing the final phase', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_COMPLETE, + state: STATE_FOR_COMPLETE, + phases: ['09-foundation', '10-read-only-queries', '11-final-phase'], + }); + const p11Dir = join(tmpDir, '.planning', 'phases', '11-final-phase'); + await writeFile(join(p11Dir, '11-01-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p11Dir, '11-01-SUMMARY.md'), 'summary', 'utf-8'); + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8'); + + const result = await phaseComplete(['11'], tmpDir); + const data = result.data as Record; + + expect(data.is_last_phase).toBe(true); + expect(data.next_phase).toBeNull(); + + // State should show milestone complete + const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + expect(state).toMatch(/Status:\s*Milestone complete/); + }); + + it('collects UAT/VERIFICATION warnings without blocking', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_COMPLETE, + state: STATE_FOR_COMPLETE, + phases: ['09-foundation', '10-read-only-queries', '11-final-phase'], + }); + const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries'); + await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8'); + // Create UAT file with pending status + await writeFile(join(p10Dir, '10-UAT.md'), '---\nresult: pending\n---\nPending tests', 'utf-8'); + // Create VERIFICATION file with gaps + await writeFile(join(p10Dir, '10-VERIFICATION.md'), '---\nstatus: gaps_found\n---\nGaps', 'utf-8'); + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8'); + + const result = await phaseComplete(['10'], tmpDir); + const data = result.data as Record; + + // Should complete despite warnings + expect(data.completed_phase).toBe('10'); + expect(data.has_warnings).toBe(true); + const warnings = data.warnings as string[]; + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.some(w => w.includes('pending'))).toBe(true); + expect(warnings.some(w => w.includes('gaps'))).toBe(true); + }); + + it('throws GSDError for missing phase', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_COMPLETE, + state: STATE_FOR_COMPLETE, + phases: ['09-foundation'], + }); + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8'); + + await expect(phaseComplete(['99'], tmpDir)).rejects.toThrow('Phase 99 not found'); + }); + + it('updates performance metrics table in STATE.md', async () => { + const { phaseComplete } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + roadmap: ROADMAP_FOR_COMPLETE, + state: STATE_FOR_COMPLETE, + phases: ['09-foundation', '10-read-only-queries', '11-final-phase'], + }); + const p10Dir = join(tmpDir, '.planning', 'phases', '10-read-only-queries'); + await writeFile(join(p10Dir, '10-01-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p10Dir, '10-02-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p10Dir, '10-03-PLAN.md'), 'plan', 'utf-8'); + await writeFile(join(p10Dir, '10-01-SUMMARY.md'), 'summary', 'utf-8'); + await writeFile(join(p10Dir, '10-02-SUMMARY.md'), 'summary', 'utf-8'); + await writeFile(join(p10Dir, '10-03-SUMMARY.md'), 'summary', 'utf-8'); + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), REQUIREMENTS_FOR_COMPLETE, 'utf-8'); + + await phaseComplete(['10'], tmpDir); + + const state = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + // Total plans completed should be incremented: 3 + 3 = 6 + expect(state).toContain('Total plans completed: 6'); + // By Phase table should have a row for phase 10 + expect(state).toMatch(/\|\s*10\s*\|\s*3\s*\|/); + }); +}); + +// ─── phasesClear ──────────────────────────────────────────────────────────── + +describe('phasesClear', () => { + it('throws GSDError without --confirm flag, showing count', async () => { + const { phasesClear } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation', '10-read-only-queries', '999.1-backlog'], + }); + + // Should throw with count of dirs to delete (2, not 3 since 999.1 is excluded) + await expect(phasesClear([], tmpDir)).rejects.toThrow(/2 phase director/); + }); + + it('deletes all dirs except 999.x with --confirm', async () => { + const { phasesClear } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation', '10-read-only-queries', '999.1-backlog'], + }); + + const result = await phasesClear(['--confirm'], tmpDir); + const data = result.data as Record; + + expect(data.cleared).toBe(2); + + // Verify filesystem + const phasesDir = join(tmpDir, '.planning', 'phases'); + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name); + expect(dirNames.length).toBe(1); + expect(dirNames[0]).toContain('999'); + }); + + it('returns 0 cleared when phases dir is empty', async () => { + const { phasesClear } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { phases: [] }); + + const result = await phasesClear(['--confirm'], tmpDir); + const data = result.data as Record; + expect(data.cleared).toBe(0); + }); +}); + +// ─── phasesArchive ────────────────────────────────────────────────────────── + +describe('phasesArchive', () => { + it('moves milestone phase dirs to milestones/{version}-phases/', async () => { + const { phasesArchive } = await import('./phase-lifecycle.js'); + await setupTestProject(tmpDir, { + phases: ['09-foundation', '10-read-only-queries'], + }); + + const result = await phasesArchive(['v3.0'], tmpDir); + const data = result.data as Record; + + expect(data.version).toBe('v3.0'); + expect((data.archived as number)).toBeGreaterThan(0); + + // Verify archive directory exists + const archiveDir = join(tmpDir, '.planning', 'milestones', 'v3.0-phases'); + expect(existsSync(archiveDir)).toBe(true); + + // Verify dirs were moved + const archivedEntries = await readdir(archiveDir, { withFileTypes: true }); + const archivedDirs = archivedEntries.filter(e => e.isDirectory()).map(e => e.name); + expect(archivedDirs.length).toBeGreaterThan(0); + + // Original dirs should be gone + const phasesDir = join(tmpDir, '.planning', 'phases'); + const remaining = await readdir(phasesDir, { withFileTypes: true }); + const remainingDirs = remaining.filter(e => e.isDirectory()).map(e => e.name); + expect(remainingDirs.length).toBe(0); + }); +}); + +// ─── Registry integration ────────────────────────────────────────────────── + +describe('lifecycle handlers in registry', () => { + it('registers all 7 lifecycle handlers with dot notation', async () => { + const { createRegistry } = await import('./index.js'); + const registry = createRegistry(); + + const commands = [ + 'phase.add', 'phase.insert', 'phase.remove', 'phase.complete', + 'phase.scaffold', 'phases.clear', 'phases.archive', + ]; + + for (const cmd of commands) { + const handler = registry.getHandler(cmd); + expect(handler, `${cmd} should be registered`).toBeDefined(); + } + }); + + it('registers space-delimited aliases', async () => { + const { createRegistry } = await import('./index.js'); + const registry = createRegistry(); + + const commands = [ + 'phase add', 'phase insert', 'phase remove', 'phase complete', + 'phase scaffold', 'phases clear', 'phases archive', + ]; + + for (const cmd of commands) { + const handler = registry.getHandler(cmd); + expect(handler, `${cmd} should be registered`).toBeDefined(); + } + }); +}); diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts new file mode 100644 index 000000000..fcb2584cc --- /dev/null +++ b/sdk/src/query/phase-lifecycle.ts @@ -0,0 +1,1433 @@ +/** + * Phase lifecycle handlers — add, insert, scaffold operations. + * + * Ported from get-shit-done/bin/lib/phase.cjs and commands.cjs. + * Provides phaseAdd (append phase), phaseInsert (decimal phase insertion), + * and phaseScaffold (template file/directory creation). + * + * Shared helpers replaceInCurrentMilestone and readModifyWriteRoadmapMd + * are exported for use by downstream handlers (phaseComplete in Plan 03). + * + * @example + * ```typescript + * import { phaseAdd, phaseInsert, phaseScaffold } from './phase-lifecycle.js'; + * + * await phaseAdd(['New Feature'], '/project'); + * await phaseInsert(['10', 'Urgent Fix'], '/project'); + * await phaseScaffold(['context', '9'], '/project'); + * ``` + */ + +import { readFile, writeFile, mkdir, readdir, rename, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { + escapeRegex, + normalizePhaseName, + comparePhaseNum, + phaseTokenMatches, + toPosixPath, + planningPaths, + stateExtractField, +} from './helpers.js'; +import { extractCurrentMilestone } from './roadmap.js'; +import { getMilestonePhaseFilter } from './state.js'; +import { acquireStateLock, releaseStateLock, stateReplaceField } from './state-mutation.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Null byte validation ──────────────────────────────────────────────── + +/** Reject strings containing null bytes (path traversal defense). */ +function assertNoNullBytes(value: string, label: string): void { + if (value.includes('\0')) { + throw new GSDError(`${label} contains null byte`, ErrorClassification.Validation); + } +} + +// ─── Slug generation (inline) ──────────────────────────────────────────── + +/** Generate kebab-case slug from description. Port of generateSlugInternal. */ +function generateSlugInternal(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 60); +} + +// ─── replaceInCurrentMilestone ────────────────────────────────────────── + +/** + * Replace a pattern only in the current milestone section of ROADMAP.md. + * + * Port of replaceInCurrentMilestone from core.cjs line 1197-1206. + * If no `` blocks exist, replaces in the entire content. + * Otherwise, only replaces in content after the last `` close tag. + * + * @param content - Full ROADMAP.md content + * @param pattern - Regex or string pattern to match + * @param replacement - Replacement string + * @returns Modified content + */ +export function replaceInCurrentMilestone( + content: string, + pattern: string | RegExp, + replacement: string, +): string { + const lastDetailsClose = content.lastIndexOf(''); + if (lastDetailsClose === -1) { + return content.replace(pattern, replacement); + } + const offset = lastDetailsClose + ''.length; + const before = content.slice(0, offset); + const after = content.slice(offset); + return before + after.replace(pattern, replacement); +} + +// ─── readModifyWriteRoadmapMd ─────────────────────────────────────────── + +/** + * Atomic read-modify-write for ROADMAP.md. + * + * Holds a lockfile across the entire read -> transform -> write cycle. + * Uses the same acquireStateLock/releaseStateLock mechanism as STATE.md + * but with a ROADMAP.md-specific lock path. + * + * @param projectDir - Project root directory + * @param modifier - Function to transform ROADMAP.md content + * @returns The final written content + */ +export async function readModifyWriteRoadmapMd( + projectDir: string, + modifier: (content: string) => string | Promise, +): Promise { + const roadmapPath = planningPaths(projectDir).roadmap; + const lockPath = await acquireStateLock(roadmapPath); + try { + let content: string; + try { + content = await readFile(roadmapPath, 'utf-8'); + } catch { + content = ''; + } + const modified = await modifier(content); + await writeFile(roadmapPath, modified, 'utf-8'); + return modified; + } finally { + await releaseStateLock(lockPath); + } +} + +// ─── phaseAdd handler ─────────────────────────────────────────────────── + +/** + * Query handler for phase.add. + * + * Port of cmdPhaseAdd from phase.cjs lines 312-392. + * Creates a new phase directory with .gitkeep, appends a phase section + * to ROADMAP.md before the last "---" separator. + * + * @param args - args[0]: description (required), args[1]: customId (optional) + * @param projectDir - Project root directory + * @returns QueryResult with { phase_number, padded, name, slug, directory, naming_mode } + */ +export const phaseAdd: QueryHandler = async (args, projectDir) => { + const description = args[0]; + if (!description) { + throw new GSDError('description required for phase add', ErrorClassification.Validation); + } + assertNoNullBytes(description, 'description'); + + const configPath = planningPaths(projectDir).config; + let config: Record = {}; + try { + config = JSON.parse(await readFile(configPath, 'utf-8')); + } catch { /* use defaults */ } + + const slug = generateSlugInternal(description); + const customId = args[1] || null; + + // Optional project code prefix (e.g., 'CK' -> 'CK-01-foundation') + const projectCode = (config.project_code as string) || ''; + const prefix = projectCode ? `${projectCode}-` : ''; + + let newPhaseId: number | string = ''; + let dirName = ''; + + await readModifyWriteRoadmapMd(projectDir, async (rawContent) => { + const content = await extractCurrentMilestone(rawContent, projectDir); + + if (customId || config.phase_naming === 'custom') { + // Custom phase naming + newPhaseId = customId || slug.toUpperCase().replace(/-/g, '_'); + if (!newPhaseId) { + throw new GSDError('--id required when phase_naming is "custom"', ErrorClassification.Validation); + } + dirName = `${prefix}${newPhaseId}-${slug}`; + } else { + // Sequential mode: find highest integer phase number (in current milestone only) + // Skip 999.x backlog phases — they live outside the active sequence + const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi; + let maxPhase = 0; + let m: RegExpExecArray | null; + while ((m = phasePattern.exec(content)) !== null) { + const num = parseInt(m[1], 10); + if (num >= 999) continue; // backlog phases use 999.x numbering + if (num > maxPhase) maxPhase = num; + } + + newPhaseId = maxPhase + 1; + const paddedNum = String(newPhaseId).padStart(2, '0'); + dirName = `${prefix}${paddedNum}-${slug}`; + } + + const dirPath = join(planningPaths(projectDir).phases, dirName); + + // Create directory with .gitkeep so git tracks empty folders + await mkdir(dirPath, { recursive: true }); + await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8'); + + // Build phase entry + const dependsOn = config.phase_naming === 'custom' + ? '' + : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`; + const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${newPhaseId} to break down)\n`; + + // Find insertion point: before last "---" or at end + const lastSeparator = rawContent.lastIndexOf('\n---'); + if (lastSeparator > 0) { + return rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator); + } + return rawContent + phaseEntry; + }); + + if (!dirName) { + throw new GSDError('Phase directory name was not computed', ErrorClassification.Execution); + } + if (newPhaseId === '') { + throw new GSDError('Phase ID was not computed', ErrorClassification.Execution); + } + + const result = { + phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId), + padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId), + name: description, + slug, + directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir).phases, dirName))), + naming_mode: config.phase_naming || 'sequential', + }; + + return { data: result }; +}; + +// ─── phaseInsert handler ──────────────────────────────────────────────── + +/** + * Query handler for phase.insert. + * + * Port of cmdPhaseInsert from phase.cjs lines 394-492. + * Creates a decimal phase directory after a target phase, inserting + * the phase section in ROADMAP.md after the target. + * + * @param args - args[0]: afterPhase (required), args[1]: description (required) + * @param projectDir - Project root directory + * @returns QueryResult with { phase_number, after_phase, name, slug, directory } + */ +export const phaseInsert: QueryHandler = async (args, projectDir) => { + const afterPhase = args[0]; + const description = args[1]; + + if (!afterPhase || !description) { + throw new GSDError('after-phase and description required for phase insert', ErrorClassification.Validation); + } + assertNoNullBytes(afterPhase, 'afterPhase'); + assertNoNullBytes(description, 'description'); + + const slug = generateSlugInternal(description); + let decimalPhase = ''; + let dirName = ''; + + await readModifyWriteRoadmapMd(projectDir, async (rawContent) => { + const content = await extractCurrentMilestone(rawContent, projectDir); + + // Normalize input then strip leading zeros for flexible matching + const normalizedAfter = normalizePhaseName(afterPhase); + const unpadded = normalizedAfter.replace(/^0+/, ''); + const afterPhaseEscaped = unpadded.replace(/\./g, '\\.'); + const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i'); + if (!targetPattern.test(content)) { + throw new GSDError(`Phase ${afterPhase} not found in ROADMAP.md`, ErrorClassification.Validation); + } + + // Calculate next decimal by scanning both directories AND ROADMAP.md entries + const phasesDir = planningPaths(projectDir).phases; + const normalizedBase = normalizePhaseName(afterPhase); + const decimalSet = new Set(); + + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`); + for (const dir of dirs) { + const dm = dir.match(decimalPattern); + if (dm) decimalSet.add(parseInt(dm[1], 10)); + } + } catch { /* intentionally empty */ } + + // Also scan ROADMAP.md content for decimal entries + const rmPhasePattern = new RegExp( + `#{2,4}\\s*Phase\\s+0*${escapeRegex(normalizedBase)}\\.(\\d+)\\s*:`, 'gi' + ); + let rmMatch: RegExpExecArray | null; + while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) { + decimalSet.add(parseInt(rmMatch[1], 10)); + } + + const nextDecimal = decimalSet.size === 0 ? 1 : Math.max(...decimalSet) + 1; + decimalPhase = `${normalizedBase}.${nextDecimal}`; + + // Optional project code prefix + let insertConfig: Record = {}; + try { + insertConfig = JSON.parse(await readFile(planningPaths(projectDir).config, 'utf-8')); + } catch { /* use defaults */ } + const projectCode = (insertConfig.project_code as string) || ''; + const pfx = projectCode ? `${projectCode}-` : ''; + dirName = `${pfx}${decimalPhase}-${slug}`; + const dirPath = join(phasesDir, dirName); + + // Create directory with .gitkeep + await mkdir(dirPath, { recursive: true }); + await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8'); + + // Build phase entry + const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${decimalPhase} to break down)\n`; + + // Insert after the target phase section + const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i'); + const headerMatch = rawContent.match(headerPattern); + if (!headerMatch) { + throw new GSDError(`Could not find Phase ${afterPhase} header`, ErrorClassification.Execution); + } + + const headerIdx = rawContent.indexOf(headerMatch[0]); + const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length); + const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i); + + let insertIdx: number; + if (nextPhaseMatch && nextPhaseMatch.index !== undefined) { + insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index; + } else { + insertIdx = rawContent.length; + } + + return rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx); + }); + + if (!decimalPhase) { + throw new GSDError('Decimal phase was not computed', ErrorClassification.Execution); + } + if (!dirName) { + throw new GSDError('Phase directory name was not computed', ErrorClassification.Execution); + } + + const result = { + phase_number: decimalPhase, + after_phase: afterPhase, + name: description, + slug, + directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir).phases, dirName))), + }; + + return { data: result }; +}; + +// ─── phaseScaffold handler ────────────────────────────────────────────── + +/** + * Internal helper: find phase directory matching a phase identifier. + * + * Reuses the same logic as findPhase handler but returns just the directory info. + */ +async function findPhaseDir( + projectDir: string, + phase: string, +): Promise<{ dirPath: string; dirName: string; phaseName: string | null } | null> { + const phasesDir = planningPaths(projectDir).phases; + const normalized = normalizePhaseName(phase); + + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const match = dirs.find(d => phaseTokenMatches(d, normalized)); + if (!match) return null; + + // Extract phase name from directory + const dirMatch = match.match(/^(?:[A-Z]{1,6}-)?\d+[A-Z]?(?:\.\d+)*-(.+)/i); + const phaseName = dirMatch ? dirMatch[1] : null; + + return { + dirPath: join(phasesDir, match), + dirName: match, + phaseName, + }; + } catch { + return null; + } +} + +/** + * Query handler for phase.scaffold. + * + * Port of cmdScaffold from commands.cjs lines 750-806. + * Creates template files (context, uat, verification) or phase directories. + * + * @param args - args[0]: type (required), args[1]: phase (required), args[2]: name (optional) + * @param projectDir - Project root directory + * @returns QueryResult with { created, path } or { created: false, reason: 'already_exists' } + */ +export const phaseScaffold: QueryHandler = async (args, projectDir) => { + const type = args[0]; + const phase = args[1]; + const name = args[2] || undefined; + + if (!type) { + throw new GSDError('type required for scaffold', ErrorClassification.Validation); + } + + const validTypes = new Set(['context', 'uat', 'verification', 'phase-dir']); + if (!validTypes.has(type)) { + throw new GSDError( + `Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`, + ErrorClassification.Validation, + ); + } + + if (phase) { + assertNoNullBytes(phase, 'phase'); + } + if (name) { + assertNoNullBytes(name, 'name'); + } + + const padded = phase ? normalizePhaseName(phase) : '00'; + const today = new Date().toISOString().split('T')[0]; + + // Handle phase-dir type separately + if (type === 'phase-dir') { + if (!phase || !name) { + throw new GSDError('phase and name required for phase-dir scaffold', ErrorClassification.Validation); + } + const slug = generateSlugInternal(name); + const dirNameNew = `${padded}-${slug}`; + const phasesParent = planningPaths(projectDir).phases; + await mkdir(phasesParent, { recursive: true }); + const dirPath = join(phasesParent, dirNameNew); + await mkdir(dirPath, { recursive: true }); + await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8'); + return { + data: { + created: true, + directory: toPosixPath(relative(projectDir, dirPath)), + path: dirPath, + }, + }; + } + + // For context/uat/verification types, find the phase directory + const phaseInfo = phase ? await findPhaseDir(projectDir, phase) : null; + if (phase && !phaseInfo) { + throw new GSDError(`Phase ${phase} directory not found`, ErrorClassification.Blocked); + } + + const phaseDir = phaseInfo!.dirPath; + const phaseName = name || phaseInfo?.phaseName || 'Unnamed'; + + let filePath: string; + let content: string; + + switch (type) { + case 'context': { + filePath = join(phaseDir, `${padded}-CONTEXT.md`); + content = `---\nphase: "${padded}"\nname: "${phaseName}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${phaseName} — Context\n\n## Decisions\n\n_Decisions will be captured during /gsd-discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`; + break; + } + case 'uat': { + filePath = join(phaseDir, `${padded}-UAT.md`); + content = `---\nphase: "${padded}"\nname: "${phaseName}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${phaseName} — User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`; + break; + } + case 'verification': { + filePath = join(phaseDir, `${padded}-VERIFICATION.md`); + content = `---\nphase: "${padded}"\nname: "${phaseName}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${phaseName} — Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`; + break; + } + default: + throw new GSDError(`Unknown scaffold type: ${type}`, ErrorClassification.Validation); + } + + // Check if file already exists + if (existsSync(filePath)) { + return { + data: { + created: false, + reason: 'already_exists', + path: filePath, + }, + }; + } + + await writeFile(filePath, content, 'utf-8'); + const relPath = toPosixPath(relative(projectDir, filePath)); + return { data: { created: true, path: relPath } }; +}; + +// ─── renameDecimalPhases ─────────────────────────────────────────────── + +/** + * Renumber sibling decimal phases after a decimal phase is removed. + * + * Port of renameDecimalPhases from phase.cjs lines 499-524. + * e.g. removing 06.2 -> 06.3 becomes 06.2, 06.4 becomes 06.3, etc. + * Renames directories AND files inside them that contain the old phase ID. + * + * CRITICAL: Sorted in DESCENDING order to avoid rename conflicts. + * + * @param phasesDir - Path to the phases directory + * @param baseInt - The integer part of the decimal phase (e.g. "06") + * @param removedDecimal - The decimal part that was removed (e.g. 2 for 06.2) + * @returns { renamedDirs, renamedFiles } + */ +async function renameDecimalPhases( + phasesDir: string, + baseInt: string, + removedDecimal: number, +): Promise<{ renamedDirs: Array<{ from: string; to: string }>; renamedFiles: Array<{ from: string; to: string }> }> { + const renamedDirs: Array<{ from: string; to: string }> = []; + const renamedFiles: Array<{ from: string; to: string }> = []; + + const decPattern = new RegExp(`^${escapeRegex(baseInt)}\\.(\\d+)-(.+)$`); + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + + const toRename = dirs + .map(dir => { + const m = dir.match(decPattern); + return m ? { dir, oldDecimal: parseInt(m[1], 10), slug: m[2] } : null; + }) + .filter((item): item is NonNullable => item !== null && item.oldDecimal > removedDecimal) + .sort((a, b) => b.oldDecimal - a.oldDecimal); // DESCENDING to avoid conflicts + + for (const item of toRename) { + const newDecimal = item.oldDecimal - 1; + const oldPhaseId = `${baseInt}.${item.oldDecimal}`; + const newPhaseId = `${baseInt}.${newDecimal}`; + const newDirName = `${baseInt}.${newDecimal}-${item.slug}`; + + await rename(join(phasesDir, item.dir), join(phasesDir, newDirName)); + renamedDirs.push({ from: item.dir, to: newDirName }); + + // Rename files inside that contain the old phase ID + const files = await readdir(join(phasesDir, newDirName)); + for (const f of files) { + if (f.includes(oldPhaseId)) { + const newFileName = f.replace(oldPhaseId, newPhaseId); + await rename(join(phasesDir, newDirName, f), join(phasesDir, newDirName, newFileName)); + renamedFiles.push({ from: f, to: newFileName }); + } + } + } + + return { renamedDirs, renamedFiles }; +} + +// ─── renameIntegerPhases ─────────────────────────────────────────────── + +/** + * Renumber all integer phases after a removed integer phase. + * + * Port of renameIntegerPhases from phase.cjs lines 531-564. + * e.g. removing phase 5 -> phase 6 becomes 5, phase 7 becomes 6, etc. + * Handles letter suffixes (12A) and decimals (6.1). + * + * CRITICAL: Sorted in DESCENDING order to avoid rename conflicts. + * + * @param phasesDir - Path to the phases directory + * @param removedInt - The integer phase number that was removed + * @returns { renamedDirs, renamedFiles } + */ +async function renameIntegerPhases( + phasesDir: string, + removedInt: number, +): Promise<{ renamedDirs: Array<{ from: string; to: string }>; renamedFiles: Array<{ from: string; to: string }> }> { + const renamedDirs: Array<{ from: string; to: string }> = []; + const renamedFiles: Array<{ from: string; to: string }> = []; + + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + + const toRename = dirs + .map(dir => { + const m = dir.match(/^(\d+)([A-Z])?(?:\.(\d+))?-(.+)$/i); + if (!m) return null; + const dirInt = parseInt(m[1], 10); + if (dirInt <= removedInt) return null; + return { + dir, + oldInt: dirInt, + letter: m[2] ? m[2].toUpperCase() : '', + decimal: m[3] !== undefined ? parseInt(m[3], 10) : null, + slug: m[4], + }; + }) + .filter((item): item is NonNullable => item !== null) + .sort((a, b) => a.oldInt !== b.oldInt + ? b.oldInt - a.oldInt + : (b.decimal ?? 0) - (a.decimal ?? 0)); // DESCENDING + + for (const item of toRename) { + const newInt = item.oldInt - 1; + const newPadded = String(newInt).padStart(2, '0'); + const oldPadded = String(item.oldInt).padStart(2, '0'); + const letterSuffix = item.letter || ''; + const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : ''; + const oldPrefix = `${oldPadded}${letterSuffix}${decimalSuffix}`; + const newPrefix = `${newPadded}${letterSuffix}${decimalSuffix}`; + const newDirName = `${newPrefix}-${item.slug}`; + + await rename(join(phasesDir, item.dir), join(phasesDir, newDirName)); + renamedDirs.push({ from: item.dir, to: newDirName }); + + // Rename files that start with the old prefix + const files = await readdir(join(phasesDir, newDirName)); + for (const f of files) { + if (f.startsWith(oldPrefix)) { + const newFileName = newPrefix + f.slice(oldPrefix.length); + await rename(join(phasesDir, newDirName, f), join(phasesDir, newDirName, newFileName)); + renamedFiles.push({ from: f, to: newFileName }); + } + } + } + + return { renamedDirs, renamedFiles }; +} + +// ─── updateRoadmapAfterPhaseRemoval ──────────────────────────────────── + +/** + * Remove a phase section from ROADMAP.md and renumber subsequent integer phases. + * + * Port of updateRoadmapAfterPhaseRemoval from phase.cjs lines 569-595. + * Uses readModifyWriteRoadmapMd for atomic writes. + * + * @param projectDir - Project root directory + * @param targetPhase - Phase identifier that was removed + * @param isDecimal - Whether the removed phase was a decimal phase + * @param removedInt - The integer part of the removed phase + */ +async function updateRoadmapAfterPhaseRemoval( + projectDir: string, + targetPhase: string, + isDecimal: boolean, + removedInt: number, +): Promise { + await readModifyWriteRoadmapMd(projectDir, (content) => { + const escaped = escapeRegex(targetPhase); + + // Remove the phase section (header + body until next phase header or end) + content = content.replace( + new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), + '', + ); + + // Remove checkbox lines referencing the phase + content = content.replace( + new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), + '', + ); + + // Remove table rows referencing the phase + content = content.replace( + new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), + '', + ); + + // For integer phase removal, renumber all subsequent phases in ROADMAP text + if (!isDecimal) { + const MAX_PHASE = 99; + for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) { + const newNum = oldNum - 1; + const oldStr = String(oldNum); + const newStr = String(newNum); + const oldPad = oldStr.padStart(2, '0'); + const newPad = newStr.padStart(2, '0'); + + // Renumber phase headers: ### Phase N: + content = content.replace( + new RegExp(`(#{2,4}\\s*Phase\\s+)${escapeRegex(oldStr)}(\\s*:)`, 'gi'), + `$1${newStr}$2`, + ); + + // Renumber inline Phase N references + content = content.replace( + new RegExp(`(Phase\\s+)${escapeRegex(oldStr)}([:\\s])`, 'g'), + `$1${newStr}$2`, + ); + + // Renumber padded plan references: 07-01 -> 06-01 + content = content.replace( + new RegExp(`${escapeRegex(oldPad)}-(\\d{2})`, 'g'), + `${newPad}-$1`, + ); + + // Renumber table row phase numbers: | 7. -> | 6. + content = content.replace( + new RegExp(`(\\|\\s*)${escapeRegex(oldStr)}\\.\\s`, 'g'), + `$1${newStr}. `, + ); + + // Renumber depends-on references + content = content.replace( + new RegExp(`(\\*\\*Depends on:\\*\\*\\s*Phase\\s+)${escapeRegex(oldStr)}\\b`, 'gi'), + `$1${newStr}`, + ); + } + } + + return content; + }); +} + +// ─── phaseRemove handler ─────────────────────────────────────────────── + +/** + * Query handler for phase.remove. + * + * Port of cmdPhaseRemove from phase.cjs lines 597-661. + * Deletes phase directory, renumbers subsequent phases on disk, + * updates ROADMAP.md (removes section + renumbers), and decrements + * STATE.md total_phases count. + * + * @param args - args[0]: targetPhase (required), args[1]: '--force' (optional) + * @param projectDir - Project root directory + * @returns QueryResult with { removed, directory_deleted, renamed_directories, renamed_files, roadmap_updated, state_updated } + */ +export const phaseRemove: QueryHandler = async (args, projectDir) => { + const targetPhase = args[0]; + if (!targetPhase) { + throw new GSDError('phase number required for phase remove', ErrorClassification.Validation); + } + assertNoNullBytes(targetPhase, 'targetPhase'); + + const paths = planningPaths(projectDir); + const phasesDir = paths.phases; + + if (!existsSync(paths.roadmap)) { + throw new GSDError('ROADMAP.md not found', ErrorClassification.Validation); + } + + const normalized = normalizePhaseName(targetPhase); + const isDecimal = targetPhase.includes('.'); + const force = args[1] === '--force'; + + // Find target directory + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const targetDir = dirs.find(d => phaseTokenMatches(d, normalized)) ?? null; + + // Guard against removing executed work + if (targetDir && !force) { + const files = await readdir(join(phasesDir, targetDir)); + const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + if (summaries.length > 0) { + throw new GSDError( + `Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`, + ErrorClassification.Validation, + ); + } + } + + // Delete directory + if (targetDir) { + await rm(join(phasesDir, targetDir), { recursive: true, force: true }); + } + + // Renumber subsequent phases on disk + let renamedDirs: Array<{ from: string; to: string }> = []; + let renamedFiles: Array<{ from: string; to: string }> = []; + try { + let renamed: { renamedDirs: Array<{ from: string; to: string }>; renamedFiles: Array<{ from: string; to: string }> }; + if (isDecimal) { + const parts = normalized.split('.'); + if (parts.length < 2 || !parts[1]) { + throw new GSDError(`Invalid decimal phase identifier: ${targetPhase}`, ErrorClassification.Validation); + } + const decimalPart = parseInt(parts[1], 10); + if (isNaN(decimalPart)) { + throw new GSDError(`Invalid decimal part in phase: ${targetPhase}`, ErrorClassification.Validation); + } + renamed = await renameDecimalPhases(phasesDir, parts[0], decimalPart); + } else { + renamed = await renameIntegerPhases(phasesDir, parseInt(normalized, 10)); + } + renamedDirs = renamed.renamedDirs; + renamedFiles = renamed.renamedFiles; + } catch { /* intentionally empty — renaming is best-effort */ } + + // Update ROADMAP.md + await updateRoadmapAfterPhaseRemoval(projectDir, targetPhase, isDecimal, parseInt(normalized, 10)); + + // Update STATE.md: decrement total_phases + let stateUpdated = false; + const statePath = paths.state; + if (existsSync(statePath)) { + const lockPath = await acquireStateLock(statePath); + try { + let stateContent = await readFile(statePath, 'utf-8'); + + // Decrement total_phases in frontmatter + const totalPhasesMatch = stateContent.match(/total_phases:\s*(\d+)/); + if (totalPhasesMatch) { + const oldTotal = parseInt(totalPhasesMatch[1], 10); + stateContent = stateContent.replace( + /total_phases:\s*\d+/, + `total_phases: ${oldTotal - 1}`, + ); + } + + // Decrement "of N" pattern in body (e.g., "Plan: 2 of 3") + const ofMatch = stateContent.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i); + if (ofMatch) { + stateContent = stateContent.replace( + /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i, + `$1${parseInt(ofMatch[2], 10) - 1}$3`, + ); + } + + // Also try stateReplaceField for "Total Phases" field + const totalRaw = stateExtractField(stateContent, 'Total Phases'); + if (totalRaw) { + const replaced = stateReplaceField(stateContent, 'Total Phases', String(parseInt(totalRaw, 10) - 1)); + if (replaced) stateContent = replaced; + } + + await writeFile(statePath, stateContent, 'utf-8'); + stateUpdated = true; + } finally { + await releaseStateLock(lockPath); + } + } + + return { + data: { + removed: targetPhase, + directory_deleted: targetDir, + renamed_directories: renamedDirs, + renamed_files: renamedFiles, + roadmap_updated: true, + state_updated: stateUpdated, + }, + }; +}; + +// ─── stateReplaceFieldWithFallback (inline) ──────────────────────────────── + +/** + * Replace a field with fallback field name support. + * + * Tries primary first, then fallback. Returns content unchanged if neither matches. + * Reimplemented here because state-mutation.ts keeps it module-private. + */ +function stateReplaceFieldWithFallback( + content: string, + primary: string, + fallback: string | null, + value: string, +): string { + let result = stateReplaceField(content, primary, value); + if (result) return result; + if (fallback) { + result = stateReplaceField(content, fallback, value); + if (result) return result; + } + return content; +} + +// ─── updatePerformanceMetricsSection ─────────────────────────────────────── + +/** + * Update the Performance Metrics section in STATE.md content. + * + * Port of updatePerformanceMetricsSection from state.cjs lines 1125-1156. + * Updates "Total plans completed" counter and upserts a row in the By Phase table. + * + * @param content - STATE.md content + * @param phaseNum - Phase number being completed + * @param planCount - Total number of plans in the phase + * @param summaryCount - Number of completed summaries + * @returns Modified content + */ +function updatePerformanceMetricsSection( + content: string, + phaseNum: string, + planCount: number, + summaryCount: number, +): string { + // Update Velocity: Total plans completed + const totalMatch = content.match(/Total plans completed:\s*(\d+|\[N\])/); + const prevTotal = totalMatch && totalMatch[1] !== '[N]' ? parseInt(totalMatch[1], 10) : 0; + const newTotal = prevTotal + summaryCount; + content = content.replace( + /Total plans completed:\s*(\d+|\[N\])/, + `Total plans completed: ${newTotal}`, + ); + + // Update By Phase table — upsert row for this phase + const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i; + const byPhaseMatch = content.match(byPhaseTablePattern); + if (byPhaseMatch) { + let tableBody = byPhaseMatch[2].trim(); + const phaseRowPattern = new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm'); + const newRow = `| ${phaseNum} | ${summaryCount} | - | - |`; + + if (phaseRowPattern.test(tableBody)) { + // Update existing row + tableBody = tableBody.replace(new RegExp(`^\\|\\s*${escapeRegex(String(phaseNum))}\\s*\\|.*$`, 'm'), newRow); + } else { + // Remove placeholder row and add new row + tableBody = tableBody.replace(/^\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|$/m, '').trim(); + tableBody = tableBody ? tableBody + '\n' + newRow : newRow; + } + + content = content.replace(byPhaseTablePattern, `$1${tableBody}\n`); + } + + return content; +} + +// ─── phaseComplete handler ──────────────────────────────────────────────── + +/** + * Query handler for phase.complete. + * + * Port of cmdPhaseComplete from phase.cjs lines 663-932. + * Marks a phase as done — updates ROADMAP.md (checkbox, progress table, + * plan count, plan checkboxes), REQUIREMENTS.md (requirement checkboxes, + * traceability table), and STATE.md (current phase, status, progress, + * performance metrics) atomically with per-file locks. + * + * @param args - args[0]: phaseNum (required) + * @param projectDir - Project root directory + * @returns QueryResult with completion details and warnings + */ +export const phaseComplete: QueryHandler = async (args, projectDir) => { + const phaseNum = args[0]; + if (!phaseNum) { + throw new GSDError('phase number required for phase complete', ErrorClassification.Validation); + } + assertNoNullBytes(phaseNum, 'phaseNum'); + + const paths = planningPaths(projectDir); + const today = new Date().toISOString().split('T')[0]; + + // Step A: Validate phase exists and get info + const phaseInfo = await findPhaseDir(projectDir, phaseNum); + if (!phaseInfo) { + throw new GSDError(`Phase ${phaseNum} not found`, ErrorClassification.Validation); + } + + const phaseDir = phaseInfo.dirPath; + let phaseFiles: string[]; + try { + phaseFiles = await readdir(phaseDir); + } catch { + phaseFiles = []; + } + + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const planCount = plans.length; + const summaryCount = summaries.length; + let requirementsUpdated = false; + + // Step B: Check for verification warnings (non-blocking) + const warnings: string[] = []; + for (const file of phaseFiles.filter(f => f.includes('-UAT') && f.endsWith('.md'))) { + try { + const content = await readFile(join(phaseDir, file), 'utf-8'); + if (/result: pending/.test(content)) warnings.push(`${file}: has pending tests`); + if (/result: blocked/.test(content)) warnings.push(`${file}: has blocked tests`); + if (/status: partial/.test(content)) warnings.push(`${file}: testing incomplete (partial)`); + if (/status: diagnosed/.test(content)) warnings.push(`${file}: has diagnosed gaps`); + } catch { /* intentionally empty */ } + } + for (const file of phaseFiles.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) { + try { + const content = await readFile(join(phaseDir, file), 'utf-8'); + if (/status: human_needed/.test(content)) warnings.push(`${file}: needs human verification`); + if (/status: gaps_found/.test(content)) warnings.push(`${file}: has unresolved gaps`); + } catch { /* intentionally empty */ } + } + + // Step C: Update ROADMAP.md atomically + if (existsSync(paths.roadmap)) { + await readModifyWriteRoadmapMd(projectDir, async (roadmapContent) => { + const phaseEscaped = escapeRegex(phaseNum); + + // Checkbox: - [ ] Phase N: -> - [x] Phase N: (...completed DATE) + const checkboxPattern = new RegExp( + `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, + 'i', + ); + roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`); + + // Progress table: update Status to Complete, add date + const tableRowPattern = new RegExp( + `^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, + 'im', + ); + roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => { + const cells = fullRow.split('|').slice(1, -1); + if (cells.length === 5) { + cells[2] = ` ${summaryCount}/${planCount} `; + cells[3] = ' Complete '; + cells[4] = ` ${today} `; + } else if (cells.length === 4) { + cells[1] = ` ${summaryCount}/${planCount} `; + cells[2] = ' Complete '; + cells[3] = ` ${today} `; + } + return '|' + cells.join('|') + '|'; + }); + + // Update plan count in phase section + const planCountPattern = new RegExp( + `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, + 'i', + ); + roadmapContent = replaceInCurrentMilestone( + roadmapContent, planCountPattern, + `$1${summaryCount}/${planCount} plans complete`, + ); + + // Mark completed plan checkboxes + for (const summaryFile of summaries) { + const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''); + if (!planId) continue; + const planEscaped = escapeRegex(planId); + const planCheckboxPattern = new RegExp( + `(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, + 'i', + ); + roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2'); + } + + // Step D: Update REQUIREMENTS.md + const reqPath = paths.requirements; + if (existsSync(reqPath)) { + const currentMilestoneRoadmap = await extractCurrentMilestone(roadmapContent, projectDir); + const phaseSectionMatch = currentMilestoneRoadmap.match( + new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i'), + ); + + const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : ''; + const reqMatch = sectionText.match(/\*\*Requirements\*?\*?:?\s*([^\n]+)/i); + + if (reqMatch) { + const reqIds = reqMatch[1].replace(/[[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean); + let reqContent = await readFile(reqPath, 'utf-8'); + + for (const reqId of reqIds) { + const reqEscaped = escapeRegex(reqId); + // Update checkbox: - [ ] **REQ-ID** -> - [x] **REQ-ID** + reqContent = reqContent.replace( + new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), + '$1x$2', + ); + // Update traceability table: Pending/In Progress -> Complete + reqContent = reqContent.replace( + new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'), + '$1 Complete $2', + ); + } + + await writeFile(reqPath, reqContent, 'utf-8'); + requirementsUpdated = true; + } + } + + return roadmapContent; + }); + } + + // Step E: Find next phase — filesystem first, then ROADMAP.md fallback + let nextPhaseNum: string | null = null; + let nextPhaseName: string | null = null; + let isLastPhase = true; + + try { + const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const entries = await readdir(paths.phases, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name) + .filter(isDirInMilestone) + .sort((a, b) => comparePhaseNum(a, b)); + + for (const dir of dirs) { + const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i); + if (dm) { + if (comparePhaseNum(dm[1], phaseNum) > 0) { + nextPhaseNum = dm[1]; + nextPhaseName = dm[2] || null; + isLastPhase = false; + break; + } + } + } + } catch { /* intentionally empty */ } + + // Fallback: check ROADMAP.md for phases not yet scaffolded + if (isLastPhase && existsSync(paths.roadmap)) { + try { + const roadmapContent = await readFile(paths.roadmap, 'utf-8'); + const roadmapForPhases = await extractCurrentMilestone(roadmapContent, projectDir); + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + let pm: RegExpExecArray | null; + while ((pm = phasePattern.exec(roadmapForPhases)) !== null) { + if (comparePhaseNum(pm[1], phaseNum) > 0) { + nextPhaseNum = pm[1]; + nextPhaseName = pm[2].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'); + isLastPhase = false; + break; + } + } + } catch { /* intentionally empty */ } + } + + // Step F: Update STATE.md atomically + let stateUpdated = false; + if (existsSync(paths.state)) { + const lockPath = await acquireStateLock(paths.state); + try { + const rawState = await readFile(paths.state, 'utf-8'); + + // Split into frontmatter and body to prevent field replacement from + // matching YAML keys (e.g., `status:` in frontmatter vs `Status:` in body). + // Pattern 11: Strip frontmatter before modifier (from Phase 11 decisions). + const fmMatch = rawState.match(/^(---\r?\n[\s\S]*?\r?\n---)\s*/); + let frontmatter = fmMatch ? fmMatch[1] : ''; + let body = fmMatch ? rawState.slice(fmMatch[0].length) : rawState; + + // Update Current Phase — preserve "X of Y (Name)" compound format + const phaseValue = nextPhaseNum || phaseNum; + const existingPhaseField = stateExtractField(body, 'Current Phase') + || stateExtractField(body, 'Phase'); + let newPhaseValue = String(phaseValue); + if (existingPhaseField) { + const totalMatch = existingPhaseField.match(/of\s+(\d+)/); + const nameMatch = existingPhaseField.match(/\(([^)]+)\)/); + if (totalMatch) { + const total = totalMatch[1]; + const nameStr = nextPhaseName + ? ` (${nextPhaseName.replace(/-/g, ' ')})` + : (nameMatch ? ` (${nameMatch[1]})` : ''); + newPhaseValue = `${phaseValue} of ${total}${nameStr}`; + } + } + body = stateReplaceFieldWithFallback(body, 'Current Phase', 'Phase', newPhaseValue); + + // Update Status + body = stateReplaceFieldWithFallback(body, 'Status', null, + isLastPhase ? 'Milestone complete' : 'Ready to plan'); + + // Update Current Plan + body = stateReplaceFieldWithFallback(body, 'Current Plan', 'Plan', 'Not started'); + + // Update Last Activity + body = stateReplaceFieldWithFallback(body, 'Last Activity', 'Last activity', today); + + // Update Performance Metrics section (operates on body only) + body = updatePerformanceMetricsSection(body, phaseNum, planCount, summaryCount); + + // Update frontmatter fields separately + // Increment completed_phases + const completedFmMatch = frontmatter.match(/completed_phases:\s*(\d+)/); + if (completedFmMatch) { + const newCompleted = parseInt(completedFmMatch[1], 10) + 1; + frontmatter = frontmatter.replace( + /completed_phases:\s*\d+/, + `completed_phases: ${newCompleted}`, + ); + + // Recalculate percent + const totalFmMatch = frontmatter.match(/total_phases:\s*(\d+)/); + if (totalFmMatch) { + const totalPhases = parseInt(totalFmMatch[1], 10); + if (totalPhases > 0) { + const newPercent = Math.round((newCompleted / totalPhases) * 100); + frontmatter = frontmatter.replace( + /(percent:\s*)\d+/, + `$1${newPercent}`, + ); + } + } + } + + // Update frontmatter status field + frontmatter = frontmatter.replace( + /status:\s*.+/, + `status: ${isLastPhase ? 'milestone_complete' : 'ready_to_plan'}`, + ); + + // Reassemble and write + const stateContent = frontmatter + '\n\n' + body; + await writeFile(paths.state, stateContent, 'utf-8'); + stateUpdated = true; + } finally { + await releaseStateLock(lockPath); + } + } + + // Step G: Return result + return { + data: { + completed_phase: phaseNum, + phase_name: phaseInfo.phaseName, + plans_executed: `${summaryCount}/${planCount}`, + next_phase: nextPhaseNum, + next_phase_name: nextPhaseName, + is_last_phase: isLastPhase, + date: today, + roadmap_updated: existsSync(paths.roadmap), + state_updated: stateUpdated, + requirements_updated: requirementsUpdated, + warnings, + has_warnings: warnings.length > 0, + }, + }; +}; + +// ─── phasesClear handler ────────────────────────────────────────────────── + +/** + * Query handler for phases.clear. + * + * Port of cmdPhasesClear from milestone.cjs lines 250-277. + * Deletes all phase directories except 999.x backlog phases. + * Requires --confirm flag to proceed. + * + * @param args - args[0]: '--confirm' to proceed (optional) + * @param projectDir - Project root directory + * @returns QueryResult with { cleared: count } + */ +export const phasesClear: QueryHandler = async (args, projectDir) => { + const phasesDir = planningPaths(projectDir).phases; + const confirm = Array.isArray(args) && args.includes('--confirm'); + let cleared = 0; + + if (existsSync(phasesDir)) { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory() && !/^999(?:\.|$)/.test(e.name)); + + if (dirs.length > 0 && !confirm) { + throw new GSDError( + `phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` + + `Pass --confirm to proceed.`, + ErrorClassification.Validation, + ); + } + + for (const entry of dirs) { + await rm(join(phasesDir, entry.name), { recursive: true, force: true }); + cleared++; + } + } + + return { data: { cleared } }; +}; + +// ─── phasesArchive handler ──────────────────────────────────────────────── + +/** + * Query handler for phases.archive. + * + * Extracted from cmdMilestoneComplete, milestone.cjs lines 210-227. + * Moves milestone phase directories to milestones/{version}-phases/. + * + * @param args - args[0]: version string (e.g., "v3.0") + * @param projectDir - Project root directory + * @returns QueryResult with { archived: count, version, archive_directory } + */ +export const phasesList: QueryHandler = async (args, projectDir) => { + const paths = planningPaths(projectDir); + const phasesDir = paths.phases; + + const typeIdx = args.indexOf('--type'); + const phaseIdx = args.indexOf('--phase'); + const type = typeIdx !== -1 ? args[typeIdx + 1] : null; + const phase = phaseIdx !== -1 ? args[phaseIdx + 1] : null; + const includeArchived = args.includes('--include-archived'); + + if (!existsSync(phasesDir)) { + return { data: type ? { files: [], count: 0 } : { directories: [], count: 0 } }; + } + + const entries = await readdir(phasesDir, { withFileTypes: true }); + let dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + + if (includeArchived) { + const milestonesDir = join(paths.planning, 'milestones'); + if (existsSync(milestonesDir)) { + const milestoneEntries = await readdir(milestonesDir, { withFileTypes: true }); + for (const mDir of milestoneEntries.filter(e => e.isDirectory() && e.name.endsWith('-phases'))) { + const milestone = mDir.name.replace(/-phases$/, ''); + const archivedEntries = await readdir(join(milestonesDir, mDir.name), { withFileTypes: true }); + for (const a of archivedEntries.filter(e => e.isDirectory())) { + dirs.push(`${a.name} [${milestone}]`); + } + } + } + } + + dirs.sort((a, b) => comparePhaseNum(a, b)); + + if (phase) { + const normalized = normalizePhaseName(phase); + const match = dirs.find(d => phaseTokenMatches(d, normalized)); + if (!match) { + return { data: { files: [], count: 0, phase_dir: null, error: 'Phase not found' } }; + } + dirs = [match]; + } + + if (type) { + const files: string[] = []; + for (const dir of dirs) { + const dirPath = join(phasesDir, dir); + if (!existsSync(dirPath)) continue; + const dirFiles = await readdir(dirPath); + let filtered: string[]; + if (type === 'plans') { + filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + } else if (type === 'summaries') { + filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + } else { + filtered = dirFiles; + } + files.push(...filtered.sort()); + } + return { data: { files, count: files.length, phase_dir: phase ? dirs[0]?.replace(/^\d+(?:\.\d+)*-?/, '') : null } }; + } + + return { data: { directories: dirs, count: dirs.length } }; +}; + +export const phaseNextDecimal: QueryHandler = async (args, projectDir) => { + const basePhase = args[0]; + if (!basePhase) { + throw new GSDError('base phase number required', ErrorClassification.Validation); + } + assertNoNullBytes(basePhase, 'basePhase'); + + const paths = planningPaths(projectDir); + const phasesDir = paths.phases; + const normalized = normalizePhaseName(basePhase); + const decimalSet = new Set(); + let baseExists = false; + + if (existsSync(phasesDir)) { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirNames = entries.filter(e => e.isDirectory()).map(e => e.name); + baseExists = dirNames.some(d => phaseTokenMatches(d, normalized)); + + const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`); + for (const dir of dirNames) { + const match = dir.match(dirPattern); + if (match) decimalSet.add(parseInt(match[1], 10)); + } + } + + const roadmapPath = paths.roadmap; + if (existsSync(roadmapPath)) { + try { + const roadmapContent = await readFile(roadmapPath, 'utf-8'); + const phasePattern = new RegExp( + `#{2,4}\\s*Phase\\s+0*${escapeRegex(normalized)}\\.(\\d+)\\s*:`, 'gi', + ); + let pm; + while ((pm = phasePattern.exec(roadmapContent)) !== null) { + decimalSet.add(parseInt(pm[1], 10)); + } + } catch { /* ROADMAP.md read failure is non-fatal */ } + } + + const existingDecimals = Array.from(decimalSet) + .sort((a, b) => a - b) + .map(n => `${normalized}.${n}`); + + const nextDecimal = decimalSet.size === 0 + ? `${normalized}.1` + : `${normalized}.${Math.max(...decimalSet) + 1}`; + + return { + data: { + found: baseExists, + base_phase: normalized, + next: nextDecimal, + existing: existingDecimals, + }, + }; +}; + +export const phasesArchive: QueryHandler = async (args, projectDir) => { + const version = args[0]; + if (!version) { + throw new GSDError('version required for phases archive', ErrorClassification.Validation); + } + assertNoNullBytes(version, 'version'); + + const paths = planningPaths(projectDir); + const phasesDir = paths.phases; + const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + + const archiveDir = join(paths.planning, 'milestones', `${version}-phases`); + await mkdir(archiveDir, { recursive: true }); + + let archivedCount = 0; + if (existsSync(phasesDir)) { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const phaseDirNames = entries.filter(e => e.isDirectory()).map(e => e.name); + + for (const dir of phaseDirNames) { + if (!isDirInMilestone(dir)) continue; + await rename(join(phasesDir, dir), join(archiveDir, dir)); + archivedCount++; + } + } + + return { + data: { + archived: archivedCount, + version, + archive_directory: toPosixPath(relative(projectDir, archiveDir)), + }, + }; +}; + +// ─── milestoneComplete ──────────────────────────────────────────────────── + +export const milestoneComplete: QueryHandler = async (args, projectDir) => { + const version = args[0] || 'current'; + try { + const archiveResult = await phasesArchive([], projectDir); + return { + data: { + completed: true, + version, + archive: archiveResult.data, + }, + }; + } catch (err) { + return { data: { completed: false, reason: String(err) } }; + } +}; diff --git a/sdk/src/query/phase.test.ts b/sdk/src/query/phase.test.ts new file mode 100644 index 000000000..483148095 --- /dev/null +++ b/sdk/src/query/phase.test.ts @@ -0,0 +1,307 @@ +/** + * Unit tests for phase query handlers. + * + * Tests findPhase and phasePlanIndex handlers. + * Uses temp directories with real .planning/ structures. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { GSDError } from '../errors.js'; + +import { findPhase, phasePlanIndex } from './phase.js'; + +// ─── Fixtures ────────────────────────────────────────────────────────────── + +const PLAN_01_CONTENT = `--- +phase: 09-foundation +plan: 01 +wave: 1 +autonomous: true +files_modified: + - sdk/src/errors.ts + - sdk/src/errors.test.ts +--- + + +Build error classification system. + + + + + Task 1: Create error types + + + Task 2: Add exit codes + + +`; + +const PLAN_02_CONTENT = `--- +phase: 09-foundation +plan: 02 +wave: 1 +autonomous: false +files_modified: + - sdk/src/query/registry.ts +--- + + +Build query registry. + + + + + Task 1: Registry class + + + Task 2: Verify registry + + +`; + +const PLAN_03_CONTENT = `--- +phase: 09-foundation +plan: 03 +wave: 2 +autonomous: true +--- + + +Golden file tests. + + + + + Task 1: Setup golden files + + +`; + +let tmpDir: string; + +// ─── Setup / Teardown ────────────────────────────────────────────────────── + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-phase-test-')); + const planningDir = join(tmpDir, '.planning'); + const phasesDir = join(planningDir, 'phases'); + + await mkdir(phasesDir, { recursive: true }); + + // Phase 09 + const phase09 = join(phasesDir, '09-foundation'); + await mkdir(phase09, { recursive: true }); + await writeFile(join(phase09, '09-01-PLAN.md'), PLAN_01_CONTENT); + await writeFile(join(phase09, '09-01-SUMMARY.md'), 'Summary 1'); + await writeFile(join(phase09, '09-02-PLAN.md'), PLAN_02_CONTENT); + await writeFile(join(phase09, '09-02-SUMMARY.md'), 'Summary 2'); + await writeFile(join(phase09, '09-03-PLAN.md'), PLAN_03_CONTENT); + // No summary for plan 03 (incomplete) + await writeFile(join(phase09, '09-RESEARCH.md'), 'Research'); + await writeFile(join(phase09, '09-CONTEXT.md'), 'Context'); + + // Phase 10 + const phase10 = join(phasesDir, '10-read-only-queries'); + await mkdir(phase10, { recursive: true }); + await writeFile(join(phase10, '10-01-PLAN.md'), '---\nphase: 10\nplan: 01\n---\n\nPort helpers.\n\n\n\n Task 1\n\n'); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── findPhase ───────────────────────────────────────────────────────────── + +describe('findPhase', () => { + it('finds existing phase by number', async () => { + const result = await findPhase(['9'], tmpDir); + const data = result.data as Record; + + expect(data.found).toBe(true); + expect(data.phase_number).toBe('09'); + expect(data.phase_name).toBe('foundation'); + }); + + it('returns posix-style directory path', async () => { + const result = await findPhase(['9'], tmpDir); + const data = result.data as Record; + + expect(data.directory).toBe('.planning/phases/09-foundation'); + // No backslashes + expect((data.directory as string)).not.toContain('\\'); + }); + + it('lists plans and summaries', async () => { + const result = await findPhase(['9'], tmpDir); + const data = result.data as Record; + + const plans = data.plans as string[]; + const summaries = data.summaries as string[]; + + expect(plans.length).toBe(3); + expect(summaries.length).toBe(2); + expect(plans).toContain('09-01-PLAN.md'); + expect(summaries).toContain('09-01-SUMMARY.md'); + }); + + it('returns not found for nonexistent phase', async () => { + const result = await findPhase(['99'], tmpDir); + const data = result.data as Record; + + expect(data.found).toBe(false); + expect(data.directory).toBeNull(); + expect(data.phase_number).toBeNull(); + expect(data.plans).toEqual([]); + expect(data.summaries).toEqual([]); + }); + + it('throws GSDError with Validation classification when no args', async () => { + await expect(findPhase([], tmpDir)).rejects.toThrow(GSDError); + try { + await findPhase([], tmpDir); + } catch (err) { + expect((err as GSDError).classification).toBe('validation'); + } + }); + + it('handles two-digit phase numbers', async () => { + const result = await findPhase(['10'], tmpDir); + const data = result.data as Record; + + expect(data.found).toBe(true); + expect(data.phase_number).toBe('10'); + expect(data.phase_name).toBe('read-only-queries'); + }); + + it('includes file stats (research, context)', async () => { + const result = await findPhase(['9'], tmpDir); + const data = result.data as Record; + + expect(data.has_research).toBe(true); + expect(data.has_context).toBe(true); + }); + + it('computes incomplete plans', async () => { + const result = await findPhase(['9'], tmpDir); + const data = result.data as Record; + const incompletePlans = data.incomplete_plans as string[]; + + expect(incompletePlans.length).toBe(1); + expect(incompletePlans[0]).toBe('09-03-PLAN.md'); + }); + + it('searches archived milestone phases', async () => { + // Create archived milestone directory + const archiveDir = join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-setup'); + await mkdir(archiveDir, { recursive: true }); + await writeFile(join(archiveDir, '01-01-PLAN.md'), '---\nphase: 01\nplan: 01\n---\nPlan'); + await writeFile(join(archiveDir, '01-01-SUMMARY.md'), 'Summary'); + + const result = await findPhase(['1'], tmpDir); + const data = result.data as Record; + + expect(data.found).toBe(true); + expect(data.archived).toBe('v1.0'); + }); +}); + +// ─── phasePlanIndex ──────────────────────────────────────────────────────── + +describe('phasePlanIndex', () => { + it('returns plan metadata for phase', async () => { + const result = await phasePlanIndex(['9'], tmpDir); + const data = result.data as Record; + + expect(data.phase).toBe('09'); + const plans = data.plans as Array>; + expect(plans.length).toBe(3); + }); + + it('includes plan details (id, wave, autonomous, objective, task_count)', async () => { + const result = await phasePlanIndex(['9'], tmpDir); + const data = result.data as Record; + const plans = data.plans as Array>; + + const plan1 = plans.find(p => p.id === '09-01'); + expect(plan1).toBeDefined(); + expect(plan1!.wave).toBe(1); + expect(plan1!.autonomous).toBe(true); + expect(plan1!.objective).toBe('Build error classification system.'); + expect(plan1!.task_count).toBe(2); + expect(plan1!.has_summary).toBe(true); + }); + + it('correctly counts XML task tags', async () => { + const result = await phasePlanIndex(['9'], tmpDir); + const data = result.data as Record; + const plans = data.plans as Array>; + + const plan1 = plans.find(p => p.id === '09-01'); + expect(plan1!.task_count).toBe(2); + + const plan2 = plans.find(p => p.id === '09-02'); + expect(plan2!.task_count).toBe(2); + + const plan3 = plans.find(p => p.id === '09-03'); + expect(plan3!.task_count).toBe(1); + }); + + it('groups plans by wave', async () => { + const result = await phasePlanIndex(['9'], tmpDir); + const data = result.data as Record; + const waves = data.waves as Record; + + expect(waves['1']).toContain('09-01'); + expect(waves['1']).toContain('09-02'); + expect(waves['2']).toContain('09-03'); + }); + + it('identifies incomplete plans', async () => { + const result = await phasePlanIndex(['9'], tmpDir); + const data = result.data as Record; + const incomplete = data.incomplete as string[]; + + expect(incomplete).toContain('09-03'); + expect(incomplete).not.toContain('09-01'); + }); + + it('detects has_checkpoints from non-autonomous plans', async () => { + const result = await phasePlanIndex(['9'], tmpDir); + const data = result.data as Record; + + // Plan 02 has autonomous: false + expect(data.has_checkpoints).toBe(true); + }); + + it('parses files_modified from frontmatter', async () => { + const result = await phasePlanIndex(['9'], tmpDir); + const data = result.data as Record; + const plans = data.plans as Array>; + + const plan1 = plans.find(p => p.id === '09-01'); + const filesModified = plan1!.files_modified as string[]; + + expect(filesModified).toContain('sdk/src/errors.ts'); + expect(filesModified).toContain('sdk/src/errors.test.ts'); + }); + + it('throws GSDError with Validation classification when no args', async () => { + await expect(phasePlanIndex([], tmpDir)).rejects.toThrow(GSDError); + try { + await phasePlanIndex([], tmpDir); + } catch (err) { + expect((err as GSDError).classification).toBe('validation'); + } + }); + + it('returns error for nonexistent phase', async () => { + const result = await phasePlanIndex(['99'], tmpDir); + const data = result.data as Record; + + expect(data.error).toBe('Phase not found'); + expect(data.plans).toEqual([]); + }); +}); diff --git a/sdk/src/query/phase.ts b/sdk/src/query/phase.ts new file mode 100644 index 000000000..944f13262 --- /dev/null +++ b/sdk/src/query/phase.ts @@ -0,0 +1,340 @@ +/** + * Phase finding and plan index query handlers. + * + * Ported from get-shit-done/bin/lib/phase.cjs and core.cjs. + * Provides find-phase (directory lookup with archived fallback) + * and phase-plan-index (plan metadata with wave grouping). + * + * @example + * ```typescript + * import { findPhase, phasePlanIndex } from './phase.js'; + * + * const found = await findPhase(['9'], '/project'); + * // { data: { found: true, directory: '.planning/phases/09-foundation', ... } } + * + * const index = await phasePlanIndex(['9'], '/project'); + * // { data: { phase: '09', plans: [...], waves: { '1': [...] }, ... } } + * ``` + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { extractFrontmatter } from './frontmatter.js'; +import { + normalizePhaseName, + comparePhaseNum, + phaseTokenMatches, + toPosixPath, + planningPaths, +} from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +interface PhaseInfo { + found: boolean; + directory: string | null; + phase_number: string | null; + phase_name: string | null; + phase_slug: string | null; + plans: string[]; + summaries: string[]; + incomplete_plans: string[]; + has_research: boolean; + has_context: boolean; + has_verification: boolean; + has_reviews: boolean; + archived?: string; +} + +// ─── Internal helpers ────────────────────────────────────────────────────── + +/** + * Get file stats for a phase directory. + * + * Port of getPhaseFileStats from core.cjs lines 1461-1471. + */ +async function getPhaseFileStats(phaseDir: string): Promise<{ + plans: string[]; + summaries: string[]; + hasResearch: boolean; + hasContext: boolean; + hasVerification: boolean; + hasReviews: boolean; +}> { + const files = await readdir(phaseDir); + return { + plans: files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'), + summaries: files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'), + hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'), + hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'), + hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'), + hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'), + }; +} + +/** + * Search for a phase directory matching the normalized name. + * + * Port of searchPhaseInDir from core.cjs lines 956-1000. + */ +async function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): Promise { + try { + const entries = await readdir(baseDir, { withFileTypes: true }); + const dirs = entries + .filter(e => e.isDirectory()) + .map(e => e.name) + .sort((a, b) => comparePhaseNum(a, b)); + + const match = dirs.find(d => phaseTokenMatches(d, normalized)); + if (!match) return null; + + // Extract phase number and name + const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) + || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) + || match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i) + || [null, match, null]; + const phaseNumber = dirMatch ? dirMatch[1] : normalized; + const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null; + const phaseDir = join(baseDir, match); + + const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = await getPhaseFileStats(phaseDir); + const plans = unsortedPlans.sort(); + const summaries = unsortedSummaries.sort(); + + const completedPlanIds = new Set( + summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) + ); + const incompletePlans = plans.filter(p => { + const planId = p.replace('-PLAN.md', '').replace('PLAN.md', ''); + return !completedPlanIds.has(planId); + }); + + return { + found: true, + directory: toPosixPath(join(relBase, match)), + phase_number: phaseNumber, + phase_name: phaseName, + phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null, + plans, + summaries, + incomplete_plans: incompletePlans, + has_research: hasResearch, + has_context: hasContext, + has_verification: hasVerification, + has_reviews: hasReviews, + }; + } catch { + return null; + } +} + +/** + * Extract objective text from plan content. + */ +function extractObjective(content: string): string | null { + const m = content.match(/\s*\n?\s*(.+)/); + return m ? m[1].trim() : null; +} + +// ─── Exported handlers ───────────────────────────────────────────────────── + +/** + * Query handler for find-phase. + * + * Locates a phase directory by number/identifier, searching current phases + * first, then archived milestone phases. + * + * Port of cmdFindPhase from phase.cjs lines 152-196, combined with + * findPhaseInternal from core.cjs lines 1002-1038. + * + * @param args - args[0] is the phase identifier (required) + * @param projectDir - Project root directory + * @returns QueryResult with PhaseInfo + * @throws GSDError with Validation classification if phase identifier missing + */ +export const findPhase: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + if (!phase) { + throw new GSDError('phase identifier required', ErrorClassification.Validation); + } + + const phasesDir = planningPaths(projectDir).phases; + const normalized = normalizePhaseName(phase); + + const notFound: PhaseInfo = { + found: false, + directory: null, + phase_number: null, + phase_name: null, + phase_slug: null, + plans: [], + summaries: [], + incomplete_plans: [], + has_research: false, + has_context: false, + has_verification: false, + has_reviews: false, + }; + + // Search current phases first + const relPhasesDir = '.planning/phases'; + const current = await searchPhaseInDir(phasesDir, relPhasesDir, normalized); + if (current) return { data: current }; + + // Search archived milestone phases (newest first) + const milestonesDir = join(projectDir, '.planning', 'milestones'); + try { + const milestoneEntries = await readdir(milestonesDir, { withFileTypes: true }); + const archiveDirs = milestoneEntries + .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) + .map(e => e.name) + .sort() + .reverse(); + + for (const archiveName of archiveDirs) { + const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/); + const version = versionMatch ? versionMatch[1] : archiveName; + const archivePath = join(milestonesDir, archiveName); + const relBase = '.planning/milestones/' + archiveName; + const result = await searchPhaseInDir(archivePath, relBase, normalized); + if (result) { + result.archived = version; + return { data: result }; + } + } + } catch { /* milestones dir doesn't exist */ } + + return { data: notFound }; +}; + +/** + * Query handler for phase-plan-index. + * + * Returns plan metadata with wave grouping for a specific phase. + * + * Port of cmdPhasePlanIndex from phase.cjs lines 203-310. + * + * @param args - args[0] is the phase identifier (required) + * @param projectDir - Project root directory + * @returns QueryResult with { phase, plans[], waves{}, incomplete[], has_checkpoints } + * @throws GSDError with Validation classification if phase identifier missing + */ +export const phasePlanIndex: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + if (!phase) { + throw new GSDError('phase required for phase-plan-index', ErrorClassification.Validation); + } + + const phasesDir = planningPaths(projectDir).phases; + const normalized = normalizePhaseName(phase); + + // Find phase directory + let phaseDir: string | null = null; + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries + .filter(e => e.isDirectory()) + .map(e => e.name) + .sort((a, b) => comparePhaseNum(a, b)); + const match = dirs.find(d => phaseTokenMatches(d, normalized)); + if (match) { + phaseDir = join(phasesDir, match); + } + } catch { /* phases dir doesn't exist */ } + + if (!phaseDir) { + return { + data: { + phase: normalized, + error: 'Phase not found', + plans: [], + waves: {}, + incomplete: [], + has_checkpoints: false, + }, + }; + } + + // Get all files in phase directory + const phaseFiles = await readdir(phaseDir); + const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort(); + const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + + // Build set of plan IDs with summaries + const completedPlanIds = new Set( + summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')) + ); + + const plans: Array> = []; + const waves: Record = {}; + const incomplete: string[] = []; + let hasCheckpoints = false; + + for (const planFile of planFiles) { + const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', ''); + const planPath = join(phaseDir, planFile); + const content = await readFile(planPath, 'utf-8'); + const fm = extractFrontmatter(content); + + // Count tasks: XML tags (canonical) or ## Task N markdown (legacy) + const xmlTasks = content.match(/]/gi) || []; + const mdTasks = content.match(/##\s*Task\s*\d+/gi) || []; + const taskCount = xmlTasks.length || mdTasks.length; + + // Parse wave as integer + const wave = parseInt(String(fm.wave), 10) || 1; + + // Parse autonomous (default true if not specified) + let autonomous = true; + if (fm.autonomous !== undefined) { + autonomous = fm.autonomous === 'true' || fm.autonomous === true; + } + + if (!autonomous) { + hasCheckpoints = true; + } + + // Parse files_modified + let filesModified: string[] = []; + const fmFiles = (fm['files_modified'] || fm['files-modified']) as string | string[] | undefined; + if (fmFiles) { + filesModified = Array.isArray(fmFiles) ? fmFiles : [fmFiles]; + } + + const hasSummary = completedPlanIds.has(planId); + if (!hasSummary) { + incomplete.push(planId); + } + + const plan = { + id: planId, + wave, + autonomous, + objective: extractObjective(content) || (fm.objective as string) || null, + files_modified: filesModified, + task_count: taskCount, + has_summary: hasSummary, + }; + + plans.push(plan); + + // Group by wave + const waveKey = String(wave); + if (!waves[waveKey]) { + waves[waveKey] = []; + } + waves[waveKey].push(planId); + } + + return { + data: { + phase: normalized, + plans, + waves, + incomplete, + has_checkpoints: hasCheckpoints, + }, + }; +}; diff --git a/sdk/src/query/pipeline.test.ts b/sdk/src/query/pipeline.test.ts new file mode 100644 index 000000000..3057bacec --- /dev/null +++ b/sdk/src/query/pipeline.test.ts @@ -0,0 +1,169 @@ +/** + * Unit tests for pipeline middleware. + * + * Tests wrapWithPipeline with dry-run mode, prepare/finalize callbacks, + * and normal execution passthrough. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { QueryRegistry } from './registry.js'; +import { wrapWithPipeline } from './pipeline.js'; +import type { QueryResult } from './utils.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-pipeline-')); + await mkdir(join(tmpDir, '.planning'), { recursive: true }); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), '# State\nstatus: idle\n'); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── Helper ─────────────────────────────────────────────────────────────── + +function makeRegistry(): QueryRegistry { + const registry = new QueryRegistry(); + registry.register('read-cmd', async (_args, _dir) => ({ data: { read: true } })); + registry.register('mut-cmd', async (_args, dir) => { + // Simulate a mutation: write a file to the project dir + const { writeFile: wf } = await import('node:fs/promises'); + await wf(join(dir, '.planning', 'MUTATED.md'), '# mutated'); + return { data: { mutated: true } }; + }); + return registry; +} + +const MUTATION_SET = new Set(['mut-cmd']); + +// ─── Tests ───────────────────────────────────────────────────────────────── + +describe('wrapWithPipeline — passthrough (no options)', () => { + it('read command passes through normally', async () => { + const registry = makeRegistry(); + wrapWithPipeline(registry, MUTATION_SET, {}); + const result = await registry.dispatch('read-cmd', [], tmpDir); + expect((result.data as Record).read).toBe(true); + }); + + it('mutation command executes and writes to disk when dryRun=false', async () => { + const registry = makeRegistry(); + wrapWithPipeline(registry, MUTATION_SET, { dryRun: false }); + const result = await registry.dispatch('mut-cmd', [], tmpDir); + expect((result.data as Record).mutated).toBe(true); + // File should have been written to the real dir + const { existsSync } = await import('node:fs'); + expect(existsSync(join(tmpDir, '.planning', 'MUTATED.md'))).toBe(true); + }); +}); + +describe('wrapWithPipeline — dry-run mode', () => { + it('dry-run mutation returns diff without writing to disk', async () => { + const registry = makeRegistry(); + wrapWithPipeline(registry, MUTATION_SET, { dryRun: true }); + const result = await registry.dispatch('mut-cmd', [], tmpDir); + const data = result.data as Record; + + // Should be a dry-run result + expect(data.dry_run).toBe(true); + expect(data.command).toBe('mut-cmd'); + expect(data.diff).toBeDefined(); + expect(typeof data.changes_summary).toBe('string'); + + // Real project should NOT have been written to + const { existsSync } = await import('node:fs'); + expect(existsSync(join(tmpDir, '.planning', 'MUTATED.md'))).toBe(false); + }); + + it('dry-run diff contains before/after for changed files', async () => { + const registry = makeRegistry(); + wrapWithPipeline(registry, MUTATION_SET, { dryRun: true }); + const result = await registry.dispatch('mut-cmd', [], tmpDir); + const data = result.data as Record; + const diff = data.diff as Record; + + // MUTATED.md is a new file — before should be null + const mutatedKey = Object.keys(diff).find(k => k.includes('MUTATED')); + expect(mutatedKey).toBeDefined(); + expect(diff[mutatedKey!].before).toBeNull(); + expect(diff[mutatedKey!].after).toBe('# mutated'); + }); + + it('dry-run read command executes normally (side-effect-free)', async () => { + const registry = makeRegistry(); + wrapWithPipeline(registry, MUTATION_SET, { dryRun: true }); + // read-cmd is NOT in MUTATION_SET, so it's not wrapped at all + const result = await registry.dispatch('read-cmd', [], tmpDir); + expect((result.data as Record).read).toBe(true); + }); + + it('dry-run changes_summary reflects number of changed files', async () => { + const registry = makeRegistry(); + wrapWithPipeline(registry, MUTATION_SET, { dryRun: true }); + const result = await registry.dispatch('mut-cmd', [], tmpDir); + const data = result.data as Record; + expect(data.changes_summary).toContain('1 file'); + }); +}); + +describe('wrapWithPipeline — prepare/finalize callbacks', () => { + it('onPrepare fires before mutation execution', async () => { + const registry = makeRegistry(); + const preparedCommands: string[] = []; + wrapWithPipeline(registry, MUTATION_SET, { + onPrepare: async (cmd) => { preparedCommands.push(cmd); }, + }); + await registry.dispatch('mut-cmd', ['arg1'], tmpDir); + expect(preparedCommands).toContain('mut-cmd'); + }); + + it('onFinalize fires after mutation with result', async () => { + const registry = makeRegistry(); + let capturedResult: QueryResult | null = null; + wrapWithPipeline(registry, MUTATION_SET, { + onFinalize: async (_cmd, _args, result) => { capturedResult = result; }, + }); + await registry.dispatch('mut-cmd', [], tmpDir); + expect(capturedResult).not.toBeNull(); + }); + + it('onPrepare receives correct args', async () => { + const registry = makeRegistry(); + let capturedArgs: string[] = []; + wrapWithPipeline(registry, MUTATION_SET, { + onPrepare: async (_cmd, args) => { capturedArgs = args; }, + }); + await registry.dispatch('mut-cmd', ['foo', 'bar'], tmpDir); + expect(capturedArgs).toEqual(['foo', 'bar']); + }); + + it('onFinalize fires even in dry-run mode', async () => { + const registry = makeRegistry(); + let finalizeCalled = false; + wrapWithPipeline(registry, MUTATION_SET, { + dryRun: true, + onFinalize: async () => { finalizeCalled = true; }, + }); + await registry.dispatch('mut-cmd', [], tmpDir); + expect(finalizeCalled).toBe(true); + }); +}); + +describe('wrapWithPipeline — unregistered command passthrough', () => { + it('commands not in mutation set are not wrapped', async () => { + const registry = makeRegistry(); + const spy = vi.fn(async (_args: string[], _dir: string): Promise => ({ data: { value: 42 } })); + registry.register('other-cmd', spy); + wrapWithPipeline(registry, MUTATION_SET, { + onPrepare: async () => { /* should not fire for non-mutation */ }, + }); + const result = await registry.dispatch('other-cmd', [], tmpDir); + // Since other-cmd is not in MUTATION_SET, it's not wrapped + expect((result.data as Record).value).toBe(42); + }); +}); diff --git a/sdk/src/query/pipeline.ts b/sdk/src/query/pipeline.ts new file mode 100644 index 000000000..76a18a29e --- /dev/null +++ b/sdk/src/query/pipeline.ts @@ -0,0 +1,246 @@ +/** + * Staged execution pipeline — registry-level middleware for pre/post hooks + * and full in-memory dry-run support. + * + * Wraps all registry handlers with prepare/execute/finalize stages. + * When dryRun=true and the command is a mutation, the mutation executes + * against a temporary directory clone of .planning/ instead of the real + * project, and the before/after diff is returned without writing to disk. + * + * Read commands are always executed normally — they are side-effect-free. + * + * @example + * ```typescript + * import { createRegistry } from './index.js'; + * import { wrapWithPipeline } from './pipeline.js'; + * + * const registry = createRegistry(); + * wrapWithPipeline(registry, MUTATION_COMMANDS, { dryRun: true }); + * // mutations now return { data: { dry_run: true, diff: { ... } } } + * ``` + */ + +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { existsSync, readdirSync } from 'node:fs'; +import { join, relative, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { QueryResult } from './utils.js'; +import type { QueryRegistry } from './registry.js'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +/** + * Configuration for the pipeline middleware. + */ +export interface PipelineOptions { + /** When true, mutations execute against a temp clone and return a diff */ + dryRun?: boolean; + /** Called before each handler invocation */ + onPrepare?: (command: string, args: string[], projectDir: string) => Promise; + /** Called after each handler invocation */ + onFinalize?: (command: string, args: string[], result: QueryResult) => Promise; +} + +/** + * A single stage in the execution pipeline. + */ +export type PipelineStage = 'prepare' | 'execute' | 'finalize'; + +// ─── Internal helpers ────────────────────────────────────────────────────── + +/** + * Recursively collect all files under a directory. + * Returns paths relative to the base directory. + */ +function collectFiles(dir: string, base: string): string[] { + const results: string[] = []; + if (!existsSync(dir)) return results; + const entries = readdirSync(dir, { withFileTypes: true }) as unknown as Array<{ + isDirectory(): boolean; + isFile(): boolean; + name: string; + }>; + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const relPath = relative(base, fullPath); + if (entry.isFile()) { + results.push(relPath); + } else if (entry.isDirectory()) { + results.push(...collectFiles(fullPath, base)); + } + } + return results; +} + +/** + * Copy .planning/ subtree from sourceDir to destDir. + * Only copies text files relevant to GSD state (skips binaries and logs). + */ +async function copyPlanningTree(sourceDir: string, destDir: string): Promise { + const planningSource = join(sourceDir, '.planning'); + if (!existsSync(planningSource)) return; + + const files = collectFiles(planningSource, planningSource); + for (const relFile of files) { + // Skip large or binary-ish files (> 1MB) — only relevant for text state + const sourcePath = join(planningSource, relFile); + const destPath = join(destDir, '.planning', relFile); + await mkdir(dirname(destPath), { recursive: true }); + try { + const content = await readFile(sourcePath, 'utf-8'); + await writeFile(destPath, content, 'utf-8'); + } catch { + // Skip unreadable files (binary, permission issues, etc.) + } + } +} + +/** + * Read all files from .planning/ in a directory into a map of relPath → content. + */ +async function readPlanningState(projectDir: string): Promise> { + const planningDir = join(projectDir, '.planning'); + const result = new Map(); + if (!existsSync(planningDir)) return result; + + const files = collectFiles(planningDir, planningDir); + for (const relFile of files) { + try { + const content = await readFile(join(planningDir, relFile), 'utf-8'); + result.set(relFile, content); + } catch { /* skip unreadable */ } + } + return result; +} + +/** + * Diff two file maps, returning files that changed (with before/after content). + */ +function diffPlanningState( + before: Map, + after: Map, +): Record { + const diff: Record = {}; + const allKeys = new Set([...before.keys(), ...after.keys()]); + for (const key of allKeys) { + const b = before.get(key) ?? null; + const a = after.get(key) ?? null; + if (b !== a) { + diff[`.planning/${key}`] = { before: b, after: a }; + } + } + return diff; +} + +// ─── wrapWithPipeline ────────────────────────────────────────────────────── + +/** + * Wrap all registered handlers with prepare/execute/finalize pipeline stages. + * + * When dryRun=true and a mutation command is dispatched, the real projectDir + * is cloned (only .planning/ subtree) into a temp directory. The mutation + * runs against the clone, a before/after diff is computed, and the temp + * directory is cleaned up in a finally block. The real project is never + * touched during a dry run. + * + * @param registry - The registry whose handlers to wrap + * @param mutationCommands - Set of command names that perform mutations + * @param options - Pipeline configuration + */ +export function wrapWithPipeline( + registry: QueryRegistry, + mutationCommands: Set, + options: PipelineOptions, +): void { + const { dryRun = false, onPrepare, onFinalize } = options; + + // Collect all currently registered commands by iterating known handlers + // We wrap by re-registering with the same name using the same technique + // as event emission wiring in index.ts + const commandsToWrap: string[] = []; + + // We need to enumerate commands. QueryRegistry doesn't expose keys directly, + // so we wrap the register method temporarily to collect known commands, + // then restore. Instead, we use the mutation commands set + a marker approach: + // wrap mutation commands for dry-run, and wrap all via onPrepare/onFinalize. + // + // For pipeline wrapping we use a two-pass approach: + // Pass 1: wrap mutation commands (for dry-run + hooks) + // Pass 2: wrap non-mutation commands (for hooks only, if hooks provided) + + const wrapHandler = (cmd: string, isMutation: boolean): void => { + const original = registry.getHandler(cmd); + if (!original) return; + + registry.register(cmd, async (args: string[], projectDir: string) => { + // ─── Prepare stage ─────────────────────────────────────────────── + if (onPrepare) { + await onPrepare(cmd, args, projectDir); + } + + let result: QueryResult; + + if (dryRun && isMutation) { + // ─── Dry-run: clone → mutate → diff ────────────────────────── + let tempDir: string | null = null; + try { + tempDir = await mkdtemp(join(tmpdir(), 'gsd-dryrun-')); + + // Snapshot state before mutation + const beforeState = await readPlanningState(projectDir); + + // Copy .planning/ to temp dir + await copyPlanningTree(projectDir, tempDir); + + // Execute mutation against temp dir clone + await original(args, tempDir); + + // Snapshot state after mutation (from temp dir) + const afterState = await readPlanningState(tempDir); + + // Compute diff + const diff = diffPlanningState(beforeState, afterState); + const changedFiles = Object.keys(diff); + + result = { + data: { + dry_run: true, + command: cmd, + args, + diff, + changes_summary: changedFiles.length > 0 + ? `${changedFiles.length} file(s) would be modified: ${changedFiles.join(', ')}` + : 'No files would be modified', + }, + }; + } finally { + // T-14-06: Always clean up temp dir, even on error + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + } + } + } else { + // ─── Normal execution ───────────────────────────────────────── + result = await original(args, projectDir); + } + + // ─── Finalize stage ─────────────────────────────────────────────── + if (onFinalize) { + await onFinalize(cmd, args, result); + } + + return result; + }); + + commandsToWrap.push(cmd); + }; + + // Wrap mutation commands (dry-run eligible + hooks) + for (const cmd of mutationCommands) { + wrapHandler(cmd, true); + } + + // Note: non-mutation commands are NOT wrapped here for performance — callers + // can provide onPrepare/onFinalize for mutations only. If full wrapping of + // read commands is needed, callers should pass their command set explicitly. +} diff --git a/sdk/src/query/profile.ts b/sdk/src/query/profile.ts new file mode 100644 index 000000000..69f7299ee --- /dev/null +++ b/sdk/src/query/profile.ts @@ -0,0 +1,367 @@ +/** + * Profile and learnings query handlers — session scanning, questionnaire, + * profile generation, and knowledge store management. + * + * Ported from get-shit-done/bin/lib/profile-pipeline.cjs, profile-output.cjs, + * and learnings.cjs. + * + * @example + * ```typescript + * import { scanSessions, profileQuestionnaire } from './profile.js'; + * + * await scanSessions([], '/project'); + * // { data: { projects: [...], project_count: 5, session_count: 42 } } + * + * await profileQuestionnaire([], '/project'); + * // { data: { questions: [...], total: 3 } } + * ``` + */ + +import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join, relative, basename, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { createHash, randomBytes } from 'node:crypto'; + +import { planningPaths, toPosixPath } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Learnings — ~/.gsd/knowledge/ knowledge store ─────────────────────── + +const STORE_DIR = join(homedir(), '.gsd', 'knowledge'); + +function ensureStore(): void { + if (!existsSync(STORE_DIR)) mkdirSync(STORE_DIR, { recursive: true }); +} + +function learningsWrite(entry: { source_project: string; learning: string; context?: string; tags?: string[] }): { created: boolean; id: string } { + ensureStore(); + const hash = createHash('sha256').update(entry.learning + '\n' + entry.source_project).digest('hex'); + for (const file of readdirSync(STORE_DIR).filter(f => f.endsWith('.json'))) { + try { + const r = JSON.parse(readFileSync(join(STORE_DIR, file), 'utf-8')); + if (r.content_hash === hash) return { created: false, id: r.id }; + } catch { /* skip */ } + } + const id = `${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`; + const record = { id, source_project: entry.source_project, date: new Date().toISOString(), context: entry.context ?? '', learning: entry.learning, tags: entry.tags ?? [], content_hash: hash }; + writeFileSync(join(STORE_DIR, `${id}.json`), JSON.stringify(record, null, 2), 'utf-8'); + return { created: true, id }; +} + +function learningsList(): Array> { + if (!existsSync(STORE_DIR)) return []; + const results: Array> = []; + for (const file of readdirSync(STORE_DIR).filter(f => f.endsWith('.json'))) { + try { + const record = JSON.parse(readFileSync(join(STORE_DIR, file), 'utf-8')); + results.push(record); + } catch { /* skip */ } + } + results.sort((a, b) => new Date(b.date as string).getTime() - new Date(a.date as string).getTime()); + return results; +} + +/** + * Query learnings from the global knowledge store, optionally filtered by tag. + * + * Port of `cmdLearningsQuery` from learnings.cjs lines 316-323. + * Called by gsd-planner agent to inject prior learnings into plan generation. + * + * Args: --tag [--limit N] + */ +export const learningsQuery: QueryHandler = async (args) => { + const tagIdx = args.indexOf('--tag'); + const tag = tagIdx !== -1 ? args[tagIdx + 1] : null; + const limitIdx = args.indexOf('--limit'); + const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : undefined; + + let results = learningsList(); + if (tag) { + results = results.filter(r => Array.isArray(r.tags) && (r.tags as string[]).includes(tag)); + } + if (limit && limit > 0) { + results = results.slice(0, limit); + } + return { data: { learnings: results, count: results.length, tag } }; +}; + +export const learningsCopy: QueryHandler = async (_args, projectDir) => { + const paths = planningPaths(projectDir); + const learningsPath = join(paths.planning, 'LEARNINGS.md'); + if (!existsSync(learningsPath)) { + return { data: { copied: false, total: 0, created: 0, skipped: 0, reason: 'No LEARNINGS.md found' } }; + } + const content = readFileSync(learningsPath, 'utf-8'); + const sourceProject = basename(resolve(projectDir)); + const sections = content.split(/^## /m).slice(1); + let created = 0; let skipped = 0; + + for (const section of sections) { + const lines = section.trim().split('\n'); + const title = lines[0].trim(); + const body = lines.slice(1).join('\n').trim(); + if (!body) continue; + const tags = title.toLowerCase().split(/\s+/).filter(w => w.length > 2); + const result = learningsWrite({ source_project: sourceProject, learning: body, context: title, tags }); + if (result.created) created++; else skipped++; + } + return { data: { copied: true, total: created + skipped, created, skipped } }; +}; + +// ─── extractMessages — session message extraction for profiling ─────────── + +/** + * Extract user messages from Claude Code session files for a given project. + * + * Port of `cmdExtractMessages` from profile-pipeline.cjs lines 252-391. + * Simplified to use the SDK's existing session scanning infrastructure. + * + * @param args - args[0]: project name/keyword (required), --limit N, --session-id ID + */ +export const extractMessages: QueryHandler = async (args) => { + const projectArg = args[0]; + if (!projectArg) { + return { data: { error: 'project name required', messages: [], total: 0 } }; + } + + const sessionsBase = join(homedir(), '.claude', 'projects'); + if (!existsSync(sessionsBase)) { + return { data: { error: 'No Claude Code sessions found', messages: [], total: 0 } }; + } + + const limitIdx = args.indexOf('--limit'); + const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) || 300 : 300; + const sessionIdIdx = args.indexOf('--session-id'); + const sessionIdFilter = sessionIdIdx !== -1 ? args[sessionIdIdx + 1] : null; + + let projectDirs: string[]; + try { + projectDirs = readdirSync(sessionsBase, { withFileTypes: true }) + .filter((e: { isDirectory(): boolean }) => e.isDirectory()) + .map((e: { name: string }) => e.name); + } catch { + return { data: { error: 'Cannot read sessions directory', messages: [], total: 0 } }; + } + + const lowerArg = projectArg.toLowerCase(); + const matchedDir = projectDirs.find(d => d === projectArg) + || projectDirs.find(d => d.toLowerCase().includes(lowerArg)); + + if (!matchedDir) { + return { data: { error: `No project matching "${projectArg}"`, available: projectDirs.slice(0, 10), messages: [], total: 0 } }; + } + + const projectPath = join(sessionsBase, matchedDir); + let sessionFiles = readdirSync(projectPath).filter(f => f.endsWith('.jsonl')); + if (sessionIdFilter) { + sessionFiles = sessionFiles.filter(f => f.includes(sessionIdFilter)); + } + + const messages: Array<{ role: string; content: string; session: string }> = []; + let sessionsProcessed = 0; + let sessionsSkipped = 0; + + for (const sessionFile of sessionFiles) { + if (messages.length >= limit) break; + try { + const content = readFileSync(join(projectPath, sessionFile), 'utf-8'); + for (const line of content.split('\n').filter(Boolean)) { + if (messages.length >= limit) break; + try { + const record = JSON.parse(line); + if (record.type === 'user' && typeof record.message?.content === 'string') { + const text = record.message.content; + if (text.length > 3 && !text.startsWith('/') && !/^\s*(y|n|yes|no|ok)\s*$/i.test(text)) { + messages.push({ + role: 'user', + content: text.length > 2000 ? text.slice(0, 2000) + '... [truncated]' : text, + session: sessionFile.replace('.jsonl', ''), + }); + } + } + } catch { /* skip malformed line */ } + } + sessionsProcessed++; + } catch { + sessionsSkipped++; + } + } + + return { + data: { + project: matchedDir, + sessions_processed: sessionsProcessed, + sessions_skipped: sessionsSkipped, + messages_extracted: messages.length, + messages, + }, + }; +}; + +// ─── Profile — session scanning and profile generation ──────────────────── + +const SESSIONS_DIR = join(homedir(), '.claude', 'projects'); + +export const scanSessions: QueryHandler = async (_args, _projectDir) => { + if (!existsSync(SESSIONS_DIR)) { + return { data: { projects: [], project_count: 0, session_count: 0 } }; + } + + const projects: Record[] = []; + let sessionCount = 0; + + try { + const projectDirs = readdirSync(SESSIONS_DIR, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + for (const pDir of projectDirs.filter(e => e.isDirectory())) { + const pPath = join(SESSIONS_DIR, pDir.name); + const sessions = readdirSync(pPath).filter(f => f.endsWith('.jsonl')); + sessionCount += sessions.length; + projects.push({ name: pDir.name, path: toPosixPath(pPath), session_count: sessions.length }); + } + } catch { /* skip */ } + + return { data: { projects, project_count: projects.length, session_count: sessionCount } }; +}; + +export const profileSample: QueryHandler = async (_args, _projectDir) => { + if (!existsSync(SESSIONS_DIR)) { + return { data: { messages: [], total: 0, projects_sampled: 0 } }; + } + const messages: string[] = []; + let projectsSampled = 0; + + try { + const projectDirs = readdirSync(SESSIONS_DIR, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + for (const pDir of projectDirs.filter(e => e.isDirectory()).slice(0, 5)) { + const pPath = join(SESSIONS_DIR, pDir.name); + const sessions = readdirSync(pPath).filter(f => f.endsWith('.jsonl')).slice(0, 3); + for (const session of sessions) { + try { + const content = readFileSync(join(pPath, session), 'utf-8'); + for (const line of content.split('\n').filter(Boolean)) { + try { + const record = JSON.parse(line); + if (record.type === 'user' && typeof record.message?.content === 'string') { + messages.push(record.message.content.slice(0, 500)); + if (messages.length >= 50) break; + } + } catch { /* skip malformed */ } + } + } catch { /* skip */ } + if (messages.length >= 50) break; + } + projectsSampled++; + if (messages.length >= 50) break; + } + } catch { /* skip */ } + + return { data: { messages, total: messages.length, projects_sampled: projectsSampled } }; +}; + +const PROFILING_QUESTIONS = [ + { dimension: 'communication_style', header: 'Communication Style', question: 'When you ask Claude to build something, how much context do you typically provide?', options: [{ label: 'Minimal', value: 'a', rating: 'terse-direct' }, { label: 'Some context', value: 'b', rating: 'conversational' }, { label: 'Detailed specs', value: 'c', rating: 'detailed-structured' }, { label: 'It depends', value: 'd', rating: 'mixed' }] }, + { dimension: 'decision_speed', header: 'Decision Making', question: 'When Claude presents you with options, how do you typically decide?', options: [{ label: 'Pick quickly', value: 'a', rating: 'fast-intuitive' }, { label: 'Ask for comparison', value: 'b', rating: 'deliberate-informed' }, { label: 'Research independently', value: 'c', rating: 'research-first' }, { label: 'Let Claude recommend', value: 'd', rating: 'delegator' }] }, + { dimension: 'explanation_depth', header: 'Explanation Preferences', question: 'When Claude explains something, how much detail do you want?', options: [{ label: 'Just the code', value: 'a', rating: 'code-only' }, { label: 'Brief explanation', value: 'b', rating: 'concise' }, { label: 'Detailed walkthrough', value: 'c', rating: 'detailed' }, { label: 'Deep dive', value: 'd', rating: 'educational' }] }, +]; + +export const profileQuestionnaire: QueryHandler = async (args, _projectDir) => { + const answersFlag = args.indexOf('--answers'); + if (answersFlag >= 0 && args[answersFlag + 1]) { + try { + const answers = JSON.parse(readFileSync(resolve(args[answersFlag + 1]), 'utf-8')) as Record; + const analysis: Record = {}; + for (const q of PROFILING_QUESTIONS) { + const answer = answers[q.dimension]; + const option = q.options.find(o => o.value === answer); + analysis[q.dimension] = option?.rating ?? 'unknown'; + } + return { data: { analysis, answered: Object.keys(answers).length, questions_total: PROFILING_QUESTIONS.length } }; + } catch { + return { data: { error: 'Failed to read answers file', path: args[answersFlag + 1] } }; + } + } + return { data: { questions: PROFILING_QUESTIONS, total: PROFILING_QUESTIONS.length } }; +}; + +export const writeProfile: QueryHandler = async (args, projectDir) => { + const inputFlag = args.indexOf('--input'); + const inputPath = inputFlag >= 0 ? args[inputFlag + 1] : null; + if (!inputPath || !existsSync(resolve(inputPath))) { + return { data: { written: false, reason: 'No --input analysis file provided' } }; + } + try { + const analysis = JSON.parse(readFileSync(resolve(inputPath), 'utf-8')) as Record; + const profilePath = join(projectDir, '.planning', 'USER-PROFILE.md'); + const lines = ['# User Developer Profile', '', `*Generated: ${new Date().toISOString()}*`, '']; + for (const [key, value] of Object.entries(analysis)) { + lines.push(`## ${key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`); + lines.push(''); + lines.push(String(value)); + lines.push(''); + } + await writeFile(profilePath, lines.join('\n'), 'utf-8'); + return { data: { written: true, path: toPosixPath(relative(projectDir, profilePath)) } }; + } catch (err) { + return { data: { written: false, reason: String(err) } }; + } +}; + +export const generateClaudeProfile: QueryHandler = async (args, _projectDir) => { + const analysisFlag = args.indexOf('--analysis'); + const analysisPath = analysisFlag >= 0 ? args[analysisFlag + 1] : null; + let profile = '> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.\n> This section is managed by `generate-claude-profile` -- do not edit manually.'; + + if (analysisPath && existsSync(resolve(analysisPath))) { + try { + const analysis = JSON.parse(readFileSync(resolve(analysisPath), 'utf-8')) as Record; + const lines = ['## Developer Profile', '']; + for (const [key, value] of Object.entries(analysis)) { + lines.push(`- **${key.replace(/_/g, ' ')}**: ${value}`); + } + profile = lines.join('\n'); + } catch { /* use fallback */ } + } + + return { data: { profile, generated: true } }; +}; + +export const generateDevPreferences: QueryHandler = async (args, projectDir) => { + const analysisFlag = args.indexOf('--analysis'); + const analysisPath = analysisFlag >= 0 ? args[analysisFlag + 1] : null; + const prefs: Record = {}; + + if (analysisPath && existsSync(resolve(analysisPath))) { + try { + const analysis = JSON.parse(readFileSync(resolve(analysisPath), 'utf-8')) as Record; + Object.assign(prefs, analysis); + } catch { /* use empty */ } + } + + const prefsPath = join(projectDir, '.planning', 'dev-preferences.md'); + const lines = ['# Developer Preferences', '', `*Generated: ${new Date().toISOString()}*`, '']; + for (const [key, value] of Object.entries(prefs)) { + lines.push(`- **${key}**: ${value}`); + } + await writeFile(prefsPath, lines.join('\n'), 'utf-8'); + return { data: { written: true, path: toPosixPath(relative(projectDir, prefsPath)), preferences: prefs } }; +}; + +export const generateClaudeMd: QueryHandler = async (_args, projectDir) => { + const safeRead = (path: string): string | null => { + try { return existsSync(path) ? readFileSync(path, 'utf-8') : null; } catch { return null; } + }; + + const sections: string[] = []; + + const projectContent = safeRead(join(projectDir, '.planning', 'PROJECT.md')); + if (projectContent) { + const h1 = projectContent.match(/^# (.+)$/m); + if (h1) sections.push(`## Project\n\n${h1[1]}\n`); + } + + const stackContent = safeRead(join(projectDir, '.planning', 'codebase', 'STACK.md')) ?? safeRead(join(projectDir, '.planning', 'research', 'STACK.md')); + if (stackContent) sections.push(`## Technology Stack\n\n${stackContent.slice(0, 1000)}\n`); + + return { data: { sections, generated: true, section_count: sections.length } }; +}; diff --git a/sdk/src/query/progress.test.ts b/sdk/src/query/progress.test.ts new file mode 100644 index 000000000..cc450bf06 --- /dev/null +++ b/sdk/src/query/progress.test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for progress query handlers. + * + * Tests progressJson and determinePhaseStatus. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { progressJson, determinePhaseStatus } from './progress.js'; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'progress-test-')); + await mkdir(join(tmpDir, '.planning', 'phases'), { recursive: true }); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── determinePhaseStatus ───────────────────────────────────────────────── + +describe('determinePhaseStatus', () => { + it('returns Pending when no plans', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '01-test'); + await mkdir(phaseDir, { recursive: true }); + const status = await determinePhaseStatus(0, 0, phaseDir); + expect(status).toBe('Pending'); + }); + + it('returns Planned when plans but no summaries', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '01-test'); + await mkdir(phaseDir, { recursive: true }); + const status = await determinePhaseStatus(3, 0, phaseDir); + expect(status).toBe('Planned'); + }); + + it('returns In Progress when some summaries', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '01-test'); + await mkdir(phaseDir, { recursive: true }); + const status = await determinePhaseStatus(3, 1, phaseDir); + expect(status).toBe('In Progress'); + }); + + it('returns Executed when all summaries but no VERIFICATION.md', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '01-test'); + await mkdir(phaseDir, { recursive: true }); + const status = await determinePhaseStatus(3, 3, phaseDir); + expect(status).toBe('Executed'); + }); + + it('returns Complete when VERIFICATION.md has status: passed', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '01-test'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, 'VERIFICATION.md'), '---\nstatus: passed\n---\n'); + const status = await determinePhaseStatus(3, 3, phaseDir); + expect(status).toBe('Complete'); + }); + + it('returns Needs Review when VERIFICATION.md has status: human_needed', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '01-test'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, 'VERIFICATION.md'), '---\nstatus: human_needed\n---\n'); + const status = await determinePhaseStatus(3, 3, phaseDir); + expect(status).toBe('Needs Review'); + }); + + it('returns Executed when VERIFICATION.md has status: gaps_found', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '01-test'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, 'VERIFICATION.md'), '---\nstatus: gaps_found\n---\n'); + const status = await determinePhaseStatus(3, 3, phaseDir); + expect(status).toBe('Executed'); + }); + + it('returns Executed when VERIFICATION.md has unrecognized status', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '01-test'); + await mkdir(phaseDir, { recursive: true }); + await writeFile(join(phaseDir, 'VERIFICATION.md'), '---\nstatus: unknown\n---\n'); + const status = await determinePhaseStatus(3, 3, phaseDir); + expect(status).toBe('Executed'); + }); +}); + +// ─── progressJson ───────────────────────────────────────────────────────── + +describe('progressJson', () => { + it('returns progress data with phases', async () => { + // Create ROADMAP.md for milestone info + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), '## v1.0: First Milestone\n'); + + // Create phase directories with plans/summaries + const phase1 = join(tmpDir, '.planning', 'phases', '01-foundation'); + const phase2 = join(tmpDir, '.planning', 'phases', '02-features'); + await mkdir(phase1, { recursive: true }); + await mkdir(phase2, { recursive: true }); + + await writeFile(join(phase1, '01-01-PLAN.md'), ''); + await writeFile(join(phase1, '01-01-SUMMARY.md'), ''); + await writeFile(join(phase2, '02-01-PLAN.md'), ''); + + const result = await progressJson([], tmpDir); + const data = result.data as Record; + + expect(data.milestone_version).toBe('v1.0'); + expect(data.milestone_name).toBe('First Milestone'); + expect(data.total_plans).toBe(2); + expect(data.total_summaries).toBe(1); + expect(data.percent).toBe(50); + + const phases = data.phases as Array>; + expect(phases.length).toBe(2); + + // Phase 1: 1 plan, 1 summary (dir name 01-foundation => number '01') + expect(phases[0].number).toBe('01'); + expect(phases[0].name).toBe('foundation'); + expect(phases[0].plans).toBe(1); + expect(phases[0].summaries).toBe(1); + + // Phase 2: 1 plan, 0 summaries (dir name 02-features => number '02') + expect(phases[1].number).toBe('02'); + expect(phases[1].plans).toBe(1); + expect(phases[1].summaries).toBe(0); + expect(phases[1].status).toBe('Planned'); + }); + + it('returns 0 percent when no plans', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), '## v1.0: Milestone\n'); + const result = await progressJson([], tmpDir); + const data = result.data as Record; + expect(data.percent).toBe(0); + expect(data.total_plans).toBe(0); + }); + + it('sorts phases by comparePhaseNum order', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), '## v1.0: Milestone\n'); + + const phase10 = join(tmpDir, '.planning', 'phases', '10-later'); + const phase2 = join(tmpDir, '.planning', 'phases', '02-early'); + await mkdir(phase10, { recursive: true }); + await mkdir(phase2, { recursive: true }); + + const result = await progressJson([], tmpDir); + const data = result.data as Record; + const phases = data.phases as Array>; + + expect(phases[0].number).toBe('02'); + expect(phases[1].number).toBe('10'); + }); +}); diff --git a/sdk/src/query/progress.ts b/sdk/src/query/progress.ts new file mode 100644 index 000000000..e0915e264 --- /dev/null +++ b/sdk/src/query/progress.ts @@ -0,0 +1,272 @@ +/** + * Progress query handlers — milestone progress rendering in JSON format. + * + * Ported from get-shit-done/bin/lib/commands.cjs (cmdProgressRender, determinePhaseStatus). + * Provides progress handler that scans disk for plan/summary counts per phase + * and determines status via VERIFICATION.md inspection. + * + * @example + * ```typescript + * import { progressJson } from './progress.js'; + * + * const result = await progressJson([], '/project'); + * // { data: { milestone_version: 'v3.0', phases: [...], total_plans: 6, percent: 83 } } + * ``` + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { comparePhaseNum, normalizePhaseName, planningPaths, toPosixPath } from './helpers.js'; +import { getMilestoneInfo, roadmapAnalyze } from './roadmap.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Internal helpers ───────────────────────────────────────────────────── + +/** + * Determine the status of a phase based on plan/summary counts and verification state. + * + * Port of determinePhaseStatus from commands.cjs lines 15-36. + * + * @param plans - Number of PLAN.md files in the phase directory + * @param summaries - Number of SUMMARY.md files in the phase directory + * @param phaseDir - Absolute path to the phase directory + * @returns Status string: Pending, Planned, In Progress, Executed, Complete, Needs Review + */ +export async function determinePhaseStatus(plans: number, summaries: number, phaseDir: string): Promise { + if (plans === 0) return 'Pending'; + if (summaries < plans && summaries > 0) return 'In Progress'; + if (summaries < plans) return 'Planned'; + + // summaries >= plans — check verification + try { + const files = await readdir(phaseDir); + const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md')); + if (verificationFile) { + const content = await readFile(join(phaseDir, verificationFile), 'utf-8'); + if (/status:\s*passed/i.test(content)) return 'Complete'; + if (/status:\s*human_needed/i.test(content)) return 'Needs Review'; + if (/status:\s*gaps_found/i.test(content)) return 'Executed'; + // Verification exists but unrecognized status — treat as executed + return 'Executed'; + } + } catch { /* directory read failed — fall through */ } + + // No verification file — executed but not verified + return 'Executed'; +} + +// ─── Exported handlers ──────────────────────────────────────────────────── + +/** + * Query handler for progress / progress.json. + * + * Port of cmdProgressRender (JSON format) from commands.cjs lines 535-597. + * Scans phases directory, counts plans/summaries, determines status per phase. + * + * @param args - Unused + * @param projectDir - Project root directory + * @returns QueryResult with milestone progress data + */ +export const progressJson: QueryHandler = async (_args, projectDir) => { + const phasesDir = planningPaths(projectDir).phases; + const milestone = await getMilestoneInfo(projectDir); + + const phases: Array> = []; + let totalPlans = 0; + let totalSummaries = 0; + + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries + .filter(e => e.isDirectory()) + .map(e => e.name) + .sort((a, b) => comparePhaseNum(a, b)); + + for (const dir of dirs) { + const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/); + const phaseNum = dm ? dm[1] : dir; + const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : ''; + const phaseFiles = await readdir(join(phasesDir, dir)); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length; + + totalPlans += plans; + totalSummaries += summaries; + + const status = await determinePhaseStatus(plans, summaries, join(phasesDir, dir)); + + phases.push({ number: phaseNum, name: phaseName, plans, summaries, status }); + } + } catch { /* intentionally empty */ } + + const percent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0; + + return { + data: { + milestone_version: milestone.version, + milestone_name: milestone.name, + phases, + total_plans: totalPlans, + total_summaries: totalSummaries, + percent, + }, + }; +}; + +// ─── progressBar ───────────────────────────────────────────────────────── + +export const progressBar: QueryHandler = async (_args, projectDir) => { + const analysis = await roadmapAnalyze([], projectDir); + const data = analysis.data as Record; + const percent = (data.progress_percent as number) || 0; + const total = 20; + const filled = Math.round((percent / 100) * total); + const bar = '[' + '#'.repeat(filled) + '-'.repeat(total - filled) + ']'; + return { data: { bar: `${bar} ${percent}%`, percent } }; +}; + +// ─── statsJson ─────────────────────────────────────────────────────────── + +export const statsJson: QueryHandler = async (_args, projectDir) => { + const paths = planningPaths(projectDir); + let phasesTotal = 0; + let plansTotal = 0; + let summariesTotal = 0; + let completedPhases = 0; + + if (existsSync(paths.phases)) { + try { + const entries = readdirSync(paths.phases, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + phasesTotal++; + const phaseDir = join(paths.phases, entry.name); + const files = readdirSync(phaseDir); + const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + plansTotal += plans.length; + summariesTotal += summaries.length; + if (summaries.length >= plans.length && plans.length > 0) completedPhases++; + } + } catch { /* skip */ } + } + + const progressPercent = phasesTotal > 0 ? Math.round((completedPhases / phasesTotal) * 100) : 0; + + return { + data: { + phases_total: phasesTotal, + plans_total: plansTotal, + summaries_total: summariesTotal, + completed_phases: completedPhases, + in_progress_phases: phasesTotal - completedPhases, + progress_percent: progressPercent, + }, + }; +}; + +// ─── todoMatchPhase ────────────────────────────────────────────────────── + +export const todoMatchPhase: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + const todosDir = join(projectDir, '.planning', 'todos'); + const todos: Array<{ file: string; phase: string }> = []; + + if (!existsSync(todosDir)) { + return { data: { todos: [], count: 0, phase: phase || null } }; + } + + try { + const files = readdirSync(todosDir).filter(f => f.endsWith('.md') || f.endsWith('.json')); + for (const file of files) { + if (!phase || file.includes(normalizePhaseName(phase)) || file.includes(phase)) { + todos.push({ file: toPosixPath(join('.planning', 'todos', file)), phase: phase || 'all' }); + } + } + } catch { /* skip */ } + + return { data: { todos, count: todos.length, phase: phase || null } }; +}; + +// ─── listTodos ────────────────────────────────────────────────────────── + +/** + * List pending todos from .planning/todos/pending/, optionally filtered by area. + * + * Port of `cmdListTodos` from commands.cjs lines 74-109. + * + * @param args - args[0]: optional area filter + */ +export const listTodos: QueryHandler = async (args, projectDir) => { + const area = args[0] || null; + const pendingDir = join(projectDir, '.planning', 'todos', 'pending'); + + const todos: Array<{ file: string; created: string; title: string; area: string; path: string }> = []; + + try { + const files = readdirSync(pendingDir).filter(f => f.endsWith('.md')); + for (const file of files) { + try { + const content = readFileSync(join(pendingDir, file), 'utf-8'); + const createdMatch = content.match(/^created:\s*(.+)$/m); + const titleMatch = content.match(/^title:\s*(.+)$/m); + const areaMatch = content.match(/^area:\s*(.+)$/m); + + const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; + if (area && todoArea !== area) continue; + + todos.push({ + file, + created: createdMatch ? createdMatch[1].trim() : 'unknown', + title: titleMatch ? titleMatch[1].trim() : 'Untitled', + area: todoArea, + path: toPosixPath(relative(projectDir, join(pendingDir, file))), + }); + } catch { /* skip */ } + } + } catch { /* skip */ } + + return { data: { count: todos.length, todos } }; +}; + +// ─── todoComplete ─────────────────────────────────────────────────────── + +/** + * Move a todo from pending to completed, adding a completion timestamp. + * + * Port of `cmdTodoComplete` from commands.cjs lines 724-749. + * + * @param args - args[0]: filename (required) + */ +export const todoComplete: QueryHandler = async (args, projectDir) => { + const filename = args[0]; + if (!filename) { + throw new (await import('../errors.js')).GSDError( + 'filename required for todo complete', + (await import('../errors.js')).ErrorClassification.Validation, + ); + } + + const pendingDir = join(projectDir, '.planning', 'todos', 'pending'); + const completedDir = join(projectDir, '.planning', 'todos', 'completed'); + const sourcePath = join(pendingDir, filename); + + if (!existsSync(sourcePath)) { + throw new (await import('../errors.js')).GSDError( + `Todo not found: ${filename}`, + (await import('../errors.js')).ErrorClassification.Validation, + ); + } + + mkdirSync(completedDir, { recursive: true }); + + let content = readFileSync(sourcePath, 'utf-8'); + const today = new Date().toISOString().split('T')[0]; + content = `completed: ${today}\n` + content; + + writeFileSync(join(completedDir, filename), content, 'utf-8'); + unlinkSync(sourcePath); + + return { data: { completed: true, file: filename, date: today } }; +}; diff --git a/sdk/src/query/registry.test.ts b/sdk/src/query/registry.test.ts new file mode 100644 index 000000000..967ea9190 --- /dev/null +++ b/sdk/src/query/registry.test.ts @@ -0,0 +1,119 @@ +/** + * Unit tests for QueryRegistry, extractField, and createRegistry factory. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { QueryRegistry, extractField } from './registry.js'; +import { createRegistry } from './index.js'; +import type { QueryResult } from './utils.js'; + +// ─── extractField ────────────────────────────────────────────────────────── + +describe('extractField', () => { + it('extracts nested value with dot notation', () => { + expect(extractField({ a: { b: 1 } }, 'a.b')).toBe(1); + }); + + it('extracts top-level value', () => { + expect(extractField({ slug: 'my-phase' }, 'slug')).toBe('my-phase'); + }); + + it('extracts array element with bracket notation', () => { + expect(extractField({ items: [10, 20, 30] }, 'items[1]')).toBe(20); + }); + + it('extracts array element with negative index', () => { + expect(extractField({ items: [10, 20, 30] }, 'items[-1]')).toBe(30); + }); + + it('returns undefined for null input', () => { + expect(extractField(null, 'a')).toBeUndefined(); + }); + + it('returns undefined for undefined input', () => { + expect(extractField(undefined, 'a')).toBeUndefined(); + }); + + it('returns undefined for missing nested path', () => { + expect(extractField({ a: 1 }, 'b.c')).toBeUndefined(); + }); + + it('returns undefined when bracket access targets non-array', () => { + expect(extractField({ items: 'not-array' }, 'items[0]')).toBeUndefined(); + }); + + it('handles deeply nested paths', () => { + expect(extractField({ a: { b: { c: { d: 42 } } } }, 'a.b.c.d')).toBe(42); + }); + + it('handles mixed dot and bracket notation', () => { + expect(extractField({ data: { items: [{ name: 'x' }] } }, 'data.items[0].name')).toBe('x'); + }); +}); + +// ─── QueryRegistry ───────────────────────────────────────────────────────── + +describe('QueryRegistry', () => { + it('register makes has() return true', () => { + const registry = new QueryRegistry(); + const handler = async () => ({ data: 'test' }); + registry.register('test-cmd', handler); + + expect(registry.has('test-cmd')).toBe(true); + }); + + it('has() returns false for unregistered command', () => { + const registry = new QueryRegistry(); + + expect(registry.has('nonexistent')).toBe(false); + }); + + it('dispatch calls registered handler', async () => { + const registry = new QueryRegistry(); + const handler = vi.fn(async (args: string[], _projectDir: string): Promise => { + return { data: { value: args[0] } }; + }); + registry.register('test-cmd', handler); + + const result = await registry.dispatch('test-cmd', ['arg1'], '/tmp'); + + expect(handler).toHaveBeenCalledWith(['arg1'], '/tmp'); + expect(result).toEqual({ data: { value: 'arg1' } }); + }); + + it('dispatch throws GSDError for unregistered command', async () => { + const registry = new QueryRegistry(); + // Bridge removed in v3.0 — unknown commands throw, not fallback + await expect(registry.dispatch('unknown-cmd', ['arg1'], '/tmp/project')) + .rejects.toThrow('Unknown command: "unknown-cmd"'); + }); +}); + +// ─── createRegistry ──────────────────────────────────────────────────────── + +describe('createRegistry', () => { + it('returns a QueryRegistry instance', () => { + const registry = createRegistry(); + + expect(registry).toBeInstanceOf(QueryRegistry); + }); + + it('has generate-slug registered', () => { + const registry = createRegistry(); + + expect(registry.has('generate-slug')).toBe(true); + }); + + it('has current-timestamp registered', () => { + const registry = createRegistry(); + + expect(registry.has('current-timestamp')).toBe(true); + }); + + it('can dispatch generate-slug', async () => { + const registry = createRegistry(); + const result = await registry.dispatch('generate-slug', ['My Phase'], '/tmp'); + + expect(result).toEqual({ data: { slug: 'my-phase' } }); + }); +}); diff --git a/sdk/src/query/registry.ts b/sdk/src/query/registry.ts new file mode 100644 index 000000000..60212ecd8 --- /dev/null +++ b/sdk/src/query/registry.ts @@ -0,0 +1,121 @@ +/** + * Query command registry — routes commands to native SDK handlers. + * + * The registry is a flat `Map` that maps command names + * to handler functions. Unknown commands throw GSDError — the gsd-tools.cjs + * fallback was removed in v3.0 when all commands were migrated to native handlers. + * + * Also exports `extractField` — a TypeScript port of the `--pick` field + * extraction logic from gsd-tools.cjs (lines 365-382). + * + * @example + * ```typescript + * import { QueryRegistry, extractField } from './registry.js'; + * + * const registry = new QueryRegistry(); + * registry.register('generate-slug', generateSlug); + * const result = await registry.dispatch('generate-slug', ['My Phase'], '/project'); + * const slug = extractField(result.data, 'slug'); // 'my-phase' + * ``` + */ + +import type { QueryResult, QueryHandler } from './utils.js'; +import { GSDError, ErrorClassification } from '../errors.js'; + +// ─── extractField ────────────────────────────────────────────────────────── + +/** + * Extract a nested field from an object using dot-notation and bracket syntax. + * + * Direct port of `extractField()` from gsd-tools.cjs (lines 365-382). + * Supports `a.b.c` dot paths, `items[0]` array indexing, and `items[-1]` + * negative indexing. + * + * @param obj - The object to extract from + * @param fieldPath - Dot-separated path with optional bracket notation + * @returns The extracted value, or undefined if the path doesn't resolve + */ +export function extractField(obj: unknown, fieldPath: string): unknown { + const parts = fieldPath.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined) return undefined; + const bracketMatch = part.match(/^(.+?)\[(-?\d+)]$/); + if (bracketMatch) { + const key = bracketMatch[1]; + const index = parseInt(bracketMatch[2], 10); + current = (current as Record)[key]; + if (!Array.isArray(current)) return undefined; + current = index < 0 ? current[current.length + index] : current[index]; + } else { + current = (current as Record)[part]; + } + } + return current; +} + +// ─── QueryRegistry ───────────────────────────────────────────────────────── + +/** + * Flat command registry that routes query commands to native handlers. + * + * Unknown commands throw `GSDError` from `dispatch()` — there is no fallback + * to gsd-tools.cjs (bridge removed in v3.0). All supported commands must be + * registered via `register()`. + */ +export class QueryRegistry { + private handlers = new Map(); + + /** + * Register a native handler for a command name. + * + * @param command - The command name (e.g., 'generate-slug', 'state.load') + * @param handler - The handler function to invoke + */ + register(command: string, handler: QueryHandler): void { + this.handlers.set(command, handler); + } + + /** + * Check if a command has a registered native handler. + * + * @param command - The command name to check + * @returns True if the command has a native handler + */ + has(command: string): boolean { + return this.handlers.has(command); + } + + /** + * Get the handler for a command without dispatching. + * + * @param command - The command name to look up + * @returns The handler function, or undefined if not registered + */ + getHandler(command: string): QueryHandler | undefined { + return this.handlers.get(command); + } + + /** + * Dispatch a command to its registered native handler. + * + * Throws GSDError for unknown commands — the gsd-tools.cjs fallback was + * removed in v3.0. All commands must be registered as native handlers (T-14-13). + * + * @param command - The command name to dispatch + * @param args - Arguments to pass to the handler + * @param projectDir - The project directory for context + * @returns The query result from the handler + * @throws GSDError if no handler is registered for the command + */ + async dispatch(command: string, args: string[], projectDir: string): Promise { + const handler = this.handlers.get(command); + if (!handler) { + throw new GSDError( + `Unknown command: "${command}". No native handler registered.`, + ErrorClassification.Validation, + ); + } + return handler(args, projectDir); + } +} diff --git a/sdk/src/query/roadmap.test.ts b/sdk/src/query/roadmap.test.ts new file mode 100644 index 000000000..e98fed2cc --- /dev/null +++ b/sdk/src/query/roadmap.test.ts @@ -0,0 +1,275 @@ +/** + * Unit tests for roadmap query handlers. + * + * Tests roadmapAnalyze, roadmapGetPhase, getMilestoneInfo, + * extractCurrentMilestone, and stripShippedMilestones. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// These will be imported once roadmap.ts is created +import { + roadmapAnalyze, + roadmapGetPhase, + getMilestoneInfo, + extractCurrentMilestone, + stripShippedMilestones, +} from './roadmap.js'; + +// ─── Test fixtures ──────────────────────────────────────────────────────── + +const ROADMAP_CONTENT = `# Roadmap + +## Current Milestone: v3.0 SDK-First Migration + +**Goal:** Migrate all deterministic orchestration into TypeScript SDK. + +- [x] **Phase 9: Foundation and Test Infrastructure** +- [ ] **Phase 10: Read-Only Queries** +- [ ] **Phase 11: Mutations** + +### Phase 9: Foundation and Test Infrastructure + +**Goal:** Build core SDK infrastructure. + +**Depends on:** None + +**Success Criteria**: +1. Error classification system exists +2. Query registry works + +### Phase 10: Read-Only Queries + +**Goal:** Port read-only query operations. + +**Depends on:** Phase 9 + +**Success Criteria**: +1. All read queries work +2. Golden file tests pass + +### Phase 11: Mutations + +**Goal:** Port mutation operations. + +**Depends on:** Phase 10 +`; + +const STATE_WITH_MILESTONE = `--- +gsd_state_version: 1.0 +milestone: v3.0 +status: executing +--- + +# Project State + +**Current Phase:** 10 +**Status:** Ready to execute +`; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'roadmap-test-')); + await mkdir(join(tmpDir, '.planning', 'phases', '09-foundation'), { recursive: true }); + await mkdir(join(tmpDir, '.planning', 'phases', '10-read-only-queries'), { recursive: true }); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── stripShippedMilestones ─────────────────────────────────────────────── + +describe('stripShippedMilestones', () => { + it('removes
blocks', () => { + const content = 'before\n
\nshipped content\n
\nafter'; + expect(stripShippedMilestones(content)).toBe('before\n\nafter'); + }); + + it('handles multiple
blocks', () => { + const content = '
a
middle
b
end'; + expect(stripShippedMilestones(content)).toBe('middleend'); + }); + + it('returns content unchanged when no details blocks', () => { + expect(stripShippedMilestones('no details here')).toBe('no details here'); + }); +}); + +// ─── getMilestoneInfo ───────────────────────────────────────────────────── + +describe('getMilestoneInfo', () => { + it('extracts version and name from heading format', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT); + const info = await getMilestoneInfo(tmpDir); + expect(info.version).toBe('v3.0'); + expect(info.name).toBe('SDK-First Migration'); + }); + + it('extracts from in-progress marker format', async () => { + const roadmap = '- \u{1F6A7} **v2.1 Belgium** \u2014 Phases 24-28 (in progress)'; + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + const info = await getMilestoneInfo(tmpDir); + expect(info.version).toBe('v2.1'); + expect(info.name).toBe('Belgium'); + }); + + it('falls back to v1.0 when ROADMAP.md missing', async () => { + const info = await getMilestoneInfo(tmpDir); + expect(info.version).toBe('v1.0'); + expect(info.name).toBe('milestone'); + }); +}); + +// ─── extractCurrentMilestone ────────────────────────────────────────────── + +describe('extractCurrentMilestone', () => { + it('scopes content to current milestone from STATE.md version', async () => { + await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT); + const result = await extractCurrentMilestone(ROADMAP_CONTENT, tmpDir); + expect(result).toContain('Phase 10'); + expect(result).toContain('v3.0'); + }); + + it('strips shipped milestones when no cwd version found', async () => { + const content = '
old
current content'; + // No STATE.md, no in-progress marker + const result = await extractCurrentMilestone(content, tmpDir); + expect(result).toBe('current content'); + }); +}); + +// ─── roadmapGetPhase ────────────────────────────────────────────────────── + +describe('roadmapGetPhase', () => { + it('returns phase info for existing phase', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE); + const result = await roadmapGetPhase(['10'], tmpDir); + const data = result.data as Record; + expect(data.found).toBe(true); + expect(data.phase_number).toBe('10'); + expect(data.phase_name).toBe('Read-Only Queries'); + expect(data.goal).toBe('Port read-only query operations.'); + expect((data.success_criteria as string[]).length).toBe(2); + expect(data.section).toContain('### Phase 10'); + }); + + it('returns { found: false } for nonexistent phase', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE); + const result = await roadmapGetPhase(['999'], tmpDir); + const data = result.data as Record; + expect(data.found).toBe(false); + expect(data.phase_number).toBe('999'); + }); + + it('throws GSDError when no phase number provided', async () => { + await expect(roadmapGetPhase([], tmpDir)).rejects.toThrow(); + }); + + it('handles malformed roadmap (checklist-only, no detail section)', async () => { + const malformed = `# Roadmap\n\n- [ ] **Phase 99: Missing Detail**\n`; + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), malformed); + const result = await roadmapGetPhase(['99'], tmpDir); + const data = result.data as Record; + expect(data.error).toBe('malformed_roadmap'); + expect(data.phase_name).toBe('Missing Detail'); + }); + + it('returns error object when ROADMAP.md not found', async () => { + const result = await roadmapGetPhase(['10'], tmpDir); + const data = result.data as Record; + expect(data.found).toBe(false); + expect(data.error).toBe('ROADMAP.md not found'); + }); +}); + +// ─── roadmapAnalyze ─────────────────────────────────────────────────────── + +describe('roadmapAnalyze', () => { + it('returns full analysis for valid roadmap', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE); + + // Create some plan/summary files for disk correlation + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-PLAN.md'), '---\n---\n'); + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md'), '---\n---\n'); + await writeFile(join(tmpDir, '.planning', 'phases', '10-read-only-queries', '10-01-PLAN.md'), '---\n---\n'); + + const result = await roadmapAnalyze([], tmpDir); + const data = result.data as Record; + + expect(data.phase_count).toBe(3); + expect((data.phases as Array>).length).toBe(3); + + const phases = data.phases as Array>; + // Phase 9 has 1 plan, 1 summary => complete (or roadmap checkbox says complete) + const p9 = phases.find(p => p.number === '9'); + expect(p9).toBeDefined(); + expect(p9!.name).toBe('Foundation and Test Infrastructure'); + expect(p9!.roadmap_complete).toBe(true); // [x] in checklist + + // Phase 10 has 1 plan, 0 summaries => planned + const p10 = phases.find(p => p.number === '10'); + expect(p10).toBeDefined(); + expect(p10!.disk_status).toBe('planned'); + expect(p10!.plan_count).toBe(1); + + // Phase 11 has no directory content + const p11 = phases.find(p => p.number === '11'); + expect(p11).toBeDefined(); + expect(p11!.disk_status).toBe('no_directory'); + + expect(data.total_plans).toBeGreaterThan(0); + expect(typeof data.progress_percent).toBe('number'); + }); + + it('returns error when ROADMAP.md not found', async () => { + const result = await roadmapAnalyze([], tmpDir); + const data = result.data as Record; + expect(data.error).toBe('ROADMAP.md not found'); + }); + + it('overrides disk_status to complete when roadmap checkbox is checked', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE); + + // Phase 9 dir is empty (no plans/summaries) but roadmap has [x] + const result = await roadmapAnalyze([], tmpDir); + const data = result.data as Record; + const phases = data.phases as Array>; + const p9 = phases.find(p => p.number === '9'); + expect(p9!.disk_status).toBe('complete'); + expect(p9!.roadmap_complete).toBe(true); + }); + + it('detects missing phase details from checklist', async () => { + const roadmapWithExtra = ROADMAP_CONTENT + '\n- [ ] **Phase 99: Future Phase**\n'; + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmapWithExtra); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE); + + const result = await roadmapAnalyze([], tmpDir); + const data = result.data as Record; + expect(data.missing_phase_details).toContain('99'); + }); + + it('handles repeated calls correctly (no lastIndex bug)', async () => { + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE_WITH_MILESTONE); + + const result1 = await roadmapAnalyze([], tmpDir); + const result2 = await roadmapAnalyze([], tmpDir); + const data1 = result1.data as Record; + const data2 = result2.data as Record; + + expect((data1.phases as unknown[]).length).toBe((data2.phases as unknown[]).length); + }); +}); diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts new file mode 100644 index 000000000..15bd059c6 --- /dev/null +++ b/sdk/src/query/roadmap.ts @@ -0,0 +1,470 @@ +/** + * Roadmap query handlers — ROADMAP.md analysis and phase lookup. + * + * Ported from get-shit-done/bin/lib/roadmap.cjs and core.cjs. + * Provides roadmap.analyze (multi-pass parsing with disk correlation) + * and roadmap.get-phase (single phase section extraction). + * + * @example + * ```typescript + * import { roadmapAnalyze, roadmapGetPhase } from './roadmap.js'; + * + * const analysis = await roadmapAnalyze([], '/project'); + * // { data: { phases: [...], phase_count: 6, progress_percent: 50, ... } } + * + * const phase = await roadmapGetPhase(['10'], '/project'); + * // { data: { found: true, phase_number: '10', phase_name: 'Read-Only Queries', ... } } + * ``` + */ + +import { readFile, writeFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { + escapeRegex, + normalizePhaseName, + phaseTokenMatches, + planningPaths, +} from './helpers.js'; +import type { QueryHandler, QueryResult } from './utils.js'; + +// ─── Internal types ─────────────────────────────────────────────────────── + +interface PhaseSection { + found: boolean; + phase_number: string; + phase_name: string; + goal?: string | null; + success_criteria?: string[]; + section?: string; + error?: string; + message?: string; +} + +// ─── Exported helpers ───────────────────────────────────────────────────── + +/** + * Strip
...
blocks from content (shipped milestones). + * + * Port of stripShippedMilestones from core.cjs line 1082-1084. + */ +export function stripShippedMilestones(content: string): string { + return content.replace(/
[\s\S]*?<\/details>/gi, ''); +} + +/** + * Get milestone version and name from ROADMAP.md. + * + * Port of getMilestoneInfo from core.cjs lines 1367-1402. + * + * @param projectDir - Project root directory + * @returns Object with version and name + */ +export async function getMilestoneInfo(projectDir: string): Promise<{ version: string; name: string }> { + try { + const roadmap = await readFile(planningPaths(projectDir).roadmap, 'utf-8'); + + // First: check for list-format using in-progress marker + const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/); + if (inProgressMatch) { + return { version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() }; + } + + // Second: heading-format — strip shipped milestones + const cleaned = stripShippedMilestones(roadmap); + const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/); + if (headingMatch) { + return { version: 'v' + headingMatch[1], name: headingMatch[2].trim() }; + } + + // Fallback: bare version match + const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/); + return { + version: versionMatch ? versionMatch[0] : 'v1.0', + name: 'milestone', + }; + } catch { + return { version: 'v1.0', name: 'milestone' }; + } +} + +/** + * Extract the current milestone section from ROADMAP.md. + * + * Port of extractCurrentMilestone from core.cjs lines 1102-1170. + * + * @param content - Full ROADMAP.md content + * @param projectDir - Working directory for reading STATE.md + * @returns Content scoped to current milestone + */ +export async function extractCurrentMilestone(content: string, projectDir: string): Promise { + // Get version from STATE.md frontmatter + let version: string | null = null; + try { + const stateRaw = await readFile(planningPaths(projectDir).state, 'utf-8'); + const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); + if (milestoneMatch) { + version = milestoneMatch[1].trim(); + } + } catch { /* intentionally empty */ } + + // Fallback: derive from ROADMAP in-progress marker + if (!version) { + const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/); + if (inProgressMatch) { + version = 'v' + inProgressMatch[1]; + } + } + + if (!version) return stripShippedMilestones(content); + + // Find section matching this version + const escapedVersion = escapeRegex(version); + const sectionPattern = new RegExp( + `(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, + 'mi' + ); + const sectionMatch = content.match(sectionPattern); + + if (!sectionMatch || sectionMatch.index === undefined) return stripShippedMilestones(content); + + const sectionStart = sectionMatch.index; + + // Find end: next milestone heading at same or higher level, or EOF + const headingLevelMatch = sectionMatch[1].match(/^(#{1,3})\s/); + const headingLevel = headingLevelMatch ? headingLevelMatch[1].length : 2; + const restContent = content.slice(sectionStart + sectionMatch[0].length); + const nextMilestonePattern = new RegExp( + `^#{1,${headingLevel}}\\s+(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, + 'mi' + ); + const nextMatch = restContent.match(nextMilestonePattern); + + let sectionEnd: number; + if (nextMatch && nextMatch.index !== undefined) { + sectionEnd = sectionStart + sectionMatch[0].length + nextMatch.index; + } else { + sectionEnd = content.length; + } + + const beforeMilestones = content.slice(0, sectionStart); + const currentSection = content.slice(sectionStart, sectionEnd); + + // Strip
from preamble + const preamble = beforeMilestones.replace(/
[\s\S]*?<\/details>/gi, ''); + + return preamble + currentSection; +} + +// ─── Internal helpers ───────────────────────────────────────────────────── + +/** + * Search for a phase section in roadmap content. + * + * Port of searchPhaseInContent from roadmap.cjs lines 14-73. + */ +function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSection | null { + // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name + const phasePattern = new RegExp( + `#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, + 'i' + ); + const headerMatch = content.match(phasePattern); + + if (!headerMatch) { + // Fallback: check if phase exists in summary list but missing detail section + const checklistPattern = new RegExp( + `-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`, + 'i' + ); + const checklistMatch = content.match(checklistPattern); + + if (checklistMatch) { + return { + found: false, + phase_number: phaseNum, + phase_name: checklistMatch[1].trim(), + error: 'malformed_roadmap', + message: `Phase ${phaseNum} exists in summary list but missing "### Phase ${phaseNum}:" detail section. ROADMAP.md needs both formats.`, + }; + } + + return null; + } + + const phaseName = headerMatch[1].trim(); + const headerIndex = headerMatch.index!; + + // Find the end of this section (next ## or ### phase header, or end of file) + const restOfContent = content.slice(headerIndex); + const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); + const sectionEnd = nextHeaderMatch + ? headerIndex + nextHeaderMatch.index! + : content.length; + + const section = content.slice(headerIndex, sectionEnd).trim(); + + // Extract goal if present (supports both **Goal:** and **Goal**: formats) + const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i); + const goal = goalMatch ? goalMatch[1].trim() : null; + + // Extract success criteria as structured array + const criteriaMatch = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i); + const success_criteria = criteriaMatch + ? criteriaMatch[1].trim().split('\n').map(line => line.replace(/^\s*\d+\.\s*/, '').trim()).filter(Boolean) + : []; + + return { + found: true, + phase_number: phaseNum, + phase_name: phaseName, + goal, + success_criteria, + section, + }; +} + +// ─── Exported handlers ──────────────────────────────────────────────────── + +/** + * Query handler for roadmap.get-phase. + * + * Port of cmdRoadmapGetPhase from roadmap.cjs lines 75-113. + * + * @param args - args[0] is phase number (required) + * @param projectDir - Project root directory + * @returns QueryResult with phase section info or { found: false } + */ +export const roadmapGetPhase: QueryHandler = async (args, projectDir) => { + const phaseNum = args[0]; + if (!phaseNum) { + throw new GSDError( + 'Usage: roadmap get-phase ', + ErrorClassification.Validation, + ); + } + + const roadmapPath = planningPaths(projectDir).roadmap; + + let rawContent: string; + try { + rawContent = await readFile(roadmapPath, 'utf-8'); + } catch { + return { data: { found: false, error: 'ROADMAP.md not found' } }; + } + + const milestoneContent = await extractCurrentMilestone(rawContent, projectDir); + const escapedPhase = escapeRegex(phaseNum); + + // Search the current milestone slice first, then fall back to full roadmap. + const fullContent = stripShippedMilestones(rawContent); + const milestoneResult = searchPhaseInContent(milestoneContent, escapedPhase, phaseNum); + const result = (milestoneResult && !milestoneResult.error) + ? milestoneResult + : searchPhaseInContent(fullContent, escapedPhase, phaseNum) || milestoneResult; + + if (!result) { + return { data: { found: false, phase_number: phaseNum } }; + } + + return { data: result }; +}; + +/** + * Query handler for roadmap.analyze. + * + * Port of cmdRoadmapAnalyze from roadmap.cjs lines 115-248. + * Multi-pass regex parsing with disk status correlation. + * + * @param args - Unused + * @param projectDir - Project root directory + * @returns QueryResult with full roadmap analysis + */ +export const roadmapAnalyze: QueryHandler = async (_args, projectDir) => { + const roadmapPath = planningPaths(projectDir).roadmap; + + let rawContent: string; + try { + rawContent = await readFile(roadmapPath, 'utf-8'); + } catch { + return { data: { error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null } }; + } + + const content = await extractCurrentMilestone(rawContent, projectDir); + const phasesDir = planningPaths(projectDir).phases; + + // IMPORTANT: Create regex INSIDE the function to avoid /g lastIndex persistence + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; + const phases: Array> = []; + let match: RegExpExecArray | null; + + while ((match = phasePattern.exec(content)) !== null) { + const phaseNum = match[1]; + const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim(); + + // Extract goal from the section + const sectionStart = match.index; + const restOfContent = content.slice(sectionStart); + const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i); + const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : 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 depends_on = dependsMatch ? dependsMatch[1].trim() : null; + + // Check completion on disk + const normalized = normalizePhaseName(phaseNum); + let diskStatus = 'no_directory'; + let planCount = 0; + let summaryCount = 0; + let hasContext = false; + let hasResearch = false; + + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries.filter(e => e.isDirectory()).map(e => e.name); + const dirMatch = dirs.find(d => phaseTokenMatches(d, normalized)); + + if (dirMatch) { + const phaseFiles = await readdir(join(phasesDir, dirMatch)); + planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length; + summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').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'; + } + } catch { /* intentionally empty */ } + + // Check ROADMAP checkbox status + const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s]`, 'i'); + const checkboxMatch = content.match(checkboxPattern); + const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false; + + // If roadmap marks phase complete, trust that over disk + if (roadmapComplete && diskStatus !== 'complete') { + diskStatus = 'complete'; + } + + phases.push({ + number: phaseNum, + name: phaseName, + goal, + depends_on, + plan_count: planCount, + summary_count: summaryCount, + has_context: hasContext, + has_research: hasResearch, + disk_status: diskStatus, + roadmap_complete: roadmapComplete, + }); + } + + // Extract milestone info + const milestones: Array<{ heading: string; version: string }> = []; + const milestonePattern = /##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi; + let mMatch: RegExpExecArray | null; + while ((mMatch = milestonePattern.exec(content)) !== null) { + milestones.push({ + heading: mMatch[1].trim(), + version: 'v' + mMatch[2], + }); + } + + // Find current and next phase + const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null; + const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null; + + // Aggregated stats + const totalPlans = phases.reduce((sum, p) => sum + (p.plan_count as number), 0); + const totalSummaries = phases.reduce((sum, p) => sum + (p.summary_count as number), 0); + const completedPhases = phases.filter(p => p.disk_status === 'complete').length; + + // Detect phases in summary list without detail sections (malformed ROADMAP) + const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi; + const checklistPhases = new Set(); + let checklistMatch: RegExpExecArray | null; + while ((checklistMatch = checklistPattern.exec(content)) !== null) { + checklistPhases.add(checklistMatch[1]); + } + const detailPhases = new Set(phases.map(p => p.number as string)); + const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p)); + + const result: Record = { + milestones, + phases, + phase_count: phases.length, + completed_phases: completedPhases, + total_plans: totalPlans, + total_summaries: totalSummaries, + progress_percent: totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0, + current_phase: currentPhase ? currentPhase.number : null, + next_phase: nextPhase ? nextPhase.number : null, + missing_phase_details: missingDetails.length > 0 ? missingDetails : null, + }; + + return { data: result }; +}; + +// ─── roadmapUpdatePlanProgress ──────────────────────────────────────────── + +export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + const paths = planningPaths(projectDir); + + if (!phase) { + return { data: { updated: false, reason: 'phase argument required' } }; + } + + try { + let content = await readFile(paths.roadmap, 'utf-8'); + const phaseNum = normalizePhaseName(phase); + const updated = content.replace( + /(-\s*\[\s*\]\s*(?:Plan\s+\d+|plan\s+\d+|\*\*Plan))/gi, + (match) => match.replace('[ ]', '[x]'), + ); + if (updated !== content) { + await writeFile(paths.roadmap, updated, 'utf-8'); + return { data: { updated: true, phase: phaseNum } }; + } + return { data: { updated: false, phase: phaseNum, reason: 'no matching checkbox found' } }; + } catch { + return { data: { updated: false, reason: 'ROADMAP.md not found or unreadable' } }; + } +}; + +// ─── requirementsMarkComplete ───────────────────────────────────────────── + +export const requirementsMarkComplete: QueryHandler = async (args, projectDir) => { + const reqIds = args; + const paths = planningPaths(projectDir); + + if (reqIds.length === 0) { + return { data: { marked: false, reason: 'requirement IDs required' } }; + } + + try { + let content = await readFile(paths.requirements, 'utf-8'); + let changeCount = 0; + + for (const id of reqIds) { + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`(-\\s*\\[\\s*\\]\\s*)([^\\n]*${escaped})`, 'gi'); + content = content.replace(pattern, (_m, _bracket, rest) => `- [x] ${rest}`.trim() + '\n' || `- [x] ${rest}`); + if (content.includes(`[x]`) && content.includes(id)) changeCount++; + } + + await writeFile(paths.requirements, content, 'utf-8'); + return { data: { marked: true, ids: reqIds, changed: changeCount } }; + } catch { + return { data: { marked: false, reason: 'REQUIREMENTS.md not found or unreadable' } }; + } +}; diff --git a/sdk/src/query/skills.ts b/sdk/src/query/skills.ts new file mode 100644 index 000000000..4d8dbc86f --- /dev/null +++ b/sdk/src/query/skills.ts @@ -0,0 +1,50 @@ +/** + * Agent skills query handler — scan installed skill directories. + * + * Reads from .claude/skills/, .agents/skills/, .cursor/skills/, .github/skills/, + * and the global ~/.claude/get-shit-done/skills/ directory. + * + * @example + * ```typescript + * import { agentSkills } from './skills.js'; + * + * await agentSkills(['gsd-executor'], '/project'); + * // { data: { agent_type: 'gsd-executor', skills: ['plan', 'verify'], skill_count: 2 } } + * ``` + */ + +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import type { QueryHandler } from './utils.js'; + +export const agentSkills: QueryHandler = async (args, projectDir) => { + const agentType = args[0] || ''; + const skillDirs = [ + join(projectDir, '.claude', 'skills'), + join(projectDir, '.agents', 'skills'), + join(projectDir, '.cursor', 'skills'), + join(projectDir, '.github', 'skills'), + join(homedir(), '.claude', 'get-shit-done', 'skills'), + ]; + + const skills: string[] = []; + for (const dir of skillDirs) { + if (!existsSync(dir)) continue; + try { + const entries = readdirSync(dir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + for (const entry of entries) { + if (entry.isDirectory()) skills.push(entry.name); + } + } catch { /* skip */ } + } + + return { + data: { + agent_type: agentType, + skills: [...new Set(skills)], + skill_count: skills.length, + }, + }; +}; diff --git a/sdk/src/query/state-mutation.test.ts b/sdk/src/query/state-mutation.test.ts new file mode 100644 index 000000000..d0f17d237 --- /dev/null +++ b/sdk/src/query/state-mutation.test.ts @@ -0,0 +1,390 @@ +/** + * Unit tests for STATE.md mutation handlers. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, readFile, rm, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { existsSync } from 'node:fs'; + +// ─── Helpers (internal) ───────────────────────────────────────────────────── + +/** Minimal STATE.md for testing. */ +const MINIMAL_STATE = `--- +gsd_state_version: 1.0 +milestone: v3.0 +milestone_name: SDK-First Migration +status: executing +--- + +# Project State + +## Project Reference + +**Core value:** Test project + +## Current Position + +Phase: 10 (Read-Only Queries) — EXECUTING +Plan: 2 of 3 +Status: Executing Phase 10 +Last activity: 2026-04-08 -- Phase 10 execution started + +Progress: [░░░░░░░░░░] 50% + +## Performance Metrics + +**Velocity:** + +| Phase | Duration | Tasks | Files | +|-------|----------|-------|-------| + +## Accumulated Context + +### Decisions + +None yet. + +### Pending Todos + +None yet. + +### Blockers/Concerns + +None yet. + +## Session Continuity + +Last session: 2026-04-07T10:00:00.000Z +Stopped at: Completed 10-02-PLAN.md +Resume file: None +`; + +/** Create a minimal .planning directory for testing. */ +async function setupTestProject(tmpDir: string, stateContent?: string): Promise { + const planningDir = join(tmpDir, '.planning'); + await mkdir(planningDir, { recursive: true }); + await mkdir(join(planningDir, 'phases'), { recursive: true }); + await writeFile(join(planningDir, 'STATE.md'), stateContent || MINIMAL_STATE, 'utf-8'); + // Minimal ROADMAP.md for buildStateFrontmatter + await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## Current Milestone: v3.0 SDK-First Migration\n\n### Phase 10: Read-Only Queries\n\nGoal: Port queries.\n', 'utf-8'); + await writeFile(join(planningDir, 'config.json'), '{"model_profile":"balanced"}', 'utf-8'); + return tmpDir; +} + +// ─── Import tests ─────────────────────────────────────────────────────────── + +describe('state-mutation imports', () => { + it('exports stateUpdate handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateUpdate).toBe('function'); + }); + + it('exports statePatch handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.statePatch).toBe('function'); + }); + + it('exports stateBeginPhase handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateBeginPhase).toBe('function'); + }); + + it('exports stateAdvancePlan handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateAdvancePlan).toBe('function'); + }); + + it('exports stateRecordMetric handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateRecordMetric).toBe('function'); + }); + + it('exports stateUpdateProgress handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateUpdateProgress).toBe('function'); + }); + + it('exports stateAddDecision handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateAddDecision).toBe('function'); + }); + + it('exports stateAddBlocker handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateAddBlocker).toBe('function'); + }); + + it('exports stateResolveBlocker handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateResolveBlocker).toBe('function'); + }); + + it('exports stateRecordSession handler', async () => { + const mod = await import('./state-mutation.js'); + expect(typeof mod.stateRecordSession).toBe('function'); + }); +}); + +// ─── stateReplaceField ────────────────────────────────────────────────────── + +describe('stateReplaceField', () => { + it('replaces bold format field', async () => { + const { stateReplaceField } = await import('./state-mutation.js'); + const content = '**Status:** executing\n**Plan:** 1'; + const result = stateReplaceField(content, 'Status', 'done'); + expect(result).toContain('**Status:** done'); + }); + + it('replaces plain format field', async () => { + const { stateReplaceField } = await import('./state-mutation.js'); + const content = 'Status: executing\nPlan: 1'; + const result = stateReplaceField(content, 'Status', 'done'); + expect(result).toContain('Status: done'); + }); + + it('returns null when field not found', async () => { + const { stateReplaceField } = await import('./state-mutation.js'); + const result = stateReplaceField('no fields here', 'Missing', 'value'); + expect(result).toBeNull(); + }); + + it('is case-insensitive', async () => { + const { stateReplaceField } = await import('./state-mutation.js'); + const content = '**status:** executing'; + const result = stateReplaceField(content, 'Status', 'done'); + expect(result).toContain('done'); + }); +}); + +// ─── acquireStateLock / releaseStateLock ───────────────────────────────────── + +describe('acquireStateLock / releaseStateLock', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-lock-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('creates and removes lockfile', async () => { + const { acquireStateLock, releaseStateLock } = await import('./state-mutation.js'); + const statePath = join(tmpDir, 'STATE.md'); + await writeFile(statePath, 'test', 'utf-8'); + + const lockPath = await acquireStateLock(statePath); + expect(existsSync(lockPath)).toBe(true); + + await releaseStateLock(lockPath); + expect(existsSync(lockPath)).toBe(false); + }); + + it('tracks lockPath in _heldStateLocks on acquire and removes on release', async () => { + const { acquireStateLock, releaseStateLock, _heldStateLocks } = await import('./state-mutation.js'); + const statePath = join(tmpDir, 'STATE.md'); + await writeFile(statePath, 'test', 'utf-8'); + + const lockPath = await acquireStateLock(statePath); + expect(_heldStateLocks.has(lockPath)).toBe(true); + + await releaseStateLock(lockPath); + expect(_heldStateLocks.has(lockPath)).toBe(false); + }); + + it('returns lockPath on non-EEXIST errors instead of throwing', async () => { + // Simulate a non-EEXIST error by using a path in a non-existent directory + // This triggers ENOENT (not EEXIST), which should return lockPath gracefully + const { acquireStateLock } = await import('./state-mutation.js'); + const badPath = join(tmpDir, 'nonexistent-dir', 'subdir', 'STATE.md'); + + // Should NOT throw — should return lockPath gracefully + const lockPath = await acquireStateLock(badPath); + expect(lockPath).toBe(badPath + '.lock'); + }); +}); + +// ─── stateUpdate ──────────────────────────────────────────────────────────── + +describe('stateUpdate', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-update-')); + await setupTestProject(tmpDir); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('updates a single field and round-trips through stateLoad', async () => { + const { stateUpdate } = await import('./state-mutation.js'); + const { stateLoad } = await import('./state.js'); + + const result = await stateUpdate(['Status', 'Phase complete'], tmpDir); + const data = result.data as Record; + expect(data.updated).toBe(true); + + // Verify round-trip + const loaded = await stateLoad([], tmpDir); + const loadedData = loaded.data as Record; + // Status gets normalized by buildStateFrontmatter + expect(loadedData.status).toBeTruthy(); + }); + + it('returns updated false when field not found', async () => { + const { stateUpdate } = await import('./state-mutation.js'); + + const result = await stateUpdate(['NonExistentField', 'value'], tmpDir); + const data = result.data as Record; + expect(data.updated).toBe(false); + }); + + it('throws on missing args', async () => { + const { stateUpdate } = await import('./state-mutation.js'); + + await expect(stateUpdate([], tmpDir)).rejects.toThrow(/field and value required/); + }); +}); + +// ─── statePatch ───────────────────────────────────────────────────────────── + +describe('statePatch', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-patch-')); + await setupTestProject(tmpDir); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('updates multiple fields in one lock cycle', async () => { + const { statePatch } = await import('./state-mutation.js'); + + const patches = JSON.stringify({ Status: 'done', Progress: '100%' }); + const result = await statePatch([patches], tmpDir); + const data = result.data as Record; + expect(data.patched).toBe(true); + + // Verify file was updated + const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + expect(content).toContain('done'); + }); +}); + +// ─── stateBeginPhase ──────────────────────────────────────────────────────── + +describe('stateBeginPhase', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-begin-')); + await setupTestProject(tmpDir); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('sets all expected fields', async () => { + const { stateBeginPhase } = await import('./state-mutation.js'); + + const result = await stateBeginPhase(['11', 'State Mutations', '3'], tmpDir); + const data = result.data as Record; + expect(data.phase).toBe('11'); + + const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + expect(content).toContain('Executing Phase 11'); + expect(content).toContain('State Mutations'); + }); +}); + +// ─── stateAdvancePlan ─────────────────────────────────────────────────────── + +describe('stateAdvancePlan', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-advance-')); + await setupTestProject(tmpDir); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('increments plan counter', async () => { + const { stateAdvancePlan } = await import('./state-mutation.js'); + + const result = await stateAdvancePlan([], tmpDir); + const data = result.data as Record; + expect(data.advanced).toBe(true); + expect(data.current_plan).toBe(3); + }); +}); + +// ─── stateAddDecision ─────────────────────────────────────────────────────── + +describe('stateAddDecision', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-decision-')); + await setupTestProject(tmpDir); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('appends decision and removes placeholder', async () => { + const { stateAddDecision } = await import('./state-mutation.js'); + + const result = await stateAddDecision(['[Phase 10]: Use lockfile atomicity'], tmpDir); + const data = result.data as Record; + expect(data.added).toBe(true); + + const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + expect(content).toContain('Use lockfile atomicity'); + // Verify "None yet." was removed from the Decisions section specifically + const decisionsMatch = content.match(/###?\s*Decisions\s*\n([\s\S]*?)(?=\n###?|\n##[^#]|$)/i); + expect(decisionsMatch).not.toBeNull(); + expect(decisionsMatch![1]).not.toContain('None yet.'); + }); +}); + +// ─── stateRecordSession ───────────────────────────────────────────────────── + +describe('stateRecordSession', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-session-')); + await setupTestProject(tmpDir); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('updates session fields', async () => { + const { stateRecordSession } = await import('./state-mutation.js'); + + const result = await stateRecordSession( + ['2026-04-08T12:00:00Z', 'Completed 11-01-PLAN.md'], + tmpDir + ); + const data = result.data as Record; + expect(data.recorded).toBe(true); + + const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + expect(content).toContain('Completed 11-01-PLAN.md'); + }); +}); diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts new file mode 100644 index 000000000..e75e918b9 --- /dev/null +++ b/sdk/src/query/state-mutation.ts @@ -0,0 +1,737 @@ +/** + * STATE.md mutation handlers — write operations with lockfile atomicity. + * + * Ported from get-shit-done/bin/lib/state.cjs. + * Provides all STATE.md mutation commands: update, patch, begin-phase, + * advance-plan, record-metric, update-progress, add-decision, add-blocker, + * resolve-blocker, record-session. + * + * All writes go through readModifyWriteStateMd which acquires a lockfile, + * applies the modifier, syncs frontmatter, normalizes markdown, and writes. + * + * @example + * ```typescript + * import { stateUpdate, stateBeginPhase } from './state-mutation.js'; + * + * await stateUpdate(['Status', 'executing'], '/project'); + * await stateBeginPhase(['11', 'State Mutations', '3'], '/project'); + * ``` + */ + +import { open, unlink, stat, readFile, writeFile, readdir } from 'node:fs/promises'; +import { constants, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { extractFrontmatter, stripFrontmatter } from './frontmatter.js'; +import { reconstructFrontmatter, spliceFrontmatter } from './frontmatter-mutation.js'; +import { escapeRegex, stateExtractField, planningPaths, normalizeMd } from './helpers.js'; +import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Process exit lock cleanup (D2 — match CJS state.cjs:16-23) ───────── + +/** + * Module-level set tracking held locks for process.on('exit') cleanup. + * Exported for test access only. + */ +export const _heldStateLocks = new Set(); + +process.on('exit', () => { + for (const lockPath of _heldStateLocks) { + try { unlinkSync(lockPath); } catch { /* already gone */ } + } +}); + +// ─── stateReplaceField ──────────────────────────────────────────────────── + +/** + * Replace a field value in STATE.md content. + * + * Uses separate regex instances (no g flag) to avoid lastIndex persistence. + * Supports both **bold:** and plain: formats. + * + * @param content - STATE.md content + * @param fieldName - Field name to replace + * @param newValue - New value to set + * @returns Updated content, or null if field not found + */ +export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null { + const escaped = escapeRegex(fieldName); + // Try **Field:** bold format first + const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'); + if (boldPattern.test(content)) { + return content.replace(new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'), (_match, prefix: string) => `${prefix}${newValue}`); + } + // Try plain Field: format + const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im'); + if (plainPattern.test(content)) { + return content.replace(new RegExp(`(^${escaped}:\\s*)(.*)`, 'im'), (_match, prefix: string) => `${prefix}${newValue}`); + } + return null; +} + +/** + * Replace a field with fallback field name support. + * + * Tries primary first, then fallback. Returns content unchanged if neither matches. + */ +function stateReplaceFieldWithFallback(content: string, primary: string, fallback: string | null, value: string): string { + let result = stateReplaceField(content, primary, value); + if (result) return result; + if (fallback) { + result = stateReplaceField(content, fallback, value); + if (result) return result; + } + return content; +} + +/** + * Update fields within the ## Current Position section. + * + * Only updates fields that already exist in the section. + */ +function updateCurrentPositionFields(content: string, fields: Record): string { + const posPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + const posMatch = content.match(posPattern); + if (!posMatch) return content; + + let posBody = posMatch[2]; + + if (fields.status && /^Status:/m.test(posBody)) { + posBody = posBody.replace(/^Status:.*$/m, `Status: ${fields.status}`); + } + if (fields.lastActivity && /^Last activity:/im.test(posBody)) { + posBody = posBody.replace(/^Last activity:.*$/im, `Last activity: ${fields.lastActivity}`); + } + if (fields.plan && /^Plan:/m.test(posBody)) { + posBody = posBody.replace(/^Plan:.*$/m, `Plan: ${fields.plan}`); + } + + return content.replace(posPattern, `${posMatch[1]}${posBody}`); +} + +// ─── Lockfile helpers ───────────────────────────────────────────────────── + +/** + * Acquire a lockfile for STATE.md operations. + * + * Uses O_CREAT|O_EXCL for atomic creation. Retries up to 10 times with + * 200ms + jitter delay. Cleans stale locks older than 10 seconds. + * + * @param statePath - Path to STATE.md + * @returns Path to the lockfile + */ +export async function acquireStateLock(statePath: string): Promise { + const lockPath = statePath + '.lock'; + const maxRetries = 10; + const retryDelay = 200; + + for (let i = 0; i < maxRetries; i++) { + try { + const fd = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); + await fd.writeFile(String(process.pid)); + await fd.close(); + _heldStateLocks.add(lockPath); + return lockPath; + } catch (err: unknown) { + if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') { + try { + const s = await stat(lockPath); + if (Date.now() - s.mtimeMs > 10000) { + await unlink(lockPath); + continue; + } + } catch { /* lock released between check */ } + + if (i === maxRetries - 1) { + try { await unlink(lockPath); } catch { /* ignore */ } + return lockPath; + } + await new Promise(r => setTimeout(r, retryDelay + Math.floor(Math.random() * 50))); + } else { + // D3: Graceful degradation on non-EEXIST errors (match CJS state.cjs:889) + return lockPath; + } + } + } + return lockPath; +} + +/** + * Release a lockfile. + * + * @param lockPath - Path to the lockfile to release + */ +export async function releaseStateLock(lockPath: string): Promise { + _heldStateLocks.delete(lockPath); + try { await unlink(lockPath); } catch { /* already gone */ } +} + +// ─── Frontmatter sync + write helpers ───────────────────────────────────── + +/** + * Sync STATE.md content with rebuilt YAML frontmatter. + * + * Strips existing frontmatter, rebuilds from body + disk, and splices back. + * Preserves existing status when body-derived status is 'unknown'. + */ +async function syncStateFrontmatter(content: string, projectDir: string): Promise { + const existingFm = extractFrontmatter(content); + const body = stripFrontmatter(content); + const derivedFm = await buildStateFrontmatter(body, projectDir); + + // Preserve existing status when body-derived is 'unknown' + if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') { + derivedFm.status = existingFm.status; + } + + const yamlStr = reconstructFrontmatter(derivedFm); + return `---\n${yamlStr}\n---\n\n${body}`; +} + +/** + * Atomic read-modify-write for STATE.md. + * + * Holds lock across the entire read -> transform -> write cycle. + * + * @param projectDir - Project root directory + * @param modifier - Function to transform STATE.md content + * @returns The final written content + */ +async function readModifyWriteStateMd( + projectDir: string, + modifier: (content: string) => string | Promise +): Promise { + const statePath = planningPaths(projectDir).state; + const lockPath = await acquireStateLock(statePath); + try { + let content: string; + try { + content = await readFile(statePath, 'utf-8'); + } catch { + content = ''; + } + // Strip frontmatter before passing to modifier so that regex replacements + // operate on body fields only (not on YAML frontmatter keys like 'status:'). + // syncStateFrontmatter rebuilds frontmatter from the modified body + disk. + const body = stripFrontmatter(content); + const modified = await modifier(body); + const synced = await syncStateFrontmatter(modified, projectDir); + const normalized = normalizeMd(synced); + await writeFile(statePath, normalized, 'utf-8'); + return normalized; + } finally { + await releaseStateLock(lockPath); + } +} + +// ─── Exported handlers ──────────────────────────────────────────────────── + +/** + * Query handler for state.update command. + * + * Replaces a single field in STATE.md. + * + * @param args - args[0]: field name, args[1]: new value + * @param projectDir - Project root directory + * @returns QueryResult with { updated: true/false, field, value } + */ +export const stateUpdate: QueryHandler = async (args, projectDir) => { + const field = args[0]; + const value = args[1]; + + if (!field || value === undefined) { + throw new GSDError('field and value required for state update', ErrorClassification.Validation); + } + + let updated = false; + await readModifyWriteStateMd(projectDir, (content) => { + const result = stateReplaceField(content, field, value); + if (result) { + updated = true; + return result; + } + return content; + }); + + return { data: { updated, field, value: updated ? value : undefined } }; +}; + +/** + * Query handler for state.patch command. + * + * Replaces multiple fields atomically in one lock cycle. + * + * @param args - args[0]: JSON string of { field: value } pairs + * @param projectDir - Project root directory + * @returns QueryResult with { patched: true, fields: [...] } + */ +export const statePatch: QueryHandler = async (args, projectDir) => { + const jsonString = args[0]; + if (!jsonString) { + throw new GSDError('JSON patches required', ErrorClassification.Validation); + } + + let patches: Record; + try { + patches = JSON.parse(jsonString) as Record; + } catch { + throw new GSDError('Invalid JSON for patches', ErrorClassification.Validation); + } + + const updatedFields: string[] = []; + await readModifyWriteStateMd(projectDir, (content) => { + for (const [field, value] of Object.entries(patches)) { + const result = stateReplaceField(content, field, String(value)); + if (result) { + content = result; + updatedFields.push(field); + } + } + return content; + }); + + return { data: { patched: updatedFields.length > 0, fields: updatedFields } }; +}; + +/** + * Query handler for state.begin-phase command. + * + * Sets phase, plan, status, progress, and current focus fields. + * Rewrites the Current Position section. + * + * @param args - args[0]: phase number, args[1]: phase name, args[2]: plan count + * @param projectDir - Project root directory + * @returns QueryResult with { phase, name, plan_count } + */ +export const stateBeginPhase: QueryHandler = async (args, projectDir) => { + const phaseNumber = args[0]; + const phaseName = args[1] || ''; + const planCount = args[2] || '?'; + + if (!phaseNumber) { + throw new GSDError('phase number required', ErrorClassification.Validation); + } + + const today = new Date().toISOString().split('T')[0]; + + await readModifyWriteStateMd(projectDir, (content) => { + // Update bold/plain fields + const statusValue = `Executing Phase ${phaseNumber}`; + content = stateReplaceField(content, 'Status', statusValue) || content; + content = stateReplaceField(content, 'Last Activity', today) || content; + + const activityDesc = `Phase ${phaseNumber} execution started`; + content = stateReplaceField(content, 'Last Activity Description', activityDesc) || content; + content = stateReplaceField(content, 'Current Phase', String(phaseNumber)) || content; + + if (phaseName) { + content = stateReplaceField(content, 'Current Phase Name', phaseName) || content; + } + + content = stateReplaceField(content, 'Current Plan', '1') || content; + + if (planCount !== '?') { + content = stateReplaceField(content, 'Total Plans in Phase', String(planCount)) || content; + } + + // Update **Current focus:** + const focusLabel = phaseName ? `Phase ${phaseNumber} — ${phaseName}` : `Phase ${phaseNumber}`; + const focusPattern = /(\*\*Current focus:\*\*\s*).*/i; + if (focusPattern.test(content)) { + content = content.replace(focusPattern, (_match, prefix: string) => `${prefix}${focusLabel}`); + } + + // Update ## Current Position section + const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i; + const positionMatch = content.match(positionPattern); + if (positionMatch) { + const header = positionMatch[1]; + let posBody = positionMatch[2]; + + const newPhase = `Phase: ${phaseNumber}${phaseName ? ` (${phaseName})` : ''} — EXECUTING`; + if (/^Phase:/m.test(posBody)) { + posBody = posBody.replace(/^Phase:.*$/m, newPhase); + } else { + posBody = newPhase + '\n' + posBody; + } + + const newPlan = `Plan: 1 of ${planCount}`; + if (/^Plan:/m.test(posBody)) { + posBody = posBody.replace(/^Plan:.*$/m, newPlan); + } else { + posBody = posBody.replace(/^(Phase:.*$)/m, `$1\n${newPlan}`); + } + + const newStatus = `Status: Executing Phase ${phaseNumber}`; + if (/^Status:/m.test(posBody)) { + posBody = posBody.replace(/^Status:.*$/m, newStatus); + } + + const newActivity = `Last activity: ${today} -- Phase ${phaseNumber} execution started`; + if (/^Last activity:/im.test(posBody)) { + posBody = posBody.replace(/^Last activity:.*$/im, newActivity); + } + + content = content.replace(positionPattern, `${header}${posBody}`); + } + + return content; + }); + + return { data: { phase: phaseNumber, name: phaseName || null, plan_count: planCount } }; +}; + +/** + * Query handler for state.advance-plan command. + * + * Increments plan counter. Detects phase completion when at last plan. + * + * @param args - unused + * @param projectDir - Project root directory + * @returns QueryResult with { advanced, current_plan, total_plans } + */ +export const stateAdvancePlan: QueryHandler = async (_args, projectDir) => { + const today = new Date().toISOString().split('T')[0]; + let result: Record = { error: 'STATE.md not found' }; + + await readModifyWriteStateMd(projectDir, (content) => { + // Parse current plan info (content already has frontmatter stripped) + const legacyPlan = stateExtractField(content, 'Current Plan'); + const legacyTotal = stateExtractField(content, 'Total Plans in Phase'); + const planField = stateExtractField(content, 'Plan'); + + let currentPlan: number; + let totalPlans: number; + let useCompoundFormat = false; + let compoundPlanField: string | null = null; + + if (legacyPlan && legacyTotal) { + currentPlan = parseInt(legacyPlan, 10); + totalPlans = parseInt(legacyTotal, 10); + } else if (planField) { + currentPlan = parseInt(planField, 10); + const ofMatch = planField.match(/of\s+(\d+)/); + totalPlans = ofMatch ? parseInt(ofMatch[1], 10) : NaN; + useCompoundFormat = true; + compoundPlanField = planField; + } else { + result = { error: 'Cannot parse Current Plan or Total Plans from STATE.md' }; + return content; + } + + if (isNaN(currentPlan) || isNaN(totalPlans)) { + result = { error: 'Cannot parse Current Plan or Total Plans from STATE.md' }; + return content; + } + + if (currentPlan >= totalPlans) { + // Phase complete + content = stateReplaceFieldWithFallback(content, 'Status', null, 'Phase complete — ready for verification'); + content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today); + content = updateCurrentPositionFields(content, { + status: 'Phase complete — ready for verification', + lastActivity: today, + }); + result = { advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans }; + return content; + } + + // Advance to next plan + const newPlan = currentPlan + 1; + let planDisplayValue: string; + if (useCompoundFormat && compoundPlanField) { + planDisplayValue = compoundPlanField.replace(/^\d+/, String(newPlan)); + content = stateReplaceField(content, 'Plan', planDisplayValue) || content; + } else { + planDisplayValue = `${newPlan} of ${totalPlans}`; + content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content; + } + content = stateReplaceFieldWithFallback(content, 'Status', null, 'Ready to execute'); + content = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', today); + content = updateCurrentPositionFields(content, { + status: 'Ready to execute', + lastActivity: today, + plan: planDisplayValue, + }); + result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }; + return content; + }); + + return { data: result }; +}; + +/** + * Query handler for state.record-metric command. + * + * Appends a row to the Performance Metrics table. + * + * @param args - args[0]: phase, args[1]: plan, args[2]: duration, args[3]: tasks, args[4]: files + * @param projectDir - Project root directory + * @returns QueryResult with { recorded: true/false } + */ +export const stateRecordMetric: QueryHandler = async (args, projectDir) => { + const phase = args[0]; + const plan = args[1]; + const duration = args[2]; + const tasks = args[3] || '-'; + const files = args[4] || '-'; + + if (!phase || !plan || !duration) { + throw new GSDError('phase, plan, and duration required', ErrorClassification.Validation); + } + + let recorded = false; + await readModifyWriteStateMd(projectDir, (content) => { + const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i; + const metricsMatch = content.match(metricsPattern); + + if (metricsMatch) { + let tableBody = metricsMatch[2].trimEnd(); + const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks} tasks | ${files} files |`; + + if (tableBody.trim() === '' || tableBody.includes('None yet')) { + tableBody = newRow; + } else { + tableBody = tableBody + '\n' + newRow; + } + + content = content.replace(metricsPattern, (_match, header: string) => `${header}${tableBody}\n`); + recorded = true; + } + return content; + }); + + return { data: { recorded } }; +}; + +/** + * Query handler for state.update-progress command. + * + * Scans disk to count completed/total plans and updates progress bar. + * + * @param args - unused + * @param projectDir - Project root directory + * @returns QueryResult with { updated, percent, completed, total } + */ +export const stateUpdateProgress: QueryHandler = async (_args, projectDir) => { + const phasesDir = planningPaths(projectDir).phases; + let totalPlans = 0; + let totalSummaries = 0; + + try { + const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const entries = await readdir(phasesDir, { withFileTypes: true }); + const phaseDirs = entries + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(isDirInMilestone); + + for (const dir of phaseDirs) { + const files = await readdir(join(phasesDir, dir)); + totalPlans += files.filter(f => /-PLAN\.md$/i.test(f)).length; + totalSummaries += files.filter(f => /-SUMMARY\.md$/i.test(f)).length; + } + } catch { /* phases dir may not exist */ } + + const percent = totalPlans > 0 ? Math.min(100, Math.round(totalSummaries / totalPlans * 100)) : 0; + const barWidth = 10; + const filled = Math.round(percent / 100 * barWidth); + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled); + const progressStr = `[${bar}] ${percent}%`; + + let updated = false; + await readModifyWriteStateMd(projectDir, (content) => { + const result = stateReplaceField(content, 'Progress', progressStr); + if (result) { + updated = true; + return result; + } + return content; + }); + + return { data: { updated, percent, completed: totalSummaries, total: totalPlans, bar: progressStr } }; +}; + +/** + * Query handler for state.add-decision command. + * + * Appends a decision to the Decisions section. Removes placeholder text. + * + * @param args - args[0]: decision text (e.g., "[Phase 10]: Use lockfile atomicity") + * @param projectDir - Project root directory + * @returns QueryResult with { added: true/false } + */ +export const stateAddDecision: QueryHandler = async (args, projectDir) => { + const decisionText = args[0]; + if (!decisionText) { + throw new GSDError('decision text required', ErrorClassification.Validation); + } + + const entry = `- ${decisionText}`; + let added = false; + + await readModifyWriteStateMd(projectDir, (content) => { + const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + let sectionBody = match[2]; + // Remove placeholders + sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, ''); + sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; + content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); + added = true; + } + return content; + }); + + return { data: { added, decision: added ? entry : undefined } }; +}; + +/** + * Query handler for state.add-blocker command. + * + * Appends a blocker to the Blockers section. + * + * @param args - args[0]: blocker text + * @param projectDir - Project root directory + * @returns QueryResult with { added: true/false } + */ +export const stateAddBlocker: QueryHandler = async (args, projectDir) => { + const blockerText = args[0]; + if (!blockerText) { + throw new GSDError('blocker text required', ErrorClassification.Validation); + } + + const entry = `- ${blockerText}`; + let added = false; + + await readModifyWriteStateMd(projectDir, (content) => { + const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + let sectionBody = match[2]; + sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, ''); + sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n'; + content = content.replace(sectionPattern, (_match, header: string) => `${header}${sectionBody}`); + added = true; + } + return content; + }); + + return { data: { added, blocker: added ? blockerText : undefined } }; +}; + +/** + * Query handler for state.resolve-blocker command. + * + * Removes the first blocker line matching the search text. + * + * @param args - args[0]: search text to match against blocker lines + * @param projectDir - Project root directory + * @returns QueryResult with { resolved: true/false } + */ +export const stateResolveBlocker: QueryHandler = async (args, projectDir) => { + const searchText = args[0]; + if (!searchText) { + throw new GSDError('search text required', ErrorClassification.Validation); + } + + let resolved = false; + + await readModifyWriteStateMd(projectDir, (content) => { + const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i; + const match = content.match(sectionPattern); + + if (match) { + const sectionBody = match[2]; + const lines = sectionBody.split('\n'); + const filtered = lines.filter(line => { + if (!line.startsWith('- ')) return true; + return !line.toLowerCase().includes(searchText.toLowerCase()); + }); + + let newBody = filtered.join('\n'); + if (!newBody.trim() || !newBody.includes('- ')) { + newBody = 'None\n'; + } + + content = content.replace(sectionPattern, (_match, header: string) => `${header}${newBody}`); + resolved = true; + } + return content; + }); + + return { data: { resolved } }; +}; + +/** + * Query handler for state.record-session command. + * + * Updates Session Continuity fields: Last session, Stopped at, Resume file. + * + * @param args - args[0]: timestamp (optional), args[1]: stopped-at text, args[2]: resume file + * @param projectDir - Project root directory + * @returns QueryResult with { recorded: true/false } + */ +export const stateRecordSession: QueryHandler = async (args, projectDir) => { + const timestamp = args[0] || new Date().toISOString(); + const stoppedAt = args[1]; + const resumeFile = args[2] || 'None'; + + const updated: string[] = []; + + await readModifyWriteStateMd(projectDir, (content) => { + // Update Last session / Last Date + let result = stateReplaceField(content, 'Last session', timestamp); + if (result) { content = result; updated.push('Last session'); } + result = stateReplaceField(content, 'Last Date', timestamp); + if (result) { content = result; updated.push('Last Date'); } + + // Update Stopped at + if (stoppedAt) { + result = stateReplaceField(content, 'Stopped At', stoppedAt); + if (!result) result = stateReplaceField(content, 'Stopped at', stoppedAt); + if (result) { content = result; updated.push('Stopped At'); } + } + + // Update Resume file + result = stateReplaceField(content, 'Resume File', resumeFile); + if (!result) result = stateReplaceField(content, 'Resume file', resumeFile); + if (result) { content = result; updated.push('Resume File'); } + + return content; + }); + + return { data: { recorded: updated.length > 0, updated } }; +}; + +// ─── statePlannedPhase ──────────────────────────────────────────────────── + +export const statePlannedPhase: QueryHandler = async (args, projectDir) => { + const phaseArg = args.find((a, i) => args[i - 1] === '--phase') || args[0]; + const nameArg = args.find((a, i) => args[i - 1] === '--name') || ''; + const plansArg = args.find((a, i) => args[i - 1] === '--plans') || '0'; + const paths = planningPaths(projectDir); + + if (!phaseArg) { + return { data: { updated: false, reason: '--phase argument required' } }; + } + + try { + let content = await readFile(paths.state, 'utf-8'); + const timestamp = new Date().toISOString(); + const record = `\n**Planned Phase:** ${phaseArg} (${nameArg}) — ${plansArg} plans — ${timestamp}\n`; + if (/\*\*Planned Phase:\*\*/.test(content)) { + content = content.replace(/\*\*Planned Phase:\*\*[^\n]*\n/, record); + } else { + content += record; + } + await writeFile(paths.state, content, 'utf-8'); + return { data: { updated: true, phase: phaseArg, name: nameArg, plans: plansArg } }; + } catch { + return { data: { updated: false, reason: 'STATE.md not found or unreadable' } }; + } +}; diff --git a/sdk/src/query/state.test.ts b/sdk/src/query/state.test.ts new file mode 100644 index 000000000..4a460bf25 --- /dev/null +++ b/sdk/src/query/state.test.ts @@ -0,0 +1,347 @@ +/** + * Unit tests for state query handlers. + * + * Tests stateLoad, stateGet, and stateSnapshot handlers. + * Uses temp directories with real .planning/ structures. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Will be imported once implemented +import { stateLoad, stateGet, stateSnapshot } from './state.js'; + +// ─── Fixtures ────────────────────────────────────────────────────────────── + +const STATE_BODY = `# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-04-07) + +**Core value:** Improve the project. +**Current focus:** Phase 10 + +## Current Position + +Phase: 10 (Read-Only Queries) — EXECUTING +Plan: 2 of 3 +Status: Ready to execute +Last Activity: 2026-04-08 +Last Activity Description: Completed plan 01 + +Progress: [████░░░░░░] 40% + +## Decisions Made + +Recent decisions affecting current work: + +| Phase | Summary | Rationale | +|-------|---------|-----------| +| 09 | Used GSDError pattern | Consistent with existing SDK errors | +| 10 | Temp dir test pattern | ESM spy limitations | + +## Blockers + +- STATE.md parsing edge cases need audit +- Verification rule inventory needs review + +## Session + +Last session: 2026-04-08T05:00:00Z +Stopped At: Completed 10-01-PLAN.md +Resume File: None +`; + +const STATE_WITH_FRONTMATTER = `--- +gsd_state_version: 1.0 +milestone: v3.0 +milestone_name: SDK-First Migration +status: executing +stopped_at: Completed 10-01-PLAN.md +last_updated: "2026-04-08T05:01:21.919Z" +--- + +${STATE_BODY}`; + +const ROADMAP_CONTENT = `# Roadmap + +## Roadmap v3.0: SDK-First Migration + +### Phase 09: Foundation +- Build infrastructure + +### Phase 10: Read-Only Queries +- Port state queries + +### Phase 11: Mutations +- Port write operations +`; + +let tmpDir: string; + +// ─── Setup / Teardown ────────────────────────────────────────────────────── + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-state-test-')); + const planningDir = join(tmpDir, '.planning'); + const phasesDir = join(planningDir, 'phases'); + + // Create .planning structure + await mkdir(phasesDir, { recursive: true }); + + // Create STATE.md with frontmatter + await writeFile(join(planningDir, 'STATE.md'), STATE_WITH_FRONTMATTER); + + // Create ROADMAP.md + await writeFile(join(planningDir, 'ROADMAP.md'), ROADMAP_CONTENT); + + // Create config.json + await writeFile(join(planningDir, 'config.json'), JSON.stringify({ + model_profile: 'quality', + workflow: { auto_advance: true }, + })); + + // Create phase directories with plans and summaries + const phase09 = join(phasesDir, '09-foundation'); + await mkdir(phase09, { recursive: true }); + await writeFile(join(phase09, '09-01-PLAN.md'), '---\nphase: 09\nplan: 01\n---\nPlan 1'); + await writeFile(join(phase09, '09-01-SUMMARY.md'), 'Summary 1'); + await writeFile(join(phase09, '09-02-PLAN.md'), '---\nphase: 09\nplan: 02\n---\nPlan 2'); + await writeFile(join(phase09, '09-02-SUMMARY.md'), 'Summary 2'); + await writeFile(join(phase09, '09-03-PLAN.md'), '---\nphase: 09\nplan: 03\n---\nPlan 3'); + await writeFile(join(phase09, '09-03-SUMMARY.md'), 'Summary 3'); + + const phase10 = join(phasesDir, '10-read-only-queries'); + await mkdir(phase10, { recursive: true }); + await writeFile(join(phase10, '10-01-PLAN.md'), '---\nphase: 10\nplan: 01\n---\nPlan 1'); + await writeFile(join(phase10, '10-01-SUMMARY.md'), 'Summary 1'); + await writeFile(join(phase10, '10-02-PLAN.md'), '---\nphase: 10\nplan: 02\n---\nPlan 2'); + await writeFile(join(phase10, '10-03-PLAN.md'), '---\nphase: 10\nplan: 03\n---\nPlan 3'); + + const phase11 = join(phasesDir, '11-mutations'); + await mkdir(phase11, { recursive: true }); + await writeFile(join(phase11, '11-01-PLAN.md'), '---\nphase: 11\nplan: 01\n---\nPlan 1'); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── stateLoad ───────────────────────────────────────────────────────────── + +describe('stateLoad', () => { + it('rebuilds frontmatter from body + disk', async () => { + const result = await stateLoad([], tmpDir); + const data = result.data as Record; + + expect(data.gsd_state_version).toBe('1.0'); + expect(data.milestone).toBe('v3.0'); + expect(data.milestone_name).toBe('SDK-First Migration'); + expect(data.status).toBe('executing'); + expect(data.last_updated).toBeDefined(); + }); + + it('returns progress with disk-scanned counts', async () => { + const result = await stateLoad([], tmpDir); + const data = result.data as Record; + const progress = data.progress as Record; + + // 3 phases in roadmap (09, 10, 11), 7 total plans, 4 summaries + expect(progress.total_phases).toBe(3); + expect(progress.total_plans).toBe(7); + expect(progress.completed_plans).toBe(4); + // Phase 09 complete (3/3), phase 10 incomplete (1/3), phase 11 incomplete (0/1) + expect(progress.completed_phases).toBe(1); + // 4/7 = 57% + expect(progress.percent).toBe(57); + }); + + it('preserves stopped_at from existing frontmatter', async () => { + const result = await stateLoad([], tmpDir); + const data = result.data as Record; + + expect(data.stopped_at).toBe('Completed 10-01-PLAN.md'); + }); + + it('preserves existing non-unknown status when body-derived is unknown', async () => { + // Create STATE.md with frontmatter status but no Status in body + const stateContent = `--- +gsd_state_version: 1.0 +status: paused +--- + +# Project State + +Phase: 10 +Plan: 2 of 3 +`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateLoad([], tmpDir); + const data = result.data as Record; + + // Body has no Status field -> derived is 'unknown', should preserve frontmatter 'paused' + expect(data.status).toBe('paused'); + }); + + it('returns error object when STATE.md not found', async () => { + const emptyDir = await mkdtemp(join(tmpdir(), 'gsd-state-empty-')); + await mkdir(join(emptyDir, '.planning'), { recursive: true }); + + const result = await stateLoad([], emptyDir); + const data = result.data as Record; + + expect(data.error).toBe('STATE.md not found'); + await rm(emptyDir, { recursive: true, force: true }); + }); + + it('normalizes status to known values', async () => { + const stateContent = `--- +gsd_state_version: 1.0 +--- + +# Project State + +Status: In Progress +`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateLoad([], tmpDir); + const data = result.data as Record; + + expect(data.status).toBe('executing'); + }); + + it('derives percent from disk counts (ground truth)', async () => { + // Body says 0% but disk has 4/7 summaries + const stateContent = `--- +gsd_state_version: 1.0 +--- + +# Project State + +Status: Ready to execute +Progress: [░░░░░░░░░░] 0% +`; + await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent); + + const result = await stateLoad([], tmpDir); + const data = result.data as Record; + const progress = data.progress as Record; + + // Disk should override the body's 0% + expect(progress.percent).toBe(57); + }); +}); + +// ─── stateGet ────────────────────────────────────────────────────────────── + +describe('stateGet', () => { + it('returns full content when no field specified', async () => { + const result = await stateGet([], tmpDir); + const data = result.data as Record; + + expect(data.content).toBeDefined(); + expect(typeof data.content).toBe('string'); + expect((data.content as string)).toContain('# Project State'); + }); + + it('extracts bold-format field', async () => { + const result = await stateGet(['Core value'], tmpDir); + const data = result.data as Record; + + expect(data['Core value']).toBe('Improve the project.'); + }); + + it('extracts plain-format field', async () => { + const result = await stateGet(['Plan'], tmpDir); + const data = result.data as Record; + + expect(data['Plan']).toBe('2 of 3'); + }); + + it('extracts section content under ## heading', async () => { + const result = await stateGet(['Current Position'], tmpDir); + const data = result.data as Record; + + expect(data['Current Position']).toBeDefined(); + expect((data['Current Position'] as string)).toContain('Phase: 10'); + }); + + it('returns error for missing field', async () => { + const result = await stateGet(['Nonexistent Field'], tmpDir); + const data = result.data as Record; + + expect(data.error).toBe('Section or field "Nonexistent Field" not found'); + }); +}); + +// ─── stateSnapshot ───────────────────────────────────────────────────────── + +describe('stateSnapshot', () => { + it('returns structured snapshot', async () => { + const result = await stateSnapshot([], tmpDir); + const data = result.data as Record; + + expect(data.current_phase).toBeDefined(); + // Status field in body is "Ready to execute" but frontmatter has "executing" + // stateSnapshot reads full content and matches "status: executing" from frontmatter first + expect(data.status).toBeDefined(); + }); + + it('parses decisions table into array', async () => { + const result = await stateSnapshot([], tmpDir); + const data = result.data as Record; + const decisions = data.decisions as Array>; + + expect(Array.isArray(decisions)).toBe(true); + expect(decisions.length).toBe(2); + expect(decisions[0].phase).toBe('09'); + expect(decisions[0].summary).toBe('Used GSDError pattern'); + expect(decisions[0].rationale).toBe('Consistent with existing SDK errors'); + }); + + it('parses blockers list', async () => { + const result = await stateSnapshot([], tmpDir); + const data = result.data as Record; + const blockers = data.blockers as string[]; + + expect(Array.isArray(blockers)).toBe(true); + expect(blockers.length).toBe(2); + expect(blockers[0]).toContain('STATE.md parsing edge cases'); + }); + + it('parses session info', async () => { + const result = await stateSnapshot([], tmpDir); + const data = result.data as Record; + const session = data.session as Record; + + expect(session).toBeDefined(); + expect(session.stopped_at).toBe('Completed 10-01-PLAN.md'); + }); + + it('returns error when STATE.md not found', async () => { + const emptyDir = await mkdtemp(join(tmpdir(), 'gsd-snap-empty-')); + await mkdir(join(emptyDir, '.planning'), { recursive: true }); + + const result = await stateSnapshot([], emptyDir); + const data = result.data as Record; + + expect(data.error).toBe('STATE.md not found'); + await rm(emptyDir, { recursive: true, force: true }); + }); + + it('returns numeric fields as numbers', async () => { + const result = await stateSnapshot([], tmpDir); + const data = result.data as Record; + + // progress_percent may be null if no Progress: N% format found + // but total_phases etc. should be numbers when present + if (data.total_phases !== null) { + expect(typeof data.total_phases).toBe('number'); + } + }); +}); diff --git a/sdk/src/query/state.ts b/sdk/src/query/state.ts new file mode 100644 index 000000000..20b614fd4 --- /dev/null +++ b/sdk/src/query/state.ts @@ -0,0 +1,395 @@ +/** + * State query handlers — STATE.md loading, field extraction, and snapshots. + * + * Ported from get-shit-done/bin/lib/state.cjs and core.cjs. + * Provides state.load (rebuild frontmatter from body + disk), state.get + * (field/section extraction), and state-snapshot (structured snapshot). + * + * @example + * ```typescript + * import { stateLoad, stateGet, stateSnapshot } from './state.js'; + * + * const loaded = await stateLoad([], '/project'); + * // { data: { gsd_state_version: '1.0', milestone: 'v3.0', ... } } + * + * const field = await stateGet(['Status'], '/project'); + * // { data: { Status: 'executing' } } + * + * const snap = await stateSnapshot([], '/project'); + * // { data: { current_phase: '10', status: 'executing', decisions: [...], ... } } + * ``` + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { extractFrontmatter, stripFrontmatter } from './frontmatter.js'; +import { stateExtractField, planningPaths, escapeRegex } from './helpers.js'; +import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Internal helpers ────────────────────────────────────────────────────── + +/** + * Build a filter function that checks if a phase directory belongs to the current milestone. + * + * Port of getMilestonePhaseFilter from core.cjs lines 1409-1442. + */ +export async function getMilestonePhaseFilter(projectDir: string): Promise<((dirName: string) => boolean) & { phaseCount: number }> { + const milestonePhaseNums = new Set(); + try { + const roadmapContent = await readFile(planningPaths(projectDir).roadmap, 'utf-8'); + const roadmap = await extractCurrentMilestone(roadmapContent, projectDir); + const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; + let m: RegExpExecArray | null; + while ((m = phasePattern.exec(roadmap)) !== null) { + milestonePhaseNums.add(m[1]); + } + } catch { /* intentionally empty */ } + + if (milestonePhaseNums.size === 0) { + const passAllFn = (_dirName: string): boolean => true; + const passAll = passAllFn as typeof passAllFn & { phaseCount: number }; + passAll.phaseCount = 0; + return passAll; + } + + const normalized = new Set( + [...milestonePhaseNums].map(n => (n.replace(/^0+/, '') || '0').toLowerCase()) + ); + + const isDirInMilestone = ((dirName: string): boolean => { + // Try numeric match first + const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/); + if (m && normalized.has(m[1].toLowerCase())) return true; + // Try custom ID match + const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/); + if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true; + return false; + }) as ((dirName: string) => boolean) & { phaseCount: number }; + + isDirInMilestone.phaseCount = milestonePhaseNums.size; + return isDirInMilestone; +} + +/** + * Build state frontmatter from STATE.md body content and disk scanning. + * + * Port of buildStateFrontmatter from state.cjs lines 650-760. + * HIGH complexity: extracts fields, scans disk, computes progress. + */ +export async function buildStateFrontmatter(bodyContent: string, projectDir: string): Promise> { + const currentPhase = stateExtractField(bodyContent, 'Current Phase'); + const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name'); + const currentPlan = stateExtractField(bodyContent, 'Current Plan'); + const totalPhasesRaw = stateExtractField(bodyContent, 'Total Phases'); + const totalPlansRaw = stateExtractField(bodyContent, 'Total Plans in Phase'); + const status = stateExtractField(bodyContent, 'Status'); + const progressRaw = stateExtractField(bodyContent, 'Progress'); + const lastActivity = stateExtractField(bodyContent, 'Last Activity'); + const stoppedAt = stateExtractField(bodyContent, 'Stopped At') || stateExtractField(bodyContent, 'Stopped at'); + const pausedAt = stateExtractField(bodyContent, 'Paused At'); + + let milestone: string | null = null; + let milestoneName: string | null = null; + try { + const info = await getMilestoneInfo(projectDir); + milestone = info.version; + milestoneName = info.name; + } catch { /* intentionally empty */ } + + let totalPhases: number | null = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null; + let completedPhases: number | null = null; + let totalPlans: number | null = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; + let completedPlans: number | null = null; + + try { + const phasesDir = planningPaths(projectDir).phases; + const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const entries = await readdir(phasesDir, { withFileTypes: true }); + const phaseDirs = entries + .filter(e => e.isDirectory()) + .map(e => e.name) + .filter(isDirInMilestone); + + let diskTotalPlans = 0; + let diskTotalSummaries = 0; + let diskCompletedPhases = 0; + + for (const dir of phaseDirs) { + const files = await readdir(join(phasesDir, dir)); + const plans = files.filter(f => /-PLAN\.md$/i.test(f)).length; + const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)).length; + diskTotalPlans += plans; + diskTotalSummaries += summaries; + if (plans > 0 && summaries >= plans) diskCompletedPhases++; + } + + totalPhases = isDirInMilestone.phaseCount > 0 + ? Math.max(phaseDirs.length, isDirInMilestone.phaseCount) + : phaseDirs.length; + completedPhases = diskCompletedPhases; + totalPlans = diskTotalPlans; + completedPlans = diskTotalSummaries; + } catch { /* intentionally empty */ } + + // Derive percent from disk counts (ground truth) + let progressPercent: number | null = null; + if (totalPlans !== null && totalPlans > 0 && completedPlans !== null) { + progressPercent = Math.min(100, Math.round(completedPlans / totalPlans * 100)); + } else if (progressRaw) { + const pctMatch = progressRaw.match(/(\d+)%/); + if (pctMatch) progressPercent = parseInt(pctMatch[1], 10); + } + + // Normalize status + let normalizedStatus = status || 'unknown'; + const statusLower = (status || '').toLowerCase(); + if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) { + normalizedStatus = 'paused'; + } else if (statusLower.includes('executing') || statusLower.includes('in progress')) { + normalizedStatus = 'executing'; + } else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) { + normalizedStatus = 'planning'; + } else if (statusLower.includes('discussing')) { + normalizedStatus = 'discussing'; + } else if (statusLower.includes('verif')) { + normalizedStatus = 'verifying'; + } else if (statusLower.includes('complete') || statusLower.includes('done')) { + normalizedStatus = 'completed'; + } else if (statusLower.includes('ready to execute')) { + normalizedStatus = 'executing'; + } + + const fm: Record = { gsd_state_version: '1.0' }; + + if (milestone) fm.milestone = milestone; + if (milestoneName) fm.milestone_name = milestoneName; + if (currentPhase) fm.current_phase = currentPhase; + if (currentPhaseName) fm.current_phase_name = currentPhaseName; + if (currentPlan) fm.current_plan = currentPlan; + fm.status = normalizedStatus; + if (stoppedAt) fm.stopped_at = stoppedAt; + if (pausedAt) fm.paused_at = pausedAt; + fm.last_updated = new Date().toISOString(); + if (lastActivity) fm.last_activity = lastActivity; + + const progress: Record = {}; + if (totalPhases !== null) progress.total_phases = totalPhases; + if (completedPhases !== null) progress.completed_phases = completedPhases; + if (totalPlans !== null) progress.total_plans = totalPlans; + if (completedPlans !== null) progress.completed_plans = completedPlans; + if (progressPercent !== null) progress.percent = progressPercent; + if (Object.keys(progress).length > 0) fm.progress = progress; + + return fm; +} + +// ─── Exported handlers ───────────────────────────────────────────────────── + +/** + * Query handler for state.load / state.json. + * + * Reads STATE.md, rebuilds frontmatter from body + disk scanning. + * Returns cached frontmatter-only fields (stopped_at, paused_at) when not in body. + * + * Port of cmdStateJson from state.cjs lines 872-901. + * + * @param args - Unused + * @param projectDir - Project root directory + * @returns QueryResult with rebuilt state frontmatter + */ +export const stateLoad: QueryHandler = async (_args, projectDir) => { + const statePath = planningPaths(projectDir).state; + + let content: string; + try { + content = await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + + const existingFm = extractFrontmatter(content); + const body = stripFrontmatter(content); + + // Always rebuild from body + disk so progress reflects current state + const built = await buildStateFrontmatter(body, projectDir); + + // Preserve frontmatter-only fields that cannot be recovered from body + if (existingFm && existingFm.stopped_at && !built.stopped_at) { + built.stopped_at = existingFm.stopped_at; + } + if (existingFm && existingFm.paused_at && !built.paused_at) { + built.paused_at = existingFm.paused_at; + } + + // Preserve existing non-unknown status when body-derived is 'unknown' + if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') { + built.status = existingFm.status; + } + + return { data: built }; +}; + +/** + * Query handler for state.get. + * + * Reads STATE.md and extracts a specific field or section. + * Returns full content when no field specified. + * + * Port of cmdStateGet from state.cjs lines 72-113. + * + * @param args - args[0] is optional field/section name + * @param projectDir - Project root directory + * @returns QueryResult with field value or full content + */ +export const stateGet: QueryHandler = async (args, projectDir) => { + const statePath = planningPaths(projectDir).state; + + let content: string; + try { + content = await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + + const section = args[0]; + if (!section) { + return { data: { content } }; + } + + const fieldEscaped = escapeRegex(section); + + // Check for **field:** value (bold format) + const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i'); + const boldMatch = content.match(boldPattern); + if (boldMatch) { + return { data: { [section]: boldMatch[1].trim() } }; + } + + // Check for field: value (plain format) + const plainPattern = new RegExp(`^${fieldEscaped}:\\s*(.*)`, 'im'); + const plainMatch = content.match(plainPattern); + if (plainMatch) { + return { data: { [section]: plainMatch[1].trim() } }; + } + + // Check for ## Section + const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i'); + const sectionMatch = content.match(sectionPattern); + if (sectionMatch) { + return { data: { [section]: sectionMatch[1].trim() } }; + } + + return { data: { error: `Section or field "${section}" not found` } }; +}; + +/** + * Query handler for state-snapshot. + * + * Returns a structured snapshot of project state with decisions, blockers, and session. + * + * Port of cmdStateSnapshot from state.cjs lines 546-641. + * + * @param args - Unused + * @param projectDir - Project root directory + * @returns QueryResult with structured snapshot + */ +export const stateSnapshot: QueryHandler = async (_args, projectDir) => { + const statePath = planningPaths(projectDir).state; + + let content: string; + try { + content = await readFile(statePath, 'utf-8'); + } catch { + return { data: { error: 'STATE.md not found' } }; + } + + // Extract basic fields + const currentPhase = stateExtractField(content, 'Current Phase'); + const currentPhaseName = stateExtractField(content, 'Current Phase Name'); + const totalPhasesRaw = stateExtractField(content, 'Total Phases'); + const currentPlan = stateExtractField(content, 'Current Plan'); + const totalPlansRaw = stateExtractField(content, 'Total Plans in Phase'); + const status = stateExtractField(content, 'Status'); + const progressRaw = stateExtractField(content, 'Progress'); + const lastActivity = stateExtractField(content, 'Last Activity'); + const lastActivityDesc = stateExtractField(content, 'Last Activity Description'); + const pausedAt = stateExtractField(content, 'Paused At'); + + // Parse numeric fields + const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null; + const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null; + const progressPercent = progressRaw ? (() => { + const m = progressRaw.match(/(\d+)%/); + return m ? parseInt(m[1], 10) : null; + })() : null; + + // Extract decisions table + const decisions: Array<{ phase: string; summary: string; rationale: string }> = []; + const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i); + if (decisionsMatch) { + const tableBody = decisionsMatch[1]; + const rows = tableBody.trim().split('\n').filter(r => r.includes('|')); + for (const row of rows) { + const cells = row.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 3) { + decisions.push({ + phase: cells[0], + summary: cells[1], + rationale: cells[2], + }); + } + } + } + + // Extract blockers list + const blockers: string[] = []; + const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i); + if (blockersMatch) { + const blockersSection = blockersMatch[1]; + const items = blockersSection.match(/^-\s+(.+)$/gm) || []; + for (const item of items) { + blockers.push(item.replace(/^-\s+/, '').trim()); + } + } + + // Extract session info + const session: { last_date: string | null; stopped_at: string | null; resume_file: string | null } = { + last_date: null, + stopped_at: null, + resume_file: null, + }; + + const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i); + if (sessionMatch) { + const sessionSection = sessionMatch[1]; + const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i) + || sessionSection.match(/^Last Date:\s*(.+)/im); + const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i) + || sessionSection.match(/^Stopped At:\s*(.+)/im); + const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i) + || sessionSection.match(/^Resume File:\s*(.+)/im); + + if (lastDateMatch) session.last_date = lastDateMatch[1].trim(); + if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim(); + if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim(); + } + + const result = { + current_phase: currentPhase, + current_phase_name: currentPhaseName, + total_phases: totalPhases, + current_plan: currentPlan, + total_plans_in_phase: totalPlansInPhase, + status, + progress_percent: progressPercent, + last_activity: lastActivity, + last_activity_desc: lastActivityDesc, + decisions, + blockers, + paused_at: pausedAt, + session, + }; + + return { data: result }; +}; diff --git a/sdk/src/query/stubs.test.ts b/sdk/src/query/stubs.test.ts new file mode 100644 index 000000000..f9ab5bfce --- /dev/null +++ b/sdk/src/query/stubs.test.ts @@ -0,0 +1,351 @@ +/** + * Unit tests for handlers decomposed from the former stubs.ts. + * + * Tests are organized by domain module — each import references the + * handler's new home after the stubs.ts → domain file decomposition. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { agentSkills } from './skills.js'; +import { roadmapUpdatePlanProgress, requirementsMarkComplete } from './roadmap.js'; +import { statePlannedPhase } from './state-mutation.js'; +import { verifySchemaDrift } from './verify.js'; +import { todoMatchPhase, statsJson, progressBar } from './progress.js'; +import { milestoneComplete } from './phase-lifecycle.js'; +import { summaryExtract, historyDigest } from './summary.js'; +import { commitToSubrepo } from './commit.js'; +import { + workstreamList, workstreamCreate, workstreamSet, + workstreamStatus, workstreamComplete, +} from './workstream.js'; +import { docsInit } from './init.js'; +import { websearch } from './websearch.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-stubs-')); + await mkdir(join(tmpDir, '.planning', 'phases', '09-foundation'), { recursive: true }); + await mkdir(join(tmpDir, '.planning', 'phases', '10-queries'), { recursive: true }); + + await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + commit_docs: false, + git: { branching_strategy: 'none' }, + workflow: {}, + })); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v3.0\n---\n# State\n'); + await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '## v3.0: Test', + '### Phase 9: Foundation', + '**Goal:** Build it', + '- [ ] Plan 1', + '### Phase 10: Queries', + '**Goal:** Query it', + ].join('\n')); + await writeFile(join(tmpDir, '.planning', 'REQUIREMENTS.md'), [ + '# Requirements', + '- [ ] REQ-01: First requirement', + '- [ ] REQ-02: Second requirement', + '- [x] REQ-03: Already done', + ].join('\n')); + + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-PLAN.md'), '---\nphase: 09\nplan: 01\ntype: execute\nmust_haves:\n truths: []\n---'); + await writeFile(join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md'), '# Done'); + await writeFile(join(tmpDir, '.planning', 'phases', '10-queries', '10-01-PLAN.md'), '---\nphase: 10\nplan: 01\ntype: execute\nmust_haves:\n truths: []\n---'); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ─── skills.ts ─────────────────────────────────────────────────────────── + +describe('agentSkills', () => { + it('returns valid QueryResult with skills array', async () => { + const result = await agentSkills(['gsd-executor'], tmpDir); + const data = result.data as Record; + expect(Array.isArray(data.skills)).toBe(true); + expect(typeof data.skill_count).toBe('number'); + expect(data.agent_type).toBe('gsd-executor'); + }); +}); + +// ─── roadmap.ts ────────────────────────────────────────────────────────── + +describe('roadmapUpdatePlanProgress', () => { + it('returns QueryResult without error', async () => { + const result = await roadmapUpdatePlanProgress(['9'], tmpDir); + expect(result.data).toBeDefined(); + const data = result.data as Record; + expect(typeof data.updated).toBe('boolean'); + }); + + it('returns false when no phase arg', async () => { + const result = await roadmapUpdatePlanProgress([], tmpDir); + const data = result.data as Record; + expect(data.updated).toBe(false); + expect(data.reason).toBeDefined(); + }); +}); + +describe('requirementsMarkComplete', () => { + it('returns QueryResult without error', async () => { + const result = await requirementsMarkComplete(['REQ-01'], tmpDir); + const data = result.data as Record; + expect(typeof data.marked).toBe('boolean'); + }); + + it('returns false when no IDs provided', async () => { + const result = await requirementsMarkComplete([], tmpDir); + const data = result.data as Record; + expect(data.marked).toBe(false); + }); +}); + +// ─── state-mutation.ts ─────────────────────────────────────────────────── + +describe('statePlannedPhase', () => { + it('updates STATE.md and returns success', async () => { + const result = await statePlannedPhase(['--phase', '10', '--name', 'queries', '--plans', '2'], tmpDir); + const data = result.data as Record; + expect(typeof data.updated).toBe('boolean'); + }); + + it('returns false without phase arg', async () => { + const result = await statePlannedPhase([], tmpDir); + const data = result.data as Record; + expect(data.updated).toBe(false); + }); +}); + +// ─── verify.ts ─────────────────────────────────────────────────────────── + +describe('verifySchemaDrift', () => { + it('returns valid/issues shape', async () => { + const result = await verifySchemaDrift([], tmpDir); + const data = result.data as Record; + expect(typeof data.valid).toBe('boolean'); + expect(Array.isArray(data.issues)).toBe(true); + expect(typeof data.checked).toBe('number'); + }); +}); + +// ─── progress.ts ───────────────────────────────────────────────────────── + +describe('todoMatchPhase', () => { + it('returns todos array (empty when no todos dir)', async () => { + const result = await todoMatchPhase(['9'], tmpDir); + const data = result.data as Record; + expect(Array.isArray(data.todos)).toBe(true); + expect(data.phase).toBe('9'); + }); +}); + +describe('statsJson', () => { + it('returns stats with phases_total and progress', async () => { + const result = await statsJson([], tmpDir); + const data = result.data as Record; + expect(typeof data.phases_total).toBe('number'); + expect(typeof data.plans_total).toBe('number'); + expect(typeof data.progress_percent).toBe('number'); + expect(data.phases_total).toBeGreaterThanOrEqual(2); + }); +}); + +describe('progressBar', () => { + it('returns bar string and percent', async () => { + const result = await progressBar([], tmpDir); + const data = result.data as Record; + expect(typeof data.bar).toBe('string'); + expect(typeof data.percent).toBe('number'); + expect(data.bar as string).toContain('['); + }); +}); + +// ─── summary.ts ────────────────────────────────────────────────────────── + +describe('summaryExtract', () => { + it('returns error when file not found', async () => { + const result = await summaryExtract(['.planning/nonexistent.md'], tmpDir); + const data = result.data as Record; + expect(data.error).toBeDefined(); + }); + + it('extracts sections from an existing summary file', async () => { + const summaryPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md'); + await writeFile(summaryPath, '# Summary\n\n## What Was Done\nBuilt it.\n\n## Tests\nAll pass.\n'); + const result = await summaryExtract(['.planning/phases/09-foundation/09-01-SUMMARY.md'], tmpDir); + const data = result.data as Record; + expect(data.sections).toBeDefined(); + }); +}); + +describe('historyDigest', () => { + it('returns phases object with completed summaries', async () => { + const result = await historyDigest([], tmpDir); + const data = result.data as Record; + expect(typeof data.phases).toBe('object'); + expect(Array.isArray(data.decisions)).toBe(true); + expect(Array.isArray(data.tech_stack)).toBe(true); + }); +}); + +// ─── workstream.ts ─────────────────────────────────────────────────────── + +describe('workstream handlers', () => { + it('workstreamList returns workstreams array', async () => { + const result = await workstreamList([], tmpDir); + const data = result.data as Record; + expect(Array.isArray(data.workstreams)).toBe(true); + }); + + it('workstreamCreate creates a directory', async () => { + const result = await workstreamCreate(['my-ws'], tmpDir); + const data = result.data as Record; + expect(typeof data.created).toBe('boolean'); + }); + + it('workstreamCreate rejects path traversal', async () => { + const result = await workstreamCreate(['../../bad'], tmpDir); + const data = result.data as Record; + expect(data.created).toBe(false); + }); + + it('workstreamSet returns set=true for existing workstream', async () => { + await mkdir(join(tmpDir, '.planning', 'workstreams', 'backend'), { recursive: true }); + const result = await workstreamSet(['backend'], tmpDir); + const data = result.data as Record; + expect(data.set).toBe(true); + expect(data.active).toBe('backend'); + }); + + it('workstreamStatus returns found boolean', async () => { + const result = await workstreamStatus(['nonexistent'], tmpDir); + const data = result.data as Record; + expect(typeof data.found).toBe('boolean'); + }); + + it('workstreamComplete archives existing workstream', async () => { + await mkdir(join(tmpDir, '.planning', 'workstreams', 'my-ws', 'phases'), { recursive: true }); + await writeFile(join(tmpDir, '.planning', 'workstreams', 'my-ws', 'STATE.md'), '# State\n'); + const result = await workstreamComplete(['my-ws'], tmpDir); + const data = result.data as Record; + expect(data.completed).toBe(true); + expect(data.archived_to).toBeDefined(); + }); +}); + +// ─── init.ts ───────────────────────────────────────────────────────────── + +describe('docsInit', () => { + it('returns docs context', async () => { + const result = await docsInit([], tmpDir); + const data = result.data as Record; + expect(typeof data.project_exists).toBe('boolean'); + expect(data.docs_dir).toBe('.planning/docs'); + }); +}); + +// ─── websearch.ts ──────────────────────────────────────────────────────── + +describe('websearch', () => { + const originalEnv = process.env.BRAVE_API_KEY; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.BRAVE_API_KEY; + } else { + process.env.BRAVE_API_KEY = originalEnv; + } + vi.restoreAllMocks(); + }); + + it('returns available:false when BRAVE_API_KEY is not set', async () => { + delete process.env.BRAVE_API_KEY; + const result = await websearch([], tmpDir); + const data = result.data as Record; + expect(data.available).toBe(false); + expect(data.reason).toBe('BRAVE_API_KEY not set'); + }); + + it('returns error when query is empty', async () => { + process.env.BRAVE_API_KEY = 'test-key'; + const result = await websearch([], tmpDir); + const data = result.data as Record; + expect(data.available).toBe(false); + expect(data.error).toBe('Query required'); + }); + + it('returns results on successful API call', async () => { + process.env.BRAVE_API_KEY = 'test-key'; + const mockResults = { + web: { + results: [ + { title: 'Result 1', url: 'https://example.com', description: 'Desc 1', age: '2d' }, + { title: 'Result 2', url: 'https://example.org', description: 'Desc 2' }, + ], + }, + }; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => mockResults, + } as Response); + + const result = await websearch(['typescript generics'], tmpDir); + const data = result.data as Record; + expect(data.available).toBe(true); + expect(data.query).toBe('typescript generics'); + expect(data.count).toBe(2); + const results = data.results as Array>; + expect(results[0].title).toBe('Result 1'); + expect(results[0].age).toBe('2d'); + expect(results[1].age).toBeNull(); + }); + + it('passes --limit and --freshness params to API', async () => { + process.env.BRAVE_API_KEY = 'test-key'; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ web: { results: [] } }), + } as Response); + + await websearch(['query', '--limit', '5', '--freshness', 'week'], tmpDir); + + const url = new URL((globalThis.fetch as ReturnType).mock.calls[0][0] as string); + expect(url.searchParams.get('count')).toBe('5'); + expect(url.searchParams.get('freshness')).toBe('week'); + }); + + it('returns error on non-ok response', async () => { + process.env.BRAVE_API_KEY = 'test-key'; + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 429, + } as Response); + + const result = await websearch(['rate limited query'], tmpDir); + const data = result.data as Record; + expect(data.available).toBe(false); + expect(data.error).toBe('API error: 429'); + }); + + it('returns error on network failure', async () => { + process.env.BRAVE_API_KEY = 'test-key'; + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await websearch(['network fail'], tmpDir); + const data = result.data as Record; + expect(data.available).toBe(false); + expect(data.error).toBe('ECONNREFUSED'); + }); +}); diff --git a/sdk/src/query/summary.ts b/sdk/src/query/summary.ts new file mode 100644 index 000000000..a19e94b12 --- /dev/null +++ b/sdk/src/query/summary.ts @@ -0,0 +1,178 @@ +/** + * Summary query handlers — extract sections and history from SUMMARY.md files. + * + * Ported from get-shit-done/bin/lib/commands.cjs (cmdSummaryExtract, cmdHistoryDigest). + * Provides summary section parsing and condensed phase history generation. + * + * @example + * ```typescript + * import { summaryExtract, historyDigest } from './summary.js'; + * + * await summaryExtract(['.planning/phases/09-foundation/09-01-SUMMARY.md'], '/project'); + * // { data: { sections: { what_was_done: '...', tests: '...' }, file: '...' } } + * + * await historyDigest([], '/project'); + * // { data: { phases: [...], count: 5 } } + * ``` + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +import { planningPaths, toPosixPath } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +export const summaryExtract: QueryHandler = async (args, projectDir) => { + const filePath = args[0] ? join(projectDir, args[0]) : null; + + if (!filePath || !existsSync(filePath)) { + return { data: { sections: {}, error: 'file not found' } }; + } + + try { + const content = await readFile(filePath, 'utf-8'); + const sections: Record = {}; + const headingPattern = /^#{1,3}\s+(.+?)[\r\n]+([\s\S]*?)(?=^#{1,3}\s|\Z)/gm; + let m: RegExpExecArray | null; + while ((m = headingPattern.exec(content)) !== null) { + const key = m[1].trim().toLowerCase().replace(/\s+/g, '_'); + sections[key] = m[2].trim(); + } + return { data: { sections, file: args[0] } }; + } catch { + return { data: { sections: {}, error: 'unreadable file' } }; + } +}; + +export const historyDigest: QueryHandler = async (_args, projectDir) => { + const paths = planningPaths(projectDir); + const digest: { + phases: Record; + decisions: Array<{ phase: string; decision: string }>; + tech_stack: string[]; + } = { phases: {}, decisions: [], tech_stack: [] }; + + const techStackSet = new Set(); + + // Collect all phase directories: archived milestones + current + const allPhaseDirs: Array<{ name: string; fullPath: string }> = []; + + // Archived phases from milestones/ + const milestonesDir = join(projectDir, '.planning', 'milestones'); + if (existsSync(milestonesDir)) { + try { + const milestoneEntries = readdirSync(milestonesDir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + const archivedPhaseDirs = milestoneEntries + .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) + .map(e => e.name) + .sort(); + for (const archiveName of archivedPhaseDirs) { + const archivePath = join(milestonesDir, archiveName); + try { + const dirs = readdirSync(archivePath, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + for (const d of dirs.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { + allPhaseDirs.push({ name: d.name, fullPath: join(archivePath, d.name) }); + } + } catch { /* skip */ } + } + } catch { /* skip */ } + } + + // Current phases + if (existsSync(paths.phases)) { + try { + const currentDirs = readdirSync(paths.phases, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + for (const d of currentDirs.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { + allPhaseDirs.push({ name: d.name, fullPath: join(paths.phases, d.name) }); + } + } catch { /* skip */ } + } + + if (allPhaseDirs.length === 0) { + return { data: digest }; + } + + for (const { name: dir, fullPath: dirPath } of allPhaseDirs) { + const summaries = readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + + for (const summary of summaries) { + try { + const content = readFileSync(join(dirPath, summary), 'utf-8'); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) continue; + + const fmBlock = fmMatch[1]; + const phaseMatch = fmBlock.match(/^phase:\s*(.+)$/m); + const nameMatch = fmBlock.match(/^name:\s*(.+)$/m); + const phaseNum = phaseMatch ? phaseMatch[1].trim() : dir.split('-')[0]; + + if (!digest.phases[phaseNum]) { + const phaseName = nameMatch + ? nameMatch[1].trim() + : dir.split('-').slice(1).join(' ') || 'Unknown'; + digest.phases[phaseNum] = { name: phaseName, provides: [], affects: [], patterns: [] }; + } + + const providesSet = new Set(digest.phases[phaseNum].provides); + const affectsSet = new Set(digest.phases[phaseNum].affects); + const patternsSet = new Set(digest.phases[phaseNum].patterns); + + // Parse provides from dependency-graph or top-level + for (const m of fmBlock.matchAll(/^\s+-\s+(.+)$/gm)) { + const line = m[1].trim(); + if (fmBlock.indexOf(m[0]) > fmBlock.indexOf('provides:') && + (fmBlock.indexOf('affects:') === -1 || fmBlock.indexOf(m[0]) < fmBlock.indexOf('affects:'))) { + providesSet.add(line); + } + } + + // Parse key-decisions + const decisionsStart = fmBlock.indexOf('key-decisions:'); + if (decisionsStart !== -1) { + const rest = fmBlock.slice(decisionsStart + 'key-decisions:'.length); + for (const line of rest.split('\n')) { + const item = line.match(/^\s+-\s+(.+)$/); + if (item) { + digest.decisions.push({ phase: phaseNum, decision: item[1].trim() }); + } else if (/^\S/.test(line) && line.trim()) { + break; + } + } + } + + // Parse patterns-established + const patternsStart = fmBlock.indexOf('patterns-established:'); + if (patternsStart !== -1) { + const rest = fmBlock.slice(patternsStart + 'patterns-established:'.length); + for (const line of rest.split('\n')) { + const item = line.match(/^\s+-\s+(.+)$/); + if (item) patternsSet.add(item[1].trim()); + else if (/^\S/.test(line) && line.trim()) break; + } + } + + // Parse tech-stack.added + const techStart = fmBlock.indexOf('tech-stack:'); + if (techStart !== -1) { + const addedStart = fmBlock.indexOf('added:', techStart); + if (addedStart !== -1) { + const rest = fmBlock.slice(addedStart + 'added:'.length); + for (const line of rest.split('\n')) { + const item = line.match(/^\s+-\s+(?:name:\s*)?(.+)$/); + if (item) techStackSet.add(item[1].trim()); + else if (/^\S/.test(line) && line.trim()) break; + } + } + } + + digest.phases[phaseNum].provides = [...providesSet]; + digest.phases[phaseNum].affects = [...affectsSet]; + digest.phases[phaseNum].patterns = [...patternsSet]; + } catch { /* skip malformed summaries */ } + } + } + + digest.tech_stack = [...techStackSet]; + return { data: digest }; +}; diff --git a/sdk/src/query/template.test.ts b/sdk/src/query/template.test.ts new file mode 100644 index 000000000..81525f26d --- /dev/null +++ b/sdk/src/query/template.test.ts @@ -0,0 +1,179 @@ +/** + * Unit tests for template.ts — templateSelect and templateFill handlers. + * + * Also tests event emission wiring in createRegistry. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { templateSelect, templateFill } from './template.js'; +import { createRegistry } from './index.js'; +import { GSDEventStream } from '../event-stream.js'; +import { GSDEventType } from '../types.js'; +import type { GSDEvent } from '../types.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = join(tmpdir(), `gsd-template-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(join(tmpDir, '.planning', 'phases', '09-foundation'), { recursive: true }); + // Create minimal STATE.md + await writeFile(join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: executing\n---\n\n# Project State\n'); + // Create minimal config.json + await writeFile(join(tmpDir, '.planning', 'config.json'), '{}'); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('templateSelect', () => { + it('returns "plan" as default when phase dir has no plans', async () => { + const result = await templateSelect([], tmpDir); + expect((result.data as Record).template).toBe('plan'); + }); + + it('returns "summary" when PLAN exists but no SUMMARY', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '09-foundation'); + await writeFile(join(phaseDir, '09-01-PLAN.md'), '---\nphase: 09\n---\n# Plan'); + const result = await templateSelect(['9'], tmpDir); + const data = result.data as Record; + expect(data.template).toBe('summary'); + }); + + it('returns "verification" when all plans have summaries', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '09-foundation'); + await writeFile(join(phaseDir, '09-01-PLAN.md'), '---\nphase: 09\n---\n# Plan'); + await writeFile(join(phaseDir, '09-01-SUMMARY.md'), '---\nphase: 09\n---\n# Summary'); + const result = await templateSelect(['9'], tmpDir); + const data = result.data as Record; + expect(data.template).toBe('verification'); + }); + + it('returns "plan" when phase dir not found', async () => { + const result = await templateSelect(['99'], tmpDir); + expect((result.data as Record).template).toBe('plan'); + }); +}); + +describe('templateFill', () => { + it('creates summary file with expected frontmatter fields', async () => { + const outPath = join(tmpDir, 'test-summary.md'); + const result = await templateFill(['summary', outPath], tmpDir); + const data = result.data as Record; + expect(data.created).toBe(true); + + const content = await readFile(outPath, 'utf-8'); + expect(content).toContain('phase:'); + expect(content).toContain('plan:'); + expect(content).toContain('subsystem:'); + expect(content).toContain('tags:'); + expect(content).toContain('## Performance'); + expect(content).toContain('## Accomplishments'); + }); + + it('creates plan file with plan frontmatter skeleton', async () => { + const outPath = join(tmpDir, 'test-plan.md'); + const result = await templateFill(['plan', outPath], tmpDir); + const data = result.data as Record; + expect(data.created).toBe(true); + + const content = await readFile(outPath, 'utf-8'); + expect(content).toContain('type: execute'); + expect(content).toContain('wave: 1'); + expect(content).toContain('autonomous: true'); + expect(content).toContain(''); + expect(content).toContain(''); + }); + + it('creates verification file with verification skeleton', async () => { + const outPath = join(tmpDir, 'test-verification.md'); + const result = await templateFill(['verification', outPath], tmpDir); + const data = result.data as Record; + expect(data.created).toBe(true); + + const content = await readFile(outPath, 'utf-8'); + expect(content).toContain('status: pending'); + expect(content).toContain('## Must-Have Checks'); + expect(content).toContain('## Result'); + }); + + it('applies key=value overrides to frontmatter', async () => { + const outPath = join(tmpDir, 'test-override.md'); + const result = await templateFill(['summary', outPath, 'phase=11-testing', 'plan=02'], tmpDir); + const data = result.data as Record; + expect(data.created).toBe(true); + + const content = await readFile(outPath, 'utf-8'); + expect(content).toContain('phase: 11-testing'); + expect(content).toContain('plan: 02'); + }); + + it('rejects path traversal attempts with .. segments', async () => { + const outPath = join(tmpDir, '..', 'escape.md'); + await expect(templateFill(['summary', outPath], tmpDir)).rejects.toThrow(); + }); +}); + +describe('event emission wiring', () => { + it('emits StateMutation event for state.update dispatch', async () => { + // Create a proper STATE.md for state.update to work with + const stateContent = [ + '---', + 'status: executing', + '---', + '', + '# Project State', + '', + '## Current Position', + '', + 'Status: Ready', + ].join('\n'); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent); + + const eventStream = new GSDEventStream(); + const events: GSDEvent[] = []; + eventStream.on('event', (e: GSDEvent) => events.push(e)); + + const registry = createRegistry(eventStream); + await registry.dispatch('state.update', ['status', 'Executing'], tmpDir); + + const mutationEvents = events.filter(e => e.type === GSDEventType.StateMutation); + expect(mutationEvents.length).toBe(1); + const evt = mutationEvents[0] as { type: string; command: string; success: boolean }; + expect(evt.command).toBe('state.update'); + expect(evt.success).toBe(true); + }); + + it('emits ConfigMutation event for config-set dispatch', async () => { + await writeFile(join(tmpDir, '.planning', 'config.json'), '{"model_profile":"balanced"}'); + + const eventStream = new GSDEventStream(); + const events: GSDEvent[] = []; + eventStream.on('event', (e: GSDEvent) => events.push(e)); + + const registry = createRegistry(eventStream); + await registry.dispatch('config-set', ['model_profile', 'quality'], tmpDir); + + const mutationEvents = events.filter(e => e.type === GSDEventType.ConfigMutation); + expect(mutationEvents.length).toBe(1); + const evt = mutationEvents[0] as { type: string; command: string; success: boolean }; + expect(evt.command).toBe('config-set'); + expect(evt.success).toBe(true); + }); + + it('emits TemplateFill event for template.fill dispatch', async () => { + const outPath = join(tmpDir, 'event-test.md'); + const eventStream = new GSDEventStream(); + const events: GSDEvent[] = []; + eventStream.on('event', (e: GSDEvent) => events.push(e)); + + const registry = createRegistry(eventStream); + await registry.dispatch('template.fill', ['summary', outPath], tmpDir); + + const templateEvents = events.filter(e => e.type === GSDEventType.TemplateFill); + expect(templateEvents.length).toBe(1); + }); +}); diff --git a/sdk/src/query/template.ts b/sdk/src/query/template.ts new file mode 100644 index 000000000..09c7d70f5 --- /dev/null +++ b/sdk/src/query/template.ts @@ -0,0 +1,242 @@ +/** + * Template handlers — template selection and fill operations. + * + * Ported from get-shit-done/bin/lib/template.cjs. + * Provides templateSelect (heuristic template type selection) and + * templateFill (create file from template with auto-generated frontmatter). + * + * @example + * ```typescript + * import { templateSelect, templateFill } from './template.js'; + * + * const selectResult = await templateSelect(['9'], projectDir); + * // { data: { template: 'summary' } } + * + * const fillResult = await templateFill(['summary', '/path/out.md', 'phase=09'], projectDir); + * // { data: { created: true, path: '/path/out.md', template: 'summary' } } + * ``` + */ + +import { readdir, writeFile } from 'node:fs/promises'; +import { join, resolve, relative } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { reconstructFrontmatter, spliceFrontmatter } from './frontmatter-mutation.js'; +import { normalizeMd, planningPaths, normalizePhaseName, phaseTokenMatches } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── templateSelect ───────────────────────────────────────────────────────── + +/** + * Select the appropriate template type based on phase directory contents. + * + * Heuristic: + * - Has all PLAN+SUMMARY pairs -> "verification" + * - Has PLAN but missing SUMMARY for latest plan -> "summary" + * - Else -> "plan" (default) + * + * @param args - [phaseNumber?] Optional phase number to check + * @param projectDir - Project root directory + * @returns QueryResult with { template: 'plan' | 'summary' | 'verification' } + */ +export const templateSelect: QueryHandler = async (args, projectDir) => { + const phaseNum = args[0]; + if (!phaseNum) { + return { data: { template: 'plan' } }; + } + + const paths = planningPaths(projectDir); + const normalized = normalizePhaseName(phaseNum); + + // Find the phase directory + let phaseDir: string | null = null; + try { + const entries = await readdir(paths.phases); + for (const entry of entries) { + if (phaseTokenMatches(entry, normalized)) { + phaseDir = join(paths.phases, entry); + break; + } + } + } catch { + return { data: { template: 'plan' } }; + } + + if (!phaseDir) { + return { data: { template: 'plan' } }; + } + + // Read directory contents and check for plans/summaries + try { + const files = await readdir(phaseDir); + const plans = files.filter(f => f.match(/-PLAN\.md$/i)); + const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i)); + + if (plans.length === 0) { + return { data: { template: 'plan' } }; + } + + // Check if all plans have corresponding summaries + const allHaveSummaries = plans.every(plan => { + // Extract plan number: e.g., 09-01-PLAN.md -> 09-01 + const prefix = plan.replace(/-PLAN\.md$/i, ''); + return summaries.some(s => s.startsWith(prefix)); + }); + + if (allHaveSummaries) { + return { data: { template: 'verification' } }; + } + + return { data: { template: 'summary' } }; + } catch { + return { data: { template: 'plan' } }; + } +}; + +// ─── templateFill ─────────────────────────────────────────────────────────── + +/** + * Create a file from a template type with auto-generated frontmatter. + * + * Port of cmdTemplateFill from template.cjs. + * + * @param args - [templateType, outputPath, ...key=value overrides] + * templateType: "summary" | "plan" | "verification" + * outputPath: Absolute or relative path for output file + * key=value: Optional frontmatter field overrides + * @param projectDir - Project root directory + * @returns QueryResult with { created: true, path, template } + */ +export const templateFill: QueryHandler = async (args, projectDir) => { + const templateType = args[0]; + const outputPath = args[1]; + + if (!templateType) { + throw new GSDError( + 'template type required: summary, plan, or verification', + ErrorClassification.Validation, + ); + } + if (!outputPath) { + throw new GSDError( + 'output path required', + ErrorClassification.Validation, + ); + } + + // T-11-10: Reject path traversal attempts + const resolvedOut = resolve(projectDir, outputPath); + const rel = relative(projectDir, resolvedOut); + if (rel.startsWith('..') || rel.includes('..')) { + throw new GSDError( + `Output path escapes project directory: ${outputPath}`, + ErrorClassification.Validation, + ); + } + + // Parse key=value overrides from remaining args + const overrides: Record = {}; + for (let i = 2; i < args.length; i++) { + const eqIdx = args[i].indexOf('='); + if (eqIdx > 0) { + overrides[args[i].slice(0, eqIdx)] = args[i].slice(eqIdx + 1); + } + } + + let fm: Record; + let body: string; + + switch (templateType) { + case 'summary': { + fm = { + phase: '', plan: '', subsystem: '', tags: [], + requires: [], provides: [], affects: [], + 'tech-stack': { added: [], patterns: [] }, + 'key-files': { created: [], modified: [] }, + 'key-decisions': [], 'patterns-established': [], + 'requirements-completed': [], + duration: '', completed: '', + }; + body = [ + '# Phase {phase} Plan {plan}: Summary', + '', + '## Performance', + '', + '## Accomplishments', + '', + '## Task Commits', + '', + '## Files Created/Modified', + '', + '## Decisions Made', + '', + '## Deviations from Plan', + '', + '## Issues Encountered', + '', + '## User Setup Required', + '', + '## Next Phase Readiness', + '', + '## Self-Check', + ].join('\n'); + break; + } + case 'plan': { + fm = { + phase: '', plan: '', type: 'execute', wave: 1, + depends_on: [], files_modified: [], autonomous: true, + requirements: [], must_haves: { truths: [], artifacts: [], key_links: [] }, + }; + body = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ].join('\n'); + break; + } + case 'verification': { + fm = { + phase: '', status: 'pending', verified_at: '', + }; + body = [ + '# Phase {phase} Verification', + '', + '## Must-Have Checks', + '', + '## Artifact Verification', + '', + '## Key-Link Verification', + '', + '## Result', + ].join('\n'); + break; + } + default: + throw new GSDError( + `Unknown template type: ${templateType}. Available: summary, plan, verification`, + ErrorClassification.Validation, + ); + } + + // Apply overrides + Object.assign(fm, overrides); + + // Generate content + const content = spliceFrontmatter('', fm) + '\n' + body + '\n'; + const normalized = normalizeMd(content); + + await writeFile(resolvedOut, normalized, 'utf-8'); + + return { data: { created: true, path: outputPath, template: templateType } }; +}; diff --git a/sdk/src/query/uat.ts b/sdk/src/query/uat.ts new file mode 100644 index 000000000..d223e9f03 --- /dev/null +++ b/sdk/src/query/uat.ts @@ -0,0 +1,175 @@ +/** + * UAT query handlers — checkpoint rendering and audit scanning. + * + * Ported from get-shit-done/bin/lib/uat.cjs. + * Provides UAT checkpoint rendering for verify-work workflows and + * audit scanning for UAT/VERIFICATION files across phases. + * + * @example + * ```typescript + * import { uatRenderCheckpoint, auditUat } from './uat.js'; + * + * await uatRenderCheckpoint(['--file', 'path/to/UAT.md'], '/project'); + * // { data: { test_number: 1, test_name: 'Login', checkpoint: '...' } } + * + * await auditUat([], '/project'); + * // { data: { results: [...], summary: { total_files: 2, total_items: 5 } } } + * ``` + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +import { planningPaths, toPosixPath } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── uatRenderCheckpoint ───────────────────────────────────────────────── + +/** + * Render the current UAT checkpoint — reads a UAT file, parses the + * "Current Test" section, and returns a formatted checkpoint prompt. + * + * Args: --file + */ +export const uatRenderCheckpoint: QueryHandler = async (args, projectDir) => { + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 ? args[fileIdx + 1] : null; + if (!filePath) { + return { data: { error: 'UAT file required: use uat render-checkpoint --file ' } }; + } + + const resolvedPath = resolve(projectDir, filePath); + if (!existsSync(resolvedPath)) { + return { data: { error: `UAT file not found: ${filePath}` } }; + } + + const content = readFileSync(resolvedPath, 'utf-8'); + + const currentTestMatch = content.match(/##\s*Current Test\s*(?:\n)?\n([\s\S]*?)(?=\n##\s|$)/i); + if (!currentTestMatch) { + return { data: { error: 'UAT file is missing a Current Test section' } }; + } + + const section = currentTestMatch[1].trimEnd(); + if (!section.trim()) { + return { data: { error: 'Current Test section is empty' } }; + } + + if (/\[testing complete\]/i.test(section)) { + return { data: { complete: true, checkpoint: null } }; + } + + const numberMatch = section.match(/^number:\s*(\d+)\s*$/m); + const nameMatch = section.match(/^name:\s*(.+)\s*$/m); + const expectedBlockMatch = section.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m) + || section.match(/^expected:\s*\|\n([\s\S]+)/m); + const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m); + + if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) { + return { data: { error: 'Current Test section is malformed — requires number, name, and expected fields' } }; + } + + let expected: string; + if (expectedBlockMatch) { + expected = expectedBlockMatch[1] + .split('\n') + .map(line => line.replace(/^ {2}/, '')) + .join('\n') + .trim(); + } else { + expected = expectedInlineMatch![1].trim(); + } + + const testNumber = parseInt(numberMatch[1], 10); + const testName = nameMatch[1].trim(); + + const checkpoint = [ + '\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557', + '\u2551 CHECKPOINT: Verification Required \u2551', + '\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d', + '', + `**Test ${testNumber}: ${testName}**`, + '', + expected, + '', + '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', + "Type `pass` or describe what's wrong.", + '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500', + ].join('\n'); + + return { + data: { + file_path: toPosixPath(relative(projectDir, resolvedPath)), + test_number: testNumber, + test_name: testName, + checkpoint, + }, + }; +}; + +// ─── auditUat ──────────────────────────────────────────────────────────── + +function parseUatItems(content: string): string[] { + const items: string[] = []; + for (const line of content.split('\n')) { + if (/^-\s*\[\s*\]/.test(line) || /^-\s*\[[ ]\]/.test(line)) { + items.push(line.trim()); + } + } + return items; +} + +function parseVerificationItems(content: string): string[] { + const items: string[] = []; + const gapSection = /## gaps?|## issues?|## failures?/i; + let inGapSection = false; + for (const line of content.split('\n')) { + if (/^##/.test(line)) { inGapSection = gapSection.test(line); continue; } + if (inGapSection && line.trim().startsWith('-')) items.push(line.trim()); + } + return items; +} + +function extractFrontmatterStatus(content: string): string { + const match = content.match(/^---[\s\S]*?^status:\s*(.+?)[\r\n]/m); + return match ? match[1].trim() : 'unknown'; +} + +export const auditUat: QueryHandler = async (_args, projectDir) => { + const paths = planningPaths(projectDir); + if (!existsSync(paths.phases)) { + return { data: { results: [], summary: { total_files: 0, total_items: 0 } } }; + } + + const results: Record[] = []; + const entries = readdirSync(paths.phases, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + + for (const entry of entries.filter(e => e.isDirectory())) { + const phaseMatch = entry.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + const phaseNum = phaseMatch ? phaseMatch[1] : entry.name; + const phaseDir = join(paths.phases, entry.name); + const files = readdirSync(phaseDir); + + for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) { + const content = readFileSync(join(phaseDir, file), 'utf-8'); + const items = parseUatItems(content); + if (items.length > 0) { + results.push({ phase: phaseNum, phase_dir: entry.name, file, file_path: toPosixPath(relative(projectDir, join(phaseDir, file))), type: 'uat', status: extractFrontmatterStatus(content), items }); + } + } + + for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) { + const content = readFileSync(join(phaseDir, file), 'utf-8'); + const status = extractFrontmatterStatus(content); + if (status === 'human_needed' || status === 'gaps_found') { + const items = parseVerificationItems(content); + if (items.length > 0) { + results.push({ phase: phaseNum, phase_dir: entry.name, file, file_path: toPosixPath(relative(projectDir, join(phaseDir, file))), type: 'verification', status, items }); + } + } + } + } + + const totalItems = results.reduce((sum, r) => sum + ((r.items as unknown[]).length), 0); + return { data: { results, summary: { total_files: results.length, total_items: totalItems } } }; +}; diff --git a/sdk/src/query/utils.test.ts b/sdk/src/query/utils.test.ts new file mode 100644 index 000000000..3efe6ed40 --- /dev/null +++ b/sdk/src/query/utils.test.ts @@ -0,0 +1,82 @@ +/** + * Unit tests for utility query handlers. + * + * Covers: generateSlug and currentTimestamp functions with output parity + * to gsd-tools.cjs cmdGenerateSlug and cmdCurrentTimestamp. + */ + +import { describe, it, expect } from 'vitest'; +import { generateSlug, currentTimestamp } from './utils.js'; +import { GSDError, ErrorClassification } from '../errors.js'; + +const PROJECT_DIR = '/tmp/test-project'; + +describe('generateSlug', () => { + it('converts simple text to kebab-case slug', async () => { + const result = await generateSlug(['My Phase Name'], PROJECT_DIR); + expect(result).toEqual({ data: { slug: 'my-phase-name' } }); + }); + + it('strips non-alphanumeric characters and collapses runs', async () => { + const result = await generateSlug([' Hello World!!! '], PROJECT_DIR); + expect(result).toEqual({ data: { slug: 'hello-world' } }); + }); + + it('strips leading and trailing hyphens', async () => { + const result = await generateSlug(['---test---'], PROJECT_DIR); + expect(result).toEqual({ data: { slug: 'test' } }); + }); + + it('truncates slug to 60 characters', async () => { + const longText = 'a'.repeat(100); + const result = await generateSlug([longText], PROJECT_DIR); + expect((result.data as { slug: string }).slug).toHaveLength(60); + }); + + it('throws GSDError with Validation classification for empty text', async () => { + await expect(generateSlug([''], PROJECT_DIR)).rejects.toThrow(GSDError); + try { + await generateSlug([''], PROJECT_DIR); + } catch (err) { + expect(err).toBeInstanceOf(GSDError); + expect((err as GSDError).classification).toBe(ErrorClassification.Validation); + } + }); + + it('throws GSDError with Validation classification for missing text', async () => { + await expect(generateSlug([], PROJECT_DIR)).rejects.toThrow(GSDError); + try { + await generateSlug([], PROJECT_DIR); + } catch (err) { + expect(err).toBeInstanceOf(GSDError); + expect((err as GSDError).classification).toBe(ErrorClassification.Validation); + } + }); +}); + +describe('currentTimestamp', () => { + it('returns full ISO timestamp by default', async () => { + const result = await currentTimestamp([], PROJECT_DIR); + const ts = (result.data as { timestamp: string }).timestamp; + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it('returns full ISO timestamp for "full" format', async () => { + const result = await currentTimestamp(['full'], PROJECT_DIR); + const ts = (result.data as { timestamp: string }).timestamp; + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('returns date-only string for "date" format', async () => { + const result = await currentTimestamp(['date'], PROJECT_DIR); + const ts = (result.data as { timestamp: string }).timestamp; + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('returns filename-safe string for "filename" format', async () => { + const result = await currentTimestamp(['filename'], PROJECT_DIR); + const ts = (result.data as { timestamp: string }).timestamp; + expect(ts).not.toContain(':'); + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/); + }); +}); diff --git a/sdk/src/query/utils.ts b/sdk/src/query/utils.ts new file mode 100644 index 000000000..d28b70b01 --- /dev/null +++ b/sdk/src/query/utils.ts @@ -0,0 +1,92 @@ +/** + * Utility query handlers — pure SDK implementations of simple commands. + * + * These handlers are direct TypeScript ports of gsd-tools.cjs functions: + * - `generateSlug` ← `cmdGenerateSlug` (commands.cjs lines 38-48) + * - `currentTimestamp` ← `cmdCurrentTimestamp` (commands.cjs lines 50-71) + * + * @example + * ```typescript + * import { generateSlug, currentTimestamp } from './utils.js'; + * + * const slug = await generateSlug(['My Phase Name'], '/path/to/project'); + * // { data: { slug: 'my-phase-name' } } + * + * const ts = await currentTimestamp(['date'], '/path/to/project'); + * // { data: { timestamp: '2026-04-08' } } + * ``` + */ + +import { GSDError, ErrorClassification } from '../errors.js'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +/** Structured result returned by all query handlers. */ +export interface QueryResult { + data: unknown; +} + +/** Signature for a query handler function. */ +export type QueryHandler = (args: string[], projectDir: string) => Promise; + +// ─── generateSlug ─────────────────────────────────────────────────────────── + +/** + * Converts text into a URL-safe kebab-case slug. + * + * Port of `cmdGenerateSlug` from `get-shit-done/bin/lib/commands.cjs`. + * Algorithm: lowercase, replace non-alphanumeric with hyphens, + * strip leading/trailing hyphens, truncate to 60 characters. + * + * @param args - `args[0]` is the text to slugify + * @param _projectDir - Unused (pure function) + * @returns Query result with `{ slug: string }` + * @throws GSDError with Validation classification if text is missing or empty + */ +export const generateSlug: QueryHandler = async (args, _projectDir) => { + const text = args[0]; + if (!text) { + throw new GSDError('text required for slug generation', ErrorClassification.Validation); + } + + const slug = text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 60); + + return { data: { slug } }; +}; + +// ─── currentTimestamp ─────────────────────────────────────────────────────── + +/** + * Returns the current timestamp in the requested format. + * + * Port of `cmdCurrentTimestamp` from `get-shit-done/bin/lib/commands.cjs`. + * Formats: `'full'` (ISO 8601), `'date'` (YYYY-MM-DD), `'filename'` (colons replaced). + * + * @param args - `args[0]` is the format (`'full'` | `'date'` | `'filename'`), defaults to `'full'` + * @param _projectDir - Unused (pure function) + * @returns Query result with `{ timestamp: string }` + */ +export const currentTimestamp: QueryHandler = async (args, _projectDir) => { + const format = args[0] || 'full'; + const now = new Date(); + let result: string; + + switch (format) { + case 'date': + result = now.toISOString().split('T')[0]; + break; + case 'filename': + result = now.toISOString().replace(/:/g, '-').replace(/\..+/, ''); + break; + case 'full': + default: + result = now.toISOString(); + break; + } + + return { data: { timestamp: result } }; +}; diff --git a/sdk/src/query/validate.test.ts b/sdk/src/query/validate.test.ts new file mode 100644 index 000000000..014d91f45 --- /dev/null +++ b/sdk/src/query/validate.test.ts @@ -0,0 +1,642 @@ +/** + * Tests for validation query handlers — verifyKeyLinks, validateConsistency, validateHealth. + * + * Uses temp directories with fixture files to test verification logic. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { GSDError } from '../errors.js'; + +import { verifyKeyLinks, validateConsistency, validateHealth } from './validate.js'; + +// ─── verifyKeyLinks ──────────────────────────────────────────────────────── + +describe('verifyKeyLinks', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-validate-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('throws GSDError Validation when no args', async () => { + let caught: unknown; + try { + await verifyKeyLinks([], tmpDir); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(GSDError); + expect((caught as GSDError).classification).toBe('validation'); + }); + + it('returns all_verified true when pattern found in source', async () => { + // Create source file with an import statement + await writeFile(join(tmpDir, 'source.ts'), "import { foo } from './target.js';"); + await writeFile(join(tmpDir, 'target.ts'), 'export const foo = 1;'); + + // Create plan with key_links + const planContent = `--- +phase: 01 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true + +must_haves: + key_links: + - from: source.ts + to: target.ts + via: "import foo" + pattern: "import.*foo.*from.*target" +--- + +# Plan +`; + await writeFile(join(tmpDir, 'plan.md'), planContent); + + const result = await verifyKeyLinks(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.all_verified).toBe(true); + expect(data.verified).toBe(1); + expect(data.total).toBe(1); + const links = data.links as Array>; + expect(links[0].detail).toBe('Pattern found in source'); + }); + + it('returns verified true with "Pattern found in target" when not in source but in target', async () => { + await writeFile(join(tmpDir, 'source.ts'), 'const x = 1;'); + await writeFile(join(tmpDir, 'target.ts'), "import { foo } from './other.js';"); + + const planContent = `--- +phase: 01 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true + +must_haves: + key_links: + - from: source.ts + to: target.ts + via: "import foo" + pattern: "import.*foo" +--- + +# Plan +`; + await writeFile(join(tmpDir, 'plan.md'), planContent); + + const result = await verifyKeyLinks(['plan.md'], tmpDir); + const data = result.data as Record; + const links = data.links as Array>; + expect(links[0].verified).toBe(true); + expect(links[0].detail).toBe('Pattern found in target'); + }); + + it('returns verified false when pattern not found in source or target', async () => { + await writeFile(join(tmpDir, 'source.ts'), 'const x = 1;'); + await writeFile(join(tmpDir, 'target.ts'), 'const y = 2;'); + + const planContent = `--- +phase: 01 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true + +must_haves: + key_links: + - from: source.ts + to: target.ts + via: "import foo" + pattern: "import.*foo" +--- + +# Plan +`; + await writeFile(join(tmpDir, 'plan.md'), planContent); + + const result = await verifyKeyLinks(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.all_verified).toBe(false); + const links = data.links as Array>; + expect(links[0].verified).toBe(false); + }); + + it('returns Source file not found when source missing', async () => { + await writeFile(join(tmpDir, 'target.ts'), 'export const foo = 1;'); + + const planContent = `--- +phase: 01 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true + +must_haves: + key_links: + - from: missing.ts + to: target.ts + via: "import" + pattern: "import" +--- + +# Plan +`; + await writeFile(join(tmpDir, 'plan.md'), planContent); + + const result = await verifyKeyLinks(['plan.md'], tmpDir); + const data = result.data as Record; + const links = data.links as Array>; + expect(links[0].detail).toBe('Source file not found'); + expect(links[0].verified).toBe(false); + }); + + it('checks target reference in source when no pattern specified', async () => { + await writeFile(join(tmpDir, 'source.ts'), "import { foo } from './target.ts';"); + await writeFile(join(tmpDir, 'target.ts'), 'export const foo = 1;'); + + const planContent = `--- +phase: 01 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true + +must_haves: + key_links: + - from: source.ts + to: target.ts + via: "import" +--- + +# Plan +`; + await writeFile(join(tmpDir, 'plan.md'), planContent); + + const result = await verifyKeyLinks(['plan.md'], tmpDir); + const data = result.data as Record; + const links = data.links as Array>; + expect(links[0].verified).toBe(true); + expect(links[0].detail).toBe('Target referenced in source'); + }); + + it('returns Invalid regex pattern for bad regex', async () => { + await writeFile(join(tmpDir, 'source.ts'), 'const x = 1;'); + await writeFile(join(tmpDir, 'target.ts'), 'const y = 2;'); + + const planContent = `--- +phase: 01 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true + +must_haves: + key_links: + - from: source.ts + to: target.ts + via: "bad regex" + pattern: "[invalid" +--- + +# Plan +`; + await writeFile(join(tmpDir, 'plan.md'), planContent); + + const result = await verifyKeyLinks(['plan.md'], tmpDir); + const data = result.data as Record; + const links = data.links as Array>; + expect(links[0].verified).toBe(false); + expect((links[0].detail as string).startsWith('Invalid regex pattern')).toBe(true); + }); + + it('returns error when no must_haves.key_links in plan', async () => { + const planContent = `--- +phase: 01 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true +--- + +# Plan +`; + await writeFile(join(tmpDir, 'plan.md'), planContent); + + const result = await verifyKeyLinks(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.error).toBe('No must_haves.key_links found in frontmatter'); + }); +}); + +// ─── validateConsistency ────────────────────────────────────────────────── + +describe('validateConsistency', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-consistency-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + /** Helper: create a .planning directory structure */ + async function createPlanning(opts: { + roadmap?: string; + phases?: Array<{ dir: string; plans?: string[]; summaries?: string[]; planContents?: Record }>; + config?: Record; + }): Promise { + const planning = join(tmpDir, '.planning'); + await mkdir(planning, { recursive: true }); + + if (opts.roadmap !== undefined) { + await writeFile(join(planning, 'ROADMAP.md'), opts.roadmap); + } + + if (opts.config) { + await writeFile(join(planning, 'config.json'), JSON.stringify(opts.config)); + } + + if (opts.phases) { + const phasesDir = join(planning, 'phases'); + await mkdir(phasesDir, { recursive: true }); + for (const phase of opts.phases) { + const phaseDir = join(phasesDir, phase.dir); + await mkdir(phaseDir, { recursive: true }); + if (phase.plans) { + for (const plan of phase.plans) { + const content = phase.planContents?.[plan] ?? `---\nphase: ${phase.dir}\nplan: 01\ntype: execute\nwave: 1\ndepends_on: []\nfiles_modified: []\nautonomous: true\n---\n\n# Plan\n`; + await writeFile(join(phaseDir, plan), content); + } + } + if (phase.summaries) { + for (const summary of phase.summaries) { + await writeFile(join(phaseDir, summary), '# Summary\n'); + } + } + } + } + } + + it('returns passed true when ROADMAP phases match disk', async () => { + await createPlanning({ + roadmap: '# Roadmap\n\n## Phase 1: Foundation\n\nGoal here.\n\n## Phase 2: Features\n\nMore goals.\n', + phases: [ + { dir: '01-foundation', plans: ['01-01-PLAN.md'], summaries: ['01-01-SUMMARY.md'] }, + { dir: '02-features', plans: ['02-01-PLAN.md'], summaries: ['02-01-SUMMARY.md'] }, + ], + config: { phase_naming: 'sequential' }, + }); + + const result = await validateConsistency([], tmpDir); + const data = result.data as Record; + expect(data.passed).toBe(true); + expect((data.errors as string[]).length).toBe(0); + expect((data.warnings as string[]).length).toBe(0); + }); + + it('warns when phase in ROADMAP but not on disk', async () => { + await createPlanning({ + roadmap: '# Roadmap\n\n## Phase 1: Foundation\n\n## Phase 2: Features\n\n## Phase 3: Polish\n', + phases: [ + { dir: '01-foundation', plans: ['01-01-PLAN.md'] }, + { dir: '02-features', plans: ['02-01-PLAN.md'] }, + ], + config: { phase_naming: 'sequential' }, + }); + + const result = await validateConsistency([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as string[]; + expect(warnings.some(w => w.includes('Phase 3') && w.includes('ROADMAP') && w.includes('no directory'))).toBe(true); + }); + + it('warns when phase on disk but not in ROADMAP', async () => { + await createPlanning({ + roadmap: '# Roadmap\n\n## Phase 1: Foundation\n', + phases: [ + { dir: '01-foundation', plans: ['01-01-PLAN.md'] }, + { dir: '02-features', plans: ['02-01-PLAN.md'] }, + ], + config: { phase_naming: 'sequential' }, + }); + + const result = await validateConsistency([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as string[]; + expect(warnings.some(w => w.includes('02') && w.includes('disk') && w.includes('not in ROADMAP'))).toBe(true); + }); + + it('warns on gap in sequential phase numbering', async () => { + await createPlanning({ + roadmap: '# Roadmap\n\n## Phase 1: Foundation\n\n## Phase 3: Polish\n', + phases: [ + { dir: '01-foundation', plans: ['01-01-PLAN.md'] }, + { dir: '03-polish', plans: ['03-01-PLAN.md'] }, + ], + config: { phase_naming: 'sequential' }, + }); + + const result = await validateConsistency([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as string[]; + expect(warnings.some(w => w.includes('Gap in phase numbering'))).toBe(true); + }); + + it('warns on plan numbering gap within phase', async () => { + await createPlanning({ + roadmap: '# Roadmap\n\n## Phase 1: Foundation\n', + phases: [ + { dir: '01-foundation', plans: ['01-01-PLAN.md', '01-03-PLAN.md'] }, + ], + config: { phase_naming: 'sequential' }, + }); + + const result = await validateConsistency([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as string[]; + expect(warnings.some(w => w.includes('Gap in plan numbering'))).toBe(true); + }); + + it('warns on summary without matching plan', async () => { + await createPlanning({ + roadmap: '# Roadmap\n\n## Phase 1: Foundation\n', + phases: [ + { dir: '01-foundation', plans: ['01-01-PLAN.md'], summaries: ['01-01-SUMMARY.md', '01-02-SUMMARY.md'] }, + ], + config: { phase_naming: 'sequential' }, + }); + + const result = await validateConsistency([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as string[]; + expect(warnings.some(w => w.includes('Summary') && w.includes('no matching PLAN'))).toBe(true); + }); + + it('warns when plan missing wave in frontmatter', async () => { + const noWavePlan = `---\nphase: 01\nplan: 01\ntype: execute\ndepends_on: []\nfiles_modified: []\nautonomous: true\n---\n\n# Plan\n`; + await createPlanning({ + roadmap: '# Roadmap\n\n## Phase 1: Foundation\n', + phases: [ + { dir: '01-foundation', plans: ['01-01-PLAN.md'], planContents: { '01-01-PLAN.md': noWavePlan } }, + ], + config: { phase_naming: 'sequential' }, + }); + + const result = await validateConsistency([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as string[]; + expect(warnings.some(w => w.includes('wave') && w.includes('frontmatter'))).toBe(true); + }); + + it('returns passed false with error when ROADMAP.md missing', async () => { + await createPlanning({ + phases: [{ dir: '01-foundation', plans: ['01-01-PLAN.md'] }], + config: { phase_naming: 'sequential' }, + }); + + const result = await validateConsistency([], tmpDir); + const data = result.data as Record; + expect(data.passed).toBe(false); + expect((data.errors as string[])).toContain('ROADMAP.md not found'); + }); +}); + +// ─── validateHealth ───────────────────────────────────────────────────────── + +describe('validateHealth', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-health-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + /** Helper: create a healthy .planning directory structure */ + async function createHealthyPlanning(): Promise { + const planning = join(tmpDir, '.planning'); + await mkdir(join(planning, 'phases', '01-foundation'), { recursive: true }); + + await writeFile(join(planning, 'PROJECT.md'), '# Project\n\n## What This Is\n\nA project.\n\n## Core Value\n\nValue here.\n\n## Requirements\n\n- Req 1\n'); + await writeFile(join(planning, 'ROADMAP.md'), '# Roadmap\n\n## Phase 1: Foundation\n\nGoals.\n'); + await writeFile(join(planning, 'STATE.md'), '---\nstatus: executing\n---\n\n# State\n\n**Current Phase:** 1\n**Status:** executing\n'); + await writeFile(join(planning, 'config.json'), JSON.stringify({ + model_profile: 'balanced', + workflow: { nyquist_validation: true }, + }, null, 2)); + + await writeFile(join(planning, 'phases', '01-foundation', '01-01-PLAN.md'), '---\nphase: 01\nplan: 01\ntype: execute\nwave: 1\ndepends_on: []\nfiles_modified: []\nautonomous: true\n---\n\n# Plan\n'); + await writeFile(join(planning, 'phases', '01-foundation', '01-01-SUMMARY.md'), '# Summary\n'); + } + + it('returns healthy status when all files present', async () => { + await createHealthyPlanning(); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + expect(data.status).toBe('healthy'); + expect((data.errors as unknown[]).length).toBe(0); + expect((data.warnings as unknown[]).length).toBe(0); + }); + + it('returns broken with E001 when no .planning/ directory', async () => { + // tmpDir has no .planning/ — already the case + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + expect(data.status).toBe('broken'); + const errors = data.errors as Array>; + expect(errors.some(e => e.code === 'E001')).toBe(true); + }); + + it('returns error E002 when PROJECT.md missing', async () => { + await createHealthyPlanning(); + const { unlink } = await import('node:fs/promises'); + await unlink(join(tmpDir, '.planning', 'PROJECT.md')); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const errors = data.errors as Array>; + expect(errors.some(e => e.code === 'E002')).toBe(true); + }); + + it('returns error E003 when ROADMAP.md missing', async () => { + await createHealthyPlanning(); + const { unlink } = await import('node:fs/promises'); + await unlink(join(tmpDir, '.planning', 'ROADMAP.md')); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const errors = data.errors as Array>; + expect(errors.some(e => e.code === 'E003')).toBe(true); + }); + + it('returns error E004 when STATE.md missing (repairable)', async () => { + await createHealthyPlanning(); + const { unlink } = await import('node:fs/promises'); + await unlink(join(tmpDir, '.planning', 'STATE.md')); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const errors = data.errors as Array>; + const e004 = errors.find(e => e.code === 'E004'); + expect(e004).toBeDefined(); + expect(e004!.repairable).toBe(true); + }); + + it('returns error E005 when config.json has invalid JSON (repairable)', async () => { + await createHealthyPlanning(); + await writeFile(join(tmpDir, '.planning', 'config.json'), '{invalid json!!!'); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const errors = data.errors as Array>; + const e005 = errors.find(e => e.code === 'E005'); + expect(e005).toBeDefined(); + expect(e005!.repairable).toBe(true); + }); + + it('returns warning W003 when config.json missing (repairable)', async () => { + await createHealthyPlanning(); + const { unlink } = await import('node:fs/promises'); + await unlink(join(tmpDir, '.planning', 'config.json')); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as Array>; + const w003 = warnings.find(w => w.code === 'W003'); + expect(w003).toBeDefined(); + expect(w003!.repairable).toBe(true); + }); + + it('returns warning W005 for bad phase directory naming', async () => { + await createHealthyPlanning(); + await mkdir(join(tmpDir, '.planning', 'phases', 'bad_name'), { recursive: true }); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as Array>; + expect(warnings.some(w => w.code === 'W005')).toBe(true); + }); + + it('returns early with E010 when CWD equals home directory', async () => { + const result = await validateHealth([], homedir()); + const data = result.data as Record; + expect(data.status).toBe('error'); + const errors = data.errors as Array>; + expect(errors.some(e => e.code === 'E010')).toBe(true); + }); + + it('returns warning W008 when config.json missing workflow.nyquist_validation', async () => { + await createHealthyPlanning(); + await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + workflow: { research: true }, + }, null, 2)); + + const result = await validateHealth([], tmpDir); + const data = result.data as Record; + const warnings = data.warnings as Array>; + expect(warnings.some(w => w.code === 'W008')).toBe(true); + }); + + it('derives status from errors (broken), warnings (degraded), none (healthy)', async () => { + // broken: no .planning/ + const r1 = await validateHealth([], tmpDir); + expect((r1.data as Record).status).toBe('broken'); + + // degraded: missing config.json (warning only, not error) + await createHealthyPlanning(); + const { unlink } = await import('node:fs/promises'); + await unlink(join(tmpDir, '.planning', 'config.json')); + const r2 = await validateHealth([], tmpDir); + expect((r2.data as Record).status).toBe('degraded'); + + // healthy: all present + await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + workflow: { nyquist_validation: true }, + }, null, 2)); + const r3 = await validateHealth([], tmpDir); + expect((r3.data as Record).status).toBe('healthy'); + }); + + // ─── Repair tests ─────────────────────────────────────────────────────── + + it('--repair with missing config.json creates config.json with defaults', async () => { + await createHealthyPlanning(); + const { unlink } = await import('node:fs/promises'); + await unlink(join(tmpDir, '.planning', 'config.json')); + + const result = await validateHealth(['--repair'], tmpDir); + const data = result.data as Record; + expect(data.repairs_performed).toBeDefined(); + const repairs = data.repairs_performed as Array>; + expect(repairs.some(r => r.action === 'createConfig' && r.success === true)).toBe(true); + + // Verify file was created + const config = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(config.model_profile).toBe('balanced'); + expect(config.workflow.nyquist_validation).toBe(true); + }); + + it('--repair with missing STATE.md generates minimal STATE.md', async () => { + await createHealthyPlanning(); + const { unlink } = await import('node:fs/promises'); + await unlink(join(tmpDir, '.planning', 'STATE.md')); + + const result = await validateHealth(['--repair'], tmpDir); + const data = result.data as Record; + const repairs = data.repairs_performed as Array>; + expect(repairs.some(r => r.action === 'regenerateState' && r.success === true)).toBe(true); + + // Verify file was created + const stateContent = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + expect(stateContent).toContain('# Session State'); + expect(stateContent).toContain('regenerated by'); + }); + + it('--repair with missing nyquist key adds workflow.nyquist_validation', async () => { + await createHealthyPlanning(); + await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + model_profile: 'balanced', + workflow: { research: true }, + }, null, 2)); + + const result = await validateHealth(['--repair'], tmpDir); + const data = result.data as Record; + const repairs = data.repairs_performed as Array>; + expect(repairs.some(r => r.action === 'addNyquistKey' && r.success === true)).toBe(true); + + // Verify key was added + const config = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8')); + expect(config.workflow.nyquist_validation).toBe(true); + }); +}); diff --git a/sdk/src/query/validate.ts b/sdk/src/query/validate.ts new file mode 100644 index 000000000..082639e73 --- /dev/null +++ b/sdk/src/query/validate.ts @@ -0,0 +1,709 @@ +/** + * Validation query handlers — key-link verification and consistency checking. + * + * Ported from get-shit-done/bin/lib/verify.cjs. + * Provides key-link integration point verification and cross-file consistency + * detection as native TypeScript query handlers registered in the SDK query registry. + * + * @example + * ```typescript + * import { verifyKeyLinks, validateConsistency } from './validate.js'; + * + * const result = await verifyKeyLinks(['path/to/plan.md'], '/project'); + * // { data: { all_verified: true, verified: 1, total: 1, links: [...] } } + * ``` + */ + +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, isAbsolute, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { extractFrontmatter, parseMustHavesBlock } from './frontmatter.js'; +import { escapeRegex, normalizePhaseName, planningPaths } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── verifyKeyLinks ─────────────────────────────────────────────────────── + +/** + * Verify key-link integration points from must_haves.key_links. + * + * Port of `cmdVerifyKeyLinks` from `verify.cjs` lines 338-396. + * Reads must_haves.key_links from plan frontmatter, checks source/target + * files for pattern matching or target reference presence. + * + * @param args - args[0]: plan file path (required) + * @param projectDir - Project root directory + * @returns QueryResult with { all_verified, verified, total, links } + * @throws GSDError with Validation classification if file path missing + */ +export const verifyKeyLinks: QueryHandler = async (args, projectDir) => { + const planFilePath = args[0]; + if (!planFilePath) { + throw new GSDError('plan file path required', ErrorClassification.Validation); + } + + // T-12-07: Null byte check on plan file path + if (planFilePath.includes('\0')) { + throw new GSDError('file path contains null bytes', ErrorClassification.Validation); + } + + const fullPath = isAbsolute(planFilePath) ? planFilePath : join(projectDir, planFilePath); + + let content: string; + try { + content = await readFile(fullPath, 'utf-8'); + } catch { + return { data: { error: 'File not found', path: planFilePath } }; + } + + const { items: keyLinks } = parseMustHavesBlock(content, 'key_links'); + if (keyLinks.length === 0) { + return { data: { error: 'No must_haves.key_links found in frontmatter', path: planFilePath } }; + } + + const results: Array<{ from: string; to: string; via: string; verified: boolean; detail: string }> = []; + + for (const link of keyLinks) { + if (typeof link === 'string') continue; + const linkObj = link as Record; + const check = { + from: (linkObj.from as string) || '', + to: (linkObj.to as string) || '', + via: (linkObj.via as string) || '', + verified: false, + detail: '', + }; + + let sourceContent: string | null = null; + try { + sourceContent = await readFile(join(projectDir, check.from), 'utf-8'); + } catch { + // Source file not found + } + + if (!sourceContent) { + check.detail = 'Source file not found'; + } else if (linkObj.pattern) { + // T-12-05: Wrap new RegExp in try/catch + try { + const regex = new RegExp(linkObj.pattern as string); + if (regex.test(sourceContent)) { + check.verified = true; + check.detail = 'Pattern found in source'; + } else { + // Try target file + let targetContent: string | null = null; + try { + targetContent = await readFile(join(projectDir, check.to), 'utf-8'); + } catch { + // Target file not found + } + if (targetContent && regex.test(targetContent)) { + check.verified = true; + check.detail = 'Pattern found in target'; + } else { + check.detail = `Pattern "${linkObj.pattern}" not found in source or target`; + } + } + } catch { + check.detail = `Invalid regex pattern: ${linkObj.pattern}`; + } + } else { + // No pattern: check if target path is referenced in source content + if (sourceContent.includes(check.to)) { + check.verified = true; + check.detail = 'Target referenced in source'; + } else { + check.detail = 'Target not referenced in source'; + } + } + + results.push(check); + } + + const verified = results.filter(r => r.verified).length; + return { + data: { + all_verified: verified === results.length, + verified, + total: results.length, + links: results, + }, + }; +}; + +// ─── validateConsistency ───────────────────────────────────────────────── + +/** + * Validate consistency between ROADMAP.md, disk phases, and plan frontmatter. + * + * Port of `cmdValidateConsistency` from `verify.cjs` lines 398-519. + * Checks ROADMAP/disk phase sync, sequential numbering, plan numbering gaps, + * summary/plan orphans, and frontmatter completeness. + * + * @param _args - No required args (operates on projectDir) + * @param projectDir - Project root directory + * @returns QueryResult with { passed, errors, warnings, warning_count } + */ +export const validateConsistency: QueryHandler = async (_args, projectDir) => { + const paths = planningPaths(projectDir); + const errors: string[] = []; + const warnings: string[] = []; + + // Read ROADMAP.md + let roadmapContent: string; + try { + roadmapContent = await readFile(paths.roadmap, 'utf-8'); + } catch { + return { data: { passed: false, errors: ['ROADMAP.md not found'], warnings: [], warning_count: 0 } }; + } + + // Strip shipped milestone
blocks + const activeContent = roadmapContent.replace(/
[\s\S]*?<\/details>/gi, ''); + + // Extract phase numbers from ROADMAP headings + const roadmapPhases = new Set(); + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + let m: RegExpExecArray | null; + while ((m = phasePattern.exec(activeContent)) !== null) { + roadmapPhases.add(m[1]); + } + + // Get phases on disk + const diskPhases = new Set(); + let diskDirs: string[] = []; + try { + const entries = await readdir(paths.phases, { withFileTypes: true }); + diskDirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort(); + for (const dir of diskDirs) { + const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + if (dm) diskPhases.add(dm[1]); + } + } catch { + // phases directory doesn't exist + } + + // Check: phases in ROADMAP but not on disk + for (const p of roadmapPhases) { + if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) { + warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`); + } + } + + // Check: phases on disk but not in ROADMAP + for (const p of diskPhases) { + const unpadded = String(parseInt(p, 10)); + if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) { + warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`); + } + } + + // Check sequential phase numbering (skip in custom naming mode) + let config: Record = {}; + try { + const configContent = await readFile(paths.config, 'utf-8'); + config = JSON.parse(configContent) as Record; + } catch { + // config not found or invalid — proceed with defaults + } + + if (config.phase_naming !== 'custom') { + const integerPhases = [...diskPhases] + .filter(p => !p.includes('.')) + .map(p => parseInt(p, 10)) + .sort((a, b) => a - b); + + for (let i = 1; i < integerPhases.length; i++) { + if (integerPhases[i] !== integerPhases[i - 1] + 1) { + warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} \u2192 ${integerPhases[i]}`); + } + } + } + + // Check plan numbering and summaries within each phase + for (const dir of diskDirs) { + let phaseFiles: string[]; + try { + phaseFiles = await readdir(join(paths.phases, dir)); + } catch { + continue; + } + + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort(); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md')); + + // Extract plan numbers and check for gaps + const planNums = plans.map(p => { + const pm = p.match(/-(\d{2})-PLAN\.md$/); + return pm ? parseInt(pm[1], 10) : null; + }).filter((n): n is number => n !== null); + + for (let i = 1; i < planNums.length; i++) { + if (planNums[i] !== planNums[i - 1] + 1) { + warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} \u2192 ${planNums[i]}`); + } + } + + // Check: summaries without matching plans + const planIds = new Set(plans.map(p => p.replace('-PLAN.md', ''))); + const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', ''))); + + for (const sid of summaryIds) { + if (!planIds.has(sid)) { + warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`); + } + } + } + + // Check frontmatter completeness in plans + for (const dir of diskDirs) { + let phaseFiles: string[]; + try { + phaseFiles = await readdir(join(paths.phases, dir)); + } catch { + continue; + } + + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')); + for (const plan of plans) { + try { + const content = await readFile(join(paths.phases, dir, plan), 'utf-8'); + const fm = extractFrontmatter(content); + if (!fm.wave) { + warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`); + } + } catch { + // Cannot read plan file + } + } + } + + const passed = errors.length === 0; + return { + data: { + passed, + errors, + warnings, + warning_count: warnings.length, + }, + }; +}; + +// ─── validateHealth ───────────────────────────────────────────────────────── + +/** + * Health check with optional repair mode. + * + * Port of `cmdValidateHealth` from `verify.cjs` lines 522-921. + * Performs 10+ checks on .planning/ directory structure, config, state, + * and cross-file consistency. With `--repair` flag, can fix missing + * config.json, STATE.md, and nyquist key. + * + * @param args - Optional: '--repair' to perform repairs + * @param projectDir - Project root directory + * @returns QueryResult with { status, errors, warnings, info, repairable_count, repairs_performed? } + */ +export const validateHealth: QueryHandler = async (args, projectDir) => { + const doRepair = args.includes('--repair'); + + // T-12-09: Home directory guard + const resolved = resolve(projectDir); + if (resolved === homedir()) { + return { + data: { + status: 'error', + errors: [{ + code: 'E010', + message: `CWD is home directory (${resolved}) — health check would read the wrong .planning/ directory. Run from your project root instead.`, + fix: 'cd into your project directory and retry', + }], + warnings: [], + info: [{ code: 'I010', message: `Resolved CWD: ${resolved}` }], + repairable_count: 0, + }, + }; + } + + const paths = planningPaths(projectDir); + const planBase = join(projectDir, '.planning'); + const projectPath = join(planBase, 'PROJECT.md'); + const roadmapPath = join(planBase, 'ROADMAP.md'); + const statePath = join(planBase, 'STATE.md'); + const configPath = join(planBase, 'config.json'); + const phasesDir = join(planBase, 'phases'); + + interface Issue { + code: string; + message: string; + fix: string; + repairable: boolean; + } + const errors: Issue[] = []; + const warnings: Issue[] = []; + const info: Issue[] = []; + const repairs: string[] = []; + + const addIssue = (severity: 'error' | 'warning' | 'info', code: string, message: string, fix: string, repairable = false) => { + const issue: Issue = { code, message, fix, repairable }; + if (severity === 'error') errors.push(issue); + else if (severity === 'warning') warnings.push(issue); + else info.push(issue); + }; + + // ─── Check 1: .planning/ exists ─────────────────────────────────────────── + if (!existsSync(planBase)) { + addIssue('error', 'E001', '.planning/ directory not found', 'Run /gsd-new-project to initialize'); + return { + data: { + status: 'broken', + errors, + warnings, + info, + repairable_count: 0, + }, + }; + } + + // ─── Check 2: PROJECT.md exists and has required sections ───────────────── + if (!existsSync(projectPath)) { + addIssue('error', 'E002', 'PROJECT.md not found', 'Run /gsd-new-project to create'); + } else { + try { + const content = await readFile(projectPath, 'utf-8'); + const requiredSections = ['## What This Is', '## Core Value', '## Requirements']; + for (const section of requiredSections) { + if (!content.includes(section)) { + addIssue('warning', 'W001', `PROJECT.md missing section: ${section}`, 'Add section manually'); + } + } + } catch { /* intentionally empty */ } + } + + // ─── Check 3: ROADMAP.md exists ─────────────────────────────────────────── + if (!existsSync(roadmapPath)) { + addIssue('error', 'E003', 'ROADMAP.md not found', 'Run /gsd-new-milestone to create roadmap'); + } + + // ─── Check 4: STATE.md exists and references valid phases ───────────────── + if (!existsSync(statePath)) { + addIssue('error', 'E004', 'STATE.md not found', 'Run /gsd-health --repair to regenerate', true); + repairs.push('regenerateState'); + } else { + try { + const stateContent = await readFile(statePath, 'utf-8'); + const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+(?:\.\d+)*)/g)].map(m => m[1]); + const diskPhases = new Set(); + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory()) { + const m = e.name.match(/^(\d+(?:\.\d+)*)/); + if (m) diskPhases.add(m[1]); + } + } + } catch { /* intentionally empty */ } + + for (const ref of phaseRefs) { + const normalizedRef = String(parseInt(ref, 10)).padStart(2, '0'); + if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) { + if (diskPhases.size > 0) { + addIssue('warning', 'W002', + `STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`, + 'Review STATE.md manually'); + } + } + } + } catch { /* intentionally empty */ } + } + + // ─── Check 5: config.json valid JSON + valid schema ─────────────────────── + if (!existsSync(configPath)) { + addIssue('warning', 'W003', 'config.json not found', 'Run /gsd-health --repair to create with defaults', true); + repairs.push('createConfig'); + } else { + try { + const raw = await readFile(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as Record; + const validProfiles = ['quality', 'balanced', 'budget', 'inherit']; + if (parsed.model_profile && !validProfiles.includes(parsed.model_profile as string)) { + addIssue('warning', 'W004', `config.json: invalid model_profile "${parsed.model_profile}"`, `Valid values: ${validProfiles.join(', ')}`); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + addIssue('error', 'E005', `config.json: JSON parse error - ${msg}`, 'Run /gsd-health --repair to reset to defaults', true); + repairs.push('resetConfig'); + } + } + + // ─── Check 5b: Nyquist validation key presence ────────────────────────── + if (existsSync(configPath)) { + try { + const configRaw = await readFile(configPath, 'utf-8'); + const configParsed = JSON.parse(configRaw) as Record; + const workflow = configParsed.workflow as Record | undefined; + if (workflow && workflow.nyquist_validation === undefined) { + addIssue('warning', 'W008', 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', 'Run /gsd-health --repair to add key', true); + if (!repairs.includes('addNyquistKey')) repairs.push('addNyquistKey'); + } + } catch { /* intentionally empty */ } + } + + // ─── Check 6: Phase directory naming (NN-name format) ───────────────────── + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)*-[\w-]+$/)) { + addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)'); + } + } + } catch { /* intentionally empty */ } + + // ─── Check 7: Orphaned plans (PLAN without SUMMARY) ─────────────────────── + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (!e.isDirectory()) continue; + const phaseFiles = await readdir(join(phasesDir, e.name)); + const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md'); + const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))); + + for (const plan of plans) { + const planBase2 = plan.replace('-PLAN.md', '').replace('PLAN.md', ''); + if (!summaryBases.has(planBase2)) { + addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress'); + } + } + } + } catch { /* intentionally empty */ } + + // ─── Check 7b: Nyquist VALIDATION.md consistency ──────────────────────── + try { + const phaseEntries = await readdir(phasesDir, { withFileTypes: true }); + for (const e of phaseEntries) { + if (!e.isDirectory()) continue; + const phaseFiles = await readdir(join(phasesDir, e.name)); + const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md')); + const hasValidation = phaseFiles.some(f => f.endsWith('-VALIDATION.md')); + if (hasResearch && !hasValidation) { + const researchFile = phaseFiles.find(f => f.endsWith('-RESEARCH.md')); + if (researchFile) { + try { + const researchContent = await readFile(join(phasesDir, e.name, researchFile), 'utf-8'); + if (researchContent.includes('## Validation Architecture')) { + addIssue('warning', 'W009', `Phase ${e.name}: has Validation Architecture in RESEARCH.md but no VALIDATION.md`, 'Re-run /gsd-plan-phase with --research to regenerate'); + } + } catch { /* intentionally empty */ } + } + } + } + } catch { /* intentionally empty */ } + + // ─── Check 8: ROADMAP/disk phase sync ───────────────────────────────────── + if (existsSync(roadmapPath)) { + try { + const roadmapContent = await readFile(roadmapPath, 'utf-8'); + const roadmapPhases = new Set(); + const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; + let m: RegExpExecArray | null; + while ((m = phasePattern.exec(roadmapContent)) !== null) { + roadmapPhases.add(m[1]); + } + + const diskPhases = new Set(); + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + for (const e of entries) { + if (e.isDirectory()) { + const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + if (dm) diskPhases.add(dm[1]); + } + } + } catch { /* intentionally empty */ } + + for (const p of roadmapPhases) { + const padded = String(parseInt(p, 10)).padStart(2, '0'); + if (!diskPhases.has(p) && !diskPhases.has(padded)) { + addIssue('warning', 'W006', `Phase ${p} in ROADMAP.md but no directory on disk`, 'Create phase directory or remove from roadmap'); + } + } + + for (const p of diskPhases) { + const unpadded = String(parseInt(p, 10)); + if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) { + addIssue('warning', 'W007', `Phase ${p} exists on disk but not in ROADMAP.md`, 'Add to roadmap or remove directory'); + } + } + } catch { /* intentionally empty */ } + } + + // ─── Check 9: STATE.md / ROADMAP.md cross-validation ───────────────────── + if (existsSync(statePath) && existsSync(roadmapPath)) { + try { + const stateContent = await readFile(statePath, 'utf-8'); + const roadmapContentFull = await readFile(roadmapPath, 'utf-8'); + + const currentPhaseMatch = stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/i) || + stateContent.match(/Current Phase:\s*(\S+)/i); + if (currentPhaseMatch) { + const statePhase = currentPhaseMatch[1].replace(/^0+/, ''); + const phaseCheckboxRe = new RegExp(`-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}[:\\s]`, 'i'); + if (phaseCheckboxRe.test(roadmapContentFull)) { + const stateStatus = stateContent.match(/\*\*Status:\*\*\s*(.+)/i); + const statusVal = stateStatus ? stateStatus[1].trim().toLowerCase() : ''; + if (statusVal !== 'complete' && statusVal !== 'done') { + addIssue('warning', 'W011', + `STATE.md says current phase is ${statePhase} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`, + 'Run /gsd-progress to re-derive current position, or manually update STATE.md'); + } + } + } + } catch { /* intentionally empty */ } + } + + // ─── Check 10: Config field validation ──────────────────────────────────── + if (existsSync(configPath)) { + try { + const configRaw = await readFile(configPath, 'utf-8'); + const configParsed = JSON.parse(configRaw) as Record; + + const validStrategies = ['none', 'phase', 'milestone']; + const bs = configParsed.branching_strategy as string | undefined; + if (bs && !validStrategies.includes(bs)) { + addIssue('warning', 'W012', + `config.json: invalid branching_strategy "${bs}"`, + `Valid values: ${validStrategies.join(', ')}`); + } + + if (configParsed.context_window !== undefined) { + const cw = configParsed.context_window; + if (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw)) { + addIssue('warning', 'W013', + `config.json: context_window should be a positive integer, got "${cw}"`, + 'Set to 200000 (default) or 1000000 (for 1M models)'); + } + } + + const pbt = configParsed.phase_branch_template as string | undefined; + if (pbt && !pbt.includes('{phase}')) { + addIssue('warning', 'W014', + 'config.json: phase_branch_template missing {phase} placeholder', + 'Template must include {phase} for phase number substitution'); + } + const mbt = configParsed.milestone_branch_template as string | undefined; + if (mbt && !mbt.includes('{milestone}')) { + addIssue('warning', 'W015', + 'config.json: milestone_branch_template missing {milestone} placeholder', + 'Template must include {milestone} for version substitution'); + } + } catch { /* parse error already caught in Check 5 */ } + } + + // ─── Perform repairs if requested ───────────────────────────────────────── + const repairActions: Array<{ action: string; success: boolean; path?: string; error?: string }> = []; + if (doRepair && repairs.length > 0) { + for (const repair of repairs) { + try { + switch (repair) { + case 'createConfig': + case 'resetConfig': { + // T-12-11: Write known-safe defaults only + const defaults = { + model_profile: 'balanced', + commit_docs: false, + search_gitignored: false, + branching_strategy: 'none', + phase_branch_template: 'feat/phase-{phase}', + milestone_branch_template: 'feat/{milestone}', + quick_branch_template: 'fix/{slug}', + workflow: { + research: true, + plan_check: true, + verifier: true, + nyquist_validation: true, + }, + parallelization: 1, + brave_search: false, + }; + await writeFile(configPath, JSON.stringify(defaults, null, 2), 'utf-8'); + repairActions.push({ action: repair, success: true, path: 'config.json' }); + break; + } + case 'regenerateState': { + // Generate minimal STATE.md from ROADMAP.md structure + let milestoneName = 'Unknown'; + let milestoneVersion = 'v1.0'; + try { + const roadmapContent = await readFile(roadmapPath, 'utf-8'); + const milestoneMatch = roadmapContent.match(/##\s+(?:Current\s+)?Milestone[:\s]+(\S+)\s*[-—]\s*(.+)/i); + if (milestoneMatch) { + milestoneVersion = milestoneMatch[1]; + milestoneName = milestoneMatch[2].trim(); + } + } catch { /* intentionally empty */ } + + let stateContent = `# Session State\n\n`; + stateContent += `## Project Reference\n\n`; + stateContent += `See: .planning/PROJECT.md\n\n`; + stateContent += `## Position\n\n`; + stateContent += `**Milestone:** ${milestoneVersion} ${milestoneName}\n`; + stateContent += `**Current phase:** (determining...)\n`; + stateContent += `**Status:** Resuming\n\n`; + stateContent += `## Session Log\n\n`; + stateContent += `- ${new Date().toISOString().split('T')[0]}: STATE.md regenerated by /gsd-health --repair\n`; + await writeFile(statePath, stateContent, 'utf-8'); + repairActions.push({ action: repair, success: true, path: 'STATE.md' }); + break; + } + case 'addNyquistKey': { + if (existsSync(configPath)) { + try { + const configRaw = await readFile(configPath, 'utf-8'); + const configParsed = JSON.parse(configRaw) as Record; + if (!configParsed.workflow) configParsed.workflow = {}; + const wf = configParsed.workflow as Record; + if (wf.nyquist_validation === undefined) { + wf.nyquist_validation = true; + await writeFile(configPath, JSON.stringify(configParsed, null, 2), 'utf-8'); + } + repairActions.push({ action: repair, success: true, path: 'config.json' }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + repairActions.push({ action: repair, success: false, error: msg }); + } + } + break; + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + repairActions.push({ action: repair, success: false, error: msg }); + } + } + } + + // ─── Determine overall status ───────────────────────────────────────────── + let status: string; + if (errors.length > 0) { + status = 'broken'; + } else if (warnings.length > 0) { + status = 'degraded'; + } else { + status = 'healthy'; + } + + const repairableCount = errors.filter(e => e.repairable).length + + warnings.filter(w => w.repairable).length; + + return { + data: { + status, + errors, + warnings, + info, + repairable_count: repairableCount, + repairs_performed: repairActions.length > 0 ? repairActions : undefined, + }, + }; +}; diff --git a/sdk/src/query/verify.test.ts b/sdk/src/query/verify.test.ts new file mode 100644 index 000000000..2fe79676d --- /dev/null +++ b/sdk/src/query/verify.test.ts @@ -0,0 +1,414 @@ +/** + * Unit tests for verification query handlers. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { GSDError } from '../errors.js'; +import { verifyPlanStructure, verifyPhaseCompleteness, verifyArtifacts } from './verify.js'; + +// ─── verifyPlanStructure ─────────────────────────────────────────────────── + +describe('verifyPlanStructure', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-verify-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns valid for plan with all required fields and task elements', async () => { + const plan = `--- +phase: 12 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/foo.ts +autonomous: true +must_haves: + truths: + - something works +--- + + + Task 1: Do something + src/foo.ts + Implement foo + Run tests + Foo works + +`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyPlanStructure(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.valid).toBe(true); + expect(data.errors).toEqual([]); + expect(data.task_count).toBe(1); + expect(data.frontmatter_fields).toContain('phase'); + }); + + it('returns invalid when required frontmatter field wave is missing', async () => { + const plan = `--- +phase: 12 +plan: 01 +type: execute +depends_on: [] +files_modified: [] +autonomous: true +must_haves: + truths: + - something +--- + + + Task 1 + Do it + +`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyPlanStructure(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.valid).toBe(false); + expect(data.errors).toContain('Missing required frontmatter field: wave'); + }); + + it('returns error when task missing element', async () => { + const plan = `--- +phase: 12 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true +must_haves: + truths: + - x +--- + + + Do something + +`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyPlanStructure(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.valid).toBe(false); + expect(data.errors).toContain('Task missing element'); + }); + + it('returns error when task missing element', async () => { + const plan = `--- +phase: 12 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true +must_haves: + truths: + - x +--- + + + Task 1 + Done + +`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyPlanStructure(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.valid).toBe(false); + expect((data.errors as string[])).toContainEqual(expect.stringContaining("missing ")); + }); + + it('returns warning when wave > 1 but depends_on is empty', async () => { + const plan = `--- +phase: 12 +plan: 01 +type: execute +wave: 2 +depends_on: [] +files_modified: [] +autonomous: true +must_haves: + truths: + - x +--- + + + Task 1 + Do it + +`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyPlanStructure(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.warnings).toContain('Wave > 1 but depends_on is empty'); + }); + + it('returns error when checkpoint task present but autonomous is not false', async () => { + const plan = `--- +phase: 12 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true +must_haves: + truths: + - x +--- + + + Check it + Verify + +`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyPlanStructure(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.valid).toBe(false); + expect(data.errors).toContain('Has checkpoint tasks but autonomous is not false'); + }); + + it('returns warning when no tasks found', async () => { + const plan = `--- +phase: 12 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: [] +autonomous: true +must_haves: + truths: + - x +--- + +No tasks here. +`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyPlanStructure(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.warnings).toContain('No elements found'); + }); + + it('returns error for missing file', async () => { + const result = await verifyPlanStructure(['nonexistent.md'], tmpDir); + const data = result.data as Record; + expect(data.error).toBe('File not found'); + }); + + it('throws GSDError with Validation classification when no args', async () => { + let caught: unknown; + try { + await verifyPlanStructure([], tmpDir); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(GSDError); + expect((caught as GSDError).classification).toBe('validation'); + }); +}); + +// ─── verifyPhaseCompleteness ─────────────────────────────────────────────── + +describe('verifyPhaseCompleteness', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-verify-phase-')); + await mkdir(join(tmpDir, '.planning', 'phases', '09-foundation'), { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns complete when all plans have matching summaries', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '09-foundation'); + await writeFile(join(phaseDir, '09-01-PLAN.md'), '---\nphase: 09\n---\n'); + await writeFile(join(phaseDir, '09-02-PLAN.md'), '---\nphase: 09\n---\n'); + await writeFile(join(phaseDir, '09-01-SUMMARY.md'), '# Summary\n'); + await writeFile(join(phaseDir, '09-02-SUMMARY.md'), '# Summary\n'); + + const result = await verifyPhaseCompleteness(['9'], tmpDir); + const data = result.data as Record; + expect(data.complete).toBe(true); + expect(data.plan_count).toBe(2); + expect(data.summary_count).toBe(2); + }); + + it('returns incomplete when plan is missing summary', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '09-foundation'); + await writeFile(join(phaseDir, '09-01-PLAN.md'), '---\nphase: 09\n---\n'); + await writeFile(join(phaseDir, '09-02-PLAN.md'), '---\nphase: 09\n---\n'); + await writeFile(join(phaseDir, '09-01-SUMMARY.md'), '# Summary\n'); + + const result = await verifyPhaseCompleteness(['9'], tmpDir); + const data = result.data as Record; + expect(data.complete).toBe(false); + expect(data.incomplete_plans).toContain('09-02'); + }); + + it('returns warning for orphan summary', async () => { + const phaseDir = join(tmpDir, '.planning', 'phases', '09-foundation'); + await writeFile(join(phaseDir, '09-01-PLAN.md'), '---\nphase: 09\n---\n'); + await writeFile(join(phaseDir, '09-01-SUMMARY.md'), '# Summary\n'); + await writeFile(join(phaseDir, '09-99-SUMMARY.md'), '# Orphan\n'); + + const result = await verifyPhaseCompleteness(['9'], tmpDir); + const data = result.data as Record; + expect((data.orphan_summaries as string[])).toContain('09-99'); + expect((data.warnings as string[]).some(w => w.includes('09-99'))).toBe(true); + }); + + it('returns error for phase not found', async () => { + const result = await verifyPhaseCompleteness(['99'], tmpDir); + const data = result.data as Record; + expect(data.error).toBe('Phase not found'); + }); + + it('throws GSDError with Validation classification when no args', async () => { + await expect(verifyPhaseCompleteness([], tmpDir)).rejects.toThrow(GSDError); + }); +}); + +// ─── verifyArtifacts ─────────────────────────────────────────────────────── + +describe('verifyArtifacts', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-verify-art-')); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns all_passed when all artifacts exist and pass checks', async () => { + await writeFile(join(tmpDir, 'src.ts'), 'export function foo() {}\nexport function bar() {}\nline3\nline4\nline5\n'); + const plan = `--- +phase: 12 +must_haves: + artifacts: + - path: src.ts + provides: Foo handler + min_lines: 3 + contains: export function foo + exports: + - foo + - bar +--- +body`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyArtifacts(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.all_passed).toBe(true); + expect(data.passed).toBe(1); + expect(data.total).toBe(1); + }); + + it('returns passed false when artifact file does not exist', async () => { + const plan = `--- +phase: 12 +must_haves: + artifacts: + - path: nonexistent.ts + provides: Something +--- +body`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyArtifacts(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.all_passed).toBe(false); + const artifacts = data.artifacts as Array>; + expect(artifacts[0].passed).toBe(false); + expect((artifacts[0].issues as string[])).toContain('File not found'); + }); + + it('returns issue when min_lines check fails', async () => { + await writeFile(join(tmpDir, 'short.ts'), 'line1\nline2\n'); + const plan = `--- +phase: 12 +must_haves: + artifacts: + - path: short.ts + min_lines: 100 +--- +body`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyArtifacts(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.all_passed).toBe(false); + const artifacts = data.artifacts as Array>; + expect((artifacts[0].issues as string[])[0]).toContain('lines'); + }); + + it('returns issue when contains check fails', async () => { + await writeFile(join(tmpDir, 'file.ts'), 'const x = 1;\n'); + const plan = `--- +phase: 12 +must_haves: + artifacts: + - path: file.ts + contains: export function missing +--- +body`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyArtifacts(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.all_passed).toBe(false); + const artifacts = data.artifacts as Array>; + expect((artifacts[0].issues as string[])[0]).toContain('Missing pattern'); + }); + + it('returns issue when exports check fails', async () => { + await writeFile(join(tmpDir, 'file.ts'), 'export function foo() {}\n'); + const plan = `--- +phase: 12 +must_haves: + artifacts: + - path: file.ts + exports: + - foo + - missingExport +--- +body`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyArtifacts(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.all_passed).toBe(false); + const artifacts = data.artifacts as Array>; + expect((artifacts[0].issues as string[]).some(i => i.includes('missingExport'))).toBe(true); + }); + + it('returns error when no must_haves.artifacts found', async () => { + const plan = `--- +phase: 12 +must_haves: + truths: + - something +--- +body`; + await writeFile(join(tmpDir, 'plan.md'), plan); + const result = await verifyArtifacts(['plan.md'], tmpDir); + const data = result.data as Record; + expect(data.error).toBe('No must_haves.artifacts found in frontmatter'); + }); + + it('throws GSDError with Validation classification when no args', async () => { + await expect(verifyArtifacts([], tmpDir)).rejects.toThrow(GSDError); + }); +}); diff --git a/sdk/src/query/verify.ts b/sdk/src/query/verify.ts new file mode 100644 index 000000000..0fdddf969 --- /dev/null +++ b/sdk/src/query/verify.ts @@ -0,0 +1,588 @@ +/** + * Verification query handlers — plan structure, phase completeness, artifact checks. + * + * Ported from get-shit-done/bin/lib/verify.cjs. + * Provides plan validation, phase completeness checking, and artifact verification + * as native TypeScript query handlers registered in the SDK query registry. + * + * @example + * ```typescript + * import { verifyPlanStructure, verifyPhaseCompleteness, verifyArtifacts } from './verify.js'; + * + * const result = await verifyPlanStructure(['path/to/plan.md'], '/project'); + * // { data: { valid: true, errors: [], warnings: [], task_count: 2, ... } } + * ``` + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, isAbsolute } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { extractFrontmatter, parseMustHavesBlock } from './frontmatter.js'; +import { normalizePhaseName, phaseTokenMatches, planningPaths } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── verifyPlanStructure ─────────────────────────────────────────────────── + +/** + * Validate plan structure against required schema. + * + * Port of `cmdVerifyPlanStructure` from `verify.cjs` lines 108-167. + * Checks required frontmatter fields, task XML elements, wave/depends_on + * consistency, and autonomous/checkpoint consistency. + * + * @param args - args[0]: file path (required) + * @param projectDir - Project root directory + * @returns QueryResult with { valid, errors, warnings, task_count, tasks, frontmatter_fields } + * @throws GSDError with Validation classification if file path missing + */ +export const verifyPlanStructure: QueryHandler = async (args, projectDir) => { + const filePath = args[0]; + if (!filePath) { + throw new GSDError('file path required', ErrorClassification.Validation); + } + + // T-12-01: Null byte rejection on file paths + if (filePath.includes('\0')) { + throw new GSDError('file path contains null bytes', ErrorClassification.Validation); + } + + const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + + let content: string; + try { + content = await readFile(fullPath, 'utf-8'); + } catch { + return { data: { error: 'File not found', path: filePath } }; + } + + const fm = extractFrontmatter(content); + const errors: string[] = []; + const warnings: string[] = []; + + // Check required frontmatter fields + const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves']; + for (const field of required) { + if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`); + } + + // Parse and check task elements + // T-12-03: Use non-greedy [\s\S]*? to avoid catastrophic backtracking + const taskPattern = /]*>([\s\S]*?)<\/task>/g; + const tasks: Array<{ name: string; hasFiles: boolean; hasAction: boolean; hasVerify: boolean; hasDone: boolean }> = []; + let taskMatch: RegExpExecArray | null; + while ((taskMatch = taskPattern.exec(content)) !== null) { + const taskContent = taskMatch[1]; + const nameMatch = taskContent.match(/([\s\S]*?)<\/name>/); + const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed'; + const hasFiles = //.test(taskContent); + const hasAction = //.test(taskContent); + const hasVerify = //.test(taskContent); + const hasDone = //.test(taskContent); + + if (!nameMatch) errors.push('Task missing element'); + if (!hasAction) errors.push(`Task '${taskName}' missing `); + if (!hasVerify) warnings.push(`Task '${taskName}' missing `); + if (!hasDone) warnings.push(`Task '${taskName}' missing `); + if (!hasFiles) warnings.push(`Task '${taskName}' missing `); + + tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone }); + } + + if (tasks.length === 0) warnings.push('No elements found'); + + // Wave/depends_on consistency + if (fm.wave && parseInt(String(fm.wave), 10) > 1 && (!fm.depends_on || (Array.isArray(fm.depends_on) && fm.depends_on.length === 0))) { + warnings.push('Wave > 1 but depends_on is empty'); + } + + // Autonomous/checkpoint consistency + const hasCheckpoints = / { + const phase = args[0]; + if (!phase) { + throw new GSDError('phase required', ErrorClassification.Validation); + } + + const phasesDir = planningPaths(projectDir).phases; + const normalized = normalizePhaseName(phase); + + // Find phase directory (mirror findPhase pattern from phase.ts) + let phaseDir: string | null = null; + let phaseNumber: string = normalized; + try { + const entries = await readdir(phasesDir, { withFileTypes: true }); + const dirs = entries + .filter(e => e.isDirectory()) + .map(e => e.name) + .sort(); + const match = dirs.find(d => phaseTokenMatches(d, normalized)); + if (match) { + phaseDir = join(phasesDir, match); + // Extract phase number from directory name + const numMatch = match.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); + if (numMatch) phaseNumber = numMatch[1]; + } + } catch { /* phases dir doesn't exist */ } + + if (!phaseDir) { + return { data: { error: 'Phase not found', phase } }; + } + + const errors: string[] = []; + const warnings: string[] = []; + + // List plans and summaries + let files: string[]; + try { + files = await readdir(phaseDir); + } catch { + return { data: { error: 'Cannot read phase directory' } }; + } + + const plans = files.filter(f => /-PLAN\.md$/i.test(f)); + const summaries = files.filter(f => /-SUMMARY\.md$/i.test(f)); + + // Extract plan IDs (everything before -PLAN.md / -SUMMARY.md) + const planIds = new Set(plans.map(p => p.replace(/-PLAN\.md$/i, ''))); + const summaryIds = new Set(summaries.map(s => s.replace(/-SUMMARY\.md$/i, ''))); + + // Plans without summaries + const incompletePlans = [...planIds].filter(id => !summaryIds.has(id)); + if (incompletePlans.length > 0) { + errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`); + } + + // Summaries without plans (orphans) + const orphanSummaries = [...summaryIds].filter(id => !planIds.has(id)); + if (orphanSummaries.length > 0) { + warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`); + } + + return { + data: { + complete: errors.length === 0, + phase: phaseNumber, + plan_count: plans.length, + summary_count: summaries.length, + incomplete_plans: incompletePlans, + orphan_summaries: orphanSummaries, + errors, + warnings, + }, + }; +}; + +// ─── verifyArtifacts ─────────────────────────────────────────────────────── + +/** + * Verify artifact file existence and content from must_haves.artifacts. + * + * Port of `cmdVerifyArtifacts` from `verify.cjs` lines 283-336. + * Reads must_haves.artifacts from plan frontmatter and checks each artifact + * for file existence, min_lines, contains, and exports. + * + * @param args - args[0]: plan file path (required) + * @param projectDir - Project root directory + * @returns QueryResult with { all_passed, passed, total, artifacts } + * @throws GSDError with Validation classification if file path missing + */ +export const verifyArtifacts: QueryHandler = async (args, projectDir) => { + const planFilePath = args[0]; + if (!planFilePath) { + throw new GSDError('plan file path required', ErrorClassification.Validation); + } + + // T-12-01: Null byte rejection on file paths + if (planFilePath.includes('\0')) { + throw new GSDError('file path contains null bytes', ErrorClassification.Validation); + } + + const fullPath = isAbsolute(planFilePath) ? planFilePath : join(projectDir, planFilePath); + + let content: string; + try { + content = await readFile(fullPath, 'utf-8'); + } catch { + return { data: { error: 'File not found', path: planFilePath } }; + } + + const { items: artifacts } = parseMustHavesBlock(content, 'artifacts'); + if (artifacts.length === 0) { + return { data: { error: 'No must_haves.artifacts found in frontmatter', path: planFilePath } }; + } + + const results: Array<{ path: string; exists: boolean; issues: string[]; passed: boolean }> = []; + + for (const artifact of artifacts) { + if (typeof artifact === 'string') continue; // skip simple string items + const artObj = artifact as Record; + const artPath = artObj.path as string | undefined; + if (!artPath) continue; + + const artFullPath = join(projectDir, artPath); + let exists = false; + let fileContent = ''; + + try { + fileContent = await readFile(artFullPath, 'utf-8'); + exists = true; + } catch { + // File doesn't exist + } + + const check: { path: string; exists: boolean; issues: string[]; passed: boolean } = { + path: artPath, + exists, + issues: [], + passed: false, + }; + + if (exists) { + const lineCount = fileContent.split('\n').length; + + if (artObj.min_lines && lineCount < (artObj.min_lines as number)) { + check.issues.push(`Only ${lineCount} lines, need ${artObj.min_lines}`); + } + if (artObj.contains && !fileContent.includes(artObj.contains as string)) { + check.issues.push(`Missing pattern: ${artObj.contains}`); + } + if (artObj.exports) { + const exports = Array.isArray(artObj.exports) ? artObj.exports : [artObj.exports]; + for (const exp of exports) { + if (!fileContent.includes(String(exp))) { + check.issues.push(`Missing export: ${exp}`); + } + } + } + check.passed = check.issues.length === 0; + } else { + check.issues.push('File not found'); + } + + results.push(check); + } + + const passed = results.filter(r => r.passed).length; + return { + data: { + all_passed: results.length > 0 && passed === results.length, + passed, + total: results.length, + artifacts: results, + }, + }; +}; + +// ─── verifyCommits ──────────────────────────────────────────────────────── + +/** + * Verify that commit hashes referenced in SUMMARY.md files actually exist. + * + * Port of `cmdVerifyCommits` from `verify.cjs` lines 262-282. + * Used by gsd-verifier agent to confirm commits mentioned in summaries + * are real commits in the git history. + * + * @param args - One or more commit hashes + * @param projectDir - Project root directory + * @returns QueryResult with { all_valid, valid, invalid, total } + */ +export const verifyCommits: QueryHandler = async (args, projectDir) => { + if (args.length === 0) { + throw new GSDError('At least one commit hash required', ErrorClassification.Validation); + } + + const { execGit } = await import('./commit.js'); + const valid: string[] = []; + const invalid: string[] = []; + + for (const hash of args) { + const result = execGit(projectDir, ['cat-file', '-t', hash]); + if (result.exitCode === 0 && result.stdout.trim() === 'commit') { + valid.push(hash); + } else { + invalid.push(hash); + } + } + + return { + data: { + all_valid: invalid.length === 0, + valid, + invalid, + total: args.length, + }, + }; +}; + +// ─── verifyReferences ───────────────────────────────────────────────────── + +/** + * Verify that @-references and backtick file paths in a document resolve. + * + * Port of `cmdVerifyReferences` from `verify.cjs` lines 217-260. + * + * @param args - args[0]: file path (required) + * @param projectDir - Project root directory + * @returns QueryResult with { valid, found, missing } + */ +export const verifyReferences: QueryHandler = async (args, projectDir) => { + const filePath = args[0]; + if (!filePath) { + throw new GSDError('file path required', ErrorClassification.Validation); + } + + const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); + + let content: string; + try { + content = await readFile(fullPath, 'utf-8'); + } catch { + return { data: { error: 'File not found', path: filePath } }; + } + + const found: string[] = []; + const missing: string[] = []; + + const atRefs = content.match(/@([^\s\n,)]+\/[^\s\n,)]+)/g) || []; + for (const ref of atRefs) { + const cleanRef = ref.slice(1); + const resolved = cleanRef.startsWith('~/') + ? join(process.env.HOME || '', cleanRef.slice(2)) + : join(projectDir, cleanRef); + if (existsSync(resolved)) { + found.push(cleanRef); + } else { + missing.push(cleanRef); + } + } + + const backtickRefs = content.match(/`([^`]+\/[^`]+\.[a-zA-Z]{1,10})`/g) || []; + for (const ref of backtickRefs) { + const cleanRef = ref.slice(1, -1); + if (cleanRef.startsWith('http') || cleanRef.includes('${') || cleanRef.includes('{{')) continue; + if (found.includes(cleanRef) || missing.includes(cleanRef)) continue; + const resolved = join(projectDir, cleanRef); + if (existsSync(resolved)) { + found.push(cleanRef); + } else { + missing.push(cleanRef); + } + } + + return { + data: { + valid: missing.length === 0, + found: found.length, + missing, + }, + }; +}; + +// ─── verifySummary ──────────────────────────────────────────────────────── + +/** + * Verify a SUMMARY.md file: existence, file spot-checks, commit refs, self-check section. + * + * Port of `cmdVerifySummary` from verify.cjs lines 13-107. + * + * @param args - args[0]: summary path (required), args[1]: optional --check-count N + */ +export const verifySummary: QueryHandler = async (args, projectDir) => { + const summaryPath = args[0]; + if (!summaryPath) { + throw new GSDError('summary-path required', ErrorClassification.Validation); + } + + const checkCountIdx = args.indexOf('--check-count'); + const checkCount = checkCountIdx !== -1 ? parseInt(args[checkCountIdx + 1], 10) || 2 : 2; + + const fullPath = join(projectDir, summaryPath); + + if (!existsSync(fullPath)) { + return { + data: { + passed: false, + checks: { + summary_exists: false, + files_created: { checked: 0, found: 0, missing: [] }, + commits_exist: false, + self_check: 'not_found', + }, + errors: ['SUMMARY.md not found'], + }, + }; + } + + const content = readFileSync(fullPath, 'utf-8'); + const errors: string[] = []; + + const mentionedFiles = new Set(); + const patterns = [ + /`([^`]+\.[a-zA-Z]+)`/g, + /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi, + ]; + for (const pattern of patterns) { + let m; + while ((m = pattern.exec(content)) !== null) { + const filePath = m[1]; + if (filePath && !filePath.startsWith('http') && filePath.includes('/')) { + mentionedFiles.add(filePath); + } + } + } + + const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount); + const missing: string[] = []; + for (const file of filesToCheck) { + if (!existsSync(join(projectDir, file))) { + missing.push(file); + } + } + + const { execGit } = await import('./commit.js'); + const commitHashPattern = /\b[0-9a-f]{7,40}\b/g; + const hashes = content.match(commitHashPattern) || []; + let commitsExist = false; + for (const hash of hashes.slice(0, 3)) { + const result = execGit(projectDir, ['cat-file', '-t', hash]); + if (result.exitCode === 0 && result.stdout.trim() === 'commit') { + commitsExist = true; + break; + } + } + + let selfCheck = 'not_found'; + const selfCheckPattern = /##\s*(?:Self[- ]?Check|Verification|Quality Check)/i; + if (selfCheckPattern.test(content)) { + const passPattern = /(?:all\s+)?(?:pass|✓|✅|complete|succeeded)/i; + const failPattern = /(?:fail|✗|❌|incomplete|blocked)/i; + const checkSection = content.slice(content.search(selfCheckPattern)); + if (failPattern.test(checkSection)) { + selfCheck = 'failed'; + } else if (passPattern.test(checkSection)) { + selfCheck = 'passed'; + } + } + + if (missing.length > 0) errors.push('Missing files: ' + missing.join(', ')); + if (!commitsExist && hashes.length > 0) errors.push('Referenced commit hashes not found in git history'); + if (selfCheck === 'failed') errors.push('Self-check section indicates failure'); + + const passed = missing.length === 0 && selfCheck !== 'failed'; + return { + data: { + passed, + checks: { + summary_exists: true, + files_created: { checked: filesToCheck.length, found: filesToCheck.length - missing.length, missing }, + commits_exist: commitsExist, + self_check: selfCheck, + }, + errors, + }, + }; +}; + +// ─── verifyPathExists ───────────────────────────────────────────────────── + +/** + * Check file/directory existence and return type. + * + * Port of `cmdVerifyPathExists` from commands.cjs lines 111-132. + * + * @param args - args[0]: path to check (required) + */ +export const verifyPathExists: QueryHandler = async (args, projectDir) => { + const targetPath = args[0]; + if (!targetPath) { + throw new GSDError('path required for verification', ErrorClassification.Validation); + } + if (targetPath.includes('\0')) { + throw new GSDError('path contains null bytes', ErrorClassification.Validation); + } + + const fullPath = isAbsolute(targetPath) ? targetPath : join(projectDir, targetPath); + + try { + const stats = statSync(fullPath); + const type = stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other'; + return { data: { exists: true, type } }; + } catch { + return { data: { exists: false, type: null } }; + } +}; + +// ─── verifySchemaDrift ──────────────────────────────────────────────────── + +export const verifySchemaDrift: QueryHandler = async (args, projectDir) => { + const phaseArg = args[0]; + const paths = planningPaths(projectDir); + + const issues: string[] = []; + const REQUIRED_FRONTMATTER = ['phase', 'plan', 'type', 'must_haves']; + + try { + const phasesDir = paths.phases; + if (!existsSync(phasesDir)) { + return { data: { valid: true, issues: [], checked: 0 } }; + } + + const entries = readdirSync(phasesDir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + let checked = 0; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (phaseArg && !entry.name.startsWith(normalizePhaseName(phaseArg))) continue; + + const phaseDir = join(phasesDir, entry.name); + const files = readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md'); + + for (const planFile of files) { + checked++; + try { + const content = await readFile(join(phaseDir, planFile), 'utf-8'); + for (const field of REQUIRED_FRONTMATTER) { + if (!new RegExp(`^${field}:`, 'm').test(content)) { + issues.push(`${planFile}: missing '${field}' in frontmatter`); + } + } + } catch { /* skip */ } + } + } + + return { data: { valid: issues.length === 0, issues, checked } }; + } catch { + return { data: { valid: true, issues: [], checked: 0 } }; + } +}; diff --git a/sdk/src/query/websearch.ts b/sdk/src/query/websearch.ts new file mode 100644 index 000000000..889678c17 --- /dev/null +++ b/sdk/src/query/websearch.ts @@ -0,0 +1,82 @@ +/** + * Web search query handler — Brave Search API integration. + * + * Provides web search for researcher agents. Returns { available: false } + * gracefully when BRAVE_API_KEY is missing so agents can fall back to + * built-in WebSearch tools. + * + * @example + * ```typescript + * import { websearch } from './websearch.js'; + * + * await websearch(['typescript generics'], '/project'); + * // { data: { available: true, query: 'typescript generics', count: 10, results: [...] } } + * ``` + */ + +import type { QueryHandler } from './utils.js'; + +/** + * Search the web via Brave Search API. + * Requires BRAVE_API_KEY env var. + * + * Args: query [--limit N] [--freshness day|week|month] + */ +export const websearch: QueryHandler = async (args) => { + const apiKey = process.env.BRAVE_API_KEY; + + if (!apiKey) { + return { data: { available: false, reason: 'BRAVE_API_KEY not set' } }; + } + + const query = args[0]; + if (!query) { + return { data: { available: false, error: 'Query required' } }; + } + + const limitIdx = args.indexOf('--limit'); + const freshnessIdx = args.indexOf('--freshness'); + const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 10; + const freshness = freshnessIdx !== -1 ? args[freshnessIdx + 1] : null; + + const params = new URLSearchParams({ + q: query, + count: String(limit), + country: 'us', + search_lang: 'en', + text_decorations: 'false', + }); + if (freshness) params.set('freshness', freshness); + + try { + const response = await fetch( + `https://api.search.brave.com/res/v1/web/search?${params}`, + { + headers: { + 'Accept': 'application/json', + 'X-Subscription-Token': apiKey, + }, + }, + ); + + if (!response.ok) { + return { data: { available: false, error: `API error: ${response.status}` } }; + } + + const body = await response.json() as { + web?: { results?: Array<{ title: string; url: string; description: string; age?: string }> }; + }; + + const results = (body.web?.results || []).map(r => ({ + title: r.title, + url: r.url, + description: r.description, + age: r.age || null, + })); + + return { data: { available: true, query, count: results.length, results } }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { data: { available: false, error: msg } }; + } +}; diff --git a/sdk/src/query/workspace.test.ts b/sdk/src/query/workspace.test.ts new file mode 100644 index 000000000..4495052a8 --- /dev/null +++ b/sdk/src/query/workspace.test.ts @@ -0,0 +1,119 @@ +/** + * Unit tests for workspace-aware state resolution. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { resolveWorkspaceContext, workspacePlanningPaths } from './workspace.js'; + +// ─── resolveWorkspaceContext ─────────────────────────────────────────────── + +describe('resolveWorkspaceContext', () => { + afterEach(() => { + delete process.env['GSD_WORKSTREAM']; + delete process.env['GSD_PROJECT']; + }); + + it('returns null values when env vars not set', () => { + delete process.env['GSD_WORKSTREAM']; + delete process.env['GSD_PROJECT']; + const ctx = resolveWorkspaceContext(); + expect(ctx.workstream).toBeNull(); + expect(ctx.project).toBeNull(); + }); + + it('reads GSD_WORKSTREAM from env', () => { + process.env['GSD_WORKSTREAM'] = 'backend'; + const ctx = resolveWorkspaceContext(); + expect(ctx.workstream).toBe('backend'); + }); + + it('reads GSD_PROJECT from env', () => { + process.env['GSD_PROJECT'] = 'api-server'; + const ctx = resolveWorkspaceContext(); + expect(ctx.project).toBe('api-server'); + }); + + it('reads both vars when both are set', () => { + process.env['GSD_WORKSTREAM'] = 'ws1'; + process.env['GSD_PROJECT'] = 'proj1'; + const ctx = resolveWorkspaceContext(); + expect(ctx.workstream).toBe('ws1'); + expect(ctx.project).toBe('proj1'); + }); +}); + +// ─── workspacePlanningPaths ──────────────────────────────────────────────── + +describe('workspacePlanningPaths', () => { + const projectDir = '/my/project'; + + it('returns default .planning/ when no context provided', () => { + const paths = workspacePlanningPaths(projectDir); + expect(paths.planning).toContain('.planning'); + expect(paths.planning).not.toContain('workstreams'); + expect(paths.planning).not.toContain('projects'); + expect(paths.state).toContain('STATE.md'); + expect(paths.phases).toContain('phases'); + }); + + it('returns default .planning/ when context has no workspace or project', () => { + const paths = workspacePlanningPaths(projectDir, { workstream: null, project: null }); + expect(paths.planning).not.toContain('workstreams'); + expect(paths.planning).not.toContain('projects'); + }); + + it('scopes to .planning/workstreams/ when workstream set', () => { + const paths = workspacePlanningPaths(projectDir, { workstream: 'backend', project: null }); + expect(paths.planning).toContain('workstreams/backend'); + expect(paths.state).toContain('workstreams/backend/STATE.md'); + expect(paths.phases).toContain('workstreams/backend/phases'); + }); + + it('scopes to .planning/projects/ when project set', () => { + const paths = workspacePlanningPaths(projectDir, { workstream: null, project: 'api-server' }); + expect(paths.planning).toContain('projects/api-server'); + expect(paths.state).toContain('projects/api-server/STATE.md'); + }); + + it('workstream takes precedence over project when both set', () => { + const paths = workspacePlanningPaths(projectDir, { workstream: 'ws1', project: 'proj1' }); + expect(paths.planning).toContain('workstreams/ws1'); + expect(paths.planning).not.toContain('projects'); + }); + + it('throws on empty workstream name', () => { + expect(() => workspacePlanningPaths(projectDir, { workstream: '', project: null })) + .toThrow('empty'); + }); + + it('throws on workstream name containing forward slash', () => { + expect(() => workspacePlanningPaths(projectDir, { workstream: 'ws/bad', project: null })) + .toThrow('path separators'); + }); + + it('throws on workstream name containing backslash', () => { + expect(() => workspacePlanningPaths(projectDir, { workstream: 'ws\\bad', project: null })) + .toThrow('path separators'); + }); + + it('throws on workstream name containing ".."', () => { + expect(() => workspacePlanningPaths(projectDir, { workstream: '../escape', project: null })) + .toThrow('..'); + }); + + it('throws on project name containing path separators', () => { + expect(() => workspacePlanningPaths(projectDir, { workstream: null, project: '../../bad' })) + .toThrow('path separators'); + }); + + it('all path fields are defined', () => { + const paths = workspacePlanningPaths(projectDir, { workstream: 'ws1', project: null }); + expect(paths.planning).toBeDefined(); + expect(paths.state).toBeDefined(); + expect(paths.roadmap).toBeDefined(); + expect(paths.project).toBeDefined(); + expect(paths.config).toBeDefined(); + expect(paths.phases).toBeDefined(); + expect(paths.requirements).toBeDefined(); + }); +}); diff --git a/sdk/src/query/workspace.ts b/sdk/src/query/workspace.ts new file mode 100644 index 000000000..afebb54a6 --- /dev/null +++ b/sdk/src/query/workspace.ts @@ -0,0 +1,131 @@ +/** + * Workspace-aware state resolution — scopes .planning/ paths to a + * GSD_WORKSTREAM or GSD_PROJECT environment context. + * + * Port of planningDir() workspace logic from get-shit-done/bin/lib/core.cjs + * (line 669+). Provides WorkspaceContext reading and validated path scoping. + * + * Security: workspace names are validated to reject path traversal (T-14-05). + * + * @example + * ```typescript + * import { resolveWorkspaceContext, workspacePlanningPaths } from './workspace.js'; + * + * const ctx = resolveWorkspaceContext(); + * // { workstream: 'backend', project: null } + * + * const paths = workspacePlanningPaths('/my/project', ctx); + * // paths.state → '/my/project/.planning/workstreams/backend/STATE.md' + * ``` + */ + +import { join } from 'node:path'; +import { GSDError, ErrorClassification } from '../errors.js'; +import { toPosixPath } from './helpers.js'; +import type { PlanningPaths } from './helpers.js'; + +// ─── Types ───────────────────────────────────────────────────────────────── + +/** + * Resolved workspace context from environment variables. + */ +export interface WorkspaceContext { + /** Active workstream name (from GSD_WORKSTREAM env var), or null */ + workstream: string | null; + /** Active project name (from GSD_PROJECT env var), or null */ + project: string | null; +} + +// ─── Validation ──────────────────────────────────────────────────────────── + +/** + * Validate a workspace or project name. + * + * Rejects names that could cause path traversal (T-14-05): + * - Empty string + * - Names containing '/' or '\' + * - Names containing '..' sequences + * + * @param name - Workspace or project name to validate + * @param kind - Label for error messages ('workstream' or 'project') + * @throws GSDError with Validation classification on invalid name + */ +function validateWorkspaceName(name: string, kind: string): void { + if (!name || name.trim() === '') { + throw new GSDError( + `${kind} name must not be empty`, + ErrorClassification.Validation, + ); + } + if (name.includes('/') || name.includes('\\')) { + throw new GSDError( + `${kind} name must not contain path separators: ${name}`, + ErrorClassification.Validation, + ); + } + if (name.includes('..')) { + throw new GSDError( + `${kind} name must not contain '..' (path traversal): ${name}`, + ErrorClassification.Validation, + ); + } +} + +// ─── resolveWorkspaceContext ─────────────────────────────────────────────── + +/** + * Read GSD_WORKSTREAM and GSD_PROJECT environment variables. + * + * Returns a WorkspaceContext with null values when the env vars are not set. + * + * @returns Resolved workspace context + */ +export function resolveWorkspaceContext(): WorkspaceContext { + return { + workstream: process.env['GSD_WORKSTREAM'] || null, + project: process.env['GSD_PROJECT'] || null, + }; +} + +// ─── workspacePlanningPaths ──────────────────────────────────────────────── + +/** + * Return PlanningPaths scoped to the active workspace or project. + * + * When context has a workstream set: base = .planning/workstreams// + * When context has a project set: base = .planning/projects// + * When context is null or empty: base = .planning/ (default) + * + * Workspace and project names are validated before path construction. + * + * @param projectDir - Absolute project root path + * @param context - Optional workspace context (defaults to no scoping) + * @returns PlanningPaths scoped to the active workspace + * @throws GSDError if workspace/project name fails validation + */ +export function workspacePlanningPaths( + projectDir: string, + context?: WorkspaceContext, +): PlanningPaths { + let base: string; + + if (context?.workstream != null) { + validateWorkspaceName(context.workstream, 'workstream'); + base = join(projectDir, '.planning', 'workstreams', context.workstream); + } else if (context?.project != null) { + validateWorkspaceName(context.project, 'project'); + base = join(projectDir, '.planning', 'projects', context.project); + } else { + base = join(projectDir, '.planning'); + } + + return { + planning: toPosixPath(base), + state: toPosixPath(join(base, 'STATE.md')), + roadmap: toPosixPath(join(base, 'ROADMAP.md')), + project: toPosixPath(join(base, 'PROJECT.md')), + config: toPosixPath(join(base, 'config.json')), + phases: toPosixPath(join(base, 'phases')), + requirements: toPosixPath(join(base, 'REQUIREMENTS.md')), + }; +} diff --git a/sdk/src/query/workstream.ts b/sdk/src/query/workstream.ts new file mode 100644 index 000000000..046d1ed80 --- /dev/null +++ b/sdk/src/query/workstream.ts @@ -0,0 +1,252 @@ +/** + * Workstream query handlers — list, create, set, status, complete, progress. + * + * Ported from get-shit-done/bin/lib/workstream.cjs. + * Manages .planning/workstreams/ directory for multi-workstream projects. + * + * @example + * ```typescript + * import { workstreamList, workstreamCreate } from './workstream.js'; + * + * await workstreamList([], '/project'); + * // { data: { workstreams: ['backend', 'frontend'], count: 2 } } + * + * await workstreamCreate(['api'], '/project'); + * // { data: { created: true, name: 'api', path: '.planning/workstreams/api' } } + * ``` + */ + +import { + existsSync, readdirSync, readFileSync, writeFileSync, + mkdirSync, renameSync, rmdirSync, unlinkSync, +} from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +import { toPosixPath } from './helpers.js'; +import type { QueryHandler } from './utils.js'; + +// ─── Internal helpers ───────────────────────────────────────────────────── + +const planningRoot = (projectDir: string) => + join(projectDir, '.planning'); + +const workstreamsDir = (projectDir: string) => + join(planningRoot(projectDir), 'workstreams'); + +function getActiveWorkstream(projectDir: string): string | null { + const filePath = join(planningRoot(projectDir), 'active-workstream'); + try { + const name = readFileSync(filePath, 'utf-8').trim(); + if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) { + try { unlinkSync(filePath); } catch { /* already gone */ } + return null; + } + const wsDir = join(workstreamsDir(projectDir), name); + if (!existsSync(wsDir)) { + try { unlinkSync(filePath); } catch { /* already gone */ } + return null; + } + return name; + } catch { + return null; + } +} + +function setActiveWorkstream(projectDir: string, name: string | null): void { + const filePath = join(planningRoot(projectDir), 'active-workstream'); + if (!name) { + try { unlinkSync(filePath); } catch { /* already gone */ } + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only'); + } + writeFileSync(filePath, name + '\n', 'utf-8'); +} + +// ─── Handlers ───────────────────────────────────────────────────────────── + +export const workstreamList: QueryHandler = async (_args, projectDir) => { + const dir = workstreamsDir(projectDir); + if (!existsSync(dir)) return { data: { mode: 'flat', workstreams: [], message: 'No workstreams — operating in flat mode' } }; + try { + const entries = readdirSync(dir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + const workstreams = entries.filter(e => e.isDirectory()).map(e => e.name); + return { data: { mode: 'workstream', workstreams, count: workstreams.length } }; + } catch { + return { data: { mode: 'flat', workstreams: [], count: 0 } }; + } +}; + +export const workstreamCreate: QueryHandler = async (args, projectDir) => { + const rawName = args[0]; + if (!rawName) return { data: { created: false, reason: 'name required' } }; + if (rawName.includes('/') || rawName.includes('\\') || rawName.includes('..')) { + return { data: { created: false, reason: 'invalid workstream name — path separators not allowed' } }; + } + + const slug = rawName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + if (!slug) return { data: { created: false, reason: 'invalid workstream name — must contain at least one alphanumeric character' } }; + + const baseDir = planningRoot(projectDir); + if (!existsSync(baseDir)) { + return { data: { created: false, reason: '.planning/ directory not found — run /gsd-new-project first' } }; + } + + const wsRoot = workstreamsDir(projectDir); + const wsDir = join(wsRoot, slug); + + if (existsSync(wsDir) && existsSync(join(wsDir, 'STATE.md'))) { + return { data: { created: false, error: 'already_exists', workstream: slug, path: toPosixPath(relative(projectDir, wsDir)) } }; + } + + mkdirSync(wsDir, { recursive: true }); + mkdirSync(join(wsDir, 'phases'), { recursive: true }); + + const today = new Date().toISOString().split('T')[0]; + const stateContent = [ + '---', + `workstream: ${slug}`, + `created: ${today}`, + '---', + '', + '# Project State', + '', + '## Current Position', + '**Status:** Not started', + '**Current Phase:** None', + `**Last Activity:** ${today}`, + '**Last Activity Description:** Workstream created', + '', + '## Progress', + '**Phases Complete:** 0', + '**Current Plan:** N/A', + '', + '## Session Continuity', + '**Stopped At:** N/A', + '**Resume File:** None', + '', + ].join('\n'); + + const statePath = join(wsDir, 'STATE.md'); + if (!existsSync(statePath)) { + writeFileSync(statePath, stateContent, 'utf-8'); + } + + setActiveWorkstream(projectDir, slug); + + const relPath = toPosixPath(relative(projectDir, wsDir)); + return { + data: { + created: true, + workstream: slug, + path: relPath, + state_path: relPath + '/STATE.md', + phases_path: relPath + '/phases', + active: true, + }, + }; +}; + +export const workstreamSet: QueryHandler = async (args, projectDir) => { + const name = args[0]; + + if (!name || name === '--clear') { + if (name !== '--clear') { + return { data: { set: false, reason: 'name required. Usage: workstream set (or workstream set --clear to unset)' } }; + } + const previous = getActiveWorkstream(projectDir); + setActiveWorkstream(projectDir, null); + return { data: { active: null, cleared: true, previous: previous || null } }; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + return { data: { active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, and underscores only' } }; + } + + const wsDir = join(workstreamsDir(projectDir), name); + if (!existsSync(wsDir)) { + return { data: { active: null, error: 'not_found', workstream: name } }; + } + + const previous = getActiveWorkstream(projectDir); + setActiveWorkstream(projectDir, name); + return { data: { active: name, previous: previous || null, set: true } }; +}; + +export const workstreamStatus: QueryHandler = async (args, projectDir) => { + const name = args[0]; + if (!name) return { data: { found: false, reason: 'name required' } }; + const wsDir = join(workstreamsDir(projectDir), name); + return { data: { name, found: existsSync(wsDir), path: toPosixPath(relative(projectDir, wsDir)) } }; +}; + +export const workstreamComplete: QueryHandler = async (args, projectDir) => { + const name = args[0]; + if (!name) return { data: { completed: false, reason: 'workstream name required' } }; + if (/[/\\]/.test(name) || name === '.' || name === '..') { + return { data: { completed: false, reason: 'invalid workstream name' } }; + } + + const root = planningRoot(projectDir); + const wsRoot = workstreamsDir(projectDir); + const wsDir = join(wsRoot, name); + + if (!existsSync(wsDir)) { + return { data: { completed: false, error: 'not_found', workstream: name } }; + } + + const active = getActiveWorkstream(projectDir); + if (active === name) setActiveWorkstream(projectDir, null); + + const archiveDir = join(root, 'milestones'); + const today = new Date().toISOString().split('T')[0]; + let archivePath = join(archiveDir, `ws-${name}-${today}`); + let suffix = 1; + while (existsSync(archivePath)) { + archivePath = join(archiveDir, `ws-${name}-${today}-${suffix++}`); + } + + mkdirSync(archivePath, { recursive: true }); + + const filesMoved: string[] = []; + try { + const entries = readdirSync(wsDir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; + for (const entry of entries) { + renameSync(join(wsDir, entry.name), join(archivePath, entry.name)); + filesMoved.push(entry.name); + } + } catch (err) { + for (const fname of filesMoved) { + try { renameSync(join(archivePath, fname), join(wsDir, fname)); } catch { /* rollback */ } + } + try { rmdirSync(archivePath); } catch { /* cleanup */ } + if (active === name) setActiveWorkstream(projectDir, name); + return { data: { completed: false, error: 'archive_failed', message: String(err), workstream: name } }; + } + + try { rmdirSync(wsDir); } catch { /* may not be empty */ } + + let remainingWs = 0; + try { + remainingWs = (readdirSync(wsRoot, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>) + .filter(e => e.isDirectory()).length; + if (remainingWs === 0) rmdirSync(wsRoot); + } catch { /* best-effort */ } + + return { + data: { + completed: true, + workstream: name, + archived_to: toPosixPath(relative(projectDir, archivePath)), + remaining_workstreams: remainingWs, + reverted_to_flat: remainingWs === 0, + }, + }; +}; + +export const workstreamProgress: QueryHandler = async (args, projectDir) => { + const { progressBar } = await import('./progress.js'); + return progressBar(args, projectDir); +}; diff --git a/sdk/src/tool-scoping.ts b/sdk/src/tool-scoping.ts index b5787f8e4..66c3aee30 100644 --- a/sdk/src/tool-scoping.ts +++ b/sdk/src/tool-scoping.ts @@ -20,6 +20,7 @@ const PHASE_DEFAULT_TOOLS: Record = { [PhaseType.Verify]: ['Read', 'Bash', 'Grep', 'Glob'], [PhaseType.Discuss]: ['Read', 'Bash', 'Grep', 'Glob'], [PhaseType.Plan]: ['Read', 'Write', 'Bash', 'Glob', 'Grep', 'WebFetch'], + [PhaseType.Repair]: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'], }; // ─── Phase → agent definition filename ────────────────────────────────────── @@ -34,6 +35,7 @@ export const PHASE_AGENT_MAP: Record = { [PhaseType.Plan]: 'gsd-planner.md', [PhaseType.Verify]: 'gsd-verifier.md', [PhaseType.Discuss]: null, + [PhaseType.Repair]: null, }; // ─── Public API ────────────────────────────────────────────────────────────── diff --git a/sdk/src/types.ts b/sdk/src/types.ts index cefca767b..276f641af 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -222,6 +222,7 @@ export enum PhaseType { Plan = 'plan', Execute = 'execute', Verify = 'verify', + Repair = 'repair', } /** @@ -258,6 +259,11 @@ export enum GSDEventType { InitStepComplete = 'init_step_complete', InitComplete = 'init_complete', InitResearchSpawn = 'init_research_spawn', + StateMutation = 'state_mutation', + ConfigMutation = 'config_mutation', + FrontmatterMutation = 'frontmatter_mutation', + GitCommit = 'git_commit', + TemplateFill = 'template_fill', } /** @@ -685,6 +691,57 @@ export interface GSDInitResearchSpawnEvent extends GSDEventBase { researchTypes: string[]; } +/** + * State mutation completed — emitted after STATE.md write operations. + */ +export interface GSDStateMutationEvent extends GSDEventBase { + type: GSDEventType.StateMutation; + command: string; + fields: string[]; + success: boolean; +} + +/** + * Config mutation completed — emitted after config.json write operations. + */ +export interface GSDConfigMutationEvent extends GSDEventBase { + type: GSDEventType.ConfigMutation; + command: string; + key: string; + success: boolean; +} + +/** + * Frontmatter mutation completed — emitted after frontmatter write operations. + */ +export interface GSDFrontmatterMutationEvent extends GSDEventBase { + type: GSDEventType.FrontmatterMutation; + command: string; + file: string; + fields: string[]; + success: boolean; +} + +/** + * Git commit completed — emitted after commit or check-commit operations. + */ +export interface GSDGitCommitEvent extends GSDEventBase { + type: GSDEventType.GitCommit; + hash: string | null; + committed: boolean; + reason: string; +} + +/** + * Template fill completed — emitted after template.fill or template.select operations. + */ +export interface GSDTemplateFillEvent extends GSDEventBase { + type: GSDEventType.TemplateFill; + templateType: string; + path: string; + created: boolean; +} + /** * Discriminated union of all GSD events. */ @@ -717,7 +774,12 @@ export type GSDEvent = | GSDInitStepStartEvent | GSDInitStepCompleteEvent | GSDInitCompleteEvent - | GSDInitResearchSpawnEvent; + | GSDInitResearchSpawnEvent + | GSDStateMutationEvent + | GSDConfigMutationEvent + | GSDFrontmatterMutationEvent + | GSDGitCommitEvent + | GSDTemplateFillEvent; /** * Transport handler interface for consuming GSD events. diff --git a/sdk/src/workstream-utils.ts b/sdk/src/workstream-utils.ts index 5f78326c5..deca1695b 100644 --- a/sdk/src/workstream-utils.ts +++ b/sdk/src/workstream-utils.ts @@ -5,7 +5,7 @@ * .planning/workstreams// instead. */ -import { join } from 'node:path'; +import { posix } from 'node:path'; /** * Validate a workstream name. @@ -28,5 +28,6 @@ export function validateWorkstreamName(name: string): boolean { */ export function relPlanningPath(workstream?: string): string { if (!workstream) return '.planning'; - return join('.planning', 'workstreams', workstream); + // Use POSIX segments so the same logical path string is used on all platforms (Windows included). + return posix.join('.planning', 'workstreams', workstream); }