imprint-mcp 0.6.2 → 0.6.4

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.
@@ -17,11 +17,16 @@ import { type ChildProcess, spawn } from 'node:child_process';
17
17
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
18
18
  import { dirname, join as pathJoin } from 'node:path';
19
19
  import { z } from 'zod';
20
+ import {
21
+ type AuthVerificationReceiptStatus,
22
+ readAuthVerificationReceiptStatus,
23
+ } from './auth-compile-tools.ts';
20
24
  import { type ProviderName, preferredAgentModel } from './llm.ts';
21
25
  import { createLog } from './log.ts';
22
26
  import { imprintHomeDir } from './paths.ts';
23
- import { discoverTools } from './tool-loader.ts';
24
- import { llmSpanAttributes, setSpanAttributes, totalPromptTokens, traced } from './tracing.ts';
27
+ import { type TeachState, loadTeachState } from './teach-state.ts';
28
+ import { type ResolvedTool, discoverTools } from './tool-loader.ts';
29
+ import { recordLlmUsageSpan, setSpanAttributes, totalPromptTokens, traced } from './tracing.ts';
25
30
 
26
31
  const log = createLog('audit');
27
32
 
@@ -110,6 +115,123 @@ interface AuditEvaluation {
110
115
  missingParameters: MissingAuditedParameter[];
111
116
  }
112
117
 
118
+ interface SkippedInteractiveAuth {
119
+ name: string;
120
+ reason: string;
121
+ verificationStatus: AuthVerificationReceiptStatus['status'];
122
+ verificationReason: string;
123
+ verificationAction?: string;
124
+ verificationBackend?: string;
125
+ verificationTimestamp?: number;
126
+ }
127
+
128
+ interface AuditToolPartition {
129
+ eligible: ResolvedTool[];
130
+ nonInteractiveAuthNames: string[];
131
+ skippedInteractiveAuth: SkippedInteractiveAuth[];
132
+ }
133
+
134
+ interface DetectedButUnemittedTool {
135
+ name: string;
136
+ reason?: string;
137
+ lastCompletedStep?: string;
138
+ }
139
+
140
+ /** Report candidates from the latest retained recording that teach detected but
141
+ * did not emit. They are diagnostic metadata, not part of the audit score: the
142
+ * audit agent can only call emitted MCP tools. */
143
+ export function detectedButUnemittedTools(
144
+ state: TeachState,
145
+ emittedToolNames: readonly string[],
146
+ ): DetectedButUnemittedTool[] {
147
+ const candidates = Object.values(state.workflows).filter((workflow) => workflow.candidate);
148
+ if (candidates.length === 0) return [];
149
+
150
+ const latest = candidates.reduce((current, workflow) =>
151
+ Date.parse(workflow.updatedAt) > Date.parse(current.updatedAt) ? workflow : current,
152
+ );
153
+ const emitted = new Set(emittedToolNames);
154
+ const byName = new Map<string, DetectedButUnemittedTool>();
155
+ for (const workflow of candidates) {
156
+ if (workflow.sessionPath !== latest.sessionPath || !workflow.candidate) continue;
157
+ const name = workflow.candidate.toolName;
158
+ // A same-named artifact may belong to an older successful teach. Only
159
+ // suppress the diagnostic when the latest recording itself reached emit
160
+ // successfully and the emitted artifact still exists.
161
+ if (emitted.has(name) && workflow.completedSteps.includes('emit') && !workflow.error) continue;
162
+ byName.set(name, {
163
+ name,
164
+ ...(workflow.error ? { reason: workflow.error.split('\n')[0] } : {}),
165
+ ...(workflow.completedSteps.at(-1)
166
+ ? { lastCompletedStep: workflow.completedSteps.at(-1) }
167
+ : {}),
168
+ });
169
+ }
170
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
171
+ }
172
+
173
+ export function countDetectedToolNames(
174
+ emittedToolNames: readonly string[],
175
+ detectedButUnemitted: readonly DetectedButUnemittedTool[],
176
+ ): number {
177
+ return new Set([...emittedToolNames, ...detectedButUnemitted.map((candidate) => candidate.name)])
178
+ .size;
179
+ }
180
+
181
+ /** Partition generated tools for a headless audit. Authentication is eligible
182
+ * only when every declared action completes directly: any pause means a human
183
+ * continuation (OTP, CAPTCHA, device approval, email link, etc.) may be needed. */
184
+ export function partitionAuditTools(tools: readonly ResolvedTool[]): AuditToolPartition {
185
+ const eligible: ResolvedTool[] = [];
186
+ const nonInteractiveAuthNames: string[] = [];
187
+ const skippedInteractiveAuth: SkippedInteractiveAuth[] = [];
188
+
189
+ for (const tool of tools) {
190
+ // authConfig is the authoritative capability marker for legacy workflows
191
+ // where the newer optional toolKind field may be absent.
192
+ const auth = tool.workflow.authConfig;
193
+ if (tool.workflow.toolKind === 'authenticate' && !auth) {
194
+ const receipt = readAuthVerificationReceiptStatus(tool.dir, tool.workflow);
195
+ skippedInteractiveAuth.push({
196
+ name: tool.workflow.toolName,
197
+ reason: 'authentication configuration is missing, so unattended completion is not proven',
198
+ verificationStatus: receipt.status,
199
+ verificationReason: receipt.reason,
200
+ ...(receipt.action ? { verificationAction: receipt.action } : {}),
201
+ ...(receipt.backend ? { verificationBackend: receipt.backend } : {}),
202
+ ...(receipt.timestamp ? { verificationTimestamp: receipt.timestamp } : {}),
203
+ });
204
+ continue;
205
+ }
206
+ const pauses = auth
207
+ ? Object.entries(auth.actions).filter(([, action]) => action.outcome.type === 'pause')
208
+ : [];
209
+ if (pauses.length === 0) {
210
+ eligible.push(tool);
211
+ if (auth) nonInteractiveAuthNames.push(tool.workflow.toolName);
212
+ continue;
213
+ }
214
+
215
+ const receipt = readAuthVerificationReceiptStatus(tool.dir, tool.workflow);
216
+ const pauseSummary = pauses
217
+ .map(([name, action]) =>
218
+ action.outcome.type === 'pause' ? `${name}: ${action.outcome.message}` : name,
219
+ )
220
+ .join('; ');
221
+ skippedInteractiveAuth.push({
222
+ name: tool.workflow.toolName,
223
+ reason: `requires human continuation (${pauseSummary})`,
224
+ verificationStatus: receipt.status,
225
+ verificationReason: receipt.reason,
226
+ ...(receipt.action ? { verificationAction: receipt.action } : {}),
227
+ ...(receipt.backend ? { verificationBackend: receipt.backend } : {}),
228
+ ...(receipt.timestamp ? { verificationTimestamp: receipt.timestamp } : {}),
229
+ });
230
+ }
231
+
232
+ return { eligible, nonInteractiveAuthNames, skippedInteractiveAuth };
233
+ }
234
+
113
235
  /**
114
236
  * Restrict an agent-authored report to the connected workflows before it is
115
237
  * scored. Agent output is not a trusted inventory: it can repeat a tool or a
@@ -405,6 +527,24 @@ export function buildAuditMcpEnvironment(assetRoot = imprintHomeDir()): Record<s
405
527
  };
406
528
  }
407
529
 
530
+ export function buildAuditMcpCommandArgs(site: string, toolNames: readonly string[]): string[] {
531
+ return ['run', CLI_PATH, 'mcp-server', site, '--include-tools', toolNames.join(',')];
532
+ }
533
+
534
+ /** Normalize provider usage to OpenInference's total prompt-token contract. */
535
+ export function auditPromptTokens(
536
+ provider: ProviderName,
537
+ inputTokens: number,
538
+ cacheReadTokens: number,
539
+ cacheWriteTokens: number,
540
+ ): number {
541
+ // Codex reports input_tokens as the total and cached_input_tokens as a subset.
542
+ // Anthropic reports input_tokens as the uncached delta, with cache buckets
543
+ // alongside it, so only Anthropic usage needs summing.
544
+ if (provider === 'codex-cli') return inputTokens;
545
+ return totalPromptTokens(inputTokens, cacheReadTokens, cacheWriteTokens) ?? inputTokens;
546
+ }
547
+
408
548
  export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
409
549
  return await traced(
410
550
  'audit.session',
@@ -415,13 +555,27 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
415
555
  },
416
556
  async (span) => {
417
557
  const assetRoot = imprintHomeDir();
418
- const tools = await discoverTools(assetRoot, opts.site, '[imprint audit]');
419
- const toolCount = tools.length;
558
+ const discoveredTools = await discoverTools(assetRoot, opts.site, '[imprint audit]');
559
+ const toolCount = discoveredTools.length;
420
560
  if (toolCount === 0) {
421
561
  throw new Error(
422
562
  `No generated tool found for site "${opts.site}" — run \`imprint teach ${opts.site}\` first, then audit it.`,
423
563
  );
424
564
  }
565
+ const {
566
+ eligible: tools,
567
+ nonInteractiveAuthNames,
568
+ skippedInteractiveAuth,
569
+ } = partitionAuditTools(discoveredTools);
570
+ const auditedToolCount = tools.length;
571
+ const detectedButUnemitted = detectedButUnemittedTools(
572
+ loadTeachState(opts.site),
573
+ discoveredTools.map((tool) => tool.workflow.toolName),
574
+ );
575
+ const detectedToolCount = countDetectedToolNames(
576
+ discoveredTools.map((tool) => tool.workflow.toolName),
577
+ detectedButUnemitted,
578
+ );
425
579
 
426
580
  const provider = opts.provider ?? 'claude-cli';
427
581
  const model = opts.model ?? preferredAgentModel(provider);
@@ -433,18 +587,29 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
433
587
  );
434
588
  }
435
589
 
590
+ const nonInteractiveAuthSet = new Set(nonInteractiveAuthNames);
436
591
  const expectedTools = tools.map((tool) => ({
437
592
  name: tool.workflow.toolName,
438
- parameters: tool.workflow.parameters.map((parameter) => parameter.name),
593
+ parameters: nonInteractiveAuthSet.has(tool.workflow.toolName)
594
+ ? []
595
+ : tool.workflow.parameters.map((parameter) => parameter.name),
439
596
  }));
440
597
  const toolNames = expectedTools.map((tool) => tool.name);
441
- log(`auditing ${toolCount} tool(s) for site "${opts.site}": ${toolNames.join(', ')}`);
598
+ log(
599
+ `auditing ${auditedToolCount}/${toolCount} eligible tool(s) for site "${opts.site}": ${toolNames.join(', ') || '(none)'}`,
600
+ );
601
+ for (const skipped of skippedInteractiveAuth) {
602
+ log(
603
+ `skipping interactive auth ${skipped.name}: ${skipped.reason}; receipt=${skipped.verificationStatus}`,
604
+ );
605
+ }
442
606
 
443
607
  // Parameters that shipped live-unverified at compile time (Fix D). Tell the
444
608
  // auditor to probe them especially — these are the most likely to be broken
445
609
  // (the compile-time differential could not confirm their effect).
446
610
  const unverifiedParams: Array<{ tool: string; params: string[] }> = [];
447
611
  for (const t of tools) {
612
+ if (nonInteractiveAuthSet.has(t.workflow.toolName)) continue;
448
613
  const params = (t.workflow.parameters ?? [])
449
614
  .filter((p) => p.verified === false)
450
615
  .map((p) => p.name);
@@ -468,16 +633,45 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
468
633
  }
469
634
  }
470
635
 
471
- const drive = await driveAudit({
472
- site: opts.site,
473
- provider,
474
- model,
475
- timeoutMs,
476
- systemPromptPath,
477
- toolNames,
478
- unverifiedParams,
479
- tokenDeps,
480
- });
636
+ const drive =
637
+ auditedToolCount === 0
638
+ ? emptyDriveAuditResult()
639
+ : await driveAudit({
640
+ site: opts.site,
641
+ provider,
642
+ model,
643
+ timeoutMs,
644
+ systemPromptPath,
645
+ toolNames,
646
+ nonInteractiveAuthNames,
647
+ unverifiedParams,
648
+ tokenDeps,
649
+ });
650
+ if (auditedToolCount === 0) {
651
+ log('all discovered tools require human-interactive authentication; auditor not spawned');
652
+ }
653
+ recordLlmUsageSpan(
654
+ 'audit.llm_usage',
655
+ {
656
+ provider,
657
+ model,
658
+ inputTokens: auditPromptTokens(
659
+ provider,
660
+ drive.inputTokens,
661
+ drive.cacheReadInputTokens,
662
+ drive.cacheCreationInputTokens,
663
+ ),
664
+ outputTokens: drive.outputTokens,
665
+ cacheReadTokens: drive.cacheReadInputTokens,
666
+ cacheWriteTokens: drive.cacheCreationInputTokens,
667
+ },
668
+ {
669
+ 'imprint.audit.turns': drive.turns,
670
+ ...(drive.totalCostUsd != null
671
+ ? { 'imprint.audit.provider_reported_cost_usd': drive.totalCostUsd }
672
+ : {}),
673
+ },
674
+ );
481
675
 
482
676
  // Cross-reference compile-time live verification with the audit grade.
483
677
  // The downgrade rule's purpose is to surface "flying blind" runs —
@@ -537,15 +731,6 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
537
731
  }
538
732
  }
539
733
 
540
- // TOTAL prompt (uncached + cache) for the cost calc; the cache split is
541
- // passed to llmSpanAttributes separately. Always a number here
542
- // (drive.inputTokens is non-null), so the cost-suppression happens via the
543
- // `|| undefined` at the call site below.
544
- const totalInputTokens = totalPromptTokens(
545
- drive.inputTokens,
546
- drive.cacheReadInputTokens,
547
- drive.cacheCreationInputTokens,
548
- );
549
734
  setSpanAttributes(span, {
550
735
  'imprint.audit.score': score.score,
551
736
  'imprint.audit.correct': score.correct,
@@ -558,6 +743,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
558
743
  'imprint.audit.params_broken': score.paramsBroken,
559
744
  'imprint.audit.params_untestable': score.paramsUntestable,
560
745
  'imprint.audit.tool_count': toolCount,
746
+ 'imprint.audit.audited_tool_count': auditedToolCount,
747
+ 'imprint.audit.detected_tool_count': detectedToolCount,
748
+ 'imprint.audit.detected_but_unemitted_count': detectedButUnemitted.length,
749
+ 'imprint.audit.skipped_interactive_auth_count': skippedInteractiveAuth.length,
561
750
  'imprint.audit.verdict': score.verdict,
562
751
  'imprint.audit.unverified_and_ungradeable_count': unverifiedAndUngradeable.length,
563
752
  'imprint.audit.missing_tool_count': missingToolNames.length,
@@ -565,16 +754,6 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
565
754
  'imprint.audit.timed_out': drive.timedOut,
566
755
  'imprint.audit.turns': drive.turns,
567
756
  ...(drive.totalCostUsd != null ? { 'imprint.audit.cost_usd': drive.totalCostUsd } : {}),
568
- ...llmSpanAttributes({
569
- provider,
570
- model,
571
- // `|| undefined`: when no usage was captured (e.g. spawn failure → 0
572
- // tokens), suppress a bogus $0 cost instead of emitting it.
573
- inputTokens: totalInputTokens || undefined,
574
- outputTokens: drive.outputTokens || undefined,
575
- cacheReadTokens: drive.cacheReadInputTokens || undefined,
576
- cacheWriteTokens: drive.cacheCreationInputTokens || undefined,
577
- }),
578
757
  });
579
758
 
580
759
  // Persist the full result (deterministic score + the raw model report).
@@ -583,6 +762,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
583
762
  report: drive.report,
584
763
  site: opts.site,
585
764
  toolCount,
765
+ auditedToolCount,
766
+ detectedToolCount,
767
+ detectedButUnemittedTools: detectedButUnemitted,
768
+ skippedInteractiveAuth,
586
769
  ungradeableTools: ungradeableNames,
587
770
  /** Connected tools without an invocation record. A partial audit
588
771
  * cannot pass merely because the auditor named an uncalled tool. */
@@ -623,6 +806,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
623
806
  missingTools: missingToolNames,
624
807
  missingParameters,
625
808
  report: drive.report,
809
+ auditedToolCount,
810
+ skippedInteractiveAuth,
811
+ detectedToolCount,
812
+ detectedButUnemittedTools: detectedButUnemitted,
626
813
  });
627
814
  }
628
815
 
@@ -661,6 +848,9 @@ interface DriveAuditOptions {
661
848
  timeoutMs: number;
662
849
  systemPromptPath: string;
663
850
  toolNames: string[];
851
+ /** Eligible password-only/direct-success auth tools. Interactive auth tools
852
+ * are filtered before the MCP session is created and never appear here. */
853
+ nonInteractiveAuthNames?: string[];
664
854
  /** Per-tool params that shipped live-unverified at compile time. */
665
855
  unverifiedParams: Array<{ tool: string; params: string[] }>;
666
856
  /** Producer→consumer token contracts (param.sourcedFrom) so the auditor chains. */
@@ -723,17 +913,18 @@ async function driveAuditWithClaude(opts: DriveAuditOptions): Promise<DriveAudit
723
913
  // with zero tools. The `imprint-audit-` prefix keeps the inline server unique.
724
914
  const serverName = `imprint-audit-${opts.site}`;
725
915
  const bunPath = process.execPath;
916
+ const mcpEnv = buildAuditMcpEnvironment();
726
917
  const mcpConfig = {
727
918
  mcpServers: {
728
919
  [serverName]: {
729
920
  command: bunPath,
730
- args: ['run', CLI_PATH, 'mcp-server', opts.site],
921
+ args: buildAuditMcpCommandArgs(opts.site, opts.toolNames),
731
922
  // Pace every audit tool call: the auditor now differentially probes
732
923
  // bot-defended idempotent reads (search/calendar) instead of bailing
733
924
  // after one call, so a deliberate inter-call delay keeps the probing
734
925
  // steady enough that the per-IP anti-bot defense isn't tripped. Only
735
926
  // the audit sets this; production mcp-server runs unpaced.
736
- env: { IMPRINT_AUDIT_PACING_MS: '5000', IMPRINT_AUDIT_TOOL_DEADLINE_MS: '120000' },
927
+ env: mcpEnv,
737
928
  },
738
929
  },
739
930
  };
@@ -829,7 +1020,7 @@ async function driveAuditWithClaude(opts: DriveAuditOptions): Promise<DriveAudit
829
1020
  async function driveAuditWithCodex(opts: DriveAuditOptions): Promise<DriveAuditResult> {
830
1021
  const serverName = `imprint-audit-${opts.site}`;
831
1022
  const bunPath = process.execPath;
832
- const mcpArgs = ['run', CLI_PATH, 'mcp-server', opts.site];
1023
+ const mcpArgs = buildAuditMcpCommandArgs(opts.site, opts.toolNames);
833
1024
  const mcpEnv = buildAuditMcpEnvironment();
834
1025
 
835
1026
  const unverifiedNote = buildUnverifiedParamNote(opts.unverifiedParams);
@@ -919,7 +1110,7 @@ ${buildAuditInitialPrompt(opts, unverifiedNote)}`;
919
1110
  transcript: session.transcript,
920
1111
  inputTokens: session.inputTokens,
921
1112
  outputTokens: session.outputTokens,
922
- cacheReadInputTokens: 0,
1113
+ cacheReadInputTokens: session.cacheReadInputTokens,
923
1114
  cacheCreationInputTokens: 0,
924
1115
  totalCostUsd: null,
925
1116
  };
@@ -937,6 +1128,10 @@ export function buildUnverifiedParamNote(
937
1128
  }
938
1129
 
939
1130
  export function buildAuditInitialPrompt(opts: DriveAuditOptions, unverifiedNote: string): string {
1131
+ const nonInteractiveAuthNames = opts.nonInteractiveAuthNames ?? [];
1132
+ const authNote = nonInteractiveAuthNames.length
1133
+ ? `\n\nThe following authentication tool(s) are eligible because every action completes without a human pause: ${nonInteractiveAuthNames.join(', ')}. Call each exactly once with one safe baseline. Never differentially probe or retry authentication actions. If any DATA tool returns AUTH_EXPIRED, classify that invocation as infra/inconclusive evidence; do not call an authentication tool in response.`
1134
+ : '\n\nNo authentication tool is eligible in this audit. If a DATA tool returns AUTH_EXPIRED, classify that invocation as infra/inconclusive evidence; do not attempt to authenticate.';
940
1135
  return `Audit every MCP tool connected to you for the site "${opts.site}".
941
1136
 
942
1137
  There are ${opts.toolNames.length} connected tool(s). For each one: read its description and input schema, invoke it with a realistic parameter set, judge the result, and classify each invocation as correct | tool_broken | infra | bad_params per your system prompt.
@@ -945,7 +1140,7 @@ Parameter coverage is mandatory for idempotent read tools. A cold cdp-replay/bro
945
1140
 
946
1141
  Promo/coupon/voucher/discount-code parameters require a known valid code to prove a visible effect. If neither the schema nor another tool output gives you a valid code, classify that parameter as untestable; do not invent a random code and call an unchanged response no_op.
947
1142
 
948
- IMPORTANT: Call tools strictly sequentially — issue exactly one tool call, wait for its result, then issue the next. Never issue tool calls in parallel or batch them in one turn. Many target sites share an anti-bot defense across endpoints, so a parallel burst trips a site-wide rate-limit (HTTP 429) that then poisons every later call. If a call returns a 429 / rate-limit / anti-bot result, classify it \`infra\` and pause before the next call.${unverifiedNote}${buildTokenDepNote(opts.tokenDeps)}
1143
+ IMPORTANT: Call tools strictly sequentially — issue exactly one tool call, wait for its result, then issue the next. Never issue tool calls in parallel or batch them in one turn. Many target sites share an anti-bot defense across endpoints, so a parallel burst trips a site-wide rate-limit (HTTP 429) that then poisons every later call. If a call returns a 429 / rate-limit / anti-bot result, classify it \`infra\` and pause before the next call.${authNote}${unverifiedNote}${buildTokenDepNote(opts.tokenDeps)}
949
1144
 
950
1145
  When you are done, end your final message with exactly one fenced \`\`\`json block containing the full report and nothing after it.`;
951
1146
  }
@@ -1130,6 +1325,7 @@ async function collectCodexAssistantText(
1130
1325
  let turns = 0;
1131
1326
  let inputTokens = 0;
1132
1327
  let outputTokens = 0;
1328
+ let cacheReadInputTokens = 0;
1133
1329
  const t0 = Date.now();
1134
1330
  const elapsedStr = (): string => {
1135
1331
  const s = Math.floor((Date.now() - t0) / 1000);
@@ -1179,6 +1375,7 @@ async function collectCodexAssistantText(
1179
1375
  if (evt.type === 'turn.completed') {
1180
1376
  inputTokens += evt.usage?.input_tokens ?? 0;
1181
1377
  outputTokens += evt.usage?.output_tokens ?? 0;
1378
+ cacheReadInputTokens += evt.usage?.cached_input_tokens ?? 0;
1182
1379
  log(`[${elapsedStr()}] turn ${turns} completed`);
1183
1380
  continue;
1184
1381
  }
@@ -1233,7 +1430,7 @@ async function collectCodexAssistantText(
1233
1430
  turns,
1234
1431
  inputTokens,
1235
1432
  outputTokens,
1236
- cacheReadInputTokens: 0,
1433
+ cacheReadInputTokens,
1237
1434
  cacheCreationInputTokens: 0,
1238
1435
  totalCostUsd: null,
1239
1436
  };
@@ -1283,6 +1480,8 @@ interface CodexStreamJsonEvent {
1283
1480
  usage?: {
1284
1481
  input_tokens?: number;
1285
1482
  output_tokens?: number;
1483
+ cached_input_tokens?: number;
1484
+ reasoning_output_tokens?: number;
1286
1485
  };
1287
1486
  message?: string;
1288
1487
  error?: { message?: string };
@@ -1391,6 +1590,10 @@ function printSummary(
1391
1590
  missingTools: string[];
1392
1591
  missingParameters: MissingAuditedParameter[];
1393
1592
  report: AuditReport;
1593
+ auditedToolCount: number;
1594
+ skippedInteractiveAuth: SkippedInteractiveAuth[];
1595
+ detectedToolCount: number;
1596
+ detectedButUnemittedTools: DetectedButUnemittedTool[];
1394
1597
  },
1395
1598
  ): void {
1396
1599
  const pct = score.graded === 0 ? 'n/a' : `${score.score.toFixed(1)}%`;
@@ -1404,8 +1607,18 @@ function printSummary(
1404
1607
  const invGraded = score.graded - paramsTested;
1405
1608
  const invTotal = invGraded + score.infra + score.badParams;
1406
1609
  console.log(
1407
- `[imprint] graded ${score.graded} unit(s) = ${invGraded}/${invTotal} invocation(s) + ${paramsTested} parameter(s) across ${toolCount} tool(s) — excluded: ${score.infra} infra, ${score.badParams} bad_params, ${score.paramsUntestable} untestable param(s)`,
1610
+ `[imprint] graded ${score.graded} unit(s) = ${invGraded}/${invTotal} invocation(s) + ${paramsTested} parameter(s) across ${extra.auditedToolCount}/${toolCount} eligible tool(s) — excluded: ${score.infra} infra, ${score.badParams} bad_params, ${score.paramsUntestable} untestable param(s)`,
1408
1611
  );
1612
+ for (const skipped of extra.skippedInteractiveAuth) {
1613
+ console.log(
1614
+ `[imprint] skipped interactive auth ${skipped.name} — ${skipped.reason}; verification receipt: ${skipped.verificationStatus}`,
1615
+ );
1616
+ }
1617
+ if (extra.detectedButUnemittedTools.length > 0) {
1618
+ console.log(
1619
+ `[imprint] ${extra.detectedButUnemittedTools.length}/${extra.detectedToolCount} detected tool(s) were not emitted and are excluded from scoring: ${extra.detectedButUnemittedTools.map((tool) => tool.name).join(', ')}`,
1620
+ );
1621
+ }
1409
1622
  if (paramsTested + score.paramsUntestable > 0) {
1410
1623
  console.log(
1411
1624
  `[imprint] parameters: ${score.paramsWorking}/${paramsTested} working — ${score.paramsNoOp} no-op, ${score.paramsBroken} broken, ${score.paramsUntestable} untestable`,
@@ -362,30 +362,81 @@ export function authWorkflowHash(workflow: Workflow): string {
362
362
  return createHash('sha256').update(JSON.stringify(workflow)).digest('hex');
363
363
  }
364
364
 
365
- function liveAttemptFailures(toolDir: string, workflow: Workflow): string[] {
365
+ export interface AuthVerificationReceiptStatus {
366
+ status: 'verified' | 'stale' | 'failed' | 'missing';
367
+ reason: string;
368
+ action?: string;
369
+ backend?: string;
370
+ timestamp?: number;
371
+ }
372
+
373
+ /** Read the latest auth verification receipt without initiating authentication.
374
+ * The workflow hash is authoritative: a successful receipt for older generated
375
+ * code is diagnostic history, not current verification evidence. */
376
+ export function readAuthVerificationReceiptStatus(
377
+ toolDir: string,
378
+ workflow: Workflow,
379
+ ): AuthVerificationReceiptStatus {
366
380
  const path = pathJoin(toolDir, AUTH_VERIFICATION_ATTEMPT_SENTINEL);
367
381
  if (!existsSync(path)) {
368
- return ['no live auth verification attempt was recorded; call run_verification before done'];
382
+ return {
383
+ status: 'missing',
384
+ reason: 'no live auth verification attempt was recorded',
385
+ };
369
386
  }
370
387
 
371
- let attempt: { action?: unknown; ok?: unknown; workflowHash?: unknown };
388
+ let attempt: {
389
+ action?: unknown;
390
+ ok?: unknown;
391
+ workflowHash?: unknown;
392
+ backend?: unknown;
393
+ timestamp?: unknown;
394
+ };
372
395
  try {
373
396
  attempt = JSON.parse(readFileSync(path, 'utf8')) as typeof attempt;
374
397
  } catch (err) {
375
- return [
376
- `live auth verification record is invalid: ${err instanceof Error ? err.message : String(err)}`,
377
- ];
398
+ return {
399
+ status: 'failed',
400
+ reason: `live auth verification record is invalid: ${err instanceof Error ? err.message : String(err)}`,
401
+ };
378
402
  }
403
+ const details = {
404
+ ...(typeof attempt.action === 'string' ? { action: attempt.action } : {}),
405
+ ...(typeof attempt.backend === 'string' ? { backend: attempt.backend } : {}),
406
+ ...(typeof attempt.timestamp === 'number' ? { timestamp: attempt.timestamp } : {}),
407
+ };
379
408
  if (attempt.ok !== true || typeof attempt.action !== 'string') {
380
- return ['the most recent live auth verification did not succeed'];
409
+ return {
410
+ status: 'failed',
411
+ reason: 'the most recent live auth verification did not succeed',
412
+ ...details,
413
+ };
381
414
  }
382
415
  if (attempt.workflowHash !== authWorkflowHash(workflow)) {
383
- return ['workflow.json changed after the most recent successful live auth verification'];
416
+ return {
417
+ status: 'stale',
418
+ reason: 'workflow.json changed after the most recent successful live auth verification',
419
+ ...details,
420
+ };
384
421
  }
385
422
  const action = workflow.authConfig?.actions[attempt.action];
386
- return action?.outcome.type === 'success'
387
- ? []
388
- : [
389
- `the most recent successful live action ${JSON.stringify(attempt.action)} does not declare a success outcome`,
390
- ];
423
+ if (action?.outcome.type !== 'success') {
424
+ return {
425
+ status: 'failed',
426
+ reason: `the most recent successful live action ${JSON.stringify(attempt.action)} does not declare a success outcome`,
427
+ ...details,
428
+ };
429
+ }
430
+ return {
431
+ status: 'verified',
432
+ reason: `live auth verification succeeded with action ${JSON.stringify(attempt.action)}`,
433
+ ...details,
434
+ };
435
+ }
436
+
437
+ function liveAttemptFailures(toolDir: string, workflow: Workflow): string[] {
438
+ const receipt = readAuthVerificationReceiptStatus(toolDir, workflow);
439
+ if (receipt.status === 'verified') return [];
440
+ const suffix = receipt.status === 'missing' ? '; call run_verification before done' : '';
441
+ return [`${receipt.reason}${suffix}`];
391
442
  }
@@ -43,7 +43,7 @@ import { COMPILE_SENTINELS, compileDeadlineAfterVerification } from './mcp-compi
43
43
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
44
44
  import {
45
45
  endTraceSpan,
46
- llmSpanAttributes,
46
+ recordLlmUsageSpan,
47
47
  setSpanAttributes,
48
48
  startTraceSpan,
49
49
  totalPromptTokens,
@@ -88,6 +88,28 @@ const USAGE_POLICY_REFUSAL =
88
88
  /** Total attempts (1 initial + retries) when a usage-policy refusal is hit. */
89
89
  const MAX_USAGE_POLICY_ATTEMPTS = 3;
90
90
 
91
+ interface CompileUsageTotals {
92
+ turns: number;
93
+ inputTokens: number;
94
+ outputTokens: number;
95
+ cacheReadInputTokens: number;
96
+ cacheCreationInputTokens: number;
97
+ }
98
+
99
+ /** Add one paid CLI attempt to the aggregate usage reported for the compile. */
100
+ export function addCompileUsageTotals(
101
+ totals: CompileUsageTotals,
102
+ attempt: CompileUsageTotals,
103
+ ): CompileUsageTotals {
104
+ return {
105
+ turns: totals.turns + attempt.turns,
106
+ inputTokens: totals.inputTokens + attempt.inputTokens,
107
+ outputTokens: totals.outputTokens + attempt.outputTokens,
108
+ cacheReadInputTokens: totals.cacheReadInputTokens + attempt.cacheReadInputTokens,
109
+ cacheCreationInputTokens: totals.cacheCreationInputTokens + attempt.cacheCreationInputTokens,
110
+ };
111
+ }
112
+
91
113
  /** Exponential backoff with jitter between refusal retries. Spacing matters:
92
114
  * bursts of near-identical requests raise the safety-filter trip rate. */
93
115
  function usagePolicyBackoffMs(attempt: number): number {
@@ -212,6 +234,7 @@ export async function compileViaClaudeCli(
212
234
  },
213
235
  async (span) => {
214
236
  const result = await compileViaClaudeCliImpl(opts);
237
+ const model = opts.model ?? preferredAgentModel('claude-cli');
215
238
  setSpanAttributes(span, {
216
239
  'imprint.compile.outcome': result.outcome,
217
240
  'imprint.compile.turns': result.turns,
@@ -220,9 +243,12 @@ export async function compileViaClaudeCli(
220
243
  'imprint.compile.output_tokens': result.outputTokens,
221
244
  'imprint.compile.cache_read_input_tokens': result.cacheReadInputTokens,
222
245
  'imprint.compile.cache_creation_input_tokens': result.cacheCreationInputTokens,
223
- ...llmSpanAttributes({
246
+ });
247
+ recordLlmUsageSpan(
248
+ 'compile.claude_cli_usage',
249
+ {
224
250
  provider: 'claude-cli',
225
- model: opts.model ?? preferredAgentModel('claude-cli'),
251
+ model,
226
252
  // TOTAL prompt (uncached + cache); the cache split is passed separately
227
253
  // for cost. `result.inputTokens` alone is the uncached delta (often a
228
254
  // few hundred), which would mislabel `llm.token_count.prompt`.
@@ -234,8 +260,9 @@ export async function compileViaClaudeCli(
234
260
  outputTokens: result.outputTokens,
235
261
  cacheReadTokens: result.cacheReadInputTokens,
236
262
  cacheWriteTokens: result.cacheCreationInputTokens,
237
- }),
238
- });
263
+ },
264
+ { 'imprint.compile.turns': result.turns },
265
+ );
239
266
  return result;
240
267
  },
241
268
  );
@@ -251,11 +278,20 @@ async function compileViaClaudeCliImpl(
251
278
  opts: CompileViaClaudeCliOptions,
252
279
  ): Promise<CompileAgentResult> {
253
280
  let lastResult: CompileAgentResult | undefined;
281
+ let usageTotals: CompileUsageTotals = {
282
+ turns: 0,
283
+ inputTokens: 0,
284
+ outputTokens: 0,
285
+ cacheReadInputTokens: 0,
286
+ cacheCreationInputTokens: 0,
287
+ };
254
288
  for (let attempt = 1; attempt <= MAX_USAGE_POLICY_ATTEMPTS; attempt++) {
255
289
  const result = await runClaudeCliAttempt(opts);
290
+ usageTotals = addCompileUsageTotals(usageTotals, result);
291
+ const resultWithAggregateUsage = { ...result, ...usageTotals };
256
292
  const isRefusal = !result.success && USAGE_POLICY_REFUSAL.test(result.message ?? '');
257
- if (!isRefusal) return result;
258
- lastResult = result;
293
+ if (!isRefusal) return resultWithAggregateUsage;
294
+ lastResult = resultWithAggregateUsage;
259
295
  if (attempt < MAX_USAGE_POLICY_ATTEMPTS) {
260
296
  const backoffMs = usagePolicyBackoffMs(attempt);
261
297
  log(