From c0709c721c3bd8d89411c701ced357d582214efa Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 7 Aug 2026 22:08:34 -0600 Subject: [PATCH 1/2] fix(index): see the flat .evolve handoff-*.md, progress.md, and current.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context index only walked .evolve/handoffs/ and .evolve/reflections/ subdirectories, while the live convention writes handoffs FLAT as .evolve/handoff-*.md with progress.md and current.json beside them — so the decision records a session actually produced were invisible to every join built on the index. Index them with their own kinds and export findContextRoot so other consumers anchor .evolve reads at the same root. --- src/session-index.ts | 31 +++++++++++++++++++++++++++++-- tests/session-index.test.ts | 13 ++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) 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/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')) From d0a2c0f27d77fde5b7313b6a13148054da776e60 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 7 Aug 2026 22:08:47 -0600 Subject: [PATCH 2/2] feat(bundle): traces bundle assembles one session's durable evidence directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `traces bundle --harness --session --out ` composes the commands this CLI already owns into one relocatable, citable directory: the transcript byte-for-byte plus the sibling subagents directory, derived session-index/report/evidence/OTLP (deterministic — a bundle never spends a model call), the repo's .evolve ledger sliced to the padded session window, git log for the window, and a manifest with sha256 + bytes per file, provenance (session id, harness, cwd, transcript hash, window), and the bundle's known limits. A missing transcript fails the assembly loudly. Every optional input that is absent — subagents, .evolve, git history — is recorded in manifest.absent with the probed path and reason, never silently skipped. Ledger JSONL rows are kept verbatim when their ts falls in the window; rows without a parseable ts are excluded but counted in the manifest. sessionReportSource moves from the CLI into report.ts so the bundle and the CLI derive the same source identity for a session. --- README.md | 24 +- src/bundle.ts | 507 +++++++++++++++++++++++++++++++++++++++++++ src/cli.ts | 90 +++----- src/index.ts | 1 + src/report.ts | 60 ++++- tests/bundle.test.ts | 275 +++++++++++++++++++++++ 6 files changed, 898 insertions(+), 59 deletions(-) create mode 100644 src/bundle.ts create mode 100644 tests/bundle.test.ts 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/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/) + }) +})