imprint-mcp 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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