fix(2137): skip worktree isolation when .gitmodules detected (#2144)

* feat(sdk): add typed query foundation and gsd-sdk query (Phase 1)

Add sdk/src/query registry and handlers with tests, GSDQueryError, CLI query wiring, and supporting type/tool-scoping hooks. Update CHANGELOG. Vitest 4 constructor mock fixes in milestone-runner tests.

Made-with: Cursor

* fix(2137): skip worktree isolation when .gitmodules detected

When a project contains git submodules, worktree isolation cannot
correctly handle submodule commits — three separate gaps exist in
worktree setup, executor commit protocol, and merge-back. Rather
than patch each gap individually, detect .gitmodules at phase start
and fall back to sequential execution, which handles submodules
transparently (Option B).

Affected workflows: execute-phase.md, quick.md

---------

Co-authored-by: David Sienkowski <dave@sienkowski.com>
This commit is contained in:
Tom Boucher
2026-04-12 08:33:04 -04:00
committed by GitHub
parent 58632e0718
commit 002bcf2a8a
63 changed files with 17872 additions and 23 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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.

View File

@@ -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 ──────────────────────────────────────────────

View File

@@ -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 <command> [args] [options]
Commands:
run <prompt> 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 <command> Execute a registered native query command (registry: sdk/src/query/index.ts)
Use --pick <field> to extract a specific field
Options:
--init <input> Bootstrap from a PRD before running (auto only)
@@ -207,8 +209,58 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
return;
}
// ─── Query command ──────────────────────────────────────────────────────
if (args.command === 'query') {
const { createRegistry } = await import('./query/index.js');
const { extractField } = await import('./query/registry.js');
const { GSDError, exitCodeFor } = await import('./errors.js');
const queryArgs = argv.slice(1); // everything after 'query'
const queryCommand = queryArgs[0];
if (!queryCommand) {
console.error('Error: "gsd-sdk query" requires a command');
process.exitCode = 10;
return;
}
// Extract --pick before dispatch
const pickIdx = queryArgs.indexOf('--pick');
let pickField: string | undefined;
if (pickIdx !== -1) {
if (pickIdx + 1 >= 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 <prompt>", "gsd-sdk auto", or "gsd-sdk init [input]"');
console.error('Error: Expected "gsd-sdk run <prompt>", "gsd-sdk auto", "gsd-sdk init [input]", or "gsd-sdk query <command>"');
console.error(USAGE);
process.exitCode = 1;
return;

View File

@@ -64,6 +64,11 @@ const PHASE_FILE_MANIFEST: Record<PhaseType, FileSpec[]> = {
{ 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 },

72
sdk/src/errors.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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 () => {

View File

@@ -28,6 +28,7 @@ const PHASE_WORKFLOW_MAP: Record<PhaseType, string> = {
[PhaseType.Plan]: 'plan-phase.md',
[PhaseType.Verify]: 'verify-phase.md',
[PhaseType.Discuss]: 'discuss-phase.md',
[PhaseType.Repair]: 'execute-plan.md',
};
// ─── XML block extraction ────────────────────────────────────────────────────

View File

@@ -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 <system> prompt [SYSTEM] test');
expect(result).not.toContain('<system>');
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);
});
});

258
sdk/src/query/commit.ts Normal file
View File

@@ -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 <<SYS>> 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<string, unknown>;
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<string, unknown>;
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) } };
}
};

View File

@@ -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 });
});
});

View File

@@ -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<string, unknown>): Promise<void> {
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<string, string> = {
'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.<agent-type>` and `features.<feature_name>`.
* 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.<agent-type>
if (/^agent_skills\.[a-zA-Z0-9_-]+$/.test(keyPath)) return { valid: true };
// Dynamic patterns: features.<feature_name>
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<string, unknown>, dotPath: string, value: unknown): void {
const keys = dotPath.split('.');
let current: Record<string, unknown> = 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<string, unknown>;
}
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 <key.path> <value>', 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<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');
config = JSON.parse(raw) as Record<string, unknown>;
} 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<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');
config = JSON.parse(raw) as Record<string, unknown>;
} 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<string, unknown> = {};
if (args[0] && args[0].trim() !== '') {
try {
userChoices = JSON.parse(args[0]) as Record<string, unknown>;
} 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<string, unknown> = {};
try {
const defaultsPath = join(homeDir, '.gsd', 'defaults.json');
const defaultsRaw = await readFile(defaultsPath, 'utf-8');
globalDefaults = JSON.parse(defaultsRaw) as Record<string, unknown>;
} 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<string, unknown> = {
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<string, unknown> = {
...defaults,
...globalDefaults,
...userChoices,
git: {
...(defaults.git as Record<string, unknown>),
...((userChoices.git as Record<string, unknown>) || {}),
},
workflow: {
...(defaults.workflow as Record<string, unknown>),
...((userChoices.workflow as Record<string, unknown>) || {}),
},
hooks: {
...(defaults.hooks as Record<string, unknown>),
...((userChoices.hooks as Record<string, unknown>) || {}),
},
agent_skills: {
...((defaults.agent_skills as Record<string, unknown>) || {}),
...((userChoices.agent_skills as Record<string, unknown>) || {}),
},
features: {
...((defaults.features as Record<string, unknown>) || {}),
...((userChoices.features as Record<string, unknown>) || {}),
},
};
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 <section>', ErrorClassification.Validation);
}
const paths = planningPaths(projectDir);
let config: Record<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Start with empty config
}
if (!(sectionName in config)) {
config[sectionName] = {};
}
await atomicWriteConfig(paths.config, config);
return { data: { ensured: true, section: sectionName } };
};

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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']);
});
});

View File

@@ -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<string, Record<string, string>> = {
'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 <key.path>', 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<string, unknown>;
try {
config = JSON.parse(raw) as Record<string, unknown>;
} 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<string, unknown>)[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<string, unknown>).model_overrides as Record<string, string> | 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<string, unknown>).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 } };
};

View File

@@ -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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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');
});
});

View File

@@ -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<string, { required: string[] }> = {
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, unknown>): 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<string, unknown>)) {
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<string, unknown>)) {
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, unknown>): 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<string, unknown>;
try {
mergeData = JSON.parse(jsonString) as Record<string, unknown>;
} 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 } };
};

View File

@@ -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');
});
});

View File

@@ -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<string, unknown> {
const frontmatter: Record<string, unknown> = {};
// 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<string, unknown> | 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<string, unknown>)[key] = value === '[' ? [] : {};
current.key = null;
// Push new context for potential nested content
stack.push({ obj: (current.obj as Record<string, unknown>)[key] as Record<string, unknown>, key: null, indent });
} else if (value.startsWith('[') && value.endsWith(']')) {
// Inline array: key: [a, b, c]
(current.obj as Record<string, unknown>)[key] = splitInlineArray(value.slice(1, -1));
current.key = null;
} else {
// Simple key: value -- strip surrounding quotes
(current.obj as Record<string, unknown>)[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<string, unknown>)) {
if ((parent.obj as Record<string, unknown>)[k] === current.obj) {
(parent.obj as Record<string, unknown>)[k] = [itemValue];
current.obj = (parent.obj as Record<string, unknown>)[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, unknown> | 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<string, unknown>;
current[kvMatch[1]] = kvMatch[2];
} else {
current = {} as Record<string, unknown>;
}
}
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 };
};

View File

@@ -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);
});
});

324
sdk/src/query/helpers.ts Normal file
View File

@@ -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<boolean>(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')),
};
}

429
sdk/src/query/index.ts Normal file
View File

@@ -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<string, unknown> | 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<string, unknown> | 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;
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const phases = data.phases as Record<string, unknown>[];
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const phases = data.phases as Record<string, unknown>[];
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<string, unknown>;
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<string, unknown>;
const phases = data.phases as Record<string, unknown>[];
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<string, unknown>;
const flags = data.manager_flags as Record<string, string>;
expect(typeof flags.discuss).toBe('string');
expect(typeof flags.plan).toBe('string');
expect(typeof flags.execute).toBe('string');
});
});

View File

@@ -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<string> {
const result = await resolveModel([agentType], projectDir);
const data = result.data as Record<string, unknown>;
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<string, unknown> = {
researcher_model: researcherModel,
synthesizer_model: synthesizerModel,
roadmapper_model: roadmapperModel,
commit_docs: config.commit_docs,
project_exists: pathExists(projectDir, '.planning/PROJECT.md'),
has_codebase_map: pathExists(projectDir, '.planning/codebase'),
planning_exists: pathExists(projectDir, '.planning'),
has_existing_code: hasExistingCode,
has_package_file: hasPackageFile,
is_brownfield: hasExistingCode || hasPackageFile,
needs_codebase_map:
(hasExistingCode || hasPackageFile) && !pathExists(projectDir, '.planning/codebase'),
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<string, unknown>[] = [];
let currentPhase: Record<string, unknown> | null = null;
let nextPhase: Record<string, unknown> | null = null;
// Build set of phases from ROADMAP for the current milestone
const roadmapPhaseNames = new Map<string, string>();
const seenPhaseNums = new Set<string>();
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<string, unknown> = {
number: phaseNumber,
name: phaseName,
directory: toPosixPath(relative(projectDir, join(paths.phases, dir))),
status,
plan_count: plans.length,
summary_count: summaries.length,
has_research: hasResearch,
};
phases.push(phaseInfo);
if (!currentPhase && (status === 'in_progress' || status === 'researched')) {
currentPhase = phaseInfo;
}
if (!nextPhase && status === 'pending') {
nextPhase = phaseInfo;
}
}
} catch { /* intentionally empty */ }
// Add ROADMAP-only phases not yet on disk
for (const [num, name] of roadmapPhaseNames) {
const stripped = num.replace(/^0+/, '') || '0';
if (!seenPhaseNums.has(stripped)) {
const phaseInfo: Record<string, unknown> = {
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<string, unknown> = {
executor_model: await getModelAlias('gsd-executor', projectDir),
planner_model: await getModelAlias('gsd-planner', projectDir),
commit_docs: config.commit_docs,
milestone_version: milestone.version,
milestone_name: milestone.name,
phases,
phase_count: phases.length,
completed_count: phases.filter(p => p.status === 'complete').length,
in_progress_count: phases.filter(p => p.status === 'in_progress').length,
current_phase: currentPhase,
next_phase: nextPhase,
paused_at: pausedAt,
has_work_in_progress: !!currentPhase,
project_exists: pathExists(projectDir, '.planning/PROJECT.md'),
roadmap_exists: existsSync(paths.roadmap),
state_exists: existsSync(paths.state),
state_path: toPosixPath(relative(projectDir, paths.state)),
roadmap_path: toPosixPath(relative(projectDir, paths.roadmap)),
project_path: '.planning/PROJECT.md',
config_path: toPosixPath(relative(projectDir, paths.config)),
};
return { data: withProjectRoot(projectDir, result) };
};
// ─── 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<string, boolean>();
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<string, unknown>[] = [];
let pMatch: RegExpExecArray | null;
while ((pMatch = phasePattern.exec(content)) !== null) {
const phaseNum = pMatch[1];
const phaseName = pMatch[2].replace(/\(INSERTED\)/i, '').trim();
const sectionStart = pMatch.index;
const restOfContent = content.slice(sectionStart);
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
const sectionEnd = nextHeader ? sectionStart + (nextHeader.index ?? 0) : content.length;
const section = content.slice(sectionStart, sectionEnd);
const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
const goal = goalMatch ? goalMatch[1].trim() : null;
const dependsMatch = section.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
const dependsOn = dependsMatch ? dependsMatch[1].trim() : null;
const normalized = normalizePhaseName(phaseNum);
let diskStatus = 'no_directory';
let planCount = 0;
let summaryCount = 0;
let hasContext = false;
let hasResearch = false;
let lastActivity: string | null = null;
let isActive = false;
try {
const dirMatch = phaseDirEntries.find(d => phaseTokenMatches(d, normalized));
if (dirMatch) {
const fullDir = join(paths.phases, dirMatch);
const phaseFiles = readdirSync(fullDir);
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<string>()): boolean {
if (visited.has(from)) return false;
visited.add(from);
const p = phaseMap.get(from);
const depPhases = p?.dep_phases as string[] | undefined;
if (!depPhases || depPhases.length === 0) return false;
if (depPhases.includes(to)) return true;
return depPhases.some(dep => reaches(dep, to, visited));
}
const activeExecuting = phases.filter(p => {
const status = p.disk_status as string;
return status === 'partial' || (status === 'planned' && p.is_active);
});
const activePlanning = phases.filter(p => {
const status = p.disk_status as string;
return p.is_active && (status === 'discussed' || status === 'researched');
});
const recommendedActions: Record<string, unknown>[] = [];
for (const phase of phases) {
const status = phase.disk_status as string;
if (status === 'complete') continue;
if (/^999(?:\.|$)/.test(phase.number as string)) continue;
if (status === 'planned' && phase.deps_satisfied) {
const action = {
phase: phase.number,
phase_name: phase.name,
action: 'execute',
reason: `${phase.plan_count} plans ready, dependencies met`,
command: `/gsd-execute-phase ${phase.number}`,
};
const isAllowed = activeExecuting.length === 0 ||
activeExecuting.every(a => !reaches(phase.number as string, a.number as string) && !reaches(a.number as string, phase.number as string));
if (isAllowed) recommendedActions.push(action);
} else if (status === 'discussed' || status === 'researched') {
const action = {
phase: phase.number,
phase_name: phase.name,
action: 'plan',
reason: 'Context gathered, ready for planning',
command: `/gsd-plan-phase ${phase.number}`,
};
const isAllowed = activePlanning.length === 0 ||
activePlanning.every(a => !reaches(phase.number as string, a.number as string) && !reaches(a.number as string, phase.number as string));
if (isAllowed) recommendedActions.push(action);
} else if ((status === 'empty' || status === 'no_directory') && phase.is_next_to_discuss) {
recommendedActions.push({
phase: phase.number,
phase_name: phase.name,
action: 'discuss',
reason: 'Unblocked, ready to gather context',
command: `/gsd-discuss-phase ${phase.number}`,
});
}
}
const completedCount = phases.filter(p => p.disk_status === 'complete').length;
// Read manager flags from config
const managerConfig = (config as Record<string, unknown>).manager as Record<string, Record<string, string>> | undefined;
const sanitizeFlags = (raw: unknown): string => {
const val = typeof raw === 'string' ? raw : '';
if (!val) return '';
const tokens = val.split(/\s+/).filter(Boolean);
const safe = tokens.every(t => /^--[a-zA-Z0-9][-a-zA-Z0-9]*$/.test(t) || /^[a-zA-Z0-9][-a-zA-Z0-9_.]*$/.test(t));
return safe ? val : '';
};
const managerFlags = {
discuss: sanitizeFlags(managerConfig?.flags?.discuss),
plan: sanitizeFlags(managerConfig?.flags?.plan),
execute: sanitizeFlags(managerConfig?.flags?.execute),
};
const result: Record<string, unknown> = {
milestone_version: milestone.version,
milestone_name: milestone.name,
phases,
phase_count: phases.length,
completed_count: completedCount,
in_progress_count: phases.filter(p => ['partial', 'planned', 'discussed', 'researched'].includes(p.disk_status as string)).length,
recommended_actions: recommendedActions,
waiting_signal: waitingSignal,
all_complete: completedCount === phases.length && phases.length > 0,
project_exists: pathExists(projectDir, '.planning/PROJECT.md'),
roadmap_exists: true,
state_exists: true,
manager_flags: managerFlags,
};
return { data: withProjectRoot(projectDir, result) };
};

308
sdk/src/query/init.test.ts Normal file
View File

@@ -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',
'---',
'<objective>Test plan</objective>',
].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<string, unknown> = { 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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(data.error).toBeDefined();
});
});

956
sdk/src/query/init.ts Normal file
View File

@@ -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: <flat JSON> }.
*
* 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<string> {
const result = await resolveModel([agentType], projectDir);
const data = result.data as Record<string, unknown>;
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<string, unknown> | null; roadmapPhase: Record<string, unknown> | null }> {
const phaseResult = await findPhase([phase], projectDir);
let phaseInfo = phaseResult.data as Record<string, unknown> | null;
const roadmapResult = await roadmapGetPhase([phase], projectDir);
const roadmapPhase = roadmapResult.data as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>,
config?: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown>).project_code as string || '';
const result: Record<string, unknown> = {
executor_model: executorModel,
verifier_model: verifierModel,
commit_docs: config.commit_docs,
sub_repos: (config as Record<string, unknown>).sub_repos ?? [],
parallelization: config.parallelization,
context_window: (config as Record<string, unknown>).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<string, unknown>) };
};
// ─── 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<string, unknown> = {
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<string, unknown>) };
};
// ─── 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<string, unknown> = {
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<string, unknown>) };
};
// ─── 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<string, unknown> = {
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<string, unknown>) };
};
// ─── 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<string, unknown> = {
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<string, unknown>) };
};
// ─── 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<string, unknown> = {
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<string, unknown>) };
};
// ─── 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<string, unknown> | null;
const roadmapResult = await roadmapGetPhase([phase], projectDir);
const roadmapPhase = roadmapResult.data as Record<string, unknown> | 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<string, unknown> = {
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<string, unknown>) };
};
// ─── 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<Record<string, unknown>> = [];
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<string, unknown> = {
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<string, unknown>) };
};
// ─── 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<string, unknown> = {
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<string, unknown>) };
};
// ─── 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<string, unknown> = {
mapper_model: mapperModel,
commit_docs: config.commit_docs,
search_gitignored: config.search_gitignored,
parallelization: config.parallelization,
subagent_timeout: (config as Record<string, unknown>).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<string, unknown>) };
};
// ─── 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<string, unknown> = {
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<Record<string, unknown>> = [];
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<string, unknown> = {
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<Record<string, unknown>> = [];
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<string, unknown> = {
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,
},
};
};

311
sdk/src/query/intel.ts Normal file
View File

@@ -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<string, string> = {
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<string, unknown> = {};
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<string, unknown> | null;
if (data?._meta) {
updatedAt = (data._meta as Record<string, unknown>).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<string, unknown> | null;
if (!snapshot) return { data: { no_baseline: true } };
const prevHashes = (snapshot.hashes as Record<string, string>) || {};
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<string, string> = {};
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<string, unknown> | null;
if (!data) { errors.push(`Invalid JSON in: ${filename}`); continue; }
const meta = data._meta as Record<string, unknown> | 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<string, unknown>;
if (!data._meta) data._meta = {};
const meta = data._meta as Record<string, unknown>;
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) } };
}
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

307
sdk/src/query/phase.test.ts Normal file
View File

@@ -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
---
<objective>
Build error classification system.
</objective>
<tasks>
<task type="auto">
<name>Task 1: Create error types</name>
</task>
<task type="auto">
<name>Task 2: Add exit codes</name>
</task>
</tasks>
`;
const PLAN_02_CONTENT = `---
phase: 09-foundation
plan: 02
wave: 1
autonomous: false
files_modified:
- sdk/src/query/registry.ts
---
<objective>
Build query registry.
</objective>
<tasks>
<task type="auto">
<name>Task 1: Registry class</name>
</task>
<task type="checkpoint:human-verify">
<name>Task 2: Verify registry</name>
</task>
</tasks>
`;
const PLAN_03_CONTENT = `---
phase: 09-foundation
plan: 03
wave: 2
autonomous: true
---
<objective>
Golden file tests.
</objective>
<tasks>
<task type="auto">
<name>Task 1: Setup golden files</name>
</task>
</tasks>
`;
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<objective>\nPort helpers.\n</objective>\n<tasks>\n<task type="auto">\n <name>Task 1</name>\n</task>\n</tasks>');
});
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(data.phase).toBe('09');
const plans = data.plans as Array<Record<string, unknown>>;
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<string, unknown>;
const plans = data.plans as Array<Record<string, unknown>>;
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<string, unknown>;
const plans = data.plans as Array<Record<string, unknown>>;
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<string, unknown>;
const waves = data.waves as Record<string, string[]>;
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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
const plans = data.plans as Array<Record<string, unknown>>;
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<string, unknown>;
expect(data.error).toBe('Phase not found');
expect(data.plans).toEqual([]);
});
});

340
sdk/src/query/phase.ts Normal file
View File

@@ -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<PhaseInfo | null> {
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(/<objective>\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<Record<string, unknown>> = [];
const waves: Record<string, string[]> = {};
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 <task> tags (canonical) or ## Task N markdown (legacy)
const xmlTasks = content.match(/<task[\s>]/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,
},
};
};

View File

@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>;
// 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<string, unknown>;
const diff = data.diff as Record<string, { before: string | null; after: string | null }>;
// 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<string, unknown>).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<string, unknown>;
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<QueryResult> => ({ 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<string, unknown>).value).toBe(42);
});
});

246
sdk/src/query/pipeline.ts Normal file
View File

@@ -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<void>;
/** Called after each handler invocation */
onFinalize?: (command: string, args: string[], result: QueryResult) => Promise<void>;
}
/**
* 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<void> {
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<Map<string, string>> {
const planningDir = join(projectDir, '.planning');
const result = new Map<string, string>();
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<string, string>,
after: Map<string, string>,
): Record<string, { before: string | null; after: string | null }> {
const diff: Record<string, { before: string | null; after: string | null }> = {};
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<string>,
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.
}

367
sdk/src/query/profile.ts Normal file
View File

@@ -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<Record<string, unknown>> {
if (!existsSync(STORE_DIR)) return [];
const results: Array<Record<string, unknown>> = [];
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 <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<string, unknown>[] = [];
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<string, string>;
const analysis: Record<string, string> = {};
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<string, unknown>;
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<string, unknown>;
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<string, unknown> = {};
if (analysisPath && existsSync(resolve(analysisPath))) {
try {
const analysis = JSON.parse(readFileSync(resolve(analysisPath), 'utf-8')) as Record<string, unknown>;
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 } };
};

View File

@@ -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<string, unknown>;
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<Record<string, unknown>>;
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<string, unknown>;
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<string, unknown>;
const phases = data.phases as Array<Record<string, unknown>>;
expect(phases[0].number).toBe('02');
expect(phases[1].number).toBe('10');
});
});

272
sdk/src/query/progress.ts Normal file
View File

@@ -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<string> {
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<Record<string, unknown>> = [];
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<string, unknown>;
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 } };
};

View File

@@ -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<QueryResult> => {
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' } });
});
});

121
sdk/src/query/registry.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* Query command registry — routes commands to native SDK handlers.
*
* The registry is a flat `Map<string, QueryHandler>` 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<string, unknown>)[key];
if (!Array.isArray(current)) return undefined;
current = index < 0 ? current[current.length + index] : current[index];
} else {
current = (current as Record<string, unknown>)[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<string, QueryHandler>();
/**
* 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<QueryResult> {
const handler = this.handlers.get(command);
if (!handler) {
throw new GSDError(
`Unknown command: "${command}". No native handler registered.`,
ErrorClassification.Validation,
);
}
return handler(args, projectDir);
}
}

View File

@@ -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 <details> blocks', () => {
const content = 'before\n<details>\nshipped content\n</details>\nafter';
expect(stripShippedMilestones(content)).toBe('before\n\nafter');
});
it('handles multiple <details> blocks', () => {
const content = '<details>a</details>middle<details>b</details>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 = '<details>old</details>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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(data.phase_count).toBe(3);
expect((data.phases as Array<Record<string, unknown>>).length).toBe(3);
const phases = data.phases as Array<Record<string, unknown>>;
// 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<string, unknown>;
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<string, unknown>;
const phases = data.phases as Array<Record<string, unknown>>;
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<string, unknown>;
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<string, unknown>;
const data2 = result2.data as Record<string, unknown>;
expect((data1.phases as unknown[]).length).toBe((data2.phases as unknown[]).length);
});
});

470
sdk/src/query/roadmap.ts Normal file
View File

@@ -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 <details>...</details> blocks from content (shipped milestones).
*
* Port of stripShippedMilestones from core.cjs line 1082-1084.
*/
export function stripShippedMilestones(content: string): string {
return content.replace(/<details>[\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<string> {
// 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 <details> from preamble
const preamble = beforeMilestones.replace(/<details>[\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 <phase-number>',
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<Record<string, unknown>> = [];
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<string>();
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<string, unknown> = {
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' } };
}
};

50
sdk/src/query/skills.ts Normal file
View File

@@ -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,
},
};
};

View File

@@ -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<string> {
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<string, unknown>;
expect(data.updated).toBe(true);
// Verify round-trip
const loaded = await stateLoad([], tmpDir);
const loadedData = loaded.data as Record<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(data.recorded).toBe(true);
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(content).toContain('Completed 11-01-PLAN.md');
});
});

View File

@@ -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<string>();
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, string | undefined>): 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<string> {
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<void>(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<void> {
_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<string> {
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<string>
): Promise<string> {
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<string, string>;
try {
patches = JSON.parse(jsonString) as Record<string, string>;
} 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<string, unknown> = { 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' } };
}
};

347
sdk/src/query/state.test.ts Normal file
View File

@@ -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<string, unknown>;
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<string, unknown>;
const progress = data.progress as Record<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const progress = data.progress as Record<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const decisions = data.decisions as Array<Record<string, string>>;
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<string, unknown>;
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<string, unknown>;
const session = data.session as Record<string, string | null>;
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<string, unknown>;
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<string, unknown>;
// 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');
}
});
});

395
sdk/src/query/state.ts Normal file
View File

@@ -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<string>();
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<string>(
[...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<Record<string, unknown>> {
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<string, unknown> = { 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<string, unknown> = {};
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 };
};

351
sdk/src/query/stubs.test.ts Normal file
View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(typeof data.updated).toBe('boolean');
});
it('returns false without phase arg', async () => {
const result = await statePlannedPhase([], tmpDir);
const data = result.data as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(typeof data.created).toBe('boolean');
});
it('workstreamCreate rejects path traversal', async () => {
const result = await workstreamCreate(['../../bad'], tmpDir);
const data = result.data as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(data.available).toBe(true);
expect(data.query).toBe('typescript generics');
expect(data.count).toBe(2);
const results = data.results as Array<Record<string, unknown>>;
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<typeof vi.fn>).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<string, unknown>;
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<string, unknown>;
expect(data.available).toBe(false);
expect(data.error).toBe('ECONNREFUSED');
});
});

178
sdk/src/query/summary.ts Normal file
View File

@@ -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<string, string> = {};
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<string, { name: string; provides: string[]; affects: string[]; patterns: string[] }>;
decisions: Array<{ phase: string; decision: string }>;
tech_stack: string[];
} = { phases: {}, decisions: [], tech_stack: [] };
const techStackSet = new Set<string>();
// 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 };
};

View File

@@ -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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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('<objective>');
expect(content).toContain('<tasks>');
});
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<string, unknown>;
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<string, unknown>;
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);
});
});

242
sdk/src/query/template.ts Normal file
View File

@@ -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<string, unknown> = {};
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<string, unknown>;
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 = [
'<objective>',
'</objective>',
'',
'<context>',
'</context>',
'',
'<tasks>',
'</tasks>',
'',
'<verification>',
'</verification>',
'',
'<success_criteria>',
'</success_criteria>',
].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 } };
};

175
sdk/src/query/uat.ts Normal file
View File

@@ -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 <path>
*/
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 <path>' } };
}
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<!--[\s\S]*?-->)?\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<string, unknown>[] = [];
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 } } };
};

View File

@@ -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}$/);
});
});

92
sdk/src/query/utils.ts Normal file
View File

@@ -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<QueryResult>;
// ─── 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 } };
};

View File

@@ -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<string, unknown>;
expect(data.all_verified).toBe(true);
expect(data.verified).toBe(1);
expect(data.total).toBe(1);
const links = data.links as Array<Record<string, unknown>>;
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<string, unknown>;
const links = data.links as Array<Record<string, unknown>>;
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<string, unknown>;
expect(data.all_verified).toBe(false);
const links = data.links as Array<Record<string, unknown>>;
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<string, unknown>;
const links = data.links as Array<Record<string, unknown>>;
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<string, unknown>;
const links = data.links as Array<Record<string, unknown>>;
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<string, unknown>;
const links = data.links as Array<Record<string, unknown>>;
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<string, unknown>;
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<string, string> }>;
config?: Record<string, unknown>;
}): Promise<void> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<void> {
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<string, unknown>;
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<string, unknown>;
expect(data.status).toBe('broken');
const errors = data.errors as Array<Record<string, unknown>>;
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<string, unknown>;
const errors = data.errors as Array<Record<string, unknown>>;
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<string, unknown>;
const errors = data.errors as Array<Record<string, unknown>>;
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<string, unknown>;
const errors = data.errors as Array<Record<string, unknown>>;
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<string, unknown>;
const errors = data.errors as Array<Record<string, unknown>>;
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<string, unknown>;
const warnings = data.warnings as Array<Record<string, unknown>>;
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<string, unknown>;
const warnings = data.warnings as Array<Record<string, unknown>>;
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<string, unknown>;
expect(data.status).toBe('error');
const errors = data.errors as Array<Record<string, unknown>>;
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<string, unknown>;
const warnings = data.warnings as Array<Record<string, unknown>>;
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>;
expect(data.repairs_performed).toBeDefined();
const repairs = data.repairs_performed as Array<Record<string, unknown>>;
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<string, unknown>;
const repairs = data.repairs_performed as Array<Record<string, unknown>>;
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<string, unknown>;
const repairs = data.repairs_performed as Array<Record<string, unknown>>;
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);
});
});

709
sdk/src/query/validate.ts Normal file
View File

@@ -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<string, unknown>;
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 <details> blocks
const activeContent = roadmapContent.replace(/<details>[\s\S]*?<\/details>/gi, '');
// Extract phase numbers from ROADMAP headings
const roadmapPhases = new Set<string>();
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<string>();
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<string, unknown> = {};
try {
const configContent = await readFile(paths.config, 'utf-8');
config = JSON.parse(configContent) as Record<string, unknown>;
} 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<string>();
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<string, unknown>;
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<string, unknown>;
const workflow = configParsed.workflow as Record<string, unknown> | 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<string>();
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<string>();
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<string, unknown>;
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<string, unknown>;
if (!configParsed.workflow) configParsed.workflow = {};
const wf = configParsed.workflow as Record<string, unknown>;
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,
},
};
};

View File

@@ -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 type="auto">
<name>Task 1: Do something</name>
<files>src/foo.ts</files>
<action>Implement foo</action>
<verify>Run tests</verify>
<done>Foo works</done>
</task>
`;
await writeFile(join(tmpDir, 'plan.md'), plan);
const result = await verifyPlanStructure(['plan.md'], tmpDir);
const data = result.data as Record<string, unknown>;
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 type="auto">
<name>Task 1</name>
<action>Do it</action>
</task>
`;
await writeFile(join(tmpDir, 'plan.md'), plan);
const result = await verifyPlanStructure(['plan.md'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.valid).toBe(false);
expect(data.errors).toContain('Missing required frontmatter field: wave');
});
it('returns error when task missing <name> element', async () => {
const plan = `---
phase: 12
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: []
autonomous: true
must_haves:
truths:
- x
---
<task type="auto">
<action>Do something</action>
</task>
`;
await writeFile(join(tmpDir, 'plan.md'), plan);
const result = await verifyPlanStructure(['plan.md'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.valid).toBe(false);
expect(data.errors).toContain('Task missing <name> element');
});
it('returns error when task missing <action> element', async () => {
const plan = `---
phase: 12
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: []
autonomous: true
must_haves:
truths:
- x
---
<task type="auto">
<name>Task 1</name>
<done>Done</done>
</task>
`;
await writeFile(join(tmpDir, 'plan.md'), plan);
const result = await verifyPlanStructure(['plan.md'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.valid).toBe(false);
expect((data.errors as string[])).toContainEqual(expect.stringContaining("missing <action>"));
});
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 type="auto">
<name>Task 1</name>
<action>Do it</action>
</task>
`;
await writeFile(join(tmpDir, 'plan.md'), plan);
const result = await verifyPlanStructure(['plan.md'], tmpDir);
const data = result.data as Record<string, unknown>;
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
---
<task type="checkpoint:human-verify">
<name>Check it</name>
<action>Verify</action>
</task>
`;
await writeFile(join(tmpDir, 'plan.md'), plan);
const result = await verifyPlanStructure(['plan.md'], tmpDir);
const data = result.data as Record<string, unknown>;
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<string, unknown>;
expect(data.warnings).toContain('No <task> elements found');
});
it('returns error for missing file', async () => {
const result = await verifyPlanStructure(['nonexistent.md'], tmpDir);
const data = result.data as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(data.all_passed).toBe(false);
const artifacts = data.artifacts as Array<Record<string, unknown>>;
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<string, unknown>;
expect(data.all_passed).toBe(false);
const artifacts = data.artifacts as Array<Record<string, unknown>>;
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<string, unknown>;
expect(data.all_passed).toBe(false);
const artifacts = data.artifacts as Array<Record<string, unknown>>;
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<string, unknown>;
expect(data.all_passed).toBe(false);
const artifacts = data.artifacts as Array<Record<string, unknown>>;
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<string, unknown>;
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);
});
});

588
sdk/src/query/verify.ts Normal file
View File

@@ -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 = /<task[^>]*>([\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(/<name>([\s\S]*?)<\/name>/);
const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed';
const hasFiles = /<files>/.test(taskContent);
const hasAction = /<action>/.test(taskContent);
const hasVerify = /<verify>/.test(taskContent);
const hasDone = /<done>/.test(taskContent);
if (!nameMatch) errors.push('Task missing <name> element');
if (!hasAction) errors.push(`Task '${taskName}' missing <action>`);
if (!hasVerify) warnings.push(`Task '${taskName}' missing <verify>`);
if (!hasDone) warnings.push(`Task '${taskName}' missing <done>`);
if (!hasFiles) warnings.push(`Task '${taskName}' missing <files>`);
tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone });
}
if (tasks.length === 0) warnings.push('No <task> 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 = /<task\s+type=["']?checkpoint/.test(content);
if (hasCheckpoints && fm.autonomous !== 'false' && fm.autonomous !== false) {
errors.push('Has checkpoint tasks but autonomous is not false');
}
return {
data: {
valid: errors.length === 0,
errors,
warnings,
task_count: tasks.length,
tasks,
frontmatter_fields: Object.keys(fm),
},
};
};
// ─── verifyPhaseCompleteness ───────────────────────────────────────────────
/**
* Check phase completeness by matching PLAN files to SUMMARY files.
*
* Port of `cmdVerifyPhaseCompleteness` from `verify.cjs` lines 169-213.
* Scans a phase directory for PLAN and SUMMARY files, identifies incomplete
* plans (no summary) and orphan summaries (no plan).
*
* @param args - args[0]: phase number (required)
* @param projectDir - Project root directory
* @returns QueryResult with { complete, phase, plan_count, summary_count, incomplete_plans, orphan_summaries, errors, warnings }
* @throws GSDError with Validation classification if phase number missing
*/
export const verifyPhaseCompleteness: QueryHandler = async (args, projectDir) => {
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<string, unknown>;
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<string>();
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 } };
}
};

View File

@@ -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 } };
}
};

View File

@@ -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/<ws> 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/<project> 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();
});
});

131
sdk/src/query/workspace.ts Normal file
View File

@@ -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/<ws>/
* When context has a project set: base = .planning/projects/<project>/
* 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')),
};
}

252
sdk/src/query/workstream.ts Normal file
View File

@@ -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 <name> (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);
};

View File

@@ -20,6 +20,7 @@ const PHASE_DEFAULT_TOOLS: Record<PhaseType, string[]> = {
[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, string | null> = {
[PhaseType.Plan]: 'gsd-planner.md',
[PhaseType.Verify]: 'gsd-verifier.md',
[PhaseType.Discuss]: null,
[PhaseType.Repair]: null,
};
// ─── Public API ──────────────────────────────────────────────────────────────

View File

@@ -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.

View File

@@ -5,7 +5,7 @@
* .planning/workstreams/<name>/ 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);
}