@brainervirus/workit-core 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -9,7 +9,16 @@
9
9
  // header for a login lookup — token bytes never enter the report, any fix
10
10
  // text, or any log event.
11
11
  import { spawnSync } from "node:child_process";
12
- import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
12
+ import {
13
+ existsSync,
14
+ lstatSync,
15
+ mkdirSync,
16
+ readFileSync,
17
+ readlinkSync,
18
+ statSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ } from "node:fs";
13
22
  import os from "node:os";
14
23
  import path from "node:path";
15
24
  import { SUPPORT_MATRIX } from "./support-matrix";
@@ -18,6 +27,7 @@ import { getDiagnosticLogger, isConfigObject } from "./config";
18
27
  import { packageRoot } from "./package-root";
19
28
  import {
20
29
  CURSOR_RUNTIME_PACKAGE,
30
+ OPENCODE_NPM_PIN,
21
31
  cursorHookDrift,
22
32
  cursorHooksEntry,
23
33
  cursorMcpServerEntry,
@@ -102,6 +112,8 @@ export type DoctorOptions = {
102
112
  dev?: string;
103
113
  cwd?: string;
104
114
  opencodeConfig?: string;
115
+ /** OpenCode npm `@latest` package cache root (test seam). */
116
+ opencodePackageCacheDir?: string;
105
117
  cursorSettings?: string;
106
118
  cursorMcp?: string;
107
119
  cursorPluginDir?: string;
@@ -120,6 +132,7 @@ type Resolved = {
120
132
  cwd: string;
121
133
  dev: string | null;
122
134
  opencodeConfig: string;
135
+ opencodePackageCacheDir: string;
123
136
  cursorSettings: string;
124
137
  cursorMcp: string;
125
138
  cursorPluginDir: string;
@@ -170,6 +183,9 @@ const resolve = (options: DoctorOptions): Resolved => {
170
183
  dev,
171
184
  opencodeConfig:
172
185
  options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
186
+ opencodePackageCacheDir:
187
+ options.opencodePackageCacheDir ??
188
+ path.join(home, ".cache", "opencode", "packages", "@brainervirus", "workit-opencode@latest"),
173
189
  cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
174
190
  cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
175
191
  cursorPluginDir:
@@ -661,15 +677,28 @@ const checkUtility = (res: Resolved): DoctorCheck => {
661
677
  };
662
678
  };
663
679
 
664
- const staleEntry = (entry: string): "ok" | "stale" | "missing-file" => {
680
+ /** file:// pins into package-manager ephemeral caches (pnpm dlx, npx). */
681
+ const isFragileCachePin = (entry: string): boolean => {
682
+ const n = entry.replaceAll("\\", "/").toLowerCase();
683
+ if (!n.startsWith("file:")) return false;
684
+ return (
685
+ n.includes("/caches/pnpm/dlx/") ||
686
+ n.includes("/_npx/") ||
687
+ n.includes("/.pacquet/") ||
688
+ n.includes("/library/caches/pnpm/")
689
+ );
690
+ };
691
+
692
+ const staleEntry = (entry: string): "ok" | "stale" | "missing-file" | "fragile-cache" => {
665
693
  if (!entry) return "stale";
666
694
  if (entry.includes("git+file")) return "stale";
695
+ if (isFragileCachePin(entry)) return "fragile-cache";
667
696
  if (entry.startsWith("file:")) {
668
697
  const target = entry.replace(/^file:\/\//, "").replace(/^file:/, "");
669
698
  return existsSync(target) ? "ok" : "missing-file";
670
699
  }
671
- // registry/ssh/git pins are not the canonical file pin, but are left to the
672
- // duplicate/versions checks rather than being labelled stale.
700
+ // Registry package names (e.g. @brainervirus/workit-opencode) are the
701
+ // published pin shape — OpenCode resolves them; not labelled stale.
673
702
  return "ok";
674
703
  };
675
704
 
@@ -695,7 +724,7 @@ const checkStalePin = (res: Resolved): DoctorCheck => {
695
724
  id: "stale_pin",
696
725
  status: "fail",
697
726
  detail: "no workit plugin registered in the opencode config",
698
- fix: "Run install-opencode-plugin.sh to pin the workit plugin entry",
727
+ fix: 'Run `workit init` and select OpenCode — pin "@brainervirus/workit-opencode"',
699
728
  };
700
729
  }
701
730
  const first = staleEntry(entries[0]);
@@ -704,7 +733,15 @@ const checkStalePin = (res: Resolved): DoctorCheck => {
704
733
  id: "stale_pin",
705
734
  status: "fail",
706
735
  detail: `stale workit pin: ${entries[0]}`,
707
- fix: "Re-run install-opencode-plugin.sh — the pin points at a git+file or non-file source",
736
+ fix: "Run `workit init` (or install-opencode-plugin.sh for a checkout) — replace git+file pins",
737
+ };
738
+ }
739
+ if (first === "fragile-cache") {
740
+ return {
741
+ id: "stale_pin",
742
+ status: "fail",
743
+ detail: `workit pin points at a package-manager cache path: ${entries[0]}`,
744
+ fix: 'Run `workit init` and select OpenCode — pin should be "@brainervirus/workit-opencode"',
708
745
  };
709
746
  }
710
747
  if (first === "missing-file") {
@@ -712,7 +749,7 @@ const checkStalePin = (res: Resolved): DoctorCheck => {
712
749
  id: "stale_pin",
713
750
  status: "fail",
714
751
  detail: `workit pin points at a missing file: ${entries[0]}`,
715
- fix: "Re-run install-opencode-plugin.sh after restoring the checkout the pin references",
752
+ fix: "Run `workit init` (or restore the checkout and re-run install-opencode-plugin.sh)",
716
753
  };
717
754
  }
718
755
  return { id: "stale_pin", status: "pass", detail: "opencode pin resolves" };
@@ -741,6 +778,13 @@ const checkStalePin = (res: Resolved): DoctorCheck => {
741
778
  // `@latest` installs skip the probe entirely: the selector resolves fresh
742
779
  // at launch, so the installed package.json version is metadata, not a
743
780
  // freshness signal.
781
+ // OpenCode npm pins (`@brainervirus/workit-opencode` / `@…@latest`) are different:
782
+ // OpenCode freezes the first resolved version under
783
+ // `~/.cache/opencode/packages/@brainervirus/workit-opencode@latest` and does not
784
+ // re-resolve on later launches. Doctor compares that cache to the published
785
+ // package (same CA-04 fail-open seam) and tells the user to delete the cache
786
+ // directory so OpenCode can resolve `@latest` again. Exact `@version` pins and
787
+ // `file://` checkout pins skip the comparison.
744
788
  // Test seams (no spawns on the canonical path): WORKIT_DOCTOR_STALE_REGISTRY_VERSION
745
789
  // short-circuits the probe with the resolved latest version; the optional
746
790
  // WORKIT_DOCTOR_STALE_REGISTRY_CMD replaces the `npm view` binary (a
@@ -756,7 +800,15 @@ const pluginMcpSelectors = (res: Resolved): string[] | null => {
756
800
  return args.map(String).filter((a) => a.startsWith("--package="));
757
801
  };
758
802
 
759
- const registryLatestVersion = (res: Resolved): string | null => {
803
+ const isFloatingOpenCodeNpmPin = (entry: string): boolean => {
804
+ const s = entry.trim();
805
+ return s === OPENCODE_NPM_PIN || s === `${OPENCODE_NPM_PIN}@latest`;
806
+ };
807
+
808
+ const registryLatestVersion = (
809
+ res: Resolved,
810
+ pkgName: string = CURSOR_RUNTIME_PACKAGE,
811
+ ): string | null => {
760
812
  const seam = res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION;
761
813
  if (typeof seam === "string" && seam) {
762
814
  return /^\d+(?:\.\d+){1,2}$/.test(seam) ? seam : null;
@@ -765,7 +817,7 @@ const registryLatestVersion = (res: Resolved): string | null => {
765
817
  res.env.WORKIT_DOCTOR_STALE_REGISTRY_CMD ?? (commandOnPath("npm", res.env) ? "npm" : null);
766
818
  if (!cmd) return null;
767
819
  try {
768
- const r = spawnSync(cmd, ["view", CURSOR_RUNTIME_PACKAGE, "version"], {
820
+ const r = spawnSync(cmd, ["view", pkgName, "version"], {
769
821
  encoding: "utf8",
770
822
  timeout: 20_000,
771
823
  env: res.env,
@@ -782,12 +834,120 @@ const installedPluginVersion = (res: Resolved): string | null => {
782
834
  return typeof pkg?.version === "string" && pkg.version ? pkg.version : null;
783
835
  };
784
836
 
837
+ const installedOpenCodeCacheVersion = (res: Resolved): string | null => {
838
+ const pkg = readJson(
839
+ path.join(
840
+ res.opencodePackageCacheDir,
841
+ "node_modules",
842
+ "@brainervirus",
843
+ "workit-opencode",
844
+ "package.json",
845
+ ),
846
+ );
847
+ return typeof pkg?.version === "string" && pkg.version ? pkg.version : null;
848
+ };
849
+
850
+ /** OpenCode freezes the first `@latest` resolve in its package cache — detect lag. */
851
+ const checkOpenCodePackageCache = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
852
+ if (!existsSync(res.opencodeConfig)) {
853
+ return {
854
+ id: "stale_install",
855
+ status: "pass",
856
+ detail: "no opencode config — package cache not inspected",
857
+ };
858
+ }
859
+ const entries = pluginEntries(readJson(res.opencodeConfig));
860
+ if (entries.length === 0) {
861
+ return {
862
+ id: "stale_install",
863
+ status: "pass",
864
+ detail: "no workit opencode pin — package cache not inspected",
865
+ };
866
+ }
867
+ const pin = entries[0]!;
868
+ if (pin.startsWith("file:") || pin.startsWith("git+file:")) {
869
+ return {
870
+ id: "stale_install",
871
+ status: "pass",
872
+ detail: "opencode checkout pin — package cache not used for npm resolution",
873
+ };
874
+ }
875
+ if (!isFloatingOpenCodeNpmPin(pin)) {
876
+ return {
877
+ id: "stale_install",
878
+ status: "pass",
879
+ detail: `opencode pin ${pin} is an exact version — not compared to registry latest`,
880
+ };
881
+ }
882
+ const installed = installedOpenCodeCacheVersion(res);
883
+ if (installed === null) {
884
+ return {
885
+ id: "stale_install",
886
+ status: "pass",
887
+ detail: "opencode @latest package cache empty — OpenCode will resolve on next launch",
888
+ };
889
+ }
890
+ const expected = registryLatestVersion(res, OPENCODE_NPM_PIN);
891
+ if (expected === null) {
892
+ return {
893
+ id: "registry_unreachable",
894
+ status: "warn",
895
+ registryProbed: true,
896
+ detail:
897
+ "registry_unreachable: cannot compare OpenCode @latest cache against published workit-opencode",
898
+ fix: `Retry when npm is reachable, or remove ${res.opencodePackageCacheDir} and restart OpenCode to re-resolve`,
899
+ };
900
+ }
901
+ if (!semverAtLeast(installed, expected)) {
902
+ return {
903
+ id: "stale_install",
904
+ status: "fail",
905
+ registryProbed: true,
906
+ detail: `stale_install: OpenCode @latest cache has workit-opencode ${installed} behind published ${expected}`,
907
+ fix: `Remove ${res.opencodePackageCacheDir} and restart OpenCode — it re-resolves @latest on the next launch`,
908
+ };
909
+ }
910
+ return {
911
+ id: "stale_install",
912
+ status: "pass",
913
+ registryProbed: true,
914
+ detail: `OpenCode @latest cache workit-opencode ${installed} matches published ${expected}`,
915
+ };
916
+ };
917
+
785
918
  const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
919
+ if (res.host === "opencode") {
920
+ return checkOpenCodePackageCache(res);
921
+ }
786
922
  if (res.host !== "cursor" && res.host !== "cli") {
787
923
  return {
788
924
  id: "stale_install",
789
925
  status: "pass",
790
- detail: "cursor plugin not inspected on the opencode host",
926
+ detail: "cursor plugin not inspected on this host",
927
+ };
928
+ }
929
+ // Symlink installs into pnpm dlx / _npx caches break when the cache is cleared.
930
+ // MCP launchers may still be canonical; the plugin dir itself must be real.
931
+ if (existsSync(res.cursorPluginDir) && lstatSync(res.cursorPluginDir).isSymbolicLink()) {
932
+ let target = "";
933
+ try {
934
+ target = readlinkSync(res.cursorPluginDir);
935
+ } catch {
936
+ target = "(unreadable)";
937
+ }
938
+ const n = target.replaceAll("\\", "/").toLowerCase();
939
+ const fragile =
940
+ n.includes("/caches/pnpm/dlx/") ||
941
+ n.includes("/_npx/") ||
942
+ n.includes("/.pacquet/") ||
943
+ n.includes("/library/caches/pnpm/");
944
+ return {
945
+ id: "stale_install",
946
+ status: "fail",
947
+ detail: fragile
948
+ ? `stale_install: cursor plugin dir is a symlink into a package-manager cache (${target})`
949
+ : `stale_install: cursor plugin dir is a symlink (${target}) — expected a real directory copy`,
950
+ fix: "Run `workit init` and select Cursor — install must copy into ~/.cursor/plugins/local/workit",
791
951
  };
792
952
  }
793
953
  const canonicalMcp = cursorMcpServerEntry("").args.find((a) => a.startsWith("--package="));
@@ -876,21 +1036,31 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
876
1036
  fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
877
1037
  };
878
1038
  }
879
- return {
880
- id: "stale_install",
881
- status: "pass",
1039
+ const cursorOk = {
1040
+ id: "stale_install" as const,
1041
+ status: "pass" as const,
882
1042
  registryProbed: true,
883
1043
  detail: `installed local-dist workit-cursor ${installed} matches the published runtime ${expected}`,
884
1044
  };
1045
+ if (res.host === "cli") {
1046
+ const oc = checkOpenCodePackageCache(res);
1047
+ if (oc.status !== "pass") return oc;
1048
+ }
1049
+ return cursorOk;
885
1050
  }
886
- return {
887
- id: "stale_install",
888
- status: "pass",
1051
+ const cursorOk = {
1052
+ id: "stale_install" as const,
1053
+ status: "pass" as const,
889
1054
  detail:
890
1055
  installed === null
891
1056
  ? "installed workit-cursor selectors are canonical"
892
1057
  : `installed workit-cursor ${installed} is metadata on the canonical @latest install (fresh at launch)`,
893
1058
  };
1059
+ if (res.host === "cli") {
1060
+ const oc = checkOpenCodePackageCache(res);
1061
+ if (oc.status !== "pass") return oc;
1062
+ }
1063
+ return cursorOk;
894
1064
  };
895
1065
 
896
1066
  const checkDuplicateRegistration = (res: Resolved): DoctorCheck => {
@@ -1035,7 +1205,16 @@ const checkCredentialMetadata = (res: Resolved): DoctorCheck => {
1035
1205
  }
1036
1206
 
1037
1207
  const vcsJson = readJson(path.join(res.configDir, "vcs.json"));
1038
- for (const key of ["gitlab", "github"] as const) {
1208
+ // Only the active VCS provider's token is required. vcs.json scaffolds both
1209
+ // github and gitlab blocks for switching later — inactive tokenFile paths
1210
+ // must not fail the doctor when provider is set to the other host.
1211
+ const active =
1212
+ typeof vcsJson?.provider === "string" ? vcsJson.provider.trim().toLowerCase() : null;
1213
+ const vcsKeys =
1214
+ active === "gitlab" || active === "github"
1215
+ ? ([active] as const)
1216
+ : (["gitlab", "github"] as const);
1217
+ for (const key of vcsKeys) {
1039
1218
  const provider = vcsJson?.[key];
1040
1219
  if (provider && typeof provider === "object") {
1041
1220
  const tf = provider.tokenFile;
@@ -6,6 +6,9 @@
6
6
  // there is exactly one source of truth for registration merging.
7
7
  import path from "node:path";
8
8
 
9
+ /** Canonical published OpenCode plugin identity (OpenCode resolves from npm). */
10
+ export const OPENCODE_NPM_PIN = "@brainervirus/workit-opencode";
11
+
9
12
  export interface MergeResult<T> {
10
13
  config: T;
11
14
  changed: string[];
package/src/core/setup.ts CHANGED
@@ -2,10 +2,12 @@ import {
2
2
  copyFileSync,
3
3
  cpSync,
4
4
  existsSync,
5
+ lstatSync,
5
6
  mkdirSync,
6
7
  mkdtempSync,
7
8
  readFileSync,
8
9
  readdirSync,
10
+ realpathSync,
9
11
  renameSync,
10
12
  rmSync,
11
13
  statSync,
@@ -38,6 +40,7 @@ import {
38
40
  mergeCursorMcp,
39
41
  mergeCursorSettings,
40
42
  mergeOpenCodeConfig,
43
+ OPENCODE_NPM_PIN,
41
44
  } from "./registration";
42
45
  import { runDoctor, type DoctorReport } from "./doctor";
43
46
  import { writeFileExclusive } from "./safe-write";
@@ -750,7 +753,19 @@ function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
750
753
  return null;
751
754
  }
752
755
 
753
- const opencodePin = (root: string): string | null => {
756
+ /** True when the adapter root is a monorepo checkout (dev pin), not a packed node_modules install. */
757
+ export const isDevOpenCodeAdapterRoot = (root: string, dev: string | null | undefined): boolean => {
758
+ if (dev) return true;
759
+ const norm = root.replaceAll("\\", "/");
760
+ return /\/packages\/workit-opencode\/?$/.test(norm);
761
+ };
762
+
763
+ /** Published installs pin the npm package; checkout installs pin file:// entry. */
764
+ export const resolveOpenCodePin = (
765
+ root: string,
766
+ opts: { dev?: string | null } = {},
767
+ ): string | null => {
768
+ if (!isDevOpenCodeAdapterRoot(root, opts.dev)) return OPENCODE_NPM_PIN;
754
769
  for (const rel of ["src/plugin.ts", "dist/plugin.js"]) {
755
770
  const entry = path.join(root, rel);
756
771
  if (existsSync(entry)) return `file://${entry}`;
@@ -758,8 +773,11 @@ const opencodePin = (root: string): string | null => {
758
773
  return null;
759
774
  };
760
775
 
776
+ const opencodePin = (root: string, res: ResolvedApply): string | null =>
777
+ resolveOpenCodePin(root, { dev: res.dev });
778
+
761
779
  function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
762
- const pin = opencodePin(root);
780
+ const pin = opencodePin(root, res);
763
781
  if (!pin) {
764
782
  return {
765
783
  platform: "opencode",
@@ -853,28 +871,39 @@ const preservedCursorRules = (src: string, dest: string): Map<string, Buffer> =>
853
871
  return preserved;
854
872
  };
855
873
 
856
- function copyPluginDir(src: string, dest: string): SetupResultStatus {
874
+ /** Install Cursor plugin files as a real directory (never a symlink into a package cache). */
875
+ export function copyPluginDir(src: string, dest: string): SetupResultStatus {
876
+ // pnpm dlx/pacquet (and similar) expose packages as symlinks into a content
877
+ // store. Node's cpSync copies a symlink *root* as a symlink, so the installed
878
+ // plugin would point into an ephemeral cache. Always materialize from the
879
+ // realpath so ~/.cursor/plugins/local/workit is a real directory.
880
+ const realSrc = realpathSync(src);
857
881
  const marker = path.join(dest, ".workit-root");
858
- const synced = readFileSafe(marker)?.trim() === src && samePluginContent(src, dest);
882
+ const destIsLink = existsSync(dest) && lstatSync(dest).isSymbolicLink();
883
+ const synced =
884
+ !destIsLink && readFileSafe(marker)?.trim() === realSrc && samePluginContent(realSrc, dest);
859
885
  if (synced) return "Skipped";
860
886
  const hadDir = existsSync(dest);
861
- const rules = preservedCursorRules(src, dest);
887
+ const rules = preservedCursorRules(realSrc, dest);
862
888
  const parent = path.dirname(dest);
863
889
  mkdirSync(parent, { recursive: true });
864
890
  const swap = mkdtempSync(path.join(parent, `.${path.basename(dest)}.swap-`));
865
891
  const stage = path.join(swap, "stage");
866
892
  const backup = path.join(swap, "backup");
867
893
  try {
868
- cpSync(src, stage, {
894
+ cpSync(realSrc, stage, {
869
895
  recursive: true,
870
- filter: (entry) => !path.relative(src, entry).split(path.sep).includes("node_modules"),
896
+ filter: (entry) => !path.relative(realSrc, entry).split(path.sep).includes("node_modules"),
871
897
  });
872
898
  for (const [name, content] of rules) {
873
899
  mkdirSync(path.join(stage, "rules"), { recursive: true });
874
900
  writeFileSync(path.join(stage, "rules", name), content);
875
901
  }
876
- writeFileSync(path.join(stage, ".workit-root"), src + "\n", "utf8");
877
- if (!samePluginContent(src, stage)) throw new Error("staged adapter content is incomplete");
902
+ writeFileSync(path.join(stage, ".workit-root"), realSrc + "\n", "utf8");
903
+ if (!samePluginContent(realSrc, stage)) throw new Error("staged adapter content is incomplete");
904
+ if (lstatSync(stage).isSymbolicLink()) {
905
+ throw new Error("staged adapter resolved to a symlink — refusing fragile cache install");
906
+ }
878
907
  if (hadDir) renameSync(dest, backup);
879
908
  try {
880
909
  renameSync(stage, dest);