@brainervirus/workit-core 0.10.0 → 1.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.
Files changed (150) hide show
  1. package/README.md +5 -3
  2. package/package.json +8 -3
  3. package/scripts/analyze-release-scope.ts +4 -1
  4. package/scripts/doctor-check.ts +3 -1
  5. package/scripts/install-codex-plugin.sh +28 -0
  6. package/scripts/install-cursor-plugin.sh +1 -0
  7. package/scripts/install-opencode-plugin.sh +1 -0
  8. package/scripts/install-pi-package.sh +56 -0
  9. package/scripts/rewrite-workspace-deps.ts +22 -5
  10. package/scripts/sync-release-manifests.ts +8 -5
  11. package/scripts/sync-runtime.sh +7 -3
  12. package/scripts/validate-cursor-marketplace.ts +16 -62
  13. package/skills/workit-babysit/SKILL.md +33 -0
  14. package/skills/workit-behavioral-tdd/SKILL.md +53 -0
  15. package/skills/workit-blast-radius/SKILL.md +31 -0
  16. package/skills/workit-challenge/SKILL.md +62 -0
  17. package/skills/workit-debug/SKILL.md +61 -0
  18. package/skills/workit-deslop/SKILL.md +36 -0
  19. package/skills/workit-diagram/SKILL.md +32 -0
  20. package/skills/workit-green-run/SKILL.md +29 -0
  21. package/skills/workit-handoff/SKILL.md +43 -0
  22. package/skills/workit-implement/SKILL.md +46 -0
  23. package/skills/workit-mockup/SKILL.md +28 -0
  24. package/skills/workit-plan/SKILL.md +66 -0
  25. package/skills/workit-review/SKILL.md +60 -0
  26. package/skills/workit-steer/SKILL.md +32 -0
  27. package/src/core/authority.ts +918 -0
  28. package/src/core/boundary.ts +29 -0
  29. package/src/core/branch.ts +26 -255
  30. package/src/core/changelog.ts +95 -18
  31. package/src/core/commit-flavors.ts +65 -0
  32. package/src/core/config-conversion.ts +183 -0
  33. package/src/core/config.ts +42 -0
  34. package/src/core/cutover.ts +721 -0
  35. package/src/core/detect-hosts.ts +107 -0
  36. package/src/core/doctor.ts +428 -18
  37. package/src/core/external-action-effects.ts +1171 -0
  38. package/src/core/external-action.ts +541 -0
  39. package/src/core/init.ts +83 -32
  40. package/src/core/methods.ts +125 -0
  41. package/src/core/policy-resolver.ts +523 -0
  42. package/src/core/pr-create.ts +7 -1
  43. package/src/core/registration.ts +69 -4
  44. package/src/core/repo-context.ts +59 -1
  45. package/src/core/setup.ts +28 -0
  46. package/src/core/skill-manifests.ts +40 -42
  47. package/src/core/support-matrix.ts +8 -3
  48. package/src/core/sync-runtime.ts +15 -3
  49. package/src/core/task-context.ts +179 -0
  50. package/src/core/task-contract.ts +1136 -0
  51. package/src/core/task-engine.ts +2562 -0
  52. package/src/core/task-evaluation.ts +679 -0
  53. package/src/core/task-store.ts +1071 -0
  54. package/src/core/tracker-issues.ts +237 -0
  55. package/src/core/uninstall.ts +67 -3
  56. package/src/core/vcs-config.ts +53 -22
  57. package/src/core/workers.ts +166 -0
  58. package/src/core/workspaces.ts +3 -1
  59. package/src/core/youtrack-tools.ts +2 -0
  60. package/src/core/youtrack.ts +145 -10
  61. package/src/core.ts +126 -0
  62. package/templates/execution-contract.md +17 -50
  63. package/templates/plan-template.md +3 -3
  64. package/templates/spec-template.md +15 -3
  65. package/templates/workit-contract.md +12 -0
  66. package/commands/wk-changelog.md +0 -2
  67. package/commands/wk-commit.md +0 -2
  68. package/commands/wk-docs-refresh.md +0 -2
  69. package/commands/wk-handoff.md +0 -2
  70. package/commands/wk-implement.md +0 -2
  71. package/commands/wk-init.md +0 -2
  72. package/commands/wk-issue-update.md +0 -2
  73. package/commands/wk-meetings.md +0 -2
  74. package/commands/wk-pr.md +0 -2
  75. package/commands/wk-release-notes.md +0 -2
  76. package/commands/wk-status.md +0 -2
  77. package/commands/wk-verify.md +0 -2
  78. package/scripts/update-superpowers.sh +0 -82
  79. package/scripts/vendor-assets.ts +0 -37
  80. package/skills/wk-changelog/SKILL.md +0 -15
  81. package/skills/wk-commit/SKILL.md +0 -16
  82. package/skills/wk-docs-refresh/SKILL.md +0 -15
  83. package/skills/wk-handoff/SKILL.md +0 -20
  84. package/skills/wk-implement/SKILL.md +0 -47
  85. package/skills/wk-init/SKILL.md +0 -31
  86. package/skills/wk-issue-update/SKILL.md +0 -27
  87. package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
  88. package/skills/wk-meetings/SKILL.md +0 -17
  89. package/skills/wk-pr/SKILL.md +0 -27
  90. package/skills/wk-release-notes/SKILL.md +0 -15
  91. package/skills/wk-status/SKILL.md +0 -16
  92. package/skills/wk-verify/SKILL.md +0 -16
  93. package/src/core/detector.ts +0 -239
  94. package/src/core/flow-state.ts +0 -3048
  95. package/src/core/handoff-context.ts +0 -136
  96. package/src/core/handoff-tools.ts +0 -133
  97. package/src/core/menu.ts +0 -70
  98. package/src/core/plan-tasks.ts +0 -43
  99. package/src/core/reminder.ts +0 -124
  100. package/src/core/sdd.ts +0 -353
  101. package/src/state.ts +0 -22
  102. package/templates/superpowers-doc-contract.md +0 -75
  103. package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
  104. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
  105. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
  106. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
  107. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
  108. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
  109. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
  110. package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
  111. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
  112. package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
  113. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
  114. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
  115. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
  116. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
  117. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -426
  118. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
  119. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
  120. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
  121. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
  122. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
  123. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
  124. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
  125. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  126. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
  127. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
  128. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
  129. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
  130. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
  131. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
  132. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
  133. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
  134. package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
  135. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
  136. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
  137. package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
  138. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
  139. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
  140. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
  141. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
  142. package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
  143. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
  144. package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
  145. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
  146. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
  147. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
  148. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
  149. package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
  150. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
@@ -0,0 +1,107 @@
1
+ // Host presence detection for the CLI setup wizard: which tools are installed
2
+ // (CLI on PATH or home config marker — never a subprocess probe) and which of
3
+ // the wizard-managed hosts already carry a workit registration. The configured
4
+ // signal reuses planUninstall's installed bit so detection and uninstall can
5
+ // never disagree; codex/pi are presence-only (their setup runs via cutover).
6
+ import { existsSync, readdirSync } from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { commandOnPath } from "./doctor";
10
+ import { planUninstall, type UninstallPaths } from "./uninstall";
11
+
12
+ export type HostId = "opencode" | "cursor" | "codex" | "pi";
13
+
14
+ export type HostDetection = {
15
+ /** CLI on PATH or a home config marker exists. */
16
+ detected: boolean;
17
+ /** A workit registration is present (opencode/cursor only; always false). */
18
+ configured: boolean;
19
+ };
20
+
21
+ export type DetectHostsOptions = UninstallPaths;
22
+
23
+ const HOSTS: HostId[] = ["opencode", "cursor", "codex", "pi"];
24
+
25
+ const HOME_MARKERS: Record<HostId, string[]> = {
26
+ opencode: [path.join(".config", "opencode")],
27
+ cursor: [".cursor"],
28
+ codex: [".codex"],
29
+ pi: [".pi"],
30
+ };
31
+
32
+ /** Hosts the wizard Apply path registers (codex/pi set up via cutover). */
33
+ export const WIZARD_HOSTS: HostId[] = ["opencode", "cursor"];
34
+
35
+ /** Detected hosts the wizard can preselect (presence ∩ wizard-managed). */
36
+ export function preselectedPlatforms(detection: Record<HostId, HostDetection>): string[] {
37
+ return WIZARD_HOSTS.filter((host) => detection[host].detected);
38
+ }
39
+
40
+ export function emptyDetection(): Record<HostId, HostDetection> {
41
+ return {
42
+ opencode: { detected: false, configured: false },
43
+ cursor: { detected: false, configured: false },
44
+ codex: { detected: false, configured: false },
45
+ pi: { detected: false, configured: false },
46
+ };
47
+ }
48
+
49
+ export function detectHosts(options: DetectHostsOptions = {}): Record<HostId, HostDetection> {
50
+ const env = options.env ?? process.env;
51
+ // Same chain as uninstall path resolution: explicit home > env.HOME > homedir.
52
+ const home = options.home ?? env.HOME ?? os.homedir();
53
+ // Pure reader: classifies the installed state, never writes.
54
+ const plan = planUninstall(options);
55
+ const configuredByHost = new Map(plan.hosts.map((h) => [h.host, h.installed]));
56
+ const found = emptyDetection();
57
+ for (const host of HOSTS) {
58
+ const detected =
59
+ cliFound(host, env, home) || HOME_MARKERS[host].some((m) => existsSync(path.join(home, m)));
60
+ found[host] = {
61
+ detected,
62
+ configured: WIZARD_HOSTS.includes(host) && (configuredByHost.get(host) ?? false),
63
+ };
64
+ }
65
+ return found;
66
+ }
67
+
68
+ /**
69
+ * CLI lookup beyond ambient PATH: version-manager shims (fnm multishells,
70
+ * nvm, asdf) and ~/.local/bin vanish from bare non-interactive PATHs, so a
71
+ * tool installed under one is still present. Presence-only statSync reads —
72
+ * never a subprocess probe.
73
+ */
74
+ export function cliFound(name: string, env: NodeJS.ProcessEnv, home: string): boolean {
75
+ if (commandOnPath(name, env)) return true;
76
+ const extra = managerBinDirs(home);
77
+ if (extra.length === 0) return false;
78
+ return commandOnPath(name, {
79
+ ...env,
80
+ PATH: [...(env.PATH ?? "").split(path.delimiter), ...extra].join(path.delimiter),
81
+ });
82
+ }
83
+
84
+ const managerBinDirs = (home: string): string[] => {
85
+ const dirs = [path.join(home, ".local", "bin"), path.join(home, ".asdf", "shims")];
86
+ // One-level layouts: fnm node-versions/<v>/installation/bin, nvm node/<v>/bin.
87
+ const versioned: Array<[base: string, tail: string]> = [
88
+ [path.join(home, ".local", "share", "fnm", "node-versions"), path.join("installation", "bin")],
89
+ [path.join(home, ".nvm", "versions", "node"), "bin"],
90
+ ];
91
+ for (const [base, tail] of versioned) {
92
+ let entries: Array<{ name: string; isDirectory: () => boolean }>;
93
+ try {
94
+ entries = readdirSync(base, { withFileTypes: true });
95
+ } catch {
96
+ continue;
97
+ }
98
+ for (const entry of entries) {
99
+ try {
100
+ if (entry.isDirectory()) dirs.push(path.join(base, entry.name, tail));
101
+ } catch {
102
+ /* keep scanning */
103
+ }
104
+ }
105
+ }
106
+ return dirs;
107
+ };
@@ -1,10 +1,13 @@
1
- // Shared offline doctor (DG-07/DG-08, CA-09). One host-neutral engine checks the
1
+ // Shared offline doctor (DG-07/CA-09). One host-neutral engine checks the
2
2
  // installed Workit surfaces — pins, versions, assets, launchers, runtimes,
3
3
  // utilities, registrations, config, workspace match, credential metadata, and
4
4
  // log writability — with no network access except the optional registry probe
5
- // behind the stale-install comparison (CA-04), which fails open. Never reads
6
- // credential values: only existence, mode, and a placeholder flag are evaluated;
7
- // token bytes never enter the report or any log event.
5
+ // behind the stale-install comparison (CA-04) and the fail-open identity
6
+ // probes behind github_identity, which fail open. Credential values are never
7
+ // reported: only existence, mode, and a placeholder flag are evaluated, and
8
+ // the identity check reads a token file solely to build an Authorization
9
+ // header for a login lookup — token bytes never enter the report, any fix
10
+ // text, or any log event.
8
11
  import { spawnSync } from "node:child_process";
9
12
  import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
10
13
  import os from "node:os";
@@ -15,12 +18,21 @@ import { getDiagnosticLogger, isConfigObject } from "./config";
15
18
  import { packageRoot } from "./package-root";
16
19
  import {
17
20
  CURSOR_RUNTIME_PACKAGE,
21
+ cursorHookDrift,
18
22
  cursorHooksEntry,
19
23
  cursorMcpServerEntry,
20
24
  isWorkitPlugin,
21
25
  } from "./registration";
22
26
  import { resolveWorkspaceFrom } from "./workspaces";
23
- import { validateCursorSkills } from "./skill-manifests";
27
+ import { validateCursorSkills, WORKIT_METHOD_SKILLS } from "./skill-manifests";
28
+ import {
29
+ classifyHostGeneration,
30
+ digestFile,
31
+ readCutoverReceipt,
32
+ readGenerationState,
33
+ type CutoverHost,
34
+ type SessionObservation,
35
+ } from "./cutover";
24
36
 
25
37
  // Mirrors init.ts TOKEN_PLACEHOLDER; kept local so the doctor never needs to
26
38
  // import the YouTrack/VCS stack just to label a credential state.
@@ -31,6 +43,7 @@ export type DoctorHost = "cli" | "opencode" | "cursor";
31
43
  export type DoctorCheckId =
32
44
  | "runtime"
33
45
  | "versions"
46
+ | "codex_pin"
34
47
  | "assets"
35
48
  | "launcher"
36
49
  | "utility"
@@ -41,7 +54,13 @@ export type DoctorCheckId =
41
54
  | "malformed_config"
42
55
  | "workspace_mismatch"
43
56
  | "credential_metadata"
44
- | "log_writable";
57
+ | "github_identity"
58
+ | "log_writable"
59
+ | "legacy_component"
60
+ | "mixed_generation"
61
+ | "active_old_session"
62
+ | "managed_content_conflict"
63
+ | "missing_v1_component";
45
64
 
46
65
  export type DoctorCheckStatus = "pass" | "warn" | "fail";
47
66
 
@@ -89,6 +108,8 @@ export type DoctorOptions = {
89
108
  env?: NodeJS.ProcessEnv;
90
109
  /** Installer run: only registration/config checks count toward exitCode. */
91
110
  installer?: boolean;
111
+ /** Observed host sessions used by generation-aware cutover checks. */
112
+ sessions?: SessionObservation[];
92
113
  };
93
114
 
94
115
  type Resolved = {
@@ -104,6 +125,18 @@ type Resolved = {
104
125
  cursorPluginDir: string;
105
126
  env: NodeJS.ProcessEnv;
106
127
  installer: boolean;
128
+ sessions: SessionObservation[];
129
+ };
130
+
131
+ const parseSessionsFromEnv = (env: NodeJS.ProcessEnv): SessionObservation[] => {
132
+ const raw = env.WORKFLOW_TOOLKIT_SESSIONS;
133
+ if (!raw) return [];
134
+ try {
135
+ const parsed = JSON.parse(raw);
136
+ return Array.isArray(parsed) ? (parsed as SessionObservation[]) : [];
137
+ } catch {
138
+ return [];
139
+ }
107
140
  };
108
141
 
109
142
  const findDevFromCwd = (cwd: string): string | null => {
@@ -143,6 +176,7 @@ const resolve = (options: DoctorOptions): Resolved => {
143
176
  options.cursorPluginDir ?? path.join(home, ".cursor", "plugins", "local", "workit"),
144
177
  env,
145
178
  installer: options.installer ?? false,
179
+ sessions: options.sessions ?? parseSessionsFromEnv(env),
146
180
  };
147
181
  };
148
182
 
@@ -175,7 +209,7 @@ const pluginEntries = (cfg: Record<string, any> | null): string[] => {
175
209
  return list.map(String).filter(isWorkitPlugin);
176
210
  };
177
211
 
178
- const commandOnPath = (name: string, env: NodeJS.ProcessEnv): boolean => {
212
+ export const commandOnPath = (name: string, env: NodeJS.ProcessEnv): boolean => {
179
213
  const dirs = (env.PATH ?? process.env.PATH ?? "").split(path.delimiter);
180
214
  // win32 executables carry an .exe suffix (bun.exe, git.exe), so probe both
181
215
  // names — statSync with the bare name would never find them.
@@ -302,7 +336,9 @@ const checkVersions = (res: Resolved): DoctorCheck => {
302
336
  problems.push(`adapters pin different core versions: ${[...refs].join(", ")}`);
303
337
  }
304
338
  const opencodePkg = readJson(path.join(res.dev, "packages/workit-opencode/package.json"));
305
- const sdk = opencodePkg?.dependencies?.["@opencode-ai/plugin"];
339
+ const sdk =
340
+ opencodePkg?.dependencies?.["@opencode-ai/plugin"] ??
341
+ opencodePkg?.devDependencies?.["@opencode-ai/plugin"];
306
342
  const sdkVersion = typeof sdk === "string" ? (sdk.match(/^\d+(?:\.\d+){0,2}/) ?? [])[0] : null;
307
343
  if (sdkVersion && !semverAtLeast(sdkVersion, SUPPORT_MATRIX.opencode.minimum)) {
308
344
  problems.push(
@@ -324,19 +360,62 @@ const checkVersions = (res: Resolved): DoctorCheck => {
324
360
  };
325
361
  };
326
362
 
327
- const assetPathsFor = (host: DoctorHost, dev: string): string[] => {
363
+ // The Codex CLI is a qualification host, not a runtime dependency: evidence
364
+ // covers exactly SUPPORT_MATRIX.codex.cli. A drifted install keeps working,
365
+ // but warns so a fresh install never silently outruns the qualification pin.
366
+ const checkCodexPin = (res: Resolved): DoctorCheck => {
367
+ const qualified = SUPPORT_MATRIX.codex.cli;
368
+ if (!commandOnPath("codex", res.env))
369
+ return {
370
+ id: "codex_pin",
371
+ status: "pass",
372
+ detail: "codex CLI not on PATH — skipping pin check",
373
+ };
374
+ const raw = versionOf("codex", res.env);
375
+ const installed = raw ? ((raw.match(/\d+\.\d+\.\d+/) ?? [])[0] ?? null) : null;
376
+ if (installed === null)
377
+ return {
378
+ id: "codex_pin",
379
+ status: "warn",
380
+ detail: `could not parse the installed codex version — cannot confirm the qualified ${qualified}`,
381
+ fix: `Reinstall the qualified Codex CLI ${qualified} or record fresh qualification evidence`,
382
+ };
383
+ if (installed === qualified)
384
+ return {
385
+ id: "codex_pin",
386
+ status: "pass",
387
+ detail: `codex CLI ${installed} matches the qualified pin`,
388
+ };
389
+ return {
390
+ id: "codex_pin",
391
+ status: "warn",
392
+ detail: `codex CLI ${installed} differs from the qualified ${qualified}`,
393
+ fix: `Reinstall the qualified Codex CLI ${qualified} or record fresh qualification evidence`,
394
+ };
395
+ };
396
+
397
+ const detectDevGeneration = (dev: string): "legacy" | "v1" =>
398
+ existsSync(path.join(dev, "packages/workit-opencode/assets/skills/workit-plan/SKILL.md"))
399
+ ? "v1"
400
+ : "legacy";
401
+
402
+ const effectiveGeneration = (res: Resolved): "legacy" | "v1" => {
403
+ const configured = readGenerationState(res.configDir).target;
404
+ if (configured === "v1") return "v1";
405
+ if (res.dev && detectDevGeneration(res.dev) === "v1") return "v1";
406
+ return "legacy";
407
+ };
408
+
409
+ const assetPathsFor = (host: DoctorHost, dev: string, _generation: "legacy" | "v1"): string[] => {
328
410
  const pkg = path.join(dev, "packages", `workit-${host}`);
329
411
  switch (host) {
330
412
  case "opencode":
331
- return [
332
- path.join(pkg, "assets", "commands", "wk-init.md"),
333
- path.join(pkg, "assets", "skills", "wk-init", "SKILL.md"),
334
- path.join(pkg, "assets", "templates", "spec-template.md"),
335
- path.join(pkg, "assets", "vendor", "superpowers", "skills", "brainstorming", "SKILL.md"),
336
- ];
413
+ return WORKIT_METHOD_SKILLS.map((skill) =>
414
+ path.join(pkg, "assets", "skills", skill, "SKILL.md"),
415
+ );
337
416
  case "cursor":
338
417
  return [
339
- path.join(pkg, "assets", "templates", "spec-template.md"),
418
+ path.join(pkg, "assets", "templates", "workit-contract.md"),
340
419
  path.join(pkg, "mcp.json"),
341
420
  path.join(pkg, ".cursor-plugin"),
342
421
  ];
@@ -352,15 +431,16 @@ const hostsFor = (host: DoctorHost): DoctorHost[] =>
352
431
 
353
432
  const checkAssets = (res: Resolved): DoctorCheck => {
354
433
  const dev = res.dev;
434
+ const generation = effectiveGeneration(res);
355
435
  const missing = dev
356
436
  ? hostsFor(res.host).flatMap((h) =>
357
- assetPathsFor(h, dev)
437
+ assetPathsFor(h, dev, generation)
358
438
  .filter((p) => !existsSync(p))
359
439
  .map((p) => `${h}: ${p}`),
360
440
  )
361
441
  : [];
362
442
  if (res.host === "cursor" || res.host === "cli") {
363
- const cursorError = validateCursorSkills(res.cursorPluginDir);
443
+ const cursorError = validateCursorSkills(res.cursorPluginDir, WORKIT_METHOD_SKILLS);
364
444
  if (cursorError) missing.push(`cursor: ${cursorError}`);
365
445
  }
366
446
  if (missing.length === 0) {
@@ -740,6 +820,18 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
740
820
  fix: "Re-run install-cursor-plugin.sh — it rewrites the sessionStart hook to the canonical @latest selector",
741
821
  };
742
822
  }
823
+ // Enforcement-event drift: a present-but-divergent preToolUse matcher (or
824
+ // beforeShellExecution command) silently narrows what the hook intercepts.
825
+ // Absent events are filled by the installer merge, so only divergence fails.
826
+ const hookDrift = existsSync(hooksFile) ? cursorHookDrift(readJson(hooksFile)) : [];
827
+ if (hookDrift.length > 0) {
828
+ return {
829
+ id: "stale_install",
830
+ status: "fail",
831
+ detail: `stale_install: cursor hook drift on ${hookDrift.join(", ")} (differs from the canonical hook entries)`,
832
+ fix: "Re-run install-cursor-plugin.sh — it rewrites the workit hook entries to canonical",
833
+ };
834
+ }
743
835
  const installed = installedPluginVersion(res);
744
836
  const source = res.dev
745
837
  ? ((readJson(path.join(res.dev, "packages/workit-cursor/package.json"))?.version as
@@ -988,6 +1080,150 @@ const checkCredentialMetadata = (res: Resolved): DoctorCheck => {
988
1080
  };
989
1081
  };
990
1082
 
1083
+ // Identity surfaces for the github_identity check. Logins are public
1084
+ // usernames (safe to report); the token itself only ever travels as an
1085
+ // Authorization header value and never enters details, fixes, or logs.
1086
+ export type IdentitySurface = { surface: string; login: string | null };
1087
+
1088
+ export type IdentityProbes = {
1089
+ origin: (cwd: string) => string | null;
1090
+ ghLogin: (env: NodeJS.ProcessEnv) => string | null;
1091
+ tokenLogin: (tokenFile: string, host: string) => string | null;
1092
+ sshLogin: (host: string) => string | null;
1093
+ };
1094
+
1095
+ export const githubHostFromRemote = (remote: string): string | null => {
1096
+ const rest = (remote || "").trim();
1097
+ if (!rest) return null;
1098
+ const scp = /^[^@/]+@([^:]+):/.exec(rest);
1099
+ if (scp) return scp[1].toLowerCase();
1100
+ try {
1101
+ const url = new URL(rest.includes("://") ? rest : `https://${rest}`);
1102
+ return url.hostname.toLowerCase() || null;
1103
+ } catch {
1104
+ return null;
1105
+ }
1106
+ };
1107
+
1108
+ export const githubIdentityFinding = (surfaces: IdentitySurface[]): DoctorCheck => {
1109
+ const resolved = surfaces.filter(
1110
+ (entry): entry is { surface: string; login: string } => typeof entry.login === "string",
1111
+ );
1112
+ const distinct = [...new Set(resolved.map((entry) => entry.login.toLowerCase()))];
1113
+ if (distinct.length < 2) {
1114
+ return {
1115
+ id: "github_identity",
1116
+ status: "pass",
1117
+ detail:
1118
+ resolved.length === 0
1119
+ ? "no GitHub identities configured"
1120
+ : "single GitHub identity across configured surfaces",
1121
+ };
1122
+ }
1123
+ return {
1124
+ id: "github_identity",
1125
+ status: "warn",
1126
+ detail: `GitHub identities disagree across configured surfaces: ${resolved
1127
+ .map((entry) => `${entry.surface} reports "${entry.login}"`)
1128
+ .join("; ")}. Operations may land under the wrong account.`,
1129
+ fix: "Align every surface on the intended account via your own git/gh configuration (gh auth login/switch plus the vcs token file at mode 0600) — workit never hardcodes accounts",
1130
+ };
1131
+ };
1132
+
1133
+ const defaultIdentityProbes = (env: NodeJS.ProcessEnv): IdentityProbes => ({
1134
+ origin: (cwd) => {
1135
+ try {
1136
+ const out = spawnSync("git", ["remote", "get-url", "origin"], {
1137
+ cwd,
1138
+ encoding: "utf8",
1139
+ env,
1140
+ });
1141
+ return out.status === 0 ? (out.stdout ?? "").trim() || null : null;
1142
+ } catch {
1143
+ return null;
1144
+ }
1145
+ },
1146
+ ghLogin: (e) => {
1147
+ try {
1148
+ const out = spawnSync("gh", ["api", "user", "--jq", ".login"], { encoding: "utf8", env: e });
1149
+ const login = (out.stdout ?? "").trim();
1150
+ return out.status === 0 && login ? login : null;
1151
+ } catch {
1152
+ return null;
1153
+ }
1154
+ },
1155
+ tokenLogin: (tokenFile, host) => {
1156
+ let token = "";
1157
+ try {
1158
+ token = readFileSync(tokenFile, "utf8").trim();
1159
+ } catch {
1160
+ return null;
1161
+ }
1162
+ if (!token || token === TOKEN_PLACEHOLDER || token.startsWith(TOKEN_PLACEHOLDER)) return null;
1163
+ try {
1164
+ const out = spawnSync("gh", ["api", "user", "--jq", ".login"], {
1165
+ encoding: "utf8",
1166
+ env: { ...env, GH_TOKEN: token, ...(host === "github.com" ? {} : { GH_HOST: host }) },
1167
+ });
1168
+ const login = (out.stdout ?? "").trim();
1169
+ return out.status === 0 && login ? login : null;
1170
+ } catch {
1171
+ return null;
1172
+ }
1173
+ },
1174
+ sshLogin: (host) => {
1175
+ try {
1176
+ const out = spawnSync(
1177
+ "ssh",
1178
+ ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T", `git@${host}`],
1179
+ {
1180
+ encoding: "utf8",
1181
+ env,
1182
+ },
1183
+ );
1184
+ const text = `${out.stdout ?? ""}${out.stderr ?? ""}`;
1185
+ const match = /^Hi ([^!]+)!/.exec(text);
1186
+ return match ? match[1].trim() || null : null;
1187
+ } catch {
1188
+ return null;
1189
+ }
1190
+ },
1191
+ });
1192
+
1193
+ const githubTokenFile = (res: Pick<Resolved, "configDir">): string | null => {
1194
+ const vcsJson = readJson(path.join(res.configDir, "vcs.json"));
1195
+ const provider = vcsJson?.github;
1196
+ if (provider && typeof provider === "object") {
1197
+ const tokenFile = (provider as Record<string, unknown>).tokenFile;
1198
+ const raw =
1199
+ typeof tokenFile === "string" ? tokenFile : path.join(res.configDir, "github.token");
1200
+ return path.isAbsolute(raw) ? raw : path.resolve(res.configDir, raw);
1201
+ }
1202
+ if (!vcsJson) {
1203
+ const fallback = path.join(res.configDir, "github.token");
1204
+ return existsSync(fallback) ? fallback : null;
1205
+ }
1206
+ return null;
1207
+ };
1208
+
1209
+ export const checkGithubIdentity = (
1210
+ res: Pick<Resolved, "cwd" | "configDir" | "env">,
1211
+ probes: IdentityProbes = defaultIdentityProbes(res.env),
1212
+ ): DoctorCheck => {
1213
+ const host = githubHostFromRemote(probes.origin(res.cwd) ?? "");
1214
+ if (!host)
1215
+ return { id: "github_identity", status: "pass", detail: "no GitHub remote configured" };
1216
+ const surfaces: IdentitySurface[] = [{ surface: "gh CLI", login: probes.ghLogin(res.env) }];
1217
+ const tokenFile = githubTokenFile(res);
1218
+ if (tokenFile)
1219
+ surfaces.push({
1220
+ surface: `token file ${tokenFile}`,
1221
+ login: probes.tokenLogin(tokenFile, host),
1222
+ });
1223
+ surfaces.push({ surface: "SSH", login: probes.sshLogin(host) });
1224
+ return githubIdentityFinding(surfaces);
1225
+ };
1226
+
991
1227
  const checkLogWritable = (res: Resolved): DoctorCheck => {
992
1228
  const logsDir = path.join(res.stateDir, "logs");
993
1229
  // Fixed-name probe: even a killed process leaves at most one bounded file that
@@ -1017,9 +1253,177 @@ const checkLogWritable = (res: Resolved): DoctorCheck => {
1017
1253
  }
1018
1254
  };
1019
1255
 
1256
+ const GENERATION_HOSTS: CutoverHost[] = ["opencode", "cursor", "codex", "pi"];
1257
+
1258
+ const generationPaths = (res: Resolved) => ({
1259
+ home: res.home,
1260
+ configDir: res.configDir,
1261
+ stateDir: res.stateDir,
1262
+ dev: res.dev,
1263
+ workspace: res.cwd,
1264
+ opencodeConfig: res.opencodeConfig,
1265
+ cursorSettings: res.cursorSettings,
1266
+ cursorMcp: res.cursorMcp,
1267
+ cursorPluginDir: res.cursorPluginDir,
1268
+ sessions: res.sessions,
1269
+ });
1270
+
1271
+ const checkMixedGeneration = (res: Resolved): DoctorCheck => {
1272
+ const paths = generationPaths(res);
1273
+ const mixed = GENERATION_HOSTS.filter((host) => classifyHostGeneration(host, paths) === "mixed");
1274
+ const target = readGenerationState(res.configDir).target;
1275
+ if (mixed.length === 0) {
1276
+ return {
1277
+ id: "mixed_generation",
1278
+ status: "pass",
1279
+ detail: "no mixed legacy/v1 components detected",
1280
+ };
1281
+ }
1282
+ const detail = `mixed legacy and v1 components: ${mixed.join(", ")}`;
1283
+ if (target === "v1") {
1284
+ return {
1285
+ id: "mixed_generation",
1286
+ status: "fail",
1287
+ detail,
1288
+ fix: "Run the approved v1 cutover preview/apply or remove the conflicting generation",
1289
+ };
1290
+ }
1291
+ return {
1292
+ id: "mixed_generation",
1293
+ status: "warn",
1294
+ detail: `${detail} (legacy target — cutover required before v1 activation)`,
1295
+ fix: "Preview cutover with `workit init` or the CLI cutover flow before activating v1",
1296
+ };
1297
+ };
1298
+
1299
+ const checkLegacyComponent = (res: Resolved): DoctorCheck => {
1300
+ const target = readGenerationState(res.configDir).target;
1301
+ if (target !== "v1") {
1302
+ return {
1303
+ id: "legacy_component",
1304
+ status: "pass",
1305
+ detail: "legacy target — 0.x components expected",
1306
+ };
1307
+ }
1308
+ const paths = generationPaths(res);
1309
+ const legacy = GENERATION_HOSTS.filter(
1310
+ (host) => classifyHostGeneration(host, paths) === "legacy",
1311
+ );
1312
+ if (legacy.length === 0) {
1313
+ return {
1314
+ id: "legacy_component",
1315
+ status: "pass",
1316
+ detail: "no legacy-only host components on v1 target",
1317
+ };
1318
+ }
1319
+ return {
1320
+ id: "legacy_component",
1321
+ status: "fail",
1322
+ detail: `legacy components remain on: ${legacy.join(", ")}`,
1323
+ fix: "Complete the approved cutover or rollback to legacy before mixing generations",
1324
+ };
1325
+ };
1326
+
1327
+ const checkMissingV1Component = (res: Resolved): DoctorCheck => {
1328
+ const target = readGenerationState(res.configDir).target;
1329
+ if (target !== "v1") {
1330
+ return { id: "missing_v1_component", status: "pass", detail: "legacy target" };
1331
+ }
1332
+ const paths = generationPaths(res);
1333
+ const missing = GENERATION_HOSTS.filter((host) => {
1334
+ const gen = classifyHostGeneration(host, paths);
1335
+ return gen === "none" || gen === "legacy";
1336
+ });
1337
+ if (missing.length === 0) {
1338
+ return {
1339
+ id: "missing_v1_component",
1340
+ status: "pass",
1341
+ detail: "v1 components present on selected hosts",
1342
+ };
1343
+ }
1344
+ return {
1345
+ id: "missing_v1_component",
1346
+ status: "fail",
1347
+ detail: `missing v1 components on: ${missing.join(", ")}`,
1348
+ fix: "Re-run the cutover apply step or the host install script for the missing target",
1349
+ };
1350
+ };
1351
+
1352
+ const checkActiveOldSession = (res: Resolved): DoctorCheck => {
1353
+ if (res.sessions.length === 0) {
1354
+ return {
1355
+ id: "active_old_session",
1356
+ status: "pass",
1357
+ detail: "no session observations supplied to doctor",
1358
+ };
1359
+ }
1360
+ const blocking = res.sessions.filter((s) => s.state === "active" || s.state === "unknown");
1361
+ if (blocking.length === 0) {
1362
+ return {
1363
+ id: "active_old_session",
1364
+ status: "pass",
1365
+ detail: "no active or unknown old sessions observed",
1366
+ };
1367
+ }
1368
+ const detail = blocking.map((s) => `${s.host}:${s.handle} (${s.state})`).join(", ");
1369
+ const target = readGenerationState(res.configDir).target;
1370
+ if (target === "v1") {
1371
+ return {
1372
+ id: "active_old_session",
1373
+ status: "fail",
1374
+ detail: `active or unknown sessions block v1 activation: ${detail}`,
1375
+ fix: "Stop or account for old sessions before cutover apply",
1376
+ };
1377
+ }
1378
+ return {
1379
+ id: "active_old_session",
1380
+ status: "warn",
1381
+ detail: `old sessions still observed: ${detail}`,
1382
+ fix: "Stop old sessions before previewing or applying v1 cutover",
1383
+ };
1384
+ };
1385
+
1386
+ const checkManagedContentConflict = (res: Resolved): DoctorCheck => {
1387
+ const gen = readGenerationState(res.configDir);
1388
+ if (!gen.cutover?.backupId) {
1389
+ return {
1390
+ id: "managed_content_conflict",
1391
+ status: "pass",
1392
+ detail: "no cutover receipt to compare",
1393
+ };
1394
+ }
1395
+ const receipt = readCutoverReceipt(gen.cutover.backupId, res.stateDir);
1396
+ if (!receipt) {
1397
+ return {
1398
+ id: "managed_content_conflict",
1399
+ status: "fail",
1400
+ detail: `cutover receipt missing for backup ${gen.cutover.backupId}`,
1401
+ fix: "Re-run cutover apply or rollback to a known state",
1402
+ };
1403
+ }
1404
+ const conflicts = receipt.managedFiles.filter((entry) => {
1405
+ const current = digestFile(entry.path);
1406
+ return current !== null && current !== entry.installedDigest;
1407
+ });
1408
+ if (conflicts.length === 0) {
1409
+ return {
1410
+ id: "managed_content_conflict",
1411
+ status: "pass",
1412
+ detail: "managed content matches cutover receipt",
1413
+ };
1414
+ }
1415
+ return {
1416
+ id: "managed_content_conflict",
1417
+ status: "fail",
1418
+ detail: `managed content drifted since cutover on: ${conflicts.map((c) => c.path).join(", ")}`,
1419
+ fix: "Reconcile managed files with the cutover receipt or roll back before re-applying",
1420
+ };
1421
+ };
1422
+
1020
1423
  const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
1021
1424
  checkRuntime,
1022
1425
  checkVersions,
1426
+ checkCodexPin,
1023
1427
  checkAssets,
1024
1428
  checkLauncher,
1025
1429
  checkUtility,
@@ -1029,7 +1433,13 @@ const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
1029
1433
  checkMalformedConfig,
1030
1434
  checkWorkspaceMismatch,
1031
1435
  checkCredentialMetadata,
1436
+ checkGithubIdentity,
1032
1437
  checkLogWritable,
1438
+ checkMixedGeneration,
1439
+ checkLegacyComponent,
1440
+ checkMissingV1Component,
1441
+ checkActiveOldSession,
1442
+ checkManagedContentConflict,
1033
1443
  ];
1034
1444
 
1035
1445
  // AR-11/CA-40: the installer guarantees the selected host itself — runtime,