imprint-mcp 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,6 +49,9 @@ interface RunMcpServerOptions {
49
49
  name?: string;
50
50
  /** Server version. */
51
51
  version?: string;
52
+ /** Optional exact tool-name allowlist. Used by audit sessions to keep
53
+ * human-interactive authentication workflows out of headless MCP exposure. */
54
+ includeToolNames?: readonly string[];
52
55
  }
53
56
 
54
57
  interface ResolvedTool extends DiscoveredTool {
@@ -58,6 +61,17 @@ interface ResolvedTool extends DiscoveredTool {
58
61
  preferredOrder?: ConcreteBackend[];
59
62
  }
60
63
 
64
+ /** Exact allowlist selection used by bounded MCP sessions such as audit. An
65
+ * omitted allowlist preserves normal production behavior and exposes all tools. */
66
+ export function selectMcpTools<T extends { workflow: { toolName: string } }>(
67
+ tools: readonly T[],
68
+ includeToolNames?: readonly string[],
69
+ ): T[] {
70
+ if (!includeToolNames) return [...tools];
71
+ const includedNames = new Set(includeToolNames);
72
+ return tools.filter((tool) => includedNames.has(tool.workflow.toolName));
73
+ }
74
+
61
75
  /** Tool description shown to MCP clients. Includes the operator's
62
76
  * recorded narration when present — surprisingly load-bearing for the
63
77
  * LLM picking the right tool. */
@@ -437,7 +451,8 @@ function formatToolError(
437
451
 
438
452
  export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
439
453
  const assetRoot = opts.assetRoot ?? imprintHomeDir();
440
- const discovered = await discoverTools(assetRoot, opts.site, '[imprint mcp]');
454
+ const discoveredAll = await discoverTools(assetRoot, opts.site, '[imprint mcp]');
455
+ const discovered = selectMcpTools(discoveredAll, opts.includeToolNames);
441
456
  const tools: ResolvedTool[] = discovered.map((t) => {
442
457
  const playbookPath = pathResolve(t.dir, 'playbook.yaml');
443
458
  const cacheStatus = loadBackendsCacheStatus(t.site, assetRoot, t.dir, {
@@ -26,6 +26,7 @@ import {
26
26
  BackendsCacheSchema,
27
27
  type ConcreteBackend,
28
28
  CronConfigSchema,
29
+ type ToolResult,
29
30
  WorkflowSchema,
30
31
  } from './types.ts';
31
32
  import { VERSION } from './version.ts';
@@ -120,20 +121,32 @@ export async function probeResolvedTool(
120
121
  log(`probing backends for ${tool.workflow.toolName}…`);
121
122
  log(` params: ${JSON.stringify(params)}`);
122
123
 
123
- // Try every backend (single-rung ladders) — operators want the full
124
- // matrix, not just the first that worked. cdp-replay is included so it
125
- // lands in preferredOrder when it works — without it, runtime always
126
- // falls through fetch-bootstrap (~30-60s) before reaching the spliced-in
127
- // cdp-replay rung, wasting time on every call.
124
+ // Probe the workflow's normal candidates first. Plain workflows keep the
125
+ // cheap fetch/stealth/playbook path; browser-backed API transports are only
126
+ // added if none of those candidates works.
128
127
  const stealthCache = new Map<string, StealthFetch>();
129
128
  const cdpPool = new Map<string, CdpBrowserFetch>();
130
129
  const allBackends = probeCandidateBackendsForWorkflow(tool.workflow);
131
130
  const results: BackendsCache['results'] = {};
132
131
  const working: BackendProbeCandidate[] = [];
133
132
  const preferredMaxMs = preferredBackendMaxMs();
133
+ const persistCurrentCache = (): BackendsCache => {
134
+ const cache: BackendsCache = {
135
+ probedAt: new Date().toISOString(),
136
+ imprintVersion: VERSION,
137
+ schemaVersion: 2,
138
+ workflowHash: workflowHash(tool.workflow),
139
+ capabilityHash: workflowCapabilityHash(tool.workflow),
140
+ preferredOrder: rankSuccessfulBackends(working),
141
+ results,
142
+ };
143
+ BackendsCacheSchema.parse(cache);
144
+ writeFileSync(outPath, `${JSON.stringify(cache, null, 2)}\n`);
145
+ return cache;
146
+ };
134
147
 
135
- try {
136
- for (const backend of allBackends) {
148
+ const probeCandidates = async (backends: ConcreteBackend[]): Promise<void> => {
149
+ for (const backend of backends) {
137
150
  log(`probing ${backend}…`);
138
151
  const t0 = Date.now();
139
152
  const { result, attempts } = await runWithLadder(
@@ -147,6 +160,14 @@ export async function probeResolvedTool(
147
160
  const durationMs = Date.now() - t0;
148
161
  const attempt = attempts[0];
149
162
 
163
+ const invariantFailure = backendInvariantProbeFailure(result);
164
+ if (invariantFailure) {
165
+ log(` ${backend}: workflow rejected the request before transport — stopping probe`);
166
+ throw new Error(
167
+ `Backend-independent workflow failure for ${tool.workflow.toolName}: ${invariantFailure}`,
168
+ );
169
+ }
170
+
150
171
  if (!attempt) {
151
172
  results[backend] = { outcome: 'skipped', detail: 'no attempt recorded' };
152
173
  continue;
@@ -189,6 +210,11 @@ export async function probeResolvedTool(
189
210
  ...(warm?.ok ? { warmDurationMs: warm.durationMs, rankingDurationMs } : {}),
190
211
  tooSlow,
191
212
  });
213
+ // Keep a valid preferred backend durable as soon as one succeeds. A
214
+ // later optional candidate may tarpit until the aggregate preparation
215
+ // deadline; losing this earlier success would force an unnecessary
216
+ // reprobe and leave the suite with no prepared backend.
217
+ persistCurrentCache();
192
218
  log(
193
219
  ` ${backend}: OK in ${durationMs}ms${warm?.ok ? ` (warm ${warm.durationMs}ms)` : ''}${tooSlow ? ' (cold slow)' : ''}`,
194
220
  );
@@ -212,6 +238,14 @@ export async function probeResolvedTool(
212
238
  log(` ${backend}: ${result.error} — ${result.message.slice(0, 100)}`);
213
239
  }
214
240
  }
241
+ };
242
+
243
+ try {
244
+ await probeCandidates(allBackends);
245
+ if (working.length === 0 && !allBackends.includes('cdp-replay')) {
246
+ log('default backends failed; probing browser-backed API fallbacks…');
247
+ await probeCandidates(['cdp-replay', 'fetch-bootstrap']);
248
+ }
215
249
  } finally {
216
250
  await closeProbeCdpPool(cdpPool);
217
251
  }
@@ -224,24 +258,24 @@ export async function probeResolvedTool(
224
258
  );
225
259
  }
226
260
 
227
- const preferredOrder = rankSuccessfulBackends(working);
228
- const cache: BackendsCache = {
229
- probedAt: new Date().toISOString(),
230
- imprintVersion: VERSION,
231
- schemaVersion: 2,
232
- workflowHash: workflowHash(tool.workflow),
233
- capabilityHash: workflowCapabilityHash(tool.workflow),
234
- preferredOrder,
235
- results,
236
- };
237
- BackendsCacheSchema.parse(cache); // catch schema drift early
238
-
239
- writeFileSync(outPath, `${JSON.stringify(cache, null, 2)}\n`);
240
- log(`wrote ${outPath} — preferred: ${preferredOrder.join(' → ')}`);
261
+ const cache = persistCurrentCache();
262
+ log(`wrote ${outPath} — preferred: ${cache.preferredOrder.join(' → ')}`);
241
263
 
242
264
  return { cache, outPath };
243
265
  }
244
266
 
267
+ /**
268
+ * Some generated-workflow failures happen before any HTTP request is sent.
269
+ * Retrying those through browser-backed transports is both expensive and
270
+ * misleading: a different transport cannot repair a deterministic transform.
271
+ * Keep remote HTTP BAD_RESPONSE failures probeable because bot defenses can
272
+ * return backend-specific 4xx responses.
273
+ */
274
+ export function backendInvariantProbeFailure(result: ToolResult): string | null {
275
+ if (result.ok || result.error !== 'BAD_RESPONSE') return null;
276
+ return /^request transform failed for request \d+:/i.test(result.message) ? result.message : null;
277
+ }
278
+
245
279
  export function rankSuccessfulBackends(candidates: BackendProbeCandidate[]): ConcreteBackend[] {
246
280
  return [...candidates]
247
281
  .sort((a, b) => {
@@ -302,9 +336,10 @@ function preferredBackendMaxMs(): number {
302
336
 
303
337
  function workflowNeedsBootstrap(workflow: ResolvedTool['workflow']): boolean {
304
338
  if (workflow.bootstrap) return true;
305
- return workflow.requests.some((r) =>
306
- (r.captures ?? []).some(
307
- (c) => c.capability === 'browser_bootstrap' || c.capability === 'stealth_bootstrap',
339
+ return workflow.requests.some((request) =>
340
+ (request.captures ?? []).some(
341
+ (capture) =>
342
+ capture.capability === 'browser_bootstrap' || capture.capability === 'stealth_bootstrap',
308
343
  ),
309
344
  );
310
345
  }
@@ -313,7 +348,7 @@ export function probeCandidateBackendsForWorkflow(
313
348
  workflow: ResolvedTool['workflow'],
314
349
  ): ConcreteBackend[] {
315
350
  return workflowNeedsBootstrap(workflow) || prefersCdpReplayFirst(workflow)
316
- ? ['fetch', 'fetch-bootstrap', 'cdp-replay', 'stealth-fetch', 'playbook']
351
+ ? ['fetch', 'cdp-replay', 'fetch-bootstrap', 'stealth-fetch', 'playbook']
317
352
  : ['fetch', 'stealth-fetch', 'playbook'];
318
353
  }
319
354
 
@@ -172,7 +172,18 @@ export function resolveWorkflowTriagedPath(
172
172
  if (!ws) return null;
173
173
 
174
174
  const explicitPath = resolveTeachStatePath(site, ws.triagedPath);
175
- if (explicitPath) return explicitPath;
175
+ if (explicitPath) {
176
+ // Older/resumed runs could accidentally triage an already-triaged session,
177
+ // leaving state pointed at `*.triaged.triaged.json`. Prefer the original
178
+ // single-triaged sibling when it still exists: it contains the complete
179
+ // load-bearing request set, while the second pass may have filtered requests
180
+ // selected by the first pass.
181
+ const singleTriagedPath = explicitPath.replace(/(?:\.triaged){2,}\.json$/, '.triaged.json');
182
+ if (singleTriagedPath !== explicitPath && existsSync(singleTriagedPath)) {
183
+ return singleTriagedPath;
184
+ }
185
+ return explicitPath;
186
+ }
176
187
 
177
188
  if (!ws.completedSteps.includes('triage')) return null;
178
189
 
@@ -186,7 +197,20 @@ export function resolveWorkflowTriagedPath(
186
197
  export function toRelativeTeachStatePath(site: string, absPath: string): string {
187
198
  const localRelative = relativeToLocalSite(site, absPath);
188
199
  if (localRelative) return localRelative;
189
- return `_external_/${pathBasename(absPath)}`;
200
+ // External artifacts are not copied under the site root. Persist their real
201
+ // absolute path so a later bounded resume can still resolve them; the former
202
+ // `_external_/<basename>` label looked portable but pointed at no actual file.
203
+ return pathResolve(absPath);
204
+ }
205
+
206
+ const TOOL_SCOPED_RESUME_STEPS = new Set<TeachStep>(['generate', 'compile-playbook', 'emit']);
207
+
208
+ /** `--tool` scopes the atomic per-tool compile unit. Earlier phases operate on
209
+ * the recording as a whole, while register is site-level, so accepting the flag
210
+ * there would either ignore it or falsely claim the run was bounded. */
211
+ export function validateToolScopedResumeStep(fromStep: TeachStep | undefined): string | null {
212
+ if (fromStep && TOOL_SCOPED_RESUME_STEPS.has(fromStep)) return null;
213
+ return 'error: --tool is only supported for bounded compile resumes starting at generate, compile-playbook, or emit';
190
214
  }
191
215
 
192
216
  export function buildTeachStateFromSession(
@@ -366,6 +390,7 @@ export function resolveStepStartTarget(
366
390
  site: string,
367
391
  state: TeachState,
368
392
  fromStep: TeachStep,
393
+ toolName?: string,
369
394
  ): { workflowKey: string; ws: WorkflowState } {
370
395
  // Exclude in-progress `_pending_*` placeholders: they never carry a candidate
371
396
  // and never reached far enough, so a stale one with a newer timestamp would
@@ -381,6 +406,19 @@ export function resolveStepStartTarget(
381
406
  ].join('\n'),
382
407
  );
383
408
  }
409
+ if (toolName) {
410
+ const target = entries.find(
411
+ ([workflowKey, ws]) => workflowKey === toolName || ws.candidate?.toolName === toolName,
412
+ );
413
+ if (!target) {
414
+ throw new Error(
415
+ `Cannot resume tool "${toolName}" for "${site}": it is not present in the persisted teach state.`,
416
+ );
417
+ }
418
+ const [workflowKey, ws] = target;
419
+ assertResumableAt(site, workflowKey, ws, fromStep);
420
+ return { workflowKey, ws };
421
+ }
384
422
  // Most-recently-updated resumable workflow — the run a developer just executed
385
423
  // and wants to resume a single phase of. Newly discovered orphan sessions can
386
424
  // have newer timestamps but only `record`/`redact`; they must not shadow the
@@ -423,6 +461,7 @@ export function selectMultiToolResumePlans(
423
461
  opts?: {
424
462
  noInteractive?: boolean;
425
463
  allTools?: boolean;
464
+ toolName?: string;
426
465
  plannedToolNames?: ReadonlySet<string>;
427
466
  },
428
467
  ): MultiToolResumeSelection {
@@ -445,6 +484,17 @@ export function selectMultiToolResumePlans(
445
484
  }
446
485
  plans.push({ workflowKey: key, candidate: ws.candidate, sharedContext: ws.sharedContext });
447
486
  }
487
+ if (opts?.toolName) {
488
+ const selected = plans.find(
489
+ (plan) => plan.workflowKey === opts.toolName || plan.candidate?.toolName === opts.toolName,
490
+ );
491
+ if (!selected) {
492
+ throw new Error(
493
+ `Cannot resume tool "${opts.toolName}": it is not resumable from "${fromStep}" in the selected recording.`,
494
+ );
495
+ }
496
+ return { plans: [selected], skipped };
497
+ }
448
498
  if (opts?.noInteractive && !opts.allTools && plans.length > 1) {
449
499
  const primary = plans.find((plan) => plan.candidate?.primary);
450
500
  const selected = primary ?? plans.find((plan) => plan.workflowKey === targetWorkflowKey);
@@ -150,6 +150,30 @@ export function hasDurableAuthState(
150
150
  return stored.cookies.length > 0 || (stored.storage?.length ?? 0) > 0;
151
151
  }
152
152
 
153
+ export function resolvedArtifactCheckpointPath(
154
+ site: string,
155
+ _storedPath: string | undefined,
156
+ resolvedPath: string,
157
+ ): string {
158
+ // toRelative keeps managed artifacts site-relative and genuine external
159
+ // artifacts absolute, so both forms remain resolvable on the next resume.
160
+ return toRelative(site, resolvedPath);
161
+ }
162
+
163
+ export function selectCompileSessionArtifact(
164
+ resumedTriagedPath: string | null,
165
+ redactedPath: string | null,
166
+ ): { path: string | null; triaged: boolean } {
167
+ const derivedTriagedPath = redactedPath?.replace(/\.redacted\.json$/, '.triaged.json') ?? null;
168
+ const path =
169
+ (resumedTriagedPath && existsSync(resumedTriagedPath) ? resumedTriagedPath : null) ??
170
+ (derivedTriagedPath && existsSync(derivedTriagedPath) ? derivedTriagedPath : redactedPath);
171
+ return {
172
+ path,
173
+ triaged: path === resumedTriagedPath || path === derivedTriagedPath,
174
+ };
175
+ }
176
+
153
177
  function fileSha256(path: string): string {
154
178
  return createHash('sha256').update(readFileSync(path)).digest('hex');
155
179
  }
@@ -205,6 +229,8 @@ interface TeachOptions {
205
229
  keepTest?: boolean;
206
230
  /** Non-interactive: compile every detected candidate instead of primary only. */
207
231
  allTools?: boolean;
232
+ /** Bounded resume: compile exactly one persisted candidate from the retained recording. */
233
+ tool?: string;
208
234
  /** Skip the replay-and-diff stage entirely. */
209
235
  skipReplay?: boolean;
210
236
  /** Run only specific phases of the teach chain. `fromStep` resumes a PRIOR run
@@ -490,6 +516,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
490
516
  let workflowKey: string | null = null;
491
517
  let sessionPath: string | null = opts.fromSession ?? null;
492
518
  let redactedPath: string | null = null;
519
+ let resumedTriagedPath: string | null = null;
493
520
  let usingFromSession = false;
494
521
 
495
522
  const hasExisting = completedWorkflows.length > 0 || incompleteWorkflows.length > 0;
@@ -506,7 +533,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
506
533
  // Non-interactive phase resume: start at a specific step, reusing a prior
507
534
  // run's persisted outputs. resolveStepStartTarget picks the most-recent
508
535
  // workflow and THROWS if it didn't reach far enough (the dependency guard).
509
- const target = resolveStepStartTarget(site, state, opts.fromStep);
536
+ const target = resolveStepStartTarget(site, state, opts.fromStep, opts.tool);
510
537
  workflowKey = target.workflowKey;
511
538
  startFrom = opts.fromStep;
512
539
  sessionPath = resolveTeachStatePath(site, target.ws.sessionPath);
@@ -1215,9 +1242,14 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1215
1242
  }
1216
1243
  const ws = state.workflows[workflowKey];
1217
1244
  const resolvedTriagedPath = resolveWorkflowTriagedPath(site, ws);
1218
- if (ws && resolvedTriagedPath && !ws.triagedPath) {
1245
+ resumedTriagedPath = resolvedTriagedPath;
1246
+ if (
1247
+ ws &&
1248
+ resolvedTriagedPath &&
1249
+ resolveTeachStatePath(site, ws.triagedPath) !== resolvedTriagedPath
1250
+ ) {
1219
1251
  updateCheckpoint(site, state, workflowKey, 'triage', {
1220
- triagedPath: toRelative(site, resolvedTriagedPath),
1252
+ triagedPath: resolvedArtifactCheckpointPath(site, ws.triagedPath, resolvedTriagedPath),
1221
1253
  });
1222
1254
  }
1223
1255
  // A `--from-step` resume into plan-prereqs/generate reconstructs the prior
@@ -1242,6 +1274,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1242
1274
  const selection = selectMultiToolResumePlans(state, workflowKey, startFrom, {
1243
1275
  noInteractive: opts.noInteractive,
1244
1276
  allTools: opts.allTools,
1277
+ toolName: opts.tool,
1245
1278
  plannedToolNames,
1246
1279
  });
1247
1280
  for (const { workflowKey: skippedKey, reason } of selection.skipped) {
@@ -1325,15 +1358,12 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1325
1358
  // thousands of irrelevant requests (large auth recordings otherwise may never converge on a
1326
1359
  // playbook because of this). The detect-candidates summary already triages;
1327
1360
  // this aligns the compile session with it.
1328
- const triagedCandidate = redactedPath?.replace(/\.redacted\.json$/, '.triaged.json');
1329
- const sessionForCompile =
1330
- triagedCandidate && existsSync(triagedCandidate) ? triagedCandidate : redactedPath;
1331
- const useTriaged = sessionForCompile === triagedCandidate;
1332
- const compileSessionPath = requireSessionFile(sessionForCompile, {
1361
+ const compileSessionArtifact = selectCompileSessionArtifact(resumedTriagedPath, redactedPath);
1362
+ const compileSessionPath = requireSessionFile(compileSessionArtifact.path, {
1333
1363
  site,
1334
1364
  workflowKey: plans[0]?.workflowKey ?? workflowKey,
1335
1365
  startFrom,
1336
- kind: useTriaged ? 'triaged' : 'redacted',
1366
+ kind: compileSessionArtifact.triaged ? 'triaged' : 'redacted',
1337
1367
  });
1338
1368
 
1339
1369
  // ── Clean up stale tools from previous teach runs ──
@@ -1380,66 +1410,61 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1380
1410
  const authDetected = plans.some((pl) => sharedContextHasAuth(pl.sharedContext));
1381
1411
  let buildPlanPath = '';
1382
1412
  let sharedModulesManifest: SharedModuleManifestEntry[] = [];
1383
- if ((selectedCandidates.length >= 2 || authDetected) && willGenerate && compileModel) {
1384
- const sidecar = buildPlanSidecarPath(site);
1385
- const firstWs = state.workflows[plans[0]?.workflowKey ?? ''];
1386
- // Reuse the cached plan only when resuming PAST plan-prereqs (e.g. --from-step
1387
- // generate). When plan-prereqs is the explicit target (`--only plan-prereqs` /
1388
- // `--from-step plan-prereqs`), the user is asking to rebuild shared modules, so
1389
- // force a fresh planner run instead of short-circuiting to the cached sidecar.
1390
- const alreadyPlanned =
1391
- opts.fromStep !== 'plan-prereqs' &&
1392
- plans.every((pl) =>
1393
- state.workflows[pl.workflowKey]?.completedSteps.includes('plan-prereqs'),
1394
- ) &&
1395
- existsSync(sidecar);
1396
- if (alreadyPlanned && firstWs) {
1397
- // Resume past plan-prereqs — reuse the persisted plan + manifest.
1398
- buildPlanPath = sidecar;
1399
- sharedModulesManifest = firstWs.sharedModules ?? [];
1400
- } else {
1401
- // Mute raw `[imprint …]` logs from the planning subtree (build-plan,
1402
- // teach-plan, prereq-builder) while the spinner is live — progress flows
1403
- // through onProgress → spinner.message instead, matching the replay and
1404
- // compile phases. The skip/timeout reason is surfaced cleanly below.
1405
- muteLog();
1406
- spinner.start('Planning shared modules');
1407
- try {
1408
- const prereq = await planAndBuildPrereqs({
1409
- site,
1410
- redactedSessionPath: compileSessionPath,
1411
- candidates: selectedCandidates,
1412
- sharedContext: plans[0]?.sharedContext,
1413
- siteClassifications,
1414
- providerName: compileProviderName,
1415
- model: compileModel,
1416
- onProgress: (msg) => spinner.message(msg),
1417
- });
1418
- buildPlanPath = prereq.buildPlanPath;
1419
- sharedModulesManifest = prereq.sharedModules;
1420
- const verified = sharedModulesManifest.filter((m) => m.verified).length;
1421
- spinner.stop(
1422
- buildPlanPath
1423
- ? `Build plan ready (${verified}/${sharedModulesManifest.length} shared module${sharedModulesManifest.length === 1 ? '' : 's'} verified).`
1424
- : 'Build plan skipped.',
1425
- );
1426
- if (prereq.skippedReason) p.log.warn(prereq.skippedReason);
1427
- } catch (err) {
1428
- spinner.stop('Build planning failed — compiling tools independently.');
1429
- p.log.warn(
1430
- `Build planning failed: ${err instanceof Error ? err.message : String(err)}\nTools will compile without shared modules.`,
1431
- );
1432
- buildPlanPath = '';
1433
- sharedModulesManifest = [];
1434
- } finally {
1435
- unmuteLog();
1436
- }
1437
- for (const pl of plans) {
1438
- updateCheckpoint(site, state, pl.workflowKey, 'plan-prereqs', {
1439
- buildPlanPath: buildPlanPath ? toRelative(site, buildPlanPath) : undefined,
1440
- sharedModules: sharedModulesManifest,
1441
- });
1442
- }
1413
+ const sidecar = buildPlanSidecarPath(site);
1414
+ const firstWs = state.workflows[plans[0]?.workflowKey ?? ''];
1415
+ // A bounded primary-only resume still belongs to its original multi-tool
1416
+ // build plan. Reuse that durable contract even though this invocation selects
1417
+ // one tool; otherwise its assigned shared modules and producer-token contract
1418
+ // silently disappear from the revision agent's context.
1419
+ const alreadyPlanned =
1420
+ opts.fromStep !== 'plan-prereqs' &&
1421
+ plans.every((pl) => state.workflows[pl.workflowKey]?.completedSteps.includes('plan-prereqs')) &&
1422
+ existsSync(sidecar);
1423
+ if (willGenerate && compileModel && alreadyPlanned && firstWs) {
1424
+ buildPlanPath = sidecar;
1425
+ sharedModulesManifest = firstWs.sharedModules ?? [];
1426
+ } else if ((selectedCandidates.length >= 2 || authDetected) && willGenerate && compileModel) {
1427
+ // Mute raw `[imprint …]` logs from the planning subtree (build-plan,
1428
+ // teach-plan, prereq-builder) while the spinner is live — progress flows
1429
+ // through onProgress → spinner.message instead, matching the replay and
1430
+ // compile phases. The skip/timeout reason is surfaced cleanly below.
1431
+ muteLog();
1432
+ spinner.start('Planning shared modules');
1433
+ try {
1434
+ const prereq = await planAndBuildPrereqs({
1435
+ site,
1436
+ redactedSessionPath: compileSessionPath,
1437
+ candidates: selectedCandidates,
1438
+ sharedContext: plans[0]?.sharedContext,
1439
+ siteClassifications,
1440
+ providerName: compileProviderName,
1441
+ model: compileModel,
1442
+ onProgress: (msg) => spinner.message(msg),
1443
+ });
1444
+ buildPlanPath = prereq.buildPlanPath;
1445
+ sharedModulesManifest = prereq.sharedModules;
1446
+ const verified = sharedModulesManifest.filter((m) => m.verified).length;
1447
+ spinner.stop(
1448
+ buildPlanPath
1449
+ ? `Build plan ready (${verified}/${sharedModulesManifest.length} shared module${sharedModulesManifest.length === 1 ? '' : 's'} verified).`
1450
+ : 'Build plan skipped.',
1451
+ );
1452
+ if (prereq.skippedReason) p.log.warn(prereq.skippedReason);
1453
+ } catch (err) {
1454
+ spinner.stop('Build planning failed — compiling tools independently.');
1455
+ p.log.warn(
1456
+ `Build planning failed: ${err instanceof Error ? err.message : String(err)}\nTools will compile without shared modules.`,
1457
+ );
1458
+ buildPlanPath = '';
1459
+ sharedModulesManifest = [];
1460
+ } finally {
1461
+ unmuteLog();
1462
+ }
1463
+ for (const pl of plans) {
1464
+ updateCheckpoint(site, state, pl.workflowKey, 'plan-prereqs', {
1465
+ buildPlanPath: buildPlanPath ? toRelative(site, buildPlanPath) : undefined,
1466
+ sharedModules: sharedModulesManifest,
1467
+ });
1443
1468
  }
1444
1469
  }
1445
1470