sentinelayer-cli 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,279 @@
1
+ import { frontendAnalyze } from "./frontend-analyze.js";
2
+
3
+ const SEVERITIES = new Set(["P0", "P1", "P2", "P3"]);
4
+
5
+ function toPosix(value) {
6
+ return String(value || "").replace(/\\/g, "/");
7
+ }
8
+
9
+ function normalizeSeverity(value, fallback = "P2") {
10
+ const normalized = String(value || "").trim().toUpperCase();
11
+ return SEVERITIES.has(normalized) ? normalized : fallback;
12
+ }
13
+
14
+ function normalizeFiles(files) {
15
+ if (!Array.isArray(files)) return [];
16
+ return files.map((file) => toPosix(file).trim()).filter(Boolean);
17
+ }
18
+
19
+ function isInScope(file, scopeFiles) {
20
+ const normalized = toPosix(file);
21
+ if (!normalized || scopeFiles.length === 0) return true;
22
+ return scopeFiles.some((candidate) =>
23
+ candidate === normalized ||
24
+ candidate.endsWith(`/${normalized}`) ||
25
+ normalized.endsWith(`/${candidate}`)
26
+ );
27
+ }
28
+
29
+ function createFinding({
30
+ kind,
31
+ severity,
32
+ file = "",
33
+ line = 0,
34
+ evidence = "",
35
+ rootCause = "",
36
+ recommendedFix = "",
37
+ confidence = 0.7,
38
+ } = {}) {
39
+ return {
40
+ persona: "frontend",
41
+ tool: "frontend-analyze",
42
+ kind: String(kind || "frontend.finding"),
43
+ severity: normalizeSeverity(severity),
44
+ file: toPosix(file),
45
+ line: Number.isFinite(Number(line)) ? Math.max(0, Math.floor(Number(line))) : 0,
46
+ evidence: String(evidence || "").trim().slice(0, 400),
47
+ rootCause: String(rootCause || "").trim(),
48
+ recommendedFix: String(recommendedFix || "").trim(),
49
+ confidence: Math.max(0, Math.min(1, Number(confidence) || 0)),
50
+ };
51
+ }
52
+
53
+ function dedupeFindings(findings) {
54
+ const seen = new Set();
55
+ const deduped = [];
56
+ for (const finding of findings) {
57
+ const key = [
58
+ finding.kind,
59
+ finding.file,
60
+ finding.line,
61
+ finding.evidence,
62
+ ].join("|");
63
+ if (seen.has(key)) continue;
64
+ seen.add(key);
65
+ deduped.push(finding);
66
+ }
67
+ return deduped;
68
+ }
69
+
70
+ function analyze(operation, rootPath) {
71
+ try {
72
+ return frontendAnalyze({ operation, path: rootPath });
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Deterministic Investor-DD adapter for Jules/frontend.
80
+ *
81
+ * `frontendAnalyze` is intentionally repo-scoped, so the Investor-DD runner
82
+ * invokes this as a single `scope: "repo"` tool per frontend persona run.
83
+ */
84
+ export async function runFrontendInvestorDdAnalyze({ rootPath, files = [] } = {}) {
85
+ const scopeFiles = normalizeFiles(files);
86
+ const findings = [];
87
+
88
+ const securitySinks = analyze("find_security_sinks", rootPath);
89
+ for (const sink of securitySinks?.sinks || []) {
90
+ if (!isInScope(sink.file, scopeFiles)) continue;
91
+ findings.push(createFinding({
92
+ kind: `frontend.security.${String(sink.type || "sink").replace(/\W+/g, "_")}`,
93
+ severity: sink.severity || "P2",
94
+ file: sink.file,
95
+ line: sink.line || 0,
96
+ evidence: `${sink.type || "frontend security sink"} detected`,
97
+ rootCause: "Frontend markup or script execution sink can create XSS or client-side code execution risk.",
98
+ recommendedFix: "Remove the sink or sanitize/escape untrusted content with a reviewed allowlist sanitizer.",
99
+ confidence: sink.severity === "P0" ? 0.85 : 0.75,
100
+ }));
101
+ }
102
+
103
+ const envExposure = analyze("find_env_exposure", rootPath);
104
+ for (const exposure of envExposure?.findings || []) {
105
+ if (!isInScope(exposure.file, scopeFiles)) continue;
106
+ findings.push(createFinding({
107
+ kind: "frontend.env_exposure",
108
+ severity: exposure.severity || (exposure.sensitive ? "P1" : "P3"),
109
+ file: exposure.file,
110
+ line: exposure.line || 0,
111
+ evidence: `${exposure.variable || "public env variable"} referenced in frontend source`,
112
+ rootCause: "Public frontend environment prefixes are shipped to the browser; sensitive names can leak credentials or encourage unsafe configuration.",
113
+ recommendedFix: "Keep secrets server-side and expose only explicitly public, non-sensitive configuration to client bundles.",
114
+ confidence: exposure.sensitive ? 0.8 : 0.45,
115
+ }));
116
+ }
117
+
118
+ const missingCleanup = analyze("find_missing_cleanup", rootPath);
119
+ for (const cleanup of missingCleanup?.findings || []) {
120
+ if (!isInScope(cleanup.file, scopeFiles)) continue;
121
+ findings.push(createFinding({
122
+ kind: "frontend.effect_missing_cleanup",
123
+ severity: cleanup.severity || "P2",
124
+ file: cleanup.file,
125
+ evidence: cleanup.pattern || "Effect with timer/subscription has no cleanup return",
126
+ rootCause: "Effects that subscribe or schedule timers without cleanup leak work across remounts and route changes.",
127
+ recommendedFix: "Return a cleanup function that unsubscribes, removes listeners, or clears timers.",
128
+ confidence: 0.7,
129
+ }));
130
+ }
131
+
132
+ const staleClosures = analyze("find_stale_closures", rootPath);
133
+ for (const closure of staleClosures?.findings || []) {
134
+ if (!isInScope(closure.file, scopeFiles)) continue;
135
+ findings.push(createFinding({
136
+ kind: "frontend.stale_closure",
137
+ severity: closure.severity || "P2",
138
+ file: closure.file,
139
+ line: closure.line || 0,
140
+ evidence: closure.pattern || "useEffect with empty dependency array may capture stale values",
141
+ rootCause: "Effects with empty dependencies can retain initial state/props while the UI continues to change.",
142
+ recommendedFix: "Declare dependencies, use stable refs intentionally, or move logic out of the effect.",
143
+ confidence: 0.55,
144
+ }));
145
+ }
146
+
147
+ const stateHooks = analyze("count_state_hooks", rootPath);
148
+ for (const component of stateHooks?.refactorCandidates || []) {
149
+ if (!isInScope(component.file, scopeFiles)) continue;
150
+ findings.push(createFinding({
151
+ kind: "frontend.state_god_component",
152
+ severity: component.risk === "god_component" ? "P2" : "P3",
153
+ file: component.file,
154
+ evidence: `${component.useStateCount} useState calls (${component.risk})`,
155
+ rootCause: "High local state count usually indicates a component with too many responsibilities and fragile render behavior.",
156
+ recommendedFix: "Split the component or consolidate related state transitions with a reducer/domain hook.",
157
+ confidence: component.risk === "god_component" ? 0.7 : 0.45,
158
+ }));
159
+ }
160
+
161
+ const accessibility = analyze("check_accessibility", rootPath);
162
+ for (const check of accessibility?.findings || []) {
163
+ const severity = normalizeSeverity(check.severity, "P3");
164
+ if (String(check.severity || "").toLowerCase() === "pass") continue;
165
+ if (Number(check.matchCount || 0) <= 0 && !String(check.check || "").includes("skip navigation")) continue;
166
+ findings.push(createFinding({
167
+ kind: "frontend.accessibility_check",
168
+ severity,
169
+ evidence: `${check.check || "accessibility heuristic"}; matches=${check.matchCount || 0}`,
170
+ rootCause: "Accessibility regressions block keyboard and assistive-technology users from completing core flows.",
171
+ recommendedFix: "Verify semantic labels, keyboard reachability, focus order, skip navigation, and automated axe coverage.",
172
+ confidence: 0.45,
173
+ }));
174
+ }
175
+
176
+ const mobile = analyze("check_mobile_responsive", rootPath);
177
+ for (const check of mobile?.findings || []) {
178
+ const rawSeverity = String(check.severity || "");
179
+ if (rawSeverity === "pass" || rawSeverity === "info") continue;
180
+ findings.push(createFinding({
181
+ kind: "frontend.mobile_responsive",
182
+ severity: check.severity || "P2",
183
+ evidence: `${check.check || "responsive heuristic"}; present=${check.present}; count=${check.count || 0}`,
184
+ rootCause: "Missing viewport or responsive styling evidence can make primary flows fail on mobile.",
185
+ recommendedFix: "Add viewport metadata and validate mobile/tablet breakpoints for the core user journey.",
186
+ confidence: 0.55,
187
+ }));
188
+ }
189
+
190
+ const errorBoundaries = analyze("check_error_boundaries", rootPath);
191
+ if (errorBoundaries && errorBoundaries.severity !== "pass") {
192
+ findings.push(createFinding({
193
+ kind: "frontend.error_boundary_gap",
194
+ severity: errorBoundaries.severity || "P2",
195
+ evidence: `errorBoundaryFiles=${errorBoundaries.errorBoundaryFiles || 0}; routeCount=${errorBoundaries.routeCount || 0}; coverage=${errorBoundaries.coverage || "n/a"}`,
196
+ rootCause: "Route-level runtime exceptions can blank the UI without a recoverable boundary.",
197
+ recommendedFix: "Add framework-native error boundaries for routed surfaces and test the failure state.",
198
+ confidence: 0.65,
199
+ }));
200
+ }
201
+
202
+ const images = analyze("check_image_optimization", rootPath);
203
+ if (images && images.severity !== "pass") {
204
+ findings.push(createFinding({
205
+ kind: "frontend.image_optimization",
206
+ severity: images.severity || "P2",
207
+ evidence: `rawImgTags=${images.rawImgTags || 0}; nextImageUsage=${images.nextImageUsage || 0}; missingDimensions=${images.missingDimensions || 0}`,
208
+ rootCause: "Unoptimized or dimensionless images can hurt LCP and cause layout shifts.",
209
+ recommendedFix: "Use framework image components or explicit dimensions/lazy loading for non-critical images.",
210
+ confidence: 0.55,
211
+ }));
212
+ } else if (images && Number(images.missingDimensions || 0) > 0) {
213
+ findings.push(createFinding({
214
+ kind: "frontend.image_dimensions_missing",
215
+ severity: "P2",
216
+ evidence: `missingDimensions=${images.missingDimensions}`,
217
+ rootCause: "Images without stable dimensions can cause cumulative layout shift.",
218
+ recommendedFix: "Set width/height or reserve aspect-ratio boxes for rendered images.",
219
+ confidence: 0.65,
220
+ }));
221
+ }
222
+
223
+ const fonts = analyze("check_font_loading", rootPath);
224
+ if (fonts && fonts.severity !== "pass") {
225
+ findings.push(createFinding({
226
+ kind: "frontend.font_loading",
227
+ severity: fonts.severity || "P2",
228
+ evidence: `fontDisplayUsage=${fonts.fontDisplayUsage || 0}; googleFontsUsage=${fonts.googleFontsUsage || 0}`,
229
+ rootCause: "Remote fonts without font-display controls can block text rendering or shift layout.",
230
+ recommendedFix: "Use font-display swap/optional, preload critical fonts, or self-host critical font assets.",
231
+ confidence: 0.55,
232
+ }));
233
+ }
234
+
235
+ const css = analyze("check_css_health", rootPath);
236
+ if (css && css.severity !== "pass") {
237
+ findings.push(createFinding({
238
+ kind: "frontend.css_health",
239
+ severity: css.severity || "P2",
240
+ evidence: `importantCount=${css.importantCount || 0}; tailwindConfigured=${Boolean(css.tailwindConfigured)}`,
241
+ rootCause: "Large numbers of !important declarations make UI state and responsive overrides brittle.",
242
+ recommendedFix: "Refactor repeated overrides into component variants or ordered utility/style layers.",
243
+ confidence: 0.45,
244
+ }));
245
+ }
246
+
247
+ const testCoverage = analyze("find_test_coverage", rootPath);
248
+ if (testCoverage && Number(testCoverage.componentCount || 0) > 0 && Number(testCoverage.untestedCount || 0) > 0) {
249
+ findings.push(createFinding({
250
+ kind: "frontend.test_coverage_gap",
251
+ severity: Number(testCoverage.testCount || 0) === 0 ? "P2" : "P3",
252
+ evidence: `components=${testCoverage.componentCount}; tests=${testCoverage.testCount}; untested=${testCoverage.untestedCount}; ratio=${testCoverage.coverageRatio || "n/a"}`,
253
+ rootCause: "Interactive components without nearby tests are likely to regress without being caught pre-merge.",
254
+ recommendedFix: "Add component/unit tests for primary states and at least one integration smoke for the core route.",
255
+ confidence: 0.55,
256
+ }));
257
+ }
258
+
259
+ return dedupeFindings(findings);
260
+ }
261
+
262
+ export const FRONTEND_TOOLS = Object.freeze({
263
+ "frontend-analyze": {
264
+ id: "frontend-analyze",
265
+ scope: "repo",
266
+ description:
267
+ "Run Jules Tanaka's deterministic frontend analyzer once for the routed frontend surface and convert UI/UX, accessibility, runtime, and web-vital heuristics into Investor-DD findings.",
268
+ schema: {
269
+ type: "object",
270
+ properties: {
271
+ rootPath: { type: "string" },
272
+ files: { type: "array", items: { type: "string" } },
273
+ },
274
+ },
275
+ handler: runFrontendInvestorDdAnalyze,
276
+ },
277
+ });
278
+
279
+ export const FRONTEND_TOOL_IDS = Object.freeze(Object.keys(FRONTEND_TOOLS));
package/src/legacy-cli.js CHANGED
@@ -1148,6 +1148,8 @@ async function runLocalOmarGateCommand(args) {
1148
1148
  const requireUsageLedger = hasCommandOption(args, "--require-usage-ledger");
1149
1149
  const model = getCommandOptionValue(args, "--model") || "gpt-5.3-codex";
1150
1150
  const provider = getCommandOptionValue(args, "--provider") || "sentinelayer";
1151
+ const personaCsvFlag = getCommandOptionValue(args, "--persona") || "";
1152
+ const skipPersonaCsvFlag = getCommandOptionValue(args, "--skip-persona") || "";
1151
1153
 
1152
1154
  const targetPath = path.resolve(process.cwd(), pathArg);
1153
1155
  if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
@@ -1155,6 +1157,11 @@ async function runLocalOmarGateCommand(args) {
1155
1157
  }
1156
1158
 
1157
1159
  const { runInvestorDd } = await import("./review/investor-dd-orchestrator.js");
1160
+ const { parsePersonaCsv, resolveFilteredPersonas } = await import("./review/scan-modes.js");
1161
+ const personaFilter = resolveFilteredPersonas("full-depth", {
1162
+ includeOnly: parsePersonaCsv(personaCsvFlag),
1163
+ skipPersonas: parsePersonaCsv(skipPersonaCsvFlag),
1164
+ });
1158
1165
  const reportEmailClient = emailOnComplete
1159
1166
  ? await import("./review/dd-report-email-client.js")
1160
1167
  : null;
@@ -1164,12 +1171,20 @@ async function runLocalOmarGateCommand(args) {
1164
1171
  printInfo(
1165
1172
  `Budget: $${maxCostUsd.toFixed(2)} / ${maxRuntimeMinutes}min / ${maxParallel} parallel`,
1166
1173
  );
1174
+ printInfo(`Personas: ${personaFilter.personas.join(", ")}`);
1175
+ if (personaFilter.unknown.length > 0) {
1176
+ printInfo(`Unknown persona IDs ignored: ${personaFilter.unknown.join(", ")}`);
1177
+ }
1178
+ if (personaFilter.dropped.length > 0) {
1179
+ printInfo(`Skipped personas: ${personaFilter.dropped.join(", ")}`);
1180
+ }
1167
1181
  if (dryRun) printInfo("Mode: dry-run (plan + stub report only)");
1168
1182
  }
1169
1183
  const result = await runInvestorDd({
1170
1184
  rootPath: targetPath,
1171
1185
  outputDir: outputDirArg,
1172
1186
  budgetOptions: { maxCostUsd, maxRuntimeMinutes, maxParallel },
1187
+ personas: personaFilter.personas,
1173
1188
  dryRun,
1174
1189
  devTestBot: {
1175
1190
  enabled: devTestBotEnabled,
@@ -2420,6 +2435,80 @@ jobs:
2420
2435
  echo "- run_id: \\\`\${{ steps.omar.outputs.run_id }}\\\`" >> "\$GITHUB_STEP_SUMMARY"
2421
2436
  echo "- gate_status: \\\`\${{ steps.omar.outputs.gate_status }}\\\`" >> "\$GITHUB_STEP_SUMMARY"
2422
2437
  echo "- findings: P0=\${{ steps.omar.outputs.p0_count }} P1=\${{ steps.omar.outputs.p1_count }} P2=\${{ steps.omar.outputs.p2_count }} P3=\${{ steps.omar.outputs.p3_count }}" >> "\$GITHUB_STEP_SUMMARY"
2438
+ - name: Stage Omar summary artifact
2439
+ shell: bash
2440
+ env:
2441
+ OMAR_RUN_ID: \${{ steps.omar.outputs.run_id || '' }}
2442
+ OMAR_GATE_STATUS: \${{ steps.omar.outputs.gate_status || '' }}
2443
+ OMAR_P0: \${{ steps.omar.outputs.p0_count || '0' }}
2444
+ OMAR_P1: \${{ steps.omar.outputs.p1_count || '0' }}
2445
+ OMAR_P2: \${{ steps.omar.outputs.p2_count || '0' }}
2446
+ OMAR_P3: \${{ steps.omar.outputs.p3_count || '0' }}
2447
+ OMAR_SCAN_MODE: \${{ github.event_name == 'workflow_dispatch' && inputs.scan_mode || 'deep' }}
2448
+ OMAR_SEVERITY_GATE: \${{ github.event_name == 'workflow_dispatch' && inputs.severity_gate || 'P1' }}
2449
+ OMAR_P2_MAX_ALLOWED: \${{ github.event_name == 'workflow_dispatch' && inputs.p2_max_allowed || '5' }}
2450
+ run: |
2451
+ set -euo pipefail
2452
+ mkdir -p omar-artifacts
2453
+ python3 - <<'PY'
2454
+ import json
2455
+ import os
2456
+ from pathlib import Path
2457
+
2458
+ def env(name, default=""):
2459
+ return os.environ.get(name, default)
2460
+
2461
+ def int_env(name):
2462
+ value = env(name, "0").strip()
2463
+ return int(value) if value.isdigit() else 0
2464
+
2465
+ github_run_id = env("GITHUB_RUN_ID")
2466
+ server_url = env("GITHUB_SERVER_URL", "https://github.com").rstrip("/")
2467
+ repository = env("GITHUB_REPOSITORY")
2468
+ run_url = f"{server_url}/{repository}/actions/runs/{github_run_id}" if repository and github_run_id else ""
2469
+ summary = {
2470
+ "schema_version": 1,
2471
+ "kind": "omar_gate_summary",
2472
+ "run_id": env("OMAR_RUN_ID"),
2473
+ "gate_status": env("OMAR_GATE_STATUS"),
2474
+ "findings": {
2475
+ "P0": int_env("OMAR_P0"),
2476
+ "P1": int_env("OMAR_P1"),
2477
+ "P2": int_env("OMAR_P2"),
2478
+ "P3": int_env("OMAR_P3"),
2479
+ },
2480
+ "threshold": {
2481
+ "severity_gate": env("OMAR_SEVERITY_GATE", "P1"),
2482
+ "p2_max_allowed": int_env("OMAR_P2_MAX_ALLOWED"),
2483
+ },
2484
+ "scan": {
2485
+ "mode": env("OMAR_SCAN_MODE", "deep"),
2486
+ "action_ref": "mrrCarter/sentinelayer-v1-action@03d7369cba7de2e9f15b959275c982111f0ee493",
2487
+ "managed_llm": False,
2488
+ "llm_failure_policy": "block",
2489
+ },
2490
+ "github": {
2491
+ "repository": repository,
2492
+ "sha": env("GITHUB_SHA"),
2493
+ "ref": env("GITHUB_REF"),
2494
+ "event_name": env("GITHUB_EVENT_NAME"),
2495
+ "workflow": env("GITHUB_WORKFLOW"),
2496
+ "run_id": github_run_id,
2497
+ "run_attempt": env("GITHUB_RUN_ATTEMPT"),
2498
+ "run_url": run_url,
2499
+ },
2500
+ }
2501
+ Path("omar-artifacts/summary.json").write_text(
2502
+ json.dumps(summary, indent=2, sort_keys=True) + "\n",
2503
+ encoding="utf-8",
2504
+ )
2505
+ PY
2506
+ - name: Upload Omar summary artifact
2507
+ uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874
2508
+ with:
2509
+ name: omar-gate-artifacts
2510
+ path: omar-artifacts/summary.json
2511
+ if-no-files-found: error
2423
2512
  `;
2424
2513
  }
2425
2514
 
@@ -41,21 +41,9 @@ import { renderInvestorDdHtml } from "./investor-dd-html-report.js";
41
41
  import { runDevTestBotPhase } from "./investor-dd-devtestbot.js";
42
42
  import { redactDdEmailError } from "./dd-report-email-client.js";
43
43
  import { buildInvestorDdProgress } from "./investor-dd-progress.js";
44
+ import { FULL_DEPTH_PERSONAS } from "./scan-modes.js";
44
45
 
45
- const INVESTOR_DD_PERSONAS = Object.freeze([
46
- "security",
47
- "backend",
48
- "code-quality",
49
- "testing",
50
- "data-layer",
51
- "reliability",
52
- "release",
53
- "observability",
54
- "infrastructure",
55
- "supply-chain",
56
- "documentation",
57
- "ai-governance",
58
- ]);
46
+ const INVESTOR_DD_PERSONAS = Object.freeze([...FULL_DEPTH_PERSONAS]);
59
47
 
60
48
  /**
61
49
  * Walk the target repo and return a list of relative POSIX file paths.
@@ -148,7 +136,10 @@ function buildSummaryMarkdown({ runId, summary, routing, byPersona }) {
148
136
  lines.push("");
149
137
  lines.push("| Persona | Files routed | Files visited | Findings |");
150
138
  lines.push("|---|---:|---:|---:|");
151
- for (const personaId of INVESTOR_DD_PERSONAS) {
139
+ const reportPersonas = Array.isArray(summary.personas) && summary.personas.length > 0
140
+ ? summary.personas
141
+ : INVESTOR_DD_PERSONAS;
142
+ for (const personaId of reportPersonas) {
152
143
  const record = byPersona[personaId] || {};
153
144
  const routed = (routing[personaId] || []).length;
154
145
  const visited = Array.isArray(record.visited) ? record.visited.length : 0;
@@ -264,7 +255,7 @@ async function triggerReportEmail({ reportEmail, runResult, dryRun, emit }) {
264
255
  * @param {string} params.rootPath
265
256
  * @param {string} [params.outputDir] - Defaults to `<rootPath>/.sentinelayer/runs/<runId>`.
266
257
  * @param {object} [params.budgetOptions] - Overrides from CLI: { maxUsd, maxRuntimeMinutes, maxParallel }.
267
- * @param {string[]} [params.personas] - Override persona list; defaults to all 12.
258
+ * @param {string[]} [params.personas] - Override persona list; defaults to all 13.
268
259
  * @param {Function} [params.onEvent] - Extra event sink (NDJSON stream is always written).
269
260
  * @param {boolean} [params.dryRun] - If true, skip tool execution, emit plan.json + stub report only.
270
261
  * @param {string[]|null} [params.compliancePacks] - Compliance pack IDs to run (default: all seven).
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Unified per-persona runner for investor-DD (#investor-dd-4..15).
3
3
  *
4
- * Wires the 12 domain personas (security, backend, code-quality, testing,
4
+ * Wires the 13 domain personas (security, backend, code-quality, testing,
5
5
  * data-layer, reliability, release, observability, infrastructure,
6
- * supply-chain, documentation, ai-governance) into the per-file review
6
+ * supply-chain, frontend, documentation, ai-governance) into the review
7
7
  * loop. Each persona dispatches its declared domain tools against every
8
8
  * file the router assigns to it, collects findings, and returns a
9
9
  * per-persona coverage proof.
@@ -12,9 +12,8 @@
12
12
  * (LLM-driven) layer sits on top in a later PR. Determinism is the
13
13
  * investor-DD ground-truth floor; the LLM layer is additive.
14
14
  *
15
- * Frontend (Jules) is excluded here because Jules has its own bespoke
16
- * envelope with live-web validation; PR-25 routes Jules into investor-DD
17
- * via a dedicated live-validator dispatcher.
15
+ * Repo-scoped tools such as Jules' frontend analyzer run once per persona
16
+ * with the full routed file set; file-scoped tools retain the per-file loop.
18
17
  */
19
18
 
20
19
  import { AI_GOVERNANCE_TOOLS } from "../agents/ai-governance/tools/index.js";
@@ -29,11 +28,12 @@ import { RELIABILITY_TOOLS } from "../agents/reliability/tools/index.js";
29
28
  import { SECURITY_TOOLS } from "../agents/security/tools/index.js";
30
29
  import { SUPPLY_CHAIN_TOOLS } from "../agents/supply-chain/tools/index.js";
31
30
  import { TESTING_TOOLS } from "../agents/testing/tools/index.js";
31
+ import { FRONTEND_TOOLS } from "../agents/jules/tools/investor-dd.js";
32
32
 
33
33
  import { checkBudget, createBudgetState } from "./investor-dd-file-loop.js";
34
34
 
35
35
  /**
36
- * Registry of persona → tool map. Frontend handled separately via Jules.
36
+ * Registry of persona → tool map.
37
37
  */
38
38
  export const INVESTOR_DD_PERSONA_TOOL_REGISTRY = Object.freeze({
39
39
  security: SECURITY_TOOLS,
@@ -46,6 +46,7 @@ export const INVESTOR_DD_PERSONA_TOOL_REGISTRY = Object.freeze({
46
46
  observability: OBSERVABILITY_TOOLS,
47
47
  infrastructure: INFRASTRUCTURE_TOOLS,
48
48
  "supply-chain": SUPPLY_CHAIN_TOOLS,
49
+ frontend: FRONTEND_TOOLS,
49
50
  documentation: DOCUMENTATION_TOOLS,
50
51
  "ai-governance": AI_GOVERNANCE_TOOLS,
51
52
  });
@@ -66,31 +67,15 @@ export function getPersonaTools(personaId) {
66
67
  return Object.values(map);
67
68
  }
68
69
 
69
- /**
70
- * Dispatch every tool for the persona against a single file scope. Each
71
- * tool handler is invoked with `{ rootPath, files: [file] }` because
72
- * every persona's tool contract accepts this shape (see #A13-#A22).
73
- *
74
- * @param {object} params
75
- * @param {string} params.personaId
76
- * @param {string} params.file - Single file (relative to rootPath).
77
- * @param {string} params.rootPath - Repo root.
78
- * @param {object} params.budget - Shared budget state.
79
- * @param {Function} [params.onEvent]
80
- * @returns {Promise<{findings: Array, toolInvocations: Array, stoppedEarly: boolean}>}
81
- */
82
- export async function runPersonaOnFile({
70
+ async function runTools({
83
71
  personaId,
84
72
  file,
73
+ files = null,
85
74
  rootPath,
86
75
  budget,
76
+ tools,
87
77
  onEvent = () => {},
88
78
  } = {}) {
89
- if (!personaId) throw new TypeError("runPersonaOnFile requires personaId");
90
- if (!file) throw new TypeError("runPersonaOnFile requires file");
91
- if (!rootPath) throw new TypeError("runPersonaOnFile requires rootPath");
92
-
93
- const tools = getPersonaTools(personaId);
94
79
  const findings = [];
95
80
  const toolInvocations = [];
96
81
  let stoppedEarly = false;
@@ -112,7 +97,7 @@ export async function runPersonaOnFile({
112
97
  onEvent({ type: "persona_file_tool_call", personaId, file, tool: tool.id });
113
98
 
114
99
  try {
115
- const results = await tool.handler({ rootPath, files: [file] });
100
+ const results = await tool.handler({ rootPath, files: Array.isArray(files) ? files : [file] });
116
101
  if (budget) budget.toolCalls = (budget.toolCalls || 0) + 1;
117
102
  const normalized = Array.isArray(results) ? results : [];
118
103
  for (const f of normalized) {
@@ -142,6 +127,40 @@ export async function runPersonaOnFile({
142
127
  return { findings, toolInvocations, stoppedEarly };
143
128
  }
144
129
 
130
+ /**
131
+ * Dispatch every tool for the persona against a single file scope. Each
132
+ * tool handler is invoked with `{ rootPath, files: [file] }` because
133
+ * every persona's tool contract accepts this shape (see #A13-#A22).
134
+ *
135
+ * @param {object} params
136
+ * @param {string} params.personaId
137
+ * @param {string} params.file - Single file (relative to rootPath).
138
+ * @param {string} params.rootPath - Repo root.
139
+ * @param {object} params.budget - Shared budget state.
140
+ * @param {Function} [params.onEvent]
141
+ * @returns {Promise<{findings: Array, toolInvocations: Array, stoppedEarly: boolean}>}
142
+ */
143
+ export async function runPersonaOnFile({
144
+ personaId,
145
+ file,
146
+ rootPath,
147
+ budget,
148
+ onEvent = () => {},
149
+ } = {}) {
150
+ if (!personaId) throw new TypeError("runPersonaOnFile requires personaId");
151
+ if (!file) throw new TypeError("runPersonaOnFile requires file");
152
+ if (!rootPath) throw new TypeError("runPersonaOnFile requires rootPath");
153
+
154
+ return runTools({
155
+ personaId,
156
+ file,
157
+ rootPath,
158
+ budget,
159
+ tools: getPersonaTools(personaId),
160
+ onEvent,
161
+ });
162
+ }
163
+
145
164
  /**
146
165
  * Run a single persona across its assigned files in the router output.
147
166
  *
@@ -167,6 +186,50 @@ export async function runPersonaAcrossFiles({
167
186
  const visited = [];
168
187
  const skipped = [];
169
188
  let terminationReason = "ok";
189
+ const tools = getPersonaTools(personaId);
190
+ const repoScopedTools = tools.filter((tool) => tool.scope === "repo");
191
+ const fileScopedTools = tools.filter((tool) => tool.scope !== "repo");
192
+
193
+ if (repoScopedTools.length > 0) {
194
+ const budgetCheck = checkBudget(safeBudget);
195
+ if (!budgetCheck.ok) {
196
+ terminationReason = budgetCheck.reason;
197
+ skipped.push(...files);
198
+ onEvent({ type: "persona_file_skipped", personaId, file: "__repo__", stopReason: budgetCheck.reason });
199
+ } else {
200
+ onEvent({ type: "persona_file_start", personaId, file: "__repo__", fileCount: files.length });
201
+ const { findings, toolInvocations, stoppedEarly } = await runTools({
202
+ personaId,
203
+ file: "__repo__",
204
+ files,
205
+ rootPath,
206
+ budget: safeBudget,
207
+ tools: repoScopedTools,
208
+ onEvent,
209
+ });
210
+ perFile.push({ file: "__repo__", scope: "repo", files, findings, toolInvocations, stoppedEarly });
211
+ allFindings.push(...findings);
212
+ visited.push(...files);
213
+ onEvent({
214
+ type: "persona_file_complete",
215
+ personaId,
216
+ file: "__repo__",
217
+ findingCount: findings.length,
218
+ toolCount: toolInvocations.length,
219
+ });
220
+ }
221
+ }
222
+
223
+ if (fileScopedTools.length === 0) {
224
+ return {
225
+ personaId,
226
+ perFile,
227
+ findings: allFindings,
228
+ visited,
229
+ skipped,
230
+ terminationReason,
231
+ };
232
+ }
170
233
 
171
234
  for (const file of files) {
172
235
  const budgetCheck = checkBudget(safeBudget);
@@ -178,11 +241,12 @@ export async function runPersonaAcrossFiles({
178
241
  }
179
242
 
180
243
  onEvent({ type: "persona_file_start", personaId, file });
181
- const { findings, toolInvocations, stoppedEarly } = await runPersonaOnFile({
244
+ const { findings, toolInvocations, stoppedEarly } = await runTools({
182
245
  personaId,
183
246
  file,
184
247
  rootPath,
185
248
  budget: safeBudget,
249
+ tools: fileScopedTools,
186
250
  onEvent,
187
251
  });
188
252
  perFile.push({ file, findings, toolInvocations, stoppedEarly });
@@ -1,20 +1,8 @@
1
+ import { FULL_DEPTH_PERSONAS } from "./scan-modes.js";
2
+
1
3
  export const INVESTOR_DD_PROGRESS_VERSION = "investor_dd_progress_v1";
2
4
 
3
- export const INVESTOR_DD_EXPECTED_PERSONAS = Object.freeze([
4
- "security",
5
- "backend",
6
- "code-quality",
7
- "testing",
8
- "data-layer",
9
- "reliability",
10
- "release",
11
- "observability",
12
- "infrastructure",
13
- "supply-chain",
14
- "documentation",
15
- "ai-governance",
16
- "frontend",
17
- ]);
5
+ export const INVESTOR_DD_EXPECTED_PERSONAS = Object.freeze([...FULL_DEPTH_PERSONAS]);
18
6
 
19
7
  const REQUIRED_FOR_SELLABLE = Object.freeze([
20
8
  "persona_roster",
@@ -335,17 +323,15 @@ export function buildInvestorDdProgress({
335
323
  ),
336
324
  },
337
325
  findingCount: Array.isArray(findings) ? findings.length : 0,
338
- nextRecommendedSlices: byId(capabilities, "persona_roster")?.status === "complete"
339
- ? [
340
- "wire Senti session id and live usage counters into Investor-DD runs",
341
- "add per-agent token/time/LOC/customer-price/margin telemetry",
342
- "require live reconciliation and report-email proof for sellable DD closeout",
343
- ]
344
- : [
345
- "add the missing frontend/Jules persona to the default Investor-DD roster",
346
- "wire Senti session id and live usage counters into Investor-DD runs",
347
- "add per-agent token/time/LOC/customer-price/margin telemetry",
348
- "require live reconciliation and report-email proof for sellable DD closeout",
349
- ],
326
+ nextRecommendedSlices: [
327
+ ...(byId(capabilities, "persona_roster")?.status === "complete"
328
+ ? []
329
+ : missingPersonas.includes("frontend")
330
+ ? ["add the missing frontend/Jules persona to the default Investor-DD roster"]
331
+ : ["run the full 13-persona roster before claiming sellable DD coverage"]),
332
+ "wire Senti session id and live usage counters into Investor-DD runs",
333
+ "add per-agent token/time/LOC/customer-price/margin telemetry",
334
+ "require live reconciliation and report-email proof for sellable DD closeout",
335
+ ],
350
336
  };
351
337
  }
@@ -34,7 +34,7 @@
34
34
  // frontend / observability / reliability.
35
35
  // - "compliance" — cross-cutting; distributed across security / ai-governance /
36
36
  // supply-chain / infrastructure / documentation via subagent dispatch.
37
- const FULL_DEPTH_PERSONAS = [
37
+ export const FULL_DEPTH_PERSONAS = Object.freeze([
38
38
  "security",
39
39
  "backend",
40
40
  "code-quality",
@@ -48,7 +48,7 @@ const FULL_DEPTH_PERSONAS = [
48
48
  "frontend",
49
49
  "documentation",
50
50
  "ai-governance",
51
- ];
51
+ ]);
52
52
 
53
53
  const SCAN_MODES = {
54
54
  baseline: ["security"],
@@ -8,6 +8,7 @@ export const SUPPORTED_PLAYWRIGHT_MODES = Object.freeze(["auto", "off", "baselin
8
8
 
9
9
  const CHECKOUT_ACTION_REF = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683";
10
10
  const SETUP_NODE_ACTION_REF = "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020";
11
+ const UPLOAD_ARTIFACT_ACTION_REF = "actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874";
11
12
  const SECRET_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/;
12
13
  const REPO_SLUG_REGEX = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
13
14
 
@@ -274,6 +275,84 @@ export function buildSecurityReviewWorkflow({ secretName = DEFAULT_SCAN_SECRET_N
274
275
  'echo "- findings: P0=${{ steps.omar.outputs.p0_count }} P1=${{ steps.omar.outputs.p1_count }} P2=${{ steps.omar.outputs.p2_count }} P3=${{ steps.omar.outputs.p3_count }}" >> "$GITHUB_STEP_SUMMARY"',
275
276
  ].join("\n"),
276
277
  },
278
+ {
279
+ name: "Stage Omar summary artifact",
280
+ shell: "bash",
281
+ env: {
282
+ OMAR_RUN_ID: "${{ steps.omar.outputs.run_id || '' }}",
283
+ OMAR_GATE_STATUS: "${{ steps.omar.outputs.gate_status || '' }}",
284
+ OMAR_P0: "${{ steps.omar.outputs.p0_count || '0' }}",
285
+ OMAR_P1: "${{ steps.omar.outputs.p1_count || '0' }}",
286
+ OMAR_P2: "${{ steps.omar.outputs.p2_count || '0' }}",
287
+ OMAR_P3: "${{ steps.omar.outputs.p3_count || '0' }}",
288
+ OMAR_SCAN_MODE: "${{ github.event_name == 'workflow_dispatch' && inputs.scan_mode || 'deep' }}",
289
+ OMAR_SEVERITY_GATE: "${{ github.event_name == 'workflow_dispatch' && inputs.severity_gate || 'P1' }}",
290
+ OMAR_P2_MAX_ALLOWED: "${{ github.event_name == 'workflow_dispatch' && inputs.p2_max_allowed || '5' }}",
291
+ },
292
+ run: [
293
+ "set -euo pipefail",
294
+ "mkdir -p omar-artifacts",
295
+ "python3 - <<'PY'",
296
+ "import json",
297
+ "import os",
298
+ "from pathlib import Path",
299
+ "",
300
+ "def env(name, default=''):",
301
+ " return os.environ.get(name, default)",
302
+ "",
303
+ "def int_env(name):",
304
+ " value = env(name, '0').strip()",
305
+ " return int(value) if value.isdigit() else 0",
306
+ "",
307
+ "github_run_id = env('GITHUB_RUN_ID')",
308
+ "server_url = env('GITHUB_SERVER_URL', 'https://github.com').rstrip('/')",
309
+ "repository = env('GITHUB_REPOSITORY')",
310
+ "run_url = f'{server_url}/{repository}/actions/runs/{github_run_id}' if repository and github_run_id else ''",
311
+ "summary = {",
312
+ " 'schema_version': 1,",
313
+ " 'kind': 'omar_gate_summary',",
314
+ " 'run_id': env('OMAR_RUN_ID'),",
315
+ " 'gate_status': env('OMAR_GATE_STATUS'),",
316
+ " 'findings': {",
317
+ " 'P0': int_env('OMAR_P0'),",
318
+ " 'P1': int_env('OMAR_P1'),",
319
+ " 'P2': int_env('OMAR_P2'),",
320
+ " 'P3': int_env('OMAR_P3'),",
321
+ " },",
322
+ " 'threshold': {",
323
+ " 'severity_gate': env('OMAR_SEVERITY_GATE', 'P1'),",
324
+ " 'p2_max_allowed': int_env('OMAR_P2_MAX_ALLOWED'),",
325
+ " },",
326
+ " 'scan': {",
327
+ " 'mode': env('OMAR_SCAN_MODE', 'deep'),",
328
+ ` 'action_ref': '${SENTINELAYER_ACTION_REF}',`,
329
+ " 'managed_llm': False,",
330
+ " 'llm_failure_policy': 'block',",
331
+ " },",
332
+ " 'github': {",
333
+ " 'repository': repository,",
334
+ " 'sha': env('GITHUB_SHA'),",
335
+ " 'ref': env('GITHUB_REF'),",
336
+ " 'event_name': env('GITHUB_EVENT_NAME'),",
337
+ " 'workflow': env('GITHUB_WORKFLOW'),",
338
+ " 'run_id': github_run_id,",
339
+ " 'run_attempt': env('GITHUB_RUN_ATTEMPT'),",
340
+ " 'run_url': run_url,",
341
+ " },",
342
+ "}",
343
+ "Path('omar-artifacts/summary.json').write_text(json.dumps(summary, indent=2, sort_keys=True) + '\\n', encoding='utf-8')",
344
+ "PY",
345
+ ].join("\n"),
346
+ },
347
+ {
348
+ name: "Upload Omar summary artifact",
349
+ uses: UPLOAD_ARTIFACT_ACTION_REF,
350
+ with: {
351
+ name: "omar-gate-artifacts",
352
+ path: "omar-artifacts/summary.json",
353
+ "if-no-files-found": "error",
354
+ },
355
+ },
277
356
  ],
278
357
  },
279
358
  },