sentinelayer-cli 0.4.5 → 0.8.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 (72) hide show
  1. package/README.md +16 -18
  2. package/package.json +7 -6
  3. package/src/agents/jules/config/definition.js +13 -62
  4. package/src/agents/jules/config/system-prompt.js +8 -1
  5. package/src/agents/jules/fix-cycle.js +12 -372
  6. package/src/agents/jules/loop.js +116 -26
  7. package/src/agents/jules/pulse.js +10 -327
  8. package/src/agents/jules/stream.js +13 -12
  9. package/src/agents/jules/swarm/orchestrator.js +3 -3
  10. package/src/agents/jules/swarm/sub-agent.js +6 -3
  11. package/src/agents/jules/tools/aidenid-email.js +189 -0
  12. package/src/agents/jules/tools/auth-audit.js +1187 -45
  13. package/src/agents/jules/tools/dispatch.js +25 -12
  14. package/src/agents/jules/tools/file-edit.js +2 -180
  15. package/src/agents/jules/tools/file-read.js +2 -100
  16. package/src/agents/jules/tools/glob.js +2 -168
  17. package/src/agents/jules/tools/grep.js +2 -228
  18. package/src/agents/jules/tools/path-guards.js +2 -161
  19. package/src/agents/jules/tools/runtime-audit.js +6 -2
  20. package/src/agents/jules/tools/shell.js +2 -383
  21. package/src/agents/persona-visuals.js +64 -0
  22. package/src/agents/shared-tools/dispatch-core.js +320 -0
  23. package/src/agents/shared-tools/file-edit.js +180 -0
  24. package/src/agents/shared-tools/file-read.js +100 -0
  25. package/src/agents/shared-tools/glob.js +168 -0
  26. package/src/agents/shared-tools/grep.js +228 -0
  27. package/src/agents/shared-tools/index.js +46 -0
  28. package/src/agents/shared-tools/path-guards.js +161 -0
  29. package/src/agents/shared-tools/shell.js +383 -0
  30. package/src/ai/aidenid.js +56 -7
  31. package/src/ai/client.js +45 -0
  32. package/src/ai/proxy.js +137 -0
  33. package/src/auth/gate.js +290 -16
  34. package/src/auth/http.js +450 -39
  35. package/src/auth/service.js +262 -47
  36. package/src/auth/session-store.js +475 -21
  37. package/src/cli.js +5 -0
  38. package/src/commands/audit.js +13 -8
  39. package/src/commands/auth.js +53 -9
  40. package/src/commands/omargate.js +10 -2
  41. package/src/commands/scan.js +10 -4
  42. package/src/commands/session.js +590 -0
  43. package/src/commands/spec.js +62 -0
  44. package/src/commands/watch.js +3 -2
  45. package/src/daemon/assignment-ledger.js +196 -0
  46. package/src/daemon/error-worker.js +599 -16
  47. package/src/daemon/fix-cycle.js +384 -0
  48. package/src/daemon/ingest-refresh.js +10 -9
  49. package/src/daemon/jira-lifecycle.js +135 -0
  50. package/src/daemon/pulse.js +327 -0
  51. package/src/daemon/scope-engine.js +1068 -0
  52. package/src/events/schema.js +190 -0
  53. package/src/interactive/index.js +18 -16
  54. package/src/legacy-cli.js +606 -37
  55. package/src/prompt/generator.js +19 -1
  56. package/src/review/ai-review.js +11 -1
  57. package/src/review/local-review.js +75 -19
  58. package/src/review/omargate-interactive.js +68 -0
  59. package/src/review/omargate-orchestrator.js +404 -0
  60. package/src/review/persona-prompts.js +296 -0
  61. package/src/review/scan-modes.js +48 -0
  62. package/src/scan/generator.js +1 -1
  63. package/src/session/agent-registry.js +352 -0
  64. package/src/session/daemon.js +801 -0
  65. package/src/session/paths.js +33 -0
  66. package/src/session/runtime-bridge.js +739 -0
  67. package/src/session/store.js +388 -0
  68. package/src/session/stream.js +325 -0
  69. package/src/spec/generator.js +100 -0
  70. package/src/telemetry/session-tracker.js +148 -32
  71. package/src/telemetry/sync.js +6 -2
  72. package/src/ui/command-hints.js +13 -0
@@ -34,6 +34,12 @@ const TARGET_GUIDANCE = Object.freeze({
34
34
  ],
35
35
  });
36
36
 
37
+ const SESSION_COORDINATION_GUIDANCE = Object.freeze([
38
+ "Multi-agent coordination: use `sl session` commands to communicate with other agents.",
39
+ "Always update the session chat room with your current activity so joining agents have context.",
40
+ "Never break your autonomous loop on unexpected file changes; ask in the session first.",
41
+ ]);
42
+
37
43
  function normalizeTarget(target) {
38
44
  const normalized = String(target || "generic").trim().toLowerCase();
39
45
  if (!SUPPORTED_PROMPT_TARGETS.includes(normalized)) {
@@ -55,6 +61,14 @@ function buildAgentHeader(target) {
55
61
  return headers[target] || headers.generic;
56
62
  }
57
63
 
64
+ function shouldAppendSessionGuidance(specMarkdown) {
65
+ const normalized = String(specMarkdown || "").toLowerCase();
66
+ if (!normalized) {
67
+ return false;
68
+ }
69
+ return normalized.includes("coordination protocol") || normalized.includes("session");
70
+ }
71
+
58
72
  export function resolvePromptTarget(target) {
59
73
  return normalizeTarget(target);
60
74
  }
@@ -81,7 +95,11 @@ export function generateExecutionPrompt({
81
95
  throw new Error("Spec content is empty. Generate or provide a spec before creating a prompt.");
82
96
  }
83
97
 
84
- const guidanceMarkdown = guidance.map((item, index) => `${index + 1}. ${item}`).join("\n");
98
+ const operatingRules = [...guidance];
99
+ if (shouldAppendSessionGuidance(specText)) {
100
+ operatingRules.push(...SESSION_COORDINATION_GUIDANCE);
101
+ }
102
+ const guidanceMarkdown = operatingRules.map((item, index) => `${index + 1}. ${item}`).join("\n");
85
103
 
86
104
  const hasAidenId = specText.toLowerCase().includes("aidenid");
87
105
  const aidenidGuidance = hasAidenId
@@ -413,11 +413,21 @@ export async function runAiReviewLayer({
413
413
  const normalizedRunId = normalizeString(runId) || "review-ai";
414
414
 
415
415
  const config = await loadConfig({ cwd: normalizedTargetPath, env });
416
- const resolvedProvider = resolveProvider({
416
+ let resolvedProvider = resolveProvider({
417
417
  provider,
418
418
  configProvider: config.resolved.defaultModelProvider,
419
419
  env,
420
420
  });
421
+ // If no explicit provider and default fell through to openai,
422
+ // check for stored sentinelayer session (async fallback)
423
+ if (resolvedProvider === "openai" && !provider && !config.resolved.defaultModelProvider) {
424
+ try {
425
+ const { resolveProviderAsync } = await import("../ai/client.js");
426
+ resolvedProvider = await resolveProviderAsync({ env });
427
+ } catch {
428
+ // keep sync result
429
+ }
430
+ }
421
431
  const resolvedModel = resolveModel({
422
432
  provider: resolvedProvider,
423
433
  model,
@@ -14,8 +14,34 @@ const IGNORED_DIRS = new Set([
14
14
  ".next",
15
15
  "dist",
16
16
  "build",
17
+ "out",
18
+ "coverage",
19
+ "__pycache__",
20
+ ".turbo",
21
+ ".cache",
22
+ ".parcel-cache",
23
+ ".svelte-kit",
24
+ ".nuxt",
25
+ ".output",
26
+ ".vercel",
17
27
  ".sentinelayer",
28
+ // v0.7 (2026-04-16): exclude generated demo/e2e folders that create massive
29
+ // self-scan noise. These are fixtures produced by the CLI itself and
30
+ // contain intentionally-naive demo code that pollutes reviewer signal.
31
+ "demo-e2e-cli-todo",
32
+ "demo-playground",
33
+ "e2e-demo-2026-04-10",
34
+ ".worktree-runtime",
18
35
  ]);
36
+
37
+ // Paths that are always "test-like" — rules with high false-positive rates
38
+ // in tests (hardcoded localhost, HTTP literals, example credentials) should
39
+ // suppress or demote findings here. Keep narrow; do not suppress P0/P1.
40
+ const TEST_LIKE_PATH_PATTERN = /(?:^|[\\/])(?:tests?|__tests__|fixtures?|e2e|demo|demo-[^\\/]*|examples?|samples?|docs?[\\/]examples?)(?:[\\/]|$)/i;
41
+
42
+ function isTestLikePath(relPath) {
43
+ return TEST_LIKE_PATH_PATTERN.test(String(relPath || ""));
44
+ }
19
45
  const MAX_FILE_SIZE_BYTES = 512 * 1024;
20
46
  const MAX_FINDINGS = 250;
21
47
  const STATIC_CHECK_TIMEOUT_MS = 120_000;
@@ -184,8 +210,12 @@ const DETERMINISTIC_REVIEW_RULES = Object.freeze([
184
210
  severity: "P2",
185
211
  message: "Plain HTTP endpoint literal found.",
186
212
  suggestedFix: "Prefer HTTPS endpoints in production paths.",
187
- regex: /\bhttp:\/\/[^\s'"]+/i,
213
+ // Skip localhost/127.0.0.1/0.0.0.0 and common local-dev host patterns —
214
+ // those are dev-time fixtures and do not warrant P2. Plain http://example.com,
215
+ // cdn URLs, and external hosts still fire.
216
+ regex: /\bhttp:\/\/(?!(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1|\[::1\]))[^\s'"]+/i,
188
217
  sourceOnly: true,
218
+ excludePathPattern: TEST_LIKE_PATH_PATTERN,
189
219
  },
190
220
  {
191
221
  id: "SL-SEC-014",
@@ -203,6 +233,7 @@ const DETERMINISTIC_REVIEW_RULES = Object.freeze([
203
233
  suggestedFix: "Replace eval with explicit parser or safe handlers.",
204
234
  regex: /\beval\s*\(/,
205
235
  sourceOnly: true,
236
+ excludePathPattern: TEST_LIKE_PATH_PATTERN,
206
237
  },
207
238
  {
208
239
  id: "SL-SEC-016",
@@ -219,6 +250,9 @@ const DETERMINISTIC_REVIEW_RULES = Object.freeze([
219
250
  suggestedFix: "Use parameterized queries and prepared statements.",
220
251
  regex: /\b(?:SELECT|INSERT|UPDATE|DELETE)\b[^;\n]{0,140}\+/i,
221
252
  sourceOnly: true,
253
+ // Self-scan dampener: the local-review source itself contains SQL-like
254
+ // regex literals and must not flag itself.
255
+ excludePathPattern: LOCAL_REVIEW_SOURCE_PATH_PATTERN,
222
256
  },
223
257
  {
224
258
  id: "SL-SEC-018",
@@ -241,8 +275,12 @@ const DETERMINISTIC_REVIEW_RULES = Object.freeze([
241
275
  severity: "P2",
242
276
  message: "Potentially sensitive value logged directly.",
243
277
  suggestedFix: "Redact secrets/tokens before logging.",
244
- regex: /console\.(?:log|debug|info)\([^)]*(token|secret|password|api[_-]?key)/i,
278
+ // Tighter regex: require the secret-like identifier to be a variable
279
+ // reference, not just appear inside any string/template. Excludes
280
+ // error-message text like "invalid token" and doc examples.
281
+ regex: /console\.(?:log|debug|info)\(\s*(?:[`"'][^`"']*\$\{)?\s*[A-Za-z_$][A-Za-z0-9_$]*\.?(token|secret|password|api[_-]?key)/i,
245
282
  sourceOnly: true,
283
+ excludePathPattern: TEST_LIKE_PATH_PATTERN,
246
284
  },
247
285
  {
248
286
  id: "SL-SEC-021",
@@ -260,6 +298,7 @@ const DETERMINISTIC_REVIEW_RULES = Object.freeze([
260
298
  suggestedFix: "Externalize callback URLs to environment config.",
261
299
  regex: /https?:\/\/localhost:\d{2,5}\//i,
262
300
  sourceOnly: true,
301
+ excludePathPattern: TEST_LIKE_PATH_PATTERN,
263
302
  },
264
303
  ]);
265
304
 
@@ -651,21 +690,33 @@ async function runPatternChecks({ rootPath, filePaths, maxFindings = MAX_FINDING
651
690
  continue;
652
691
  }
653
692
 
654
- if (/dangerouslySetInnerHTML/.test(line) || /innerHTML\s*=/.test(line)) {
655
- tryPushFinding(
656
- findings,
657
- createFinding({
658
- severity: "P1",
659
- file: relativePath,
660
- line: index + 1,
661
- message: "Direct HTML sink detected; validate/sanitize untrusted content.",
662
- excerpt: sanitizeLineForExcerpt(line),
663
- ruleId: "SL-PAT-002",
664
- suggestedFix: "Apply strict sanitization and avoid raw HTML sinks.",
665
- layer: "pattern",
666
- }),
667
- maxFindings
668
- );
693
+ // Require actual JSX attribute usage OR DOM property assignment, not
694
+ // bare mentions in strings/docstrings (common in prompt templates and
695
+ // detector files that search for the pattern as a label).
696
+ const realJsxUsage = /dangerouslySetInnerHTML\s*=\s*\{\s*\{/.test(line);
697
+ const realDomAssign = /(?:^|[.\s])innerHTML\s*=\s*(?!=)/.test(line);
698
+ if (realJsxUsage || realDomAssign) {
699
+ const isPromptOrConfig =
700
+ /(?:^|[\\/])(?:config|prompts?|templates?|system-prompt|swarm|agents)[\\/]/i.test(relativePath) ||
701
+ /-prompts?\.(?:m?js|tsx?)$/i.test(relativePath) ||
702
+ /system-prompt\.(?:m?js|tsx?)$/i.test(relativePath) ||
703
+ /(?:file-scanner|pattern-hunter|-scanner|-hunter)\.(?:m?js|tsx?)$/i.test(relativePath);
704
+ if (!isTestLikePath(relativePath) && !isPromptOrConfig) {
705
+ tryPushFinding(
706
+ findings,
707
+ createFinding({
708
+ severity: "P1",
709
+ file: relativePath,
710
+ line: index + 1,
711
+ message: "Direct HTML sink detected; validate/sanitize untrusted content.",
712
+ excerpt: sanitizeLineForExcerpt(line),
713
+ ruleId: "SL-PAT-002",
714
+ suggestedFix: "Apply strict sanitization and avoid raw HTML sinks.",
715
+ layer: "pattern",
716
+ }),
717
+ maxFindings
718
+ );
719
+ }
669
720
  }
670
721
 
671
722
  if (/useEffect\s*\(/.test(line)) {
@@ -690,7 +741,7 @@ async function runPatternChecks({ rootPath, filePaths, maxFindings = MAX_FINDING
690
741
 
691
742
  if (/(for|while)\s*\([^)]*\)/.test(line)) {
692
743
  const window = lines.slice(index, Math.min(lines.length, index + 10)).join("\n");
693
- if (/findMany\(|query\(|SELECT\b|fetch\(/i.test(window)) {
744
+ if (/findMany\(|query\(|SELECT\b|fetch\(/i.test(window) && !isTestLikePath(relativePath)) {
694
745
  tryPushFinding(
695
746
  findings,
696
747
  createFinding({
@@ -710,7 +761,12 @@ async function runPatternChecks({ rootPath, filePaths, maxFindings = MAX_FINDING
710
761
  }
711
762
 
712
763
  const sqlConcat = /\b(?:SELECT|INSERT|UPDATE|DELETE)\b[^\n]{0,160}\+/i.exec(text);
713
- if (sqlConcat && findings.length < maxFindings) {
764
+ if (
765
+ sqlConcat &&
766
+ findings.length < maxFindings &&
767
+ !isTestLikePath(relativePath) &&
768
+ !LOCAL_REVIEW_SOURCE_PATH_PATTERN.test(relativePath)
769
+ ) {
714
770
  const lineNumber = resolveLineNumberFromIndex(text, sqlConcat.index);
715
771
  tryPushFinding(
716
772
  findings,
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Interactive post-scan menu for Omar Gate deep-dive.
3
+ *
4
+ * After the scan completes, prompts the user to select a domain agent
5
+ * for full agentic loop analysis (multi-turn, tool-using).
6
+ */
7
+
8
+ import { createInterface } from "node:readline/promises";
9
+ import { stdin as input, stdout as output } from "node:process";
10
+ import pc from "picocolors";
11
+
12
+ import { PERSONA_IDS } from "./persona-prompts.js";
13
+
14
+ /**
15
+ * Show interactive persona selection after Omar Gate scan.
16
+ *
17
+ * @param {object} options
18
+ * @param {object} options.scanResult - Result from runOmarGateOrchestrator
19
+ * @returns {Promise<string|null>} Selected persona ID, "all", or null (skip)
20
+ */
21
+ export async function promptPersonaDeepDive({ scanResult } = {}) {
22
+ const summary = scanResult?.summary || {};
23
+ const personas = scanResult?.personas || [];
24
+
25
+ console.log("");
26
+ console.log(pc.bold("Omar Gate scan complete."));
27
+ console.log(
28
+ `Findings: P0=${summary.P0 || 0} P1=${summary.P1 || 0} P2=${summary.P2 || 0} P3=${summary.P3 || 0} | ` +
29
+ `Cost: $${(scanResult?.totalCostUsd || 0).toFixed(4)} | ` +
30
+ `Duration: ${((scanResult?.totalDurationMs || 0) / 1000).toFixed(1)}s`
31
+ );
32
+ console.log("");
33
+
34
+ // Show persona results
35
+ for (const p of personas) {
36
+ const icon = p.status === "ok" ? pc.green("✓") : p.status === "skipped" ? pc.gray("○") : pc.red("✗");
37
+ const count = p.findings || 0;
38
+ console.log(` ${icon} ${p.id} — ${count} finding${count === 1 ? "" : "s"}`);
39
+ }
40
+
41
+ console.log("");
42
+ console.log(pc.gray("Deep-dive runs a full agentic loop (multi-turn, tool-using) for deeper analysis."));
43
+ console.log(pc.gray(`Available: ${PERSONA_IDS.join(", ")}, all, none`));
44
+ console.log("");
45
+
46
+ const rl = createInterface({ input, output });
47
+ try {
48
+ const answer = await rl.question(pc.cyan("Deep-dive into which agent? [none] "));
49
+ const normalized = String(answer || "").trim().toLowerCase();
50
+
51
+ if (!normalized || normalized === "none" || normalized === "n" || normalized === "skip") {
52
+ return null;
53
+ }
54
+
55
+ if (normalized === "all") {
56
+ return "all";
57
+ }
58
+
59
+ if (PERSONA_IDS.includes(normalized)) {
60
+ return normalized;
61
+ }
62
+
63
+ console.log(pc.yellow(`Unknown agent '${normalized}'. Skipping deep-dive.`));
64
+ return null;
65
+ } finally {
66
+ rl.close();
67
+ }
68
+ }
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Omar Gate multi-persona orchestrator.
3
+ *
4
+ * Runs N persona-scoped AI review calls in parallel (bounded concurrency),
5
+ * merges findings into a blackboard, deduplicates, and produces a unified report.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+
10
+ import { runAiReviewLayer } from "./ai-review.js";
11
+ import { buildPersonaReviewPrompt, PERSONA_IDS } from "./persona-prompts.js";
12
+ import { resolveScanMode } from "./scan-modes.js";
13
+ import { reconcileReviewFindings } from "./report.js";
14
+ import { resolvePersonaVisual } from "../agents/persona-visuals.js";
15
+ import { syncRunToDashboard } from "../telemetry/sync.js";
16
+ import { createAgentEvent } from "../events/schema.js";
17
+
18
+ const OMAR_ORCHESTRATOR_AGENT = Object.freeze({
19
+ id: "omar-orchestrator",
20
+ persona: "Omar Gate Orchestrator",
21
+ });
22
+
23
+ /**
24
+ * Run bounded-concurrency parallel execution.
25
+ * @param {Array} items
26
+ * @param {number} maxConcurrent
27
+ * @param {Function} fn
28
+ * @returns {Promise<Array>}
29
+ */
30
+ async function runWithConcurrency(items, maxConcurrent, fn) {
31
+ const results = [];
32
+ const executing = new Set();
33
+
34
+ for (const item of items) {
35
+ const p = fn(item).then((result) => {
36
+ executing.delete(p);
37
+ return result;
38
+ });
39
+ executing.add(p);
40
+ results.push(p);
41
+
42
+ if (executing.size >= maxConcurrent) {
43
+ await Promise.race(executing);
44
+ }
45
+ }
46
+
47
+ return Promise.allSettled(results);
48
+ }
49
+
50
+ /**
51
+ * Annotate persona result with visual identity so stream consumers
52
+ * and downstream reports never see faceless persona IDs.
53
+ */
54
+ function decoratePersonaResult(personaId, baseResult) {
55
+ const visual = resolvePersonaVisual(personaId) || {};
56
+ return {
57
+ ...baseResult,
58
+ personaId,
59
+ persona: {
60
+ id: personaId,
61
+ shortName: visual.shortName || personaId,
62
+ fullName: visual.fullName || personaId,
63
+ avatar: visual.avatar || "",
64
+ color: visual.color || "gray",
65
+ domain: visual.domain || personaId,
66
+ },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Run the Omar Gate multi-persona orchestrator.
72
+ *
73
+ * @param {object} options
74
+ * @param {string} options.targetPath - Repository path
75
+ * @param {string} [options.scanMode] - "baseline", "deep", or "full-depth"
76
+ * @param {number} [options.maxParallel] - Max concurrent persona calls (default 4)
77
+ * @param {string} [options.provider] - LLM provider override
78
+ * @param {string} [options.model] - LLM model override
79
+ * @param {number} [options.maxCostUsd] - Global cost ceiling (default 5.0)
80
+ * @param {boolean} [options.dryRun] - Dry-run mode (no LLM calls)
81
+ * @param {string} [options.outputDir] - Output directory override
82
+ * @param {object} [options.deterministic] - Deterministic scan results
83
+ * @param {Function} [options.onEvent] - Event callback for streaming
84
+ * @returns {Promise<object>} Orchestrated results
85
+ */
86
+ export async function runOmarGateOrchestrator({
87
+ targetPath,
88
+ scanMode = "deep",
89
+ maxParallel = 4,
90
+ provider = "",
91
+ model = "",
92
+ maxCostUsd = 5.0,
93
+ dryRun = false,
94
+ outputDir = "",
95
+ deterministic = null,
96
+ onEvent = null,
97
+ } = {}) {
98
+ const runId = `omargate-${Date.now()}-${randomUUID().slice(0, 8)}`;
99
+ const startTime = Date.now();
100
+
101
+ const { mode, personas } = resolveScanMode(scanMode);
102
+
103
+ const roster = personas.map((personaId) => {
104
+ const visual = resolvePersonaVisual(personaId) || {};
105
+ return {
106
+ id: personaId,
107
+ shortName: visual.shortName || personaId,
108
+ fullName: visual.fullName || personaId,
109
+ avatar: visual.avatar || "",
110
+ color: visual.color || "gray",
111
+ domain: visual.domain || personaId,
112
+ };
113
+ });
114
+
115
+ if (onEvent) {
116
+ onEvent(createAgentEvent({
117
+ event: "omargate_start",
118
+ agent: OMAR_ORCHESTRATOR_AGENT,
119
+ payload: { runId, mode, personas, roster, maxParallel, maxCostUsd, dryRun },
120
+ runId,
121
+ }));
122
+ }
123
+
124
+ const detSummary = deterministic?.summary || { P0: 0, P1: 0, P2: 0, P3: 0 };
125
+ const detFindings = deterministic?.findings || [];
126
+
127
+ // Per-persona cost budget = global / persona count (with minimum floor)
128
+ const perPersonaCost = Math.max(0.25, maxCostUsd / personas.length);
129
+ let runningCostUsd = 0;
130
+
131
+ const personaResults = await runWithConcurrency(personas, maxParallel, async (personaId) => {
132
+ const visual = resolvePersonaVisual(personaId) || {};
133
+ const identity = {
134
+ id: personaId,
135
+ shortName: visual.shortName || personaId,
136
+ fullName: visual.fullName || personaId,
137
+ avatar: visual.avatar || "",
138
+ color: visual.color || "gray",
139
+ domain: visual.domain || personaId,
140
+ };
141
+
142
+ // Global budget check — skip remaining personas if exhausted
143
+ if (runningCostUsd >= maxCostUsd) {
144
+ if (onEvent) {
145
+ onEvent(createAgentEvent({
146
+ event: "persona_skipped",
147
+ agent: {
148
+ id: identity.id,
149
+ persona: identity.fullName,
150
+ shortName: identity.shortName,
151
+ color: identity.color,
152
+ avatar: identity.avatar,
153
+ domain: identity.domain,
154
+ },
155
+ payload: { personaId, identity, reason: "global_budget_exhausted", runningCostUsd, maxCostUsd },
156
+ runId,
157
+ }));
158
+ }
159
+ return {
160
+ personaId,
161
+ status: "skipped",
162
+ findings: [],
163
+ summary: { P0: 0, P1: 0, P2: 0, P3: 0 },
164
+ costUsd: 0,
165
+ durationMs: 0,
166
+ reason: "global_budget_exhausted",
167
+ };
168
+ }
169
+
170
+ const personaStart = Date.now();
171
+
172
+ if (onEvent) {
173
+ onEvent(createAgentEvent({
174
+ event: "persona_start",
175
+ agent: {
176
+ id: identity.id,
177
+ persona: identity.fullName,
178
+ shortName: identity.shortName,
179
+ color: identity.color,
180
+ avatar: identity.avatar,
181
+ domain: identity.domain,
182
+ },
183
+ payload: { personaId, identity, mode, runId },
184
+ runId,
185
+ }));
186
+ }
187
+
188
+ try {
189
+ const systemPrompt = buildPersonaReviewPrompt({
190
+ personaId,
191
+ targetPath,
192
+ deterministicSummary: detSummary,
193
+ });
194
+
195
+ const result = await runAiReviewLayer({
196
+ targetPath,
197
+ mode: "full",
198
+ runId: `${runId}-${personaId}`,
199
+ runDirectory: targetPath,
200
+ deterministic: {
201
+ summary: detSummary,
202
+ findings: detFindings,
203
+ metadata: deterministic?.metadata || {},
204
+ },
205
+ outputDir,
206
+ provider: provider || undefined,
207
+ model: model || undefined,
208
+ maxCostUsd: perPersonaCost,
209
+ dryRun,
210
+ env: process.env,
211
+ });
212
+
213
+ const findings = (result?.findings || []).map((f) => ({
214
+ ...f,
215
+ persona: personaId,
216
+ layer: personaId,
217
+ }));
218
+
219
+ if (onEvent) {
220
+ for (const finding of findings) {
221
+ onEvent(createAgentEvent({
222
+ event: "persona_finding",
223
+ agent: {
224
+ id: identity.id,
225
+ persona: identity.fullName,
226
+ shortName: identity.shortName,
227
+ color: identity.color,
228
+ avatar: identity.avatar,
229
+ domain: identity.domain,
230
+ },
231
+ payload: { personaId, identity, ...finding },
232
+ runId,
233
+ }));
234
+ }
235
+ onEvent(createAgentEvent({
236
+ event: "persona_complete",
237
+ agent: {
238
+ id: identity.id,
239
+ persona: identity.fullName,
240
+ shortName: identity.shortName,
241
+ color: identity.color,
242
+ avatar: identity.avatar,
243
+ domain: identity.domain,
244
+ },
245
+ payload: {
246
+ personaId,
247
+ identity,
248
+ findings: findings.length,
249
+ summary: result?.summary || {},
250
+ costUsd: result?.costUsd || 0,
251
+ durationMs: Date.now() - personaStart,
252
+ },
253
+ runId,
254
+ }));
255
+ }
256
+
257
+ const personaCost = result?.costUsd || 0;
258
+ runningCostUsd += personaCost;
259
+
260
+ return {
261
+ personaId,
262
+ status: "ok",
263
+ findings,
264
+ summary: result?.summary || { P0: 0, P1: 0, P2: 0, P3: 0 },
265
+ costUsd: personaCost,
266
+ model: result?.model || model || null,
267
+ durationMs: Date.now() - personaStart,
268
+ };
269
+ } catch (err) {
270
+ if (onEvent) {
271
+ onEvent(createAgentEvent({
272
+ event: "persona_error",
273
+ agent: {
274
+ id: identity.id,
275
+ persona: identity.fullName,
276
+ shortName: identity.shortName,
277
+ color: identity.color,
278
+ avatar: identity.avatar,
279
+ domain: identity.domain,
280
+ },
281
+ payload: { personaId, identity, error: err.message },
282
+ runId,
283
+ }));
284
+ }
285
+ return {
286
+ personaId,
287
+ status: "error",
288
+ findings: [],
289
+ summary: { P0: 0, P1: 0, P2: 0, P3: 0 },
290
+ costUsd: 0,
291
+ error: err.message,
292
+ durationMs: Date.now() - personaStart,
293
+ };
294
+ }
295
+ });
296
+
297
+ // Collect results (handle settled promises)
298
+ const settled = personaResults.map((r) =>
299
+ r.status === "fulfilled"
300
+ ? decoratePersonaResult(r.value.personaId, r.value)
301
+ : decoratePersonaResult("unknown", {
302
+ status: "error",
303
+ findings: [],
304
+ summary: { P0: 0, P1: 0, P2: 0, P3: 0 },
305
+ costUsd: 0,
306
+ error: r.reason?.message || "unknown",
307
+ durationMs: 0,
308
+ })
309
+ );
310
+
311
+ // Reconcile AI findings with deterministic findings — canonical single list.
312
+ // Confidence boost when multiple layers agree; deterministic findings get
313
+ // confidence 1.0; AI findings keep their self-reported confidence.
314
+ const allAiFindings = settled.flatMap((r) => r.findings);
315
+ const reconciled = reconcileReviewFindings({
316
+ deterministicFindings: detFindings,
317
+ aiFindings: allAiFindings,
318
+ });
319
+ const reconciledFindings = reconciled.findings;
320
+ const reconciledSummary = reconciled.summary;
321
+
322
+ const totalCost = settled.reduce((sum, r) => sum + (r.costUsd || 0), 0);
323
+ const totalDuration = Date.now() - startTime;
324
+
325
+ const result = {
326
+ runId,
327
+ mode,
328
+ roster,
329
+ personas: settled.map((r) => ({
330
+ id: r.personaId,
331
+ identity: r.persona,
332
+ status: r.status,
333
+ findings: (r.findings || []).length,
334
+ summary: r.summary || { P0: 0, P1: 0, P2: 0, P3: 0 },
335
+ costUsd: r.costUsd,
336
+ durationMs: r.durationMs,
337
+ model: r.model || null,
338
+ error: r.error || null,
339
+ })),
340
+ findings: reconciledFindings,
341
+ findingsBySource: {
342
+ deterministic: detFindings.length,
343
+ ai: allAiFindings.length,
344
+ reconciled: reconciledFindings.length,
345
+ },
346
+ summary: reconciledSummary,
347
+ totalCostUsd: totalCost,
348
+ totalDurationMs: totalDuration,
349
+ reconciliation: {
350
+ deterministicFindings: detFindings.length,
351
+ aiFindings: allAiFindings.length,
352
+ reconciledFindings: reconciledFindings.length,
353
+ dedupedCount: detFindings.length + allAiFindings.length - reconciledFindings.length,
354
+ multiSourceFindings: reconciledFindings.filter(
355
+ (f) => Array.isArray(f.sources) && f.sources.length > 1
356
+ ).length,
357
+ },
358
+ dryRun,
359
+ };
360
+
361
+ if (onEvent) {
362
+ onEvent(createAgentEvent({
363
+ event: "omargate_complete",
364
+ agent: OMAR_ORCHESTRATOR_AGENT,
365
+ payload: {
366
+ runId,
367
+ mode,
368
+ personaCount: settled.length,
369
+ findings: reconciledFindings.length,
370
+ summary: result.summary,
371
+ reconciliation: result.reconciliation,
372
+ totalCostUsd: totalCost,
373
+ totalDurationMs: totalDuration,
374
+ },
375
+ runId,
376
+ }));
377
+ }
378
+
379
+ // Fire-and-forget telemetry sync to dashboard
380
+ syncRunToDashboard({
381
+ command: `omargate deep --scan-mode ${mode}`,
382
+ persona: "omar-orchestrator",
383
+ usage: {
384
+ inputTokens: 0,
385
+ outputTokens: 0,
386
+ costUsd: totalCost,
387
+ durationMs: totalDuration,
388
+ toolCalls: personas.length,
389
+ },
390
+ summary: result.summary,
391
+ reconciliation: result.reconciliation,
392
+ stopReason: result.summary.blocking ? "blocked" : "passed",
393
+ personaBreakdown: settled.map((r) => ({
394
+ personaId: r.personaId,
395
+ fullName: r.persona?.fullName || r.personaId,
396
+ findings: r.findings?.length || 0,
397
+ costUsd: r.costUsd || 0,
398
+ durationMs: r.durationMs || 0,
399
+ status: r.status,
400
+ })),
401
+ }).catch(() => {});
402
+
403
+ return result;
404
+ }