sentinelayer-cli 0.39.2 → 0.41.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.
Files changed (62) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -3
  3. package/src/agents/ai-governance/tools/base.js +19 -166
  4. package/src/agents/backend/tools/base.js +33 -186
  5. package/src/agents/code-quality/tools/base.js +27 -154
  6. package/src/agents/data-layer/tools/base.js +35 -180
  7. package/src/agents/documentation/tools/base.js +18 -146
  8. package/src/agents/infrastructure/tools/base.js +17 -164
  9. package/src/agents/observability/tools/base.js +35 -180
  10. package/src/agents/performance/tools/base.js +35 -205
  11. package/src/agents/release/tools/base.js +35 -180
  12. package/src/agents/reliability/tools/base.js +35 -180
  13. package/src/agents/security/tools/base.js +33 -187
  14. package/src/agents/shared-tools/domain-base.js +246 -0
  15. package/src/agents/supply-chain/tools/base.js +15 -144
  16. package/src/agents/testing/tools/base.js +33 -184
  17. package/src/cli.js +5 -0
  18. package/src/commands/mcp.js +1 -1
  19. package/src/commands/ring-owner.js +58 -0
  20. package/src/commands/session.js +703 -263
  21. package/src/engram/governance.js +67 -0
  22. package/src/engram/index.js +64 -0
  23. package/src/engram/namespace.js +89 -0
  24. package/src/engram/observations.js +34 -0
  25. package/src/engram/sla.js +104 -0
  26. package/src/engram/store.js +115 -0
  27. package/src/engram/tools.js +146 -0
  28. package/src/engram/trust.js +105 -0
  29. package/src/legacy-cli.js +6 -3
  30. package/src/mcp/registry.js +27 -10
  31. package/src/mcp/session-stdio-server.js +209 -18
  32. package/src/mcp/smoke.js +9 -8
  33. package/src/pocket/ring-owner.js +97 -0
  34. package/src/review/local-review.js +9 -5
  35. package/src/review/omargate-orchestrator.js +111 -14
  36. package/src/review/spec-binding.js +16 -1
  37. package/src/scan/generator.js +1 -1
  38. package/src/session/agent-registry.js +22 -63
  39. package/src/session/checkpoints.js +194 -0
  40. package/src/session/coordination-guidance.js +6 -6
  41. package/src/session/daemon.js +19 -124
  42. package/src/session/file-lease-integrations.js +1300 -0
  43. package/src/session/file-locks.js +967 -464
  44. package/src/session/listener.js +243 -20
  45. package/src/session/listeners.js +71 -80
  46. package/src/session/paths.js +2 -0
  47. package/src/session/recall/embedder.js +181 -0
  48. package/src/session/recall/entities.js +0 -0
  49. package/src/session/recall/index-build.js +38 -0
  50. package/src/session/recall/index-core.js +108 -0
  51. package/src/session/recall/index.js +131 -0
  52. package/src/session/recall/needle-eval.js +279 -0
  53. package/src/session/recall/observation-core.js +183 -0
  54. package/src/session/recall/observations.js +21 -0
  55. package/src/session/recall/provenance.js +153 -0
  56. package/src/session/recall/retrieve.js +374 -0
  57. package/src/session/recall/text.js +0 -0
  58. package/src/session/recall/token-stats.js +81 -0
  59. package/src/session/recap.js +29 -23
  60. package/src/session/remote-hydrate.js +5 -1
  61. package/src/session/sync.js +661 -11
  62. package/src/session/wake/pump.js +8 -3
package/src/mcp/smoke.js CHANGED
@@ -5,6 +5,7 @@ import { requestHostedMcpAccessToken } from "./token-service.js";
5
5
 
6
6
  const PASS = "PASS";
7
7
  const FAIL = "FAIL";
8
+ const SESSION_EVENTS_LIST_TOOL = "sessions_events_list";
8
9
 
9
10
  function joinUrl(base, suffix) {
10
11
  const normalizedBase = String(base || "").replace(/\/+$/, "");
@@ -233,13 +234,13 @@ export async function runHostedMcpSmoke({
233
234
  }
234
235
 
235
236
  if (normalizedSessionId) {
236
- if (!toolNames.includes("sessions.events.list")) {
237
+ if (!toolNames.includes(SESSION_EVENTS_LIST_TOOL)) {
237
238
  probes.push({
238
239
  id: "session_events_list",
239
- label: "MCP sessions.events.list",
240
+ label: `MCP ${SESSION_EVENTS_LIST_TOOL}`,
240
241
  status: 0,
241
242
  verdict: FAIL,
242
- detail: "sessions.events.list tool was not advertised by tools/list",
243
+ detail: `${SESSION_EVENTS_LIST_TOOL} tool was not advertised by tools/list`,
243
244
  sessionId: normalizedSessionId,
244
245
  });
245
246
  } else {
@@ -254,7 +255,7 @@ export async function runHostedMcpSmoke({
254
255
  id: "mcp-smoke-events",
255
256
  method: "tools/call",
256
257
  params: {
257
- name: "sessions.events.list",
258
+ name: SESSION_EVENTS_LIST_TOOL,
258
259
  arguments: {
259
260
  sessionId: normalizedSessionId,
260
261
  limit: normalizedLimit,
@@ -266,7 +267,7 @@ export async function runHostedMcpSmoke({
266
267
  if (!eventsResponse.reached) {
267
268
  probes.push({
268
269
  id: "session_events_list",
269
- label: "MCP sessions.events.list",
270
+ label: `MCP ${SESSION_EVENTS_LIST_TOOL}`,
270
271
  status: eventsResponse.status,
271
272
  verdict: FAIL,
272
273
  detail: eventsResponse.detail,
@@ -275,7 +276,7 @@ export async function runHostedMcpSmoke({
275
276
  } else if (eventsResponse.status !== 200) {
276
277
  probes.push({
277
278
  id: "session_events_list",
278
- label: "MCP sessions.events.list",
279
+ label: `MCP ${SESSION_EVENTS_LIST_TOOL}`,
279
280
  status: eventsResponse.status,
280
281
  verdict: FAIL,
281
282
  detail: `expected HTTP 200, got HTTP ${eventsResponse.status}`,
@@ -284,7 +285,7 @@ export async function runHostedMcpSmoke({
284
285
  } else if (eventsResponse.json?.error) {
285
286
  probes.push({
286
287
  id: "session_events_list",
287
- label: "MCP sessions.events.list",
288
+ label: `MCP ${SESSION_EVENTS_LIST_TOOL}`,
288
289
  status: eventsResponse.status,
289
290
  verdict: FAIL,
290
291
  detail: summarizeJsonRpcError(eventsResponse.json.error, redactionSecrets),
@@ -297,7 +298,7 @@ export async function runHostedMcpSmoke({
297
298
  .filter((value) => Number.isFinite(value));
298
299
  probes.push({
299
300
  id: "session_events_list",
300
- label: "MCP sessions.events.list",
301
+ label: `MCP ${SESSION_EVENTS_LIST_TOOL}`,
301
302
  status: eventsResponse.status,
302
303
  verdict: PASS,
303
304
  detail: `${events.length} event(s) returned`,
@@ -0,0 +1,97 @@
1
+ import process from "node:process";
2
+
3
+ import { requestJsonMutation } from "../auth/http.js";
4
+ import { resolveActiveAuthSession } from "../auth/service.js";
5
+
6
+ // The client half of Senti Pocket "ring the owner": an authed agent/CLI DESCRIBES a decision the owner must make and the
7
+ // pocket-gateway (POST /dial/ring-owner) builds the canonical NeedCarterSignal server-side + rings the owner's phone.
8
+ // SECURITY: this AUTHORS NOTHING — it only rings. The ring TARGET is the CALLER's own verified identity (the gateway
9
+ // derives it from the bearer, NEVER a body field), so a caller can only ring THEIR OWN phone. The answered-call write
10
+ // keeps its own governed confirm (dialing relaxes nothing).
11
+ export const RING_OWNER_KINDS = Object.freeze(["decisionYours", "pickOption", "go", "info", "checkpointReady"]);
12
+
13
+ function normalizeString(value) {
14
+ return String(value || "").trim();
15
+ }
16
+
17
+ /**
18
+ * Resolve the pocket-gateway base URL — DISTINCT from the senti apiUrl (do NOT reuse resolveApiUrl; that is the API host).
19
+ * Precedence: env SENTI_POCKET_URL -> env POCKET_GATEWAY_URL -> a config value if the caller passed one. Absent -> "".
20
+ * A present value MUST be an http(s) URL (fail-closed on a bogus scheme). Trailing slashes trimmed.
21
+ */
22
+ export function resolvePocketGatewayUrl({ env = process.env, configUrl = "" } = {}) {
23
+ const raw =
24
+ normalizeString(env.SENTI_POCKET_URL) ||
25
+ normalizeString(env.POCKET_GATEWAY_URL) ||
26
+ normalizeString(configUrl);
27
+ if (!raw) return "";
28
+ if (!/^https?:\/\//i.test(raw)) throw new Error("pocket gateway URL must be an http(s) URL");
29
+ return raw.replace(/\/+$/, "");
30
+ }
31
+
32
+ /**
33
+ * Ring the owner about a session decision via the pocket-gateway POST /dial/ring-owner.
34
+ * @param {string} question the decision/question to put to the owner
35
+ * @param {object} opts kind/sessionId/options/whatWeNeed/checkpointId/requestedBy/idempotencyKey/gatewayUrl + injectable
36
+ * resolveAuthSession + requestMutation (hermetic tests) + env
37
+ * @returns the gateway result ({ dialId, dispatched, reason?, kind?, idempotent? }).
38
+ */
39
+ export async function ringOwner(
40
+ question,
41
+ {
42
+ kind = "decisionYours",
43
+ sessionId = "",
44
+ options = [],
45
+ whatWeNeed = "",
46
+ checkpointId = "",
47
+ requestedBy = "",
48
+ idempotencyKey = "",
49
+ gatewayUrl = "",
50
+ configUrl = "",
51
+ env = process.env,
52
+ cwd = process.cwd(),
53
+ resolveAuthSession = resolveActiveAuthSession,
54
+ requestMutation = requestJsonMutation,
55
+ } = {},
56
+ ) {
57
+ const q = normalizeString(question);
58
+ if (!q) throw new Error("question is required.");
59
+ const sid = normalizeString(sessionId);
60
+ if (!sid) throw new Error("session id is required (--session <id>).");
61
+ const normKind = normalizeString(kind) || "decisionYours";
62
+ if (!RING_OWNER_KINDS.includes(normKind)) {
63
+ throw new Error(`kind must be one of: ${RING_OWNER_KINDS.join(", ")}`);
64
+ }
65
+ if (normKind === "pickOption" && (!Array.isArray(options) || options.length === 0)) {
66
+ throw new Error("pickOption requires at least one --option.");
67
+ }
68
+
69
+ const gw = normalizeString(gatewayUrl)
70
+ ? normalizeString(gatewayUrl).replace(/\/+$/, "")
71
+ : resolvePocketGatewayUrl({ env, configUrl });
72
+ if (!gw) {
73
+ throw new Error("pocket gateway URL not configured. Set SENTI_POCKET_URL to the pocket-gateway base URL.");
74
+ }
75
+
76
+ const auth = await resolveAuthSession({ cwd, env, autoRotate: false });
77
+ if (!auth?.token) {
78
+ throw new Error("Not authenticated. Run `sl auth login` first.");
79
+ }
80
+
81
+ const context = { sessionId: sid };
82
+ if (normalizeString(whatWeNeed)) context.whatWeNeed = normalizeString(whatWeNeed);
83
+ if (normalizeString(checkpointId)) context.checkpointId = normalizeString(checkpointId);
84
+ const body = { question: q, kind: normKind, context };
85
+ if (normKind === "pickOption") body.options = options.map((o) => String(o));
86
+ if (normalizeString(requestedBy)) body.requestedBy = normalizeString(requestedBy);
87
+ if (normalizeString(idempotencyKey)) body.idempotencyKey = normalizeString(idempotencyKey);
88
+
89
+ // requestJsonMutation auto-derives a transport Idempotency-Key from operationName (retry-safe at the wire); the
90
+ // gateway's own ring-level dedupe (PR-B4) keys on body.idempotencyKey OR a content hash, so an identical retry rings once.
91
+ return requestMutation(`${gw}/dial/ring-owner`, {
92
+ method: "POST",
93
+ operationName: "pocket.ring_owner",
94
+ headers: { Authorization: `Bearer ${auth.token}` },
95
+ body,
96
+ });
97
+ }
@@ -73,6 +73,12 @@ const SEVERITY_ORDER = new Map([
73
73
 
74
74
  const TEST_OR_FIXTURE_PATH_PATTERN = /(?:^|[\\/])(?:test|tests|__tests__|fixtures?)(?:[\\/]|$)/i;
75
75
  const LOCAL_REVIEW_SOURCE_PATH_PATTERN = /(?:^|[\\/])src[\\/]review[\\/]local-review\.js$/i;
76
+ const SQL_STRING_CONCATENATION_PATTERN =
77
+ /["'`]\s*(?:SELECT|INSERT|UPDATE|DELETE)\b[^\n]{0,160}["'`]\s*\+/i;
78
+ const SQL_CONCAT_EXCLUDE_PATH_PATTERN = new RegExp(
79
+ `${TEST_LIKE_PATH_PATTERN.source}|${LOCAL_REVIEW_SOURCE_PATH_PATTERN.source}`,
80
+ "i"
81
+ );
76
82
  const WORK_ITEM_MARKER_EXCLUDE_PATH_PATTERN = new RegExp(
77
83
  `${TEST_OR_FIXTURE_PATH_PATTERN.source}|${LOCAL_REVIEW_SOURCE_PATH_PATTERN.source}`,
78
84
  "i"
@@ -249,11 +255,9 @@ const DETERMINISTIC_REVIEW_RULES = Object.freeze([
249
255
  severity: "P2",
250
256
  message: "Possible SQL string concatenation detected.",
251
257
  suggestedFix: "Use parameterized queries and prepared statements.",
252
- regex: /\b(?:SELECT|INSERT|UPDATE|DELETE)\b[^;\n]{0,140}\+/i,
258
+ regex: SQL_STRING_CONCATENATION_PATTERN,
253
259
  sourceOnly: true,
254
- // Self-scan dampener: the local-review source itself contains SQL-like
255
- // regex literals and must not flag itself.
256
- excludePathPattern: LOCAL_REVIEW_SOURCE_PATH_PATTERN,
260
+ excludePathPattern: SQL_CONCAT_EXCLUDE_PATH_PATTERN,
257
261
  },
258
262
  {
259
263
  id: "SL-SEC-018",
@@ -774,7 +778,7 @@ async function runPatternChecks({ rootPath, filePaths, maxFindings = MAX_FINDING
774
778
  }
775
779
  }
776
780
 
777
- const sqlConcat = /\b(?:SELECT|INSERT|UPDATE|DELETE)\b[^\n]{0,160}\+/i.exec(text);
781
+ const sqlConcat = SQL_STRING_CONCATENATION_PATTERN.exec(text);
778
782
  if (
779
783
  sqlConcat &&
780
784
  findings.length < maxFindings &&
@@ -376,6 +376,79 @@ function summarizeFindings(findings = []) {
376
376
  };
377
377
  }
378
378
 
379
+ function normalizeEvidenceNumber(value) {
380
+ const parsed = Number(value);
381
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
382
+ }
383
+
384
+ function resolveAiResultCostUsd(result = {}) {
385
+ return Math.max(
386
+ normalizeEvidenceNumber(result?.costUsd),
387
+ normalizeEvidenceNumber(result?.usage?.costUsd),
388
+ );
389
+ }
390
+
391
+ function buildAiCallEvidence(result = {}, { dryRun = false } = {}) {
392
+ if (dryRun) {
393
+ return {
394
+ confirmedCalls: 0,
395
+ usageBackedCalls: 0,
396
+ ledgerBackedCalls: 0,
397
+ pricedCalls: 0,
398
+ inputTokens: 0,
399
+ outputTokens: 0,
400
+ totalTokens: 0,
401
+ providerCostUsd: 0,
402
+ customerCostUsd: 0,
403
+ };
404
+ }
405
+
406
+ const usage = result?.usage && typeof result.usage === "object" ? result.usage : {};
407
+ const ledger = result?.billing?.ok === true && result?.billing?.ledgerEntry
408
+ ? result.billing.ledgerEntry
409
+ : {};
410
+ const usageInputTokens = normalizeEvidenceNumber(usage.inputTokens);
411
+ const usageOutputTokens = normalizeEvidenceNumber(usage.outputTokens);
412
+ const usageTotalTokens = normalizeEvidenceNumber(usage.totalTokens);
413
+ const ledgerInputTokens = normalizeEvidenceNumber(ledger.inputTokens);
414
+ const ledgerOutputTokens = normalizeEvidenceNumber(ledger.outputTokens);
415
+ const ledgerTotalTokens = normalizeEvidenceNumber(ledger.totalTokens);
416
+ const inputTokens = Math.max(usageInputTokens, ledgerInputTokens);
417
+ const outputTokens = Math.max(usageOutputTokens, ledgerOutputTokens);
418
+ const totalTokens = Math.max(usageTotalTokens, ledgerTotalTokens, inputTokens + outputTokens);
419
+ const ledgerProviderCostUsd = normalizeEvidenceNumber(ledger.providerCostUsd);
420
+ const providerCostUsd = Math.max(resolveAiResultCostUsd(result), ledgerProviderCostUsd);
421
+ const customerCostUsd = normalizeEvidenceNumber(ledger.customerCostUsd);
422
+ const usageBacked = usageInputTokens + usageOutputTokens + usageTotalTokens > 0;
423
+ const ledgerBacked =
424
+ ledgerInputTokens + ledgerOutputTokens + ledgerTotalTokens > 0 || ledgerProviderCostUsd > 0;
425
+ const priced = providerCostUsd > 0 || customerCostUsd > 0;
426
+ const confirmed = usageBacked || ledgerBacked || priced;
427
+
428
+ return {
429
+ confirmedCalls: confirmed ? 1 : 0,
430
+ usageBackedCalls: usageBacked ? 1 : 0,
431
+ ledgerBackedCalls: ledgerBacked ? 1 : 0,
432
+ pricedCalls: priced ? 1 : 0,
433
+ inputTokens,
434
+ outputTokens,
435
+ totalTokens,
436
+ providerCostUsd,
437
+ customerCostUsd,
438
+ };
439
+ }
440
+
441
+ function combineAiCallEvidence(entries = []) {
442
+ const combined = buildAiCallEvidence({}, { dryRun: true });
443
+ for (const entry of entries) {
444
+ if (!entry || typeof entry !== "object") continue;
445
+ for (const key of Object.keys(combined)) {
446
+ combined[key] += normalizeEvidenceNumber(entry[key]);
447
+ }
448
+ }
449
+ return combined;
450
+ }
451
+
379
452
  function buildPersonaHealthFindings(personaHealth = {}) {
380
453
  if (personaHealth.healthy || personaHealth.total <= 0) {
381
454
  return [];
@@ -386,13 +459,16 @@ function buildPersonaHealthFindings(personaHealth = {}) {
386
459
  const total = Number(personaHealth.total || 0);
387
460
  const errorRatio = Number(personaHealth.errorRatio || 0);
388
461
  const totalCostUsd = Number(personaHealth.totalCostUsd || 0);
462
+ const aiCallEvidence = personaHealth.aiCallEvidence || {};
463
+ const okCount = Number(personaHealth.ok || 0);
464
+ const unverifiedOkCount = Number(personaHealth.unverifiedOk || 0);
389
465
  const allPersonasFailed = total > 0 && errorCount === total;
390
466
  const severity = allPersonasFailed ? "P0" : "P1";
391
467
  let message = `Omar AI coverage degraded: ${errorCount}/${total} dispatched personas errored (${Math.round(errorRatio * 100)}%).`;
392
468
  if (allPersonasFailed) {
393
469
  message = `Omar AI coverage failed closed: all ${total} dispatched personas errored before producing findings.`;
394
- } else if (errorCount === 0 && totalCostUsd <= 0) {
395
- message = "Omar AI coverage failed closed: personas reported ok but total AI cost was $0.00, indicating no LLM calls were made.";
470
+ } else if (errorCount === 0 && unverifiedOkCount > 0) {
471
+ message = `Omar AI coverage failed closed: ${unverifiedOkCount}/${okCount} successful personas lacked token-bearing usage, billing-ledger, or priced-call evidence.`;
396
472
  }
397
473
 
398
474
  return [
@@ -417,6 +493,9 @@ function buildPersonaHealthFindings(personaHealth = {}) {
417
493
  total,
418
494
  errorRatio,
419
495
  totalCostUsd,
496
+ okCount,
497
+ unverifiedOkCount,
498
+ aiCallEvidence,
420
499
  },
421
500
  },
422
501
  ];
@@ -636,6 +715,8 @@ async function runOmarPersonaSwarm({
636
715
  });
637
716
  return normalized;
638
717
  });
718
+ const costUsd = resolveAiResultCostUsd(result);
719
+ const aiCallEvidence = buildAiCallEvidence(result, { dryRun });
639
720
 
640
721
  if (onEvent) {
641
722
  for (const finding of findings) {
@@ -659,11 +740,11 @@ async function runOmarPersonaSwarm({
659
740
  fileCount: scopedFiles.length,
660
741
  findings: findings.length,
661
742
  summary: result?.summary || summarizeFindings(findings),
662
- costUsd: result?.usage?.costUsd || 0,
743
+ costUsd,
663
744
  durationMs: Date.now() - subagentStart,
664
745
  },
665
746
  usage: {
666
- costUsd: result?.usage?.costUsd || 0,
747
+ costUsd,
667
748
  durationMs: Date.now() - subagentStart,
668
749
  toolCalls: result?.usage?.toolCalls || 0,
669
750
  },
@@ -678,7 +759,8 @@ async function runOmarPersonaSwarm({
678
759
  files: scopedFiles,
679
760
  findings,
680
761
  summary: result?.summary || summarizeFindings(findings),
681
- costUsd: result?.usage?.costUsd || 0,
762
+ costUsd,
763
+ aiCallEvidence,
682
764
  model: result?.model || model || null,
683
765
  billing: result?.billing || null,
684
766
  artifacts: result?.artifacts || null,
@@ -739,6 +821,9 @@ async function runOmarPersonaSwarm({
739
821
  );
740
822
  const findings = settledSubagents.flatMap((result) => result.findings || []);
741
823
  const totalCostUsd = settledSubagents.reduce((sum, result) => sum + (result.costUsd || 0), 0);
824
+ const aiCallEvidence = combineAiCallEvidence(
825
+ settledSubagents.map((result) => result.aiCallEvidence),
826
+ );
742
827
  const summary = summarizeFindings(findings);
743
828
  const okCount = settledSubagents.filter((result) => result.status === "ok").length;
744
829
  const errorCount = settledSubagents.filter((result) => result.status === "error").length;
@@ -811,6 +896,7 @@ async function runOmarPersonaSwarm({
811
896
  findings,
812
897
  summary,
813
898
  costUsd: totalCostUsd,
899
+ aiCallEvidence,
814
900
  model: model || null,
815
901
  durationMs: Date.now() - startedAt,
816
902
  error: okCount > 0 ? null : settledSubagents.find((result) => result.error)?.error || null,
@@ -829,6 +915,7 @@ async function runOmarPersonaSwarm({
829
915
  files: result.files,
830
916
  findings: (result.findings || []).length,
831
917
  costUsd: result.costUsd || 0,
918
+ aiCallEvidence: result.aiCallEvidence || null,
832
919
  billing: result.billing || null,
833
920
  durationMs: result.durationMs || 0,
834
921
  error: result.error || null,
@@ -1142,6 +1229,8 @@ export async function runOmarGateOrchestrator({
1142
1229
  confidenceFloor: personaConfidenceFloor,
1143
1230
  personaConfidenceFloor,
1144
1231
  }));
1232
+ const personaCost = resolveAiResultCostUsd(result);
1233
+ const aiCallEvidence = buildAiCallEvidence(result, { dryRun });
1145
1234
 
1146
1235
  if (onEvent) {
1147
1236
  for (const finding of findings) {
@@ -1174,14 +1263,13 @@ export async function runOmarGateOrchestrator({
1174
1263
  identity,
1175
1264
  findings: findings.length,
1176
1265
  summary: result?.summary || {},
1177
- costUsd: result?.costUsd || 0,
1266
+ costUsd: personaCost,
1178
1267
  durationMs: Date.now() - personaStart,
1179
1268
  },
1180
1269
  runId,
1181
1270
  }));
1182
1271
  }
1183
1272
 
1184
- const personaCost = result?.costUsd || 0;
1185
1273
  runningCostUsd += personaCost;
1186
1274
 
1187
1275
  return {
@@ -1190,6 +1278,7 @@ export async function runOmarGateOrchestrator({
1190
1278
  findings,
1191
1279
  summary: result?.summary || { P0: 0, P1: 0, P2: 0, P3: 0 },
1192
1280
  costUsd: personaCost,
1281
+ aiCallEvidence,
1193
1282
  model: result?.model || model || null,
1194
1283
  billing: result?.billing || null,
1195
1284
  artifacts: result?.artifacts || null,
@@ -1269,28 +1358,35 @@ export async function runOmarGateOrchestrator({
1269
1358
  );
1270
1359
 
1271
1360
  const totalCost = settled.reduce((sum, r) => sum + (r.costUsd || 0), 0);
1361
+ const aiCallEvidence = combineAiCallEvidence(settled.map((result) => result.aiCallEvidence));
1272
1362
  const totalDuration = Date.now() - startTime;
1273
1363
 
1274
- // Silent-failure detection: if >=50% of personas errored OR total cost is
1275
- // zero with non-zero personas dispatched, treat as a LOUD orchestrator
1276
- // warning. Prior behavior silently returned zero AI findings, masking
1277
- // auth failures or LLM proxy outages as "clean scan".
1364
+ // Silent-failure detection requires evidence of an actual provider call.
1365
+ // Customer cost is not evidence by itself because internal/zero-priced
1366
+ // billing can still carry token-bearing usage and provider-cost receipts.
1278
1367
  const personaErrorCount = settled.filter((r) => r.status === "error").length;
1279
1368
  const personaSkippedCount = settled.filter((r) => r.status === "skipped").length;
1280
1369
  const personaOkCount = settled.filter((r) => r.status === "ok").length;
1370
+ const personaVerifiedOkCount = settled.filter(
1371
+ (r) => r.status === "ok" && Number(r.aiCallEvidence?.confirmedCalls || 0) > 0
1372
+ ).length;
1373
+ const personaUnverifiedOkCount = personaOkCount - personaVerifiedOkCount;
1281
1374
  const totalPersonas = settled.length;
1282
1375
  const errorRatio = totalPersonas > 0 ? personaErrorCount / totalPersonas : 0;
1283
1376
  const aiCoverageHealthy =
1284
1377
  totalPersonas === 0 ||
1285
- (personaOkCount > 0 && totalCost > 0 && errorRatio < 0.5 && !dryRun);
1378
+ (personaOkCount > 0 && personaUnverifiedOkCount === 0 && errorRatio < 0.5 && !dryRun);
1286
1379
 
1287
1380
  const personaHealth = {
1288
1381
  ok: personaOkCount,
1289
1382
  error: personaErrorCount,
1290
1383
  skipped: personaSkippedCount,
1291
1384
  total: totalPersonas,
1385
+ verifiedOk: personaVerifiedOkCount,
1386
+ unverifiedOk: personaUnverifiedOkCount,
1292
1387
  errorRatio,
1293
1388
  totalCostUsd: totalCost,
1389
+ aiCallEvidence,
1294
1390
  healthy: aiCoverageHealthy || dryRun,
1295
1391
  warnings: [],
1296
1392
  };
@@ -1304,9 +1400,9 @@ export async function runOmarGateOrchestrator({
1304
1400
  `${personaErrorCount}/${totalPersonas} personas errored (${Math.round(errorRatio * 100)}%). AI coverage is degraded.`
1305
1401
  );
1306
1402
  }
1307
- if (personaOkCount > 0 && totalCost <= 0) {
1403
+ if (personaUnverifiedOkCount > 0) {
1308
1404
  personaHealth.warnings.push(
1309
- `Personas reported ok status but totalCost=$0.00 — likely silently returned empty findings without making LLM calls.`
1405
+ `${personaUnverifiedOkCount}/${personaOkCount} successful personas lacked token-bearing usage, billing-ledger, or priced-call evidence.`
1310
1406
  );
1311
1407
  }
1312
1408
  }
@@ -1330,6 +1426,7 @@ export async function runOmarGateOrchestrator({
1330
1426
  findings: (r.findings || []).length,
1331
1427
  summary: r.summary || { P0: 0, P1: 0, P2: 0, P3: 0 },
1332
1428
  costUsd: r.costUsd,
1429
+ aiCallEvidence: r.aiCallEvidence || null,
1333
1430
  durationMs: r.durationMs,
1334
1431
  model: r.model || null,
1335
1432
  billing: r.billing || null,
@@ -201,6 +201,19 @@ function tokenizePath(relativePath) {
201
201
  return [...tokens];
202
202
  }
203
203
 
204
+ function specMentionsExactPath(markdown, relativePath) {
205
+ const normalizedPath = toPosixPath(relativePath).toLowerCase().replace(/^\.\/+/, "");
206
+ if (!normalizedPath) {
207
+ return false;
208
+ }
209
+ const escapedPath = normalizedPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
210
+ const normalizedMarkdown = toPosixPath(markdown).toLowerCase();
211
+ return new RegExp(
212
+ `(^|[^a-z0-9._/-])(?:\\./)?${escapedPath}(?=$|[^a-z0-9._/-])`,
213
+ "m"
214
+ ).test(normalizedMarkdown);
215
+ }
216
+
204
217
  function isSourceFile(relativePath) {
205
218
  return SOURCE_EXTENSIONS.has(path.extname(String(relativePath || "")).toLowerCase());
206
219
  }
@@ -393,7 +406,9 @@ export async function runSpecBindingChecks({
393
406
  tokenMatchesByPath.set(relativePath, true);
394
407
  continue;
395
408
  }
396
- const tokenMatches = tokenizePath(relativePath).some((token) => normalizedSpecText.includes(token));
409
+ const tokenMatches =
410
+ specMentionsExactPath(spec.markdown, relativePath) ||
411
+ tokenizePath(relativePath).some((token) => normalizedSpecText.includes(token));
397
412
  tokenMatchesByPath.set(relativePath, tokenMatches);
398
413
  if (evaluateScopeDrift && !tokenMatches) {
399
414
  pushFinding(
@@ -2,7 +2,7 @@ import YAML from "yaml";
2
2
 
3
3
  export const DEFAULT_SCAN_WORKFLOW_PATH = ".github/workflows/omar-gate.yml";
4
4
  export const DEFAULT_SCAN_SECRET_NAME = "SENTINELAYER_TOKEN";
5
- export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@a496be33a466c0cc3f8616d66bbd7d78f7d3c31d";
5
+ export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@8afb7dbb42a5e1c25233d422c4cabe401ba02380";
6
6
  export const SUPPORTED_E2E_HINTS = Object.freeze(["auto", "yes", "no"]);
7
7
  export const SUPPORTED_PLAYWRIGHT_MODES = Object.freeze(["auto", "off", "baseline", "audit"]);
8
8
 
@@ -4,16 +4,14 @@ import path from "node:path";
4
4
  import process from "node:process";
5
5
 
6
6
  import { STUCK_THRESHOLDS } from "../agents/jules/pulse.js";
7
- import { createAgentEvent } from "../events/schema.js";
8
7
  import { inferSessionAgentIdentity } from "./agent-identity.js";
9
8
  import { resolveSessionPaths } from "./paths.js";
10
- import { emitContextBriefing } from "./recap.js";
9
+ import { buildContextBriefing } from "./recap.js";
11
10
  import {
12
11
  assignFriendlyName,
13
12
  buildSentiWelcome,
14
13
  shouldAutoRenameInRegistry,
15
14
  } from "./senti-naming.js";
16
- import { appendToStream } from "./stream.js";
17
15
 
18
16
  const AGENT_SNAPSHOT_SCHEMA_VERSION = "1.0.0";
19
17
 
@@ -177,28 +175,6 @@ async function writeAgentSnapshot(snapshotPath, snapshot) {
177
175
  }
178
176
  }
179
177
 
180
- async function emitAgentEvent(
181
- sessionId,
182
- event,
183
- payload,
184
- { targetPath = process.cwd(), awaitRemoteSync = false } = {}
185
- ) {
186
- const envelope = createAgentEvent({
187
- event,
188
- agent: {
189
- id: payload.agentId,
190
- model: payload.model,
191
- displayName: payload.displayName,
192
- provider: payload.provider,
193
- role: payload.role,
194
- clientKind: payload.clientKind,
195
- },
196
- sessionId,
197
- payload,
198
- });
199
- await appendToStream(sessionId, envelope, { targetPath, awaitRemoteSync });
200
- }
201
-
202
178
  function buildAgentSnapshotPath(paths, agentId) {
203
179
  const normalizedAgentId = normalizeString(agentId);
204
180
  if (!normalizedAgentId) {
@@ -288,17 +264,15 @@ function _untrackLocalAgent(sessionId, agentId) {
288
264
  _localAgents.delete(_agentKey(sessionId, agentId));
289
265
  }
290
266
 
291
- async function _emitLeaveForAllLocalAgents(reason) {
267
+ async function _leaveAllLocalAgents(reason) {
292
268
  const entries = [..._localAgents.values()];
293
269
  _localAgents.clear();
294
270
  for (const entry of entries) {
295
271
  try {
296
- await emitAgentEvent(
297
- entry.sessionId,
298
- "agent_leave",
299
- { agentId: entry.agentId, reason, model: "unknown", role: "participant" },
300
- { targetPath: entry.targetPath },
301
- );
272
+ await unregisterAgent(entry.sessionId, entry.agentId, {
273
+ reason,
274
+ targetPath: entry.targetPath,
275
+ });
302
276
  } catch {
303
277
  // Best-effort: a stuck filesystem or network shouldn't block exit.
304
278
  }
@@ -309,7 +283,7 @@ function _ensureExitHooksInstalled() {
309
283
  if (_exitHooksInstalled) return;
310
284
  _exitHooksInstalled = true;
311
285
  const onSignal = (signal) => {
312
- void _emitLeaveForAllLocalAgents("manual").finally(() => {
286
+ void _leaveAllLocalAgents("manual").finally(() => {
313
287
  process.removeListener(signal, onSignal);
314
288
  process.kill(process.pid, signal);
315
289
  });
@@ -317,7 +291,7 @@ function _ensureExitHooksInstalled() {
317
291
  process.on("SIGINT", onSignal);
318
292
  process.on("SIGTERM", onSignal);
319
293
  process.on("beforeExit", () => {
320
- void _emitLeaveForAllLocalAgents("manual");
294
+ void _leaveAllLocalAgents("manual");
321
295
  });
322
296
  }
323
297
 
@@ -332,7 +306,6 @@ export async function registerAgent(
332
306
  role = "observer",
333
307
  targetPath = process.cwd(),
334
308
  trackProcessExit = true,
335
- awaitRemoteSync = false,
336
309
  } = {}
337
310
  ) {
338
311
  const paths = resolveSessionPaths(sessionId, { targetPath });
@@ -426,46 +399,39 @@ export async function registerAgent(
426
399
  );
427
400
 
428
401
  await writeAgentSnapshot(snapshotPath, snapshot);
429
- await emitAgentEvent(paths.sessionId, "agent_join", {
430
- agentId: snapshot.agentId,
431
- model: snapshot.model,
432
- displayName: snapshot.displayName,
433
- provider: snapshot.provider,
434
- clientKind: snapshot.clientKind,
435
- role: snapshot.role,
436
- status: snapshot.status,
437
- }, { targetPath, awaitRemoteSync });
438
402
  if (trackProcessExit) {
439
403
  _trackLocalAgent(paths.sessionId, snapshot.agentId, targetPath);
440
404
  }
441
405
 
406
+ let welcome = null;
442
407
  if (renamedFrom) {
443
- const welcome = buildSentiWelcome({
408
+ welcome = buildSentiWelcome({
444
409
  agentId: snapshot.agentId,
445
410
  model: snapshot.model,
446
411
  role: snapshot.role,
447
412
  wasAnonymous: true,
448
413
  originalAgentId: renamedFrom,
449
414
  });
450
- await emitAgentEvent(paths.sessionId, "agent_identified", {
451
- ...welcome,
452
- sessionId: paths.sessionId,
453
- }, { targetPath });
454
415
  }
455
416
 
417
+ let contextBriefing = null;
456
418
  if (normalizeString(snapshot.agentId).toLowerCase() !== "senti") {
457
- await emitContextBriefing(paths.sessionId, {
419
+ contextBriefing = await buildContextBriefing(paths.sessionId, {
458
420
  forAgentId: snapshot.agentId,
459
421
  targetPath,
460
- awaitRemoteSync,
461
422
  }).catch(() => {});
462
423
  }
463
424
 
464
425
  return {
465
426
  ...snapshot,
466
427
  snapshotPath,
467
- emittedJoinEvent: true,
468
- emittedContextBriefing: normalizeString(snapshot.agentId).toLowerCase() !== "senti",
428
+ emittedJoinEvent: false,
429
+ emittedContextBriefing: false,
430
+ returnedContextBriefing: Boolean(contextBriefing),
431
+ onboarding: {
432
+ welcome,
433
+ contextBriefing,
434
+ },
469
435
  refreshedExistingAgent: false,
470
436
  };
471
437
  }
@@ -607,16 +573,9 @@ export async function unregisterAgent(
607
573
  );
608
574
 
609
575
  await writeAgentSnapshot(snapshotPath, snapshot);
610
- await emitAgentEvent(paths.sessionId, "agent_leave", {
611
- agentId: snapshot.agentId,
612
- reason: normalizedReason,
613
- role: snapshot.role,
614
- model: snapshot.model,
615
- displayName: snapshot.displayName,
616
- provider: snapshot.provider,
617
- clientKind: snapshot.clientKind,
618
- }, { targetPath });
619
- // Already left explicitly — don't double-emit on process exit.
576
+ // Presence and roster state are projections, not transcript events. The
577
+ // snapshot is authoritative for this workspace; remote listener presence is
578
+ // renewed through the TTL endpoint.
620
579
  _untrackLocalAgent(paths.sessionId, snapshot.agentId);
621
580
 
622
581
  return {