sentinelayer-cli 0.26.0 → 0.27.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.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -58,6 +58,9 @@ export function buildLegacyArgs(baseArgs, { commandOptions = {}, command } = {})
58
58
  appendPassthroughFlag(args, "--model", commandOptions.model);
59
59
  appendPassthroughFlag(args, "--provider", commandOptions.provider);
60
60
  appendPassthroughFlag(args, "--reuse-omargate", commandOptions.reuseOmargate);
61
+ appendPassthroughFlag(args, "--scope-mode", commandOptions.scopeMode);
62
+ appendBooleanFlag(args, "--diff", commandOptions.diff);
63
+ appendBooleanFlag(args, "--staged", commandOptions.staged);
61
64
  appendPassthroughFlag(args, "--notify-email", commandOptions.notifyEmail);
62
65
  appendPassthroughFlag(args, "--email-on-complete", commandOptions.emailOnComplete);
63
66
  appendPassthroughFlag(args, "--notify-session", commandOptions.notifySession);
@@ -12,6 +12,9 @@ export function registerOmarGateCommand(program, invokeLegacy) {
12
12
  .option("--output-dir <path>", "Artifact root for report output")
13
13
  .option("--no-ai", "Skip AI review layer (deterministic only)")
14
14
  .option("--ai-dry-run", "Run AI layer in dry-run mode (no LLM call)")
15
+ .option("--scope-mode <mode>", "Deterministic file scope: full, diff, or staged")
16
+ .option("--diff", "Alias for --scope-mode diff")
17
+ .option("--staged", "Alias for --scope-mode staged")
15
18
  .option("--scan-mode <mode>", "Scan depth: baseline (1 persona), deep (13), full-depth (13)")
16
19
  .option("--max-parallel <n>", "Max concurrent persona calls (default: 4)")
17
20
  .option("--model <id>", "LLM model override (default: gpt-5.3-codex)")
package/src/legacy-cli.js CHANGED
@@ -847,6 +847,33 @@ function hasCommandOption(args, optionName) {
847
847
  return args.some((arg) => String(arg || "").trim() === optionName);
848
848
  }
849
849
 
850
+ function resolveScopeModeFromOptions(args, { defaultMode = "full" } = {}) {
851
+ const explicitMode = String(getCommandOptionValue(args, "--scope-mode") || "").trim().toLowerCase();
852
+ const diffFlag = hasCommandOption(args, "--diff");
853
+ const stagedFlag = hasCommandOption(args, "--staged");
854
+
855
+ if (diffFlag && stagedFlag) {
856
+ throw new Error("Use only one of --diff or --staged.");
857
+ }
858
+
859
+ let modeFromFlags = "";
860
+ if (diffFlag) {
861
+ modeFromFlags = "diff";
862
+ } else if (stagedFlag) {
863
+ modeFromFlags = "staged";
864
+ }
865
+
866
+ if (explicitMode && modeFromFlags && explicitMode !== modeFromFlags) {
867
+ throw new Error(`Conflicting scope selection: --scope-mode ${explicitMode} with --${modeFromFlags}.`);
868
+ }
869
+
870
+ const resolved = explicitMode || modeFromFlags || defaultMode;
871
+ if (!["full", "diff", "staged"].includes(resolved)) {
872
+ throw new Error("scope mode must be one of: full, diff, staged.");
873
+ }
874
+ return resolved;
875
+ }
876
+
850
877
  async function collectScanFiles(rootPath) {
851
878
  const files = [];
852
879
  const stack = [rootPath];
@@ -1237,6 +1264,7 @@ async function runLocalOmarGateCommand(args) {
1237
1264
  const asJson = hasCommandOption(args, "--json");
1238
1265
  const pathArg = getCommandOptionValue(args, "--path") || ".";
1239
1266
  const outputDirArg = getCommandOptionValue(args, "--output-dir") || "";
1267
+ const scopeMode = resolveScopeModeFromOptions(args, { defaultMode: "full" });
1240
1268
  const aiEnabled = !hasCommandOption(args, "--no-ai");
1241
1269
  const aiDryRun = hasCommandOption(args, "--ai-dry-run");
1242
1270
  const maxCostUsd = parseFloat(getCommandOptionValue(args, "--max-cost") || "5.0") || 5.0;
@@ -1261,6 +1289,7 @@ async function runLocalOmarGateCommand(args) {
1261
1289
  if (!asJson) {
1262
1290
  printSection("Local Omar Gate Deep");
1263
1291
  printInfo(`Target: ${targetPath}`);
1292
+ printInfo(`Scope mode: ${scopeMode}`);
1264
1293
  printInfo(`Scan mode: ${scanMode} | AI: ${aiEnabled ? "enabled" : "disabled"}`);
1265
1294
  console.error("");
1266
1295
  console.error(pc.gray(` [${formatElapsed(Date.now() - commandStartedAt)}] Phase 1: Deterministic analysis (22 rules)...`));
@@ -1270,13 +1299,17 @@ async function runLocalOmarGateCommand(args) {
1270
1299
  const { runDeterministicReviewPipeline } = await import("./review/local-review.js");
1271
1300
  const deterministic = await runDeterministicReviewPipeline({
1272
1301
  targetPath,
1273
- mode: "full",
1302
+ mode: scopeMode,
1274
1303
  outputDir: outputDirArg,
1275
1304
  });
1276
1305
 
1277
1306
  const detFindings = deterministic.findings || [];
1278
1307
  const detSummary = deterministic.summary || { P0: 0, P1: 0, P2: 0, P3: 0, blocking: false };
1279
- const scannedFiles = deterministic.metadata?.ingest?.filesScanned || deterministic.metadata?.scannedFiles || detFindings.length;
1308
+ const scannedFiles =
1309
+ deterministic.scope?.scannedFiles ??
1310
+ deterministic.metadata?.ingest?.filesScanned ??
1311
+ deterministic.metadata?.scannedFiles ??
1312
+ detFindings.length;
1280
1313
 
1281
1314
  if (!asJson) {
1282
1315
  console.error(` ${pc.green("✓")} [${formatElapsed(Date.now() - commandStartedAt)}] Deterministic: ${scannedFiles} files → P1=${detSummary.P1} P2=${detSummary.P2} findings`);
@@ -1312,6 +1345,7 @@ async function runLocalOmarGateCommand(args) {
1312
1345
  deterministic: {
1313
1346
  summary: detSummary,
1314
1347
  findings: detFindings,
1348
+ mode: deterministic.mode || scopeMode,
1315
1349
  scope: deterministic.scope || {},
1316
1350
  layers: deterministic.layers || {},
1317
1351
  metadata: deterministic.metadata || {},
@@ -1430,6 +1464,8 @@ async function runLocalOmarGateCommand(args) {
1430
1464
  runId: orchestratorResult.runId,
1431
1465
  mode: orchestratorResult.mode,
1432
1466
  roster: orchestratorResult.roster,
1467
+ basePersonas: orchestratorResult.basePersonas || [],
1468
+ personaRouting: orchestratorResult.personaRouting || null,
1433
1469
  findings: (orchestratorResult.personas || []).flatMap((p) => []),
1434
1470
  aiFindings: aiFindings,
1435
1471
  personaCount: (orchestratorResult.personas || []).length,
@@ -1445,6 +1481,7 @@ async function runLocalOmarGateCommand(args) {
1445
1481
  findings: orchestratorResult.findings || [],
1446
1482
  summary: orchestratorResult.summary,
1447
1483
  reconciliation: orchestratorResult.reconciliation,
1484
+ personaRouting: orchestratorResult.personaRouting || null,
1448
1485
  findingsBySource: orchestratorResult.findingsBySource,
1449
1486
  });
1450
1487
  }
@@ -1461,6 +1498,8 @@ Elapsed: ${totalElapsed}
1461
1498
 
1462
1499
  Summary:
1463
1500
  - Files scanned: ${scannedFiles}
1501
+ - Scope mode: ${scopeMode}
1502
+ - Persona routing: ${orchestratorResult?.personaRouting?.enabled ? `${orchestratorResult.personaRouting.effectivePersonas.length}/${orchestratorResult.personaRouting.basePersonas.length} personas for ${orchestratorResult.personaRouting.changedFileCount} changed files` : "full roster"}
1464
1503
  - Deterministic findings: P0=${detSummary.P0} P1=${detSummary.P1} P2=${detSummary.P2} P3=${detSummary.P3 || 0}
1465
1504
  - AI findings (raw, pre-reconciliation): ${aiResult ? `P0=${aiResult.summary?.P0 || 0} P1=${aiResult.summary?.P1 || 0} P2=${aiResult.summary?.P2 || 0} P3=${aiResult.summary?.P3 || 0}` : "skipped"}
1466
1505
  - Reconciled (deduped + confidence-boosted): P0=${combinedP0} P1=${combinedP1} P2=${combinedP2} P3=${combinedP3}
@@ -1494,6 +1533,8 @@ ${formatFindingsMarkdown(allFindings)}
1494
1533
  targetPath,
1495
1534
  runId: omargateRunId,
1496
1535
  reportPath,
1536
+ scopeMode,
1537
+ scopedFiles: deterministic.scope?.scannedRelativeFiles || [],
1497
1538
  scannedFiles,
1498
1539
  p0: combinedP0,
1499
1540
  p1: combinedP1,
@@ -1518,6 +1559,8 @@ ${formatFindingsMarkdown(allFindings)}
1518
1559
  }
1519
1560
  : null,
1520
1561
  reconciliation: orchestratorResult?.reconciliation || null,
1562
+ personaRouting: orchestratorResult?.personaRouting || null,
1563
+ basePersonas: orchestratorResult?.basePersonas || [],
1521
1564
  roster: orchestratorResult?.roster || [],
1522
1565
  },
1523
1566
  null,
@@ -15,6 +15,7 @@ import { reconcileReviewFindings } from "./report.js";
15
15
  import { resolvePersonaVisual } from "../agents/persona-visuals.js";
16
16
  import { syncRunToDashboard } from "../telemetry/sync.js";
17
17
  import { createAgentEvent } from "../events/schema.js";
18
+ import { buildOwnershipMap, computeRoutingStats, loadScaffoldConfig } from "../ingest/ownership.js";
18
19
 
19
20
  const OMAR_ORCHESTRATOR_AGENT = Object.freeze({
20
21
  id: "omar-orchestrator",
@@ -98,6 +99,158 @@ function uniqueScopeFiles(files = []) {
98
99
  return normalized;
99
100
  }
100
101
 
102
+ function normalizeScopeMode(value) {
103
+ const normalized = normalizeString(value).toLowerCase();
104
+ if (normalized === "diff" || normalized === "staged") {
105
+ return normalized;
106
+ }
107
+ return "full";
108
+ }
109
+
110
+ function findingRelativePath(finding = {}) {
111
+ return toPosixPath(finding.file || finding.path || finding.location || "");
112
+ }
113
+
114
+ function summarizeDeterministicFindings(findings = []) {
115
+ return summarizeFindings(findings);
116
+ }
117
+
118
+ function buildScopeForFiles(scope = {}, files = []) {
119
+ const wanted = new Set(files.map((file) => toPosixPath(file)));
120
+ const scopedObjects = filesFromScope(scope).filter((file) => wanted.has(file.path));
121
+ const scopedFiles =
122
+ scopedObjects.length > 0
123
+ ? scopedObjects
124
+ : files.map((filePath) => ({ path: toPosixPath(filePath), loc: 0 }));
125
+ const totalLoc = scopedFiles.reduce((sum, file) => sum + (file.loc > 0 ? file.loc : 80), 0);
126
+ return {
127
+ ...scope,
128
+ files: scopedFiles,
129
+ primary: scopedFiles,
130
+ scannedFiles: files.length,
131
+ scannedRelativeFiles: files,
132
+ totalLoc,
133
+ estimatedLoc: totalLoc,
134
+ };
135
+ }
136
+
137
+ function buildPersonaScopedDeterministic(deterministic = {}, personaId, personaRouting = {}) {
138
+ if (!personaRouting?.enabled) {
139
+ return deterministic;
140
+ }
141
+ const files = Array.isArray(personaRouting.filesByPersona?.[personaId])
142
+ ? personaRouting.filesByPersona[personaId]
143
+ : [];
144
+ const wanted = new Set(files);
145
+ const findings = (deterministic?.findings || []).filter((finding) =>
146
+ wanted.has(findingRelativePath(finding))
147
+ );
148
+ const summary = summarizeDeterministicFindings(findings);
149
+ return {
150
+ ...deterministic,
151
+ summary,
152
+ findings,
153
+ scope: buildScopeForFiles(deterministic?.scope || {}, files),
154
+ metadata: {
155
+ ...(deterministic?.metadata || {}),
156
+ personaRouting: {
157
+ enabled: true,
158
+ scopeMode: personaRouting.scopeMode,
159
+ personaId,
160
+ scopedFiles: files.length,
161
+ },
162
+ },
163
+ };
164
+ }
165
+
166
+ async function resolveChangedFilePersonaRouting({
167
+ targetPath,
168
+ personas = [],
169
+ deterministic = {},
170
+ manualInclude = false,
171
+ } = {}) {
172
+ const scopeMode = normalizeScopeMode(deterministic?.mode || deterministic?.metadata?.scopeMode);
173
+ const basePersonas = [...personas];
174
+ const base = {
175
+ enabled: false,
176
+ scopeMode,
177
+ reason: "full_scope",
178
+ basePersonas,
179
+ effectivePersonas: basePersonas,
180
+ droppedPersonas: [],
181
+ changedFileCount: 0,
182
+ personaCoverage: {},
183
+ tokenReductionEstimatePct: 0,
184
+ filesByPersona: {},
185
+ scaffold: { found: false },
186
+ };
187
+
188
+ if (scopeMode === "full") {
189
+ return base;
190
+ }
191
+ if (manualInclude) {
192
+ return {
193
+ ...base,
194
+ reason: "manual_persona_filter",
195
+ };
196
+ }
197
+
198
+ const scopedFiles = filesFromScope(deterministic?.scope || {})
199
+ .map((file) => file.path)
200
+ .filter(Boolean);
201
+ if (scopedFiles.length === 0) {
202
+ return {
203
+ ...base,
204
+ enabled: true,
205
+ reason: "no_changed_files",
206
+ effectivePersonas: [],
207
+ droppedPersonas: basePersonas,
208
+ changedFileCount: 0,
209
+ };
210
+ }
211
+
212
+ let scaffold = { found: false, ownershipRules: [] };
213
+ try {
214
+ scaffold = await loadScaffoldConfig({ targetPath });
215
+ } catch {
216
+ scaffold = { found: false, ownershipRules: [] };
217
+ }
218
+ const ownershipMap = buildOwnershipMap(scopedFiles, scaffold);
219
+ const stats = computeRoutingStats(ownershipMap);
220
+ const allowed = new Set(basePersonas);
221
+ const filesByPersona = {};
222
+ for (const [file, persona] of ownershipMap.entries()) {
223
+ if (!allowed.has(persona)) {
224
+ continue;
225
+ }
226
+ if (!filesByPersona[persona]) {
227
+ filesByPersona[persona] = [];
228
+ }
229
+ filesByPersona[persona].push(file);
230
+ }
231
+
232
+ for (const persona of Object.keys(filesByPersona)) {
233
+ filesByPersona[persona].sort((left, right) => left.localeCompare(right));
234
+ }
235
+
236
+ const effectivePersonas = basePersonas.filter((persona) => filesByPersona[persona]?.length > 0);
237
+ return {
238
+ ...base,
239
+ enabled: true,
240
+ reason: "changed_file_scope",
241
+ effectivePersonas,
242
+ droppedPersonas: basePersonas.filter((persona) => !effectivePersonas.includes(persona)),
243
+ changedFileCount: scopedFiles.length,
244
+ personaCoverage: stats.personaCoverage,
245
+ tokenReductionEstimatePct: stats.tokenReductionEstimatePct,
246
+ filesByPersona,
247
+ scaffold: {
248
+ found: Boolean(scaffold?.found),
249
+ ruleCount: Array.isArray(scaffold?.ownershipRules) ? scaffold.ownershipRules.length : 0,
250
+ },
251
+ };
252
+ }
253
+
101
254
  function filesFromScope(scope = {}) {
102
255
  if (Array.isArray(scope.files)) {
103
256
  return uniqueScopeFiles(scope.files);
@@ -624,7 +777,7 @@ export async function runOmarGateOrchestrator({
624
777
  })
625
778
  : { ...resolveScanMode(scanMode), dropped: [], unknown: [] };
626
779
 
627
- const { mode, personas } = resolved;
780
+ const { mode, personas: basePersonas } = resolved;
628
781
  const droppedPersonas = resolved.dropped || [];
629
782
  const unknownPersonas = resolved.unknown || [];
630
783
 
@@ -637,7 +790,28 @@ export async function runOmarGateOrchestrator({
637
790
  mode,
638
791
  dropped: droppedPersonas,
639
792
  unknown: unknownPersonas,
640
- effective: personas,
793
+ effective: basePersonas,
794
+ },
795
+ runId,
796
+ }));
797
+ }
798
+
799
+ const personaRouting = await resolveChangedFilePersonaRouting({
800
+ targetPath,
801
+ personas: basePersonas,
802
+ deterministic,
803
+ manualInclude: Array.isArray(includeOnly) && includeOnly.length > 0,
804
+ });
805
+ const personas = personaRouting.effectivePersonas;
806
+
807
+ if (onEvent && personaRouting.enabled) {
808
+ onEvent(createAgentEvent({
809
+ event: "omargate_persona_routing",
810
+ agent: OMAR_ORCHESTRATOR_AGENT,
811
+ payload: {
812
+ runId,
813
+ mode,
814
+ routing: personaRouting,
641
815
  },
642
816
  runId,
643
817
  }));
@@ -659,7 +833,17 @@ export async function runOmarGateOrchestrator({
659
833
  onEvent(createAgentEvent({
660
834
  event: "omargate_start",
661
835
  agent: OMAR_ORCHESTRATOR_AGENT,
662
- payload: { runId, mode, personas, roster, maxParallel, maxCostUsd, dryRun },
836
+ payload: {
837
+ runId,
838
+ mode,
839
+ personas,
840
+ basePersonas,
841
+ roster,
842
+ maxParallel,
843
+ maxCostUsd,
844
+ dryRun,
845
+ personaRouting,
846
+ },
663
847
  runId,
664
848
  }));
665
849
  }
@@ -667,8 +851,8 @@ export async function runOmarGateOrchestrator({
667
851
  const detSummary = deterministic?.summary || { P0: 0, P1: 0, P2: 0, P3: 0 };
668
852
  const detFindings = deterministic?.findings || [];
669
853
 
670
- // Per-persona cost budget = global / persona count (with minimum floor)
671
- const perPersonaCost = Math.max(0.25, maxCostUsd / personas.length);
854
+ // Per-persona cost budget = global / effective persona count (with minimum floor).
855
+ const perPersonaCost = personas.length > 0 ? Math.max(0.25, maxCostUsd / personas.length) : 0;
672
856
  let runningCostUsd = 0;
673
857
 
674
858
  const personaResults = await runWithConcurrency(personas, maxParallel, async (personaId) => {
@@ -711,6 +895,11 @@ export async function runOmarGateOrchestrator({
711
895
  }
712
896
 
713
897
  const personaStart = Date.now();
898
+ const personaDeterministic = buildPersonaScopedDeterministic(
899
+ deterministic,
900
+ personaId,
901
+ personaRouting
902
+ );
714
903
 
715
904
  if (onEvent) {
716
905
  onEvent(createAgentEvent({
@@ -735,7 +924,7 @@ export async function runOmarGateOrchestrator({
735
924
  targetPath,
736
925
  mode,
737
926
  runId,
738
- deterministic,
927
+ deterministic: personaDeterministic,
739
928
  outputDir,
740
929
  provider,
741
930
  model,
@@ -788,7 +977,7 @@ export async function runOmarGateOrchestrator({
788
977
  const systemPrompt = buildPersonaReviewPrompt({
789
978
  personaId,
790
979
  targetPath,
791
- deterministicSummary: detSummary,
980
+ deterministicSummary: personaDeterministic?.summary || detSummary,
792
981
  });
793
982
 
794
983
  const result = await runAiReviewLayer({
@@ -801,11 +990,11 @@ export async function runOmarGateOrchestrator({
801
990
  personaId
802
991
  ),
803
992
  deterministic: {
804
- summary: detSummary,
805
- findings: detFindings,
806
- scope: deterministic?.scope || {},
807
- layers: deterministic?.layers || {},
808
- metadata: deterministic?.metadata || {},
993
+ summary: personaDeterministic?.summary || detSummary,
994
+ findings: personaDeterministic?.findings || detFindings,
995
+ scope: personaDeterministic?.scope || deterministic?.scope || {},
996
+ layers: personaDeterministic?.layers || deterministic?.layers || {},
997
+ metadata: personaDeterministic?.metadata || deterministic?.metadata || {},
809
998
  },
810
999
  outputDir,
811
1000
  provider: provider || undefined,
@@ -982,7 +1171,9 @@ export async function runOmarGateOrchestrator({
982
1171
  const result = {
983
1172
  runId,
984
1173
  mode,
1174
+ basePersonas,
985
1175
  roster,
1176
+ personaRouting,
986
1177
  personas: settled.map((r) => ({
987
1178
  id: r.personaId,
988
1179
  identity: r.persona,
@@ -1051,6 +1242,7 @@ export async function runOmarGateOrchestrator({
1051
1242
  findings: reconciledFindings.length,
1052
1243
  summary: result.summary,
1053
1244
  reconciliation: result.reconciliation,
1245
+ personaRouting,
1054
1246
  totalCostUsd: totalCost,
1055
1247
  totalDurationMs: totalDuration,
1056
1248
  },