imprint-mcp 0.2.0 → 0.3.0

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.
Files changed (129) hide show
  1. package/README.md +165 -201
  2. package/examples/discoverandgo/README.md +1 -1
  3. package/examples/echo/README.md +1 -1
  4. package/examples/google-flights/README.md +28 -0
  5. package/examples/google-flights/_shared/batchexecute.ts +63 -0
  6. package/examples/google-flights/_shared/flights_request.ts +95 -0
  7. package/examples/google-flights/_shared/package.json +9 -0
  8. package/examples/google-flights/get_flight_booking_details/index.ts +159 -0
  9. package/examples/google-flights/get_flight_booking_details/package.json +9 -0
  10. package/examples/google-flights/get_flight_booking_details/parser.ts +182 -0
  11. package/examples/google-flights/get_flight_booking_details/playbook.yaml +138 -0
  12. package/examples/google-flights/get_flight_booking_details/request-transform.ts +86 -0
  13. package/examples/google-flights/get_flight_booking_details/workflow.json +98 -0
  14. package/examples/google-flights/get_flight_calendar_prices/index.ts +131 -0
  15. package/examples/google-flights/get_flight_calendar_prices/package.json +9 -0
  16. package/examples/google-flights/get_flight_calendar_prices/parser.ts +86 -0
  17. package/examples/google-flights/get_flight_calendar_prices/playbook.yaml +97 -0
  18. package/examples/google-flights/get_flight_calendar_prices/request-transform.ts +31 -0
  19. package/examples/google-flights/get_flight_calendar_prices/workflow.json +76 -0
  20. package/examples/google-flights/lookup_airport/index.ts +101 -0
  21. package/examples/google-flights/lookup_airport/package.json +9 -0
  22. package/examples/google-flights/lookup_airport/parser.ts +66 -0
  23. package/examples/google-flights/lookup_airport/playbook.yaml +47 -0
  24. package/examples/google-flights/lookup_airport/request-transform.ts +20 -0
  25. package/examples/google-flights/lookup_airport/workflow.json +57 -0
  26. package/examples/google-flights/search_flights/index.ts +219 -0
  27. package/examples/google-flights/search_flights/package.json +9 -0
  28. package/examples/google-flights/search_flights/parser.ts +169 -0
  29. package/examples/google-flights/search_flights/playbook.yaml +184 -0
  30. package/examples/google-flights/search_flights/request-transform.ts +119 -0
  31. package/examples/google-flights/search_flights/workflow.json +143 -0
  32. package/examples/google-hotels/README.md +29 -0
  33. package/examples/google-hotels/_shared/batchexecute.ts +73 -0
  34. package/examples/google-hotels/_shared/freq.ts +158 -0
  35. package/examples/google-hotels/_shared/package.json +9 -0
  36. package/examples/google-hotels/autocomplete_hotel_location/index.ts +80 -0
  37. package/examples/google-hotels/autocomplete_hotel_location/package.json +9 -0
  38. package/examples/google-hotels/autocomplete_hotel_location/parser.ts +71 -0
  39. package/examples/google-hotels/autocomplete_hotel_location/playbook.yaml +36 -0
  40. package/examples/google-hotels/autocomplete_hotel_location/request-transform.ts +37 -0
  41. package/examples/google-hotels/autocomplete_hotel_location/workflow.json +36 -0
  42. package/examples/google-hotels/get_hotel_booking_options/index.ts +143 -0
  43. package/examples/google-hotels/get_hotel_booking_options/package.json +9 -0
  44. package/examples/google-hotels/get_hotel_booking_options/parser.ts +271 -0
  45. package/examples/google-hotels/get_hotel_booking_options/playbook.yaml +154 -0
  46. package/examples/google-hotels/get_hotel_booking_options/request-transform.ts +154 -0
  47. package/examples/google-hotels/get_hotel_booking_options/workflow.json +84 -0
  48. package/examples/google-hotels/get_hotel_reviews/index.ts +81 -0
  49. package/examples/google-hotels/get_hotel_reviews/package.json +9 -0
  50. package/examples/google-hotels/get_hotel_reviews/parser.ts +128 -0
  51. package/examples/google-hotels/get_hotel_reviews/playbook.yaml +64 -0
  52. package/examples/google-hotels/get_hotel_reviews/request-transform.ts +42 -0
  53. package/examples/google-hotels/get_hotel_reviews/workflow.json +37 -0
  54. package/examples/google-hotels/search_hotels/index.ts +207 -0
  55. package/examples/google-hotels/search_hotels/package.json +9 -0
  56. package/examples/google-hotels/search_hotels/parser.ts +260 -0
  57. package/examples/google-hotels/search_hotels/playbook.yaml +87 -0
  58. package/examples/google-hotels/search_hotels/request-transform.ts +197 -0
  59. package/examples/google-hotels/search_hotels/workflow.json +127 -0
  60. package/package.json +3 -2
  61. package/prompts/audit-agent.md +71 -0
  62. package/prompts/build-planning.md +74 -0
  63. package/prompts/compile-agent.md +132 -28
  64. package/prompts/prereq-builder.md +64 -0
  65. package/prompts/prereq-planner.md +34 -0
  66. package/prompts/tool-planning.md +39 -0
  67. package/src/cli.ts +111 -4
  68. package/src/imprint/agent.ts +5 -0
  69. package/src/imprint/audit.ts +996 -0
  70. package/src/imprint/backend-ladder.ts +1214 -184
  71. package/src/imprint/build-plan.ts +1051 -0
  72. package/src/imprint/cdp-browser-fetch.ts +589 -0
  73. package/src/imprint/cdp-jar-cache.ts +320 -0
  74. package/src/imprint/chromium.ts +135 -0
  75. package/src/imprint/claude-cli-compile.ts +125 -25
  76. package/src/imprint/codex-cli-compile.ts +26 -23
  77. package/src/imprint/compile-agent-types.ts +38 -0
  78. package/src/imprint/compile-agent.ts +65 -27
  79. package/src/imprint/compile-tools.ts +1656 -64
  80. package/src/imprint/compile.ts +14 -2
  81. package/src/imprint/concurrency.ts +87 -0
  82. package/src/imprint/credential-extract.ts +174 -25
  83. package/src/imprint/cron.ts +1 -0
  84. package/src/imprint/doctor.ts +39 -0
  85. package/src/imprint/emit.ts +85 -0
  86. package/src/imprint/freeform-redact.ts +5 -4
  87. package/src/imprint/integrations.ts +2 -2
  88. package/src/imprint/llm.ts +56 -8
  89. package/src/imprint/mcp-compile-server.ts +43 -10
  90. package/src/imprint/mcp-maintenance.ts +9 -101
  91. package/src/imprint/mcp-server.ts +73 -7
  92. package/src/imprint/multi-progress.ts +7 -2
  93. package/src/imprint/param-grounding.ts +367 -0
  94. package/src/imprint/paths.ts +29 -0
  95. package/src/imprint/playbook-runner.ts +101 -40
  96. package/src/imprint/prereq-builder.ts +651 -0
  97. package/src/imprint/probe-backends.ts +6 -3
  98. package/src/imprint/record.ts +10 -1
  99. package/src/imprint/redact.ts +30 -2
  100. package/src/imprint/replay-capture.ts +19 -18
  101. package/src/imprint/runtime.ts +19 -10
  102. package/src/imprint/sensitive-keys.ts +141 -7
  103. package/src/imprint/session-diff.ts +79 -2
  104. package/src/imprint/session-merge.ts +9 -5
  105. package/src/imprint/stealth-chromium.ts +81 -0
  106. package/src/imprint/stealth-fetch.ts +309 -29
  107. package/src/imprint/stealth-token-cache.ts +88 -0
  108. package/src/imprint/teach-plan.ts +251 -0
  109. package/src/imprint/teach-state.ts +17 -0
  110. package/src/imprint/teach.ts +582 -147
  111. package/src/imprint/tool-candidates.ts +72 -14
  112. package/src/imprint/tool-plan.ts +313 -0
  113. package/src/imprint/tracing.ts +135 -6
  114. package/src/imprint/types.ts +61 -3
  115. package/examples/google-flights/search_google_flights/index.ts +0 -101
  116. package/examples/google-flights/search_google_flights/parser.test.ts +0 -140
  117. package/examples/google-flights/search_google_flights/parser.ts +0 -189
  118. package/examples/google-flights/search_google_flights/playbook.yaml +0 -130
  119. package/examples/google-flights/search_google_flights/workflow.json +0 -48
  120. package/examples/google-hotels/search_google_hotels/index.ts +0 -194
  121. package/examples/google-hotels/search_google_hotels/parser.test.ts +0 -168
  122. package/examples/google-hotels/search_google_hotels/parser.ts +0 -330
  123. package/examples/google-hotels/search_google_hotels/playbook.yaml +0 -125
  124. package/examples/google-hotels/search_google_hotels/workflow.json +0 -111
  125. package/examples/namecheap-domains/search_namecheap_domains/index.ts +0 -144
  126. package/examples/namecheap-domains/search_namecheap_domains/parser.ts +0 -380
  127. package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +0 -50
  128. package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +0 -136
  129. package/examples/namecheap-domains/search_namecheap_domains/workflow.json +0 -97
@@ -9,9 +9,20 @@
9
9
 
10
10
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
- import { join as pathJoin, resolve as pathResolve } from 'node:path';
12
+ import {
13
+ basename as pathBasename,
14
+ dirname as pathDirname,
15
+ join as pathJoin,
16
+ resolve as pathResolve,
17
+ } from 'node:path';
13
18
  import * as p from '@clack/prompts';
14
19
  import type { OnDeadlineReached } from './agent.ts';
20
+ import {
21
+ type SharedModuleManifestEntry,
22
+ buildPlanSidecarPath,
23
+ readBuildPlanFile,
24
+ topoLevelsForTools,
25
+ } from './build-plan.ts';
15
26
  import {
16
27
  type CompileAgentProgress,
17
28
  type TriageResult,
@@ -19,6 +30,7 @@ import {
19
30
  generate,
20
31
  triageRequests,
21
32
  } from './compile.ts';
33
+ import { mapLimit, mapLimitSettled } from './concurrency.ts';
22
34
  import {
23
35
  type CredentialFinding,
24
36
  type Replacement,
@@ -41,15 +53,23 @@ import {
41
53
  isTeachCompatibleProvider,
42
54
  } from './llm.ts';
43
55
  import { loadJsonFile } from './load-json.ts';
44
- import { muteLog, unmuteLog } from './log.ts';
56
+ import { createLog, muteLog, unmuteLog } from './log.ts';
45
57
  import { MultiProgress } from './multi-progress.ts';
46
58
  import { localSiteDir, localToolDir } from './paths.ts';
47
59
  import { describeAgentActivity, formatElapsed } from './progress.ts';
48
60
  import { record } from './record.ts';
49
61
  import { detectPageMintedHeaders, redactSession } from './redact.ts';
50
62
  import { loadCredentialStore } from './runtime.ts';
63
+ import { isSensitiveCredentialKey, passwordLikeTokens } from './sensitive-keys.ts';
51
64
  import type { ClassifiedValue } from './session-diff.ts';
52
- import { listSiteSessions, mergeSessions, writeCombinedSession } from './session-merge.ts';
65
+ import {
66
+ listSessionsInDir,
67
+ listSiteSessions,
68
+ mergeSessions,
69
+ writeCombinedSession,
70
+ } from './session-merge.ts';
71
+ import { clearCachedToken } from './stealth-token-cache.ts';
72
+ import { planAndBuildPrereqs } from './teach-plan.ts';
53
73
  import {
54
74
  TEACH_STEPS as STEPS,
55
75
  type TeachStep as Step,
@@ -73,11 +93,25 @@ import {
73
93
  detectToolCandidates,
74
94
  primaryToolCandidate,
75
95
  } from './tool-candidates.ts';
96
+ import { planToolCompile } from './tool-plan.ts';
97
+ import { setSpanAttributes, traced } from './tracing.ts';
76
98
  import { CronConfigSchema, SessionSchema, WorkflowSchema } from './types.ts';
77
99
  import type { CronConfig, Playbook, Session, Workflow } from './types.ts';
78
100
 
79
101
  export { buildTeachStateFromSession, resolveTeachStatePath } from './teach-state.ts';
80
102
 
103
+ /**
104
+ * How many compile agents run in parallel when more than one tool is selected.
105
+ * Kept at 2 (not 3): bursts of near-identical reverse-engineering requests in a
106
+ * short window raise the model's usage-policy safety-filter false-positive rate,
107
+ * so we trade a little wall-clock for fewer spurious refusals. Single-tool runs
108
+ * still use concurrency 1.
109
+ */
110
+ const COMPILE_CONCURRENCY = 2;
111
+
112
+ /** Module logger — suppressed during teach's spinner phases via muteLog(). */
113
+ const log = createLog('teach');
114
+
81
115
  // ─── Types ──────────────────────────────────────────────────────────────────
82
116
 
83
117
  interface TeachOptions {
@@ -89,7 +123,7 @@ interface TeachOptions {
89
123
  provider?: ProviderName;
90
124
  /** Override the compile model (otherwise prompted or auto-detected). */
91
125
  model?: string;
92
- /** Per-tool compile timeout in ms. Default 10 minutes. */
126
+ /** Per-tool compile timeout in ms. Default 20 minutes. */
93
127
  maxDurationMs?: number;
94
128
  fromSession?: string;
95
129
  /** Retain parser.test.ts after successful compile-agent verification. */
@@ -317,7 +351,7 @@ export async function promptForTeachProvider(
317
351
  async function promptForModel(provider: ProviderName): Promise<string> {
318
352
  const { availableModelsForProvider } = await import('./llm.ts');
319
353
  const models = availableModelsForProvider(provider);
320
- if (models.length <= 1) return models[0]?.model ?? 'claude-opus-4-7';
354
+ if (models.length <= 1) return models[0]?.model ?? 'claude-opus-4-8';
321
355
 
322
356
  const choice = await p.select({
323
357
  message: 'Which model should compile this workflow?',
@@ -500,16 +534,25 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
500
534
  if (startIdx <= STEPS.indexOf('record')) {
501
535
  const startUrl = await resolveStartUrl(opts);
502
536
 
503
- spinner.start('Recording...');
537
+ spinner.start('Recording');
504
538
  spinner.stop('Ready to record.');
505
539
  console.log('');
506
540
 
507
- const recordResult = await record({
508
- site: site,
509
- url: startUrl,
510
- persistProfile: opts.persistProfile,
511
- signal: opts.signal,
512
- });
541
+ const recordResult = await traced(
542
+ 'teach.record',
543
+ 'CHAIN',
544
+ { 'imprint.site': site, 'imprint.url': startUrl },
545
+ async (span) => {
546
+ const res = await record({
547
+ site: site,
548
+ url: startUrl,
549
+ persistProfile: opts.persistProfile,
550
+ signal: opts.signal,
551
+ });
552
+ setSpanAttributes(span, { 'imprint.record.event_count': res.count });
553
+ return res;
554
+ },
555
+ );
513
556
  sessionPath = recordResult.sessionPath;
514
557
 
515
558
  checkpoint(site, state, workflowKey, {
@@ -518,21 +561,30 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
518
561
  startedAt: new Date().toISOString(),
519
562
  updatedAt: new Date().toISOString(),
520
563
  });
564
+ }
521
565
 
522
- // ── 1b. Combine with past sessions (optional) ────────────────────
523
- const originalSessionPath = sessionPath;
524
- sessionPath = await promptSessionCombine({
525
- site,
526
- currentSessionPath: sessionPath,
527
- noInteractive: opts.noInteractive ?? false,
528
- });
529
- if (sessionPath !== originalSessionPath) {
530
- checkpoint(site, state, workflowKey, {
531
- sessionPath: toRelative(site, sessionPath),
532
- completedSteps: ['record'],
533
- startedAt: new Date().toISOString(),
534
- updatedAt: new Date().toISOString(),
566
+ // ── 1b. Combine with past sessions (optional) ──────────────────────
567
+ // Runs after recording OR when --from-session is provided. Skipped when
568
+ // resuming from a checkpoint (the checkpoint already stores the final
569
+ // session path, possibly combined from a previous run).
570
+ if (sessionPath && (startIdx <= STEPS.indexOf('record') || usingFromSession)) {
571
+ const isCombinedSession = pathBasename(sessionPath).startsWith('combined-');
572
+ if (!isCombinedSession) {
573
+ const originalSessionPath = sessionPath;
574
+ sessionPath = await combineAvailableSessions({
575
+ site,
576
+ currentSessionPath: sessionPath,
577
+ noInteractive: opts.noInteractive ?? false,
578
+ fromSession: usingFromSession,
535
579
  });
580
+ if (sessionPath !== originalSessionPath) {
581
+ checkpoint(site, state, workflowKey, {
582
+ sessionPath: toRelative(site, sessionPath),
583
+ completedSteps: ['record'],
584
+ startedAt: new Date().toISOString(),
585
+ updatedAt: new Date().toISOString(),
586
+ });
587
+ }
536
588
  }
537
589
  }
538
590
 
@@ -581,14 +633,33 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
581
633
  }
582
634
  }
583
635
 
584
- spinner.start('Redacting credentials...');
585
- const pageMintedHeaders = detectPageMintedHeaders(session);
586
- const { session: scrubbed, stats } = redactSession(session, {
587
- replacements: confirmedReplacements,
588
- keepHeaders: pageMintedHeaders,
589
- });
636
+ spinner.start('Redacting credentials');
590
637
  redactedPath = sessionPath.replace(/\.json$/, '.redacted.json');
591
- writeFileSync(redactedPath, `${JSON.stringify(scrubbed, null, 2)}\n`, 'utf8');
638
+ const { stats } = await traced(
639
+ 'teach.redact',
640
+ 'CHAIN',
641
+ { 'imprint.site': site },
642
+ async (span) => {
643
+ const pageMintedHeaders = detectPageMintedHeaders(session);
644
+ const redaction = redactSession(session, {
645
+ replacements: confirmedReplacements,
646
+ keepHeaders: pageMintedHeaders,
647
+ });
648
+ writeFileSync(
649
+ redactedPath as string,
650
+ `${JSON.stringify(redaction.session, null, 2)}\n`,
651
+ 'utf8',
652
+ );
653
+ setSpanAttributes(span, {
654
+ 'imprint.redact.totalRedactions': redaction.stats.totalRedactions,
655
+ 'imprint.redact.requestsRedacted': redaction.stats.requestsRedacted,
656
+ 'imprint.redact.cookiesRedacted': redaction.stats.cookiesRedacted,
657
+ 'imprint.redact.placeholdersInjected': redaction.stats.placeholdersInjected,
658
+ 'imprint.redact.freeformRedactions': redaction.stats.freeformRedactions,
659
+ });
660
+ return redaction;
661
+ },
662
+ );
592
663
  const placeholderNote =
593
664
  stats.placeholdersInjected > 0
594
665
  ? `, ${stats.placeholdersInjected} replaced with credential placeholders`
@@ -599,8 +670,38 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
599
670
  `Redacted ${stats.totalRedactions} value(s) across ${stats.requestsRedacted} request(s) and ${stats.cookiesRedacted} cookie(s)${placeholderNote}${freeformNote}.`,
600
671
  );
601
672
 
673
+ // Post-redact pairing audit: if any request body contained a
674
+ // password-shaped field but credential extraction failed to produce a
675
+ // confirmed username+password pair, the downstream compile stage will
676
+ // template credentials as `${param.X}` instead of `${credential.X}` —
677
+ // shipping a broken MCP tool that asks callers to provide credentials
678
+ // by hand instead of pulling from the credential store.
679
+ //
680
+ // The most common reason is an unusual request framing (custom
681
+ // Content-Type, unusual key naming) that the extractor's dictionaries
682
+ // or parsers don't yet cover. Surface this loudly so the user can
683
+ // either re-record, file a bug, or proceed knowing the tool needs
684
+ // hand-editing.
685
+ const warnings: string[] = [];
686
+ const unpairedPasswordSeqs = findUnpairedPasswordRequests(session);
687
+ if (unpairedPasswordSeqs.length > 0 && confirmedReplacements.length === 0) {
688
+ warnings.push('credentials_not_paired');
689
+ const seqList = unpairedPasswordSeqs.slice(0, 5).join(', ');
690
+ const more = unpairedPasswordSeqs.length > 5 ? ', …' : '';
691
+ p.log.warn(
692
+ [
693
+ `Detected ${unpairedPasswordSeqs.length} request(s) with a password-shaped field (seqs: ${seqList}${more}) but no username+password pair was extracted.`,
694
+ 'The generated workflow will treat credentials as plain parameters and will NOT pull from the credential store.',
695
+ 'This usually means the request body uses an unusual framing (Content-Type, key naming, multipart variant) the extractor did not recognise.',
696
+ `→ Recommended: file a bug with the redacted session at ${toRelative(site, redactedPath)}, then re-record once the extractor is fixed.`,
697
+ '→ To proceed anyway, just continue — the tool will need manual credential wiring before it works.',
698
+ ].join('\n'),
699
+ );
700
+ }
701
+
602
702
  updateCheckpoint(site, state, workflowKey, 'redact', {
603
703
  redactedPath: toRelative(site, redactedPath),
704
+ warnings: warnings.length > 0 ? warnings : undefined,
604
705
  });
605
706
  }
606
707
 
@@ -717,7 +818,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
717
818
  const model = await getModel();
718
819
  mp.pause();
719
820
  mp.clear();
720
- spinner.start('Triaging requests...');
821
+ spinner.start('Triaging requests');
721
822
  localTriageResult = await triageRequests(triageSession, {
722
823
  provider: providerName,
723
824
  model,
@@ -751,7 +852,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
751
852
  const model = await getModel();
752
853
  mp.pause();
753
854
  mp.clear();
754
- spinner.start('Detecting candidate tools...');
855
+ spinner.start('Detecting candidate tools');
755
856
  const detection = await detectTeachCandidates({
756
857
  sessionPath: compileSessionPath,
757
858
  providerName,
@@ -827,7 +928,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
827
928
  await new Promise((r) => setTimeout(r, 0));
828
929
  const showedSpinner = !replaySettled;
829
930
  if (showedSpinner) {
830
- spinner.start('Waiting for replay to finish...');
931
+ spinner.start('Waiting for replay to finish');
831
932
  }
832
933
  siteClassifications = await replayPromise;
833
934
  if (showedSpinner) {
@@ -885,7 +986,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
885
986
  let compileModel = '';
886
987
  if (needsCompileProvider) {
887
988
  compileModel = await getModel();
888
- const timeoutMs = opts.maxDurationMs ?? 10 * 60 * 1000;
989
+ const timeoutMs = opts.maxDurationMs ?? 20 * 60 * 1000;
889
990
  const timeoutDisplay =
890
991
  timeoutMs >= 3_600_000
891
992
  ? `${Math.round(timeoutMs / 3_600_000)}h`
@@ -898,10 +999,22 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
898
999
  `Timeout: ${timeoutDisplay} per tool`,
899
1000
  '',
900
1001
  plans.length === 1
901
- ? 'An LLM agent will reverse-engineer the API response format.'
902
- : `${plans.length} LLM compile agents will reverse-engineer selected tools with concurrency 3.`,
903
- `Expect up to ${timeoutDisplay} per tool and moderate to high token use, depending on`,
904
- 'the complexity of the recording. You can interrupt with Ctrl-C.',
1002
+ ? 'An LLM agent will reverse-engineer the API response format,'
1003
+ : `${plans.length} LLM compile agents will reverse-engineer selected tools with concurrency ${COMPILE_CONCURRENCY},`,
1004
+ 'write the MCP server, and run thorough verification tests.',
1005
+ 'Most complex tools take 10-15 minutes please be patient.',
1006
+ `Timeout: ${timeoutDisplay} per tool. You can interrupt with Ctrl-C.`,
1007
+ ...(plans.length > 1
1008
+ ? [
1009
+ '',
1010
+ 'Shared helper modules are planned + built once under _shared/ before',
1011
+ 'the tools compile, so each tool reuses them. Set IMPRINT_NO_BUILD_PLAN=1',
1012
+ 'to disable and compile every tool independently.',
1013
+ ]
1014
+ : []),
1015
+ '',
1016
+ 'To persist the generated tests after compilation, set IMPRINT_KEEP_TEST=1',
1017
+ 'or pass --keep-test.',
905
1018
  ].join('\n'),
906
1019
  'Compile step',
907
1020
  );
@@ -937,7 +1050,75 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
937
1050
  }
938
1051
  }
939
1052
 
940
- if (plans.length > 1) muteLog();
1053
+ // ── plan-prereqs: plan + build shared modules once before the fan-out ──
1054
+ // Only engages for ≥2 selected tools that are about to be (re)generated.
1055
+ // Single-tool runs and resumes-past-generate are unchanged.
1056
+ const selectedCandidates = plans.map((pl) => pl.candidate).filter((c): c is ToolCandidate => !!c);
1057
+ const willGenerate = plans.some((pl) => STEPS.indexOf(pl.startFrom) <= STEPS.indexOf('generate'));
1058
+ let buildPlanPath = '';
1059
+ let sharedModulesManifest: SharedModuleManifestEntry[] = [];
1060
+ if (selectedCandidates.length >= 2 && willGenerate && compileModel) {
1061
+ const sidecar = buildPlanSidecarPath(site);
1062
+ const firstWs = state.workflows[plans[0]?.workflowKey ?? ''];
1063
+ const alreadyPlanned =
1064
+ plans.every((pl) =>
1065
+ state.workflows[pl.workflowKey]?.completedSteps.includes('plan-prereqs'),
1066
+ ) && existsSync(sidecar);
1067
+ if (alreadyPlanned && firstWs) {
1068
+ // Resume past plan-prereqs — reuse the persisted plan + manifest.
1069
+ buildPlanPath = sidecar;
1070
+ sharedModulesManifest = firstWs.sharedModules ?? [];
1071
+ } else {
1072
+ // Mute raw `[imprint …]` logs from the planning subtree (build-plan,
1073
+ // teach-plan, prereq-builder) while the spinner is live — progress flows
1074
+ // through onProgress → spinner.message instead, matching the replay and
1075
+ // compile phases. The skip/timeout reason is surfaced cleanly below.
1076
+ muteLog();
1077
+ spinner.start('Planning shared modules');
1078
+ try {
1079
+ const prereq = await planAndBuildPrereqs({
1080
+ site,
1081
+ redactedSessionPath: compileSessionPath,
1082
+ candidates: selectedCandidates,
1083
+ sharedContext: plans[0]?.sharedContext,
1084
+ siteClassifications,
1085
+ providerName: compileProviderName,
1086
+ model: compileModel,
1087
+ onProgress: (msg) => spinner.message(msg),
1088
+ });
1089
+ buildPlanPath = prereq.buildPlanPath;
1090
+ sharedModulesManifest = prereq.sharedModules;
1091
+ const verified = sharedModulesManifest.filter((m) => m.verified).length;
1092
+ spinner.stop(
1093
+ buildPlanPath
1094
+ ? `Build plan ready (${verified}/${sharedModulesManifest.length} shared module${sharedModulesManifest.length === 1 ? '' : 's'} verified).`
1095
+ : 'Build plan skipped.',
1096
+ );
1097
+ if (prereq.skippedReason) p.log.warn(prereq.skippedReason);
1098
+ } catch (err) {
1099
+ spinner.stop('Build planning failed — compiling tools independently.');
1100
+ p.log.warn(
1101
+ `Build planning failed: ${err instanceof Error ? err.message : String(err)}\nTools will compile without shared modules.`,
1102
+ );
1103
+ buildPlanPath = '';
1104
+ sharedModulesManifest = [];
1105
+ } finally {
1106
+ unmuteLog();
1107
+ }
1108
+ for (const pl of plans) {
1109
+ updateCheckpoint(site, state, pl.workflowKey, 'plan-prereqs', {
1110
+ buildPlanPath: buildPlanPath ? toRelative(site, buildPlanPath) : undefined,
1111
+ sharedModules: sharedModulesManifest,
1112
+ });
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ // Mute raw `[imprint …]` logs from the compile subtree while the spinner /
1118
+ // MultiProgress is live. This covers single-tool runs too: they drive the
1119
+ // shared spinner and would otherwise leak compile.ts diagnostics into it,
1120
+ // just as concurrent multi-tool runs would interleave their logs.
1121
+ muteLog();
941
1122
  let results: TeachToolResult[];
942
1123
  try {
943
1124
  results = await compileCandidatePlans({
@@ -953,9 +1134,12 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
953
1134
  sharedTriageResult: triageResult,
954
1135
  siteClassifications,
955
1136
  teachCredentials,
1137
+ allTools: opts.allTools,
1138
+ buildPlanPath: buildPlanPath || undefined,
1139
+ sharedModules: sharedModulesManifest.length > 0 ? sharedModulesManifest : undefined,
956
1140
  });
957
1141
  } finally {
958
- if (plans.length > 1) unmuteLog();
1142
+ unmuteLog();
959
1143
  }
960
1144
 
961
1145
  if (results.length === 0) {
@@ -1019,8 +1203,32 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1019
1203
  updateCheckpoint(site, state, result.workflow.toolName, 'register');
1020
1204
  }
1021
1205
 
1206
+ // Drop the transient compile-time stealth token (shared across this site's
1207
+ // per-tool `bun test` processes). It holds a live session token and is no
1208
+ // longer needed once every tool has compiled.
1209
+ clearCachedToken(localSiteDir(site));
1210
+
1211
+ // Surface any tools that shipped without a passing live integration test
1212
+ // (waived during compile due to anti-bot / infra). These rely on the runtime
1213
+ // playbook last-ditch path, which is a degraded fallback — operators should
1214
+ // know rather than discover at audit/runtime.
1215
+ const unverified = results.filter((r) => r.workflow.liveVerified === false);
1216
+ if (unverified.length > 0) {
1217
+ for (const r of unverified) {
1218
+ const waiver = r.workflow.liveVerifiedWaiver;
1219
+ const reason = waiver
1220
+ ? `${waiver.kind} (exhausted: ${waiver.exhaustedBackends.join(', ') || 'n/a'}; first error: ${waiver.firstError})`
1221
+ : 'reason not recorded';
1222
+ p.log.warn(
1223
+ `tool "${r.workflow.toolName}" shipped without live verification: ${reason}\n → runtime callers fall through to the playbook last-ditch rung; treat this tool as unverified until audit confirms it.`,
1224
+ );
1225
+ }
1226
+ }
1227
+
1022
1228
  p.outro(
1023
- `Done! ${results.length} tool${results.length === 1 ? '' : 's'} ready: ${results.map((r) => r.workflow.toolName).join(', ')}`,
1229
+ `Done! ${results.length} tool${results.length === 1 ? '' : 's'} ready: ${results.map((r) => r.workflow.toolName).join(', ')}${
1230
+ unverified.length > 0 ? ` (${unverified.length} unverified — see warnings above)` : ''
1231
+ }`,
1024
1232
  );
1025
1233
 
1026
1234
  return {
@@ -1036,7 +1244,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1036
1244
 
1037
1245
  // ─── Candidate detection + per-tool compile ────────────────────────────────
1038
1246
 
1039
- interface CandidateCompilePlan {
1247
+ export interface CandidateCompilePlan {
1040
1248
  workflowKey: string;
1041
1249
  startFrom: Step;
1042
1250
  candidate?: ToolCandidate;
@@ -1116,8 +1324,16 @@ async function compileCandidatePlans(opts: {
1116
1324
  sharedTriageResult?: TriageResult;
1117
1325
  siteClassifications?: ClassifiedValue[];
1118
1326
  teachCredentials?: { site: string; values: Record<string, string> };
1327
+ /** Mirror of TeachOptions.allTools — when true, partial failures abort
1328
+ * the run with a non-zero exit so the user notices missing tools instead
1329
+ * of getting a silent warning. */
1330
+ allTools?: boolean;
1331
+ /** Absolute path to the multi-tool build plan sidecar (.build-plan.json). */
1332
+ buildPlanPath?: string;
1333
+ /** Shared-module build manifest (verified flags) for this site. */
1334
+ sharedModules?: SharedModuleManifestEntry[];
1119
1335
  }): Promise<TeachToolResult[]> {
1120
- const concurrency = opts.plans.length === 1 ? 1 : 3;
1336
+ const concurrency = opts.plans.length === 1 ? 1 : COMPILE_CONCURRENCY;
1121
1337
  const mp = opts.plans.length > 1 ? new MultiProgress() : null;
1122
1338
 
1123
1339
  // Mutex for deadline prompts: concurrent compile agents can hit their
@@ -1126,7 +1342,7 @@ async function compileCandidatePlans(opts: {
1126
1342
  // input from the first, causing it to auto-resolve as cancelled.
1127
1343
  let promptLock: Promise<void> = Promise.resolve();
1128
1344
 
1129
- const outcomes = await mapLimitSettled(opts.plans, concurrency, async (plan) => {
1345
+ const compileOne = async (plan: CandidateCompilePlan) => {
1130
1346
  const displayName = plan.candidate?.toolName ?? plan.workflowKey;
1131
1347
  let lastActivity = '';
1132
1348
  const onProgress = (progress: CompileAgentProgress): void => {
@@ -1164,7 +1380,7 @@ async function compileCandidatePlans(opts: {
1164
1380
  if (mp) {
1165
1381
  mp.resume();
1166
1382
  } else {
1167
- opts.spinner.start(`Compiling ${displayName}...`);
1383
+ opts.spinner.start(`Compiling ${displayName}`);
1168
1384
  }
1169
1385
  if (p.isCancel(extend) || !extend) return null;
1170
1386
  return 10 * 60 * 1000;
@@ -1174,7 +1390,7 @@ async function compileCandidatePlans(opts: {
1174
1390
  }
1175
1391
  : undefined;
1176
1392
 
1177
- if (!mp) opts.spinner.start(`Compiling ${displayName}...`);
1393
+ if (!mp) opts.spinner.start(`Compiling ${displayName}`);
1178
1394
  try {
1179
1395
  const result = await compileSelectedCandidate({
1180
1396
  ...opts,
@@ -1209,29 +1425,109 @@ async function compileCandidatePlans(opts: {
1209
1425
  }
1210
1426
  throw err;
1211
1427
  }
1212
- });
1428
+ };
1429
+
1430
+ // Compile producer tools before their consumers so a consumer's chained
1431
+ // verification test can mint a fresh token from the producer's live workflow.
1432
+ // With no token contracts declared, every tool lands in a single level — the
1433
+ // behavior is identical to the prior single concurrent fan-out.
1434
+ type CompileOutcome = { ok: true; value: TeachToolResult } | { ok: false; error: unknown };
1435
+ const buildPlan = opts.buildPlanPath ? readBuildPlanFile(opts.buildPlanPath) : null;
1436
+ const levels = topoLevelsForTools(
1437
+ opts.plans.map((plan) => ({ toolName: plan.candidate?.toolName ?? plan.workflowKey, plan })),
1438
+ buildPlan,
1439
+ );
1440
+ const outcomeByKey = new Map<string, CompileOutcome>();
1441
+ for (const level of levels) {
1442
+ const levelPlans = level.map((k) => k.plan);
1443
+ const levelOutcomes = await mapLimitSettled(levelPlans, concurrency, compileOne);
1444
+ levelPlans.forEach((plan, i) => {
1445
+ const outcome = levelOutcomes[i];
1446
+ if (outcome) outcomeByKey.set(plan.workflowKey, outcome);
1447
+ });
1448
+ }
1449
+ const outcomes: CompileOutcome[] = opts.plans.map(
1450
+ (plan) =>
1451
+ outcomeByKey.get(plan.workflowKey) ?? {
1452
+ ok: false,
1453
+ error: new Error(`no compile outcome recorded for ${plan.workflowKey}`),
1454
+ },
1455
+ );
1456
+
1457
+ const summary = summarizeCompileOutcomes(outcomes, opts.plans);
1213
1458
 
1459
+ // Print the structured summary on every multi-tool run so users see
1460
+ // exactly what compiled vs what failed — a single warn line buried in
1461
+ // log output is easy to miss when 4 of 6 tools compiled cleanly.
1462
+ if (opts.plans.length > 1) {
1463
+ const lines = renderCompileSummary(summary);
1464
+ if (summary.failures.length === 0) {
1465
+ p.log.success(lines.join('\n'));
1466
+ } else {
1467
+ p.log.warn(lines.join('\n'));
1468
+ }
1469
+ } else if (summary.failures.length > 0) {
1470
+ // Single-tool run: keep the old single-line warn for backwards-compat
1471
+ // since there's nothing to summarize.
1472
+ const first = summary.failures[0];
1473
+ if (first) p.log.warn(`${first.name}: ${first.firstLineError}`);
1474
+ }
1475
+
1476
+ // Hard-fail when --all-tools was requested AND any tool failed. Silent
1477
+ // partial compiles ship MCP servers with missing tools; the user only
1478
+ // notices later when an LLM tries to call one that doesn't exist.
1479
+ if (opts.allTools && summary.failures.length > 0) {
1480
+ throw new Error(
1481
+ `--all-tools requested but ${summary.failures.length} of ${opts.plans.length} tools failed to compile. See the summary above; re-run \`imprint teach\` after addressing the failures (or omit --all-tools to ship only what compiled).`,
1482
+ );
1483
+ }
1484
+
1485
+ return summary.successes;
1486
+ }
1487
+
1488
+ /** Pure summarizer — extracted so unit tests can drive arbitrary outcome
1489
+ * shapes without spinning up real compile pipelines. */
1490
+ interface CompileOutcomeSummary {
1491
+ detected: number;
1492
+ successes: TeachToolResult[];
1493
+ successNames: string[];
1494
+ failures: Array<{ name: string; firstLineError: string }>;
1495
+ }
1496
+
1497
+ export function summarizeCompileOutcomes(
1498
+ outcomes: Array<{ ok: true; value: TeachToolResult } | { ok: false; error: unknown } | null>,
1499
+ plans: CandidateCompilePlan[],
1500
+ ): CompileOutcomeSummary {
1214
1501
  const successes: TeachToolResult[] = [];
1215
- const failures: string[] = [];
1502
+ const successNames: string[] = [];
1503
+ const failures: Array<{ name: string; firstLineError: string }> = [];
1216
1504
  for (let i = 0; i < outcomes.length; i++) {
1217
1505
  const outcome = outcomes[i];
1218
- const displayName = opts.plans[i]?.candidate?.toolName ?? opts.plans[i]?.workflowKey ?? '?';
1506
+ const displayName = plans[i]?.candidate?.toolName ?? plans[i]?.workflowKey ?? '?';
1219
1507
  if (outcome?.ok) {
1220
1508
  successes.push(outcome.value);
1509
+ successNames.push(displayName);
1221
1510
  } else {
1222
1511
  const msg = outcome?.error instanceof Error ? outcome.error.message : String(outcome?.error);
1223
- failures.push(`${displayName}: ${msg.split('\n')[0]}`);
1512
+ failures.push({ name: displayName, firstLineError: msg.split('\n')[0] ?? '' });
1224
1513
  }
1225
1514
  }
1515
+ return { detected: plans.length, successes, successNames, failures };
1516
+ }
1226
1517
 
1227
- if (failures.length > 0) {
1228
- p.log.warn(
1229
- `${successes.length} of ${outcomes.length} tools compiled. ` +
1230
- `${failures.length} failed:\n${failures.map((f) => ` • ${f}`).join('\n')}`,
1231
- );
1518
+ function renderCompileSummary(summary: CompileOutcomeSummary): string[] {
1519
+ const lines: string[] = [];
1520
+ lines.push(`Compile summary: ${summary.successes.length}/${summary.detected} tools compiled.`);
1521
+ if (summary.successNames.length > 0) {
1522
+ lines.push(`Compiled: ${summary.successNames.join(', ')}`);
1232
1523
  }
1233
-
1234
- return successes;
1524
+ if (summary.failures.length > 0) {
1525
+ lines.push(`Failed (${summary.failures.length}):`);
1526
+ for (const f of summary.failures) {
1527
+ lines.push(` • ${f.name}: ${f.firstLineError}`);
1528
+ }
1529
+ }
1530
+ return lines;
1235
1531
  }
1236
1532
 
1237
1533
  async function compileSelectedCandidate(opts: {
@@ -1248,6 +1544,8 @@ async function compileSelectedCandidate(opts: {
1248
1544
  sharedTriageResult?: TriageResult;
1249
1545
  siteClassifications?: ClassifiedValue[];
1250
1546
  teachCredentials?: { site: string; values: Record<string, string> };
1547
+ buildPlanPath?: string;
1548
+ sharedModules?: SharedModuleManifestEntry[];
1251
1549
  }): Promise<TeachToolResult> {
1252
1550
  const { plan, site, state } = opts;
1253
1551
  const startIdx = STEPS.indexOf(plan.startFrom);
@@ -1255,14 +1553,34 @@ async function compileSelectedCandidate(opts: {
1255
1553
  const workflowDir = localToolDir(site, toolName);
1256
1554
  mkdirSync(workflowDir, { recursive: true });
1257
1555
 
1258
- // ── Step 1: generate (workflow.json, enriched with site-level classifications) ──
1259
- let genResult: { workflow: Workflow; workflowPath: string };
1556
+ // ── Step 1: plan THEN execute (workflow.json) ──
1557
+ let genResult: { workflow: Workflow; workflowPath: string } | undefined;
1260
1558
  if (startIdx <= STEPS.indexOf('generate')) {
1559
+ const llmConfig = { provider: opts.providerName, model: opts.compileModel };
1560
+
1561
+ // Plan THEN execute: derive a per-tool implementation plan (param→field
1562
+ // mapping, request construction, response parsing, shared-module imports),
1563
+ // then run a single compile that follows it. Best-effort — a timeout or
1564
+ // error yields no plan and the compile proceeds exactly as before.
1565
+ const toolPlan = plan.candidate
1566
+ ? await planToolCompile({
1567
+ site,
1568
+ toolName,
1569
+ candidate: plan.candidate,
1570
+ sharedContext: plan.sharedContext,
1571
+ sessionPath: opts.sessionPath,
1572
+ buildPlanPath: opts.buildPlanPath,
1573
+ sharedModules: opts.sharedModules,
1574
+ providerName: opts.providerName,
1575
+ model: opts.compileModel,
1576
+ })
1577
+ : undefined;
1578
+
1261
1579
  const result = await generate({
1262
1580
  sessionPath: opts.sessionPath,
1263
1581
  outDir: workflowDir,
1264
1582
  maxDurationMs: opts.maxDurationMs,
1265
- llmConfig: { provider: opts.providerName, model: opts.compileModel },
1583
+ llmConfig,
1266
1584
  keepTest: opts.keepTest,
1267
1585
  candidate: plan.candidate,
1268
1586
  sharedContext: plan.sharedContext,
@@ -1270,7 +1588,11 @@ async function compileSelectedCandidate(opts: {
1270
1588
  onDeadlineReached: opts.onDeadlineReached,
1271
1589
  classifications: opts.siteClassifications,
1272
1590
  teachCredentials: opts.teachCredentials,
1591
+ buildPlanPath: opts.buildPlanPath,
1592
+ sharedModules: opts.sharedModules,
1593
+ toolPlan,
1273
1594
  });
1595
+
1274
1596
  assertCandidateToolName('Compiled workflow', result.workflow.toolName, plan.candidate);
1275
1597
  genResult = { workflow: result.workflow, workflowPath: result.workflowPath };
1276
1598
  updateCheckpoint(site, state, plan.workflowKey, 'generate', {
@@ -1287,6 +1609,9 @@ async function compileSelectedCandidate(opts: {
1287
1609
  );
1288
1610
  genResult = { workflow, workflowPath };
1289
1611
  }
1612
+ if (!genResult) {
1613
+ throw new Error(`generate step did not produce a workflow for "${toolName}".`);
1614
+ }
1290
1615
 
1291
1616
  // ── Step 2: compile-playbook (after generate — runtime artifact, not needed for dual-pass) ──
1292
1617
  let pbResult: { playbook: Playbook; playbookPath: string };
@@ -1349,7 +1674,9 @@ async function siteReplayAndDiff(
1349
1674
  ): Promise<ClassifiedValue[] | undefined> {
1350
1675
  try {
1351
1676
  const { replayRawSession } = await import('./replay-capture.ts');
1352
- const { diffTriagedSessions, triageByAlignment } = await import('./session-diff.ts');
1677
+ const { diffTriagedSessions, triageByAlignment, mergeClassifications } = await import(
1678
+ './session-diff.ts'
1679
+ );
1353
1680
 
1354
1681
  const session = loadJsonFile(
1355
1682
  sessionPath,
@@ -1392,9 +1719,49 @@ async function siteReplayAndDiff(
1392
1719
 
1393
1720
  mp.update('replay', 'Diffing replay against original...');
1394
1721
 
1722
+ // Pass 1: original recording vs the automated browser replay.
1395
1723
  const triaged2Seqs = triageByAlignment(session.requests, replayRequests);
1396
1724
  const triaged2Requests = replayRequests.filter((r) => triaged2Seqs.includes(r.seq));
1397
- const diffResult = diffTriagedSessions(session, { requests: triaged2Requests });
1725
+ const replayDiff = diffTriagedSessions(session, { requests: triaged2Requests });
1726
+ const diffPasses: ClassifiedValue[][] = [replayDiff.classifications];
1727
+
1728
+ // Additional passes: original recording vs every OTHER real recording of
1729
+ // this site. Real recordings come from a trusted browser, so they reproduce
1730
+ // anti-bot-protected requests the automated replay may be blocked from
1731
+ // making (e.g. Akamai denies Playwright at the page level). A value
1732
+ // identical across time-separated recordings is static infrastructure
1733
+ // (GraphQL safelisting signatures, persisted-query hashes, app keys) and
1734
+ // must be kept even when the replay never observed it — see
1735
+ // mergeClassifications. All passes share `session` as the original, so
1736
+ // originalSeq aligns them.
1737
+ let crossRecordingCount = 0;
1738
+ try {
1739
+ const sessionAbs = pathResolve(sessionPath);
1740
+ const others = listSiteSessions(site).filter((s) => pathResolve(s.absPath) !== sessionAbs);
1741
+ for (const info of others) {
1742
+ try {
1743
+ const other = loadJsonFile(
1744
+ info.absPath,
1745
+ SessionSchema,
1746
+ { notFound: 'Other recording not found.' },
1747
+ 'session',
1748
+ );
1749
+ const seqs = triageByAlignment(session.requests, other.requests);
1750
+ const reqs = other.requests.filter((r) => seqs.includes(r.seq));
1751
+ diffPasses.push(diffTriagedSessions(session, { requests: reqs }).classifications);
1752
+ crossRecordingCount++;
1753
+ } catch {
1754
+ // Skip a malformed sibling recording; the other passes still stand.
1755
+ }
1756
+ }
1757
+ } catch {
1758
+ // No sibling recordings available — replay-only classification stands.
1759
+ }
1760
+
1761
+ const diffResult = {
1762
+ ...replayDiff,
1763
+ classifications: mergeClassifications(diffPasses),
1764
+ };
1398
1765
 
1399
1766
  const classPath = pathJoin(localSiteDir(site), '.classifications.json');
1400
1767
  writeFileSync(classPath, JSON.stringify(diffResult, null, 2));
@@ -1402,6 +1769,10 @@ async function siteReplayAndDiff(
1402
1769
  mp.clear();
1403
1770
  mp.remove('replay');
1404
1771
 
1772
+ const sourcesLabel =
1773
+ crossRecordingCount > 0
1774
+ ? `replay + ${crossRecordingCount} recording${crossRecordingCount === 1 ? '' : 's'}`
1775
+ : 'replay';
1405
1776
  const nonConstant = diffResult.classifications.filter((c) => c.classification !== 'constant');
1406
1777
  if (nonConstant.length > 0) {
1407
1778
  const counts: Record<string, number> = {};
@@ -1410,10 +1781,12 @@ async function siteReplayAndDiff(
1410
1781
  .map(([k, v]) => `${v} ${k}`)
1411
1782
  .join(', ');
1412
1783
  p.log.info(
1413
- `Dual-pass: ${nonConstant.length} ephemeral values (${breakdown}). ${replayRequests.length} requests captured.`,
1784
+ `Dual-pass (${sourcesLabel}): ${nonConstant.length} ephemeral values (${breakdown}). ${replayRequests.length} requests captured.`,
1414
1785
  );
1415
1786
  } else {
1416
- p.log.info(`Dual-pass: all values constant. ${replayRequests.length} requests captured.`);
1787
+ p.log.info(
1788
+ `Dual-pass (${sourcesLabel}): all values constant. ${replayRequests.length} requests captured.`,
1789
+ );
1417
1790
  }
1418
1791
 
1419
1792
  mp.render();
@@ -1427,55 +1800,9 @@ async function siteReplayAndDiff(
1427
1800
  }
1428
1801
  }
1429
1802
 
1430
- export async function mapLimit<T, R>(
1431
- items: T[],
1432
- concurrency: number,
1433
- fn: (item: T) => Promise<R>,
1434
- ): Promise<R[]> {
1435
- const results = new Array<R>(items.length);
1436
- let next = 0;
1437
- let firstError: unknown;
1438
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
1439
- while (next < items.length && firstError === undefined) {
1440
- const index = next++;
1441
- const item = items[index];
1442
- if (item === undefined) continue;
1443
- try {
1444
- results[index] = await fn(item);
1445
- } catch (err) {
1446
- firstError ??= err;
1447
- }
1448
- }
1449
- });
1450
- await Promise.allSettled(workers);
1451
- if (firstError !== undefined) throw firstError;
1452
- return results;
1453
- }
1454
-
1455
- type SettledResult<R> = { ok: true; value: R } | { ok: false; error: unknown };
1456
-
1457
- export async function mapLimitSettled<T, R>(
1458
- items: T[],
1459
- concurrency: number,
1460
- fn: (item: T) => Promise<R>,
1461
- ): Promise<SettledResult<R>[]> {
1462
- const results = new Array<SettledResult<R>>(items.length);
1463
- let next = 0;
1464
- const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
1465
- while (next < items.length) {
1466
- const index = next++;
1467
- const item = items[index];
1468
- if (item === undefined) continue;
1469
- try {
1470
- results[index] = { ok: true, value: await fn(item) };
1471
- } catch (err) {
1472
- results[index] = { ok: false, error: err };
1473
- }
1474
- }
1475
- });
1476
- await Promise.allSettled(workers);
1477
- return results;
1478
- }
1803
+ // Bounded-concurrency fan-out helpers now live in concurrency.ts (so teach-plan.ts
1804
+ // can reuse them without an import cycle). Re-exported here for existing callers.
1805
+ export { mapLimit, mapLimitSettled };
1479
1806
 
1480
1807
  // ─── Credential capture (interactive) ───────────────────────────────────────
1481
1808
 
@@ -1568,6 +1895,92 @@ async function promptAndPersistCredentials(opts: {
1568
1895
  };
1569
1896
  }
1570
1897
 
1898
+ /** Find request seqs whose body contains a password-shaped key (per the
1899
+ * shared sensitive-keys dictionary) — regardless of whether credential
1900
+ * extraction succeeded in pairing it with a username.
1901
+ *
1902
+ * Used by the post-redact pairing audit to detect the failure mode where
1903
+ * a recorded login *did* happen but the extractor couldn't pair its
1904
+ * fields, so the redacted session has no `${credential.X}` placeholders
1905
+ * and the compile stage will template credentials as plain parameters.
1906
+ *
1907
+ * Body shapes covered:
1908
+ * - JSON (any nesting depth)
1909
+ * - form-urlencoded (`a=b&c=d`)
1910
+ * - multipart/form-data (sniffed by leading `--<boundary>`)
1911
+ * - URL query string (covers GET-based logins)
1912
+ *
1913
+ * The scan is intentionally lossy and fast: we substring-check for
1914
+ * password-like key names in the raw body text plus exact-key checks in
1915
+ * parsed JSON. False positives are tolerable here (one extra warning);
1916
+ * false negatives are not (silent failure recurrence). */
1917
+ export function findUnpairedPasswordRequests(session: Session): number[] {
1918
+ const PASSWORD_LIKE_TOKENS = passwordLikeTokens();
1919
+ const out: number[] = [];
1920
+ for (const req of session.requests) {
1921
+ let hit = false;
1922
+ // 1. Check URL query string for password-shaped param names.
1923
+ try {
1924
+ const u = new URL(req.url);
1925
+ for (const k of u.searchParams.keys()) {
1926
+ if (isSensitiveCredentialKey(k)) {
1927
+ hit = true;
1928
+ break;
1929
+ }
1930
+ }
1931
+ } catch {
1932
+ // Bad URL — skip URL-side check.
1933
+ }
1934
+
1935
+ // 2. Check body — try JSON first, then fall back to substring scan
1936
+ // that covers form-urlencoded and multipart in one pass.
1937
+ if (!hit && req.body) {
1938
+ const body = req.body;
1939
+ // JSON path.
1940
+ try {
1941
+ const parsed = JSON.parse(body);
1942
+ if (hasPasswordLikeKey(parsed)) hit = true;
1943
+ } catch {
1944
+ // Not JSON — substring scan handles form / multipart / anything
1945
+ // else that contains the key name verbatim.
1946
+ }
1947
+ if (!hit) {
1948
+ const lower = body.toLowerCase();
1949
+ for (const tok of PASSWORD_LIKE_TOKENS) {
1950
+ // Match a key-shaped occurrence: `"password"` (JSON), `password=`
1951
+ // (form/query), or `name="password"` (multipart). Avoid bare
1952
+ // substring matches that could fire on prose payloads.
1953
+ if (
1954
+ lower.includes(`"${tok}"`) ||
1955
+ lower.includes(`${tok}=`) ||
1956
+ lower.includes(`name="${tok}"`)
1957
+ ) {
1958
+ hit = true;
1959
+ break;
1960
+ }
1961
+ }
1962
+ }
1963
+ }
1964
+ if (hit) out.push(req.seq);
1965
+ }
1966
+ return out;
1967
+ }
1968
+
1969
+ /** Recursive helper for findUnpairedPasswordRequests' JSON path. */
1970
+ function hasPasswordLikeKey(node: unknown): boolean {
1971
+ if (Array.isArray(node)) {
1972
+ for (const v of node) if (hasPasswordLikeKey(v)) return true;
1973
+ return false;
1974
+ }
1975
+ if (node && typeof node === 'object') {
1976
+ for (const [k, v] of Object.entries(node)) {
1977
+ if (isSensitiveCredentialKey(k)) return true;
1978
+ if (hasPasswordLikeKey(v)) return true;
1979
+ }
1980
+ }
1981
+ return false;
1982
+ }
1983
+
1571
1984
  /** Write `<workflowDir>/credentials.manifest.json` so consumers of the
1572
1985
  * generated tool know what credentials to provision. No values, just names. */
1573
1986
  function exportSiteManifest(
@@ -1958,47 +2371,58 @@ async function offerSkillExport(opts: {
1958
2371
  }
1959
2372
  }
1960
2373
 
1961
- // ─── Session combination (post-record, pre-redact) ────────────────────────
2374
+ // ─── Session combination (post-record or post-from-session, pre-redact) ──
1962
2375
 
1963
- async function promptSessionCombine(opts: {
2376
+ async function combineAvailableSessions(opts: {
1964
2377
  site: string;
1965
2378
  currentSessionPath: string;
1966
2379
  noInteractive: boolean;
2380
+ fromSession: boolean;
1967
2381
  }): Promise<string> {
1968
- if (opts.noInteractive) return opts.currentSessionPath;
1969
-
1970
- const pastSessions = listSiteSessions(opts.site).filter(
1971
- (s) => s.absPath !== opts.currentSessionPath,
1972
- );
2382
+ // Discover sibling sessions. For --from-session, look in the source
2383
+ // directory (which may differ from the target site's sessions dir).
2384
+ // For normal recordings, look in the site's sessions directory.
2385
+ const pastSessions = opts.fromSession
2386
+ ? listSessionsInDir(pathDirname(opts.currentSessionPath)).filter(
2387
+ (s) => s.absPath !== opts.currentSessionPath,
2388
+ )
2389
+ : listSiteSessions(opts.site).filter((s) => s.absPath !== opts.currentSessionPath);
1973
2390
 
1974
2391
  if (pastSessions.length === 0) return opts.currentSessionPath;
1975
2392
 
1976
- const combine = await p.confirm({
1977
- message: `Found ${pastSessions.length} past recording session${pastSessions.length === 1 ? '' : 's'} for "${opts.site}". Combine with the new recording?`,
1978
- initialValue: false,
1979
- });
2393
+ let selectedPaths: string[];
1980
2394
 
1981
- if (p.isCancel(combine) || !combine) return opts.currentSessionPath;
2395
+ if (opts.noInteractive) {
2396
+ // Auto-combine all available sessions
2397
+ selectedPaths = pastSessions.map((s) => s.absPath);
2398
+ p.log.info(`Auto-combining ${pastSessions.length + 1} session(s) for "${opts.site}".`);
2399
+ } else {
2400
+ const combine = await p.confirm({
2401
+ message: `Found ${pastSessions.length} past recording session${pastSessions.length === 1 ? '' : 's'}${opts.fromSession ? ' in the source directory' : ` for "${opts.site}"`}. Combine with the ${opts.fromSession ? 'provided' : 'new'} recording?`,
2402
+ initialValue: true,
2403
+ });
1982
2404
 
1983
- const selected = await p.multiselect({
1984
- message:
1985
- 'Select sessions to combine with the new recording:\n (press [space] to toggle, [enter] to submit)',
1986
- required: true,
1987
- initialValues: pastSessions.map((s) => s.absPath),
1988
- options: pastSessions.map((s) => ({
1989
- value: s.absPath,
1990
- label: `${s.friendlyTimestamp} — ${s.url}`,
1991
- hint: `${s.requestCount} requests, ${s.narrationCount} narrations`,
1992
- })),
1993
- });
2405
+ if (p.isCancel(combine) || !combine) return opts.currentSessionPath;
1994
2406
 
1995
- if (p.isCancel(selected)) return opts.currentSessionPath;
2407
+ const selected = await p.multiselect({
2408
+ message: 'Select sessions to combine:\n (press [space] to toggle, [enter] to submit)',
2409
+ required: true,
2410
+ initialValues: pastSessions.map((s) => s.absPath),
2411
+ options: pastSessions.map((s) => ({
2412
+ value: s.absPath,
2413
+ label: `${s.friendlyTimestamp} — ${s.url}`,
2414
+ hint: `${s.requestCount} requests, ${s.narrationCount} narrations`,
2415
+ })),
2416
+ });
1996
2417
 
1997
- const selectedPaths = selected as string[];
1998
- if (selectedPaths.length === 0) return opts.currentSessionPath;
2418
+ if (p.isCancel(selected)) return opts.currentSessionPath;
2419
+
2420
+ selectedPaths = selected as string[];
2421
+ if (selectedPaths.length === 0) return opts.currentSessionPath;
2422
+ }
1999
2423
 
2000
2424
  const spinner = p.spinner();
2001
- spinner.start('Combining sessions...');
2425
+ spinner.start('Combining sessions');
2002
2426
 
2003
2427
  const sessions: Session[] = [];
2004
2428
  for (const path of selectedPaths) {
@@ -2020,8 +2444,21 @@ async function promptSessionCombine(opts: {
2020
2444
  ),
2021
2445
  );
2022
2446
 
2023
- const combined = mergeSessions(sessions);
2024
- const combinedPath = writeCombinedSession(opts.site, combined);
2447
+ const { combined, combinedPath } = await traced(
2448
+ 'teach.combine_sessions',
2449
+ 'CHAIN',
2450
+ { 'imprint.site': opts.site },
2451
+ async (span) => {
2452
+ const merged = mergeSessions(sessions);
2453
+ const path = writeCombinedSession(opts.site, merged);
2454
+ setSpanAttributes(span, {
2455
+ 'imprint.combine.session_count': sessions.length,
2456
+ 'imprint.combine.request_count': merged.requests.length,
2457
+ 'imprint.combine.narration_count': merged.narration.length,
2458
+ });
2459
+ return { combined: merged, combinedPath: path };
2460
+ },
2461
+ );
2025
2462
 
2026
2463
  spinner.stop(
2027
2464
  `Combined ${sessions.length} sessions (${combined.requests.length} requests, ${combined.narration.length} narrations).`,
@@ -2110,9 +2547,7 @@ async function writeQuickBackendsCache(workflowDir: string, workflow: Workflow):
2110
2547
  },
2111
2548
  };
2112
2549
  writeFileSync(backendsPath, `${JSON.stringify(cache, null, 2)}\n`);
2113
- process.stderr.write(
2114
- `[imprint teach] backend probe: fetch blocked → wrote ${backendsPath}\n`,
2115
- );
2550
+ log(`backend probe: fetch blocked → wrote ${backendsPath}`);
2116
2551
  }
2117
2552
  } catch {
2118
2553
  // Fetch failed (timeout, network error) — don't write cache, let runtime discover