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.
@@ -11,6 +11,7 @@ import { join as pathJoin } from 'node:path';
11
11
  import {
12
12
  type AgentProgress,
13
13
  type AgentResult,
14
+ type AgentTool,
14
15
  type OnDeadlineReached,
15
16
  doneTool,
16
17
  giveUpTool,
@@ -19,7 +20,12 @@ import {
19
20
  import { type SharedModuleManifestEntry, resolvePlanSliceFromFile } from './build-plan.ts';
20
21
  import { compileViaClaudeCli } from './claude-cli-compile.ts';
21
22
  import { compileViaCodexCli } from './codex-cli-compile.ts';
22
- import type { CompileAgentProgress, CompileAgentResult } from './compile-agent-types.ts';
23
+ import {
24
+ type CompileAgentProgress,
25
+ type CompileAgentResult,
26
+ advanceIncompleteSemanticVerificationRuns,
27
+ advanceSemanticVerificationCycle,
28
+ } from './compile-agent-types.ts';
23
29
  import { formatCandidateContext, formatToolPlan } from './compile-agent-types.ts';
24
30
  import {
25
31
  applyLiveVerification,
@@ -29,11 +35,13 @@ import {
29
35
  } from './compile-tools.ts';
30
36
  import { type Replacement, extractCredentials } from './credential-extract.ts';
31
37
  import {
38
+ isInfrastructureOnlyInconclusiveReport,
32
39
  mergeSemanticParamVerification,
33
40
  runLiveSemanticVerification,
34
41
  semanticVerificationFailures,
35
42
  } from './live-verifier.ts';
36
43
  import {
44
+ DEFAULT_VERIFICATION_PROVIDER,
37
45
  type LLMOptions,
38
46
  type ProviderName,
39
47
  type ToolUseProvider,
@@ -43,6 +51,11 @@ import {
43
51
  } from './llm.ts';
44
52
  import { loadJsonFile } from './load-json.ts';
45
53
  import { createLog } from './log.ts';
54
+ import {
55
+ canAcceptInconclusiveDecision,
56
+ compileArtifactFingerprint,
57
+ inconclusiveDecisionAtSemanticCap,
58
+ } from './mcp-compile-server.ts';
46
59
  import { localSiteDir } from './paths.ts';
47
60
  import { rebindExistingBackendsCacheToWorkflow } from './probe-backends.ts';
48
61
  import { detectPageMintedHeaders, redactSession } from './redact.ts';
@@ -57,6 +70,31 @@ const log = createLog('compile-agent');
57
70
  const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
58
71
  const PROMPTS_DIR = pathJoin(REPO_ROOT, 'prompts');
59
72
 
73
+ function compileDoneTool(): AgentTool {
74
+ const tool = doneTool();
75
+ return {
76
+ ...tool,
77
+ description:
78
+ 'Call this when compilation is complete. After an infrastructure-only inconclusive live review, call it again on unchanged artifacts with an explicit acceptance decision and reasoning.',
79
+ input_schema: {
80
+ ...tool.input_schema,
81
+ properties: {
82
+ ...tool.input_schema.properties,
83
+ accept_inconclusive: {
84
+ type: 'boolean',
85
+ description:
86
+ 'Explicitly ship an unchanged deterministic artifact after an infrastructure-only inconclusive live review.',
87
+ },
88
+ inconclusive_reason: {
89
+ type: 'string',
90
+ description:
91
+ 'Concrete compiler reasoning for accepting the unchanged artifact without a liveVerified stamp.',
92
+ },
93
+ },
94
+ },
95
+ };
96
+ }
97
+
60
98
  let claudeCliCompiler = compileViaClaudeCli;
61
99
  let codexCliCompiler = compileViaCodexCli;
62
100
 
@@ -237,7 +275,7 @@ export async function compileAgent(opts: CompileAgentOptions): Promise<CompileAg
237
275
  sharedModules: opts.sharedModules,
238
276
  revisionMode: opts.revisionMode,
239
277
  }),
240
- doneTool(),
278
+ compileDoneTool(),
241
279
  giveUpTool(),
242
280
  ];
243
281
 
@@ -327,12 +365,20 @@ Begin by calling read_session_summary to orient yourself, then proceed per the s
327
365
  let conversationLog: AgentResult['conversationLog'] = [];
328
366
 
329
367
  const MAX_VERIFICATION_CYCLES = 5;
330
- let verificationCycle = 0;
368
+ const MAX_INCOMPLETE_VERIFIER_RUNS = 2;
369
+ let semanticVerificationCycles = 0;
370
+ let incompleteVerifierRuns = 0;
371
+ let pendingInconclusive:
372
+ | {
373
+ artifactFingerprint: string;
374
+ report: Awaited<ReturnType<typeof runLiveSemanticVerification>>;
375
+ }
376
+ | undefined;
331
377
  let result: AgentResult | null = null;
332
378
  let currentInitialMessage = initialUserMessage;
333
379
 
334
- while (verificationCycle < MAX_VERIFICATION_CYCLES) {
335
- verificationCycle++;
380
+ while (true) {
381
+ const currentSemanticCycle = Math.min(semanticVerificationCycles + 1, MAX_VERIFICATION_CYCLES);
336
382
 
337
383
  // Wrap the user's onProgress callback to inject verification cycle info
338
384
  const userOnProgress = opts.onProgress;
@@ -340,7 +386,7 @@ Begin by calling read_session_summary to orient yourself, then proceed per the s
340
386
  ? (p: AgentProgress) =>
341
387
  userOnProgress({
342
388
  ...p,
343
- verificationCycle,
389
+ verificationCycle: currentSemanticCycle,
344
390
  maxVerificationCycles: MAX_VERIFICATION_CYCLES,
345
391
  })
346
392
  : undefined;
@@ -399,42 +445,48 @@ Begin by calling read_session_summary to orient yourself, then proceed per the s
399
445
  log(`verification warnings (non-blocking):\n${warnings.join('\n')}`);
400
446
  }
401
447
 
448
+ let semanticReviewCompleted = false;
449
+ let semanticReviewAttempted = false;
402
450
  if (failures.length === 0) {
403
- const semanticVerificationStartedAt = Date.now();
404
- const semantic = await runLiveSemanticVerification({
405
- provider: provider.name,
406
- toolDir: absoluteToolDir,
407
- evidence: integrationEvidence,
408
- // Live semantic verification is a distinct phase. Do not inherit a
409
- // nearly exhausted compile-agent deadline.
410
- });
411
- deadlineMs += Date.now() - semanticVerificationStartedAt;
412
- failures.push(...semanticVerificationFailures(semantic.report));
413
- if (semantic.report.status === 'approved_with_gaps') {
414
- warnings.push(
415
- `independent semantic verification approved with gaps: ${semantic.report.gaps.join('; ')}`,
416
- );
417
- }
418
- if (failures.length === 0) {
419
- // Success (possibly with explicit semantic gaps). Persist per-parameter
420
- // flags and mint liveVerified=true only after independent approval.
421
- applyLiveVerification(absoluteToolDir, undefined);
451
+ const doneInput = result.doneInput ?? {};
452
+ const currentFingerprint = compileArtifactFingerprint(absoluteToolDir);
453
+ if (
454
+ pendingInconclusive &&
455
+ canAcceptInconclusiveDecision({
456
+ semanticVerificationCycles,
457
+ maxVerificationCycles: MAX_VERIFICATION_CYCLES,
458
+ completedReview: pendingInconclusive.report.completedReview,
459
+ infrastructureOnly: isInfrastructureOnlyInconclusiveReport(
460
+ pendingInconclusive.report.report,
461
+ ),
462
+ pendingFingerprint: pendingInconclusive.artifactFingerprint,
463
+ currentFingerprint,
464
+ acceptInconclusive: doneInput.accept_inconclusive === true,
465
+ inconclusiveReason:
466
+ typeof doneInput.inconclusive_reason === 'string'
467
+ ? doneInput.inconclusive_reason
468
+ : undefined,
469
+ })
470
+ ) {
471
+ const semantic = pendingInconclusive.report;
472
+ const inconclusiveReason = String(doneInput.inconclusive_reason).trim();
473
+ applyLiveVerification(absoluteToolDir, {
474
+ kind: 'waived-infra',
475
+ firstError: inconclusiveReason,
476
+ exhaustedBackends: [],
477
+ });
422
478
  const paramWarnings = applyParamVerification(
423
479
  absoluteToolDir,
424
480
  mergeSemanticParamVerification(paramVerification, semantic.report),
425
481
  );
426
- // liveVerified/parameter annotations change the hash-strict cache key,
427
- // but not transport capabilities. Rebind the backend already exercised
428
- // by the verifier instead of triggering another probe.
429
482
  rebindExistingBackendsCacheToWorkflow(absoluteToolDir);
430
- const allWarnings = [...warnings, ...paramWarnings];
431
- if (paramWarnings.length > 0) {
432
- log(`parameter verification:\n${paramWarnings.join('\n')}`);
433
- }
483
+ warnings.push(
484
+ `independent semantic verification was inconclusive: ${semantic.report.summary}`,
485
+ `compiler accepted the unchanged artifact without live verification: ${inconclusiveReason}`,
486
+ ...paramWarnings,
487
+ );
434
488
  message = result.doneSummary ?? 'Task completed';
435
- if (allWarnings.length > 0) {
436
- message += `\n\nWarnings:\n${allWarnings.join('\n')}`;
437
- }
489
+ message += `\n\nWarnings:\n${warnings.join('\n')}`;
438
490
  if (!opts.keepTest) {
439
491
  for (const f of ['parser.test.ts', 'integration.test.ts']) {
440
492
  const testPath = pathJoin(absoluteToolDir, f);
@@ -443,22 +495,127 @@ Begin by calling read_session_summary to orient yourself, then proceed per the s
443
495
  }
444
496
  break;
445
497
  }
498
+
499
+ const capDecision = inconclusiveDecisionAtSemanticCap({
500
+ semanticVerificationCycles,
501
+ maxVerificationCycles: MAX_VERIFICATION_CYCLES,
502
+ pendingFingerprint: pendingInconclusive?.artifactFingerprint,
503
+ currentFingerprint,
504
+ });
505
+ if (capDecision === 'fail-artifact-changed') {
506
+ pendingInconclusive = undefined;
507
+ failures.push(
508
+ 'compile artifacts changed after the final allowed inconclusive semantic review; refusing to run an additional review beyond the semantic cycle cap',
509
+ );
510
+ } else if (capDecision === 'await-compiler-decision') {
511
+ failures.push(
512
+ 'semantic review limit reached with an unchanged infrastructure-only inconclusive result; explicitly accept the unverified artifact with reasoning or revise it',
513
+ );
514
+ } else {
515
+ if (pendingInconclusive && pendingInconclusive.artifactFingerprint !== currentFingerprint) {
516
+ pendingInconclusive = undefined;
517
+ }
518
+ const semanticVerificationStartedAt = Date.now();
519
+ semanticReviewAttempted = true;
520
+ const semantic = await runLiveSemanticVerification({
521
+ provider: DEFAULT_VERIFICATION_PROVIDER,
522
+ toolDir: absoluteToolDir,
523
+ evidence: integrationEvidence,
524
+ // Live semantic verification is a distinct phase. Do not inherit a
525
+ // nearly exhausted compile-agent deadline.
526
+ });
527
+ deadlineMs += Date.now() - semanticVerificationStartedAt;
528
+ semanticReviewCompleted = semantic.completedReview;
529
+ if (semantic.report.status === 'inconclusive') {
530
+ pendingInconclusive = {
531
+ artifactFingerprint: compileArtifactFingerprint(absoluteToolDir),
532
+ report: semantic,
533
+ };
534
+ } else {
535
+ pendingInconclusive = undefined;
536
+ }
537
+ failures.push(...semanticVerificationFailures(semantic.report));
538
+ if (semantic.report.status === 'approved_with_gaps') {
539
+ warnings.push(
540
+ `independent semantic verification approved with gaps: ${semantic.report.gaps.join('; ')}`,
541
+ );
542
+ }
543
+ if (failures.length === 0) {
544
+ // Success (possibly with explicit semantic gaps). Persist per-parameter
545
+ // flags and mint liveVerified=true only after independent approval.
546
+ applyLiveVerification(absoluteToolDir, undefined);
547
+ const paramWarnings = applyParamVerification(
548
+ absoluteToolDir,
549
+ mergeSemanticParamVerification(paramVerification, semantic.report),
550
+ );
551
+ // liveVerified/parameter annotations change the hash-strict cache key,
552
+ // but not transport capabilities. Rebind the backend already exercised
553
+ // by the verifier instead of triggering another probe.
554
+ rebindExistingBackendsCacheToWorkflow(absoluteToolDir);
555
+ const allWarnings = [...warnings, ...paramWarnings];
556
+ if (paramWarnings.length > 0) {
557
+ log(`parameter verification:\n${paramWarnings.join('\n')}`);
558
+ }
559
+ message = result.doneSummary ?? 'Task completed';
560
+ if (allWarnings.length > 0) {
561
+ message += `\n\nWarnings:\n${allWarnings.join('\n')}`;
562
+ }
563
+ if (!opts.keepTest) {
564
+ for (const f of ['parser.test.ts', 'integration.test.ts']) {
565
+ const testPath = pathJoin(absoluteToolDir, f);
566
+ if (existsSync(testPath)) unlinkSync(testPath);
567
+ }
568
+ }
569
+ break;
570
+ }
571
+ }
446
572
  }
447
573
 
448
574
  // Verification failed — re-enter the loop with a continuation message
449
- if (verificationCycle >= MAX_VERIFICATION_CYCLES) {
575
+ semanticVerificationCycles = advanceSemanticVerificationCycle(
576
+ semanticVerificationCycles,
577
+ semanticReviewCompleted,
578
+ );
579
+ incompleteVerifierRuns = advanceIncompleteSemanticVerificationRuns(
580
+ incompleteVerifierRuns,
581
+ semanticReviewAttempted,
582
+ semanticReviewCompleted,
583
+ );
584
+ if (incompleteVerifierRuns >= MAX_INCOMPLETE_VERIFIER_RUNS) {
450
585
  outcome = 'error';
451
- message = `Verification failed after ${MAX_VERIFICATION_CYCLES} cycles. Final failures:\n${failures.join('\n')}`;
586
+ message = `Independent semantic verifier failed to complete after ${MAX_INCOMPLETE_VERIFIER_RUNS} bounded runs. Final failures:\n${failures.join('\n')}`;
587
+ break;
588
+ }
589
+ const awaitingFinalInconclusiveDecision =
590
+ semanticVerificationCycles >= MAX_VERIFICATION_CYCLES && pendingInconclusive !== undefined;
591
+ if (
592
+ semanticVerificationCycles >= MAX_VERIFICATION_CYCLES &&
593
+ !awaitingFinalInconclusiveDecision
594
+ ) {
595
+ outcome = 'error';
596
+ message = `Semantic verification failed after ${MAX_VERIFICATION_CYCLES} cycles. Final failures:\n${failures.join('\n')}`;
452
597
  break;
453
598
  }
454
599
 
455
- log(`verification failed (cycle ${verificationCycle}), resuming agent loop...`);
456
- currentInitialMessage = `You called done but verification failed:
600
+ const failurePhase = semanticReviewCompleted
601
+ ? `semantic verification failed (cycle ${semanticVerificationCycles}/${MAX_VERIFICATION_CYCLES})`
602
+ : semanticReviewAttempted
603
+ ? 'semantic verifier did not complete a review (semantic cycle limit unchanged)'
604
+ : 'deterministic verification failed (semantic cycle limit unchanged)';
605
+ log(`${failurePhase}, resuming agent loop...`);
606
+ currentInitialMessage = `You called done but ${failurePhase}:
457
607
 
458
608
  ${failures.map((f) => `- ${f}`).join('\n')}
459
609
 
460
610
  Resume your work. Read the files you wrote (workflow.json, parser.ts, parser.test.ts), fix the issues, re-run tests, and call done again when fixed.`;
461
- currentInitialMessage += `\n\nUse the verifier's semantic evidence, not a requirement for 100% of the original candidate inputs. If the core intent works but a secondary parameter cannot be supported from the recording and live evidence without guessing, remove that parameter and add workflow.limitations with omittedParameters and a specific reason. Do not use a limitation to hide a broken core tool, and do not keep a known broken or ignored public input.`;
611
+ currentInitialMessage += `\n\nUse the verifier's semantic evidence, not a requirement for 100% of the original candidate inputs. If the core intent works but a secondary parameter cannot be supported from the recording and live evidence without guessing, remove that parameter and add workflow.limitations with omittedParameters and a specific reason. Treat emitsTokens as downstream compatibility targets, not automatic definitions of producer success: when useful core records lack an optional consumer token, preserve the records and supported input classes, document the missing output and affected consumers, and leave those consumers limited/unverified. Narrow an input class only when its core results are unusable or misleading. Do not use a limitation to hide a broken core tool, and do not keep a known broken or ignored public input.`;
612
+ if (pendingInconclusive) {
613
+ currentInitialMessage +=
614
+ semanticVerificationCycles >= MAX_VERIFICATION_CYCLES &&
615
+ pendingInconclusive.report.completedReview
616
+ ? '\n\nThe final allowed live semantic review was infrastructure-only inconclusive. Review its evidence and log. If no tool defect is exposed and the unchanged deterministic artifact should ship explicitly unverified, call done again with accept_inconclusive=true and a concrete inconclusive_reason. This does not mint liveVerified.'
617
+ : '\n\nThe live verifier was infrastructure-only inconclusive. Review its evidence and log. Fix any exposed tool defect, or call done normally to request another independent review. Explicit unverified acceptance is available only after the semantic review limit is reached.';
618
+ }
462
619
  }
463
620
 
464
621
  // 10. Final flush of the complete conversation log
@@ -57,6 +57,7 @@ import {
57
57
  } from './types.ts';
58
58
 
59
59
  const REPO_ROOT = pathJoin(import.meta.dir, '..', '..');
60
+ const MAX_SHARED_MODULE_SOURCE_CHARS = 50_000;
60
61
 
61
62
  // Env var read by the agent-written parser.test.ts to locate the redacted
62
63
  // session. The test loads it, finds the load-bearing request seq, and feeds
@@ -160,11 +161,11 @@ function buildReadBuildPlanTool(
160
161
  ];
161
162
  if (emitsTokens.length > 0) {
162
163
  tokenNotes.push(
163
- `PRODUCER CONTRACT: your parser MUST emit ${emitsTokens
164
+ `PRODUCER COMPATIBILITY TARGET: emit ${emitsTokens
164
165
  .map((e) => `\`${e.field}\``)
165
166
  .join(
166
167
  ', ',
167
- )} in each result item, in the exact shape described (the FULL value a sibling consumer needs — never a bare fragment). Sibling tools mint their input from these fields; the verifier fails this tool if a declared field is missing from the parser output.`,
168
+ )} in the exact shape described whenever a live result supplies or proves the full value a sibling consumer needs. Never fabricate a missing token. Useful core records may retain a null/absent token when workflow.limitations names the missing output and affected consumers; preserve their supported input classes. A missing downstream token alone is not a reason to discard valid producer records.`,
168
169
  );
169
170
  }
170
171
  for (const tp of tokenParams) {
@@ -175,7 +176,7 @@ function buildReadBuildPlanTool(
175
176
  ? ` The producer declares shape: ${producerShape}. If that shape contains requiredKeys=[...], request-transform.ts must decode/unpack the fresh value and reference each required key in the outgoing request.`
176
177
  : '';
177
178
  tokenNotes.push(
178
- `CONSUMER CONTRACT: param \`${tp.param}\` is an opaque token minted by the \`${tp.sourceTool}\` tool's \`${tp.sourceField}\` output.${shapeNote} Write a CHAINED \`param:${tp.param}\` integration test that calls \`runCapturedIntegrationCase\` on \`../${tp.sourceTool}/workflow.json\` directly inside the \`param:${tp.param}\` test block, reads \`${tp.sourceField}\` from the producer's ACTUAL output shape (top-level field, or an item in any returned collection such as flights/items/results), and passes THAT fresh value (not the recorded constant) into this tool — then asserts the response is non-empty. When several params come from the same producer, mint once and choose one producer item containing all sibling fields so the values stay from the same result. On producer bot/infra error, rethrow so the suite waives; if the producer ran but the field is absent, fix the producer parser/output contract instead of waiving. Do not fabricate a fallback value or mutate producer.result.data to make the test pass.`,
179
+ `CONSUMER CONTRACT: param \`${tp.param}\` is an opaque token minted by the \`${tp.sourceTool}\` tool's \`${tp.sourceField}\` output.${shapeNote} Write a CHAINED \`param:${tp.param}\` integration test that calls \`runCapturedIntegrationCase\` on \`../${tp.sourceTool}/workflow.json\` directly inside the \`param:${tp.param}\` test block, reads \`${tp.sourceField}\` from the producer's ACTUAL output shape (top-level field, or an item in any returned collection such as flights/items/results), and passes THAT fresh value (not the recorded constant) into this tool — then asserts the response is non-empty. When several params come from the same producer, mint once and choose one producer item containing all sibling fields so the values stay from the same result. On producer bot/infra error, rethrow so the suite waives. If useful producer records legitimately lack the field, do not fabricate or mutate output: keep this consumer verified:false, waived-chain, or inconclusive with diagnostics, and require an honest producer limitation.`,
179
180
  );
180
181
  }
181
182
  // Per-source wiring guidance for the general dependency contract. These are
@@ -212,6 +213,7 @@ function buildReadBuildPlanTool(
212
213
  kind: m.kind,
213
214
  purpose: m.purpose,
214
215
  exportSignatures: m.exportSignatures,
216
+ ...readAssignedSharedModuleSource(buildPlanPath, m.path),
215
217
  })),
216
218
  parserGuidance: slice.tool.parserGuidance,
217
219
  paramChecklist: slice.tool.paramChecklist,
@@ -238,6 +240,30 @@ function buildReadBuildPlanTool(
238
240
  };
239
241
  }
240
242
 
243
+ function readAssignedSharedModuleSource(
244
+ buildPlanPath: string,
245
+ modulePath: string,
246
+ ): { source?: string; sourceTruncated?: boolean; sourceUnavailable?: string } {
247
+ const siteDir = realpathSync(dirname(buildPlanPath));
248
+ const candidate = pathJoin(siteDir, modulePath);
249
+ if (!existsSync(candidate)) {
250
+ return { sourceUnavailable: 'The verified shared module file is missing.' };
251
+ }
252
+
253
+ const resolved = realpathSync(candidate);
254
+ const relative = pathRelative(siteDir, resolved);
255
+ if (relative.startsWith('..') || relative === '') {
256
+ return { sourceUnavailable: 'The shared module resolved outside the site directory.' };
257
+ }
258
+
259
+ const source = readFileSync(resolved, 'utf8');
260
+ if (source.length <= MAX_SHARED_MODULE_SOURCE_CHARS) return { source };
261
+ return {
262
+ source: source.slice(0, MAX_SHARED_MODULE_SOURCE_CHARS),
263
+ sourceTruncated: true,
264
+ };
265
+ }
266
+
241
267
  // ─── Tool: read_session_summary ──────────────────────────────────────────────
242
268
 
243
269
  export function buildReadSessionSummaryTool(
@@ -1887,24 +1913,121 @@ interface TestBlock {
1887
1913
  body: string;
1888
1914
  }
1889
1915
 
1916
+ function matchingClosingBrace(source: string, openIndex: number): number | null {
1917
+ let depth = 0;
1918
+ let quote: "'" | '"' | '`' | null = null;
1919
+ let escaped = false;
1920
+ let lineComment = false;
1921
+ let blockComment = false;
1922
+ for (let index = openIndex; index < source.length; index++) {
1923
+ const char = source[index] ?? '';
1924
+ const next = source[index + 1] ?? '';
1925
+ if (lineComment) {
1926
+ if (char === '\n') lineComment = false;
1927
+ continue;
1928
+ }
1929
+ if (blockComment) {
1930
+ if (char === '*' && next === '/') {
1931
+ blockComment = false;
1932
+ index++;
1933
+ }
1934
+ continue;
1935
+ }
1936
+ if (quote) {
1937
+ if (escaped) {
1938
+ escaped = false;
1939
+ } else if (char === '\\') {
1940
+ escaped = true;
1941
+ } else if (char === quote) {
1942
+ quote = null;
1943
+ }
1944
+ continue;
1945
+ }
1946
+ if (char === '/' && next === '/') {
1947
+ lineComment = true;
1948
+ index++;
1949
+ continue;
1950
+ }
1951
+ if (char === '/' && next === '*') {
1952
+ blockComment = true;
1953
+ index++;
1954
+ continue;
1955
+ }
1956
+ if (char === "'" || char === '"' || char === '`') {
1957
+ quote = char;
1958
+ continue;
1959
+ }
1960
+ if (char === '{') depth++;
1961
+ if (char === '}' && --depth === 0) return index;
1962
+ }
1963
+ return null;
1964
+ }
1965
+
1890
1966
  function hasCapturedLiveWorkflowCall(source: string): boolean {
1891
1967
  return /\b(?:runWorkflowWithLadder|runCapturedIntegrationCase)\s*\(/.test(source);
1892
1968
  }
1893
1969
 
1894
- /** Split a test file into `test(...)` / `it(...)` blocks (title + source from
1895
- * that test's start to the next test's start). Good enough to check whether a
1896
- * named per-parameter test's body actually calls the workflow. */
1970
+ /** Resolve small local function wrappers around a captured workflow call. The
1971
+ * generated suites commonly factor one invocation helper above their tests;
1972
+ * keeping the resolution local prevents a call in an unrelated param test from
1973
+ * laundering a synthetic baseline elsewhere in the file. */
1974
+ function capturedWorkflowHelperNames(source: string): Set<string> {
1975
+ const functionRe = /\b(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g;
1976
+ const functions = [...source.matchAll(functionRe)].map((match) => ({
1977
+ name: match[1] ?? '',
1978
+ index: match.index ?? 0,
1979
+ }));
1980
+ const testStarts = [...source.matchAll(/\b(?:test|it)\s*\(/g)].map((match) => match.index ?? 0);
1981
+ const boundaries = [...functions.map((entry) => entry.index), ...testStarts, source.length].sort(
1982
+ (a, b) => a - b,
1983
+ );
1984
+ const bodies = new Map<string, string>();
1985
+ for (const entry of functions) {
1986
+ const end = boundaries.find((boundary) => boundary > entry.index) ?? source.length;
1987
+ bodies.set(entry.name, source.slice(entry.index, end));
1988
+ }
1989
+ const captured = new Set<string>();
1990
+ let changed = true;
1991
+ while (changed) {
1992
+ changed = false;
1993
+ for (const [name, body] of bodies) {
1994
+ if (captured.has(name)) continue;
1995
+ const callsCapturedHelper = [...captured].some((helper) =>
1996
+ new RegExp(`\\b${helper.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`).test(body),
1997
+ );
1998
+ if (hasCapturedLiveWorkflowCall(body) || callsCapturedHelper) {
1999
+ captured.add(name);
2000
+ changed = true;
2001
+ }
2002
+ }
2003
+ }
2004
+ return captured;
2005
+ }
2006
+
2007
+ /** Split a test file into `test(...)` / `it(...)` blocks (title + callback
2008
+ * source). Good enough to check whether a named per-parameter test's body
2009
+ * actually calls the workflow. */
1897
2010
  export function extractTestBlocks(src: string): TestBlock[] {
1898
2011
  const re = /\b(?:test|it)\s*\(\s*(['"`])((?:\\.|(?!\1).)*)\1/g;
1899
- const starts: Array<{ index: number; title: string }> = [];
2012
+ const starts: Array<{ index: number; title: string; afterTitle: number }> = [];
1900
2013
  for (const m of src.matchAll(re)) {
1901
- starts.push({ index: m.index ?? 0, title: m[2] ?? '' });
2014
+ starts.push({
2015
+ index: m.index ?? 0,
2016
+ title: m[2] ?? '',
2017
+ afterTitle: (m.index ?? 0) + m[0].length,
2018
+ });
1902
2019
  }
1903
2020
  const blocks: TestBlock[] = [];
1904
2021
  for (let i = 0; i < starts.length; i++) {
1905
2022
  const start = starts[i];
1906
2023
  if (!start) continue;
1907
- const end = i + 1 < starts.length ? (starts[i + 1]?.index ?? src.length) : src.length;
2024
+ const fallbackEnd = i + 1 < starts.length ? (starts[i + 1]?.index ?? src.length) : src.length;
2025
+ const callbackOpen = src.indexOf('{', start.afterTitle);
2026
+ const callbackClose =
2027
+ callbackOpen >= 0 && callbackOpen < fallbackEnd
2028
+ ? matchingClosingBrace(src, callbackOpen)
2029
+ : null;
2030
+ const end = callbackClose === null ? fallbackEnd : callbackClose + 1;
1908
2031
  blocks.push({ title: start.title, body: src.slice(start.index, end) });
1909
2032
  }
1910
2033
  return blocks;
@@ -2287,6 +2410,7 @@ export function classifyIntegrationOutcome(input: {
2287
2410
  * - chained pass → `{ verified: true, sourcedFrom }`
2288
2411
  * - passed but not chained (the recorded-value tautology) → `unchained` (blocking)
2289
2412
  * - suite waived (producer anti-bot) → `{ verified: false, reason: 'waived-chain' }`
2413
+ * - explicitly annotated unavailable producer output → `{ verified: false, reason: 'waived-chain' }`
2290
2414
  * - else → `unchained` (blocking)
2291
2415
  */
2292
2416
  export function classifyParamCoverage(opts: {
@@ -2321,6 +2445,10 @@ export function classifyParamCoverage(opts: {
2321
2445
  const token = `param:${lp.name}`;
2322
2446
  const passedLive = [...opts.passedTests].some((n) => n.includes(token));
2323
2447
  const block = blocks.find((b) => b.title.includes(token));
2448
+ const annotationRe = new RegExp(
2449
+ `//\\s*exposed-but-not-verified[^\\n]*\\b${lp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`,
2450
+ );
2451
+ const isAnnotated = annotationRe.test(opts.integrationSrc);
2324
2452
 
2325
2453
  // Producer-sourced token param: requires a chained test that mints a fresh
2326
2454
  // value from the producer's sibling workflow.
@@ -2344,17 +2472,24 @@ export function classifyParamCoverage(opts: {
2344
2472
  reason: 'waived-chain',
2345
2473
  sourcedFrom,
2346
2474
  });
2475
+ } else if (isAnnotated) {
2476
+ // The compile agent may explicitly preserve a limited consumer when
2477
+ // its producer returns useful core records but cannot mint this token.
2478
+ // The durable verified:false+sourcedFrom stamp keeps orchestration
2479
+ // honest; opaque defaults and passing recorded-token tests remain
2480
+ // blocked by the independent guards above and below.
2481
+ paramVerification.push({
2482
+ name: lp.name,
2483
+ verified: false,
2484
+ reason: 'waived-chain',
2485
+ sourcedFrom,
2486
+ });
2347
2487
  } else {
2348
2488
  unchained.push(lp.name);
2349
2489
  }
2350
2490
  continue;
2351
2491
  }
2352
2492
 
2353
- const annotationRe = new RegExp(
2354
- `//\\s*exposed-but-not-verified[^\\n]*\\b${lp.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`,
2355
- );
2356
- const isAnnotated = annotationRe.test(opts.integrationSrc);
2357
-
2358
2493
  if (passedLive) {
2359
2494
  // Anti-tautology: a passing per-param test must actually exercise the live
2360
2495
  // workflow, not assert a constant.
@@ -2388,13 +2523,20 @@ export function hasLiveBaselineWorkflowTest(integrationSrc: string): boolean {
2388
2523
  const throwsOnFailure = /if\s*\(\s*!result\.ok\s*\)\s*\{[\s\S]{0,500}\bthrow\b/;
2389
2524
  const returnsOnFailedResult =
2390
2525
  /if\s*\([^)]*![^)]*\.result\.ok[\s\S]{0,200}\)\s*(?:\{[\s\S]{0,300}\breturn\b|return\b)/;
2391
- return extractTestBlocks(integrationSrc).some(
2392
- (block) =>
2526
+ const capturedHelpers = capturedWorkflowHelperNames(integrationSrc);
2527
+ return extractTestBlocks(integrationSrc).some((block) => {
2528
+ const invokesCapturedWorkflow =
2529
+ hasCapturedLiveWorkflowCall(block.body) ||
2530
+ [...capturedHelpers].some((helper) =>
2531
+ new RegExp(`\\b${helper.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`).test(block.body),
2532
+ );
2533
+ return (
2393
2534
  !block.title.includes('param:') &&
2394
- hasCapturedLiveWorkflowCall(block.body) &&
2535
+ invokesCapturedWorkflow &&
2395
2536
  !returnsOnFailedResult.test(block.body) &&
2396
- (requiresOk.test(block.body) || throwsOnFailure.test(block.body)),
2397
- );
2537
+ (requiresOk.test(block.body) || throwsOnFailure.test(block.body))
2538
+ );
2539
+ });
2398
2540
  }
2399
2541
 
2400
2542
  /**
@@ -4149,7 +4291,7 @@ export async function externalVerification(
4149
4291
  })
4150
4292
  .join(', ');
4151
4293
  failures.push(
4152
- `${coverage.unchained.length} producer-sourced token param(s) lack a CHAINED \`param:<name>\` test that mints a FRESH value from the producer tool: ${details}. Each test must call runWorkflowWithLadder on the named producer's \`workflow.json\`, read the named field from its result, and pass THAT value (not the recorded constant) into this tool — then assert the response is non-empty. If the producer only emits a bare fragment, fix the PRODUCER to emit the full value this tool consumes. See prompts/compile-agent.md "Producer-sourced token parameters".`,
4294
+ `${coverage.unchained.length} producer-sourced token param(s) lack a CHAINED \`param:<name>\` test that mints a FRESH value from the producer tool: ${details}. Each test must call runWorkflowWithLadder on the named producer's \`workflow.json\`, read the named field from its result, and pass THAT value (not the recorded constant) into this tool — then assert the response is non-empty. If live producer results legitimately cannot supply the token, keep the consumer explicitly unverified with an \`// exposed-but-not-verified: <param> ...\` annotation and document the producer/consumer limitation instead of fabricating it. See prompts/compile-agent.md "Producer-sourced token parameters".`,
4153
4295
  );
4154
4296
  }
4155
4297
  }
@@ -4175,9 +4317,9 @@ export async function externalVerification(
4175
4317
  missing.length === 1 ? 'it' : 'them'
4176
4318
  } as an input token, but parser.ts does not emit ${
4177
4319
  missing.length === 1 ? 'that field' : 'those fields'
4178
- }. Emit ${
4320
+ }. Reference ${
4179
4321
  missing.length === 1 ? 'it' : 'each'
4180
- } in every result item under the EXACT field name (the full value a consumer needs, never a bare fragment) — see read_build_plan "emitsTokens".`,
4322
+ } under the EXACT field name and emit the full value whenever live evidence supplies it; null/absent values are allowed only for useful records whose missing output and affected consumers are documented in workflow.limitations — see read_build_plan "emitsTokens".`,
4181
4323
  );
4182
4324
  }
4183
4325