sentinelayer-cli 0.8.12 → 0.9.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.
@@ -517,6 +517,9 @@ export function registerSwarmCommand(program) {
517
517
  .option("--scenario-file <path>", "Scenario DSL file (.sls) for runtime actions")
518
518
  .option("--registry-file <path>", "Optional custom swarm registry file (when building plan inline)")
519
519
  .option("--agents <ids>", "Comma-separated agent ids for inline plan mode", "security,testing,reliability")
520
+ .option("--agent <id>", "Single agent id alias for --agents")
521
+ .option("--scope <scope>", "Runtime scope alias for --scenario, used by devTestBot")
522
+ .option("--identity-id <id>", "AIdenID identity id for devTestBot runtime")
520
523
  .option("--scenario <id>", "Scenario identifier for inline plan mode", "qa_audit")
521
524
  .option(
522
525
  "--objective <text>",
@@ -571,7 +574,7 @@ export function registerSwarmCommand(program) {
571
574
  const registry = await loadSwarmRegistry({
572
575
  registryFile: options.registryFile,
573
576
  });
574
- const selected = selectSwarmAgents(registry.agents, options.agents);
577
+ const selected = selectSwarmAgents(registry.agents, options.agent || options.agents);
575
578
  if (selected.missing.length > 0) {
576
579
  throw new Error(`Unknown agent id(s): ${selected.missing.join(", ")}`);
577
580
  }
@@ -581,7 +584,7 @@ export function registerSwarmCommand(program) {
581
584
  const selectedAgents = ensureOmarIncluded(registry.agents, selected.selected);
582
585
  plan = buildSwarmExecutionPlan({
583
586
  targetPath,
584
- scenario: scenarioIdOverride || options.scenario,
587
+ scenario: scenarioIdOverride || options.scope || options.scenario,
585
588
  objective: options.objective,
586
589
  agents: selectedAgents,
587
590
  maxParallel: parseMaxParallel(options.maxParallel),
@@ -612,6 +615,8 @@ export function registerSwarmCommand(program) {
612
615
  execute: Boolean(options.execute),
613
616
  maxSteps: parseMaxSteps(options.maxSteps),
614
617
  startUrl: startUrlOverride || options.startUrl,
618
+ identityId: options.identityId,
619
+ devTestBotScope: options.scope || scenarioIdOverride || options.scenario,
615
620
  playbookActions,
616
621
  outputDir: options.outputDir,
617
622
  env: process.env,
@@ -631,6 +636,10 @@ export function registerSwarmCommand(program) {
631
636
  stop: runtime.stop,
632
637
  usage: runtime.usage,
633
638
  eventCount: runtime.eventCount,
639
+ findingCount: runtime.findingCount,
640
+ findings: runtime.findings,
641
+ artifactBundles: runtime.artifactBundles,
642
+ devTestBotRuns: runtime.devTestBotRuns,
634
643
  runtimeDirectory: runtime.runtimeDirectory,
635
644
  runtimeJsonPath: runtime.runtimeJsonPath,
636
645
  runtimeMarkdownPath: runtime.runtimeMarkdownPath,
@@ -1,3 +1,9 @@
1
+ import {
2
+ getCoordinationEtiquetteItems,
3
+ renderCoordinationMarkdownSection,
4
+ renderCoordinationTicketBlock,
5
+ } from "../session/coordination-guidance.js";
6
+
1
7
  export const SUPPORTED_GUIDE_EXPORT_FORMATS = Object.freeze([
2
8
  "jira",
3
9
  "linear",
@@ -167,6 +173,8 @@ function buildTicket(phase, index) {
167
173
  "",
168
174
  "Acceptance criteria:",
169
175
  acceptanceBlock || "1. Phase outcomes are verified by deterministic checks.",
176
+ "",
177
+ renderCoordinationTicketBlock(),
170
178
  ].join("\n"),
171
179
  };
172
180
  }
@@ -235,6 +243,8 @@ ${goal}
235
243
  ## Phase Execution Plan
236
244
  ${phaseMarkdown}
237
245
 
246
+ ${renderCoordinationMarkdownSection()}
247
+
238
248
  ## Suggested PR Sequence
239
249
  ${resolvedPhases
240
250
  .map((phase, index) => `${index + 1}. ${phase.title} (${phase.effort.label})`)
@@ -246,6 +256,7 @@ ${resolvedPhases
246
256
  goal,
247
257
  phases: resolvedPhases,
248
258
  tickets,
259
+ coordinationRules: getCoordinationEtiquetteItems(),
249
260
  markdown,
250
261
  };
251
262
  }
@@ -256,12 +267,14 @@ export function renderGuideExport({ format, guide }) {
256
267
  project: guide.projectName,
257
268
  generated_at: new Date().toISOString(),
258
269
  issues: guide.tickets,
270
+ coordination_rules: Array.isArray(guide.coordinationRules) ? guide.coordinationRules : [],
259
271
  };
260
272
 
261
273
  if (normalized === "jira") {
262
274
  return JSON.stringify(
263
275
  {
264
276
  format: "jira",
277
+ coordination_rules: payload.coordination_rules,
265
278
  issues: payload.issues.map((issue) => ({
266
279
  summary: issue.title,
267
280
  description: issue.description,
@@ -279,6 +292,7 @@ export function renderGuideExport({ format, guide }) {
279
292
  return JSON.stringify(
280
293
  {
281
294
  format: "linear",
295
+ coordination_rules: payload.coordination_rules,
282
296
  issues: payload.issues.map((issue, index) => ({
283
297
  title: issue.title,
284
298
  description: issue.description,
package/src/legacy-cli.js CHANGED
@@ -25,6 +25,10 @@ import { normalizeAgentEvent } from "./events/schema.js";
25
25
  import { collectCodebaseIngest, formatIngestSummary } from "./ingest/engine.js";
26
26
  import { getExpressTemplate, getPackageJsonTemplate, buildReadmeContent } from "./scaffold/templates.js";
27
27
  import { generateScaffold } from "./scaffold/generator.js";
28
+ import {
29
+ getCoordinationEtiquetteItems,
30
+ renderCoordinationNumberedList,
31
+ } from "./session/coordination-guidance.js";
28
32
 
29
33
  let DEFAULT_API_URL = process.env.SENTINELAYER_API_URL || "https://api.sentinelayer.com";
30
34
  let DEFAULT_WEB_URL = process.env.SENTINELAYER_WEB_URL || "https://sentinelayer.com";
@@ -1121,6 +1125,10 @@ async function runLocalOmarGateCommand(args) {
1121
1125
  const maxParallel =
1122
1126
  parseInt(getCommandOptionValue(args, "--max-parallel") || "3", 10) || 3;
1123
1127
  const streamEnabled = hasCommandOption(args, "--stream");
1128
+ const devTestBotEnabled = !hasCommandOption(args, "--no-devtestbot");
1129
+ const devTestBotBaseUrl = getCommandOptionValue(args, "--devtestbot-base-url") || "";
1130
+ const devTestBotScope = getCommandOptionValue(args, "--devtestbot-scope") || "";
1131
+ const emailOnComplete = getCommandOptionValue(args, "--email-on-complete") || "";
1124
1132
 
1125
1133
  const targetPath = path.resolve(process.cwd(), pathArg);
1126
1134
  if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
@@ -1128,6 +1136,9 @@ async function runLocalOmarGateCommand(args) {
1128
1136
  }
1129
1137
 
1130
1138
  const { runInvestorDd } = await import("./review/investor-dd-orchestrator.js");
1139
+ const reportEmailClient = emailOnComplete
1140
+ ? await import("./review/dd-report-email-client.js")
1141
+ : null;
1131
1142
  if (!asJson) {
1132
1143
  printSection("Investor-DD Audit");
1133
1144
  printInfo(`Target: ${targetPath}`);
@@ -1141,6 +1152,24 @@ async function runLocalOmarGateCommand(args) {
1141
1152
  outputDir: outputDirArg,
1142
1153
  budgetOptions: { maxCostUsd, maxRuntimeMinutes, maxParallel },
1143
1154
  dryRun,
1155
+ devTestBot: {
1156
+ enabled: devTestBotEnabled,
1157
+ baseUrl: devTestBotBaseUrl,
1158
+ scope: devTestBotScope,
1159
+ },
1160
+ reportEmail: emailOnComplete
1161
+ ? {
1162
+ to: emailOnComplete,
1163
+ client: {
1164
+ send: ({ runId, to }) => reportEmailClient.sendDdReportEmail({
1165
+ runId,
1166
+ to,
1167
+ cwd: targetPath,
1168
+ env: process.env,
1169
+ }),
1170
+ },
1171
+ }
1172
+ : null,
1144
1173
  onEvent: streamEnabled
1145
1174
  ? (event) => process.stdout.write(`${JSON.stringify(event)}\n`)
1146
1175
  : () => {},
@@ -1950,6 +1979,7 @@ function buildCodingAgentConfigTemplate({ agentProfile, projectName }) {
1950
1979
  "Read docs/spec.md, docs/build-guide.md, tasks/todo.md, and AGENT_HANDOFF_PROMPT.md in order.",
1951
1980
  "Work one PR scope at a time and keep changes deterministic.",
1952
1981
  "Run local checks before push: /omargate deep and /audit.",
1982
+ ...getCoordinationEtiquetteItems(),
1953
1983
  ];
1954
1984
 
1955
1985
  if (agentProfile.id === "aider") {
@@ -1960,7 +1990,7 @@ read:
1960
1990
  - tasks/todo.md
1961
1991
  - AGENT_HANDOFF_PROMPT.md
1962
1992
  notes:
1963
- - ${commonChecklist.join("\n - ")}
1993
+ - ${commonChecklist.map((item) => JSON.stringify(item)).join("\n - ")}
1964
1994
  `;
1965
1995
  }
1966
1996
 
@@ -2186,16 +2216,7 @@ Repo context:
2186
2216
 
2187
2217
  ## Multi-Agent Coordination (if session active)
2188
2218
 
2189
- If a SentinelLayer session is active (check \`sl session list\`):
2190
-
2191
- 1. **On start:** Run \`sl session join <id> --name <your-name> --role coder\`
2192
- 2. **Periodically:** Run \`sl session say <id> "status: working on <file>"\` so others know what you're doing
2193
- 3. **On unexpected file change:** Instead of stopping, run:
2194
- \`sl session say <id> "help: unexpected change in <file> - who modified this?"\`
2195
- Then check \`sl session read <id> --tail 5\` for the answer and continue.
2196
- 4. **On findings:** Run \`sl session say <id> "finding: [P2] <title> in <file>:<line>"\`
2197
- 5. **On completion:** Run \`sl session leave <id>\`
2198
- 6. **Before modifying a file:** Check \`sl session read <id> --tail 10\` for recent activity on that file
2219
+ ${renderCoordinationNumberedList()}
2199
2220
 
2200
2221
  Start now and continue autonomously.
2201
2222
  `;
@@ -2204,12 +2225,8 @@ Start now and continue autonomously.
2204
2225
  export function buildAgentsSessionGuideContent() {
2205
2226
  return `# SentinelLayer Session Guide for AI Agents
2206
2227
 
2207
- ## Quick Start
2208
- 1. Check: \`sl session list\` - is there an active session?
2209
- 2. Join: \`sl session join <id> --name <your-short-name> --role <coder|reviewer|tester>\`
2210
- 3. Read context: \`sl session read <id> --tail 20\` — see what others are doing
2211
- 4. Work: emit status every 5 min, post findings, ask for help instead of stopping
2212
- 5. Leave: \`sl session leave <id>\` when done
2228
+ ## Required Etiquette
2229
+ ${renderCoordinationNumberedList()}
2213
2230
 
2214
2231
  ## Why This Matters
2215
2232
  - Other agents can see what you're working on and avoid file conflicts
@@ -1,3 +1,5 @@
1
+ import { getCoordinationEtiquetteItems } from "../session/coordination-guidance.js";
2
+
1
3
  export const SUPPORTED_PROMPT_TARGETS = Object.freeze([
2
4
  "claude",
3
5
  "cursor",
@@ -34,11 +36,7 @@ const TARGET_GUIDANCE = Object.freeze({
34
36
  ],
35
37
  });
36
38
 
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
- ]);
39
+ const SESSION_COORDINATION_GUIDANCE = Object.freeze(getCoordinationEtiquetteItems());
42
40
 
43
41
  function normalizeTarget(target) {
44
42
  const normalized = String(target || "generic").trim().toLowerCase();
@@ -61,14 +59,6 @@ function buildAgentHeader(target) {
61
59
  return headers[target] || headers.generic;
62
60
  }
63
61
 
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
-
72
62
  export function resolvePromptTarget(target) {
73
63
  return normalizeTarget(target);
74
64
  }
@@ -96,9 +86,7 @@ export function generateExecutionPrompt({
96
86
  }
97
87
 
98
88
  const operatingRules = [...guidance];
99
- if (shouldAppendSessionGuidance(specText)) {
100
- operatingRules.push(...SESSION_COORDINATION_GUIDANCE);
101
- }
89
+ operatingRules.push(...SESSION_COORDINATION_GUIDANCE);
102
90
  const guidanceMarkdown = operatingRules.map((item, index) => `${index + 1}. ${item}`).join("\n");
103
91
 
104
92
  const hasAidenId = specText.toLowerCase().includes("aidenid");
@@ -58,6 +58,20 @@ function sanitizeExcerpt(text) {
58
58
  .slice(0, 180);
59
59
  }
60
60
 
61
+ function cloneJsonCompatible(value) {
62
+ if (value === undefined || value === null || value === "") {
63
+ return null;
64
+ }
65
+ if (typeof value === "string") {
66
+ return normalizeString(value) || null;
67
+ }
68
+ try {
69
+ return JSON.parse(JSON.stringify(value));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
61
75
  function extractJsonPayload(rawText) {
62
76
  const text = String(rawText || "").trim();
63
77
  if (!text) {
@@ -80,7 +94,10 @@ function extractJsonPayload(rawText) {
80
94
  for (const candidate of candidates) {
81
95
  try {
82
96
  const parsed = JSON.parse(candidate);
83
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
97
+ if (Array.isArray(parsed)) {
98
+ return { findings: parsed };
99
+ }
100
+ if (parsed && typeof parsed === "object") {
84
101
  return parsed;
85
102
  }
86
103
  } catch {
@@ -118,14 +135,31 @@ function normalizeConfidence(value) {
118
135
  return Math.max(0, Math.min(1, normalized));
119
136
  }
120
137
 
138
+ function normalizeTrafficLight(value) {
139
+ const normalized = normalizeString(value).toLowerCase();
140
+ if (["green", "yellow", "red"].includes(normalized)) {
141
+ return normalized;
142
+ }
143
+ return "";
144
+ }
145
+
121
146
  function normalizeAiFinding(rawFinding, index) {
122
147
  if (!rawFinding || typeof rawFinding !== "object" || Array.isArray(rawFinding)) {
123
148
  return null;
124
149
  }
125
150
 
126
151
  const message = normalizeString(rawFinding.title || rawFinding.message);
127
- const rationale = normalizeString(rawFinding.rationale || rawFinding.excerpt);
128
- const suggestedFix = normalizeString(rawFinding.suggestedFix);
152
+ const evidence = normalizeString(rawFinding.evidence || rawFinding.excerpt);
153
+ const rootCause = normalizeString(rawFinding.rootCause || rawFinding.root_cause || rawFinding.rationale);
154
+ const recommendedFix = normalizeString(
155
+ rawFinding.recommendedFix || rawFinding.recommended_fix || rawFinding.suggestedFix
156
+ );
157
+ const rationale = normalizeString(rawFinding.rationale || rootCause || evidence || rawFinding.excerpt);
158
+ const suggestedFix = normalizeString(rawFinding.suggestedFix || recommendedFix);
159
+ const lensEvidence = cloneJsonCompatible(rawFinding.lensEvidence || rawFinding.lens_evidence);
160
+ const reproduction = cloneJsonCompatible(rawFinding.reproduction);
161
+ const userImpact = normalizeString(rawFinding.userImpact || rawFinding.user_impact);
162
+ const trafficLight = normalizeTrafficLight(rawFinding.trafficLight || rawFinding.traffic_light);
129
163
 
130
164
  return {
131
165
  severity: normalizeSeverity(rawFinding.severity),
@@ -134,6 +168,13 @@ function normalizeAiFinding(rawFinding, index) {
134
168
  message: message || `AI finding ${index + 1}`,
135
169
  rationale: rationale || "AI reviewer flagged a potential issue requiring validation.",
136
170
  suggestedFix: suggestedFix || "Review and remediate this finding.",
171
+ evidence,
172
+ lensEvidence,
173
+ reproduction,
174
+ userImpact,
175
+ trafficLight,
176
+ rootCause,
177
+ recommendedFix: recommendedFix || suggestedFix || "Review and remediate this finding.",
137
178
  confidence: normalizeConfidence(rawFinding.confidence),
138
179
  };
139
180
  }
@@ -212,6 +253,7 @@ export function buildAiReviewPrompt({
212
253
  deterministicFindings = [],
213
254
  scopedFiles = [],
214
255
  specContext = null,
256
+ systemPrompt = "",
215
257
  maxFindings = DEFAULT_AI_MAX_FINDINGS,
216
258
  } = {}) {
217
259
  const normalizedSummary = deterministicSummary || { P0: 0, P1: 0, P2: 0, P3: 0 };
@@ -226,7 +268,7 @@ export function buildAiReviewPrompt({
226
268
  const specAcceptanceCriteriaCount = Number(specContext?.acceptanceCriteriaCount || 0);
227
269
  const specPreview = Array.isArray(specContext?.endpointsPreview) ? specContext.endpointsPreview : [];
228
270
 
229
- return [
271
+ const basePrompt = [
230
272
  "You are Sentinelayer Omar reviewer layer 9.3.",
231
273
  "Review the deterministic findings and scoped files. Add ONLY materially new findings.",
232
274
  "Do not repeat deterministic findings unless you add new exploitability rationale.",
@@ -241,6 +283,13 @@ export function buildAiReviewPrompt({
241
283
  ' "file": "relative/path",',
242
284
  ' "line": 1,',
243
285
  ' "title": "finding title",',
286
+ ' "evidence": "concrete code excerpt or static trace evidence",',
287
+ ' "lensEvidence": {"A": "passed|failed|not_applicable: short evidence"},',
288
+ ' "reproduction": {"type": "static_trace|manual_step|shell|runtime_probe", "steps": ["step 1"]},',
289
+ ' "user_impact": "operator/user/system impact",',
290
+ ' "trafficLight": "green|yellow|red",',
291
+ ' "rootCause": "why this exists",',
292
+ ' "recommendedFix": "specific remediation",',
244
293
  ' "rationale": "why this matters",',
245
294
  ' "suggestedFix": "specific remediation",',
246
295
  ' "confidence": 0.0',
@@ -265,6 +314,18 @@ export function buildAiReviewPrompt({
265
314
  "Deterministic findings:",
266
315
  findingLines || "- none",
267
316
  ].join("\n");
317
+
318
+ const promptPrelude = normalizeString(systemPrompt);
319
+ if (!promptPrelude) {
320
+ return basePrompt;
321
+ }
322
+ return [
323
+ promptPrelude,
324
+ "",
325
+ "---",
326
+ "",
327
+ basePrompt,
328
+ ].join("\n");
268
329
  }
269
330
 
270
331
  function maybeEstimateModelCost({ modelId, inputTokens, outputTokens }) {
@@ -305,6 +366,9 @@ function composeAiReviewMarkdown({
305
366
  (finding, index) =>
306
367
  `${index + 1}. [${finding.severity}] ${finding.file}:${finding.line} ${finding.message}\n` +
307
368
  ` rationale: ${finding.rationale}\n` +
369
+ (finding.evidence ? ` evidence: ${finding.evidence}\n` : "") +
370
+ (finding.userImpact ? ` user_impact: ${finding.userImpact}\n` : "") +
371
+ (finding.trafficLight ? ` traffic_light: ${finding.trafficLight}\n` : "") +
308
372
  ` suggested_fix: ${finding.suggestedFix}` +
309
373
  (finding.confidence === null ? "" : `\n confidence: ${finding.confidence.toFixed(2)}`)
310
374
  )
@@ -335,16 +399,25 @@ function composeAiReviewMarkdown({
335
399
  }
336
400
 
337
401
  function toReviewFinding(aiFinding, index) {
402
+ const suggestedFix = aiFinding.suggestedFix || aiFinding.recommendedFix;
338
403
  return {
339
404
  severity: aiFinding.severity,
340
405
  file: aiFinding.file,
341
406
  line: aiFinding.line,
342
407
  message: aiFinding.message,
343
- excerpt: sanitizeExcerpt(aiFinding.rationale),
408
+ excerpt: sanitizeExcerpt(aiFinding.evidence || aiFinding.rationale),
344
409
  ruleId: `SL-AI-${String(index + 1).padStart(3, "0")}`,
345
- suggestedFix: aiFinding.suggestedFix,
410
+ suggestedFix,
346
411
  layer: "ai_reasoning",
347
412
  confidence: aiFinding.confidence,
413
+ evidence: aiFinding.evidence,
414
+ lensEvidence: aiFinding.lensEvidence,
415
+ reproduction: aiFinding.reproduction,
416
+ userImpact: aiFinding.userImpact,
417
+ trafficLight: aiFinding.trafficLight,
418
+ rootCause: aiFinding.rootCause,
419
+ recommendedFix: aiFinding.recommendedFix || suggestedFix,
420
+ rationale: aiFinding.rationale,
348
421
  };
349
422
  }
350
423
 
@@ -357,6 +430,20 @@ function buildDryRunResponse({ deterministicSummary, maxFindings } = {}) {
357
430
  file: "src/example.js",
358
431
  line: 1 + index,
359
432
  title: `DRY_RUN finding ${index + 1}`,
433
+ evidence: "const unsafe = exampleInput;",
434
+ lensEvidence: {
435
+ A: "not_applicable: no route/runtime boundary in dry-run fixture",
436
+ J: "failed: synthetic path needs targeted verification before merge",
437
+ K: "passed: no AI tool permission escalation in dry-run fixture",
438
+ },
439
+ reproduction: {
440
+ type: "static_trace",
441
+ steps: ["Inspect src/example.js", "Trace exampleInput into the synthetic finding path"],
442
+ },
443
+ user_impact: "Operator sees a synthetic risk used to validate OmarGate evidence plumbing.",
444
+ trafficLight: index === 0 ? "yellow" : "green",
445
+ rootCause: "DRY_RUN synthetic root cause for evidence-contract validation.",
446
+ recommendedFix: "Validate this path with targeted remediation.",
360
447
  rationale: `Synthetic AI rationale with deterministic context P1=${deterministicSummary.P1}.`,
361
448
  suggestedFix: "Validate this path with targeted remediation.",
362
449
  confidence: index === 0 ? 0.72 : 0.54,
@@ -393,6 +480,7 @@ export async function runAiReviewLayer({
393
480
  maxToolCalls = 0,
394
481
  maxNoProgress = 3,
395
482
  warningThresholdPercent = 80,
483
+ systemPrompt = "",
396
484
  dryRun = false,
397
485
  env = process.env,
398
486
  } = {}) {
@@ -436,6 +524,7 @@ export async function runAiReviewLayer({
436
524
  deterministicFindings: deterministic?.findings || [],
437
525
  scopedFiles: deterministic?.scope?.scannedRelativeFiles || [],
438
526
  specContext: deterministic?.layers?.specBinding || null,
527
+ systemPrompt,
439
528
  maxFindings: normalizedMaxFindings,
440
529
  });
441
530
 
@@ -0,0 +1,148 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { resolveActiveAuthSession } from "../auth/service.js";
4
+ import { requestJson } from "../auth/http.js";
5
+
6
+ export const DD_REPORT_EMAIL_TIMEOUT_MS = 10_000;
7
+
8
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
9
+
10
+ function normalizeString(value) {
11
+ return String(value || "").trim();
12
+ }
13
+
14
+ export function normalizeReportEmail(value) {
15
+ const normalized = normalizeString(value);
16
+ if (!EMAIL_RE.test(normalized)) {
17
+ return "";
18
+ }
19
+ return normalized;
20
+ }
21
+
22
+ export function buildReportEmailIdempotencyKey({ runId, to }) {
23
+ const digest = crypto
24
+ .createHash("sha256")
25
+ .update(`${normalizeString(runId)}\0${normalizeString(to).toLowerCase()}`)
26
+ .digest("hex")
27
+ .slice(0, 32);
28
+ return `sl-cli-dd-email-${digest}`;
29
+ }
30
+
31
+ export function redactDdEmailError(value) {
32
+ return normalizeString(value)
33
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
34
+ .replace(/[A-Za-z]:[\\/][^\s"'<>]+/g, "[LOCAL_PATH]")
35
+ .replace(/api[_-]?key\s*=\s*[^&\s]+/gi, "api_key=[REDACTED]")
36
+ .slice(0, 500);
37
+ }
38
+
39
+ function normalizeTimeoutMs(env = process.env) {
40
+ const parsed = Number(env.SENTINELAYER_DD_EMAIL_TIMEOUT_MS);
41
+ if (Number.isFinite(parsed) && parsed > 0) {
42
+ return Math.max(100, Math.floor(parsed));
43
+ }
44
+ return DD_REPORT_EMAIL_TIMEOUT_MS;
45
+ }
46
+
47
+ function errorResult({ runId, to, code, message, status = 0, requestId = null }) {
48
+ return {
49
+ queued: false,
50
+ sent: false,
51
+ runId: normalizeString(runId),
52
+ to: normalizeString(to),
53
+ code: normalizeString(code) || "DD_EMAIL_FAILED",
54
+ error: redactDdEmailError(message) || "DD report email request failed.",
55
+ status: Number(status || 0),
56
+ requestId: requestId ? String(requestId) : null,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Trigger the API-side investor-DD report email endpoint for a completed run.
62
+ *
63
+ * The caller owns run completion and event emission. This helper only handles
64
+ * auth resolution, bounded network behavior, idempotency, and redacted errors.
65
+ */
66
+ export async function sendDdReportEmail({
67
+ runId,
68
+ to,
69
+ cwd = process.cwd(),
70
+ env = process.env,
71
+ resolveAuthSession = resolveActiveAuthSession,
72
+ requestJsonImpl = requestJson,
73
+ timeoutMs = normalizeTimeoutMs(env),
74
+ } = {}) {
75
+ const normalizedRunId = normalizeString(runId);
76
+ const normalizedTo = normalizeReportEmail(to);
77
+ if (!normalizedRunId) {
78
+ return errorResult({ runId, to, code: "DD_EMAIL_RUN_ID_REQUIRED", message: "runId is required." });
79
+ }
80
+ if (!normalizedTo) {
81
+ return errorResult({ runId, to, code: "DD_EMAIL_INVALID_RECIPIENT", message: "Invalid report email recipient." });
82
+ }
83
+
84
+ let session = null;
85
+ try {
86
+ session = await resolveAuthSession({
87
+ cwd,
88
+ env,
89
+ autoRotate: false,
90
+ });
91
+ } catch (err) {
92
+ return errorResult({
93
+ runId: normalizedRunId,
94
+ to: normalizedTo,
95
+ code: "DD_EMAIL_AUTH_UNAVAILABLE",
96
+ message: err instanceof Error ? err.message : String(err),
97
+ });
98
+ }
99
+
100
+ if (!session || !session.token) {
101
+ return errorResult({
102
+ runId: normalizedRunId,
103
+ to: normalizedTo,
104
+ code: "DD_EMAIL_AUTH_REQUIRED",
105
+ message: "Authenticate before sending DD report email.",
106
+ });
107
+ }
108
+
109
+ const apiUrl = normalizeString(session.apiUrl) || "https://api.sentinelayer.com";
110
+ const endpoint = `${apiUrl.replace(/\/+$/, "")}/api/v1/runs/${encodeURIComponent(
111
+ normalizedRunId,
112
+ )}/send-report-email`;
113
+ const idempotencyKey = buildReportEmailIdempotencyKey({
114
+ runId: normalizedRunId,
115
+ to: normalizedTo,
116
+ });
117
+
118
+ try {
119
+ const response = await requestJsonImpl(endpoint, {
120
+ method: "POST",
121
+ headers: {
122
+ Authorization: `Bearer ${session.token}`,
123
+ },
124
+ idempotencyKey,
125
+ body: { to: normalizedTo },
126
+ timeoutMs,
127
+ maxRetries: 1,
128
+ });
129
+ return {
130
+ queued: true,
131
+ sent: Boolean(response?.sent ?? true),
132
+ runId: normalizeString(response?.run_id) || normalizedRunId,
133
+ to: normalizeString(response?.to) || normalizedTo,
134
+ messageId: normalizeString(response?.message_id),
135
+ replay: Boolean(response?.replay),
136
+ idempotencyKey,
137
+ };
138
+ } catch (err) {
139
+ return errorResult({
140
+ runId: normalizedRunId,
141
+ to: normalizedTo,
142
+ code: err?.code || "DD_EMAIL_REQUEST_FAILED",
143
+ message: err instanceof Error ? err.message : String(err),
144
+ status: err?.status || 0,
145
+ requestId: err?.requestId || null,
146
+ });
147
+ }
148
+ }