sentinelayer-cli 0.8.0 → 0.8.2

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 (153) hide show
  1. package/README.md +23 -2
  2. package/package.json +4 -4
  3. package/src/agents/ai-governance/index.js +12 -0
  4. package/src/agents/ai-governance/tools/base.js +171 -0
  5. package/src/agents/ai-governance/tools/eval-regression.js +47 -0
  6. package/src/agents/ai-governance/tools/hitl-audit.js +81 -0
  7. package/src/agents/ai-governance/tools/index.js +52 -0
  8. package/src/agents/ai-governance/tools/prompt-drift.js +42 -0
  9. package/src/agents/ai-governance/tools/provenance-check.js +69 -0
  10. package/src/agents/backend/index.js +12 -0
  11. package/src/agents/backend/tools/base.js +189 -0
  12. package/src/agents/backend/tools/circuit-breaker-check.js +123 -0
  13. package/src/agents/backend/tools/idempotency-audit.js +105 -0
  14. package/src/agents/backend/tools/index.js +87 -0
  15. package/src/agents/backend/tools/retry-audit.js +132 -0
  16. package/src/agents/backend/tools/timeout-audit.js +144 -0
  17. package/src/agents/code-quality/index.js +12 -0
  18. package/src/agents/code-quality/tools/base.js +159 -0
  19. package/src/agents/code-quality/tools/complexity-measure.js +197 -0
  20. package/src/agents/code-quality/tools/coupling-analysis.js +81 -0
  21. package/src/agents/code-quality/tools/cycle-detect.js +49 -0
  22. package/src/agents/code-quality/tools/dep-graph.js +196 -0
  23. package/src/agents/code-quality/tools/index.js +89 -0
  24. package/src/agents/data-layer/index.js +12 -0
  25. package/src/agents/data-layer/tools/base.js +181 -0
  26. package/src/agents/data-layer/tools/index-audit.js +165 -0
  27. package/src/agents/data-layer/tools/index.js +83 -0
  28. package/src/agents/data-layer/tools/migration-scan.js +135 -0
  29. package/src/agents/data-layer/tools/query-explain.js +120 -0
  30. package/src/agents/data-layer/tools/tenancy-scan.js +166 -0
  31. package/src/agents/documentation/index.js +12 -0
  32. package/src/agents/documentation/tools/api-diff.js +91 -0
  33. package/src/agents/documentation/tools/base.js +151 -0
  34. package/src/agents/documentation/tools/dead-link-check.js +58 -0
  35. package/src/agents/documentation/tools/docstring-coverage.js +78 -0
  36. package/src/agents/documentation/tools/index.js +52 -0
  37. package/src/agents/documentation/tools/readme-freshness.js +61 -0
  38. package/src/agents/envelope/fix-cycle.js +45 -0
  39. package/src/agents/envelope/index.js +31 -0
  40. package/src/agents/envelope/loop.js +150 -0
  41. package/src/agents/envelope/pulse.js +18 -0
  42. package/src/agents/envelope/stream.js +40 -0
  43. package/src/agents/infrastructure/index.js +12 -0
  44. package/src/agents/infrastructure/tools/base.js +171 -0
  45. package/src/agents/infrastructure/tools/checkov-run.js +32 -0
  46. package/src/agents/infrastructure/tools/drift-detect.js +59 -0
  47. package/src/agents/infrastructure/tools/iam-least-priv-check.js +78 -0
  48. package/src/agents/infrastructure/tools/index.js +52 -0
  49. package/src/agents/infrastructure/tools/tflint-run.js +31 -0
  50. package/src/agents/jules/loop.js +7 -4
  51. package/src/agents/jules/swarm/sub-agent.js +5 -1
  52. package/src/agents/jules/tools/auth-audit.js +10 -1
  53. package/src/agents/mode.js +113 -0
  54. package/src/agents/observability/index.js +12 -0
  55. package/src/agents/observability/tools/alert-audit.js +39 -0
  56. package/src/agents/observability/tools/base.js +181 -0
  57. package/src/agents/observability/tools/dashboard-gap.js +42 -0
  58. package/src/agents/observability/tools/index.js +54 -0
  59. package/src/agents/observability/tools/log-schema-check.js +74 -0
  60. package/src/agents/observability/tools/span-coverage.js +74 -0
  61. package/src/agents/persona-visuals.js +38 -0
  62. package/src/agents/release/index.js +12 -0
  63. package/src/agents/release/tools/base.js +181 -0
  64. package/src/agents/release/tools/changelog-diff.js +86 -0
  65. package/src/agents/release/tools/feature-flag-audit.js +126 -0
  66. package/src/agents/release/tools/index.js +61 -0
  67. package/src/agents/release/tools/rollback-verify.js +129 -0
  68. package/src/agents/release/tools/semver-check.js +109 -0
  69. package/src/agents/reliability/index.js +12 -0
  70. package/src/agents/reliability/tools/backpressure-check.js +129 -0
  71. package/src/agents/reliability/tools/base.js +181 -0
  72. package/src/agents/reliability/tools/chaos-probe.js +109 -0
  73. package/src/agents/reliability/tools/graceful-degradation-check.js +114 -0
  74. package/src/agents/reliability/tools/health-check-audit.js +111 -0
  75. package/src/agents/reliability/tools/index.js +87 -0
  76. package/src/agents/run-persona.js +109 -0
  77. package/src/agents/security/index.js +12 -0
  78. package/src/agents/security/tools/authz-audit.js +134 -0
  79. package/src/agents/security/tools/base.js +190 -0
  80. package/src/agents/security/tools/crypto-review.js +175 -0
  81. package/src/agents/security/tools/index.js +97 -0
  82. package/src/agents/security/tools/sast-scan.js +175 -0
  83. package/src/agents/security/tools/secrets-scan.js +216 -0
  84. package/src/agents/supply-chain/index.js +12 -0
  85. package/src/agents/supply-chain/tools/attestation-check.js +42 -0
  86. package/src/agents/supply-chain/tools/base.js +151 -0
  87. package/src/agents/supply-chain/tools/index.js +52 -0
  88. package/src/agents/supply-chain/tools/lockfile-integrity.js +73 -0
  89. package/src/agents/supply-chain/tools/package-verify.js +56 -0
  90. package/src/agents/supply-chain/tools/sbom-diff.js +34 -0
  91. package/src/agents/testing/index.js +12 -0
  92. package/src/agents/testing/tools/base.js +202 -0
  93. package/src/agents/testing/tools/coverage-gap.js +144 -0
  94. package/src/agents/testing/tools/flake-detect.js +125 -0
  95. package/src/agents/testing/tools/index.js +85 -0
  96. package/src/agents/testing/tools/mutation-test.js +143 -0
  97. package/src/agents/testing/tools/snapshot-diff.js +103 -0
  98. package/src/auth/gate.js +65 -37
  99. package/src/cli.js +1 -1
  100. package/src/commands/chat.js +3 -10
  101. package/src/commands/legacy-args.js +10 -0
  102. package/src/commands/omargate.js +36 -2
  103. package/src/commands/persona.js +46 -1
  104. package/src/commands/scan.js +3 -10
  105. package/src/commands/session.js +654 -6
  106. package/src/commands/spec.js +3 -10
  107. package/src/coord/events-log.js +141 -0
  108. package/src/coord/handshake.js +719 -0
  109. package/src/coord/index.js +35 -0
  110. package/src/coord/paths.js +84 -0
  111. package/src/coord/priority.js +62 -0
  112. package/src/coord/tarjan.js +157 -0
  113. package/src/cost/tokenizer.js +160 -0
  114. package/src/cost/tracker.js +61 -0
  115. package/src/daemon/artifact-lineage.js +362 -0
  116. package/src/daemon/assignment-ledger.js +117 -0
  117. package/src/daemon/ast-drift.js +496 -0
  118. package/src/daemon/ingest-refresh.js +69 -2
  119. package/src/ingest/engine.js +15 -0
  120. package/src/ingest/ownership.js +380 -0
  121. package/src/legacy-cli.js +68 -1
  122. package/src/orchestrator/kai-chen.js +126 -0
  123. package/src/review/ai-review.js +3 -10
  124. package/src/review/compliance-pack.js +389 -0
  125. package/src/review/investor-dd-config.js +54 -0
  126. package/src/review/investor-dd-file-loop.js +303 -0
  127. package/src/review/investor-dd-file-router.js +406 -0
  128. package/src/review/investor-dd-html-report.js +233 -0
  129. package/src/review/investor-dd-notification.js +120 -0
  130. package/src/review/investor-dd-orchestrator.js +405 -0
  131. package/src/review/investor-dd-persona-runner.js +275 -0
  132. package/src/review/live-validator.js +253 -0
  133. package/src/review/omargate-orchestrator.js +90 -2
  134. package/src/review/persona-prompts.js +244 -56
  135. package/src/review/reconciliation-rules.js +329 -0
  136. package/src/review/reproducibility-chain.js +136 -0
  137. package/src/review/scan-modes.js +102 -3
  138. package/src/session/agent-registry.js +7 -0
  139. package/src/session/analytics.js +479 -0
  140. package/src/session/daemon.js +609 -14
  141. package/src/session/file-locks.js +666 -0
  142. package/src/session/paths.js +4 -0
  143. package/src/session/recap.js +567 -0
  144. package/src/session/redact.js +82 -0
  145. package/src/session/runtime-bridge.js +24 -1
  146. package/src/session/scoring.js +406 -0
  147. package/src/session/setup-guides.js +304 -0
  148. package/src/session/store.js +318 -2
  149. package/src/session/stream.js +9 -1
  150. package/src/session/sync.js +753 -0
  151. package/src/session/tasks.js +1054 -0
  152. package/src/session/templates.js +188 -0
  153. package/src/swarm/runtime.js +1 -8
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Live-web validator for investor-DD (#investor-dd-25..28).
3
+ *
4
+ * Jules owns this lane. For each interactive element discovered by
5
+ * scanning the frontend source (buttons, forms, links), the validator
6
+ * provisions an ephemeral AIdenID identity, drives devTestBot to
7
+ * perform the interaction against the running site, and captures:
8
+ *
9
+ * - the observed HTTP status
10
+ * - console errors
11
+ * - network errors
12
+ * - navigation outcome
13
+ * - a short free-form observed-behavior summary
14
+ * - trace + video URIs (supplied by devTestBot)
15
+ *
16
+ * The module is driven through a pluggable client surface so the main
17
+ * flow can be unit-tested without spinning up a real browser and a
18
+ * real AIdenID tenant. Production wiring is a separate PR that swaps
19
+ * the stub client for the real devTestBot + AIdenID SDKs.
20
+ */
21
+
22
+ import fsp from "node:fs/promises";
23
+ import path from "node:path";
24
+
25
+ const INTERACTIVE_TAGS = Object.freeze([
26
+ "button",
27
+ "a",
28
+ "input",
29
+ "form",
30
+ "select",
31
+ "textarea",
32
+ ]);
33
+
34
+ const SOURCE_EXTENSIONS = Object.freeze([".tsx", ".jsx", ".html", ".vue", ".svelte"]);
35
+
36
+ /**
37
+ * Walk the frontend directory and extract candidate interactive
38
+ * elements from JSX/HTML-like files. Deliberately simple regex-based
39
+ * extraction; misses dynamic elements. Caller can fall back to a live
40
+ * DOM crawl when static extraction returns < 80% of expected element
41
+ * counts.
42
+ *
43
+ * @param {string} rootPath
44
+ * @param {string[]} [globLike] Optional include roots (default common frontend folders).
45
+ * @returns {Promise<Array<{elementLabel: string, sourceFile: string, lineIndex: number}>>}
46
+ */
47
+ export async function discoverInteractiveElements(rootPath, globLike = null) {
48
+ const candidateRoots = globLike || [
49
+ "src",
50
+ "app",
51
+ "pages",
52
+ "components",
53
+ "web",
54
+ "frontend",
55
+ "client",
56
+ ];
57
+ const elements = [];
58
+ for (const candidate of candidateRoots) {
59
+ const abs = path.join(rootPath, candidate);
60
+ try {
61
+ await fsp.access(abs);
62
+ } catch {
63
+ continue;
64
+ }
65
+ await walk(abs, candidate);
66
+ }
67
+ return elements;
68
+
69
+ async function walk(abs, rel) {
70
+ let entries;
71
+ try {
72
+ entries = await fsp.readdir(abs, { withFileTypes: true });
73
+ } catch {
74
+ return;
75
+ }
76
+ for (const entry of entries) {
77
+ if (entry.name.startsWith(".")) continue;
78
+ const absPath = path.join(abs, entry.name);
79
+ const relPath = `${rel}/${entry.name}`;
80
+ if (entry.isDirectory()) {
81
+ if (entry.name === "node_modules" || entry.name === "dist") continue;
82
+ await walk(absPath, relPath);
83
+ } else if (entry.isFile()) {
84
+ const ext = path.extname(entry.name).toLowerCase();
85
+ if (!SOURCE_EXTENSIONS.includes(ext)) continue;
86
+ try {
87
+ const stat = await fsp.stat(absPath);
88
+ if (stat.size > 512 * 1024) continue;
89
+ const text = await fsp.readFile(absPath, "utf-8");
90
+ extractFromText(text, relPath, elements);
91
+ } catch {
92
+ // skip unreadable
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ function extractFromText(text, sourceFile, elements) {
100
+ const lines = text.split(/\r?\n/);
101
+ for (let i = 0; i < lines.length; i += 1) {
102
+ const line = lines[i];
103
+ for (const tag of INTERACTIVE_TAGS) {
104
+ // Match both lowercase and Capitalized component forms; avoid
105
+ // overmatching by requiring a tag-like opener.
106
+ const re = new RegExp(`<${tag}[\\s>]|<${tag[0].toUpperCase()}${tag.slice(1)}[\\s>]`, "i");
107
+ if (!re.test(line)) continue;
108
+ const labelMatch =
109
+ /(?:aria-label|title|data-testid|id)="([^"]+)"/i.exec(line) ||
110
+ />([^<]{1,40})</.exec(line);
111
+ const elementLabel = labelMatch ? labelMatch[1].trim() : `${tag}-anon-${i}`;
112
+ elements.push({
113
+ elementLabel,
114
+ sourceFile,
115
+ lineIndex: i + 1,
116
+ });
117
+ break;
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @typedef {object} DevTestBotClient
124
+ * @property {(element: {elementLabel: string, sourceFile: string}, identity: object) => Promise<LiveObservation>} interact
125
+ * @property {(runId: string) => Promise<{videoUri: string, traceUri: string}>} [artifact]
126
+ */
127
+
128
+ /**
129
+ * @typedef {object} AidenidClient
130
+ * @property {(runId: string) => Promise<{identityId: string, email: string}>} provisionEphemeralIdentity
131
+ * @property {(identityId: string) => Promise<void>} [release]
132
+ */
133
+
134
+ /**
135
+ * Run the live validator across the discovered element plan.
136
+ *
137
+ * @param {object} params
138
+ * @param {string} params.runId
139
+ * @param {Array<{elementLabel: string, sourceFile: string}>} params.elements
140
+ * @param {DevTestBotClient} params.devTestBot
141
+ * @param {AidenidClient} params.aidenid
142
+ * @param {Function} [params.onEvent]
143
+ * @param {number} [params.maxInteractions] - Cap; defaults to elements.length.
144
+ * @returns {Promise<{identity: object, observations: Array<object>, skipped: number}>}
145
+ */
146
+ export async function runLiveValidator({
147
+ runId,
148
+ elements,
149
+ devTestBot,
150
+ aidenid,
151
+ onEvent = () => {},
152
+ maxInteractions = Infinity,
153
+ } = {}) {
154
+ if (!runId) throw new TypeError("runLiveValidator requires runId");
155
+ if (!Array.isArray(elements)) throw new TypeError("runLiveValidator requires elements array");
156
+ if (!devTestBot || typeof devTestBot.interact !== "function") {
157
+ throw new TypeError("runLiveValidator requires a devTestBot client with interact()");
158
+ }
159
+ if (!aidenid || typeof aidenid.provisionEphemeralIdentity !== "function") {
160
+ throw new TypeError(
161
+ "runLiveValidator requires an AIdenID client with provisionEphemeralIdentity()",
162
+ );
163
+ }
164
+
165
+ onEvent({ type: "live_validator_start", runId, elementCount: elements.length });
166
+ const identity = await aidenid.provisionEphemeralIdentity(runId);
167
+ onEvent({ type: "live_validator_identity_ready", runId, identityId: identity.identityId });
168
+
169
+ const observations = [];
170
+ let skipped = 0;
171
+ const budget = Number.isFinite(maxInteractions) ? maxInteractions : elements.length;
172
+ for (let i = 0; i < Math.min(elements.length, budget); i += 1) {
173
+ const element = elements[i];
174
+ onEvent({ type: "live_validator_interaction_start", runId, element });
175
+ try {
176
+ const obs = await devTestBot.interact(element, identity);
177
+ const enriched = {
178
+ ...obs,
179
+ sourceFile: element.sourceFile,
180
+ elementLabel: element.elementLabel,
181
+ interactionId: obs.interactionId || `${element.sourceFile}#${i}`,
182
+ };
183
+ observations.push(enriched);
184
+ onEvent({
185
+ type: "live_validator_interaction_complete",
186
+ runId,
187
+ interactionId: enriched.interactionId,
188
+ });
189
+ } catch (err) {
190
+ skipped += 1;
191
+ onEvent({
192
+ type: "live_validator_interaction_error",
193
+ runId,
194
+ element,
195
+ error: err instanceof Error ? err.message : String(err),
196
+ });
197
+ }
198
+ }
199
+
200
+ if (typeof aidenid.release === "function") {
201
+ try {
202
+ await aidenid.release(identity.identityId);
203
+ } catch {
204
+ // release errors never block the report
205
+ }
206
+ }
207
+
208
+ onEvent({
209
+ type: "live_validator_complete",
210
+ runId,
211
+ observationCount: observations.length,
212
+ skipped,
213
+ });
214
+ return { identity, observations, skipped };
215
+ }
216
+
217
+ /**
218
+ * Build a lookup map keyed by `sourceFile:lineIndex` so the
219
+ * reconciliation engine can pair each source finding with 0 or 1
220
+ * matching live observation.
221
+ *
222
+ * @param {Array<{sourceFile: string, lineIndex?: number, interactionId: string}>} observations
223
+ * @returns {Map<string, object>}
224
+ */
225
+ export function buildObservationIndex(observations) {
226
+ const map = new Map();
227
+ for (const obs of observations || []) {
228
+ if (!obs.sourceFile) continue;
229
+ const fileKey = obs.sourceFile;
230
+ if (obs.lineIndex) {
231
+ map.set(`${fileKey}:${obs.lineIndex}`, obs);
232
+ }
233
+ if (!map.has(fileKey)) {
234
+ map.set(fileKey, obs);
235
+ }
236
+ }
237
+ return map;
238
+ }
239
+
240
+ /**
241
+ * Pair function factory for reconcileFindings(). Looks up an
242
+ * observation for each finding by (file, line) or (file) fallback.
243
+ *
244
+ * @param {Map<string, object>} index
245
+ * @returns {(finding: object) => object | null}
246
+ */
247
+ export function createFindingObservationPair(index) {
248
+ return (finding) => {
249
+ if (!finding || !finding.file) return null;
250
+ const key = finding.line ? `${finding.file}:${finding.line}` : finding.file;
251
+ return index.get(key) || index.get(finding.file) || null;
252
+ };
253
+ }
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
9
9
 
10
10
  import { runAiReviewLayer } from "./ai-review.js";
11
11
  import { buildPersonaReviewPrompt, PERSONA_IDS } from "./persona-prompts.js";
12
- import { resolveScanMode } from "./scan-modes.js";
12
+ import { resolveFilteredPersonas, resolveScanMode } from "./scan-modes.js";
13
13
  import { reconcileReviewFindings } from "./report.js";
14
14
  import { resolvePersonaVisual } from "../agents/persona-visuals.js";
15
15
  import { syncRunToDashboard } from "../telemetry/sync.js";
@@ -81,6 +81,8 @@ function decoratePersonaResult(personaId, baseResult) {
81
81
  * @param {string} [options.outputDir] - Output directory override
82
82
  * @param {object} [options.deterministic] - Deterministic scan results
83
83
  * @param {Function} [options.onEvent] - Event callback for streaming
84
+ * @param {string[] | null} [options.includeOnly] - Only run these persona IDs (filters scan-mode roster).
85
+ * @param {string[] | null} [options.skipPersonas] - Skip these persona IDs (filters scan-mode roster).
84
86
  * @returns {Promise<object>} Orchestrated results
85
87
  */
86
88
  export async function runOmarGateOrchestrator({
@@ -94,11 +96,41 @@ export async function runOmarGateOrchestrator({
94
96
  outputDir = "",
95
97
  deterministic = null,
96
98
  onEvent = null,
99
+ includeOnly = null,
100
+ skipPersonas = null,
97
101
  } = {}) {
98
102
  const runId = `omargate-${Date.now()}-${randomUUID().slice(0, 8)}`;
99
103
  const startTime = Date.now();
100
104
 
101
- const { mode, personas } = resolveScanMode(scanMode);
105
+ const filterRequested =
106
+ (Array.isArray(includeOnly) && includeOnly.length > 0)
107
+ || (Array.isArray(skipPersonas) && skipPersonas.length > 0);
108
+
109
+ const resolved = filterRequested
110
+ ? resolveFilteredPersonas(scanMode, {
111
+ includeOnly: Array.isArray(includeOnly) ? includeOnly : undefined,
112
+ skipPersonas: Array.isArray(skipPersonas) ? skipPersonas : undefined,
113
+ })
114
+ : { ...resolveScanMode(scanMode), dropped: [], unknown: [] };
115
+
116
+ const { mode, personas } = resolved;
117
+ const droppedPersonas = resolved.dropped || [];
118
+ const unknownPersonas = resolved.unknown || [];
119
+
120
+ if (onEvent && (droppedPersonas.length > 0 || unknownPersonas.length > 0)) {
121
+ onEvent(createAgentEvent({
122
+ event: "omargate_persona_filter",
123
+ agent: OMAR_ORCHESTRATOR_AGENT,
124
+ payload: {
125
+ runId,
126
+ mode,
127
+ dropped: droppedPersonas,
128
+ unknown: unknownPersonas,
129
+ effective: personas,
130
+ },
131
+ runId,
132
+ }));
133
+ }
102
134
 
103
135
  const roster = personas.map((personaId) => {
104
136
  const visual = resolvePersonaVisual(personaId) || {};
@@ -322,6 +354,45 @@ export async function runOmarGateOrchestrator({
322
354
  const totalCost = settled.reduce((sum, r) => sum + (r.costUsd || 0), 0);
323
355
  const totalDuration = Date.now() - startTime;
324
356
 
357
+ // Silent-failure detection: if >=50% of personas errored OR total cost is
358
+ // zero with non-zero personas dispatched, treat as a LOUD orchestrator
359
+ // warning. Prior behavior silently returned zero AI findings, masking
360
+ // auth failures or LLM proxy outages as "clean scan".
361
+ const personaErrorCount = settled.filter((r) => r.status === "error").length;
362
+ const personaSkippedCount = settled.filter((r) => r.status === "skipped").length;
363
+ const personaOkCount = settled.filter((r) => r.status === "ok").length;
364
+ const totalPersonas = settled.length;
365
+ const errorRatio = totalPersonas > 0 ? personaErrorCount / totalPersonas : 0;
366
+ const aiCoverageHealthy =
367
+ totalPersonas === 0 ||
368
+ (personaOkCount > 0 && totalCost > 0 && errorRatio < 0.5 && !dryRun);
369
+
370
+ const personaHealth = {
371
+ ok: personaOkCount,
372
+ error: personaErrorCount,
373
+ skipped: personaSkippedCount,
374
+ total: totalPersonas,
375
+ errorRatio,
376
+ healthy: aiCoverageHealthy || dryRun,
377
+ warnings: [],
378
+ };
379
+ if (!dryRun && totalPersonas > 0) {
380
+ if (personaOkCount === 0 && personaErrorCount > 0) {
381
+ personaHealth.warnings.push(
382
+ `ALL ${totalPersonas} personas errored. AI coverage is ZERO. Re-check auth (sl auth login) or LLM proxy config.`
383
+ );
384
+ } else if (errorRatio >= 0.5) {
385
+ personaHealth.warnings.push(
386
+ `${personaErrorCount}/${totalPersonas} personas errored (${Math.round(errorRatio * 100)}%). AI coverage is degraded.`
387
+ );
388
+ }
389
+ if (personaOkCount > 0 && totalCost <= 0) {
390
+ personaHealth.warnings.push(
391
+ `Personas reported ok status but totalCost=$0.00 — likely silently returned empty findings without making LLM calls.`
392
+ );
393
+ }
394
+ }
395
+
325
396
  const result = {
326
397
  runId,
327
398
  mode,
@@ -337,6 +408,7 @@ export async function runOmarGateOrchestrator({
337
408
  model: r.model || null,
338
409
  error: r.error || null,
339
410
  })),
411
+ personaHealth,
340
412
  findings: reconciledFindings,
341
413
  findingsBySource: {
342
414
  deterministic: detFindings.length,
@@ -358,6 +430,22 @@ export async function runOmarGateOrchestrator({
358
430
  dryRun,
359
431
  };
360
432
 
433
+ // Emit warnings to the event stream so terminal handler can render them.
434
+ if (onEvent && personaHealth.warnings.length > 0) {
435
+ onEvent(createAgentEvent({
436
+ event: "persona_health_warning",
437
+ agent: { id: "orchestrator", persona: "Omar Orchestrator" },
438
+ payload: {
439
+ ok: personaOkCount,
440
+ error: personaErrorCount,
441
+ total: totalPersonas,
442
+ errorRatio,
443
+ warnings: personaHealth.warnings,
444
+ },
445
+ runId,
446
+ }));
447
+ }
448
+
361
449
  if (onEvent) {
362
450
  onEvent(createAgentEvent({
363
451
  event: "omargate_complete",