Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -118,6 +119,14 @@ async function main(): Promise<number> {
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(),
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const SUBCOMMANDS = [
'skills',
'cron',
'contract',
'ledger',
'scheduler',
'setup-token',
'completion',
Expand Down
130 changes: 130 additions & 0 deletions apps/cli/src/ledger-cmd.test.ts
Original file line number Diff line number Diff line change
@@ -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:');
});
});
177 changes: 177 additions & 0 deletions apps/cli/src/ledger-cmd.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<number> {
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<number> {
const id = args[0];
if (!id) {
err.write('Usage: deepcode ledger show <id>\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<number> {
const home = deps.home ?? homedir();
const kind = parseKind(args, 'changes');
if (kind === 'both') {
err.write('Usage: deepcode ledger export [--kind changes|governance] [--out <path>]\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 <subcommand>',
'',
' list [--kind changes|governance] [--limit N] Recent records (default)',
' show <id> One record in full',
' export [--kind K] [--out <path>] Markdown digest',
'',
`Stored under ${dirname(ledgerPath('<project>', 'changes', '<home>'))}`,
'',
].join('\n'),
);
}
1 change: 1 addition & 0 deletions apps/cli/src/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ USAGE
deepcode setup-token [<token>] Store a long-lived DeepSeek auth token (CI)
deepcode cron <cmd> Scheduled tasks: install/uninstall/list/status
deepcode contract <show|init|check> Inspect or create the path-axis file contract
deepcode ledger <list|show|export> 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)
Expand Down
Loading
Loading