imprint-mcp 0.6.0 → 0.6.2

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 (43) hide show
  1. package/README.md +2 -0
  2. package/package.json +4 -2
  3. package/prompts/audit-agent.md +4 -2
  4. package/prompts/auth-compile-agent.md +149 -158
  5. package/prompts/build-planning.md +14 -9
  6. package/prompts/compile-agent.md +46 -23
  7. package/prompts/live-verifier-agent.md +101 -0
  8. package/prompts/playbook-compilation.md +8 -1
  9. package/prompts/tool-candidate-detection.md +9 -29
  10. package/src/cli.ts +83 -1
  11. package/src/imprint/audit.ts +219 -20
  12. package/src/imprint/auth-compile-agent.ts +215 -155
  13. package/src/imprint/auth-compile-tools.ts +300 -118
  14. package/src/imprint/auth-verifier.ts +167 -180
  15. package/src/imprint/backend-cache.ts +18 -11
  16. package/src/imprint/backend-ladder.ts +225 -177
  17. package/src/imprint/build-plan.ts +3 -8
  18. package/src/imprint/cdp-browser-fetch.ts +930 -83
  19. package/src/imprint/claude-cli-compile.ts +46 -20
  20. package/src/imprint/codex-cli-compile.ts +436 -139
  21. package/src/imprint/compile-agent-types.ts +15 -10
  22. package/src/imprint/compile-agent.ts +88 -28
  23. package/src/imprint/compile-tools.ts +228 -39
  24. package/src/imprint/compile-verification.ts +187 -0
  25. package/src/imprint/compile.ts +71 -2
  26. package/src/imprint/credential-store.ts +57 -44
  27. package/src/imprint/emit.ts +3 -1
  28. package/src/imprint/freeform-redact.ts +27 -6
  29. package/src/imprint/live-verifier.ts +1665 -0
  30. package/src/imprint/llm.ts +22 -3
  31. package/src/imprint/login.ts +107 -25
  32. package/src/imprint/mcp-compile-server.ts +447 -114
  33. package/src/imprint/mcp-live-verifier-server.ts +359 -0
  34. package/src/imprint/mcp-server.ts +116 -35
  35. package/src/imprint/probe-backends.ts +88 -7
  36. package/src/imprint/recording-request.ts +36 -0
  37. package/src/imprint/request-capture.ts +40 -17
  38. package/src/imprint/runtime.ts +490 -337
  39. package/src/imprint/teach-state.ts +36 -2
  40. package/src/imprint/teach.ts +302 -146
  41. package/src/imprint/tool-candidates.ts +8 -10
  42. package/src/imprint/tool-loader.ts +12 -4
  43. package/src/imprint/types.ts +137 -68
@@ -7,6 +7,7 @@
7
7
  * step, and multiple workflows per site (each in its own subdirectory).
8
8
  */
9
9
 
10
+ import { createHash } from 'node:crypto';
10
11
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
12
  import { homedir } from 'node:os';
12
13
  import {
@@ -18,12 +19,14 @@ import {
18
19
  import * as p from '@clack/prompts';
19
20
  import type { OnDeadlineReached } from './agent.ts';
20
21
  import { compileAuthAgent } from './auth-compile-agent.ts';
22
+ import { authWorkflowHash } from './auth-compile-tools.ts';
21
23
  import {
22
24
  type SharedModuleManifestEntry,
23
25
  buildPlanSidecarPath,
24
26
  readBuildPlanFile,
25
27
  topoLevelsForTools,
26
28
  } from './build-plan.ts';
29
+ import type { CompileAgentResult } from './compile-agent-types.ts';
27
30
  import {
28
31
  type CompileAgentProgress,
29
32
  type TriageResult,
@@ -55,6 +58,7 @@ import {
55
58
  generateSkillMd,
56
59
  } from './integrations.ts';
57
60
  import {
61
+ type LLMOptions,
58
62
  type ProviderName,
59
63
  type ProviderStatus,
60
64
  detectTeachProvider,
@@ -65,6 +69,7 @@ import { loadJsonFile } from './load-json.ts';
65
69
  import { createLog, muteLog, unmuteLog } from './log.ts';
66
70
  import { MultiProgress } from './multi-progress.ts';
67
71
  import { localSiteDir, localToolDir } from './paths.ts';
72
+ import { rebindExistingBackendsCacheToWorkflow } from './probe-backends.ts';
68
73
  import { describeAgentActivity, formatElapsed } from './progress.ts';
69
74
  import { record } from './record.ts';
70
75
  import { detectPageMintedHeaders, redactSession } from './redact.ts';
@@ -118,6 +123,37 @@ import { setSpanAttributes, shutdownTracing, traced } from './tracing.ts';
118
123
  import { CronConfigSchema, SessionSchema, WorkflowSchema } from './types.ts';
119
124
  import type { CronConfig, Playbook, Session, Workflow } from './types.ts';
120
125
 
126
+ export function authCompileLlmConfig(provider: ProviderName, model: string): LLMOptions {
127
+ return { provider, model };
128
+ }
129
+
130
+ export function authCompletionMatches(
131
+ completion: WorkflowState['authCompletion'],
132
+ expected: { toolName: string; buildPlanHash: string; workflowHash: string },
133
+ ): boolean {
134
+ return Boolean(
135
+ completion &&
136
+ completion.toolName === expected.toolName &&
137
+ completion.buildPlanHash === expected.buildPlanHash &&
138
+ completion.workflowHash === expected.workflowHash,
139
+ );
140
+ }
141
+
142
+ export function hasDurableAuthState(
143
+ workflow: Workflow,
144
+ stored: { values: Record<string, string>; cookies: unknown[]; storage?: unknown[] },
145
+ ): boolean {
146
+ const required = workflow.authConfig?.persist ?? [];
147
+ if (required.length > 0) {
148
+ return required.every((name) => String(stored.values[name] ?? '').length > 0);
149
+ }
150
+ return stored.cookies.length > 0 || (stored.storage?.length ?? 0) > 0;
151
+ }
152
+
153
+ function fileSha256(path: string): string {
154
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
155
+ }
156
+
121
157
  export {
122
158
  buildTeachStateFromSession,
123
159
  resolveTeachStatePath,
@@ -126,9 +162,11 @@ export {
126
162
 
127
163
  /**
128
164
  * How many compile agents run in parallel when more than one tool is selected.
165
+ * Each compiler can now invoke a separate live semantic verifier, so keep this
166
+ * at 2 to avoid provider rate-limit bursts and same-site anti-bot pressure.
129
167
  * Single-tool runs still use concurrency 1.
130
168
  */
131
- const COMPILE_CONCURRENCY = 3;
169
+ const COMPILE_CONCURRENCY = 2;
132
170
 
133
171
  /** Module logger — suppressed during teach's spinner phases via muteLog(). */
134
172
  const log = createLog('teach');
@@ -957,16 +995,12 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
957
995
  allLoginSeqs.length > 0
958
996
  ? {
959
997
  sharedContext: {
960
- loginRequestSeqs: allLoginSeqs,
998
+ loginRequestSeqs: credentialSeqs,
961
999
  credentialNames: [],
962
1000
  tokenExtractionNotes: '',
963
1001
  sharedHelperNotes: '',
964
- twoFactorDetected: false,
965
- twoFactorType: 'none' as const,
966
- twoFactorRequestSeqs: [],
967
- authCompletionSeqs: [],
968
- twoFactorContext: [],
969
- twoFactorNotes: '',
1002
+ authRequestSeqs: allLoginSeqs,
1003
+ authNotes: '',
970
1004
  },
971
1005
  }
972
1006
  : {},
@@ -1187,21 +1221,36 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1187
1221
  });
1188
1222
  }
1189
1223
  // A `--from-step` resume into plan-prereqs/generate reconstructs the prior
1190
- // run's tools from persisted state (shared-module planning needs ≥2 tools, and
1191
- // a multi-tool generate resumes all of them) — not just the most-recent
1192
- // workflow. selectMultiToolResumePlans scopes this to the same recording as the
1224
+ // run's tools from persisted state (shared-module planning may need ≥2 tools).
1225
+ // An explicit --all-tools or interactive resume keeps the full set; documented
1226
+ // non-interactive behavior selects only the persisted primary candidate.
1227
+ // selectMultiToolResumePlans scopes reconstruction to the same recording as the
1193
1228
  // resume target and to tools that actually reached `startFrom`'s prerequisites,
1194
1229
  // so a sibling from a different run can't be compiled against the wrong session
1195
1230
  // and one that failed earlier can't crash loading a missing artifact. Confined
1196
1231
  // to --from-step so interactive resume keeps its single-tool behavior.
1197
1232
  const allCandidatePlans: CandidateCompilePlan[] = [];
1198
1233
  if (opts.fromStep) {
1199
- const selection = selectMultiToolResumePlans(state, workflowKey, startFrom);
1234
+ let plannedToolNames: Set<string> | undefined;
1235
+ const persistedBuildPlanPath = resolveTeachStatePath(site, ws?.buildPlanPath);
1236
+ if (persistedBuildPlanPath && existsSync(persistedBuildPlanPath)) {
1237
+ const persistedBuildPlan = readBuildPlanFile(persistedBuildPlanPath);
1238
+ if (persistedBuildPlan) {
1239
+ plannedToolNames = new Set(persistedBuildPlan.perTool.map((tool) => tool.toolName));
1240
+ }
1241
+ }
1242
+ const selection = selectMultiToolResumePlans(state, workflowKey, startFrom, {
1243
+ noInteractive: opts.noInteractive,
1244
+ allTools: opts.allTools,
1245
+ plannedToolNames,
1246
+ });
1200
1247
  for (const { workflowKey: skippedKey, reason } of selection.skipped) {
1201
1248
  p.log.warn(
1202
1249
  reason === 'different-recording'
1203
1250
  ? `Skipping tool "${skippedKey}" for --from-step ${startFrom}: it belongs to a different recording than the resume target.`
1204
- : `Skipping tool "${skippedKey}" for --from-step ${startFrom}: its prior run didn't reach "${startFrom}" — resume an earlier step or re-run it.`,
1251
+ : reason === 'not-in-build-plan'
1252
+ ? `Skipping tool "${skippedKey}" for --from-step ${startFrom}: it is not in the current build plan.`
1253
+ : `Skipping tool "${skippedKey}" for --from-step ${startFrom}: its prior run didn't reach "${startFrom}" — resume an earlier step or re-run it.`,
1205
1254
  );
1206
1255
  }
1207
1256
  for (const sel of selection.plans) {
@@ -1273,7 +1322,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1273
1322
  // full) while dropping the bulk of telemetry/asset/noise requests — e.g. 415
1274
1323
  // vs 4396 requests on a big multi-tool site. Handing the agent the entire
1275
1324
  // redacted recording instead makes it burn its turn/time budget exploring
1276
- // thousands of irrelevant requests (the amex auth compile never converged on a
1325
+ // thousands of irrelevant requests (large auth recordings otherwise may never converge on a
1277
1326
  // playbook because of this). The detect-candidates summary already triages;
1278
1327
  // this aligns the compile session with it.
1279
1328
  const triagedCandidate = redactedPath?.replace(/\.redacted\.json$/, '.triaged.json');
@@ -1398,92 +1447,142 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1398
1447
  // before any tool (auth or data) compiles.
1399
1448
  if (stopIdx < STEPS.indexOf('generate')) await finishEarly();
1400
1449
 
1450
+ if (authDetected && willGenerate && !buildPlanPath) {
1451
+ throw new Error(
1452
+ 'Authentication was detected, but auth planning did not produce a build plan. Data-tool compilation was not started.',
1453
+ );
1454
+ }
1455
+
1401
1456
  // ── auth-tool: agentic compile loop + interactive 2FA ──
1402
1457
  if (buildPlanPath && willGenerate) {
1403
1458
  const buildPlan = readBuildPlanFile(buildPlanPath);
1404
1459
  if (buildPlan?.authTool) {
1405
1460
  const authPlan = buildPlan.authTool;
1406
- const redactedSession = SessionSchema.parse(
1407
- JSON.parse(readFileSync(compileSessionPath, 'utf8')),
1461
+ const buildPlanHash = fileSha256(buildPlanPath);
1462
+ const existingAuthWorkflowPath = pathJoin(
1463
+ localToolDir(site, authPlan.toolName),
1464
+ 'workflow.json',
1408
1465
  );
1409
- // Passwordless / OTP-only logins (e.g. email + emailed code, magic link)
1410
- // carry no password for the username+password extractor to pair, so
1411
- // teachCredentials is empty. Derive the planner-declared credential values
1412
- // from the recorded login request(s), persist them, and back-fill
1413
- // ${credential.X} into the redacted session the agent reads.
1414
- if (!teachCredentials && sessionPath && existsSync(sessionPath)) {
1415
- try {
1416
- const rawForCreds = SessionSchema.parse(JSON.parse(readFileSync(sessionPath, 'utf8')));
1417
- const derived = deriveLoginCredentials(
1418
- rawForCreds,
1419
- authPlan.loginRequestSeqs,
1466
+ const existingAuthIndexPath = pathJoin(localToolDir(site, authPlan.toolName), 'index.ts');
1467
+ const existingAuthWorkflow =
1468
+ existsSync(existingAuthWorkflowPath) && existsSync(existingAuthIndexPath)
1469
+ ? WorkflowSchema.parse(JSON.parse(readFileSync(existingAuthWorkflowPath, 'utf8')))
1470
+ : undefined;
1471
+ const storedAuthState = existingAuthWorkflow
1472
+ ? await loadSiteCredentials(site).catch(() => null)
1473
+ : null;
1474
+ const existingAuthWorkflowHash = existingAuthWorkflow
1475
+ ? authWorkflowHash(existingAuthWorkflow)
1476
+ : undefined;
1477
+ const reusableCompletion =
1478
+ existingAuthWorkflow &&
1479
+ existingAuthWorkflowHash &&
1480
+ storedAuthState &&
1481
+ hasDurableAuthState(existingAuthWorkflow, storedAuthState)
1482
+ ? plans
1483
+ .map((plan) => state.workflows[plan.workflowKey]?.authCompletion)
1484
+ .find((completion) =>
1485
+ authCompletionMatches(completion, {
1486
+ toolName: authPlan.toolName,
1487
+ buildPlanHash,
1488
+ workflowHash: existingAuthWorkflowHash as string,
1489
+ }),
1490
+ )
1491
+ : undefined;
1492
+ if (reusableCompletion) {
1493
+ p.log.info(
1494
+ `Reusing verified auth tool "${authPlan.toolName}"; auth artifacts are unchanged.`,
1495
+ );
1496
+ } else {
1497
+ const redactedSession = SessionSchema.parse(
1498
+ JSON.parse(readFileSync(compileSessionPath, 'utf8')),
1499
+ );
1500
+ // A resumed generate step may still have valid credentials from an earlier
1501
+ // successful teach. Prefer those over values reconstructed from the old
1502
+ // recording so a stale recording cannot overwrite the credential store.
1503
+ if (!teachCredentials) {
1504
+ const view = await loadSiteCredentials(site).catch(() => null);
1505
+ const storedValues = selectCompleteAuthCredentials(
1506
+ view?.values ?? {},
1420
1507
  authPlan.credentialNames,
1421
1508
  );
1422
- if (Object.keys(derived.values).length > 0) {
1423
- const backend = await getCredentialBackend();
1424
- for (const [name, value] of Object.entries(derived.values)) {
1425
- await backend.setSecret(site, name, value);
1426
- upsertManifestEntry(site, {
1427
- name,
1428
- kind: 'username',
1429
- description: 'Login identifier',
1430
- });
1509
+ if (storedValues) {
1510
+ teachCredentials = { site, values: storedValues };
1511
+ if (authPlan.credentialNames.length > 0) {
1512
+ p.log.info(
1513
+ `Using stored credentials for "${site}": ${authPlan.credentialNames.join(', ')}`,
1514
+ );
1431
1515
  }
1432
- applyCredentialPlaceholders(redactedSession, derived.replacements);
1433
- teachCredentials = { site, values: derived.values };
1434
- p.log.success(
1435
- `Derived ${Object.keys(derived.values).length} credential(s) for passwordless login: ${Object.keys(derived.values).join(', ')}`,
1436
- );
1437
1516
  }
1438
- } catch (err) {
1439
- p.log.warn(
1440
- `Credential derivation failed: ${err instanceof Error ? err.message : String(err)}`,
1441
- );
1442
1517
  }
1443
- }
1444
1518
 
1445
- // The recording can't always yield credentials — hosted/redirect logins
1446
- // (Auth0, Okta, …) submit the password as a page navigation (no XHR body),
1447
- // and the capture listener masks password fields. Before skipping, reuse
1448
- // anything already stored, then — when interactive — prompt for exactly the
1449
- // credentials the detection LLM identified for this login
1450
- // (authPlan.credentialNames; the live 2FA code is intentionally not in that
1451
- // list — it's entered during verification).
1452
- if (!teachCredentials) {
1453
- const view = await loadSiteCredentials(site).catch(() => null);
1454
- if (view && Object.keys(view.values).length > 0) {
1455
- teachCredentials = { site, values: view.values };
1456
- p.log.info(
1457
- `Using stored credentials for "${site}": ${Object.keys(view.values).join(', ')}`,
1458
- );
1459
- }
1460
- }
1461
- if (!teachCredentials && !opts.noInteractive && authPlan.credentialNames.length > 0) {
1462
- let detectedUsername: string | undefined;
1463
- if (sessionPath && existsSync(sessionPath)) {
1519
+ // Passwordless / OTP-only logins (e.g. email + emailed code, magic link)
1520
+ // carry no password for the username+password extractor to pair, so
1521
+ // teachCredentials can be empty. If no complete stored set exists, derive
1522
+ // the planner-declared credential values from the recorded login request(s),
1523
+ // persist them, and back-fill ${credential.X} into the redacted session the
1524
+ // agent reads.
1525
+ if (!teachCredentials && sessionPath && existsSync(sessionPath)) {
1464
1526
  try {
1465
- detectedUsername = detectRecordedUsername(
1466
- SessionSchema.parse(JSON.parse(readFileSync(sessionPath, 'utf8'))),
1527
+ const rawForCreds = SessionSchema.parse(JSON.parse(readFileSync(sessionPath, 'utf8')));
1528
+ const derived = deriveLoginCredentials(
1529
+ rawForCreds,
1530
+ authPlan.credentialRequestSeqs,
1531
+ authPlan.credentialNames,
1532
+ );
1533
+ if (Object.keys(derived.values).length > 0) {
1534
+ const backend = await getCredentialBackend();
1535
+ for (const [name, value] of Object.entries(derived.values)) {
1536
+ await backend.setSecret(site, name, value);
1537
+ upsertManifestEntry(site, {
1538
+ name,
1539
+ kind: 'username',
1540
+ description: 'Login identifier',
1541
+ });
1542
+ }
1543
+ applyCredentialPlaceholders(redactedSession, derived.replacements);
1544
+ teachCredentials = { site, values: derived.values };
1545
+ p.log.success(
1546
+ `Derived ${Object.keys(derived.values).length} credential(s) for passwordless login: ${Object.keys(derived.values).join(', ')}`,
1547
+ );
1548
+ }
1549
+ } catch (err) {
1550
+ p.log.warn(
1551
+ `Credential derivation failed: ${err instanceof Error ? err.message : String(err)}`,
1467
1552
  );
1468
- } catch {
1469
- /* best-effort pre-fill only */
1470
1553
  }
1471
1554
  }
1472
- teachCredentials = await promptForCredentials({
1473
- site,
1474
- names: authPlan.credentialNames,
1475
- detectedUsername,
1476
- });
1477
- }
1478
1555
 
1479
- if (!teachCredentials) {
1480
- const hint = opts.noInteractive
1481
- ? ` Set them with \`imprint credential set ${site} <name>\` (${authPlan.credentialNames.join(', ') || 'the login credentials'}), then resume with \`imprint teach ${site} --from-step generate\`.`
1482
- : '';
1483
- p.log.warn(
1484
- `Auth tool "${authPlan.toolName}" was planned but no credentials are available — skipping auth compile.${hint} Data tools will attempt inline login.`,
1485
- );
1486
- } else {
1556
+ // The recording cannot always yield credentials because the capture layer
1557
+ // masks sensitive fields. Reuse a complete stored set or prompt for the
1558
+ // credential names identified during planning.
1559
+ if (!teachCredentials && !opts.noInteractive && authPlan.credentialNames.length > 0) {
1560
+ let detectedUsername: string | undefined;
1561
+ if (sessionPath && existsSync(sessionPath)) {
1562
+ try {
1563
+ detectedUsername = detectRecordedUsername(
1564
+ SessionSchema.parse(JSON.parse(readFileSync(sessionPath, 'utf8'))),
1565
+ );
1566
+ } catch {
1567
+ /* best-effort pre-fill only */
1568
+ }
1569
+ }
1570
+ teachCredentials = await promptForCredentials({
1571
+ site,
1572
+ names: authPlan.credentialNames,
1573
+ detectedUsername,
1574
+ });
1575
+ }
1576
+
1577
+ if (!teachCredentials) {
1578
+ const hint = opts.noInteractive
1579
+ ? ` Set them with \`imprint credential set ${site} <name>\` (${authPlan.credentialNames.join(', ') || 'the login credentials'}), then resume with \`imprint teach ${site} --from-step generate\`.`
1580
+ : '';
1581
+ throw new Error(
1582
+ `Auth tool "${authPlan.toolName}" was planned but no credentials are available.${hint} Data-tool compilation was not started.`,
1583
+ );
1584
+ }
1585
+
1487
1586
  spinner.start(`Compiling auth tool: ${authPlan.toolName}`);
1488
1587
  try {
1489
1588
  muteLog();
@@ -1493,23 +1592,15 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1493
1592
  sessionPath: compileSessionPath,
1494
1593
  authToolPlan: authPlan,
1495
1594
  teachCredentials,
1496
- llmConfig: { provider: compileProviderName },
1497
- // Browser-minted logins compile on the playbook rung — each live test
1498
- // launches a real browser (navigate + anti-bot settle + form submit),
1499
- // which is far slower than an API replay. Give the agent room to write
1500
- // and iterate a playbook, not just a workflow.json.
1595
+ llmConfig: authCompileLlmConfig(compileProviderName, compileModel),
1501
1596
  maxDurationMs: 20 * 60 * 1000,
1502
1597
  onProgress: (prog) => spinner.message(formatAuthProgress(prog)),
1503
- // Interactive 2FA bridge: the agent (via the verification stage) reaches
1504
- // the OTP/push challenge, then asks the user — here — for the live second
1505
- // factor. Stop the spinner + unmute logs around the prompt so it renders
1506
- // cleanly, then restore. Omitted in --no-interactive (the agent falls
1507
- // back to an unattended placeholder attempt).
1598
+ // Render agent-requested user input outside the spinner.
1508
1599
  onPrompt: opts.noInteractive
1509
1600
  ? undefined
1510
1601
  : async (message, options) => {
1511
1602
  unmuteLog();
1512
- spinner.stop('2FA required — your input needed.');
1603
+ spinner.stop('Authentication requires your input.');
1513
1604
  let answer = '';
1514
1605
  if (options && options.length > 0) {
1515
1606
  const sel = await p.select({
@@ -1522,16 +1613,15 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1522
1613
  answer = p.isCancel(txt) ? '' : String(txt ?? '');
1523
1614
  }
1524
1615
  muteLog();
1525
- spinner.start('Completing 2FA verification');
1616
+ spinner.start('Continuing authentication verification');
1526
1617
  return answer;
1527
1618
  },
1528
- // Cool-off bridge: wait out a rate-flag with NO login, informing the
1529
- // user. Bounded to 10 min; overridable via IMPRINT_AUTH_COOLOFF_MS.
1619
+ // Honor an agent-requested delay without firing a live action.
1530
1620
  onCooldown: async (minutes, reason) => {
1531
1621
  const envMs = Number(process.env.IMPRINT_AUTH_COOLOFF_MS);
1532
1622
  const ms = Number.isFinite(envMs)
1533
1623
  ? Math.max(0, envMs)
1534
- : Math.min(Math.max(minutes, 1), 10) * 60_000;
1624
+ : Math.max(0, minutes) * 60_000;
1535
1625
  unmuteLog();
1536
1626
  p.log.info(
1537
1627
  `Cooling off ~${Math.round(ms / 60000)} min before retrying the login${reason ? ` (${reason})` : ''} — no login fires during the wait.`,
@@ -1543,36 +1633,43 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1543
1633
  });
1544
1634
  unmuteLog();
1545
1635
 
1546
- if (!authResult.success || !authResult.workflowPath) {
1547
- spinner.stop('Auth tool compilation failed.');
1548
- p.log.warn(`Auth agent: ${authResult.message}\nData tools will attempt inline login.`);
1549
- } else {
1550
- emit({
1551
- workflowPath: authResult.workflowPath,
1552
- outDir: pathDirname(authResult.workflowPath),
1553
- force: true,
1554
- });
1555
- // The point of completing auth is a stored session token the data
1556
- // tools reuse. Confirm one was persisted before claiming success.
1557
- let sessionStored = false;
1558
- try {
1559
- const { loadSiteCredentials } = await import('./credential-store.ts');
1560
- const view = await loadSiteCredentials(site);
1561
- sessionStored = view.cookies.length > 0 || Object.keys(view.values).length > 0;
1562
- } catch {
1563
- /* non-fatal */
1636
+ assertSuccessfulAuthCompile(authResult);
1637
+ emit({
1638
+ workflowPath: authResult.workflowPath,
1639
+ outDir: pathDirname(authResult.workflowPath),
1640
+ force: true,
1641
+ });
1642
+ const emittedWorkflow = WorkflowSchema.parse(
1643
+ JSON.parse(readFileSync(authResult.workflowPath, 'utf8')),
1644
+ );
1645
+ const storedState = await loadSiteCredentials(site).catch(() => null);
1646
+ const sessionStored = Boolean(
1647
+ storedState && hasDurableAuthState(emittedWorkflow, storedState),
1648
+ );
1649
+ const completion = {
1650
+ toolName: authPlan.toolName,
1651
+ buildPlanHash,
1652
+ workflowHash: authWorkflowHash(emittedWorkflow),
1653
+ completedAt: new Date().toISOString(),
1654
+ };
1655
+ if (sessionStored) {
1656
+ for (const plan of plans) {
1657
+ const ws = state.workflows[plan.workflowKey];
1658
+ if (ws) ws.authCompletion = completion;
1564
1659
  }
1565
- spinner.stop(
1566
- sessionStored
1567
- ? `Auth tool compiled + session stored (${authResult.turns} turns, ${Math.round(authResult.durationMs / 1000)}s) — data tools will reuse it.`
1568
- : `Auth tool compiled (${authResult.turns} turns) — no live session stored; data tools will be unverified until you run \`imprint login ${site}\`.`,
1569
- );
1660
+ saveTeachState(site, state);
1570
1661
  }
1662
+ spinner.stop(
1663
+ sessionStored
1664
+ ? `Auth tool compiled + session stored (${authResult.turns} turns, ${Math.round(authResult.durationMs / 1000)}s) — data tools will reuse it.`
1665
+ : `Auth tool compiled (${authResult.turns} turns) — no live session stored; data tools will be unverified until you run \`imprint login ${site}\`.`,
1666
+ );
1571
1667
  } catch (err) {
1572
1668
  unmuteLog();
1573
1669
  spinner.stop('Auth tool compilation failed.');
1574
- p.log.warn(
1575
- `Auth tool failed: ${err instanceof Error ? err.message : String(err)}\nData tools will attempt inline login.`,
1670
+ throw new Error(
1671
+ `Auth stage failed; data-tool compilation was not started: ${err instanceof Error ? err.message : String(err)}`,
1672
+ { cause: err },
1576
1673
  );
1577
1674
  }
1578
1675
  }
@@ -1602,6 +1699,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1602
1699
  allTools: opts.allTools,
1603
1700
  buildPlanPath: buildPlanPath || undefined,
1604
1701
  sharedModules: sharedModulesManifest.length > 0 ? sharedModulesManifest : undefined,
1702
+ resumeExistingArtifacts: Boolean(opts.fromStep),
1605
1703
  });
1606
1704
  } finally {
1607
1705
  unmuteLog();
@@ -1695,6 +1793,21 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1695
1793
  }
1696
1794
  }
1697
1795
 
1796
+ // Capability reductions are agent decisions, not silent compile losses.
1797
+ // Keep the reasoning in workflow.json and repeat it in the final teach
1798
+ // result so operators can distinguish an intentionally smaller honest tool
1799
+ // from a compiler that simply forgot an input.
1800
+ for (const result of results) {
1801
+ for (const limitation of result.workflow.limitations ?? []) {
1802
+ const omitted = limitation.omittedParameters?.length
1803
+ ? `; omitted inputs: ${limitation.omittedParameters.join(', ')}`
1804
+ : '';
1805
+ p.log.warn(
1806
+ `tool "${result.workflow.toolName}" capability limitation — ${limitation.feature}: ${limitation.reason}${omitted}`,
1807
+ );
1808
+ }
1809
+ }
1810
+
1698
1811
  // `--to-step emit/generate/compile-playbook` runs the per-tool compile but stops
1699
1812
  // before register (platform integration) — inWindow('register') already skipped
1700
1813
  // it above. Finish with the phase-window summary instead of the normal "Done!"
@@ -1839,6 +1952,9 @@ async function compileCandidatePlans(opts: {
1839
1952
  buildPlanPath?: string;
1840
1953
  /** Shared-module build manifest (verified flags) for this site. */
1841
1954
  sharedModules?: SharedModuleManifestEntry[];
1955
+ /** The user explicitly resumed a prior teach phase; generated artifacts that
1956
+ * already exist are revision inputs, not disposable scratch output. */
1957
+ resumeExistingArtifacts?: boolean;
1842
1958
  }): Promise<TeachToolResult[]> {
1843
1959
  const concurrency = opts.plans.length === 1 ? 1 : COMPILE_CONCURRENCY;
1844
1960
  const mp = opts.plans.length > 1 ? new MultiProgress() : null;
@@ -2058,6 +2174,32 @@ function renderCompileSummary(summary: CompileOutcomeSummary): string[] {
2058
2174
  return lines;
2059
2175
  }
2060
2176
 
2177
+ type SuccessfulAuthCompile = CompileAgentResult & { success: true; workflowPath: string };
2178
+
2179
+ export function assertSuccessfulAuthCompile(
2180
+ result: CompileAgentResult,
2181
+ ): asserts result is SuccessfulAuthCompile {
2182
+ if (!result.success) {
2183
+ throw new Error(`Auth agent did not complete successfully: ${result.message}`);
2184
+ }
2185
+ if (!result.workflowPath) {
2186
+ throw new Error('Auth agent reported success without producing workflow.json.');
2187
+ }
2188
+ }
2189
+
2190
+ export function selectCompleteAuthCredentials(
2191
+ values: Record<string, string>,
2192
+ requiredNames: string[],
2193
+ ): Record<string, string> | null {
2194
+ const selected: Record<string, string> = {};
2195
+ for (const name of requiredNames) {
2196
+ const value = values[name];
2197
+ if (typeof value !== 'string' || value.length === 0) return null;
2198
+ selected[name] = value;
2199
+ }
2200
+ return selected;
2201
+ }
2202
+
2061
2203
  async function compileSelectedCandidate(opts: {
2062
2204
  plan: CandidateCompilePlan;
2063
2205
  site: string;
@@ -2074,12 +2216,15 @@ async function compileSelectedCandidate(opts: {
2074
2216
  teachCredentials?: { site: string; values: Record<string, string> };
2075
2217
  buildPlanPath?: string;
2076
2218
  sharedModules?: SharedModuleManifestEntry[];
2219
+ resumeExistingArtifacts?: boolean;
2077
2220
  }): Promise<TeachToolResult> {
2078
2221
  const { plan, site, state } = opts;
2079
2222
  const startIdx = STEPS.indexOf(plan.startFrom);
2080
2223
  const toolName = plan.candidate?.toolName ?? plan.workflowKey;
2081
2224
  const workflowDir = localToolDir(site, toolName);
2082
2225
  mkdirSync(workflowDir, { recursive: true });
2226
+ const revisionMode =
2227
+ Boolean(opts.resumeExistingArtifacts) && existsSync(pathJoin(workflowDir, 'workflow.json'));
2083
2228
 
2084
2229
  // The per-tool compile (generate → compile-playbook → emit) is ATOMIC by design:
2085
2230
  // each phase gates on startIdx ONLY (not the window's stopIdx), so once started it
@@ -2098,19 +2243,20 @@ async function compileSelectedCandidate(opts: {
2098
2243
  // mapping, request construction, response parsing, shared-module imports),
2099
2244
  // then run a single compile that follows it. Best-effort — a timeout or
2100
2245
  // error yields no plan and the compile proceeds exactly as before.
2101
- const toolPlan = plan.candidate
2102
- ? await planToolCompile({
2103
- site,
2104
- toolName,
2105
- candidate: plan.candidate,
2106
- sharedContext: plan.sharedContext,
2107
- sessionPath: opts.sessionPath,
2108
- buildPlanPath: opts.buildPlanPath,
2109
- sharedModules: opts.sharedModules,
2110
- providerName: opts.providerName,
2111
- model: opts.compileModel,
2112
- })
2113
- : undefined;
2246
+ const toolPlan =
2247
+ plan.candidate && !revisionMode
2248
+ ? await planToolCompile({
2249
+ site,
2250
+ toolName,
2251
+ candidate: plan.candidate,
2252
+ sharedContext: plan.sharedContext,
2253
+ sessionPath: opts.sessionPath,
2254
+ buildPlanPath: opts.buildPlanPath,
2255
+ sharedModules: opts.sharedModules,
2256
+ providerName: opts.providerName,
2257
+ model: opts.compileModel,
2258
+ })
2259
+ : undefined;
2114
2260
 
2115
2261
  const result = await generate({
2116
2262
  sessionPath: opts.sessionPath,
@@ -2127,6 +2273,7 @@ async function compileSelectedCandidate(opts: {
2127
2273
  buildPlanPath: opts.buildPlanPath,
2128
2274
  sharedModules: opts.sharedModules,
2129
2275
  toolPlan,
2276
+ revisionMode,
2130
2277
  });
2131
2278
 
2132
2279
  assertCandidateToolName('Compiled workflow', result.workflow.toolName, plan.candidate);
@@ -2148,6 +2295,16 @@ async function compileSelectedCandidate(opts: {
2148
2295
  if (!genResult) {
2149
2296
  throw new Error(`generate step did not produce a workflow for "${toolName}".`);
2150
2297
  }
2298
+ // Semantic verification may revise the workflow after generate() returns
2299
+ // (for example, omitting an unsupported secondary parameter and recording a
2300
+ // limitation). Reload that final artifact before compiling any fallback so
2301
+ // later agents cannot work from the pre-verification object.
2302
+ genResult.workflow = loadJsonFile(
2303
+ genResult.workflowPath,
2304
+ WorkflowSchema,
2305
+ { notFound: `workflow.json not found at ${genResult.workflowPath}` },
2306
+ 'workflow.json',
2307
+ );
2151
2308
 
2152
2309
  // ── Step 2: compile-playbook (after generate — runtime artifact, not needed for dual-pass) ──
2153
2310
  let pbResult: { playbook: Playbook; playbookPath: string };
@@ -2157,6 +2314,7 @@ async function compileSelectedCandidate(opts: {
2157
2314
  outPath: pathJoin(workflowDir, 'playbook.yaml'),
2158
2315
  llmConfig: { provider: opts.providerName },
2159
2316
  candidate: plan.candidate,
2317
+ workflow: genResult.workflow,
2160
2318
  sharedContext: plan.sharedContext,
2161
2319
  preTriagedSession: opts.sharedTriageResult,
2162
2320
  });
@@ -2187,6 +2345,11 @@ async function compileSelectedCandidate(opts: {
2187
2345
 
2188
2346
  exportSiteManifest(site, workflowDir, genResult.workflow, pbResult.playbook);
2189
2347
 
2348
+ // Emission and post-verification annotations can change workflow.json's
2349
+ // metadata hash without changing the already-proven transport behavior.
2350
+ // Reuse the existing no-network rebind so a bounded resume does not trigger
2351
+ // an unnecessary comprehensive probe on its first real call.
2352
+ rebindExistingBackendsCacheToWorkflow(workflowDir);
2190
2353
  await writeQuickBackendsCache(workflowDir, genResult.workflow);
2191
2354
 
2192
2355
  return {
@@ -3022,21 +3185,14 @@ function formatCompileProgress(progress: CompileAgentProgress): string {
3022
3185
  return `Compiling • ${activity} (${formatElapsed(progress.elapsedMs)}${retry})`;
3023
3186
  }
3024
3187
 
3025
- /** Build the auth-compile spinner line. Pure (formatting only). The turn is
3026
- * monotonic across resumable segments (no per-segment reset). When the most
3027
- * recent live verification FAILED, surface the reason (phase + error + HTTP
3028
- * status) and which live-login attempt of the budget it was — so the user sees
3029
- * what happened and why it's taking longer, not a silently-resetting counter. */
3188
+ /** Build the auth-compile spinner line. Turns stay monotonic across resumed
3189
+ * segments; live failures surface the requested action and observed status. */
3030
3190
  export function formatAuthProgress(progress: CompileAgentProgress): string {
3031
3191
  const base = `Auth compile: turn ${progress.turn}`;
3032
3192
  const lv = progress.lastVerification;
3033
3193
  if (lv && !lv.ok) {
3034
3194
  const status = typeof lv.status === 'number' ? ` HTTP ${lv.status}` : '';
3035
- const attempt =
3036
- typeof progress.attempt === 'number' && typeof progress.maxAttempts === 'number'
3037
- ? `; attempt ${progress.attempt}/${progress.maxAttempts}`
3038
- : '';
3039
- return `${base} — verify ${lv.phase} FAILED (${lv.error ?? 'error'}${status})${attempt} — agent retrying`;
3195
+ return `${base} — verify ${lv.action} FAILED (${lv.error ?? 'error'}${status}) — agent revising`;
3040
3196
  }
3041
3197
  return base;
3042
3198
  }