@brainervirus/workit-core 2.8.0 → 3.0.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.
@@ -1,1789 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import {
3
- closeSync,
4
- cpSync,
5
- existsSync,
6
- lstatSync,
7
- mkdirSync,
8
- opendirSync,
9
- openSync,
10
- readFileSync,
11
- readSync,
12
- readdirSync,
13
- unlinkSync,
14
- constants as fsConstants,
15
- } from "node:fs";
16
- import os from "node:os";
17
- import path from "node:path";
18
- import { failure, success, type Digest, type Id, type Result } from "./task-contract";
19
- import {
20
- applyConversionConfig,
21
- previewConversion,
22
- type ConversionPreview,
23
- } from "./config-conversion";
24
- import {
25
- CURSOR_RUNTIME_PACKAGE,
26
- cursorHooksEntry,
27
- cursorMcpServerEntry,
28
- isWorkitPlugin,
29
- mergeCursorMcp,
30
- mergeCursorSettings,
31
- mergeOpenCodeConfig,
32
- } from "./registration";
33
- import { WORKIT_METHOD_SKILLS, WORKIT_SKILL_ALIASES } from "./skill-manifests";
34
- import { writeFileAtomic, writeFileExclusive } from "./safe-write";
35
- import { LEGACY_CURSOR_ASSET_DIGESTS } from "./legacy-ownership";
36
-
37
- export type CutoverHost = "opencode" | "cursor" | "codex" | "pi";
38
-
39
- export type SessionObservation = {
40
- host: CutoverHost | "opencode" | "cursor";
41
- handle: string;
42
- state: "stopped" | "active" | "unknown";
43
- };
44
-
45
- export type CutoverInventoryEntry = {
46
- path: string;
47
- kind: "file" | "directory" | "symlink" | "other";
48
- category:
49
- | "configuration"
50
- | "recovery"
51
- | "project-state"
52
- | "project-history"
53
- | "host-configuration"
54
- | "integration";
55
- disposition: "preserve" | "convert" | "unknown";
56
- bytes: number;
57
- digest: Digest | null;
58
- ownershipEvidence: string | null;
59
- };
60
-
61
- export type CutoverInventory = {
62
- complete: boolean;
63
- entries: CutoverInventoryEntry[];
64
- files: number;
65
- bytes: number;
66
- bytesByCategory: Partial<Record<CutoverInventoryEntry["category"], number>>;
67
- issues: string[];
68
- revision: Digest;
69
- };
70
-
71
- export type CutoverPlan = {
72
- id: Id;
73
- archiveDir: string | null;
74
- managedFiles: { path: string; beforeDigest: Digest | null; afterDigest: Digest }[];
75
- inventory: CutoverInventory;
76
- sessions: SessionObservation[];
77
- unresolved: { key: string; reason: string }[];
78
- conversion: ConversionPreview;
79
- hosts: CutoverHost[];
80
- blocked: string[];
81
- };
82
-
83
- export type CutoverReceipt = {
84
- backupId: Id;
85
- archiveDir: string;
86
- planId: Id;
87
- generation: "legacy" | "v1";
88
- hosts: CutoverHost[];
89
- managedFiles: { path: string; installedDigest: Digest }[];
90
- partial: boolean;
91
- notes: string[];
92
- };
93
-
94
- type CutoverJournal = {
95
- schemaVersion: 1;
96
- backupId: Id;
97
- archiveDir: string;
98
- planId: Id;
99
- hosts: CutoverHost[];
100
- resolutions: Record<string, string>;
101
- conversionNeeded: boolean;
102
- phase: "applying" | "interrupted" | "partial" | "complete";
103
- completedSteps: string[];
104
- appliedHosts: CutoverHost[];
105
- currentStep: string | null;
106
- error: string | null;
107
- startedAt: string;
108
- notes: string[];
109
- };
110
-
111
- export type CutoverDecision = {
112
- approve: true;
113
- hosts: CutoverHost[];
114
- resolutions?: Record<string, string>;
115
- };
116
-
117
- export type RollbackPreview = {
118
- backupId: Id;
119
- restorable: {
120
- path: string;
121
- currentDigest: Digest;
122
- installedDigest: Digest;
123
- restore: "backup" | "remove";
124
- }[];
125
- conflicts: { path: string; currentDigest: Digest | null; installedDigest: Digest }[];
126
- preserved: string[];
127
- issues: string[];
128
- };
129
-
130
- export type CutoverPaths = {
131
- home?: string;
132
- configDir?: string;
133
- stateDir?: string;
134
- archiveDir?: string;
135
- dev?: string | null;
136
- workspace?: string;
137
- opencodeConfig?: string;
138
- cursorSettings?: string;
139
- cursorMcp?: string;
140
- cursorPluginDir?: string;
141
- piConfig?: string;
142
- piSettings?: string;
143
- sessions?: SessionObservation[];
144
- env?: NodeJS.ProcessEnv;
145
- };
146
-
147
- export type GenerationState = {
148
- target: "legacy" | "v1";
149
- cutover?: { backupId: Id; planId: Id; at: string };
150
- };
151
-
152
- const GENERATION_FILE = "generation.json";
153
- const CUTOVER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
154
- const validCutoverId = (value: string): boolean => CUTOVER_ID.test(value);
155
-
156
- const resolvePaths = (options: CutoverPaths = {}) => {
157
- const home = options.home ?? options.env?.HOME ?? os.homedir();
158
- const configDir =
159
- options.configDir ??
160
- options.env?.WORKFLOW_TOOLKIT_CONFIG ??
161
- path.join(home, ".config", "workit");
162
- const stateDir =
163
- options.stateDir ??
164
- options.env?.WORKFLOW_TOOLKIT_STATE ??
165
- path.join(home, ".local", "state", "workit");
166
- const piAgentDir = options.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
167
- return {
168
- home,
169
- configDir,
170
- stateDir,
171
- dev: options.dev ?? options.env?.WORKFLOW_TOOLKIT_DEV ?? null,
172
- workspace: options.workspace ?? process.cwd(),
173
- opencodeConfig:
174
- options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
175
- cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
176
- cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
177
- cursorPluginDir:
178
- options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
179
- piConfig: options.piConfig ?? path.join(home, ".pi", "config.json"),
180
- piSettings: options.piSettings ?? path.join(piAgentDir, "settings.json"),
181
- sessions: options.sessions ?? [],
182
- };
183
- };
184
-
185
- const MAX_CUTOVER_INVENTORY_ENTRIES = 50_000;
186
- const MAX_CUTOVER_INVENTORY_DEPTH = 64;
187
-
188
- const isMissing = (error: unknown): boolean =>
189
- typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
190
-
191
- const lstatOrNull = (file: string) => {
192
- try {
193
- return lstatSync(file);
194
- } catch (error) {
195
- if (isMissing(error)) return null;
196
- throw error;
197
- }
198
- };
199
-
200
- const noSymlinkAncestor = (file: string): boolean => {
201
- let current = path.resolve(file);
202
- for (;;) {
203
- const stat = lstatOrNull(current);
204
- if (stat?.isSymbolicLink()) return false;
205
- const parent = path.dirname(current);
206
- if (parent === current) return true;
207
- current = parent;
208
- }
209
- };
210
-
211
- const readDirNamesBounded = (
212
- dir: string,
213
- limit: number,
214
- ): { names: string[]; truncated: boolean } => {
215
- const handle = opendirSync(dir);
216
- const names: string[] = [];
217
- try {
218
- for (;;) {
219
- const entry = handle.readSync();
220
- if (!entry) return { names: names.toSorted(), truncated: false };
221
- if (names.length === limit) return { names: names.toSorted(), truncated: true };
222
- names.push(entry.name);
223
- }
224
- } finally {
225
- handle.closeSync();
226
- }
227
- };
228
-
229
- export const digestFile = (file: string): Digest | null => {
230
- if (!noSymlinkAncestor(path.dirname(file))) return null;
231
- const stat = lstatOrNull(file);
232
- if (!stat?.isFile() || stat.isSymbolicLink()) return null;
233
- let fd: number | undefined;
234
- try {
235
- fd = openSync(file, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
236
- const hash = createHash("sha256");
237
- const chunk = Buffer.allocUnsafe(64 * 1024);
238
- let offset = 0;
239
- for (;;) {
240
- const read = readSync(fd, chunk, 0, chunk.length, offset);
241
- if (read === 0) break;
242
- hash.update(chunk.subarray(0, read));
243
- offset += read;
244
- }
245
- return hash.digest("hex");
246
- } catch (error) {
247
- if (isMissing(error)) return null;
248
- throw error;
249
- } finally {
250
- if (fd !== undefined) closeSync(fd);
251
- }
252
- };
253
-
254
- export const readGenerationState = (configDir: string): GenerationState => {
255
- const file = path.join(configDir, GENERATION_FILE);
256
- if (!existsSync(file)) return { target: "legacy" };
257
- try {
258
- const parsed = JSON.parse(readFileSync(file, "utf8"));
259
- if (parsed?.target === "v1") return parsed as GenerationState;
260
- } catch {
261
- /* keep legacy default */
262
- }
263
- return { target: "legacy" };
264
- };
265
-
266
- export const writeGenerationState = (configDir: string, state: GenerationState): void => {
267
- writeFileAtomic(path.join(configDir, GENERATION_FILE), JSON.stringify(state, null, 2) + "\n");
268
- };
269
-
270
- const listSkillNames = (root: string): string[] => {
271
- const dir = path.join(root, "skills");
272
- if (!existsSync(dir)) return [];
273
- return readdirSync(dir, { withFileTypes: true })
274
- .filter((d) => d.isDirectory())
275
- .map((d) => d.name);
276
- };
277
-
278
- export type HostGeneration = "legacy" | "v1" | "none" | "mixed";
279
-
280
- export const classifyHostGeneration = (
281
- host: CutoverHost,
282
- paths: ReturnType<typeof resolvePaths>,
283
- ): HostGeneration => {
284
- if (host === "cursor") {
285
- const skills = listSkillNames(paths.cursorPluginDir);
286
- const legacy =
287
- skills.some((s) => s.startsWith("wk-")) ||
288
- existsSync(path.join(paths.cursorPluginDir, "vendor", "superpowers"));
289
- const v1 = WORKIT_METHOD_SKILLS.every((s) => skills.includes(s));
290
- if (legacy && v1) return "mixed";
291
- if (v1) return "v1";
292
- if (legacy) return "legacy";
293
- return "none";
294
- }
295
- if (host === "opencode") {
296
- if (!existsSync(paths.opencodeConfig)) return "none";
297
- try {
298
- const cfg = JSON.parse(readFileSync(paths.opencodeConfig, "utf8"));
299
- const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : cfg.plugin ? [cfg.plugin] : [];
300
- const workitPins = plugins.filter((p: unknown) => isWorkitPlugin(p));
301
- const canonicalV1Pin = `file://${paths.dev}/packages/workit-opencode/dist/plugin.js`;
302
- const v1 = workitPins.some((p: unknown) => String(p) === canonicalV1Pin);
303
- const legacy = workitPins.some((p: unknown) => String(p) !== canonicalV1Pin);
304
- if (legacy && v1) return "mixed";
305
- if (v1) return "v1";
306
- if (legacy) return "legacy";
307
- return "none";
308
- } catch {
309
- return "none";
310
- }
311
- }
312
- if (host === "codex") {
313
- const codexDir = path.join(paths.home, ".codex");
314
- const plugin = path.join(codexDir, "plugins", "workit");
315
- if (!existsSync(plugin)) return "none";
316
- return existsSync(path.join(plugin, ".codex-plugin")) ? "v1" : "legacy";
317
- }
318
- if (host === "pi") {
319
- const extension = paths.dev && path.resolve(paths.dev, "packages/workit-pi/dist/workit.js");
320
- const packageDir = paths.dev && path.resolve(paths.dev, "packages/workit-pi");
321
- if (!existsSync(paths.piConfig) && !existsSync(paths.piSettings)) return "none";
322
- try {
323
- const config = existsSync(paths.piConfig)
324
- ? JSON.parse(readFileSync(paths.piConfig, "utf8"))
325
- : {};
326
- const settings = existsSync(paths.piSettings)
327
- ? JSON.parse(readFileSync(paths.piSettings, "utf8"))
328
- : {};
329
- const ext = config?.extensions ?? config?.pi?.extensions ?? [];
330
- const packages = settings?.packages ?? [];
331
- const has =
332
- (typeof extension === "string" &&
333
- Array.isArray(ext) &&
334
- ext.some(
335
- (entry: unknown) => typeof entry === "string" && path.resolve(entry) === extension,
336
- )) ||
337
- (typeof packageDir === "string" &&
338
- Array.isArray(packages) &&
339
- packages.some(
340
- (entry: unknown) => typeof entry === "string" && path.resolve(entry) === packageDir,
341
- ));
342
- return has ? "v1" : "none";
343
- } catch {
344
- return "none";
345
- }
346
- }
347
- return "none";
348
- };
349
-
350
- export const detectSourceLinkedOpenCode = (opencodeConfig: string): boolean => {
351
- if (!existsSync(opencodeConfig)) return false;
352
- try {
353
- const cfg = JSON.parse(readFileSync(opencodeConfig, "utf8"));
354
- const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : cfg.plugin ? [cfg.plugin] : [];
355
- return plugins.some((p: unknown) => {
356
- const s = String(p);
357
- return s.startsWith("file://") && s.includes("/packages/workit-opencode/");
358
- });
359
- } catch {
360
- return false;
361
- }
362
- };
363
-
364
- export const detectCursorLatest = (cursorMcp: string): boolean => {
365
- if (!existsSync(cursorMcp)) return false;
366
- try {
367
- const cfg = JSON.parse(readFileSync(cursorMcp, "utf8"));
368
- const server = cfg?.mcpServers?.workit;
369
- if (!server || server.command !== "npx" || !Array.isArray(server.args)) return false;
370
- return (
371
- server.args[0] === "-y" &&
372
- server.args[1] === "--prefer-online" &&
373
- server.args[2] === "--min-release-age=0" &&
374
- server.args[3] === `--package=${CURSOR_RUNTIME_PACKAGE}` &&
375
- server.args[4] === "workit-cursor-mcp"
376
- );
377
- } catch {
378
- return false;
379
- }
380
- };
381
-
382
- const legacyFlowFiles = (workspace: string): string[] => {
383
- const docs = path.join(workspace, "docs");
384
- if (!existsSync(docs)) return [];
385
- const out: string[] = [];
386
- for (const slug of readdirSync(docs)) {
387
- const file = path.join(docs, slug, "sdd", "flow.json");
388
- if (existsSync(file)) out.push(file);
389
- }
390
- return out;
391
- };
392
-
393
- const managedCutoverFiles = (paths: ReturnType<typeof resolvePaths>): string[] => [
394
- path.join(paths.configDir, "config.json"),
395
- path.join(paths.configDir, "youtrack.json"),
396
- path.join(paths.configDir, "vcs.json"),
397
- path.join(paths.configDir, "workspaces.json"),
398
- path.join(paths.configDir, "cutover-choices.json"),
399
- paths.opencodeConfig,
400
- paths.cursorSettings,
401
- paths.cursorMcp,
402
- paths.piConfig,
403
- paths.piSettings,
404
- path.join(paths.cursorPluginDir, "hooks", "hooks-cursor.json"),
405
- ];
406
-
407
- type CutoverScope = {
408
- path: string;
409
- category: CutoverInventoryEntry["category"];
410
- disposition: CutoverInventoryEntry["disposition"];
411
- };
412
-
413
- const exactRegistrationEvidence = (
414
- file: string,
415
- paths: ReturnType<typeof resolvePaths>,
416
- ): string | null => {
417
- try {
418
- if (!noSymlinkAncestor(path.dirname(file))) return null;
419
- const fileStat = lstatOrNull(file);
420
- if (!fileStat?.isFile() || fileStat.isSymbolicLink()) return null;
421
- if (file === paths.opencodeConfig) {
422
- const cfg = JSON.parse(readFileSync(file, "utf8"));
423
- const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : cfg.plugin ? [cfg.plugin] : [];
424
- const workit = plugins.filter((plugin: unknown) => isWorkitPlugin(plugin));
425
- return workit.length > 0 ? "exact Workit plugin entry in OpenCode config" : null;
426
- }
427
- if (file === paths.cursorSettings) {
428
- const cfg = JSON.parse(readFileSync(file, "utf8"));
429
- const enabled = cfg?.enabled_plugins;
430
- const pluginEnabled =
431
- enabled &&
432
- typeof enabled === "object" &&
433
- Object.entries(enabled).some(([name, value]) => isWorkitPlugin(name) && value === true);
434
- const dirs = Array.isArray(cfg?.plugin_dirs) ? cfg.plugin_dirs : [];
435
- const dirRegistered = dirs.some(
436
- (dir: unknown) => typeof dir === "string" && path.resolve(dir) === paths.cursorPluginDir,
437
- );
438
- return pluginEnabled || dirRegistered
439
- ? "exact Workit plugin key or directory in Cursor settings"
440
- : null;
441
- }
442
- if (file === paths.cursorMcp) {
443
- const cfg = JSON.parse(readFileSync(file, "utf8"));
444
- const servers = cfg?.mcpServers;
445
- const workit =
446
- servers &&
447
- typeof servers === "object" &&
448
- Object.entries(servers).some(([name, value]) => {
449
- if (isWorkitPlugin(name)) return true;
450
- const server = value as { command?: unknown; args?: unknown } | null;
451
- return (
452
- server?.command === "npx" &&
453
- Array.isArray(server.args) &&
454
- server.args.includes(`--package=${CURSOR_RUNTIME_PACKAGE}`)
455
- );
456
- });
457
- return workit ? "exact Workit MCP server entry in Cursor config" : null;
458
- }
459
- if (file === paths.piConfig) {
460
- const cfg = JSON.parse(readFileSync(file, "utf8"));
461
- const ext = paths.dev && path.join(paths.dev, "packages/workit-pi/dist/workit.js");
462
- const skills = paths.dev && path.join(paths.dev, "packages/workit-pi/skills");
463
- const matches = (value: unknown, target: string | null): boolean =>
464
- target !== null &&
465
- typeof value === "string" &&
466
- path.resolve(value) === path.resolve(target);
467
- const extensions = cfg?.extensions ?? cfg?.pi?.extensions;
468
- const skillPaths = cfg?.skills ?? cfg?.pi?.skills;
469
- if (
470
- (Array.isArray(extensions) && extensions.some((entry: unknown) => matches(entry, ext))) ||
471
- (Array.isArray(skillPaths) && skillPaths.some((entry: unknown) => matches(entry, skills)))
472
- ) {
473
- return "exact Workit extension or skills path in Pi config";
474
- }
475
- }
476
- if (file === paths.piSettings) {
477
- const cfg = JSON.parse(readFileSync(file, "utf8"));
478
- const packageDir = paths.dev && path.join(paths.dev, "packages/workit-pi");
479
- const matches = (entry: unknown): boolean =>
480
- typeof packageDir === "string" &&
481
- typeof entry === "string" &&
482
- path.resolve(entry) === path.resolve(packageDir);
483
- if (Array.isArray(cfg?.packages) && cfg.packages.some(matches)) {
484
- return "exact Workit package path in Pi settings";
485
- }
486
- }
487
- } catch {
488
- return null;
489
- }
490
- return null;
491
- };
492
-
493
- const appendExactPath = (value: unknown, target: string): unknown[] => {
494
- const entries = Array.isArray(value) ? value : value === undefined ? [] : [value];
495
- return [
496
- ...entries.filter(
497
- (entry) => typeof entry !== "string" || path.resolve(entry) !== path.resolve(target),
498
- ),
499
- target,
500
- ];
501
- };
502
-
503
- const mergePiConfig = (current: Record<string, unknown>, dev: string): Record<string, unknown> => {
504
- const nestedPi =
505
- current.pi && typeof current.pi === "object" && !Array.isArray(current.pi)
506
- ? (current.pi as Record<string, unknown>)
507
- : null;
508
- const target = nestedPi ?? current;
509
- const extension = path.join(dev, "packages/workit-pi/dist/workit.js");
510
- const skills = path.join(dev, "packages/workit-pi/skills");
511
- return nestedPi
512
- ? {
513
- ...current,
514
- pi: {
515
- ...nestedPi,
516
- extensions: appendExactPath(nestedPi.extensions, extension),
517
- skills: appendExactPath(nestedPi.skills, skills),
518
- },
519
- }
520
- : {
521
- ...target,
522
- extensions: appendExactPath(target.extensions, extension),
523
- skills: appendExactPath(target.skills, skills),
524
- };
525
- };
526
-
527
- const mergePiSettings = (
528
- current: Record<string, unknown>,
529
- dev: string,
530
- ): Record<string, unknown> => ({
531
- ...current,
532
- packages: appendExactPath(current.packages, path.join(dev, "packages/workit-pi")),
533
- });
534
-
535
- const legacyFlowInventoryTargets = (workspace: string, issues: Set<string>): string[] => {
536
- const docs = path.join(workspace, "docs");
537
- let docsStat: ReturnType<typeof lstatSync> | null;
538
- try {
539
- if (!noSymlinkAncestor(path.dirname(docs))) {
540
- issues.add(`symlinked parent is outside the inventoried scope: ${path.dirname(docs)}`);
541
- return [];
542
- }
543
- docsStat = lstatOrNull(docs);
544
- } catch (error) {
545
- issues.add(`cannot inspect project history at ${docs}: ${String(error)}`);
546
- return [];
547
- }
548
- if (!docsStat) return [];
549
- if (docsStat.isSymbolicLink()) return [docs];
550
- if (!docsStat.isDirectory()) return [];
551
-
552
- const targets: string[] = [];
553
- try {
554
- const { names: slugs, truncated } = readDirNamesBounded(docs, MAX_CUTOVER_INVENTORY_ENTRIES);
555
- if (truncated) {
556
- issues.add(`project history inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} entries`);
557
- }
558
- for (const slug of slugs) {
559
- const slugPath = path.join(docs, slug);
560
- const slugStat = lstatOrNull(slugPath);
561
- if (!slugStat) continue;
562
- if (slugStat.isSymbolicLink()) {
563
- targets.push(slugPath);
564
- continue;
565
- }
566
- if (!slugStat.isDirectory()) continue;
567
- const sddPath = path.join(slugPath, "sdd");
568
- const sddStat = lstatOrNull(sddPath);
569
- if (!sddStat) continue;
570
- if (sddStat.isSymbolicLink()) {
571
- targets.push(sddPath);
572
- continue;
573
- }
574
- if (!sddStat.isDirectory()) continue;
575
- const flow = path.join(sddPath, "flow.json");
576
- if (lstatOrNull(flow)) targets.push(flow);
577
- if (targets.length >= MAX_CUTOVER_INVENTORY_ENTRIES) {
578
- issues.add(`project history inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} entries`);
579
- break;
580
- }
581
- }
582
- } catch (error) {
583
- issues.add(`cannot enumerate project history at ${docs}: ${String(error)}`);
584
- }
585
- return targets;
586
- };
587
-
588
- const cutoverInventoryScopes = (
589
- paths: ReturnType<typeof resolvePaths>,
590
- issues: Set<string>,
591
- ): CutoverScope[] => [
592
- { path: paths.configDir, category: "configuration", disposition: "preserve" },
593
- { path: paths.stateDir, category: "recovery", disposition: "preserve" },
594
- {
595
- path: path.join(paths.workspace, ".workit"),
596
- category: "project-state",
597
- disposition: "preserve",
598
- },
599
- ...legacyFlowInventoryTargets(paths.workspace, issues).map((target) => ({
600
- path: target,
601
- category: "project-history" as const,
602
- disposition: "preserve" as const,
603
- })),
604
- { path: paths.opencodeConfig, category: "host-configuration", disposition: "unknown" },
605
- { path: paths.cursorSettings, category: "host-configuration", disposition: "unknown" },
606
- { path: paths.cursorMcp, category: "host-configuration", disposition: "unknown" },
607
- {
608
- path: paths.cursorPluginDir,
609
- category: "integration",
610
- disposition: "unknown",
611
- },
612
- {
613
- path: path.join(paths.home, ".codex", "plugins", "workit"),
614
- category: "integration",
615
- disposition: "unknown",
616
- },
617
- {
618
- path: paths.piConfig,
619
- category: "host-configuration",
620
- disposition: "unknown",
621
- },
622
- {
623
- path: paths.piSettings,
624
- category: "host-configuration",
625
- disposition: "unknown",
626
- },
627
- ];
628
-
629
- const buildCutoverInventory = (paths: ReturnType<typeof resolvePaths>): CutoverInventory => {
630
- const entries: CutoverInventoryEntry[] = [];
631
- const issues = new Set<string>();
632
- const seen = new Set<string>();
633
- const cursorPluginRegistered = exactRegistrationEvidence(paths.cursorSettings, paths) !== null;
634
- const add = (scope: CutoverScope, file: string, depth: number): void => {
635
- if (entries.length >= MAX_CUTOVER_INVENTORY_ENTRIES) {
636
- issues.add(`cutover inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} entries`);
637
- return;
638
- }
639
- if (seen.has(file)) return;
640
- seen.add(file);
641
-
642
- if (depth === 0) {
643
- try {
644
- if (!noSymlinkAncestor(path.dirname(file))) {
645
- issues.add(`symlinked parent is outside the inventoried scope: ${path.dirname(file)}`);
646
- return;
647
- }
648
- } catch (error) {
649
- issues.add(`cannot inspect parent for ${file}: ${String(error)}`);
650
- return;
651
- }
652
- }
653
- let stat: ReturnType<typeof lstatSync> | null;
654
- try {
655
- stat = lstatOrNull(file);
656
- } catch (error) {
657
- issues.add(`cannot inspect ${file}: ${String(error)}`);
658
- return;
659
- }
660
- if (!stat) return;
661
-
662
- const kind: CutoverInventoryEntry["kind"] = stat.isSymbolicLink()
663
- ? "symlink"
664
- : stat.isDirectory()
665
- ? "directory"
666
- : stat.isFile()
667
- ? "file"
668
- : "other";
669
- let digest: Digest | null = null;
670
- if (kind === "file") {
671
- try {
672
- digest = digestFile(file);
673
- if (digest === null) issues.add(`file changed during inventory: ${file}`);
674
- } catch (error) {
675
- issues.add(`cannot read ${file}: ${String(error)}`);
676
- }
677
- } else if (kind === "symlink") {
678
- issues.add(`symlink not followed during inventory: ${file}`);
679
- } else if (kind === "other") {
680
- issues.add(`unsupported filesystem entry during inventory: ${file}`);
681
- }
682
-
683
- const inCursorPlugin =
684
- file === paths.cursorPluginDir || file.startsWith(`${paths.cursorPluginDir}${path.sep}`);
685
- const cursorAssetPath = inCursorPlugin
686
- ? path.relative(paths.cursorPluginDir, file).split(path.sep).join("/")
687
- : "";
688
- const expectedCursorDigest = LEGACY_CURSOR_ASSET_DIGESTS[cursorAssetPath];
689
- const verifiedCursorAsset =
690
- kind === "file" && digest !== null && expectedCursorDigest === digest;
691
- const ownershipEvidence = verifiedCursorAsset
692
- ? `SHA-256 matches generated Cursor asset from Workit 1.2.1: ${cursorAssetPath}`
693
- : kind === "file"
694
- ? exactRegistrationEvidence(file, paths)
695
- : inCursorPlugin && cursorPluginRegistered
696
- ? "Cursor settings registers this plugin directory; individual files remain unverified"
697
- : null;
698
- const disposition =
699
- file === path.join(paths.configDir, "config.json")
700
- ? "convert"
701
- : verifiedCursorAsset || (ownershipEvidence && scope.category === "host-configuration")
702
- ? "convert"
703
- : scope.disposition;
704
- entries.push({
705
- path: file,
706
- kind,
707
- category: scope.category,
708
- disposition,
709
- bytes: kind === "file" ? stat.size : 0,
710
- digest,
711
- ownershipEvidence,
712
- });
713
-
714
- if (kind !== "directory") return;
715
- if (depth >= MAX_CUTOVER_INVENTORY_DEPTH) {
716
- issues.add(`inventory depth exceeds ${MAX_CUTOVER_INVENTORY_DEPTH}: ${file}`);
717
- return;
718
- }
719
- try {
720
- const { names, truncated } = readDirNamesBounded(
721
- file,
722
- Math.max(0, MAX_CUTOVER_INVENTORY_ENTRIES - entries.length),
723
- );
724
- if (truncated)
725
- issues.add(`cutover inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} entries`);
726
- for (const name of names) add(scope, path.join(file, name), depth + 1);
727
- } catch (error) {
728
- issues.add(`cannot enumerate ${file}: ${String(error)}`);
729
- }
730
- };
731
-
732
- for (const scope of cutoverInventoryScopes(paths, issues)) add(scope, scope.path, 0);
733
- entries.sort((a, b) => a.path.localeCompare(b.path));
734
- const bytesByCategory: CutoverInventory["bytesByCategory"] = {};
735
- let files = 0;
736
- let bytes = 0;
737
- const hash = createHash("sha256");
738
- for (const entry of entries) {
739
- if (entry.kind === "file") {
740
- files += 1;
741
- bytes += entry.bytes;
742
- bytesByCategory[entry.category] = (bytesByCategory[entry.category] ?? 0) + entry.bytes;
743
- }
744
- hash.update(`${entry.path}\0${entry.kind}\0${entry.bytes}\0${entry.digest ?? ""}\n`);
745
- }
746
- const issueList = [...issues].toSorted();
747
- for (const issue of issueList) hash.update(`!${issue}\n`);
748
- return {
749
- complete: issueList.length === 0,
750
- entries,
751
- files,
752
- bytes,
753
- bytesByCategory,
754
- issues: issueList,
755
- revision: hash.digest("hex"),
756
- };
757
- };
758
-
759
- const hostMutationTargets = (
760
- paths: ReturnType<typeof resolvePaths>,
761
- hosts: CutoverHost[],
762
- ): string[] => {
763
- const targets: string[] = [];
764
- for (const host of hosts) {
765
- if (host === "cursor")
766
- targets.push(
767
- path.join(paths.cursorPluginDir, "skills"),
768
- path.join(paths.cursorPluginDir, "commands"),
769
- path.join(paths.cursorPluginDir, "rules", "workit-contract.mdc"),
770
- );
771
- if (host === "codex") targets.push(path.join(paths.home, ".codex", "plugins", "workit"));
772
- if (host === "pi") targets.push(paths.piConfig, paths.piSettings);
773
- }
774
- return targets;
775
- };
776
-
777
- const expandBackupTargets = (targets: string[]): { files: string[]; issues: string[] } => {
778
- const files: string[] = [];
779
- const issues = new Set<string>();
780
- for (const target of targets) {
781
- const walk = (file: string, depth: number): void => {
782
- if (files.length >= MAX_CUTOVER_INVENTORY_ENTRIES) {
783
- issues.add(`backup inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} files`);
784
- return;
785
- }
786
- if (depth === 0 && !noSymlinkAncestor(path.dirname(file))) {
787
- issues.add(`symlinked parent is outside the backup scope: ${path.dirname(file)}`);
788
- return;
789
- }
790
- let stat: ReturnType<typeof lstatSync> | null;
791
- try {
792
- stat = lstatOrNull(file);
793
- } catch (error) {
794
- issues.add(`cannot inspect backup target ${file}: ${String(error)}`);
795
- return;
796
- }
797
- if (!stat) return;
798
- if (stat.isSymbolicLink()) {
799
- issues.add(`symlink not followed in backup target: ${file}`);
800
- return;
801
- }
802
- if (stat.isDirectory()) {
803
- if (depth >= MAX_CUTOVER_INVENTORY_DEPTH) {
804
- issues.add(`backup inventory depth exceeds ${MAX_CUTOVER_INVENTORY_DEPTH}: ${file}`);
805
- return;
806
- }
807
- try {
808
- const { names, truncated } = readDirNamesBounded(
809
- file,
810
- Math.max(0, MAX_CUTOVER_INVENTORY_ENTRIES - files.length),
811
- );
812
- if (truncated)
813
- issues.add(`backup inventory exceeds ${MAX_CUTOVER_INVENTORY_ENTRIES} files`);
814
- for (const name of names) walk(path.join(file, name), depth + 1);
815
- } catch (error) {
816
- issues.add(`cannot enumerate backup target ${file}: ${String(error)}`);
817
- }
818
- } else if (stat.isFile()) {
819
- files.push(file);
820
- } else {
821
- issues.add(`unsupported backup entry: ${file}`);
822
- }
823
- };
824
- walk(target, 0);
825
- }
826
- return { files, issues: [...issues] };
827
- };
828
-
829
- const cutoverBackupTargets = (
830
- paths: ReturnType<typeof resolvePaths>,
831
- hosts: CutoverHost[],
832
- ): string[] => [...new Set([...managedCutoverFiles(paths), ...hostMutationTargets(paths, hosts)])];
833
-
834
- const backupRoot = (archiveDir: string, backupId: Id) => path.join(archiveDir, backupId);
835
-
836
- const archiveDestinationError = (
837
- archiveDir: string,
838
- paths: ReturnType<typeof resolvePaths>,
839
- ): string | null => {
840
- const resolved = path.resolve(archiveDir);
841
- const scopes = [paths.configDir, paths.stateDir, paths.workspace].map((p) => path.resolve(p));
842
- if (
843
- scopes.some(
844
- (scope) =>
845
- resolved === scope ||
846
- resolved.startsWith(scope + path.sep) ||
847
- scope.startsWith(resolved + path.sep),
848
- )
849
- ) {
850
- return "archive destination must be separate from config, state, and workspace";
851
- }
852
- if (!noSymlinkAncestor(resolved)) return "archive destination has a symlinked ancestor";
853
- const existing = lstatOrNull(resolved);
854
- if (existing && !existing.isDirectory()) return "archive destination is not a directory";
855
- return null;
856
- };
857
-
858
- const backupRelPath = (file: string, paths: ReturnType<typeof resolvePaths>): string => {
859
- if (file.startsWith(paths.configDir + path.sep) || file === paths.configDir) {
860
- return path.join("config", path.relative(paths.configDir, file));
861
- }
862
- return path.join("home", path.relative(paths.home, file));
863
- };
864
-
865
- type VerifiedBackupFile = { digest: Digest; storagePath: string };
866
-
867
- const readBackupFiles = (
868
- backupId: Id,
869
- archiveDir: string,
870
- paths: ReturnType<typeof resolvePaths>,
871
- ): { files: Map<string, VerifiedBackupFile>; issues: string[] } => {
872
- const root = backupRoot(archiveDir, backupId);
873
- const manifestPath = path.join(root, "manifest.json");
874
- const files = new Map<string, VerifiedBackupFile>();
875
- const issues: string[] = [];
876
- if (digestFile(manifestPath) === null) {
877
- return { files, issues: ["backup manifest is missing or unsafe"] };
878
- }
879
-
880
- let manifest: unknown;
881
- try {
882
- manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
883
- } catch {
884
- return { files, issues: ["backup manifest is unreadable"] };
885
- }
886
- if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
887
- return { files, issues: ["backup manifest is malformed"] };
888
- }
889
-
890
- const data = manifest as Record<string, unknown>;
891
- const hashed = data.schemaVersion === 1 && data.storage === "sha256-path-v1";
892
- const legacy = data.schemaVersion === undefined && data.storage === undefined;
893
- if (data.backupId !== backupId || (!hashed && !legacy) || !Array.isArray(data.files)) {
894
- return { files, issues: ["backup manifest identity or format is invalid"] };
895
- }
896
-
897
- for (const item of data.files) {
898
- if (!item || typeof item !== "object" || Array.isArray(item)) {
899
- issues.push("backup manifest contains an invalid file entry");
900
- continue;
901
- }
902
- const entry = item as Record<string, unknown>;
903
- if (
904
- typeof entry.path !== "string" ||
905
- !path.isAbsolute(entry.path) ||
906
- path.resolve(entry.path) !== entry.path
907
- ) {
908
- issues.push("backup manifest contains an invalid file path");
909
- continue;
910
- }
911
- const digest = hashed ? entry.digest : entry.installedDigest;
912
- if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) {
913
- issues.push(`backup digest is invalid: ${entry.path}`);
914
- continue;
915
- }
916
- if (files.has(entry.path)) {
917
- issues.push(`backup path is duplicated: ${entry.path}`);
918
- continue;
919
- }
920
-
921
- const relative = hashed
922
- ? path.join("files", createHash("sha256").update(entry.path).digest("hex"))
923
- : backupRelPath(entry.path, paths);
924
- const storagePath = path.resolve(root, relative);
925
- const fromRoot = path.relative(path.resolve(root), storagePath);
926
- if (
927
- fromRoot === "" ||
928
- fromRoot === ".." ||
929
- fromRoot.startsWith(`..${path.sep}`) ||
930
- path.isAbsolute(fromRoot)
931
- ) {
932
- issues.push(`backup path escapes its directory: ${entry.path}`);
933
- continue;
934
- }
935
- if (digestFile(storagePath) !== digest) {
936
- issues.push(`backup content failed verification: ${entry.path}`);
937
- continue;
938
- }
939
- files.set(entry.path, { digest: digest, storagePath });
940
- }
941
- return { files, issues };
942
- };
943
-
944
- const writeBackup = (
945
- backupId: Id,
946
- archiveDir: string,
947
- paths: ReturnType<typeof resolvePaths>,
948
- files: string[],
949
- ): void => {
950
- const root = backupRoot(archiveDir, backupId);
951
- mkdirSync(root, { recursive: true });
952
- const manifest: { path: string; digest: Digest }[] = [];
953
- for (const file of files) {
954
- if (!existsSync(file)) continue;
955
- const before = digestFile(file);
956
- if (before === null) throw new Error(`backup source is not a regular file: ${file}`);
957
- const dest = path.join(
958
- root,
959
- "files",
960
- createHash("sha256").update(path.resolve(file)).digest("hex"),
961
- );
962
- mkdirSync(path.dirname(dest), { recursive: true });
963
- cpSync(file, dest, { dereference: false, errorOnExist: true });
964
- if (digestFile(file) !== before || digestFile(dest) !== before) {
965
- throw new Error(`backup verification failed: ${file}`);
966
- }
967
- manifest.push({ path: path.resolve(file), digest: before });
968
- }
969
- writeFileAtomic(
970
- path.join(root, "manifest.json"),
971
- JSON.stringify(
972
- { schemaVersion: 1, storage: "sha256-path-v1", backupId, files: manifest },
973
- null,
974
- 2,
975
- ) + "\n",
976
- );
977
- };
978
-
979
- const receiptPath = (stateDir: string, backupId: Id) =>
980
- path.join(stateDir, "cutover", "receipts", `${backupId}.json`);
981
-
982
- const writeReceipt = (receipt: CutoverReceipt, stateDir: string): void => {
983
- writeFileAtomic(receiptPath(stateDir, receipt.backupId), JSON.stringify(receipt, null, 2) + "\n");
984
- };
985
-
986
- const cutoverJournalPath = (stateDir: string, backupId: Id): string =>
987
- path.join(stateDir, "cutover", "journals", `${backupId}.json`);
988
-
989
- const writeCutoverJournal = (journal: CutoverJournal, stateDir: string): void => {
990
- writeFileAtomic(
991
- cutoverJournalPath(stateDir, journal.backupId),
992
- JSON.stringify(journal, null, 2) + "\n",
993
- );
994
- };
995
-
996
- const isCutoverHost = (value: unknown): value is CutoverHost =>
997
- value === "opencode" || value === "cursor" || value === "codex" || value === "pi";
998
-
999
- const readCutoverJournal = (backupId: Id, stateDir: string): CutoverJournal | null => {
1000
- if (!validCutoverId(backupId)) return null;
1001
- const file = cutoverJournalPath(stateDir, backupId);
1002
- if (digestFile(file) === null) return null;
1003
- try {
1004
- const value = JSON.parse(readFileSync(file, "utf8")) as Partial<CutoverJournal>;
1005
- const stepNames = new Set([
1006
- "configuration",
1007
- "generation",
1008
- "verification",
1009
- "receipt",
1010
- ...(value.hosts ?? []).map((h) => `host:${h}`),
1011
- ]);
1012
- if (
1013
- value.schemaVersion !== 1 ||
1014
- value.backupId !== backupId ||
1015
- typeof value.archiveDir !== "string" ||
1016
- !path.isAbsolute(value.archiveDir) ||
1017
- path.resolve(value.archiveDir) !== value.archiveDir ||
1018
- typeof value.planId !== "string" ||
1019
- !Array.isArray(value.hosts) ||
1020
- !value.hosts.every(isCutoverHost) ||
1021
- !value.hosts.length ||
1022
- !value.resolutions ||
1023
- typeof value.resolutions !== "object" ||
1024
- Object.values(value.resolutions).some((entry) => typeof entry !== "string") ||
1025
- typeof value.conversionNeeded !== "boolean" ||
1026
- !["applying", "interrupted", "partial", "complete"].includes(value.phase ?? "") ||
1027
- !Array.isArray(value.completedSteps) ||
1028
- value.completedSteps.some((step) => typeof step !== "string" || !stepNames.has(step)) ||
1029
- !Array.isArray(value.appliedHosts) ||
1030
- value.appliedHosts.some((host) => !isCutoverHost(host) || !value.hosts?.includes(host)) ||
1031
- !(
1032
- value.currentStep === null ||
1033
- (typeof value.currentStep === "string" && stepNames.has(value.currentStep))
1034
- ) ||
1035
- !(value.error === null || typeof value.error === "string") ||
1036
- typeof value.startedAt !== "string" ||
1037
- !Array.isArray(value.notes) ||
1038
- value.notes.some((note) => typeof note !== "string")
1039
- )
1040
- return null;
1041
- return value as CutoverJournal;
1042
- } catch {
1043
- return null;
1044
- }
1045
- };
1046
-
1047
- export const readCutoverReceipt = (backupId: Id, stateDir: string): CutoverReceipt | null => {
1048
- if (!validCutoverId(backupId)) return null;
1049
- const file = receiptPath(stateDir, backupId);
1050
- if (!existsSync(file)) return null;
1051
- try {
1052
- return JSON.parse(readFileSync(file, "utf8")) as CutoverReceipt;
1053
- } catch {
1054
- return null;
1055
- }
1056
- };
1057
-
1058
- const plannedAfterDigest = (
1059
- file: string,
1060
- host: CutoverHost,
1061
- paths: ReturnType<typeof resolvePaths>,
1062
- ): Digest => {
1063
- if (file === paths.opencodeConfig && paths.dev) {
1064
- const pin = `file://${paths.dev}/packages/workit-opencode/dist/plugin.js`;
1065
- const merged = mergeOpenCodeConfig(
1066
- existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {},
1067
- pin,
1068
- );
1069
- return createHash("sha256").update(JSON.stringify(merged.config)).digest("hex");
1070
- }
1071
- if (file === paths.cursorSettings) {
1072
- const merged = mergeCursorSettings(
1073
- existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {},
1074
- paths.cursorPluginDir,
1075
- );
1076
- return createHash("sha256").update(JSON.stringify(merged.config)).digest("hex");
1077
- }
1078
- if (file === paths.cursorMcp) {
1079
- const merged = mergeCursorMcp(
1080
- existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { mcpServers: {} },
1081
- "workit",
1082
- cursorMcpServerEntry(paths.cursorPluginDir),
1083
- );
1084
- return createHash("sha256").update(JSON.stringify(merged.config)).digest("hex");
1085
- }
1086
- if (file === paths.piConfig && paths.dev) {
1087
- const current = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
1088
- const next = mergePiConfig(current, paths.dev);
1089
- return createHash("sha256").update(JSON.stringify(next)).digest("hex");
1090
- }
1091
- if (file === paths.piSettings && paths.dev) {
1092
- const current = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
1093
- const next = mergePiSettings(current, paths.dev);
1094
- return createHash("sha256").update(JSON.stringify(next)).digest("hex");
1095
- }
1096
- if (file.endsWith("hooks-cursor.json")) {
1097
- const entry = cursorHooksEntry(paths.cursorPluginDir);
1098
- const base = existsSync(file)
1099
- ? JSON.parse(readFileSync(file, "utf8"))
1100
- : { version: 1, hooks: {} };
1101
- const next = {
1102
- ...base,
1103
- hooks: { ...base.hooks, sessionStart: [{ command: entry.command }] },
1104
- };
1105
- return createHash("sha256").update(JSON.stringify(next)).digest("hex");
1106
- }
1107
- return createHash("sha256").update(readFileSync(file)).digest("hex");
1108
- };
1109
-
1110
- export function previewCutover(
1111
- options: CutoverPaths = {},
1112
- hosts: CutoverHost[] = ["opencode", "cursor"],
1113
- ): CutoverPlan {
1114
- const paths = resolvePaths(options);
1115
- const blocked: string[] = [];
1116
- const archiveDir = options.archiveDir ? path.resolve(options.archiveDir) : null;
1117
- if (archiveDir) {
1118
- const archiveError = archiveDestinationError(archiveDir, paths);
1119
- if (archiveError) blocked.push(archiveError);
1120
- }
1121
- const inventory = buildCutoverInventory(paths);
1122
- let conversion: ConversionPreview = { mappings: [], preserved: [], unresolved: [] };
1123
- if (inventory.complete) {
1124
- try {
1125
- conversion = previewConversion({ configDir: paths.configDir });
1126
- } catch (error) {
1127
- blocked.push(`cannot preview configuration conversion: ${String(error)}`);
1128
- }
1129
- }
1130
- const unresolved = [...conversion.unresolved];
1131
-
1132
- if (inventory.complete) {
1133
- for (const host of hosts) {
1134
- const gen = classifyHostGeneration(host, paths);
1135
- if (gen === "mixed") blocked.push(`${host}: mixed legacy and v1 components`);
1136
- }
1137
- }
1138
-
1139
- for (const session of paths.sessions) {
1140
- if (session.state === "active" || session.state === "unknown") {
1141
- blocked.push(`${session.host} session ${session.handle} is ${session.state}`);
1142
- }
1143
- }
1144
-
1145
- for (const issue of inventory.issues) blocked.push(`inventory: ${issue}`);
1146
-
1147
- const managedFiles = managedCutoverFiles(paths).map((filePath) => {
1148
- if (!noSymlinkAncestor(path.dirname(filePath))) {
1149
- blocked.push(`symlinked parent is outside the inventoried scope: ${path.dirname(filePath)}`);
1150
- return {
1151
- path: filePath,
1152
- beforeDigest: null,
1153
- afterDigest: createHash("sha256").update("").digest("hex"),
1154
- };
1155
- }
1156
- const stat = lstatOrNull(filePath);
1157
- if (stat && !stat.isFile()) {
1158
- blocked.push(`managed path is not a regular file: ${filePath}`);
1159
- return {
1160
- path: filePath,
1161
- beforeDigest: null,
1162
- afterDigest: createHash("sha256").update("").digest("hex"),
1163
- };
1164
- }
1165
- const beforeDigest = digestFile(filePath);
1166
- try {
1167
- return {
1168
- path: filePath,
1169
- beforeDigest,
1170
- afterDigest:
1171
- beforeDigest === null
1172
- ? createHash("sha256").update("").digest("hex")
1173
- : plannedAfterDigest(filePath, hosts[0] ?? "cursor", paths),
1174
- };
1175
- } catch (error) {
1176
- blocked.push(`cannot plan managed file ${filePath}: ${String(error)}`);
1177
- return {
1178
- path: filePath,
1179
- beforeDigest,
1180
- afterDigest: beforeDigest ?? createHash("sha256").update("").digest("hex"),
1181
- };
1182
- }
1183
- });
1184
-
1185
- return {
1186
- id: randomUUID(),
1187
- archiveDir,
1188
- managedFiles,
1189
- inventory,
1190
- sessions: paths.sessions,
1191
- unresolved,
1192
- conversion,
1193
- hosts,
1194
- blocked,
1195
- };
1196
- }
1197
-
1198
- const syncV1CursorSkills = (pluginDir: string, dev: string | null, notes: string[]) => {
1199
- const syncGeneratedFile = (relative: string, file: string, content: string): void => {
1200
- if (!noSymlinkAncestor(path.dirname(file))) throw new Error(`symlinked parent: ${file}`);
1201
- const stat = lstatOrNull(file);
1202
- if (stat?.isSymbolicLink()) throw new Error(`refusing to write through symlink: ${file}`);
1203
- if (stat && !stat.isFile()) throw new Error(`refusing to replace non-file: ${file}`);
1204
- const desiredDigest = createHash("sha256").update(content).digest("hex");
1205
- if (!stat) {
1206
- writeFileExclusive(file, content);
1207
- return;
1208
- }
1209
- const currentDigest = digestFile(file);
1210
- if (currentDigest === desiredDigest) return;
1211
- if (currentDigest === LEGACY_CURSOR_ASSET_DIGESTS[relative]) {
1212
- writeFileAtomic(file, content);
1213
- notes.push(`cursor: refreshed verified generated asset ${relative}`);
1214
- } else {
1215
- notes.push(`cursor: preserved modified or unknown asset ${relative}`);
1216
- }
1217
- };
1218
- mkdirSync(path.join(pluginDir, "skills"), { recursive: true });
1219
- for (const skill of WORKIT_METHOD_SKILLS) {
1220
- const src = dev ? path.join(dev, "packages/workit-cursor/skills", skill, "SKILL.md") : null;
1221
- const destDir = path.join(pluginDir, "skills", skill);
1222
- mkdirSync(destDir, { recursive: true });
1223
- syncGeneratedFile(
1224
- `skills/${skill}/SKILL.md`,
1225
- path.join(destDir, "SKILL.md"),
1226
- src && existsSync(src) ? readFileSync(src, "utf8") : "# v1\n",
1227
- );
1228
- }
1229
- // Bare slash aliases ship next to skills so installs match the repo.
1230
- mkdirSync(path.join(pluginDir, "commands"), { recursive: true });
1231
- for (const alias of Object.keys(WORKIT_SKILL_ALIASES)) {
1232
- const src = dev ? path.join(dev, "packages/workit-cursor/commands", `${alias}.md`) : null;
1233
- syncGeneratedFile(
1234
- `commands/${alias}.md`,
1235
- path.join(pluginDir, "commands", `${alias}.md`),
1236
- src && existsSync(src) ? readFileSync(src, "utf8") : `# /${alias}\n`,
1237
- );
1238
- }
1239
- const ruleSource = dev && path.join(dev, "packages/workit-cursor/rules/workit-contract.mdc");
1240
- if (ruleSource && existsSync(ruleSource)) {
1241
- mkdirSync(path.join(pluginDir, "rules"), { recursive: true });
1242
- syncGeneratedFile(
1243
- "rules/workit-contract.mdc",
1244
- path.join(pluginDir, "rules", "workit-contract.mdc"),
1245
- readFileSync(ruleSource, "utf8"),
1246
- );
1247
- }
1248
- };
1249
-
1250
- const applyHostCutover = (
1251
- host: CutoverHost,
1252
- paths: ReturnType<typeof resolvePaths>,
1253
- notes: string[],
1254
- ): boolean => {
1255
- if (host === "cursor") {
1256
- syncV1CursorSkills(paths.cursorPluginDir, paths.dev, notes);
1257
- const settings = existsSync(paths.cursorSettings)
1258
- ? JSON.parse(readFileSync(paths.cursorSettings, "utf8"))
1259
- : {};
1260
- writeFileAtomic(
1261
- paths.cursorSettings,
1262
- JSON.stringify(mergeCursorSettings(settings, paths.cursorPluginDir).config, null, 2) + "\n",
1263
- );
1264
- const mcp = existsSync(paths.cursorMcp)
1265
- ? JSON.parse(readFileSync(paths.cursorMcp, "utf8"))
1266
- : { mcpServers: {} };
1267
- writeFileAtomic(
1268
- paths.cursorMcp,
1269
- JSON.stringify(
1270
- mergeCursorMcp(mcp, "workit", cursorMcpServerEntry(paths.cursorPluginDir)).config,
1271
- null,
1272
- 2,
1273
- ) + "\n",
1274
- );
1275
- const hooksPath = path.join(paths.cursorPluginDir, "hooks", "hooks-cursor.json");
1276
- const hooks = existsSync(hooksPath)
1277
- ? JSON.parse(readFileSync(hooksPath, "utf8"))
1278
- : { version: 1, hooks: {} };
1279
- const entry = cursorHooksEntry(paths.cursorPluginDir);
1280
- const hookConfig = hooks && typeof hooks === "object" ? hooks : { version: 1, hooks: {} };
1281
- const hookMap =
1282
- hookConfig.hooks && typeof hookConfig.hooks === "object" ? hookConfig.hooks : {};
1283
- const sessionStart = Array.isArray(hookMap.sessionStart) ? hookMap.sessionStart : [];
1284
- const isWorkitSessionStart = (value: unknown): boolean => {
1285
- const command =
1286
- typeof value === "string"
1287
- ? value
1288
- : value && typeof value === "object" && "command" in value
1289
- ? String(value.command)
1290
- : "";
1291
- return (
1292
- command === entry.command ||
1293
- command ===
1294
- `npx -y --prefer-online --min-release-age=0 --package=${CURSOR_RUNTIME_PACKAGE} workit-cursor-session-start`
1295
- );
1296
- };
1297
- writeFileAtomic(
1298
- hooksPath,
1299
- JSON.stringify(
1300
- {
1301
- ...hookConfig,
1302
- hooks: {
1303
- ...hookMap,
1304
- sessionStart: [
1305
- ...sessionStart.filter((item: unknown) => !isWorkitSessionStart(item)),
1306
- entry,
1307
- ],
1308
- },
1309
- },
1310
- null,
1311
- 2,
1312
- ) + "\n",
1313
- );
1314
- notes.push(
1315
- "cursor: replaced legacy skills with v1 method skills and canonical @latest registration",
1316
- );
1317
- return true;
1318
- }
1319
- if (host === "opencode" && paths.dev) {
1320
- const pin = `file://${paths.dev}/packages/workit-opencode/dist/plugin.js`;
1321
- const distDir = path.join(paths.dev, "packages/workit-opencode/dist");
1322
- mkdirSync(distDir, { recursive: true });
1323
- const dist = path.join(distDir, "plugin.js");
1324
- if (!existsSync(dist)) writeFileExclusive(dist, "export default {};\n");
1325
- const current = existsSync(paths.opencodeConfig)
1326
- ? JSON.parse(readFileSync(paths.opencodeConfig, "utf8"))
1327
- : {};
1328
- writeFileAtomic(
1329
- paths.opencodeConfig,
1330
- JSON.stringify(mergeOpenCodeConfig(current, pin).config, null, 2) + "\n",
1331
- );
1332
- notes.push("opencode: repinned to v1 dist entry");
1333
- return true;
1334
- }
1335
- if (host === "codex" && paths.dev) {
1336
- const pluginDir = path.join(paths.home, ".codex", "plugins", "workit");
1337
- mkdirSync(pluginDir, { recursive: true });
1338
- cpSync(
1339
- path.join(paths.dev, "packages/workit-codex/.codex-plugin"),
1340
- path.join(pluginDir, ".codex-plugin"),
1341
- {
1342
- recursive: true,
1343
- force: false,
1344
- errorOnExist: false,
1345
- dereference: false,
1346
- },
1347
- );
1348
- notes.push("codex: installed v1 plugin scaffold");
1349
- return true;
1350
- }
1351
- if (host === "pi" && paths.dev) {
1352
- mkdirSync(path.dirname(paths.piConfig), { recursive: true });
1353
- mkdirSync(path.dirname(paths.piSettings), { recursive: true });
1354
- const readConfig = (file: string): Record<string, unknown> => {
1355
- if (!existsSync(file)) return {};
1356
- const value = JSON.parse(readFileSync(file, "utf8"));
1357
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1358
- };
1359
- writeFileAtomic(
1360
- paths.piConfig,
1361
- JSON.stringify(mergePiConfig(readConfig(paths.piConfig), paths.dev), null, 2) + "\n",
1362
- );
1363
- writeFileAtomic(
1364
- paths.piSettings,
1365
- JSON.stringify(mergePiSettings(readConfig(paths.piSettings), paths.dev), null, 2) + "\n",
1366
- );
1367
- notes.push(
1368
- "pi: registered v1 package, extension, and skills while preserving unrelated entries",
1369
- );
1370
- return true;
1371
- }
1372
- return false;
1373
- };
1374
-
1375
- export function applyCutover(
1376
- plan: CutoverPlan,
1377
- decision: CutoverDecision,
1378
- options: CutoverPaths = {},
1379
- ): Result<CutoverReceipt> {
1380
- if (!decision.approve) return failure("permission_denied", "cutover not approved");
1381
- if (plan.blocked.length > 0) {
1382
- return failure("requirements_unsatisfied", "cutover blocked", {
1383
- fields: plan.blocked.map((b) => ({ path: b, reason: "blocked" })),
1384
- });
1385
- }
1386
- for (const item of plan.unresolved) {
1387
- if (!decision.resolutions?.[item.key]) {
1388
- return failure("needs_input", `unresolved setting requires a decision: ${item.key}`);
1389
- }
1390
- }
1391
-
1392
- const paths = resolvePaths(options);
1393
- if (!options.archiveDir || !plan.archiveDir)
1394
- return failure("needs_input", "archive destination is required (--archive-dir)");
1395
- const archiveDir = path.resolve(options.archiveDir);
1396
- if (archiveDir !== plan.archiveDir)
1397
- return failure("revision_conflict", "archive destination changed since preview");
1398
- const archiveError = archiveDestinationError(archiveDir, paths);
1399
- if (archiveError) return failure("requirements_unsatisfied", archiveError);
1400
- if (!plan.inventory?.complete) {
1401
- return failure("requirements_unsatisfied", "cutover inventory is incomplete");
1402
- }
1403
- const currentInventory = buildCutoverInventory(paths);
1404
- if (!currentInventory.complete) {
1405
- return failure("requirements_unsatisfied", "cutover inventory is incomplete", {
1406
- fields: currentInventory.issues.map((issue) => ({ path: issue, reason: "inventory" })),
1407
- });
1408
- }
1409
- if (currentInventory.revision !== plan.inventory.revision) {
1410
- return failure("revision_conflict", "cutover sources changed since preview");
1411
- }
1412
- for (const entry of plan.managedFiles) {
1413
- const current = digestFile(entry.path);
1414
- if (entry.beforeDigest !== null && current !== entry.beforeDigest) {
1415
- return failure("revision_conflict", `managed file changed since preview: ${entry.path}`, {
1416
- path: entry.path,
1417
- });
1418
- }
1419
- }
1420
- for (const session of paths.sessions) {
1421
- if (session.state === "active" || session.state === "unknown") {
1422
- return failure(
1423
- "requirements_unsatisfied",
1424
- `old session still ${session.state}: ${session.handle}`,
1425
- );
1426
- }
1427
- }
1428
- for (const host of decision.hosts) {
1429
- if (classifyHostGeneration(host, paths) === "mixed") {
1430
- return failure("requirements_unsatisfied", `mixed legacy and v1 components on ${host}`);
1431
- }
1432
- }
1433
-
1434
- const backupId = randomUUID();
1435
- const backupTargets = cutoverBackupTargets(paths, decision.hosts);
1436
- const backupInventory = expandBackupTargets(backupTargets);
1437
- if (backupInventory.issues.length > 0) {
1438
- return failure("requirements_unsatisfied", "cutover backup inventory is incomplete", {
1439
- fields: backupInventory.issues.map((issue) => ({ path: issue, reason: "inventory" })),
1440
- });
1441
- }
1442
- try {
1443
- mkdirSync(archiveDir, { recursive: true });
1444
- writeBackup(backupId, archiveDir, paths, backupInventory.files);
1445
- const journal: CutoverJournal = {
1446
- schemaVersion: 1,
1447
- backupId,
1448
- archiveDir,
1449
- planId: plan.id,
1450
- hosts: [...decision.hosts],
1451
- resolutions: { ...decision.resolutions },
1452
- conversionNeeded: plan.unresolved.length > 0 || plan.conversion.mappings.length > 0,
1453
- phase: "applying",
1454
- completedSteps: [],
1455
- appliedHosts: [],
1456
- currentStep: null,
1457
- error: null,
1458
- startedAt: new Date().toISOString(),
1459
- notes: [],
1460
- };
1461
- writeCutoverJournal(journal, paths.stateDir);
1462
- return runCutoverJournal(journal, paths, plan.conversion);
1463
- } catch (error) {
1464
- return failure("requirements_unsatisfied", `could not stage cutover backup: ${String(error)}`);
1465
- }
1466
- }
1467
-
1468
- const runCutoverJournal = (
1469
- journal: CutoverJournal,
1470
- paths: ReturnType<typeof resolvePaths>,
1471
- conversion?: ConversionPreview,
1472
- ): Result<CutoverReceipt> => {
1473
- const completed = (step: string): boolean => journal.completedSteps.includes(step);
1474
- const fail = (step: string, error: unknown): Result<CutoverReceipt> => {
1475
- journal.phase = "interrupted";
1476
- journal.currentStep = step;
1477
- journal.error = String(error);
1478
- try {
1479
- writeCutoverJournal(journal, paths.stateDir);
1480
- } catch (journalError) {
1481
- return failure(
1482
- "requirements_unsatisfied",
1483
- `cutover interrupted at ${step}; journal write failed: ${String(journalError)}`,
1484
- { path: journal.backupId, operation: step },
1485
- );
1486
- }
1487
- return failure(
1488
- "requirements_unsatisfied",
1489
- `cutover interrupted at ${step}; resume backup ${journal.backupId}`,
1490
- {
1491
- path: journal.backupId,
1492
- operation: step,
1493
- guidance: journal.error,
1494
- },
1495
- );
1496
- };
1497
- const begin = (step: string): void => {
1498
- journal.phase = "applying";
1499
- journal.currentStep = step;
1500
- journal.error = null;
1501
- writeCutoverJournal(journal, paths.stateDir);
1502
- };
1503
- const finish = (step: string): void => {
1504
- if (!completed(step)) journal.completedSteps.push(step);
1505
- journal.currentStep = null;
1506
- journal.error = null;
1507
- writeCutoverJournal(journal, paths.stateDir);
1508
- };
1509
-
1510
- if (journal.conversionNeeded && !completed("configuration")) {
1511
- try {
1512
- begin("configuration");
1513
- const applied = applyConversionConfig(
1514
- paths.configDir,
1515
- conversion ?? previewConversion({ configDir: paths.configDir }),
1516
- journal.resolutions,
1517
- );
1518
- const note = `config: wrote ${applied.configPath} and recorded choices at ${applied.choicesPath}`;
1519
- if (!journal.notes.includes(note)) journal.notes.push(note);
1520
- finish("configuration");
1521
- } catch (error) {
1522
- return fail("configuration", error);
1523
- }
1524
- }
1525
-
1526
- for (const host of journal.hosts) {
1527
- const step = `host:${host}`;
1528
- if (completed(step)) continue;
1529
- try {
1530
- begin(step);
1531
- const before = new Set(journal.notes);
1532
- if (!applyHostCutover(host, paths, journal.notes)) {
1533
- journal.phase = "partial";
1534
- journal.currentStep = null;
1535
- journal.error = `${host} requires a local dev installation before activation`;
1536
- writeCutoverJournal(journal, paths.stateDir);
1537
- continue;
1538
- }
1539
- journal.appliedHosts.push(host);
1540
- journal.notes = [...before, ...journal.notes.filter((note) => !before.has(note))];
1541
- finish(step);
1542
- } catch (error) {
1543
- return fail(step, error);
1544
- }
1545
- }
1546
-
1547
- const partial = journal.appliedHosts.length !== journal.hosts.length;
1548
- if (!partial && !completed("generation")) {
1549
- try {
1550
- begin("generation");
1551
- writeGenerationState(paths.configDir, {
1552
- target: "v1",
1553
- cutover: { backupId: journal.backupId, planId: journal.planId, at: journal.startedAt },
1554
- });
1555
- finish("generation");
1556
- } catch (error) {
1557
- return fail("generation", error);
1558
- }
1559
- }
1560
-
1561
- const backupTargets = cutoverBackupTargets(paths, journal.hosts);
1562
- const postCutoverInventory = expandBackupTargets(backupTargets);
1563
- if (postCutoverInventory.issues.length > 0) {
1564
- return fail("verification", postCutoverInventory.issues.join("; "));
1565
- }
1566
- const managedFiles: CutoverReceipt["managedFiles"] = [];
1567
- for (const file of postCutoverInventory.files) {
1568
- const installedDigest = digestFile(file);
1569
- if (installedDigest === null) return fail("verification", `cannot verify ${file}`);
1570
- managedFiles.push({ path: file, installedDigest });
1571
- }
1572
-
1573
- const receipt: CutoverReceipt = {
1574
- backupId: journal.backupId,
1575
- archiveDir: journal.archiveDir,
1576
- planId: journal.planId,
1577
- generation: partial ? "legacy" : "v1",
1578
- hosts: [...journal.appliedHosts],
1579
- managedFiles,
1580
- partial,
1581
- notes: [...journal.notes],
1582
- };
1583
- try {
1584
- begin("receipt");
1585
- writeReceipt(receipt, paths.stateDir);
1586
- journal.phase = partial ? "partial" : "complete";
1587
- journal.currentStep = null;
1588
- journal.error = partial ? journal.error : null;
1589
- finish("receipt");
1590
- return success(null, null, receipt);
1591
- } catch (error) {
1592
- return fail("receipt", error);
1593
- }
1594
- };
1595
-
1596
- export function resumeCutover(backupId: Id, options: CutoverPaths = {}): Result<CutoverReceipt> {
1597
- if (!validCutoverId(backupId))
1598
- return failure("not_found", `cutover journal not found: ${backupId}`);
1599
- const paths = resolvePaths(options);
1600
- const journal = readCutoverJournal(backupId, paths.stateDir);
1601
- if (!journal) return failure("not_found", `cutover journal not found: ${backupId}`);
1602
- if (journal.phase === "complete") {
1603
- const receipt = readCutoverReceipt(backupId, paths.stateDir);
1604
- return receipt
1605
- ? success(null, null, receipt)
1606
- : failure("requirements_unsatisfied", `cutover receipt is missing: ${backupId}`);
1607
- }
1608
- return runCutoverJournal(journal, paths);
1609
- }
1610
-
1611
- export function previewRollback(backupId: Id, options: CutoverPaths = {}): RollbackPreview {
1612
- const paths = resolvePaths(options);
1613
- if (!validCutoverId(backupId)) {
1614
- return {
1615
- backupId,
1616
- restorable: [],
1617
- conflicts: [],
1618
- preserved: [],
1619
- issues: ["invalid cutover backup id"],
1620
- };
1621
- }
1622
- const receipt = readCutoverReceipt(backupId, paths.stateDir);
1623
- const restorable: RollbackPreview["restorable"] = [];
1624
- const conflicts: RollbackPreview["conflicts"] = [];
1625
- const preserved: string[] = [];
1626
- const issues: string[] = [];
1627
-
1628
- if (
1629
- !receipt ||
1630
- receipt.backupId !== backupId ||
1631
- typeof receipt.archiveDir !== "string" ||
1632
- !path.isAbsolute(receipt.archiveDir) ||
1633
- path.resolve(receipt.archiveDir) !== receipt.archiveDir
1634
- ) {
1635
- return {
1636
- backupId,
1637
- restorable,
1638
- conflicts,
1639
- preserved,
1640
- issues: ["cutover receipt or archive destination is missing"],
1641
- };
1642
- }
1643
- const archiveError = archiveDestinationError(receipt.archiveDir, paths);
1644
- if (archiveError) {
1645
- return { backupId, restorable, conflicts, preserved, issues: [archiveError] };
1646
- }
1647
- const root = backupRoot(receipt.archiveDir, backupId);
1648
-
1649
- if (!existsSync(root)) {
1650
- return { backupId, restorable, conflicts, preserved, issues: ["backup directory is missing"] };
1651
- }
1652
- if (!receipt) {
1653
- return {
1654
- backupId,
1655
- restorable,
1656
- conflicts,
1657
- preserved,
1658
- issues: ["cutover receipt is missing or unreadable"],
1659
- };
1660
- }
1661
- const backup = readBackupFiles(backupId, receipt.archiveDir, paths);
1662
- issues.push(...backup.issues);
1663
-
1664
- const seen = new Set<string>();
1665
- for (const entry of receipt.managedFiles) {
1666
- if (
1667
- !path.isAbsolute(entry.path) ||
1668
- path.resolve(entry.path) !== entry.path ||
1669
- seen.has(entry.path)
1670
- ) {
1671
- issues.push(`receipt contains an invalid or duplicate path: ${entry.path}`);
1672
- continue;
1673
- }
1674
- seen.add(entry.path);
1675
- const current = digestFile(entry.path);
1676
- const original = backup.files.get(entry.path);
1677
- if (current === null) {
1678
- if (original || lstatOrNull(entry.path)) {
1679
- conflicts.push({
1680
- path: entry.path,
1681
- currentDigest: null,
1682
- installedDigest: entry.installedDigest,
1683
- });
1684
- }
1685
- continue;
1686
- }
1687
- if (current === entry.installedDigest) {
1688
- restorable.push({
1689
- path: entry.path,
1690
- currentDigest: current,
1691
- installedDigest: entry.installedDigest,
1692
- restore: original ? "backup" : "remove",
1693
- });
1694
- } else if (original && current === original.digest) {
1695
- // A previous rollback attempt already restored this file.
1696
- continue;
1697
- } else {
1698
- conflicts.push({
1699
- path: entry.path,
1700
- currentDigest: current,
1701
- installedDigest: entry.installedDigest,
1702
- });
1703
- }
1704
- }
1705
-
1706
- const v1Task = path.join(paths.workspace, ".workit");
1707
- if (existsSync(v1Task)) preserved.push(v1Task);
1708
-
1709
- return { backupId, restorable, conflicts, preserved, issues };
1710
- }
1711
-
1712
- export function applyRollback(
1713
- backupId: Id,
1714
- options: CutoverPaths = {},
1715
- ): Result<{ restored: string[] }> {
1716
- if (!validCutoverId(backupId)) return failure("not_found", `backup not found: ${backupId}`);
1717
- const paths = resolvePaths(options);
1718
- const preview = previewRollback(backupId, options);
1719
- if (preview.issues.length > 0) {
1720
- return failure("recovery_required", "rollback backup failed integrity checks", {
1721
- path: paths.stateDir,
1722
- guidance: preview.issues.join("; "),
1723
- });
1724
- }
1725
- if (preview.conflicts.length > 0) {
1726
- return failure(
1727
- "revision_conflict",
1728
- "managed files changed after cutover; reconcile before rollback",
1729
- {
1730
- path: preview.conflicts[0]?.path,
1731
- },
1732
- );
1733
- }
1734
-
1735
- const receipt = readCutoverReceipt(backupId, paths.stateDir);
1736
- if (!receipt) return failure("not_found", `backup not found: ${backupId}`);
1737
- const root = backupRoot(receipt.archiveDir, backupId);
1738
- if (!existsSync(root)) return failure("not_found", `backup not found: ${backupId}`);
1739
- const backup = readBackupFiles(backupId, receipt.archiveDir, paths);
1740
- if (backup.issues.length > 0) {
1741
- return failure("recovery_required", "rollback backup failed integrity checks", {
1742
- path: root,
1743
- guidance: backup.issues.join("; "),
1744
- });
1745
- }
1746
-
1747
- const restored: string[] = [];
1748
- for (const entry of preview.restorable) {
1749
- if (digestFile(entry.path) !== entry.currentDigest) {
1750
- return failure("revision_conflict", "managed file changed during rollback", {
1751
- path: entry.path,
1752
- });
1753
- }
1754
- if (entry.restore === "remove") {
1755
- unlinkSync(entry.path);
1756
- restored.push(entry.path);
1757
- continue;
1758
- }
1759
- const original = backup.files.get(entry.path);
1760
- if (!original) {
1761
- return failure("recovery_required", "verified rollback content is missing", {
1762
- path: entry.path,
1763
- });
1764
- }
1765
- const content = readFileSync(original.storagePath);
1766
- const digest = createHash("sha256").update(content).digest("hex");
1767
- if (digest !== original.digest) {
1768
- return failure("recovery_required", "rollback content changed during apply", {
1769
- path: original.storagePath,
1770
- });
1771
- }
1772
- writeFileAtomic(entry.path, content.toString("utf8"));
1773
- if (digestFile(entry.path) !== original.digest) {
1774
- return failure("storage_error", "restored file failed verification", { path: entry.path });
1775
- }
1776
- restored.push(entry.path);
1777
- }
1778
-
1779
- writeGenerationState(paths.configDir, { target: "legacy" });
1780
- return success(null, null, { restored });
1781
- }
1782
-
1783
- export const countLegacyFlowRecords = (workspace: string): number =>
1784
- legacyFlowFiles(workspace).length;
1785
-
1786
- export const legacyFlowRecordDigests = (workspace: string): Digest[] =>
1787
- legacyFlowFiles(workspace).map((f) => digestFile(f)!);
1788
-
1789
- export { previewConversion };