| @@ -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); | |
| }); |
| @@ -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); | |
| } | ||
| } | ||
| @@ -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, |
| @@ -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; | ||
| } |
| @@ -1,2 +1,4 @@ | ||
| export const REPO_URL = | ||
| process.env.TREETRACE_REPO_URL || 'https://github.com/TreeTraceTool/TreeTrace'; | ||
| + | ||
| + | export const SCHEMA_VERSION = '0.2'; |
| @@ -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, |
| @@ -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 ''; | ||
| } |
| @@ -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 | |
| ); | ||
| } | ||
| @@ -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, |
| @@ -85,3 +85,19 @@ export function escapeMdTags(text) { | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>'); | ||
| } | ||
| + | ||
| + | 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; | |
| + | } | |
| + | } |
| @@ -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'); |