@brainervirus/workit-core 1.2.1 → 1.3.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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/skills/workit-babysit/SKILL.md +20 -26
  3. package/skills/workit-behavioral-tdd/SKILL.md +0 -5
  4. package/skills/workit-blast-radius/SKILL.md +0 -5
  5. package/skills/workit-challenge/SKILL.md +30 -47
  6. package/skills/workit-debug/SKILL.md +5 -27
  7. package/skills/workit-deslop/SKILL.md +0 -5
  8. package/skills/workit-diagram/SKILL.md +0 -5
  9. package/skills/workit-green-run/SKILL.md +0 -5
  10. package/skills/workit-handoff/SKILL.md +2 -8
  11. package/skills/workit-implement/SKILL.md +14 -20
  12. package/skills/workit-mockup/SKILL.md +0 -5
  13. package/skills/workit-plan/SKILL.md +30 -63
  14. package/skills/workit-review/SKILL.md +0 -5
  15. package/skills/workit-steer/SKILL.md +27 -27
  16. package/src/core/authority.ts +6 -1
  17. package/src/core/auto-approval.ts +34 -14
  18. package/src/core/branch.ts +293 -42
  19. package/src/core/config-conversion.ts +4 -4
  20. package/src/core/config-guard.ts +1 -7
  21. package/src/core/config.ts +1 -1
  22. package/src/core/cutover.ts +1207 -140
  23. package/src/core/doctor.ts +155 -82
  24. package/src/core/external-action-effects.ts +886 -172
  25. package/src/core/external-action.ts +214 -63
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +56 -52
  29. package/src/core/ports/pr-create.ts +23 -15
  30. package/src/core/pr-create.ts +374 -84
  31. package/src/core/registration.ts +0 -15
  32. package/src/core/repo-tools.ts +0 -1
  33. package/src/core/route-intent.ts +34 -82
  34. package/src/core/safe-write.ts +66 -1
  35. package/src/core/setup-state.ts +7 -0
  36. package/src/core/setup.ts +175 -82
  37. package/src/core/task-contract.ts +7 -1
  38. package/src/core/task-engine.ts +47 -2
  39. package/src/core/task-store.ts +159 -1
  40. package/src/core/tracker-issues.ts +55 -118
  41. package/src/core/vcs-config.ts +178 -187
  42. package/src/core/workspaces.ts +548 -46
  43. package/src/core.ts +1 -2
  44. package/src/core/ports/vcs-token-create-urls.ts +0 -4
@@ -1,13 +1,17 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
+ closeSync,
3
4
  cpSync,
4
5
  existsSync,
6
+ lstatSync,
5
7
  mkdirSync,
8
+ opendirSync,
9
+ openSync,
6
10
  readFileSync,
11
+ readSync,
7
12
  readdirSync,
8
- rmSync,
9
- statSync,
10
- writeFileSync,
13
+ unlinkSync,
14
+ constants as fsConstants,
11
15
  } from "node:fs";
12
16
  import os from "node:os";
13
17
  import path from "node:path";
@@ -27,6 +31,8 @@ import {
27
31
  mergeOpenCodeConfig,
28
32
  } from "./registration";
29
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";
30
36
 
31
37
  export type CutoverHost = "opencode" | "cursor" | "codex" | "pi";
32
38
 
@@ -36,9 +42,37 @@ export type SessionObservation = {
36
42
  state: "stopped" | "active" | "unknown";
37
43
  };
38
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
+
39
71
  export type CutoverPlan = {
40
72
  id: Id;
73
+ archiveDir: string | null;
41
74
  managedFiles: { path: string; beforeDigest: Digest | null; afterDigest: Digest }[];
75
+ inventory: CutoverInventory;
42
76
  sessions: SessionObservation[];
43
77
  unresolved: { key: string; reason: string }[];
44
78
  conversion: ConversionPreview;
@@ -48,6 +82,7 @@ export type CutoverPlan = {
48
82
 
49
83
  export type CutoverReceipt = {
50
84
  backupId: Id;
85
+ archiveDir: string;
51
86
  planId: Id;
52
87
  generation: "legacy" | "v1";
53
88
  hosts: CutoverHost[];
@@ -56,6 +91,23 @@ export type CutoverReceipt = {
56
91
  notes: string[];
57
92
  };
58
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
+
59
111
  export type CutoverDecision = {
60
112
  approve: true;
61
113
  hosts: CutoverHost[];
@@ -64,21 +116,30 @@ export type CutoverDecision = {
64
116
 
65
117
  export type RollbackPreview = {
66
118
  backupId: Id;
67
- restorable: { path: string; currentDigest: Digest; installedDigest: Digest }[];
68
- conflicts: { path: string; currentDigest: Digest; installedDigest: Digest }[];
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 }[];
69
126
  preserved: string[];
127
+ issues: string[];
70
128
  };
71
129
 
72
130
  export type CutoverPaths = {
73
131
  home?: string;
74
132
  configDir?: string;
75
133
  stateDir?: string;
134
+ archiveDir?: string;
76
135
  dev?: string | null;
77
136
  workspace?: string;
78
137
  opencodeConfig?: string;
79
138
  cursorSettings?: string;
80
139
  cursorMcp?: string;
81
140
  cursorPluginDir?: string;
141
+ piConfig?: string;
142
+ piSettings?: string;
82
143
  sessions?: SessionObservation[];
83
144
  env?: NodeJS.ProcessEnv;
84
145
  };
@@ -89,6 +150,8 @@ export type GenerationState = {
89
150
  };
90
151
 
91
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);
92
155
 
93
156
  const resolvePaths = (options: CutoverPaths = {}) => {
94
157
  const home = options.home ?? options.env?.HOME ?? os.homedir();
@@ -100,6 +163,7 @@ const resolvePaths = (options: CutoverPaths = {}) => {
100
163
  options.stateDir ??
101
164
  options.env?.WORKFLOW_TOOLKIT_STATE ??
102
165
  path.join(home, ".local", "state", "workit");
166
+ const piAgentDir = options.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
103
167
  return {
104
168
  home,
105
169
  configDir,
@@ -112,13 +176,79 @@ const resolvePaths = (options: CutoverPaths = {}) => {
112
176
  cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
113
177
  cursorPluginDir:
114
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"),
115
181
  sessions: options.sessions ?? [],
116
182
  };
117
183
  };
118
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.sort(), truncated: false };
221
+ if (names.length === limit) return { names: names.sort(), truncated: true };
222
+ names.push(entry.name);
223
+ }
224
+ } finally {
225
+ handle.closeSync();
226
+ }
227
+ };
228
+
119
229
  export const digestFile = (file: string): Digest | null => {
120
- if (!existsSync(file)) return null;
121
- return createHash("sha256").update(readFileSync(file)).digest("hex");
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
+ }
122
252
  };
123
253
 
124
254
  export const readGenerationState = (configDir: string): GenerationState => {
@@ -134,8 +264,7 @@ export const readGenerationState = (configDir: string): GenerationState => {
134
264
  };
135
265
 
136
266
  export const writeGenerationState = (configDir: string, state: GenerationState): void => {
137
- mkdirSync(configDir, { recursive: true });
138
- writeFileSync(path.join(configDir, GENERATION_FILE), JSON.stringify(state, null, 2) + "\n");
267
+ writeFileAtomic(path.join(configDir, GENERATION_FILE), JSON.stringify(state, null, 2) + "\n");
139
268
  };
140
269
 
141
270
  const listSkillNames = (root: string): string[] => {
@@ -168,22 +297,13 @@ export const classifyHostGeneration = (
168
297
  try {
169
298
  const cfg = JSON.parse(readFileSync(paths.opencodeConfig, "utf8"));
170
299
  const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : cfg.plugin ? [cfg.plugin] : [];
171
- const hasLegacyPin = plugins.some(
172
- (p: unknown) =>
173
- String(p).includes("workflow-toolkit") || String(p).includes("/assets/commands/wk-"),
174
- );
175
- const hasWorkit = plugins.some((p: unknown) => isWorkitPlugin(p));
176
- const devAssets =
177
- paths.dev &&
178
- existsSync(path.join(paths.dev, "packages/workit-opencode/assets/commands/wk-init.md"));
179
- const legacy = hasLegacyPin || Boolean(devAssets);
180
- const v1 =
181
- hasWorkit &&
182
- paths.dev &&
183
- existsSync(path.join(paths.dev, "packages/workit-opencode/dist/plugin.js"));
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);
184
304
  if (legacy && v1) return "mixed";
185
305
  if (v1) return "v1";
186
- if (legacy || hasWorkit) return "legacy";
306
+ if (legacy) return "legacy";
187
307
  return "none";
188
308
  } catch {
189
309
  return "none";
@@ -196,14 +316,29 @@ export const classifyHostGeneration = (
196
316
  return existsSync(path.join(plugin, ".codex-plugin")) ? "v1" : "legacy";
197
317
  }
198
318
  if (host === "pi") {
199
- const piConfig = path.join(paths.home, ".pi", "config.json");
200
- if (!existsSync(piConfig)) return "none";
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";
201
322
  try {
202
- const cfg = JSON.parse(readFileSync(piConfig, "utf8"));
203
- const exts = cfg?.extensions ?? cfg?.pi?.extensions ?? [];
204
- const has = (Array.isArray(exts) ? exts : []).some((e: unknown) =>
205
- String(e).includes("workit"),
206
- );
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
+ ));
207
342
  return has ? "v1" : "none";
208
343
  } catch {
209
344
  return "none";
@@ -264,45 +399,431 @@ const managedCutoverFiles = (paths: ReturnType<typeof resolvePaths>): string[] =
264
399
  paths.opencodeConfig,
265
400
  paths.cursorSettings,
266
401
  paths.cursorMcp,
402
+ paths.piConfig,
403
+ paths.piSettings,
267
404
  path.join(paths.cursorPluginDir, "hooks", "hooks-cursor.json"),
268
405
  ];
269
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((path) => ({
600
+ path,
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].sort();
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
+
270
759
  const hostMutationTargets = (
271
760
  paths: ReturnType<typeof resolvePaths>,
272
761
  hosts: CutoverHost[],
273
762
  ): string[] => {
274
763
  const targets: string[] = [];
275
764
  for (const host of hosts) {
276
- if (host === "cursor") {
277
- targets.push(path.join(paths.cursorPluginDir, "skills"));
278
- const vendor = path.join(paths.cursorPluginDir, "vendor");
279
- if (existsSync(vendor)) targets.push(vendor);
280
- }
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
+ );
281
771
  if (host === "codex") targets.push(path.join(paths.home, ".codex", "plugins", "workit"));
282
- if (host === "pi") targets.push(path.join(paths.home, ".pi", "config.json"));
772
+ if (host === "pi") targets.push(paths.piConfig, paths.piSettings);
283
773
  }
284
774
  return targets;
285
775
  };
286
776
 
287
- const expandBackupTargets = (targets: string[]): string[] => {
777
+ const expandBackupTargets = (targets: string[]): { files: string[]; issues: string[] } => {
288
778
  const files: string[] = [];
779
+ const issues = new Set<string>();
289
780
  for (const target of targets) {
290
- if (!existsSync(target)) continue;
291
- const st = statSync(target);
292
- if (st.isDirectory()) {
293
- const walk = (dir: string) => {
294
- for (const name of readdirSync(dir)) {
295
- const p = path.join(dir, name);
296
- if (statSync(p).isDirectory()) walk(p);
297
- else files.push(p);
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;
298
806
  }
299
- };
300
- walk(target);
301
- } else {
302
- files.push(target);
303
- }
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);
304
825
  }
305
- return files;
826
+ return { files, issues: [...issues] };
306
827
  };
307
828
 
308
829
  const cutoverBackupTargets = (
@@ -310,8 +831,29 @@ const cutoverBackupTargets = (
310
831
  hosts: CutoverHost[],
311
832
  ): string[] => [...new Set([...managedCutoverFiles(paths), ...hostMutationTargets(paths, hosts)])];
312
833
 
313
- const backupRoot = (stateDir: string, backupId: Id) =>
314
- path.join(stateDir, "cutover", "backups", backupId);
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
+ };
315
857
 
316
858
  const backupRelPath = (file: string, paths: ReturnType<typeof resolvePaths>): string => {
317
859
  if (file.startsWith(paths.configDir + path.sep) || file === paths.configDir) {
@@ -320,24 +862,117 @@ const backupRelPath = (file: string, paths: ReturnType<typeof resolvePaths>): st
320
862
  return path.join("home", path.relative(paths.home, file));
321
863
  };
322
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 as Digest, storagePath });
940
+ }
941
+ return { files, issues };
942
+ };
943
+
323
944
  const writeBackup = (
324
945
  backupId: Id,
946
+ archiveDir: string,
325
947
  paths: ReturnType<typeof resolvePaths>,
326
948
  files: string[],
327
949
  ): void => {
328
- const root = backupRoot(paths.stateDir, backupId);
950
+ const root = backupRoot(archiveDir, backupId);
329
951
  mkdirSync(root, { recursive: true });
330
- const manifest: CutoverReceipt["managedFiles"] = [];
952
+ const manifest: { path: string; digest: Digest }[] = [];
331
953
  for (const file of files) {
332
954
  if (!existsSync(file)) continue;
333
- const dest = path.join(root, backupRelPath(file, paths));
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
+ );
334
962
  mkdirSync(path.dirname(dest), { recursive: true });
335
- cpSync(file, dest);
336
- manifest.push({ path: file, installedDigest: digestFile(file)! });
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 });
337
968
  }
338
- writeFileSync(
969
+ writeFileAtomic(
339
970
  path.join(root, "manifest.json"),
340
- JSON.stringify({ backupId, files: manifest }, null, 2) + "\n",
971
+ JSON.stringify(
972
+ { schemaVersion: 1, storage: "sha256-path-v1", backupId, files: manifest },
973
+ null,
974
+ 2,
975
+ ) + "\n",
341
976
  );
342
977
  };
343
978
 
@@ -345,11 +980,72 @@ const receiptPath = (stateDir: string, backupId: Id) =>
345
980
  path.join(stateDir, "cutover", "receipts", `${backupId}.json`);
346
981
 
347
982
  const writeReceipt = (receipt: CutoverReceipt, stateDir: string): void => {
348
- mkdirSync(path.dirname(receiptPath(stateDir, receipt.backupId)), { recursive: true });
349
- writeFileSync(receiptPath(stateDir, receipt.backupId), JSON.stringify(receipt, null, 2) + "\n");
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
+ }
350
1045
  };
351
1046
 
352
1047
  export const readCutoverReceipt = (backupId: Id, stateDir: string): CutoverReceipt | null => {
1048
+ if (!validCutoverId(backupId)) return null;
353
1049
  const file = receiptPath(stateDir, backupId);
354
1050
  if (!existsSync(file)) return null;
355
1051
  try {
@@ -387,6 +1083,16 @@ const plannedAfterDigest = (
387
1083
  );
388
1084
  return createHash("sha256").update(JSON.stringify(merged.config)).digest("hex");
389
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
+ }
390
1096
  if (file.endsWith("hooks-cursor.json")) {
391
1097
  const entry = cursorHooksEntry(paths.cursorPluginDir);
392
1098
  const base = existsSync(file)
@@ -406,13 +1112,28 @@ export function previewCutover(
406
1112
  hosts: CutoverHost[] = ["opencode", "cursor"],
407
1113
  ): CutoverPlan {
408
1114
  const paths = resolvePaths(options);
409
- const conversion = previewConversion({ configDir: paths.configDir });
410
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
+ }
411
1130
  const unresolved = [...conversion.unresolved];
412
1131
 
413
- for (const host of hosts) {
414
- const gen = classifyHostGeneration(host, paths);
415
- if (gen === "mixed") blocked.push(`${host}: mixed legacy and v1 components`);
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
+ }
416
1137
  }
417
1138
 
418
1139
  for (const session of paths.sessions) {
@@ -421,17 +1142,51 @@ export function previewCutover(
421
1142
  }
422
1143
  }
423
1144
 
424
- const managedFiles = managedCutoverFiles(paths).map((filePath) => ({
425
- path: filePath,
426
- beforeDigest: digestFile(filePath),
427
- afterDigest: existsSync(filePath)
428
- ? plannedAfterDigest(filePath, hosts[0] ?? "cursor", paths)
429
- : createHash("sha256").update("").digest("hex"),
430
- }));
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
+ });
431
1184
 
432
1185
  return {
433
1186
  id: randomUUID(),
1187
+ archiveDir,
434
1188
  managedFiles,
1189
+ inventory,
435
1190
  sessions: paths.sessions,
436
1191
  unresolved,
437
1192
  conversion,
@@ -440,32 +1195,56 @@ export function previewCutover(
440
1195
  };
441
1196
  }
442
1197
 
443
- const syncV1CursorSkills = (pluginDir: string, dev: string | null) => {
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
+ };
444
1218
  mkdirSync(path.join(pluginDir, "skills"), { recursive: true });
445
1219
  for (const skill of WORKIT_METHOD_SKILLS) {
446
1220
  const src = dev ? path.join(dev, "packages/workit-cursor/skills", skill, "SKILL.md") : null;
447
1221
  const destDir = path.join(pluginDir, "skills", skill);
448
1222
  mkdirSync(destDir, { recursive: true });
449
- writeFileSync(
1223
+ syncGeneratedFile(
1224
+ `skills/${skill}/SKILL.md`,
450
1225
  path.join(destDir, "SKILL.md"),
451
1226
  src && existsSync(src) ? readFileSync(src, "utf8") : "# v1\n",
452
1227
  );
453
1228
  }
454
- for (const name of readdirSync(path.join(pluginDir, "skills"))) {
455
- if (name.startsWith("wk-"))
456
- rmSync(path.join(pluginDir, "skills", name), { recursive: true, force: true });
457
- }
458
1229
  // Bare slash aliases ship next to skills so installs match the repo.
459
1230
  mkdirSync(path.join(pluginDir, "commands"), { recursive: true });
460
1231
  for (const alias of Object.keys(WORKIT_SKILL_ALIASES)) {
461
1232
  const src = dev ? path.join(dev, "packages/workit-cursor/commands", `${alias}.md`) : null;
462
- writeFileSync(
1233
+ syncGeneratedFile(
1234
+ `commands/${alias}.md`,
463
1235
  path.join(pluginDir, "commands", `${alias}.md`),
464
1236
  src && existsSync(src) ? readFileSync(src, "utf8") : `# /${alias}\n`,
465
1237
  );
466
1238
  }
467
- const vendor = path.join(pluginDir, "vendor");
468
- if (existsSync(vendor)) rmSync(vendor, { recursive: true, force: true });
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
+ }
469
1248
  };
470
1249
 
471
1250
  const applyHostCutover = (
@@ -474,18 +1253,18 @@ const applyHostCutover = (
474
1253
  notes: string[],
475
1254
  ): boolean => {
476
1255
  if (host === "cursor") {
477
- syncV1CursorSkills(paths.cursorPluginDir, paths.dev);
1256
+ syncV1CursorSkills(paths.cursorPluginDir, paths.dev, notes);
478
1257
  const settings = existsSync(paths.cursorSettings)
479
1258
  ? JSON.parse(readFileSync(paths.cursorSettings, "utf8"))
480
1259
  : {};
481
- writeFileSync(
1260
+ writeFileAtomic(
482
1261
  paths.cursorSettings,
483
1262
  JSON.stringify(mergeCursorSettings(settings, paths.cursorPluginDir).config, null, 2) + "\n",
484
1263
  );
485
1264
  const mcp = existsSync(paths.cursorMcp)
486
1265
  ? JSON.parse(readFileSync(paths.cursorMcp, "utf8"))
487
1266
  : { mcpServers: {} };
488
- writeFileSync(
1267
+ writeFileAtomic(
489
1268
  paths.cursorMcp,
490
1269
  JSON.stringify(
491
1270
  mergeCursorMcp(mcp, "workit", cursorMcpServerEntry(paths.cursorPluginDir)).config,
@@ -498,10 +1277,36 @@ const applyHostCutover = (
498
1277
  ? JSON.parse(readFileSync(hooksPath, "utf8"))
499
1278
  : { version: 1, hooks: {} };
500
1279
  const entry = cursorHooksEntry(paths.cursorPluginDir);
501
- writeFileSync(
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(
502
1298
  hooksPath,
503
1299
  JSON.stringify(
504
- { ...hooks, hooks: { ...hooks.hooks, sessionStart: [{ command: entry.command }] } },
1300
+ {
1301
+ ...hookConfig,
1302
+ hooks: {
1303
+ ...hookMap,
1304
+ sessionStart: [
1305
+ ...sessionStart.filter((item: unknown) => !isWorkitSessionStart(item)),
1306
+ entry,
1307
+ ],
1308
+ },
1309
+ },
505
1310
  null,
506
1311
  2,
507
1312
  ) + "\n",
@@ -516,11 +1321,11 @@ const applyHostCutover = (
516
1321
  const distDir = path.join(paths.dev, "packages/workit-opencode/dist");
517
1322
  mkdirSync(distDir, { recursive: true });
518
1323
  const dist = path.join(distDir, "plugin.js");
519
- if (!existsSync(dist)) writeFileSync(dist, "export default {};\n");
1324
+ if (!existsSync(dist)) writeFileExclusive(dist, "export default {};\n");
520
1325
  const current = existsSync(paths.opencodeConfig)
521
1326
  ? JSON.parse(readFileSync(paths.opencodeConfig, "utf8"))
522
1327
  : {};
523
- writeFileSync(
1328
+ writeFileAtomic(
524
1329
  paths.opencodeConfig,
525
1330
  JSON.stringify(mergeOpenCodeConfig(current, pin).config, null, 2) + "\n",
526
1331
  );
@@ -535,22 +1340,33 @@ const applyHostCutover = (
535
1340
  path.join(pluginDir, ".codex-plugin"),
536
1341
  {
537
1342
  recursive: true,
1343
+ force: false,
1344
+ errorOnExist: false,
1345
+ dereference: false,
538
1346
  },
539
1347
  );
540
1348
  notes.push("codex: installed v1 plugin scaffold");
541
1349
  return true;
542
1350
  }
543
1351
  if (host === "pi" && paths.dev) {
544
- const piConfig = path.join(paths.home, ".pi", "config.json");
545
- mkdirSync(path.dirname(piConfig), { recursive: true });
546
- const current = existsSync(piConfig) ? JSON.parse(readFileSync(piConfig, "utf8")) : {};
547
- const next = {
548
- ...current,
549
- extensions: [`${path.join(paths.dev, "packages/workit-pi/dist/workit.js")}`],
550
- skills: [`${path.join(paths.dev, "packages/workit-pi/skills")}`],
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 : {};
551
1358
  };
552
- writeFileSync(piConfig, JSON.stringify(next, null, 2) + "\n");
553
- notes.push("pi: registered v1 extension and skills");
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
+ );
554
1370
  return true;
555
1371
  }
556
1372
  return false;
@@ -574,6 +1390,25 @@ export function applyCutover(
574
1390
  }
575
1391
 
576
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
+ }
577
1412
  for (const entry of plan.managedFiles) {
578
1413
  const current = digestFile(entry.path);
579
1414
  if (entry.beforeDigest !== null && current !== entry.beforeDigest) {
@@ -598,74 +1433,267 @@ export function applyCutover(
598
1433
 
599
1434
  const backupId = randomUUID();
600
1435
  const backupTargets = cutoverBackupTargets(paths, decision.hosts);
601
- const backupFiles = expandBackupTargets(backupTargets);
602
- writeBackup(backupId, paths, backupFiles);
603
-
604
- const conversionNotes: string[] = [];
605
- if (plan.unresolved.length > 0 || plan.conversion.mappings.length > 0) {
606
- const applied = applyConversionConfig(
607
- paths.configDir,
608
- plan.conversion,
609
- decision.resolutions ?? {},
610
- );
611
- conversionNotes.push(
612
- `config: wrote ${applied.configPath} and recorded choices at ${applied.choicesPath}`,
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
+ },
613
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
+ }
614
1524
  }
615
1525
 
616
- const notes: string[] = [...conversionNotes];
617
- let partial = false;
618
- const applied: CutoverHost[] = [];
619
- for (const host of decision.hosts) {
620
- const ok = applyHostCutover(host, paths, notes);
621
- if (ok) applied.push(host);
622
- else partial = true;
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
+ }
623
1545
  }
624
1546
 
625
- if (!partial) {
626
- writeGenerationState(paths.configDir, {
627
- target: "v1",
628
- cutover: { backupId, planId: plan.id, at: new Date().toISOString() },
629
- });
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
+ }
630
1559
  }
631
1560
 
632
- const managedFiles = expandBackupTargets(backupTargets)
633
- .filter((f) => existsSync(f))
634
- .map((f) => ({ path: f, installedDigest: digestFile(f)! }));
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
+ }
635
1572
 
636
1573
  const receipt: CutoverReceipt = {
637
- backupId,
638
- planId: plan.id,
1574
+ backupId: journal.backupId,
1575
+ archiveDir: journal.archiveDir,
1576
+ planId: journal.planId,
639
1577
  generation: partial ? "legacy" : "v1",
640
- hosts: applied,
1578
+ hosts: [...journal.appliedHosts],
641
1579
  managedFiles,
642
1580
  partial,
643
- notes,
1581
+ notes: [...journal.notes],
644
1582
  };
645
- writeReceipt(receipt, paths.stateDir);
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
+ };
646
1595
 
647
- return success(null, null, receipt);
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);
648
1609
  }
649
1610
 
650
1611
  export function previewRollback(backupId: Id, options: CutoverPaths = {}): RollbackPreview {
651
1612
  const paths = resolvePaths(options);
652
- const root = backupRoot(paths.stateDir, backupId);
1613
+ if (!validCutoverId(backupId)) {
1614
+ return {
1615
+ backupId,
1616
+ restorable: [],
1617
+ conflicts: [],
1618
+ preserved: [],
1619
+ issues: ["invalid cutover backup id"],
1620
+ };
1621
+ }
653
1622
  const receipt = readCutoverReceipt(backupId, paths.stateDir);
654
1623
  const restorable: RollbackPreview["restorable"] = [];
655
1624
  const conflicts: RollbackPreview["conflicts"] = [];
656
1625
  const preserved: string[] = [];
1626
+ const issues: string[] = [];
657
1627
 
658
- if (!existsSync(root) || !receipt) return { backupId, restorable, conflicts, preserved };
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);
659
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>();
660
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);
661
1675
  const current = digestFile(entry.path);
662
- if (current === null) continue;
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
+ }
663
1687
  if (current === entry.installedDigest) {
664
1688
  restorable.push({
665
1689
  path: entry.path,
666
1690
  currentDigest: current,
667
1691
  installedDigest: entry.installedDigest,
1692
+ restore: original ? "backup" : "remove",
668
1693
  });
1694
+ } else if (original && current === original.digest) {
1695
+ // A previous rollback attempt already restored this file.
1696
+ continue;
669
1697
  } else {
670
1698
  conflicts.push({
671
1699
  path: entry.path,
@@ -678,15 +1706,22 @@ export function previewRollback(backupId: Id, options: CutoverPaths = {}): Rollb
678
1706
  const v1Task = path.join(paths.workspace, ".workit");
679
1707
  if (existsSync(v1Task)) preserved.push(v1Task);
680
1708
 
681
- return { backupId, restorable, conflicts, preserved };
1709
+ return { backupId, restorable, conflicts, preserved, issues };
682
1710
  }
683
1711
 
684
1712
  export function applyRollback(
685
1713
  backupId: Id,
686
1714
  options: CutoverPaths = {},
687
1715
  ): Result<{ restored: string[] }> {
1716
+ if (!validCutoverId(backupId)) return failure("not_found", `backup not found: ${backupId}`);
688
1717
  const paths = resolvePaths(options);
689
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
+ }
690
1725
  if (preview.conflicts.length > 0) {
691
1726
  return failure(
692
1727
  "revision_conflict",
@@ -697,15 +1732,47 @@ export function applyRollback(
697
1732
  );
698
1733
  }
699
1734
 
700
- const root = backupRoot(paths.stateDir, backupId);
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);
701
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
+ }
702
1746
 
703
1747
  const restored: string[] = [];
704
1748
  for (const entry of preview.restorable) {
705
- const src = path.join(root, backupRelPath(entry.path, paths));
706
- if (!existsSync(src)) continue;
707
- mkdirSync(path.dirname(entry.path), { recursive: true });
708
- cpSync(src, entry.path);
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
+ }
709
1776
  restored.push(entry.path);
710
1777
  }
711
1778