Skip to content
Closed
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ The figures above come from the local suite run on this source revision. They de

## Build from source

For large worker histories, `get_thread({threadId, metadataOnly: true})` returns
routing and completion fields without transcript or tool history.
`get_thread_messages({threadId, limit: 1, maxChars: 4000, tail: true})` retains the
end of the latest message, including a final report after a long tool transcript.
Omit these optional fields to keep the existing full-read response.

From a source checkout, export a bounded review patch with:

```sh
node scripts/export-review-diff.mjs --cwd /path/to/worktree --evidence /path/to/new-evidence
```

The exporter selects Git paths before reading contents, skips generated evidence
and private worker state, and records every exclusion or truncation. Repeated
exports do not recursively include earlier exports or overwrite an existing result.

```sh
pnpm install --frozen-lockfile
pnpm typecheck
Expand Down
63 changes: 63 additions & 0 deletions scripts/diff-export.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import fs from 'node:fs/promises';
import path from 'node:path';

const inside = (root, file) => {
const relative = path.relative(root, file);
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
};

// Evidence is reviewed from its manifest, never by recursively diffing prior
// exports. Preserve a list of every skipped path so exclusions remain visible.
export async function selectDiffExports({ cwd, evidence, changes, trackedPaths, maxFiles = 256, maxFileBytes = 2 * 1024 * 1024 }) {
const tracked = new Set(trackedPaths.map((file) => file.replaceAll('\\', '/')));
const files = [];
const skipped = [];
const seen = new Set();
const ordered = [...(changes.files ?? [])].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
for (const entry of ordered) {
const absolute = path.resolve(cwd, entry.path);
const relative = path.relative(cwd, absolute).replaceAll('\\', '/');
let reason;
if (!inside(cwd, absolute)) reason = 'outside_worktree';
else if (inside(evidence, absolute)) reason = 'current_evidence_directory';
else if (/(^|\/)\.astra-freebuff-evidence(\/|$)/.test(relative)) reason = 'generated_evidence_directory';
else if (/(^|\/)\.freebuff(\/|$)/.test(relative)) reason = 'private_worker_state';
else if (/(^|\/)\.env($|\.)/.test(relative) && !relative.endsWith('.env.example')) reason = 'private_environment_file';
else if (!tracked.has(relative) && /^docs\/agent-work\//.test(relative)) reason = 'untracked_agent_work_evidence';
else if (/(^|\/)(diff-\d+\.json|git\.diff)$/.test(relative)) reason = 'generated_diff_artifact';
else if (seen.has(relative)) reason = 'duplicate_path';
else {
try {
const stat = await fs.lstat(absolute);
if (!stat.isFile()) reason = 'non_regular_file';
else if (stat.size > maxFileBytes) reason = 'file_size_limit';
else {
const resolved = await fs.realpath(absolute);
if (!inside(cwd, resolved)) reason = 'outside_worktree_realpath';
else if (inside(evidence, resolved)) reason = 'evidence_realpath';
}
} catch (error) {
// Deleted tracked files still need a diff. Other missing paths do not.
if (error.code !== 'ENOENT' || !tracked.has(relative)) reason = 'unavailable_file';
}
}
if (!reason && files.length >= maxFiles) reason = 'file_count_limit';
if (reason) skipped.push({ path: entry.path, reason });
else {
seen.add(relative);
files.push({ ...entry, path: relative });
}
}
files.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
return { files, skipped, limits: { maxFiles, maxFileBytes } };
}

export async function exportWorkerDiffs({ plan, threadId, call, save }) {
const files = [];
for (const [index, file] of plan.files.entries()) {
const artifact = `diff-${index}.json`;
await save(artifact, await call('get_diff', { threadId, file: file.path, scope: 'uncommitted' }));
files.push({ path: file.path, artifact });
}
return { files, skipped: plan.skipped, limits: plan.limits };
}
74 changes: 74 additions & 0 deletions scripts/diff-export.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { selectDiffExports, exportWorkerDiffs } from './diff-export.mjs';

test('repeated exports exclude generated evidence and retain tracked and untracked code', async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-export-'));
const evidence = path.join(cwd, 'docs/agent-work/task/worker-next');
try {
for (const file of ['src/catalog.rs', 'examples/audit.rs', 'docs/agent-work/task/worker-old/diff-0.json', 'docs/agent-work/task/labels.json']) {
await fs.mkdir(path.dirname(path.join(cwd, file)), { recursive: true });
await fs.writeFile(path.join(cwd, file), 'preserve');
}
await fs.mkdir(evidence, { recursive: true });
const changes = { files: ['src/catalog.rs', 'examples/audit.rs', 'docs/agent-work/task/worker-old/diff-0.json', 'docs/agent-work/task/labels.json', 'docs/agent-work/task/worker-next/diff-0.json', '../outside.rs', 'src/catalog.rs'].map((p) => ({ path: p })) };
const calls = [];
const call = async (name, args) => { calls.push(args.file); return { files: [{ path: args.file, diff: 'real patch' }] }; };
const save = (name, value) => fs.writeFile(path.join(evidence, name), JSON.stringify(value));
const first = await selectDiffExports({ cwd, evidence, changes, trackedPaths: ['src/catalog.rs'] });
await exportWorkerDiffs({ plan: first, threadId: 'thread', call, save });
const second = await selectDiffExports({ cwd, evidence, changes, trackedPaths: ['src/catalog.rs'] });
await exportWorkerDiffs({ plan: second, threadId: 'thread', call, save });
assert.deepEqual(first.files.map((f) => f.path), ['examples/audit.rs', 'src/catalog.rs']);
assert.deepEqual(second.files, first.files);
assert.deepEqual(calls, ['examples/audit.rs', 'src/catalog.rs', 'examples/audit.rs', 'src/catalog.rs']);
assert.equal(await fs.readFile(path.join(cwd, 'docs/agent-work/task/worker-old/diff-0.json'), 'utf8'), 'preserve');
} finally { await fs.rm(cwd, { recursive: true, force: true }); }
});

test('limits, deleted tracked files and generated artifact names have explicit exclusions', async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-export-'));
try {
await fs.writeFile(path.join(cwd, 'large.rs'), '12345');
const plan = await selectDiffExports({ cwd, evidence: path.join(cwd, 'evidence'), changes: { files: ['large.rs', 'deleted.rs', 'diff-9.json', 'second-deleted.rs'].map((p) => ({ path: p })) }, trackedPaths: ['large.rs', 'deleted.rs', 'second-deleted.rs'], maxFileBytes: 4, maxFiles: 1 });
assert.deepEqual(plan.files.map((f) => f.path), ['deleted.rs']);
assert.deepEqual(plan.skipped.map((f) => f.reason).sort(), ['file_size_limit', 'generated_diff_artifact', 'file_count_limit'].sort());
} finally { await fs.rm(cwd, { recursive: true, force: true }); }
});

test('bounded selection is stable under input order and skips tracked exports and private state', async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-order-'));
try {
const paths = ['z.rs', 'a.rs', 'git.diff', 'diff-0.json', '.freebuff/chat.json', '.env'];
for (const file of paths) {
await fs.mkdir(path.dirname(path.join(cwd, file)), { recursive: true });
await fs.writeFile(path.join(cwd, file), 'fixture');
}
const input = { cwd, evidence: path.join(cwd, 'evidence'), trackedPaths: paths, maxFiles: 1 };
const first = await selectDiffExports({ ...input, changes: { files: paths.map((path) => ({ path })) } });
const reversed = await selectDiffExports({ ...input, changes: { files: [...paths].reverse().map((path) => ({ path })) } });
assert.deepEqual(first, reversed);
assert.deepEqual(first.files.map((file) => file.path), ['a.rs']);
assert.equal(first.skipped.filter((file) => file.reason === 'generated_diff_artifact').length, 2);
assert.ok(first.skipped.some((file) => file.reason === 'private_worker_state'));
assert.ok(first.skipped.some((file) => file.reason === 'private_environment_file'));
} finally { await fs.rm(cwd, { recursive: true, force: true }); }
});

test('a directory link cannot export a file outside the worktree', async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-link-'));
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-outside-'));
try {
await fs.writeFile(path.join(outside, 'private.js'), 'outside fixture');
await fs.symlink(outside, path.join(cwd, 'linked'), process.platform === 'win32' ? 'junction' : 'dir');
const plan = await selectDiffExports({ cwd, evidence: path.join(cwd, 'evidence'), changes: { files: [{ path: 'linked/private.js' }] }, trackedPaths: ['linked/private.js'] });
assert.deepEqual(plan.files, []);
assert.deepEqual(plan.skipped, [{ path: 'linked/private.js', reason: 'outside_worktree_realpath' }]);
} finally {
await fs.rm(cwd, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
}
});
54 changes: 54 additions & 0 deletions scripts/export-review-diff.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { selectDiffExports, exportWorkerDiffs } from './diff-export.mjs';

// Inventory paths once, select once, and read only those diffs. Earlier exports
// never become inputs, even when the evidence directory lives in the worktree.
export async function captureReviewDiff({ cwd, evidence, save }) {
cwd = await fs.realpath(cwd);
evidence = path.resolve(evidence);
await fs.mkdir(evidence, { recursive: true });
save ??= async (name, value) => fs.writeFile(path.join(evidence, name), JSON.stringify(value, null, 2) + '\n', { flag: 'wx' });
const git = (...args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
const names = (...args) => git(...args).split('\0').filter(Boolean);
const trackedPaths = [...new Set([...names('ls-files', '-z'), ...names('ls-tree', '-r', '--name-only', '-z', 'HEAD')])];
const tracked = new Set(trackedPaths);
const changed = names('diff', 'HEAD', '--name-only', '-z');
const untracked = names('ls-files', '--others', '--exclude-standard', '-z');
const changes = { source: 'local_git_path_inventory', files: [...new Set([...changed, ...untracked])].map((path) => ({ path })) };
const plan = await selectDiffExports({ cwd, evidence, changes, trackedPaths });
await save('changes.json', changes);
await save('diff-selection.json', plan);
const patches = [];
const maxPatchCharacters = 256 * 1024;
const call = async (_name, { file }) => {
let patch;
try {
patch = tracked.has(file) ? git('diff', '--no-ext-diff', '--no-textconv', 'HEAD', '--', file)
: git('diff', '--no-ext-diff', '--no-textconv', '--no-index', '--', process.platform === 'win32' ? 'NUL' : '/dev/null', path.join(cwd, file));
} catch (error) {
// A native no-index exit 1 means a difference, not an execution failure.
if (error.status !== 1 || typeof error.stdout !== 'string') throw error;
patch = error.stdout;
}
const excerpt = patch.slice(0, maxPatchCharacters);
patches.push(excerpt);
return { source: 'local_git', files: [{ path: file, diff: excerpt,
...(patch.length > maxPatchCharacters ? { truncated: true, fullCharacters: patch.length } : {}) }] };
};
const result = await exportWorkerDiffs({ plan, call, save });
result.limits.maxPatchCharacters = maxPatchCharacters;
await save('diff.json', result);
await fs.writeFile(path.join(evidence, 'git.diff'), patches.join('\n'), { flag: 'wx' });
return result;
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
if (args.length !== 4 || args[0] !== '--cwd' || args[2] !== '--evidence') throw new Error('Use --cwd WORKTREE --evidence NEW_DIRECTORY');
const result = await captureReviewDiff({ cwd: args[1], evidence: args[3] });
process.stdout.write(JSON.stringify({ files: result.files.length, skipped: result.skipped.length, evidence: path.resolve(args[3]) }) + '\n');
}
40 changes: 40 additions & 0 deletions scripts/export-review-diff.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import test from 'node:test';
import { captureReviewDiff } from './export-review-diff.mjs';

test('real Git exports retain code without reading earlier evidence or overwriting a result', async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-export-git-'));
const git = (...args) => execFileSync('git', ['-C', cwd, ...args], { stdio: 'pipe' });
try {
await fs.mkdir(path.join(cwd, 'src'));
await fs.writeFile(path.join(cwd, 'src/code.js'), 'export const value = 1;\n');
await fs.writeFile(path.join(cwd, 'src/deleted.js'), 'export const retired = true;\n');
git('init');
git('add', 'src/code.js', 'src/deleted.js');
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'Synthetic baseline');
await fs.writeFile(path.join(cwd, 'src/code.js'), 'export const value = 2;\n');
await fs.writeFile(path.join(cwd, 'new-code.js'), 'export const added = true;\n');
git('rm', 'src/deleted.js');
const old = path.join(cwd, 'docs/agent-work/task/old');
await fs.mkdir(old, { recursive: true });
await fs.writeFile(path.join(old, 'diff-0.json'), 'DO_NOT_EXPORT'.repeat(100000));
const firstDir = path.join(cwd, 'docs/agent-work/task/first');
const secondDir = path.join(cwd, 'docs/agent-work/task/second');
const first = await captureReviewDiff({ cwd, evidence: firstDir });
const second = await captureReviewDiff({ cwd, evidence: secondDir });
assert.deepEqual(first.files.map((f) => f.path), ['new-code.js', 'src/code.js', 'src/deleted.js']);
assert.deepEqual(second.files, first.files);
const patch = await fs.readFile(path.join(secondDir, 'git.diff'), 'utf8');
assert.match(patch, /value = 2/);
assert.match(patch, /added = true/);
assert.match(patch, /deleted file mode/);
assert.match(patch, /-export const retired = true/);
assert.doesNotMatch(patch, /DO_NOT_EXPORT/);
assert.equal((await fs.readFile(path.join(old, 'diff-0.json'), 'utf8')).length, 1300000);
await assert.rejects(captureReviewDiff({ cwd, evidence: secondDir }), { code: 'EEXIST' });
} finally { await fs.rm(cwd, { recursive: true, force: true }); }
});
4 changes: 2 additions & 2 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

async function collect(dir) {
const entries = await readdir(path.join(root, dir), { withFileTypes: true }).catch(() => []);
return entries.filter((e) => e.isFile() && e.name.endsWith('.test.ts')).map((e) => path.join(dir, e.name));
return entries.filter((e) => e.isFile() && /\.test\.(ts|mjs)$/.test(e.name)).map((e) => path.join(dir, e.name));
}

const files = [...await collect('test'), ...await collect(path.join('test', 'integration'))].sort();
const files = [...await collect('test'), ...await collect(path.join('test', 'integration')), ...await collect('scripts')].sort();
if (!files.length) { console.error('No test files found'); process.exit(1); }

const result = spawnSync(process.execPath, ['--test', '--import', 'tsx', ...files], {
Expand Down
28 changes: 26 additions & 2 deletions src/mcp-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,32 @@ export function createV2ServerFromAdapter(adapter: V2Adapter): McpServer {

read('list_projects', 'List discovered Freebuff projects.', {}, () => shapeError(async () => { await requireSessions(); return adapter.backend.listProjects(); }));
read('list_threads', 'List Freebuff threads.', { projectId: id.optional() }, (args) => shapeError(async () => { await requireSessions(); const threads = await adapter.backend.listThreads(); return args.projectId ? (Array.isArray(threads) ? threads : []).filter((t) => (t as Record<string, unknown>).projectId === args.projectId || (t as Record<string, unknown>).projectPath === args.projectId) : threads; }));
read('get_thread', 'Read thread metadata.', { threadId: id }, (args) => shapeError(() => adapter.backend.getThread(assertSafeId(args.threadId))));
read('get_thread_messages', 'Read visible thread messages.', { threadId: id }, (args) => shapeError(() => adapter.backend.getMessages(assertSafeId(args.threadId))));
read('get_thread', 'Read thread metadata; metadataOnly omits transcript and tool history.', { threadId: id, metadataOnly: z.boolean().optional() }, (args) => shapeError(async () => {
const value = await adapter.backend.getThread(assertSafeId(args.threadId));
if (!args.metadataOnly) return value;
const thread = asObject(value);
if (!thread) throw new Error('Unreadable thread metadata.');
const fields = ['id', 'projectId', 'projectPath', 'title', 'status', 'harnessId', 'model', 'reasoningEffort', 'agentMode', 'executionMode', 'branch', 'worktreePath', 'turnState', 'queuePaused', 'lastPromptAt', 'lastTurnFinishedAt', 'lastTurnOutcome', 'missionOn', 'willContinue', 'pendingWorkCount', 'stopping', 'createdAt', 'updatedAt', 'compacting'];
const metadata: Record<string, unknown> = {};
for (const field of fields) {
const item = thread[field];
if (item === undefined) continue;
if ((typeof item === 'string' && item.length <= 8192) || typeof item === 'boolean' || typeof item === 'number' || item === null) metadata[field] = item;
else throw new Error(`Invalid or oversized thread metadata field: ${field}`);
}
return metadata;
}));
read('get_thread_messages', 'Read visible thread messages. limit/maxChars bound excerpts; tail reads their end so a long message cannot hide its final report.', { threadId: id, limit: z.number().int().min(1).max(50).optional(), maxChars: z.number().int().min(128).max(16000).optional(), tail: z.boolean().optional() }, (args) => shapeError(async () => {
const value = await adapter.backend.getMessages(assertSafeId(args.threadId));
if (args.limit === undefined && args.maxChars === undefined && !args.tail) return value;
if (!Array.isArray(value)) throw new Error('Unreadable thread messages.');
const selected = value.slice(-(args.limit ?? (args.tail ? 1 : 10)));
const maxChars = args.maxChars ?? 4000;
return { totalMessages: value.length, omittedMessages: value.length - selected.length, messages: selected.map((message) => {
const serialized = JSON.stringify(message);
return { excerpt: args.tail ? serialized.slice(-maxChars) : serialized.slice(0, maxChars), truncated: serialized.length > maxChars, ...(args.tail ? { fromEnd: true } : {}) };
}) };
}));
read('get_active_work', 'Read visible active work.', {}, () => shapeError(async () => {
await requireSessions();
const threads = await adapter.backend.listThreads();
Expand Down
Loading