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.
- package/package.json +4 -4
- package/src/agents/jules/stream.js +2 -12
- package/src/audit/orchestrator.js +471 -114
- package/src/audit/persona-loop.js +1342 -0
- package/src/audit/registry.js +58 -2
- package/src/commands/audit.js +42 -1
- package/src/commands/legacy-args.js +28 -1
- package/src/commands/session.js +80 -20
- package/src/cost/history.js +41 -21
- package/src/events/schema.js +27 -1
- package/src/legacy-cli.js +76 -1
- package/src/review/omargate-cache.js +285 -0
- package/src/review/omargate-orchestrator.js +586 -3
- package/src/review/report.js +128 -2
- package/src/session/agent-registry.js +59 -0
- package/src/session/senti-naming.js +36 -0
- package/src/session/sync.js +23 -0
package/src/review/report.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/src/session/sync.js
CHANGED
|
@@ -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 {
|