imprint-mcp 0.3.1 → 0.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imprint-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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
@@ -62,6 +62,10 @@ INSTALL
62
62
  install [<site>] Install an emitted MCP server into an AI platform.
63
63
  uninstall [<site>] Remove an installed Imprint MCP server from an AI platform.
64
64
 
65
+ SHARE
66
+ export <site> [<site2>] Bundle site tools into a portable .tar.gz archive.
67
+ import <archive.tar.gz> Unpack an archive into ~/.imprint and set up tools.
68
+
65
69
  RUN
66
70
  mcp-server <site> Serve one site's tools as MCP (stdio default).
67
71
  cron <site> Polling daemon for ~/.imprint/<site>/<toolName>/cron.json.
@@ -284,6 +288,39 @@ export const VERB_HELP: Record<string, VerbHelp> = {
284
288
  ],
285
289
  example: 'imprint uninstall google-flights --platform claude-desktop',
286
290
  },
291
+ export: {
292
+ summary:
293
+ 'Bundle one or more site tool sets into a portable .tar.gz archive for sharing across machines.',
294
+ usage: ['imprint export <site> [<site2> ...] [--out <path>] [--include-credentials]'],
295
+ flags: [
296
+ {
297
+ name: '--out <path>',
298
+ description:
299
+ 'Output path. Defaults to ./imprint-export-<site>.tar.gz (single) or ./imprint-export-<timestamp>.tar.gz (multi).',
300
+ },
301
+ {
302
+ name: '--include-credentials',
303
+ description: 'Embed encrypted credential bundles (prompts for a passphrase per site).',
304
+ },
305
+ ],
306
+ example: 'imprint export avis southwest marriott --out tools.tar.gz --include-credentials',
307
+ },
308
+ import: {
309
+ summary: 'Unpack an imprint export archive into ~/.imprint and set up tools for use.',
310
+ usage: ['imprint import <archive.tar.gz> [--force] [--platform <name>]'],
311
+ flags: [
312
+ {
313
+ name: '--force',
314
+ description: 'Overwrite existing sites instead of skipping them.',
315
+ },
316
+ {
317
+ name: '--platform <name>',
318
+ description:
319
+ 'Auto-install MCP servers after import: claude-code, codex, claude-desktop, openclaw, hermes.',
320
+ },
321
+ ],
322
+ example: 'imprint import tools.tar.gz --force --platform claude-code',
323
+ },
287
324
  login: {
288
325
  summary: 'Persist auth cookies for <site> from a captured session.',
289
326
  usage: ['imprint login <site> --from-session <session.json>'],
@@ -896,6 +933,114 @@ async function main(argv: string[]): Promise<number> {
896
933
  return 0;
897
934
  }
898
935
 
936
+ case 'export': {
937
+ const sites: string[] = [];
938
+ let i = 1;
939
+ for (; i < argv.length; i++) {
940
+ const arg = argv[i];
941
+ if (!arg || arg.startsWith('-')) break;
942
+ sites.push(arg);
943
+ }
944
+ if (sites.length === 0) {
945
+ console.error('error: `imprint export` requires at least one <site> argument.');
946
+ return 2;
947
+ }
948
+ const { values } = parseArgs({
949
+ args: argv.slice(i),
950
+ options: {
951
+ out: { type: 'string' },
952
+ 'include-credentials': { type: 'boolean' },
953
+ },
954
+ allowPositionals: false,
955
+ });
956
+ const defaultOut =
957
+ sites.length === 1
958
+ ? `imprint-export-${sites[0]}.tar.gz`
959
+ : `imprint-export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.tar.gz`;
960
+ const out = values.out ?? defaultOut;
961
+ const { exportArchive } = await import('./imprint/export-archive.ts');
962
+ const result = await exportArchive({
963
+ sites,
964
+ out,
965
+ includeCredentials: values['include-credentials'],
966
+ });
967
+ console.log(`[imprint] exported → ${result.archivePath}`);
968
+ for (const s of result.sites) {
969
+ console.log(
970
+ `[imprint] ${s.name}: ${s.tools.length} tool${s.tools.length === 1 ? '' : 's'} (${s.tools.join(', ')})`,
971
+ );
972
+ }
973
+ const kb = (result.byteSize / 1024).toFixed(1);
974
+ console.log(`[imprint] archive size: ${kb} KB`);
975
+ console.log('');
976
+ console.log('next step:');
977
+ console.log(` imprint import ${out} # on the target machine`);
978
+ return 0;
979
+ }
980
+
981
+ case 'import': {
982
+ const archivePath = requirePositional(argv, 'import', 'an <archive.tar.gz> argument');
983
+ if (archivePath === null) return 2;
984
+ const { values } = parseArgs({
985
+ args: argv.slice(2),
986
+ options: {
987
+ force: { type: 'boolean' },
988
+ platform: { type: 'string' },
989
+ },
990
+ allowPositionals: false,
991
+ });
992
+
993
+ if (values.platform) {
994
+ const { PLATFORMS } = await import('./imprint/integrations.ts');
995
+ if (!PLATFORMS.includes(values.platform as (typeof PLATFORMS)[number])) {
996
+ console.error(
997
+ `error: unknown platform '${values.platform}' — valid: ${PLATFORMS.join(', ')}`,
998
+ );
999
+ return 2;
1000
+ }
1001
+ }
1002
+
1003
+ const { importArchive } = await import('./imprint/export-archive.ts');
1004
+ const result = await importArchive({
1005
+ archivePath,
1006
+ force: values.force,
1007
+ });
1008
+
1009
+ for (const s of result.sites) {
1010
+ if (s.skipped) {
1011
+ console.log(`[imprint] ${s.name}: skipped (already exists)`);
1012
+ } else {
1013
+ console.log(
1014
+ `[imprint] ${s.name}: imported ${s.tools.length} tool${s.tools.length === 1 ? '' : 's'} (${s.tools.join(', ')})${s.credentialsImported ? ' + credentials' : ''}`,
1015
+ );
1016
+ }
1017
+ }
1018
+
1019
+ const imported = result.sites.filter((s) => !s.skipped);
1020
+ if (imported.length > 0 && !values.platform) {
1021
+ console.log('');
1022
+ console.log('next steps:');
1023
+ for (const s of imported) {
1024
+ console.log(` imprint install ${s.name} # register MCP server`);
1025
+ }
1026
+ }
1027
+
1028
+ if (values.platform) {
1029
+ const { install } = await import('./imprint/install.ts');
1030
+ const { PLATFORMS } = await import('./imprint/integrations.ts');
1031
+ for (const s of imported) {
1032
+ const installResult = await install({
1033
+ site: s.name,
1034
+ platform: values.platform as (typeof PLATFORMS)[number],
1035
+ noInteractive: true,
1036
+ });
1037
+ console.log(`[imprint] ${installResult.message}`);
1038
+ }
1039
+ }
1040
+
1041
+ return 0;
1042
+ }
1043
+
899
1044
  case 'login': {
900
1045
  const site = requirePositional(argv, 'login', 'a <site> argument');
901
1046
  if (site === null) return 2;
@@ -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;