@brainervirus/workit-core 2.9.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/scripts/bundled-deps.json +1 -7
- package/scripts/install-codex-plugin.sh +1 -2
- package/scripts/install-cursor-plugin.sh +3 -4
- package/scripts/install-opencode-plugin.sh +2 -3
- package/scripts/install-pi-package.sh +2 -3
- package/scripts/sync-runtime.sh +0 -13
- package/src/core/init.ts +1 -1
- package/src/core/methods.ts +4 -1
- package/src/core/store-lock.ts +13 -2
- package/src/core/support-matrix.ts +4 -4
- package/src/core/task-contract.ts +17 -18
- package/src/core/task-engine.ts +108 -58
- package/src/core/task-store.ts +1857 -1259
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core.ts +1 -6
- package/src/hooks/context.ts +57 -5
- package/src/hooks/hosts/opencode.ts +2 -2
- package/src/ledger.ts +12 -18
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/scripts/doctor-check.ts +0 -37
- package/src/core/config-conversion.ts +0 -180
- package/src/core/cutover.ts +0 -1789
- package/src/core/detect-hosts.ts +0 -115
- package/src/core/doctor.ts +0 -1882
- package/src/core/host-install.ts +0 -617
- package/src/core/legacy-ownership.ts +0 -47
- package/src/core/registration.ts +0 -306
- package/src/core/setup-state.ts +0 -61
- package/src/core/setup.ts +0 -1582
- package/src/core/uninstall.ts +0 -544
package/src/core/registration.ts
DELETED
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
// Registration merge helpers for the OpenCode/Cursor installers (RR-06).
|
|
2
|
-
// Pure functions: accept the existing user config, return the deduplicated
|
|
3
|
-
// config PLUS the explicit list of keys changed. Unrelated user settings are
|
|
4
|
-
// never rewritten — their values round-trip JSON-identical. The install
|
|
5
|
-
// scripts (`packages/workit-core/scripts/install-*-plugin.sh`) import these so
|
|
6
|
-
// there is exactly one source of truth for registration merging.
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
|
|
9
|
-
/** Canonical published OpenCode plugin identity (OpenCode resolves from npm). */
|
|
10
|
-
export const OPENCODE_NPM_PIN = "@brainervirus/workit-opencode";
|
|
11
|
-
|
|
12
|
-
export interface MergeResult<T> {
|
|
13
|
-
config: T;
|
|
14
|
-
changed: string[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
18
|
-
v !== null && typeof v === "object" && !Array.isArray(v);
|
|
19
|
-
|
|
20
|
-
/** Exact identity match — `name` or `name@version` — never a substring (D3). */
|
|
21
|
-
const named = (s: string, name: string) => s === name || s.startsWith(`${name}@`);
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* True when a plugin identity is a current or legacy Workit entry. Matched by
|
|
25
|
-
* exact identity, never by substring: an unrelated plugin whose id merely
|
|
26
|
-
* contains "workflow-toolkit" is preserved (D3). Path-style identities are
|
|
27
|
-
* compared with normalized separators so a Windows file:// pin (backslashes)
|
|
28
|
-
* still matches the packages/workit-* path checks.
|
|
29
|
-
*/
|
|
30
|
-
export function isWorkitPlugin(value: unknown): boolean {
|
|
31
|
-
const s = String(value).replaceAll("\\", "/");
|
|
32
|
-
const url = s.startsWith("file://") || s.startsWith("git+file://");
|
|
33
|
-
const pkgPath =
|
|
34
|
-
/\/(?:packages\/workit-(?:opencode|cursor)|node_modules\/@brainervirus\/workit-(?:opencode|cursor))(?:\/|$)/.test(
|
|
35
|
-
s,
|
|
36
|
-
);
|
|
37
|
-
return (
|
|
38
|
-
named(s, "workflow-toolkit") ||
|
|
39
|
-
named(s, "workflow-toolkit-opencode") ||
|
|
40
|
-
named(s, "local/workflow-toolkit") ||
|
|
41
|
-
named(s, "workit") ||
|
|
42
|
-
named(s, "local/workit") ||
|
|
43
|
-
named(s, "@brainervirus/workit-opencode") ||
|
|
44
|
-
named(s, "@brainervirus/workit-cursor") ||
|
|
45
|
-
(url && pkgPath) ||
|
|
46
|
-
(s.startsWith("git+file://") && s.includes("workflow-toolkit"))
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Deduplicate every legacy/current Workit plugin identity to one dev pin. */
|
|
51
|
-
export function mergeOpenCodePlugins(plugin: unknown, pin: string): MergeResult<unknown[]> {
|
|
52
|
-
const existing = Array.isArray(plugin) ? plugin : typeof plugin === "string" ? [plugin] : [];
|
|
53
|
-
const config = [pin, ...existing.filter((p) => !isWorkitPlugin(p))];
|
|
54
|
-
const changed = JSON.stringify(config) !== JSON.stringify(existing) ? ["plugin"] : [];
|
|
55
|
-
return { config, changed };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Merge an existing OpenCode config with a dev pin; preserve unrelated keys. */
|
|
59
|
-
export function mergeOpenCodeConfig(
|
|
60
|
-
config: unknown,
|
|
61
|
-
pin: string,
|
|
62
|
-
): MergeResult<Record<string, unknown>> {
|
|
63
|
-
const base = isRecord(config) ? { ...config } : {};
|
|
64
|
-
const changed: string[] = [];
|
|
65
|
-
|
|
66
|
-
const plugins = mergeOpenCodePlugins(base.plugin, pin);
|
|
67
|
-
if (plugins.changed.length > 0) {
|
|
68
|
-
base.plugin = plugins.config;
|
|
69
|
-
changed.push("plugin");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return { config: base, changed };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Collapse current + legacy Cursor plugin identities to the canonical `workit`. */
|
|
76
|
-
export function mergeCursorEnabledPlugins(enabled: unknown): MergeResult<Record<string, boolean>> {
|
|
77
|
-
const prev = isRecord(enabled) ? { ...(enabled as Record<string, boolean>) } : {};
|
|
78
|
-
const next: Record<string, boolean> = { ...prev, workit: true };
|
|
79
|
-
delete next["workflow-toolkit"]; // legacy identity
|
|
80
|
-
delete next["local/workflow-toolkit"]; // legacy duplicate identity
|
|
81
|
-
const changed = JSON.stringify(next) !== JSON.stringify(prev) ? ["enabled_plugins"] : [];
|
|
82
|
-
return { config: next, changed };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Append the plugin dir once, dropping the exact legacy sibling directory. */
|
|
86
|
-
export function mergeCursorPluginDirs(
|
|
87
|
-
pluginDirs: unknown,
|
|
88
|
-
pluginDir: string,
|
|
89
|
-
): MergeResult<string[]> {
|
|
90
|
-
// path.join does not strip a single trailing separator; do it explicitly for
|
|
91
|
-
// the dedup comparison (guarding the filesystem-root case).
|
|
92
|
-
const strip = (p: string) => {
|
|
93
|
-
const j = path.join(p);
|
|
94
|
-
return path.dirname(j) === j ? j : j.replace(/[\\/]+$/, "");
|
|
95
|
-
};
|
|
96
|
-
const normalized = strip(pluginDir);
|
|
97
|
-
// CA-08: the legacy local plugin dir is the exact sibling of the canonical
|
|
98
|
-
// dir; remove only that entry, never a similarly-named unrelated dir (D3).
|
|
99
|
-
const legacy = strip(path.join(path.dirname(pluginDir), "workflow-toolkit"));
|
|
100
|
-
const prev = Array.isArray(pluginDirs) ? pluginDirs.map(String) : [];
|
|
101
|
-
const kept = prev.filter((d) => strip(d) !== legacy);
|
|
102
|
-
// Normalize both sides for comparison so a trailing-slash variant of an
|
|
103
|
-
// existing entry is not appended as a duplicate; existing entries are kept
|
|
104
|
-
// verbatim.
|
|
105
|
-
const exists = kept.some((d) => strip(d) === normalized);
|
|
106
|
-
const next = exists ? kept : [...kept, normalized];
|
|
107
|
-
const changed = JSON.stringify(next) !== JSON.stringify(prev) ? ["plugin_dirs"] : [];
|
|
108
|
-
return { config: next, changed };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Merge Cursor settings: one plugin identity, dirs appended, unrelated keys kept. */
|
|
112
|
-
export function mergeCursorSettings(
|
|
113
|
-
settings: unknown,
|
|
114
|
-
pluginDir: string,
|
|
115
|
-
): MergeResult<Record<string, unknown>> {
|
|
116
|
-
const base = isRecord(settings) ? { ...settings } : {};
|
|
117
|
-
const changed: string[] = [];
|
|
118
|
-
|
|
119
|
-
const enabled = mergeCursorEnabledPlugins(base.enabled_plugins);
|
|
120
|
-
if (enabled.changed.length > 0) {
|
|
121
|
-
base.enabled_plugins = enabled.config;
|
|
122
|
-
changed.push("enabled_plugins");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const dirs = mergeCursorPluginDirs(base.plugin_dirs, pluginDir);
|
|
126
|
-
if (dirs.changed.length > 0) {
|
|
127
|
-
base.plugin_dirs = dirs.config;
|
|
128
|
-
changed.push("plugin_dirs");
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return { config: base, changed };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Set one portable workit MCP server, dropping the legacy server name. */
|
|
135
|
-
export function mergeCursorMcp(
|
|
136
|
-
mcp: unknown,
|
|
137
|
-
serverName: string,
|
|
138
|
-
server: Record<string, unknown>,
|
|
139
|
-
): MergeResult<Record<string, unknown>> {
|
|
140
|
-
const base = isRecord(mcp) ? { ...mcp } : {};
|
|
141
|
-
const servers = isRecord(base.mcpServers) ? { ...base.mcpServers } : {};
|
|
142
|
-
delete servers["workflow-toolkit"]; // legacy duplicate registration
|
|
143
|
-
servers[serverName] = server;
|
|
144
|
-
const changed = JSON.stringify(servers) !== JSON.stringify(base.mcpServers) ? ["mcpServers"] : [];
|
|
145
|
-
base.mcpServers = servers;
|
|
146
|
-
return { config: base, changed };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Swap the sessionStart hook command and normalize the enforcement events to
|
|
150
|
-
* canonical (missing or divergent preToolUse/beforeShellExecution entries are
|
|
151
|
-
* rewritten so the installer heals exactly what the doctor flags). Other
|
|
152
|
-
* hooks and fields are preserved. */
|
|
153
|
-
export function mergeCursorHooks(
|
|
154
|
-
hooks: unknown,
|
|
155
|
-
sessionStartEntry: Record<string, unknown>,
|
|
156
|
-
): MergeResult<Record<string, unknown>> {
|
|
157
|
-
const base: Record<string, unknown> = isRecord(hooks) ? { ...hooks } : { version: 1 };
|
|
158
|
-
const hooksMap = isRecord(base.hooks) ? { ...base.hooks } : {};
|
|
159
|
-
const changed: string[] = [];
|
|
160
|
-
const list = Array.isArray(hooksMap.sessionStart) ? hooksMap.sessionStart : [];
|
|
161
|
-
const same =
|
|
162
|
-
list.length === 1 &&
|
|
163
|
-
isRecord(list[0]) &&
|
|
164
|
-
JSON.stringify(list[0]) === JSON.stringify(sessionStartEntry);
|
|
165
|
-
if (!same) {
|
|
166
|
-
hooksMap.sessionStart = [sessionStartEntry];
|
|
167
|
-
changed.push("hooks.sessionStart");
|
|
168
|
-
}
|
|
169
|
-
for (const event of ["preToolUse", "beforeShellExecution"] as const) {
|
|
170
|
-
const canonical = canonicalHookEntry(event);
|
|
171
|
-
const current = hooksMap[event];
|
|
172
|
-
if (
|
|
173
|
-
!Array.isArray(current) ||
|
|
174
|
-
current.length !== 1 ||
|
|
175
|
-
!isRecord(current[0]) ||
|
|
176
|
-
JSON.stringify({ ...current[0], failClosed: true }) !==
|
|
177
|
-
JSON.stringify({ ...canonical, failClosed: true })
|
|
178
|
-
) {
|
|
179
|
-
hooksMap[event] = [canonical];
|
|
180
|
-
changed.push(`hooks.${event}`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
base.hooks = hooksMap;
|
|
184
|
-
return { config: base, changed };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Canonical selector for the Cursor npm runtime: `@latest` with `--prefer-online`
|
|
189
|
-
* (README "Update review"). The single source for every source-derived Cursor
|
|
190
|
-
* runtime selector; the committed manifests keep the literal (static data
|
|
191
|
-
* cannot import TS). `--min-release-age=0` works around npm/cli#9765, where
|
|
192
|
-
* npx ignores the user's scoped release-age exclusion.
|
|
193
|
-
*/
|
|
194
|
-
export const CURSOR_RUNTIME_PACKAGE = "@brainervirus/workit-cursor@latest";
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Canonical Cursor hook launcher (single source of truth for the shipped
|
|
198
|
-
* hooks-cursor.json, the installer merge, and the doctor drift check).
|
|
199
|
-
*/
|
|
200
|
-
export const CURSOR_HOOK_RUN_COMMAND = `npx -y --prefer-online --min-release-age=0 --package=${CURSOR_RUNTIME_PACKAGE} workit-cursor-hook`;
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* preToolUse matcher covering every name the hook's write-tool guard treats
|
|
204
|
-
* as a write (see CURSOR_WRITE_TOOL_NAMES in the Cursor hook). A narrower
|
|
205
|
-
* installed matcher lets write tools bypass enforcement silently.
|
|
206
|
-
*/
|
|
207
|
-
export const CURSOR_PRETOOLUSE_MATCHER =
|
|
208
|
-
"Write|Edit|Delete|Shell|Remove|ApplyPatch|Apply_Patch|Patch|Rename|Mkdir|Mv|Cp|Touch";
|
|
209
|
-
|
|
210
|
-
const canonicalHookEntry = (
|
|
211
|
-
event: "preToolUse" | "beforeShellExecution",
|
|
212
|
-
): Record<string, unknown> =>
|
|
213
|
-
event === "preToolUse"
|
|
214
|
-
? { command: CURSOR_HOOK_RUN_COMMAND, matcher: CURSOR_PRETOOLUSE_MATCHER, failClosed: true }
|
|
215
|
-
: { command: CURSOR_HOOK_RUN_COMMAND, failClosed: true };
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Drift between an installed hooks file and the canonical event entries.
|
|
219
|
-
* Only present-but-divergent entries count: absent events are filled in by
|
|
220
|
-
* the installer merge on the next run, and minimal installs predate them.
|
|
221
|
-
*/
|
|
222
|
-
export function cursorHookDrift(installed: unknown): string[] {
|
|
223
|
-
if (!isRecord(installed) || !isRecord(installed.hooks)) return ["hooks file is not a hook map"];
|
|
224
|
-
const hooks = installed.hooks;
|
|
225
|
-
const drift: string[] = [];
|
|
226
|
-
for (const event of ["preToolUse", "beforeShellExecution"] as const) {
|
|
227
|
-
const list = hooks[event];
|
|
228
|
-
if (list === undefined) continue;
|
|
229
|
-
const canonical = canonicalHookEntry(event);
|
|
230
|
-
const entry = Array.isArray(list) ? list[0] : undefined;
|
|
231
|
-
if (
|
|
232
|
-
!isRecord(entry) ||
|
|
233
|
-
entry.command !== canonical.command ||
|
|
234
|
-
(event === "preToolUse" && entry.matcher !== canonical.matcher)
|
|
235
|
-
)
|
|
236
|
-
drift.push(event);
|
|
237
|
-
}
|
|
238
|
-
return drift;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Portable Cursor MCP server entry (CA-16/CA-17): launch the published package
|
|
243
|
-
* through npx against its npm bin, so the Marketplace plugin never depends on a
|
|
244
|
-
* repo-relative or untracked dist file.
|
|
245
|
-
*/
|
|
246
|
-
export function cursorMcpServerEntry(_packageDir: string): {
|
|
247
|
-
command: string;
|
|
248
|
-
args: string[];
|
|
249
|
-
} {
|
|
250
|
-
return {
|
|
251
|
-
command: "npx",
|
|
252
|
-
args: [
|
|
253
|
-
"-y",
|
|
254
|
-
"--prefer-online",
|
|
255
|
-
"--min-release-age=0",
|
|
256
|
-
`--package=${CURSOR_RUNTIME_PACKAGE}`,
|
|
257
|
-
"workit-cursor-mcp",
|
|
258
|
-
"${workspaceFolder}",
|
|
259
|
-
],
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Portable Cursor sessionStart hook entry (CA-17): a single command string in
|
|
265
|
-
* Cursor's documented format (no args array).
|
|
266
|
-
*/
|
|
267
|
-
export function cursorHooksEntry(_packageDir: string): {
|
|
268
|
-
command: string;
|
|
269
|
-
args: string[];
|
|
270
|
-
} {
|
|
271
|
-
return {
|
|
272
|
-
command: `npx -y --prefer-online --min-release-age=0 --package=${CURSOR_RUNTIME_PACKAGE} workit-cursor-session-start`,
|
|
273
|
-
args: [],
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Local-dev Cursor MCP server entry: run the installed plugin's own built dist
|
|
279
|
-
* through node instead of the published npx pin, so a checkout install runs the
|
|
280
|
-
* branch's code (rename included) without waiting for a release. The committed
|
|
281
|
-
* Marketplace manifests keep the reviewed pin; this form is written by the
|
|
282
|
-
* local install path only.
|
|
283
|
-
*/
|
|
284
|
-
export function cursorMcpLocalDistEntry(packageDir: string): {
|
|
285
|
-
command: string;
|
|
286
|
-
args: string[];
|
|
287
|
-
} {
|
|
288
|
-
return {
|
|
289
|
-
command: "node",
|
|
290
|
-
args: [path.join(packageDir, "dist", "mcp-server.js"), "${workspaceFolder}"],
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Local-dev Cursor sessionStart hook entry: node against the installed dist, in
|
|
296
|
-
* Cursor's documented single-command-string hook format (no args array).
|
|
297
|
-
*/
|
|
298
|
-
export function cursorHookLocalDistEntry(packageDir: string): {
|
|
299
|
-
command: string;
|
|
300
|
-
args: string[];
|
|
301
|
-
} {
|
|
302
|
-
return {
|
|
303
|
-
command: `node ${path.join(packageDir, "dist", "cursor-session-start.js")}`,
|
|
304
|
-
args: [],
|
|
305
|
-
};
|
|
306
|
-
}
|
package/src/core/setup-state.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { isConfigObject, resolveConfigDir } from "./config";
|
|
4
|
-
import { readWorkspacesResult } from "./workspaces";
|
|
5
|
-
|
|
6
|
-
// WZ-06: typed setup-state reader distinguishing missing from malformed
|
|
7
|
-
// configuration. Purely read-only — nothing here ever writes; Apply decision
|
|
8
|
-
// (Task 13/14) reads this state and refuses to proceed on malformed files.
|
|
9
|
-
|
|
10
|
-
export type FileState = {
|
|
11
|
-
file: string;
|
|
12
|
-
status: "missing" | "valid" | "malformed";
|
|
13
|
-
error?: string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export type SetupState = {
|
|
17
|
-
configDir: string;
|
|
18
|
-
config: FileState;
|
|
19
|
-
youtrack: FileState;
|
|
20
|
-
vcs: FileState;
|
|
21
|
-
workspaces: FileState;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export const classifySetupFile = (dir: string, name: string): FileState => {
|
|
25
|
-
const file = path.join(dir, name);
|
|
26
|
-
let raw: string;
|
|
27
|
-
try {
|
|
28
|
-
raw = readFileSync(file, "utf8");
|
|
29
|
-
} catch {
|
|
30
|
-
// An existing-but-unreadable file (EACCES) is NOT "missing": mutations must
|
|
31
|
-
// never be generated against it. Report it as malformed so risky consumers
|
|
32
|
-
// (wizard/installer/doctor) block on the exact path.
|
|
33
|
-
if (existsSync(file)) {
|
|
34
|
-
return { file, status: "malformed", error: `${file} is not readable` };
|
|
35
|
-
}
|
|
36
|
-
return { file, status: "missing" };
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
const parsed = JSON.parse(raw);
|
|
40
|
-
if (!isConfigObject(parsed)) {
|
|
41
|
-
return { file, status: "malformed", error: `${file} is not a JSON object` };
|
|
42
|
-
}
|
|
43
|
-
} catch {
|
|
44
|
-
return { file, status: "malformed", error: `${file} is not valid JSON` };
|
|
45
|
-
}
|
|
46
|
-
if (name === "workspaces.json") {
|
|
47
|
-
const workspaces = readWorkspacesResult(dir);
|
|
48
|
-
if (workspaces.status === "malformed" || workspaces.status === "invalid") {
|
|
49
|
-
return { file, status: "malformed", error: workspaces.error };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return { file, status: "valid" };
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const readSetupState = (dir: string = resolveConfigDir()): SetupState => ({
|
|
56
|
-
configDir: dir,
|
|
57
|
-
config: classifySetupFile(dir, "config.json"),
|
|
58
|
-
youtrack: classifySetupFile(dir, "youtrack.json"),
|
|
59
|
-
vcs: classifySetupFile(dir, "vcs.json"),
|
|
60
|
-
workspaces: classifySetupFile(dir, "workspaces.json"),
|
|
61
|
-
});
|