Files
msd-core/sdk/src/plan-parser.ts
TÂCHES 596ce2d252 feat: GSD SDK — headless CLI with init + auto commands (#1407)
* test: Bootstrapped sdk/ as TypeScript ESM package with full GSD-1 PLAN.…

- "sdk/package.json"
- "sdk/tsconfig.json"
- "sdk/vitest.config.ts"
- "sdk/src/types.ts"
- "sdk/src/plan-parser.ts"
- "sdk/src/plan-parser.test.ts"

GSD-Task: S01/T01

* test: Implemented config reader and gsd-tools bridge with 25 unit tests…

- "sdk/src/config.ts"
- "sdk/src/config.test.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/gsd-tools.test.ts"

GSD-Task: S01/T02

* test: Built prompt-builder, session-runner, and GSD class — 85 total un…

- "sdk/src/prompt-builder.ts"
- "sdk/src/prompt-builder.test.ts"
- "sdk/src/session-runner.ts"
- "sdk/src/index.ts"
- "sdk/src/types.ts"

GSD-Task: S01/T03

* test: Created E2E integration test with fixtures proving full SDK pipel…

- "sdk/src/e2e.integration.test.ts"
- "sdk/test-fixtures/sample-plan.md"
- "sdk/test-fixtures/.planning/config.json"
- "sdk/test-fixtures/.planning/STATE.md"
- "vitest.config.ts"
- "tsconfig.json"

GSD-Task: S01/T04

* test: Added PhaseType/GSDEventType enums, 16-variant GSDEvent union, GS…

- "sdk/src/types.ts"
- "sdk/src/event-stream.ts"
- "sdk/src/logger.ts"
- "sdk/src/event-stream.test.ts"
- "sdk/src/logger.test.ts"

GSD-Task: S02/T01

* test: Built ContextEngine for phase-aware context file resolution, getT…

- "sdk/src/context-engine.ts"
- "sdk/src/tool-scoping.ts"
- "sdk/src/phase-prompt.ts"
- "sdk/src/context-engine.test.ts"
- "sdk/src/tool-scoping.test.ts"
- "sdk/src/phase-prompt.test.ts"

GSD-Task: S02/T02

* test: Wired event stream into session runner, added onEvent()/addTransp…

- "sdk/src/session-runner.ts"
- "sdk/src/index.ts"
- "sdk/src/e2e.integration.test.ts"

GSD-Task: S02/T03

* feat: Added PhaseStepType enum, PhaseOpInfo interface, phase lifecycle…

- "sdk/src/types.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/session-runner.ts"
- "sdk/src/index.ts"
- "sdk/src/phase-runner-types.test.ts"

GSD-Task: S03/T01

* test: Implemented PhaseRunner state machine with 39 unit tests covering…

- "sdk/src/phase-runner.ts"
- "sdk/src/phase-runner.test.ts"

GSD-Task: S03/T02

* test: Wired PhaseRunner into GSD.runPhase() public API with full re-exp…

- "sdk/src/index.ts"
- "sdk/src/phase-runner.integration.test.ts"
- "sdk/src/phase-runner.ts"

GSD-Task: S03/T03

* test: Expanded runVerifyStep with full gap closure cycle (plan → execut…

- "sdk/src/types.ts"
- "sdk/src/phase-runner.ts"
- "sdk/src/phase-runner.test.ts"

GSD-Task: S04/T02

* fix: Added 3 integration tests proving phasePlanIndex returns correct t…

- "sdk/src/phase-runner.integration.test.ts"
- "sdk/src/index.ts"

GSD-Task: S04/T03

* test: Add milestone-level types, typed roadmapAnalyze(), GSD.run() orch…

- "sdk/src/types.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/index.ts"
- "sdk/src/milestone-runner.test.ts"

GSD-Task: S05/T01

* test: Added CLITransport (structured stdout log lines) and WSTransport…

- "sdk/src/cli-transport.ts"
- "sdk/src/cli-transport.test.ts"
- "sdk/src/ws-transport.ts"
- "sdk/src/ws-transport.test.ts"
- "sdk/src/index.ts"
- "sdk/package.json"

GSD-Task: S05/T02

* test: Added gsd-sdk CLI entry point with argument parsing, bin field, p…

- "sdk/src/cli.ts"
- "sdk/src/cli.test.ts"
- "sdk/package.json"

GSD-Task: S05/T03

* feat: Add InitNewProjectInfo type, initNewProject()/configSet() GSDTool…

- "sdk/src/types.ts"
- "sdk/src/gsd-tools.ts"
- "sdk/src/cli.ts"
- "sdk/src/cli.test.ts"
- "sdk/src/gsd-tools.test.ts"

GSD-Task: S01/T01

* chore: Created InitRunner orchestrator with setup → config → PROJECT.md…

- "sdk/src/init-runner.ts"
- "sdk/src/types.ts"
- "sdk/src/index.ts"

GSD-Task: S01/T02

* test: Wired InitRunner into CLI main() for full gsd-sdk init dispatch a…

- "sdk/src/cli.ts"
- "sdk/src/init-runner.test.ts"
- "sdk/src/cli.test.ts"

GSD-Task: S01/T03

* test: Add PlanCheck step, AI self-discuss, and retryOnce wrapper to Pha…

- "sdk/src/types.ts"
- "sdk/src/phase-runner.ts"
- "sdk/src/session-runner.ts"
- "sdk/src/phase-runner.test.ts"
- "sdk/src/phase-runner-types.test.ts"

GSD-Task: S02/T01

* feat: Rewrite CLITransport with ANSI colors, phase banners, spawn indic…

- "sdk/src/cli-transport.ts"
- "sdk/src/cli-transport.test.ts"

GSD-Task: S02/T02

* test: Add `gsd-sdk auto` command with autoMode config override, USAGE t…

- "sdk/src/cli.ts"
- "sdk/src/cli.test.ts"
- "sdk/src/index.ts"
- "sdk/src/types.ts"

GSD-Task: S02/T03

* fix: CLI shebang + gsd-tools non-JSON output handling

Three bugs found during first real gsd-sdk run:

1. cli.ts shebang was commented out — shell executed JS as bash,
   triggering ImageMagick's import command instead of Node

2. configSet() called exec() which JSON.parse()d the output, but
   gsd-tools config-set returns 'key=value' text, not JSON.
   Added execRaw() method for commands that return plain text.

3. Same JSON parse bug affected commit() (returns git SHA),
   stateLoad(), verifySummary(), initExecutePhase(), stateBeginPhase(),
   and phaseComplete(). All switched to execRaw().

Tests updated to match real gsd-tools output format (plain text
instead of mocked JSON). 376/376 tests pass.
2026-03-26 20:27:51 -06:00

428 lines
14 KiB
TypeScript

/**
* plan-parser.ts — Parse GSD-1 PLAN.md files into structured data.
*
* Extracts YAML frontmatter, XML task bodies, and markdown sections
* (<objective>, <execution_context>, <context>) from plan files.
*
* Ported from get-shit-done/bin/lib/frontmatter.cjs with TypeScript types.
*/
import { readFile } from 'node:fs/promises';
import type {
PlanFrontmatter,
PlanTask,
ParsedPlan,
MustHaves,
MustHaveArtifact,
MustHaveKeyLink,
} from './types.js';
// ─── YAML frontmatter extraction ─────────────────────────────────────────────
/**
* Extract frontmatter from a PLAN.md content string.
*
* Uses a stack-based parser that handles nested objects, inline arrays,
* multi-line arrays, and boolean/numeric coercion. Ported from the CJS
* reference implementation with the same edge-case coverage.
*/
export function extractFrontmatter(content: string): Record<string, unknown> {
const frontmatter: Record<string, unknown> = {};
// Find ALL frontmatter blocks — if multiple exist (corruption), use the last one
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 tracks nested objects: [{obj, key, indent}]
const stack: Array<{ obj: Record<string, unknown> | unknown[]; key: string | null; indent: number }> = [
{ obj: frontmatter, key: null, indent: -1 },
];
for (const line of lines) {
if (line.trim() === '') continue;
const indentMatch = line.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[1].length : 0;
// Pop stack back to appropriate level
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
}
const current = stack[stack.length - 1];
const currentObj = current.obj as Record<string, unknown>;
// Key: value pattern
const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
if (keyMatch) {
const key = keyMatch[2];
const value = keyMatch[3].trim();
if (value === '' || value === '[') {
// Key with no value or opening bracket — nested object or array (TBD)
currentObj[key] = value === '[' ? [] : {};
current.key = null;
stack.push({ obj: currentObj[key] as Record<string, unknown>, key: null, indent });
} else if (value.startsWith('[') && value.endsWith(']')) {
// Inline array: key: [a, b, c]
currentObj[key] = value
.slice(1, -1)
.split(',')
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
.filter(Boolean);
current.key = null;
} else {
// Simple key: value — coerce booleans and numbers
const cleanValue = value.replace(/^["']|["']$/g, '');
currentObj[key] = coerceValue(cleanValue);
current.key = null;
}
} else if (line.trim().startsWith('- ')) {
// Array item — could be a plain string or "- key: value" (start of mapping item)
const afterDash = line.trim().slice(2);
const dashKvMatch = afterDash.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
// Determine the value to push
let itemToPush: unknown;
if (dashKvMatch) {
// "- key: value" → start of a mapping item (object in array)
const obj: Record<string, unknown> = {};
const val = dashKvMatch[2].trim().replace(/^["']|["']$/g, '');
obj[dashKvMatch[1]] = coerceValue(val);
itemToPush = obj;
} else {
const itemValue = afterDash.replace(/^["']|["']$/g, '');
itemToPush = coerceValue(itemValue);
}
// If current context is an empty object, convert to array
if (
typeof current.obj === 'object' &&
!Array.isArray(current.obj) &&
Object.keys(current.obj).length === 0
) {
const parent = stack.length > 1 ? stack[stack.length - 2] : null;
if (parent && typeof parent.obj === 'object' && !Array.isArray(parent.obj)) {
const parentObj = parent.obj as Record<string, unknown>;
for (const k of Object.keys(parentObj)) {
if (parentObj[k] === current.obj) {
parentObj[k] = [itemToPush];
current.obj = parentObj[k] as unknown[];
break;
}
}
}
} else if (Array.isArray(current.obj)) {
current.obj.push(itemToPush);
}
// If we pushed a mapping object, push it onto the stack so subsequent
// indented key-value lines populate the same object
if (dashKvMatch && typeof itemToPush === 'object') {
stack.push({
obj: itemToPush as Record<string, unknown>,
key: null,
indent, // use dash indent so sub-keys (more indented) populate this object
});
}
}
}
return frontmatter;
}
/**
* Coerce string values to appropriate JS types.
* Preserves leading-zero strings (e.g., "01") as strings.
*/
function coerceValue(value: string): unknown {
if (value === 'true') return true;
if (value === 'false') return false;
// Only coerce numbers without leading zeros (01, 007 stay as strings)
if (/^[1-9]\d*$/.test(value) || value === '0') return parseInt(value, 10);
if (/^\d+\.\d+$/.test(value) && !value.startsWith('0')) return parseFloat(value);
return value;
}
// ─── must_haves block parsing ────────────────────────────────────────────────
/**
* Parse the must_haves nested structure from raw frontmatter.
*
* The must_haves field has three sub-keys: truths (string[]),
* artifacts (object[]), and key_links (object[]).
* The stack-based parser above produces these as nested objects
* which need further normalization.
*/
function parseMustHaves(raw: unknown): MustHaves {
const defaults: MustHaves = { truths: [], artifacts: [], key_links: [] };
if (!raw || typeof raw !== 'object') return defaults;
const obj = raw as Record<string, unknown>;
return {
truths: normalizeStringArray(obj.truths),
artifacts: normalizeArtifacts(obj.artifacts),
key_links: normalizeKeyLinks(obj.key_links),
};
}
function normalizeStringArray(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
return [];
}
function normalizeArtifacts(val: unknown): MustHaveArtifact[] {
if (!Array.isArray(val)) return [];
return val
.filter((item) => typeof item === 'object' && item !== null)
.map((item) => {
const obj = item as Record<string, unknown>;
return {
path: String(obj.path ?? ''),
provides: String(obj.provides ?? ''),
...(obj.min_lines !== undefined ? { min_lines: Number(obj.min_lines) } : {}),
...(obj.exports !== undefined ? { exports: normalizeStringArray(obj.exports) } : {}),
...(obj.contains !== undefined ? { contains: String(obj.contains) } : {}),
};
});
}
function normalizeKeyLinks(val: unknown): MustHaveKeyLink[] {
if (!Array.isArray(val)) return [];
return val
.filter((item) => typeof item === 'object' && item !== null)
.map((item) => {
const obj = item as Record<string, unknown>;
return {
from: String(obj.from ?? ''),
to: String(obj.to ?? ''),
via: String(obj.via ?? ''),
...(obj.pattern !== undefined ? { pattern: String(obj.pattern) } : {}),
};
});
}
// ─── XML task extraction ─────────────────────────────────────────────────────
/**
* Extract inner text of an XML element from a task body.
* Handles multiline content and trims whitespace.
*/
function extractElement(taskBody: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i');
const match = taskBody.match(regex);
return match ? match[1].trim() : '';
}
/**
* Extract the type attribute from a <task> opening tag.
*/
function extractTaskType(taskTag: string): string {
const match = taskTag.match(/type\s*=\s*["']([^"']+)["']/);
return match ? match[1] : 'auto';
}
/**
* Parse XML task blocks from the <tasks> section.
*
* Uses a regex to match <task ...>...</task> blocks, then extracts
* inner elements (name, files, read_first, action, verify,
* acceptance_criteria, done).
*
* Handles:
* - Multiline <action> blocks (including code snippets with angle brackets)
* - Optional elements (missing elements → empty string/array)
* - Both auto and checkpoint task types
*/
export function parseTasks(content: string): PlanTask[] {
const tasks: PlanTask[] = [];
// Extract the <tasks>...</tasks> section first
const tasksSection = content.match(/<tasks>([\s\S]*?)<\/tasks>/i);
const taskContent = tasksSection ? tasksSection[1] : content;
// Match individual task blocks — use a greedy-enough approach
// that handles nested angle brackets in action blocks
const taskRegex = /<task\b([^>]*)>([\s\S]*?)<\/task>/gi;
let taskMatch: RegExpExecArray | null;
while ((taskMatch = taskRegex.exec(taskContent)) !== null) {
const attrs = taskMatch[1];
const body = taskMatch[2];
const type = extractTaskType(attrs);
const name = extractElement(body, 'name');
const filesStr = extractElement(body, 'files');
const readFirstStr = extractElement(body, 'read_first');
const action = extractElement(body, 'action');
const verify = extractElement(body, 'verify');
const done = extractElement(body, 'done');
// Parse acceptance_criteria — can be a block with "- " list items
const acRaw = extractElement(body, 'acceptance_criteria');
const acceptance_criteria = acRaw
? acRaw
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('- '))
.map((line) => line.slice(2).trim())
: [];
// Parse file lists (comma-separated)
const files = filesStr
? filesStr
.split(',')
.map((f) => f.trim())
.filter(Boolean)
: [];
const read_first = readFirstStr
? readFirstStr
.split(',')
.map((f) => f.trim())
.filter(Boolean)
: [];
tasks.push({
type,
name,
files,
read_first,
action,
verify,
acceptance_criteria,
done,
});
}
return tasks;
}
// ─── Section extraction ──────────────────────────────────────────────────────
/**
* Extract content of a named XML section (e.g., <objective>...</objective>).
*/
function extractSection(content: string, sectionName: string): string {
const regex = new RegExp(`<${sectionName}>([\\s\\S]*?)</${sectionName}>`, 'i');
const match = content.match(regex);
return match ? match[1].trim() : '';
}
/**
* Extract context references from the <context> block.
* Returns an array of file paths (lines starting with @).
*/
function extractContextRefs(content: string): string[] {
const contextBlock = extractSection(content, 'context');
if (!contextBlock) return [];
return contextBlock
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('@'))
.map((line) => line.slice(1).trim());
}
/**
* Extract execution_context references.
* Returns an array of file paths (lines starting with @).
*/
function extractExecutionContext(content: string): string[] {
const block = extractSection(content, 'execution_context');
if (!block) return [];
return block
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('@'))
.map((line) => line.slice(1).trim());
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Parse a GSD-1 PLAN.md content string into a structured ParsedPlan.
*
* Extracts:
* - YAML frontmatter (phase, wave, depends_on, must_haves, etc.)
* - <objective> section
* - <execution_context> references
* - <context> file references
* - <task> blocks with all inner elements
*
* Handles edge cases:
* - Empty input → empty frontmatter, no tasks
* - Missing frontmatter → empty object with defaults
* - Malformed XML → partial extraction, no crash
*/
export function parsePlan(content: string): ParsedPlan {
if (!content || typeof content !== 'string') {
return {
frontmatter: createDefaultFrontmatter(),
objective: '',
execution_context: [],
context_refs: [],
tasks: [],
raw: content ?? '',
};
}
const rawFrontmatter = extractFrontmatter(content);
// Build typed frontmatter with defaults
const frontmatter: PlanFrontmatter = {
phase: String(rawFrontmatter.phase ?? ''),
plan: String(rawFrontmatter.plan ?? ''),
type: String(rawFrontmatter.type ?? 'execute'),
wave: Number(rawFrontmatter.wave ?? 1),
depends_on: normalizeStringArray(rawFrontmatter.depends_on),
files_modified: normalizeStringArray(rawFrontmatter.files_modified),
autonomous: rawFrontmatter.autonomous !== false,
requirements: normalizeStringArray(rawFrontmatter.requirements),
must_haves: parseMustHaves(rawFrontmatter.must_haves),
};
// Preserve any extra frontmatter keys
for (const [key, value] of Object.entries(rawFrontmatter)) {
if (!(key in frontmatter)) {
frontmatter[key] = value;
}
}
return {
frontmatter,
objective: extractSection(content, 'objective'),
execution_context: extractExecutionContext(content),
context_refs: extractContextRefs(content),
tasks: parseTasks(content),
raw: content,
};
}
function createDefaultFrontmatter(): PlanFrontmatter {
return {
phase: '',
plan: '',
type: 'execute',
wave: 1,
depends_on: [],
files_modified: [],
autonomous: true,
requirements: [],
must_haves: { truths: [], artifacts: [], key_links: [] },
};
}
/**
* Convenience wrapper — reads a PLAN.md file from disk and parses it.
*/
export async function parsePlanFile(filePath: string): Promise<ParsedPlan> {
const content = await readFile(filePath, 'utf-8');
return parsePlan(content);
}