Files
msd-core/sdk/src/query/profile-extract-messages.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

248 lines
7.8 KiB
TypeScript

/**
* `extract-messages` — parity with `get-shit-done/bin/lib/profile-pipeline.cjs` `cmdExtractMessages`.
* Writes JSONL to a temp file and returns metadata (same shape as CJS stdout JSON).
*/
import { appendFileSync, mkdtempSync, readdirSync, statSync } from 'node:fs';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { basename, join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js';
import { getScanSessionsRoot, scanProjectDir, readSessionIndex, getProjectName } from './profile-scan-sessions.js';
export type ExtractMessagesResult = {
output_file: string;
project: string;
sessions_processed: number;
sessions_skipped: number;
messages_extracted: number;
messages_truncated: number;
};
/** JSONL line shape from session exports — shared by filters and stream parser. */
export type SessionJsonlRecord = {
type?: string;
userType?: string;
isMeta?: boolean;
isSidechain?: boolean;
message?: { content?: string };
cwd?: string;
timestamp?: string | number;
};
/** Same filter as CJS `isGenuineUserMessage` in profile-pipeline.cjs. */
export function isGenuineUserMessage(record: SessionJsonlRecord): boolean {
if (record.type !== 'user') return false;
if (record.userType !== 'external') return false;
if (record.isMeta === true) return false;
if (record.isSidechain === true) return false;
const content = record.message?.content;
if (typeof content !== 'string') return false;
if (content.length === 0) return false;
if (content.startsWith('<local-command')) return false;
if (content.startsWith('<command-')) return false;
if (content.startsWith('<task-notification')) return false;
if (content.startsWith('<local-command-stdout')) return false;
return true;
}
/** Default maxLen 2000 matches CJS `truncateContent` for stream extraction. */
export function truncateContent(content: string, maxLen = 2000): string {
if (content.length <= maxLen) return content;
return content.substring(0, maxLen) + '... [truncated]';
}
/** Line-delimited JSONL reader — same behavior as CJS `streamExtractMessages`. */
export async function streamExtractMessages(
filePath: string,
filterFn: (r: SessionJsonlRecord) => boolean,
maxMessages: number,
): Promise<
Array<{
sessionId: string;
projectPath: string | null;
timestamp: string | number | null;
content: string;
}>
> {
const rl = createInterface({
input: createReadStream(filePath),
crlfDelay: Infinity,
terminal: false,
});
const messages: Array<{
sessionId: string;
projectPath: string | null;
timestamp: string | number | null;
content: string;
}> = [];
const sessionId = basename(filePath, '.jsonl');
for await (const line of rl) {
if (messages.length >= maxMessages) break;
let record: SessionJsonlRecord;
try {
record = JSON.parse(line) as SessionJsonlRecord;
} catch {
continue;
}
if (!filterFn(record)) continue;
const content = record.message?.content;
if (typeof content !== 'string') continue;
messages.push({
sessionId,
projectPath: record.cwd ?? null,
timestamp: record.timestamp ?? null,
content: truncateContent(content),
});
}
return messages;
}
/**
* Port of `cmdExtractMessages` — same JSON result as `gsd-tools extract-messages` (stdout object;
* message lines are in `output_file` JSONL, not inlined).
*/
export async function runExtractMessages(
projectArg: string,
options: { sessionId: string | null; limit: number | null },
overridePath: string | null,
): Promise<ExtractMessagesResult> {
const sessionsDir = getScanSessionsRoot(overridePath);
if (!sessionsDir) {
const searchedPath = overridePath || '~/.claude/projects';
throw new GSDError(
`No Claude Code sessions found at ${searchedPath}.${overridePath ? '' : ' Is Claude Code installed?'}`,
ErrorClassification.Validation,
);
}
let projectDirs: string[];
try {
projectDirs = readdirSync(sessionsDir).filter((entry) => {
const fullPath = join(sessionsDir, entry);
try {
return statSync(fullPath).isDirectory();
} catch {
return false;
}
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new GSDError(`Cannot read sessions directory: ${msg}`, ErrorClassification.Validation);
}
let matchedDir: string | null = null;
let matchedName: string | null = null;
for (const dirName of projectDirs) {
if (dirName === projectArg) {
matchedDir = dirName;
break;
}
}
if (!matchedDir) {
const lowerArg = projectArg.toLowerCase();
const matches = projectDirs.filter((d) => d.toLowerCase().includes(lowerArg));
if (matches.length === 1) {
matchedDir = matches[0]!;
} else if (matches.length > 1) {
const exactNameMatches: Array<{ dirName: string; name: string }> = [];
for (const dirName of matches) {
const indexData = readSessionIndex(join(sessionsDir, dirName));
const pName = getProjectName(dirName, indexData);
if (pName.toLowerCase() === lowerArg) {
exactNameMatches.push({ dirName, name: pName });
}
}
if (exactNameMatches.length === 1) {
matchedDir = exactNameMatches[0]!.dirName;
matchedName = exactNameMatches[0]!.name;
} else {
const names = matches.map((d) => {
const idx = readSessionIndex(join(sessionsDir, d));
return ` - ${getProjectName(d, idx)} (${d})`;
});
throw new GSDError(
`Multiple projects match "${projectArg}":\n${names.join('\n')}\nBe more specific.`,
ErrorClassification.Validation,
);
}
}
}
if (!matchedDir) {
const available = projectDirs.map((d) => {
const idx = readSessionIndex(join(sessionsDir, d));
return ` - ${getProjectName(d, idx)}`;
});
throw new GSDError(
`No project matching "${projectArg}". Available projects:\n${available.join('\n')}`,
ErrorClassification.Validation,
);
}
const projectPath = join(sessionsDir, matchedDir);
const indexData = readSessionIndex(projectPath);
const projectName = matchedName || getProjectName(matchedDir, indexData);
let sessions = scanProjectDir(projectPath);
if (options.sessionId) {
sessions = sessions.filter((s) => s.sessionId === options.sessionId);
if (sessions.length === 0) {
throw new GSDError(
`Session "${options.sessionId}" not found in project "${projectName}".`,
ErrorClassification.Validation,
);
}
}
if (options.limit !== null && options.limit !== undefined && options.limit > 0) {
sessions = sessions.slice(0, options.limit);
}
const tmpDir = mkdtempSync(join(tmpdir(), 'gsd-pipeline-'));
const outputPath = join(tmpDir, 'extracted-messages.jsonl');
appendFileSync(outputPath, '');
let sessionsProcessed = 0;
let sessionsSkipped = 0;
let messagesExtracted = 0;
let messagesTruncated = 0;
const batchLimit = 300;
for (let i = 0; i < sessions.length; i++) {
if (messagesExtracted >= batchLimit) break;
const session = sessions[i]!;
try {
const remaining = batchLimit - messagesExtracted;
const msgs = await streamExtractMessages(session.filePath, isGenuineUserMessage, remaining);
for (const msg of msgs) {
appendFileSync(outputPath, JSON.stringify(msg) + '\n');
messagesExtracted++;
if (msg.content.endsWith('... [truncated]')) {
messagesTruncated++;
}
}
sessionsProcessed++;
} catch {
sessionsSkipped++;
}
}
return {
output_file: outputPath,
project: projectName,
sessions_processed: sessionsProcessed,
sessions_skipped: sessionsSkipped,
messages_extracted: messagesExtracted,
messages_truncated: messagesTruncated,
};
}