@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.
@@ -1,1882 +0,0 @@
1
- // Shared offline doctor (DG-07/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 except the optional registry probe
5
- // behind the stale-install comparison (CA-04) and fail-open provider CLI
6
- // identity probes. Credentials are handled by gh/glab; token values never enter
7
- // the report, any fix text, or any log event.
8
- import { spawnSync } from "node:child_process";
9
- import {
10
- existsSync,
11
- lstatSync,
12
- mkdirSync,
13
- readFileSync,
14
- readlinkSync,
15
- statSync,
16
- unlinkSync,
17
- writeFileSync,
18
- } from "node:fs";
19
- import os from "node:os";
20
- import path from "node:path";
21
- import { SUPPORT_MATRIX } from "./support-matrix";
22
- import { inspectMetadataLock } from "./store-lock";
23
- import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
24
- import { EVENT } from "./boundary";
25
- import { getDiagnosticLogger, isConfigObject } from "./config";
26
- import { packageRoot } from "./package-root";
27
- import {
28
- CURSOR_RUNTIME_PACKAGE,
29
- OPENCODE_NPM_PIN,
30
- cursorHookDrift,
31
- cursorHooksEntry,
32
- cursorMcpServerEntry,
33
- isWorkitPlugin,
34
- } from "./registration";
35
- import { readWorkspacesResult, resolveWorkspaceFrom } from "./workspaces";
36
- import {
37
- CLAUDE_MARKETPLACE_NAME,
38
- claudeWorkitInstalls,
39
- type ClaudeWorkitInstall,
40
- } from "./host-install";
41
- import { validateCursorSkills, WORKIT_METHOD_SKILLS } from "./skill-manifests";
42
- import {
43
- classifyHostGeneration,
44
- digestFile,
45
- readCutoverReceipt,
46
- readGenerationState,
47
- type CutoverHost,
48
- type SessionObservation,
49
- } from "./cutover";
50
-
51
- // Mirrors init.ts TOKEN_PLACEHOLDER; kept local so the doctor never needs to
52
- // import the YouTrack/VCS stack just to label a credential state.
53
- const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE";
54
-
55
- export type DoctorHost = "cli" | "opencode" | "cursor";
56
-
57
- export type DoctorCheckId =
58
- | "runtime"
59
- | "versions"
60
- | "codex_pin"
61
- | "claude_plugin"
62
- | "assets"
63
- | "launcher"
64
- | "utility"
65
- | "stale_pin"
66
- | "stale_install"
67
- | "registry_unreachable"
68
- | "duplicate_registration"
69
- | "malformed_config"
70
- | "workspace_mismatch"
71
- | "workspace_lock"
72
- | "credential_metadata"
73
- | "github_identity"
74
- | "gitlab_identity"
75
- | "log_writable"
76
- | "legacy_component"
77
- | "mixed_generation"
78
- | "active_old_session"
79
- | "managed_content_conflict"
80
- | "missing_v1_component";
81
-
82
- export type DoctorCheckStatus = "pass" | "warn" | "fail";
83
-
84
- export type DoctorCheck = {
85
- id: DoctorCheckId;
86
- status: DoctorCheckStatus;
87
- /** Bounded, path-level detail. Never contains credential values. */
88
- detail: string;
89
- fix?: string;
90
- };
91
-
92
- export type DoctorFix = { id: DoctorCheckId; fix: string };
93
-
94
- export type DoctorSummary = {
95
- passed: number;
96
- warned: number;
97
- failed: number;
98
- total: number;
99
- };
100
-
101
- export type DoctorReport = {
102
- ok: boolean;
103
- exitCode: number;
104
- /** False when the doctor consulted the npm registry (local-dist version probe). */
105
- offline: boolean;
106
- host: DoctorHost;
107
- checked_at: string;
108
- summary: DoctorSummary;
109
- checks: DoctorCheck[];
110
- fixes: DoctorFix[];
111
- };
112
-
113
- export type DoctorOptions = {
114
- host?: DoctorHost;
115
- home?: string;
116
- configDir?: string;
117
- stateDir?: string;
118
- /** Checkout containing packages/ (monorepo or share clone). */
119
- dev?: string;
120
- cwd?: string;
121
- /** Workit store root for the lock check (default: WORKFLOW_WORKSPACE_ROOT, then cwd). */
122
- workspaceRoot?: string;
123
- opencodeConfig?: string;
124
- /** OpenCode npm `@latest` package cache root (test seam). */
125
- opencodePackageCacheDir?: string;
126
- cursorSettings?: string;
127
- cursorMcp?: string;
128
- cursorPluginDir?: string;
129
- env?: NodeJS.ProcessEnv;
130
- /** Installer run: only registration/config checks count toward exitCode. */
131
- installer?: boolean;
132
- /** Observed host sessions used by generation-aware cutover checks. */
133
- sessions?: SessionObservation[];
134
- };
135
-
136
- type Resolved = {
137
- host: DoctorHost;
138
- home: string;
139
- configDir: string;
140
- stateDir: string;
141
- cwd: string;
142
- workspaceRoot: string;
143
- dev: string | null;
144
- opencodeConfig: string;
145
- opencodePackageCacheDir: string;
146
- cursorSettings: string;
147
- cursorMcp: string;
148
- cursorPluginDir: string;
149
- env: NodeJS.ProcessEnv;
150
- installer: boolean;
151
- sessions: SessionObservation[];
152
- };
153
-
154
- const parseSessionsFromEnv = (env: NodeJS.ProcessEnv): SessionObservation[] => {
155
- const raw = env.WORKFLOW_TOOLKIT_SESSIONS;
156
- if (!raw) return [];
157
- try {
158
- const parsed = JSON.parse(raw);
159
- return Array.isArray(parsed) ? (parsed as SessionObservation[]) : [];
160
- } catch {
161
- return [];
162
- }
163
- };
164
-
165
- const findDevFromCwd = (cwd: string): string | null => {
166
- let dir = path.resolve(cwd);
167
- while (true) {
168
- if (existsSync(path.join(dir, "packages", "workit-core", "package.json"))) return dir;
169
- const parent = path.dirname(dir);
170
- if (parent === dir) return null;
171
- dir = parent;
172
- }
173
- };
174
-
175
- const resolve = (options: DoctorOptions): Resolved => {
176
- const env = options.env ?? process.env;
177
- const home = options.home ?? env.HOME ?? os.homedir();
178
- const configDir =
179
- options.configDir ??
180
- env.WORKFLOW_TOOLKIT_CONFIG ??
181
- env.WORKFLOW_TOOLKIT_CONFIG_DIR ??
182
- path.join(home, ".config", "workit");
183
- const stateDir =
184
- options.stateDir ?? env.WORKFLOW_TOOLKIT_STATE ?? path.join(home, ".local", "state", "workit");
185
- const cwd = options.cwd ?? process.cwd();
186
- const dev = options.dev ?? env.WORKFLOW_TOOLKIT_DEV ?? findDevFromCwd(cwd);
187
- return {
188
- host: options.host ?? "cli",
189
- home,
190
- configDir,
191
- stateDir,
192
- cwd,
193
- workspaceRoot: options.workspaceRoot ?? env.WORKFLOW_WORKSPACE_ROOT ?? cwd,
194
- dev,
195
- opencodeConfig:
196
- options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
197
- opencodePackageCacheDir:
198
- options.opencodePackageCacheDir ??
199
- path.join(home, ".cache", "opencode", "packages", "@brainervirus", "workit-opencode@latest"),
200
- cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
201
- cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
202
- cursorPluginDir:
203
- options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
204
- env,
205
- installer: options.installer ?? false,
206
- sessions: options.sessions ?? parseSessionsFromEnv(env),
207
- };
208
- };
209
-
210
- const readJson = (p: string): Record<string, any> | null => {
211
- try {
212
- const parsed = JSON.parse(readFileSync(p, "utf8"));
213
- return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
214
- ? (parsed as Record<string, any>)
215
- : null;
216
- } catch {
217
- return null;
218
- }
219
- };
220
-
221
- // AR-07/CA-37: a JSON file that parses but is not an object (null, scalar,
222
- // array) is not a config file — the readers classify it malformed, so the
223
- // doctor must flag it too (a parse-only gate would call it healthy = fail-open).
224
- const parsesAsConfigObject = (p: string): boolean => {
225
- try {
226
- return isConfigObject(JSON.parse(readFileSync(p, "utf8")));
227
- } catch {
228
- return false;
229
- }
230
- };
231
-
232
- // V2 uses plugins; V1 accepts plugin as a string or array. Inspect both.
233
- const pluginEntries = (cfg: Record<string, any> | null): string[] => {
234
- const entries = [cfg?.plugins, cfg?.plugin].flatMap((value) =>
235
- Array.isArray(value) ? value : typeof value === "string" ? [value] : [],
236
- );
237
- return [
238
- ...new Set(
239
- entries.filter(
240
- (entry): entry is string => typeof entry === "string" && isWorkitPlugin(entry),
241
- ),
242
- ),
243
- ];
244
- };
245
-
246
- export const commandOnPath = (name: string, env: NodeJS.ProcessEnv): boolean => {
247
- const dirs = (env.PATH ?? process.env.PATH ?? "").split(path.delimiter);
248
- // win32 executables carry an .exe suffix (bun.exe, git.exe), so probe both
249
- // names — statSync with the bare name would never find them.
250
- const names = process.platform === "win32" ? [name, `${name}.exe`] : [name];
251
- for (const dir of dirs) {
252
- if (!dir) continue;
253
- for (const candidateName of names) {
254
- const candidate = path.join(dir, candidateName);
255
- try {
256
- const st = statSync(candidate);
257
- if (process.platform !== "win32" && (st.mode & 0o111) === 0) continue;
258
- return true;
259
- } catch {
260
- /* keep scanning */
261
- }
262
- }
263
- }
264
- return false;
265
- };
266
-
267
- const versionOf = (bin: string, env: NodeJS.ProcessEnv): string | null => {
268
- const r = spawnSync(bin, ["--version"], { encoding: "utf8", env });
269
- if (r.error) return null;
270
- return (r.stdout ?? "").trim();
271
- };
272
-
273
- const semverAtLeast = (version: string, min: string): boolean => {
274
- const a = version
275
- .replace(/^v/, "")
276
- .split(".")
277
- .map((n) => Number(n) || 0);
278
- const b = min.split(".").map((n) => Number(n) || 0);
279
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
280
- const av = a[i] ?? 0;
281
- const bv = b[i] ?? 0;
282
- if (av !== bv) return av > bv;
283
- }
284
- return true;
285
- };
286
-
287
- const resolveBun = (env: NodeJS.ProcessEnv): string | null => {
288
- if (env.BUN && existsSync(env.BUN)) return env.BUN;
289
- for (const candidate of [
290
- path.join(os.homedir(), ".bun/bin/bun"),
291
- "/usr/local/bin/bun",
292
- "/usr/bin/bun",
293
- ]) {
294
- if (existsSync(candidate)) return candidate;
295
- }
296
- return commandOnPath("bun", env) ? "bun" : null;
297
- };
298
-
299
- // Installation checks ---------------------------------------------------------
300
-
301
- const checkRuntime = (res: Resolved): DoctorCheck => {
302
- const node = versionOf("node", res.env);
303
- const nodeOk = node !== null && semverAtLeast(node, SUPPORT_MATRIX.node.minimum);
304
- const bun = resolveBun(res.env);
305
- const bunVersion = bun ? versionOf(bun, res.env) : null;
306
- const bunOk = bunVersion !== null;
307
- const needsBun = res.dev !== null;
308
- if (!nodeOk) {
309
- return {
310
- id: "runtime",
311
- status: "fail",
312
- detail: `node ${SUPPORT_MATRIX.node.minimum}+ required (found ${node ?? "none on PATH"})`,
313
- fix: `Install Node ${SUPPORT_MATRIX.node.minimum}+ (declared support: ${SUPPORT_MATRIX.node.minimum}/current)`,
314
- };
315
- }
316
- if (!bunOk && needsBun) {
317
- return {
318
- id: "runtime",
319
- status: "fail",
320
- detail: `bun not found on PATH (dev checkout requires the pinned toolchain ${SUPPORT_MATRIX.bun})`,
321
- fix: `Install bun ${SUPPORT_MATRIX.bun} (curl -fsSL https://bun.sh/install | bash)`,
322
- };
323
- }
324
- if (!bunOk) {
325
- return {
326
- id: "runtime",
327
- status: "warn",
328
- detail: "bun not found on PATH — not required for published artifacts",
329
- fix: "Install bun for development/installer use",
330
- };
331
- }
332
- return {
333
- id: "runtime",
334
- status: "pass",
335
- detail: `node ${node} (>=${SUPPORT_MATRIX.node.minimum}), bun ${bunVersion ?? "n/a"}`,
336
- };
337
- };
338
-
339
- const checkVersions = (res: Resolved): DoctorCheck => {
340
- if (!res.dev) {
341
- return {
342
- id: "versions",
343
- status: "warn",
344
- detail: "no dev checkout found (WORKFLOW_TOOLKIT_DEV) — skipping version parity",
345
- };
346
- }
347
- const corePkg = readJson(path.join(res.dev, "packages/workit-core/package.json"));
348
- if (!corePkg) {
349
- return {
350
- id: "versions",
351
- status: "warn",
352
- detail: "dev checkout has no workit-core manifest — skipping version parity",
353
- };
354
- }
355
- const refs = new Set<string>();
356
- for (const name of ["workit-opencode", "workit-cursor", "workit-cli"]) {
357
- const pkg = readJson(path.join(res.dev, "packages", name, "package.json"));
358
- const dep = pkg?.dependencies?.["@brainervirus/workit-core"];
359
- if (typeof dep === "string") refs.add(dep);
360
- }
361
- if (refs.size === 0) {
362
- return {
363
- id: "versions",
364
- status: "warn",
365
- detail: "no adapter core references found in the dev checkout",
366
- };
367
- }
368
- const problems: string[] = [];
369
- if (refs.size > 1) {
370
- problems.push(`adapters pin different core versions: ${[...refs].join(", ")}`);
371
- }
372
- const opencodePkg = readJson(path.join(res.dev, "packages/workit-opencode/package.json"));
373
- const sdk =
374
- opencodePkg?.dependencies?.["@opencode-ai/plugin"] ??
375
- opencodePkg?.devDependencies?.["@opencode-ai/plugin"];
376
- const sdkVersion = typeof sdk === "string" ? (sdk.match(/^\d+(?:\.\d+){0,2}/) ?? [])[0] : null;
377
- if (sdkVersion && !semverAtLeast(sdkVersion, SUPPORT_MATRIX.opencode.minimum)) {
378
- problems.push(
379
- `@opencode-ai/plugin ${sdk} is older than the supported minimum ${SUPPORT_MATRIX.opencode.minimum}`,
380
- );
381
- }
382
- if (problems.length === 0) {
383
- return {
384
- id: "versions",
385
- status: "pass",
386
- detail: `adapter core references consistent (${[...refs].join(", ")})`,
387
- };
388
- }
389
- return {
390
- id: "versions",
391
- status: "fail",
392
- detail: problems.join("; "),
393
- fix: "Align every adapter to the same @brainervirus/workit-core version (rewrite-workspace-deps.ts) or reinstall",
394
- };
395
- };
396
-
397
- // The Codex CLI is a qualification host, not a runtime dependency: evidence
398
- // covers exactly SUPPORT_MATRIX.codex.cli. A drifted install keeps working,
399
- // but warns so a fresh install never silently outruns the qualification pin.
400
- const checkCodexPin = (res: Resolved): DoctorCheck => {
401
- const qualified = SUPPORT_MATRIX.codex.cli;
402
- if (!commandOnPath("codex", res.env))
403
- return {
404
- id: "codex_pin",
405
- status: "pass",
406
- detail: "codex CLI not on PATH — skipping pin check",
407
- };
408
- const raw = versionOf("codex", res.env);
409
- const installed = raw ? ((raw.match(/\d+\.\d+\.\d+/) ?? [])[0] ?? null) : null;
410
- if (installed === null)
411
- return {
412
- id: "codex_pin",
413
- status: "warn",
414
- detail: `could not parse the installed codex version — cannot confirm the qualified ${qualified}`,
415
- fix: `Reinstall the qualified Codex CLI ${qualified} or record fresh qualification evidence`,
416
- };
417
- if (installed === qualified)
418
- return {
419
- id: "codex_pin",
420
- status: "pass",
421
- detail: `codex CLI ${installed} matches the qualified pin`,
422
- };
423
- return {
424
- id: "codex_pin",
425
- status: "warn",
426
- detail: `codex CLI ${installed} differs from the qualified ${qualified}`,
427
- fix: `Reinstall the qualified Codex CLI ${qualified} or record fresh qualification evidence`,
428
- };
429
- };
430
-
431
- const detectDevGeneration = (dev: string): "legacy" | "v1" =>
432
- existsSync(path.join(dev, "packages/workit-opencode/assets/skills/workit-plan/SKILL.md"))
433
- ? "v1"
434
- : "legacy";
435
-
436
- const effectiveGeneration = (res: Resolved): "legacy" | "v1" => {
437
- const configured = readGenerationState(res.configDir).target;
438
- if (configured === "v1") return "v1";
439
- if (res.dev && detectDevGeneration(res.dev) === "v1") return "v1";
440
- return "legacy";
441
- };
442
-
443
- const assetPathsFor = (host: DoctorHost, dev: string, _generation: "legacy" | "v1"): string[] => {
444
- const pkg = path.join(dev, "packages", `workit-${host}`);
445
- switch (host) {
446
- case "opencode":
447
- return WORKIT_METHOD_SKILLS.map((skill) =>
448
- path.join(pkg, "assets", "skills", skill, "SKILL.md"),
449
- );
450
- case "cursor":
451
- return [
452
- path.join(pkg, "assets", "templates", "workit-contract.md"),
453
- path.join(pkg, "mcp.json"),
454
- path.join(pkg, ".cursor-plugin"),
455
- ];
456
- case "cli":
457
- return [path.join(pkg, "assets", "templates", "spec-template.md")];
458
- }
459
- };
460
-
461
- // The CLI doctor is comprehensive: it verifies every host package, while the
462
- // host tools verify only their own package.
463
- const hostsFor = (host: DoctorHost): DoctorHost[] =>
464
- host === "cli" ? ["opencode", "cursor", "cli"] : [host];
465
-
466
- const checkAssets = (res: Resolved): DoctorCheck => {
467
- const dev = res.dev;
468
- const generation = effectiveGeneration(res);
469
- const missing = dev
470
- ? hostsFor(res.host).flatMap((h) =>
471
- assetPathsFor(h, dev, generation)
472
- .filter((p) => !existsSync(p))
473
- .map((p) => `${h}: ${p}`),
474
- )
475
- : [];
476
- if (res.host === "cursor" || res.host === "cli") {
477
- const cursorError = validateCursorSkills(res.cursorPluginDir, WORKIT_METHOD_SKILLS);
478
- if (cursorError) missing.push(`cursor: ${cursorError}`);
479
- }
480
- if (missing.length === 0) {
481
- if (!dev && res.host === "opencode") {
482
- return {
483
- id: "assets",
484
- status: "warn",
485
- detail: "no dev checkout found (WORKFLOW_TOOLKIT_DEV) — skipping asset check",
486
- };
487
- }
488
- return {
489
- id: "assets",
490
- status: "pass",
491
- detail: dev ? `${res.host} assets present` : "installed Cursor skills valid",
492
- };
493
- }
494
- return {
495
- id: "assets",
496
- status: "fail",
497
- detail: `missing assets: ${missing.join(", ")}`,
498
- fix: "Reinstall or rebuild the workit package (missing assets under packages/workit-<host>)",
499
- };
500
- };
501
-
502
- const launcherSlotsFor = (host: DoctorHost, dev: string): string[][] => {
503
- const pkg = path.join(dev, "packages", `workit-${host}`);
504
- switch (host) {
505
- case "opencode":
506
- return [[path.join(pkg, "src", "plugin.ts"), path.join(pkg, "dist", "plugin.js")]];
507
- case "cursor":
508
- // The dist entries are the npm bin targets the npx launcher executes.
509
- return [
510
- [path.join(pkg, "dist", "mcp-server.js")],
511
- [path.join(pkg, "dist", "cursor-session-start.js")],
512
- ];
513
- case "cli":
514
- return [[path.join(pkg, "src", "index.tsx"), path.join(pkg, "dist", "index.js")]];
515
- }
516
- };
517
-
518
- const validNodeEntry = (entry: string, runtime: string, env: NodeJS.ProcessEnv): boolean => {
519
- try {
520
- const stat = statSync(entry);
521
- if (
522
- !stat.isFile() ||
523
- stat.size === 0 ||
524
- !readFileSync(entry, "utf8").startsWith("#!/usr/bin/env node\n")
525
- ) {
526
- return false;
527
- }
528
- return spawnSync(runtime, ["--check", entry], { encoding: "utf8", env }).status === 0;
529
- } catch {
530
- return false;
531
- }
532
- };
533
-
534
- type CursorLauncher = { kind: "node"; runtime: string; entry: string } | { kind: "npx" };
535
-
536
- const registeredCursorLauncher = (res: Resolved): CursorLauncher | null | "invalid" => {
537
- if (!existsSync(res.cursorMcp)) return "invalid";
538
- const config = readJson(res.cursorMcp);
539
- if (!config) return null; // malformed_config owns malformed JSON/object reporting
540
- const server = config.mcpServers?.workit;
541
- if (!server || typeof server !== "object" || Array.isArray(server)) return "invalid";
542
- const command = server.command;
543
- const args = server.args;
544
- if (typeof command !== "string" || !Array.isArray(args) || typeof args[0] !== "string") {
545
- return "invalid";
546
- }
547
- const executable = path.basename(command).toLowerCase();
548
- // CA-17: the canonical launcher runs the published package through npx; the
549
- // offline doctor validates its shape (never the registry reachability).
550
- // Exact positional tokens — a substring match would accept `@latest-alpha`
551
- // or `workit-cursor-mcp-foo`, and any extra/missing token would weaken the
552
- // freshness guarantee (`--prefer-online` and the npm release-age workaround
553
- // are mandatory).
554
- const canonical = cursorMcpServerEntry("").args;
555
- if (executable === "npx" || executable === "npx.exe" || executable === "npx.cmd") {
556
- if (args.length !== canonical.length || args.some((a, i) => a !== canonical[i]))
557
- return "invalid";
558
- return { kind: "npx" };
559
- }
560
- if (executable !== "node" && executable !== "node.exe") return "invalid";
561
- return {
562
- kind: "node",
563
- runtime: command,
564
- entry: path.isAbsolute(args[0]) ? args[0] : path.resolve(path.dirname(res.cursorMcp), args[0]),
565
- };
566
- };
567
-
568
- // CA-17: the canonical session-start hook runs the published package through
569
- // npx as a single command string (Cursor's documented hook format). The doctor
570
- // validates its shape exactly like the MCP launcher — no substring matching,
571
- // no version abstraction.
572
- const canonicalCursorHook = cursorHooksEntry("").command;
573
-
574
- // Returns the registered hook command, null when absent, or "invalid".
575
- const registeredCursorHook = (res: Resolved): string | null => {
576
- const hooksFile = path.join(res.cursorPluginDir, "hooks", "hooks-cursor.json");
577
- if (!existsSync(hooksFile)) return "invalid";
578
- const config = readJson(hooksFile);
579
- // Unlike mcp.json, the hook file is not covered by checkMalformedConfig, so
580
- // an unparseable hook must fail the launcher check rather than slip through.
581
- if (!config) return "invalid";
582
- const sessionStart = config.hooks?.sessionStart;
583
- if (!Array.isArray(sessionStart) || sessionStart.length !== 1) return "invalid";
584
- const entry = sessionStart[0];
585
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return "invalid";
586
- return typeof entry.command === "string" ? entry.command : "invalid";
587
- };
588
-
589
- // The local-dist install points the session-start hook at the plugin's own
590
- // built dist (`node <pluginDir>/dist/cursor-session-start.js`) so Cursor runs
591
- // the checkout's code. Accept that form — validated against the real dist entry
592
- // — alongside the canonical npx pin, mirroring the MCP launcher's node form.
593
- const validLocalDistHook = (command: string, res: Resolved): boolean => {
594
- const m = /^node\s+(.+)$/.exec(command.trim());
595
- if (!m) return false;
596
- const entry = m[1].trim();
597
- if (!path.isAbsolute(entry)) return false;
598
- if (
599
- path.resolve(entry) !==
600
- path.resolve(path.join(res.cursorPluginDir, "dist", "cursor-session-start.js"))
601
- ) {
602
- return false;
603
- }
604
- return validNodeEntry(entry, "node", res.env);
605
- };
606
-
607
- const checkLauncher = (res: Resolved): DoctorCheck => {
608
- const dev = res.dev;
609
- const hosts = hostsFor(res.host);
610
- const registered = hosts.includes("cursor") ? registeredCursorLauncher(res) : null;
611
- const runtime =
612
- registered && registered !== "invalid" && registered.kind === "node"
613
- ? registered.runtime
614
- : "node";
615
- const missing = hosts.includes("cursor")
616
- ? ["dist/mcp-server.js", "dist/cursor-session-start.js"]
617
- .map((rel) => path.join(res.cursorPluginDir, rel))
618
- .filter((p) => !validNodeEntry(p, runtime, res.env))
619
- .map((p) => `cursor: ${p}`)
620
- : [];
621
- if (hosts.includes("cursor")) {
622
- if (registered === "invalid") {
623
- missing.push(`cursor: canonical workit MCP launcher in ${res.cursorMcp}`);
624
- } else if (
625
- registered &&
626
- registered.kind === "node" &&
627
- !validNodeEntry(registered.entry, registered.runtime, res.env)
628
- ) {
629
- missing.push(`cursor: registered ${registered.entry}`);
630
- }
631
- const hook = registeredCursorHook(res);
632
- if (hook === "invalid") {
633
- missing.push(
634
- `cursor: canonical session-start hook in ${path.join(res.cursorPluginDir, "hooks", "hooks-cursor.json")}`,
635
- );
636
- } else if (hook !== null && hook !== canonicalCursorHook && !validLocalDistHook(hook, res)) {
637
- missing.push(
638
- `cursor: canonical session-start hook in ${path.join(res.cursorPluginDir, "hooks", "hooks-cursor.json")} (registered ${hook})`,
639
- );
640
- }
641
- }
642
- if (dev) {
643
- missing.push(
644
- ...hosts
645
- .filter((h) => h !== "cursor")
646
- .flatMap((h) =>
647
- launcherSlotsFor(h, dev)
648
- .filter((slot) => !slot.some((p) => existsSync(p)))
649
- .map((slot) => `${h}: ${slot.join(" or ")}`),
650
- ),
651
- );
652
- }
653
- if (missing.length > 0) {
654
- return {
655
- id: "launcher",
656
- status: "fail",
657
- detail: `missing or invalid launcher entry: ${missing.join("; ")}`,
658
- fix: `Rebuild and reinstall the workit package (bun run build) — dist entries must be non-empty Node launchers`,
659
- };
660
- }
661
- if (!dev && res.host !== "cursor") {
662
- return {
663
- id: "launcher",
664
- status: "warn",
665
- detail: "no dev checkout found (WORKFLOW_TOOLKIT_DEV) — skipping non-Cursor launcher checks",
666
- };
667
- }
668
- return {
669
- id: "launcher",
670
- status: "pass",
671
- detail: `${res.host} launcher/hook entries present`,
672
- };
673
- };
674
-
675
- const checkUtility = (res: Resolved): DoctorCheck => {
676
- const git = commandOnPath("git", res.env);
677
- if (!git) {
678
- return {
679
- id: "utility",
680
- status: "fail",
681
- detail: "git not found in PATH — installers, PRs and verification require it",
682
- fix: "Install git and ensure it is on PATH",
683
- };
684
- }
685
- if (process.platform !== "win32" && !commandOnPath("flock", res.env)) {
686
- return {
687
- id: "utility",
688
- status: "warn",
689
- detail: "flock (util-linux) not found — sync-runtime requires it for locking",
690
- fix: "Install util-linux (flock)",
691
- };
692
- }
693
- return {
694
- id: "utility",
695
- status: "pass",
696
- detail: "git (and flock where required) on PATH",
697
- };
698
- };
699
-
700
- /** file:// pins into package-manager ephemeral caches (pnpm dlx, npx). */
701
- const isFragileCachePin = (entry: string): boolean => {
702
- const n = entry.replaceAll("\\", "/").toLowerCase();
703
- if (!n.startsWith("file:")) return false;
704
- return isEphemeralCachePath(entry);
705
- };
706
-
707
- const staleEntry = (entry: string): "ok" | "stale" | "missing-file" | "fragile-cache" => {
708
- if (!entry) return "stale";
709
- if (entry.includes("git+file")) return "stale";
710
- if (isFragileCachePin(entry)) return "fragile-cache";
711
- if (entry.startsWith("file:")) {
712
- const target = entry.replace(/^file:\/\//, "").replace(/^file:/, "");
713
- return existsSync(target) ? "ok" : "missing-file";
714
- }
715
- // Registry package names (e.g. @brainervirus/workit-opencode) are the
716
- // published pin shape — OpenCode resolves them; not labelled stale.
717
- return "ok";
718
- };
719
-
720
- const checkStalePin = (res: Resolved): DoctorCheck => {
721
- if (res.host === "cursor") {
722
- return {
723
- id: "stale_pin",
724
- status: "pass",
725
- detail: "opencode pin not inspected on the cursor host",
726
- };
727
- }
728
- if (!existsSync(res.opencodeConfig)) {
729
- return {
730
- id: "stale_pin",
731
- status: "pass",
732
- detail: "no opencode config — not registered",
733
- };
734
- }
735
- const cfg = readJson(res.opencodeConfig);
736
- const entries = pluginEntries(cfg);
737
- if (entries.length === 0) {
738
- return {
739
- id: "stale_pin",
740
- status: "fail",
741
- detail: "no workit plugin registered in the opencode config",
742
- fix: 'Run `workit init` and select OpenCode — pin "@brainervirus/workit-opencode"',
743
- };
744
- }
745
- const first = staleEntry(entries[0]);
746
- if (first === "stale") {
747
- return {
748
- id: "stale_pin",
749
- status: "fail",
750
- detail: `stale workit pin: ${entries[0]}`,
751
- fix: "Run `workit init` (or install-opencode-plugin.sh for a checkout) — replace git+file pins",
752
- };
753
- }
754
- if (first === "fragile-cache") {
755
- return {
756
- id: "stale_pin",
757
- status: "fail",
758
- detail: `workit pin points at a package-manager cache path: ${entries[0]}`,
759
- fix: 'Run `workit init` and select OpenCode — pin should be "@brainervirus/workit-opencode"',
760
- };
761
- }
762
- if (first === "missing-file") {
763
- return {
764
- id: "stale_pin",
765
- status: "fail",
766
- detail: `workit pin points at a missing file: ${entries[0]}`,
767
- fix: "Run `workit init` (or restore the checkout and re-run install-opencode-plugin.sh)",
768
- };
769
- }
770
- return { id: "stale_pin", status: "pass", detail: "opencode pin resolves" };
771
- };
772
-
773
- // Cursor plugin stale-install detection (CA-01/CA-04). Compares the installed
774
- // `~/.cursor/plugins/local/workit` against the current runtime source of truth
775
- // (`CURSOR_RUNTIME_PACKAGE` + the canonical entry builders from registration.ts,
776
- // D-02) and surfaces a structured `stale_install` finding with the exact repair
777
- // step. A stale install means the plugin's sessionStart hook and MCP entry run
778
- // an ancient runtime that no longer auto-registers the workflow features.
779
- //
780
- // Three independent staleness signals are read from the installed plugin dir:
781
- // 1. Legacy selectors (offline evidence, checked first): the plugin's own
782
- // mcp.json (`--package=...workit-cursor@<exact>` pins other than the
783
- // canonical `@latest`) and hooks-cursor.json sessionStart command.
784
- // 2. Installed version behind the current source release (offline): the dev
785
- // checkout's workit-cursor version (or the doctor's own package version)
786
- // is the release train the doctor ships with.
787
- // 3. Local-dist installs behind the published runtime (needs the registry):
788
- // the probe is the sole network read in the doctor. A SUCCEEDED probe with
789
- // the installed version genuinely behind the published runtime is a hard
790
- // `stale_install` fail (the installer self-heals on it); an unreachable
791
- // registry fails open (CA-04) as a `registry_unreachable` warning, never a
792
- // false `stale_install` and never a hard doctor failure. Canonical
793
- // `@latest` installs skip the probe entirely: the selector resolves fresh
794
- // at launch, so the installed package.json version is metadata, not a
795
- // freshness signal.
796
- // OpenCode npm pins (`@brainervirus/workit-opencode` / `@…@latest`) are different:
797
- // OpenCode freezes the first resolved version under
798
- // `~/.cache/opencode/packages/@brainervirus/workit-opencode@latest` and does not
799
- // re-resolve on later launches. Doctor compares that cache to the published
800
- // package (same CA-04 fail-open seam) and tells the user to delete the cache
801
- // directory so OpenCode can resolve `@latest` again. Exact `@version` pins and
802
- // `file://` checkout pins skip the comparison.
803
- // Test seams (no spawns on the canonical path): WORKIT_DOCTOR_STALE_REGISTRY_VERSION
804
- // short-circuits the probe with the resolved latest version; the optional
805
- // WORKIT_DOCTOR_STALE_REGISTRY_CMD replaces the `npm view` binary (a
806
- // nonexistent path deterministically exercises the fail-open path).
807
- const pluginMcpSelectors = (res: Resolved): string[] | null => {
808
- const p = path.join(res.cursorPluginDir, "mcp.json");
809
- if (!existsSync(p)) return null;
810
- const cfg = readJson(p);
811
- const server = cfg?.mcpServers?.workit;
812
- if (!server || typeof server !== "object" || Array.isArray(server)) return null;
813
- const args = server.args;
814
- if (!Array.isArray(args)) return null;
815
- return args.map(String).filter((a) => a.startsWith("--package="));
816
- };
817
-
818
- const isFloatingOpenCodeNpmPin = (entry: string): boolean => {
819
- const s = entry.trim();
820
- return s === OPENCODE_NPM_PIN || s === `${OPENCODE_NPM_PIN}@latest`;
821
- };
822
-
823
- const registryLatestVersion = (
824
- res: Resolved,
825
- pkgName: string = CURSOR_RUNTIME_PACKAGE,
826
- ): string | null => {
827
- const seam = res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION;
828
- if (typeof seam === "string" && seam) {
829
- return /^\d+(?:\.\d+){1,2}$/.test(seam) ? seam : null;
830
- }
831
- const cmd =
832
- res.env.WORKIT_DOCTOR_STALE_REGISTRY_CMD ?? (commandOnPath("npm", res.env) ? "npm" : null);
833
- if (!cmd) return null;
834
- try {
835
- const r = spawnSync(cmd, ["view", pkgName, "version"], {
836
- encoding: "utf8",
837
- timeout: 20_000,
838
- env: res.env,
839
- });
840
- const v = (r.stdout ?? "").trim().split(/\s+/)[0];
841
- return r.status === 0 && /^\d+(?:\.\d+){1,2}$/.test(v) ? v : null;
842
- } catch {
843
- return null;
844
- }
845
- };
846
-
847
- const installedPluginVersion = (res: Resolved): string | null => {
848
- const pkg = readJson(path.join(res.cursorPluginDir, "package.json"));
849
- return typeof pkg?.version === "string" && pkg.version ? pkg.version : null;
850
- };
851
-
852
- const installedOpenCodeCacheVersion = (res: Resolved): string | null => {
853
- const pkg = readJson(
854
- path.join(
855
- res.opencodePackageCacheDir,
856
- "node_modules",
857
- "@brainervirus",
858
- "workit-opencode",
859
- "package.json",
860
- ),
861
- );
862
- return typeof pkg?.version === "string" && pkg.version ? pkg.version : null;
863
- };
864
-
865
- /** OpenCode freezes the first `@latest` resolve in its package cache — detect lag. */
866
- const checkOpenCodePackageCache = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
867
- if (!existsSync(res.opencodeConfig)) {
868
- return {
869
- id: "stale_install",
870
- status: "pass",
871
- detail: "no opencode config — package cache not inspected",
872
- };
873
- }
874
- const entries = pluginEntries(readJson(res.opencodeConfig));
875
- if (entries.length === 0) {
876
- return {
877
- id: "stale_install",
878
- status: "pass",
879
- detail: "no workit opencode pin — package cache not inspected",
880
- };
881
- }
882
- const pin = entries[0];
883
- if (pin.startsWith("file:") || pin.startsWith("git+file:")) {
884
- return {
885
- id: "stale_install",
886
- status: "pass",
887
- detail: "opencode checkout pin — package cache not used for npm resolution",
888
- };
889
- }
890
- if (!isFloatingOpenCodeNpmPin(pin)) {
891
- return {
892
- id: "stale_install",
893
- status: "pass",
894
- detail: `opencode pin ${pin} is an exact version — not compared to registry latest`,
895
- };
896
- }
897
- const installed = installedOpenCodeCacheVersion(res);
898
- if (installed === null) {
899
- return {
900
- id: "stale_install",
901
- status: "pass",
902
- detail: "opencode @latest package cache empty — OpenCode will resolve on next launch",
903
- };
904
- }
905
- const expected = registryLatestVersion(res, OPENCODE_NPM_PIN);
906
- if (expected === null) {
907
- return {
908
- id: "registry_unreachable",
909
- status: "warn",
910
- registryProbed: true,
911
- detail:
912
- "registry_unreachable: cannot compare OpenCode @latest cache against published workit-opencode",
913
- fix: `Retry when npm is reachable, or remove ${res.opencodePackageCacheDir} and restart OpenCode to re-resolve`,
914
- };
915
- }
916
- if (!semverAtLeast(installed, expected)) {
917
- return {
918
- id: "stale_install",
919
- status: "fail",
920
- registryProbed: true,
921
- detail: `stale_install: OpenCode @latest cache has workit-opencode ${installed} behind published ${expected}`,
922
- fix: `Remove ${res.opencodePackageCacheDir} and restart OpenCode — it re-resolves @latest on the next launch`,
923
- };
924
- }
925
- return {
926
- id: "stale_install",
927
- status: "pass",
928
- registryProbed: true,
929
- detail: `OpenCode @latest cache workit-opencode ${installed} matches published ${expected}`,
930
- };
931
- };
932
-
933
- const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
934
- if (res.host === "opencode") {
935
- return checkOpenCodePackageCache(res);
936
- }
937
- if (res.host !== "cursor" && res.host !== "cli") {
938
- return {
939
- id: "stale_install",
940
- status: "pass",
941
- detail: "cursor plugin not inspected on this host",
942
- };
943
- }
944
- // Symlink installs into pnpm dlx / _npx caches break when the cache is cleared.
945
- // MCP launchers may still be canonical; the plugin dir itself must be real.
946
- if (existsSync(res.cursorPluginDir) && lstatSync(res.cursorPluginDir).isSymbolicLink()) {
947
- let target = "";
948
- try {
949
- target = readlinkSync(res.cursorPluginDir);
950
- } catch {
951
- target = "(unreadable)";
952
- }
953
- const fragile = isEphemeralCachePath(target);
954
- return {
955
- id: "stale_install",
956
- status: "fail",
957
- detail: fragile
958
- ? `stale_install: cursor plugin dir is a symlink into a package-manager cache (${target})`
959
- : `stale_install: cursor plugin dir is a symlink (${target}) — expected a real directory copy`,
960
- fix: "Run `workit init` and select Cursor — install must copy into ~/.cursor/plugins/local/workit",
961
- };
962
- }
963
- const canonicalMcp = cursorMcpServerEntry("").args.find((a) => a.startsWith("--package="));
964
- const mcpSelectors = pluginMcpSelectors(res);
965
- const legacyPin = (mcpSelectors ?? []).find(
966
- (s) => s !== canonicalMcp && s.includes("@brainervirus/workit-cursor@"),
967
- );
968
- if (legacyPin) {
969
- return {
970
- id: "stale_install",
971
- status: "fail",
972
- detail: `stale_install: plugin mcp.json pins a legacy selector ${legacyPin} (canonical: ${canonicalMcp})`,
973
- fix: "Re-run install-cursor-plugin.sh — it rewrites the workit MCP entry to the canonical @latest selector",
974
- };
975
- }
976
- const hooksFile = path.join(res.cursorPluginDir, "hooks", "hooks-cursor.json");
977
- const hookCmd =
978
- (readJson(hooksFile)?.hooks?.sessionStart?.[0]?.command as string | undefined) ?? null;
979
- const canonicalHook = canonicalCursorHook;
980
- const staleHook =
981
- hookCmd !== null &&
982
- hookCmd !== canonicalHook &&
983
- !hookCmd.startsWith("node ") &&
984
- hookCmd.includes("@brainervirus/workit-cursor@");
985
- if (staleHook) {
986
- return {
987
- id: "stale_install",
988
- status: "fail",
989
- detail: `stale_install: sessionStart hook runs a legacy selector (canonical: ${canonicalHook})`,
990
- fix: "Re-run install-cursor-plugin.sh — it rewrites the sessionStart hook to the canonical @latest selector",
991
- };
992
- }
993
- // Enforcement-event drift: a present-but-divergent preToolUse matcher (or
994
- // beforeShellExecution command) silently narrows what the hook intercepts.
995
- // Absent events are filled by the installer merge, so only divergence fails.
996
- const hookDrift = existsSync(hooksFile) ? cursorHookDrift(readJson(hooksFile)) : [];
997
- if (hookDrift.length > 0) {
998
- return {
999
- id: "stale_install",
1000
- status: "fail",
1001
- detail: `stale_install: cursor hook drift on ${hookDrift.join(", ")} (differs from the canonical hook entries)`,
1002
- fix: "Re-run install-cursor-plugin.sh — it rewrites the workit hook entries to canonical",
1003
- };
1004
- }
1005
- const installed = installedPluginVersion(res);
1006
- const source = res.dev
1007
- ? ((readJson(path.join(res.dev, "packages/workit-cursor/package.json"))?.version as
1008
- | string
1009
- | undefined) ?? null)
1010
- : ((readJson(path.join(packageRoot(), "package.json"))?.version as string | undefined) ?? null);
1011
- // A local-dist install (node entry) runs the installed dir's own dist, so its
1012
- // version is comparable against the current runtime (and the published one
1013
- // below). Canonical @latest installs resolve fresh at launch — the installed
1014
- // package.json version is metadata, not a freshness signal (CA-04), so they
1015
- // skip both version comparisons and never fail stale_install on metadata.
1016
- const localDist = mcpSelectors === null && hookCmd !== null && hookCmd.startsWith("node ");
1017
- if (localDist && installed !== null && source !== null && !semverAtLeast(installed, source)) {
1018
- return {
1019
- id: "stale_install",
1020
- status: "fail",
1021
- detail: `stale_install: installed workit-cursor ${installed} is behind the current runtime ${source}`,
1022
- fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory and rewrites the workit MCP/hook entries",
1023
- };
1024
- }
1025
- // Same version, different bytes: the install was hand-edited or built from
1026
- // a dirty tree. Version strings cannot see that; the bundle hash can. The
1027
- // dev dist is byte-deterministic, so a mismatch heals with reinstall.
1028
- if (localDist && installed !== null && source !== null && res.dev) {
1029
- const entry = /^node\s+(.+)$/.exec((hookCmd ?? "").trim())?.[1]?.trim() ?? "";
1030
- const sourceBundle =
1031
- entry && path.isAbsolute(entry)
1032
- ? path.join(res.dev, "packages", "workit-cursor", "dist", path.basename(entry))
1033
- : "";
1034
- if (entry && sourceBundle) {
1035
- const installedHash = bundleHashOfFile(entry);
1036
- const sourceHash = bundleHashOfFile(sourceBundle);
1037
- if (installedHash !== null && sourceHash !== null && installedHash !== sourceHash) {
1038
- return {
1039
- id: "stale_install",
1040
- status: "fail",
1041
- detail: `stale_install: installed workit-cursor ${installed} runs different bytes than the current runtime build (${path.basename(entry)} hash mismatch)`,
1042
- fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
1043
- };
1044
- }
1045
- }
1046
- }
1047
- if (installed !== null && localDist) {
1048
- const expected = registryLatestVersion(res);
1049
- if (expected === null) {
1050
- return {
1051
- id: "registry_unreachable",
1052
- status: "warn",
1053
- registryProbed: true,
1054
- detail:
1055
- "registry_unreachable: cannot compare installed workit-cursor against the published runtime",
1056
- fix: "Retry when the npm registry is reachable, or re-run install-cursor-plugin.sh to refresh the install",
1057
- };
1058
- }
1059
- if (!semverAtLeast(installed, expected)) {
1060
- // The probe SUCCEEDED and the installed version is genuinely behind the
1061
- // published runtime: report a hard fail so `doctor-check.ts cursor --stale`
1062
- // exits 2 and the installer self-heals the local-dist install.
1063
- return {
1064
- id: "stale_install",
1065
- status: "fail",
1066
- registryProbed: true,
1067
- detail: `stale_install: local-dist workit-cursor ${installed} is behind the published runtime ${expected}`,
1068
- fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
1069
- };
1070
- }
1071
- const cursorOk = {
1072
- id: "stale_install" as const,
1073
- status: "pass" as const,
1074
- registryProbed: true,
1075
- detail: `installed local-dist workit-cursor ${installed} matches the published runtime ${expected}`,
1076
- };
1077
- if (res.host === "cli") {
1078
- const oc = checkOpenCodePackageCache(res);
1079
- if (oc.status !== "pass") return oc;
1080
- }
1081
- return cursorOk;
1082
- }
1083
- const cursorOk = {
1084
- id: "stale_install" as const,
1085
- status: "pass" as const,
1086
- detail:
1087
- installed === null
1088
- ? "installed workit-cursor selectors are canonical"
1089
- : `installed workit-cursor ${installed} is metadata on the canonical @latest install (fresh at launch)`,
1090
- };
1091
- if (res.host === "cli") {
1092
- const oc = checkOpenCodePackageCache(res);
1093
- if (oc.status !== "pass") return oc;
1094
- }
1095
- return cursorOk;
1096
- };
1097
-
1098
- const checkDuplicateRegistration = (res: Resolved): DoctorCheck => {
1099
- const problems: string[] = [];
1100
- const opencodeHost = res.host !== "cursor";
1101
- const cursorHost = res.host !== "opencode";
1102
-
1103
- if (opencodeHost && existsSync(res.opencodeConfig)) {
1104
- const cfg = readJson(res.opencodeConfig);
1105
- const entries = pluginEntries(cfg);
1106
- if (entries.length > 1)
1107
- problems.push(`opencode registers ${entries.length} workit plugin entries`);
1108
- }
1109
- if (cursorHost && existsSync(res.cursorSettings)) {
1110
- const settings = readJson(res.cursorSettings);
1111
- const enabled = settings?.enabled_plugins;
1112
- if (enabled && typeof enabled === "object") {
1113
- const keys = Object.keys(enabled).filter((k) => isWorkitPlugin(k));
1114
- if (keys.length > 1)
1115
- problems.push(
1116
- `cursor enables ${keys.length} workit plugin identities (${keys.join(", ")})`,
1117
- );
1118
- }
1119
- const dirs = Array.isArray(settings?.plugin_dirs)
1120
- ? settings.plugin_dirs.map(String).filter((d) => {
1121
- // Exact local plugin-dir identities only (CA-09): a similarly-named
1122
- // unrelated dir (e.g. `local/workflow-toolkit-extra`) is preserved
1123
- // and must never be counted as a Workit entry.
1124
- const n = d.replaceAll("\\", "/").replace(/\/+$/, "");
1125
- return (
1126
- isWorkitPlugin(d) || n.endsWith("local/workit") || n.endsWith("local/workflow-toolkit")
1127
- );
1128
- })
1129
- : [];
1130
- if (dirs.length > 1) problems.push(`cursor plugin_dirs has ${dirs.length} workit entries`);
1131
- }
1132
- if (cursorHost && existsSync(res.cursorMcp)) {
1133
- const mcp = readJson(res.cursorMcp);
1134
- const servers = mcp?.mcpServers ?? {};
1135
- if (servers && typeof servers === "object") {
1136
- const workitServers = Object.keys(servers).filter(
1137
- (s) => s === "workit" || s.includes("workflow-toolkit"),
1138
- );
1139
- if (workitServers.length > 1)
1140
- problems.push(
1141
- `cursor registers ${workitServers.length} workit MCP servers (${workitServers.join(", ")})`,
1142
- );
1143
- }
1144
- }
1145
- if (problems.length === 0) {
1146
- return {
1147
- id: "duplicate_registration",
1148
- status: "pass",
1149
- detail: "no duplicate registrations",
1150
- };
1151
- }
1152
- return {
1153
- id: "duplicate_registration",
1154
- status: "fail",
1155
- detail: problems.join("; "),
1156
- fix: "Re-run install-opencode-plugin.sh / install-cursor-plugin.sh to deduplicate registrations",
1157
- };
1158
- };
1159
-
1160
- const checkMalformedConfig = (res: Resolved): DoctorCheck => {
1161
- const files: string[] = [];
1162
- for (const name of ["config.json", "youtrack.json", "vcs.json", "workspaces.json"]) {
1163
- const p = path.join(res.configDir, name);
1164
- if (existsSync(p)) files.push(p);
1165
- }
1166
- const opencodeHost = res.host !== "cursor";
1167
- const cursorHost = res.host !== "opencode";
1168
- if (opencodeHost && existsSync(res.opencodeConfig)) files.push(res.opencodeConfig);
1169
- if (cursorHost && existsSync(res.cursorSettings)) files.push(res.cursorSettings);
1170
- if (cursorHost && existsSync(res.cursorMcp)) files.push(res.cursorMcp);
1171
- const bad = files.filter((p) => !parsesAsConfigObject(p));
1172
- if (bad.length === 0)
1173
- return {
1174
- id: "malformed_config",
1175
- status: "pass",
1176
- detail: "config files parse",
1177
- };
1178
- return {
1179
- id: "malformed_config",
1180
- status: "fail",
1181
- detail: `malformed config: ${bad.join(", ")}`,
1182
- fix: `Repair the malformed config in ${bad[0]}`,
1183
- };
1184
- };
1185
-
1186
- const checkWorkspaceMismatch = (res: Resolved): DoctorCheck => {
1187
- const result = readWorkspacesResult(res.configDir);
1188
- const file = result.path;
1189
- if (result.status === "missing" || (result.status === "valid" && result.entries.length === 0))
1190
- return {
1191
- id: "workspace_mismatch",
1192
- status: "pass",
1193
- detail: "no workspaces configured",
1194
- };
1195
- if (result.status === "malformed" || result.status === "invalid")
1196
- return {
1197
- id: "workspace_mismatch",
1198
- status: "fail",
1199
- detail: result.error ?? `${file} has invalid workspace configuration`,
1200
- fix: `Repair workspace configuration in ${file}`,
1201
- };
1202
- let match;
1203
- try {
1204
- match = resolveWorkspaceFrom(
1205
- res.cwd,
1206
- res.configDir,
1207
- res.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,
1208
- );
1209
- } catch (error) {
1210
- return {
1211
- id: "workspace_mismatch",
1212
- status: "fail",
1213
- detail: error instanceof Error ? error.message : String(error),
1214
- fix: `Choose a matching workspace with WORKFLOW_WORKSPACE_NAME or repair ${file}`,
1215
- };
1216
- }
1217
- if (match)
1218
- return {
1219
- id: "workspace_mismatch",
1220
- status: "pass",
1221
- detail: `current directory matches workspace "${match.name}"`,
1222
- };
1223
- return {
1224
- id: "workspace_mismatch",
1225
- status: "fail",
1226
- detail: `current directory ${res.cwd} does not match any configured workspace`,
1227
- fix: `Add a workspace glob matching this directory to ${file} or run /wk-init`,
1228
- };
1229
- };
1230
-
1231
- // Credential metadata: existence, mode, placeholder — never the value.
1232
- const isPlaceholder = (p: string): boolean => {
1233
- try {
1234
- return readFileSync(p, "utf8").trim() === TOKEN_PLACEHOLDER;
1235
- } catch {
1236
- return false;
1237
- }
1238
- };
1239
-
1240
- const checkCredentialMetadata = (res: Resolved): DoctorCheck => {
1241
- const tokenPaths: string[] = [];
1242
- const youtrackJson = readJson(path.join(res.configDir, "youtrack.json"));
1243
- if (youtrackJson) {
1244
- tokenPaths.push(
1245
- typeof youtrackJson.tokenFile === "string"
1246
- ? youtrackJson.tokenFile
1247
- : path.join(res.configDir, "youtrack.token"),
1248
- );
1249
- } else if (existsSync(path.join(res.configDir, "youtrack.token"))) {
1250
- tokenPaths.push(path.join(res.configDir, "youtrack.token"));
1251
- }
1252
-
1253
- if (tokenPaths.length === 0) {
1254
- return {
1255
- id: "credential_metadata",
1256
- status: "pass",
1257
- detail: "no credentials configured",
1258
- };
1259
- }
1260
- const problems: string[] = [];
1261
- for (const raw of tokenPaths) {
1262
- const p = path.isAbsolute(raw) ? raw : path.resolve(res.configDir, raw);
1263
- if (!existsSync(p)) {
1264
- problems.push(`${p} is missing`);
1265
- continue;
1266
- }
1267
- if (process.platform !== "win32") {
1268
- const mode = statSync(p).mode & 0o777;
1269
- if (mode !== 0o600) problems.push(`${p} must be mode 0600 (found ${mode.toString(8)})`);
1270
- }
1271
- if (isPlaceholder(p)) problems.push(`${p} still contains the placeholder token`);
1272
- }
1273
- if (problems.length === 0) {
1274
- return {
1275
- id: "credential_metadata",
1276
- status: "pass",
1277
- detail: "credential files present with safe metadata",
1278
- };
1279
- }
1280
- return {
1281
- id: "credential_metadata",
1282
- status: "fail",
1283
- detail: problems.join("; "),
1284
- fix: "Create or fix the token file (mode 0600, real value) — see /wk-status for the exact path",
1285
- };
1286
- };
1287
-
1288
- // Provider CLI and SSH identities are public usernames; Workit never reads a VCS token file.
1289
- export type IdentitySurface = { surface: string; login: string | null };
1290
-
1291
- export type IdentityProbes = {
1292
- origin: (cwd: string) => string | null;
1293
- ghLogin: (env: NodeJS.ProcessEnv) => string | null;
1294
- glabLogin?: (env: NodeJS.ProcessEnv) => string | null;
1295
- sshLogin: (host: string) => string | null;
1296
- };
1297
-
1298
- export const githubHostFromRemote = (remote: string): string | null => {
1299
- const rest = (remote || "").trim();
1300
- if (!rest) return null;
1301
- const scp = /^[^@/]+@([^:]+):/.exec(rest);
1302
- if (scp) return scp[1].toLowerCase();
1303
- try {
1304
- const url = new URL(rest.includes("://") ? rest : `https://${rest}`);
1305
- return url.hostname.toLowerCase() || null;
1306
- } catch {
1307
- return null;
1308
- }
1309
- };
1310
-
1311
- const identityProvider = (
1312
- host: string,
1313
- config: Record<string, any> | null,
1314
- workspaceProvider?: unknown,
1315
- ): "github" | "gitlab" | null =>
1316
- workspaceProvider === "github" || workspaceProvider === "gitlab"
1317
- ? workspaceProvider
1318
- : host === "github.com"
1319
- ? "github"
1320
- : host === "gitlab.com"
1321
- ? "gitlab"
1322
- : config?.provider === "github" || config?.provider === "gitlab"
1323
- ? config.provider
1324
- : config?.github
1325
- ? "github"
1326
- : config?.gitlab
1327
- ? "gitlab"
1328
- : null;
1329
-
1330
- const providerIdentityFinding = (
1331
- provider: "github" | "gitlab",
1332
- surfaces: IdentitySurface[],
1333
- ): DoctorCheck => {
1334
- const id: "github_identity" | "gitlab_identity" = `${provider}_identity`;
1335
- const label = provider === "github" ? "GitHub" : "GitLab";
1336
- const resolved = surfaces.filter(
1337
- (entry): entry is { surface: string; login: string } => typeof entry.login === "string",
1338
- );
1339
- const distinct = [...new Set(resolved.map((entry) => entry.login.toLowerCase()))];
1340
- if (distinct.length < 2) {
1341
- return {
1342
- id,
1343
- status: "pass",
1344
- detail:
1345
- resolved.length === 0
1346
- ? `no ${label} identities configured`
1347
- : `single ${label} identity across configured surfaces`,
1348
- };
1349
- }
1350
- return {
1351
- id,
1352
- status: "warn",
1353
- detail: `${label} identities disagree across configured surfaces: ${resolved
1354
- .map((entry) => `${entry.surface} reports "${entry.login}"`)
1355
- .join("; ")}. Operations may land under the wrong account.`,
1356
- fix:
1357
- provider === "github"
1358
- ? "Align the effective gh account and SSH identity via gh auth switch/login and your Git configuration"
1359
- : "Align the effective glab account and SSH identity via glab auth login and your Git configuration",
1360
- };
1361
- };
1362
-
1363
- export const githubIdentityFinding = (surfaces: IdentitySurface[]): DoctorCheck =>
1364
- providerIdentityFinding("github", surfaces);
1365
-
1366
- export const gitlabIdentityFinding = (surfaces: IdentitySurface[]): DoctorCheck =>
1367
- providerIdentityFinding("gitlab", surfaces);
1368
-
1369
- const workspaceProviderForIdentity = (
1370
- cwd: string,
1371
- configDir: string,
1372
- env: NodeJS.ProcessEnv,
1373
- ): { provider?: unknown; error?: string } => {
1374
- try {
1375
- return {
1376
- provider: resolveWorkspaceFrom(
1377
- cwd,
1378
- configDir,
1379
- env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,
1380
- )?.vcs?.provider,
1381
- };
1382
- } catch (error) {
1383
- return { error: error instanceof Error ? error.message : String(error) };
1384
- }
1385
- };
1386
-
1387
- const defaultIdentityProbes = (env: NodeJS.ProcessEnv): IdentityProbes => ({
1388
- origin: (cwd) => {
1389
- try {
1390
- const out = spawnSync("git", ["remote", "get-url", "origin"], {
1391
- cwd,
1392
- encoding: "utf8",
1393
- env,
1394
- });
1395
- return out.status === 0 ? (out.stdout ?? "").trim() || null : null;
1396
- } catch {
1397
- return null;
1398
- }
1399
- },
1400
- ghLogin: (e) => {
1401
- try {
1402
- const out = spawnSync("gh", ["api", "user", "--jq", ".login"], { encoding: "utf8", env: e });
1403
- const login = (out.stdout ?? "").trim();
1404
- return out.status === 0 && login ? login : null;
1405
- } catch {
1406
- return null;
1407
- }
1408
- },
1409
- glabLogin: (e) => {
1410
- try {
1411
- const out = spawnSync("glab", ["api", "user", "--jq", ".username"], {
1412
- encoding: "utf8",
1413
- env: e,
1414
- });
1415
- const login = (out.stdout ?? "").trim();
1416
- return out.status === 0 && login ? login : null;
1417
- } catch {
1418
- return null;
1419
- }
1420
- },
1421
- sshLogin: (host) => {
1422
- try {
1423
- const out = spawnSync(
1424
- "ssh",
1425
- ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T", `git@${host}`],
1426
- {
1427
- encoding: "utf8",
1428
- env,
1429
- },
1430
- );
1431
- const text = `${out.stdout ?? ""}${out.stderr ?? ""}`;
1432
- const match = /^(?:Hi |Welcome to GitLab, @)([^!]+)!/m.exec(text);
1433
- return match ? match[1].trim() || null : null;
1434
- } catch {
1435
- return null;
1436
- }
1437
- },
1438
- });
1439
-
1440
- export const checkGithubIdentity = (
1441
- res: Pick<Resolved, "cwd" | "configDir" | "env">,
1442
- probes: IdentityProbes = defaultIdentityProbes(res.env),
1443
- ): DoctorCheck => {
1444
- const remoteHost = githubHostFromRemote(probes.origin(res.cwd) ?? "");
1445
- if (!remoteHost)
1446
- return { id: "github_identity", status: "pass", detail: "no GitHub remote configured" };
1447
- const vcsJson = readJson(res.env.WORKFLOW_VCS_CONFIG ?? path.join(res.configDir, "vcs.json"));
1448
- const workspace = workspaceProviderForIdentity(res.cwd, res.configDir, res.env);
1449
- if (workspace.error)
1450
- return {
1451
- id: "github_identity",
1452
- status: "fail",
1453
- detail: workspace.error,
1454
- fix: "Repair workspace configuration or choose a matching workspace",
1455
- };
1456
- const provider = identityProvider(remoteHost, vcsJson, workspace.provider);
1457
- if (provider !== "github")
1458
- return { id: "github_identity", status: "pass", detail: "no GitHub remote configured" };
1459
- const cliHost = String(vcsJson?.github?.host ?? "github.com").toLowerCase();
1460
- const cliLogin = probes.ghLogin({ ...res.env, GH_HOST: cliHost });
1461
- if (!cliLogin)
1462
- return {
1463
- id: "github_identity",
1464
- status: "fail",
1465
- detail: "gh CLI is not authenticated",
1466
- fix: "Run gh auth login",
1467
- };
1468
- const surfaces: IdentitySurface[] = [{ surface: "gh CLI", login: cliLogin }];
1469
- surfaces.push({ surface: "SSH", login: probes.sshLogin(remoteHost) });
1470
- return githubIdentityFinding(surfaces);
1471
- };
1472
-
1473
- export const checkGitLabIdentity = (
1474
- res: Pick<Resolved, "cwd" | "configDir" | "env">,
1475
- probes: IdentityProbes = defaultIdentityProbes(res.env),
1476
- ): DoctorCheck => {
1477
- const remoteHost = githubHostFromRemote(probes.origin(res.cwd) ?? "");
1478
- if (!remoteHost)
1479
- return { id: "gitlab_identity", status: "pass", detail: "no GitLab remote configured" };
1480
- const vcsJson = readJson(res.env.WORKFLOW_VCS_CONFIG ?? path.join(res.configDir, "vcs.json"));
1481
- const workspace = workspaceProviderForIdentity(res.cwd, res.configDir, res.env);
1482
- if (workspace.error)
1483
- return {
1484
- id: "gitlab_identity",
1485
- status: "fail",
1486
- detail: workspace.error,
1487
- fix: "Repair workspace configuration or choose a matching workspace",
1488
- };
1489
- const provider = identityProvider(remoteHost, vcsJson, workspace.provider);
1490
- if (provider !== "gitlab")
1491
- return { id: "gitlab_identity", status: "pass", detail: "no GitLab remote configured" };
1492
- const cliHost = String(vcsJson?.gitlab?.host ?? "gitlab.com").toLowerCase();
1493
- const cliLogin = probes.glabLogin?.({ ...res.env, GITLAB_HOST: cliHost }) ?? null;
1494
- if (!cliLogin)
1495
- return {
1496
- id: "gitlab_identity",
1497
- status: "fail",
1498
- detail: "glab CLI is not authenticated",
1499
- fix: "Run glab auth login",
1500
- };
1501
- return gitlabIdentityFinding([
1502
- { surface: "glab CLI", login: cliLogin },
1503
- { surface: "SSH", login: probes.sshLogin(remoteHost) },
1504
- ]);
1505
- };
1506
-
1507
- const checkLogWritable = (res: Resolved): DoctorCheck => {
1508
- const logsDir = path.join(res.stateDir, "logs");
1509
- // Fixed-name probe: even a killed process leaves at most one bounded file that
1510
- // the next run overwrites; the finally removes it on any thrown path.
1511
- const probe = path.join(logsDir, "doctor-probe.tmp");
1512
- try {
1513
- mkdirSync(logsDir, { recursive: true, mode: 0o700 });
1514
- writeFileSync(probe, '{"probe":true}\n', { mode: 0o600 });
1515
- return {
1516
- id: "log_writable",
1517
- status: "pass",
1518
- detail: "log directory writable",
1519
- };
1520
- } catch (err) {
1521
- return {
1522
- id: "log_writable",
1523
- status: "fail",
1524
- detail: `log directory not writable: ${err instanceof Error ? err.message : String(err)}`,
1525
- fix: `Fix permissions on ${logsDir} or set WORKFLOW_TOOLKIT_STATE to a writable directory`,
1526
- };
1527
- } finally {
1528
- try {
1529
- unlinkSync(probe);
1530
- } catch {
1531
- /* already gone */
1532
- }
1533
- }
1534
- };
1535
-
1536
- const GENERATION_HOSTS: CutoverHost[] = ["opencode", "cursor", "codex", "pi"];
1537
-
1538
- const generationPaths = (res: Resolved) => ({
1539
- home: res.home,
1540
- configDir: res.configDir,
1541
- stateDir: res.stateDir,
1542
- dev: res.dev,
1543
- workspace: res.cwd,
1544
- opencodeConfig: res.opencodeConfig,
1545
- cursorSettings: res.cursorSettings,
1546
- cursorMcp: res.cursorMcp,
1547
- cursorPluginDir: res.cursorPluginDir,
1548
- piConfig: path.join(res.home, ".pi", "config.json"),
1549
- piSettings: path.join(
1550
- res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"),
1551
- "settings.json",
1552
- ),
1553
- sessions: res.sessions,
1554
- });
1555
-
1556
- const checkMixedGeneration = (res: Resolved): DoctorCheck => {
1557
- const paths = generationPaths(res);
1558
- const mixed = GENERATION_HOSTS.filter((host) => classifyHostGeneration(host, paths) === "mixed");
1559
- const target = readGenerationState(res.configDir).target;
1560
- if (mixed.length === 0) {
1561
- return {
1562
- id: "mixed_generation",
1563
- status: "pass",
1564
- detail: "no mixed legacy/v1 components detected",
1565
- };
1566
- }
1567
- const detail = `mixed legacy and v1 components: ${mixed.join(", ")}`;
1568
- if (target === "v1") {
1569
- return {
1570
- id: "mixed_generation",
1571
- status: "fail",
1572
- detail,
1573
- fix: "Run the approved v1 cutover preview/apply or remove the conflicting generation",
1574
- };
1575
- }
1576
- return {
1577
- id: "mixed_generation",
1578
- status: "warn",
1579
- detail: `${detail} (legacy target — cutover required before v1 activation)`,
1580
- fix: "Preview cutover with `workit init` or the CLI cutover flow before activating v1",
1581
- };
1582
- };
1583
-
1584
- const checkLegacyComponent = (res: Resolved): DoctorCheck => {
1585
- const target = readGenerationState(res.configDir).target;
1586
- if (target !== "v1") {
1587
- return {
1588
- id: "legacy_component",
1589
- status: "pass",
1590
- detail: "legacy target — 0.x components expected",
1591
- };
1592
- }
1593
- const paths = generationPaths(res);
1594
- const legacy = GENERATION_HOSTS.filter(
1595
- (host) => classifyHostGeneration(host, paths) === "legacy",
1596
- );
1597
- if (legacy.length === 0) {
1598
- return {
1599
- id: "legacy_component",
1600
- status: "pass",
1601
- detail: "no legacy-only host components on v1 target",
1602
- };
1603
- }
1604
- return {
1605
- id: "legacy_component",
1606
- status: "fail",
1607
- detail: `legacy components remain on: ${legacy.join(", ")}`,
1608
- fix: "Complete the approved cutover or rollback to legacy before mixing generations",
1609
- };
1610
- };
1611
-
1612
- const checkMissingV1Component = (res: Resolved): DoctorCheck => {
1613
- const target = readGenerationState(res.configDir).target;
1614
- if (target !== "v1") {
1615
- return { id: "missing_v1_component", status: "pass", detail: "legacy target" };
1616
- }
1617
- const paths = generationPaths(res);
1618
- const missing = GENERATION_HOSTS.filter((host) => {
1619
- const gen = classifyHostGeneration(host, paths);
1620
- return gen === "none" || gen === "legacy";
1621
- });
1622
- if (missing.length === 0) {
1623
- return {
1624
- id: "missing_v1_component",
1625
- status: "pass",
1626
- detail: "v1 components present on selected hosts",
1627
- };
1628
- }
1629
- return {
1630
- id: "missing_v1_component",
1631
- status: "fail",
1632
- detail: `missing v1 components on: ${missing.join(", ")}`,
1633
- fix: "Re-run the cutover apply step or the host install script for the missing target",
1634
- };
1635
- };
1636
-
1637
- const checkActiveOldSession = (res: Resolved): DoctorCheck => {
1638
- if (res.sessions.length === 0) {
1639
- return {
1640
- id: "active_old_session",
1641
- status: "pass",
1642
- detail: "no session observations supplied to doctor",
1643
- };
1644
- }
1645
- const blocking = res.sessions.filter((s) => s.state === "active" || s.state === "unknown");
1646
- if (blocking.length === 0) {
1647
- return {
1648
- id: "active_old_session",
1649
- status: "pass",
1650
- detail: "no active or unknown old sessions observed",
1651
- };
1652
- }
1653
- const detail = blocking.map((s) => `${s.host}:${s.handle} (${s.state})`).join(", ");
1654
- const target = readGenerationState(res.configDir).target;
1655
- if (target === "v1") {
1656
- return {
1657
- id: "active_old_session",
1658
- status: "fail",
1659
- detail: `active or unknown sessions block v1 activation: ${detail}`,
1660
- fix: "Stop or account for old sessions before cutover apply",
1661
- };
1662
- }
1663
- return {
1664
- id: "active_old_session",
1665
- status: "warn",
1666
- detail: `old sessions still observed: ${detail}`,
1667
- fix: "Stop old sessions before previewing or applying v1 cutover",
1668
- };
1669
- };
1670
-
1671
- const checkManagedContentConflict = (res: Resolved): DoctorCheck => {
1672
- const gen = readGenerationState(res.configDir);
1673
- if (!gen.cutover?.backupId) {
1674
- return {
1675
- id: "managed_content_conflict",
1676
- status: "pass",
1677
- detail: "no cutover receipt to compare",
1678
- };
1679
- }
1680
- const receipt = readCutoverReceipt(gen.cutover.backupId, res.stateDir);
1681
- if (!receipt) {
1682
- return {
1683
- id: "managed_content_conflict",
1684
- status: "fail",
1685
- detail: `cutover receipt missing for backup ${gen.cutover.backupId}`,
1686
- fix: "Re-run cutover apply or rollback to a known state",
1687
- };
1688
- }
1689
- const conflicts = receipt.managedFiles.filter((entry) => {
1690
- const current = digestFile(entry.path);
1691
- return current !== null && current !== entry.installedDigest;
1692
- });
1693
- if (conflicts.length === 0) {
1694
- return {
1695
- id: "managed_content_conflict",
1696
- status: "pass",
1697
- detail: "managed content matches cutover receipt",
1698
- };
1699
- }
1700
- return {
1701
- id: "managed_content_conflict",
1702
- status: "fail",
1703
- detail: `managed content drifted since cutover on: ${conflicts.map((c) => c.path).join(", ")}`,
1704
- fix: "Reconcile managed files with the cutover receipt or roll back before re-applying",
1705
- };
1706
- };
1707
-
1708
- // The checkout's `.workit/metadata.lock`. Writes reclaim a stale lock by
1709
- // themselves, so a stale lock is a warning with an explicit cleanup command.
1710
- const BLOCKING_LOCK_WARN_MS = 30_000;
1711
- const checkWorkspaceLock = (res: Resolved): DoctorCheck => {
1712
- const lock = inspectMetadataLock(res.workspaceRoot);
1713
- const fix = "workit doctor --fix-lock";
1714
- if (lock.guard === "abandoned")
1715
- return {
1716
- id: "workspace_lock",
1717
- status: "warn",
1718
- detail: `abandoned lock reclaim guard at ${lock.path}.reclaim`,
1719
- fix,
1720
- };
1721
- if (lock.state === "absent")
1722
- return { id: "workspace_lock", status: "pass", detail: "no metadata lock held" };
1723
- if (lock.state === "stale")
1724
- return {
1725
- id: "workspace_lock",
1726
- status: "warn",
1727
- detail: `stale metadata lock at ${lock.path}: ${lock.reason}`,
1728
- fix,
1729
- };
1730
- // An unverifiable owner (other host, pid namespace, or an older Workit's
1731
- // lock) that has blocked writes this long needs an explicit decision.
1732
- if (lock.state === "unknown" && (lock.ageMs ?? 0) > BLOCKING_LOCK_WARN_MS)
1733
- return {
1734
- id: "workspace_lock",
1735
- status: "warn",
1736
- detail: `metadata lock at ${lock.path} has blocked writes for ${Math.round((lock.ageMs ?? 0) / 1000)}s and its owner cannot be verified: ${lock.reason}`,
1737
- fix: "workit doctor --fix-lock --force --yes",
1738
- };
1739
- return {
1740
- id: "workspace_lock",
1741
- status: "pass",
1742
- detail: `metadata lock ${lock.reason} (writes retry, then report busy)`,
1743
- };
1744
- };
1745
-
1746
- const CLAUDE_PLUGIN_PACKAGE = "@brainervirus/workit-claude-code";
1747
-
1748
- const versionBehind = (version: string, latest: string): boolean =>
1749
- version !== latest && semverAtLeast(latest, version);
1750
-
1751
- /**
1752
- * Claude Code installs the marketplace plugin as a snapshot of the published
1753
- * package (auto-update is off by default), so an install can lag the release.
1754
- * It warns only when a newer plugin version is actually published: a plugin
1755
- * older than this CLI is normal when no plugin payload changed since (the
1756
- * plugin is republished only when its own or its bundled sources change).
1757
- * The plugin keeps working; the fix is one native update. A `--plugin-dir`
1758
- * local pin is per-session, never recorded, and never checked here.
1759
- */
1760
- const checkClaudePlugin = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
1761
- // Only installs Claude loads here: user scope, plus this project's.
1762
- const installs = claudeWorkitInstalls(res.home, res.env, res.cwd);
1763
- if (installs.length === 0)
1764
- return {
1765
- id: "claude_plugin",
1766
- status: "pass",
1767
- detail: "no Workit Claude Code plugin install recorded — skipping",
1768
- };
1769
- const fix = (install: ClaudeWorkitInstall) =>
1770
- `claude plugin marketplace update ${CLAUDE_MARKETPLACE_NAME} && ${
1771
- install.projectPath ? `cd ${JSON.stringify(install.projectPath)} && ` : ""
1772
- }claude plugin update ${install.id}${install.scope === "user" ? "" : ` --scope ${install.scope}`}`;
1773
- const latest = registryLatestVersion(res, CLAUDE_PLUGIN_PACKAGE);
1774
- const problems: string[] = [];
1775
- let repair: string | undefined;
1776
- for (const install of installs) {
1777
- const label = `${install.id} ${install.version ?? "(unknown version)"}${install.scope === "user" ? "" : ` (${install.scope} scope)`}`;
1778
- if (latest && install.version && versionBehind(install.version, latest)) {
1779
- problems.push(`stale_install: ${label} is behind published ${latest}`);
1780
- repair ??= fix(install);
1781
- }
1782
- }
1783
- const registryProbed = latest !== null && !res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION;
1784
- if (problems.length === 0)
1785
- return {
1786
- id: "claude_plugin",
1787
- status: "pass",
1788
- detail: `Claude Code plugin ${installs.map((i) => `${i.id} ${i.version ?? "?"}`).join(", ")} is current`,
1789
- registryProbed,
1790
- };
1791
- return {
1792
- id: "claude_plugin",
1793
- status: "warn",
1794
- detail: problems.join("; "),
1795
- fix: repair,
1796
- registryProbed,
1797
- };
1798
- };
1799
-
1800
- const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
1801
- checkRuntime,
1802
- checkVersions,
1803
- checkCodexPin,
1804
- checkClaudePlugin,
1805
- checkAssets,
1806
- checkLauncher,
1807
- checkUtility,
1808
- checkStalePin,
1809
- checkStaleInstall,
1810
- checkDuplicateRegistration,
1811
- checkMalformedConfig,
1812
- checkWorkspaceMismatch,
1813
- checkWorkspaceLock,
1814
- checkCredentialMetadata,
1815
- checkGithubIdentity,
1816
- checkGitLabIdentity,
1817
- checkLogWritable,
1818
- checkMixedGeneration,
1819
- checkLegacyComponent,
1820
- checkMissingV1Component,
1821
- checkActiveOldSession,
1822
- checkManagedContentConflict,
1823
- ];
1824
-
1825
- // AR-11/CA-40: the installer guarantees the selected host itself — runtime,
1826
- // assets, launchers, registration, and required utilities — plus the config it
1827
- // just wrote. Those defects stay failures with nonzero status; only optional
1828
- // parity checks (versions/workspace/credentials/log) may downgrade to warnings.
1829
- const INSTALLER_REQUIRED = new Set<DoctorCheckId>([
1830
- "runtime",
1831
- "assets",
1832
- "launcher",
1833
- "utility",
1834
- "stale_pin",
1835
- "stale_install",
1836
- "duplicate_registration",
1837
- "malformed_config",
1838
- ]);
1839
-
1840
- export const runDoctor = (options: DoctorOptions = {}): DoctorReport => {
1841
- const res = resolve(options);
1842
- const raw = RUN_CHECKS.map((fn) => fn(res));
1843
- const checks = res.installer
1844
- ? raw.map((c) =>
1845
- c.status === "fail" && !INSTALLER_REQUIRED.has(c.id)
1846
- ? {
1847
- ...c,
1848
- status: "warn" as const,
1849
- detail: `${c.detail} (not enforced by installer)`,
1850
- }
1851
- : c,
1852
- )
1853
- : raw;
1854
-
1855
- const failed = checks.filter((c) => c.status === "fail").length;
1856
- const warned = checks.filter((c) => c.status === "warn").length;
1857
- const passed = checks.filter((c) => c.status === "pass").length;
1858
- const exitCode = failed > 0 ? 1 : 0;
1859
- const offline = !raw.some((c) => "registryProbed" in c && c.registryProbed);
1860
- const report: DoctorReport = {
1861
- ok: failed === 0,
1862
- exitCode,
1863
- offline,
1864
- host: res.host,
1865
- checked_at: new Date().toISOString(),
1866
- summary: { passed, warned, failed, total: checks.length },
1867
- checks,
1868
- fixes: checks
1869
- .filter((c) => c.status === "fail" && c.fix)
1870
- .map((c) => ({ id: c.id, fix: c.fix! })),
1871
- };
1872
-
1873
- getDiagnosticLogger()?.info(EVENT.doctor, {
1874
- host: res.host,
1875
- exit_code: exitCode,
1876
- offline,
1877
- failed: checks.filter((c) => c.status === "fail").map((c) => c.id),
1878
- total: checks.length,
1879
- });
1880
-
1881
- return report;
1882
- };