sentinelayer-cli 0.16.0 → 0.17.1

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.
@@ -0,0 +1,351 @@
1
+ export const INVESTOR_DD_PROGRESS_VERSION = "investor_dd_progress_v1";
2
+
3
+ export const INVESTOR_DD_EXPECTED_PERSONAS = Object.freeze([
4
+ "security",
5
+ "backend",
6
+ "code-quality",
7
+ "testing",
8
+ "data-layer",
9
+ "reliability",
10
+ "release",
11
+ "observability",
12
+ "infrastructure",
13
+ "supply-chain",
14
+ "documentation",
15
+ "ai-governance",
16
+ "frontend",
17
+ ]);
18
+
19
+ const REQUIRED_FOR_SELLABLE = Object.freeze([
20
+ "persona_roster",
21
+ "persona_agentic_loops",
22
+ "senti_streaming",
23
+ "usage_margin_telemetry",
24
+ "live_reconciliation",
25
+ "devtestbot_runtime",
26
+ "report_email",
27
+ "artifact_bundle",
28
+ ]);
29
+
30
+ function uniqueStrings(values) {
31
+ return Array.from(new Set((values || []).map((value) => String(value || "").trim()).filter(Boolean)));
32
+ }
33
+
34
+ function compactBudgetState(budgetState) {
35
+ if (!budgetState || typeof budgetState !== "object") return null;
36
+ return {
37
+ spentUsd: Number.isFinite(budgetState.spentUsd) ? budgetState.spentUsd : 0,
38
+ maxUsd: Number.isFinite(budgetState.maxUsd) ? budgetState.maxUsd : null,
39
+ toolCalls: Number.isFinite(budgetState.toolCalls) ? budgetState.toolCalls : 0,
40
+ llmCalls: Number.isFinite(budgetState.llmCalls) ? budgetState.llmCalls : 0,
41
+ };
42
+ }
43
+
44
+ function usageLedgerKey(entry) {
45
+ return [
46
+ entry?.ledgerEntry?.ledgerEntryId,
47
+ entry?.action,
48
+ entry?.ledgerEntry?.idempotencyKey,
49
+ entry?.inputTokens,
50
+ entry?.outputTokens,
51
+ ].map((value) => String(value || "")).join(":");
52
+ }
53
+
54
+ function collectSessionUsageLedgerEntries({ budgetState = null, devTestBotPhase = null, usageLedgerEntries = [] }) {
55
+ const candidates = [
56
+ ...(Array.isArray(usageLedgerEntries) ? usageLedgerEntries : []),
57
+ ...(Array.isArray(budgetState?.sessionUsageLedgerEntries) ? budgetState.sessionUsageLedgerEntries : []),
58
+ devTestBotPhase?.plan?.usageLedger,
59
+ ].filter((entry) => entry?.ok);
60
+ const seen = new Set();
61
+ const entries = [];
62
+ for (const entry of candidates) {
63
+ const key = usageLedgerKey(entry);
64
+ if (seen.has(key)) continue;
65
+ seen.add(key);
66
+ entries.push(entry);
67
+ }
68
+ return entries;
69
+ }
70
+
71
+ function byId(capabilities, id) {
72
+ return capabilities.find((capability) => capability.id === id);
73
+ }
74
+
75
+ function addCapability(capabilities, capability) {
76
+ capabilities.push({
77
+ requiredForSellable: REQUIRED_FOR_SELLABLE.includes(capability.id),
78
+ evidence: [],
79
+ gaps: [],
80
+ ...capability,
81
+ });
82
+ }
83
+
84
+ function countByStatus(capabilities, status) {
85
+ return capabilities.filter((capability) => capability.status === status).length;
86
+ }
87
+
88
+ export function summarizeInvestorDdProgress(capabilities) {
89
+ const required = capabilities.filter((capability) => capability.requiredForSellable);
90
+ const blockingGaps = required
91
+ .filter((capability) => capability.status !== "complete")
92
+ .flatMap((capability) => capability.gaps.map((gap) => ({ capabilityId: capability.id, gap })));
93
+
94
+ return {
95
+ total: capabilities.length,
96
+ complete: countByStatus(capabilities, "complete"),
97
+ partial: countByStatus(capabilities, "partial"),
98
+ deferred: countByStatus(capabilities, "deferred"),
99
+ notConfigured: countByStatus(capabilities, "not_configured"),
100
+ requiredTotal: required.length,
101
+ requiredComplete: required.filter((capability) => capability.status === "complete").length,
102
+ blockingGapCount: blockingGaps.length,
103
+ blockingGaps,
104
+ };
105
+ }
106
+
107
+ export function buildInvestorDdProgress({
108
+ runId,
109
+ generatedAt = new Date().toISOString(),
110
+ personas = [],
111
+ dryRun = false,
112
+ routing = {},
113
+ byPersona = {},
114
+ findings = [],
115
+ compliance = null,
116
+ reconciliationAvailable = false,
117
+ liveValidator = null,
118
+ devTestBotPhase = null,
119
+ reportEmailConfigured = false,
120
+ reportEmailResult = null,
121
+ notification = null,
122
+ artifactFiles = [],
123
+ budgetState = null,
124
+ usageLedgerEntries = [],
125
+ } = {}) {
126
+ const activePersonas = uniqueStrings(personas);
127
+ const missingPersonas = INVESTOR_DD_EXPECTED_PERSONAS.filter(
128
+ (personaId) => !activePersonas.includes(personaId),
129
+ );
130
+ const extraPersonas = activePersonas.filter(
131
+ (personaId) => !INVESTOR_DD_EXPECTED_PERSONAS.includes(personaId),
132
+ );
133
+ const capabilities = [];
134
+
135
+ addCapability(capabilities, {
136
+ id: "persona_roster",
137
+ label: "13-persona DD roster",
138
+ status: missingPersonas.length === 0 ? "complete" : "partial",
139
+ evidence: [
140
+ `activePersonas=${activePersonas.length}`,
141
+ `expectedPersonas=${INVESTOR_DD_EXPECTED_PERSONAS.length}`,
142
+ ],
143
+ gaps: missingPersonas.length
144
+ ? [`missing expected personas: ${missingPersonas.join(", ")}`]
145
+ : [],
146
+ metadata: { activePersonas, expectedPersonas: INVESTOR_DD_EXPECTED_PERSONAS, missingPersonas, extraPersonas },
147
+ });
148
+
149
+ const personaRecords = activePersonas.filter((personaId) => byPersona && byPersona[personaId]);
150
+ const allActivePersonasRecorded =
151
+ activePersonas.length > 0 && personaRecords.length === activePersonas.length;
152
+ addCapability(capabilities, {
153
+ id: "persona_agentic_loops",
154
+ label: "Per-persona file loop execution",
155
+ status: dryRun ? "deferred" : allActivePersonasRecorded ? "complete" : "partial",
156
+ evidence: dryRun
157
+ ? ["dryRun=true"]
158
+ : [`personaRecords=${personaRecords.length}/${activePersonas.length}`],
159
+ gaps: dryRun
160
+ ? ["dry-run skips persona execution"]
161
+ : allActivePersonasRecorded
162
+ ? []
163
+ : ["not every active persona produced a persona artifact"],
164
+ });
165
+
166
+ const streamPresent = artifactFiles.includes("stream.ndjson");
167
+ addCapability(capabilities, {
168
+ id: "senti_streaming",
169
+ label: "Senti session streaming and progress spine",
170
+ status: streamPresent ? "partial" : "not_configured",
171
+ evidence: streamPresent ? ["stream.ndjson artifact present"] : [],
172
+ gaps: [
173
+ "orchestrator emits local NDJSON but does not record an attached Senti session id",
174
+ "live web/session token counters are not part of the DD summary contract",
175
+ ],
176
+ });
177
+
178
+ const compactBudget = compactBudgetState(budgetState);
179
+ const sessionUsageLedgerEntries = collectSessionUsageLedgerEntries({
180
+ budgetState,
181
+ devTestBotPhase,
182
+ usageLedgerEntries,
183
+ });
184
+ const hasSessionUsageLedger = sessionUsageLedgerEntries.length > 0;
185
+ addCapability(capabilities, {
186
+ id: "usage_margin_telemetry",
187
+ label: "Billing-grade per-agent usage, token, time, LOC, and margin telemetry",
188
+ status: compactBudget || hasSessionUsageLedger ? "partial" : dryRun ? "deferred" : "not_configured",
189
+ evidence: compactBudget || hasSessionUsageLedger
190
+ ? [
191
+ ...(compactBudget
192
+ ? [
193
+ `localBudgetSpentUsd=${compactBudget.spentUsd}`,
194
+ `localBudgetToolCalls=${compactBudget.toolCalls}`,
195
+ `localBudgetLlmCalls=${compactBudget.llmCalls}`,
196
+ ]
197
+ : []),
198
+ `sessionUsageLedger=${hasSessionUsageLedger}`,
199
+ ...sessionUsageLedgerEntries.map((entry) =>
200
+ `usageLedgerEntry=${entry.ledgerEntry?.ledgerEntryId || "recorded"}`
201
+ ),
202
+ ]
203
+ : dryRun
204
+ ? ["dryRun=true"]
205
+ : [],
206
+ gaps: [
207
+ hasSessionUsageLedger
208
+ ? "only optional DD planner calls are wired to billing-grade session_usage"
209
+ : "budgetState is a local run governor, not the billing-grade session_usage ledger",
210
+ "summary does not include per-agent token totals",
211
+ "summary does not include per-agent runtime, LOC scanned, customer price, or margin",
212
+ ],
213
+ });
214
+
215
+ addCapability(capabilities, {
216
+ id: "compliance_pack",
217
+ label: "Compliance pack dispatch",
218
+ requiredForSellable: false,
219
+ status: compliance ? "complete" : dryRun ? "deferred" : "not_configured",
220
+ evidence: compliance
221
+ ? [`totalCovered=${compliance.totalCovered || 0}`, `totalGaps=${compliance.totalGaps || 0}`]
222
+ : dryRun
223
+ ? ["dryRun=true"]
224
+ : [],
225
+ gaps: compliance ? [] : ["compliance pack did not run for this invocation"],
226
+ });
227
+
228
+ addCapability(capabilities, {
229
+ id: "live_reconciliation",
230
+ label: "Live product validation and reconciliation",
231
+ status: reconciliationAvailable ? "complete" : liveValidator ? "partial" : "not_configured",
232
+ evidence: reconciliationAvailable
233
+ ? ["live-observations.json and reconciliation verdicts available"]
234
+ : liveValidator
235
+ ? ["liveValidator config supplied"]
236
+ : [],
237
+ gaps: reconciliationAvailable
238
+ ? []
239
+ : ["findings were not reconciled against live product observations"],
240
+ });
241
+
242
+ const devSkipped = Boolean(devTestBotPhase?.skipped);
243
+ addCapability(capabilities, {
244
+ id: "devtestbot_runtime",
245
+ label: "devTestBot/AIdenID runtime phase",
246
+ status: devTestBotPhase
247
+ ? devSkipped
248
+ ? "partial"
249
+ : "complete"
250
+ : dryRun
251
+ ? "deferred"
252
+ : "not_configured",
253
+ evidence: devTestBotPhase
254
+ ? [
255
+ `skipped=${devSkipped}`,
256
+ `findingCount=${devTestBotPhase.findingCount || 0}`,
257
+ `artifactRoot=${devTestBotPhase.artifactRoot || ""}`,
258
+ ]
259
+ : dryRun
260
+ ? ["dryRun=true"]
261
+ : [],
262
+ gaps: devTestBotPhase && !devSkipped
263
+ ? []
264
+ : ["devTestBot runtime evidence is missing or skipped for this run"],
265
+ });
266
+
267
+ addCapability(capabilities, {
268
+ id: "report_email",
269
+ label: "Investor report email delivery",
270
+ status: reportEmailResult?.queued
271
+ ? "complete"
272
+ : reportEmailConfigured
273
+ ? "partial"
274
+ : dryRun
275
+ ? "deferred"
276
+ : "not_configured",
277
+ evidence: reportEmailResult
278
+ ? [
279
+ `queued=${Boolean(reportEmailResult.queued)}`,
280
+ `skipped=${Boolean(reportEmailResult.skipped)}`,
281
+ `code=${reportEmailResult.code || ""}`,
282
+ ]
283
+ : dryRun
284
+ ? ["dryRun=true"]
285
+ : [],
286
+ gaps: reportEmailResult?.queued
287
+ ? []
288
+ : ["no queued DD report email is recorded for this run"],
289
+ });
290
+
291
+ const artifactRequired = dryRun
292
+ ? ["plan.json", "stream.ndjson", "summary.json", "report.md", "report.html"]
293
+ : ["plan.json", "stream.ndjson", "summary.json", "report.md", "report.html", "findings.json"];
294
+ const missingArtifacts = artifactRequired.filter((file) => !artifactFiles.includes(file));
295
+ addCapability(capabilities, {
296
+ id: "artifact_bundle",
297
+ label: "Portable artifact bundle",
298
+ status: missingArtifacts.length === 0 ? "complete" : "partial",
299
+ evidence: [`artifactFiles=${artifactFiles.length}`, "manifest.json is written after progress.json"],
300
+ gaps: missingArtifacts.map((file) => `missing artifact: ${file}`),
301
+ });
302
+
303
+ addCapability(capabilities, {
304
+ id: "notification_delivery",
305
+ label: "Dashboard/email notification delivery",
306
+ requiredForSellable: false,
307
+ status: notification ? "partial" : "not_configured",
308
+ evidence: notification ? ["notification config supplied"] : [],
309
+ gaps: notification
310
+ ? ["notification result is not captured in summary.json/progress.json"]
311
+ : ["notification clients were not configured for this invocation"],
312
+ });
313
+
314
+ const progressSummary = summarizeInvestorDdProgress(capabilities);
315
+ const sellableReady = progressSummary.requiredComplete === progressSummary.requiredTotal;
316
+
317
+ return {
318
+ version: INVESTOR_DD_PROGRESS_VERSION,
319
+ generatedAt,
320
+ runId: runId || "",
321
+ overallStatus: sellableReady ? "complete" : "partial",
322
+ sellableReady,
323
+ truthfulClaim: sellableReady ? "sellable_ready" : "not_sellable_ready",
324
+ summary: progressSummary,
325
+ requiredCapabilityIds: REQUIRED_FOR_SELLABLE,
326
+ activePersonaCount: activePersonas.length,
327
+ plannedPersonaCount: INVESTOR_DD_EXPECTED_PERSONAS.length,
328
+ missingPersonas,
329
+ capabilities,
330
+ routingSummary: {
331
+ routedPersonas: Object.keys(routing || {}).length,
332
+ routedFiles: Object.values(routing || {}).reduce(
333
+ (count, files) => count + (Array.isArray(files) ? files.length : 0),
334
+ 0,
335
+ ),
336
+ },
337
+ findingCount: Array.isArray(findings) ? findings.length : 0,
338
+ nextRecommendedSlices: byId(capabilities, "persona_roster")?.status === "complete"
339
+ ? [
340
+ "wire Senti session id and live usage counters into Investor-DD runs",
341
+ "add per-agent token/time/LOC/customer-price/margin telemetry",
342
+ "require live reconciliation and report-email proof for sellable DD closeout",
343
+ ]
344
+ : [
345
+ "add the missing frontend/Jules persona to the default Investor-DD roster",
346
+ "wire Senti session id and live usage counters into Investor-DD runs",
347
+ "add per-agent token/time/LOC/customer-price/margin telemetry",
348
+ "require live reconciliation and report-email proof for sellable DD closeout",
349
+ ],
350
+ };
351
+ }
@@ -0,0 +1,227 @@
1
+ import { recordCliLlmSessionUsage, usageNumber } from "../billing/llm-session-usage.js";
2
+ import { sanitizeBillingMetadata } from "../billing/ledger-entry.js";
3
+
4
+ export const INVESTOR_DD_USAGE_ACTIONS = Object.freeze({
5
+ devTestBotPlanner: "investor_dd_devtestbot_planner",
6
+ filePlanner: "investor_dd_file_planner",
7
+ });
8
+
9
+ export class InvestorDdUsageLedgerError extends Error {
10
+ constructor(message, result = null) {
11
+ super(message);
12
+ this.name = "InvestorDdUsageLedgerError";
13
+ this.code = "INVESTOR_DD_USAGE_LEDGER_FAILED";
14
+ this.result = result;
15
+ }
16
+ }
17
+
18
+ export function isInvestorDdUsageLedgerError(error) {
19
+ return error instanceof InvestorDdUsageLedgerError
20
+ || String(error?.code || "") === "INVESTOR_DD_USAGE_LEDGER_FAILED";
21
+ }
22
+
23
+ function normalizeString(value) {
24
+ return String(value || "").trim();
25
+ }
26
+
27
+ function plainObject(value) {
28
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
29
+ }
30
+
31
+ function normalizeBool(value) {
32
+ return value === true || value === "true" || value === "1";
33
+ }
34
+
35
+ function modelFrom(response, fallback) {
36
+ return normalizeString(response?.usage?.model)
37
+ || normalizeString(response?.model)
38
+ || normalizeString(fallback)
39
+ || "gpt-5.3-codex";
40
+ }
41
+
42
+ function providerFrom(response, fallback) {
43
+ return normalizeString(response?.usage?.provider)
44
+ || normalizeString(response?.provider)
45
+ || normalizeString(fallback);
46
+ }
47
+
48
+ function normalizeUsageContext(usageContext = {}, defaults = {}) {
49
+ const context = plainObject(usageContext);
50
+ const fallback = plainObject(defaults);
51
+ return {
52
+ sessionId: normalizeString(context.sessionId || fallback.sessionId),
53
+ agentId: normalizeString(context.agentId || fallback.agentId),
54
+ model: normalizeString(context.model || fallback.model),
55
+ provider: normalizeString(context.provider || fallback.provider),
56
+ targetPath: normalizeString(context.targetPath || fallback.targetPath),
57
+ billingTier: normalizeString(context.billingTier || fallback.billingTier) || "internal",
58
+ sourceCommand: normalizeString(context.sourceCommand || fallback.sourceCommand) || "omargate investor-dd",
59
+ syncRemote: context.syncRemote !== undefined ? context.syncRemote !== false : fallback.syncRemote !== false,
60
+ required: normalizeBool(context.required ?? fallback.required),
61
+ recorder: typeof context.recorder === "function"
62
+ ? context.recorder
63
+ : typeof fallback.recorder === "function"
64
+ ? fallback.recorder
65
+ : recordCliLlmSessionUsage,
66
+ };
67
+ }
68
+
69
+ function firstProviderUsageNumber(usage, keys) {
70
+ const source = plainObject(usage);
71
+ for (const key of keys) {
72
+ if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
73
+ const parsed = Number(source[key]);
74
+ if (Number.isFinite(parsed) && parsed >= 0) {
75
+ return { found: true, value: Math.floor(parsed) };
76
+ }
77
+ }
78
+ return { found: false, value: 0 };
79
+ }
80
+
81
+ function usageFailure(context, reason, message) {
82
+ const result = {
83
+ ok: false,
84
+ reason,
85
+ required: context.required,
86
+ };
87
+ if (context.required) {
88
+ throw new InvestorDdUsageLedgerError(message, result);
89
+ }
90
+ return result;
91
+ }
92
+
93
+ export function assertInvestorDdUsageContextReady({
94
+ usageContext = {},
95
+ defaults = {},
96
+ action,
97
+ agentId = "",
98
+ model = "",
99
+ targetPath = "",
100
+ } = {}) {
101
+ const context = normalizeUsageContext(usageContext, {
102
+ ...defaults,
103
+ agentId,
104
+ model,
105
+ targetPath,
106
+ });
107
+ if (!context.required) {
108
+ return { ok: true, required: false };
109
+ }
110
+ const normalizedAction = normalizeString(action);
111
+ const normalizedAgentId = normalizeString(agentId || context.agentId);
112
+ const normalizedModel = normalizeString(model || context.model);
113
+ if (!context.sessionId || !normalizedAgentId || !normalizedAction || !normalizedModel) {
114
+ throw new InvestorDdUsageLedgerError(
115
+ "Investor-DD required usage ledger context is incomplete before planner spend.",
116
+ {
117
+ ok: false,
118
+ reason: "missing_session_usage_context",
119
+ required: true,
120
+ },
121
+ );
122
+ }
123
+ return { ok: true, required: true };
124
+ }
125
+
126
+ export async function recordInvestorDdLlmUsage({
127
+ usageContext = {},
128
+ defaults = {},
129
+ action,
130
+ agentId = "",
131
+ phase = "",
132
+ response = "",
133
+ model = "",
134
+ provider = "",
135
+ startedAtIso = "",
136
+ targetPath = "",
137
+ metadata = {},
138
+ } = {}) {
139
+ const context = normalizeUsageContext(usageContext, {
140
+ ...defaults,
141
+ agentId,
142
+ model,
143
+ provider,
144
+ targetPath,
145
+ });
146
+ const normalizedAction = normalizeString(action);
147
+ const normalizedAgentId = normalizeString(agentId || context.agentId);
148
+ const normalizedModel = modelFrom(response, model || context.model);
149
+ const normalizedProvider = providerFrom(response, provider || context.provider);
150
+ const createdAt = normalizeString(startedAtIso) || new Date().toISOString();
151
+
152
+ if (!context.sessionId || !normalizedAgentId || !normalizedAction || !normalizedModel) {
153
+ const result = {
154
+ ok: false,
155
+ reason: "missing_session_usage_context",
156
+ required: context.required,
157
+ };
158
+ if (context.required) {
159
+ throw new InvestorDdUsageLedgerError("Investor-DD session usage context is required.", result);
160
+ }
161
+ return result;
162
+ }
163
+
164
+ const usage = plainObject(response?.usage);
165
+ const input = firstProviderUsageNumber(usage, ["inputTokens", "input_tokens", "tokens_in"]);
166
+ const output = firstProviderUsageNumber(usage, ["outputTokens", "output_tokens", "tokens_out"]);
167
+ if (!input.found && !output.found) {
168
+ return usageFailure(
169
+ context,
170
+ "missing_provider_usage",
171
+ "Investor-DD planner response did not include provider token usage.",
172
+ );
173
+ }
174
+ const inputTokens = usageNumber(input.value, 0);
175
+ const outputTokens = usageNumber(output.value, 0);
176
+ if (inputTokens + outputTokens <= 0) {
177
+ return usageFailure(
178
+ context,
179
+ "zero_provider_tokens",
180
+ "Investor-DD planner response reported zero provider tokens.",
181
+ );
182
+ }
183
+
184
+ try {
185
+ const result = await context.recorder({
186
+ sessionId: context.sessionId,
187
+ agentId: normalizedAgentId,
188
+ action: normalizedAction,
189
+ model: normalizedModel,
190
+ inputTokens,
191
+ outputTokens,
192
+ startedAtIso: createdAt,
193
+ targetPath: context.targetPath,
194
+ billingTier: context.billingTier,
195
+ sourceCommand: context.sourceCommand,
196
+ provider: normalizedProvider,
197
+ syncRemote: context.syncRemote,
198
+ metadata: sanitizeBillingMetadata({
199
+ phase,
200
+ ...plainObject(metadata),
201
+ }),
202
+ });
203
+ if ((!result || result.ok === false) && context.required) {
204
+ throw new InvestorDdUsageLedgerError(
205
+ `Investor-DD session usage ledger failed: ${result?.reason || "unknown"}`,
206
+ result || null,
207
+ );
208
+ }
209
+ return {
210
+ ...(result || { ok: false, reason: "missing_result" }),
211
+ inputTokens,
212
+ outputTokens,
213
+ action: normalizedAction,
214
+ };
215
+ } catch (error) {
216
+ if (error instanceof InvestorDdUsageLedgerError) throw error;
217
+ const result = {
218
+ ok: false,
219
+ reason: error instanceof Error ? error.message : String(error || "session_usage_failed"),
220
+ required: context.required,
221
+ };
222
+ if (context.required) {
223
+ throw new InvestorDdUsageLedgerError(result.reason, result);
224
+ }
225
+ return result;
226
+ }
227
+ }