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.
@@ -14,6 +14,12 @@ import crypto from "node:crypto";
14
14
  import { recordProvisionedIdentity } from "../ai/identity-store.js";
15
15
  import { runDevTestBotSession } from "../agents/devtestbot/tool.js";
16
16
  import { checkBudget } from "./investor-dd-file-loop.js";
17
+ import {
18
+ INVESTOR_DD_USAGE_ACTIONS,
19
+ assertInvestorDdUsageContextReady,
20
+ isInvestorDdUsageLedgerError,
21
+ recordInvestorDdLlmUsage,
22
+ } from "./investor-dd-usage.js";
17
23
 
18
24
  export const DEVTESTBOT_PHASE_MAX_CONCURRENT = 4;
19
25
  export const DEVTESTBOT_PHASE_DEFAULT_SCOPE = "smoke";
@@ -80,23 +86,69 @@ function buildPlannerPrompt({ rootPath, files = [], findings = [], budget = {} }
80
86
  ].join("\n");
81
87
  }
82
88
 
83
- async function callPlannerClient({ plannerClient, rootPath, files, findings, budget }) {
84
- if (!plannerClient) return {};
89
+ async function callPlannerClient({ plannerClient, rootPath, files, findings, budget, sessionUsage }) {
90
+ if (!plannerClient) return { planned: {}, usageLedger: null };
85
91
  const prompt = buildPlannerPrompt({ rootPath, files, findings, budget });
86
92
  if (typeof plannerClient.decideDevTestBotPhase === "function") {
87
- return plannerClient.decideDevTestBotPhase({ rootPath, files, findings, budget, prompt });
93
+ return {
94
+ planned: await plannerClient.decideDevTestBotPhase({ rootPath, files, findings, budget, prompt }),
95
+ usageLedger: null,
96
+ };
88
97
  }
98
+ if (sessionUsage) {
99
+ assertInvestorDdUsageContextReady({
100
+ usageContext: sessionUsage,
101
+ action: INVESTOR_DD_USAGE_ACTIONS.devTestBotPlanner,
102
+ agentId: "investor-dd-devtestbot-planner",
103
+ targetPath: rootPath,
104
+ });
105
+ }
106
+ const startedAtIso = new Date().toISOString();
89
107
  if (typeof plannerClient.invoke === "function") {
90
108
  const response = await plannerClient.invoke({ prompt, stream: false });
91
- return parsePlannerJson(response?.text || response);
109
+ const usageLedger = sessionUsage
110
+ ? await recordInvestorDdLlmUsage({
111
+ usageContext: sessionUsage,
112
+ action: INVESTOR_DD_USAGE_ACTIONS.devTestBotPlanner,
113
+ agentId: "investor-dd-devtestbot-planner",
114
+ phase: "devtestbot_planner",
115
+ prompt,
116
+ response,
117
+ model: response?.model,
118
+ provider: response?.provider,
119
+ startedAtIso,
120
+ targetPath: rootPath,
121
+ metadata: {
122
+ plannerClient: "invoke",
123
+ },
124
+ })
125
+ : null;
126
+ return { planned: parsePlannerJson(response?.text || response), usageLedger };
92
127
  }
93
128
  if (typeof plannerClient.generatePlan === "function") {
94
129
  const response = await plannerClient.generatePlan([{ role: "user", content: prompt }], {
95
130
  phase: "devtestbot",
96
131
  });
97
- return parsePlannerJson(response?.text || response?.content || response);
132
+ const usageLedger = sessionUsage
133
+ ? await recordInvestorDdLlmUsage({
134
+ usageContext: sessionUsage,
135
+ action: INVESTOR_DD_USAGE_ACTIONS.devTestBotPlanner,
136
+ agentId: "investor-dd-devtestbot-planner",
137
+ phase: "devtestbot_planner",
138
+ messages: [{ role: "user", content: prompt }],
139
+ response,
140
+ model: response?.model,
141
+ provider: response?.provider,
142
+ startedAtIso,
143
+ targetPath: rootPath,
144
+ metadata: {
145
+ plannerClient: "generatePlan",
146
+ },
147
+ })
148
+ : null;
149
+ return { planned: parsePlannerJson(response?.text || response?.content || response), usageLedger };
98
150
  }
99
- return {};
151
+ return { planned: {}, usageLedger: null };
100
152
  }
101
153
 
102
154
  function chooseScope({ requestedScope, files = [], findings = [], plannedScope }) {
@@ -135,6 +187,7 @@ function normalizePhaseOptions(options = {}) {
135
187
  runner: source.runner || null,
136
188
  provisionIdentity: source.provisionIdentity || null,
137
189
  maxConcurrentAgents: source.maxConcurrentAgents,
190
+ sessionUsage: source.sessionUsage || null,
138
191
  };
139
192
  }
140
193
 
@@ -178,15 +231,22 @@ export async function planDevTestBotPhase({
178
231
  }
179
232
 
180
233
  let planned = {};
234
+ let usageLedger = null;
181
235
  try {
182
- planned = await callPlannerClient({
236
+ const plannerResult = await callPlannerClient({
183
237
  plannerClient: normalized.plannerClient,
184
238
  rootPath,
185
239
  files,
186
240
  findings,
187
241
  budget,
242
+ sessionUsage: normalized.sessionUsage,
188
243
  });
189
- } catch {
244
+ planned = plannerResult.planned || {};
245
+ usageLedger = plannerResult.usageLedger || null;
246
+ } catch (error) {
247
+ if (isInvestorDdUsageLedgerError(error)) {
248
+ throw error;
249
+ }
190
250
  planned = {};
191
251
  }
192
252
  const swarmCount = clampInt(normalized.swarmCount ?? planned.swarmCount, {
@@ -236,6 +296,7 @@ export async function planDevTestBotPhase({
236
296
  baseUrl: normalized.baseUrl,
237
297
  recordVideo: normalized.recordVideo !== false,
238
298
  maxConcurrentAgents,
299
+ usageLedger,
239
300
  };
240
301
  }
241
302
 
@@ -415,6 +476,20 @@ export async function runDevTestBotPhase({
415
476
  maxConcurrentAgents: plan.maxConcurrentAgents,
416
477
  },
417
478
  });
479
+ if (plan.usageLedger?.ok) {
480
+ onEvent({
481
+ type: "devtestbot_planner_usage_recorded",
482
+ phase: "devtestbot",
483
+ action: INVESTOR_DD_USAGE_ACTIONS.devTestBotPlanner,
484
+ ledgerEntryId: plan.usageLedger.ledgerEntry?.ledgerEntryId || "",
485
+ });
486
+ } else if (plan.usageLedger?.ok === false) {
487
+ onEvent({
488
+ type: "devtestbot_planner_usage_unrecorded",
489
+ phase: "devtestbot",
490
+ reason: plan.usageLedger.reason || "unknown",
491
+ });
492
+ }
418
493
 
419
494
  if (!plan.enabled) {
420
495
  const skipped = {
@@ -18,6 +18,11 @@
18
18
  */
19
19
 
20
20
  import { runEnvelopeLoop } from "../agents/envelope/index.js";
21
+ import {
22
+ INVESTOR_DD_USAGE_ACTIONS,
23
+ assertInvestorDdUsageContextReady,
24
+ recordInvestorDdLlmUsage,
25
+ } from "./investor-dd-usage.js";
21
26
 
22
27
  export const INVESTOR_DD_DEFAULT_MAX_TURNS_PER_FILE = 6;
23
28
  export const INVESTOR_DD_DEFAULT_STUCK_THRESHOLD = 2;
@@ -122,21 +127,75 @@ function meterTools(tools, budget, onToolCall) {
122
127
  }));
123
128
  }
124
129
 
130
+ function rememberUsageLedgerEntry(budget, entry) {
131
+ if (!budget || typeof budget !== "object" || !entry?.ok) return;
132
+ if (!Array.isArray(budget.sessionUsageLedgerEntries)) {
133
+ budget.sessionUsageLedgerEntries = [];
134
+ }
135
+ budget.sessionUsageLedgerEntries.push(entry);
136
+ }
137
+
125
138
  /**
126
139
  * Wrap the caller's LLM client so every generatePlan call increments the
127
- * llmCalls counter. Cost accounting for LLM calls is the client's
128
- * responsibility (it knows the model and tokens), so the client adds to
129
- * `budget.spentUsd` directly.
140
+ * llmCalls counter. Billing-grade token accounting is recorded only from
141
+ * provider-returned usage after the planner returns.
130
142
  *
131
143
  * @param {object} client
132
144
  * @param {InvestorDdBudgetState} budget
133
145
  */
134
- function meterClient(client, budget) {
146
+ function meterClient(client, budget, { personaId, sessionUsage, onEvent }) {
135
147
  return {
136
148
  ...client,
137
149
  generatePlan: async (messages, options) => {
150
+ const startedAtIso = new Date().toISOString();
151
+ if (sessionUsage) {
152
+ try {
153
+ assertInvestorDdUsageContextReady({
154
+ usageContext: sessionUsage,
155
+ action: INVESTOR_DD_USAGE_ACTIONS.filePlanner,
156
+ agentId: `investor-dd-${personaId}`,
157
+ });
158
+ } catch (error) {
159
+ budget.usageLedgerError = error;
160
+ throw error;
161
+ }
162
+ }
138
163
  budget.llmCalls += 1;
139
- return client.generatePlan(messages, options);
164
+ const response = await client.generatePlan(messages, options);
165
+ if (!sessionUsage) {
166
+ return response;
167
+ }
168
+ const usageResult = await recordInvestorDdLlmUsage({
169
+ usageContext: sessionUsage,
170
+ action: INVESTOR_DD_USAGE_ACTIONS.filePlanner,
171
+ agentId: `investor-dd-${personaId}`,
172
+ phase: "persona_file_loop",
173
+ messages,
174
+ response,
175
+ startedAtIso,
176
+ metadata: {
177
+ personaId,
178
+ turn: options?.turn || 0,
179
+ },
180
+ }).catch((error) => {
181
+ budget.usageLedgerError = error;
182
+ throw error;
183
+ });
184
+ if (usageResult?.ok === false) {
185
+ onEvent({
186
+ type: "persona_llm_usage_unrecorded",
187
+ personaId,
188
+ reason: usageResult.reason || "unknown",
189
+ });
190
+ } else if (usageResult?.ok) {
191
+ rememberUsageLedgerEntry(budget, usageResult);
192
+ onEvent({
193
+ type: "persona_llm_usage_recorded",
194
+ personaId,
195
+ action: INVESTOR_DD_USAGE_ACTIONS.filePlanner,
196
+ });
197
+ }
198
+ return response;
140
199
  },
141
200
  };
142
201
  }
@@ -157,6 +216,7 @@ function meterClient(client, budget) {
157
216
  * @param {object} [params.options]
158
217
  * @param {number} [params.options.maxTurnsPerFile]
159
218
  * @param {number} [params.options.stuckThreshold]
219
+ * @param {object} [params.sessionUsage] - Optional billing-grade Senti usage context.
160
220
  * @returns {Promise<InvestorDdFileLoopResult>}
161
221
  */
162
222
  export async function runPerFileReviewLoop({
@@ -167,6 +227,7 @@ export async function runPerFileReviewLoop({
167
227
  buildInitialMessages,
168
228
  budget,
169
229
  onEvent = () => {},
230
+ sessionUsage = null,
170
231
  options = {},
171
232
  } = {}) {
172
233
  if (!personaId || typeof personaId !== "string") {
@@ -193,7 +254,9 @@ export async function runPerFileReviewLoop({
193
254
  : INVESTOR_DD_DEFAULT_STUCK_THRESHOLD;
194
255
 
195
256
  const safeBudget = budget || createBudgetState();
196
- const meteredClient = meterClient(client, safeBudget);
257
+ const initialUsageLedgerEntryCount = Array.isArray(safeBudget.sessionUsageLedgerEntries)
258
+ ? safeBudget.sessionUsageLedgerEntries.length
259
+ : 0;
197
260
 
198
261
  const perFile = [];
199
262
  const allFindings = [];
@@ -225,6 +288,11 @@ export async function runPerFileReviewLoop({
225
288
  emit({ type: "persona_file_tool_call", personaId, file, tool, input });
226
289
  });
227
290
  const initialMessages = buildInitialMessages(file);
291
+ const meteredClient = meterClient(client, safeBudget, {
292
+ personaId,
293
+ sessionUsage,
294
+ onEvent: emit,
295
+ });
228
296
 
229
297
  let loopResult;
230
298
  try {
@@ -256,7 +324,13 @@ export async function runPerFileReviewLoop({
256
324
  },
257
325
  },
258
326
  });
327
+ if (safeBudget.usageLedgerError) {
328
+ throw safeBudget.usageLedgerError;
329
+ }
259
330
  } catch (err) {
331
+ if (safeBudget.usageLedgerError && err === safeBudget.usageLedgerError) {
332
+ throw err;
333
+ }
260
334
  terminationReason = "client-error";
261
335
  emit({
262
336
  type: "persona_file_error",
@@ -298,6 +372,9 @@ export async function runPerFileReviewLoop({
298
372
  findings: allFindings,
299
373
  visited,
300
374
  skipped,
375
+ usageLedgerEntries: Array.isArray(safeBudget.sessionUsageLedgerEntries)
376
+ ? safeBudget.sessionUsageLedgerEntries.slice(initialUsageLedgerEntryCount).filter((entry) => entry?.ok)
377
+ : [],
301
378
  terminationReason,
302
379
  };
303
380
  }
@@ -10,6 +10,7 @@
10
10
  * persona-<id>.json — per-persona findings + coverage proof
11
11
  * findings.json — flat list across all personas (dedup in PR-29)
12
12
  * summary.json — run metadata (timings, cost, terminationReason)
13
+ * progress.json — truthful sellable-readiness capability ledger
13
14
  * report.md — human-readable summary
14
15
  * manifest.json — SHA-256 chain of every artifact
15
16
  *
@@ -39,6 +40,7 @@ import { attachReproducibilityChain } from "./reproducibility-chain.js";
39
40
  import { renderInvestorDdHtml } from "./investor-dd-html-report.js";
40
41
  import { runDevTestBotPhase } from "./investor-dd-devtestbot.js";
41
42
  import { redactDdEmailError } from "./dd-report-email-client.js";
43
+ import { buildInvestorDdProgress } from "./investor-dd-progress.js";
42
44
 
43
45
  const INVESTOR_DD_PERSONAS = Object.freeze([
44
46
  "security",
@@ -271,6 +273,7 @@ async function triggerReportEmail({ reportEmail, runResult, dryRun, emit }) {
271
273
  * @param {object} [params.liveValidator.aidenid] - AIdenID client.
272
274
  * @param {number} [params.liveValidator.maxInteractions]
273
275
  * @param {object|false} [params.devTestBot] - Automated devTestBot phase config.
276
+ * @param {object|null} [params.sessionUsage] - Optional Senti session_usage context for DD LLM calls.
274
277
  * @param {object|null} [params.reportEmail] - Optional API-side report email trigger.
275
278
  * @param {string} [params.reportEmail.to]
276
279
  * @param {object} [params.reportEmail.client] - { send({ runId, to, run }) }.
@@ -290,6 +293,7 @@ export async function runInvestorDd({
290
293
  compliancePacks = COMPLIANCE_PACK_CATALOG,
291
294
  liveValidator = null,
292
295
  devTestBot = {},
296
+ sessionUsage = null,
293
297
  reportEmail = null,
294
298
  notification = null,
295
299
  } = {}) {
@@ -382,7 +386,12 @@ export async function runInvestorDd({
382
386
  files,
383
387
  findings,
384
388
  budget: budgetState,
385
- options: devTestBot === false ? { enabled: false } : devTestBot || {},
389
+ options: devTestBot === false
390
+ ? { enabled: false }
391
+ : {
392
+ ...(devTestBot || {}),
393
+ sessionUsage,
394
+ },
386
395
  onEvent: emit,
387
396
  });
388
397
  findings.push(...(devTestBotPhase.findings || []));
@@ -503,6 +512,38 @@ export async function runInvestorDd({
503
512
  });
504
513
  }
505
514
 
515
+ const artifactFilesBeforeManifest = await fsp.readdir(artifactBase);
516
+ const ddProgress = buildInvestorDdProgress({
517
+ runId,
518
+ personas,
519
+ dryRun,
520
+ routing,
521
+ byPersona,
522
+ findings,
523
+ compliance,
524
+ reconciliationAvailable,
525
+ liveValidator,
526
+ devTestBotPhase,
527
+ reportEmailConfigured: Boolean(reportEmail),
528
+ reportEmailResult: runResult.reportEmail || null,
529
+ notification,
530
+ artifactFiles: artifactFilesBeforeManifest,
531
+ budgetState,
532
+ });
533
+ summary.ddProgress = {
534
+ version: ddProgress.version,
535
+ overallStatus: ddProgress.overallStatus,
536
+ sellableReady: ddProgress.sellableReady,
537
+ complete: ddProgress.summary.complete,
538
+ requiredComplete: ddProgress.summary.requiredComplete,
539
+ requiredTotal: ddProgress.summary.requiredTotal,
540
+ blockingGapCount: ddProgress.summary.blockingGapCount,
541
+ artifact: "progress.json",
542
+ };
543
+ runResult.progress = ddProgress;
544
+ await writeJson(path.join(artifactBase, "progress.json"), ddProgress);
545
+ await writeJson(path.join(artifactBase, "summary.json"), summary);
546
+
506
547
  await streamHandle.close();
507
548
 
508
549
  const artifactFiles = await fsp.readdir(artifactBase);