@brainervirus/workit-core 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -22,6 +22,14 @@ request, and a timeout is not proof that a worker stopped.
22
22
  until process exit or explicit recovery.
23
23
  4. Reconcile helper reports and run the checks appropriate to the requested
24
24
  outcome. If delegation is unavailable, continue inline when useful.
25
+ 5. Before a cross-repo mutation, resolve the actual checkout, branch and remote
26
+ from the request and current context. Ask only if competing plausible targets
27
+ remain unresolved. Branch, commit, direct push, PR-ready, merge and release
28
+ are distinct endpoints; perform only the authorized one under target rules.
29
+ Before saying done, reconcile every named deliverable against that checkout
30
+ and verify the requested result. For a push, observe the destination remote
31
+ ref and confirm it contains the delivered commit; report drift or missing
32
+ items as blockers instead of treating local success as remote delivery.
25
33
 
26
34
  Do not edit Workit metadata directly, create nested helper trees, widen paths, or
27
35
  create a second lifecycle. Read-only investigation and bounded reports do not
@@ -18,7 +18,11 @@ request. Preserve continuity when it helps; do not manufacture task mutations.
18
18
  3. For a separate request, do not silently resume an old objective. Park a
19
19
  concise checkpoint only when substantial work needs to continue later. Start
20
20
  a distinct tracked record only if the new work benefits from continuity,
21
- dependencies, coordination, or durable decisions.
21
+ dependencies, coordination, or durable decisions. For work spanning repos,
22
+ checkpoint each unfinished item with its checkout, branch, requested
23
+ deliverables, and delivery endpoint. Keep explicitly held items parked with
24
+ their resume condition until the user resumes them. A conversational
25
+ checkpoint is sufficient when no task record is needed.
22
26
  4. Before resuming a named tracked task, reconcile its checkout, branch, dirty
23
27
  state, current policy, stale evidence, uncertain effects, and ownership. Do
24
28
  not change branches, stash, fetch large histories, or seize ownership just
@@ -1,20 +1,24 @@
1
- // Host presence detection for the CLI setup wizard: which tools are installed
2
- // (CLI on PATH or home config marker — never a subprocess probe) and which of
3
- // the wizard-managed hosts already carry a workit registration. The configured
4
- // signal reuses planUninstall's installed bit so detection and uninstall can
5
- // never disagree; codex/pi are presence-only (their setup runs via cutover).
6
- import { existsSync, readdirSync } from "node:fs";
1
+ // Host presence detection uses actual CLI executables or supported desktop app
2
+ // locations; configuration directories alone never count as installation.
3
+ // Existing registrations reuse uninstall planning so the two paths agree.
4
+ import { readdirSync } from "node:fs";
7
5
  import os from "node:os";
8
6
  import path from "node:path";
9
7
  import { commandOnPath } from "./doctor";
10
8
  import { planUninstall, type UninstallPaths } from "./uninstall";
11
-
12
- export type HostId = "opencode" | "cursor" | "codex" | "pi";
9
+ import {
10
+ findHostExecutable,
11
+ installedHostApp,
12
+ isCodexWorkitInstalled,
13
+ isPiWorkitInstalled,
14
+ type HostId,
15
+ } from "./host-install";
16
+ export type { HostId } from "./host-install";
13
17
 
14
18
  export type HostDetection = {
15
- /** CLI on PATH or a home config marker exists. */
19
+ /** Host executable or supported desktop application is present. */
16
20
  detected: boolean;
17
- /** A workit registration is present (opencode/cursor only; always false). */
21
+ /** A Workit registration is present for this host. */
18
22
  configured: boolean;
19
23
  };
20
24
 
@@ -22,15 +26,8 @@ export type DetectHostsOptions = UninstallPaths;
22
26
 
23
27
  const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi"];
24
28
 
25
- const HOME_MARKERS: Record<HostId, string[]> = {
26
- opencode: [path.join(".config", "opencode")],
27
- cursor: [".cursor"],
28
- codex: [".codex"],
29
- pi: [".pi"],
30
- };
31
-
32
- /** Hosts the wizard Apply path registers (codex/pi set up via cutover). */
33
- export const WIZARD_HOSTS: HostId[] = ["opencode", "cursor"];
29
+ /** Hosts the setup wizard configures through their native install paths. */
30
+ export const WIZARD_HOSTS: HostId[] = [...HOSTS];
34
31
 
35
32
  /** Detected hosts the wizard can preselect (presence ∩ wizard-managed). */
36
33
  export function preselectedPlatforms(detection: Record<HostId, HostDetection>): string[] {
@@ -51,15 +48,20 @@ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, Ho
51
48
  // Same chain as uninstall path resolution: explicit home > env.HOME > homedir.
52
49
  const home = options.home ?? env.HOME ?? os.homedir();
53
50
  // Pure reader: classifies the installed state, never writes.
54
- const plan = planUninstall(options);
51
+ const plan = planUninstall({ ...options, home, env });
55
52
  const configuredByHost = new Map(plan.hosts.map((h) => [h.host, h.installed]));
56
53
  const found = emptyDetection();
57
54
  for (const host of HOSTS) {
58
55
  const detected =
59
- cliFound(host, env, home) || HOME_MARKERS[host].some((m) => existsSync(path.join(home, m)));
56
+ findHostExecutable(host, { home, env }) !== null || installedHostApp(host, home, env);
60
57
  found[host] = {
61
58
  detected,
62
- configured: WIZARD_HOSTS.includes(host) && (configuredByHost.get(host) ?? false),
59
+ configured:
60
+ host === "codex"
61
+ ? isCodexWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
62
+ : host === "pi"
63
+ ? isPiWorkitInstalled(home, env) || (configuredByHost.get(host) ?? false)
64
+ : (configuredByHost.get(host) ?? false),
63
65
  };
64
66
  }
65
67
  return found;
@@ -217,11 +217,18 @@ const parsesAsConfigObject = (p: string): boolean => {
217
217
  }
218
218
  };
219
219
 
220
- // OpenCode accepts `plugin` as a string or an array; normalize before reading.
220
+ // V2 uses plugins; V1 accepts plugin as a string or array. Inspect both.
221
221
  const pluginEntries = (cfg: Record<string, any> | null): string[] => {
222
- const plugin = cfg?.plugin;
223
- const list = Array.isArray(plugin) ? plugin : typeof plugin === "string" ? [plugin] : [];
224
- return list.map(String).filter(isWorkitPlugin);
222
+ const entries = [cfg?.plugins, cfg?.plugin].flatMap((value) =>
223
+ Array.isArray(value) ? value : typeof value === "string" ? [value] : [],
224
+ );
225
+ return [
226
+ ...new Set(
227
+ entries.filter(
228
+ (entry): entry is string => typeof entry === "string" && isWorkitPlugin(entry),
229
+ ),
230
+ ),
231
+ ];
225
232
  };
226
233
 
227
234
  export const commandOnPath = (name: string, env: NodeJS.ProcessEnv): boolean => {
@@ -0,0 +1,458 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export type HostId = "opencode" | "cursor" | "codex" | "pi";
7
+
8
+ /** A reviewed argv vector. Arguments are never composed into a shell string. */
9
+ export type HostInstallCommand = {
10
+ command: string;
11
+ args: string[];
12
+ cwd?: string;
13
+ purpose: string;
14
+ /** Optional managed package tree to stage, for Cursor's local plugin copy. */
15
+ packageRoot?: string;
16
+ packageTarget?: string;
17
+ skipWhenOutputIncludes?: string;
18
+ };
19
+
20
+ export type HostCommandResult = {
21
+ exitCode: number;
22
+ stdout: string;
23
+ stderr: string;
24
+ };
25
+
26
+ export type HostCommandRunner = (step: HostInstallCommand) => HostCommandResult;
27
+ export type HostCommandContext = { home?: string; env?: NodeJS.ProcessEnv };
28
+
29
+ export function hostCommand(
30
+ name: string,
31
+ args: string[],
32
+ options: HostInstallOptions = {},
33
+ ): HostInstallCommand {
34
+ const executable = findHostExecutable(name, options);
35
+ if (!executable)
36
+ throw new Error(`${name} executable was not found on PATH or supported user bin directories`);
37
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(executable)) {
38
+ const node = findHostExecutable("node", options);
39
+ if (!node) throw new Error(`Node.js is required to run the ${name} Windows shim`);
40
+ const script = resolveWindowsShimScript(executable);
41
+ if (!script)
42
+ throw new Error(`could not resolve the ${name} Windows shim to a JavaScript entrypoint`);
43
+ return { command: node, args: [script, ...args], cwd: options.cwd, purpose: `${name} CLI` };
44
+ }
45
+ return { command: executable, args, cwd: options.cwd, purpose: `${name} CLI` };
46
+ }
47
+
48
+ export function resolveWindowsShimEntry(
49
+ contents: string,
50
+ shim: string,
51
+ exists: (candidate: string) => boolean = existsSync,
52
+ ): string | null {
53
+ const root = path.win32.dirname(shim);
54
+ const matches = contents.matchAll(/(["']?)([^\r\n"']*node_modules[\\/][^\r\n"']+?\.js)\1/gi);
55
+ for (const match of matches) {
56
+ const raw = match[2].replace(/%~?dp0%?/gi, root);
57
+ const candidate = path.win32.isAbsolute(raw) ? raw : path.win32.resolve(root, raw);
58
+ if (exists(candidate)) return candidate;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ const resolveWindowsShimScript = (shim: string): string | null => {
64
+ try {
65
+ const contents = readFileSync(shim, "utf8");
66
+ return resolveWindowsShimEntry(contents, shim);
67
+ } catch {
68
+ return null;
69
+ }
70
+ };
71
+
72
+ export type HostInstallOptions = {
73
+ home?: string;
74
+ cwd?: string;
75
+ env?: NodeJS.ProcessEnv;
76
+ packageVersion?: string;
77
+ alreadyInstalled?: boolean;
78
+ targetDir?: string;
79
+ mode?: "install" | "upgrade";
80
+ };
81
+
82
+ const MARKETPLACE = "https://github.com/BrainerVirus/workit.git";
83
+ const CODEX_MARKETPLACE_NAME = "workflow-toolkit";
84
+ const validPackageVersion = (value: string): boolean =>
85
+ value === "latest" || /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
86
+
87
+ const nodePrerequisite = (options: HostInstallOptions): HostInstallCommand => {
88
+ const node = findHostExecutable("node", options);
89
+ if (!node) throw new Error("Workit host packages require Node.js 24+; install Node.js and retry");
90
+ return {
91
+ command: node,
92
+ args: [
93
+ "-e",
94
+ 'const v=Number(process.versions.node.split(".")[0]);if(v<24){console.error(`Workit host packages require Node.js 24+ (found ${process.versions.node})`);process.exit(1)}',
95
+ ],
96
+ cwd: options.cwd,
97
+ purpose: "Check the Node.js 24+ package runtime requirement",
98
+ };
99
+ };
100
+
101
+ /** Resolve host executables through PATH plus common user-level Node managers. */
102
+ export function findHostExecutable(
103
+ name: string,
104
+ options: { home?: string; env?: NodeJS.ProcessEnv } = {},
105
+ ): string | null {
106
+ const env = options.env ?? process.env;
107
+ const home = options.home ?? env.HOME ?? os.homedir();
108
+ const suffixes = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
109
+ const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
110
+ dirs.push(path.join(home, ".local", "bin"), path.join(home, ".asdf", "shims"));
111
+ for (const [base, tail] of [
112
+ [path.join(home, ".local", "share", "fnm", "node-versions"), path.join("installation", "bin")],
113
+ [path.join(home, ".nvm", "versions", "node"), "bin"],
114
+ ] as const) {
115
+ try {
116
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
117
+ if (entry.isDirectory()) dirs.push(path.join(base, entry.name, tail));
118
+ }
119
+ } catch {
120
+ // A missing version-manager directory is normal.
121
+ }
122
+ }
123
+ for (const dir of dirs) {
124
+ for (const suffix of suffixes) {
125
+ const candidate = path.join(dir, `${name}${suffix}`);
126
+ try {
127
+ if (statSync(candidate).isFile()) {
128
+ if (process.platform === "win32") return candidate;
129
+ accessSync(candidate, constants.X_OK);
130
+ return candidate;
131
+ }
132
+ } catch {
133
+ // Continue through PATH entries and platform shims.
134
+ }
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ export const isPiWorkitInstalled = (
141
+ home: string,
142
+ env: NodeJS.ProcessEnv = process.env,
143
+ ): boolean => {
144
+ const settings = path.join(
145
+ env.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent"),
146
+ "settings.json",
147
+ );
148
+ try {
149
+ const document = JSON.parse(readFileSync(settings, "utf8")) as { packages?: unknown[] };
150
+ return (document.packages ?? []).some((entry) => {
151
+ const source =
152
+ entry && typeof entry === "object" && !Array.isArray(entry)
153
+ ? (entry as { source?: unknown }).source
154
+ : entry;
155
+ const value = String(source).replaceAll("\\", "/").replace(/\/+$/, "");
156
+ return (
157
+ /^npm:@brainervirus\/workit-pi(?:@[^/]+)?$/.test(value) ||
158
+ value === "@brainervirus/workit-pi" ||
159
+ /(?:^|\/)packages\/workit-pi$/.test(value)
160
+ );
161
+ });
162
+ } catch {
163
+ return false;
164
+ }
165
+ };
166
+
167
+ const isCanonicalCodexPackage = (root: string): boolean => {
168
+ for (const file of [
169
+ path.join(root, ".codex-plugin", "plugin.json"),
170
+ path.join(root, "package.json"),
171
+ ]) {
172
+ try {
173
+ const manifest = JSON.parse(readFileSync(file, "utf8")) as {
174
+ name?: string;
175
+ repository?: string | { url?: string };
176
+ };
177
+ const repository =
178
+ typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url;
179
+ if (
180
+ (manifest.name === "workit" || manifest.name === "@brainervirus/workit-codex") &&
181
+ repository?.replace(/\.git$/, "").toLowerCase() === "https://github.com/brainervirus/workit"
182
+ )
183
+ return true;
184
+ } catch {
185
+ // Try the other manifest path.
186
+ }
187
+ }
188
+ return false;
189
+ };
190
+
191
+ export const isCodexWorkitInstalled = (
192
+ home: string,
193
+ env: NodeJS.ProcessEnv = process.env,
194
+ ): boolean => {
195
+ const codexHome = env.CODEX_HOME ?? path.join(home, ".codex");
196
+ const isPackageOrVersionDirectory = (root: string): boolean => {
197
+ if (isCanonicalCodexPackage(root)) return true;
198
+ try {
199
+ return readdirSync(root, { withFileTypes: true }).some(
200
+ (entry) => entry.isDirectory() && isCanonicalCodexPackage(path.join(root, entry.name)),
201
+ );
202
+ } catch {
203
+ return false;
204
+ }
205
+ };
206
+ if (isPackageOrVersionDirectory(path.join(codexHome, "plugins", "workit"))) return true;
207
+ const root = path.join(codexHome, "plugins", "cache");
208
+ try {
209
+ for (const marketplace of readdirSync(root, { withFileTypes: true })) {
210
+ if (!marketplace.isDirectory()) continue;
211
+ if (isPackageOrVersionDirectory(path.join(root, marketplace.name, "workit"))) return true;
212
+ }
213
+ } catch {
214
+ return false;
215
+ }
216
+ return false;
217
+ };
218
+
219
+ export function installedHostApp(
220
+ host: HostId,
221
+ home: string,
222
+ env: NodeJS.ProcessEnv = process.env,
223
+ platform: NodeJS.Platform = process.platform,
224
+ exists: (candidate: string) => boolean = existsSync,
225
+ ): boolean {
226
+ const candidates: string[] = [];
227
+ const pathApi = platform === "win32" ? path.win32 : path;
228
+ if (platform === "darwin") {
229
+ const app =
230
+ host === "cursor"
231
+ ? "Cursor.app"
232
+ : host === "codex"
233
+ ? "Codex.app"
234
+ : host === "opencode"
235
+ ? "OpenCode.app"
236
+ : "";
237
+ if (app) candidates.push(`/Applications/${app}`, pathApi.join(home, "Applications", app));
238
+ } else if (platform === "win32") {
239
+ const local = env.LOCALAPPDATA ?? pathApi.join(home, "AppData", "Local");
240
+ if (host === "cursor") candidates.push(pathApi.join(local, "Programs", "Cursor", "Cursor.exe"));
241
+ if (host === "codex") candidates.push(pathApi.join(local, "Programs", "Codex", "Codex.exe"));
242
+ if (host === "opencode")
243
+ candidates.push(pathApi.join(local, "Programs", "OpenCode", "OpenCode.exe"));
244
+ }
245
+ return candidates.some(exists);
246
+ }
247
+
248
+ /** Plan the host-native registration commands used by setup and upgrade. */
249
+ export function planHostInstall(
250
+ host: HostId,
251
+ options: HostInstallOptions = {},
252
+ ): HostInstallCommand[] {
253
+ const env = options.env ?? process.env;
254
+ const home = options.home ?? env.HOME ?? os.homedir();
255
+ const cwd = options.cwd ?? process.cwd();
256
+ const version = options.packageVersion ?? "latest";
257
+ if (!validPackageVersion(version))
258
+ throw new Error(`unsupported package version selector: ${version}`);
259
+ if (options.alreadyInstalled && options.mode !== "upgrade") return [];
260
+ const upgrading = options.mode === "upgrade";
261
+ if (host === "codex") {
262
+ if (!upgrading && isCodexWorkitInstalled(home, env)) return [];
263
+ const base = hostCommand("codex", [], { home, cwd, env });
264
+ return [
265
+ nodePrerequisite({ home, cwd, env }),
266
+ {
267
+ ...base,
268
+ args: [...base.args, "plugin", "marketplace", "list"],
269
+ purpose: "Check whether the Workit marketplace is already registered",
270
+ },
271
+ {
272
+ ...base,
273
+ args: [
274
+ ...base.args,
275
+ "plugin",
276
+ "marketplace",
277
+ upgrading ? "upgrade" : "add",
278
+ ...(upgrading ? [CODEX_MARKETPLACE_NAME] : [MARKETPLACE]),
279
+ ],
280
+ purpose: upgrading
281
+ ? "Refresh the Workit Codex marketplace"
282
+ : "Register the Workit Codex marketplace",
283
+ ...(upgrading ? {} : { skipWhenOutputIncludes: CODEX_MARKETPLACE_NAME }),
284
+ },
285
+ {
286
+ ...base,
287
+ args: [...base.args, "plugin", "add", `workit@${CODEX_MARKETPLACE_NAME}`],
288
+ purpose: upgrading ? "Refresh the Workit Codex plugin" : "Install the Workit Codex plugin",
289
+ },
290
+ ];
291
+ }
292
+ if (host === "pi") {
293
+ if (!upgrading && isPiWorkitInstalled(home, env)) return [];
294
+ const base = hostCommand("pi", [], { home, cwd, env });
295
+ return [
296
+ nodePrerequisite({ home, cwd, env }),
297
+ {
298
+ ...base,
299
+ args: upgrading
300
+ ? [...base.args, "update", "--extension", "npm:@brainervirus/workit-pi"]
301
+ : [...base.args, "install", `npm:@brainervirus/workit-pi@${version}`],
302
+ purpose: upgrading
303
+ ? "Update the Workit Pi package"
304
+ : "Install the Workit Pi package in personal Pi settings",
305
+ },
306
+ ];
307
+ }
308
+ if (host === "cursor") {
309
+ const node = findHostExecutable("node", { home, env });
310
+ if (!node)
311
+ throw new Error("Workit host packages require Node.js 24+; install Node.js and retry");
312
+ const npm = findHostExecutable("npm", { home, env });
313
+ const npmCli = npm ? findNpmCli(npm) : null;
314
+ if (!npmCli)
315
+ throw new Error("npm CLI could not be resolved; install Node.js 24+ and npm, then retry");
316
+ const stage = path.join(home, ".cursor", "plugins", "local", ".workit-package-bootstrap");
317
+ const target = options.targetDir ?? path.join(home, ".cursor", "plugins", "local", "workit");
318
+ return [
319
+ nodePrerequisite({ home, cwd, env }),
320
+ {
321
+ command: node,
322
+ args: [
323
+ npmCli,
324
+ "install",
325
+ "--prefix",
326
+ stage,
327
+ "--no-save",
328
+ "--package-lock=false",
329
+ `@brainervirus/workit-cursor@${version}`,
330
+ ],
331
+ cwd,
332
+ purpose: upgrading
333
+ ? "Download the current Workit Cursor adapter for the managed local plugin directory"
334
+ : "Download the Workit Cursor adapter package for the managed local plugin directory",
335
+ packageRoot: path.join(stage, "node_modules", "@brainervirus", "workit-cursor"),
336
+ packageTarget: target,
337
+ },
338
+ ];
339
+ }
340
+ if (host === "opencode") {
341
+ if (upgrading)
342
+ throw new Error(
343
+ "OpenCode CLI cannot target the Workit server plugin for a scoped update; upgrade is unsupported and the current registration was preserved",
344
+ );
345
+ // The normal setup path merges this package pin into OpenCode's config;
346
+ // OpenCode's native resolver installs and refreshes npm plugin packages.
347
+ return [];
348
+ }
349
+ return [];
350
+ }
351
+
352
+ /** Execute a reviewed plan serially and stop immediately on the first failure. */
353
+ export function runHostInstall(
354
+ commands: HostInstallCommand[],
355
+ runner?: HostCommandRunner,
356
+ context: HostCommandContext = {},
357
+ ): {
358
+ ok: boolean;
359
+ results: Array<{ command: HostInstallCommand; result: HostCommandResult }>;
360
+ packageRoot?: string;
361
+ } {
362
+ const results: Array<{ command: HostInstallCommand; result: HostCommandResult }> = [];
363
+ let packageRoot: string | undefined;
364
+ let outputHistory = "";
365
+ for (const command of commands) {
366
+ if (command.skipWhenOutputIncludes && outputHistory.includes(command.skipWhenOutputIncludes)) {
367
+ results.push({
368
+ command,
369
+ result: { exitCode: 0, stdout: "", stderr: "already registered; skipped" },
370
+ });
371
+ continue;
372
+ }
373
+ const result = runner ? runner(command) : runHostCommand(command, context);
374
+ results.push({ command, result });
375
+ if (result.exitCode !== 0) return { ok: false, results };
376
+ outputHistory += `\n${result.stdout}\n${result.stderr}`;
377
+ if (command.packageRoot && command.packageTarget) {
378
+ try {
379
+ if (!existsSync(command.packageRoot)) {
380
+ return {
381
+ ok: false,
382
+ results: [
383
+ {
384
+ command,
385
+ result: {
386
+ ...result,
387
+ exitCode: 1,
388
+ stderr: "npm completed without producing the expected Cursor package",
389
+ },
390
+ },
391
+ ],
392
+ };
393
+ } else {
394
+ packageRoot = command.packageRoot;
395
+ }
396
+ } catch (error) {
397
+ return {
398
+ ok: false,
399
+ results: [
400
+ {
401
+ command,
402
+ result: {
403
+ ...result,
404
+ exitCode: 1,
405
+ stderr: error instanceof Error ? error.message : String(error),
406
+ },
407
+ },
408
+ ],
409
+ };
410
+ }
411
+ }
412
+ }
413
+ return { ok: true, results, packageRoot };
414
+ }
415
+
416
+ /** Synchronous, bounded command runner for the synchronous setup Apply API. */
417
+ export function runHostCommand(
418
+ step: HostInstallCommand,
419
+ context: HostCommandContext = {},
420
+ ): HostCommandResult {
421
+ if (process.platform === "win32" && /\.(?:cmd|bat)$/i.test(step.command)) {
422
+ return {
423
+ exitCode: 1,
424
+ stdout: "",
425
+ stderr: `Cannot safely execute batch shim ${step.command}; resolve its Node.js CLI entrypoint and retry`,
426
+ };
427
+ }
428
+ const result = spawnSync(step.command, step.args, {
429
+ cwd: step.cwd,
430
+ env: (() => {
431
+ const home = context.home ?? context.env?.HOME ?? os.homedir();
432
+ const env: NodeJS.ProcessEnv = { ...process.env, ...context.env, HOME: home };
433
+ env.CODEX_HOME = context.env?.CODEX_HOME ?? path.join(home, ".codex");
434
+ env.PI_CODING_AGENT_DIR = context.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
435
+ env.XDG_CONFIG_HOME = context.env?.XDG_CONFIG_HOME ?? path.join(home, ".config");
436
+ return env;
437
+ })(),
438
+ encoding: "utf8",
439
+ timeout: 60_000,
440
+ windowsHide: true,
441
+ shell: false,
442
+ });
443
+ return {
444
+ exitCode: result.status ?? (result.error ? 1 : 1),
445
+ stdout: result.stdout ?? "",
446
+ stderr: result.stderr ?? result.error?.message ?? "",
447
+ };
448
+ }
449
+
450
+ const findNpmCli = (npm: string): string | null => {
451
+ const dir = path.dirname(npm);
452
+ const candidates = [
453
+ path.join(dir, "node_modules", "npm", "bin", "npm-cli.js"),
454
+ path.join(dir, "..", "node_modules", "npm", "bin", "npm-cli.js"),
455
+ path.join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
456
+ ];
457
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
458
+ };
@@ -138,7 +138,8 @@ let imported decisions grant authority. Never turn a host deny into allow or
138
138
  claim enforcement/evidence a host cannot provide. A precise request with settled
139
139
  constraints does not need an interview. Present genuine unresolved options with
140
140
  evidence and a recommendation, then continue toward the requested delivery
141
- endpoint: investigation, implementation, PR/MR-ready, merge, or release. Run
141
+ endpoint: investigation, implementation, branch/commit/push, PR/MR-ready,
142
+ merge, or release. Run
142
143
  applicable checks and safe repairs without repeated continuation questions;
143
144
  stop for missing host authority, a new consequential choice, a conflicting edit,
144
145
  or an unresolved blocker. A tracked record may close when actual evidence
@@ -150,6 +151,12 @@ confirm the stop. Preserve unresolved requirements, gaps, and uncertain workers.
150
151
  When context changes, distinguish a quick question, same-task adjustment, and
151
152
  separate request. Answer a quick question without pausing/resuming task state;
152
153
  park a concise checkpoint only when substantial work needs to continue later.
154
+ For work spanning repositories, bind each unfinished item to its actual checkout,
155
+ branch, requested deliverables, and delivery endpoint. Preserve held items with
156
+ an explicit resume condition. Resolve competing plausible targets before a
157
+ mutation; ask only when available context cannot settle that consequential choice.
158
+ Before reporting delivery, reconcile every requested item and observe the actual
159
+ target result; a local commit alone is not evidence of a requested remote push.
153
160
 
154
161
  Skill routing: slash aliases /wk-* load on demand. Use workit-steer for a
155
162
  substantial interruption or change of direction, workit-deslop when relevant
@@ -31,10 +31,9 @@ export function isWorkitPlugin(value: unknown): boolean {
31
31
  const s = String(value).replaceAll("\\", "/");
32
32
  const url = s.startsWith("file://") || s.startsWith("git+file://");
33
33
  const pkgPath =
34
- s.includes("/packages/workit-opencode/") ||
35
- s.includes("/packages/workit-cursor/") ||
36
- s.includes("/node_modules/@brainervirus/workit-opencode/") ||
37
- s.includes("/node_modules/@brainervirus/workit-cursor/");
34
+ /\/(?:packages\/workit-(?:opencode|cursor)|node_modules\/@brainervirus\/workit-(?:opencode|cursor))(?:\/|$)/.test(
35
+ s,
36
+ );
38
37
  return (
39
38
  named(s, "workflow-toolkit") ||
40
39
  named(s, "workflow-toolkit-opencode") ||
package/src/core/setup.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  readConfigFromDir,
23
23
  resolveConfigDir,
24
24
  type BranchPreset,
25
+ type ToolkitConfig,
25
26
  } from "./config";
26
27
  import { detectBranchPolicy } from "./branch-policy";
27
28
  import { readSetupState, type SetupState } from "./setup-state";
@@ -38,13 +39,26 @@ import { planHygieneFiles } from "./hygiene";
38
39
  import { packageRoot } from "./package-root";
39
40
  import {
40
41
  cursorMcpServerEntry,
42
+ CURSOR_RUNTIME_PACKAGE,
43
+ isWorkitPlugin,
41
44
  mergeCursorMcp,
45
+ mergeOpenCodePlugins,
42
46
  mergeCursorSettings,
43
47
  mergeOpenCodeConfig,
44
48
  OPENCODE_NPM_PIN,
45
49
  } from "./registration";
46
50
  import { runDoctor, type DoctorReport } from "./doctor";
47
51
  import { writeFileExclusive } from "./safe-write";
52
+ import {
53
+ isCodexWorkitInstalled,
54
+ isPiWorkitInstalled,
55
+ planHostInstall,
56
+ runHostInstall,
57
+ type HostCommandRunner,
58
+ type HostId,
59
+ type HostInstallCommand,
60
+ runHostCommand,
61
+ } from "./host-install";
48
62
 
49
63
  // Setup preview + apply (WZ-04-WZ-09, WZ-13-WZ-15; CA-12, CA-14, CA-22, CA-23).
50
64
  // buildSetupPreview is a pure reader: it classifies the current setup state and
@@ -82,6 +96,8 @@ export type SetupPreviewInput = {
82
96
  * configured tokenFile, the replacement is planned as its own distinct
83
97
  * reviewed mutation (AR-10). */
84
98
  tokenPaths?: { youtrack?: string; gitlab?: string; github?: string };
99
+ /** Optional global commit policy used as the base for repository overrides. */
100
+ commitPolicy?: ToolkitConfig["commitPolicy"];
85
101
  };
86
102
 
87
103
  export type SetupMutation =
@@ -98,6 +114,7 @@ export type SetupMutation =
98
114
  // during preview so the reviewed mutation set IS the Apply write set.
99
115
  | { type: "register-platform"; platform: Platform; path: string }
100
116
  | { type: "install-adapter"; platform: "cursor"; path: string }
117
+ | { type: "install-host"; platform: HostId; path: string; commands: HostInstallCommand[] }
101
118
  // AR-10: changing a configured tokenFile path is never hidden inside a
102
119
  // generic merge — it is its own reviewed mutation.
103
120
  | { type: "set-token-path"; path: string; key: string; value: string };
@@ -249,6 +266,7 @@ export function buildSetupPreview(
249
266
  locale: values.locale,
250
267
  localeOptions: current.localeOptions,
251
268
  timezone: values.timezone,
269
+ ...(values.commitPolicy === undefined ? {} : { commitPolicy: values.commitPolicy }),
252
270
  branchPolicy: mergePreset(
253
271
  values.branchPreset,
254
272
  {
@@ -344,9 +362,56 @@ export function buildSetupPreview(
344
362
  if (platform === "opencode") {
345
363
  mutations.push({ type: "register-platform", platform, path: paths.opencodeConfig });
346
364
  } else if (platform === "cursor") {
365
+ const cursorRoot = adapterRoot("cursor", {
366
+ ...paths,
367
+ dev: opts.dev ?? env.WORKFLOW_TOOLKIT_DEV ?? null,
368
+ });
369
+ if (cursorRoot === null) {
370
+ const commands = planHostInstall("cursor", {
371
+ home: paths.home,
372
+ cwd: paths.cwd,
373
+ env,
374
+ packageVersion: "latest",
375
+ targetDir: paths.cursorPluginDir,
376
+ });
377
+ mutations.push({
378
+ type: "install-host",
379
+ platform: "cursor",
380
+ path: path.join(
381
+ paths.home,
382
+ ".cursor",
383
+ "plugins",
384
+ "local",
385
+ ".workit-package-bootstrap",
386
+ ),
387
+ commands,
388
+ });
389
+ }
347
390
  mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
348
391
  mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
349
392
  mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
393
+ } else if (platform === "codex" || platform === "pi") {
394
+ const commands = planHostInstall(platform, { home: paths.home, cwd: paths.cwd, env });
395
+ if (commands.length > 0) {
396
+ mutations.push({
397
+ type: "install-host",
398
+ platform,
399
+ path:
400
+ platform === "codex"
401
+ ? path.join(
402
+ env.CODEX_HOME ?? path.join(paths.home, ".codex"),
403
+ "plugins",
404
+ "cache",
405
+ "workflow-toolkit",
406
+ "workit",
407
+ )
408
+ : path.join(
409
+ env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
410
+ "settings.json",
411
+ ),
412
+ commands,
413
+ });
414
+ }
350
415
  }
351
416
  }
352
417
  }
@@ -391,7 +456,7 @@ export type Platform = "opencode" | "cursor";
391
456
  export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
392
457
 
393
458
  export type SetupResultEntry = {
394
- platform: Platform | "core";
459
+ platform: HostId | "core";
395
460
  file: string;
396
461
  status: SetupResultStatus;
397
462
  detail?: string;
@@ -422,6 +487,10 @@ export type ApplySetupOptions = {
422
487
  cursorMcp?: string;
423
488
  cursorPluginDir?: string;
424
489
  stateDir?: string;
490
+ /** Injectable synchronous host runner; defaults to a bounded no-shell spawn. */
491
+ runHostCommand?: HostCommandRunner;
492
+ /** Prefer staged npm adapter assets, used by the explicit upgrade flow. */
493
+ preferInstalledAdapter?: boolean;
425
494
  };
426
495
 
427
496
  type ResolvedApply = {
@@ -528,7 +597,7 @@ const readFileSafe = (p: string): string | null => {
528
597
  // adapter resolution (AR-09).
529
598
  type CoreMutation = Exclude<
530
599
  SetupMutation,
531
- { type: "register-platform" } | { type: "install-adapter" }
600
+ { type: "register-platform" } | { type: "install-adapter" } | { type: "install-host" }
532
601
  >;
533
602
 
534
603
  const workspaceEntriesAlreadyMatch = (
@@ -735,8 +804,23 @@ const isAdapter = (root: string, platform: Platform): boolean => {
735
804
  }
736
805
  };
737
806
 
738
- function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
807
+ function adapterRoot(
808
+ platform: Platform,
809
+ res: ResolvedApply,
810
+ preferInstalledAdapter = false,
811
+ ): string | null {
739
812
  const candidates: string[] = [];
813
+ const stagedCursor = path.join(
814
+ res.home,
815
+ ".cursor",
816
+ "plugins",
817
+ "local",
818
+ ".workit-package-bootstrap",
819
+ "node_modules",
820
+ "@brainervirus",
821
+ "workit-cursor",
822
+ );
823
+ if (platform === "cursor" && preferInstalledAdapter) candidates.push(stagedCursor);
740
824
  if (res.dev) {
741
825
  // A dev checkout is the complete source of truth for adapter packages: when
742
826
  // WORKFLOW_TOOLKIT_DEV is set, a package missing from it is a real failure,
@@ -757,6 +841,7 @@ function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
757
841
  candidates.push(
758
842
  path.join(res.home, ".local", "share", "workit", "packages", `workit-${platform}`),
759
843
  );
844
+ if (platform === "cursor") candidates.push(stagedCursor);
760
845
  for (const candidate of candidates) {
761
846
  if (isAdapter(candidate, platform)) return candidate;
762
847
  }
@@ -787,8 +872,8 @@ const opencodePin = (root: string, res: ResolvedApply): string | null =>
787
872
  resolveOpenCodePin(root, { dev: res.dev });
788
873
 
789
874
  function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
790
- const pin = opencodePin(root, res);
791
- if (!pin) {
875
+ const resolvedPin = opencodePin(root, res);
876
+ if (!resolvedPin) {
792
877
  return {
793
878
  platform: "opencode",
794
879
  file: res.opencodeConfig,
@@ -807,7 +892,33 @@ function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
807
892
  detail: `cannot merge into malformed config: ${existing.error} — repair or remove the file`,
808
893
  };
809
894
  }
810
- const merged = mergeOpenCodeConfig(existing.kind === "record" ? existing.value : {}, pin);
895
+ const configuredPlugins =
896
+ existing.kind === "record"
897
+ ? Array.isArray(existing.value.plugins)
898
+ ? existing.value.plugins
899
+ : typeof existing.value.plugins === "string"
900
+ ? [existing.value.plugins]
901
+ : typeof existing.value.plugin === "string"
902
+ ? [existing.value.plugin]
903
+ : Array.isArray(existing.value.plugin)
904
+ ? existing.value.plugin
905
+ : []
906
+ : [];
907
+ const configuredPin = configuredPlugins.find(
908
+ (entry): entry is string => typeof entry === "string" && isWorkitPlugin(entry),
909
+ );
910
+ const pin = configuredPin ?? resolvedPin;
911
+ const base = existing.kind === "record" ? existing.value : {};
912
+ const merged =
913
+ existing.kind === "record" && Object.hasOwn(existing.value, "plugins")
914
+ ? (() => {
915
+ const plugins = mergeOpenCodePlugins(existing.value.plugins, pin);
916
+ return {
917
+ config: { ...existing.value, plugins: plugins.config },
918
+ changed: plugins.changed.length > 0 ? ["plugins"] : [],
919
+ };
920
+ })()
921
+ : mergeOpenCodeConfig(base, pin);
811
922
  if (merged.changed.length === 0) {
812
923
  return {
813
924
  platform: "opencode",
@@ -996,11 +1107,21 @@ function applyCursorMcp(root: string, res: ResolvedApply): SetupResultEntry {
996
1107
  detail: `cannot merge into malformed config: ${mcpExisting.error} — repair or remove the file`,
997
1108
  };
998
1109
  }
999
- const mcp = mergeCursorMcp(
1000
- mcpExisting.kind === "record" ? mcpExisting.value : {},
1001
- "workit",
1002
- cursorMcpServerEntry(res.cursorPluginDir),
1110
+ const mcpBase = mcpExisting.kind === "record" ? mcpExisting.value : {};
1111
+ const server = cursorMcpServerEntry(res.cursorPluginDir);
1112
+ const servers = isRecord(mcpBase.mcpServers) ? mcpBase.mcpServers : {};
1113
+ const current = isRecord(servers.workit) ? servers.workit : {};
1114
+ const previousArgs = Array.isArray(current.args) ? current.args.map(String) : [];
1115
+ const previousPackage = previousArgs.find(
1116
+ (arg) =>
1117
+ arg.startsWith("--package=@brainervirus/workit-cursor@") &&
1118
+ arg !== `--package=${CURSOR_RUNTIME_PACKAGE}`,
1003
1119
  );
1120
+ if (previousPackage) {
1121
+ const index = server.args.findIndex((arg) => arg.startsWith("--package="));
1122
+ if (index >= 0) server.args[index] = previousPackage;
1123
+ }
1124
+ const mcp = mergeCursorMcp(mcpBase, "workit", server);
1004
1125
  if (mcp.changed.length === 0) {
1005
1126
  return {
1006
1127
  platform: "cursor",
@@ -1117,6 +1238,77 @@ export function applySetupPreview(
1117
1238
  // legacy identity or its registration.
1118
1239
  let cursorCopyOk = false;
1119
1240
  for (const mutation of preview.mutations) {
1241
+ if (mutation.type === "install-host") {
1242
+ const expectedPath =
1243
+ mutation.platform === "cursor"
1244
+ ? path.join(res.home, ".cursor", "plugins", "local", ".workit-package-bootstrap")
1245
+ : mutation.platform === "codex"
1246
+ ? path.join(
1247
+ res.env.CODEX_HOME ?? path.join(res.home, ".codex"),
1248
+ "plugins",
1249
+ "cache",
1250
+ "workflow-toolkit",
1251
+ "workit",
1252
+ )
1253
+ : mutation.platform === "pi"
1254
+ ? path.join(
1255
+ res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"),
1256
+ "settings.json",
1257
+ )
1258
+ : "";
1259
+ if (mutation.path !== expectedPath) {
1260
+ entries.push({
1261
+ platform: mutation.platform,
1262
+ file: mutation.path,
1263
+ status: "Failed",
1264
+ detail: `preview/apply path mismatch: apply resolved ${expectedPath}, the previewed host install was ${mutation.path} — rebuild the preview with the same options`,
1265
+ });
1266
+ continue;
1267
+ }
1268
+ const run =
1269
+ options.runHostCommand ??
1270
+ ((step: HostInstallCommand) => runHostCommand(step, { home: res.home, env: res.env }));
1271
+ const installed = runHostInstall(mutation.commands, run);
1272
+ for (const { command, result } of installed.results) {
1273
+ entries.push({
1274
+ platform: mutation.platform,
1275
+ file: command.command,
1276
+ status: result.exitCode === 0 ? "Installed" : "Failed",
1277
+ detail:
1278
+ result.exitCode === 0
1279
+ ? `${command.purpose} (${command.args.join(" ")})`
1280
+ : `${command.purpose} failed (${command.args.join(" ")}): ${result.stderr || `exit ${result.exitCode}`}`,
1281
+ });
1282
+ }
1283
+ if (!installed.ok && installed.results.length === 0) {
1284
+ entries.push({
1285
+ platform: mutation.platform,
1286
+ file: mutation.platform,
1287
+ status: "Failed",
1288
+ detail: "host installation did not run",
1289
+ });
1290
+ }
1291
+ if (!installed.ok) continue;
1292
+ if (mutation.platform === "cursor" && installed.packageRoot) {
1293
+ rootFor.set("cursor", installed.packageRoot);
1294
+ }
1295
+ const verified =
1296
+ mutation.platform === "codex"
1297
+ ? isCodexWorkitInstalled(res.home, res.env)
1298
+ : mutation.platform === "pi"
1299
+ ? isPiWorkitInstalled(res.home, res.env)
1300
+ : true;
1301
+ if (!verified) {
1302
+ entries.push({
1303
+ platform: mutation.platform,
1304
+ file: mutation.platform,
1305
+ status: "Failed",
1306
+ detail:
1307
+ "the host command succeeded, but Workit registration was not found; inspect the host's native package list and retry",
1308
+ });
1309
+ }
1310
+ continue;
1311
+ }
1120
1312
  if (mutation.type !== "register-platform" && mutation.type !== "install-adapter") {
1121
1313
  entries.push(applyMutation(mutation));
1122
1314
  continue;
@@ -1141,7 +1333,7 @@ export function applySetupPreview(
1141
1333
  }
1142
1334
  let root = rootFor.get(platform);
1143
1335
  if (root === undefined) {
1144
- root = adapterRoot(platform, res);
1336
+ root = adapterRoot(platform, res, options.preferInstalledAdapter);
1145
1337
  rootFor.set(platform, root);
1146
1338
  }
1147
1339
  if (root === null) {
@@ -67,6 +67,7 @@ type CompactDecision = {
67
67
  purpose: Decision["purpose"];
68
68
  status: Decision["response"];
69
69
  digest: string;
70
+ choice: string;
70
71
  references: CompactReference[];
71
72
  };
72
73
  type CompactMethod = Pick<SelectedMethod, "id" | "assurance"> & { reason: string };
@@ -100,6 +101,14 @@ const compactText = (value: string | null, limit: number): string | null =>
100
101
  })
101
102
  .slice(0, limit);
102
103
  const compactBytes = (value: string): number => new TextEncoder().encode(value).byteLength;
104
+ const compactTimestamp = (value: Utc): string =>
105
+ `${value.slice(0, 19)}.${value.slice(20, -1).padEnd(9, "0")}`;
106
+ const compactDecisionChoice = (entry: TaskView["task"]["decisions"][number]): string | null => {
107
+ const stated = entry.data.binding.statedChoice?.text;
108
+ const approved = entry.data.response === "approved" ? entry.data.binding.approvedContent : "";
109
+ const source = stated?.trim() || approved.trim() || null;
110
+ return compactText(source, COMPACT_TEXT_BYTES);
111
+ };
103
112
  const compactReference = (ref: Ref): CompactReference => {
104
113
  if (ref.kind === "file")
105
114
  return {
@@ -118,13 +127,21 @@ const compactReference = (ref: Ref): CompactReference => {
118
127
  export function compactTaskContext(view: TaskView): string {
119
128
  const decisions: CompactDecision[] = view.task.decisions
120
129
  .slice()
121
- .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))
130
+ .sort((left, right) => {
131
+ const leftAt = compactTimestamp(left.recordedAt);
132
+ const rightAt = compactTimestamp(right.recordedAt);
133
+ if (leftAt !== rightAt) return leftAt > rightAt ? -1 : 1;
134
+ return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
135
+ })
122
136
  .slice(0, COMPACT_MAX_ITEMS)
123
137
  .map((entry) => ({
124
138
  id: entry.id,
125
139
  purpose: entry.data.purpose,
126
140
  status: entry.data.response,
127
141
  digest: entry.data.digest,
142
+ choice:
143
+ compactDecisionChoice(entry) ||
144
+ `task.inspect taskId=${view.task.id} view=full; decision=${entry.id}`,
128
145
  references: entry.data.binding.contentRefs.slice(0, 1).map(compactReference),
129
146
  }));
130
147
  const methods: CompactMethod[] = view.task.policy
@@ -191,10 +191,8 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
191
191
  (hasWorkspacePolicy ? policyDefault : (cfg.defaultTargetBranch ?? policyDefault)) ??
192
192
  "develop",
193
193
  );
194
- const linkIssues =
195
- provider === "gitlab" && typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
196
- const youtrackBaseUrl =
197
- provider === "gitlab" && typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
194
+ const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
195
+ const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
198
196
  // github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
199
197
  let issuesProvider: string | null = null;
200
198
  let linkOnPr: boolean | null = null;
@@ -134,13 +134,6 @@ const workspaceConfigSchema = z
134
134
  })
135
135
  .passthrough()
136
136
  .superRefine((workspace, context) => {
137
- if (workspace.youtrack && workspace.vcs?.provider !== "gitlab") {
138
- context.addIssue({
139
- code: "custom",
140
- path: ["youtrack"],
141
- message: `YouTrack issue linking requires the gitlab provider, got ${workspace.vcs?.provider ?? "unset"}`,
142
- });
143
- }
144
137
  if (workspace.issues && workspace.vcs?.provider !== "github") {
145
138
  context.addIssue({
146
139
  code: "custom",
@@ -446,23 +439,21 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
446
439
  return mostSpecific[0];
447
440
  };
448
441
 
449
- /** Match a cwd against the workspaces.json under an explicit config dir. */
450
- export const resolveWorkspaceFrom = (
442
+ /** Shared pure workspace matcher for draft previews and disk-backed resolution. */
443
+ export const resolveWorkspaceFromEntries = <T extends { name: string; glob: string }>(
451
444
  cwd: string,
452
- dir: string,
445
+ entries: readonly T[],
453
446
  workspaceName?: string,
454
- ): WorkspaceConfig | null => {
447
+ ): T | null => {
455
448
  // macOS/Windows tmpdir symlinks (/var -> /private/var): git's
456
449
  // --show-toplevel returns the realpath while config globs are usually
457
450
  // written with the logical path, so a workspace would silently stop
458
451
  // matching on macOS. Match both forms on each side — same class as the
459
452
  // docs-migration escape-guard realpath comparison; on Linux both forms
460
453
  // are identical so behavior is unchanged.
461
- const result = readWorkspacesResult(dir);
462
- if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
463
454
  const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
464
- const matches: WorkspaceConfig[] = [];
465
- for (const ws of result.entries) {
455
+ const matches: T[] = [];
456
+ for (const ws of entries) {
466
457
  const glob = ws.glob.replaceAll("\\", "/");
467
458
  const canonical = canonicalGlob(glob);
468
459
  let matched = false;
@@ -480,6 +471,17 @@ export const resolveWorkspaceFrom = (
480
471
  return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
481
472
  };
482
473
 
474
+ /** Match a cwd against the workspaces.json under an explicit config dir. */
475
+ export const resolveWorkspaceFrom = (
476
+ cwd: string,
477
+ dir: string,
478
+ workspaceName?: string,
479
+ ): WorkspaceConfig | null => {
480
+ const result = readWorkspacesResult(dir);
481
+ if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
482
+ return resolveWorkspaceFromEntries(cwd, result.entries, workspaceName);
483
+ };
484
+
483
485
  export const resolveWorkspace = (
484
486
  cwd: string,
485
487
  workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,