Zion Boggan zionboggan.com ↗

feat: shared schema version, structured exit codes, MCP tree tool

Centralize SCHEMA_VERSION in config.js and consume it from every artifact
writer so the version cannot drift between tree.json, the JSON render, the
hallucination export, and the MCP eval payload.

Add an ExitCode enum and a TreetraceError class in util.js. User-facing
failures now throw TreetraceError with a meaningful code (2 usage, 3 no data,
4 would-leak) and bin/treetrace.js maps it to the process exit status, so CI
and scripts can branch on why a run stopped. Documented under "Exit codes:"
in --help.

Add a read-only MCP "tree" tool that returns the canonical prompt-tree JSON,
the structured counterpart to the existing Markdown tools.

Tests: structured exit codes (bad option -> 2, empty dir -> 3) and the MCP
tool list now includes "tree". Suite at 125.
3d1ebdb   Zion Boggan committed on Jun 18, 2026 (4 days ago)
bin/treetrace.js +1 -1
@@ -4,5 +4,5 @@ import { main } from '../src/cli.js';
main(process.argv.slice(2)).catch((err) => {
console.error(`treetrace: ${err && err.message ? err.message : err}`);
if (process.env.TREETRACE_DEBUG) console.error(err.stack);
- process.exit(1);
+ process.exit(err && Number.isInteger(err.exitCode) ? err.exitCode : 1);
});
src/adapters/index.js +2 -1
@@ -5,6 +5,7 @@ import { detectChatGPT, parseChatGPT } from './chatgpt.js';
import { detectCopilot, parseCopilot } from './copilot.js';
import { detectGrok, parseGrok } from './grok.js';
import { detectCursor, parseCursor } from './cursor.js';
+import { TreetraceError, ExitCode } from '../util.js';
export const TOOLS = ['claude', 'codex', 'chatgpt', 'gemini', 'copilot', 'grok', 'cursor', 'transcript'];
@@ -35,7 +36,7 @@ export function adaptFrom(tool, text, path) {
case 'cursor':
return [parseCursor(json, path, id)];
default:
- throw new Error(`unknown --from tool "${tool}" (expected one of: ${TOOLS.join(', ')})`);
+ throw new TreetraceError(`unknown --from tool "${tool}" (expected one of: ${TOOLS.join(', ')})`, ExitCode.USAGE);
}
}
src/analyze.js +3 -2
@@ -1,4 +1,5 @@
import { truncate, escapeMd } from './util.js';
+import { SCHEMA_VERSION } from './config.js';
const FAILURE_TYPES = new Set([
'ignored_constraint',
@@ -536,7 +537,7 @@ export function analyzeTree(tree) {
const topFailureTypes = countTypes(failures);
tree.analysis = {
- schemaVersion: '0.2',
+ schemaVersion: SCHEMA_VERSION,
summary: {
totalFailureSignals: failures.length,
topFailureTypes,
@@ -558,7 +559,7 @@ export function analyzeTree(tree) {
export function renderFailuresJson(tree, opts = {}) {
const analysis = analyzeTree(tree);
return {
- schemaVersion: '0.2',
+ schemaVersion: SCHEMA_VERSION,
project: projectBlock(opts),
summary: analysis.summary,
failures: analysis.failures,
src/cli.js +21 -15
@@ -22,7 +22,7 @@ import { makeTitle } from './extract.js';
import { renderHallucinationsJson } from './hallucinate.js';
import { renderSecurityReport } from './security-report.js';
import { startMcpServer } from './mcp.js';
-import { c, plural, truncate } from './util.js';
+import { c, plural, truncate, TreetraceError, ExitCode } from './util.js';
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -66,7 +66,10 @@ Options:
Every export passes a redaction gate: detected secrets must be resolved
(redact/keep/edit) before anything is written. Outside a terminal, every
-hit is redacted automatically - treetrace fails closed.`;
+hit is redacted automatically - treetrace fails closed.
+
+Exit codes: 0 ok, 1 generic error, 2 usage error, 3 nothing to trace,
+4 redaction gate refused to write an unresolved secret.`;
export async function main(argv) {
const opts = parseArgs(argv);
@@ -210,10 +213,11 @@ export async function loadRedactedTree(opts, projectDir, projectName, log = () =
? found.filter((s) => s.mtimeMs >= Date.parse(opts.since))
: found;
if (!filtered.length) {
- throw new Error(
+ throw new TreetraceError(
`no Claude Code sessions found for ${projectDir}.\n` +
`Looked in ~/.claude/projects/ for sessions started from this directory.\n` +
- `Use --file <transcript> or --stdin to import a transcript directly.`
+ `Use --file <transcript> or --stdin to import a transcript directly.`,
+ ExitCode.NO_DATA
);
}
const totalMB = filtered.reduce((a, s) => a + s.sizeBytes, 0) / 1048576;
@@ -230,16 +234,17 @@ export async function loadRedactedTree(opts, projectDir, projectName, log = () =
if (opts.since) {
sessions = sessions.filter((s) => s.lastTs && s.lastTs >= opts.since);
if (!sessions.length) {
- throw new Error(
+ throw new TreetraceError(
`no sessions on or after ${opts.since}. --since only applies to timestamped sessions; ` +
- `plain transcripts carry no timestamps and are excluded when --since is set.`
+ `plain transcripts carry no timestamps and are excluded when --since is set.`,
+ ExitCode.NO_DATA
);
}
}
const nodes = classifyPrompts(sessions);
if (!nodes.length) {
- throw new Error('no human prompts found in these sessions, nothing to trace.');
+ throw new TreetraceError('no human prompts found in these sessions, nothing to trace.', ExitCode.NO_DATA);
}
const tree = buildTree(sessions, nodes);
@@ -404,10 +409,11 @@ function requestedArtifacts(opts, artifacts) {
export function assertClean(rendered, decisions, label) {
const leaks = shadowScan(rendered, decisions);
if (leaks.length) {
- throw new Error(
+ throw new TreetraceError(
`shadow scan found ${plural(leaks.length, 'unresolved secret')} in the rendered ${label} ` +
`(${[...new Set(leaks.map((l) => l.ruleId))].join(', ')}). Refusing to write. ` +
- `This is a bug worth reporting; as a workaround run interactively to resolve hits.`
+ `This is a bug worth reporting; as a workaround run interactively to resolve hits.`,
+ ExitCode.WOULD_LEAK
);
}
}
@@ -531,7 +537,7 @@ export function parseArgs(argv) {
const requireValue = (flag) => {
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
- throw new Error(`${flag} requires a value`);
+ throw new TreetraceError(`${flag} requires a value`, ExitCode.USAGE);
}
return argv[++i];
};
@@ -540,7 +546,7 @@ export function parseArgs(argv) {
switch (a) {
case '--file':
if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) {
- throw new Error('--file requires at least one path');
+ throw new TreetraceError('--file requires at least one path', ExitCode.USAGE);
}
while (argv[i + 1] && !argv[i + 1].startsWith('--')) opts.files.push(argv[++i]);
break;
@@ -567,7 +573,7 @@ export function parseArgs(argv) {
case '--from':
opts.from = requireValue('--from');
if (!TOOLS.includes(opts.from)) {
- throw new Error(`unknown --from value "${opts.from}" (expected one of: ${TOOLS.join(', ')})`);
+ throw new TreetraceError(`unknown --from value "${opts.from}" (expected one of: ${TOOLS.join(', ')})`, ExitCode.USAGE);
}
break;
case '--dir': opts.dir = requireValue('--dir'); break;
@@ -576,15 +582,15 @@ export function parseArgs(argv) {
case '--since':
opts.since = requireValue('--since');
if (!/^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(opts.since) || Number.isNaN(Date.parse(opts.since))) {
- throw new Error(`--since expects a date like YYYY-MM-DD (got "${opts.since}")`);
+ throw new TreetraceError(`--since expects a date like YYYY-MM-DD (got "${opts.since}")`, ExitCode.USAGE);
}
break;
default:
- throw new Error(`unknown option ${a} (try --help)`);
+ throw new TreetraceError(`unknown option ${a} (try --help)`, ExitCode.USAGE);
}
}
if (opts.stdin && opts.from === 'claude') {
- throw new Error('--stdin cannot be combined with --from claude: Claude Code JSONL sessions are read from files. Use --file, or omit --from to paste a plain transcript.');
+ throw new TreetraceError('--stdin cannot be combined with --from claude: Claude Code JSONL sessions are read from files. Use --file, or omit --from to paste a plain transcript.', ExitCode.USAGE);
}
return opts;
}
src/config.js +2 -0
@@ -1,2 +1,4 @@
export const REPO_URL =
process.env.TREETRACE_REPO_URL || 'https://github.com/TreeTraceTool/TreeTrace';
+
+export const SCHEMA_VERSION = '0.2';
src/hallucinate.js +4 -3
@@ -1,6 +1,7 @@
import { readFileSync, existsSync, statSync } from 'node:fs';
import { isAbsolute, join, resolve, sep } from 'node:path';
import { truncate } from './util.js';
+import { SCHEMA_VERSION } from './config.js';
const NODE_BUILTINS = new Set([
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', 'constants',
@@ -286,7 +287,7 @@ function isRelativeOrLocalSpec(spec) {
export function detectHallucinations(tree, projectDir, opts = {}) {
const hallucinations = [];
if (!projectDir || !existsSync(projectDir)) {
- return { schemaVersion: '0.2', verifiedAgainstWorkingTree: false, hallucinations, summary: emptySummary() };
+ return { schemaVersion: SCHEMA_VERSION, verifiedAgainstWorkingTree: false, hallucinations, summary: emptySummary() };
}
const created = collectCreatedFiles(tree, projectDir);
@@ -336,7 +337,7 @@ export function detectHallucinations(tree, projectDir, opts = {}) {
}
return {
- schemaVersion: '0.2',
+ schemaVersion: SCHEMA_VERSION,
verifiedAgainstWorkingTree: true,
manifestSeen: hasManifest,
hallucinations,
@@ -360,7 +361,7 @@ function summarize(hallucinations) {
export function renderHallucinationsJson(tree, projectDir, opts = {}) {
const result = detectHallucinations(tree, projectDir, opts);
return {
- schemaVersion: '0.2',
+ schemaVersion: SCHEMA_VERSION,
project: { name: opts.projectName || null, generatedAt: opts.generatedAt || null },
verifiedAgainstWorkingTree: result.verifiedAgainstWorkingTree,
manifestSeen: result.manifestSeen || false,
src/mcp.js +14 -3
@@ -5,6 +5,9 @@ import { renderHandoff } from './handoff.js';
import { renderLessonsMarkdown, analyzeTree } from './analyze.js';
import { renderSecurityReport } from './security-report.js';
import { renderHallucinationsJson } from './hallucinate.js';
+import { renderJson } from './render-json.js';
+import { SCHEMA_VERSION } from './config.js';
+import { TreetraceError, ExitCode } from './util.js';
const PROTOCOL_VERSION = '2024-11-05';
const MAX_REQUEST_BYTES = 1048576;
@@ -30,6 +33,11 @@ const TOOL_DEFS = [
description: 'Compact regression cases derived from session corrections and hallucinated references. Read only.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
},
+ {
+ name: 'tree',
+ description: 'Full prompt-lineage tree as canonical JSON (nodes, stats, analysis). The structured counterpart to the Markdown reports. Read only.',
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
+ },
];
export async function startMcpServer({ argv, version }, io = {}) {
@@ -37,9 +45,10 @@ export async function startMcpServer({ argv, version }, io = {}) {
const output = io.output || process.stdout;
const opts = parseArgs((argv || []).filter((a) => a !== 'mcp' && a !== '--mcp'));
if (opts.stdin) {
- throw new Error(
+ throw new TreetraceError(
'treetrace mcp does not support --stdin: stdin is the JSON-RPC transport for the MCP server. ' +
- 'Point the server at a project with --dir, or import a transcript with --file.'
+ 'Point the server at a project with --dir, or import a transcript with --file.',
+ ExitCode.USAGE
);
}
const projectDir = resolve(opts.dir || process.cwd());
@@ -168,12 +177,14 @@ function renderTool(name, tree, renderOpts) {
const analysis = analyzeTree(tree);
const hall = renderHallucinationsJson(tree, renderOpts.projectDir || null, renderOpts);
const payload = {
- schemaVersion: '0.2',
+ schemaVersion: SCHEMA_VERSION,
evalCandidates: analysis.evalCandidates,
hallucinationEvalCandidates: hall.hallucinations.map((h) => h.evalCandidate),
};
return JSON.stringify(payload, null, 2);
}
+ case 'tree':
+ return JSON.stringify(renderJson(tree, renderOpts), null, 2);
default:
return '';
}
src/parse.js +4 -2
@@ -1,5 +1,6 @@
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
+import { TreetraceError, ExitCode } from './util.js';
const DAG_TYPES = new Set(['user', 'assistant', 'system', 'attachment']);
@@ -363,9 +364,10 @@ export function parsePlainTranscript(text, label = 'pasted-transcript') {
if (current && current.text.trim()) prompts.push(current);
if (!sawMarkers) {
- throw new Error(
+ throw new TreetraceError(
'could not find user/assistant turn markers in the transcript. ' +
- 'Expected lines like "User:", "## User", "Human:", "Assistant:" separating turns.'
+ 'Expected lines like "User:", "## User", "Human:", "Assistant:" separating turns.',
+ ExitCode.NO_DATA
);
}
src/render-json.js +2 -2
@@ -1,4 +1,4 @@
-import { REPO_URL } from './config.js';
+import { REPO_URL, SCHEMA_VERSION } from './config.js';
import { analyzeTree } from './analyze.js';
const RELATIONSHIP_BY_KIND = {
@@ -16,7 +16,7 @@ export function renderJson(tree, opts = {}) {
const analysis = analyzeTree(tree);
return {
- schemaVersion: '0.2',
+ schemaVersion: SCHEMA_VERSION,
generator: { name: generatedBy, version, url: REPO_URL },
project: {
name: projectName,
src/util.js +16 -0
@@ -85,3 +85,19 @@ export function escapeMdTags(text) {
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
+
+export const ExitCode = Object.freeze({
+ OK: 0,
+ ERROR: 1,
+ USAGE: 2,
+ NO_DATA: 3,
+ WOULD_LEAK: 4,
+});
+
+export class TreetraceError extends Error {
+ constructor(message, exitCode = ExitCode.ERROR) {
+ super(message);
+ this.name = 'TreetraceError';
+ this.exitCode = exitCode;
+ }
+}
test/treetrace.test.js +21 -1
@@ -1026,6 +1026,26 @@ test('security report and hallucinations.json do not leak injected secrets via t
}
});
+test('cli: structured exit codes for CI consumers', async () => {
+ const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'treetrace.js');
+ const run = (args) =>
+ new Promise((resolve) => {
+ const child = spawn('node', [bin, ...args], { stdio: ['ignore', 'ignore', 'pipe'] });
+ let stderr = '';
+ child.stderr.on('data', (d) => { stderr += d; });
+ child.on('close', (code) => resolve({ code, stderr }));
+ });
+ const empty = mkdtempSync(join(tmpdir(), 'treetrace-exit-'));
+ try {
+ const usage = await run(['--bogus']);
+ assert.equal(usage.code, 2, `bad option should exit 2 (got ${usage.code}): ${usage.stderr}`);
+ const nodata = await run(['--dir', empty]);
+ assert.equal(nodata.code, 3, `nothing-to-trace should exit 3 (got ${nodata.code}): ${nodata.stderr}`);
+ } finally {
+ rmSync(empty, { recursive: true, force: true });
+ }
+});
+
test('mcp: initialize, tools/list, and tools/call return well-formed JSON-RPC', async () => {
const dir = tempProject();
const convo = [{
@@ -1066,7 +1086,7 @@ test('mcp: initialize, tools/list, and tools/call return well-formed JSON-RPC',
const list = responses.find((r) => r.id === 2);
const names = list.result.tools.map((t) => t.name).sort();
- assert.deepEqual(names, ['eval_candidates', 'handoff', 'lessons', 'security_summary']);
+ assert.deepEqual(names, ['eval_candidates', 'handoff', 'lessons', 'security_summary', 'tree']);
const call = responses.find((r) => r.id === 3);
assert.ok(call.result && Array.isArray(call.result.content), 'tools/call must return content array');