Files
msd-core/sdk/src/query/profile-scan-sessions.ts
Rezolv c5b1445529 feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A) (#2341)
* 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
2026-04-20 18:09:02 -04:00

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