@brainervirus/workit-core 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +3 -7
  2. package/scripts/doctor-check.ts +20 -0
  3. package/scripts/install-cursor-plugin.sh +51 -28
  4. package/scripts/install-opencode-plugin.sh +19 -21
  5. package/scripts/rewrite-workspace-deps.ts +15 -9
  6. package/scripts/sync-runtime.sh +71 -19
  7. package/scripts/vendor-assets.ts +37 -0
  8. package/skills/wk-implement/SKILL.md +2 -2
  9. package/skills/wk-pr/SKILL.md +1 -1
  10. package/src/core/boundary.ts +27 -0
  11. package/src/core/branch-policy.ts +63 -0
  12. package/src/core/branch.ts +30 -16
  13. package/src/core/config.ts +193 -31
  14. package/src/core/docs-layout.ts +251 -0
  15. package/src/core/docs-migration.ts +639 -0
  16. package/src/core/docs-repo.ts +11 -9
  17. package/src/core/docs-validate.ts +18 -6
  18. package/src/core/doctor.ts +801 -0
  19. package/src/core/flow-state.ts +1579 -141
  20. package/src/core/git.ts +22 -5
  21. package/src/{tools/handoff.ts → core/handoff-tools.ts} +5 -57
  22. package/src/core/hygiene.ts +26 -12
  23. package/src/core/init.ts +43 -11
  24. package/src/core/logger.ts +321 -0
  25. package/src/core/package-root.ts +28 -0
  26. package/src/core/ports/init-toolkit-status.ts +1 -1
  27. package/src/core/ports/vcs-verify-token.ts +1 -1
  28. package/src/core/ports/youtrack-api.ts +1 -1
  29. package/src/core/ports/youtrack-verify-token.ts +1 -1
  30. package/src/core/pr-create.ts +116 -21
  31. package/src/core/registration.ts +215 -0
  32. package/src/core/repo-context.ts +447 -0
  33. package/src/core/repo-tools.ts +23 -0
  34. package/src/core/safe-write.ts +22 -0
  35. package/src/core/scripts.ts +3 -44
  36. package/src/core/sdd.ts +45 -28
  37. package/src/core/setup-state.ts +54 -0
  38. package/src/core/setup.ts +1216 -0
  39. package/src/core/skill-manifests.ts +95 -0
  40. package/src/core/support-matrix.ts +12 -0
  41. package/src/core/sync-runtime.ts +348 -0
  42. package/src/core/templates.ts +2 -2
  43. package/src/core/vcs-config.ts +107 -37
  44. package/src/core/verify-project.ts +181 -0
  45. package/src/core/workspaces.ts +136 -17
  46. package/src/core/youtrack-tools.ts +228 -0
  47. package/src/core/youtrack.ts +125 -67
  48. package/templates/execution-contract.md +9 -7
  49. package/templates/superpowers-doc-contract.md +4 -3
  50. package/scripts/_shared/common.sh +0 -173
  51. package/scripts/changelog-context.sh +0 -42
  52. package/scripts/docs-refresh-context.sh +0 -40
  53. package/scripts/init/apply.sh +0 -5
  54. package/scripts/init/status.sh +0 -5
  55. package/scripts/init/toolkit-status.sh +0 -5
  56. package/scripts/pr-create.sh +0 -5
  57. package/scripts/pr-ready-context.sh +0 -88
  58. package/scripts/present/ascii-wireframe.sh +0 -5
  59. package/scripts/present/flow-diagram.sh +0 -5
  60. package/scripts/release-notes-context.sh +0 -40
  61. package/scripts/vcs/config.sh +0 -5
  62. package/scripts/vcs/merged-style.sh +0 -5
  63. package/scripts/vcs/token-create-urls.sh +0 -5
  64. package/scripts/vcs/verify-token.sh +0 -5
  65. package/scripts/verify-project.sh +0 -140
  66. package/scripts/youtrack/api.sh +0 -5
  67. package/scripts/youtrack/config.sh +0 -5
  68. package/scripts/youtrack/greeting.sh +0 -5
  69. package/scripts/youtrack/parse-duration.sh +0 -5
  70. package/scripts/youtrack/token-create-url.sh +0 -5
  71. package/scripts/youtrack/verify-token.sh +0 -5
  72. package/scripts/youtrack/work-date-ms.sh +0 -5
  73. package/src/tools/docs-repo.ts +0 -51
  74. package/src/tools/flow.ts +0 -99
  75. package/src/tools/index.ts +0 -22
  76. package/src/tools/present.ts +0 -49
  77. package/src/tools/repo.ts +0 -490
  78. package/src/tools/rules.ts +0 -30
  79. package/src/tools/sdd.ts +0 -216
  80. package/src/tools/templates.ts +0 -27
  81. package/src/tools/youtrack.ts +0 -423
@@ -0,0 +1,801 @@
1
+ // Shared offline doctor (DG-07/DG-08, CA-09). One host-neutral engine checks the
2
+ // installed Workit surfaces — pins, versions, assets, launchers, runtimes,
3
+ // utilities, registrations, config, workspace match, credential metadata, and
4
+ // log writability — with no network access. Never reads credential values: only
5
+ // existence, mode, and a placeholder flag are evaluated; token bytes never enter
6
+ // the report or any log event.
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { SUPPORT_MATRIX } from "./support-matrix";
12
+ import { EVENT } from "./boundary";
13
+ import { getDiagnosticLogger, isConfigObject } from "./config";
14
+ import { isWorkitPlugin } from "./registration";
15
+ import { resolveWorkspaceFrom } from "./workspaces";
16
+ import { validateCursorSkills } from "./skill-manifests";
17
+
18
+ // Mirrors init.ts TOKEN_PLACEHOLDER; kept local so the doctor never needs to
19
+ // import the YouTrack/VCS stack just to label a credential state.
20
+ const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
21
+
22
+ export type DoctorHost = "cli" | "opencode" | "cursor";
23
+
24
+ export type DoctorCheckId =
25
+ | "runtime"
26
+ | "versions"
27
+ | "assets"
28
+ | "launcher"
29
+ | "utility"
30
+ | "stale_pin"
31
+ | "duplicate_registration"
32
+ | "malformed_config"
33
+ | "workspace_mismatch"
34
+ | "credential_metadata"
35
+ | "log_writable";
36
+
37
+ export type DoctorCheckStatus = "pass" | "warn" | "fail";
38
+
39
+ export type DoctorCheck = {
40
+ id: DoctorCheckId;
41
+ status: DoctorCheckStatus;
42
+ /** Bounded, path-level detail. Never contains credential values. */
43
+ detail: string;
44
+ fix?: string;
45
+ };
46
+
47
+ export type DoctorFix = { id: DoctorCheckId; fix: string };
48
+
49
+ export type DoctorSummary = { passed: number; warned: number; failed: number; total: number };
50
+
51
+ export type DoctorReport = {
52
+ ok: boolean;
53
+ exitCode: number;
54
+ offline: true;
55
+ host: DoctorHost;
56
+ checked_at: string;
57
+ summary: DoctorSummary;
58
+ checks: DoctorCheck[];
59
+ fixes: DoctorFix[];
60
+ };
61
+
62
+ export type DoctorOptions = {
63
+ host?: DoctorHost;
64
+ home?: string;
65
+ configDir?: string;
66
+ stateDir?: string;
67
+ /** Checkout containing packages/ (monorepo or share clone). */
68
+ dev?: string;
69
+ cwd?: string;
70
+ opencodeConfig?: string;
71
+ cursorSettings?: string;
72
+ cursorMcp?: string;
73
+ cursorPluginDir?: string;
74
+ env?: NodeJS.ProcessEnv;
75
+ /** Installer run: only registration/config checks count toward exitCode. */
76
+ installer?: boolean;
77
+ };
78
+
79
+ type Resolved = {
80
+ host: DoctorHost;
81
+ home: string;
82
+ configDir: string;
83
+ stateDir: string;
84
+ cwd: string;
85
+ dev: string | null;
86
+ opencodeConfig: string;
87
+ cursorSettings: string;
88
+ cursorMcp: string;
89
+ cursorPluginDir: string;
90
+ env: NodeJS.ProcessEnv;
91
+ installer: boolean;
92
+ };
93
+
94
+ const findDevFromCwd = (cwd: string): string | null => {
95
+ let dir = path.resolve(cwd);
96
+ while (true) {
97
+ if (existsSync(path.join(dir, "packages", "workit-core", "package.json"))) return dir;
98
+ const parent = path.dirname(dir);
99
+ if (parent === dir) return null;
100
+ dir = parent;
101
+ }
102
+ };
103
+
104
+ const resolve = (options: DoctorOptions): Resolved => {
105
+ const env = options.env ?? process.env;
106
+ const home = options.home ?? env.HOME ?? os.homedir();
107
+ const configDir =
108
+ options.configDir ??
109
+ env.WORKFLOW_TOOLKIT_CONFIG ??
110
+ env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
111
+ path.join(home, ".config", "workit");
112
+ const stateDir =
113
+ options.stateDir ?? env.WORKFLOW_TOOLKIT_STATE ?? path.join(home, ".local", "state", "workit");
114
+ const cwd = options.cwd ?? process.cwd();
115
+ const dev = options.dev ?? env.WORKFLOW_TOOLKIT_DEV ?? findDevFromCwd(cwd);
116
+ return {
117
+ host: options.host ?? "cli",
118
+ home,
119
+ configDir,
120
+ stateDir,
121
+ cwd,
122
+ dev,
123
+ opencodeConfig:
124
+ options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
125
+ cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
126
+ cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
127
+ cursorPluginDir:
128
+ options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workflow-toolkit"),
129
+ env,
130
+ installer: options.installer ?? false,
131
+ };
132
+ };
133
+
134
+ const readJson = (p: string): Record<string, any> | null => {
135
+ try {
136
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
137
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
138
+ ? (parsed as Record<string, any>)
139
+ : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ };
144
+
145
+ // AR-07/CA-37: a JSON file that parses but is not an object (null, scalar,
146
+ // array) is not a config file — the readers classify it malformed, so the
147
+ // doctor must flag it too (a parse-only gate would call it healthy = fail-open).
148
+ const parsesAsConfigObject = (p: string): boolean => {
149
+ try {
150
+ return isConfigObject(JSON.parse(readFileSync(p, "utf8")));
151
+ } catch {
152
+ return false;
153
+ }
154
+ };
155
+
156
+ // OpenCode accepts `plugin` as a string or an array; normalize before reading.
157
+ const pluginEntries = (cfg: Record<string, any> | null): string[] => {
158
+ const plugin = cfg?.plugin;
159
+ const list = Array.isArray(plugin) ? plugin : typeof plugin === "string" ? [plugin] : [];
160
+ return list.map(String).filter(isWorkitPlugin);
161
+ };
162
+
163
+ const commandOnPath = (name: string, env: NodeJS.ProcessEnv): boolean => {
164
+ const dirs = (env.PATH ?? process.env.PATH ?? "").split(path.delimiter);
165
+ // win32 executables carry an .exe suffix (bun.exe, git.exe), so probe both
166
+ // names — statSync with the bare name would never find them.
167
+ const names = process.platform === "win32" ? [name, `${name}.exe`] : [name];
168
+ for (const dir of dirs) {
169
+ if (!dir) continue;
170
+ for (const candidateName of names) {
171
+ const candidate = path.join(dir, candidateName);
172
+ try {
173
+ const st = statSync(candidate);
174
+ if (process.platform !== "win32" && (st.mode & 0o111) === 0) continue;
175
+ return true;
176
+ } catch {
177
+ /* keep scanning */
178
+ }
179
+ }
180
+ }
181
+ return false;
182
+ };
183
+
184
+ const versionOf = (bin: string, env: NodeJS.ProcessEnv): string | null => {
185
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8", env });
186
+ if (r.error) return null;
187
+ return (r.stdout ?? "").trim();
188
+ };
189
+
190
+ const semverAtLeast = (version: string, min: string): boolean => {
191
+ const a = version
192
+ .replace(/^v/, "")
193
+ .split(".")
194
+ .map((n) => Number(n) || 0);
195
+ const b = min.split(".").map((n) => Number(n) || 0);
196
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
197
+ const av = a[i] ?? 0;
198
+ const bv = b[i] ?? 0;
199
+ if (av !== bv) return av > bv;
200
+ }
201
+ return true;
202
+ };
203
+
204
+ const resolveBun = (env: NodeJS.ProcessEnv): string | null => {
205
+ if (env.BUN && existsSync(env.BUN)) return env.BUN;
206
+ for (const candidate of [
207
+ path.join(os.homedir(), ".bun/bin/bun"),
208
+ "/usr/local/bin/bun",
209
+ "/usr/bin/bun",
210
+ ]) {
211
+ if (existsSync(candidate)) return candidate;
212
+ }
213
+ return commandOnPath("bun", env) ? "bun" : null;
214
+ };
215
+
216
+ // Installation checks ---------------------------------------------------------
217
+
218
+ const checkRuntime = (res: Resolved): DoctorCheck => {
219
+ const node = versionOf("node", res.env);
220
+ const nodeOk = node !== null && semverAtLeast(node, SUPPORT_MATRIX.node.minimum);
221
+ const bun = resolveBun(res.env);
222
+ const bunVersion = bun ? versionOf(bun, res.env) : null;
223
+ const bunOk = bunVersion !== null;
224
+ const needsBun = res.dev !== null;
225
+ if (!nodeOk) {
226
+ return {
227
+ id: "runtime",
228
+ status: "fail",
229
+ detail: `node ${SUPPORT_MATRIX.node.minimum}+ required (found ${node ?? "none on PATH"})`,
230
+ fix: `Install Node ${SUPPORT_MATRIX.node.minimum}+ (declared support: ${SUPPORT_MATRIX.node.minimum}/current)`,
231
+ };
232
+ }
233
+ if (!bunOk && needsBun) {
234
+ return {
235
+ id: "runtime",
236
+ status: "fail",
237
+ detail: `bun not found on PATH (dev checkout requires the pinned toolchain ${SUPPORT_MATRIX.bun})`,
238
+ fix: `Install bun ${SUPPORT_MATRIX.bun} (curl -fsSL https://bun.sh/install | bash)`,
239
+ };
240
+ }
241
+ if (!bunOk) {
242
+ return {
243
+ id: "runtime",
244
+ status: "warn",
245
+ detail: "bun not found on PATH — not required for published artifacts",
246
+ fix: "Install bun for development/installer use",
247
+ };
248
+ }
249
+ return {
250
+ id: "runtime",
251
+ status: "pass",
252
+ detail: `node ${node} (>=${SUPPORT_MATRIX.node.minimum}), bun ${bunVersion ?? "n/a"}`,
253
+ };
254
+ };
255
+
256
+ const checkVersions = (res: Resolved): DoctorCheck => {
257
+ if (!res.dev) {
258
+ return {
259
+ id: "versions",
260
+ status: "warn",
261
+ detail: "no dev checkout found (WORKFLOW_TOOLKIT_DEV) — skipping version parity",
262
+ };
263
+ }
264
+ const corePkg = readJson(path.join(res.dev, "packages/workit-core/package.json"));
265
+ if (!corePkg) {
266
+ return {
267
+ id: "versions",
268
+ status: "warn",
269
+ detail: "dev checkout has no workit-core manifest — skipping version parity",
270
+ };
271
+ }
272
+ const refs = new Set<string>();
273
+ for (const name of ["workit-opencode", "workit-cursor", "workit-cli"]) {
274
+ const pkg = readJson(path.join(res.dev, "packages", name, "package.json"));
275
+ const dep = pkg?.dependencies?.["@brainervirus/workit-core"];
276
+ if (typeof dep === "string") refs.add(dep);
277
+ }
278
+ if (refs.size === 0) {
279
+ return {
280
+ id: "versions",
281
+ status: "warn",
282
+ detail: "no adapter core references found in the dev checkout",
283
+ };
284
+ }
285
+ const problems: string[] = [];
286
+ if (refs.size > 1) {
287
+ problems.push(`adapters pin different core versions: ${[...refs].join(", ")}`);
288
+ }
289
+ const opencodePkg = readJson(path.join(res.dev, "packages/workit-opencode/package.json"));
290
+ const sdk = opencodePkg?.dependencies?.["@opencode-ai/plugin"];
291
+ const sdkVersion = typeof sdk === "string" ? (sdk.match(/^\d+(?:\.\d+){0,2}/) ?? [])[0] : null;
292
+ if (sdkVersion && !semverAtLeast(sdkVersion, SUPPORT_MATRIX.opencode.minimum)) {
293
+ problems.push(
294
+ `@opencode-ai/plugin ${sdk} is older than the supported minimum ${SUPPORT_MATRIX.opencode.minimum}`,
295
+ );
296
+ }
297
+ if (problems.length === 0) {
298
+ return {
299
+ id: "versions",
300
+ status: "pass",
301
+ detail: `adapter core references consistent (${[...refs].join(", ")})`,
302
+ };
303
+ }
304
+ return {
305
+ id: "versions",
306
+ status: "fail",
307
+ detail: problems.join("; "),
308
+ fix: "Align every adapter to the same @brainervirus/workit-core version (rewrite-workspace-deps.ts) or reinstall",
309
+ };
310
+ };
311
+
312
+ const assetPathsFor = (host: DoctorHost, dev: string): string[] => {
313
+ const pkg = path.join(dev, "packages", `workit-${host}`);
314
+ switch (host) {
315
+ case "opencode":
316
+ return [
317
+ path.join(pkg, "assets", "commands", "wk-init.md"),
318
+ path.join(pkg, "assets", "skills", "wk-init", "SKILL.md"),
319
+ path.join(pkg, "assets", "templates", "spec-template.md"),
320
+ path.join(pkg, "assets", "vendor", "superpowers", "skills", "brainstorming", "SKILL.md"),
321
+ ];
322
+ case "cursor":
323
+ return [
324
+ path.join(pkg, "assets", "templates", "spec-template.md"),
325
+ path.join(pkg, "mcp.json"),
326
+ path.join(pkg, "marketplace.json"),
327
+ path.join(pkg, ".cursor-plugin"),
328
+ ];
329
+ case "cli":
330
+ return [path.join(pkg, "assets", "templates", "spec-template.md")];
331
+ }
332
+ };
333
+
334
+ // The CLI doctor is comprehensive: it verifies every host package, while the
335
+ // host tools verify only their own package.
336
+ const hostsFor = (host: DoctorHost): DoctorHost[] =>
337
+ host === "cli" ? ["opencode", "cursor", "cli"] : [host];
338
+
339
+ const checkAssets = (res: Resolved): DoctorCheck => {
340
+ const dev = res.dev;
341
+ const missing = dev
342
+ ? hostsFor(res.host).flatMap((h) =>
343
+ assetPathsFor(h, dev)
344
+ .filter((p) => !existsSync(p))
345
+ .map((p) => `${h}: ${p}`),
346
+ )
347
+ : [];
348
+ if (res.host === "cursor" || res.host === "cli") {
349
+ const cursorError = validateCursorSkills(res.cursorPluginDir);
350
+ if (cursorError) missing.push(`cursor: ${cursorError}`);
351
+ }
352
+ if (missing.length === 0) {
353
+ if (!dev && res.host === "opencode") {
354
+ return {
355
+ id: "assets",
356
+ status: "warn",
357
+ detail: "no dev checkout found (WORKFLOW_TOOLKIT_DEV) — skipping asset check",
358
+ };
359
+ }
360
+ return {
361
+ id: "assets",
362
+ status: "pass",
363
+ detail: dev ? `${res.host} assets present` : "installed Cursor skills valid",
364
+ };
365
+ }
366
+ return {
367
+ id: "assets",
368
+ status: "fail",
369
+ detail: `missing assets: ${missing.join(", ")}`,
370
+ fix: "Reinstall or rebuild the workit package (missing assets under packages/workit-<host>)",
371
+ };
372
+ };
373
+
374
+ const launcherSlotsFor = (host: DoctorHost, dev: string): string[][] => {
375
+ const pkg = path.join(dev, "packages", `workit-${host}`);
376
+ switch (host) {
377
+ case "opencode":
378
+ return [[path.join(pkg, "src", "plugin.ts"), path.join(pkg, "dist", "plugin.js")]];
379
+ case "cursor":
380
+ return [
381
+ [path.join(pkg, "dist", "mcp-server.js"), path.join(pkg, "mcp", "run-server.sh")],
382
+ [
383
+ path.join(pkg, "dist", "cursor-session-start.js"),
384
+ path.join(pkg, "hooks", "session-start"),
385
+ ],
386
+ ];
387
+ case "cli":
388
+ return [[path.join(pkg, "src", "index.tsx"), path.join(pkg, "dist", "index.js")]];
389
+ }
390
+ };
391
+
392
+ const validNodeEntry = (entry: string, runtime: string, env: NodeJS.ProcessEnv): boolean => {
393
+ try {
394
+ const stat = statSync(entry);
395
+ if (
396
+ !stat.isFile() ||
397
+ stat.size === 0 ||
398
+ !readFileSync(entry, "utf8").startsWith("#!/usr/bin/env node\n")
399
+ ) {
400
+ return false;
401
+ }
402
+ return spawnSync(runtime, ["--check", entry], { encoding: "utf8", env }).status === 0;
403
+ } catch {
404
+ return false;
405
+ }
406
+ };
407
+
408
+ const registeredCursorLauncher = (
409
+ res: Resolved,
410
+ ): { runtime: string; entry: string } | null | "invalid" => {
411
+ if (!existsSync(res.cursorMcp)) return "invalid";
412
+ const config = readJson(res.cursorMcp);
413
+ if (!config) return null; // malformed_config owns malformed JSON/object reporting
414
+ const server = config.mcpServers?.workit;
415
+ if (!server || typeof server !== "object" || Array.isArray(server)) return "invalid";
416
+ const command = server.command;
417
+ const args = server.args;
418
+ if (typeof command !== "string" || !Array.isArray(args) || typeof args[0] !== "string") {
419
+ return "invalid";
420
+ }
421
+ const executable = path.basename(command).toLowerCase();
422
+ if (executable !== "node" && executable !== "node.exe") return "invalid";
423
+ return {
424
+ runtime: command,
425
+ entry: path.isAbsolute(args[0]) ? args[0] : path.resolve(path.dirname(res.cursorMcp), args[0]),
426
+ };
427
+ };
428
+
429
+ const checkLauncher = (res: Resolved): DoctorCheck => {
430
+ const dev = res.dev;
431
+ const hosts = hostsFor(res.host);
432
+ const registered = hosts.includes("cursor") ? registeredCursorLauncher(res) : null;
433
+ const runtime = registered && registered !== "invalid" ? registered.runtime : "node";
434
+ const missing = hosts.includes("cursor")
435
+ ? ["dist/mcp-server.js", "dist/cursor-session-start.js"]
436
+ .map((rel) => path.join(res.cursorPluginDir, rel))
437
+ .filter((p) => !validNodeEntry(p, runtime, res.env))
438
+ .map((p) => `cursor: ${p}`)
439
+ : [];
440
+ if (hosts.includes("cursor")) {
441
+ if (registered === "invalid") {
442
+ missing.push(`cursor: canonical workit MCP launcher in ${res.cursorMcp}`);
443
+ } else if (registered && !validNodeEntry(registered.entry, registered.runtime, res.env)) {
444
+ missing.push(`cursor: registered ${registered.entry}`);
445
+ }
446
+ }
447
+ if (dev) {
448
+ missing.push(
449
+ ...hosts
450
+ .filter((h) => h !== "cursor")
451
+ .flatMap((h) =>
452
+ launcherSlotsFor(h, dev)
453
+ .filter((slot) => !slot.some((p) => existsSync(p)))
454
+ .map((slot) => `${h}: ${slot.join(" or ")}`),
455
+ ),
456
+ );
457
+ }
458
+ if (missing.length > 0) {
459
+ return {
460
+ id: "launcher",
461
+ status: "fail",
462
+ detail: `missing or invalid launcher entry: ${missing.join("; ")}`,
463
+ fix: `Rebuild and reinstall the workit package (bun run build) — dist entries must be non-empty Node launchers`,
464
+ };
465
+ }
466
+ if (!dev && res.host !== "cursor") {
467
+ return {
468
+ id: "launcher",
469
+ status: "warn",
470
+ detail: "no dev checkout found (WORKFLOW_TOOLKIT_DEV) — skipping non-Cursor launcher checks",
471
+ };
472
+ }
473
+ return { id: "launcher", status: "pass", detail: `${res.host} launcher/hook entries present` };
474
+ };
475
+
476
+ const checkUtility = (res: Resolved): DoctorCheck => {
477
+ const git = commandOnPath("git", res.env);
478
+ if (!git) {
479
+ return {
480
+ id: "utility",
481
+ status: "fail",
482
+ detail: "git not found in PATH — installers, PRs and verification require it",
483
+ fix: "Install git and ensure it is on PATH",
484
+ };
485
+ }
486
+ if (process.platform !== "win32" && !commandOnPath("flock", res.env)) {
487
+ return {
488
+ id: "utility",
489
+ status: "warn",
490
+ detail: "flock (util-linux) not found — sync-runtime requires it for locking",
491
+ fix: "Install util-linux (flock)",
492
+ };
493
+ }
494
+ return { id: "utility", status: "pass", detail: "git (and flock where required) on PATH" };
495
+ };
496
+
497
+ const staleEntry = (entry: string): "ok" | "stale" | "missing-file" => {
498
+ if (!entry) return "stale";
499
+ if (entry.includes("git+file")) return "stale";
500
+ if (entry.startsWith("file:")) {
501
+ const target = entry.replace(/^file:\/\//, "").replace(/^file:/, "");
502
+ return existsSync(target) ? "ok" : "missing-file";
503
+ }
504
+ // registry/ssh/git pins are not the canonical file pin, but are left to the
505
+ // duplicate/versions checks rather than being labelled stale.
506
+ return "ok";
507
+ };
508
+
509
+ const checkStalePin = (res: Resolved): DoctorCheck => {
510
+ if (res.host === "cursor") {
511
+ return {
512
+ id: "stale_pin",
513
+ status: "pass",
514
+ detail: "opencode pin not inspected on the cursor host",
515
+ };
516
+ }
517
+ if (!existsSync(res.opencodeConfig)) {
518
+ return { id: "stale_pin", status: "pass", detail: "no opencode config — not registered" };
519
+ }
520
+ const cfg = readJson(res.opencodeConfig);
521
+ const entries = pluginEntries(cfg);
522
+ if (entries.length === 0) {
523
+ return {
524
+ id: "stale_pin",
525
+ status: "fail",
526
+ detail: "no workit plugin registered in the opencode config",
527
+ fix: "Run install-opencode-plugin.sh to pin the workit plugin entry",
528
+ };
529
+ }
530
+ const first = staleEntry(entries[0]);
531
+ if (first === "stale") {
532
+ return {
533
+ id: "stale_pin",
534
+ status: "fail",
535
+ detail: `stale workit pin: ${entries[0]}`,
536
+ fix: "Re-run install-opencode-plugin.sh — the pin points at a git+file or non-file source",
537
+ };
538
+ }
539
+ if (first === "missing-file") {
540
+ return {
541
+ id: "stale_pin",
542
+ status: "fail",
543
+ detail: `workit pin points at a missing file: ${entries[0]}`,
544
+ fix: "Re-run install-opencode-plugin.sh after restoring the checkout the pin references",
545
+ };
546
+ }
547
+ return { id: "stale_pin", status: "pass", detail: "opencode pin resolves" };
548
+ };
549
+
550
+ const checkDuplicateRegistration = (res: Resolved): DoctorCheck => {
551
+ const problems: string[] = [];
552
+ const opencodeHost = res.host !== "cursor";
553
+ const cursorHost = res.host !== "opencode";
554
+
555
+ if (opencodeHost && existsSync(res.opencodeConfig)) {
556
+ const cfg = readJson(res.opencodeConfig);
557
+ const entries = pluginEntries(cfg);
558
+ if (entries.length > 1)
559
+ problems.push(`opencode registers ${entries.length} workit plugin entries`);
560
+ }
561
+ if (cursorHost && existsSync(res.cursorSettings)) {
562
+ const settings = readJson(res.cursorSettings);
563
+ const enabled = settings?.enabled_plugins;
564
+ if (enabled && typeof enabled === "object") {
565
+ const keys = Object.keys(enabled).filter((k) => isWorkitPlugin(k));
566
+ if (keys.length > 1)
567
+ problems.push(
568
+ `cursor enables ${keys.length} workit plugin identities (${keys.join(", ")})`,
569
+ );
570
+ }
571
+ const dirs = Array.isArray(settings?.plugin_dirs)
572
+ ? settings.plugin_dirs
573
+ .map(String)
574
+ .filter((d) => isWorkitPlugin(d) || d.includes("workflow-toolkit"))
575
+ : [];
576
+ if (dirs.length > 1) problems.push(`cursor plugin_dirs has ${dirs.length} workit entries`);
577
+ }
578
+ if (cursorHost && existsSync(res.cursorMcp)) {
579
+ const mcp = readJson(res.cursorMcp);
580
+ const servers = mcp?.mcpServers ?? {};
581
+ if (servers && typeof servers === "object") {
582
+ const workitServers = Object.keys(servers).filter(
583
+ (s) => s === "workit" || s.includes("workflow-toolkit"),
584
+ );
585
+ if (workitServers.length > 1)
586
+ problems.push(
587
+ `cursor registers ${workitServers.length} workit MCP servers (${workitServers.join(", ")})`,
588
+ );
589
+ }
590
+ }
591
+ if (problems.length === 0) {
592
+ return { id: "duplicate_registration", status: "pass", detail: "no duplicate registrations" };
593
+ }
594
+ return {
595
+ id: "duplicate_registration",
596
+ status: "fail",
597
+ detail: problems.join("; "),
598
+ fix: "Re-run install-opencode-plugin.sh / install-cursor-plugin.sh to deduplicate registrations",
599
+ };
600
+ };
601
+
602
+ const checkMalformedConfig = (res: Resolved): DoctorCheck => {
603
+ const files: string[] = [];
604
+ for (const name of ["config.json", "youtrack.json", "vcs.json", "workspaces.json"]) {
605
+ const p = path.join(res.configDir, name);
606
+ if (existsSync(p)) files.push(p);
607
+ }
608
+ const opencodeHost = res.host !== "cursor";
609
+ const cursorHost = res.host !== "opencode";
610
+ if (opencodeHost && existsSync(res.opencodeConfig)) files.push(res.opencodeConfig);
611
+ if (cursorHost && existsSync(res.cursorSettings)) files.push(res.cursorSettings);
612
+ if (cursorHost && existsSync(res.cursorMcp)) files.push(res.cursorMcp);
613
+ const bad = files.filter((p) => !parsesAsConfigObject(p));
614
+ if (bad.length === 0)
615
+ return { id: "malformed_config", status: "pass", detail: "config files parse" };
616
+ return {
617
+ id: "malformed_config",
618
+ status: "fail",
619
+ detail: `malformed config: ${bad.join(", ")}`,
620
+ fix: `Repair the malformed config in ${bad[0]}`,
621
+ };
622
+ };
623
+
624
+ const checkWorkspaceMismatch = (res: Resolved): DoctorCheck => {
625
+ const file = path.join(res.configDir, "workspaces.json");
626
+ if (!existsSync(file))
627
+ return { id: "workspace_mismatch", status: "pass", detail: "no workspaces configured" };
628
+ const ws = readJson(file);
629
+ if (!Array.isArray(ws?.workspaces)) {
630
+ return { id: "workspace_mismatch", status: "pass", detail: "no workspaces configured" };
631
+ }
632
+ const match = resolveWorkspaceFrom(res.cwd, res.configDir);
633
+ if (match)
634
+ return {
635
+ id: "workspace_mismatch",
636
+ status: "pass",
637
+ detail: `current directory matches workspace "${match.name}"`,
638
+ };
639
+ return {
640
+ id: "workspace_mismatch",
641
+ status: "fail",
642
+ detail: `current directory ${res.cwd} does not match any configured workspace`,
643
+ fix: `Add a workspace glob matching this directory to ${file} or run /wk-init`,
644
+ };
645
+ };
646
+
647
+ // Credential metadata: existence, mode, placeholder — never the value.
648
+ const isPlaceholder = (p: string): boolean => {
649
+ try {
650
+ return readFileSync(p, "utf8").trim() === TOKEN_PLACEHOLDER;
651
+ } catch {
652
+ return false;
653
+ }
654
+ };
655
+
656
+ const checkCredentialMetadata = (res: Resolved): DoctorCheck => {
657
+ const tokenPaths: string[] = [];
658
+ const youtrackJson = readJson(path.join(res.configDir, "youtrack.json"));
659
+ if (youtrackJson) {
660
+ tokenPaths.push(
661
+ typeof youtrackJson.tokenFile === "string"
662
+ ? youtrackJson.tokenFile
663
+ : path.join(res.configDir, "youtrack.token"),
664
+ );
665
+ } else if (existsSync(path.join(res.configDir, "youtrack.token"))) {
666
+ tokenPaths.push(path.join(res.configDir, "youtrack.token"));
667
+ }
668
+
669
+ const vcsJson = readJson(path.join(res.configDir, "vcs.json"));
670
+ for (const key of ["gitlab", "github"] as const) {
671
+ const provider = vcsJson?.[key];
672
+ if (provider && typeof provider === "object") {
673
+ const tf = provider.tokenFile;
674
+ tokenPaths.push(typeof tf === "string" ? tf : path.join(res.configDir, `${key}.token`));
675
+ } else if (!vcsJson && existsSync(path.join(res.configDir, `${key}.token`))) {
676
+ tokenPaths.push(path.join(res.configDir, `${key}.token`));
677
+ }
678
+ }
679
+
680
+ if (tokenPaths.length === 0) {
681
+ return { id: "credential_metadata", status: "pass", detail: "no credentials configured" };
682
+ }
683
+ const problems: string[] = [];
684
+ for (const raw of tokenPaths) {
685
+ const p = path.isAbsolute(raw) ? raw : path.resolve(res.configDir, raw);
686
+ if (!existsSync(p)) {
687
+ problems.push(`${p} is missing`);
688
+ continue;
689
+ }
690
+ if (process.platform !== "win32") {
691
+ const mode = statSync(p).mode & 0o777;
692
+ if (mode !== 0o600) problems.push(`${p} must be mode 0600 (found ${mode.toString(8)})`);
693
+ }
694
+ if (isPlaceholder(p)) problems.push(`${p} still contains the placeholder token`);
695
+ }
696
+ if (problems.length === 0) {
697
+ return {
698
+ id: "credential_metadata",
699
+ status: "pass",
700
+ detail: "credential files present with safe metadata",
701
+ };
702
+ }
703
+ return {
704
+ id: "credential_metadata",
705
+ status: "fail",
706
+ detail: problems.join("; "),
707
+ fix: "Create or fix the token file (mode 0600, real value) — see /wk-status for the exact path",
708
+ };
709
+ };
710
+
711
+ const checkLogWritable = (res: Resolved): DoctorCheck => {
712
+ const logsDir = path.join(res.stateDir, "logs");
713
+ // Fixed-name probe: even a killed process leaves at most one bounded file that
714
+ // the next run overwrites; the finally removes it on any thrown path.
715
+ const probe = path.join(logsDir, "doctor-probe.tmp");
716
+ try {
717
+ mkdirSync(logsDir, { recursive: true, mode: 0o700 });
718
+ writeFileSync(probe, '{"probe":true}\n', { mode: 0o600 });
719
+ return { id: "log_writable", status: "pass", detail: "log directory writable" };
720
+ } catch (err) {
721
+ return {
722
+ id: "log_writable",
723
+ status: "fail",
724
+ detail: `log directory not writable: ${err instanceof Error ? err.message : String(err)}`,
725
+ fix: `Fix permissions on ${logsDir} or set WORKFLOW_TOOLKIT_STATE to a writable directory`,
726
+ };
727
+ } finally {
728
+ try {
729
+ unlinkSync(probe);
730
+ } catch {
731
+ /* already gone */
732
+ }
733
+ }
734
+ };
735
+
736
+ const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
737
+ checkRuntime,
738
+ checkVersions,
739
+ checkAssets,
740
+ checkLauncher,
741
+ checkUtility,
742
+ checkStalePin,
743
+ checkDuplicateRegistration,
744
+ checkMalformedConfig,
745
+ checkWorkspaceMismatch,
746
+ checkCredentialMetadata,
747
+ checkLogWritable,
748
+ ];
749
+
750
+ // AR-11/CA-40: the installer guarantees the selected host itself — runtime,
751
+ // assets, launchers, registration, and required utilities — plus the config it
752
+ // just wrote. Those defects stay failures with nonzero status; only optional
753
+ // parity checks (versions/workspace/credentials/log) may downgrade to warnings.
754
+ const INSTALLER_REQUIRED = new Set<DoctorCheckId>([
755
+ "runtime",
756
+ "assets",
757
+ "launcher",
758
+ "utility",
759
+ "stale_pin",
760
+ "duplicate_registration",
761
+ "malformed_config",
762
+ ]);
763
+
764
+ export const runDoctor = (options: DoctorOptions = {}): DoctorReport => {
765
+ const res = resolve(options);
766
+ const raw = RUN_CHECKS.map((fn) => fn(res));
767
+ const checks = res.installer
768
+ ? raw.map((c) =>
769
+ c.status === "fail" && !INSTALLER_REQUIRED.has(c.id)
770
+ ? { ...c, status: "warn" as const, detail: `${c.detail} (not enforced by installer)` }
771
+ : c,
772
+ )
773
+ : raw;
774
+
775
+ const failed = checks.filter((c) => c.status === "fail").length;
776
+ const warned = checks.filter((c) => c.status === "warn").length;
777
+ const passed = checks.filter((c) => c.status === "pass").length;
778
+ const exitCode = failed > 0 ? 1 : 0;
779
+ const report: DoctorReport = {
780
+ ok: failed === 0,
781
+ exitCode,
782
+ offline: true,
783
+ host: res.host,
784
+ checked_at: new Date().toISOString(),
785
+ summary: { passed, warned, failed, total: checks.length },
786
+ checks,
787
+ fixes: checks
788
+ .filter((c) => c.status === "fail" && c.fix)
789
+ .map((c) => ({ id: c.id, fix: c.fix! })),
790
+ };
791
+
792
+ getDiagnosticLogger()?.info(EVENT.doctor, {
793
+ host: res.host,
794
+ exit_code: exitCode,
795
+ offline: true,
796
+ failed: checks.filter((c) => c.status === "fail").map((c) => c.id),
797
+ total: checks.length,
798
+ });
799
+
800
+ return report;
801
+ };