diff --git a/README.md b/README.md index dcec665..52e5f81 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Emitting the contract is the supported way to integrate a new system. The adapte - [Watch a run tree](#watch-a-run-tree) - [Improvement engine](#improvement-engine) - [Session index](#session-index) +- [Session bundle](#session-bundle) - [Policy-mining evidence](#policy-mining-evidence) - [Upload to the Intelligence Platform](#upload-to-the-intelligence-platform) - [Trace analysts](#trace-analysts) @@ -243,6 +244,7 @@ traces analyze --otlp spans.otlp.jsonl # analyse foreign OTLP, no ad traces analyze --otlp results/sessions --out report.md traces convert --harness claude-code --last 1 --otlp-out spans.jsonl # OTLP only traces index --all --since 24h --out session-index.json +traces bundle --harness claude-code --session --out bundle-dir # one session's durable evidence dir traces inspect session-index.json --out inspection-report.md traces evidence --harness codex --last 20 --out policy-evidence.jsonl traces evidence --harness codex-exec --session /tmp/codex.jsonl --cwd "$PWD" --out policy-evidence.jsonl @@ -450,11 +452,31 @@ The index contains: - one row per session with harness, session id, path, cwd, repo labels, and time bounds - behavior metrics: spans, LLM turns, tool calls, tool errors, tokens, models, and tools - signal summaries: stuck loops and tool error rate -- nearby context files for joins: `AGENTS.md`, `CLAUDE.md`, and `.evolve` JSONL / reflection artifacts, with markdown heading/ToC and JSONL key summaries +- nearby context files for joins: `AGENTS.md`, `CLAUDE.md`, and `.evolve` artifacts — the JSONL ledgers, `scorecard.json` / `current.json`, reflections, `handoffs/` plus the flat `handoff-*.md` convention, and `progress.md` — with markdown heading/ToC and JSONL key summaries `traces inspect` reads that index back and prints ranked improvement findings over the sessions and nearby context. It is intentionally read-only: it points to repeated-call loops, high tool-error sessions, missing repo attribution, long docs without Contents, invalid JSONL rows, and skill-run rows that cannot be joined back to a session. +## Session bundle + +`traces bundle` assembles ONE session's durable evidence directory — the input for any downstream consumer that must cite the session after the live stores rotate. +It composes commands this CLI already owns and never spends a model call. + +```bash +traces bundle --harness claude-code --session --out bundle-dir +``` + +The bundle directory: + +- `session/` — the transcript byte-for-byte, plus the sibling subagents directory when one exists +- `derived/` — `session-index.json`, the deterministic `report.md`, `evidence.jsonl`, and the OTLP span artifact +- `ledger/` — the repo's `.evolve` slices for the session window: `experiments.jsonl` and `skill-runs.jsonl` rows inside the padded span window, `current.json` / `scorecard.json` / `progress.md` copied whole, the latest flat `handoff-*.md`, and reflections dated inside the window +- `repo/git-log.txt` — commits in the session window from the session's cwd +- `manifest.json` — SHA-256 + byte count per file, provenance (session id, harness, cwd, transcript hash, session window), every absent artifact with its reason, per-slice row counts, and the bundle's known limits + +A missing transcript fails the assembly loudly. +Every optional input that is absent (no subagents, no `.evolve`, no git repo) is recorded in `manifest.absent` with the probed path — a fact, not an error. + ## Policy-mining evidence `traces` does **not** emit benchmark campaign cells. It emits normalized coding-agent session evidence that another system can mine. diff --git a/src/bundle.ts b/src/bundle.ts new file mode 100644 index 0000000..3a16748 --- /dev/null +++ b/src/bundle.ts @@ -0,0 +1,507 @@ +/** + * `traces bundle` — assemble one session's durable evidence directory. + * + * A bundle is the input contract for downstream consumers (distillers, + * report writers, auditors) that must cite a session long after the live + * stores have rotated: the raw transcript + subagent transcripts, every + * derived artifact this CLI already knows how to produce (session index, + * deterministic analyze report, policy evidence, OTLP spans), the repo's + * `.evolve` ledger sliced to the session window, the git log for that + * window, and a manifest with a SHA-256 per file so any later claim can be + * checked against the exact bytes it cites. + * + * The transcript is REQUIRED — no transcript, no bundle, loudly. Everything + * else is an optional input whose absence is a recorded fact in the + * manifest (`absent`, with the reason), never a silent gap and never an + * error: a session without subagents or without an `.evolve` ledger is a + * complete bundle of a smaller session. + */ + +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join, relative, resolve, sep } from 'node:path' +import { buildPolicyEvidenceRecord, serializePolicyEvidence } from './evidence.js' +import { runTraceInvestigation } from './improvement.js' +import { isMissingPathError } from './json.js' +import { sessionReportSource } from './report.js' +import { + buildSessionIndexFromRows, + findContextRoot, + serializeSessionIndex, +} from './session-index.js' +import { parseSession } from './session-source.js' +import type { HarnessTraceAdapter, SessionRef } from './types.js' + +export interface SessionBundleFile { + /** Bundle-relative path, `/`-separated. */ + readonly path: string + readonly bytes: number + readonly sha256: string +} + +export interface SessionBundleAbsence { + /** Bundle-relative path the artifact would have occupied. */ + readonly path: string + readonly reason: string +} + +export type SessionBundleSliceRule = + /** JSONL rows kept verbatim when their `ts` falls inside the padded session window. */ + | 'session-window-rows' + | 'copied-whole' + /** The lexically last `handoff-*.md` — names are date-prefixed, so name order is time order. */ + | 'latest-by-name' + /** Files whose name carries a `YYYY-MM-DD` date inside the session window's dates. */ + | 'session-window-dated-files' + +export interface SessionBundleLedgerSlice { + readonly path: string + /** Absolute source path the slice was read from. */ + readonly source: string + readonly rule: SessionBundleSliceRule + readonly totalRows?: number + readonly keptRows?: number + /** Rows excluded because no `ts` could be parsed — counted, never silently dropped. */ + readonly unparseableTsRows?: number +} + +export interface SessionBundleManifest { + readonly schemaVersion: 1 + readonly kind: 'traces.session_bundle' + readonly createdAt: string + readonly provenance: { + readonly sessionId: string + readonly harness: string + readonly cwd: string | null + /** Absolute path of the source transcript at assembly time. */ + readonly transcriptPath: string + readonly transcriptSha256: string + /** Root the `.evolve` ledger and git log were read under, when found. */ + readonly contextRoot: string | null + readonly tracesVersion: string + readonly sessionWindow: { + readonly firstSpanAt: string | null + readonly lastSpanAt: string | null + /** Pad applied to each side of the window when slicing ledger rows. */ + readonly padMs: number + } + } + readonly files: readonly SessionBundleFile[] + readonly absent: readonly SessionBundleAbsence[] + readonly ledgerSlices: readonly SessionBundleLedgerSlice[] + readonly knownLimits: readonly string[] +} + +export interface SessionBundleResult { + readonly directory: string + readonly manifestPath: string + readonly manifest: SessionBundleManifest +} + +export interface AssembleSessionBundleOptions { + readonly adapter: HarnessTraceAdapter + readonly ref: SessionRef + /** Bundle directory to create. Must be new or empty: one bundle, one session. */ + readonly outDir: string + readonly generatedAt?: string + readonly minLoopOccurrences?: number + readonly signal?: AbortSignal + readonly log?: (msg: string, fields?: Record) => void +} + +/** + * Ledger rows land moments AFTER the last span (handoff/reflect run at + * session close), so a bare span window would drop exactly the decision + * records the bundle exists to keep. 15 minutes admits session-close writes + * without swallowing a neighboring session's rows; the manifest records the + * value so the slice rule is inspectable, not folklore. + */ +const SESSION_WINDOW_PAD_MS = 15 * 60 * 1000 + +/** + * Honest limits of what a v1 bundle captures, carried in every manifest so a + * consumer reads the boundary from the artifact instead of discovering it. + */ +const KNOWN_LIMITS: readonly string[] = [ + "'what was decided' is prose-only: handoff, progress, and reflection files are copied as markdown with no structured decision extraction", + "'what was measured' is split between experiments.jsonl free-text fields and progress/handoff markdown tables", + 'session-to-ledger join is by timestamp/cwd inference: ledger rows are window-sliced, not linked by transcript path', + 'progress.md is copied whole; no session-dated section extraction is performed', + 'open pull-request state is not captured: the bundler makes no network calls', +] + +function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function bundlePath(...parts: string[]): string { + return parts.join('/') +} + +async function statOrNull(path: string): Promise> | null> { + try { + return await stat(path) + } catch (error) { + if (isMissingPathError(error)) return null + // ENOTDIR: a path component is a file — for probing purposes the target + // does not exist, which is not a failure worth killing the bundle over. + if ((error as NodeJS.ErrnoException).code === 'ENOTDIR') return null + throw error + } +} + +interface JsonlSlice { + readonly kept: readonly string[] + readonly totalRows: number + readonly unparseableTsRows: number +} + +/** + * Keep rows VERBATIM (raw line bytes, no re-serialization) when their `ts` + * parses inside the window. Rows without a parseable `ts` — including invalid + * JSON lines — are excluded from the slice but counted in the manifest, so + * nothing disappears without a number pointing at it. + */ +function sliceJsonlByWindow(text: string, windowStartMs: number, windowEndMs: number): JsonlSlice { + const kept: string[] = [] + let totalRows = 0 + let unparseableTsRows = 0 + for (const line of text.split('\n')) { + if (!line.trim()) continue + totalRows += 1 + let tsMs = Number.NaN + try { + const row: unknown = JSON.parse(line) + const ts = row !== null && typeof row === 'object' && !Array.isArray(row) + ? (row as { ts?: unknown }).ts + : undefined + if (typeof ts === 'string') tsMs = Date.parse(ts) + } catch { + // invalid JSON row → no timestamp → counted below + } + if (Number.isNaN(tsMs)) { + unparseableTsRows += 1 + continue + } + if (tsMs >= windowStartMs && tsMs <= windowEndMs) kept.push(line) + } + return { kept, totalRows, unparseableTsRows } +} + +interface GitLogOutcome { + readonly succeeded: boolean + readonly stdout?: string + readonly error?: string +} + +async function runGitLog(cwd: string, sinceIso: string, untilIso: string): Promise { + const { execFile } = await import('node:child_process') + const { promisify } = await import('node:util') + const run = promisify(execFile) + try { + const { stdout } = await run( + 'git', + ['-C', cwd, 'log', `--since=${sinceIso}`, `--until=${untilIso}`, '--date=iso-strict', '--pretty=format:%H %ad %an %s'], + { timeout: 5000 }, + ) + return { succeeded: true, stdout } + } catch (error) { + return { succeeded: false, error: error instanceof Error ? error.message.split('\n', 1)[0] : String(error) } + } +} + +async function walkBundleFiles(root: string): Promise { + const out: string[] = [] + const pending = [root] + while (pending.length > 0) { + const dir = pending.pop()! + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) pending.push(path) + else if (entry.isFile()) out.push(path) + } + } + return out.sort() +} + +interface LedgerContext { + readonly evolveDir: string + readonly ledgerDir: string + readonly windowStartMs: number | null + readonly windowEndMs: number | null + readonly slices: SessionBundleLedgerSlice[] + readonly absent: SessionBundleAbsence[] +} + +async function sliceLedgerJsonl(ctx: LedgerContext, name: string): Promise { + const source = join(ctx.evolveDir, name) + const s = await statOrNull(source) + if (!s?.isFile()) { + ctx.absent.push({ path: bundlePath('ledger', name), reason: `no ${name} at ${source}` }) + return + } + const text = await readFile(source, 'utf8') + const target = join(ctx.ledgerDir, name) + if (ctx.windowStartMs === null || ctx.windowEndMs === null) { + await writeFile(target, text, 'utf8') + ctx.slices.push({ path: bundlePath('ledger', name), source, rule: 'copied-whole' }) + return + } + const slice = sliceJsonlByWindow(text, ctx.windowStartMs, ctx.windowEndMs) + await writeFile(target, slice.kept.length > 0 ? `${slice.kept.join('\n')}\n` : '', 'utf8') + ctx.slices.push({ + path: bundlePath('ledger', name), + source, + rule: 'session-window-rows', + totalRows: slice.totalRows, + keptRows: slice.kept.length, + unparseableTsRows: slice.unparseableTsRows, + }) +} + +async function copyLedgerWhole(ctx: LedgerContext, name: string): Promise { + const source = join(ctx.evolveDir, name) + const s = await statOrNull(source) + if (!s?.isFile()) { + ctx.absent.push({ path: bundlePath('ledger', name), reason: `no ${name} at ${source}` }) + return + } + await cp(source, join(ctx.ledgerDir, name)) + ctx.slices.push({ path: bundlePath('ledger', name), source, rule: 'copied-whole' }) +} + +async function copyLatestHandoff(ctx: LedgerContext): Promise { + let names: string[] + try { + names = (await readdir(ctx.evolveDir, { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^handoff-.*\.md$/.test(entry.name)) + .map((entry) => entry.name) + .sort() + } catch { + names = [] + } + const latest = names[names.length - 1] + if (!latest) { + ctx.absent.push({ + path: bundlePath('ledger', 'handoff-*.md'), + reason: `no flat handoff-*.md files in ${ctx.evolveDir}`, + }) + return + } + const source = join(ctx.evolveDir, latest) + await cp(source, join(ctx.ledgerDir, latest)) + ctx.slices.push({ path: bundlePath('ledger', latest), source, rule: 'latest-by-name' }) +} + +async function copyWindowReflections(ctx: LedgerContext): Promise { + const reflectionsDir = join(ctx.evolveDir, 'reflections') + const s = await statOrNull(reflectionsDir) + if (!s?.isDirectory()) { + ctx.absent.push({ + path: bundlePath('ledger', 'reflections'), + reason: `no reflections directory at ${reflectionsDir}`, + }) + return + } + const names = (await readdir(reflectionsDir, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => entry.name) + .sort() + const windowed = ctx.windowStartMs === null || ctx.windowEndMs === null + ? names + : names.filter((name) => { + const date = name.match(/(\d{4}-\d{2}-\d{2})/)?.[1] + if (!date) return false + // Date-only comparison: a reflection is dated to a day, not an instant. + const startDate = new Date(ctx.windowStartMs!).toISOString().slice(0, 10) + const endDate = new Date(ctx.windowEndMs!).toISOString().slice(0, 10) + return date >= startDate && date <= endDate + }) + if (windowed.length === 0) { + ctx.absent.push({ + path: bundlePath('ledger', 'reflections'), + reason: `no reflections dated inside the session window in ${reflectionsDir}`, + }) + return + } + await mkdir(join(ctx.ledgerDir, 'reflections'), { recursive: true }) + const rule: SessionBundleSliceRule = ctx.windowStartMs === null ? 'copied-whole' : 'session-window-dated-files' + for (const name of windowed) { + const source = join(reflectionsDir, name) + await cp(source, join(ctx.ledgerDir, 'reflections', name)) + ctx.slices.push({ path: bundlePath('ledger', 'reflections', name), source, rule }) + } +} + +/** + * Assemble one session's bundle directory. Throws when the transcript is + * missing or unparseable — a bundle without its primary source is not a + * degraded bundle, it is not a bundle. Optional inputs (subagents directory, + * `.evolve` ledger, git history) that are absent are recorded in + * `manifest.absent` with the probed path and reason. + */ +export async function assembleSessionBundle(opts: AssembleSessionBundleOptions): Promise { + const { adapter, ref } = opts + const generatedAt = opts.generatedAt ?? new Date().toISOString() + + let transcriptBytes: Buffer + try { + transcriptBytes = await readFile(ref.path) + } catch (error) { + if (isMissingPathError(error)) { + throw new Error( + `session transcript not found at ${ref.path} — a bundle cannot be assembled without its transcript`, + ) + } + throw error + } + const transcriptSha256 = sha256Hex(transcriptBytes) + + // Parse BEFORE creating the output directory: an unparseable session must + // fail without leaving a half-written bundle behind. + const spans = await parseSession(adapter, ref, { signal: opts.signal }) + + const outDir = resolve(opts.outDir) + await mkdir(outDir, { recursive: true }) + if ((await readdir(outDir)).length > 0) { + throw new Error( + `bundle output directory ${outDir} is not empty — pass a new directory so one bundle holds exactly one session`, + ) + } + + const absent: SessionBundleAbsence[] = [] + const slices: SessionBundleLedgerSlice[] = [] + + // session/ — the raw sources, byte-for-byte. + await mkdir(join(outDir, 'session'), { recursive: true }) + await writeFile(join(outDir, 'session', 'transcript.jsonl'), transcriptBytes) + const subagentsSource = join(ref.path.replace(/\.jsonl$/, ''), 'subagents') + if ((await statOrNull(subagentsSource))?.isDirectory()) { + await cp(subagentsSource, join(outDir, 'session', 'subagents'), { recursive: true }) + } else { + absent.push({ path: bundlePath('session', 'subagents'), reason: `no subagents directory at ${subagentsSource}` }) + } + + // derived/ — every derivation this CLI already owns, deterministic only: + // a bundle assembly must never spend a model call. + await mkdir(join(outDir, 'derived'), { recursive: true }) + const otlpPath = join(outDir, 'derived', 'trace.otlp.jsonl') + const investigation = await runTraceInvestigation({ + spans, + harness: ref.harness, + sources: [sessionReportSource(ref, spans)], + cwds: ref.cwd ? [ref.cwd] : [], + minLoopOccurrences: opts.minLoopOccurrences, + otlpOutPath: otlpPath, + generatedAt, + signal: opts.signal, + log: opts.log, + }) + await writeFile(join(outDir, 'derived', 'report.md'), investigation.report, 'utf8') + + const evidence = await buildPolicyEvidenceRecord(ref, spans, { + generatedAt, + minLoopOccurrences: opts.minLoopOccurrences, + // Bundle-relative pointer: the bundle must stay internally valid when moved. + otlpPath: bundlePath('derived', 'trace.otlp.jsonl'), + sourceSha256: transcriptSha256, + }) + await writeFile(join(outDir, 'derived', 'evidence.jsonl'), serializePolicyEvidence([evidence]), 'utf8') + + const index = await buildSessionIndexFromRows([{ ref, spans }], { + generatedAt, + minLoopOccurrences: opts.minLoopOccurrences, + selection: { command: 'bundle', harness: ref.harness, session: ref.sessionId }, + }) + await writeFile(join(outDir, 'derived', 'session-index.json'), serializeSessionIndex(index), 'utf8') + + const { firstSpanAt, lastSpanAt } = evidence.metrics + const windowStartMs = firstSpanAt !== null ? Date.parse(firstSpanAt) - SESSION_WINDOW_PAD_MS : null + const windowEndMs = lastSpanAt !== null ? Date.parse(lastSpanAt) + SESSION_WINDOW_PAD_MS : null + + // ledger/ — the .evolve slices for this session's window. + const contextRoot = await findContextRoot(ref.cwd) + if (!contextRoot) { + absent.push({ path: 'ledger', reason: `no context root found from session cwd ${ref.cwd ?? '(unknown)'}` }) + } else { + const evolveDir = join(contextRoot, '.evolve') + if (!(await statOrNull(evolveDir))?.isDirectory()) { + absent.push({ path: 'ledger', reason: `no .evolve ledger at ${evolveDir}` }) + } else { + const ledgerDir = join(outDir, 'ledger') + await mkdir(ledgerDir, { recursive: true }) + const ctx: LedgerContext = { evolveDir, ledgerDir, windowStartMs, windowEndMs, slices, absent } + await sliceLedgerJsonl(ctx, 'experiments.jsonl') + await sliceLedgerJsonl(ctx, 'skill-runs.jsonl') + await copyLedgerWhole(ctx, 'current.json') + await copyLedgerWhole(ctx, 'scorecard.json') + await copyLedgerWhole(ctx, 'progress.md') + await copyLatestHandoff(ctx) + await copyWindowReflections(ctx) + } + } + + // repo/ — commit history for the session window, from the resolved cwd. + const gitLogPath = bundlePath('repo', 'git-log.txt') + if (!ref.cwd) { + absent.push({ path: gitLogPath, reason: 'session has no recorded cwd to read git history from' }) + } else if (windowStartMs === null || windowEndMs === null) { + absent.push({ path: gitLogPath, reason: 'session window unavailable (spans carry no timestamps)' }) + } else { + const log = await runGitLog(ref.cwd, new Date(windowStartMs).toISOString(), new Date(windowEndMs).toISOString()) + if (log.succeeded) { + await mkdir(join(outDir, 'repo'), { recursive: true }) + // An empty log is data: the session touched a repo with no commits in + // the window. The file exists so a consumer reads "0 commits", not "unknown". + await writeFile(join(outDir, 'repo', 'git-log.txt'), log.stdout ? `${log.stdout}\n` : '', 'utf8') + } else { + absent.push({ path: gitLogPath, reason: `git log failed in ${ref.cwd}: ${log.error}` }) + } + } + + // manifest.json — sha256 per file, written LAST so it covers every byte. + const files: SessionBundleFile[] = [] + for (const path of await walkBundleFiles(outDir)) { + const bytes = await readFile(path) + files.push({ + path: relative(outDir, path).split(sep).join('/'), + bytes: bytes.length, + sha256: sha256Hex(bytes), + }) + } + const manifest: SessionBundleManifest = { + schemaVersion: 1, + kind: 'traces.session_bundle', + createdAt: generatedAt, + provenance: { + sessionId: ref.sessionId, + harness: ref.harness, + cwd: ref.cwd, + transcriptPath: resolve(ref.path), + transcriptSha256, + contextRoot, + tracesVersion: tracesPackageVersion(), + sessionWindow: { firstSpanAt, lastSpanAt, padMs: SESSION_WINDOW_PAD_MS }, + }, + files, + absent, + ledgerSlices: slices, + knownLimits: KNOWN_LIMITS, + } + const manifestPath = join(outDir, 'manifest.json') + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + return { directory: outDir, manifestPath, manifest } +} + +let cachedVersion: string | undefined + +function tracesPackageVersion(): string { + if (cachedVersion) return cachedVersion + const pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ) as { version?: unknown } + if (typeof pkg.version !== 'string' || !pkg.version) throw new Error('package.json is missing version') + cachedVersion = pkg.version + return cachedVersion +} diff --git a/src/cli.ts b/src/cli.ts index c11a302..108b57f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ * traces improve [input.jsonl] [--format auto] --dir .traces/improvement * traces convert [--harness claude-code] [--last 1] --otlp-out spans.jsonl * traces index [--harness claude-code] [--last 20] --out session-index.json + * traces bundle --harness claude-code --session --out * traces inspect session-index.json [--out inspection-report.md] * traces export --out spans.openinference.jsonl * traces import-codetracebench --trajectory-dir --out --revision <40-or-64-character-hex> @@ -41,9 +42,8 @@ import { readFileSync } from 'node:fs' import { readdir, readFile, stat, writeFile } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' -import { ACTOR_ATTR } from './adapters/conversation.js' import { appendAll } from './arrays.js' -import { ATTR, indexSessionIdsByTrace, sessionIdFromAttributes } from './attributes.js' +import { indexSessionIdsByTrace } from './attributes.js' import { importCodeTraceBench } from './codetracebench.js' import { buildPolicyEvidenceRecord, serializePolicyEvidence, writePolicyEvidenceFile } from './evidence.js' import { cmdReplayVerifyBatch } from './replay-batch.js' @@ -103,16 +103,14 @@ import type { TraceValidation } from '@tangle-network/agent-trace-contract' import { watchSessions } from './observer.js' import { knownHarnesses, resolveAdapter, selectAdapters } from './registry.js' import { locateSessions, parseSession } from './session-source.js' -import { - describeSessionRelationship, -} from './session-relationship.js' import { collectSessionSelection, type SessionSelection } from './session-selection.js' import { type SessionWorkflowIssue, type SessionWorkflowSummary, } from './session-workflow.js' +import { assembleSessionBundle } from './bundle.js' import { buildSessionIndexFromRows, serializeSessionIndex, writeSessionIndexFile } from './session-index.js' -import { CORRUPTION_RECEIPT_DISPLAY_LIMIT } from './report.js' +import { sessionReportSource } from './report.js' import type { ReportSource } from './report.js' import { parseSince } from './time.js' import type { HarnessTraceAdapter, SessionRef } from './types.js' @@ -324,6 +322,7 @@ const CURRENT_SESSION_COMMANDS = new Set([ 'convert', 'index', 'evidence', + 'bundle', 'stream', ]) @@ -569,54 +568,6 @@ interface CollectedSpans { conformanceUnreadable?: UnreadableSourceRows } -function selectedSessionSource( - ref: SessionRef, - spans: readonly OtlpSpan[], - sessionIdOverride?: string, -): SelectedSessionSource { - const root = spans.find((item) => item.parent_span_id === null) ?? spans[0] - const prompt = spans.find( - (item) => item.name === 'user.prompt' && item.attributes[ACTOR_ATTR] === 'human', - ) ?? spans.find((item) => item.name === 'user.prompt') ?? spans.find( - (item) => item.attributes['span.type'] === 'interaction' && typeof item.attributes.content === 'string', - ) - const content = typeof prompt?.attributes.content === 'string' ? prompt.attributes.content : '' - const firstLine = content.split(/\r?\n/, 1)[0]!.trim() - const subject = firstLine.length > 240 - ? `${firstLine.slice(0, 240)}… [+${firstLine.length - 240} chars]` - : firstLine - const role = root?.attributes['traces.session.role'] - const parentSessionId = root?.attributes['traces.parent_session_id'] - const relationship = describeSessionRelationship(ref, spans) - const corruptionDigest = root?.attributes[ATTR.CORRUPTION_DIGEST] - const sessionId = [ - sessionIdOverride, - root ? sessionIdFromAttributes(root.attributes) : undefined, - root?.trace_id, - ref.sessionId, - ].find((value): value is string => typeof value === 'string' && value.length > 0)! - return { - sessionId, - path: ref.path, - subject, - role: role === 'operator' || role === 'child' ? role : 'unknown', - ...(typeof parentSessionId === 'string' ? { parentSessionId } : {}), - childSessionIds: relationship.childSessionIds, - ...(relationship.depth !== undefined ? { depth: relationship.depth } : {}), - ...(relationship.agentNickname ? { agentNickname: relationship.agentNickname } : {}), - ...(relationship.agentRole ? { agentRole: relationship.agentRole } : {}), - ...(relationship.agentPath ? { agentPath: relationship.agentPath } : {}), - ...(relationship.taskScope ? { taskScope: relationship.taskScope } : {}), - ...(relationship.turnId ? { turnId: relationship.turnId } : {}), - integrity: ref.integrity?.status ?? 'complete', - ...(ref.integrity ? { - corruptionCount: ref.integrity.corruptions.length, - ...(typeof corruptionDigest === 'string' ? { corruptionDigest } : {}), - corruptions: ref.integrity.corruptions.slice(0, CORRUPTION_RECEIPT_DISPLAY_LIMIT), - } : {}), - } -} - /** * Read OTLP spans a foreign system emitted, skipping the adapter stage * entirely: the analysis engine is already OTLP-native, so conforming spans @@ -644,7 +595,7 @@ async function collectOtlpSpans(path: string): Promise { spans: [...input.spans], harness: 'otlp', cwds: [], - sources: [...byTrace].map(([traceId, spans]) => selectedSessionSource({ + sources: [...byTrace].map(([traceId, spans]) => sessionReportSource({ harness: 'otlp', sessionId: traceId, path: fileByTrace.get(traceId) ?? path, @@ -675,7 +626,7 @@ async function collectSpans(args: Args): Promise { for (const row of selection.rows) { harnesses.add(row.adapter.harness) appendAll(spans, row.spans) - sources.push(selectedSessionSource(row.ref, row.spans)) + sources.push(sessionReportSource(row.ref, row.spans)) if (row.ref.cwd) cwds.push(row.ref.cwd) } return { @@ -819,6 +770,26 @@ async function cmdIndex(args: Args): Promise { } } +async function cmdBundle(args: Args): Promise { + if (!args.session) { + throw new Error('bundle needs --session ; run `traces list` to pick a session ID') + } + if (!args.out) throw new Error('bundle needs --out — a new or empty directory for the bundle') + const { adapter, ref } = await resolveSelectedSession(args) + const result = await assembleSessionBundle({ + adapter, + ref, + outDir: args.out, + minLoopOccurrences: args.minLoop, + log: analystLog, + }) + const { manifest } = result + console.log( + `session bundle → ${result.directory} (${manifest.files.length} file(s), ` + + `${manifest.ledgerSlices.length} ledger slice(s), ${manifest.absent.length} recorded absent)`, + ) +} + async function cmdInspect(args: Args): Promise { if (!args.input) throw new Error('inspect needs an index file; run `traces index --out session-index.json` first') const index = await readSessionIndexFile(args.input) @@ -1089,7 +1060,7 @@ async function collectImportedSpans(args: Args): Promise { spans: result.spans, harness: result.format, cwds: [], - sources: [...sessions].map(([sessionId, spans]) => selectedSessionSource({ + sources: [...sessions].map(([sessionId, spans]) => sessionReportSource({ harness: result.format, sessionId, path: args.input!, @@ -1564,6 +1535,10 @@ Commands: improve Write findings, evidence, report, and canonical trace artifacts convert Emit OTLP-JSONL only, to --otlp-out (HALO: use analyze --analyzer halo) index Emit a reusable session index JSON for later investigation + bundle Assemble one session's durable evidence directory: transcript + + subagents, derived index/report/evidence/OTLP, the repo's .evolve + ledger sliced to the session window, git log, and a sha256 manifest + (needs --session and --out ) inspect Read a session index and print ranked improvement findings export Convert evidence/events files to OpenInference JSONL for HALO import-codetracebench @@ -1707,6 +1682,7 @@ async function main(): Promise { case 'improve': await cmdImprove(args); break case 'convert': await cmdConvert(args); break case 'index': await cmdIndex(args); break + case 'bundle': await cmdBundle(args); break case 'inspect': await cmdInspect(args); break case 'export': await cmdExport(args); break case 'import-codetracebench': await cmdImportCodeTraceBench(args); break diff --git a/src/index.ts b/src/index.ts index 8d5af3d..3f9e1aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,6 +88,7 @@ export * from './analyze.js' // analyzeSpans({ registry? }) — run YOUR analyst export * from './execution.js' // shared execution accounting over normalized spans export * from './evidence.js' // policy-evidence JSONL for downstream miners export * from './session-index.js' // collectSessionIndex() — reusable session catalog +export * from './bundle.js' // assembleSessionBundle() — one session's durable evidence dir export * from './inspect.js' // inspectSessionIndex() — ranked findings from a session catalog export * from './file-export.js' // convert evidence/events files to OpenInference JSONL export * from './chat-trajectory.js' // generic chat trajectory to stable step spans diff --git a/src/report.ts b/src/report.ts index a07b5d5..d7b6235 100644 --- a/src/report.ts +++ b/src/report.ts @@ -13,12 +13,16 @@ import type { TokenUsageInsight, } from '@tangle-network/agent-eval/contract' import type { AdoptionReport } from './adoption.js' +import { ACTOR_ATTR } from './adapters/conversation.js' +import { ATTR, sessionIdFromAttributes } from './attributes.js' import { incompleteInputsNote, type UnavailableCapabilities } from './conformance.js' import type { LoopConvergenceReport, SteeringChainReport } from './loop-analysis.js' +import type { OtlpSpan } from './otlp.js' import type { PipelineReport } from './pipelines.js' import type { ReactionReport } from './reactions.js' +import { describeSessionRelationship } from './session-relationship.js' import type { SessionWorkflowIssue, SessionWorkflowSummary } from './session-workflow.js' -import type { SessionCorruptionReceipt } from './types.js' +import type { SessionCorruptionReceipt, SessionRef } from './types.js' const SEVERITY_RANK: Record = { critical: 0, high: 1, medium: 2, low: 3, info: 4 } export const CORRUPTION_RECEIPT_DISPLAY_LIMIT = 100 @@ -75,6 +79,60 @@ export interface ReportSource { corruptions?: readonly SessionCorruptionReceipt[] } +/** + * Build the report-source header row for one parsed session: subject line from + * the first human prompt, parent/child relationship, and any corruption + * receipts. Every span-consuming surface (CLI report, bundle) derives its + * source identity through here so the same session always reads the same. + */ +export function sessionReportSource( + ref: SessionRef, + spans: readonly OtlpSpan[], + sessionIdOverride?: string, +): ReportSource { + const root = spans.find((item) => item.parent_span_id === null) ?? spans[0] + const prompt = spans.find( + (item) => item.name === 'user.prompt' && item.attributes[ACTOR_ATTR] === 'human', + ) ?? spans.find((item) => item.name === 'user.prompt') ?? spans.find( + (item) => item.attributes['span.type'] === 'interaction' && typeof item.attributes.content === 'string', + ) + const content = typeof prompt?.attributes.content === 'string' ? prompt.attributes.content : '' + const firstLine = content.split(/\r?\n/, 1)[0]!.trim() + const subject = firstLine.length > 240 + ? `${firstLine.slice(0, 240)}… [+${firstLine.length - 240} chars]` + : firstLine + const role = root?.attributes['traces.session.role'] + const parentSessionId = root?.attributes['traces.parent_session_id'] + const relationship = describeSessionRelationship(ref, spans) + const corruptionDigest = root?.attributes[ATTR.CORRUPTION_DIGEST] + const sessionId = [ + sessionIdOverride, + root ? sessionIdFromAttributes(root.attributes) : undefined, + root?.trace_id, + ref.sessionId, + ].find((value): value is string => typeof value === 'string' && value.length > 0)! + return { + sessionId, + path: ref.path, + subject, + role: role === 'operator' || role === 'child' ? role : 'unknown', + ...(typeof parentSessionId === 'string' ? { parentSessionId } : {}), + childSessionIds: relationship.childSessionIds, + ...(relationship.depth !== undefined ? { depth: relationship.depth } : {}), + ...(relationship.agentNickname ? { agentNickname: relationship.agentNickname } : {}), + ...(relationship.agentRole ? { agentRole: relationship.agentRole } : {}), + ...(relationship.agentPath ? { agentPath: relationship.agentPath } : {}), + ...(relationship.taskScope ? { taskScope: relationship.taskScope } : {}), + ...(relationship.turnId ? { turnId: relationship.turnId } : {}), + integrity: ref.integrity?.status ?? 'complete', + ...(ref.integrity ? { + corruptionCount: ref.integrity.corruptions.length, + ...(typeof corruptionDigest === 'string' ? { corruptionDigest } : {}), + corruptions: ref.integrity.corruptions.slice(0, CORRUPTION_RECEIPT_DISPLAY_LIMIT), + } : {}), + } +} + export interface DeterministicSummary { stuckLoops: number reactionSignals: number diff --git a/src/session-index.ts b/src/session-index.ts index 71dac2d..d427770 100644 --- a/src/session-index.ts +++ b/src/session-index.ts @@ -174,7 +174,14 @@ async function dirExists(path: string): Promise { return Boolean((await pathStat(path))?.isDirectory()) } -async function findContextRoot(cwd: string | null | undefined): Promise { +/** + * Walk up from a session cwd to the root the session's local context lives + * under: the first ancestor carrying `.git`, `.evolve`, `AGENTS.md`, or + * `CLAUDE.md`. The context index and the session bundler both anchor their + * `.evolve` reads here so "which ledger belongs to this session" has exactly + * one answer. + */ +export async function findContextRoot(cwd: string | null | undefined): Promise { if (!cwd) return null let current = cwd const s = await pathStat(current) @@ -286,16 +293,36 @@ async function collectContextRoot(root: string): Promise { const summary = await summarizeFile(join(evolve, name), 'evolve-jsonl') if (summary) files.push(summary) } - for (const name of ['scorecard.json']) { + for (const name of ['scorecard.json', 'current.json']) { const summary = await summarizeFile(join(evolve, name), 'evolve-json') if (summary) files.push(summary) } files.push(...(await walkMarkdown(join(evolve, 'reflections'), 'reflection'))) files.push(...(await walkMarkdown(join(evolve, 'handoffs'), 'handoff'))) + // Live convention: handoffs are written FLAT as `.evolve/handoff-*.md`, with + // `progress.md` beside them — a handoffs/ subdirectory walk alone leaves the + // current decision records invisible to the index. + for (const name of await listEvolveRootFiles(evolve)) { + if (/^handoff-.*\.md$/.test(name)) { + const summary = await summarizeFile(join(evolve, name), 'handoff') + if (summary) files.push(summary) + } + } + const progress = await summarizeFile(join(evolve, 'progress.md'), 'other') + if (progress) files.push(progress) return { root, files: files.sort((a, b) => a.path.localeCompare(b.path)) } } +async function listEvolveRootFiles(evolve: string): Promise { + try { + const entries = await readdir(evolve, { withFileTypes: true }) + return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort() + } catch { + return [] + } +} + async function collectContextIndex(records: readonly PolicyEvidenceRecord[]): Promise { const rootSet = new Set() for (const record of records) { diff --git a/tests/bundle.test.ts b/tests/bundle.test.ts new file mode 100644 index 0000000..ab59f60 --- /dev/null +++ b/tests/bundle.test.ts @@ -0,0 +1,275 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { ClaudeAdapter } from '../src/adapters/claude.js' +import { assembleSessionBundle, type SessionBundleManifest } from '../src/bundle.js' +import type { SessionRef } from '../src/types.js' + +const dir = mkdtempSync(join(tmpdir(), 'traces-bundle-')) +afterAll(() => rmSync(dir, { recursive: true, force: true })) + +let outSeq = 0 +function newOutDir(): string { + outSeq += 1 + return join(dir, `out-${outSeq}`) +} + +function refFor(path: string, cwd: string | null = null): SessionRef { + return { + harness: 'claude-code', + sessionId: 'bundle-fixture', + path, + cwd, + mtimeMs: Date.parse('2026-01-01T00:00:05Z'), + } +} + +/** Root transcript with one Agent tool call — the shape the Claude store writes. */ +function writeTranscript(path: string): void { + writeFileSync( + path, + [ + { + type: 'user', + uuid: 'root-user', + sessionId: 'bundle-fixture', + timestamp: '2026-01-01T00:00:00Z', + message: { role: 'user', content: 'BUNDLE ROOT TASK' }, + }, + { + type: 'assistant', + uuid: 'root-assistant', + sessionId: 'bundle-fixture', + timestamp: '2026-01-01T00:00:01Z', + message: { + id: 'root-message', + role: 'assistant', + content: [{ type: 'tool_use', id: 'call-one', name: 'Agent', input: {} }], + }, + }, + ].map((event) => JSON.stringify(event)).join('\n'), + ) +} + +function writeChild(subDir: string, id: string, toolUseId: string, timestamp: string): void { + mkdirSync(subDir, { recursive: true }) + writeFileSync( + join(subDir, `agent-${id}.jsonl`), + [ + { + type: 'user', + uuid: `${id}-user`, + timestamp, + isSidechain: true, + message: { role: 'user', content: `${id} TASK` }, + }, + { + type: 'assistant', + uuid: `${id}-assistant`, + timestamp: new Date(Date.parse(timestamp) + 1_000).toISOString(), + message: { id: `${id}-message`, role: 'assistant', content: `${id} ANSWER` }, + }, + ].map((event) => JSON.stringify(event)).join('\n'), + ) + writeFileSync( + join(subDir, `agent-${id}.meta.json`), + JSON.stringify({ agentType: 'worker', toolUseId }), + ) +} + +/** A context root carrying the live `.evolve` convention: flat handoffs, progress.md, jsonl ledgers. */ +async function writeEvolveFixture(root: string): Promise { + const evolve = join(root, '.evolve') + await mkdir(join(evolve, 'reflections'), { recursive: true }) + await writeFile( + join(evolve, 'experiments.jsonl'), + [ + JSON.stringify({ ts: '2026-01-01T00:00:03Z', round: 1, verdict: 'KEEP' }), + JSON.stringify({ ts: '2026-03-01T00:00:00Z', round: 2, verdict: 'DROP' }), + JSON.stringify({ note: 'row without ts' }), + 'not-json', + ].join('\n'), + 'utf8', + ) + await writeFile( + join(evolve, 'skill-runs.jsonl'), + `${JSON.stringify({ skill: '/verify', ts: '2026-01-01T00:00:04Z' })}\n`, + 'utf8', + ) + await writeFile(join(evolve, 'current.json'), '{"focus":"bundle"}\n', 'utf8') + await writeFile(join(evolve, 'scorecard.json'), '{"score":1}\n', 'utf8') + await writeFile(join(evolve, 'progress.md'), '# Progress\n\n2026-01-01: bundled.\n', 'utf8') + await writeFile(join(evolve, 'handoff-2026-01-01-first.md'), '# Handoff first\n', 'utf8') + await writeFile(join(evolve, 'handoff-2026-01-02-latest.md'), '# Handoff latest\n', 'utf8') + await writeFile(join(evolve, 'reflections', '2026-01-01.md'), '# In-window reflection\n', 'utf8') + await writeFile(join(evolve, 'reflections', '2026-03-05.md'), '# Out-of-window reflection\n', 'utf8') + await writeFile(join(evolve, 'reflections', 'notes.md'), '# Undated notes\n', 'utf8') +} + +function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function fileEntry(manifest: SessionBundleManifest, path: string) { + return manifest.files.find((file) => file.path === path) +} + +describe('assembleSessionBundle', () => { + it('assembles transcript, subagents, derived artifacts, ledger slices, and a sha256 manifest', async () => { + const ctxRoot = join(dir, 'ctx-full') + await mkdir(ctxRoot, { recursive: true }) + await writeEvolveFixture(ctxRoot) + const transcript = join(dir, 'full-session.jsonl') + writeTranscript(transcript) + writeChild(join(dir, 'full-session', 'subagents'), 'worker-a', 'call-one', '2026-01-01T00:00:02Z') + + const outDir = newOutDir() + const { manifest, manifestPath, directory } = await assembleSessionBundle({ + adapter: new ClaudeAdapter(), + ref: refFor(transcript, ctxRoot), + outDir, + generatedAt: '2026-01-01T01:00:00.000Z', + }) + + expect(directory).toBe(outDir) + expect(manifest.kind).toBe('traces.session_bundle') + expect(manifest.createdAt).toBe('2026-01-01T01:00:00.000Z') + expect(manifest.provenance.sessionId).toBe('bundle-fixture') + expect(manifest.provenance.harness).toBe('claude-code') + expect(manifest.provenance.cwd).toBe(ctxRoot) + expect(manifest.provenance.contextRoot).toBe(ctxRoot) + expect(manifest.provenance.transcriptPath).toBe(transcript) + expect(manifest.provenance.sessionWindow.firstSpanAt).toBe('2026-01-01T00:00:00Z') + + // The transcript is copied byte-for-byte and its hash is the provenance anchor. + const copied = await readFile(join(outDir, 'session', 'transcript.jsonl')) + expect(sha256Hex(copied)).toBe(manifest.provenance.transcriptSha256) + expect(copied.equals(await readFile(transcript))).toBe(true) + + // The subagents directory is copied verbatim, meta files included. + expect(fileEntry(manifest, 'session/subagents/agent-worker-a.jsonl')).toBeDefined() + expect(fileEntry(manifest, 'session/subagents/agent-worker-a.meta.json')).toBeDefined() + + // Derived artifacts exist and parse. + const index = JSON.parse(await readFile(join(outDir, 'derived', 'session-index.json'), 'utf8')) + expect(index.kind).toBe('traces.session_index') + expect(index.totals.sessions).toBe(1) + // The index context join sees the flat handoffs and progress.md of the live convention. + const contextPaths = index.context.roots[0].files.map((file: { path: string }) => file.path) + expect(contextPaths.some((p: string) => p.endsWith('handoff-2026-01-02-latest.md'))).toBe(true) + expect(contextPaths.some((p: string) => p.endsWith('progress.md'))).toBe(true) + expect(contextPaths.some((p: string) => p.endsWith('current.json'))).toBe(true) + + const evidenceRows = (await readFile(join(outDir, 'derived', 'evidence.jsonl'), 'utf8')) + .trim().split('\n').map((line) => JSON.parse(line)) + expect(evidenceRows).toHaveLength(1) + expect(evidenceRows[0].kind).toBe('traces.policy_evidence.session') + expect(evidenceRows[0].provenance.sourceSha256).toBe(manifest.provenance.transcriptSha256) + expect(evidenceRows[0].provenance.otlpPath).toBe('derived/trace.otlp.jsonl') + + const report = await readFile(join(outDir, 'derived', 'report.md'), 'utf8') + expect(report.length).toBeGreaterThan(0) + expect(report).toContain('BUNDLE ROOT TASK') + + const otlpLines = (await readFile(join(outDir, 'derived', 'trace.otlp.jsonl'), 'utf8')).trim().split('\n') + expect(otlpLines.length).toBeGreaterThan(0) + for (const line of otlpLines) expect(() => JSON.parse(line)).not.toThrow() + + // Ledger jsonl slices keep only session-window rows, verbatim, with full counts. + const experiments = manifest.ledgerSlices.find((slice) => slice.path === 'ledger/experiments.jsonl') + expect(experiments).toMatchObject({ + rule: 'session-window-rows', + totalRows: 4, + keptRows: 1, + unparseableTsRows: 2, + }) + const keptRows = (await readFile(join(outDir, 'ledger', 'experiments.jsonl'), 'utf8')).trim().split('\n') + expect(keptRows).toEqual([JSON.stringify({ ts: '2026-01-01T00:00:03Z', round: 1, verdict: 'KEEP' })]) + expect(manifest.ledgerSlices.find((slice) => slice.path === 'ledger/skill-runs.jsonl')).toMatchObject({ + rule: 'session-window-rows', + totalRows: 1, + keptRows: 1, + unparseableTsRows: 0, + }) + + // Whole-copies, the lexically-latest flat handoff, and only the in-window reflection. + for (const name of ['current.json', 'scorecard.json', 'progress.md']) { + expect(manifest.ledgerSlices.find((slice) => slice.path === `ledger/${name}`)).toMatchObject({ rule: 'copied-whole' }) + } + expect(fileEntry(manifest, 'ledger/handoff-2026-01-02-latest.md')).toBeDefined() + expect(fileEntry(manifest, 'ledger/handoff-2026-01-01-first.md')).toBeUndefined() + expect(fileEntry(manifest, 'ledger/reflections/2026-01-01.md')).toBeDefined() + expect(fileEntry(manifest, 'ledger/reflections/2026-03-05.md')).toBeUndefined() + expect(fileEntry(manifest, 'ledger/reflections/notes.md')).toBeUndefined() + + // No git repo at the context root → git log is a recorded absence, not an error. + expect(manifest.absent.some((entry) => entry.path === 'repo/git-log.txt')).toBe(true) + + // Every file on disk (manifest.json aside) is hashed in the manifest, and the hashes are real. + const manifestNames = new Set(manifest.files.map((file) => file.path)) + const walk = async (sub: string): Promise => { + const entries = await readdir(join(outDir, sub), { withFileTypes: true }) + const out: string[] = [] + for (const entry of entries) { + const rel = sub ? `${sub}/${entry.name}` : entry.name + if (entry.isDirectory()) out.push(...(await walk(rel))) + else out.push(rel) + } + return out + } + for (const rel of await walk('')) { + if (rel === 'manifest.json') continue + expect(manifestNames.has(rel)).toBe(true) + } + const experimentsEntry = fileEntry(manifest, 'ledger/experiments.jsonl')! + const experimentsBytes = await readFile(join(outDir, 'ledger', 'experiments.jsonl')) + expect(experimentsEntry.sha256).toBe(sha256Hex(experimentsBytes)) + expect(experimentsEntry.bytes).toBe(experimentsBytes.length) + + expect(manifest.knownLimits.length).toBeGreaterThan(0) + expect(JSON.parse(await readFile(manifestPath, 'utf8')).kind).toBe('traces.session_bundle') + }) + + it('records an absent subagents directory instead of failing', async () => { + const transcript = join(dir, 'no-subagents.jsonl') + writeTranscript(transcript) + + const { manifest } = await assembleSessionBundle({ + adapter: new ClaudeAdapter(), + ref: refFor(transcript), + outDir: newOutDir(), + }) + + const absence = manifest.absent.find((entry) => entry.path === 'session/subagents') + expect(absence).toBeDefined() + expect(absence!.reason).toContain('no subagents directory') + // No context root reachable from a null cwd → the whole ledger is one recorded absence. + expect(manifest.absent.some((entry) => entry.path === 'ledger')).toBe(true) + expect(fileEntry(manifest, 'session/transcript.jsonl')).toBeDefined() + }) + + it('fails loud when the transcript is missing', async () => { + await expect(assembleSessionBundle({ + adapter: new ClaudeAdapter(), + ref: refFor(join(dir, 'does-not-exist.jsonl')), + outDir: newOutDir(), + })).rejects.toThrow(/session transcript not found/) + }) + + it('refuses a non-empty output directory', async () => { + const transcript = join(dir, 'occupied-out.jsonl') + writeTranscript(transcript) + const outDir = newOutDir() + await mkdir(outDir, { recursive: true }) + await writeFile(join(outDir, 'already-here.txt'), 'x', 'utf8') + + await expect(assembleSessionBundle({ + adapter: new ClaudeAdapter(), + ref: refFor(transcript), + outDir, + })).rejects.toThrow(/is not empty/) + }) +}) diff --git a/tests/session-index.test.ts b/tests/session-index.test.ts index a3dc4dd..7b41256 100644 --- a/tests/session-index.test.ts +++ b/tests/session-index.test.ts @@ -122,6 +122,10 @@ describe('session index', () => { await writeFile(join(root, '.evolve', 'skill-runs.jsonl'), '{"skill":"/evolve","verdict":"pass"}\n{"skill":"/verify"}\nnot-json\n', 'utf8') await writeFile(join(root, '.evolve', 'governor.jsonl'), '{"next":"verify"}\n', 'utf8') await writeFile(join(root, '.evolve', 'reflections', 'r.md'), '# Reflection\n\nNext: verify.\n', 'utf8') + // The live convention: flat handoffs + progress.md + current.json at the .evolve root. + await writeFile(join(root, '.evolve', 'handoff-2026-01-01-close.md'), '# Handoff\n\nResume: tests.\n', 'utf8') + await writeFile(join(root, '.evolve', 'progress.md'), '# Progress\n\nDone: index.\n', 'utf8') + await writeFile(join(root, '.evolve', 'current.json'), '{"focus":"index"}\n', 'utf8') const adapter: HarnessTraceAdapter = { harness: 'synthetic', @@ -140,15 +144,22 @@ describe('session index', () => { expect(index.context?.totals.roots).toBe(1) expect(index.context?.totals.instructionDocs).toBe(1) - expect(index.context?.totals.evolveFiles).toBe(2) + expect(index.context?.totals.evolveFiles).toBe(3) expect(index.context?.totals.jsonlRows).toBe(4) expect(index.context?.totals.invalidJsonlRows).toBe(1) expect(index.context?.roots[0]?.files.map((file) => file.kind).sort()).toEqual([ + 'evolve-json', 'evolve-jsonl', 'evolve-jsonl', + 'handoff', 'instruction-doc', + 'other', 'reflection', ]) + const handoff = index.context?.roots[0]?.files.find((file) => file.kind === 'handoff') + expect(handoff?.path.endsWith('handoff-2026-01-01-close.md')).toBe(true) + const progress = index.context?.roots[0]?.files.find((file) => file.kind === 'other') + expect(progress?.path.endsWith('progress.md')).toBe(true) const agents = index.context?.roots[0]?.files.find((file) => file.path.endsWith('AGENTS.md')) expect(agents?.markdown).toEqual({ headings: 3, hasToc: true }) const skillRuns = index.context?.roots[0]?.files.find((file) => file.path.endsWith('skill-runs.jsonl'))