imprint-mcp 0.3.1 → 0.4.1

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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * `imprint export` / `imprint import` — portable .tar.gz archives of
3
+ * generated MCP tools, optionally including encrypted credential bundles.
4
+ *
5
+ * Archive layout:
6
+ * manifest.json
7
+ * <site>/<tool>/{ workflow.json, playbook.yaml, index.ts, ... }
8
+ * <site>/_shared/{ *.ts, package.json }
9
+ * <site>/credentials.imprintbundle (when --include-credentials)
10
+ */
11
+
12
+ import { execSync } from 'node:child_process';
13
+ import {
14
+ copyFileSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ mkdtempSync,
18
+ readFileSync,
19
+ readdirSync,
20
+ rmSync,
21
+ statSync,
22
+ writeFileSync,
23
+ } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join as pathJoin, resolve as pathResolve } from 'node:path';
26
+ import * as p from '@clack/prompts';
27
+ import { type BundleEnvelope, exportBundle, importBundle } from './credential-bundle.ts';
28
+ import { getCredentialBackend } from './credential-store.ts';
29
+ import { imprintHomeDir, localSharedDir, localSiteDir } from './paths.ts';
30
+ import { ensureImprintRuntimeLink } from './runtime-link.ts';
31
+ import { availableSitesHint } from './sites.ts';
32
+ import { VERSION } from './version.ts';
33
+
34
+ const TOOL_FILES = [
35
+ 'workflow.json',
36
+ 'playbook.yaml',
37
+ 'index.ts',
38
+ 'parser.ts',
39
+ 'request-transform.ts',
40
+ 'package.json',
41
+ 'backends.json',
42
+ 'cron.json',
43
+ ];
44
+
45
+ const SHARED_SKIP = new Set(['node_modules', 'bun.lock']);
46
+
47
+ interface ExportManifest {
48
+ version: 1;
49
+ imprintVersion: string;
50
+ createdAt: string;
51
+ sites: Array<{
52
+ name: string;
53
+ tools: string[];
54
+ hasCredentials: boolean;
55
+ hasShared: boolean;
56
+ }>;
57
+ }
58
+
59
+ interface ExportResult {
60
+ archivePath: string;
61
+ sites: Array<{ name: string; tools: string[] }>;
62
+ byteSize: number;
63
+ }
64
+
65
+ interface ImportResult {
66
+ sites: Array<{
67
+ name: string;
68
+ tools: string[];
69
+ credentialsImported: boolean;
70
+ skipped: boolean;
71
+ }>;
72
+ }
73
+
74
+ function isToolDir(dir: string): boolean {
75
+ return existsSync(pathJoin(dir, 'index.ts'));
76
+ }
77
+
78
+ function discoverToolNames(siteDir: string): string[] {
79
+ if (!existsSync(siteDir)) return [];
80
+ return readdirSync(siteDir)
81
+ .filter((entry) => {
82
+ if (
83
+ entry.startsWith('.') ||
84
+ entry.startsWith('_') ||
85
+ entry === 'sessions' ||
86
+ entry === 'node_modules'
87
+ )
88
+ return false;
89
+ const full = pathJoin(siteDir, entry);
90
+ try {
91
+ return statSync(full).isDirectory() && isToolDir(full);
92
+ } catch {
93
+ return false;
94
+ }
95
+ })
96
+ .sort();
97
+ }
98
+
99
+ function collectToolFiles(toolDir: string): string[] {
100
+ return TOOL_FILES.filter((f) => existsSync(pathJoin(toolDir, f)));
101
+ }
102
+
103
+ function collectSharedFiles(sharedDir: string): string[] {
104
+ if (!existsSync(sharedDir)) return [];
105
+ return readdirSync(sharedDir).filter((f) => {
106
+ if (SHARED_SKIP.has(f)) return false;
107
+ if (f.endsWith('.test.ts') || f.endsWith('.plan.md')) return false;
108
+ const full = pathJoin(sharedDir, f);
109
+ try {
110
+ return statSync(full).isFile();
111
+ } catch {
112
+ return false;
113
+ }
114
+ });
115
+ }
116
+
117
+ export async function exportArchive(opts: {
118
+ sites: string[];
119
+ out: string;
120
+ includeCredentials?: boolean;
121
+ }): Promise<ExportResult> {
122
+ for (const site of opts.sites) {
123
+ const dir = localSiteDir(site);
124
+ if (!existsSync(dir)) {
125
+ throw new Error(
126
+ `Site "${site}" not found at ${dir}.\n${availableSitesHint(imprintHomeDir(), site)}`,
127
+ );
128
+ }
129
+ }
130
+
131
+ const staging = mkdtempSync(pathJoin(tmpdir(), 'imprint-export-'));
132
+
133
+ try {
134
+ const manifest: ExportManifest = {
135
+ version: 1,
136
+ imprintVersion: VERSION,
137
+ createdAt: new Date().toISOString(),
138
+ sites: [],
139
+ };
140
+
141
+ for (const site of opts.sites) {
142
+ const siteDir = localSiteDir(site);
143
+ const tools = discoverToolNames(siteDir);
144
+ if (tools.length === 0) {
145
+ throw new Error(
146
+ `Site "${site}" has no tools (no subdirectories with index.ts). Nothing to export.`,
147
+ );
148
+ }
149
+
150
+ const stagingSite = pathJoin(staging, site);
151
+ mkdirSync(stagingSite, { recursive: true });
152
+
153
+ for (const tool of tools) {
154
+ const toolDir = pathJoin(siteDir, tool);
155
+ const stagingTool = pathJoin(stagingSite, tool);
156
+ mkdirSync(stagingTool, { recursive: true });
157
+
158
+ for (const file of collectToolFiles(toolDir)) {
159
+ copyFileSync(pathJoin(toolDir, file), pathJoin(stagingTool, file));
160
+ }
161
+ }
162
+
163
+ const sharedDir = localSharedDir(site);
164
+ const sharedFiles = collectSharedFiles(sharedDir);
165
+ let hasShared = false;
166
+ if (sharedFiles.length > 0) {
167
+ const stagingShared = pathJoin(stagingSite, '_shared');
168
+ mkdirSync(stagingShared, { recursive: true });
169
+ for (const file of sharedFiles) {
170
+ copyFileSync(pathJoin(sharedDir, file), pathJoin(stagingShared, file));
171
+ }
172
+ hasShared = true;
173
+ }
174
+
175
+ let hasCredentials = false;
176
+ if (opts.includeCredentials) {
177
+ const backend = await getCredentialBackend();
178
+ const secrets = await backend.listSecrets(site);
179
+ const cookies = await backend.getCookies(site);
180
+ if (secrets.length > 0 || cookies.length > 0) {
181
+ const passphrase = await p.password({
182
+ message: `Passphrase to encrypt credentials for "${site}" (min 8 chars):`,
183
+ validate: (v) =>
184
+ (v ?? '').length < 8 ? 'Passphrase must be at least 8 characters.' : undefined,
185
+ });
186
+ if (p.isCancel(passphrase)) {
187
+ throw new Error('Export cancelled.');
188
+ }
189
+ const envelope = await exportBundle({
190
+ backend,
191
+ site,
192
+ passphrase,
193
+ });
194
+ writeFileSync(
195
+ pathJoin(stagingSite, 'credentials.imprintbundle'),
196
+ JSON.stringify(envelope, null, 2),
197
+ 'utf8',
198
+ );
199
+ hasCredentials = true;
200
+ }
201
+ }
202
+
203
+ manifest.sites.push({ name: site, tools, hasCredentials, hasShared });
204
+ }
205
+
206
+ writeFileSync(pathJoin(staging, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
207
+
208
+ const archivePath = pathResolve(opts.out);
209
+ execSync(`tar czf ${shellEscape(archivePath)} -C ${shellEscape(staging)} .`, {
210
+ stdio: 'pipe',
211
+ });
212
+
213
+ const byteSize = statSync(archivePath).size;
214
+ return {
215
+ archivePath,
216
+ sites: manifest.sites.map((s) => ({ name: s.name, tools: s.tools })),
217
+ byteSize,
218
+ };
219
+ } finally {
220
+ rmSync(staging, { recursive: true, force: true });
221
+ }
222
+ }
223
+
224
+ export async function importArchive(opts: {
225
+ archivePath: string;
226
+ force?: boolean;
227
+ }): Promise<ImportResult> {
228
+ const archivePath = pathResolve(opts.archivePath);
229
+ if (!existsSync(archivePath)) {
230
+ throw new Error(`Archive not found: ${archivePath}`);
231
+ }
232
+
233
+ const staging = mkdtempSync(pathJoin(tmpdir(), 'imprint-import-'));
234
+
235
+ try {
236
+ execSync(`tar xzf ${shellEscape(archivePath)} -C ${shellEscape(staging)}`, {
237
+ stdio: 'pipe',
238
+ });
239
+
240
+ const manifestPath = pathJoin(staging, 'manifest.json');
241
+ if (!existsSync(manifestPath)) {
242
+ throw new Error(
243
+ 'Invalid archive: missing manifest.json. This does not appear to be an imprint export.',
244
+ );
245
+ }
246
+
247
+ const manifest: ExportManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
248
+ if (manifest.version !== 1) {
249
+ throw new Error(
250
+ `Unsupported archive version ${manifest.version}. Update imprint and try again.`,
251
+ );
252
+ }
253
+
254
+ const home = imprintHomeDir();
255
+ const result: ImportResult = { sites: [] };
256
+
257
+ for (const entry of manifest.sites) {
258
+ const targetDir = localSiteDir(entry.name);
259
+ const skipped = false;
260
+
261
+ if (existsSync(targetDir) && !opts.force) {
262
+ console.error(
263
+ `warning: site "${entry.name}" already exists at ${targetDir} — skipping (use --force to overwrite).`,
264
+ );
265
+ result.sites.push({
266
+ name: entry.name,
267
+ tools: entry.tools,
268
+ credentialsImported: false,
269
+ skipped: true,
270
+ });
271
+ continue;
272
+ }
273
+
274
+ const stagedSite = pathJoin(staging, entry.name);
275
+
276
+ if (existsSync(targetDir)) {
277
+ rmSync(targetDir, { recursive: true, force: true });
278
+ }
279
+
280
+ for (const tool of entry.tools) {
281
+ assertSafeSegment('tool name', tool);
282
+ const src = pathJoin(stagedSite, tool);
283
+ const dest = pathJoin(targetDir, tool);
284
+ mkdirSync(dest, { recursive: true });
285
+ for (const file of readdirSync(src)) {
286
+ const srcFile = pathJoin(src, file);
287
+ if (statSync(srcFile).isFile()) {
288
+ copyFileSync(srcFile, pathJoin(dest, file));
289
+ }
290
+ }
291
+ }
292
+
293
+ if (entry.hasShared) {
294
+ const sharedSrc = pathJoin(stagedSite, '_shared');
295
+ const sharedDest = pathJoin(targetDir, '_shared');
296
+ if (existsSync(sharedSrc)) {
297
+ mkdirSync(sharedDest, { recursive: true });
298
+ for (const file of readdirSync(sharedSrc)) {
299
+ const srcFile = pathJoin(sharedSrc, file);
300
+ if (statSync(srcFile).isFile()) {
301
+ copyFileSync(srcFile, pathJoin(sharedDest, file));
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ let credentialsImported = false;
308
+ const bundlePath = pathJoin(stagedSite, 'credentials.imprintbundle');
309
+ if (entry.hasCredentials && existsSync(bundlePath)) {
310
+ const envelope: BundleEnvelope = JSON.parse(readFileSync(bundlePath, 'utf8'));
311
+ const passphrase = await p.password({
312
+ message: `Passphrase to decrypt credentials for "${entry.name}":`,
313
+ });
314
+ if (p.isCancel(passphrase)) {
315
+ console.error(`Skipping credential import for "${entry.name}".`);
316
+ } else {
317
+ try {
318
+ const backend = await getCredentialBackend();
319
+ await importBundle({ backend, envelope, passphrase });
320
+ credentialsImported = true;
321
+ } catch (err) {
322
+ console.error(
323
+ `warning: credential import failed for "${entry.name}": ${err instanceof Error ? err.message : String(err)}`,
324
+ );
325
+ }
326
+ }
327
+ }
328
+
329
+ ensureImprintRuntimeLink(home);
330
+
331
+ result.sites.push({
332
+ name: entry.name,
333
+ tools: entry.tools,
334
+ credentialsImported,
335
+ skipped,
336
+ });
337
+ }
338
+
339
+ return result;
340
+ } finally {
341
+ rmSync(staging, { recursive: true, force: true });
342
+ }
343
+ }
344
+
345
+ function assertSafeSegment(label: string, value: string): void {
346
+ if (value.includes('..') || value.includes('/') || value.includes('\\')) {
347
+ throw new Error(
348
+ `Invalid ${label} in archive: "${value}". Must not contain path separators or ".." sequences.`,
349
+ );
350
+ }
351
+ }
352
+
353
+ function shellEscape(s: string): string {
354
+ return `'${s.replace(/'/g, "'\\''")}'`;
355
+ }
@@ -30,6 +30,7 @@ import { imprintHomeDir, localSiteDir } from './paths.ts';
30
30
  import {
31
31
  type WorkflowState,
32
32
  loadTeachState,
33
+ pruneStalePendingTeachWorkflows,
33
34
  resolveTeachStatePath,
34
35
  saveTeachState,
35
36
  teachStatePath,
@@ -37,7 +38,7 @@ import {
37
38
 
38
39
  type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
39
40
  type LocalDeleteMode = 'none' | 'tool' | 'site';
40
- type IssueKind = 'incomplete' | 'missing-session' | 'stale-registration';
41
+ type IssueKind = 'missing-session' | 'stale-registration';
41
42
 
42
43
  const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
43
44
  const DISABLED_STORE_VERSION = 1;
@@ -427,7 +428,7 @@ function fixIssue(issue: McpIssue, status: McpStatus, ctx: MaintenanceContext):
427
428
  };
428
429
  }
429
430
 
430
- if ((issue.kind === 'incomplete' || issue.kind === 'missing-session') && issue.workflow) {
431
+ if (issue.kind === 'missing-session' && issue.workflow) {
431
432
  return pruneSingleTeachWorkflow(issue.site, issue.workflow);
432
433
  }
433
434
 
@@ -688,8 +689,6 @@ function issueFixHint(issue: McpIssue): string | null {
688
689
  switch (issue.kind) {
689
690
  case 'stale-registration':
690
691
  return `choose "Fix an issue" or run: imprint mcp delete ${issue.name ?? `imprint-${issue.site}`} --client ${issue.client ?? 'all'} --yes`;
691
- case 'incomplete':
692
- return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --incomplete --yes`;
693
692
  case 'missing-session':
694
693
  return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
695
694
  }
@@ -737,6 +736,9 @@ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
737
736
  }
738
737
 
739
738
  const state = loadTeachState(site);
739
+ if (pruneStalePendingTeachWorkflows(site, state)) {
740
+ saveTeachState(site, state);
741
+ }
740
742
  const workflows = Object.entries(state.workflows)
741
743
  .map(([name, ws]) => workflowStatus(site, name, ws, tools))
742
744
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -803,14 +805,6 @@ function collectIssues(opts: {
803
805
  path: wf.sessionPath ?? undefined,
804
806
  });
805
807
  }
806
- if (wf.incomplete) {
807
- issues.push({
808
- kind: 'incomplete',
809
- site: site.site,
810
- workflow: wf.name,
811
- message: `${site.site}/${wf.name} is incomplete (${wf.completedSteps.join(', ') || 'no completed steps'})`,
812
- });
813
- }
814
808
  }
815
809
  }
816
810
 
@@ -148,6 +148,24 @@ export function resolveTeachStatePath(
148
148
  return resolveLocalSitePath(site, value);
149
149
  }
150
150
 
151
+ export function resolveWorkflowTriagedPath(
152
+ site: string,
153
+ ws: WorkflowState | undefined,
154
+ ): string | null {
155
+ if (!ws) return null;
156
+
157
+ const explicitPath = resolveTeachStatePath(site, ws.triagedPath);
158
+ if (explicitPath) return explicitPath;
159
+
160
+ if (!ws.completedSteps.includes('triage')) return null;
161
+
162
+ const redactedPath = resolveTeachStatePath(site, ws.redactedPath);
163
+ if (!redactedPath?.endsWith('.redacted.json')) return null;
164
+
165
+ const derivedPath = redactedPath.replace(/\.redacted\.json$/, '.triaged.json');
166
+ return existsSync(derivedPath) ? derivedPath : null;
167
+ }
168
+
151
169
  export function toRelativeTeachStatePath(site: string, absPath: string): string {
152
170
  const localRelative = relativeToLocalSite(site, absPath);
153
171
  if (localRelative) return localRelative;
@@ -245,6 +263,25 @@ export function isExistingTeachFile(path: string | null | undefined): path is st
245
263
  }
246
264
  }
247
265
 
266
+ function hasRecoverableRawOrRedactedSession(site: string, ws: WorkflowState): boolean {
267
+ return (
268
+ isExistingTeachFile(resolveTeachStatePath(site, ws.sessionPath)) ||
269
+ isExistingTeachFile(resolveTeachStatePath(site, ws.redactedPath))
270
+ );
271
+ }
272
+
273
+ export function pruneStalePendingTeachWorkflows(site: string, state: TeachState): boolean {
274
+ let changed = false;
275
+ for (const [key, ws] of Object.entries(state.workflows)) {
276
+ if (!key.startsWith('_pending_')) continue;
277
+ if (hasRecoverableRawOrRedactedSession(site, ws)) continue;
278
+ delete state.workflows[key];
279
+ changed = true;
280
+ }
281
+
282
+ return changed;
283
+ }
284
+
248
285
  export function friendlySessionTimestamp(sessionPath: string): string {
249
286
  const m = sessionPath.match(/(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})/);
250
287
  if (!m) return pathBasename(sessionPath);
@@ -82,7 +82,9 @@ import {
82
82
  isExistingTeachFile as isExistingFile,
83
83
  loadTeachState,
84
84
  nextTeachStep as nextStep,
85
+ pruneStalePendingTeachWorkflows,
85
86
  resolveTeachStatePath,
87
+ resolveWorkflowTriagedPath,
86
88
  saveTeachState,
87
89
  toRelativeTeachStatePath as toRelative,
88
90
  } from './teach-state.ts';
@@ -98,7 +100,11 @@ import { setSpanAttributes, traced } from './tracing.ts';
98
100
  import { CronConfigSchema, SessionSchema, WorkflowSchema } from './types.ts';
99
101
  import type { CronConfig, Playbook, Session, Workflow } from './types.ts';
100
102
 
101
- export { buildTeachStateFromSession, resolveTeachStatePath } from './teach-state.ts';
103
+ export {
104
+ buildTeachStateFromSession,
105
+ resolveTeachStatePath,
106
+ resolveWorkflowTriagedPath,
107
+ } from './teach-state.ts';
102
108
 
103
109
  /**
104
110
  * How many compile agents run in parallel when more than one tool is selected.
@@ -396,6 +402,9 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
396
402
 
397
403
  const completedWorkflows = discoverCompletedWorkflows(site);
398
404
  const completedSet = new Set(completedWorkflows);
405
+ if (pruneStalePendingTeachWorkflows(site, state)) {
406
+ saveTeachState(site, state);
407
+ }
399
408
  const incompleteWorkflows = Object.entries(state.workflows).filter(
400
409
  ([name]) => !completedSet.has(name),
401
410
  );
@@ -725,7 +734,6 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
725
734
  // Run them in parallel so the user can select tools while replay runs.
726
735
  let siteClassifications: ClassifiedValue[] | undefined;
727
736
  let triageResult: TriageResult | undefined;
728
- let triagedPath: string | null = null;
729
737
  let plans: CandidateCompilePlan[];
730
738
 
731
739
  let needsReplay = startIdx <= STEPS.indexOf('replay-and-diff') && !opts.skipReplay;
@@ -783,7 +791,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
783
791
 
784
792
  // Branch B: triage → detect-candidates → user selection (fast, ~30s)
785
793
  type CandidateChainResult = {
786
- triageRes?: { result: TriageResult; sessionPath: string };
794
+ triageResult?: TriageResult;
787
795
  plans: CandidateCompilePlan[];
788
796
  };
789
797
  const candidatePromise = (async (): Promise<CandidateChainResult> => {
@@ -836,8 +844,11 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
836
844
  );
837
845
  } else {
838
846
  const ws = state.workflows[workflowKey];
839
- if (ws?.triagedPath) {
840
- localTriagedPath = resolveTeachStatePath(site, ws.triagedPath);
847
+ localTriagedPath = resolveWorkflowTriagedPath(site, ws);
848
+ if (ws && localTriagedPath && !ws.triagedPath) {
849
+ updateCheckpoint(site, state, workflowKey, 'triage', {
850
+ triagedPath: toRelative(site, localTriagedPath),
851
+ });
841
852
  }
842
853
  }
843
854
 
@@ -875,6 +886,9 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
875
886
  kind: 'raw',
876
887
  });
877
888
  const baseState = buildTeachStateFromSession(site, rawSessionPath, redactedPath);
889
+ if (localTriagedPath) {
890
+ baseState.triagedPath = toRelative(site, localTriagedPath);
891
+ }
878
892
  const candidatePlans = selected.map((candidate) => {
879
893
  checkpoint(site, state, candidate.toolName, {
880
894
  ...baseState,
@@ -896,9 +910,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
896
910
  }
897
911
 
898
912
  return {
899
- triageRes: localTriageResult
900
- ? { result: localTriageResult, sessionPath: replaySessionPath }
901
- : undefined,
913
+ triageResult: localTriageResult,
902
914
  plans: candidatePlans,
903
915
  };
904
916
  })();
@@ -907,13 +919,7 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
907
919
  const candidateResult = await candidatePromise;
908
920
  plans = candidateResult.plans;
909
921
 
910
- if (candidateResult.triageRes) {
911
- triageResult = candidateResult.triageRes.result;
912
- triagedPath = candidateResult.triageRes.sessionPath.replace(
913
- /\.redacted\.json$/,
914
- '.triaged.json',
915
- );
916
- }
922
+ triageResult = candidateResult.triageResult;
917
923
 
918
924
  // Wait for replay — may already be done, or show progress while waiting
919
925
  let replaySettled = false;
@@ -938,18 +944,19 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
938
944
  mp.clear();
939
945
 
940
946
  // Checkpoints — write sequentially after both complete
941
- if (needsReplay) {
942
- updateCheckpoint(site, state, workflowKey, 'replay-and-diff', {
943
- classificationsPath: siteClassifications
944
- ? toRelative(site, pathJoin(localSiteDir(site), '.classifications.json'))
945
- : undefined,
946
- });
947
- }
948
- if (candidateResult.triageRes && triagedPath) {
949
- updateCheckpoint(site, state, workflowKey, 'triage', {
950
- triagedPath: toRelative(site, triagedPath),
951
- });
952
- }
947
+ updateCandidateStageCheckpoints({
948
+ site,
949
+ state,
950
+ plans,
951
+ fallbackWorkflowKey: workflowKey,
952
+ replay: needsReplay
953
+ ? {
954
+ classificationsPath: siteClassifications
955
+ ? toRelative(site, pathJoin(localSiteDir(site), '.classifications.json'))
956
+ : undefined,
957
+ }
958
+ : undefined,
959
+ });
953
960
  } finally {
954
961
  unmuteLog();
955
962
  }
@@ -964,8 +971,11 @@ export async function teach(opts: TeachOptions): Promise<TeachResult> {
964
971
  }
965
972
  }
966
973
  const ws = state.workflows[workflowKey];
967
- if (ws?.triagedPath) {
968
- triagedPath = resolveTeachStatePath(site, ws.triagedPath);
974
+ const resolvedTriagedPath = resolveWorkflowTriagedPath(site, ws);
975
+ if (ws && resolvedTriagedPath && !ws.triagedPath) {
976
+ updateCheckpoint(site, state, workflowKey, 'triage', {
977
+ triagedPath: toRelative(site, resolvedTriagedPath),
978
+ });
969
979
  }
970
980
  plans = [
971
981
  {
@@ -1251,6 +1261,29 @@ export interface CandidateCompilePlan {
1251
1261
  sharedContext?: SharedCompileContext;
1252
1262
  }
1253
1263
 
1264
+ function candidateStageCheckpointKeys(
1265
+ plans: CandidateCompilePlan[],
1266
+ fallbackWorkflowKey: string,
1267
+ ): string[] {
1268
+ const keys = plans.map((plan) => plan.workflowKey).filter((key) => key.length > 0);
1269
+ return [...new Set(keys.length > 0 ? keys : [fallbackWorkflowKey])];
1270
+ }
1271
+
1272
+ export function updateCandidateStageCheckpoints(opts: {
1273
+ site: string;
1274
+ state: TeachState;
1275
+ plans: CandidateCompilePlan[];
1276
+ fallbackWorkflowKey: string;
1277
+ replay?: Partial<WorkflowState>;
1278
+ triage?: Partial<WorkflowState>;
1279
+ }): void {
1280
+ const keys = candidateStageCheckpointKeys(opts.plans, opts.fallbackWorkflowKey);
1281
+ for (const key of keys) {
1282
+ if (opts.replay) updateCheckpoint(opts.site, opts.state, key, 'replay-and-diff', opts.replay);
1283
+ if (opts.triage) updateCheckpoint(opts.site, opts.state, key, 'triage', opts.triage);
1284
+ }
1285
+ }
1286
+
1254
1287
  async function detectTeachCandidates(opts: {
1255
1288
  sessionPath: string;
1256
1289
  providerName: ProviderName;