crumbtrail-node 0.1.0 → 0.2.1

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/index.cjs CHANGED
@@ -123,6 +123,7 @@ __export(index_exports, {
123
123
  cloudWatchEvidenceProvider: () => cloudWatchEvidenceProvider,
124
124
  cloudflareEvidenceProvider: () => cloudflareEvidenceProvider,
125
125
  compareSessions: () => compareSessions,
126
+ comparisonTitle: () => comparisonTitle,
126
127
  createCrumbtrailExpressErrorMiddleware: () => createCrumbtrailExpressErrorMiddleware,
127
128
  createCrumbtrailExpressMiddleware: () => createCrumbtrailExpressMiddleware,
128
129
  createServer: () => createServer,
@@ -172,6 +173,7 @@ __export(index_exports, {
172
173
  renderProviderReadme: () => renderProviderReadme,
173
174
  resolveDbRequestContext: () => resolveDbRequestContext,
174
175
  sentryEvidenceProvider: () => sentryEvidenceProvider,
176
+ sessionRefLabel: () => sessionRefLabel,
175
177
  signSigV4: () => signSigV4,
176
178
  splunkEvidenceProvider: () => splunkEvidenceProvider,
177
179
  splunkSearchDeepLink: () => splunkSearchDeepLink,
@@ -1298,21 +1300,139 @@ function inferIntent(evidence, commits) {
1298
1300
  var FUSION_SCHEMA_VERSION = "fusion.v1";
1299
1301
  function assembleBundle(input) {
1300
1302
  const evidence = rankEvidence(input.symptom, input.evidence);
1301
- const hypotheses = classifyHypotheses(
1303
+ const classified = classifyHypotheses(
1302
1304
  input.symptom,
1303
1305
  input.evidence,
1304
1306
  input.intent
1305
1307
  );
1306
1308
  const gaps = input.gaps ?? [];
1309
+ const located = input.located;
1310
+ const hypotheses = classified.map((hypothesis) => {
1311
+ const verification = deriveVerification(hypothesis, evidence);
1312
+ return verification.length > 0 ? { ...hypothesis, verification } : hypothesis;
1313
+ });
1314
+ const contextCompleteness = deriveContextCompleteness(
1315
+ evidence,
1316
+ gaps,
1317
+ hypotheses,
1318
+ located
1319
+ );
1320
+ const escalation = deriveEscalation(contextCompleteness, hypotheses);
1307
1321
  return {
1308
1322
  schemaVersion: FUSION_SCHEMA_VERSION,
1309
1323
  symptom: input.symptom,
1310
1324
  evidence,
1311
1325
  opinion: { stance: "advisory", hypotheses },
1312
1326
  gaps,
1313
- directives: suggestCaptureDirectives(input.symptom, input.evidence, gaps)
1327
+ directives: suggestCaptureDirectives(input.symptom, input.evidence, gaps),
1328
+ contextCompleteness,
1329
+ escalation,
1330
+ ...located ? { located } : {}
1314
1331
  };
1315
1332
  }
1333
+ function clamp01(value) {
1334
+ if (Number.isNaN(value)) return 0;
1335
+ return Math.max(0, Math.min(1, value));
1336
+ }
1337
+ var COMPLETENESS_LANES = [
1338
+ "network",
1339
+ "db",
1340
+ "flow",
1341
+ "browser",
1342
+ "env"
1343
+ ];
1344
+ function deriveContextCompleteness(evidence, gaps, hypotheses, located) {
1345
+ const lanesPresent = new Set(evidence.map((item) => item.lane));
1346
+ const informativePresent = COMPLETENESS_LANES.filter(
1347
+ (lane) => lanesPresent.has(lane)
1348
+ );
1349
+ const breadth = Math.min(1, informativePresent.length / 3);
1350
+ const volume = Math.min(1, evidence.length / 5);
1351
+ const top = hypotheses[0];
1352
+ const hypothesisStrength = !top || top.kind === "inconclusive" ? 0 : top.confidence;
1353
+ let score = 0.4 * breadth + 0.25 * volume + 0.35 * hypothesisStrength;
1354
+ if (located) {
1355
+ score = located.outcome === "matched" ? 0.85 * score + 0.15 * clamp01(located.confidence) : score * 0.6;
1356
+ }
1357
+ const hardGaps = gaps.filter((gap) => gap.kind === "source-unavailable").length;
1358
+ const softGaps = gaps.length - hardGaps;
1359
+ const gapPenalty = Math.min(0.5, softGaps * 0.1 + hardGaps * 0.3);
1360
+ score = clamp01(score - gapPenalty);
1361
+ const level = score < 0.34 ? "low" : score < 0.67 ? "medium" : "high";
1362
+ const reasons = [];
1363
+ const missingLanes = COMPLETENESS_LANES.filter(
1364
+ (lane) => !lanesPresent.has(lane)
1365
+ );
1366
+ if (informativePresent.length === 0) {
1367
+ reasons.push("no network/db/flow/browser/env evidence captured");
1368
+ } else if (missingLanes.length > 0) {
1369
+ reasons.push(`missing evidence lanes: ${missingLanes.join(", ")}`);
1370
+ }
1371
+ if (evidence.length > 0 && evidence.length < 3) {
1372
+ reasons.push(`thin evidence (${evidence.length} item(s))`);
1373
+ }
1374
+ if (hardGaps > 0) reasons.push(`source unavailable for ${hardGaps} lane(s)`);
1375
+ if (softGaps > 0) reasons.push(`${softGaps} evidence gap(s)`);
1376
+ if (located?.outcome === "inconclusive") {
1377
+ reasons.push("incident location inconclusive");
1378
+ }
1379
+ if (!top || top.kind === "inconclusive") {
1380
+ reasons.push("no distinguishing hypothesis");
1381
+ }
1382
+ return { score, level, reasons };
1383
+ }
1384
+ function pkString(pk) {
1385
+ return Object.keys(pk).sort().map((key) => `${key}=${String(pk[key])}`).join(", ");
1386
+ }
1387
+ function deriveVerification(hypothesis, evidence) {
1388
+ if (hypothesis.kind === "inconclusive") return [];
1389
+ const cited = new Set(hypothesis.evidenceIds);
1390
+ const out = [];
1391
+ for (const item of evidence) {
1392
+ if (!cited.has(item.id)) continue;
1393
+ const ref = item.ref;
1394
+ if (item.lane === "db" && ref.table && ref.pk && Object.keys(ref.pk).length > 0) {
1395
+ out.push({
1396
+ observation: `row in ${ref.table} (${pkString(ref.pk)}) matches the intended post-fix state`,
1397
+ evidenceIds: [item.id],
1398
+ how: "db"
1399
+ });
1400
+ } else if (ref.requestId) {
1401
+ const sigPart = ref.sig ? ` for signature ${ref.sig}` : "";
1402
+ out.push({
1403
+ observation: `request ${ref.requestId} succeeds${sigPart} on a fresh run (no error response)`,
1404
+ evidenceIds: [item.id],
1405
+ how: "request"
1406
+ });
1407
+ } else if (ref.sig) {
1408
+ out.push({
1409
+ observation: `error signature ${ref.sig} no longer appears in a fresh session over the same route`,
1410
+ evidenceIds: [item.id],
1411
+ how: "session"
1412
+ });
1413
+ }
1414
+ }
1415
+ return out;
1416
+ }
1417
+ function deriveEscalation(completeness, hypotheses) {
1418
+ const allInconclusive = hypotheses.length > 0 && hypotheses.every((hypothesis) => hypothesis.kind === "inconclusive");
1419
+ const recommended = completeness.level === "low" || allInconclusive;
1420
+ if (!recommended) return { recommended: false, when: [] };
1421
+ const when = [
1422
+ "if you cannot reproduce the symptom via the anchored request or session, stop and request human triage \u2014 do not widen the search"
1423
+ ];
1424
+ if (completeness.level === "low") {
1425
+ when.push(
1426
+ "context is thin: confirm the missing evidence lanes before acting on the top hypothesis"
1427
+ );
1428
+ }
1429
+ if (allInconclusive) {
1430
+ when.push(
1431
+ "no hypothesis is distinguished; treat the listed causes as equally unproven"
1432
+ );
1433
+ }
1434
+ return { recommended, when };
1435
+ }
1316
1436
  var LANE_PRIOR = {
1317
1437
  db: 0.2,
1318
1438
  network: 0.2,
@@ -3081,6 +3201,7 @@ var SentryError = class extends Error {
3081
3201
  this.status = status;
3082
3202
  this.name = "SentryError";
3083
3203
  }
3204
+ status;
3084
3205
  };
3085
3206
  function sanitizeUrl2(u) {
3086
3207
  try {
@@ -3494,6 +3615,8 @@ var CloudWatchError = class extends Error {
3494
3615
  this.target = target;
3495
3616
  this.name = "CloudWatchError";
3496
3617
  }
3618
+ status;
3619
+ target;
3497
3620
  };
3498
3621
  function isTransient2(error) {
3499
3622
  if (error instanceof CloudWatchError) {
@@ -3906,6 +4029,7 @@ var SplunkError = class extends Error {
3906
4029
  this.status = status;
3907
4030
  this.name = "SplunkError";
3908
4031
  }
4032
+ status;
3909
4033
  };
3910
4034
  function sanitizeUrl3(u) {
3911
4035
  try {
@@ -4256,6 +4380,7 @@ var DatadogError = class extends Error {
4256
4380
  this.status = status;
4257
4381
  this.name = "DatadogError";
4258
4382
  }
4383
+ status;
4259
4384
  };
4260
4385
  function sanitizeUrl4(u) {
4261
4386
  try {
@@ -4619,6 +4744,7 @@ var PostHogError = class extends Error {
4619
4744
  this.status = status;
4620
4745
  this.name = "PostHogError";
4621
4746
  }
4747
+ status;
4622
4748
  };
4623
4749
  function sanitizeUrl5(u) {
4624
4750
  try {
@@ -5009,6 +5135,8 @@ var CloudflareError = class extends Error {
5009
5135
  this.op = op;
5010
5136
  this.name = "CloudflareError";
5011
5137
  }
5138
+ status;
5139
+ op;
5012
5140
  };
5013
5141
  function isTransient6(error) {
5014
5142
  if (error instanceof CloudflareError) {
@@ -5811,7 +5939,7 @@ function timeProximity(now, candidateTime) {
5811
5939
  const decay = Math.pow(0.5, ageMs / TIME_PROXIMITY_HALF_LIFE_MS);
5812
5940
  return { boost: TIME_PROXIMITY_WEIGHT * decay, recent: decay >= 0.5 };
5813
5941
  }
5814
- function clamp01(value) {
5942
+ function clamp012(value) {
5815
5943
  return Math.min(1, Math.max(0, value));
5816
5944
  }
5817
5945
  function locateIncident(symptom, store, opts = {}) {
@@ -5840,7 +5968,7 @@ function locateIncident(symptom, store, opts = {}) {
5840
5968
  candidates.push({
5841
5969
  sessionId: id,
5842
5970
  bugId: bug.bugId,
5843
- confidence: clamp01(base.score + time.boost + releaseBoost),
5971
+ confidence: clamp012(base.score + time.boost + releaseBoost),
5844
5972
  reasons,
5845
5973
  bug
5846
5974
  });
@@ -5986,7 +6114,20 @@ async function locateAndAssemble(symptom, store, opts = {}) {
5986
6114
  const adapter = await gatherAdapterEvidence(symptom, located, opts);
5987
6115
  const evidence = [...located.evidence, ...adapter.items];
5988
6116
  const gaps = located.evidence.length === 0 ? [NO_LOCATED_SESSION_GAP, ...adapter.gaps] : [...adapter.gaps];
5989
- const bundle = assembleBundle({ symptom, evidence, intent: [], gaps });
6117
+ const located_ = {
6118
+ outcome: located.match.outcome,
6119
+ confidence: located.match.confidence,
6120
+ method: "fuzzy",
6121
+ ...located.match.sessionId ? { sessionId: located.match.sessionId } : {},
6122
+ reasons: located.match.reasons
6123
+ };
6124
+ const bundle = assembleBundle({
6125
+ symptom,
6126
+ evidence,
6127
+ intent: [],
6128
+ gaps,
6129
+ located: located_
6130
+ });
5990
6131
  return { bundle, match: located.match, sources: adapter.sources };
5991
6132
  }
5992
6133
 
@@ -11027,6 +11168,8 @@ var AudioProcessingError = class extends Error {
11027
11168
  this.cause = cause;
11028
11169
  this.name = "AudioProcessingError";
11029
11170
  }
11171
+ phase;
11172
+ cause;
11030
11173
  };
11031
11174
  var AudioTranscriptionUnavailableError = class extends Error {
11032
11175
  constructor() {
@@ -12777,12 +12920,12 @@ var import_node_fs10 = __toESM(require("fs"), 1);
12777
12920
  // package.json
12778
12921
  var package_default = {
12779
12922
  name: "crumbtrail-node",
12780
- version: "0.1.0",
12923
+ version: "0.2.1",
12781
12924
  description: "Local Crumbtrail server, MCP evidence tools, Express middleware, and CLI for self-hosted debugging sessions.",
12782
12925
  license: "MIT",
12783
12926
  repository: {
12784
12927
  type: "git",
12785
- url: "https://github.com/oshabana/crumbtrail.git",
12928
+ url: "git+https://github.com/crumbtrail-dev/crumbtrail-js.git",
12786
12929
  directory: "packages/node"
12787
12930
  },
12788
12931
  keywords: [
@@ -12835,6 +12978,10 @@ var package_default = {
12835
12978
  "@types/node": "^25.5.0",
12836
12979
  express: "^5.2.1",
12837
12980
  typescript: "^5.7.0"
12981
+ },
12982
+ homepage: "https://github.com/crumbtrail-dev/crumbtrail-js/tree/main/packages/node#readme",
12983
+ bugs: {
12984
+ url: "https://github.com/crumbtrail-dev/crumbtrail-js/issues"
12838
12985
  }
12839
12986
  };
12840
12987
 
@@ -13485,6 +13632,10 @@ var RequestValidationError = class extends Error {
13485
13632
  this.retryable = retryable;
13486
13633
  this.name = "RequestValidationError";
13487
13634
  }
13635
+ status;
13636
+ publicMessage;
13637
+ code;
13638
+ retryable;
13488
13639
  };
13489
13640
  function serveStatic(res, staticDir, urlPath) {
13490
13641
  const resolved = resolveStaticPath(staticDir, urlPath);
@@ -14214,6 +14365,7 @@ var BoundedTraceSessionCache = class {
14214
14365
  constructor(maxEntries) {
14215
14366
  this.maxEntries = maxEntries;
14216
14367
  }
14368
+ maxEntries;
14217
14369
  entries = /* @__PURE__ */ new Map();
14218
14370
  get(traceId) {
14219
14371
  const existing = this.entries.get(traceId);
@@ -14912,6 +15064,20 @@ function createServer(config) {
14912
15064
  });
14913
15065
  server.on("close", () => sweeper.stop());
14914
15066
  }
15067
+ if (config.ai?.enabled && config.ai.backfillOnStart) {
15068
+ const sessionDirs = sessions.listSummaries().map((summary) => sessions.getExistingSessionDir(summary.id)).filter((dir) => dir !== void 0);
15069
+ const timer = setTimeout(() => {
15070
+ void backfillAiDiagnoses(
15071
+ sessionDirs,
15072
+ config.ai
15073
+ ).then(
15074
+ (result) => config.ai?.log?.(
15075
+ `Crumbtrail AI diagnosis backfill complete: ${JSON.stringify(result)}`
15076
+ )
15077
+ );
15078
+ }, 0);
15079
+ server.on("close", () => clearTimeout(timer));
15080
+ }
14915
15081
  if (fastFinalizer) {
14916
15082
  server.on("close", () => fastFinalizer.stop());
14917
15083
  }
@@ -14961,6 +15127,7 @@ var FixContextError = class extends Error {
14961
15127
  this.code = code2;
14962
15128
  this.name = "FixContextError";
14963
15129
  }
15130
+ code;
14964
15131
  };
14965
15132
  function buildFixContext(sessionDirOrId, opts = {}) {
14966
15133
  const sessionDir = resolveSessionDir(sessionDirOrId, opts);
@@ -16020,16 +16187,85 @@ function diffEnvironment(a, b, rules, disabled) {
16020
16187
  addSuppressedRules(rules, beforeRules, afterRules, disabled);
16021
16188
  return [];
16022
16189
  }
16190
+ const envDelta = buildEnvDiff(a, b, disabled);
16023
16191
  return [
16024
16192
  {
16025
16193
  plane: "env",
16026
16194
  kind: "env.snapshot",
16027
16195
  before: a,
16028
16196
  after: b,
16029
- brief: "feature flag, config, release, or build snapshot changed between sessions"
16197
+ brief: envBrief(envDelta),
16198
+ envDelta
16030
16199
  }
16031
16200
  ];
16032
16201
  }
16202
+ function buildEnvDiff(a, b, disabled) {
16203
+ const delta = {
16204
+ flags: channelDelta(recordField(a.flags), recordField(b.flags), disabled),
16205
+ config: channelDelta(
16206
+ recordField(a.config),
16207
+ recordField(b.config),
16208
+ disabled
16209
+ )
16210
+ };
16211
+ const releaseBefore = stringOrUndefined(a.release);
16212
+ const releaseAfter = stringOrUndefined(b.release);
16213
+ if (releaseBefore !== releaseAfter) {
16214
+ delta.release = {
16215
+ ...releaseBefore !== void 0 ? { before: releaseBefore } : {},
16216
+ ...releaseAfter !== void 0 ? { after: releaseAfter } : {}
16217
+ };
16218
+ }
16219
+ const buildBefore = stringOrUndefined(a.build);
16220
+ const buildAfter = stringOrUndefined(b.build);
16221
+ if (buildBefore !== buildAfter) {
16222
+ delta.build = {
16223
+ ...buildBefore !== void 0 ? { before: buildBefore } : {},
16224
+ ...buildAfter !== void 0 ? { after: buildAfter } : {}
16225
+ };
16226
+ }
16227
+ return delta;
16228
+ }
16229
+ function recordField(value) {
16230
+ return isRecord14(value) ? value : {};
16231
+ }
16232
+ function channelDelta(a, b, disabled) {
16233
+ const added = [];
16234
+ const removed = [];
16235
+ const changed = [];
16236
+ for (const key of Object.keys(a).sort()) {
16237
+ if (!(key in b)) {
16238
+ removed.push({ key, before: a[key] });
16239
+ } else if (envValuesDiffer(a[key], b[key], disabled)) {
16240
+ changed.push({ key, before: a[key], after: b[key] });
16241
+ }
16242
+ }
16243
+ for (const key of Object.keys(b).sort()) {
16244
+ if (!(key in a)) added.push({ key, after: b[key] });
16245
+ }
16246
+ return { added, removed, changed };
16247
+ }
16248
+ function envValuesDiffer(before, after, disabled) {
16249
+ const beforeRules = /* @__PURE__ */ new Set();
16250
+ const afterRules = /* @__PURE__ */ new Set();
16251
+ if (canonicalize(before, beforeRules) !== canonicalize(after, afterRules))
16252
+ return true;
16253
+ const rawDiffers = stableStringify(before) !== stableStringify(after);
16254
+ return rawDiffers && hasDisabledRule(beforeRules, afterRules, disabled);
16255
+ }
16256
+ function envBrief(delta) {
16257
+ const counts = delta.flags.added.length + delta.flags.removed.length + delta.flags.changed.length + delta.config.added.length + delta.config.removed.length + delta.config.changed.length + (delta.release ? 1 : 0) + (delta.build ? 1 : 0);
16258
+ const parts = [];
16259
+ const flagCount = delta.flags.added.length + delta.flags.removed.length + delta.flags.changed.length;
16260
+ const configCount = delta.config.added.length + delta.config.removed.length + delta.config.changed.length;
16261
+ if (flagCount > 0) parts.push(`${flagCount} flag(s)`);
16262
+ if (configCount > 0) parts.push(`${configCount} config value(s)`);
16263
+ if (delta.release) parts.push("release");
16264
+ if (delta.build) parts.push("build");
16265
+ if (counts === 0 || parts.length === 0)
16266
+ return "feature flag, config, release, or build snapshot changed between sessions";
16267
+ return `environment delta between sessions: ${parts.join(", ")} changed`;
16268
+ }
16033
16269
  function confidenceFor(divergences) {
16034
16270
  if (divergences.length === 0) return "high";
16035
16271
  if (divergences.some(
@@ -16056,6 +16292,9 @@ async function compareSessions(aDir, bDir, options = {}) {
16056
16292
  ...diffDb(a.dbWrites, b.dbWrites, rules, disabled),
16057
16293
  ...diffEnvironment(a.environment, b.environment, rules, disabled)
16058
16294
  ];
16295
+ const envDelta = divergences.find(
16296
+ (divergence) => divergence.kind === "env.snapshot"
16297
+ )?.envDelta;
16059
16298
  return {
16060
16299
  schemaVersion: SESSION_COMPARE_SCHEMA_VERSION,
16061
16300
  verdict: divergences.length > 0 ? "regression" : "clean",
@@ -16078,20 +16317,187 @@ async function compareSessions(aDir, bDir, options = {}) {
16078
16317
  divergences,
16079
16318
  noise: { suppressedCount: rules.size, rules: [...rules].sort() },
16080
16319
  evidence: divergencesToEvidence(divergences),
16081
- intent: []
16320
+ intent: [],
16321
+ ...envDelta ? { envDelta } : {}
16082
16322
  };
16083
16323
  }
16084
16324
 
16085
16325
  // src/compare/regression-context.ts
16086
16326
  var import_node_fs13 = __toESM(require("fs"), 1);
16087
16327
  var import_node_path13 = __toESM(require("path"), 1);
16328
+
16329
+ // src/compare/report.ts
16330
+ var PLANE_ORDER = ["flow", "network", "db", "env"];
16331
+ var PLANE_LABELS = {
16332
+ flow: "Flow steps",
16333
+ network: "Network calls",
16334
+ db: "Database rows",
16335
+ env: "Environment and flags"
16336
+ };
16337
+ function renderCompareReport(comparison) {
16338
+ const lines = [];
16339
+ const regressed = comparison.verdict === "regression";
16340
+ lines.push(
16341
+ `# Session comparison - ${formatSessionRef(comparison.a)} vs ${formatSessionRef(comparison.b)}`
16342
+ );
16343
+ lines.push("");
16344
+ lines.push(
16345
+ regressed ? `> **Verdict: REGRESSION** (confidence: ${comparison.confidence}) - recorded behavior changed between these sessions.` : `> **Verdict: CLEAN** (confidence: ${comparison.confidence}) - the recorded sessions show no behavioral divergence.`
16346
+ );
16347
+ lines.push("");
16348
+ lines.push(
16349
+ "A is the baseline session; B is the candidate release/build. Every row below is grounded in the recorded evidence of both sessions."
16350
+ );
16351
+ lines.push("");
16352
+ lines.push("## Aligned flow");
16353
+ lines.push("");
16354
+ const { matchedSteps, unmatchedA, unmatchedB } = comparison.alignment;
16355
+ lines.push(
16356
+ `${matchedSteps} step(s) matched by component identity \xB7 ${unmatchedA} only in A \xB7 ${unmatchedB} only in B`
16357
+ );
16358
+ lines.push("");
16359
+ if (comparison.divergences.length > 0) {
16360
+ lines.push("| Plane | Kind | Component | Divergence |");
16361
+ lines.push("|---|---|---|---|");
16362
+ for (const d of comparison.divergences) {
16363
+ const component = d.sig ? `\`${escapeCell(d.sig)}\`` : "\u2014";
16364
+ lines.push(
16365
+ `| **${d.plane}** | \`${d.kind}\` | ${component} | ${escapeCell(d.brief)} |`
16366
+ );
16367
+ }
16368
+ lines.push("");
16369
+ }
16370
+ for (const plane of PLANE_ORDER) {
16371
+ const planeDivergences = comparison.divergences.filter(
16372
+ (d) => d.plane === plane
16373
+ );
16374
+ if (planeDivergences.length === 0) continue;
16375
+ lines.push(`## ${PLANE_LABELS[plane]}`);
16376
+ lines.push("");
16377
+ for (const d of planeDivergences) {
16378
+ lines.push(`### \`${d.kind}\` - ${escapeCell(d.brief)}`);
16379
+ lines.push("");
16380
+ if (d.sig) lines.push(`- Component: \`${d.sig}\``);
16381
+ if (d.requestId) lines.push(`- Request: \`${d.requestId}\``);
16382
+ if (d.table)
16383
+ lines.push(
16384
+ `- Table: \`${d.table}\`${d.pk ? ` \xB7 pk \`${JSON.stringify(d.pk)}\`` : ""}`
16385
+ );
16386
+ lines.push("");
16387
+ if (d.envDelta) {
16388
+ lines.push(...renderEnvDelta(d.envDelta));
16389
+ } else {
16390
+ lines.push("```diff");
16391
+ lines.push(`- before: ${JSON.stringify(d.before)}`);
16392
+ lines.push(`+ after: ${JSON.stringify(d.after)}`);
16393
+ lines.push("```");
16394
+ }
16395
+ lines.push("");
16396
+ }
16397
+ }
16398
+ lines.push("---");
16399
+ lines.push("");
16400
+ lines.push(
16401
+ comparison.noise.suppressedCount > 0 ? `*${comparison.noise.suppressedCount} expected-variance difference(s) suppressed by the noise model (${comparison.noise.rules.join(", ")}).*` : "*No differences were suppressed by the noise model.*"
16402
+ );
16403
+ lines.push("");
16404
+ return lines.join("\n");
16405
+ }
16406
+ function formatComparisonSummary(comparison) {
16407
+ const lines = [];
16408
+ lines.push(
16409
+ `crumbtrail-server compare - ${formatSessionRef(comparison.a)} vs ${formatSessionRef(comparison.b)}`
16410
+ );
16411
+ lines.push(` Schema: ${comparison.schemaVersion}`);
16412
+ lines.push(
16413
+ ` Verdict: ${comparison.verdict.toUpperCase()} (confidence ${comparison.confidence})`
16414
+ );
16415
+ lines.push(
16416
+ ` Alignment: ${comparison.alignment.matchedSteps} matched \xB7 ${comparison.alignment.unmatchedA} only in A \xB7 ${comparison.alignment.unmatchedB} only in B`
16417
+ );
16418
+ lines.push(` Divergences: ${comparison.divergences.length}`);
16419
+ for (const d of comparison.divergences.slice(0, 5)) {
16420
+ lines.push(` [${d.plane}] ${d.kind} - ${d.brief}`);
16421
+ }
16422
+ if (comparison.divergences.length > 5) {
16423
+ lines.push(
16424
+ ` ... and ${comparison.divergences.length - 5} more (use --json or --report for the complete list)`
16425
+ );
16426
+ }
16427
+ const rules = comparison.noise.rules.length > 0 ? ` (${comparison.noise.rules.join(", ")})` : "";
16428
+ lines.push(
16429
+ ` Noise: ${comparison.noise.suppressedCount} suppressed${rules}`
16430
+ );
16431
+ return lines.join("\n");
16432
+ }
16433
+ function escapeCell(text3) {
16434
+ return text3.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
16435
+ }
16436
+ function sessionRefLabel(ref) {
16437
+ return ref.release ?? ref.sessionId;
16438
+ }
16439
+ function comparisonTitle(comparison) {
16440
+ return `${sessionRefLabel(comparison.a)} vs ${sessionRefLabel(comparison.b)}`;
16441
+ }
16442
+ function renderEnvDelta(delta) {
16443
+ const lines = ["```diff"];
16444
+ renderEnvChannel(lines, "flags", delta.flags);
16445
+ renderEnvChannel(lines, "config", delta.config);
16446
+ if (delta.release) {
16447
+ lines.push(
16448
+ `~ release: ${JSON.stringify(delta.release.before ?? null)} -> ${JSON.stringify(delta.release.after ?? null)}`
16449
+ );
16450
+ }
16451
+ if (delta.build) {
16452
+ lines.push(
16453
+ `~ build: ${JSON.stringify(delta.build.before ?? null)} -> ${JSON.stringify(delta.build.after ?? null)}`
16454
+ );
16455
+ }
16456
+ if (lines.length === 1) lines.push(" (no field-level changes)");
16457
+ lines.push("```");
16458
+ return lines;
16459
+ }
16460
+ function renderEnvChannel(lines, channel, delta) {
16461
+ for (const change of delta.added) {
16462
+ lines.push(
16463
+ `+ ${channel}.${change.key}: ${JSON.stringify(change.after ?? null)}`
16464
+ );
16465
+ }
16466
+ for (const change of delta.removed) {
16467
+ lines.push(
16468
+ `- ${channel}.${change.key}: ${JSON.stringify(change.before ?? null)}`
16469
+ );
16470
+ }
16471
+ for (const change of delta.changed) {
16472
+ lines.push(
16473
+ `~ ${channel}.${change.key}: ${JSON.stringify(change.before ?? null)} -> ${JSON.stringify(change.after ?? null)}`
16474
+ );
16475
+ }
16476
+ }
16477
+ function formatSessionRef(ref) {
16478
+ if (ref.release) {
16479
+ const detail = [
16480
+ `session ${ref.sessionId}`,
16481
+ ref.build ? `build ${ref.build}` : void 0
16482
+ ].filter(Boolean);
16483
+ return `${ref.release} (${detail.join(", ")})`;
16484
+ }
16485
+ return ref.build ? `${ref.sessionId} (build ${ref.build})` : ref.sessionId;
16486
+ }
16487
+
16488
+ // src/compare/regression-context.ts
16088
16489
  var REGRESSION_CONTEXT_SCHEMA_VERSION = "regression-context.v1";
16089
16490
  function buildRegressionContext(comparison, bDir) {
16090
- const requestIds = unique(comparison.divergences.map((d) => d.requestId).filter(isString));
16091
- const times = requestIds.flatMap((requestId) => requestTimesFromIndex(bDir, requestId));
16491
+ const requestIds = unique(
16492
+ comparison.divergences.map((d) => d.requestId).filter(isString)
16493
+ );
16494
+ const times = requestIds.flatMap(
16495
+ (requestId) => requestTimesFromIndex(bDir, requestId)
16496
+ );
16092
16497
  const firstSig = comparison.divergences.map((d) => d.sig).find(isString);
16093
16498
  return {
16094
16499
  schemaVersion: REGRESSION_CONTEXT_SCHEMA_VERSION,
16500
+ title: comparisonTitle(comparison),
16095
16501
  comparison,
16096
16502
  divergent_interaction: firstSig ? interactionForSig(bDir, firstSig) : null,
16097
16503
  causal_window: requestIds.length > 0 ? {
@@ -16109,6 +16515,7 @@ function buildRegressionContext(comparison, bDir) {
16109
16515
  before: d.before,
16110
16516
  after: d.after
16111
16517
  })),
16518
+ env_delta: comparison.envDelta ?? null,
16112
16519
  repro_hint: reproHint(bDir)
16113
16520
  };
16114
16521
  }
@@ -16153,7 +16560,8 @@ function collectRefTimes(value, times) {
16153
16560
  const start = isRecord15(value.start) ? value.start : void 0;
16154
16561
  const end = isRecord15(value.end) ? value.end : void 0;
16155
16562
  for (const candidate of [ref, start, end]) {
16156
- if (typeof candidate?.t === "number" && Number.isFinite(candidate.t)) times.push(candidate.t);
16563
+ if (typeof candidate?.t === "number" && Number.isFinite(candidate.t))
16564
+ times.push(candidate.t);
16157
16565
  }
16158
16566
  }
16159
16567
  function reproHint(sessionDir) {
@@ -17078,12 +17486,29 @@ var McpServer = class {
17078
17486
  "sha"
17079
17487
  ]))
17080
17488
  continue;
17081
- sessions.push(meta);
17489
+ sessions.push(this.withReleaseBuild(meta));
17082
17490
  } catch {
17083
17491
  }
17084
17492
  }
17085
17493
  return textResult(sessions);
17086
17494
  }
17495
+ /**
17496
+ * Surfaces release/build as first-class list-row fields regardless of which
17497
+ * alias the app used (release/releaseId/version, build/buildId/commit/sha), so
17498
+ * an agent can label and group sessions by release without re-reading each
17499
+ * meta. Additive: the raw meta keys are preserved.
17500
+ */
17501
+ withReleaseBuild(meta) {
17502
+ const release = stringField5(meta.release ?? meta.releaseId ?? meta.version);
17503
+ const build = stringField5(
17504
+ meta.build ?? meta.buildId ?? meta.commit ?? meta.sha
17505
+ );
17506
+ return {
17507
+ ...meta,
17508
+ ...release !== void 0 ? { release } : {},
17509
+ ...build !== void 0 ? { build } : {}
17510
+ };
17511
+ }
17087
17512
  sessionMetadataMatches(meta, expected, keys) {
17088
17513
  return keys.some((key) => meta[key] === expected);
17089
17514
  }
@@ -17487,6 +17912,7 @@ var McpServer = class {
17487
17912
  const currentSession = stringField5(args.currentSession);
17488
17913
  let evidence = [];
17489
17914
  let intent = [];
17915
+ let locatedDecision;
17490
17916
  const adapterGaps = [];
17491
17917
  let sessionlessAdapterBundle = false;
17492
17918
  if (baselineSession && currentSession) {
@@ -17502,6 +17928,13 @@ var McpServer = class {
17502
17928
  try {
17503
17929
  const located = locateEvidence(symptom, this.recallStore());
17504
17930
  evidence = located.evidence;
17931
+ locatedDecision = {
17932
+ outcome: located.match.outcome,
17933
+ confidence: located.match.confidence,
17934
+ method: "fuzzy",
17935
+ ...located.match.sessionId ? { sessionId: located.match.sessionId } : {},
17936
+ reasons: located.match.reasons
17937
+ };
17505
17938
  const adapter = await gatherAdapterEvidence(symptom, located, {
17506
17939
  sources: this.evidenceSources()
17507
17940
  });
@@ -17587,7 +18020,13 @@ var McpServer = class {
17587
18020
  }
17588
18021
  ] : []
17589
18022
  ];
17590
- const bundle = assembleBundle({ symptom, evidence, intent, gaps });
18023
+ const bundle = assembleBundle({
18024
+ symptom,
18025
+ evidence,
18026
+ intent,
18027
+ gaps,
18028
+ located: locatedDecision
18029
+ });
17591
18030
  if (budget.maxTokens === void 0) return textResult(bundle);
17592
18031
  return this.budgetedTextResult(
17593
18032
  bundle,
@@ -20089,6 +20528,7 @@ var InspectError = class extends Error {
20089
20528
  this.code = code2;
20090
20529
  this.name = "InspectError";
20091
20530
  }
20531
+ code;
20092
20532
  };
20093
20533
  function inspectSession(sessionDirOrId, opts = {}) {
20094
20534
  const sessionDir = resolveSessionDir2(sessionDirOrId, opts);
@@ -20715,117 +21155,6 @@ function isRecord21(value) {
20715
21155
  return value !== null && typeof value === "object" && !Array.isArray(value);
20716
21156
  }
20717
21157
 
20718
- // src/compare/report.ts
20719
- var PLANE_ORDER = ["flow", "network", "db", "env"];
20720
- var PLANE_LABELS = {
20721
- flow: "Flow steps",
20722
- network: "Network calls",
20723
- db: "Database rows",
20724
- env: "Environment and flags"
20725
- };
20726
- function renderCompareReport(comparison) {
20727
- const lines = [];
20728
- const regressed = comparison.verdict === "regression";
20729
- lines.push(
20730
- `# Session comparison - ${formatSessionRef(comparison.a)} vs ${formatSessionRef(comparison.b)}`
20731
- );
20732
- lines.push("");
20733
- lines.push(
20734
- regressed ? `> **Verdict: REGRESSION** (confidence: ${comparison.confidence}) - recorded behavior changed between these sessions.` : `> **Verdict: CLEAN** (confidence: ${comparison.confidence}) - the recorded sessions show no behavioral divergence.`
20735
- );
20736
- lines.push("");
20737
- lines.push(
20738
- "A is the baseline session; B is the candidate release/build. Every row below is grounded in the recorded evidence of both sessions."
20739
- );
20740
- lines.push("");
20741
- lines.push("## Aligned flow");
20742
- lines.push("");
20743
- const { matchedSteps, unmatchedA, unmatchedB } = comparison.alignment;
20744
- lines.push(
20745
- `${matchedSteps} step(s) matched by component identity \xB7 ${unmatchedA} only in A \xB7 ${unmatchedB} only in B`
20746
- );
20747
- lines.push("");
20748
- if (comparison.divergences.length > 0) {
20749
- lines.push("| Plane | Kind | Component | Divergence |");
20750
- lines.push("|---|---|---|---|");
20751
- for (const d of comparison.divergences) {
20752
- const component = d.sig ? `\`${escapeCell(d.sig)}\`` : "\u2014";
20753
- lines.push(
20754
- `| **${d.plane}** | \`${d.kind}\` | ${component} | ${escapeCell(d.brief)} |`
20755
- );
20756
- }
20757
- lines.push("");
20758
- }
20759
- for (const plane of PLANE_ORDER) {
20760
- const planeDivergences = comparison.divergences.filter(
20761
- (d) => d.plane === plane
20762
- );
20763
- if (planeDivergences.length === 0) continue;
20764
- lines.push(`## ${PLANE_LABELS[plane]}`);
20765
- lines.push("");
20766
- for (const d of planeDivergences) {
20767
- lines.push(`### \`${d.kind}\` - ${escapeCell(d.brief)}`);
20768
- lines.push("");
20769
- if (d.sig) lines.push(`- Component: \`${d.sig}\``);
20770
- if (d.requestId) lines.push(`- Request: \`${d.requestId}\``);
20771
- if (d.table)
20772
- lines.push(
20773
- `- Table: \`${d.table}\`${d.pk ? ` \xB7 pk \`${JSON.stringify(d.pk)}\`` : ""}`
20774
- );
20775
- lines.push("");
20776
- lines.push("```diff");
20777
- lines.push(`- before: ${JSON.stringify(d.before)}`);
20778
- lines.push(`+ after: ${JSON.stringify(d.after)}`);
20779
- lines.push("```");
20780
- lines.push("");
20781
- }
20782
- }
20783
- lines.push("---");
20784
- lines.push("");
20785
- lines.push(
20786
- comparison.noise.suppressedCount > 0 ? `*${comparison.noise.suppressedCount} expected-variance difference(s) suppressed by the noise model (${comparison.noise.rules.join(", ")}).*` : "*No differences were suppressed by the noise model.*"
20787
- );
20788
- lines.push("");
20789
- return lines.join("\n");
20790
- }
20791
- function formatComparisonSummary(comparison) {
20792
- const lines = [];
20793
- lines.push(
20794
- `crumbtrail-server compare - ${formatSessionRef(comparison.a)} vs ${formatSessionRef(comparison.b)}`
20795
- );
20796
- lines.push(` Schema: ${comparison.schemaVersion}`);
20797
- lines.push(
20798
- ` Verdict: ${comparison.verdict.toUpperCase()} (confidence ${comparison.confidence})`
20799
- );
20800
- lines.push(
20801
- ` Alignment: ${comparison.alignment.matchedSteps} matched \xB7 ${comparison.alignment.unmatchedA} only in A \xB7 ${comparison.alignment.unmatchedB} only in B`
20802
- );
20803
- lines.push(` Divergences: ${comparison.divergences.length}`);
20804
- for (const d of comparison.divergences.slice(0, 5)) {
20805
- lines.push(` [${d.plane}] ${d.kind} - ${d.brief}`);
20806
- }
20807
- if (comparison.divergences.length > 5) {
20808
- lines.push(
20809
- ` ... and ${comparison.divergences.length - 5} more (use --json or --report for the complete list)`
20810
- );
20811
- }
20812
- const rules = comparison.noise.rules.length > 0 ? ` (${comparison.noise.rules.join(", ")})` : "";
20813
- lines.push(
20814
- ` Noise: ${comparison.noise.suppressedCount} suppressed${rules}`
20815
- );
20816
- return lines.join("\n");
20817
- }
20818
- function escapeCell(text3) {
20819
- return text3.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
20820
- }
20821
- function formatSessionRef(ref) {
20822
- const tags = [
20823
- ref.release ? `release ${ref.release}` : void 0,
20824
- ref.build ? `build ${ref.build}` : void 0
20825
- ].filter(Boolean);
20826
- return tags.length > 0 ? `${ref.sessionId} (${tags.join(", ")})` : ref.sessionId;
20827
- }
20828
-
20829
21158
  // src/ticket/comment.ts
20830
21159
  var REASON_PHRASES = {
20831
21160
  semantic: "wording overlap with the captured incident",
@@ -21276,6 +21605,7 @@ function bounded(value, max) {
21276
21605
  cloudWatchEvidenceProvider,
21277
21606
  cloudflareEvidenceProvider,
21278
21607
  compareSessions,
21608
+ comparisonTitle,
21279
21609
  createCrumbtrailExpressErrorMiddleware,
21280
21610
  createCrumbtrailExpressMiddleware,
21281
21611
  createServer,
@@ -21325,6 +21655,7 @@ function bounded(value, max) {
21325
21655
  renderProviderReadme,
21326
21656
  resolveDbRequestContext,
21327
21657
  sentryEvidenceProvider,
21658
+ sessionRefLabel,
21328
21659
  signSigV4,
21329
21660
  splunkEvidenceProvider,
21330
21661
  splunkSearchDeepLink,