sentinelayer-cli 0.8.12 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +7 -2
  2. package/src/agents/backend/tools/timeout-audit.js +33 -17
  3. package/src/agents/devtestbot/config/definition.js +100 -0
  4. package/src/agents/devtestbot/config/system-prompt.js +92 -0
  5. package/src/agents/devtestbot/index.js +9 -0
  6. package/src/agents/devtestbot/runner.js +775 -0
  7. package/src/agents/devtestbot/tool.js +707 -0
  8. package/src/commands/legacy-args.js +4 -0
  9. package/src/commands/omargate.js +4 -0
  10. package/src/commands/session.js +960 -159
  11. package/src/commands/swarm.js +11 -2
  12. package/src/guide/generator.js +14 -0
  13. package/src/legacy-cli.js +35 -18
  14. package/src/prompt/generator.js +4 -16
  15. package/src/review/ai-review.js +95 -6
  16. package/src/review/dd-report-email-client.js +148 -0
  17. package/src/review/investor-dd-devtestbot.js +599 -0
  18. package/src/review/investor-dd-orchestrator.js +135 -3
  19. package/src/review/omargate-orchestrator.js +20 -2
  20. package/src/review/persona-prompts.js +34 -1
  21. package/src/review/report.js +61 -2
  22. package/src/scan/generator.js +1 -1
  23. package/src/session/coordination-guidance.js +49 -0
  24. package/src/session/daemon.js +3 -2
  25. package/src/session/event-identity.js +139 -0
  26. package/src/session/listener.js +330 -0
  27. package/src/session/live-source.js +11 -2
  28. package/src/session/mentions.js +130 -0
  29. package/src/session/remote-hydrate.js +223 -8
  30. package/src/session/setup-guides.js +3 -15
  31. package/src/session/store.js +117 -5
  32. package/src/session/stream.js +17 -7
  33. package/src/session/sync.js +375 -26
  34. package/src/session/title-sync.js +107 -0
  35. package/src/spec/generator.js +8 -10
  36. package/src/swarm/registry.js +20 -0
  37. package/src/swarm/runtime.js +139 -1
@@ -37,6 +37,8 @@ import {
37
37
  import { notifyRunCompleted } from "./investor-dd-notification.js";
38
38
  import { attachReproducibilityChain } from "./reproducibility-chain.js";
39
39
  import { renderInvestorDdHtml } from "./investor-dd-html-report.js";
40
+ import { runDevTestBotPhase } from "./investor-dd-devtestbot.js";
41
+ import { redactDdEmailError } from "./dd-report-email-client.js";
40
42
 
41
43
  const INVESTOR_DD_PERSONAS = Object.freeze([
42
44
  "security",
@@ -164,10 +166,95 @@ function buildSummaryMarkdown({ runId, summary, routing, byPersona }) {
164
166
  lines.push(`- **${sev}**: ${count}`);
165
167
  }
166
168
  lines.push("");
169
+ if (summary.devTestBot) {
170
+ lines.push("## devTestBot");
171
+ lines.push("");
172
+ lines.push(`- Skipped: ${summary.devTestBot.skipped ? "yes" : "no"}`);
173
+ lines.push(`- Subagents: ${summary.devTestBot.swarmCount || 0}`);
174
+ lines.push(`- Identities: ${summary.devTestBot.identityCount || 0}`);
175
+ lines.push(`- Findings: ${summary.devTestBot.findingCount || 0}`);
176
+ lines.push(`- Artifacts: ${summary.devTestBot.artifactRoot || "n/a"}`);
177
+ lines.push("");
178
+ }
167
179
  lines.push(`Total: ${allFindings.length}`);
168
180
  return lines.join("\n");
169
181
  }
170
182
 
183
+ async function triggerReportEmail({ reportEmail, runResult, dryRun, emit }) {
184
+ const to = String(reportEmail?.to || "").trim();
185
+ if (!to) return null;
186
+
187
+ if (dryRun && reportEmail.skipWhenDryRun !== false) {
188
+ const result = { queued: false, skipped: true, runId: runResult.runId, to, code: "DD_EMAIL_DRY_RUN" };
189
+ emit({
190
+ type: "dd_email_skipped",
191
+ event: "dd_email_skipped",
192
+ runId: runResult.runId,
193
+ to,
194
+ reason: "dry_run",
195
+ });
196
+ return result;
197
+ }
198
+
199
+ const client = reportEmail.client;
200
+ if (!client || typeof client.send !== "function") {
201
+ const result = { queued: false, runId: runResult.runId, to, code: "DD_EMAIL_CLIENT_MISSING" };
202
+ emit({
203
+ type: "dd_email_error",
204
+ event: "dd_email_error",
205
+ runId: runResult.runId,
206
+ to,
207
+ code: result.code,
208
+ error: "DD report email client is not configured.",
209
+ });
210
+ return result;
211
+ }
212
+
213
+ try {
214
+ const result = await client.send({ runId: runResult.runId, to, run: runResult });
215
+ if (result?.queued) {
216
+ emit({
217
+ type: "dd_email_queued",
218
+ event: "dd_email_queued",
219
+ runId: String(result.runId || runResult.runId),
220
+ to: String(result.to || to),
221
+ messageId: result.messageId || "",
222
+ replay: Boolean(result.replay),
223
+ sent: result.sent !== false,
224
+ });
225
+ return result;
226
+ }
227
+
228
+ emit({
229
+ type: "dd_email_error",
230
+ event: "dd_email_error",
231
+ runId: runResult.runId,
232
+ to,
233
+ code: String(result?.code || "DD_EMAIL_FAILED"),
234
+ status: Number(result?.status || 0),
235
+ error: redactDdEmailError(result?.error || "DD report email request failed."),
236
+ });
237
+ return result || { queued: false, runId: runResult.runId, to };
238
+ } catch (err) {
239
+ const result = {
240
+ queued: false,
241
+ runId: runResult.runId,
242
+ to,
243
+ code: "DD_EMAIL_EXCEPTION",
244
+ error: redactDdEmailError(err instanceof Error ? err.message : String(err)),
245
+ };
246
+ emit({
247
+ type: "dd_email_error",
248
+ event: "dd_email_error",
249
+ runId: runResult.runId,
250
+ to,
251
+ code: result.code,
252
+ error: result.error,
253
+ });
254
+ return result;
255
+ }
256
+ }
257
+
171
258
  /**
172
259
  * Run the investor-DD orchestration end to end.
173
260
  *
@@ -183,6 +270,10 @@ function buildSummaryMarkdown({ runId, summary, routing, byPersona }) {
183
270
  * @param {object} [params.liveValidator.devTestBot] - DevTestBot client.
184
271
  * @param {object} [params.liveValidator.aidenid] - AIdenID client.
185
272
  * @param {number} [params.liveValidator.maxInteractions]
273
+ * @param {object|false} [params.devTestBot] - Automated devTestBot phase config.
274
+ * @param {object|null} [params.reportEmail] - Optional API-side report email trigger.
275
+ * @param {string} [params.reportEmail.to]
276
+ * @param {object} [params.reportEmail.client] - { send({ runId, to, run }) }.
186
277
  * @param {object} [params.notification] - Optional notification config.
187
278
  * @param {string} [params.notification.notifyEmail]
188
279
  * @param {object} [params.notification.emailClient]
@@ -198,6 +289,8 @@ export async function runInvestorDd({
198
289
  dryRun = false,
199
290
  compliancePacks = COMPLIANCE_PACK_CATALOG,
200
291
  liveValidator = null,
292
+ devTestBot = {},
293
+ reportEmail = null,
201
294
  notification = null,
202
295
  } = {}) {
203
296
  if (!rootPath) throw new TypeError("runInvestorDd requires rootPath");
@@ -207,6 +300,10 @@ export async function runInvestorDd({
207
300
  const artifactBase = outputDir
208
301
  ? path.resolve(outputDir, runId, INVESTOR_DD_ARTIFACT_SUBDIR)
209
302
  : path.resolve(rootPath, ".sentinelayer", "runs", runId, INVESTOR_DD_ARTIFACT_SUBDIR);
303
+ const runRoot = path.dirname(artifactBase);
304
+ const outputRoot = outputDir
305
+ ? path.resolve(outputDir)
306
+ : path.resolve(rootPath, ".sentinelayer");
210
307
  await fsp.mkdir(artifactBase, { recursive: true });
211
308
 
212
309
  const streamPath = path.join(artifactBase, "stream.ndjson");
@@ -244,9 +341,11 @@ export async function runInvestorDd({
244
341
  let terminationReason = "ok";
245
342
  let reconciliationAvailable = false;
246
343
  let compliance = null;
344
+ let devTestBotPhase = null;
345
+ let budgetState = null;
247
346
 
248
347
  if (!dryRun) {
249
- const budgetState = createBudgetState({
348
+ budgetState = createBudgetState({
250
349
  maxUsd: resolvedBudget.maxCostUsd,
251
350
  maxRuntimeMs: resolvedBudget.maxRuntimeMinutes * 60_000,
252
351
  });
@@ -274,6 +373,20 @@ export async function runInvestorDd({
274
373
  totalGaps: compliance.totalGaps,
275
374
  });
276
375
 
376
+ devTestBotPhase = await runDevTestBotPhase({
377
+ runId,
378
+ rootPath,
379
+ outputRoot,
380
+ runRoot,
381
+ artifactDir: artifactBase,
382
+ files,
383
+ findings,
384
+ budget: budgetState,
385
+ options: devTestBot === false ? { enabled: false } : devTestBot || {},
386
+ onEvent: emit,
387
+ });
388
+ findings.push(...(devTestBotPhase.findings || []));
389
+
277
390
  // Live-web validation (Jules): optional; only runs when both
278
391
  // devTestBot + aidenid clients are supplied (pluggable contracts).
279
392
  if (
@@ -346,6 +459,16 @@ export async function runInvestorDd({
346
459
  ? { totalCovered: compliance.totalCovered, totalGaps: compliance.totalGaps }
347
460
  : null,
348
461
  reconciliation: reconciliationAvailable,
462
+ devTestBot: devTestBotPhase
463
+ ? {
464
+ skipped: Boolean(devTestBotPhase.skipped),
465
+ reason: devTestBotPhase.reason || "",
466
+ identityCount: devTestBotPhase.plan?.identityCount || devTestBotPhase.identities?.length || 0,
467
+ swarmCount: devTestBotPhase.plan?.swarmCount || devTestBotPhase.subagents?.length || 0,
468
+ findingCount: devTestBotPhase.findingCount || 0,
469
+ artifactRoot: devTestBotPhase.artifactRoot || "",
470
+ }
471
+ : null,
349
472
  };
350
473
  await writeJson(path.join(artifactBase, "summary.json"), summary);
351
474
 
@@ -369,6 +492,17 @@ export async function runInvestorDd({
369
492
  durationSeconds,
370
493
  terminationReason,
371
494
  });
495
+
496
+ const runResult = { runId, artifactDir: artifactBase, summary, findings, devTestBot: devTestBotPhase };
497
+ if (reportEmail) {
498
+ runResult.reportEmail = await triggerReportEmail({
499
+ reportEmail,
500
+ runResult,
501
+ dryRun,
502
+ emit,
503
+ });
504
+ }
505
+
372
506
  await streamHandle.close();
373
507
 
374
508
  const artifactFiles = await fsp.readdir(artifactBase);
@@ -385,8 +519,6 @@ export async function runInvestorDd({
385
519
  }
386
520
  await writeJson(path.join(artifactBase, "manifest.json"), manifest);
387
521
 
388
- const runResult = { runId, artifactDir: artifactBase, summary, findings };
389
-
390
522
  // Fire-and-forget notification dispatch (email + dashboard). Failures
391
523
  // are non-fatal — the report is already persisted to disk + manifest.
392
524
  if (notification && (notification.emailClient || notification.dashboardClient)) {
@@ -321,7 +321,13 @@ async function runOmarPersonaSwarm({
321
321
  }));
322
322
  }
323
323
 
324
- const parentRunDirectory = deterministic?.artifacts?.runDirectory || targetPath;
324
+ const parentRunDirectory =
325
+ deterministic?.artifacts?.runDirectory || path.join(targetPath, ".sentinelayer", "reviews", runId);
326
+ const systemPrompt = buildPersonaReviewPrompt({
327
+ personaId,
328
+ targetPath,
329
+ deterministicSummary: deterministic?.summary || {},
330
+ });
325
331
  const subagentResults = await runWithConcurrency(
326
332
  partitions.map((files, index) => ({ files, subagentIndex: index + 1 })),
327
333
  maxConcurrent,
@@ -380,6 +386,7 @@ async function runOmarPersonaSwarm({
380
386
  model: model || undefined,
381
387
  sessionId: `${subagentRunId}-ai`,
382
388
  maxCostUsd: budget.maxCostUsd,
389
+ systemPrompt,
383
390
  dryRun,
384
391
  env: process.env,
385
392
  });
@@ -450,6 +457,7 @@ async function runOmarPersonaSwarm({
450
457
  summary: result?.summary || summarizeFindings(findings),
451
458
  costUsd: result?.usage?.costUsd || 0,
452
459
  model: result?.model || model || null,
460
+ artifacts: result?.artifacts || null,
453
461
  durationMs: Date.now() - subagentStart,
454
462
  };
455
463
  } catch (err) {
@@ -564,6 +572,7 @@ async function runOmarPersonaSwarm({
564
572
  costUsd: result.costUsd || 0,
565
573
  durationMs: result.durationMs || 0,
566
574
  error: result.error || null,
575
+ artifacts: result.artifacts || null,
567
576
  })),
568
577
  },
569
578
  };
@@ -786,16 +795,23 @@ export async function runOmarGateOrchestrator({
786
795
  targetPath,
787
796
  mode: "full",
788
797
  runId: `${runId}-${personaId}`,
789
- runDirectory: targetPath,
798
+ runDirectory: path.join(
799
+ deterministic?.artifacts?.runDirectory || path.join(targetPath, ".sentinelayer", "reviews", runId),
800
+ "personas",
801
+ personaId
802
+ ),
790
803
  deterministic: {
791
804
  summary: detSummary,
792
805
  findings: detFindings,
806
+ scope: deterministic?.scope || {},
807
+ layers: deterministic?.layers || {},
793
808
  metadata: deterministic?.metadata || {},
794
809
  },
795
810
  outputDir,
796
811
  provider: provider || undefined,
797
812
  model: model || undefined,
798
813
  maxCostUsd: perPersonaCost,
814
+ systemPrompt,
799
815
  dryRun,
800
816
  env: process.env,
801
817
  });
@@ -857,6 +873,7 @@ export async function runOmarGateOrchestrator({
857
873
  summary: result?.summary || { P0: 0, P1: 0, P2: 0, P3: 0 },
858
874
  costUsd: personaCost,
859
875
  model: result?.model || model || null,
876
+ artifacts: result?.artifacts || null,
860
877
  durationMs: Date.now() - personaStart,
861
878
  };
862
879
  } catch (err) {
@@ -977,6 +994,7 @@ export async function runOmarGateOrchestrator({
977
994
  model: r.model || null,
978
995
  error: r.error || null,
979
996
  swarm: r.swarm || null,
997
+ artifacts: r.artifacts || null,
980
998
  })),
981
999
  personaHealth,
982
1000
  findings: reconciledFindings,
@@ -27,6 +27,29 @@ Non-negotiables for your review:
27
27
 
28
28
  Your output must help an acquirer decide whether to buy this codebase. Be FOUND-violations accurate, not speculation-padded.`;
29
29
 
30
+ export const ELEVEN_LENS_EVIDENCE_APPENDIX = `## 11-lens evidence contract
31
+ Evaluate every confirmed finding through these lenses before returning it:
32
+
33
+ A. Route/runtime boundary integrity
34
+ B. State, lifecycle, and hook correctness
35
+ C. Render cost, re-render, and scalability mechanics
36
+ D. Hydration, SSR, streaming, and environment divergence
37
+ E. Data fetching, caching, timeout, and freshness behavior
38
+ F. Bundle/dependency footprint and code-splitting risk
39
+ G. Assets, scripts, layout stability, and resource loading
40
+ H. Accessibility, keyboard, focus, and trust-critical UX
41
+ I. Mobile/responsive reliability across 360px, 768px, and desktop
42
+ J. Verification, rollback, and QA readiness
43
+ K. AI governance, provenance, HITL, and agent/tool permission surfaces
44
+
45
+ For each finding include:
46
+ - lensEvidence: object keyed by lens letter with "passed", "failed", or "not_applicable" plus one short evidence sentence
47
+ - reproduction: object with type (manual_step | shell | runtime_probe | static_trace) and steps array; required for P0/P1
48
+ - user_impact: one sentence describing what a user/operator/system experiences
49
+ - trafficLight: green | yellow | red for automation safety
50
+ - rootCause: why the issue exists
51
+ - recommendedFix: concrete fix path`;
52
+
30
53
  const PERSONA_PROMPTS = {
31
54
  security: {
32
55
  role: "Nina Patel — Security Specialist",
@@ -291,6 +314,8 @@ ${FAANG_GRADE_PREAMBLE}
291
314
 
292
315
  ${persona.focus}
293
316
 
317
+ ${ELEVEN_LENS_EVIDENCE_APPENDIX}
318
+
294
319
  ${checklistBlock}
295
320
  ## Context
296
321
  Target: ${targetPath || "(not provided)"}
@@ -313,6 +338,10 @@ Return a JSON OBJECT (not array) with this shape — return ONLY the JSON, no ot
313
338
  "line": 42,
314
339
  "title": "Brief description",
315
340
  "evidence": "Concrete code excerpt at file:line (min 1 line)",
341
+ "lensEvidence": { "A": "not_applicable: no route/runtime boundary impact", "K": "passed: no AI governance surface involved" },
342
+ "reproduction": { "type": "static_trace", "steps": ["Inspect path/to/file.ext:42", "Trace the value/control flow to the failing behavior"] },
343
+ "user_impact": "One sentence describing the user/operator/system failure mode",
344
+ "trafficLight": "green|yellow|red",
316
345
  "rootCause": "Why this is a problem",
317
346
  "recommendedFix": "Specific code change to apply",
318
347
  "confidence": 0.85,
@@ -326,6 +355,8 @@ Rules:
326
355
  - Maximum ${maxFindings} findings.
327
356
  - Only report findings you have HIGH confidence in (>= 0.7).
328
357
  - Every finding MUST have concrete file:line evidence AND a non-empty \`evidence\` code excerpt.
358
+ - Every finding MUST include \`lensEvidence\`, \`user_impact\`, \`trafficLight\`, \`rootCause\`, and \`recommendedFix\`.
359
+ - P0/P1 findings MUST include \`reproduction\` steps.
329
360
  - Do NOT repeat findings already in the deterministic scan.
330
361
  - Do NOT report hypothetical/speculative issues.
331
362
  - Focus on REAL, EXPLOITABLE, IMPACTFUL problems in your domain.
@@ -337,10 +368,12 @@ Rules:
337
368
  function buildGenericPrompt({ targetPath, deterministicSummary, maxFindings }) {
338
369
  return `You are a senior code reviewer. Analyze the code for security, quality, and reliability issues.
339
370
 
371
+ ${ELEVEN_LENS_EVIDENCE_APPENDIX}
372
+
340
373
  Target: ${targetPath || "(not provided)"}
341
374
  Deterministic scan: P0=${deterministicSummary.P0 || 0} P1=${deterministicSummary.P1 || 0} P2=${deterministicSummary.P2 || 0}
342
375
 
343
- Return a JSON array of up to ${maxFindings} findings with: severity, file, line, title, evidence, rootCause, recommendedFix, confidence.
376
+ Return a JSON object with inspectedFiles, coverage, and up to ${maxFindings} findings. Each finding needs: severity, file, line, title, evidence, lensEvidence, reproduction for P0/P1, user_impact, trafficLight, rootCause, recommendedFix, confidence.
344
377
  Only report findings with concrete evidence. Do NOT repeat deterministic findings.`;
345
378
  }
346
379
 
@@ -70,6 +70,28 @@ function normalizeConfidenceFloor(value) {
70
70
  return Math.max(0, Math.min(1, normalized));
71
71
  }
72
72
 
73
+ function normalizeTrafficLight(value) {
74
+ const normalized = normalizeString(value).toLowerCase();
75
+ if (["green", "yellow", "red"].includes(normalized)) {
76
+ return normalized;
77
+ }
78
+ return "";
79
+ }
80
+
81
+ function cloneJsonCompatible(value) {
82
+ if (value === undefined || value === null || value === "") {
83
+ return null;
84
+ }
85
+ if (typeof value === "string") {
86
+ return normalizeString(value) || null;
87
+ }
88
+ try {
89
+ return JSON.parse(JSON.stringify(value));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
73
95
  function confidenceFloorForFinding(finding = {}, {
74
96
  source = "ai",
75
97
  confidenceFloors = {},
@@ -202,15 +224,28 @@ export function reconcileReviewFindings({
202
224
  confidenceFloors,
203
225
  defaultConfidenceFloor: normalizedDefaultConfidenceFloor,
204
226
  });
227
+ const evidence = normalizeString(finding.evidence || finding.excerpt);
228
+ const rootCause = normalizeString(finding.rootCause || finding.root_cause);
229
+ const recommendedFix = normalizeString(
230
+ finding.recommendedFix || finding.recommended_fix || finding.suggestedFix
231
+ );
232
+ const suggestedFix = normalizeString(finding.suggestedFix || recommendedFix);
205
233
  const normalized = {
206
234
  findingId: "",
207
235
  severity: normalizeSeverity(finding.severity),
208
236
  file: toPosixPath(normalizeString(finding.file) || "unknown"),
209
237
  line: Math.max(1, Math.floor(Number(finding.line || 1))),
210
238
  message: normalizeString(finding.message) || "Unnamed finding",
211
- excerpt: normalizeString(finding.excerpt),
239
+ excerpt: normalizeString(finding.excerpt || evidence || rootCause),
212
240
  ruleId: normalizeString(finding.ruleId),
213
- suggestedFix: normalizeString(finding.suggestedFix),
241
+ suggestedFix,
242
+ evidence,
243
+ lensEvidence: cloneJsonCompatible(finding.lensEvidence || finding.lens_evidence),
244
+ reproduction: cloneJsonCompatible(finding.reproduction),
245
+ userImpact: normalizeString(finding.userImpact || finding.user_impact),
246
+ trafficLight: normalizeTrafficLight(finding.trafficLight || finding.traffic_light),
247
+ rootCause,
248
+ recommendedFix: recommendedFix || suggestedFix,
214
249
  persona,
215
250
  layer: normalizeString(finding.layer),
216
251
  confidence: source === "deterministic" ? 1 : formatConfidence(finding.confidence),
@@ -260,6 +295,27 @@ export function reconcileReviewFindings({
260
295
  if (!preferred.suggestedFix) {
261
296
  preferred.suggestedFix = existing.suggestedFix || normalized.suggestedFix;
262
297
  }
298
+ if (!preferred.evidence) {
299
+ preferred.evidence = existing.evidence || normalized.evidence;
300
+ }
301
+ if (!preferred.lensEvidence) {
302
+ preferred.lensEvidence = existing.lensEvidence || normalized.lensEvidence;
303
+ }
304
+ if (!preferred.reproduction) {
305
+ preferred.reproduction = existing.reproduction || normalized.reproduction;
306
+ }
307
+ if (!preferred.userImpact) {
308
+ preferred.userImpact = existing.userImpact || normalized.userImpact;
309
+ }
310
+ if (!preferred.trafficLight) {
311
+ preferred.trafficLight = existing.trafficLight || normalized.trafficLight;
312
+ }
313
+ if (!preferred.rootCause) {
314
+ preferred.rootCause = existing.rootCause || normalized.rootCause;
315
+ }
316
+ if (!preferred.recommendedFix) {
317
+ preferred.recommendedFix = existing.recommendedFix || normalized.recommendedFix;
318
+ }
263
319
  merged.set(key, preferred);
264
320
  };
265
321
 
@@ -354,6 +410,9 @@ function composeReportMarkdown(report = {}) {
354
410
  ` confidence: ${(formatConfidence(finding.confidence) * 100).toFixed(0)}%\n` +
355
411
  ` sources: ${(finding.sources || []).join(", ") || "none"}\n` +
356
412
  ` verdict: ${finding.adjudication?.verdict || "pending"}\n` +
413
+ (finding.trafficLight ? ` traffic_light: ${finding.trafficLight}\n` : "") +
414
+ (finding.userImpact ? ` user_impact: ${finding.userImpact}\n` : "") +
415
+ (finding.rootCause ? ` root_cause: ${finding.rootCause}\n` : "") +
357
416
  ` suggested_fix: ${finding.suggestedFix || "Review and remediate as needed."}`
358
417
  )
359
418
  .join("\n")
@@ -2,7 +2,7 @@ import YAML from "yaml";
2
2
 
3
3
  export const DEFAULT_SCAN_WORKFLOW_PATH = ".github/workflows/omar-gate.yml";
4
4
  export const DEFAULT_SCAN_SECRET_NAME = "SENTINELAYER_TOKEN";
5
- export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@55a2c158f637d7d92e26ab0ef3ba81db791da4be";
5
+ export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@b13504565105b2496c5b1dbb7a3e9bf914c2a9f8";
6
6
  export const SUPPORTED_E2E_HINTS = Object.freeze(["auto", "yes", "no"]);
7
7
  export const SUPPORTED_PLAYWRIGHT_MODES = Object.freeze(["auto", "off", "baseline", "audit"]);
8
8
 
@@ -0,0 +1,49 @@
1
+ export const COORDINATION_GUIDANCE_TITLE = "Multi-Agent Coordination Protocol";
2
+
3
+ export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
4
+ "Find the recent Senti session for this codebase: run `sl session list --path .` and `sl session list --remote --path .`; join the right room with `sl session join <id> --name <your-name> --role coder`.",
5
+ "When you have an agent grant, post agent updates with `sl session post-agent <id> \"status: <update>\" --agent <your-agent-id>` so they render as the agent, not the human relay.",
6
+ "Before implementation, post a short plan and file claims with `sl session say <id> \"plan: <scope>; files: <paths>\"`.",
7
+ "Claim shared files before editing with `lock: <file> - <intent>` and release them with `unlock: <file> - done`.",
8
+ "Run a background listener for replies: `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval 5 --emit ndjson`; this idles at 60s and switches to 5s after human activity. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
9
+ "Run `sl review --diff` after each finished file or PR-ready diff and post the result summary back to the session.",
10
+ "Post findings through `sl session say <id> \"finding: [P2] <title> in <file>:<line>\"` with enough context for a peer to act.",
11
+ "Ask for help in-session instead of stopping on unexpected file changes, blocked context, or ambiguous ownership.",
12
+ "Offer non-conflicting follow-up work to peers when you finish your claimed scope or discover separable tasks.",
13
+ "Run `sl --help` when you hit an unfamiliar workflow before guessing at command syntax.",
14
+ "Leave the session when done with `sl session leave <id>` after posting the final status and verification evidence.",
15
+ ]);
16
+
17
+ export function getCoordinationEtiquetteItems() {
18
+ return [...COORDINATION_ETIQUETTE_ITEMS];
19
+ }
20
+
21
+ export function renderCoordinationNumberedList({
22
+ items = COORDINATION_ETIQUETTE_ITEMS,
23
+ indent = "",
24
+ } = {}) {
25
+ return items.map((item, index) => `${indent}${index + 1}. ${item}`).join("\n");
26
+ }
27
+
28
+ export function renderCoordinationBulletList({
29
+ items = COORDINATION_ETIQUETTE_ITEMS,
30
+ indent = "",
31
+ } = {}) {
32
+ return items.map((item) => `${indent}- ${item}`).join("\n");
33
+ }
34
+
35
+ export function renderCoordinationMarkdownSection({
36
+ headingLevel = 2,
37
+ title = COORDINATION_GUIDANCE_TITLE,
38
+ } = {}) {
39
+ const level = Math.max(1, Math.min(6, Number.parseInt(String(headingLevel || 2), 10) || 2));
40
+ return `${"#".repeat(level)} ${title}
41
+ ${renderCoordinationNumberedList()}`;
42
+ }
43
+
44
+ export function renderCoordinationTicketBlock() {
45
+ return [
46
+ "Coordination rules:",
47
+ renderCoordinationNumberedList(),
48
+ ].join("\n");
49
+ }
@@ -45,6 +45,7 @@ const HELP_MODEL_TIMEOUT_MS = 3_000;
45
45
  const HELP_CONTEXT_EVENT_TAIL = 50;
46
46
  const HELP_CONTEXT_RESULT_LIMIT = 6;
47
47
  const HELP_BLACKBOARD_ENTRY_LIMIT = 40;
48
+ const WATCHER_STARTUP_REPLAY_TAIL = 100;
48
49
  const FILE_CONFLICT_WINDOW_MS = 60_000;
49
50
  const RENEWAL_WINDOW_MS = 60 * 60 * 1000;
50
51
  const RENEWAL_THRESHOLD_EVENTS = 10;
@@ -608,7 +609,7 @@ async function runHelpWatcher(daemonState) {
608
609
  targetPath: daemonState.targetPath,
609
610
  signal,
610
611
  since: daemonState.startedAt,
611
- replayTail: 0,
612
+ replayTail: WATCHER_STARTUP_REPLAY_TAIL,
612
613
  pollMs: Math.max(25, Math.min(250, Math.floor(daemonState.helpRequestTimeoutMs / 4))),
613
614
  })) {
614
615
  if (!daemonState.running) {
@@ -781,7 +782,7 @@ async function runSessionDirectiveWatcher(daemonState) {
781
782
  targetPath: daemonState.targetPath,
782
783
  signal,
783
784
  since: daemonState.startedAt,
784
- replayTail: 0,
785
+ replayTail: WATCHER_STARTUP_REPLAY_TAIL,
785
786
  pollMs: 100,
786
787
  })) {
787
788
  if (!daemonState.running) {
@@ -0,0 +1,139 @@
1
+ function keyString(value) {
2
+ return String(value || "").trim();
3
+ }
4
+
5
+ function timestampKey(...values) {
6
+ for (const value of values) {
7
+ const normalized = keyString(value);
8
+ if (!normalized) continue;
9
+ const epoch = Date.parse(normalized);
10
+ if (Number.isFinite(epoch)) {
11
+ return new Date(epoch).toISOString();
12
+ }
13
+ return normalized;
14
+ }
15
+ return "";
16
+ }
17
+
18
+ function stableJsonValue(value) {
19
+ if (Array.isArray(value)) {
20
+ return value.map((item) => stableJsonValue(item));
21
+ }
22
+ if (value && typeof value === "object") {
23
+ return Object.fromEntries(
24
+ Object.entries(value)
25
+ .filter(([, entryValue]) => entryValue !== undefined)
26
+ .sort(([left], [right]) => left.localeCompare(right))
27
+ .map(([key, entryValue]) => [key, stableJsonValue(entryValue)])
28
+ );
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function stableStringify(value) {
34
+ return JSON.stringify(stableJsonValue(value));
35
+ }
36
+
37
+ export function sessionEventIdentityKeys(event = {}) {
38
+ if (!event || typeof event !== "object") return [];
39
+ const keys = [];
40
+ const id = keyString(event.id);
41
+ if (id) {
42
+ keys.push(`id:${id}`);
43
+ }
44
+ if (typeof event.cursor === "string" && event.cursor.trim()) {
45
+ keys.push(`cursor:${event.cursor.trim()}`);
46
+ }
47
+ if (typeof event.eventId === "string" && event.eventId.trim()) {
48
+ keys.push(`event:${event.eventId.trim()}`);
49
+ }
50
+ if (typeof event.idempotencyToken === "string" && event.idempotencyToken.trim()) {
51
+ keys.push(`idempotency:${event.idempotencyToken.trim()}`);
52
+ }
53
+ const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
54
+ const messageId = typeof payload.messageId === "string" ? payload.messageId.trim() : "";
55
+ if (messageId) {
56
+ keys.push(`message:${messageId}`);
57
+ }
58
+ const timestamp = timestampKey(event.ts, event.timestamp, event.at);
59
+ const hasPayloadSignal = Object.keys(payload).length > 0;
60
+ const hasFingerprintSignal =
61
+ id || messageId || keyString(event.eventId) || keyString(event.idempotencyToken) ||
62
+ keyString(event.agent?.id || event.agentId) || timestamp || hasPayloadSignal;
63
+ if (hasFingerprintSignal) {
64
+ try {
65
+ keys.push(`fingerprint:${stableStringify({
66
+ event: event.event || event.type || "",
67
+ id,
68
+ eventId: keyString(event.eventId),
69
+ idempotencyToken: keyString(event.idempotencyToken),
70
+ agent: event.agent?.id || event.agentId || "",
71
+ payload,
72
+ ts: timestamp,
73
+ })}`);
74
+ } catch {
75
+ // Best-effort duplicate suppression only.
76
+ }
77
+ }
78
+ const message = keyString(payload.message || payload.text || payload.body);
79
+ if (message) {
80
+ try {
81
+ keys.push(`content:${stableStringify({
82
+ event: keyString(event.event || event.type),
83
+ agent: keyString(event.agent?.id || event.agentId || payload.agentId || payload.authorId),
84
+ payload: {
85
+ channel: keyString(payload.channel),
86
+ clientKind: keyString(payload.clientKind),
87
+ message,
88
+ source: keyString(payload.source),
89
+ to: payload.to || payload.recipient || payload.mentions || null,
90
+ },
91
+ ts: timestampKey(event.ts, event.timestamp, event.at),
92
+ })}`);
93
+ } catch {
94
+ // Best-effort duplicate suppression only.
95
+ }
96
+ }
97
+ return keys;
98
+ }
99
+
100
+ export function sessionEventHasKnownIdentity(event = {}, knownKeys = new Set()) {
101
+ const keys = sessionEventIdentityKeys(event);
102
+ return keys.length > 0 && keys.some((key) => knownKeys.has(key));
103
+ }
104
+
105
+ export function addSessionEventIdentityKeys(knownKeys, event = {}) {
106
+ for (const key of sessionEventIdentityKeys(event)) {
107
+ knownKeys.add(key);
108
+ }
109
+ }
110
+
111
+ export function dedupeSessionEvents(events = []) {
112
+ const normalizedEvents = Array.isArray(events) ? events : [];
113
+ const deduped = [];
114
+ const indexByKey = new Map();
115
+
116
+ for (const event of normalizedEvents) {
117
+ const keys = sessionEventIdentityKeys(event);
118
+ const existingIndexes = keys
119
+ .map((key) => indexByKey.get(key))
120
+ .filter((index) => Number.isInteger(index) && index >= 0);
121
+ const existingIndex = existingIndexes.length > 0 ? Math.min(...existingIndexes) : -1;
122
+
123
+ if (existingIndex >= 0) {
124
+ deduped[existingIndex] = event;
125
+ for (const key of keys) {
126
+ indexByKey.set(key, existingIndex);
127
+ }
128
+ continue;
129
+ }
130
+
131
+ const nextIndex = deduped.length;
132
+ deduped.push(event);
133
+ for (const key of keys) {
134
+ indexByKey.set(key, nextIndex);
135
+ }
136
+ }
137
+
138
+ return deduped;
139
+ }