* feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A) Golden/read-only parity tests and registry alignment, query handler fixes (check-completion, state-mutation, commit, validate, summary, etc.), and WAITING.json dual-write for .gsd/.planning readers. Refs gsd-build/get-shit-done#2341 * fix(sdk): getMilestoneInfo matches GSD ROADMAP (🟡, last bold, STATE fallback) - Recognize in-flight 🟡 milestone bullets like 🚧. - Derive from last **vX.Y Title** before ## Phases when emoji absent. - Fall back to STATE.md milestone when ROADMAP is missing; use last bare vX.Y in cleaned text instead of first (avoids v1.0 from shipped list). - Fixes init.execute-phase milestone_version and buildStateFrontmatter after state.begin-phase (syncStateFrontmatter). * feat(sdk): phase list, plan task structure, requirements extract handlers - Register phase.list-plans, phase.list-artifacts, plan.task-structure, requirements.extract-from-plans (SDK-only; golden-policy exceptions). - Add unit tests; document in QUERY-HANDLERS.md. - writeProfile: honor --output, render dimensions, return profile_path and dimensions_scored. * feat(sdk): centralize getGsdAgentsDir in query helpers Extract agent directory resolution to helpers (GSD_AGENTS_DIR, primary ~/.claude/agents, legacy path). Use from init and docs-init init bundles. docs(15): add 15-CONTEXT for autonomous phase-15 run. * feat(sdk): query CLI CJS fallback and session correlation - createRegistry(eventStream, sessionId) threads correlation into mutation events - gsd-sdk query falls back to gsd-tools.cjs when no native handler matches (disable with GSD_QUERY_FALLBACK=off); stderr bridge warnings - Export createRegistry from @gsd-build/sdk; add sdk/README.md - Update QUERY-HANDLERS.md and registry module docs for fallback + sessionId - Agents: prefer node dist/cli.js query over cat/grep for STATE and plans * fix(sdk): init phase_found parity, docs-init agents path, state field extract - Normalize findPhase not-found to null before roadmap fallback (matches findPhaseInternal) - docs-init: use detectRuntime + resolveAgentsDir for checkAgentsInstalled - state.cjs stateExtractField: horizontal whitespace only after colon (YAML progress guard) - Tests: commit_docs default true; config-get golden uses temp config; golden integration green Refs: #2302 * refactor(sdk): share SessionJsonlRecord in profile-extract-messages CodeRabbit nit: dedupe JSONL record shape for isGenuineUserMessage and streamExtractMessages. * fix(sdk): address CodeRabbit major threads (paths, gates, audit, verify) - Resolve @file: and CLI JSON indirection relative to projectDir; guard empty normalized query command - plan.task-structure + intel extract/patch-meta: resolvePathUnderProject containment - check.config-gates: safe string booleans; plan_checker alias precedence over plan_check default - state.validate/sync: phaseTokenMatches + comparePhaseNum ordering - verify.schema-drift: token match phase dirs; files_modified from parsed frontmatter - audit-open: has_scan_errors, unreadable rows, human report when scans fail - requirements PLANNED key PLAN for root PLAN.md; gsd-tools timeout note - ingest-docs: repo-root path containment; classifier output slug-hash Golden parity test strips has_scan_errors until CJS adds field. * fix: Resolve CodeRabbit security and quality findings - Secure intel.ts and cli.ts against path traversal - Catch and validate git add status in commit.ts - Expand roadmap milestone marker extraction - Fix parsing array-of-objects in frontmatter YAML - Fix unhandled config evaluations - Improve coverage test parity mapping * test: raise planner character extraction limit to 48K * fix(sdk): resolve TS build error in docs-init passing config
175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
/**
|
|
* Session scan — parity with `get-shit-done/bin/lib/profile-pipeline.cjs` `cmdScanSessions`.
|
|
* Used by `scanSessions` query handler so SDK JSON matches `gsd-tools.cjs scan-sessions --json`.
|
|
*/
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { basename, join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
/** One project entry in the JSON array emitted by `scan-sessions --json`. */
|
|
export type ScanSessionsProject = {
|
|
name: string;
|
|
directory: string;
|
|
sessionCount: number;
|
|
totalSize: number;
|
|
totalSizeHuman: string;
|
|
lastActive: string;
|
|
dateRange: { first: string; last: string };
|
|
sessions?: Array<{
|
|
sessionId: string;
|
|
size: number;
|
|
sizeHuman: string;
|
|
/** Full ISO-8601, same as CJS `scan-sessions --json --verbose`. */
|
|
modified: string;
|
|
summary?: string;
|
|
messageCount?: number;
|
|
created?: string;
|
|
}>;
|
|
};
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
return `${(bytes / 1073741824).toFixed(1)} GB`;
|
|
}
|
|
|
|
/** Same as CJS `scanProjectDir` in profile-pipeline.cjs (sessions sorted newest-first). */
|
|
export function scanProjectDir(projectDirPath: string): Array<{
|
|
sessionId: string;
|
|
filePath: string;
|
|
size: number;
|
|
modified: Date;
|
|
}> {
|
|
const entries = readdirSync(projectDirPath);
|
|
const sessions: Array<{ sessionId: string; filePath: string; size: number; modified: Date }> = [];
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.endsWith('.jsonl')) continue;
|
|
const sessionId = entry.replace('.jsonl', '');
|
|
const filePath = join(projectDirPath, entry);
|
|
const stat = statSync(filePath);
|
|
|
|
sessions.push({
|
|
sessionId,
|
|
filePath,
|
|
size: stat.size,
|
|
modified: stat.mtime,
|
|
});
|
|
}
|
|
|
|
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
return sessions;
|
|
}
|
|
|
|
export function readSessionIndex(projectDirPath: string): {
|
|
originalPath: string | null;
|
|
entries: Map<string, { sessionId?: string; summary?: string; messageCount?: number; created?: string }>;
|
|
} {
|
|
try {
|
|
const indexPath = join(projectDirPath, 'sessions-index.json');
|
|
const raw = readFileSync(indexPath, 'utf-8');
|
|
const parsed = JSON.parse(raw) as { entries?: unknown[]; originalPath?: string | null };
|
|
const entries = new Map<string, { sessionId?: string; summary?: string; messageCount?: number; created?: string }>();
|
|
for (const entry of parsed.entries || []) {
|
|
const e = entry as { sessionId?: string; summary?: string; messageCount?: number; created?: string };
|
|
if (e.sessionId) {
|
|
entries.set(e.sessionId, e);
|
|
}
|
|
}
|
|
return { originalPath: parsed.originalPath ?? null, entries };
|
|
} catch {
|
|
return { originalPath: null, entries: new Map() };
|
|
}
|
|
}
|
|
|
|
export function getProjectName(projectDirName: string, indexData: ReturnType<typeof readSessionIndex>): string {
|
|
if (indexData.originalPath) {
|
|
return basename(indexData.originalPath);
|
|
}
|
|
return projectDirName;
|
|
}
|
|
|
|
/** Same resolution as CJS `getSessionsDir` in profile-pipeline.cjs. */
|
|
export function getScanSessionsRoot(overridePath: string | null): string | null {
|
|
const dir = overridePath || join(homedir(), '.claude', 'projects');
|
|
if (!existsSync(dir)) return null;
|
|
return dir;
|
|
}
|
|
|
|
/**
|
|
* Build the same project array as CJS `cmdScanSessions` (stdout JSON when `--json`).
|
|
*/
|
|
export function buildScanSessionsProjects(
|
|
overridePath: string | null,
|
|
options: { verbose: boolean },
|
|
): ScanSessionsProject[] {
|
|
const sessionsDir = getScanSessionsRoot(overridePath);
|
|
if (!sessionsDir) {
|
|
return [];
|
|
}
|
|
|
|
let projectDirs: string[];
|
|
try {
|
|
projectDirs = readdirSync(sessionsDir).filter((entry) => {
|
|
const fullPath = join(sessionsDir, entry);
|
|
try {
|
|
return statSync(fullPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const projects: ScanSessionsProject[] = [];
|
|
|
|
for (const dirName of projectDirs) {
|
|
const projectPath = join(sessionsDir, dirName);
|
|
const sessions = scanProjectDir(projectPath);
|
|
if (sessions.length === 0) continue;
|
|
|
|
const indexData = readSessionIndex(projectPath);
|
|
const projectName = getProjectName(dirName, indexData);
|
|
|
|
const totalSize = sessions.reduce((sum, s) => sum + s.size, 0);
|
|
const lastActive = sessions[0].modified.toISOString();
|
|
const oldest = sessions[sessions.length - 1].modified.toISOString();
|
|
const newest = sessions[0].modified.toISOString();
|
|
|
|
const project: ScanSessionsProject = {
|
|
name: projectName,
|
|
directory: dirName,
|
|
sessionCount: sessions.length,
|
|
totalSize,
|
|
totalSizeHuman: formatBytes(totalSize),
|
|
lastActive: lastActive.replace('T', ' ').substring(0, 19),
|
|
dateRange: { first: oldest, last: newest },
|
|
};
|
|
|
|
if (options.verbose) {
|
|
project.sessions = sessions.map((s) => {
|
|
const indexed = indexData.entries.get(s.sessionId);
|
|
const session: NonNullable<ScanSessionsProject['sessions']>[number] = {
|
|
sessionId: s.sessionId,
|
|
size: s.size,
|
|
sizeHuman: formatBytes(s.size),
|
|
modified: s.modified.toISOString(),
|
|
};
|
|
if (indexed) {
|
|
if (indexed.summary) session.summary = indexed.summary;
|
|
if (indexed.messageCount !== undefined) session.messageCount = indexed.messageCount;
|
|
if (indexed.created) session.created = indexed.created;
|
|
}
|
|
return session;
|
|
});
|
|
}
|
|
|
|
projects.push(project);
|
|
}
|
|
|
|
projects.sort((a, b) => b.dateRange.last.localeCompare(a.dateRange.last));
|
|
return projects;
|
|
}
|