@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 +1 -1
- package/skills/workit-implement/SKILL.md +8 -0
- package/skills/workit-steer/SKILL.md +5 -1
- package/src/core/detect-hosts.ts +24 -22
- package/src/core/doctor.ts +11 -4
- package/src/core/host-install.ts +458 -0
- package/src/core/methods.ts +8 -1
- package/src/core/registration.ts +3 -4
- package/src/core/setup.ts +203 -11
- package/src/core/task-context.ts +18 -1
- package/src/core/vcs-config.ts +2 -4
- package/src/core/workspaces.ts +17 -15
package/package.json
CHANGED
|
@@ -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
|
package/src/core/detect-hosts.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
|
-
// Host presence detection
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
19
|
+
/** Host executable or supported desktop application is present. */
|
|
16
20
|
detected: boolean;
|
|
17
|
-
/** A
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
56
|
+
findHostExecutable(host, { home, env }) !== null || installedHostApp(host, home, env);
|
|
60
57
|
found[host] = {
|
|
61
58
|
detected,
|
|
62
|
-
configured:
|
|
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;
|
package/src/core/doctor.ts
CHANGED
|
@@ -217,11 +217,18 @@ const parsesAsConfigObject = (p: string): boolean => {
|
|
|
217
217
|
}
|
|
218
218
|
};
|
|
219
219
|
|
|
220
|
-
//
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
};
|
package/src/core/methods.ts
CHANGED
|
@@ -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,
|
|
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
|
package/src/core/registration.ts
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
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(
|
|
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
|
|
791
|
-
if (!
|
|
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
|
|
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
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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) {
|
package/src/core/task-context.ts
CHANGED
|
@@ -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) =>
|
|
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
|
package/src/core/vcs-config.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/core/workspaces.ts
CHANGED
|
@@ -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
|
-
/**
|
|
450
|
-
export const
|
|
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
|
-
|
|
445
|
+
entries: readonly T[],
|
|
453
446
|
workspaceName?: string,
|
|
454
|
-
):
|
|
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:
|
|
465
|
-
for (const ws of
|
|
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,
|