@tangle-network/traces 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  appendAll,
9
9
  assembleSessionBundle,
10
10
  assertOutsideSourceBundle,
11
+ assessSpans,
11
12
  buildPolicyEvidenceRecord,
12
13
  buildSessionFactsReport,
13
14
  buildSessionIndexFromRows,
@@ -17,11 +18,13 @@ import {
17
18
  collectSessionSelection,
18
19
  commandAnalyzer,
19
20
  commandRedactor,
21
+ diffRuns,
20
22
  executeUpload,
21
23
  exportTraceEvidenceFile,
22
24
  externalFailureMessage,
23
25
  fileRunContextSupervisorRunReader,
24
26
  findingRejectionDetail,
27
+ formatVerdict,
25
28
  haloAnalyzer,
26
29
  hodoscopeAnalyzer,
27
30
  importCodeTraceBench,
@@ -34,14 +37,19 @@ import {
34
37
  locateSessions,
35
38
  mergeTracesConfig,
36
39
  normalizeTraceQuestions,
40
+ openAgenticTraceStore,
41
+ parseIsoToEpochMs,
37
42
  parseSession,
38
43
  parseSince,
39
44
  planUpload,
40
45
  primeAnalyzer,
41
46
  projectSessionBundle,
47
+ readJsonl,
42
48
  readOtlpInput,
43
49
  readSessionIndexFile,
50
+ redactSpans,
44
51
  renderInspectionReport,
52
+ renderRunDiff,
45
53
  renderSessionFacts,
46
54
  renderValidation,
47
55
  resolveAdapter,
@@ -57,26 +65,31 @@ import {
57
65
  serializeTraceStreamEvent,
58
66
  sessionReportSource,
59
67
  streamSessions,
68
+ toolArgumentsFromAttributes,
60
69
  totalAgenticFailureMessage,
61
70
  traceStreamEventsFromSpans,
71
+ tracesVersion,
62
72
  validationExitCode,
73
+ verdictExitCode,
63
74
  verifyFindingsInSandbox,
75
+ verifySafe,
64
76
  verifySessionBundle,
65
77
  watchRunTarget,
66
78
  watchSessions,
79
+ writeAnalysisTraceFile,
67
80
  writeInspectionReportFile,
68
81
  writeOtlpFile,
69
82
  writePolicyEvidenceFile,
70
83
  writeSessionIndexFile,
71
84
  writeTraceEvidenceExportFile,
72
85
  writeTraceQuestionsArtifacts
73
- } from "./chunk-SKOW5V5U.js";
86
+ } from "./chunk-G5KKIQZ6.js";
74
87
 
75
88
  // src/cli.ts
76
89
  import { readFileSync } from "fs";
77
- import { mkdir, mkdtemp, readFile, stat, writeFile } from "fs/promises";
90
+ import { mkdir as mkdir2, mkdtemp, readFile as readFile2, stat, writeFile as writeFile2 } from "fs/promises";
78
91
  import { tmpdir } from "os";
79
- import { basename, join, resolve } from "path";
92
+ import { basename, join as join2, resolve } from "path";
80
93
  import {
81
94
  parseCorpusFlag,
82
95
  renderVerifiedFindingsSection
@@ -89,11 +102,306 @@ import {
89
102
  renderSupervisorRunMarkdown,
90
103
  rollupSupervisorRuns
91
104
  } from "@tangle-network/agent-eval/supervisor-run";
92
- function packageVersion() {
93
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
94
- if (typeof pkg.version !== "string" || !pkg.version) throw new Error("package.json is missing version");
95
- return pkg.version;
105
+
106
+ // src/mcp.ts
107
+ import { rm } from "fs/promises";
108
+ import { dirname } from "path";
109
+ import { buildTraceAnalysisToolDescriptors, redact, shareAllowed, UNTRUSTED_TRACE_TEXT } from "@tangle-network/agent-eval/traces";
110
+ import { createStdioToolServer } from "@tangle-network/agent-runtime/mcp";
111
+ var MCP_RESULT_BYTE_CAP = 512 * 1024;
112
+ async function traceMcpTools(options) {
113
+ const { spans } = redactSpans(options.spans);
114
+ const verdict = assessSpans(spans);
115
+ if (!shareAllowed(verdict)) {
116
+ const findings = verdict.findings.map((f) => `${f.category}/${f.detector} (${f.count})`).join(", ");
117
+ throw new Error(
118
+ `mcp: refusing to serve \u2014 redacted spans are still ${verdict.status}` + (findings ? `: ${findings}` : "") + (verdict.unreadable.length > 0 ? `; unreadable: ${verdict.unreadable.join("; ")}` : "")
119
+ );
120
+ }
121
+ const file = await writeAnalysisTraceFile(spans);
122
+ const store = await openAgenticTraceStore(file);
123
+ const descriptors = buildTraceAnalysisToolDescriptors({ store }).filter((d) => d.name !== "readSpanSource");
124
+ const tools = descriptors.map((descriptor) => ({
125
+ name: descriptor.name,
126
+ description: descriptor.description,
127
+ inputSchema: descriptor.parameters,
128
+ annotations: {
129
+ readOnlyHint: descriptor.readOnly,
130
+ idempotentHint: descriptor.idempotent,
131
+ destructiveHint: false,
132
+ openWorldHint: false
133
+ },
134
+ handler: async (args) => {
135
+ const { value } = redact(await descriptor.handler(args));
136
+ const result = { untrusted: UNTRUSTED_TRACE_TEXT, result: value };
137
+ const bytes = Buffer.byteLength(JSON.stringify(result));
138
+ if (bytes > MCP_RESULT_BYTE_CAP) {
139
+ throw new RangeError(
140
+ `${descriptor.name}: result is ${bytes} bytes, above the ${MCP_RESULT_BYTE_CAP}-byte cap; narrow the request`
141
+ );
142
+ }
143
+ return result;
144
+ }
145
+ }));
146
+ return { tools, otlpPath: file.otlpPath };
147
+ }
148
+ async function serveTraceMcp(options) {
149
+ const { tools, otlpPath } = await traceMcpTools(options);
150
+ const server = createStdioToolServer({ serverName: "traces", serverVersion: tracesVersion(), tools });
151
+ try {
152
+ await server.serve();
153
+ } finally {
154
+ await rm(dirname(otlpPath), { recursive: true, force: true });
155
+ }
156
+ }
157
+
158
+ // src/cli.ts
159
+ import { explainTraceContract as explainTraceContract2 } from "@tangle-network/agent-eval";
160
+
161
+ // src/check.ts
162
+ import { mkdir, readFile, writeFile } from "fs/promises";
163
+ import { join } from "path";
164
+ import {
165
+ compileTraceContractSpec,
166
+ evaluateTraceContract,
167
+ explainTraceContract,
168
+ lintTraceContractSpec
169
+ } from "@tangle-network/agent-eval";
170
+ var CHECK_EXIT = Object.freeze({
171
+ pass: 0,
172
+ fail: 1,
173
+ /** The contract is malformed or contradicts itself, or a rule could not run. */
174
+ badContract: 2,
175
+ unreadable: 3,
176
+ ambiguous: 4
177
+ });
178
+ var AmbiguousTraceInputError = class extends Error {
179
+ name = "AmbiguousTraceInputError";
180
+ };
181
+ var ContractFileError = class extends Error {
182
+ name = "ContractFileError";
183
+ };
184
+ async function loadContract(path) {
185
+ let text;
186
+ try {
187
+ text = await readFile(path, "utf8");
188
+ } catch (error) {
189
+ throw new ContractFileError(`cannot read contract ${path}: ${errorText(error)}`);
190
+ }
191
+ let json;
192
+ try {
193
+ json = JSON.parse(text);
194
+ } catch (error) {
195
+ throw new ContractFileError(`contract ${path} is not JSON: ${errorText(error)}`);
196
+ }
197
+ try {
198
+ const findings = lintTraceContractSpec(json);
199
+ const errors = findings.filter((f) => f.level === "error");
200
+ if (errors.length > 0) {
201
+ throw new ContractFileError(
202
+ `contract ${path} contradicts itself:
203
+ ${errors.map((f) => ` ${f.path}: ${f.message} [${f.code}]`).join("\n")}`
204
+ );
205
+ }
206
+ return {
207
+ contract: compileTraceContractSpec(json),
208
+ warnings: findings.map((f) => `${f.path}: ${f.message} [${f.code}]`)
209
+ };
210
+ } catch (error) {
211
+ if (error instanceof ContractFileError) throw error;
212
+ throw new ContractFileError(`contract ${path}: ${errorText(error)}`);
213
+ }
214
+ }
215
+ var SNIFF_ROWS = 20;
216
+ async function sniffCheckInput(path) {
217
+ const rows = [];
218
+ for await (const row of readJsonl(path)) {
219
+ rows.push(row);
220
+ if (rows.length >= SNIFF_ROWS) break;
221
+ }
222
+ const objects = rows.filter(isObject);
223
+ if (objects.length === 0) throw new Error(`${path} holds no JSON rows`);
224
+ const kinds = [];
225
+ if (objects.every((r) => typeof r.trace_id === "string" && typeof r.span_id === "string")) kinds.push("otlp");
226
+ if (objects.some((r) => (typeof r.sessionId === "string" || typeof r.session_id === "string") && typeof r.type === "string") && !objects.some(hasSpanId)) {
227
+ kinds.push("claude-code");
228
+ }
229
+ if (objects.some((r) => CODEX_LINE_TYPES.has(String(r.type)) && isObject(r.payload))) kinds.push("codex");
230
+ if (objects.every(isEvidenceRow)) kinds.push("evidence");
231
+ if (kinds.length === 1) return kinds[0];
232
+ if (kinds.length === 0) {
233
+ throw new Error(`${path} is not a trace this command reads (OTLP spans, a Claude Code or Codex session, or trace evidence)`);
234
+ }
235
+ throw new AmbiguousTraceInputError(
236
+ `${path} reads as ${kinds.join(" and ")}; pass --format ${kinds.join(" or --format ")}`
237
+ );
238
+ }
239
+ var CODEX_LINE_TYPES = /* @__PURE__ */ new Set(["session_meta", "response_item", "event_msg", "turn_context"]);
240
+ function hasSpanId(row) {
241
+ return typeof row.span_id === "string";
242
+ }
243
+ function isEvidenceRow(row) {
244
+ if (hasSpanId(row)) return false;
245
+ if (row.kind === "traces.policy_evidence.session") return true;
246
+ if (Array.isArray(row.messages) || typeof row.role === "string" && "content" in row) return true;
247
+ return typeof row.trace_id === "string" && typeof row.name === "string" && isObject(row.attributes);
248
+ }
249
+ function contractSpansFromOtlp(spans) {
250
+ return spans.map((span) => {
251
+ const startedAt = epochMs(span.start_time);
252
+ const endedAt = epochMs(span.end_time);
253
+ const args = toolArgumentsFromAttributes(span.attributes);
254
+ const truncated = span.attributes["traces.input.truncated"] === true;
255
+ return {
256
+ spanId: span.span_id,
257
+ parentSpanId: span.parent_span_id,
258
+ name: span.name,
259
+ ...startedAt === void 0 ? {} : { startedAt },
260
+ ...endedAt === void 0 ? {} : { endedAt },
261
+ ...span.status.code === "ERROR" ? { status: "error" } : span.status.code === "OK" ? { status: "ok" } : {},
262
+ // A truncated argument capture is not the call's arguments.
263
+ ...args.argsCaptured && !truncated ? { args: args.args } : { argsCaptured: false },
264
+ attributes: span.attributes
265
+ };
266
+ });
96
267
  }
268
+ function epochMs(value) {
269
+ try {
270
+ return parseIsoToEpochMs(value);
271
+ } catch {
272
+ return void 0;
273
+ }
274
+ }
275
+ function groupByTrace(spans) {
276
+ const groups = /* @__PURE__ */ new Map();
277
+ for (const span of spans) {
278
+ const list = groups.get(span.trace_id) ?? [];
279
+ list.push(span);
280
+ groups.set(span.trace_id, list);
281
+ }
282
+ return [...groups].map(([traceId, list]) => ({ traceId, spans: list }));
283
+ }
284
+ async function runCheck(contract, spans, opts) {
285
+ const traces = [];
286
+ for (const group of groupByTrace(spans)) {
287
+ const verdict = evaluateTraceContract(contract, contractSpansFromOtlp(group.spans));
288
+ const check = { traceId: group.traceId, spanCount: group.spans.length, verdict };
289
+ if (verdict.status !== "pass") {
290
+ check.evidenceDir = await writeEvidence(opts.evidenceRoot, contract, group.traceId, group.spans, verdict);
291
+ }
292
+ traces.push(check);
293
+ }
294
+ const status = traces.some((t) => t.verdict.status === "error") ? "error" : traces.every((t) => t.verdict.status === "pass") ? "pass" : "fail";
295
+ const exitCode = status === "pass" ? CHECK_EXIT.pass : status === "fail" ? CHECK_EXIT.fail : CHECK_EXIT.badContract;
296
+ return { contract: contract.name, status, exitCode, traces };
297
+ }
298
+ async function writeEvidence(root, contract, traceId, spans, verdict) {
299
+ const dir = join(root, `${safeName(contract.name)}-${safeName(traceId).slice(0, 40)}`);
300
+ await mkdir(dir, { recursive: true });
301
+ const cited = new Set(verdict.violations.map((v) => v.spanId).filter((id) => id !== void 0));
302
+ const redacted = redactSpans(spans.filter((s) => cited.has(s.span_id)));
303
+ await writeFile(join(dir, "verdict.json"), `${JSON.stringify({ traceId, verdict }, null, 2)}
304
+ `);
305
+ await writeFile(join(dir, "contract.json"), `${JSON.stringify(contract, null, 2)}
306
+ `);
307
+ await writeFile(join(dir, "explain.txt"), `${explainTraceContract(contract)}
308
+ `);
309
+ await writeFile(join(dir, "cited-spans.jsonl"), redacted.spans.map((s) => JSON.stringify(s)).join("\n") + (redacted.spans.length ? "\n" : ""));
310
+ return dir;
311
+ }
312
+ function safeName(value) {
313
+ return value.replace(/[^A-Za-z0-9._-]+/g, "_");
314
+ }
315
+ function ruleLines(verdict) {
316
+ const branchPassed = /* @__PURE__ */ new Map();
317
+ for (const e of verdict.ruleExecutions) {
318
+ if (e.alternative === void 0) continue;
319
+ branchPassed.set(e.alternative, (branchPassed.get(e.alternative) ?? true) && e.status === "pass");
320
+ }
321
+ const anyBranchPassed = [...branchPassed.values()].some(Boolean);
322
+ return verdict.ruleExecutions.map((e) => {
323
+ const rule = e.alternative ? `${e.alternative}/${e.rule}` : e.rule;
324
+ const status = e.alternative && anyBranchPassed && !branchPassed.get(e.alternative) ? "skipped" : e.status;
325
+ return {
326
+ rule,
327
+ status,
328
+ ...e.error === void 0 ? {} : { error: e.error },
329
+ details: verdict.violations.filter((x) => x.rule === rule).map((x) => x.detail)
330
+ };
331
+ });
332
+ }
333
+ function renderCheckText(report) {
334
+ const lines = [];
335
+ for (const t of report.traces) {
336
+ const v = t.verdict;
337
+ lines.push(`${v.status.toUpperCase()} contract ${report.contract} trace ${t.traceId} (${t.spanCount} spans, ${v.notes})`);
338
+ for (const r of ruleLines(v)) {
339
+ lines.push(` ${r.status.padEnd(7)} ${r.rule}${r.error ? ` ${r.error}` : ""}`);
340
+ if (r.status === "fail") for (const detail of r.details.slice(0, 5)) lines.push(` ${detail}`);
341
+ }
342
+ if (t.evidenceDir) lines.push(` evidence \u2192 ${t.evidenceDir}`);
343
+ }
344
+ return lines.join("\n");
345
+ }
346
+ function renderJUnit(report) {
347
+ const suites = report.traces.map((t) => {
348
+ const rules = ruleLines(t.verdict);
349
+ const count = (status) => rules.filter((r) => r.status === status).length;
350
+ const cases = rules.map((r) => {
351
+ const open = ` <testcase classname="${xml(report.contract)}" name="${xml(r.rule)}">`;
352
+ if (r.status === "pass") return `${open}</testcase>`;
353
+ if (r.status === "skipped") return `${open}
354
+ <skipped message="another alternative passed"/>
355
+ </testcase>`;
356
+ if (r.status === "error") {
357
+ return `${open}
358
+ <error message="${xml(r.error ?? "rule could not be evaluated")}"/>
359
+ </testcase>`;
360
+ }
361
+ return `${open}
362
+ <failure message="${xml(r.details[0] ?? "rule failed")}">${xml(r.details.join("\n"))}</failure>
363
+ </testcase>`;
364
+ });
365
+ return [
366
+ ` <testsuite name="${xml(`${report.contract} @ ${t.traceId}`)}" tests="${rules.length}" failures="${count("fail")}" errors="${count("error")}" skipped="${count("skipped")}">`,
367
+ ...cases,
368
+ " </testsuite>"
369
+ ].join("\n");
370
+ });
371
+ return `<?xml version="1.0" encoding="UTF-8"?>
372
+ <testsuites name="traces check">
373
+ ${suites.join("\n")}
374
+ </testsuites>
375
+ `;
376
+ }
377
+ function renderGithubAnnotations(report) {
378
+ const lines = [];
379
+ for (const t of report.traces) {
380
+ for (const r of ruleLines(t.verdict)) {
381
+ if (r.status === "pass" || r.status === "skipped") continue;
382
+ const detail = r.status === "error" ? `could not be evaluated: ${r.error ?? "unknown"}` : r.details.join("\n");
383
+ lines.push(`::error title=${ghProperty(`${report.contract}: ${r.rule}`)}::${ghData(`trace ${t.traceId}: ${detail}`)}`);
384
+ }
385
+ }
386
+ return lines.join("\n");
387
+ }
388
+ function xml(value) {
389
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
390
+ }
391
+ function ghData(value) {
392
+ return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
393
+ }
394
+ function ghProperty(value) {
395
+ return ghData(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
396
+ }
397
+ function isObject(value) {
398
+ return typeof value === "object" && value !== null && !Array.isArray(value);
399
+ }
400
+ function errorText(error) {
401
+ return error instanceof Error ? error.message : String(error);
402
+ }
403
+
404
+ // src/cli.ts
97
405
  function parseArgs(argv) {
98
406
  const a = {
99
407
  command: argv[0] === "--help" || argv[0] === "-h" ? "help" : argv[0] ?? "help",
@@ -123,7 +431,9 @@ function parseArgs(argv) {
123
431
  concurrency: 4,
124
432
  verifyFindings: false,
125
433
  replayCorpora: [],
126
- questions: []
434
+ questions: [],
435
+ annotations: false,
436
+ explain: false
127
437
  };
128
438
  for (let i = 1; i < argv.length; i++) {
129
439
  const arg = argv[i];
@@ -280,6 +590,21 @@ function parseArgs(argv) {
280
590
  case "--format":
281
591
  a.format = next();
282
592
  break;
593
+ case "--contract":
594
+ a.contract = next();
595
+ break;
596
+ case "--junit":
597
+ a.junit = next();
598
+ break;
599
+ case "--evidence":
600
+ a.evidence = next();
601
+ break;
602
+ case "--annotations":
603
+ a.annotations = true;
604
+ break;
605
+ case "--explain":
606
+ a.explain = true;
607
+ break;
283
608
  case "--help":
284
609
  case "-h":
285
610
  a.help = true;
@@ -296,22 +621,24 @@ function parseArgs(argv) {
296
621
  }
297
622
  return a;
298
623
  }
299
- var OTLP_INPUT_COMMANDS = /* @__PURE__ */ new Set(["analyze", "facts", "investigate", "improve", "ask", "stream", "validate"]);
300
- function migrateWritingOtlpFlag(args) {
301
- if (args.otlp === void 0 || OTLP_INPUT_COMMANDS.has(args.command)) return args;
302
- if (args.otlpOut !== void 0) {
624
+ var OTLP_INPUT_COMMANDS = /* @__PURE__ */ new Set([
625
+ "analyze",
626
+ "check",
627
+ "facts",
628
+ "investigate",
629
+ "improve",
630
+ "ask",
631
+ "stream",
632
+ "validate",
633
+ "mcp"
634
+ ]);
635
+ function validateOtlpSelection(args) {
636
+ if (args.otlp === void 0) return args;
637
+ if (!OTLP_INPUT_COMMANDS.has(args.command)) {
303
638
  throw new Error(
304
- `${args.command} was given both --otlp and --otlp-out. --otlp is the deprecated spelling of --otlp-out on writing commands; pass only --otlp-out.`
639
+ `${args.command} does not read --otlp. --otlp reads OTLP-JSONL on ${[...OTLP_INPUT_COMMANDS].join(", ")}; use --otlp-out <path> to write the artifact.`
305
640
  );
306
641
  }
307
- console.error(
308
- `warning: --otlp is deprecated on \`${args.command}\` and will be removed in 0.12. --otlp now READS OTLP-JSONL; use --otlp-out <path> to write the artifact. Treating it as --otlp-out for this run.`
309
- );
310
- return { ...args, otlpOut: args.otlp, otlp: void 0 };
311
- }
312
- function validateOtlpSelection(raw) {
313
- const args = migrateWritingOtlpFlag(raw);
314
- if (args.otlp === void 0) return args;
315
642
  if (args.input) throw new Error("--otlp cannot be combined with an input file");
316
643
  if (args.session) throw new Error("--otlp reads a file and cannot be combined with --session");
317
644
  if (args.supervisorRunDir) throw new Error("--otlp cannot be combined with --supervisor-run-dir");
@@ -448,7 +775,7 @@ async function resolveSelectedSession(args) {
448
775
  `no ${args.all ? "selected" : adapter.harness} session with ID "${args.session}"; run \`traces list\` to select an ID, or pass an explicit session path`
449
776
  );
450
777
  }
451
- throw new Error(
778
+ throw new AmbiguousTraceInputError(
452
779
  `session ID "${args.session}" is ambiguous across ${matches.length} files; select one harness or pass its explicit session path`
453
780
  );
454
781
  }
@@ -531,7 +858,7 @@ async function collectSpans(args) {
531
858
  for (const output of [args.out, args.dir, args.otlpOut]) {
532
859
  if (output) await assertOutsideSourceBundle(args.sourceBundle, output);
533
860
  }
534
- return collectOtlpSpans(join(args.sourceBundle, "derived", "trace.otlp.jsonl"));
861
+ return collectOtlpSpans(join2(args.sourceBundle, "derived", "trace.otlp.jsonl"));
535
862
  }
536
863
  if (args.otlp) return collectOtlpSpans(args.otlp);
537
864
  if (args.input) {
@@ -583,6 +910,62 @@ async function cmdValidate(args) {
583
910
  }
584
911
  process.exitCode = validationExitCode(validation);
585
912
  }
913
+ var EVIDENCE_FORMATS = /* @__PURE__ */ new Set(["policy-evidence", "sandbox-events", "openinference", "intelligence-spans", "chat-trajectory"]);
914
+ async function checkInputArgs(args) {
915
+ if (!args.input) return args;
916
+ if (args.otlp || args.session) throw new Error("name the trace once: a positional path, --otlp, or --session");
917
+ const info = await stat(args.input);
918
+ let kind;
919
+ if (args.format && EVIDENCE_FORMATS.has(args.format)) kind = "evidence";
920
+ else if (args.format === "otlp" || args.format === "claude-code" || args.format === "codex") kind = args.format;
921
+ else if (args.format) throw new Error(`unknown --format "${args.format}" (otlp, claude-code, codex, or an evidence format)`);
922
+ else kind = info.isDirectory() ? "otlp" : await sniffCheckInput(args.input);
923
+ if (kind === "otlp") return { ...args, otlp: args.input, input: void 0 };
924
+ if (kind === "evidence") return args;
925
+ return { ...args, harness: kind, harnessExplicit: true, session: args.input, input: void 0 };
926
+ }
927
+ async function cmdCheck(args) {
928
+ if (!args.contract) {
929
+ process.stderr.write("error: check needs --contract <contract.json>\n");
930
+ process.exitCode = CHECK_EXIT.badContract;
931
+ return;
932
+ }
933
+ let loaded;
934
+ try {
935
+ loaded = await loadContract(args.contract);
936
+ } catch (error) {
937
+ process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}
938
+ `);
939
+ process.exitCode = CHECK_EXIT.badContract;
940
+ return;
941
+ }
942
+ for (const warning of loaded.warnings) process.stderr.write(`warning: ${warning}
943
+ `);
944
+ if (args.explain) {
945
+ console.log(explainTraceContract2(loaded.contract));
946
+ return;
947
+ }
948
+ let spans;
949
+ try {
950
+ spans = (await collectSpans(await checkInputArgs(args))).spans;
951
+ if (spans.length === 0) throw new Error("the trace holds no spans");
952
+ } catch (error) {
953
+ process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}
954
+ `);
955
+ process.exitCode = error instanceof AmbiguousTraceInputError ? CHECK_EXIT.ambiguous : CHECK_EXIT.unreadable;
956
+ return;
957
+ }
958
+ const report = await runCheck(loaded.contract, spans, { evidenceRoot: args.evidence ?? join2(".traces", "check") });
959
+ console.log(renderCheckText(report));
960
+ if (args.junit) await writeFile2(args.junit, renderJUnit(report), "utf8");
961
+ if (args.out) await writeFile2(args.out, `${JSON.stringify(report, null, 2)}
962
+ `, "utf8");
963
+ if (args.annotations || process.env.GITHUB_ACTIONS === "true") {
964
+ const annotations = renderGithubAnnotations(report);
965
+ if (annotations) console.log(annotations);
966
+ }
967
+ process.exitCode = report.exitCode;
968
+ }
586
969
  async function cmdConvert(args) {
587
970
  const { spans, workflow } = await collectSpans(args);
588
971
  if (spans.length === 0) throw new Error("no spans found for the given selection");
@@ -686,6 +1069,73 @@ async function cmdBundle(args) {
686
1069
  `session bundle (${manifest.view} view) \u2192 ${result.directory} (${manifest.files.length} file(s), ${manifest.ledgerSlices.length} ledger slice(s), ${manifest.absent.length} recorded absent)`
687
1070
  );
688
1071
  }
1072
+ async function cmdMcp(args) {
1073
+ if (args.help) {
1074
+ process.stdout.write(`traces mcp \u2014 read-only MCP server over stdio for the selected traces
1075
+
1076
+ Usage:
1077
+ traces mcp --otlp <file|dir> any OTLP-JSONL
1078
+ traces mcp --harness claude-code --last 5 recent harness sessions (any selection flag)
1079
+
1080
+ Register with a client, for example:
1081
+ claude mcp add traces -- traces mcp --otlp ./spans.otlp.jsonl
1082
+
1083
+ Every tool is read-only and idempotent and says so in its MCP annotations.
1084
+ Spans (including causal links) and results are redacted, the server refuses
1085
+ to start if the redacted spans are still UNSAFE or UNKNOWN, each result
1086
+ carries an untrusted-text notice, and a result above 512 KiB is refused
1087
+ rather than truncated. readSpanSource is not served: windowed reads of
1088
+ original source bytes cannot be redacted.
1089
+ `);
1090
+ return;
1091
+ }
1092
+ const selected = validateOtlpSelection(validateWorkflowSelection(applyCurrentSessionSelection(args)));
1093
+ const collected = await collectSpans(selected);
1094
+ if (collected.spans.length === 0) throw new Error("mcp: no spans found for the given selection");
1095
+ warnIncompleteWorkflow(collected.workflow);
1096
+ process.stderr.write(`traces mcp: serving ${collected.spans.length} span(s) over stdio
1097
+ `);
1098
+ await serveTraceMcp({ spans: collected.spans });
1099
+ }
1100
+ async function cmdDiff(argv) {
1101
+ const inputs = [];
1102
+ const kinds = [];
1103
+ let format = "text";
1104
+ for (let i = 0; i < argv.length; i++) {
1105
+ const arg = argv[i];
1106
+ if (arg === "--help" || arg === "-h") {
1107
+ process.stdout.write(`traces diff \u2014 where two runs of one task first diverge
1108
+
1109
+ Usage:
1110
+ traces diff <a> <b> [--kind TOOL]... [--format text|json]
1111
+
1112
+ Each side is an OTLP file or directory, any file convert reads, file#<trace id>,
1113
+ or file#branch=<id> for one arm (agent.branch.id or agent.branch.arm, and every
1114
+ span below it \u2014 refused if the label names more than one subtree). Steps pair
1115
+ by span id, then position with the same name and kind, then name and kind
1116
+ anywhere. Exit 0 when the runs agree on outcome, 1 when they diverge, 2 when a
1117
+ side cannot be read, 3 when the steps line up with no divergence but neither
1118
+ side carries a definitive OK/ERROR status or a TOOL step \u2014 there is no
1119
+ recorded outcome to call agreement on.
1120
+ `);
1121
+ return;
1122
+ }
1123
+ if (arg === "--format") format = argv[++i] ?? "";
1124
+ else if (arg === "--kind") kinds.push(argv[++i] ?? "");
1125
+ else if (arg.startsWith("--")) throw new Error(`diff: unknown flag ${arg}`);
1126
+ else inputs.push(arg);
1127
+ }
1128
+ if (inputs.length !== 2) {
1129
+ throw new Error("diff needs two runs: traces diff <a> <b> (a file, a directory, file#<trace id> or file#branch=<id>)");
1130
+ }
1131
+ if (format !== "json" && format !== "text") throw new Error(`diff: --format must be json or text, got "${format}"`);
1132
+ if (kinds.some((kind) => !kind)) throw new Error("diff: --kind needs a span kind, e.g. --kind TOOL");
1133
+ const report = await diffRuns(inputs[0], inputs[1], { kinds });
1134
+ process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}
1135
+ ` : renderRunDiff(report));
1136
+ if (report.diff.firstDivergence) process.exitCode = 1;
1137
+ else if (report.caveat) process.exitCode = 3;
1138
+ }
689
1139
  async function cmdBundleVerify(argv) {
690
1140
  let dir;
691
1141
  let format = "text";
@@ -754,7 +1204,7 @@ async function cmdFacts(args) {
754
1204
  const rendered = format === "json" ? `${JSON.stringify(report, null, 2)}
755
1205
  ` : renderSessionFacts(report);
756
1206
  if (args.out) {
757
- await writeFile(args.out, rendered, "utf8");
1207
+ await writeFile2(args.out, rendered, "utf8");
758
1208
  console.log(`session facts \u2192 ${args.out} (${report.sessions.length} session(s), $0, no model call)`);
759
1209
  return;
760
1210
  }
@@ -850,7 +1300,7 @@ function parseAttributeValue(raw) {
850
1300
  async function loadExportAttributes(args) {
851
1301
  const attributes = {};
852
1302
  if (args.metadata) {
853
- const parsed = JSON.parse(await readFile(args.metadata, "utf8"));
1303
+ const parsed = JSON.parse(await readFile2(args.metadata, "utf8"));
854
1304
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
855
1305
  throw new Error("--metadata must point to a JSON object");
856
1306
  }
@@ -1075,8 +1525,8 @@ async function cmdAsk(args) {
1075
1525
  const collected = await collectSpans(args);
1076
1526
  if (collected.spans.length === 0) throw new Error("no spans found for the given selection");
1077
1527
  warnIncompleteWorkflow(collected.workflow);
1078
- const directory = resolve(args.dir ?? await mkdtemp(join(tmpdir(), "traces-ask-")));
1079
- await mkdir(directory, { recursive: true });
1528
+ const directory = resolve(args.dir ?? await mkdtemp(join2(tmpdir(), "traces-ask-")));
1529
+ await mkdir2(directory, { recursive: true });
1080
1530
  const controller = new AbortController();
1081
1531
  const interrupt = () => controller.abort(new Error("ask interrupted"));
1082
1532
  process.once("SIGINT", interrupt);
@@ -1090,7 +1540,7 @@ async function cmdAsk(args) {
1090
1540
  concurrency: args.concurrency,
1091
1541
  ...args.budget !== void 0 ? { budgetUsd: args.budget } : {},
1092
1542
  ...args.sourceBundle ? { sourceBundle: { path: args.sourceBundle } } : {},
1093
- otlpOutPath: args.otlpOut ?? join(directory, "traces.otlp.jsonl"),
1543
+ otlpOutPath: args.otlpOut ?? join2(directory, "traces.otlp.jsonl"),
1094
1544
  signal: controller.signal,
1095
1545
  log: analystLog
1096
1546
  });
@@ -1296,11 +1746,11 @@ async function cmdUpload(args) {
1296
1746
  const redactorParts = args.redactorCmd?.split(/\s+/).filter(Boolean) ?? [];
1297
1747
  const redactor = redactorParts[0] ? commandRedactor({ command: redactorParts[0], args: redactorParts.slice(1) }) : void 0;
1298
1748
  const candidates = plan.items;
1299
- const byRule = {};
1749
+ const byDetector = {};
1300
1750
  let totalRedactions = 0;
1301
1751
  for (const i of candidates) {
1302
1752
  totalRedactions += i.redaction.redactionCount;
1303
- for (const [r, n] of Object.entries(i.redaction.byRule)) byRule[r] = (byRule[r] ?? 0) + n;
1753
+ for (const [r, n] of Object.entries(i.redaction.byDetector)) byDetector[r] = (byDetector[r] ?? 0) + n;
1304
1754
  }
1305
1755
  const w = (s) => process.stderr.write(s);
1306
1756
  w(`
@@ -1308,7 +1758,7 @@ Window: since ${new Date(sinceMs).toISOString()}
1308
1758
  `);
1309
1759
  w(`Sessions found: ${plan.items.length} \xB7 final privacy-mode dedup runs before upload
1310
1760
  `);
1311
- w(`PII/secrets redacted: ${totalRedactions}${Object.keys(byRule).length ? ` (${Object.entries(byRule).map(([r, n]) => `${r}:${n}`).join(", ")})` : ""}
1761
+ w(`PII/secrets redacted: ${totalRedactions}${Object.keys(byDetector).length ? ` (${Object.entries(byDetector).map(([r, n]) => `${r}:${n}`).join(", ")})` : ""}
1312
1762
  `);
1313
1763
  for (const i of candidates.slice(0, 25)) {
1314
1764
  w(` + [${i.ref.harness}] ${i.ref.sessionId.slice(0, 8)} ${i.spans.length} spans ${i.redaction.redactionCount} redacted ${i.ref.cwd ?? ""}
@@ -1322,7 +1772,8 @@ Window: since ${new Date(sinceMs).toISOString()}
1322
1772
  }
1323
1773
  if (args.dryRun) {
1324
1774
  const res2 = await executeUpload(plan, { dryRun: true, otlpOut: args.otlpOut, stripContent: args.noContent, redactor });
1325
- console.log(`dry run: ${candidates.length - res2.skippedSessions} session(s), ${totalRedactions} redaction(s). Redacted OTLP -> ${res2.otlpPath}`);
1775
+ reportRefused(res2.refused);
1776
+ console.log(`dry run: ${candidates.length - res2.skippedSessions - res2.refused.length} session(s), ${totalRedactions} redaction(s). Redacted OTLP -> ${res2.otlpPath}`);
1326
1777
  console.log("No upload performed. Set TANGLE_INGEST_URL / TANGLE_INGEST_API_KEY / TANGLE_TENANT_ID and drop --dry-run to send.");
1327
1778
  return;
1328
1779
  }
@@ -1335,10 +1786,79 @@ Window: since ${new Date(sinceMs).toISOString()}
1335
1786
  }
1336
1787
  const res = await executeUpload(plan, { log: (m) => w(`${m}
1337
1788
  `), stripContent: args.noContent, redactor });
1789
+ reportRefused(res.refused);
1338
1790
  console.log(
1339
- `Uploaded ${res.uploadedSessions} session(s), ${res.acceptedSpans} spans accepted, ${res.redactionCount} redaction(s); ${res.skippedSessions} already-uploaded skipped.`
1791
+ `Uploaded ${res.uploadedSessions} session(s), ${res.acceptedSpans} spans accepted, ${res.redactionCount} redaction(s); ${res.skippedSessions} already-uploaded skipped; ${res.refused.length} refused.`
1340
1792
  );
1341
1793
  }
1794
+ function reportRefused(refused) {
1795
+ for (const { harness, sessionId, verdict } of refused) {
1796
+ process.stderr.write(` REFUSED [${harness}] ${sessionId.slice(0, 8)}: ${verdict.status}
1797
+ `);
1798
+ for (const finding of verdict.findings.filter((f) => f.severity === "error")) {
1799
+ process.stderr.write(` ${finding.category}/${finding.detector} x${finding.count} at ${finding.paths.join(", ")}
1800
+ `);
1801
+ }
1802
+ for (const entry of verdict.unreadable) process.stderr.write(` unread ${entry}
1803
+ `);
1804
+ }
1805
+ if (refused.length > 0) {
1806
+ process.stderr.write(`${refused.length} session(s) refused by the share-safety check and kept on this machine.
1807
+ `);
1808
+ process.exitCode = 1;
1809
+ }
1810
+ }
1811
+ async function cmdVerifySafe(argv) {
1812
+ let target;
1813
+ let format = "text";
1814
+ let profile = "share";
1815
+ const knownSecrets = [];
1816
+ for (let i = 0; i < argv.length; i++) {
1817
+ const arg = argv[i];
1818
+ if (arg === "--format") format = argv[++i] ?? "";
1819
+ else if (arg === "--profile") profile = argv[++i] ?? "";
1820
+ else if (arg === "--known-secret-env") {
1821
+ const name = argv[++i] ?? "";
1822
+ const value = process.env[name];
1823
+ if (!value) throw new Error(`verify-safe: environment variable ${name || "(missing name)"} is not set`);
1824
+ knownSecrets.push(value);
1825
+ } else if (arg === "--help" || arg === "-h") {
1826
+ usageVerifySafe();
1827
+ return;
1828
+ } else if (arg.startsWith("--")) throw new Error(`verify-safe: unknown flag ${arg}`);
1829
+ else if (target === void 0) target = arg;
1830
+ else throw new Error(`verify-safe: unexpected argument ${arg}`);
1831
+ }
1832
+ if (!target) throw new Error("verify-safe needs a file or directory: traces verify-safe <file|dir>");
1833
+ if (format !== "json" && format !== "text") throw new Error(`verify-safe: --format must be json or text, got "${format}"`);
1834
+ if (profile !== "default" && profile !== "share" && profile !== "strict") {
1835
+ throw new Error(`verify-safe: --profile must be default, share or strict, got "${profile}"`);
1836
+ }
1837
+ const result = await verifySafe(target, { profile, knownSecrets });
1838
+ process.stdout.write(format === "json" ? `${JSON.stringify(result, null, 2)}
1839
+ ` : `${formatVerdict(result)}
1840
+ `);
1841
+ process.exitCode = verdictExitCode(result);
1842
+ }
1843
+ function usageVerifySafe() {
1844
+ console.log(`traces verify-safe <file|dir> [--profile default|share|strict] [--known-secret-env NAME]... [--format text|json]
1845
+
1846
+ Check whether files are safe to share, without changing them. A directory is read
1847
+ recursively. .json files are parsed whole, .jsonl and .ndjson line by line, and
1848
+ any other file is scanned as text.
1849
+
1850
+ SAFE nothing found exit 0
1851
+ SAFE_WITH_WARNINGS only warnings (raw content, identifiers, media) exit 0
1852
+ UNSAFE a credential, or personal data under share/strict exit 1
1853
+ UNKNOWN a file, line or byte range could not be read exit 2
1854
+
1855
+ --profile default | share (default) | strict; see agent-eval docs/redaction.md
1856
+ --known-secret-env name of an environment variable holding a secret to look for in
1857
+ any encoding (repeatable); the value is never printed
1858
+ --format text (default) | json
1859
+
1860
+ Findings name the file, line and JSON Pointer, never the matched value.`);
1861
+ }
1342
1862
  function usageExport() {
1343
1863
  console.log(`traces export: convert trace evidence files to OpenInference JSONL
1344
1864
 
@@ -1365,7 +1885,7 @@ Examples:
1365
1885
  halo spans.openinference.jsonl --prompt "Analyze this trace slice" --max-turns 1
1366
1886
 
1367
1887
  Safety:
1368
- export runs the same local regex redaction used by upload before writing spans.
1888
+ export runs the same local redaction (agent-eval's redaction core) used by upload before writing spans.
1369
1889
  --metadata must be a JSON object; --attr key=value is repeatable and overrides matching metadata keys.`);
1370
1890
  }
1371
1891
  function usageImportCodeTraceBench() {
@@ -1434,6 +1954,14 @@ Two ways in:
1434
1954
 
1435
1955
  Commands:
1436
1956
  validate Report what a trace can and cannot answer (exit 1 on error findings)
1957
+ check Gate a run on a trace contract: check <trace> --contract <file.json>
1958
+ [--junit out.xml] [--out report.json] [--evidence dir] [--annotations].
1959
+ check --contract <file.json> --explain prints what the contract checks.
1960
+ <trace> is claude -p stream-json output, OTLP spans (file or dir),
1961
+ a Claude Code or Codex session file, or trace evidence; --format
1962
+ names it when the sniff cannot.
1963
+ Exit 0 pass, 1 fail, 2 bad contract or a rule could not run,
1964
+ 3 unreadable trace, 4 ambiguous trace reference
1437
1965
  list List discovered sessions
1438
1966
  analyze Run analyst suite + loop/waste pipelines over OTLP (--otlp), sessions,
1439
1967
  or an input file; names every analysis the spans could not support
@@ -1447,7 +1975,8 @@ Commands:
1447
1975
  facts Print the deterministic session-facts sheet for the selected
1448
1976
  sessions: tool calls excluding synthesized spans, subagent spawns
1449
1977
  with task names, human turns in order, the final message per task,
1450
- changed paths, first/last record times, and the harness token total.
1978
+ changed paths, first/last record times, the harness token total, and
1979
+ the first failure by agent-eval's fixed precedence (or none).
1451
1980
  No model call, no budget, $0. Every fact names the span ids it came
1452
1981
  from; a fact the spans cannot support is null with its reason.
1453
1982
  --format json (default) or text (exit 1 when a session cannot be read)
@@ -1458,6 +1987,17 @@ Commands:
1458
1987
  ledger sliced to the session window, git log, and a sha256 manifest
1459
1988
  (needs --session <id|path> and --out <new-dir>). This is the FULL
1460
1989
  view: it holds the session's own words, for a reader who cites them
1990
+ mcp Serve the selected traces to an MCP client over stdio: 7 read-only,
1991
+ idempotent trace tools; spans and results redacted, results capped
1992
+ and marked untrusted (traces mcp --help)
1993
+ diff <a> <b>
1994
+ Pair two runs' steps (by span id, then position with the same name
1995
+ and kind, then name and kind anywhere) and mark the first divergence:
1996
+ changed, replaced, only-in-a, only-in-b or reordered. Each side is an
1997
+ OTLP file or directory, any file convert reads, file#<trace id>, or
1998
+ file#branch=<id> for one arm (agent.branch.id or agent.branch.arm).
1999
+ --kind TOOL (repeatable) keeps only steps of that span kind.
2000
+ Exit 0 agree, 1 diverge, 2 unreadable (--format text|json)
1461
2001
  bundle verify <bundle-dir>
1462
2002
  Check a bundle against its manifest: every listed file present with
1463
2003
  its recorded size and SHA-256, no unlisted or non-regular file beside
@@ -1487,7 +2027,11 @@ Commands:
1487
2027
  stream Emit JSONL trace stream events for live visualizers or replay
1488
2028
  watch Online observer: tail active sessions, notify on loops + semantic findings
1489
2029
  (watch <target> tails ONE run tree instead: a run directory or a span file)
1490
- upload Redact + upload sessions in a time window to the Tangle Intelligence Platform
2030
+ upload Redact + upload sessions in a time window to the Tangle Intelligence Platform;
2031
+ a session whose final events are UNSAFE or UNKNOWN is refused (exit 1)
2032
+ verify-safe <file|dir>
2033
+ Say whether files are safe to share: SAFE, SAFE_WITH_WARNINGS, UNSAFE
2034
+ (exit 1) or UNKNOWN (exit 2). Read-only (--help for flags)
1491
2035
 
1492
2036
  Options:
1493
2037
  --harness <id> Harness or alias (default: claude-code). Known: ${knownHarnesses().join(", ")}
@@ -1513,9 +2057,8 @@ Options:
1513
2057
  A directory reads the OTLP files under it \u2014 only the otlp/
1514
2058
  subdirectory when the producer made one \u2014 and names the JSONL
1515
2059
  that is not OTLP instead of reading it as broken spans.
1516
- Supported by: validate, analyze, investigate, improve, ask, stream.
1517
- On a WRITING command it is the deprecated spelling of
1518
- --otlp-out; it still works, with a warning, until 0.12.
2060
+ Supported by: validate, analyze, facts, investigate, improve, ask,
2061
+ stream, mcp. Writing commands take --otlp-out.
1519
2062
  --source-bundle <dir> Analyze a retained full bundle; explicitly grant source-field reads.
1520
2063
  Available for analyze, investigate, improve, and ask.
1521
2064
  --otlp-out <path> WRITE the OTLP-JSONL artifact here (also evidence
@@ -1567,7 +2110,7 @@ Options:
1567
2110
  --min-loop <n> Min identical repeated calls to flag a loop (default 3)
1568
2111
  --dry-run upload: redact + dedup + preview, write OTLP, but do NOT send
1569
2112
  --no-content upload: strip prompt/response text; send metadata only
1570
- --redactor <cmd> upload: external PII scrubber (JSON array stdin\u2192stdout) after the regex pass
2113
+ --redactor <cmd> upload: external PII scrubber (JSON array stdin\u2192stdout) after the redaction core
1571
2114
  --yes, -y upload: skip the confirmation prompt
1572
2115
  --version, -v Print the installed traces version
1573
2116
  --help, -h Show help (use \`traces validate --help\`, \`traces export --help\`,
@@ -1585,7 +2128,7 @@ Upload env: TANGLE_INGEST_URL (or TANGLE_ORCHESTRATOR_URL), TANGLE_INGEST_API_KE
1585
2128
  async function main() {
1586
2129
  const rawArgs = process.argv.slice(2);
1587
2130
  if (rawArgs[0] === "--version" || rawArgs[0] === "-v" || rawArgs[0] === "version") {
1588
- console.log(`traces ${packageVersion()}`);
2131
+ console.log(`traces ${tracesVersion()}`);
1589
2132
  return;
1590
2133
  }
1591
2134
  if (rawArgs[0] === "replay-verify") {
@@ -1600,10 +2143,28 @@ async function main() {
1600
2143
  await cmdVerifyFindings(rawArgs.slice(1));
1601
2144
  return;
1602
2145
  }
2146
+ if (rawArgs[0] === "mcp") {
2147
+ await cmdMcp(parseArgs(rawArgs));
2148
+ return;
2149
+ }
2150
+ if (rawArgs[0] === "diff") {
2151
+ try {
2152
+ await cmdDiff(rawArgs.slice(1));
2153
+ } catch (err) {
2154
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
2155
+ `);
2156
+ process.exitCode = 2;
2157
+ }
2158
+ return;
2159
+ }
1603
2160
  if (rawArgs[0] === "bundle" && rawArgs[1] === "verify") {
1604
2161
  await cmdBundleVerify(rawArgs.slice(2));
1605
2162
  return;
1606
2163
  }
2164
+ if (rawArgs[0] === "verify-safe") {
2165
+ await cmdVerifySafe(rawArgs.slice(1));
2166
+ return;
2167
+ }
1607
2168
  const parsedArgs = parseArgs(rawArgs);
1608
2169
  if (parsedArgs.help) {
1609
2170
  if (parsedArgs.command === "import-codetracebench" || parsedArgs.command === "help" && parsedArgs.input === "import-codetracebench") usageImportCodeTraceBench();
@@ -1631,6 +2192,9 @@ async function main() {
1631
2192
  case "validate":
1632
2193
  await cmdValidate(args);
1633
2194
  break;
2195
+ case "check":
2196
+ await cmdCheck(args);
2197
+ break;
1634
2198
  case "investigate":
1635
2199
  await cmdInvestigate(args);
1636
2200
  break;
@@ -1683,5 +2247,5 @@ async function main() {
1683
2247
  main().catch((err) => {
1684
2248
  process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
1685
2249
  `);
1686
- process.exitCode = 1;
2250
+ process.exitCode = process.argv[2] === "check" ? CHECK_EXIT.badContract : 1;
1687
2251
  });