imprint-mcp 0.6.4 → 0.6.5

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.
package/README.md CHANGED
@@ -93,6 +93,11 @@ regenerate/record it with `bun scripts/demo-teach.ts`.)*
93
93
  > [!TIP]
94
94
  > All three steps happen in a single `imprint teach` command. Credentials and PII are redacted automatically before anything reaches the LLM.
95
95
 
96
+ When one recording yields multiple tools, all candidates are selected by default.
97
+ Imprint automatically includes upstream lookup/producer tools required by any
98
+ selected downstream tool. Use `--primary-tool` to compile only the primary tool
99
+ and its prerequisites; `--all-tools` makes the default full-set selection explicit.
100
+
96
101
  The recorder also has a bounded fallback for Next.js React Server Component
97
102
  responses that Chromium evicts before `Network.getResponseBody` can read them.
98
103
  It streams only narrowly identified GET navigation/prefetch and POST Server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprint-mcp",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/cli.ts CHANGED
@@ -40,6 +40,45 @@ export function parseDuration(dur: string): number | null {
40
40
  return num;
41
41
  }
42
42
 
43
+ export type TeachSelectionMode =
44
+ | 'tool'
45
+ | 'primary'
46
+ | 'all-explicit'
47
+ | 'all-default'
48
+ | 'interactive-default-all';
49
+
50
+ /** Pure CLI selection-mode resolver shared by validation and telemetry. */
51
+ export function resolveTeachSelectionMode(opts: {
52
+ tool?: string;
53
+ primaryTool?: boolean;
54
+ allTools?: boolean;
55
+ noInteractive?: boolean;
56
+ }): { mode: TeachSelectionMode; error?: string } {
57
+ if (opts.tool && opts.allTools) {
58
+ return { mode: 'tool', error: 'error: --tool cannot be combined with --all-tools' };
59
+ }
60
+ if (opts.tool && opts.primaryTool) {
61
+ return { mode: 'tool', error: 'error: --tool cannot be combined with --primary-tool' };
62
+ }
63
+ if (opts.allTools && opts.primaryTool) {
64
+ return {
65
+ mode: 'primary',
66
+ error: 'error: --all-tools cannot be combined with --primary-tool',
67
+ };
68
+ }
69
+ return {
70
+ mode: opts.tool
71
+ ? 'tool'
72
+ : opts.primaryTool
73
+ ? 'primary'
74
+ : opts.allTools
75
+ ? 'all-explicit'
76
+ : opts.noInteractive
77
+ ? 'all-default'
78
+ : 'interactive-default-all',
79
+ };
80
+ }
81
+
43
82
  const HELP = `imprint v${VERSION} — teach an AI agent to use any website. Once.
44
83
 
45
84
  USAGE
@@ -112,7 +151,7 @@ export const VERB_HELP: Record<string, VerbHelp> = {
112
151
  summary:
113
152
  'Record a workflow, compile both artifacts, emit the tool, and connect to your AI platform — all in one interactive flow. Supports resuming incomplete runs and multiple workflows per site.',
114
153
  usage: [
115
- 'imprint teach <site> [--url <url>] [--from-session <path>] [--persist-profile] [--no-interactive] [--all-tools] [--tool <toolName>] [--provider <name>] [--model <name>] [--timeout <duration>] [--keep-test] [--skip-replay] [--from-step <step>] [--to-step <step>] [--only <step>]',
154
+ 'imprint teach <site> [--url <url>] [--from-session <path>] [--persist-profile] [--no-interactive] [--primary-tool | --all-tools] [--tool <toolName>] [--provider <name>] [--model <name>] [--timeout <duration>] [--keep-test] [--skip-replay] [--from-step <step>] [--to-step <step>] [--only <step>]',
116
155
  ],
117
156
  flags: [
118
157
  { name: '--url <url>', description: 'Starting URL (else about:blank).' },
@@ -123,13 +162,15 @@ export const VERB_HELP: Record<string, VerbHelp> = {
123
162
  { name: '--persist-profile', description: 'Reuse a stable Chrome profile for this site.' },
124
163
  {
125
164
  name: '--no-interactive',
126
- description:
127
- 'Run without prompts; compile the primary detected tool and print integration snippets.',
165
+ description: 'Run without prompts; compile all detected tools by default.',
128
166
  },
129
167
  {
130
168
  name: '--all-tools',
131
- description:
132
- 'With --no-interactive, compile every detected candidate tool instead of only the primary.',
169
+ description: 'Explicitly compile all detected tools (the default).',
170
+ },
171
+ {
172
+ name: '--primary-tool',
173
+ description: 'Compile only the primary detected tool and its upstream dependencies.',
133
174
  },
134
175
  {
135
176
  name: '--tool <toolName>',
@@ -1423,6 +1464,7 @@ async function main(argv: string[]): Promise<number> {
1423
1464
  'persist-profile': { type: 'boolean' },
1424
1465
  'no-interactive': { type: 'boolean' },
1425
1466
  'all-tools': { type: 'boolean' },
1467
+ 'primary-tool': { type: 'boolean' },
1426
1468
  tool: { type: 'string' },
1427
1469
  provider: { type: 'string' },
1428
1470
  model: { type: 'string' },
@@ -1464,8 +1506,14 @@ async function main(argv: string[]): Promise<number> {
1464
1506
  return 2;
1465
1507
  }
1466
1508
  }
1467
- if (values.tool && values['all-tools']) {
1468
- console.error('error: --tool cannot be combined with --all-tools');
1509
+ const selection = resolveTeachSelectionMode({
1510
+ tool: values.tool,
1511
+ primaryTool: values['primary-tool'],
1512
+ allTools: values['all-tools'],
1513
+ noInteractive: values['no-interactive'],
1514
+ });
1515
+ if (selection.error) {
1516
+ console.error(selection.error);
1469
1517
  return 2;
1470
1518
  }
1471
1519
 
@@ -1500,6 +1548,7 @@ async function main(argv: string[]): Promise<number> {
1500
1548
  const ctrl = new AbortController();
1501
1549
  const onSigint = (): void => ctrl.abort();
1502
1550
  process.once('SIGINT', onSigint);
1551
+ const teachSelectionMode = selection.mode;
1503
1552
 
1504
1553
  try {
1505
1554
  const { teach } = await import('./imprint/teach.ts');
@@ -1513,7 +1562,10 @@ async function main(argv: string[]): Promise<number> {
1513
1562
  'imprint.provider': values.provider ?? 'auto',
1514
1563
  'imprint.model': values.model ?? 'auto',
1515
1564
  'imprint.timeout_ms': teachTimeoutMs ?? 'default',
1516
- 'imprint.all_tools': values['all-tools'] ?? false,
1565
+ 'imprint.all_tools':
1566
+ teachSelectionMode === 'all-explicit' || teachSelectionMode === 'all-default',
1567
+ 'imprint.selection_mode': teachSelectionMode,
1568
+ 'imprint.primary_tool': values['primary-tool'] ?? false,
1517
1569
  'imprint.tool': values.tool,
1518
1570
  'imprint.no_interactive': values['no-interactive'] ?? false,
1519
1571
  'imprint.skip_replay': values['skip-replay'] ?? false,
@@ -1530,7 +1582,7 @@ async function main(argv: string[]): Promise<number> {
1530
1582
  model: values.model,
1531
1583
  maxDurationMs: teachTimeoutMs,
1532
1584
  keepTest: values['keep-test'] || process.env.IMPRINT_KEEP_TEST === '1',
1533
- allTools: values['all-tools'],
1585
+ primaryTool: values['primary-tool'],
1534
1586
  tool: values.tool,
1535
1587
  skipReplay: values['skip-replay'],
1536
1588
  fromStep: fromStepArg,
@@ -33,6 +33,7 @@ import {
33
33
  type SharedCompileContext,
34
34
  SharedCompileContextSchema,
35
35
  type ToolCandidate,
36
+ closeCandidateSelection,
36
37
  } from './tool-candidates.ts';
37
38
 
38
39
  export const TEACH_STEPS = [
@@ -55,6 +56,8 @@ export interface WorkflowState {
55
56
  redactedPath?: string;
56
57
  triagedPath?: string;
57
58
  classificationsPath?: string;
59
+ /** Hash of the replay classification payload used for candidate/build planning. */
60
+ classificationsHash?: string;
58
61
  completedSteps: TeachStep[];
59
62
  error?: string;
60
63
  startedAt: string;
@@ -90,6 +93,94 @@ export interface TeachState {
90
93
  workflows: Record<string, WorkflowState>;
91
94
  }
92
95
 
96
+ /** Candidate-backed resumes must reconstruct the persisted tool set so a
97
+ * downstream tool is never resumed without its upstream dependencies. */
98
+ export function isCandidateBackedResume(
99
+ ws: WorkflowState | undefined,
100
+ ): ws is WorkflowState & { candidate: ToolCandidate } {
101
+ return ws?.candidate !== undefined;
102
+ }
103
+
104
+ /** Only a fresh detection run supersedes existing tools. A resume may
105
+ * intentionally exclude tools from another recording or an earlier phase. */
106
+ export function staleCompletedToolsForRun(
107
+ completedToolNames: readonly string[],
108
+ incomingToolNames: ReadonlySet<string>,
109
+ cleanupStaleTools: boolean,
110
+ ): string[] {
111
+ return cleanupStaleTools ? completedToolNames.filter((name) => !incomingToolNames.has(name)) : [];
112
+ }
113
+
114
+ /** Resolve behavior from the actual run chosen by the CLI/TUI, rather than from
115
+ * the presence of a CLI flag. Interactive Continue/Redo must follow the same
116
+ * resume semantics as an explicit `--from-step`. */
117
+ export function resolvedTeachRunPolicy(startFrom: TeachStep, resumesExistingRun: boolean) {
118
+ return {
119
+ cleanupStaleTools: !resumesExistingRun,
120
+ reuseExistingBuildPlan: startFrom !== 'plan-prereqs',
121
+ reviseExistingArtifacts: resumesExistingRun,
122
+ };
123
+ }
124
+
125
+ function candidatePlanningSignature(candidate: ToolCandidate): string {
126
+ return JSON.stringify(candidate);
127
+ }
128
+
129
+ /** Whether re-detection changed the candidate set or any planning-relevant
130
+ * candidate evidence for this recording. A changed graph invalidates prior
131
+ * plan-prereqs/compile progress even when candidate names stayed the same. */
132
+ export function candidateSelectionChanged(
133
+ state: TeachState,
134
+ sessionPath: string,
135
+ selected: ToolCandidate[],
136
+ sharedContext?: SharedCompileContext,
137
+ classificationsHash?: string,
138
+ ): boolean {
139
+ const priorWorkflows = Object.values(state.workflows).filter(
140
+ (workflow) => workflow.sessionPath === sessionPath && workflow.candidate,
141
+ );
142
+ if (priorWorkflows.length === 0) return false;
143
+ const prior = priorWorkflows.map((workflow) => workflow.candidate as ToolCandidate);
144
+ const signatures = (candidates: ToolCandidate[]): string[] =>
145
+ candidates.map(candidatePlanningSignature).sort();
146
+ if (JSON.stringify(signatures(prior)) !== JSON.stringify(signatures(selected))) return true;
147
+ const priorContexts = new Set(
148
+ priorWorkflows.map((workflow) => JSON.stringify(workflow.sharedContext ?? null)),
149
+ );
150
+ if (priorContexts.size !== 1 || !priorContexts.has(JSON.stringify(sharedContext ?? null))) {
151
+ return true;
152
+ }
153
+ const priorClassificationHashes = new Set(
154
+ priorWorkflows.map((workflow) => workflow.classificationsHash ?? null),
155
+ );
156
+ return (
157
+ priorClassificationHashes.size !== 1 ||
158
+ !priorClassificationHashes.has(classificationsHash ?? null)
159
+ );
160
+ }
161
+
162
+ /** Remove obsolete candidate state for one recording after a new selection is
163
+ * finalized. Other recordings and pending placeholders remain untouched. */
164
+ export function pruneUnselectedCandidateWorkflows(
165
+ state: TeachState,
166
+ sessionPath: string,
167
+ selectedToolNames: ReadonlySet<string>,
168
+ ): string[] {
169
+ const removed: string[] = [];
170
+ for (const [key, workflow] of Object.entries(state.workflows)) {
171
+ if (
172
+ workflow.sessionPath !== sessionPath ||
173
+ !workflow.candidate ||
174
+ selectedToolNames.has(workflow.candidate.toolName)
175
+ ) {
176
+ continue;
177
+ }
178
+ delete state.workflows[key];
179
+ removed.push(key);
180
+ }
181
+ return removed;
182
+ }
183
+
93
184
  export function teachStatePath(site: string): string {
94
185
  return pathJoin(localSiteDir(site), '.teach-state.json');
95
186
  }
@@ -106,9 +197,10 @@ export function loadTeachState(site: string): TeachState {
106
197
  try {
107
198
  const state = JSON.parse(readFileSync(loadPath, 'utf8')) as TeachState;
108
199
  for (const workflow of Object.values(state.workflows)) {
109
- if (!workflow.sharedContext) continue;
110
- const parsed = SharedCompileContextSchema.safeParse(workflow.sharedContext);
111
- workflow.sharedContext = parsed.success ? parsed.data : undefined;
200
+ if (workflow.sharedContext) {
201
+ const parsed = SharedCompileContextSchema.safeParse(workflow.sharedContext);
202
+ workflow.sharedContext = parsed.success ? parsed.data : undefined;
203
+ }
112
204
  }
113
205
  return isLegacy ? normalizeLegacyTeachState(site, state) : state;
114
206
  } catch {
@@ -434,8 +526,8 @@ export function resolveStepStartTarget(
434
526
  return { workflowKey, ws };
435
527
  }
436
528
 
437
- /** A multi-tool `--from-step` resume reconstructs the prior run's tools from
438
- * persisted state. Scope it to (a) tools from the SAME recording as the resume
529
+ /** A candidate-backed resume reconstructs the prior run's tools from persisted
530
+ * state. Scope it to (a) tools from the SAME recording as the resume
439
531
  * target — cross-recording tools have a different session and would otherwise be
440
532
  * compiled against the wrong one — and (b) tools whose prior run actually reached
441
533
  * `fromStep`'s prerequisites — a tool that failed earlier has no
@@ -445,13 +537,14 @@ export function resolveStepStartTarget(
445
537
  interface MultiToolResumeSelection {
446
538
  plans: {
447
539
  workflowKey: string;
448
- candidate: WorkflowState['candidate'];
540
+ candidate: ToolCandidate;
449
541
  sharedContext: WorkflowState['sharedContext'];
450
542
  }[];
451
543
  skipped: {
452
544
  workflowKey: string;
453
545
  reason: 'different-recording' | 'not-resumable' | 'not-in-build-plan';
454
546
  }[];
547
+ cycles: string[][];
455
548
  }
456
549
 
457
550
  export function selectMultiToolResumePlans(
@@ -459,13 +552,18 @@ export function selectMultiToolResumePlans(
459
552
  targetWorkflowKey: string,
460
553
  fromStep: TeachStep,
461
554
  opts?: {
462
- noInteractive?: boolean;
463
- allTools?: boolean;
555
+ primaryTool?: boolean;
464
556
  toolName?: string;
465
557
  plannedToolNames?: ReadonlySet<string>;
466
558
  },
467
559
  ): MultiToolResumeSelection {
468
560
  const target = state.workflows[targetWorkflowKey];
561
+ const persistedPrimary = Object.entries(state.workflows).find(
562
+ ([key, workflow]) =>
563
+ !key.startsWith('_pending_') &&
564
+ workflow.candidate?.primary &&
565
+ (!target || workflow.sessionPath === target.sessionPath),
566
+ );
469
567
  const plans: MultiToolResumeSelection['plans'] = [];
470
568
  const skipped: MultiToolResumeSelection['skipped'] = [];
471
569
  for (const [key, ws] of Object.entries(state.workflows)) {
@@ -484,6 +582,34 @@ export function selectMultiToolResumePlans(
484
582
  }
485
583
  plans.push({ workflowKey: key, candidate: ws.candidate, sharedContext: ws.sharedContext });
486
584
  }
585
+ if (target?.candidate && !plans.some((plan) => plan.workflowKey === targetWorkflowKey)) {
586
+ const reason = skipped.find((entry) => entry.workflowKey === targetWorkflowKey)?.reason;
587
+ throw new Error(
588
+ `Cannot resume tool "${target.candidate.toolName}" from "${fromStep}": the target is ${reason ?? 'unavailable'}. Resume an earlier step or re-run from "detect-candidates".`,
589
+ );
590
+ }
591
+ const closePlans = (rootNames: string[]): Pick<MultiToolResumeSelection, 'plans' | 'cycles'> => {
592
+ const candidatePlans = plans;
593
+ const closure = closeCandidateSelection(
594
+ candidatePlans.map((plan) => plan.candidate),
595
+ rootNames,
596
+ );
597
+ const selectedNames = new Set(closure.selected.map((candidate) => candidate.toolName));
598
+ const eligibleNames = new Set(candidatePlans.map((plan) => plan.candidate.toolName));
599
+ for (const candidate of closure.selected) {
600
+ for (const dependency of candidate.dependsOnTools) {
601
+ if (eligibleNames.has(dependency)) continue;
602
+ throw new Error(
603
+ `Cannot resume tool "${candidate.toolName}" from "${fromStep}": required upstream tool "${dependency}" is unavailable. Re-run from "detect-candidates" to rebuild a complete tool set.`,
604
+ );
605
+ }
606
+ }
607
+ return {
608
+ plans: plans.filter((plan) => selectedNames.has(plan.candidate.toolName)),
609
+ cycles: closure.cycles,
610
+ };
611
+ };
612
+
487
613
  if (opts?.toolName) {
488
614
  const selected = plans.find(
489
615
  (plan) => plan.workflowKey === opts.toolName || plan.candidate?.toolName === opts.toolName,
@@ -493,14 +619,30 @@ export function selectMultiToolResumePlans(
493
619
  `Cannot resume tool "${opts.toolName}": it is not resumable from "${fromStep}" in the selected recording.`,
494
620
  );
495
621
  }
496
- return { plans: [selected], skipped };
622
+ return { ...closePlans([selected.candidate.toolName]), skipped };
497
623
  }
498
- if (opts?.noInteractive && !opts.allTools && plans.length > 1) {
499
- const primary = plans.find((plan) => plan.candidate?.primary);
500
- const selected = primary ?? plans.find((plan) => plan.workflowKey === targetWorkflowKey);
501
- return { plans: selected ? [selected] : [], skipped };
624
+ if (opts?.primaryTool) {
625
+ if (!persistedPrimary) {
626
+ throw new Error(
627
+ `Cannot resume from "${fromStep}" with --primary-tool: the selected recording has no primary candidate. Re-run from "detect-candidates".`,
628
+ );
629
+ }
630
+ const primary = plans.find((plan) => plan.workflowKey === persistedPrimary[0]);
631
+ if (!primary) {
632
+ const reason = skipped.find((entry) => entry.workflowKey === persistedPrimary[0])?.reason;
633
+ throw new Error(
634
+ `Cannot resume primary tool "${persistedPrimary[1].candidate?.toolName ?? persistedPrimary[0]}" from "${fromStep}": it is ${reason ?? 'unavailable'}. Resume an earlier step or re-run from "detect-candidates".`,
635
+ );
636
+ }
637
+ return {
638
+ ...closePlans([primary.candidate.toolName]),
639
+ skipped,
640
+ };
502
641
  }
503
- return { plans, skipped };
642
+ return {
643
+ ...closePlans(plans.map((plan) => plan.candidate.toolName)),
644
+ skipped,
645
+ };
504
646
  }
505
647
 
506
648
  /** True when the `[startIdx, stopIdx]` phase window overlaps the atomic analysis
@@ -23,6 +23,7 @@ import { authWorkflowHash } from './auth-compile-tools.ts';
23
23
  import {
24
24
  type SharedModuleManifestEntry,
25
25
  buildPlanSidecarPath,
26
+ deriveTokenContractHints,
26
27
  readBuildPlanFile,
27
28
  topoLevelsForTools,
28
29
  } from './build-plan.ts';
@@ -95,26 +96,34 @@ import {
95
96
  type WorkflowState,
96
97
  analysisBlockRunsForWindow,
97
98
  buildTeachStateFromSession,
99
+ candidateSelectionChanged,
98
100
  detectCandidatesCompletedSteps,
99
101
  discoverCompletedWorkflows,
100
102
  discoverOrphanSession,
101
103
  friendlySessionTimestamp,
104
+ isCandidateBackedResume,
102
105
  isExistingTeachFile as isExistingFile,
103
106
  loadTeachState,
104
107
  nextTeachStep as nextStep,
105
108
  pruneStalePendingTeachWorkflows,
109
+ pruneUnselectedCandidateWorkflows,
106
110
  resolveStepStartTarget,
107
111
  resolveTeachStatePath,
108
112
  resolveWorkflowTriagedPath,
113
+ resolvedTeachRunPolicy,
109
114
  saveTeachState,
110
115
  selectMultiToolResumePlans,
116
+ staleCompletedToolsForRun,
111
117
  toRelativeTeachStatePath as toRelative,
112
118
  } from './teach-state.ts';
113
119
  import {
114
120
  type SharedCompileContext,
115
121
  type ToolCandidate,
116
122
  buildSharedCompileContext as buildCandidateSharedCompileContext,
123
+ closeCandidateSelection,
124
+ deriveStructuralCandidateDependencies,
117
125
  detectToolCandidates,
126
+ mergeCandidateDependencies,
118
127
  primaryToolCandidate,
119
128
  sharedContextHasAuth,
120
129
  } from './tool-candidates.ts';
@@ -178,6 +187,40 @@ function fileSha256(path: string): string {
178
187
  return createHash('sha256').update(readFileSync(path)).digest('hex');
179
188
  }
180
189
 
190
+ /** Read replay classifications only when the sidecar belongs to the exact
191
+ * recording being compiled. Request seq numbers are recording-local, so using
192
+ * a site-global cache from another recording could fabricate dependency edges. */
193
+ export function loadCachedClassificationsForSession(
194
+ classPath: string,
195
+ sessionPath: string,
196
+ ): ClassifiedValue[] | undefined {
197
+ if (!existsSync(classPath) || !existsSync(sessionPath)) return undefined;
198
+ try {
199
+ const cached = JSON.parse(readFileSync(classPath, 'utf8')) as {
200
+ sourceSessionHash?: string;
201
+ classifications?: ClassifiedValue[];
202
+ };
203
+ if (
204
+ !cached.sourceSessionHash ||
205
+ cached.sourceSessionHash !== fileSha256(sessionPath) ||
206
+ !Array.isArray(cached.classifications)
207
+ ) {
208
+ return undefined;
209
+ }
210
+ return cached.classifications;
211
+ } catch {
212
+ return undefined;
213
+ }
214
+ }
215
+
216
+ function classificationsPlanningHash(
217
+ classifications: ClassifiedValue[] | undefined,
218
+ ): string | undefined {
219
+ return classifications
220
+ ? createHash('sha256').update(JSON.stringify(classifications)).digest('hex')
221
+ : undefined;
222
+ }
223
+
181
224
  export {
182
225
  buildTeachStateFromSession,
183
226
  resolveTeachStatePath,
@@ -227,8 +270,8 @@ interface TeachOptions {
227
270
  fromSession?: string;
228
271
  /** Retain parser.test.ts after successful compile-agent verification. */
229
272
  keepTest?: boolean;
230
- /** Non-interactive: compile every detected candidate instead of primary only. */
231
- allTools?: boolean;
273
+ /** Compile only the primary detected candidate and its upstream dependencies. */
274
+ primaryTool?: boolean;
232
275
  /** Bounded resume: compile exactly one persisted candidate from the retained recording. */
233
276
  tool?: string;
234
277
  /** Skip the replay-and-diff stage entirely. */
@@ -518,6 +561,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
518
561
  let redactedPath: string | null = null;
519
562
  let resumedTriagedPath: string | null = null;
520
563
  let usingFromSession = false;
564
+ let resumesExistingRun = false;
521
565
 
522
566
  const hasExisting = completedWorkflows.length > 0 || incompleteWorkflows.length > 0;
523
567
 
@@ -534,6 +578,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
534
578
  // run's persisted outputs. resolveStepStartTarget picks the most-recent
535
579
  // workflow and THROWS if it didn't reach far enough (the dependency guard).
536
580
  const target = resolveStepStartTarget(site, state, opts.fromStep, opts.tool);
581
+ resumesExistingRun = true;
537
582
  workflowKey = target.workflowKey;
538
583
  startFrom = opts.fromStep;
539
584
  sessionPath = resolveTeachStatePath(site, target.ws.sessionPath);
@@ -571,6 +616,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
571
616
  if (choice.action === 'new') {
572
617
  startFrom = 'record';
573
618
  } else if (choice.action === 'continue') {
619
+ resumesExistingRun = true;
574
620
  workflowKey = choice.workflowKey;
575
621
  const ws = state.workflows[workflowKey];
576
622
  if (!ws) {
@@ -584,6 +630,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
584
630
  } else if (choice.action === 'redo') {
585
631
  workflowKey = choice.workflowKey;
586
632
  startFrom = choice.fromStep;
633
+ resumesExistingRun = startFrom !== 'record';
587
634
  const ws = state.workflows[workflowKey];
588
635
  if (ws) {
589
636
  sessionPath = resolveTeachStatePath(site, ws.sessionPath);
@@ -614,6 +661,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
614
661
  }
615
662
 
616
663
  const startIdx = STEPS.indexOf(startFrom);
664
+ const runPolicy = resolvedTeachRunPolicy(startFrom, resumesExistingRun);
617
665
  // Upper bound of the phase window: `--to-step`/`--only` stop the chain after a
618
666
  // given step (default = the last step, i.e. run to the end). A phase runs only
619
667
  // when it falls within [startFrom, toStep].
@@ -912,6 +960,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
912
960
  if (stopIdx < STEPS.indexOf('replay-and-diff')) await finishEarly();
913
961
 
914
962
  let plans: CandidateCompilePlan[];
963
+ let candidateBackedResume = false;
915
964
 
916
965
  // The replay→triage→detect-candidates analysis is one atomic block (the sub-
917
966
  // steps share a parallel run + the triaged session), so the window can START
@@ -959,24 +1008,30 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
959
1008
  const replayPromise = (async () => {
960
1009
  if (!needsReplay) {
961
1010
  const classPath = pathJoin(localSiteDir(site), '.classifications.json');
962
- if (existsSync(classPath)) {
963
- try {
964
- return JSON.parse(readFileSync(classPath, 'utf8'))
965
- .classifications as ClassifiedValue[];
966
- } catch {
967
- /* proceed without */
968
- }
1011
+ const cached = loadCachedClassificationsForSession(classPath, replaySessionPath);
1012
+ if (!cached && existsSync(classPath)) {
1013
+ p.log.warn(
1014
+ 'Ignoring cached replay classifications because they are untagged or belong to a different recording.',
1015
+ );
969
1016
  }
970
- return undefined;
1017
+ return cached;
971
1018
  }
972
1019
  return siteReplayAndDiff(site, replaySessionPath, mp);
973
1020
  })();
974
1021
 
975
1022
  // Branch B: triage → detect-candidates → user selection (fast, ~30s)
976
- type CandidateChainResult = {
977
- triageResult?: TriageResult;
978
- plans: CandidateCompilePlan[];
979
- };
1023
+ type CandidateChainResult =
1024
+ | {
1025
+ triageResult?: TriageResult;
1026
+ plans: CandidateCompilePlan[];
1027
+ }
1028
+ | {
1029
+ triageResult?: TriageResult;
1030
+ detection: Awaited<ReturnType<typeof detectTeachCandidates>>;
1031
+ preliminarySelected: ToolCandidate[];
1032
+ baseState: WorkflowState;
1033
+ pendingKey: string | null;
1034
+ };
980
1035
  const candidatePromise = (async (): Promise<CandidateChainResult> => {
981
1036
  if (!needsCandidates) {
982
1037
  const ws = state.workflows[workflowKey];
@@ -1065,12 +1120,16 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1065
1120
  mp.pause();
1066
1121
  mp.clear();
1067
1122
  spinner.start('Detecting candidate tools');
1068
- const detection = await detectTeachCandidates({
1123
+ const detected = await detectTeachCandidates({
1069
1124
  sessionPath: compileSessionPath,
1070
1125
  providerName,
1071
1126
  model,
1072
1127
  trustSessionScope: !!localTriagedPath,
1073
1128
  });
1129
+ const detection = {
1130
+ ...detected,
1131
+ candidates: deriveStructuralCandidateDependencies(detected.candidates),
1132
+ };
1074
1133
  spinner.stop(
1075
1134
  `Detected ${detection.candidates.length} candidate tool${detection.candidates.length === 1 ? '' : 's'}.`,
1076
1135
  );
@@ -1079,8 +1138,6 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1079
1138
  const selected = await selectTeachCandidates(detection, opts);
1080
1139
  mp.resume();
1081
1140
 
1082
- const sharedContext = buildCandidateSharedCompileContext(detection, selected);
1083
-
1084
1141
  // ── Credential prompt (deferred until here so the LLM decides which login succeeded) ──
1085
1142
  const llmLoginSeqs = new Set(detection.sharedContext?.loginRequestSeqs ?? []);
1086
1143
  if (credentialFindings.length > 0 && llmLoginSeqs.size > 0) {
@@ -1147,48 +1204,17 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1147
1204
  if (localTriagedPath) {
1148
1205
  baseState.triagedPath = toRelative(site, localTriagedPath);
1149
1206
  }
1150
- const candidatePlans = selected.map((candidate) => {
1151
- checkpoint(site, state, candidate.toolName, {
1152
- ...baseState,
1153
- // Preserve prior progress only when re-detecting the SAME recording, so
1154
- // a re-run of the analysis block (`--from-step`/`--only detect-candidates`,
1155
- // or interactive redo) doesn't regress a tool that already reached
1156
- // generate…register (which would break a later `--from-step register`).
1157
- // A fresh / different recording producing the same toolName must NOT
1158
- // inherit the old `plan-prereqs` marker, or the alreadyPlanned shortcut
1159
- // below would skip re-planning and compile against the previous
1160
- // recording's `_shared/` modules — detectCandidatesCompletedSteps gates
1161
- // on the recording's sessionPath.
1162
- completedSteps: detectCandidatesCompletedSteps(
1163
- state.workflows[candidate.toolName],
1164
- baseState.sessionPath,
1165
- ),
1166
- candidate,
1167
- sharedContext,
1168
- });
1169
- return {
1170
- workflowKey: candidate.toolName,
1171
- startFrom: 'generate' as Step,
1172
- candidate,
1173
- sharedContext,
1174
- };
1175
- });
1176
-
1177
- if (pendingKey && state.workflows[pendingKey]) {
1178
- delete state.workflows[pendingKey];
1179
- saveTeachState(site, state);
1180
- }
1181
-
1182
1207
  return {
1183
1208
  triageResult: localTriageResult,
1184
- plans: candidatePlans,
1209
+ detection,
1210
+ preliminarySelected: selected,
1211
+ baseState,
1212
+ pendingKey,
1185
1213
  };
1186
1214
  })();
1187
1215
 
1188
1216
  // Wait for candidate chain (includes user interaction)
1189
1217
  const candidateResult = await candidatePromise;
1190
- plans = candidateResult.plans;
1191
-
1192
1218
  triageResult = candidateResult.triageResult;
1193
1219
 
1194
1220
  // Wait for replay — may already be done, or show progress while waiting
@@ -1213,6 +1239,70 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1213
1239
 
1214
1240
  mp.clear();
1215
1241
 
1242
+ if ('plans' in candidateResult) {
1243
+ plans = candidateResult.plans;
1244
+ } else {
1245
+ const finalizedSelection = finalizeTeachCandidateSelection(
1246
+ candidateResult.detection.candidates,
1247
+ candidateResult.preliminarySelected.map((candidate) => candidate.toolName),
1248
+ siteClassifications,
1249
+ );
1250
+ const candidates = finalizedSelection.candidates;
1251
+ const finalDetection = { ...candidateResult.detection, candidates };
1252
+ const notice = formatTeachCandidateAutoAddNotice(finalizedSelection.autoAdded, 'replay');
1253
+ if (notice) p.note(notice.body, notice.title);
1254
+ for (const cycle of finalizedSelection.cycles) {
1255
+ p.log.warn(`Tool dependency cycle detected: ${cycle.join(' → ')}`);
1256
+ }
1257
+
1258
+ const sharedContext = buildCandidateSharedCompileContext(
1259
+ finalDetection,
1260
+ finalizedSelection.selected,
1261
+ );
1262
+ const classificationsHash = classificationsPlanningHash(siteClassifications);
1263
+ const selectedNames = new Set(
1264
+ finalizedSelection.selected.map((candidate) => candidate.toolName),
1265
+ );
1266
+ const selectionChanged = candidateSelectionChanged(
1267
+ state,
1268
+ candidateResult.baseState.sessionPath,
1269
+ finalizedSelection.selected,
1270
+ sharedContext,
1271
+ classificationsHash,
1272
+ );
1273
+ pruneUnselectedCandidateWorkflows(
1274
+ state,
1275
+ candidateResult.baseState.sessionPath,
1276
+ selectedNames,
1277
+ );
1278
+ plans = finalizedSelection.selected.map((candidate) => {
1279
+ checkpoint(site, state, candidate.toolName, {
1280
+ ...candidateResult.baseState,
1281
+ // Preserve prior progress only when re-detecting the SAME recording, so
1282
+ // a re-run of the analysis block doesn't regress a tool that already
1283
+ // reached generate…register. A different recording must not inherit the
1284
+ // old plan-prereqs marker or shared-module build.
1285
+ completedSteps: detectCandidatesCompletedSteps(
1286
+ selectionChanged ? undefined : state.workflows[candidate.toolName],
1287
+ candidateResult.baseState.sessionPath,
1288
+ ),
1289
+ candidate,
1290
+ sharedContext,
1291
+ classificationsHash,
1292
+ });
1293
+ return {
1294
+ workflowKey: candidate.toolName,
1295
+ startFrom: 'generate' as Step,
1296
+ candidate,
1297
+ sharedContext,
1298
+ };
1299
+ });
1300
+ if (candidateResult.pendingKey && state.workflows[candidateResult.pendingKey]) {
1301
+ delete state.workflows[candidateResult.pendingKey];
1302
+ saveTeachState(site, state);
1303
+ }
1304
+ }
1305
+
1216
1306
  // Checkpoints — write sequentially after both complete
1217
1307
  updateCandidateStageCheckpoints({
1218
1308
  site,
@@ -1233,12 +1323,8 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1233
1323
  } else {
1234
1324
  // Resuming from generate or later — load cached data
1235
1325
  const classPath = pathJoin(localSiteDir(site), '.classifications.json');
1236
- if (existsSync(classPath)) {
1237
- try {
1238
- siteClassifications = JSON.parse(readFileSync(classPath, 'utf8')).classifications;
1239
- } catch {
1240
- /* proceed without */
1241
- }
1326
+ if (redactedPath) {
1327
+ siteClassifications = loadCachedClassificationsForSession(classPath, redactedPath);
1242
1328
  }
1243
1329
  const ws = state.workflows[workflowKey];
1244
1330
  const resolvedTriagedPath = resolveWorkflowTriagedPath(site, ws);
@@ -1252,17 +1338,18 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1252
1338
  triagedPath: resolvedArtifactCheckpointPath(site, ws.triagedPath, resolvedTriagedPath),
1253
1339
  });
1254
1340
  }
1255
- // A `--from-step` resume into plan-prereqs/generate reconstructs the prior
1256
- // run's tools from persisted state (shared-module planning may need ≥2 tools).
1257
- // An explicit --all-tools or interactive resume keeps the full set; documented
1258
- // non-interactive behavior selects only the persisted primary candidate.
1341
+ // A candidate-backed resume reconstructs the prior run's tools from persisted
1342
+ // state (shared-module planning may need ≥2 tools). This applies both to an
1343
+ // explicit `--from-step` and to the interactive Continue/Redo paths.
1344
+ // Resume defaults to the full persisted set. --primary-tool narrows to the
1345
+ // primary candidate plus its upstream dependency closure.
1259
1346
  // selectMultiToolResumePlans scopes reconstruction to the same recording as the
1260
1347
  // resume target and to tools that actually reached `startFrom`'s prerequisites,
1261
1348
  // so a sibling from a different run can't be compiled against the wrong session
1262
- // and one that failed earlier can't crash loading a missing artifact. Confined
1263
- // to --from-step so interactive resume keeps its single-tool behavior.
1349
+ // and one that failed earlier can't crash loading a missing artifact.
1350
+ candidateBackedResume = isCandidateBackedResume(ws);
1264
1351
  const allCandidatePlans: CandidateCompilePlan[] = [];
1265
- if (opts.fromStep) {
1352
+ if (candidateBackedResume) {
1266
1353
  let plannedToolNames: Set<string> | undefined;
1267
1354
  const persistedBuildPlanPath = resolveTeachStatePath(site, ws?.buildPlanPath);
1268
1355
  if (persistedBuildPlanPath && existsSync(persistedBuildPlanPath)) {
@@ -1272,20 +1359,22 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1272
1359
  }
1273
1360
  }
1274
1361
  const selection = selectMultiToolResumePlans(state, workflowKey, startFrom, {
1275
- noInteractive: opts.noInteractive,
1276
- allTools: opts.allTools,
1362
+ primaryTool: opts.primaryTool,
1277
1363
  toolName: opts.tool,
1278
1364
  plannedToolNames,
1279
1365
  });
1280
1366
  for (const { workflowKey: skippedKey, reason } of selection.skipped) {
1281
1367
  p.log.warn(
1282
1368
  reason === 'different-recording'
1283
- ? `Skipping tool "${skippedKey}" for --from-step ${startFrom}: it belongs to a different recording than the resume target.`
1369
+ ? `Skipping tool "${skippedKey}" while resuming from ${startFrom}: it belongs to a different recording than the resume target.`
1284
1370
  : reason === 'not-in-build-plan'
1285
- ? `Skipping tool "${skippedKey}" for --from-step ${startFrom}: it is not in the current build plan.`
1286
- : `Skipping tool "${skippedKey}" for --from-step ${startFrom}: its prior run didn't reach "${startFrom}" — resume an earlier step or re-run it.`,
1371
+ ? `Skipping tool "${skippedKey}" while resuming from ${startFrom}: it is not in the current build plan.`
1372
+ : `Skipping tool "${skippedKey}" while resuming from ${startFrom}: its prior run didn't reach "${startFrom}" — resume an earlier step or re-run it.`,
1287
1373
  );
1288
1374
  }
1375
+ for (const cycle of selection.cycles) {
1376
+ p.log.warn(`Tool dependency cycle detected: ${cycle.join(' → ')}`);
1377
+ }
1289
1378
  for (const sel of selection.plans) {
1290
1379
  allCandidatePlans.push({
1291
1380
  workflowKey: sel.workflowKey,
@@ -1295,10 +1384,14 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1295
1384
  });
1296
1385
  }
1297
1386
  }
1298
- plans =
1299
- allCandidatePlans.length > 0
1300
- ? allCandidatePlans
1301
- : [{ workflowKey, startFrom, candidate: ws?.candidate, sharedContext: ws?.sharedContext }];
1387
+ if (candidateBackedResume && allCandidatePlans.length === 0) {
1388
+ throw new Error(
1389
+ `Cannot resume from "${startFrom}": the selected recording has no resumable candidates. Re-run from "detect-candidates".`,
1390
+ );
1391
+ }
1392
+ plans = candidateBackedResume
1393
+ ? allCandidatePlans
1394
+ : [{ workflowKey, startFrom, candidate: ws?.candidate, sharedContext: ws?.sharedContext }];
1302
1395
  }
1303
1396
 
1304
1397
  // Early stop: `--to-step replay-and-diff|triage|detect-candidates` finishes
@@ -1367,16 +1460,18 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1367
1460
  });
1368
1461
 
1369
1462
  // ── Clean up stale tools from previous teach runs ──
1370
- // Skipped entirely on a `--from-step` resume: a resume is scoped to a subset of
1463
+ // Skipped on every resume, including interactive Continue/Redo: a resume is scoped to a subset of
1371
1464
  // the site's tools (selectMultiToolResumePlans intentionally leaves other
1372
1465
  // recordings' tools — and same-recording tools that didn't reach the step —
1373
1466
  // alone), so "not in the resume set" does NOT mean "stale". Treating it as stale
1374
1467
  // here would silently rmSync a tool the resume just promised to preserve. Cleanup
1375
1468
  // only applies to a fresh run that produces a superseding tool set.
1376
1469
  const incomingToolNames = new Set(plans.map((pl) => pl.candidate?.toolName ?? pl.workflowKey));
1377
- const staleTools = opts.fromStep
1378
- ? []
1379
- : discoverCompletedWorkflows(site).filter((name) => !incomingToolNames.has(name));
1470
+ const staleTools = staleCompletedToolsForRun(
1471
+ discoverCompletedWorkflows(site),
1472
+ incomingToolNames,
1473
+ runPolicy.cleanupStaleTools,
1474
+ );
1380
1475
  if (staleTools.length > 0) {
1381
1476
  let shouldReplace = true;
1382
1477
  if (!opts.noInteractive) {
@@ -1417,7 +1512,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1417
1512
  // one tool; otherwise its assigned shared modules and producer-token contract
1418
1513
  // silently disappear from the revision agent's context.
1419
1514
  const alreadyPlanned =
1420
- opts.fromStep !== 'plan-prereqs' &&
1515
+ runPolicy.reuseExistingBuildPlan &&
1421
1516
  plans.every((pl) => state.workflows[pl.workflowKey]?.completedSteps.includes('plan-prereqs')) &&
1422
1517
  existsSync(sidecar);
1423
1518
  if (willGenerate && compileModel && alreadyPlanned && firstWs) {
@@ -1721,10 +1816,9 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1721
1816
  sharedTriageResult: triageResult,
1722
1817
  siteClassifications,
1723
1818
  teachCredentials,
1724
- allTools: opts.allTools,
1725
1819
  buildPlanPath: buildPlanPath || undefined,
1726
1820
  sharedModules: sharedModulesManifest.length > 0 ? sharedModulesManifest : undefined,
1727
- resumeExistingArtifacts: Boolean(opts.fromStep),
1821
+ resumeExistingArtifacts: runPolicy.reviseExistingArtifacts,
1728
1822
  });
1729
1823
  } finally {
1730
1824
  unmuteLog();
@@ -1754,7 +1848,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
1754
1848
  }
1755
1849
  }
1756
1850
 
1757
- const primaryResult = results[0] as TeachToolResult;
1851
+ const primaryResult = selectPrimaryNamedResult(results, plans) as TeachToolResult;
1758
1852
 
1759
1853
  // ── 6. Platform integration ────────────────────────────────────────
1760
1854
  if (inWindow('register')) {
@@ -1913,47 +2007,133 @@ async function detectTeachCandidates(opts: {
1913
2007
  );
1914
2008
  }
1915
2009
 
1916
- async function selectTeachCandidates(
2010
+ export async function selectTeachCandidates(
1917
2011
  detection: Awaited<ReturnType<typeof detectToolCandidates>>,
1918
2012
  opts: TeachOptions,
1919
2013
  ): Promise<ToolCandidate[]> {
1920
2014
  if (detection.candidates.length === 1) return [detection.candidates[0] as ToolCandidate];
1921
2015
 
1922
- if (opts.noInteractive) {
1923
- if (opts.allTools) return detection.candidates;
2016
+ if (opts.primaryTool) {
1924
2017
  const primary = primaryToolCandidate(detection);
1925
- p.log.warn(
1926
- `Detected ${detection.candidates.length} candidate tools; --no-interactive compiles only primary "${primary.toolName}". Pass --all-tools to compile all.`,
1927
- );
1928
- return [primary];
2018
+ const closure = finalizeTeachCandidateSelection(detection.candidates, [primary.toolName]);
2019
+ for (const cycle of closure.cycles) {
2020
+ p.log.warn(`Tool dependency cycle detected: ${cycle.join(' → ')}`);
2021
+ }
2022
+ return closure.selected;
2023
+ }
2024
+
2025
+ if (opts.noInteractive) {
2026
+ return detection.candidates;
1929
2027
  }
1930
2028
 
2029
+ const picker = buildTeachCandidatePicker(detection.candidates);
1931
2030
  const answer = await p.multiselect({
1932
2031
  message:
1933
2032
  'Which tools should Imprint compile from this recording?\n (press [space] to toggle, [enter] to submit)',
1934
2033
  required: true,
1935
- initialValues: detection.candidates
1936
- .filter((candidate) => candidate.primary)
1937
- .map((c) => c.toolName),
1938
- options: detection.candidates.map((candidate) => ({
1939
- value: candidate.toolName,
1940
- label: `${candidate.toolName}${candidate.primary ? ' (primary)' : ''}`,
1941
- hint: `${Math.round(candidate.confidence * 100)}% — ${candidate.description}`,
1942
- })),
2034
+ initialValues: picker.initialValues,
2035
+ options: picker.options,
1943
2036
  });
1944
2037
  if (p.isCancel(answer)) {
1945
2038
  p.outro('Cancelled.');
1946
2039
  process.exit(0);
1947
2040
  }
1948
2041
 
1949
- const selectedNames = new Set(answer as string[]);
1950
- const selected = detection.candidates.filter((candidate) =>
1951
- selectedNames.has(candidate.toolName),
1952
- );
1953
- if (selected.length === 0) {
2042
+ const closure = finalizeTeachCandidateSelection(detection.candidates, answer as string[]);
2043
+ if (closure.selected.length === 0) {
1954
2044
  throw new Error('At least one tool candidate must be selected.');
1955
2045
  }
1956
- return selected;
2046
+ const notice = formatTeachCandidateAutoAddNotice(closure.autoAdded, 'structural');
2047
+ if (notice) p.note(notice.body, notice.title);
2048
+ for (const cycle of closure.cycles) {
2049
+ p.log.warn(`Tool dependency cycle detected: ${cycle.join(' → ')}`);
2050
+ }
2051
+ return closure.selected;
2052
+ }
2053
+
2054
+ export function buildTeachCandidatePicker(candidates: ToolCandidate[]): {
2055
+ initialValues: string[];
2056
+ options: Array<{ value: string; label: string; hint: string }>;
2057
+ } {
2058
+ return {
2059
+ initialValues: candidates.map((candidate) => candidate.toolName),
2060
+ options: candidates.map((candidate) => ({
2061
+ value: candidate.toolName,
2062
+ label: `${candidate.toolName}${candidate.primary ? ' (primary)' : ''}`,
2063
+ hint: [
2064
+ `${Math.round(candidate.confidence * 100)}% — ${candidate.description}`,
2065
+ candidate.dependsOnTools.length > 0
2066
+ ? `requires ${candidate.dependsOnTools.join(', ')}`
2067
+ : undefined,
2068
+ ]
2069
+ .filter(Boolean)
2070
+ .join(' — '),
2071
+ })),
2072
+ };
2073
+ }
2074
+
2075
+ /** Merge replay-grounded producer edges using the persisted ClassifiedValue
2076
+ * shape. The token deriver deliberately accepts a value-only projection so raw
2077
+ * recorded values stay out of planner payloads; adapt value1 at this boundary. */
2078
+ export function mergeReplayCandidateDependencies(
2079
+ candidates: ToolCandidate[],
2080
+ siteClassifications?: ClassifiedValue[],
2081
+ ): ToolCandidate[] {
2082
+ const tokenHints = siteClassifications
2083
+ ? deriveTokenContractHints({
2084
+ selectedTools: candidates,
2085
+ ephemeralValues: siteClassifications.map((classification) => ({
2086
+ classification: classification.classification,
2087
+ originalSeq: classification.originalSeq,
2088
+ location: classification.location,
2089
+ producerSeq: classification.producerSeq,
2090
+ producerPath: classification.producerPath,
2091
+ value: classification.value1,
2092
+ })),
2093
+ })
2094
+ : [];
2095
+ return mergeCandidateDependencies(
2096
+ candidates,
2097
+ tokenHints.map((hint) => ({
2098
+ consumerTool: hint.consumerTool,
2099
+ producerTool: hint.producerTool,
2100
+ })),
2101
+ );
2102
+ }
2103
+
2104
+ /** Pure boundary between detected candidates, optional replay evidence, and
2105
+ * the exact dependency-closed set handed to checkpointing/build planning. */
2106
+ export function finalizeTeachCandidateSelection(
2107
+ candidates: ToolCandidate[],
2108
+ selectedToolNames: Iterable<string>,
2109
+ siteClassifications?: ClassifiedValue[],
2110
+ ): ReturnType<typeof closeCandidateSelection> & { candidates: ToolCandidate[] } {
2111
+ const mergedCandidates = mergeReplayCandidateDependencies(candidates, siteClassifications);
2112
+ return {
2113
+ candidates: mergedCandidates,
2114
+ ...closeCandidateSelection(mergedCandidates, selectedToolNames),
2115
+ };
2116
+ }
2117
+
2118
+ export function formatTeachCandidateAutoAddNotice(
2119
+ autoAdded: ToolCandidate[],
2120
+ source: 'structural' | 'replay',
2121
+ ): { body: string; title: string } | undefined {
2122
+ if (autoAdded.length === 0) return undefined;
2123
+ return {
2124
+ body: autoAdded.map((candidate) => ` + ${candidate.toolName}`).join('\n'),
2125
+ title: source === 'replay' ? 'Added recorded prerequisites' : 'Added required upstream tools',
2126
+ };
2127
+ }
2128
+
2129
+ /** Select the compiled result corresponding to the detected primary candidate.
2130
+ * Dependency closure preserves detection order, which need not put primary first. */
2131
+ export function selectPrimaryNamedResult<T extends { workflow: { toolName: string } }>(
2132
+ results: readonly T[],
2133
+ plans: ReadonlyArray<{ candidate?: { toolName: string; primary: boolean } }>,
2134
+ ): T | undefined {
2135
+ const primaryToolName = plans.find((plan) => plan.candidate?.primary)?.candidate?.toolName;
2136
+ return results.find((result) => result.workflow.toolName === primaryToolName) ?? results[0];
1957
2137
  }
1958
2138
 
1959
2139
  async function compileCandidatePlans(opts: {
@@ -1969,10 +2149,6 @@ async function compileCandidatePlans(opts: {
1969
2149
  sharedTriageResult?: TriageResult;
1970
2150
  siteClassifications?: ClassifiedValue[];
1971
2151
  teachCredentials?: { site: string; values: Record<string, string> };
1972
- /** Mirror of TeachOptions.allTools — when true, partial failures abort
1973
- * the run with a non-zero exit so the user notices missing tools instead
1974
- * of getting a silent warning. */
1975
- allTools?: boolean;
1976
2152
  /** Absolute path to the multi-tool build plan sidecar (.build-plan.json). */
1977
2153
  buildPlanPath?: string;
1978
2154
  /** Shared-module build manifest (verified flags) for this site. */
@@ -2142,18 +2318,20 @@ async function compileCandidatePlans(opts: {
2142
2318
  if (first) p.log.warn(`${first.name}: ${first.firstLineError}`);
2143
2319
  }
2144
2320
 
2145
- // Hard-fail when --all-tools was requested AND any tool failed. Silent
2146
- // partial compiles ship MCP servers with missing tools; the user only
2147
- // notices later when an LLM tries to call one that doesn't exist.
2148
- if (opts.allTools && summary.failures.length > 0) {
2149
- throw new Error(
2150
- `--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).`,
2151
- );
2152
- }
2321
+ assertCompleteSelectedToolSet(summary.failures.length, opts.plans.length);
2153
2322
 
2154
2323
  return summary.successes;
2155
2324
  }
2156
2325
 
2326
+ /** Enforce that compilation never ships a partial selected set. A missing
2327
+ * producer can make every otherwise-successful consumer unusable. */
2328
+ export function assertCompleteSelectedToolSet(failureCount: number, selectedCount: number): void {
2329
+ if (failureCount === 0) return;
2330
+ throw new Error(
2331
+ `Selected tool set is incomplete: ${failureCount} of ${selectedCount} tools failed to compile. See the summary above and re-run \`imprint teach\` after addressing the failures.`,
2332
+ );
2333
+ }
2334
+
2157
2335
  /** Pure summarizer — extracted so unit tests can drive arbitrary outcome
2158
2336
  * shapes without spinning up real compile pipelines. */
2159
2337
  interface CompileOutcomeSummary {
@@ -2488,7 +2666,10 @@ async function siteReplayAndDiff(
2488
2666
  };
2489
2667
 
2490
2668
  const classPath = pathJoin(localSiteDir(site), '.classifications.json');
2491
- writeFileSync(classPath, JSON.stringify(diffResult, null, 2));
2669
+ writeFileSync(
2670
+ classPath,
2671
+ JSON.stringify({ ...diffResult, sourceSessionHash: fileSha256(sessionPath) }, null, 2),
2672
+ );
2492
2673
 
2493
2674
  mp.clear();
2494
2675
  mp.remove('replay');
@@ -133,6 +133,9 @@ export const ToolCandidateSchema = z.object({
133
133
  expectedOutput: z.string().default(''),
134
134
  likelyParams: z.array(CandidateParamSchema).default([]),
135
135
  dependencySeqs: z.array(z.number().int().nonnegative()).default([]),
136
+ /** Direct user-visible tool prerequisites. A consumer may only be selected
137
+ * together with the transitive closure of these producer tools. */
138
+ dependsOnTools: z.array(z.string().regex(/^[a-z][a-z0-9_]*$/)).default([]),
136
139
  });
137
140
  export type ToolCandidate = z.infer<typeof ToolCandidateSchema>;
138
141
 
@@ -328,6 +331,124 @@ export function primaryToolCandidate(detection: ToolCandidateDetection): ToolCan
328
331
  return primary;
329
332
  }
330
333
 
334
+ /** Add deterministic candidate-level dependencies from request evidence.
335
+ * A dependency request creates an edge only when exactly one candidate owns
336
+ * that request seq. Shared/ambiguous ownership, unowned auth/bootstrap seqs,
337
+ * and self-owned seqs deliberately create no edge. */
338
+ export function deriveStructuralCandidateDependencies(
339
+ candidates: ToolCandidate[],
340
+ ): ToolCandidate[] {
341
+ const ownersBySeq = new Map<number, Set<string>>();
342
+ for (const candidate of candidates) {
343
+ for (const seq of candidate.requestSeqs) {
344
+ const owners = ownersBySeq.get(seq) ?? new Set<string>();
345
+ owners.add(candidate.toolName);
346
+ ownersBySeq.set(seq, owners);
347
+ }
348
+ }
349
+
350
+ const edges: Array<{ consumerTool: string; producerTool: string }> = [];
351
+ for (const candidate of candidates) {
352
+ for (const seq of candidate.dependencySeqs) {
353
+ const owners = ownersBySeq.get(seq);
354
+ if (!owners || owners.size !== 1) continue;
355
+ const producerTool = owners.values().next().value as string | undefined;
356
+ if (!producerTool || producerTool === candidate.toolName) continue;
357
+ edges.push({ consumerTool: candidate.toolName, producerTool });
358
+ }
359
+ }
360
+ return mergeCandidateDependencies(candidates, edges);
361
+ }
362
+
363
+ /** Merge grounded producer edges into candidate metadata. Unknown tools and
364
+ * self-edges are ignored; dependency names are de-duplicated and normalized to
365
+ * candidate detection order for stable persistence and display. */
366
+ export function mergeCandidateDependencies(
367
+ candidates: ToolCandidate[],
368
+ edges: ReadonlyArray<{ consumerTool: string; producerTool: string }>,
369
+ ): ToolCandidate[] {
370
+ const names = new Set(candidates.map((candidate) => candidate.toolName));
371
+ const dependencySets = new Map<string, Set<string>>();
372
+ for (const candidate of candidates) {
373
+ dependencySets.set(
374
+ candidate.toolName,
375
+ new Set(
376
+ candidate.dependsOnTools.filter((name) => names.has(name) && name !== candidate.toolName),
377
+ ),
378
+ );
379
+ }
380
+ for (const edge of edges) {
381
+ if (
382
+ edge.consumerTool === edge.producerTool ||
383
+ !names.has(edge.consumerTool) ||
384
+ !names.has(edge.producerTool)
385
+ ) {
386
+ continue;
387
+ }
388
+ dependencySets.get(edge.consumerTool)?.add(edge.producerTool);
389
+ }
390
+
391
+ const order = new Map(candidates.map((candidate, index) => [candidate.toolName, index]));
392
+ return candidates.map((candidate) => ({
393
+ ...candidate,
394
+ dependsOnTools: [...(dependencySets.get(candidate.toolName) ?? [])].sort(
395
+ (a, b) =>
396
+ (order.get(a) ?? Number.MAX_SAFE_INTEGER) - (order.get(b) ?? Number.MAX_SAFE_INTEGER),
397
+ ),
398
+ }));
399
+ }
400
+
401
+ interface CandidateSelectionClosure {
402
+ selected: ToolCandidate[];
403
+ autoAdded: ToolCandidate[];
404
+ /** Cycles encountered while walking selected dependencies. Each path repeats
405
+ * its first name at the end, e.g. ["a", "b", "a"]. */
406
+ cycles: string[][];
407
+ }
408
+
409
+ /** Return the transitive dependency closure in original detection order.
410
+ * Cycles are included exactly once and reported, never treated as recursion
411
+ * errors. Unknown selected/dependency names are ignored. */
412
+ export function closeCandidateSelection(
413
+ candidates: ToolCandidate[],
414
+ selectedToolNames: Iterable<string>,
415
+ ): CandidateSelectionClosure {
416
+ const byName = new Map(candidates.map((candidate) => [candidate.toolName, candidate]));
417
+ const initiallySelected = new Set([...selectedToolNames].filter((name) => byName.has(name)));
418
+ const included = new Set<string>();
419
+ const visiting: string[] = [];
420
+ const cycles: string[][] = [];
421
+ const cycleKeys = new Set<string>();
422
+
423
+ const include = (name: string): void => {
424
+ if (included.has(name)) return;
425
+ const candidate = byName.get(name);
426
+ if (!candidate) return;
427
+ const cycleStart = visiting.indexOf(name);
428
+ if (cycleStart >= 0) {
429
+ const cycle = [...visiting.slice(cycleStart), name];
430
+ const key = [...new Set(cycle.slice(0, -1))].sort().join('\u0000');
431
+ if (!cycleKeys.has(key)) {
432
+ cycleKeys.add(key);
433
+ cycles.push(cycle);
434
+ }
435
+ return;
436
+ }
437
+ visiting.push(name);
438
+ for (const dependency of candidate.dependsOnTools) include(dependency);
439
+ visiting.pop();
440
+ included.add(name);
441
+ };
442
+
443
+ for (const name of initiallySelected) include(name);
444
+ const selected = candidates.filter((candidate) => included.has(candidate.toolName));
445
+ return {
446
+ selected,
447
+ autoAdded: selected.filter((candidate) => !initiallySelected.has(candidate.toolName)),
448
+ cycles,
449
+ };
450
+ }
451
+
331
452
  export function buildSharedCompileContext(
332
453
  detection: ToolCandidateDetection,
333
454
  _selected: ToolCandidate[],