@brainervirus/workit-core 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +3 -7
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +19 -21
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +30 -16
  13. package/src/core/config.ts +193 -31
  14. package/src/core/docs-layout.ts +251 -0
  15. package/src/core/docs-migration.ts +639 -0
  16. package/src/core/docs-repo.ts +11 -9
  17. package/src/core/docs-validate.ts +18 -6
  18. package/src/core/doctor.ts +801 -0
  19. package/src/core/flow-state.ts +1579 -141
  20. package/src/core/git.ts +22 -5
  21. package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
  22. package/src/core/hygiene.ts +26 -12
  23. package/src/core/init.ts +43 -11
  24. package/src/core/logger.ts +321 -0
  25. package/src/core/package-root.ts +28 -0
  26. package/src/core/ports/init-toolkit-status.ts +1 -1
  27. package/src/core/ports/vcs-verify-token.ts +1 -1
  28. package/src/core/ports/youtrack-api.ts +1 -1
  29. package/src/core/ports/youtrack-verify-token.ts +1 -1
  30. package/src/core/pr-create.ts +116 -21
  31. package/src/core/registration.ts +215 -0
  32. package/src/core/repo-context.ts +447 -0
  33. package/src/core/repo-tools.ts +23 -0
  34. package/src/core/safe-write.ts +22 -0
  35. package/src/core/scripts.ts +3 -44
  36. package/src/core/sdd.ts +45 -28
  37. package/src/core/setup-state.ts +54 -0
  38. package/src/core/setup.ts +1216 -0
  39. package/src/core/skill-manifests.ts +95 -0
  40. package/src/core/support-matrix.ts +12 -0
  41. package/src/core/sync-runtime.ts +348 -0
  42. package/src/core/templates.ts +2 -2
  43. package/src/core/vcs-config.ts +107 -37
  44. package/src/core/verify-project.ts +181 -0
  45. package/src/core/workspaces.ts +136 -17
  46. package/src/core/youtrack-tools.ts +228 -0
  47. package/src/core/youtrack.ts +125 -67
  48. package/templates/execution-contract.md +9 -7
  49. package/templates/superpowers-doc-contract.md +4 -3
  50. package/scripts/_shared/common.sh +0 -173
  51. package/scripts/changelog-context.sh +0 -42
  52. package/scripts/docs-refresh-context.sh +0 -40
  53. package/scripts/init/apply.sh +0 -5
  54. package/scripts/init/status.sh +0 -5
  55. package/scripts/init/toolkit-status.sh +0 -5
  56. package/scripts/pr-create.sh +0 -5
  57. package/scripts/pr-ready-context.sh +0 -88
  58. package/scripts/present/ascii-wireframe.sh +0 -5
  59. package/scripts/present/flow-diagram.sh +0 -5
  60. package/scripts/release-notes-context.sh +0 -40
  61. package/scripts/vcs/config.sh +0 -5
  62. package/scripts/vcs/merged-style.sh +0 -5
  63. package/scripts/vcs/token-create-urls.sh +0 -5
  64. package/scripts/vcs/verify-token.sh +0 -5
  65. package/scripts/verify-project.sh +0 -140
  66. package/scripts/youtrack/api.sh +0 -5
  67. package/scripts/youtrack/config.sh +0 -5
  68. package/scripts/youtrack/greeting.sh +0 -5
  69. package/scripts/youtrack/parse-duration.sh +0 -5
  70. package/scripts/youtrack/token-create-url.sh +0 -5
  71. package/scripts/youtrack/verify-token.sh +0 -5
  72. package/scripts/youtrack/work-date-ms.sh +0 -5
  73. package/src/tools/docs-repo.ts +0 -51
  74. package/src/tools/flow.ts +0 -99
  75. package/src/tools/index.ts +0 -22
  76. package/src/tools/present.ts +0 -49
  77. package/src/tools/repo.ts +0 -490
  78. package/src/tools/rules.ts +0 -30
  79. package/src/tools/sdd.ts +0 -216
  80. package/src/tools/templates.ts +0 -27
  81. package/src/tools/youtrack.ts +0 -423
@@ -0,0 +1,1216 @@
1
+ import {
2
+ chmodSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { isDeepStrictEqual } from "node:util";
17
+ import {
18
+ configDir,
19
+ mergePreset,
20
+ readConfigFromDir,
21
+ resolveConfigDir,
22
+ type BranchPreset,
23
+ } from "./config";
24
+ import { detectBranchPolicy } from "./branch-policy";
25
+ import { readSetupState, type SetupState } from "./setup-state";
26
+ import {
27
+ loadWorkspacesFrom,
28
+ matchWorkspace,
29
+ readWorkspacesResult,
30
+ validateWorkspaceGlob,
31
+ type WorkspaceConfig,
32
+ } from "./workspaces";
33
+ import { GITIGNORE_ENTRIES } from "./gitignore";
34
+ import { planHygieneFiles } from "./hygiene";
35
+ import { packageRoot } from "./package-root";
36
+ import {
37
+ cursorMcpServerEntry,
38
+ mergeCursorMcp,
39
+ mergeCursorSettings,
40
+ mergeOpenCodeConfig,
41
+ } from "./registration";
42
+ import { runDoctor, type DoctorReport } from "./doctor";
43
+ import { writeFileExclusive } from "./safe-write";
44
+
45
+ // Setup preview + apply (WZ-04-WZ-09, WZ-13-WZ-15; CA-12, CA-14, CA-22, CA-23).
46
+ // buildSetupPreview is a pure reader: it classifies the current setup state and
47
+ // returns the exact mutations Apply would perform WITHOUT applying them, so the
48
+ // preview is authoritative and nothing touches the filesystem before Apply.
49
+ // applySetupPreview applies ONLY the reviewed mutations using package-native
50
+ // registration/assets, reports every platform/file independently, preserves
51
+ // unrelated user config byte-for-byte, and verifies the result with the shared
52
+ // offline doctor. Integrations are neutral and optional: youtrack is skipped
53
+ // when baseUrl is empty, vcs when the provider is "skip".
54
+
55
+ export const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
56
+
57
+ export type VcsProvider = "gitlab" | "github";
58
+
59
+ export function parseList(raw: string): string[] {
60
+ return raw
61
+ .split(",")
62
+ .map((s) => s.trim())
63
+ .filter(Boolean);
64
+ }
65
+
66
+ export type SetupPreviewInput = {
67
+ platforms?: string[];
68
+ locale: string;
69
+ timezone: string;
70
+ branchPreset: BranchPreset;
71
+ branchAllowed: string;
72
+ branchProtected: string;
73
+ baseUrl: string;
74
+ vcsProvider: VcsProvider | "skip";
75
+ workspaces: WorkspaceConfig[];
76
+ applyProject: boolean;
77
+ /** Deliberate token-path overrides. When they differ from an existing
78
+ * configured tokenFile, the replacement is planned as its own distinct
79
+ * reviewed mutation (AR-10). */
80
+ tokenPaths?: { youtrack?: string; gitlab?: string; github?: string };
81
+ };
82
+
83
+ export type SetupMutation =
84
+ | { type: "create-file"; path: string; content: string; mode?: number }
85
+ | { type: "merge-json"; path: string; value: unknown }
86
+ | { type: "update-workspaces"; path: string; entries: WorkspaceConfig[] }
87
+ | { type: "append-gitignore"; path: string; entries: string[] }
88
+ // AR-09: host registration merges and the adapter package copy are planned
89
+ // during preview so the reviewed mutation set IS the Apply write set.
90
+ | { type: "register-platform"; platform: Platform; path: string }
91
+ | { type: "install-adapter"; platform: "cursor"; path: string }
92
+ // AR-10: changing a configured tokenFile path is never hidden inside a
93
+ // generic merge — it is its own reviewed mutation.
94
+ | { type: "set-token-path"; path: string; key: string; value: string };
95
+
96
+ export type SetupOverride = {
97
+ envKey: string;
98
+ affects: string;
99
+ value: string;
100
+ note: string;
101
+ };
102
+
103
+ export type SetupPreview = {
104
+ ok: boolean;
105
+ /** Path-specific blocking diagnostics, set when any setup file is malformed. */
106
+ blocked: string[];
107
+ mutations: SetupMutation[];
108
+ /** Environment overrides active for the shell init/apply path (RL-06). */
109
+ overrides: SetupOverride[];
110
+ /** Credential files left byte-for-byte untouched. */
111
+ preserved: string[];
112
+ /** Selected platform adapters to register on Apply (WZ-09). */
113
+ platforms: string[];
114
+ state: SetupState;
115
+ };
116
+
117
+ const YT_OVERRIDES: { envKey: string; affects: string }[] = [
118
+ { envKey: "WORKFLOW_YT_BASE_URL", affects: "youtrack.json baseUrl" },
119
+ { envKey: "WORKFLOW_YT_TOKEN_FILE", affects: "youtrack.json tokenFile" },
120
+ { envKey: "WORKFLOW_YT_TIMEZONE", affects: "youtrack.json timezone" },
121
+ { envKey: "WORKFLOW_YT_MENTION", affects: "youtrack.json defaultMention" },
122
+ { envKey: "WORKFLOW_YT_MEETING_ISSUE", affects: "youtrack.json meetingIssue" },
123
+ { envKey: "WORKFLOW_YT_WEB_MEETING_ISSUE", affects: "youtrack.json web meeting issue" },
124
+ ];
125
+
126
+ const VCS_OVERRIDES: { envKey: string; affects: string }[] = [
127
+ { envKey: "WORKFLOW_VCS_PROVIDER", affects: "vcs.json provider" },
128
+ { envKey: "WORKFLOW_VCS_TARGET_BRANCH", affects: "vcs.json defaultTargetBranch" },
129
+ { envKey: "WORKFLOW_GITLAB_HOST", affects: "vcs.json gitlab.host" },
130
+ { envKey: "WORKFLOW_GITLAB_API_URL", affects: "vcs.json gitlab.apiUrl" },
131
+ { envKey: "WORKFLOW_GITHUB_HOST", affects: "vcs.json github.host" },
132
+ ];
133
+
134
+ const SETUP_OVERRIDES = [...YT_OVERRIDES, ...VCS_OVERRIDES];
135
+
136
+ export const activeSetupOverrides = (env: NodeJS.ProcessEnv = process.env): SetupOverride[] => {
137
+ const overrides: SetupOverride[] = [];
138
+ for (const { envKey, affects } of SETUP_OVERRIDES) {
139
+ const value = env[envKey];
140
+ if (value === undefined || value === "") continue;
141
+ overrides.push({
142
+ envKey,
143
+ affects,
144
+ value,
145
+ note: "set in the environment; the interactive wizard does not apply it, but the shell init/apply path would use it",
146
+ });
147
+ }
148
+ return overrides;
149
+ };
150
+
151
+ // Neutral youtrack.json draft: no organization names, issue IDs, greetings, or
152
+ // language defaults (WZ-04/CA-14). merge-json preserves unrelated keys on Apply.
153
+ // The tokenFile is the RESOLVED path (existing configured path reused, override
154
+ // honored) — a default path never silently replaces a custom one (AR-10).
155
+ function youtrackDraft(values: SetupPreviewInput, tokenPath: string): Record<string, unknown> {
156
+ return {
157
+ baseUrl: values.baseUrl.replace(/\/+$/, ""),
158
+ tokenFile: tokenPath,
159
+ timezone: values.timezone,
160
+ locale: values.locale,
161
+ tokenDefaults: {
162
+ name: "workit",
163
+ description: "OpenCode workit — /wk-issue-update and /wk-meetings",
164
+ scopes: ["YouTrack"],
165
+ profileTab: "account-security",
166
+ },
167
+ };
168
+ }
169
+
170
+ function vcsDraft(
171
+ provider: VcsProvider,
172
+ gitlabToken: string,
173
+ githubToken: string,
174
+ ): Record<string, unknown> {
175
+ return {
176
+ provider,
177
+ gitlab: {
178
+ host: "gitlab.com",
179
+ apiUrl: "https://gitlab.com/api/v4",
180
+ tokenFile: gitlabToken,
181
+ },
182
+ github: { host: "github.com", tokenFile: githubToken },
183
+ pr: { squashOnMerge: true, removeSourceBranch: true, pushBranch: true, confirmSkip: true },
184
+ tokenDefaults: {
185
+ name: "workit",
186
+ description: "OpenCode workit — /wk-pr and glab/gh",
187
+ gitlabScopes: ["api"],
188
+ githubPermissions: { pull_requests: "write", contents: "write", metadata: "read" },
189
+ githubClassicScopes: ["repo"],
190
+ },
191
+ };
192
+ }
193
+
194
+ // Resolve the token file for a service: an explicit override wins, then the
195
+ // configured tokenFile from an existing valid config, then the default path.
196
+ // Reuse means a custom credential location stays authoritative (AR-10).
197
+ const configuredTokenPath = (
198
+ config: Record<string, unknown> | null,
199
+ ...keys: string[]
200
+ ): string | null => {
201
+ let cursor: unknown = config;
202
+ for (const key of keys) {
203
+ if (!isRecord(cursor)) return null;
204
+ cursor = cursor[key];
205
+ }
206
+ if (typeof cursor === "string" && cursor.trim() !== "") return cursor;
207
+ return null;
208
+ };
209
+
210
+ const resolveTokenPath = (
211
+ configured: string | null,
212
+ fallback: string,
213
+ override?: string,
214
+ ): string => {
215
+ if (override && override.trim() !== "") return override;
216
+ return configured ?? fallback;
217
+ };
218
+
219
+ // Read a config file that readSetupState already classified as valid. Only
220
+ // called inside the blocked.length === 0 branch, so unreadable/malformed files
221
+ // never reach here; a missing file returns null.
222
+ const readConfigRecord = (file: string): Record<string, unknown> | null => {
223
+ try {
224
+ const value = JSON.parse(readFileSync(file, "utf8"));
225
+ return isRecord(value) ? value : null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ };
230
+
231
+ const deleteAtPath = (obj: Record<string, unknown>, keyPath: string): void => {
232
+ const keys = keyPath.split(".");
233
+ let cursor = obj;
234
+ for (const key of keys.slice(0, -1)) {
235
+ const next = cursor[key];
236
+ if (!isRecord(next)) return;
237
+ cursor = next;
238
+ }
239
+ delete cursor[keys[keys.length - 1]];
240
+ };
241
+
242
+ export function buildSetupPreview(
243
+ values: SetupPreviewInput,
244
+ opts: SetupPreviewOptions = {},
245
+ ): SetupPreview {
246
+ const env = opts.env ?? process.env;
247
+ const state = readSetupState(opts.dir ?? resolveConfigDir());
248
+ const blocked: string[] = [];
249
+ const mutations: SetupMutation[] = [];
250
+ const preserved: string[] = [];
251
+ const overrides = activeSetupOverrides(env);
252
+
253
+ for (const entry of [state.config, state.youtrack, state.vcs, state.workspaces]) {
254
+ if (entry.status === "malformed") blocked.push(entry.error ?? entry.file);
255
+ }
256
+
257
+ if (blocked.length === 0) {
258
+ const current = readConfigFromDir(state.configDir);
259
+ mutations.push({
260
+ type: "merge-json",
261
+ path: path.join(state.configDir, "config.json"),
262
+ value: {
263
+ locale: values.locale,
264
+ localeOptions: current.localeOptions,
265
+ timezone: values.timezone,
266
+ branchPolicy: mergePreset(
267
+ values.branchPreset,
268
+ {
269
+ allowed: values.branchPreset === "custom" ? parseList(values.branchAllowed) : undefined,
270
+ protectedNames:
271
+ values.branchPreset === "custom" ? parseList(values.branchProtected) : undefined,
272
+ },
273
+ current,
274
+ ),
275
+ },
276
+ });
277
+
278
+ // WZ-12 parity: the draft is authoritative but a no-op workspaces section
279
+ // (unchanged vs disk, including the initial seed) must not claim a rewrite.
280
+ // Removing every workspace differs from disk and therefore still writes [].
281
+ const diskWorkspaces = loadWorkspacesFrom(state.configDir);
282
+ if (!isDeepStrictEqual(values.workspaces, diskWorkspaces)) {
283
+ // RL-08: unsupported matcher grammar is rejected before any write —
284
+ // same gate the direct writeWorkspaces path and the wizard enforce.
285
+ for (const entry of values.workspaces) {
286
+ if (!entry || typeof entry.glob !== "string") continue;
287
+ const v = validateWorkspaceGlob(entry.glob);
288
+ if (!v.ok) {
289
+ blocked.push(v.error);
290
+ break;
291
+ }
292
+ }
293
+ if (blocked.length === 0) {
294
+ mutations.push({
295
+ type: "update-workspaces",
296
+ path: path.join(state.configDir, "workspaces.json"),
297
+ entries: values.workspaces,
298
+ });
299
+ }
300
+ }
301
+
302
+ if (values.baseUrl.trim()) {
303
+ const ytPath = path.join(state.configDir, "youtrack.json");
304
+ const ytExisting = readConfigRecord(ytPath);
305
+ const ytConfigured = configuredTokenPath(ytExisting, "tokenFile");
306
+ const tokenPath = resolveTokenPath(
307
+ ytConfigured,
308
+ path.join(state.configDir, "youtrack.token"),
309
+ values.tokenPaths?.youtrack,
310
+ );
311
+ const draft = youtrackDraft(values, tokenPath);
312
+ if (ytConfigured !== null && tokenPath !== ytConfigured) {
313
+ // AR-10: a real tokenFile replacement is its own reviewed mutation —
314
+ // never hidden inside the generic config merge.
315
+ delete draft.tokenFile;
316
+ mutations.push({
317
+ type: "set-token-path",
318
+ path: ytPath,
319
+ key: "tokenFile",
320
+ value: tokenPath,
321
+ });
322
+ }
323
+ mutations.push({
324
+ type: "merge-json",
325
+ path: ytPath,
326
+ value: draft,
327
+ });
328
+ if (existsSync(tokenPath)) preserved.push(tokenPath);
329
+ else
330
+ mutations.push({
331
+ type: "create-file",
332
+ path: tokenPath,
333
+ content: TOKEN_PLACEHOLDER + "\n",
334
+ mode: 0o600,
335
+ });
336
+ }
337
+
338
+ if (values.vcsProvider !== "skip") {
339
+ const vcsPath = path.join(state.configDir, "vcs.json");
340
+ const vcsExisting = readConfigRecord(vcsPath);
341
+ const glConfigured = configuredTokenPath(vcsExisting, "gitlab", "tokenFile");
342
+ const ghConfigured = configuredTokenPath(vcsExisting, "github", "tokenFile");
343
+ const gitlabToken = resolveTokenPath(
344
+ glConfigured,
345
+ path.join(state.configDir, "gitlab.token"),
346
+ values.tokenPaths?.gitlab,
347
+ );
348
+ const githubToken = resolveTokenPath(
349
+ ghConfigured,
350
+ path.join(state.configDir, "github.token"),
351
+ values.tokenPaths?.github,
352
+ );
353
+ const draft = vcsDraft(values.vcsProvider, gitlabToken, githubToken);
354
+ for (const [configured, token, key] of [
355
+ [glConfigured, gitlabToken, "gitlab.tokenFile"],
356
+ [ghConfigured, githubToken, "github.tokenFile"],
357
+ ] as const) {
358
+ if (configured !== null && token !== configured) {
359
+ deleteAtPath(draft, key);
360
+ mutations.push({
361
+ type: "set-token-path",
362
+ path: vcsPath,
363
+ key,
364
+ value: token,
365
+ });
366
+ }
367
+ }
368
+ mutations.push({
369
+ type: "merge-json",
370
+ path: vcsPath,
371
+ value: draft,
372
+ });
373
+ const activeToken = values.vcsProvider === "gitlab" ? gitlabToken : githubToken;
374
+ if (existsSync(activeToken)) preserved.push(activeToken);
375
+ else
376
+ mutations.push({
377
+ type: "create-file",
378
+ path: activeToken,
379
+ content: TOKEN_PLACEHOLDER + "\n",
380
+ mode: 0o600,
381
+ });
382
+ }
383
+
384
+ // AR-09: host registrations and the adapter package copy are planned here
385
+ // with the exact target paths Apply will write, so the reviewed mutation
386
+ // set IS the Apply write set. Apply re-resolves the adapter SOURCE with
387
+ // its own options (dev/cwd) — a resolution failure reports Failed without
388
+ // writing, it never creates an unreviewed write.
389
+ const platforms = values.platforms ?? [];
390
+ if (platforms.length > 0) {
391
+ const paths = resolveSetupPaths(opts, state.configDir);
392
+ for (const platform of platforms) {
393
+ if (platform === "opencode") {
394
+ mutations.push({ type: "register-platform", platform, path: paths.opencodeConfig });
395
+ } else if (platform === "cursor") {
396
+ mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
397
+ mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
398
+ mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
399
+ }
400
+ }
401
+ }
402
+
403
+ if (values.applyProject) {
404
+ const root = path.resolve(opts.cwd ?? process.cwd());
405
+ const gitignorePath = path.join(root, ".gitignore");
406
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
407
+ const existingLines = new Set(
408
+ existing
409
+ .split("\n")
410
+ .map((l) => l.trim())
411
+ .filter(Boolean),
412
+ );
413
+ const entries = GITIGNORE_ENTRIES.filter(
414
+ (e) => e.trim() !== "" && !existingLines.has(e.trim()),
415
+ );
416
+ mutations.push({ type: "append-gitignore", path: gitignorePath, entries });
417
+ for (const planned of planHygieneFiles(root)) {
418
+ mutations.push({ type: "create-file", path: planned.path, content: planned.content });
419
+ }
420
+ }
421
+ }
422
+
423
+ return {
424
+ ok: blocked.length === 0,
425
+ blocked,
426
+ mutations,
427
+ overrides,
428
+ preserved,
429
+ platforms: values.platforms ?? [],
430
+ state,
431
+ };
432
+ }
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // Apply (Task 14: WZ-09, WZ-10, WZ-13-WZ-15; CA-08, CA-13, CA-14, CA-31).
436
+ // ---------------------------------------------------------------------------
437
+
438
+ export type Platform = "opencode" | "cursor";
439
+
440
+ export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
441
+
442
+ export type SetupResultEntry = {
443
+ platform: Platform | "core";
444
+ file: string;
445
+ status: SetupResultStatus;
446
+ detail?: string;
447
+ };
448
+
449
+ export type SetupResult = {
450
+ ok: boolean;
451
+ exitCode: number;
452
+ entries: SetupResultEntry[];
453
+ /** Credential files left byte-for-byte untouched. */
454
+ preserved: string[];
455
+ /** Path-specific blocking diagnostics when the preview was malformed. */
456
+ blocked: string[];
457
+ /** Post-apply doctor reports, one per verified platform host. */
458
+ doctor: DoctorReport[];
459
+ };
460
+
461
+ export type SetupPreviewOptions = ApplySetupOptions & { dir?: string };
462
+
463
+ export type ApplySetupOptions = {
464
+ home?: string;
465
+ configDir?: string;
466
+ dev?: string;
467
+ cwd?: string;
468
+ env?: NodeJS.ProcessEnv;
469
+ opencodeConfig?: string;
470
+ cursorSettings?: string;
471
+ cursorMcp?: string;
472
+ cursorPluginDir?: string;
473
+ stateDir?: string;
474
+ };
475
+
476
+ type ResolvedApply = {
477
+ home: string;
478
+ configDir: string;
479
+ dev: string | null;
480
+ cwd: string;
481
+ env: NodeJS.ProcessEnv;
482
+ opencodeConfig: string;
483
+ cursorSettings: string;
484
+ cursorMcp: string;
485
+ cursorPluginDir: string;
486
+ };
487
+
488
+ // Shared path resolution for preview AND apply: the preview plans the platform
489
+ // write targets with the same resolution apply uses, so the mutation paths ARE
490
+ // the apply write paths (AR-09). Callers must build the preview and apply with
491
+ // the same options (defaulting both from env) — tests pass `home` explicitly.
492
+ const resolveSetupPaths = (
493
+ options: ApplySetupOptions,
494
+ configDir: string,
495
+ ): Omit<ResolvedApply, "dev"> => {
496
+ const env = options.env ?? process.env;
497
+ const home = options.home ?? env.HOME ?? os.homedir();
498
+ return {
499
+ home,
500
+ configDir,
501
+ cwd: options.cwd ?? process.cwd(),
502
+ env,
503
+ opencodeConfig:
504
+ options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
505
+ cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
506
+ cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
507
+ cursorPluginDir:
508
+ options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workflow-toolkit"),
509
+ };
510
+ };
511
+
512
+ function resolveApply(preview: SetupPreview, options: ApplySetupOptions): ResolvedApply {
513
+ const paths = resolveSetupPaths(options, options.configDir ?? preview.state.configDir);
514
+ return {
515
+ ...paths,
516
+ dev: options.dev ?? paths.env.WORKFLOW_TOOLKIT_DEV ?? null,
517
+ };
518
+ }
519
+
520
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
521
+ v !== null && typeof v === "object" && !Array.isArray(v);
522
+
523
+ type Existing =
524
+ | { kind: "missing" }
525
+ | { kind: "malformed"; error: string }
526
+ | { kind: "record"; value: Record<string, unknown>; raw: string };
527
+
528
+ // Platform config (opencode.json/settings.json/mcp.json) is NOT covered by the
529
+ // WZ-06 preview scan (which gates only the workit config dir), so a malformed
530
+ // file here must not be silently replaced with a fresh config (CA-14). Callers
531
+ // turn { kind: "malformed" } into a Failed entry and leave the file untouched.
532
+ const readExisting = (p: string): Existing => {
533
+ let raw: string;
534
+ try {
535
+ raw = readFileSync(p, "utf8");
536
+ } catch {
537
+ // A read-permission error (EACCES) must not look like a missing file: a
538
+ // write attempt would throw after partially touching the filesystem.
539
+ // Classify it malformed so callers report Failed with the path untouched.
540
+ if (existsSync(p)) return { kind: "malformed", error: `${p} is not readable` };
541
+ return { kind: "missing" };
542
+ }
543
+ let value: unknown;
544
+ try {
545
+ value = JSON.parse(raw);
546
+ } catch {
547
+ return { kind: "malformed", error: `${p} is not valid JSON` };
548
+ }
549
+ if (isRecord(value)) return { kind: "record", value, raw };
550
+ return { kind: "malformed", error: `${p} is not a JSON object` };
551
+ };
552
+
553
+ // Deep-merge preserves every unrelated key/value; arrays and scalars are
554
+ // replaced wholesale by the patch (the review is authoritative on its fields).
555
+ function deepMerge(
556
+ base: Record<string, unknown>,
557
+ patch: Record<string, unknown>,
558
+ ): Record<string, unknown> {
559
+ const out: Record<string, unknown> = { ...base };
560
+ for (const [key, value] of Object.entries(patch)) {
561
+ const prev = out[key];
562
+ out[key] = isRecord(prev) && isRecord(value) ? deepMerge(prev, value) : value;
563
+ }
564
+ return out;
565
+ }
566
+
567
+ const readFileSafe = (p: string): string | null => {
568
+ try {
569
+ return readFileSync(p, "utf8");
570
+ } catch {
571
+ return null;
572
+ }
573
+ };
574
+
575
+ // Core mutations are dispatched by applyMutation; platform mutations
576
+ // (register-platform / install-adapter) are handled by the dispatcher with
577
+ // adapter resolution (AR-09).
578
+ type CoreMutation = Exclude<
579
+ SetupMutation,
580
+ { type: "register-platform" } | { type: "install-adapter" }
581
+ >;
582
+
583
+ function applyMutation(m: CoreMutation): SetupResultEntry {
584
+ const dir = path.dirname(m.path);
585
+ switch (m.type) {
586
+ case "create-file": {
587
+ mkdirSync(dir, { recursive: true });
588
+ const res = writeFileExclusive(m.path, m.content, m.mode);
589
+ return res === "created"
590
+ ? { platform: "core", file: m.path, status: "Installed" }
591
+ : {
592
+ platform: "core",
593
+ file: m.path,
594
+ status: "Skipped",
595
+ detail: "file already exists — preserved",
596
+ };
597
+ }
598
+ case "merge-json": {
599
+ const existing = readExisting(m.path);
600
+ if (existing.kind === "malformed") {
601
+ return {
602
+ platform: "core",
603
+ file: m.path,
604
+ status: "Failed",
605
+ detail: `cannot merge into malformed config: ${existing.error}`,
606
+ };
607
+ }
608
+ const merged =
609
+ existing.kind === "record"
610
+ ? deepMerge(existing.value, m.value as Record<string, unknown>)
611
+ : (m.value as Record<string, unknown>);
612
+ if (existing.kind === "record" && JSON.stringify(existing.value) === JSON.stringify(merged)) {
613
+ return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
614
+ }
615
+ mkdirSync(dir, { recursive: true });
616
+ writeFileSync(m.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
617
+ return existing.kind === "record"
618
+ ? { platform: "core", file: m.path, status: "Configured" }
619
+ : { platform: "core", file: m.path, status: "Installed" };
620
+ }
621
+ case "update-workspaces": {
622
+ // RL-08: a mutation built by a non-wizard caller still cannot write an
623
+ // unsupported matcher pattern — reported Failed, never silently stored.
624
+ for (const entry of m.entries) {
625
+ if (!entry || typeof entry.glob !== "string") continue;
626
+ const v = validateWorkspaceGlob(entry.glob);
627
+ if (!v.ok) return { platform: "core", file: m.path, status: "Failed", detail: v.error };
628
+ }
629
+ const next = JSON.stringify({ workspaces: m.entries }, null, 2) + "\n";
630
+ const prev = readFileSafe(m.path);
631
+ if (prev === next)
632
+ return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
633
+ mkdirSync(dir, { recursive: true });
634
+ writeFileSync(m.path, next, "utf8");
635
+ return prev === null
636
+ ? { platform: "core", file: m.path, status: "Installed" }
637
+ : { platform: "core", file: m.path, status: "Configured" };
638
+ }
639
+ case "append-gitignore": {
640
+ const existing = readFileSafe(m.path) ?? "";
641
+ const existingLines = new Set(
642
+ existing
643
+ .split("\n")
644
+ .map((l) => l.trim())
645
+ .filter(Boolean),
646
+ );
647
+ const add = m.entries.filter((e) => e.trim() !== "" && !existingLines.has(e.trim()));
648
+ if (add.length === 0) {
649
+ return {
650
+ platform: "core",
651
+ file: m.path,
652
+ status: "Skipped",
653
+ detail: "all entries already present",
654
+ };
655
+ }
656
+ mkdirSync(dir, { recursive: true });
657
+ const separator = existing && !existing.endsWith("\n") ? "\n" : "";
658
+ writeFileSync(
659
+ m.path,
660
+ existing + separator + (existing ? "\n" : "") + add.join("\n") + "\n",
661
+ "utf8",
662
+ );
663
+ return {
664
+ platform: "core",
665
+ file: m.path,
666
+ status: "Configured",
667
+ detail: `appended ${add.length} entr${add.length === 1 ? "y" : "ies"}`,
668
+ };
669
+ }
670
+ case "set-token-path": {
671
+ // AR-10: a reviewed tokenFile replacement. Writes ONLY the config key at
672
+ // the key path — the token file itself is a separate create-file or
673
+ // preserved entry, and any file at the old path is never touched.
674
+ const existing = readExisting(m.path);
675
+ if (existing.kind === "malformed") {
676
+ return {
677
+ platform: "core",
678
+ file: m.path,
679
+ status: "Failed",
680
+ detail: `cannot merge into malformed config: ${existing.error}`,
681
+ };
682
+ }
683
+ const keys = m.key.split(".");
684
+ const patch: Record<string, unknown> = {};
685
+ let cursor = patch;
686
+ for (const key of keys.slice(0, -1)) {
687
+ const next: Record<string, unknown> = {};
688
+ cursor[key] = next;
689
+ cursor = next;
690
+ }
691
+ cursor[keys[keys.length - 1]] = m.value;
692
+ const merged = deepMerge(existing.kind === "record" ? existing.value : {}, patch);
693
+ if (existing.kind === "record" && JSON.stringify(existing.value) === JSON.stringify(merged)) {
694
+ return {
695
+ platform: "core",
696
+ file: m.path,
697
+ status: "Skipped",
698
+ detail: "already configured",
699
+ };
700
+ }
701
+ mkdirSync(path.dirname(m.path), { recursive: true });
702
+ writeFileSync(m.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
703
+ return existing.kind === "record"
704
+ ? { platform: "core", file: m.path, status: "Configured" }
705
+ : { platform: "core", file: m.path, status: "Installed" };
706
+ }
707
+ }
708
+ }
709
+
710
+ // Package-native adapter resolution: the packaged CLI/core resolve the adapter
711
+ // packages from the same install (node_modules sibling), a dev checkout
712
+ // (WORKFLOW_TOOLKIT_DEV / packages/workit-<host> walking up from cwd), or the
713
+ // share clone (~/.local/share/workflow-toolkit) — mirroring the install scripts.
714
+ const isAdapter = (root: string, platform: Platform): boolean => {
715
+ try {
716
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as {
717
+ name?: string;
718
+ };
719
+ return pkg.name === `@brainervirus/workit-${platform}`;
720
+ } catch {
721
+ return false;
722
+ }
723
+ };
724
+
725
+ function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
726
+ const candidates: string[] = [];
727
+ if (res.dev) {
728
+ // A dev checkout is the complete source of truth for adapter packages: when
729
+ // WORKFLOW_TOOLKIT_DEV is set, a package missing from it is a real failure,
730
+ // not a reason to fall through to the running process's own install.
731
+ candidates.push(path.join(res.dev, "packages", `workit-${platform}`));
732
+ } else {
733
+ // node_modules sibling (packed install) or monorepo sibling (source)
734
+ candidates.push(path.join(packageRoot(), "..", `workit-${platform}`));
735
+ // dev checkout walking up from cwd
736
+ let dir = path.resolve(res.cwd);
737
+ while (true) {
738
+ candidates.push(path.join(dir, "packages", `workit-${platform}`));
739
+ const parent = path.dirname(dir);
740
+ if (parent === dir) break;
741
+ dir = parent;
742
+ }
743
+ }
744
+ candidates.push(
745
+ path.join(res.home, ".local", "share", "workflow-toolkit", "packages", `workit-${platform}`),
746
+ );
747
+ for (const candidate of candidates) {
748
+ if (isAdapter(candidate, platform)) return candidate;
749
+ }
750
+ return null;
751
+ }
752
+
753
+ const opencodePin = (root: string): string | null => {
754
+ for (const rel of ["src/plugin.ts", "dist/plugin.js"]) {
755
+ const entry = path.join(root, rel);
756
+ if (existsSync(entry)) return `file://${entry}`;
757
+ }
758
+ return null;
759
+ };
760
+
761
+ function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
762
+ const pin = opencodePin(root);
763
+ if (!pin) {
764
+ return {
765
+ platform: "opencode",
766
+ file: res.opencodeConfig,
767
+ status: "Failed",
768
+ detail:
769
+ "workit-opencode package has no loadable plugin entry (src/plugin.ts or dist/plugin.js)",
770
+ };
771
+ }
772
+ mkdirSync(path.dirname(res.opencodeConfig), { recursive: true });
773
+ const existing = readExisting(res.opencodeConfig);
774
+ if (existing.kind === "malformed") {
775
+ return {
776
+ platform: "opencode",
777
+ file: res.opencodeConfig,
778
+ status: "Failed",
779
+ detail: `cannot merge into malformed config: ${existing.error} — repair or remove the file`,
780
+ };
781
+ }
782
+ const merged = mergeOpenCodeConfig(existing.kind === "record" ? existing.value : {}, pin);
783
+ if (merged.changed.length === 0) {
784
+ return {
785
+ platform: "opencode",
786
+ file: res.opencodeConfig,
787
+ status: "Skipped",
788
+ detail: "already registered",
789
+ };
790
+ }
791
+ writeFileSync(res.opencodeConfig, JSON.stringify(merged.config, null, 2) + "\n", "utf8");
792
+ return existing.kind === "record"
793
+ ? {
794
+ platform: "opencode",
795
+ file: res.opencodeConfig,
796
+ status: "Configured",
797
+ detail: `pinned ${pin}`,
798
+ }
799
+ : {
800
+ platform: "opencode",
801
+ file: res.opencodeConfig,
802
+ status: "Installed",
803
+ detail: `pinned ${pin}`,
804
+ };
805
+ }
806
+
807
+ // Mirror the sync-runtime plugin mirror: copy the package (minus node_modules),
808
+ // keep launchers executable, and write the same `.workflow-toolkit-root` marker
809
+ // so a re-sync from the same source is a truthful Skipped.
810
+ //
811
+ // The installed plugin's mcp.json is a derived artifact: the shipped manifest
812
+ // stays package-relative (PT-10), but Cursor spawns plugin MCP servers with
813
+ // the workspace as cwd, so the installed copy must carry an absolute entry.
814
+ const cursorMcpManifest = (dir: string): string =>
815
+ JSON.stringify({ mcpServers: { workit: cursorMcpServerEntry(dir) } }, null, 2) + "\n";
816
+
817
+ const samePluginContent = (src: string, dest: string, relative = ""): boolean => {
818
+ try {
819
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
820
+ if (entry.name === "node_modules") continue;
821
+ // mcp.json is derived at install time (cursorMcpManifest), not copied.
822
+ if (relative === "" && entry.name === "mcp.json") continue;
823
+ const source = path.join(src, entry.name);
824
+ const installed = path.join(dest, entry.name);
825
+ if (entry.isDirectory()) {
826
+ if (
827
+ !statSync(installed).isDirectory() ||
828
+ !samePluginContent(source, installed, path.join(relative, entry.name))
829
+ )
830
+ return false;
831
+ } else if (!readFileSync(source).equals(readFileSync(installed))) {
832
+ return false;
833
+ }
834
+ }
835
+ for (const entry of readdirSync(dest, { withFileTypes: true })) {
836
+ if (existsSync(path.join(src, entry.name))) continue;
837
+ const rel = path.join(relative, entry.name);
838
+ if (rel === ".workflow-toolkit-root") continue;
839
+ if (relative === "rules" && entry.isFile() && entry.name.endsWith(".mdc")) continue;
840
+ if (relative === "" && entry.name === "mcp.json") continue;
841
+ return false;
842
+ }
843
+ return true;
844
+ } catch {
845
+ return false;
846
+ }
847
+ };
848
+
849
+ const preservedCursorRules = (src: string, dest: string): Map<string, Buffer> => {
850
+ const preserved = new Map<string, Buffer>();
851
+ const sourceRules = path.join(src, "rules");
852
+ const installedRules = path.join(dest, "rules");
853
+ try {
854
+ for (const entry of readdirSync(installedRules, { withFileTypes: true })) {
855
+ if (!entry.isFile() || !entry.name.endsWith(".mdc")) continue;
856
+ if (existsSync(path.join(sourceRules, entry.name))) continue;
857
+ preserved.set(entry.name, readFileSync(path.join(installedRules, entry.name)));
858
+ }
859
+ } catch {
860
+ /* no compiled user rules */
861
+ }
862
+ return preserved;
863
+ };
864
+
865
+ function copyPluginDir(src: string, dest: string): SetupResultStatus {
866
+ const marker = path.join(dest, ".workflow-toolkit-root");
867
+ const synced =
868
+ readFileSafe(marker)?.trim() === src &&
869
+ samePluginContent(src, dest) &&
870
+ // The mcp.json equality makes the Skipped verdict truthful: the derived
871
+ // manifest on disk must be the one this install would write.
872
+ readFileSafe(path.join(dest, "mcp.json")) === cursorMcpManifest(dest);
873
+ if (synced) return "Skipped";
874
+ const hadDir = existsSync(dest);
875
+ const rules = preservedCursorRules(src, dest);
876
+ const parent = path.dirname(dest);
877
+ mkdirSync(parent, { recursive: true });
878
+ const swap = mkdtempSync(path.join(parent, `.${path.basename(dest)}.swap-`));
879
+ const stage = path.join(swap, "stage");
880
+ const backup = path.join(swap, "backup");
881
+ try {
882
+ cpSync(src, stage, {
883
+ recursive: true,
884
+ filter: (entry) => !path.relative(src, entry).split(path.sep).includes("node_modules"),
885
+ });
886
+ for (const [name, content] of rules) {
887
+ mkdirSync(path.join(stage, "rules"), { recursive: true });
888
+ writeFileSync(path.join(stage, "rules", name), content);
889
+ }
890
+ writeFileSync(path.join(stage, ".workflow-toolkit-root"), src + "\n", "utf8");
891
+ for (const rel of ["hooks/session-start", "mcp/run-server.sh"]) {
892
+ try {
893
+ chmodSync(path.join(stage, rel), 0o755);
894
+ } catch {
895
+ /* optional launcher */
896
+ }
897
+ }
898
+ if (!samePluginContent(src, stage)) throw new Error("staged adapter content is incomplete");
899
+ if (hadDir) renameSync(dest, backup);
900
+ try {
901
+ renameSync(stage, dest);
902
+ } catch (error) {
903
+ if (hadDir && !existsSync(dest)) renameSync(backup, dest);
904
+ throw error;
905
+ }
906
+ // Derive the installed manifest against the FINAL installed path: the
907
+ // stage path dies with the swap dir, and the entry's dist check needs the
908
+ // live plugin dir to exist.
909
+ writeFileSync(path.join(dest, "mcp.json"), cursorMcpManifest(dest), "utf8");
910
+ rmSync(backup, { recursive: true, force: true });
911
+ } finally {
912
+ rmSync(swap, { recursive: true, force: true });
913
+ }
914
+ return hadDir ? "Configured" : "Installed";
915
+ }
916
+
917
+ // One reviewed mutation per Cursor write target (AR-09): the settings merge and
918
+ // the mcp merge are dispatched independently, exactly like the adapter copy.
919
+ function applyCursorSettings(root: string, res: ResolvedApply): SetupResultEntry {
920
+ mkdirSync(path.dirname(res.cursorSettings), { recursive: true });
921
+ const settingsExisting = readExisting(res.cursorSettings);
922
+ if (settingsExisting.kind === "malformed") {
923
+ return {
924
+ platform: "cursor",
925
+ file: res.cursorSettings,
926
+ status: "Failed",
927
+ detail: `cannot merge into malformed config: ${settingsExisting.error} — repair or remove the file`,
928
+ };
929
+ }
930
+ const settings = mergeCursorSettings(
931
+ settingsExisting.kind === "record" ? settingsExisting.value : {},
932
+ res.cursorPluginDir,
933
+ );
934
+ if (settings.changed.length === 0) {
935
+ return {
936
+ platform: "cursor",
937
+ file: res.cursorSettings,
938
+ status: "Skipped",
939
+ detail: "already registered",
940
+ };
941
+ }
942
+ writeFileSync(res.cursorSettings, JSON.stringify(settings.config, null, 2) + "\n", "utf8");
943
+ return {
944
+ platform: "cursor",
945
+ file: res.cursorSettings,
946
+ status: settingsExisting.kind === "record" ? "Configured" : "Installed",
947
+ };
948
+ }
949
+
950
+ function applyCursorMcp(root: string, res: ResolvedApply): SetupResultEntry {
951
+ mkdirSync(path.dirname(res.cursorMcp), { recursive: true });
952
+ const mcpExisting = readExisting(res.cursorMcp);
953
+ if (mcpExisting.kind === "malformed") {
954
+ return {
955
+ platform: "cursor",
956
+ file: res.cursorMcp,
957
+ status: "Failed",
958
+ detail: `cannot merge into malformed config: ${mcpExisting.error} — repair or remove the file`,
959
+ };
960
+ }
961
+ const mcp = mergeCursorMcp(
962
+ mcpExisting.kind === "record" ? mcpExisting.value : {},
963
+ "workit",
964
+ cursorMcpServerEntry(res.cursorPluginDir),
965
+ );
966
+ if (mcp.changed.length === 0) {
967
+ return {
968
+ platform: "cursor",
969
+ file: res.cursorMcp,
970
+ status: "Skipped",
971
+ detail: "already registered",
972
+ };
973
+ }
974
+ writeFileSync(res.cursorMcp, JSON.stringify(mcp.config, null, 2) + "\n", "utf8");
975
+ return {
976
+ platform: "cursor",
977
+ file: res.cursorMcp,
978
+ status: mcpExisting.kind === "record" ? "Configured" : "Installed",
979
+ };
980
+ }
981
+
982
+ function verifyPlatform(platform: Platform, res: ResolvedApply): DoctorReport | null {
983
+ if (platform === "opencode" && !existsSync(res.opencodeConfig)) return null;
984
+ if (platform === "cursor" && !existsSync(res.cursorSettings) && !existsSync(res.cursorMcp)) {
985
+ return null;
986
+ }
987
+ return runDoctor({
988
+ host: platform,
989
+ installer: true,
990
+ home: res.home,
991
+ configDir: res.configDir,
992
+ stateDir: res.env.WORKFLOW_TOOLKIT_STATE,
993
+ dev: res.dev ?? undefined,
994
+ cwd: res.cwd,
995
+ env: res.env,
996
+ opencodeConfig: res.opencodeConfig,
997
+ cursorSettings: res.cursorSettings,
998
+ cursorMcp: res.cursorMcp,
999
+ cursorPluginDir: res.cursorPluginDir,
1000
+ });
1001
+ }
1002
+
1003
+ const failedDetail = (report: DoctorReport): string =>
1004
+ report.checks
1005
+ .filter((c) => c.status === "fail")
1006
+ .map((c) => `${c.id}: ${c.detail}`)
1007
+ .join("; ");
1008
+
1009
+ export function applySetupPreview(
1010
+ preview: SetupPreview,
1011
+ options: ApplySetupOptions = {},
1012
+ ): SetupResult {
1013
+ if (!preview.ok) {
1014
+ const malformed = [
1015
+ preview.state.config,
1016
+ preview.state.youtrack,
1017
+ preview.state.vcs,
1018
+ preview.state.workspaces,
1019
+ ].filter((s) => s.status === "malformed");
1020
+ return {
1021
+ ok: false,
1022
+ exitCode: 1,
1023
+ entries: malformed.map((s) => ({
1024
+ platform: "core" as const,
1025
+ file: s.file,
1026
+ status: "Failed" as const,
1027
+ detail: s.error ?? "apply blocked by malformed configuration",
1028
+ })),
1029
+ preserved: [],
1030
+ blocked: preview.blocked,
1031
+ doctor: [],
1032
+ };
1033
+ }
1034
+
1035
+ const res = resolveApply(preview, options);
1036
+ const entries: SetupResultEntry[] = [];
1037
+ // AR-09/AR-13: Apply dispatches ONLY the reviewed mutation union. The adapter
1038
+ // source is re-resolved with the apply options (dev/cwd); a resolution
1039
+ // failure reports Failed without writing — it never produces a write that
1040
+ // was not previewed. The reviewed path itself is code-enforced: a platform
1041
+ // mutation whose planned path does not exactly equal the apply-time resolved
1042
+ // path (a caller that resolved the preview with different options) fails
1043
+ // fast — Failed, no write — instead of silently writing an unreviewed path.
1044
+ const rootFor = new Map<Platform, string | null>();
1045
+ for (const mutation of preview.mutations) {
1046
+ if (mutation.type !== "register-platform" && mutation.type !== "install-adapter") {
1047
+ entries.push(applyMutation(mutation));
1048
+ continue;
1049
+ }
1050
+ const platform = mutation.platform;
1051
+ const resolvedPath =
1052
+ mutation.type === "install-adapter"
1053
+ ? res.cursorPluginDir
1054
+ : platform === "opencode"
1055
+ ? res.opencodeConfig
1056
+ : mutation.path === res.cursorSettings
1057
+ ? res.cursorSettings
1058
+ : res.cursorMcp;
1059
+ if (mutation.path !== resolvedPath) {
1060
+ entries.push({
1061
+ platform,
1062
+ file: mutation.path,
1063
+ status: "Failed",
1064
+ detail: `preview/apply path mismatch: apply resolved ${resolvedPath}, the previewed write was ${mutation.path} — rebuild the preview with the same options`,
1065
+ });
1066
+ continue;
1067
+ }
1068
+ let root = rootFor.get(platform);
1069
+ if (root === undefined) {
1070
+ root = adapterRoot(platform, res);
1071
+ rootFor.set(platform, root);
1072
+ }
1073
+ if (root === null) {
1074
+ entries.push({
1075
+ platform,
1076
+ file: mutation.path,
1077
+ status: "Failed",
1078
+ detail: `@brainervirus/workit-${platform} package not found — set WORKFLOW_TOOLKIT_DEV to your checkout or install the package`,
1079
+ });
1080
+ continue;
1081
+ }
1082
+ if (mutation.type === "install-adapter") {
1083
+ try {
1084
+ entries.push({ platform, file: mutation.path, status: copyPluginDir(root, mutation.path) });
1085
+ } catch (error) {
1086
+ entries.push({
1087
+ platform,
1088
+ file: mutation.path,
1089
+ status: "Failed",
1090
+ detail: `adapter install failed: ${error instanceof Error ? error.message : String(error)}`,
1091
+ });
1092
+ }
1093
+ } else if (platform === "opencode") {
1094
+ entries.push(applyOpenCode(root, res));
1095
+ } else {
1096
+ entries.push(
1097
+ mutation.path === res.cursorSettings
1098
+ ? applyCursorSettings(root, res)
1099
+ : applyCursorMcp(root, res),
1100
+ );
1101
+ }
1102
+ }
1103
+ for (const preserved of preview.preserved) {
1104
+ entries.push({
1105
+ platform: "core",
1106
+ file: preserved,
1107
+ status: "Skipped",
1108
+ detail: "existing credential preserved",
1109
+ });
1110
+ }
1111
+
1112
+ // Post-apply verification per platform (read-only): a registered host whose
1113
+ // writes succeeded is verified by the shared offline doctor.
1114
+ const doctor: DoctorReport[] = [];
1115
+ for (const platform of preview.platforms) {
1116
+ if (platform !== "opencode" && platform !== "cursor") continue;
1117
+ const root = rootFor.get(platform);
1118
+ if (root === null || root === undefined) continue;
1119
+ if (!entries.some((e) => e.platform === platform && e.status !== "Failed")) continue;
1120
+ const report = verifyPlatform(platform, res);
1121
+ if (!report) continue;
1122
+ doctor.push(report);
1123
+ if (report.exitCode === 0) continue;
1124
+ if (platform === "opencode") {
1125
+ const entry = entries.find((e) => e.platform === "opencode" && e.file === res.opencodeConfig);
1126
+ if (entry) {
1127
+ entry.status = "Failed";
1128
+ entry.detail = `${entry.detail ? entry.detail + " — " : ""}doctor: ${failedDetail(report)}`;
1129
+ }
1130
+ } else {
1131
+ const primary = entries.find((e) => e.platform === "cursor" && e.file === res.cursorSettings);
1132
+ if (primary) {
1133
+ primary.status = "Failed";
1134
+ primary.detail = `${primary.detail ? primary.detail + " — " : ""}doctor: ${failedDetail(report)}`;
1135
+ } else {
1136
+ entries.push({
1137
+ platform: "cursor",
1138
+ file: res.cursorSettings,
1139
+ status: "Failed",
1140
+ detail: `doctor: ${failedDetail(report)}`,
1141
+ });
1142
+ }
1143
+ }
1144
+ }
1145
+
1146
+ const failed = entries.some((e) => e.status === "Failed");
1147
+ return {
1148
+ ok: !failed,
1149
+ exitCode: failed ? 1 : 0,
1150
+ entries,
1151
+ preserved: preview.preserved,
1152
+ blocked: [],
1153
+ doctor,
1154
+ };
1155
+ }
1156
+
1157
+ // Completion guidance shown by the CLI after Apply (and on the non-TTY path):
1158
+ // the doctor command + /wk-status are the next steps for verifying the result.
1159
+ export const setupCompletionGuidance = (): string[] => [
1160
+ "Run `workit doctor` to verify your installation.",
1161
+ "Run /wk-status in OpenCode to review token setup steps.",
1162
+ ];
1163
+
1164
+ // Shared per-workspace branch policy writer (CA-06): the initApplyData
1165
+ // branch_policy action (OpenCode/Cursor) and the Task 5 wizard both route
1166
+ // through this proposal→write path, so the written bytes are identical. The
1167
+ // write is idempotent: a matching entry is reported already-configured without
1168
+ // touching the file; an existing entry is updated; otherwise appended.
1169
+ export function applyWorkspaceBranchPolicy(opts: {
1170
+ workspace_root: string;
1171
+ env?: NodeJS.ProcessEnv;
1172
+ }): Record<string, any> {
1173
+ const { workspace_root, env = process.env } = opts;
1174
+ const dir = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
1175
+ const { status, path: wsPath, entries } = readWorkspacesResult(dir);
1176
+ if (status === "malformed") return { ok: false, error: `malformed workspaces.json: ${wsPath}` };
1177
+ const detection = detectBranchPolicy(workspace_root);
1178
+ const name = String(env.WORKFLOW_BP_NAME ?? path.basename(workspace_root));
1179
+ const integration = (env.WORKFLOW_BP_INTEGRATION ?? detection.integration) as "pr" | "merge";
1180
+ const policy = {
1181
+ preset: detection.preset,
1182
+ developBranch: env.WORKFLOW_BP_DEVELOP ?? detection.developBranch ?? undefined,
1183
+ prefixes: detection.prefixes,
1184
+ allowed: detection.allowed,
1185
+ protected: detection.protected,
1186
+ integration,
1187
+ };
1188
+ const glob = `${workspace_root.replace(/[\\/]+$/, "")}/**`;
1189
+ if (!validateWorkspaceGlob(glob).ok)
1190
+ return { ok: false, error: `invalid workspace glob: ${glob}` };
1191
+ const idx = entries.findIndex((w) => matchWorkspace(w.glob, workspace_root));
1192
+ const existing = idx >= 0 ? entries[idx] : null;
1193
+ if (existing?.branchPolicy && isDeepStrictEqual(existing.branchPolicy, policy)) {
1194
+ return {
1195
+ ok: true,
1196
+ status: "already-configured",
1197
+ workspace: existing,
1198
+ policy,
1199
+ config_path: wsPath,
1200
+ };
1201
+ }
1202
+ const next = existing
1203
+ ? entries.map((w, i) => (i === idx ? { ...w, branchPolicy: policy } : w))
1204
+ : [...entries, { name, glob, branchPolicy: policy }];
1205
+ mkdirSync(path.dirname(wsPath), { recursive: true });
1206
+ writeFileSync(wsPath, JSON.stringify({ workspaces: next }, null, 2) + "\n", "utf8");
1207
+ return {
1208
+ ok: true,
1209
+ status: existing ? "updated" : "configured",
1210
+ workspace: existing
1211
+ ? { ...existing, branchPolicy: policy }
1212
+ : { name, glob, branchPolicy: policy },
1213
+ policy,
1214
+ config_path: wsPath,
1215
+ };
1216
+ }