diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 45721e8..ccbe63c 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -14,6 +14,7 @@ import { runOnboarding } from './onboarding.js'; import { helpText, parseArgs } from './parse-args.js'; import { startRepl } from './repl.js'; import { runContractCommand } from './contract-cmd.js'; +import { runLedgerCommand } from './ledger-cmd.js'; import { runCronCommand, runSchedulerRun } from './scheduler.js'; import { runTrustCommand } from './trust-cmd.js'; import { TrustStore } from './trust.js'; @@ -118,6 +119,14 @@ async function main(): Promise { errOutput: process.stderr, }); } + if (args.positional[0] === 'ledger') { + return runLedgerCommand(args.positional.slice(1), { + cwd: process.cwd(), + output: process.stdout, + errOutput: process.stderr, + json: args.json, + }); + } if (args.positional[0] === 'trust') { return runTrustCommand(args.positional.slice(1), { cwd: process.cwd(), diff --git a/apps/cli/src/completion.ts b/apps/cli/src/completion.ts index fe5854e..4b1226f 100644 --- a/apps/cli/src/completion.ts +++ b/apps/cli/src/completion.ts @@ -59,6 +59,7 @@ const SUBCOMMANDS = [ 'skills', 'cron', 'contract', + 'ledger', 'scheduler', 'setup-token', 'completion', diff --git a/apps/cli/src/ledger-cmd.test.ts b/apps/cli/src/ledger-cmd.test.ts new file mode 100644 index 0000000..315f8ec --- /dev/null +++ b/apps/cli/src/ledger-cmd.test.ts @@ -0,0 +1,130 @@ +import { FileLedger } from '@deepcode/core'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { runLedgerCommand } from './ledger-cmd.js'; + +function capture(): { stream: PassThrough; text: () => string } { + const stream = new PassThrough(); + let buf = ''; + stream.on('data', (c: Buffer) => { + buf += c.toString('utf8'); + }); + return { stream, text: () => buf }; +} + +describe('deepcode ledger', () => { + let cwd: string; + let home: string; + let ledger: FileLedger; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-ledger-cli-')); + home = await mkdtemp(join(tmpdir(), 'dc-ledger-cli-home-')); + ledger = new FileLedger({ cwd, home }); + }); + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + }); + + it('says so plainly when there is nothing recorded', async () => { + const out = capture(); + expect(await runLedgerCommand(['list'], { cwd, home, output: out.stream })).toBe(0); + expect(out.text()).toContain('No ledger records'); + }); + + it('lists records from both timelines, oldest first', async () => { + await ledger.append('changes', { actor: 'agent', paths: ['a.ts'], summary: 'edited a.ts' }); + await ledger.append('governance', { actor: 'user', paths: [], summary: 'granted trust' }); + const out = capture(); + await runLedgerCommand(['list'], { cwd, home, output: out.stream }); + expect(out.text()).toContain('edited a.ts'); + expect(out.text()).toContain('[gov]'); + }); + + it('filters by timeline', async () => { + await ledger.append('changes', { actor: 'agent', paths: [], summary: 'edited a.ts' }); + await ledger.append('governance', { actor: 'user', paths: [], summary: 'granted trust' }); + const out = capture(); + await runLedgerCommand(['list', '--kind', 'governance'], { cwd, home, output: out.stream }); + expect(out.text()).toContain('granted trust'); + expect(out.text()).not.toContain('edited a.ts'); + }); + + it('reports how many records the limit hid', async () => { + // A truncated list that looks complete is how people conclude the agent + // changed less than it did. + for (let i = 0; i < 5; i++) { + await ledger.append('changes', { actor: 'agent', paths: [], summary: `s${i}` }); + } + const out = capture(); + await runLedgerCommand(['list', '--limit', '2'], { cwd, home, output: out.stream }); + expect(out.text()).toContain('3 older records not shown'); + }); + + it('shows one record with its rollback handle', async () => { + const written = await ledger.append('changes', { + actor: 'agent', + tool: 'Edit', + intent: 'fix auth', + paths: ['src/auth.ts'], + summary: 'edited src/auth.ts', + rollbackHint: { kind: 'snapshot', ref: '3' }, + }); + const out = capture(); + await runLedgerCommand(['show', written!.id], { cwd, home, output: out.stream }); + const text = out.text(); + expect(text).toContain('fix auth'); + expect(text).toContain('src/auth.ts'); + expect(text).toContain('snapshot @ 3'); + }); + + it('says a record has no rollback rather than implying one', async () => { + const written = await ledger.append('changes', { + actor: 'agent', + paths: ['a.ts'], + summary: 'edited a.ts', + }); + const out = capture(); + await runLedgerCommand(['show', written!.id], { cwd, home, output: out.stream }); + expect(out.text()).toContain('none recorded'); + }); + + it('fails on an unknown id', async () => { + const err = capture(); + expect(await runLedgerCommand(['show', 'nope'], { cwd, home, errOutput: err.stream })).toBe(1); + expect(err.text()).toContain('nope'); + }); + + it('exports Markdown to stdout', async () => { + await ledger.append('changes', { actor: 'agent', paths: ['a.ts'], summary: 'edited a.ts' }); + const out = capture(); + await runLedgerCommand(['export'], { cwd, home, output: out.stream }); + expect(out.text()).toContain('# Workspace changes'); + expect(out.text()).toContain('`a.ts`'); + }); + + it('exports to a file when asked', async () => { + await ledger.append('changes', { actor: 'agent', paths: ['a.ts'], summary: 'edited a.ts' }); + const out = capture(); + await runLedgerCommand(['export', '--out', 'audit.md'], { cwd, home, output: out.stream }); + expect(await readFile(join(cwd, 'audit.md'), 'utf8')).toContain('edited a.ts'); + }); + + it('emits JSON when asked', async () => { + await ledger.append('changes', { actor: 'agent', paths: ['a.ts'], summary: 'edited a.ts' }); + const out = capture(); + await runLedgerCommand(['list'], { cwd, home, output: out.stream, json: true }); + const parsed = JSON.parse(out.text()) as Array<{ record: { summary: string } }>; + expect(parsed[0]!.record.summary).toBe('edited a.ts'); + }); + + it('rejects an unknown subcommand with usage', async () => { + const err = capture(); + expect(await runLedgerCommand(['bogus'], { cwd, home, errOutput: err.stream })).toBe(2); + expect(err.text()).toContain('Usage:'); + }); +}); diff --git a/apps/cli/src/ledger-cmd.ts b/apps/cli/src/ledger-cmd.ts new file mode 100644 index 0000000..c96fe44 --- /dev/null +++ b/apps/cli/src/ledger-cmd.ts @@ -0,0 +1,177 @@ +// `deepcode ledger [list|show|export]` — read the change ledger. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.B · Docs: docs/change-ledger.md + +import { + LEDGER_KINDS, + findLedgerRecord, + ledgerPath, + readProjectLedger, + renderLedgerMarkdown, + type LedgerKind, + type LedgerRecord, +} from '@deepcode/core'; +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import type { Writable } from 'node:stream'; + +export interface LedgerCmdDeps { + cwd: string; + home?: string; + output?: Writable; + errOutput?: Writable; + json?: boolean; +} + +export async function runLedgerCommand(args: string[], deps: LedgerCmdDeps): Promise { + const out = deps.output ?? process.stdout; + const err = deps.errOutput ?? process.stderr; + const sub = args[0] ?? 'list'; + + switch (sub) { + case 'list': + return list(args.slice(1), deps, out); + case 'show': + return show(args.slice(1), deps, out, err); + case 'export': + return exportCmd(args.slice(1), deps, out, err); + default: + err.write(`Unknown subcommand "${sub}".\n\n`); + usage(err); + return 2; + } +} + +function parseKind(args: string[], fallback: LedgerKind | 'both'): LedgerKind | 'both' { + const i = args.indexOf('--kind'); + if (i === -1) return fallback; + const value = args[i + 1]; + if (value && (LEDGER_KINDS as readonly string[]).includes(value)) return value as LedgerKind; + return fallback; +} + +function parseNumber(args: string[], flag: string, fallback: number): number { + const i = args.indexOf(flag); + if (i === -1) return fallback; + const parsed = Number(args[i + 1]); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +} + +async function list(args: string[], deps: LedgerCmdDeps, out: Writable): Promise { + const home = deps.home ?? homedir(); + const kind = parseKind(args, 'both'); + const limit = parseNumber(args, '--limit', 20); + const kinds = kind === 'both' ? LEDGER_KINDS : [kind]; + + const collected: Array<{ kind: LedgerKind; record: LedgerRecord }> = []; + for (const k of kinds) { + for (const record of await readProjectLedger(deps.cwd, k, home)) + collected.push({ kind: k, record }); + } + collected.sort((a, b) => a.record.timestamp.localeCompare(b.record.timestamp)); + const shown = collected.slice(-limit); + + if (deps.json) { + out.write(JSON.stringify(shown, null, 2) + '\n'); + return 0; + } + + if (shown.length === 0) { + out.write('No ledger records for this project yet.\n'); + return 0; + } + for (const { kind: k, record } of shown) { + const where = record.paths.length > 0 ? record.paths.join(', ') : '—'; + out.write( + `${record.id} ${record.timestamp} ${k === 'governance' ? '[gov] ' : ''}${record.summary}\n`, + ); + out.write(` paths: ${where}\n`); + if (record.intent) out.write(` intent: ${record.intent}\n`); + } + // Say what was withheld rather than letting a truncated list read as complete. + if (collected.length > shown.length) { + out.write(`\n(${collected.length - shown.length} older records not shown; use --limit)\n`); + } + return 0; +} + +async function show( + args: string[], + deps: LedgerCmdDeps, + out: Writable, + err: Writable, +): Promise { + const id = args[0]; + if (!id) { + err.write('Usage: deepcode ledger show \n'); + return 2; + } + const found = await findLedgerRecord(deps.cwd, id, deps.home ?? homedir()); + if (!found) { + err.write(`No ledger record with id "${id}".\n`); + return 1; + } + if (deps.json) { + out.write(JSON.stringify(found.record, null, 2) + '\n'); + return 0; + } + const r = found.record; + out.write(`${r.id}\n`); + out.write(` timeline : ${found.kind}\n`); + out.write(` when : ${r.timestamp}\n`); + out.write(` actor : ${r.actor}${r.tool ? ` (${r.tool})` : ''}\n`); + if (r.intent) out.write(` intent : ${r.intent}\n`); + out.write(` paths : ${r.paths.length > 0 ? r.paths.join(', ') : '—'}\n`); + out.write(` summary : ${r.summary}\n`); + if (r.rollbackHint) { + out.write(` rollback : ${r.rollbackHint.kind}`); + if (r.rollbackHint.ref) out.write(` @ ${r.rollbackHint.ref}`); + out.write('\n'); + } else { + out.write(` rollback : none recorded\n`); + } + return 0; +} + +async function exportCmd( + args: string[], + deps: LedgerCmdDeps, + out: Writable, + err: Writable, +): Promise { + const home = deps.home ?? homedir(); + const kind = parseKind(args, 'changes'); + if (kind === 'both') { + err.write('Usage: deepcode ledger export [--kind changes|governance] [--out ]\n'); + return 2; + } + const records = await readProjectLedger(deps.cwd, kind, home); + const markdown = renderLedgerMarkdown(kind, records); + + const outIdx = args.indexOf('--out'); + const target = outIdx === -1 ? undefined : args[outIdx + 1]; + if (!target) { + out.write(markdown); + return 0; + } + const abs = resolve(deps.cwd, target); + await fs.mkdir(dirname(abs), { recursive: true }); + await fs.writeFile(abs, markdown, 'utf8'); + out.write(`Wrote ${records.length} record(s) to ${abs}\n`); + return 0; +} + +function usage(out: Writable): void { + out.write( + [ + 'Usage: deepcode ledger ', + '', + ' list [--kind changes|governance] [--limit N] Recent records (default)', + ' show One record in full', + ' export [--kind K] [--out ] Markdown digest', + '', + `Stored under ${dirname(ledgerPath('', 'changes', ''))}`, + '', + ].join('\n'), + ); +} diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index 814a570..d41c488 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -324,6 +324,7 @@ USAGE deepcode setup-token [] Store a long-lived DeepSeek auth token (CI) deepcode cron Scheduled tasks: install/uninstall/list/status deepcode contract Inspect or create the path-axis file contract + deepcode ledger Audit what the agent changed deepcode scheduler run Run due scheduled jobs (invoked by launchd) deepcode mcp serve Expose DeepCode tools as an MCP server (stdio) deepcode app-server Run the experimental lifecycle server (JSONL stdio) diff --git a/docs/change-ledger.md b/docs/change-ledger.md new file mode 100644 index 0000000..9a3c99a --- /dev/null +++ b/docs/change-ledger.md @@ -0,0 +1,98 @@ +# Change ledger + +The ledger records what the agent changed, why, and how to undo it. + +```bash +deepcode ledger list # recent records +deepcode ledger show # one record in full +deepcode ledger export --out audit.md +``` + +## Why it exists separately from sessions + +Sessions already store the message stream, and snapshots already capture file +state before and after each mutation. Neither answers the question people +actually ask after a run: **what did it change, and how do I undo that one +thing?** A message log means reading a conversation to find out; snapshots are +addressable but carry no intent. + +The ledger is the index over both — it pairs each mutation with the request that +motivated it and the checkpoint that reverses it. + +## Two timelines + +| File | Contents | +| ------------------ | ------------------------------------------------------------- | +| `changes.jsonl` | Workspace mutations — `Write`, `Edit`, `NotebookEdit`, `Bash` | +| `governance.jsonl` | Contract edits, plugin installs, trust grants, rollbacks | + +Kept apart rather than as one file with a type column. Governance events are +rare and high-impact; interleaved with thousands of edits, nobody would ever see +them again. + +## Where it is stored + +``` +~/.deepcode/projects//ledger/{changes,governance}.jsonl +``` + +**Not in your repository.** Appending to a tracked file on every edit would turn +`git status` into noise during exactly the activity you're reviewing. If you do +want it committed, `deepcode ledger export --out ` writes a Markdown +digest wherever you like. + +## Record shape + +```jsonc +{ + "id": "chg-lz4k2p-01", + "timestamp": "2026-08-08T09:12:33.417Z", + "actor": "agent", + "threadId": "thread-lz4k1x-a3f9", + "turnId": "turn-lz4k2m-77c1", + "tool": "Edit", + "intent": "fix the expired-token branch in auth.ts", + "paths": ["src/auth.ts"], + "summary": "edited src/auth.ts", + "rollbackHint": { "kind": "snapshot", "ref": "7" }, +} +``` + +`intent` is the request that drove the turn. `paths` are workspace-relative, so +records stay readable after the repo moves. `rollbackHint` points at the +snapshot or git checkpoint already taken for that specific call — and is +**absent when no checkpoint exists**, rather than guessing at one. + +## What is not recorded + +- **Reads.** A ledger answering "what changed" has nothing to say about a read, + and the traffic would bury the mutations. +- **Failed or blocked calls.** A ledger of things that did not happen is worse + than no ledger. +- **File contents.** Only paths and summaries, so the ledger never becomes a + second copy of your secrets. + +`Bash` **is** recorded, with no paths — a shell command's effects can't be +declared ahead of time, but "the agent ran this" is exactly what an audit needs. +The pre-Bash git checkpoint is what makes it reversible. + +## Failure behaviour + +Ledger writes never fail a tool call. If the disk is full or the path is +unwritable, the edit still succeeds and the record is dropped. An audit trail +that can cost you a completed edit is worse than one with a gap. + +Corrupt lines are skipped on read. Unlike a session, nothing is reconstructed +from these records, so the readable ones stay useful on their own. + +## Retention + +Newest 5000 records per file, nothing older than 90 days; trimmed automatically +as records accumulate. A log that only grows is one somebody eventually deletes +wholesale — which loses the recent records too. + +## It is not an authority + +The ledger records decisions; it never influences one. Nothing in the permission +path reads it. (Selfware draws the same line: memory must not become protocol +authority.) diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index ceb75d7..bfc8272 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runAgent as runAgentCore, type RunAgentOptions } from './agent.js'; +import type { LedgerKind, LedgerSink, NewLedgerRecord } from './ledger/index.js'; import { HookDispatcher } from './hooks/index.js'; import { SessionManager } from './sessions/index.js'; import { ToolRegistry } from './tools/registry.js'; @@ -939,4 +940,158 @@ describe('runAgent', () => { expect(result.stopReason).toBe('end_turn'); }); }); + // ── change ledger ──────────────────────────────────────────────────── + describe('change ledger', () => { + function recordingSink(): { sink: LedgerSink; entries: Array<[LedgerKind, NewLedgerRecord]> } { + const entries: Array<[LedgerKind, NewLedgerRecord]> = []; + return { + entries, + sink: { + async append(kind, record) { + entries.push([kind, record]); + return null; + }, + }, + }; + } + + const writeTool: ToolHandler = { + name: 'Write', + definition: { name: 'Write', description: 'w', inputSchema: { type: 'object' } }, + async execute() { + return { content: 'ok' }; + }, + }; + + const failingWrite: ToolHandler = { + name: 'Write', + definition: { name: 'Write', description: 'w', inputSchema: { type: 'object' } }, + async execute() { + return { content: 'boom', isError: true }; + }, + }; + + function writeCall(): ToolUseBlock { + return { + type: 'tool_use', + id: 'w-1', + name: 'Write', + input: { file_path: 'out.txt', content: 'x' }, + }; + } + + it('records a completed write, with the turn request as intent', async () => { + const ledger = recordingSink(); + await runAgent({ + provider: new MockProvider([toolUse('writing', writeCall()), endTurn('done')]), + tools: new ToolRegistry([writeTool]), + systemPrompt: '', + userMessage: 'fix the auth bug', + model: 'deepseek-chat', + cwd, + ledger: ledger.sink, + }); + expect(ledger.entries).toHaveLength(1); + const [kind, record] = ledger.entries[0]!; + expect(kind).toBe('changes'); + expect(record.tool).toBe('Write'); + expect(record.paths).toEqual(['out.txt']); + expect(record.intent).toBe('fix the auth bug'); + }); + + it('does not record a failed tool call', async () => { + // A ledger of things that did not happen is worse than no ledger. + const ledger = recordingSink(); + await runAgent({ + provider: new MockProvider([toolUse('writing', writeCall()), endTurn('done')]), + tools: new ToolRegistry([failingWrite]), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + ledger: ledger.sink, + }); + expect(ledger.entries).toEqual([]); + }); + + it('does not record a blocked tool call', async () => { + const ledger = recordingSink(); + await runAgent({ + provider: new MockProvider([toolUse('writing', writeCall()), endTurn('done')]), + tools: new ToolRegistry([writeTool]), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + mode: 'default', + unattended: true, + ledger: ledger.sink, + }); + expect(ledger.entries).toEqual([]); + }); + + it('does not record reads', async () => { + await fs.writeFile(join(cwd, 'a.txt'), 'hi'); + const ledger = recordingSink(); + await runAgent({ + provider: new MockProvider([ + toolUse('reading', { + type: 'tool_use', + id: 'r-1', + name: 'Read', + input: { file_path: join(cwd, 'a.txt') }, + }), + endTurn('done'), + ]), + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + ledger: ledger.sink, + }); + expect(ledger.entries).toEqual([]); + }); + + it('a sink that throws cannot fail the tool call', async () => { + // Bookkeeping must never cost a completed edit. + let executed = 0; + const exploding: LedgerSink = { + async append() { + throw new Error('disk full'); + }, + }; + const counting: ToolHandler = { + name: 'Write', + definition: { name: 'Write', description: 'w', inputSchema: { type: 'object' } }, + async execute() { + executed++; + return { content: 'ok' }; + }, + }; + const result = await runAgent({ + provider: new MockProvider([toolUse('writing', writeCall()), endTurn('done')]), + tools: new ToolRegistry([counting]), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + ledger: exploding, + }); + expect(executed).toBe(1); + expect(result.stopReason).toBe('end_turn'); + }); + + it('records nothing when no sink is supplied', async () => { + const result = await runAgent({ + provider: new MockProvider([toolUse('writing', writeCall()), endTurn('done')]), + tools: new ToolRegistry([writeTool]), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + }); + expect(result.stopReason).toBe('end_turn'); + }); + }); }); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 09cef66..be7f2e2 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -5,6 +5,8 @@ import { compact, shouldCompact } from './compaction/index.js'; import type { PermissionRules } from './config/types.js'; import type { FileContract } from './config/file-contract.js'; import type { UnattendedApprovalPolicy } from './cron/index.js'; +import type { LedgerSink } from './ledger/index.js'; +import { buildToolCallRecord, ledgerKindForTool } from './ledger/record-tool-call.js'; import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatcher.js'; import { TaskManager, type TaskRunner } from './tasks/manager.js'; import type { HookDispatcher } from './hooks/index.js'; @@ -72,6 +74,8 @@ export interface RunAgentOptions { permissions?: PermissionRules; /** Path-axis rules; RuntimeHost loads this so clients need not remember to. */ contract?: FileContract; + /** Audit sink for completed mutations. RuntimeHost supplies one by default. */ + ledger?: LedgerSink; hooks?: HookDispatcher; approval?: ApprovalCallback; /** @@ -718,16 +722,20 @@ export async function runAgent(opts: RunAgentOptions): Promise { // concurrently; mutating tools call it one at a time (see partition below). const execOne = async ({ toolUse, handler }: Ready): Promise => { const isFileMutation = toolUse.name === 'Edit' || toolUse.name === 'Write'; + // Remembered so a ledger record can point at the checkpoint taken for + // this specific call rather than at "whatever was last captured". + let preSeq: number | undefined; if (opts.enableSnapshots !== false && opts.session && isFileMutation) { const filePath = (toolUse.input as { file_path?: string }).file_path; if (filePath) { + preSeq = ++snapshotSeq; await opts.session.manager.snapshot({ sessionId: opts.session.id, cwd: opts.cwd, filePath, reason: `pre-${toolUse.name}`, - seq: ++snapshotSeq, + seq: preSeq, turnId: opts.session.turnId, }); } @@ -737,11 +745,12 @@ export async function runAgent(opts: RunAgentOptions): Promise { // a git working-tree checkpoint instead (no-op outside a git repo). This // lets `/rewind code` revert what the command changed. if (opts.enableSnapshots !== false && opts.session && toolUse.name === 'Bash') { + preSeq = ++snapshotSeq; await opts.session.manager.gitCheckpoint({ sessionId: opts.session.id, cwd: opts.cwd, reason: 'pre-Bash', - seq: ++snapshotSeq, + seq: preSeq, turnId: opts.session.turnId, }); } @@ -782,6 +791,27 @@ export async function runAgent(opts: RunAgentOptions): Promise { } } + // One write point for the whole loop. Per-tool writes are exactly the + // shape AGENTS.md rules out — a new mutating tool would silently go + // unrecorded if each site had to remember. + if (opts.ledger && !tr.isError) { + const kind = ledgerKindForTool(toolUse.name); + const record = + kind && + buildToolCallRecord({ + tool: toolUse.name, + input: toolUse.input, + cwd: opts.cwd, + intent: opts.userMessage, + threadId: opts.session?.id, + turnId: opts.session?.turnId, + snapshotSeq: preSeq, + }); + // Never let bookkeeping fail a completed edit; FileLedger already + // swallows its own I/O errors, this covers a host-supplied sink. + if (kind && record) await opts.ledger.append(kind, record).catch(() => undefined); + } + opts.onEvent?.({ type: 'tool_result', id: toolUse.id, result: tr }); resultsById.set(toolUse.id, { type: 'tool_result', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9984876..b4116ac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -515,3 +515,29 @@ export { type OutputStyleFrontmatter, type LoadOutputStylesOpts, } from './output-styles/index.js'; + +// Change ledger — append-only audit of workspace mutations (plan §2.B) +export { + FileLedger, + DEFAULT_RETENTION, + LEDGER_KINDS, + findLedgerRecord, + ledgerPath, + newLedgerId, + projectLedgerDir, + readLedger, + readProjectLedger, + renderLedgerMarkdown, + type LedgerKind, + type LedgerRecord, + type LedgerRetention, + type LedgerSink, + type NewLedgerRecord, + type RollbackHint, +} from './ledger/index.js'; +export { + buildToolCallRecord, + isRecordableTool, + ledgerKindForTool, + type ToolCallRecordInput, +} from './ledger/record-tool-call.js'; diff --git a/packages/core/src/ledger/index.test.ts b/packages/core/src/ledger/index.test.ts new file mode 100644 index 0000000..50f6b5c --- /dev/null +++ b/packages/core/src/ledger/index.test.ts @@ -0,0 +1,253 @@ +import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + FileLedger, + findLedgerRecord, + ledgerPath, + readLedger, + readProjectLedger, + renderLedgerMarkdown, + type LedgerRecord, +} from './index.js'; +import { buildToolCallRecord, isRecordableTool, ledgerKindForTool } from './record-tool-call.js'; + +describe('FileLedger', () => { + let cwd: string; + let home: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-ledger-cwd-')); + home = await mkdtemp(join(tmpdir(), 'dc-ledger-home-')); + }); + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + }); + + const base = { actor: 'agent', paths: ['a.ts'], summary: 'wrote a.ts' }; + + it('appends and reads back', async () => { + const ledger = new FileLedger({ cwd, home }); + const written = await ledger.append('changes', base); + expect(written?.id).toMatch(/^chg-/); + const records = await readProjectLedger(cwd, 'changes', home); + expect(records).toHaveLength(1); + expect(records[0]!.summary).toBe('wrote a.ts'); + }); + + it('keeps the two timelines in separate files', async () => { + // The split is the point: a rare governance event must not be buried under + // thousands of edits. + const ledger = new FileLedger({ cwd, home }); + await ledger.append('changes', base); + await ledger.append('governance', { actor: 'user', paths: [], summary: 'installed a plugin' }); + expect(await readProjectLedger(cwd, 'changes', home)).toHaveLength(1); + expect(await readProjectLedger(cwd, 'governance', home)).toHaveLength(1); + }); + + it('writes outside the repository, so git status stays clean', async () => { + const ledger = new FileLedger({ cwd, home }); + await ledger.append('changes', base); + expect(ledger.path('changes').startsWith(home)).toBe(true); + expect(ledger.path('changes').startsWith(cwd)).toBe(false); + }); + + it('generates unique ids for rapid appends', async () => { + const ledger = new FileLedger({ cwd, home }); + const a = await ledger.append('changes', base); + const b = await ledger.append('changes', base); + expect(a!.id).not.toBe(b!.id); + }); + + it('returns null instead of throwing when the write fails', async () => { + // An audit trail must never be able to fail a completed edit. + const ledger = new FileLedger({ cwd, home }); + const blocker = ledger.path('changes'); + await mkdir(blocker, { recursive: true }); // a directory where the file goes + const result = await ledger.append('changes', base); + expect(result).toBeNull(); + expect(ledger.lastError).toBeDefined(); + }); + + it('truncates an oversized summary rather than dropping the record', async () => { + const ledger = new FileLedger({ cwd, home }); + await ledger.append('changes', { ...base, summary: 'x'.repeat(9000) }); + const [record] = await readProjectLedger(cwd, 'changes', home); + expect(record).toBeDefined(); + expect(record!.summary.length).toBeLessThan(600); + }); + + it('skips corrupt lines but keeps the readable ones', async () => { + const ledger = new FileLedger({ cwd, home }); + await ledger.append('changes', base); + await writeFile( + ledger.path('changes'), + (await readFile(ledger.path('changes'), 'utf8')) + 'not json\n', + ); + await ledger.append('changes', { ...base, summary: 'second' }); + const records = await readProjectLedger(cwd, 'changes', home); + expect(records.map((r) => r.summary)).toEqual(['wrote a.ts', 'second']); + }); + + describe('retention', () => { + it('keeps only the newest maxRecords', async () => { + const ledger = new FileLedger({ cwd, home, retention: { maxRecords: 3 } }); + for (let i = 0; i < 10; i++) await ledger.append('changes', { ...base, summary: `s${i}` }); + const dropped = await ledger.prune('changes'); + expect(dropped).toBe(7); + const kept = await readProjectLedger(cwd, 'changes', home); + expect(kept.map((r) => r.summary)).toEqual(['s7', 's8', 's9']); + }); + + it('drops records past maxAgeDays', async () => { + let clock = new Date('2026-01-01T00:00:00.000Z'); + const ledger = new FileLedger({ + cwd, + home, + retention: { maxAgeDays: 30 }, + now: () => clock, + }); + await ledger.append('changes', { ...base, summary: 'old' }); + clock = new Date('2026-06-01T00:00:00.000Z'); + await ledger.append('changes', { ...base, summary: 'new' }); + await ledger.prune('changes'); + expect((await readProjectLedger(cwd, 'changes', home)).map((r) => r.summary)).toEqual([ + 'new', + ]); + }); + + it('leaves the file alone when nothing is out of window', async () => { + const ledger = new FileLedger({ cwd, home }); + await ledger.append('changes', base); + expect(await ledger.prune('changes')).toBe(0); + expect(await readProjectLedger(cwd, 'changes', home)).toHaveLength(1); + }); + }); + + it('finds a record by id across both timelines', async () => { + const ledger = new FileLedger({ cwd, home }); + const gov = await ledger.append('governance', { + actor: 'user', + paths: [], + summary: 'granted trust', + }); + const found = await findLedgerRecord(cwd, gov!.id, home); + expect(found?.kind).toBe('governance'); + }); + + it('reads a missing ledger as empty rather than failing', async () => { + expect(await readLedger(ledgerPath(cwd, 'changes', home))).toEqual([]); + }); +}); + +describe('buildToolCallRecord', () => { + const cwd = '/work/repo'; + + it('records the mutating tools', () => { + for (const tool of ['Write', 'Edit', 'NotebookEdit', 'Bash']) { + expect(isRecordableTool(tool)).toBe(true); + expect(ledgerKindForTool(tool)).toBe('changes'); + } + }); + + it('does not record reads', () => { + // A ledger answering "what changed, how do I undo it" has nothing to say + // about a read, and the traffic would bury the mutations. + for (const tool of ['Read', 'Grep', 'Glob', 'WebFetch']) { + expect(isRecordableTool(tool)).toBe(false); + expect(buildToolCallRecord({ tool, input: {}, cwd })).toBeNull(); + } + }); + + it('stores workspace-relative paths so the record survives a repo move', () => { + const record = buildToolCallRecord({ + tool: 'Write', + input: { file_path: '/work/repo/src/a.ts' }, + cwd, + }); + expect(record?.paths).toEqual(['src/a.ts']); + }); + + it('reads NotebookEdit from notebook_path', () => { + const record = buildToolCallRecord({ + tool: 'NotebookEdit', + input: { notebook_path: 'nb.ipynb', edit_mode: 'insert' }, + cwd, + }); + expect(record?.paths).toEqual(['nb.ipynb']); + expect(record?.summary).toContain('insert'); + }); + + it('records a Bash command with no paths', () => { + // "The agent ran this" is exactly what an audit needs, even though no path + // can be declared for it. + const record = buildToolCallRecord({ tool: 'Bash', input: { command: 'rm -rf build' }, cwd }); + expect(record?.paths).toEqual([]); + expect(record?.summary).toBe('ran: rm -rf build'); + }); + + it('carries intent and thread identity through', () => { + const record = buildToolCallRecord({ + tool: 'Write', + input: { file_path: 'a.ts' }, + cwd, + intent: 'fix the auth bug', + threadId: 'thread-1', + turnId: 'turn-2', + }); + expect(record?.intent).toBe('fix the auth bug'); + expect(record?.threadId).toBe('thread-1'); + expect(record?.turnId).toBe('turn-2'); + }); + + it('points the rollback hint at the checkpoint taken for this call', () => { + const write = buildToolCallRecord({ + tool: 'Write', + input: { file_path: 'a.ts' }, + cwd, + snapshotSeq: 7, + }); + expect(write?.rollbackHint).toMatchObject({ kind: 'snapshot', ref: '7' }); + + const bash = buildToolCallRecord({ + tool: 'Bash', + input: { command: 'x' }, + cwd, + snapshotSeq: 8, + }); + expect(bash?.rollbackHint).toMatchObject({ kind: 'git', ref: '8' }); + }); + + it('omits the rollback hint when no checkpoint exists, rather than guessing', () => { + const record = buildToolCallRecord({ tool: 'Write', input: { file_path: 'a.ts' }, cwd }); + expect(record?.rollbackHint).toBeUndefined(); + }); +}); + +describe('renderLedgerMarkdown', () => { + const record: LedgerRecord = { + id: 'chg-1', + timestamp: '2026-08-08T00:00:00.000Z', + actor: 'agent', + tool: 'Edit', + intent: 'fix auth', + paths: ['src/auth.ts'], + summary: 'edited src/auth.ts', + rollbackHint: { kind: 'snapshot', ref: '3' }, + }; + + it('renders a record with its intent, paths and rollback', () => { + const md = renderLedgerMarkdown('changes', [record]); + expect(md).toContain('# Workspace changes'); + expect(md).toContain('chg-1'); + expect(md).toContain('fix auth'); + expect(md).toContain('`src/auth.ts`'); + expect(md).toContain('snapshot'); + }); + + it('says so plainly when empty', () => { + expect(renderLedgerMarkdown('governance', [])).toContain('No records.'); + }); +}); diff --git a/packages/core/src/ledger/index.ts b/packages/core/src/ledger/index.ts new file mode 100644 index 0000000..b6c0a00 --- /dev/null +++ b/packages/core/src/ledger/index.ts @@ -0,0 +1,271 @@ +// Change ledger — an append-only record of what the agent changed and how to +// undo it. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.B +// +// Sessions already store a message stream and snapshots already capture file +// state, but neither answers the question a user actually asks after a run: +// "what did it change, why, and how do I undo that one thing?" A message log +// requires reading a conversation to find out; snapshots are addressable but +// carry no intent. This is the missing index over both. +// +// Not an authority. The ledger records decisions; it never influences one. +// (Selfware makes the same split: memory must not become protocol authority.) + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; + +/** + * Two timelines, deliberately. + * + * `changes` is high-frequency workspace edits — the "what did it do" stream. + * `governance` is low-frequency, high-impact: contract edits, plugin installs, + * trust grants, rollbacks. Interleaving them buries the second in the first, + * which is the whole reason to keep the split rather than one file with a type + * column: the rare, important events stay readable on their own. + */ +export type LedgerKind = 'changes' | 'governance'; + +export const LEDGER_KINDS: readonly LedgerKind[] = ['changes', 'governance']; + +export interface RollbackHint { + /** `git` when a repo checkpoint exists, `snapshot` for per-file captures. */ + kind: 'git' | 'snapshot' | 'manual'; + /** Commit-ish, snapshot address, or a human instruction for `manual`. */ + ref?: string; + /** Ready-to-run command, when one exists. */ + command?: string; +} + +export interface LedgerRecord { + id: string; + /** ISO 8601. */ + timestamp: string; + /** `user` | `agent` | `hook` | `plugin` | `subagent:`. */ + actor: string; + threadId?: string; + turnId?: string; + /** Tool that produced the change, when one did. */ + tool?: string; + /** What the run was trying to achieve — the user's request, abridged. */ + intent?: string; + /** Workspace-relative paths. Empty when the effect cannot be pinned to files. */ + paths: string[]; + summary: string; + rollbackHint?: RollbackHint; +} + +export type NewLedgerRecord = Omit; + +/** + * Where the agent loop writes. An interface so a host can substitute one, and + * so tests need no filesystem. + */ +export interface LedgerSink { + append(kind: LedgerKind, record: NewLedgerRecord): Promise; +} + +export interface LedgerRetention { + /** Newest records to keep per file. */ + maxRecords: number; + /** Drop records older than this. */ + maxAgeDays: number; +} + +export const DEFAULT_RETENTION: LedgerRetention = { maxRecords: 5000, maxAgeDays: 90 }; + +/** + * Ledgers live under the DeepCode data dir, keyed by project — not in the + * repository. + * + * Selfware keeps its change log inside the instance, which suits a document + * workspace. Here it would append to a tracked file on every edit and turn + * `git status` into noise during the exact activity the user is reviewing. + * `deepcode ledger export` covers the case where they do want it committed. + */ +export function projectLedgerDir(cwd: string, home: string = homedir()): string { + const key = resolve(cwd).replace(/[/\\]+/g, '-'); + return join(home, '.deepcode', 'projects', key, 'ledger'); +} + +export function ledgerPath(cwd: string, kind: LedgerKind, home: string = homedir()): string { + return join(projectLedgerDir(cwd, home), `${kind}.jsonl`); +} + +let ledgerSeq = 0; +export function newLedgerId(now: number = Date.now()): string { + return `chg-${now.toString(36)}-${(ledgerSeq++).toString(36).padStart(2, '0')}`; +} + +/** Cap on one serialized record, so a single append stays a single atomic write. */ +const MAX_LINE_BYTES = 4000; + +export interface FileLedgerOpts { + cwd: string; + home?: string; + retention?: Partial; + /** Clock injection for tests. */ + now?: () => Date; +} + +export class FileLedger implements LedgerSink { + private readonly retention: LedgerRetention; + private readonly now: () => Date; + /** Appends since the last prune check, per kind. */ + private readonly sinceCheck = new Map(); + + constructor(private readonly opts: FileLedgerOpts) { + this.retention = { ...DEFAULT_RETENTION, ...opts.retention }; + this.now = opts.now ?? (() => new Date()); + } + + path(kind: LedgerKind): string { + return ledgerPath(this.opts.cwd, kind, this.opts.home ?? homedir()); + } + + /** + * Append one record. Returns it, or null if it could not be written. + * + * Never throws. A ledger is an audit trail, not a precondition — failing a + * tool call because bookkeeping failed would trade a working edit for a lost + * one. Failures surface through `lastError`. + */ + async append(kind: LedgerKind, record: NewLedgerRecord): Promise { + const full: LedgerRecord = { + id: newLedgerId(this.now().getTime()), + timestamp: this.now().toISOString(), + ...record, + summary: truncate(record.summary, 500), + ...(record.intent ? { intent: truncate(record.intent, 500) } : {}), + }; + let line = JSON.stringify(full); + if (Buffer.byteLength(line) > MAX_LINE_BYTES) { + // Prefer a shorter honest record over a dropped one. + full.paths = full.paths.slice(0, 20); + full.summary = truncate(full.summary, 200); + line = JSON.stringify(full); + } + const path = this.path(kind); + try { + await fs.mkdir(dirname(path), { recursive: true }); + await fs.appendFile(path, `${line}\n`, 'utf8'); + } catch (err) { + this.lastError = err as Error; + return null; + } + const n = (this.sinceCheck.get(kind) ?? 0) + 1; + if (n >= 200) { + this.sinceCheck.set(kind, 0); + await this.prune(kind).catch((err: Error) => { + this.lastError = err; + }); + } else { + this.sinceCheck.set(kind, n); + } + return full; + } + + lastError?: Error; + + /** + * Trim to the retention window: newest `maxRecords`, nothing older than + * `maxAgeDays`. + * + * Retention ships with the writer rather than as a follow-up. A log that only + * ever grows is a log someone eventually deletes wholesale, which loses the + * old records the retention policy would have kept anyway. + */ + async prune(kind: LedgerKind): Promise { + const path = this.path(kind); + const records = await readLedger(path); + const cutoff = this.now().getTime() - this.retention.maxAgeDays * 86_400_000; + const kept = records + .filter((r) => Date.parse(r.timestamp) >= cutoff) + .slice(-this.retention.maxRecords); + if (kept.length === records.length) return 0; + const tmp = `${path}.tmp`; + await fs.writeFile( + tmp, + kept.map((r) => JSON.stringify(r)).join('\n') + (kept.length ? '\n' : ''), + 'utf8', + ); + await fs.rename(tmp, path); + return records.length - kept.length; + } +} + +/** + * Read a ledger file, skipping unparseable lines. + * + * A corrupt line here is not worth failing over: unlike a session, no later + * state is reconstructed from these records, so the readable ones stay useful + * on their own. + */ +export async function readLedger(path: string): Promise { + let raw: string; + try { + raw = await fs.readFile(path, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } + const out: LedgerRecord[] = []; + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as LedgerRecord; + if (parsed && typeof parsed.id === 'string' && Array.isArray(parsed.paths)) out.push(parsed); + } catch { + /* skip */ + } + } + return out; +} + +export async function readProjectLedger( + cwd: string, + kind: LedgerKind, + home: string = homedir(), +): Promise { + return readLedger(ledgerPath(cwd, kind, home)); +} + +/** Find one record by id across both timelines. */ +export async function findLedgerRecord( + cwd: string, + id: string, + home: string = homedir(), +): Promise<{ kind: LedgerKind; record: LedgerRecord } | null> { + for (const kind of LEDGER_KINDS) { + const found = (await readProjectLedger(cwd, kind, home)).find((r) => r.id === id); + if (found) return { kind, record: found }; + } + return null; +} + +function truncate(text: string, max: number): string { + const trimmed = (text ?? '').trim(); + return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1)}…`; +} + +/** Render records as a human-readable Markdown digest. */ +export function renderLedgerMarkdown(kind: LedgerKind, records: LedgerRecord[]): string { + const title = kind === 'changes' ? 'Workspace changes' : 'Governance changes'; + if (records.length === 0) return `# ${title}\n\nNo records.\n`; + const lines = [`# ${title}`, '']; + for (const r of records) { + lines.push(`## ${r.id} — ${r.timestamp}`); + lines.push(''); + lines.push(`- **actor**: ${r.actor}${r.tool ? ` (${r.tool})` : ''}`); + if (r.intent) lines.push(`- **intent**: ${r.intent}`); + if (r.paths.length > 0) lines.push(`- **paths**: ${r.paths.map((p) => `\`${p}\``).join(', ')}`); + lines.push(`- **summary**: ${r.summary}`); + if (r.rollbackHint) { + const hint = r.rollbackHint; + const detail = hint.command ? `\`${hint.command}\`` : (hint.ref ?? 'see notes'); + lines.push(`- **rollback**: ${hint.kind} — ${detail}`); + } + lines.push(''); + } + return lines.join('\n'); +} diff --git a/packages/core/src/ledger/record-tool-call.ts b/packages/core/src/ledger/record-tool-call.ts new file mode 100644 index 0000000..e1e0f68 --- /dev/null +++ b/packages/core/src/ledger/record-tool-call.ts @@ -0,0 +1,109 @@ +// Turning a completed tool call into a ledger record. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.B +// +// Split out of the agent loop so the mapping — which tools are recordable, +// which paths they touched, what the rollback handle is — can be tested without +// running a turn. + +import { normalizeContractPath } from '../config/file-contract.js'; +import type { LedgerKind, NewLedgerRecord, RollbackHint } from './index.js'; + +/** + * Tools whose completion is worth recording, and where their path lives. + * + * Reads are absent on purpose. A ledger answering "what changed and how do I + * undo it" has nothing to say about a read, and mixing them in would bury the + * mutations under routine traffic — the same reasoning that splits `changes` + * from `governance`. + */ +const RECORDABLE: Record = { + Write: { field: 'file_path', kind: 'changes' }, + Edit: { field: 'file_path', kind: 'changes' }, + NotebookEdit: { field: 'notebook_path', kind: 'changes' }, + // Bash has no declarable path. It is still recorded, because "the agent ran + // this command" is exactly what someone auditing a run needs to see; the + // pre-Bash git checkpoint is what makes it reversible. + Bash: { kind: 'changes' }, +}; + +export function isRecordableTool(tool: string): boolean { + return tool in RECORDABLE; +} + +export function ledgerKindForTool(tool: string): LedgerKind | undefined { + return RECORDABLE[tool]?.kind; +} + +export interface ToolCallRecordInput { + tool: string; + input: Record; + cwd: string; + /** The user request driving this turn, used as `intent`. */ + intent?: string; + threadId?: string; + turnId?: string; + actor?: string; + /** Snapshot/checkpoint sequence captured before the call, if any. */ + snapshotSeq?: number; +} + +/** Build the record for a completed tool call, or null if the tool isn't recordable. */ +export function buildToolCallRecord(input: ToolCallRecordInput): NewLedgerRecord | null { + const spec = RECORDABLE[input.tool]; + if (!spec) return null; + + const paths: string[] = []; + if (spec.field) { + const raw = input.input[spec.field]; + if (typeof raw === 'string' && raw) { + // Workspace-relative so a ledger stays readable after the repo moves. + paths.push(normalizeContractPath(input.cwd, raw) ?? raw); + } + } + + return { + actor: input.actor ?? 'agent', + tool: input.tool, + ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.intent ? { intent: input.intent } : {}), + paths, + summary: summarize(input.tool, input.input, paths), + ...(rollbackFor(input) ? { rollbackHint: rollbackFor(input)! } : {}), + }; +} + +function summarize(tool: string, args: Record, paths: string[]): string { + if (tool === 'Bash') { + const command = typeof args.command === 'string' ? args.command : '(no command)'; + return `ran: ${command}`; + } + const target = paths[0] ?? '(unknown path)'; + if (tool === 'Write') return `wrote ${target}`; + if (tool === 'Edit') { + const all = args.replace_all === true ? ' (all occurrences)' : ''; + return `edited ${target}${all}`; + } + if (tool === 'NotebookEdit') { + const mode = typeof args.edit_mode === 'string' ? args.edit_mode : 'replace'; + return `${mode} notebook cell in ${target}`; + } + return `${tool} on ${target}`; +} + +/** + * How to undo this call. + * + * Snapshots and git checkpoints are already taken around mutating calls; the + * ledger only makes them addressable and pairs them with intent. Without a + * sequence number there is nothing honest to point at, so the record carries no + * hint rather than a guess. + */ +function rollbackFor(input: ToolCallRecordInput): RollbackHint | undefined { + if (input.snapshotSeq === undefined) return undefined; + return { + kind: input.tool === 'Bash' ? 'git' : 'snapshot', + ref: String(input.snapshotSeq), + command: `deepcode ledger rollback `, + }; +} diff --git a/packages/core/src/runtime/host.ts b/packages/core/src/runtime/host.ts index 666aaaa..aa720be 100644 --- a/packages/core/src/runtime/host.ts +++ b/packages/core/src/runtime/host.ts @@ -12,6 +12,7 @@ import type { } from '../config/types.js'; import { fileContractWarnings } from '../config/contract-dispatch.js'; import { loadFileContract, type LoadedFileContract } from '../config/file-contract-loader.js'; +import { FileLedger, type LedgerSink } from '../ledger/index.js'; import { resolveSandboxMode } from '../sandbox/policy.js'; import type { HookDispatcher } from '../hooks/index.js'; import type { Provider } from '../providers/types.js'; @@ -47,6 +48,10 @@ export interface RuntimeHostOptions { * read it from; every real host wants the default. */ disableFileContract?: boolean; + /** Substitute audit sink; the host builds a FileLedger when absent. */ + ledger?: LedgerSink; + /** Turn off change recording entirely. */ + disableLedger?: boolean; } type HostBoundOption = @@ -60,7 +65,8 @@ type HostBoundOption = | 'sandboxConfig' | 'sandboxDefaultMode' | 'pluginDirs' - | 'contract'; + | 'contract' + | 'ledger'; export type RuntimeTurnOptions = Omit & { cwd?: string; @@ -79,6 +85,7 @@ export class RuntimeHost { readonly permissions: PermissionRules; /** Populated on first run; per-cwd because a turn may name its own. */ private readonly contracts = new Map(); + private readonly ledgers = new Map(); constructor(private readonly options: RuntimeHostOptions) { const policy = resolveRuntimePolicy(options); @@ -104,6 +111,22 @@ export class RuntimeHost { return loaded; } + /** + * The audit sink for a workspace. + * + * Built here for the same reason the contract is: a client that forgets loses + * the audit trail silently, and nobody notices until they need it. + */ + ledgerFor(cwd: string): LedgerSink | undefined { + if (this.options.disableLedger) return undefined; + if (this.options.ledger) return this.options.ledger; + const cached = this.ledgers.get(cwd); + if (cached) return cached; + const ledger = new FileLedger({ cwd, home: this.options.home }); + this.ledgers.set(cwd, ledger); + return ledger; + } + /** * Operator warnings about how far the contract actually reaches, for hosts to * print at startup and for `deepcode doctor`. @@ -142,6 +165,7 @@ export class RuntimeHost { mode: modeOverride ?? this.mode, permissions: this.permissions, contract, + ledger: this.ledgerFor(cwd), hooks: this.options.hooks, approval: approval ?? this.options.approval, autoMode: this.options.autoMode,