mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-26 01:35:29 +02:00
The ingest-docs workflow called `gsd-sdk query init.ingest-docs` with a
fallback to `init.default` — neither was registered in createRegistry(),
so the workflow proceeded with `{}` and tried to parse project_exists,
planning_exists, has_git, and project_path from empty.
- Add initIngestDocs handler; register dotted + space aliases
- Simplify workflow call; drop broken fallback
- Repo-wide drift guard scans commands/, agents/, get-shit-done/,
hooks/, bin/, scripts/, docs/ for `gsd-sdk query <cmd>` and fails
on any reference with no registered handler (file:line citations)
- Unit tests for the new handler
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
985 lines
40 KiB
TypeScript
985 lines
40 KiB
TypeScript
/**
|
|
* 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, type Dirent } from 'node:fs';
|
|
import { readFile, readdir } from 'node:fs/promises';
|
|
import { join, relative, basename } from 'node:path';
|
|
import { execSync } from 'node:child_process';
|
|
import { homedir } from 'node:os';
|
|
|
|
import { 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, resolveAgentsDir, detectRuntime } 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.
|
|
*
|
|
* Runtime-aware per issue #2402: detects the invoking runtime
|
|
* (`GSD_RUNTIME` → `config.runtime` → 'claude') and probes that runtime's
|
|
* canonical `agents/` directory. `GSD_AGENTS_DIR` still short-circuits.
|
|
*
|
|
* Port of checkAgentsInstalled from core.cjs lines 1274-1306.
|
|
*/
|
|
function checkAgentsInstalled(config?: { runtime?: unknown }): { agents_installed: boolean; missing_agents: string[] } {
|
|
const runtime = detectRuntime(config);
|
|
const agentsDir = resolveAgentsDir(runtime);
|
|
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(config);
|
|
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 now = new Date();
|
|
const codebaseDir = join(projectDir, '.planning', 'codebase');
|
|
let existingMaps: string[] = [];
|
|
try {
|
|
existingMaps = readdirSync(codebaseDir).filter(f => f.endsWith('.md'));
|
|
} catch { /* intentionally empty */ }
|
|
|
|
const mapperModel = await getModelAlias('gsd-codebase-mapper', projectDir);
|
|
|
|
const result: Record<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,
|
|
date: now.toISOString().split('T')[0],
|
|
timestamp: now.toISOString(),
|
|
codebase_dir: '.planning/codebase',
|
|
existing_maps: existingMaps,
|
|
has_maps: existingMaps.length > 0,
|
|
planning_exists: pathExists(projectDir, '.planning'),
|
|
codebase_dir_exists: pathExists(projectDir, '.planning/codebase'),
|
|
};
|
|
|
|
return { data: withProjectRoot(projectDir, result, config as Record<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: Dirent[] = [];
|
|
try {
|
|
entries = readdirSync(defaultBase, { withFileTypes: true });
|
|
} catch { entries = []; }
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const wsPath = join(defaultBase, String(entry.name));
|
|
const manifestPath = join(wsPath, 'WORKSPACE.md');
|
|
if (!existsSync(manifestPath)) continue;
|
|
|
|
let repoCount = 0;
|
|
let strategy = 'unknown';
|
|
try {
|
|
const manifest = readFileSync(manifestPath, 'utf8');
|
|
const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
|
|
if (strategyMatch) strategy = strategyMatch[1].trim();
|
|
const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---'));
|
|
repoCount = tableRows.length;
|
|
} catch { /* best-effort */ }
|
|
const hasProject = existsSync(join(wsPath, '.planning', 'PROJECT.md'));
|
|
|
|
workspaces.push({
|
|
name: entry.name,
|
|
path: wsPath,
|
|
repo_count: repoCount,
|
|
strategy,
|
|
has_project: hasProject,
|
|
});
|
|
}
|
|
}
|
|
|
|
const result: Record<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 };
|
|
};
|
|
|
|
// ─── initIngestDocs ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Init handler for ingest-docs workflow.
|
|
* Mirrors `initResume` shape but without current-agent-id lookup — the
|
|
* ingest-docs workflow reads `project_exists`, `planning_exists`, `has_git`,
|
|
* and `project_path` to branch between new-project vs merge-milestone modes.
|
|
*/
|
|
export const initIngestDocs: QueryHandler = async (_args, projectDir) => {
|
|
const config = await loadConfig(projectDir);
|
|
const result: Record<string, unknown> = {
|
|
project_exists: pathExists(projectDir, '.planning/PROJECT.md'),
|
|
planning_exists: pathExists(projectDir, '.planning'),
|
|
has_git: pathExists(projectDir, '.git'),
|
|
project_path: '.planning/PROJECT.md',
|
|
commit_docs: config.commit_docs,
|
|
};
|
|
return { data: withProjectRoot(projectDir, result, config as Record<string, unknown>) };
|
|
};
|
|
|
|
// ─── 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,
|
|
},
|
|
};
|
|
};
|