imprint-mcp 0.6.2 → 0.6.3

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,10 +17,15 @@ 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';
27
+ import { type TeachState, loadTeachState } from './teach-state.ts';
28
+ import { type ResolvedTool, discoverTools } from './tool-loader.ts';
24
29
  import { llmSpanAttributes, setSpanAttributes, totalPromptTokens, traced } from './tracing.ts';
25
30
 
26
31
  const log = createLog('audit');
@@ -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,10 @@ 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
+
408
534
  export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
409
535
  return await traced(
410
536
  'audit.session',
@@ -415,13 +541,27 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
415
541
  },
416
542
  async (span) => {
417
543
  const assetRoot = imprintHomeDir();
418
- const tools = await discoverTools(assetRoot, opts.site, '[imprint audit]');
419
- const toolCount = tools.length;
544
+ const discoveredTools = await discoverTools(assetRoot, opts.site, '[imprint audit]');
545
+ const toolCount = discoveredTools.length;
420
546
  if (toolCount === 0) {
421
547
  throw new Error(
422
548
  `No generated tool found for site "${opts.site}" — run \`imprint teach ${opts.site}\` first, then audit it.`,
423
549
  );
424
550
  }
551
+ const {
552
+ eligible: tools,
553
+ nonInteractiveAuthNames,
554
+ skippedInteractiveAuth,
555
+ } = partitionAuditTools(discoveredTools);
556
+ const auditedToolCount = tools.length;
557
+ const detectedButUnemitted = detectedButUnemittedTools(
558
+ loadTeachState(opts.site),
559
+ discoveredTools.map((tool) => tool.workflow.toolName),
560
+ );
561
+ const detectedToolCount = countDetectedToolNames(
562
+ discoveredTools.map((tool) => tool.workflow.toolName),
563
+ detectedButUnemitted,
564
+ );
425
565
 
426
566
  const provider = opts.provider ?? 'claude-cli';
427
567
  const model = opts.model ?? preferredAgentModel(provider);
@@ -433,18 +573,29 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
433
573
  );
434
574
  }
435
575
 
576
+ const nonInteractiveAuthSet = new Set(nonInteractiveAuthNames);
436
577
  const expectedTools = tools.map((tool) => ({
437
578
  name: tool.workflow.toolName,
438
- parameters: tool.workflow.parameters.map((parameter) => parameter.name),
579
+ parameters: nonInteractiveAuthSet.has(tool.workflow.toolName)
580
+ ? []
581
+ : tool.workflow.parameters.map((parameter) => parameter.name),
439
582
  }));
440
583
  const toolNames = expectedTools.map((tool) => tool.name);
441
- log(`auditing ${toolCount} tool(s) for site "${opts.site}": ${toolNames.join(', ')}`);
584
+ log(
585
+ `auditing ${auditedToolCount}/${toolCount} eligible tool(s) for site "${opts.site}": ${toolNames.join(', ') || '(none)'}`,
586
+ );
587
+ for (const skipped of skippedInteractiveAuth) {
588
+ log(
589
+ `skipping interactive auth ${skipped.name}: ${skipped.reason}; receipt=${skipped.verificationStatus}`,
590
+ );
591
+ }
442
592
 
443
593
  // Parameters that shipped live-unverified at compile time (Fix D). Tell the
444
594
  // auditor to probe them especially — these are the most likely to be broken
445
595
  // (the compile-time differential could not confirm their effect).
446
596
  const unverifiedParams: Array<{ tool: string; params: string[] }> = [];
447
597
  for (const t of tools) {
598
+ if (nonInteractiveAuthSet.has(t.workflow.toolName)) continue;
448
599
  const params = (t.workflow.parameters ?? [])
449
600
  .filter((p) => p.verified === false)
450
601
  .map((p) => p.name);
@@ -468,16 +619,23 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
468
619
  }
469
620
  }
470
621
 
471
- const drive = await driveAudit({
472
- site: opts.site,
473
- provider,
474
- model,
475
- timeoutMs,
476
- systemPromptPath,
477
- toolNames,
478
- unverifiedParams,
479
- tokenDeps,
480
- });
622
+ const drive =
623
+ auditedToolCount === 0
624
+ ? emptyDriveAuditResult()
625
+ : await driveAudit({
626
+ site: opts.site,
627
+ provider,
628
+ model,
629
+ timeoutMs,
630
+ systemPromptPath,
631
+ toolNames,
632
+ nonInteractiveAuthNames,
633
+ unverifiedParams,
634
+ tokenDeps,
635
+ });
636
+ if (auditedToolCount === 0) {
637
+ log('all discovered tools require human-interactive authentication; auditor not spawned');
638
+ }
481
639
 
482
640
  // Cross-reference compile-time live verification with the audit grade.
483
641
  // The downgrade rule's purpose is to surface "flying blind" runs —
@@ -558,6 +716,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
558
716
  'imprint.audit.params_broken': score.paramsBroken,
559
717
  'imprint.audit.params_untestable': score.paramsUntestable,
560
718
  'imprint.audit.tool_count': toolCount,
719
+ 'imprint.audit.audited_tool_count': auditedToolCount,
720
+ 'imprint.audit.detected_tool_count': detectedToolCount,
721
+ 'imprint.audit.detected_but_unemitted_count': detectedButUnemitted.length,
722
+ 'imprint.audit.skipped_interactive_auth_count': skippedInteractiveAuth.length,
561
723
  'imprint.audit.verdict': score.verdict,
562
724
  'imprint.audit.unverified_and_ungradeable_count': unverifiedAndUngradeable.length,
563
725
  'imprint.audit.missing_tool_count': missingToolNames.length,
@@ -583,6 +745,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
583
745
  report: drive.report,
584
746
  site: opts.site,
585
747
  toolCount,
748
+ auditedToolCount,
749
+ detectedToolCount,
750
+ detectedButUnemittedTools: detectedButUnemitted,
751
+ skippedInteractiveAuth,
586
752
  ungradeableTools: ungradeableNames,
587
753
  /** Connected tools without an invocation record. A partial audit
588
754
  * cannot pass merely because the auditor named an uncalled tool. */
@@ -623,6 +789,10 @@ export async function runAudit(opts: RunAuditOptions): Promise<AuditScore> {
623
789
  missingTools: missingToolNames,
624
790
  missingParameters,
625
791
  report: drive.report,
792
+ auditedToolCount,
793
+ skippedInteractiveAuth,
794
+ detectedToolCount,
795
+ detectedButUnemittedTools: detectedButUnemitted,
626
796
  });
627
797
  }
628
798
 
@@ -661,6 +831,9 @@ interface DriveAuditOptions {
661
831
  timeoutMs: number;
662
832
  systemPromptPath: string;
663
833
  toolNames: string[];
834
+ /** Eligible password-only/direct-success auth tools. Interactive auth tools
835
+ * are filtered before the MCP session is created and never appear here. */
836
+ nonInteractiveAuthNames?: string[];
664
837
  /** Per-tool params that shipped live-unverified at compile time. */
665
838
  unverifiedParams: Array<{ tool: string; params: string[] }>;
666
839
  /** Producer→consumer token contracts (param.sourcedFrom) so the auditor chains. */
@@ -723,17 +896,18 @@ async function driveAuditWithClaude(opts: DriveAuditOptions): Promise<DriveAudit
723
896
  // with zero tools. The `imprint-audit-` prefix keeps the inline server unique.
724
897
  const serverName = `imprint-audit-${opts.site}`;
725
898
  const bunPath = process.execPath;
899
+ const mcpEnv = buildAuditMcpEnvironment();
726
900
  const mcpConfig = {
727
901
  mcpServers: {
728
902
  [serverName]: {
729
903
  command: bunPath,
730
- args: ['run', CLI_PATH, 'mcp-server', opts.site],
904
+ args: buildAuditMcpCommandArgs(opts.site, opts.toolNames),
731
905
  // Pace every audit tool call: the auditor now differentially probes
732
906
  // bot-defended idempotent reads (search/calendar) instead of bailing
733
907
  // after one call, so a deliberate inter-call delay keeps the probing
734
908
  // steady enough that the per-IP anti-bot defense isn't tripped. Only
735
909
  // the audit sets this; production mcp-server runs unpaced.
736
- env: { IMPRINT_AUDIT_PACING_MS: '5000', IMPRINT_AUDIT_TOOL_DEADLINE_MS: '120000' },
910
+ env: mcpEnv,
737
911
  },
738
912
  },
739
913
  };
@@ -829,7 +1003,7 @@ async function driveAuditWithClaude(opts: DriveAuditOptions): Promise<DriveAudit
829
1003
  async function driveAuditWithCodex(opts: DriveAuditOptions): Promise<DriveAuditResult> {
830
1004
  const serverName = `imprint-audit-${opts.site}`;
831
1005
  const bunPath = process.execPath;
832
- const mcpArgs = ['run', CLI_PATH, 'mcp-server', opts.site];
1006
+ const mcpArgs = buildAuditMcpCommandArgs(opts.site, opts.toolNames);
833
1007
  const mcpEnv = buildAuditMcpEnvironment();
834
1008
 
835
1009
  const unverifiedNote = buildUnverifiedParamNote(opts.unverifiedParams);
@@ -937,6 +1111,10 @@ export function buildUnverifiedParamNote(
937
1111
  }
938
1112
 
939
1113
  export function buildAuditInitialPrompt(opts: DriveAuditOptions, unverifiedNote: string): string {
1114
+ const nonInteractiveAuthNames = opts.nonInteractiveAuthNames ?? [];
1115
+ const authNote = nonInteractiveAuthNames.length
1116
+ ? `\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.`
1117
+ : '\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
1118
  return `Audit every MCP tool connected to you for the site "${opts.site}".
941
1119
 
942
1120
  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 +1123,7 @@ Parameter coverage is mandatory for idempotent read tools. A cold cdp-replay/bro
945
1123
 
946
1124
  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
1125
 
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)}
1126
+ 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
1127
 
950
1128
  When you are done, end your final message with exactly one fenced \`\`\`json block containing the full report and nothing after it.`;
951
1129
  }
@@ -1391,6 +1569,10 @@ function printSummary(
1391
1569
  missingTools: string[];
1392
1570
  missingParameters: MissingAuditedParameter[];
1393
1571
  report: AuditReport;
1572
+ auditedToolCount: number;
1573
+ skippedInteractiveAuth: SkippedInteractiveAuth[];
1574
+ detectedToolCount: number;
1575
+ detectedButUnemittedTools: DetectedButUnemittedTool[];
1394
1576
  },
1395
1577
  ): void {
1396
1578
  const pct = score.graded === 0 ? 'n/a' : `${score.score.toFixed(1)}%`;
@@ -1404,8 +1586,18 @@ function printSummary(
1404
1586
  const invGraded = score.graded - paramsTested;
1405
1587
  const invTotal = invGraded + score.infra + score.badParams;
1406
1588
  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)`,
1589
+ `[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
1590
  );
1591
+ for (const skipped of extra.skippedInteractiveAuth) {
1592
+ console.log(
1593
+ `[imprint] skipped interactive auth ${skipped.name} — ${skipped.reason}; verification receipt: ${skipped.verificationStatus}`,
1594
+ );
1595
+ }
1596
+ if (extra.detectedButUnemittedTools.length > 0) {
1597
+ console.log(
1598
+ `[imprint] ${extra.detectedButUnemittedTools.length}/${extra.detectedToolCount} detected tool(s) were not emitted and are excluded from scoring: ${extra.detectedButUnemittedTools.map((tool) => tool.name).join(', ')}`,
1599
+ );
1600
+ }
1409
1601
  if (paramsTested + score.paramsUntestable > 0) {
1410
1602
  console.log(
1411
1603
  `[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
  }
@@ -10,6 +10,29 @@ import type { AgentProgress } from './agent.ts';
10
10
  import { type AssignedSharedModule, describeAssignedModules } from './build-plan.ts';
11
11
  import type { SharedCompileContext, ToolCandidate } from './tool-candidates.ts';
12
12
 
13
+ /** Only an independent semantic-verifier result consumes the bounded review
14
+ * budget. Deterministic preflight failures remain deadline-bounded but do not
15
+ * spend a compiler↔reviewer iteration. */
16
+ export function advanceSemanticVerificationCycle(
17
+ current: number,
18
+ semanticReviewCompleted: boolean,
19
+ ): number {
20
+ return semanticReviewCompleted ? current + 1 : current;
21
+ }
22
+
23
+ /** Bound verifier infrastructure failures separately from semantic review
24
+ * cycles. One verifier invocation already includes its own provider retry; a
25
+ * second failed invocation gives the compiler one chance to react without
26
+ * allowing a missing provider to extend the compile deadline forever. */
27
+ export function advanceIncompleteSemanticVerificationRuns(
28
+ current: number,
29
+ semanticReviewAttempted: boolean,
30
+ semanticReviewCompleted: boolean,
31
+ ): number {
32
+ if (semanticReviewCompleted) return 0;
33
+ return semanticReviewAttempted ? current + 1 : current;
34
+ }
35
+
13
36
  /** Render a per-tool implementation plan (param→field mapping, request
14
37
  * construction, response parsing, shared-module imports, edge cases) into an
15
38
  * initial-message section the compile agent must follow. Shared verbatim by the
@@ -47,8 +70,8 @@ Compile only the selected candidate. Do not create tools for other actions in th
47
70
  }
48
71
 
49
72
  export interface CompileAgentProgress extends AgentProgress {
50
- /** 1-based verification cycle. Cycle 1 is the initial agent run. Subsequent cycles
51
- * happen when the agent claims done() but external verification fails. */
73
+ /** 1-based semantic-review cycle. Deterministic preflight retries leave this
74
+ * value unchanged. */
52
75
  verificationCycle: number;
53
76
  /** Verification cap for bounded data-tool compiles; auth is deadline-bounded. */
54
77
  maxVerificationCycles?: number;