sentinelayer-cli 0.8.10 → 0.8.12

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.
@@ -62,6 +62,53 @@ function formatConfidence(value) {
62
62
  return Math.max(0, Math.min(1, normalized));
63
63
  }
64
64
 
65
+ function normalizeConfidenceFloor(value) {
66
+ const normalized = Number(value);
67
+ if (!Number.isFinite(normalized)) {
68
+ return 0.7;
69
+ }
70
+ return Math.max(0, Math.min(1, normalized));
71
+ }
72
+
73
+ function confidenceFloorForFinding(finding = {}, {
74
+ source = "ai",
75
+ confidenceFloors = {},
76
+ defaultConfidenceFloor = 0.7,
77
+ } = {}) {
78
+ const persona = normalizeString(finding.persona || finding.personaId || finding.agentId);
79
+ const layer = normalizeString(finding.layer);
80
+ const identity = sourceIdentityForFinding(finding, source);
81
+ const floor =
82
+ finding.confidenceFloor ??
83
+ finding.personaConfidenceFloor ??
84
+ confidenceFloors[identity] ??
85
+ confidenceFloors[persona] ??
86
+ confidenceFloors[layer] ??
87
+ confidenceFloors[source] ??
88
+ defaultConfidenceFloor;
89
+ return normalizeConfidenceFloor(floor);
90
+ }
91
+
92
+ function sourceIdentityForFinding(finding = {}, source = "ai") {
93
+ if (source === "deterministic") {
94
+ return "deterministic";
95
+ }
96
+ const persona = normalizeString(
97
+ finding.persona || finding.personaId || finding.agentId || finding.layer
98
+ );
99
+ return `ai:${persona || "generic"}`;
100
+ }
101
+
102
+ function hasMultiSourceConfirmation(finding = {}) {
103
+ const confirmationSources = Array.isArray(finding.confirmationSources)
104
+ ? finding.confirmationSources
105
+ : [];
106
+ const sourceIdentities = confirmationSources.length > 0
107
+ ? confirmationSources
108
+ : (Array.isArray(finding.sources) ? finding.sources : []);
109
+ return new Set(sourceIdentities.filter(Boolean)).size >= 2;
110
+ }
111
+
65
112
  function dedupeKeyForFinding(finding = {}) {
66
113
  const file = toPosixPath(normalizeString(finding.file) || "unknown");
67
114
  const line = Number(finding.line || 1);
@@ -104,13 +151,57 @@ function summarizeFindings(findings = []) {
104
151
  };
105
152
  }
106
153
 
154
+ export function dropBelowConfidence(findings = [], { threshold = 0.7 } = {}) {
155
+ const defaultThreshold = normalizeConfidenceFloor(threshold);
156
+ const kept = [];
157
+ const dropped = [];
158
+
159
+ for (const finding of findings || []) {
160
+ const confidence = formatConfidence(finding.confidence);
161
+ const confidenceFloor = normalizeConfidenceFloor(
162
+ finding.confidenceFloor ?? finding.personaConfidenceFloor ?? defaultThreshold
163
+ );
164
+ if (!hasMultiSourceConfirmation(finding) && confidence < confidenceFloor) {
165
+ dropped.push({
166
+ ...finding,
167
+ confidence,
168
+ confidenceFloor,
169
+ droppedReason: "below_confidence_floor_single_source",
170
+ });
171
+ continue;
172
+ }
173
+ kept.push({
174
+ ...finding,
175
+ confidence,
176
+ confidenceFloor,
177
+ });
178
+ }
179
+
180
+ return {
181
+ findings: kept,
182
+ dropped,
183
+ droppedCount: dropped.length,
184
+ threshold: defaultThreshold,
185
+ };
186
+ }
187
+
107
188
  export function reconcileReviewFindings({
108
189
  deterministicFindings = [],
109
190
  aiFindings = [],
191
+ confidenceFloor = 0.7,
192
+ defaultConfidenceFloor = confidenceFloor,
193
+ confidenceFloors = {},
110
194
  } = {}) {
111
195
  const merged = new Map();
196
+ const normalizedDefaultConfidenceFloor = normalizeConfidenceFloor(defaultConfidenceFloor);
112
197
 
113
198
  const addFinding = (finding, source) => {
199
+ const persona = normalizeString(finding.persona || finding.personaId || finding.agentId);
200
+ const confidenceFloorForSource = confidenceFloorForFinding(finding, {
201
+ source,
202
+ confidenceFloors,
203
+ defaultConfidenceFloor: normalizedDefaultConfidenceFloor,
204
+ });
114
205
  const normalized = {
115
206
  findingId: "",
116
207
  severity: normalizeSeverity(finding.severity),
@@ -120,9 +211,12 @@ export function reconcileReviewFindings({
120
211
  excerpt: normalizeString(finding.excerpt),
121
212
  ruleId: normalizeString(finding.ruleId),
122
213
  suggestedFix: normalizeString(finding.suggestedFix),
214
+ persona,
123
215
  layer: normalizeString(finding.layer),
124
216
  confidence: source === "deterministic" ? 1 : formatConfidence(finding.confidence),
217
+ confidenceFloor: confidenceFloorForSource,
125
218
  sources: [source],
219
+ confirmationSources: [sourceIdentityForFinding(finding, source)],
126
220
  adjudication: {
127
221
  verdict: "pending",
128
222
  note: "",
@@ -138,8 +232,25 @@ export function reconcileReviewFindings({
138
232
  }
139
233
 
140
234
  const nextSources = new Set([...(existing.sources || []), source]);
235
+ const nextConfirmationSources = new Set([
236
+ ...(existing.confirmationSources || []),
237
+ ...(normalized.confirmationSources || []),
238
+ ]);
141
239
  const preferred = compareFindingPriority(existing, normalized) <= 0 ? existing : normalized;
142
240
  preferred.sources = [...nextSources].sort((left, right) => left.localeCompare(right));
241
+ preferred.confirmationSources = [...nextConfirmationSources].sort((left, right) =>
242
+ left.localeCompare(right)
243
+ );
244
+ preferred.confidenceFloor = Math.max(
245
+ normalizeConfidenceFloor(existing.confidenceFloor),
246
+ normalizeConfidenceFloor(normalized.confidenceFloor)
247
+ );
248
+ if (!preferred.persona) {
249
+ preferred.persona = existing.persona || normalized.persona;
250
+ }
251
+ if (!preferred.layer) {
252
+ preferred.layer = existing.layer || normalized.layer;
253
+ }
143
254
  if (!preferred.excerpt) {
144
255
  preferred.excerpt = existing.excerpt || normalized.excerpt;
145
256
  }
@@ -159,7 +270,10 @@ export function reconcileReviewFindings({
159
270
  addFinding(finding, "ai");
160
271
  }
161
272
 
162
- const findings = [...merged.values()].sort((left, right) => {
273
+ const confidenceFilter = dropBelowConfidence([...merged.values()], {
274
+ threshold: normalizedDefaultConfidenceFloor,
275
+ });
276
+ const findings = confidenceFilter.findings.sort((left, right) => {
163
277
  const severityDelta = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity];
164
278
  if (severityDelta !== 0) {
165
279
  return severityDelta;
@@ -177,7 +291,13 @@ export function reconcileReviewFindings({
177
291
 
178
292
  return {
179
293
  findings,
180
- summary: summarizeFindings(findings),
294
+ droppedFindings: confidenceFilter.dropped,
295
+ summary: {
296
+ ...summarizeFindings(findings),
297
+ confidenceFloor: confidenceFilter.threshold,
298
+ droppedBelowConfidence: confidenceFilter.droppedCount,
299
+ droppedBelowConfidenceSingleSource: confidenceFilter.droppedCount,
300
+ },
181
301
  };
182
302
  }
183
303
 
@@ -250,6 +370,7 @@ function composeReportMarkdown(report = {}) {
250
370
  `- Findings: P0=${report.summary.P0} P1=${report.summary.P1} P2=${report.summary.P2} P3=${report.summary.P3}`,
251
371
  `- Blocking: ${report.summary.blocking ? "yes" : "no"}`,
252
372
  `- Total findings: ${report.findings.length}`,
373
+ `- Dropped below confidence floor (single-source): ${report.summary.droppedBelowConfidence || 0}`,
253
374
  "",
254
375
  "Metadata:",
255
376
  `- commit_sha: ${report.metadata.git.commitSha || "unknown"}`,
@@ -280,6 +401,8 @@ export async function buildUnifiedReviewReport({
280
401
  deterministic,
281
402
  aiLayer = null,
282
403
  specFile = "",
404
+ defaultConfidenceFloor = 0.7,
405
+ confidenceFloors = {},
283
406
  } = {}) {
284
407
  const normalizedTargetPath = path.resolve(String(targetPath || "."));
285
408
  const normalizedMode = normalizeString(mode) || "full";
@@ -289,6 +412,8 @@ export async function buildUnifiedReviewReport({
289
412
  const reconciliation = reconcileReviewFindings({
290
413
  deterministicFindings: deterministic?.findings || [],
291
414
  aiFindings: aiLayer?.findings || [],
415
+ defaultConfidenceFloor,
416
+ confidenceFloors,
292
417
  });
293
418
  const spec = await resolveSpecMetadata(normalizedTargetPath, specFile);
294
419
  const commitSha = runGit(normalizedTargetPath, ["rev-parse", "HEAD"]);
@@ -303,6 +428,7 @@ export async function buildUnifiedReviewReport({
303
428
  mode: normalizedMode,
304
429
  summary: reconciliation.summary,
305
430
  findings: reconciliation.findings,
431
+ droppedFindings: reconciliation.droppedFindings,
306
432
  severityMatrix: buildSeverityMatrix(),
307
433
  metadata: {
308
434
  git: {
@@ -180,6 +180,62 @@ export function generateAgentId(modelName) {
180
180
  return `${prefix}-${suffix}`;
181
181
  }
182
182
 
183
+ // In-process registry of agents registered by *this* CLI process. The
184
+ // dashboard treats any participant without a terminal agent_leave /
185
+ // agent_killed / session_killed event as "active". When a CLI exits via
186
+ // SIGINT/SIGTERM/crash without explicitly leaving, the dashboard shows
187
+ // "Last activity: 15h ago — active" indefinitely. This registry lets a
188
+ // single process-wide exit hook flush leave events for every agent it
189
+ // owns so the participant roster stays honest.
190
+ const _localAgents = new Map(); // key: `${sessionId}::${agentId}` -> { sessionId, agentId, targetPath }
191
+ let _exitHooksInstalled = false;
192
+
193
+ function _agentKey(sessionId, agentId) {
194
+ return `${sessionId}::${agentId}`;
195
+ }
196
+
197
+ function _trackLocalAgent(sessionId, agentId, targetPath) {
198
+ _localAgents.set(_agentKey(sessionId, agentId), { sessionId, agentId, targetPath });
199
+ _ensureExitHooksInstalled();
200
+ }
201
+
202
+ function _untrackLocalAgent(sessionId, agentId) {
203
+ _localAgents.delete(_agentKey(sessionId, agentId));
204
+ }
205
+
206
+ async function _emitLeaveForAllLocalAgents(reason) {
207
+ const entries = [..._localAgents.values()];
208
+ _localAgents.clear();
209
+ for (const entry of entries) {
210
+ try {
211
+ await emitAgentEvent(
212
+ entry.sessionId,
213
+ "agent_leave",
214
+ { agentId: entry.agentId, reason, model: "unknown", role: "participant" },
215
+ { targetPath: entry.targetPath },
216
+ );
217
+ } catch {
218
+ // Best-effort: a stuck filesystem or network shouldn't block exit.
219
+ }
220
+ }
221
+ }
222
+
223
+ function _ensureExitHooksInstalled() {
224
+ if (_exitHooksInstalled) return;
225
+ _exitHooksInstalled = true;
226
+ const onSignal = (signal) => {
227
+ void _emitLeaveForAllLocalAgents("manual").finally(() => {
228
+ process.removeListener(signal, onSignal);
229
+ process.kill(process.pid, signal);
230
+ });
231
+ };
232
+ process.on("SIGINT", onSignal);
233
+ process.on("SIGTERM", onSignal);
234
+ process.on("beforeExit", () => {
235
+ void _emitLeaveForAllLocalAgents("manual");
236
+ });
237
+ }
238
+
183
239
  export async function registerAgent(
184
240
  sessionId,
185
241
  { agentId = "", model = "", role = "observer", targetPath = process.cwd() } = {}
@@ -234,6 +290,7 @@ export async function registerAgent(
234
290
  role: snapshot.role,
235
291
  status: snapshot.status,
236
292
  }, { targetPath });
293
+ _trackLocalAgent(paths.sessionId, snapshot.agentId, targetPath);
237
294
 
238
295
  if (renamedFrom) {
239
296
  const welcome = buildSentiWelcome({
@@ -347,6 +404,8 @@ export async function unregisterAgent(
347
404
  role: snapshot.role,
348
405
  model: snapshot.model,
349
406
  }, { targetPath });
407
+ // Already left explicitly — don't double-emit on process exit.
408
+ _untrackLocalAgent(paths.sessionId, snapshot.agentId);
350
409
 
351
410
  return {
352
411
  ...snapshot,
@@ -129,6 +129,42 @@ export function isAnonymousAgent(agent = {}) {
129
129
  return idAnonymous || modelAnonymous;
130
130
  }
131
131
 
132
+ /**
133
+ * Derive a deterministic session title from a workspace path + clock.
134
+ *
135
+ * Carter's complaint: every CLI invocation minted an unnamed session, so the
136
+ * web sidebar filled with hundreds of "<null>" rows that all looked like the
137
+ * same chat re-created. The fix: when the caller doesn't pass `--title`, give
138
+ * the session a stable label based on the codebase basename + today's date in
139
+ * UTC, e.g. `create-sentinelayer-2026-04-28`.
140
+ *
141
+ * - Basename only (we never leak the absolute path).
142
+ * - Sanitized to `[a-z0-9-]` so the title is URL-safe + dashboard-friendly.
143
+ * - Date is UTC ISO short form (YYYY-MM-DD) for reproducibility regardless of
144
+ * the host timezone.
145
+ * - Falls back to `session-<date>` if the path has no usable basename.
146
+ *
147
+ * @param {string} targetPath
148
+ * @param {{now?: Date}} [options]
149
+ * @returns {string}
150
+ */
151
+ export function deriveSessionTitle(targetPath, { now = new Date() } = {}) {
152
+ const raw = String(targetPath || "").trim();
153
+ // Use forward slashes consistently — Windows paths come through with
154
+ // backslashes from path.resolve. We don't import the `path` module here
155
+ // to keep this function pure + cheap to test.
156
+ const last = raw.split(/[/\\]+/).filter(Boolean).pop() || "";
157
+ const slug = last
158
+ .toLowerCase()
159
+ .replace(/[^a-z0-9]+/g, "-")
160
+ .replace(/^-+|-+$/g, "")
161
+ .slice(0, 60);
162
+ const stamp = (now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date())
163
+ .toISOString()
164
+ .slice(0, 10);
165
+ return slug ? `${slug}-${stamp}` : `session-${stamp}`;
166
+ }
167
+
132
168
  /**
133
169
  * Build the payload Senti emits as `agent_identified` when it has
134
170
  * stepped in to name a participant. Consumers (CLI / web) render it
@@ -407,6 +407,17 @@ export async function syncSessionEventToApi(
407
407
  return { synced: false, reason: "invalid_input" };
408
408
  }
409
409
 
410
+ // Test-fixture leak guard. Tests in this repo (and downstream consumers)
411
+ // create + tear down sessions using a temp workspace; on a developer
412
+ // machine those calls inherit the user's stored auth and silently posted
413
+ // hundreds of orphan rooms to prod (Carter saw ~200 "<null>" sessions).
414
+ // Honoring SENTINELAYER_SKIP_REMOTE_SYNC=1 keeps everything local while
415
+ // still exercising the appendToStream + agent_join code paths the tests
416
+ // care about. Local NDJSON durability is unaffected.
417
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
418
+ return { synced: false, reason: "remote_sync_disabled_env" };
419
+ }
420
+
410
421
  const normalizedNowMs = Number(nowMs()) || Date.now();
411
422
  if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
412
423
  return { synced: false, reason: "circuit_breaker_open" };
@@ -501,6 +512,13 @@ async function syncSessionAuxPayload(
501
512
  return { synced: false, reason: "invalid_input" };
502
513
  }
503
514
 
515
+ // Same test-fixture leak guard as syncSessionEventToApi — keep parity
516
+ // so neither the event channel nor the metadata/error channels can
517
+ // exfiltrate a test session into prod when the env flag is set.
518
+ if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
519
+ return { synced: false, reason: "remote_sync_disabled_env" };
520
+ }
521
+
504
522
  const normalizedNowMs = Number(nowMs()) || Date.now();
505
523
  if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
506
524
  return { synced: false, reason: "circuit_breaker_open" };
@@ -1034,6 +1052,11 @@ export function resetSessionSyncStateForTests() {
1034
1052
  inboundCircuit.openedAtMs = 0;
1035
1053
  sessionIngestWindowBySessionId.clear();
1036
1054
  humanRelayWindowBySessionId.clear();
1055
+ // Tests that exercise the network path explicitly need the
1056
+ // SENTINELAYER_SKIP_REMOTE_SYNC guard off — otherwise the function
1057
+ // short-circuits before the mocked fetchImpl is ever called. Tests that
1058
+ // want the guard on can re-set the env after resetting.
1059
+ delete process.env.SENTINELAYER_SKIP_REMOTE_SYNC;
1037
1060
  }
1038
1061
 
1039
1062
  export {