From 697c95a288b475179e3b7ab80c73f58ee19f2717 Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 8 Aug 2026 18:04:46 +0800 Subject: [PATCH] feat(core): add the change ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions store a message stream and snapshots capture file state, but neither answers what people ask after a run: what did it change, why, and how do I undo that one thing? A message log means reading a conversation; snapshots are addressable but carry no intent. The ledger is the index over both. Two timelines, not one file with a type column: `changes` is high-frequency workspace edits, `governance` is rare and high-impact. Interleaved, the second would never be seen again. Stored under ~/.deepcode/projects//ledger/, not in the repo. Selfware keeps its log inside the instance, which suits a document workspace; here it would append to a tracked file on every edit and fill `git status` with noise during the exact activity being reviewed. `deepcode ledger export` covers wanting it committed. One write point in the loop, after a tool succeeds. Per-tool writes are the shape AGENTS.md rules out — a new mutating tool would silently go unrecorded. Deliberately not recorded: reads (nothing to say about them, and the traffic buries mutations), failed and blocked calls (a ledger of things that did not happen is worse than none), and file contents (so it never becomes a second copy of a secret). Bash is recorded with no paths, since "the agent ran this" is what an audit needs even when the effects cannot be declared. Writes never fail a tool call — a completed edit is worth more than its bookkeeping. Retention ships with the writer rather than as a follow-up; a log that only grows is one somebody eventually deletes wholesale. rollbackHint is absent when no checkpoint was taken, rather than guessing. Adds `deepcode ledger `. `list` reports how many records a --limit hid: a truncated list that reads as complete is how someone concludes the agent changed less than it did. Co-Authored-By: Claude Opus 5 --- apps/cli/src/cli.ts | 9 + apps/cli/src/completion.ts | 1 + apps/cli/src/ledger-cmd.test.ts | 130 +++++++++ apps/cli/src/ledger-cmd.ts | 177 ++++++++++++ apps/cli/src/parse-args.ts | 1 + docs/change-ledger.md | 98 +++++++ packages/core/src/agent.test.ts | 155 +++++++++++ packages/core/src/agent.ts | 34 ++- packages/core/src/index.ts | 26 ++ packages/core/src/ledger/index.test.ts | 253 +++++++++++++++++ packages/core/src/ledger/index.ts | 271 +++++++++++++++++++ packages/core/src/ledger/record-tool-call.ts | 109 ++++++++ packages/core/src/runtime/host.ts | 26 +- 13 files changed, 1287 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/ledger-cmd.test.ts create mode 100644 apps/cli/src/ledger-cmd.ts create mode 100644 docs/change-ledger.md create mode 100644 packages/core/src/ledger/index.test.ts create mode 100644 packages/core/src/ledger/index.ts create mode 100644 packages/core/src/ledger/record-tool-call.ts 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,