sentinelayer-cli 0.32.2 → 0.32.3

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.32.2",
3
+ "version": "0.32.3",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -20,7 +20,7 @@ export function registerOmarGateCommand(program, invokeLegacy) {
20
20
  .option("--model <id>", "LLM model override (default: gpt-5.3-codex)")
21
21
  .option("--provider <name>", "LLM provider: sentinelayer, openai, anthropic")
22
22
  .option("--max-cost <usd>", "Maximum AI layer cost in USD (default: 5.0)")
23
- .option("--notify-session <session-id>", "Write Omar Deep billing-grade usage entries into this Senti session")
23
+ .option("--notify-session <session-id>", "Write Omar Deep billing-grade usage entries into this Senti session (default: auto-detect active Senti session)")
24
24
  .option("--require-usage-ledger", "Fail if Omar Deep AI calls cannot write billing-grade session_usage")
25
25
  .option("--persona <csv>", "Only run these personas (comma-separated IDs); unknown IDs are dropped + warned")
26
26
  .option("--skip-persona <csv>", "Skip these personas (comma-separated IDs)")
@@ -54,7 +54,7 @@ export function registerOmarGateCommand(program, invokeLegacy) {
54
54
  .option("--stream", "Emit NDJSON events to stdout as personas work file-by-file")
55
55
  .option("--notify-email <addr>", "Send final report to this email (default: account email)")
56
56
  .option("--email-on-complete <addr>", "Trigger the API-side DD report email after the run completes")
57
- .option("--notify-session <session-id>", "Stream progress into this Senti session (default: auto-start)")
57
+ .option("--notify-session <session-id>", "Stream progress + billing-grade usage into this Senti session (default: auto-detect active Senti session)")
58
58
  .option("--require-usage-ledger", "Fail if Investor-DD LLM planner calls cannot write session_usage")
59
59
  .option("--no-email", "Skip email dispatch")
60
60
  .option("--no-dashboard", "Skip dashboard card persistence")
package/src/legacy-cli.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  getCoordinationEtiquetteItems,
30
30
  renderCoordinationNumberedList,
31
31
  } from "./session/coordination-guidance.js";
32
+ import { resolveUsageSessionId } from "./session/usage-session-resolver.js";
32
33
 
33
34
  let DEFAULT_API_URL = process.env.SENTINELAYER_API_URL || "https://api.sentinelayer.com";
34
35
  let DEFAULT_WEB_URL = process.env.SENTINELAYER_WEB_URL || "https://sentinelayer.com";
@@ -1200,7 +1201,7 @@ async function runLocalOmarGateCommand(args) {
1200
1201
  const devTestBotBaseUrl = getCommandOptionValue(args, "--devtestbot-base-url") || "";
1201
1202
  const devTestBotScope = getCommandOptionValue(args, "--devtestbot-scope") || "";
1202
1203
  const emailOnComplete = getCommandOptionValue(args, "--email-on-complete") || "";
1203
- const notifySession = getCommandOptionValue(args, "--notify-session") || "";
1204
+ const notifySessionFlag = getCommandOptionValue(args, "--notify-session") || "";
1204
1205
  const requireUsageLedger = hasCommandOption(args, "--require-usage-ledger");
1205
1206
  const model = getCommandOptionValue(args, "--model") || "gpt-5.3-codex";
1206
1207
  const provider = getCommandOptionValue(args, "--provider") || "sentinelayer";
@@ -1211,6 +1212,12 @@ async function runLocalOmarGateCommand(args) {
1211
1212
  if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
1212
1213
  throw new Error(`Invalid --path target: ${targetPath}`);
1213
1214
  }
1215
+ const usageSession = await resolveUsageSessionId({
1216
+ explicitSessionId: notifySessionFlag,
1217
+ targetPath,
1218
+ env: process.env,
1219
+ });
1220
+ const notifySession = usageSession.sessionId || "";
1214
1221
 
1215
1222
  const { runInvestorDd } = await import("./review/investor-dd-orchestrator.js");
1216
1223
  const { parsePersonaCsv, resolveFilteredPersonas } = await import("./review/scan-modes.js");
@@ -1235,6 +1242,15 @@ async function runLocalOmarGateCommand(args) {
1235
1242
  printInfo(`Skipped personas: ${personaFilter.dropped.join(", ")}`);
1236
1243
  }
1237
1244
  if (dryRun) printInfo("Mode: dry-run (plan + stub report only)");
1245
+ if (notifySession) {
1246
+ printInfo(
1247
+ `Usage ledger: ${usageSession.source === "explicit" ? "using" : "auto-detected"} Senti session ${notifySession}`,
1248
+ );
1249
+ } else if (requireUsageLedger) {
1250
+ printInfo(
1251
+ `Usage ledger: required but no active Senti session was auto-detected (${usageSession.reason || "missing_session"}).`,
1252
+ );
1253
+ }
1238
1254
  }
1239
1255
  const result = await runInvestorDd({
1240
1256
  rootPath: targetPath,
@@ -1298,7 +1314,7 @@ async function runLocalOmarGateCommand(args) {
1298
1314
  const maxCostUsd = parseFloat(getCommandOptionValue(args, "--max-cost") || "5.0") || 5.0;
1299
1315
  const modelOverride = getCommandOptionValue(args, "--model") || "";
1300
1316
  const providerOverride = getCommandOptionValue(args, "--provider") || "";
1301
- const notifySession = getCommandOptionValue(args, "--notify-session") || "";
1317
+ const notifySessionFlag = getCommandOptionValue(args, "--notify-session") || "";
1302
1318
  const requireUsageLedger = hasCommandOption(args, "--require-usage-ledger");
1303
1319
  const scanMode = getCommandOptionValue(args, "--scan-mode") || "deep";
1304
1320
  const maxParallel = parseInt(getCommandOptionValue(args, "--max-parallel") || "4", 10) || 4;
@@ -1315,6 +1331,12 @@ async function runLocalOmarGateCommand(args) {
1315
1331
  if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
1316
1332
  throw new Error(`Invalid --path target: ${targetPath}`);
1317
1333
  }
1334
+ const usageSession = await resolveUsageSessionId({
1335
+ explicitSessionId: notifySessionFlag,
1336
+ targetPath,
1337
+ env: process.env,
1338
+ });
1339
+ const notifySession = usageSession.sessionId || "";
1318
1340
  // Default scope to changed-files when the target has a git delta (uncommitted
1319
1341
  // or staged), matching gh's PR-scoped Omar; fall back to full repo otherwise.
1320
1342
  // Explicit --scope-mode/--diff/--staged always override (see resolveScopeModeFromOptions).
@@ -1327,6 +1349,15 @@ async function runLocalOmarGateCommand(args) {
1327
1349
  printInfo(`Target: ${targetPath}`);
1328
1350
  printInfo(`Scope mode: ${scopeMode}`);
1329
1351
  printInfo(`Scan mode: ${scanMode} | AI: ${aiEnabled ? "enabled" : "disabled"}`);
1352
+ if (notifySession) {
1353
+ printInfo(
1354
+ `Usage ledger: ${usageSession.source === "explicit" ? "using" : "auto-detected"} Senti session ${notifySession}`,
1355
+ );
1356
+ } else if (requireUsageLedger) {
1357
+ printInfo(
1358
+ `Usage ledger: required but no active Senti session was auto-detected (${usageSession.reason || "missing_session"}).`,
1359
+ );
1360
+ }
1330
1361
  console.error("");
1331
1362
  console.error(pc.gray(` [${formatElapsed(Date.now() - commandStartedAt)}] Phase 1: Deterministic analysis (22 rules)...`));
1332
1363
  }
@@ -0,0 +1,132 @@
1
+ import process from "node:process";
2
+
3
+ import { listActiveSessions } from "./store.js";
4
+
5
+ const USAGE_SESSION_ENV_KEYS = [
6
+ "SENTINELAYER_NOTIFY_SESSION",
7
+ "SENTINELAYER_SESSION_ID",
8
+ "SL_SESSION_ID",
9
+ ];
10
+
11
+ function normalizeString(value) {
12
+ return String(value || "").trim();
13
+ }
14
+
15
+ function isSafeUsageSessionId(value) {
16
+ const normalized = normalizeString(value);
17
+ if (!/^[A-Za-z0-9._-]{1,200}$/.test(normalized)) return false;
18
+ if (normalized === "." || normalized === "..") return false;
19
+ if (normalized.includes("..")) return false;
20
+ return true;
21
+ }
22
+
23
+ function normalizeTrustedUsageSessionId(value, source) {
24
+ const normalized = normalizeString(value);
25
+ if (!normalized) return "";
26
+ if (!isSafeUsageSessionId(normalized)) {
27
+ throw new Error(
28
+ `Invalid ${source} usage session id: must be a single session identifier without path traversal, separators, or control characters.`,
29
+ );
30
+ }
31
+ return normalized;
32
+ }
33
+
34
+ function parseEpoch(value) {
35
+ const epoch = Date.parse(normalizeString(value));
36
+ return Number.isFinite(epoch) ? epoch : 0;
37
+ }
38
+
39
+ function sessionRecencyEpoch(session = {}) {
40
+ return Math.max(
41
+ parseEpoch(session.lastInteractionAt),
42
+ parseEpoch(session.updatedAt),
43
+ parseEpoch(session.createdAt),
44
+ );
45
+ }
46
+
47
+ function resolveEnvUsageSession(env = process.env) {
48
+ for (const key of USAGE_SESSION_ENV_KEYS) {
49
+ const sessionId = normalizeTrustedUsageSessionId(env?.[key], key);
50
+ if (sessionId) {
51
+ return { sessionId, source: "env", envKey: key };
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ export function selectMostRecentUsageSession(sessions = []) {
58
+ const candidatesBySessionId = new Map();
59
+ for (const session of Array.isArray(sessions) ? sessions : []) {
60
+ const sessionId = normalizeString(session?.sessionId);
61
+ if (!isSafeUsageSessionId(sessionId)) continue;
62
+
63
+ const candidate = {
64
+ sessionId,
65
+ title: normalizeString(session?.title),
66
+ recencyEpoch: sessionRecencyEpoch(session),
67
+ };
68
+ const existing = candidatesBySessionId.get(sessionId);
69
+ if (!existing || candidate.recencyEpoch > existing.recencyEpoch) {
70
+ candidatesBySessionId.set(sessionId, candidate);
71
+ }
72
+ }
73
+
74
+ const candidates = Array.from(candidatesBySessionId.values());
75
+
76
+ if (candidates.length === 0) {
77
+ return { sessionId: "", source: "none", reason: "no_active_session" };
78
+ }
79
+
80
+ candidates.sort((left, right) => {
81
+ if (right.recencyEpoch !== left.recencyEpoch) {
82
+ return right.recencyEpoch - left.recencyEpoch;
83
+ }
84
+ return left.sessionId.localeCompare(right.sessionId);
85
+ });
86
+
87
+ const [winner, runnerUp] = candidates;
88
+ if (runnerUp && runnerUp.recencyEpoch === winner.recencyEpoch) {
89
+ return {
90
+ sessionId: "",
91
+ source: "ambiguous",
92
+ reason: "multiple_active_sessions_same_recency",
93
+ candidates: candidates.map((candidate) => candidate.sessionId),
94
+ };
95
+ }
96
+
97
+ return {
98
+ sessionId: winner.sessionId,
99
+ source: "local_active_session",
100
+ title: winner.title || null,
101
+ candidates: candidates.map((candidate) => candidate.sessionId),
102
+ };
103
+ }
104
+
105
+ export async function resolveUsageSessionId({
106
+ explicitSessionId = "",
107
+ targetPath = process.cwd(),
108
+ env = process.env,
109
+ listActiveSessionsFn = listActiveSessions,
110
+ } = {}) {
111
+ const explicit = normalizeTrustedUsageSessionId(explicitSessionId, "--notify-session");
112
+ if (explicit) {
113
+ return { sessionId: explicit, source: "explicit" };
114
+ }
115
+
116
+ const envSession = resolveEnvUsageSession(env);
117
+ if (envSession) {
118
+ return envSession;
119
+ }
120
+
121
+ try {
122
+ const sessions = await listActiveSessionsFn({ targetPath });
123
+ return selectMostRecentUsageSession(sessions);
124
+ } catch (error) {
125
+ return {
126
+ sessionId: "",
127
+ source: "error",
128
+ reason: "list_active_sessions_failed",
129
+ errorCode: error && typeof error === "object" ? normalizeString(error.code) || null : null,
130
+ };
131
+ }
132
+ }