@brainervirus/workit-core 0.11.0 → 1.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.
Files changed (150) hide show
  1. package/README.md +5 -3
  2. package/package.json +8 -3
  3. package/scripts/analyze-release-scope.ts +4 -1
  4. package/scripts/doctor-check.ts +3 -1
  5. package/scripts/install-codex-plugin.sh +28 -0
  6. package/scripts/install-cursor-plugin.sh +1 -0
  7. package/scripts/install-opencode-plugin.sh +1 -0
  8. package/scripts/install-pi-package.sh +56 -0
  9. package/scripts/rewrite-workspace-deps.ts +22 -5
  10. package/scripts/sync-release-manifests.ts +8 -5
  11. package/scripts/sync-runtime.sh +7 -3
  12. package/scripts/validate-cursor-marketplace.ts +16 -62
  13. package/skills/workit-babysit/SKILL.md +33 -0
  14. package/skills/workit-behavioral-tdd/SKILL.md +53 -0
  15. package/skills/workit-blast-radius/SKILL.md +31 -0
  16. package/skills/workit-challenge/SKILL.md +62 -0
  17. package/skills/workit-debug/SKILL.md +61 -0
  18. package/skills/workit-deslop/SKILL.md +36 -0
  19. package/skills/workit-diagram/SKILL.md +32 -0
  20. package/skills/workit-green-run/SKILL.md +29 -0
  21. package/skills/workit-handoff/SKILL.md +43 -0
  22. package/skills/workit-implement/SKILL.md +46 -0
  23. package/skills/workit-mockup/SKILL.md +28 -0
  24. package/skills/workit-plan/SKILL.md +66 -0
  25. package/skills/workit-review/SKILL.md +60 -0
  26. package/skills/workit-steer/SKILL.md +32 -0
  27. package/src/core/authority.ts +918 -0
  28. package/src/core/boundary.ts +29 -0
  29. package/src/core/branch.ts +26 -255
  30. package/src/core/changelog.ts +95 -18
  31. package/src/core/commit-flavors.ts +65 -0
  32. package/src/core/config-conversion.ts +183 -0
  33. package/src/core/config.ts +42 -0
  34. package/src/core/cutover.ts +721 -0
  35. package/src/core/detect-hosts.ts +107 -0
  36. package/src/core/doctor.ts +428 -18
  37. package/src/core/external-action-effects.ts +1171 -0
  38. package/src/core/external-action.ts +541 -0
  39. package/src/core/init.ts +83 -32
  40. package/src/core/methods.ts +125 -0
  41. package/src/core/policy-resolver.ts +523 -0
  42. package/src/core/pr-create.ts +7 -1
  43. package/src/core/registration.ts +69 -4
  44. package/src/core/repo-context.ts +59 -1
  45. package/src/core/setup.ts +28 -0
  46. package/src/core/skill-manifests.ts +40 -42
  47. package/src/core/support-matrix.ts +8 -3
  48. package/src/core/sync-runtime.ts +15 -3
  49. package/src/core/task-context.ts +179 -0
  50. package/src/core/task-contract.ts +1136 -0
  51. package/src/core/task-engine.ts +2562 -0
  52. package/src/core/task-evaluation.ts +679 -0
  53. package/src/core/task-store.ts +1071 -0
  54. package/src/core/tracker-issues.ts +237 -0
  55. package/src/core/uninstall.ts +67 -3
  56. package/src/core/vcs-config.ts +53 -22
  57. package/src/core/workers.ts +166 -0
  58. package/src/core/workspaces.ts +3 -1
  59. package/src/core/youtrack-tools.ts +2 -0
  60. package/src/core/youtrack.ts +145 -10
  61. package/src/core.ts +126 -0
  62. package/templates/execution-contract.md +17 -50
  63. package/templates/plan-template.md +3 -3
  64. package/templates/spec-template.md +15 -3
  65. package/templates/workit-contract.md +12 -0
  66. package/commands/wk-changelog.md +0 -2
  67. package/commands/wk-commit.md +0 -2
  68. package/commands/wk-docs-refresh.md +0 -2
  69. package/commands/wk-handoff.md +0 -2
  70. package/commands/wk-implement.md +0 -2
  71. package/commands/wk-init.md +0 -2
  72. package/commands/wk-issue-update.md +0 -2
  73. package/commands/wk-meetings.md +0 -2
  74. package/commands/wk-pr.md +0 -2
  75. package/commands/wk-release-notes.md +0 -2
  76. package/commands/wk-status.md +0 -2
  77. package/commands/wk-verify.md +0 -2
  78. package/scripts/update-superpowers.sh +0 -82
  79. package/scripts/vendor-assets.ts +0 -37
  80. package/skills/wk-changelog/SKILL.md +0 -15
  81. package/skills/wk-commit/SKILL.md +0 -16
  82. package/skills/wk-docs-refresh/SKILL.md +0 -15
  83. package/skills/wk-handoff/SKILL.md +0 -20
  84. package/skills/wk-implement/SKILL.md +0 -47
  85. package/skills/wk-init/SKILL.md +0 -31
  86. package/skills/wk-issue-update/SKILL.md +0 -27
  87. package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
  88. package/skills/wk-meetings/SKILL.md +0 -17
  89. package/skills/wk-pr/SKILL.md +0 -27
  90. package/skills/wk-release-notes/SKILL.md +0 -15
  91. package/skills/wk-status/SKILL.md +0 -16
  92. package/skills/wk-verify/SKILL.md +0 -16
  93. package/src/core/detector.ts +0 -239
  94. package/src/core/flow-state.ts +0 -3308
  95. package/src/core/handoff-context.ts +0 -136
  96. package/src/core/handoff-tools.ts +0 -133
  97. package/src/core/menu.ts +0 -70
  98. package/src/core/plan-tasks.ts +0 -43
  99. package/src/core/reminder.ts +0 -124
  100. package/src/core/sdd.ts +0 -353
  101. package/src/state.ts +0 -22
  102. package/templates/superpowers-doc-contract.md +0 -75
  103. package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
  104. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
  105. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
  106. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
  107. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
  108. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
  109. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
  110. package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
  111. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
  112. package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
  113. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
  114. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
  115. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
  116. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
  117. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -428
  118. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
  119. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
  120. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
  121. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
  122. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
  123. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
  124. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
  125. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  126. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
  127. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
  128. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
  129. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
  130. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
  131. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
  132. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
  133. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
  134. package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
  135. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
  136. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
  137. package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
  138. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
  139. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
  140. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
  141. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
  142. package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
  143. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
  144. package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
  145. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
  146. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
  147. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
  148. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
  149. package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
  150. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
@@ -0,0 +1,721 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { failure, success, type Digest, type Id, type Result } from "./task-contract";
15
+ import {
16
+ applyConversionConfig,
17
+ previewConversion,
18
+ type ConversionPreview,
19
+ } from "./config-conversion";
20
+ import {
21
+ CURSOR_RUNTIME_PACKAGE,
22
+ cursorHooksEntry,
23
+ cursorMcpServerEntry,
24
+ isWorkitPlugin,
25
+ mergeCursorMcp,
26
+ mergeCursorSettings,
27
+ mergeOpenCodeConfig,
28
+ } from "./registration";
29
+ import { WORKIT_METHOD_SKILLS, WORKIT_SKILL_ALIASES } from "./skill-manifests";
30
+
31
+ export type CutoverHost = "opencode" | "cursor" | "codex" | "pi";
32
+
33
+ export type SessionObservation = {
34
+ host: CutoverHost | "opencode" | "cursor";
35
+ handle: string;
36
+ state: "stopped" | "active" | "unknown";
37
+ };
38
+
39
+ export type CutoverPlan = {
40
+ id: Id;
41
+ managedFiles: { path: string; beforeDigest: Digest | null; afterDigest: Digest }[];
42
+ sessions: SessionObservation[];
43
+ unresolved: { key: string; reason: string }[];
44
+ conversion: ConversionPreview;
45
+ hosts: CutoverHost[];
46
+ blocked: string[];
47
+ };
48
+
49
+ export type CutoverReceipt = {
50
+ backupId: Id;
51
+ planId: Id;
52
+ generation: "legacy" | "v1";
53
+ hosts: CutoverHost[];
54
+ managedFiles: { path: string; installedDigest: Digest }[];
55
+ partial: boolean;
56
+ notes: string[];
57
+ };
58
+
59
+ export type CutoverDecision = {
60
+ approve: true;
61
+ hosts: CutoverHost[];
62
+ resolutions?: Record<string, string>;
63
+ };
64
+
65
+ export type RollbackPreview = {
66
+ backupId: Id;
67
+ restorable: { path: string; currentDigest: Digest; installedDigest: Digest }[];
68
+ conflicts: { path: string; currentDigest: Digest; installedDigest: Digest }[];
69
+ preserved: string[];
70
+ };
71
+
72
+ export type CutoverPaths = {
73
+ home?: string;
74
+ configDir?: string;
75
+ stateDir?: string;
76
+ dev?: string | null;
77
+ workspace?: string;
78
+ opencodeConfig?: string;
79
+ cursorSettings?: string;
80
+ cursorMcp?: string;
81
+ cursorPluginDir?: string;
82
+ sessions?: SessionObservation[];
83
+ env?: NodeJS.ProcessEnv;
84
+ };
85
+
86
+ export type GenerationState = {
87
+ target: "legacy" | "v1";
88
+ cutover?: { backupId: Id; planId: Id; at: string };
89
+ };
90
+
91
+ const GENERATION_FILE = "generation.json";
92
+
93
+ const resolvePaths = (options: CutoverPaths = {}) => {
94
+ const home = options.home ?? options.env?.HOME ?? os.homedir();
95
+ const configDir =
96
+ options.configDir ??
97
+ options.env?.WORKFLOW_TOOLKIT_CONFIG ??
98
+ path.join(home, ".config", "workit");
99
+ const stateDir =
100
+ options.stateDir ??
101
+ options.env?.WORKFLOW_TOOLKIT_STATE ??
102
+ path.join(home, ".local", "state", "workit");
103
+ return {
104
+ home,
105
+ configDir,
106
+ stateDir,
107
+ dev: options.dev ?? options.env?.WORKFLOW_TOOLKIT_DEV ?? null,
108
+ workspace: options.workspace ?? process.cwd(),
109
+ opencodeConfig:
110
+ options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
111
+ cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
112
+ cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
113
+ cursorPluginDir:
114
+ options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
115
+ sessions: options.sessions ?? [],
116
+ };
117
+ };
118
+
119
+ export const digestFile = (file: string): Digest | null => {
120
+ if (!existsSync(file)) return null;
121
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
122
+ };
123
+
124
+ export const readGenerationState = (configDir: string): GenerationState => {
125
+ const file = path.join(configDir, GENERATION_FILE);
126
+ if (!existsSync(file)) return { target: "legacy" };
127
+ try {
128
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
129
+ if (parsed?.target === "v1") return parsed as GenerationState;
130
+ } catch {
131
+ /* keep legacy default */
132
+ }
133
+ return { target: "legacy" };
134
+ };
135
+
136
+ 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");
139
+ };
140
+
141
+ const listSkillNames = (root: string): string[] => {
142
+ const dir = path.join(root, "skills");
143
+ if (!existsSync(dir)) return [];
144
+ return readdirSync(dir, { withFileTypes: true })
145
+ .filter((d) => d.isDirectory())
146
+ .map((d) => d.name);
147
+ };
148
+
149
+ export type HostGeneration = "legacy" | "v1" | "none" | "mixed";
150
+
151
+ export const classifyHostGeneration = (
152
+ host: CutoverHost,
153
+ paths: ReturnType<typeof resolvePaths>,
154
+ ): HostGeneration => {
155
+ if (host === "cursor") {
156
+ const skills = listSkillNames(paths.cursorPluginDir);
157
+ const legacy =
158
+ skills.some((s) => s.startsWith("wk-")) ||
159
+ existsSync(path.join(paths.cursorPluginDir, "vendor", "superpowers"));
160
+ const v1 = WORKIT_METHOD_SKILLS.every((s) => skills.includes(s));
161
+ if (legacy && v1) return "mixed";
162
+ if (v1) return "v1";
163
+ if (legacy) return "legacy";
164
+ return "none";
165
+ }
166
+ if (host === "opencode") {
167
+ if (!existsSync(paths.opencodeConfig)) return "none";
168
+ try {
169
+ const cfg = JSON.parse(readFileSync(paths.opencodeConfig, "utf8"));
170
+ 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"));
184
+ if (legacy && v1) return "mixed";
185
+ if (v1) return "v1";
186
+ if (legacy || hasWorkit) return "legacy";
187
+ return "none";
188
+ } catch {
189
+ return "none";
190
+ }
191
+ }
192
+ if (host === "codex") {
193
+ const codexDir = path.join(paths.home, ".codex");
194
+ const plugin = path.join(codexDir, "plugins", "workit");
195
+ if (!existsSync(plugin)) return "none";
196
+ return existsSync(path.join(plugin, ".codex-plugin")) ? "v1" : "legacy";
197
+ }
198
+ if (host === "pi") {
199
+ const piConfig = path.join(paths.home, ".pi", "config.json");
200
+ if (!existsSync(piConfig)) return "none";
201
+ 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
+ );
207
+ return has ? "v1" : "none";
208
+ } catch {
209
+ return "none";
210
+ }
211
+ }
212
+ return "none";
213
+ };
214
+
215
+ export const detectSourceLinkedOpenCode = (opencodeConfig: string): boolean => {
216
+ if (!existsSync(opencodeConfig)) return false;
217
+ try {
218
+ const cfg = JSON.parse(readFileSync(opencodeConfig, "utf8"));
219
+ const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : cfg.plugin ? [cfg.plugin] : [];
220
+ return plugins.some((p: unknown) => {
221
+ const s = String(p);
222
+ return s.startsWith("file://") && s.includes("/packages/workit-opencode/");
223
+ });
224
+ } catch {
225
+ return false;
226
+ }
227
+ };
228
+
229
+ export const detectCursorLatest = (cursorMcp: string): boolean => {
230
+ if (!existsSync(cursorMcp)) return false;
231
+ try {
232
+ const cfg = JSON.parse(readFileSync(cursorMcp, "utf8"));
233
+ const server = cfg?.mcpServers?.workit;
234
+ if (!server || server.command !== "npx" || !Array.isArray(server.args)) return false;
235
+ return (
236
+ server.args[0] === "-y" &&
237
+ server.args[1] === "--prefer-online" &&
238
+ server.args[2] === `--package=${CURSOR_RUNTIME_PACKAGE}` &&
239
+ server.args[3] === "workit-cursor-mcp"
240
+ );
241
+ } catch {
242
+ return false;
243
+ }
244
+ };
245
+
246
+ const legacyFlowFiles = (workspace: string): string[] => {
247
+ const docs = path.join(workspace, "docs");
248
+ if (!existsSync(docs)) return [];
249
+ const out: string[] = [];
250
+ for (const slug of readdirSync(docs)) {
251
+ const file = path.join(docs, slug, "sdd", "flow.json");
252
+ if (existsSync(file)) out.push(file);
253
+ }
254
+ return out;
255
+ };
256
+
257
+ const managedCutoverFiles = (paths: ReturnType<typeof resolvePaths>): string[] => [
258
+ path.join(paths.configDir, "config.json"),
259
+ path.join(paths.configDir, "youtrack.json"),
260
+ path.join(paths.configDir, "vcs.json"),
261
+ path.join(paths.configDir, "workspaces.json"),
262
+ path.join(paths.configDir, "cutover-choices.json"),
263
+ paths.opencodeConfig,
264
+ paths.cursorSettings,
265
+ paths.cursorMcp,
266
+ path.join(paths.cursorPluginDir, "hooks", "hooks-cursor.json"),
267
+ ];
268
+
269
+ const hostMutationTargets = (
270
+ paths: ReturnType<typeof resolvePaths>,
271
+ hosts: CutoverHost[],
272
+ ): string[] => {
273
+ const targets: string[] = [];
274
+ for (const host of hosts) {
275
+ if (host === "cursor") {
276
+ targets.push(path.join(paths.cursorPluginDir, "skills"));
277
+ const vendor = path.join(paths.cursorPluginDir, "vendor");
278
+ if (existsSync(vendor)) targets.push(vendor);
279
+ }
280
+ if (host === "codex") targets.push(path.join(paths.home, ".codex", "plugins", "workit"));
281
+ if (host === "pi") targets.push(path.join(paths.home, ".pi", "config.json"));
282
+ }
283
+ return targets;
284
+ };
285
+
286
+ const expandBackupTargets = (targets: string[]): string[] => {
287
+ const files: string[] = [];
288
+ for (const target of targets) {
289
+ if (!existsSync(target)) continue;
290
+ const st = statSync(target);
291
+ if (st.isDirectory()) {
292
+ const walk = (dir: string) => {
293
+ for (const name of readdirSync(dir)) {
294
+ const p = path.join(dir, name);
295
+ if (statSync(p).isDirectory()) walk(p);
296
+ else files.push(p);
297
+ }
298
+ };
299
+ walk(target);
300
+ } else {
301
+ files.push(target);
302
+ }
303
+ }
304
+ return files;
305
+ };
306
+
307
+ const cutoverBackupTargets = (
308
+ paths: ReturnType<typeof resolvePaths>,
309
+ hosts: CutoverHost[],
310
+ ): string[] => [...new Set([...managedCutoverFiles(paths), ...hostMutationTargets(paths, hosts)])];
311
+
312
+ const backupRoot = (stateDir: string, backupId: Id) =>
313
+ path.join(stateDir, "cutover", "backups", backupId);
314
+
315
+ const backupRelPath = (file: string, paths: ReturnType<typeof resolvePaths>): string => {
316
+ if (file.startsWith(paths.configDir + path.sep) || file === paths.configDir) {
317
+ return path.join("config", path.relative(paths.configDir, file));
318
+ }
319
+ return path.join("home", path.relative(paths.home, file));
320
+ };
321
+
322
+ const writeBackup = (
323
+ backupId: Id,
324
+ paths: ReturnType<typeof resolvePaths>,
325
+ files: string[],
326
+ ): void => {
327
+ const root = backupRoot(paths.stateDir, backupId);
328
+ mkdirSync(root, { recursive: true });
329
+ const manifest: CutoverReceipt["managedFiles"] = [];
330
+ for (const file of files) {
331
+ if (!existsSync(file)) continue;
332
+ const dest = path.join(root, backupRelPath(file, paths));
333
+ mkdirSync(path.dirname(dest), { recursive: true });
334
+ cpSync(file, dest);
335
+ manifest.push({ path: file, installedDigest: digestFile(file)! });
336
+ }
337
+ writeFileSync(
338
+ path.join(root, "manifest.json"),
339
+ JSON.stringify({ backupId, files: manifest }, null, 2) + "\n",
340
+ );
341
+ };
342
+
343
+ const receiptPath = (stateDir: string, backupId: Id) =>
344
+ path.join(stateDir, "cutover", "receipts", `${backupId}.json`);
345
+
346
+ const writeReceipt = (receipt: CutoverReceipt, stateDir: string): void => {
347
+ mkdirSync(path.dirname(receiptPath(stateDir, receipt.backupId)), { recursive: true });
348
+ writeFileSync(receiptPath(stateDir, receipt.backupId), JSON.stringify(receipt, null, 2) + "\n");
349
+ };
350
+
351
+ export const readCutoverReceipt = (backupId: Id, stateDir: string): CutoverReceipt | null => {
352
+ const file = receiptPath(stateDir, backupId);
353
+ if (!existsSync(file)) return null;
354
+ try {
355
+ return JSON.parse(readFileSync(file, "utf8")) as CutoverReceipt;
356
+ } catch {
357
+ return null;
358
+ }
359
+ };
360
+
361
+ const plannedAfterDigest = (
362
+ file: string,
363
+ host: CutoverHost,
364
+ paths: ReturnType<typeof resolvePaths>,
365
+ ): Digest => {
366
+ if (file === paths.opencodeConfig && paths.dev) {
367
+ const pin = `file://${paths.dev}/packages/workit-opencode/dist/plugin.js`;
368
+ const merged = mergeOpenCodeConfig(
369
+ existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {},
370
+ pin,
371
+ );
372
+ return createHash("sha256").update(JSON.stringify(merged.config)).digest("hex");
373
+ }
374
+ if (file === paths.cursorSettings) {
375
+ const merged = mergeCursorSettings(
376
+ existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {},
377
+ paths.cursorPluginDir,
378
+ );
379
+ return createHash("sha256").update(JSON.stringify(merged.config)).digest("hex");
380
+ }
381
+ if (file === paths.cursorMcp) {
382
+ const merged = mergeCursorMcp(
383
+ existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { mcpServers: {} },
384
+ "workit",
385
+ cursorMcpServerEntry(paths.cursorPluginDir),
386
+ );
387
+ return createHash("sha256").update(JSON.stringify(merged.config)).digest("hex");
388
+ }
389
+ if (file.endsWith("hooks-cursor.json")) {
390
+ const entry = cursorHooksEntry(paths.cursorPluginDir);
391
+ const base = existsSync(file)
392
+ ? JSON.parse(readFileSync(file, "utf8"))
393
+ : { version: 1, hooks: {} };
394
+ const next = {
395
+ ...base,
396
+ hooks: { ...base.hooks, sessionStart: [{ command: entry.command }] },
397
+ };
398
+ return createHash("sha256").update(JSON.stringify(next)).digest("hex");
399
+ }
400
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
401
+ };
402
+
403
+ export function previewCutover(
404
+ options: CutoverPaths = {},
405
+ hosts: CutoverHost[] = ["opencode", "cursor"],
406
+ ): CutoverPlan {
407
+ const paths = resolvePaths(options);
408
+ const conversion = previewConversion({ configDir: paths.configDir });
409
+ const blocked: string[] = [];
410
+ const unresolved = [...conversion.unresolved];
411
+
412
+ for (const host of hosts) {
413
+ const gen = classifyHostGeneration(host, paths);
414
+ if (gen === "mixed") blocked.push(`${host}: mixed legacy and v1 components`);
415
+ }
416
+
417
+ for (const session of paths.sessions) {
418
+ if (session.state === "active" || session.state === "unknown") {
419
+ blocked.push(`${session.host} session ${session.handle} is ${session.state}`);
420
+ }
421
+ }
422
+
423
+ const managedFiles = managedCutoverFiles(paths).map((filePath) => ({
424
+ path: filePath,
425
+ beforeDigest: digestFile(filePath),
426
+ afterDigest: existsSync(filePath)
427
+ ? plannedAfterDigest(filePath, hosts[0] ?? "cursor", paths)
428
+ : createHash("sha256").update("").digest("hex"),
429
+ }));
430
+
431
+ return {
432
+ id: randomUUID(),
433
+ managedFiles,
434
+ sessions: paths.sessions,
435
+ unresolved,
436
+ conversion,
437
+ hosts,
438
+ blocked,
439
+ };
440
+ }
441
+
442
+ const syncV1CursorSkills = (pluginDir: string, dev: string | null) => {
443
+ mkdirSync(path.join(pluginDir, "skills"), { recursive: true });
444
+ for (const skill of WORKIT_METHOD_SKILLS) {
445
+ const src = dev ? path.join(dev, "packages/workit-cursor/skills", skill, "SKILL.md") : null;
446
+ const destDir = path.join(pluginDir, "skills", skill);
447
+ mkdirSync(destDir, { recursive: true });
448
+ writeFileSync(
449
+ path.join(destDir, "SKILL.md"),
450
+ src && existsSync(src) ? readFileSync(src, "utf8") : "# v1\n",
451
+ );
452
+ }
453
+ for (const name of readdirSync(path.join(pluginDir, "skills"))) {
454
+ if (name.startsWith("wk-"))
455
+ rmSync(path.join(pluginDir, "skills", name), { recursive: true, force: true });
456
+ }
457
+ // Bare slash aliases ship next to skills so installs match the repo.
458
+ mkdirSync(path.join(pluginDir, "commands"), { recursive: true });
459
+ for (const alias of Object.keys(WORKIT_SKILL_ALIASES)) {
460
+ const src = dev ? path.join(dev, "packages/workit-cursor/commands", `${alias}.md`) : null;
461
+ writeFileSync(
462
+ path.join(pluginDir, "commands", `${alias}.md`),
463
+ src && existsSync(src) ? readFileSync(src, "utf8") : `# /${alias}\n`,
464
+ );
465
+ }
466
+ const vendor = path.join(pluginDir, "vendor");
467
+ if (existsSync(vendor)) rmSync(vendor, { recursive: true, force: true });
468
+ };
469
+
470
+ const applyHostCutover = (
471
+ host: CutoverHost,
472
+ paths: ReturnType<typeof resolvePaths>,
473
+ notes: string[],
474
+ ): boolean => {
475
+ if (host === "cursor") {
476
+ syncV1CursorSkills(paths.cursorPluginDir, paths.dev);
477
+ const settings = existsSync(paths.cursorSettings)
478
+ ? JSON.parse(readFileSync(paths.cursorSettings, "utf8"))
479
+ : {};
480
+ writeFileSync(
481
+ paths.cursorSettings,
482
+ JSON.stringify(mergeCursorSettings(settings, paths.cursorPluginDir).config, null, 2) + "\n",
483
+ );
484
+ const mcp = existsSync(paths.cursorMcp)
485
+ ? JSON.parse(readFileSync(paths.cursorMcp, "utf8"))
486
+ : { mcpServers: {} };
487
+ writeFileSync(
488
+ paths.cursorMcp,
489
+ JSON.stringify(
490
+ mergeCursorMcp(mcp, "workit", cursorMcpServerEntry(paths.cursorPluginDir)).config,
491
+ null,
492
+ 2,
493
+ ) + "\n",
494
+ );
495
+ const hooksPath = path.join(paths.cursorPluginDir, "hooks", "hooks-cursor.json");
496
+ const hooks = existsSync(hooksPath)
497
+ ? JSON.parse(readFileSync(hooksPath, "utf8"))
498
+ : { version: 1, hooks: {} };
499
+ const entry = cursorHooksEntry(paths.cursorPluginDir);
500
+ writeFileSync(
501
+ hooksPath,
502
+ JSON.stringify(
503
+ { ...hooks, hooks: { ...hooks.hooks, sessionStart: [{ command: entry.command }] } },
504
+ null,
505
+ 2,
506
+ ) + "\n",
507
+ );
508
+ notes.push(
509
+ "cursor: replaced legacy skills with v1 method skills and canonical @latest registration",
510
+ );
511
+ return true;
512
+ }
513
+ if (host === "opencode" && paths.dev) {
514
+ const pin = `file://${paths.dev}/packages/workit-opencode/dist/plugin.js`;
515
+ const distDir = path.join(paths.dev, "packages/workit-opencode/dist");
516
+ mkdirSync(distDir, { recursive: true });
517
+ const dist = path.join(distDir, "plugin.js");
518
+ if (!existsSync(dist)) writeFileSync(dist, "export default {};\n");
519
+ const current = existsSync(paths.opencodeConfig)
520
+ ? JSON.parse(readFileSync(paths.opencodeConfig, "utf8"))
521
+ : {};
522
+ writeFileSync(
523
+ paths.opencodeConfig,
524
+ JSON.stringify(mergeOpenCodeConfig(current, pin).config, null, 2) + "\n",
525
+ );
526
+ notes.push("opencode: repinned to v1 dist entry");
527
+ return true;
528
+ }
529
+ if (host === "codex" && paths.dev) {
530
+ const pluginDir = path.join(paths.home, ".codex", "plugins", "workit");
531
+ mkdirSync(pluginDir, { recursive: true });
532
+ cpSync(
533
+ path.join(paths.dev, "packages/workit-codex/.codex-plugin"),
534
+ path.join(pluginDir, ".codex-plugin"),
535
+ {
536
+ recursive: true,
537
+ },
538
+ );
539
+ notes.push("codex: installed v1 plugin scaffold");
540
+ return true;
541
+ }
542
+ if (host === "pi" && paths.dev) {
543
+ const piConfig = path.join(paths.home, ".pi", "config.json");
544
+ mkdirSync(path.dirname(piConfig), { recursive: true });
545
+ const current = existsSync(piConfig) ? JSON.parse(readFileSync(piConfig, "utf8")) : {};
546
+ const next = {
547
+ ...current,
548
+ extensions: [`${path.join(paths.dev, "packages/workit-pi/dist/workit.js")}`],
549
+ skills: [`${path.join(paths.dev, "packages/workit-pi/skills")}`],
550
+ };
551
+ writeFileSync(piConfig, JSON.stringify(next, null, 2) + "\n");
552
+ notes.push("pi: registered v1 extension and skills");
553
+ return true;
554
+ }
555
+ return false;
556
+ };
557
+
558
+ export function applyCutover(
559
+ plan: CutoverPlan,
560
+ decision: CutoverDecision,
561
+ options: CutoverPaths = {},
562
+ ): Result<CutoverReceipt> {
563
+ if (!decision.approve) return failure("permission_denied", "cutover not approved");
564
+ if (plan.blocked.length > 0) {
565
+ return failure("requirements_unsatisfied", "cutover blocked", {
566
+ fields: plan.blocked.map((b) => ({ path: b, reason: "blocked" })),
567
+ });
568
+ }
569
+ for (const item of plan.unresolved) {
570
+ if (!decision.resolutions?.[item.key]) {
571
+ return failure("needs_input", `unresolved setting requires a decision: ${item.key}`);
572
+ }
573
+ }
574
+
575
+ const paths = resolvePaths(options);
576
+ for (const entry of plan.managedFiles) {
577
+ const current = digestFile(entry.path);
578
+ if (entry.beforeDigest !== null && current !== entry.beforeDigest) {
579
+ return failure("revision_conflict", `managed file changed since preview: ${entry.path}`, {
580
+ path: entry.path,
581
+ });
582
+ }
583
+ }
584
+ for (const session of paths.sessions) {
585
+ if (session.state === "active" || session.state === "unknown") {
586
+ return failure(
587
+ "requirements_unsatisfied",
588
+ `old session still ${session.state}: ${session.handle}`,
589
+ );
590
+ }
591
+ }
592
+ for (const host of decision.hosts) {
593
+ if (classifyHostGeneration(host, paths) === "mixed") {
594
+ return failure("requirements_unsatisfied", `mixed legacy and v1 components on ${host}`);
595
+ }
596
+ }
597
+
598
+ const backupId = randomUUID();
599
+ const backupTargets = cutoverBackupTargets(paths, decision.hosts);
600
+ const backupFiles = expandBackupTargets(backupTargets);
601
+ writeBackup(backupId, paths, backupFiles);
602
+
603
+ const conversionNotes: string[] = [];
604
+ if (plan.unresolved.length > 0 || plan.conversion.mappings.length > 0) {
605
+ const applied = applyConversionConfig(
606
+ paths.configDir,
607
+ plan.conversion,
608
+ decision.resolutions ?? {},
609
+ );
610
+ conversionNotes.push(
611
+ `config: wrote ${applied.configPath} and recorded choices at ${applied.choicesPath}`,
612
+ );
613
+ }
614
+
615
+ const notes: string[] = [...conversionNotes];
616
+ let partial = false;
617
+ const applied: CutoverHost[] = [];
618
+ for (const host of decision.hosts) {
619
+ const ok = applyHostCutover(host, paths, notes);
620
+ if (ok) applied.push(host);
621
+ else partial = true;
622
+ }
623
+
624
+ if (!partial) {
625
+ writeGenerationState(paths.configDir, {
626
+ target: "v1",
627
+ cutover: { backupId, planId: plan.id, at: new Date().toISOString() },
628
+ });
629
+ }
630
+
631
+ const managedFiles = expandBackupTargets(backupTargets)
632
+ .filter((f) => existsSync(f))
633
+ .map((f) => ({ path: f, installedDigest: digestFile(f)! }));
634
+
635
+ const receipt: CutoverReceipt = {
636
+ backupId,
637
+ planId: plan.id,
638
+ generation: partial ? "legacy" : "v1",
639
+ hosts: applied,
640
+ managedFiles,
641
+ partial,
642
+ notes,
643
+ };
644
+ writeReceipt(receipt, paths.stateDir);
645
+
646
+ return success(null, null, receipt);
647
+ }
648
+
649
+ export function previewRollback(backupId: Id, options: CutoverPaths = {}): RollbackPreview {
650
+ const paths = resolvePaths(options);
651
+ const root = backupRoot(paths.stateDir, backupId);
652
+ const receipt = readCutoverReceipt(backupId, paths.stateDir);
653
+ const restorable: RollbackPreview["restorable"] = [];
654
+ const conflicts: RollbackPreview["conflicts"] = [];
655
+ const preserved: string[] = [];
656
+
657
+ if (!existsSync(root) || !receipt) return { backupId, restorable, conflicts, preserved };
658
+
659
+ for (const entry of receipt.managedFiles) {
660
+ const current = digestFile(entry.path);
661
+ if (current === null) continue;
662
+ if (current === entry.installedDigest) {
663
+ restorable.push({
664
+ path: entry.path,
665
+ currentDigest: current,
666
+ installedDigest: entry.installedDigest,
667
+ });
668
+ } else {
669
+ conflicts.push({
670
+ path: entry.path,
671
+ currentDigest: current,
672
+ installedDigest: entry.installedDigest,
673
+ });
674
+ }
675
+ }
676
+
677
+ const v1Task = path.join(paths.workspace, ".workit");
678
+ if (existsSync(v1Task)) preserved.push(v1Task);
679
+
680
+ return { backupId, restorable, conflicts, preserved };
681
+ }
682
+
683
+ export function applyRollback(
684
+ backupId: Id,
685
+ options: CutoverPaths = {},
686
+ ): Result<{ restored: string[] }> {
687
+ const paths = resolvePaths(options);
688
+ const preview = previewRollback(backupId, options);
689
+ if (preview.conflicts.length > 0) {
690
+ return failure(
691
+ "revision_conflict",
692
+ "managed files changed after cutover; reconcile before rollback",
693
+ {
694
+ path: preview.conflicts[0]?.path,
695
+ },
696
+ );
697
+ }
698
+
699
+ const root = backupRoot(paths.stateDir, backupId);
700
+ if (!existsSync(root)) return failure("not_found", `backup not found: ${backupId}`);
701
+
702
+ const restored: string[] = [];
703
+ for (const entry of preview.restorable) {
704
+ const src = path.join(root, backupRelPath(entry.path, paths));
705
+ if (!existsSync(src)) continue;
706
+ mkdirSync(path.dirname(entry.path), { recursive: true });
707
+ cpSync(src, entry.path);
708
+ restored.push(entry.path);
709
+ }
710
+
711
+ writeGenerationState(paths.configDir, { target: "legacy" });
712
+ return success(null, null, { restored });
713
+ }
714
+
715
+ export const countLegacyFlowRecords = (workspace: string): number =>
716
+ legacyFlowFiles(workspace).length;
717
+
718
+ export const legacyFlowRecordDigests = (workspace: string): Digest[] =>
719
+ legacyFlowFiles(workspace).map((f) => digestFile(f)!);
720
+
721
+ export { previewConversion };