@workit-dev/core 0.0.1 → 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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,208 @@
1
+ // Model fallback on a usage limit (#655): a subscription that hits its usage
2
+ // limit (Anthropic 5-hour or weekly, OpenAI usage limit, a 429 quota) is
3
+ // marked exhausted until its reset, per user, in `model-limits.json` under the
4
+ // state dir. A role whose `models.roles` value is an ordered list then runs on
5
+ // the first model whose provider is not exhausted (core/role-models.ts), and
6
+ // on its primary again once the reset passes.
7
+ import { createHash } from "node:crypto";
8
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { resolveStateDir } from "./logger";
11
+ import { hostFamily, modelSources } from "./workspaces";
12
+
13
+ export const LIMITS_FILE = "model-limits.json";
14
+ export const DEFAULT_LIMIT_WINDOW_MS = 5 * 3_600_000;
15
+ /** Entries older than this are dropped on the next write. */
16
+ const KEEP_MS = 9 * 86_400_000;
17
+
18
+ export type Exhaustion = { until: string; since: string; reason: string };
19
+ type Store = {
20
+ exhausted: Record<string, Exhaustion>;
21
+ /** Limit evidence already counted (a transcript tail, an OpenCode call). */
22
+ seen: Record<string, string>;
23
+ /** Worker prompts already re-dispatched once after a limit. */
24
+ redispatched: Record<string, string>;
25
+ };
26
+
27
+ const FAMILIES = ["fable", "opus", "sonnet", "haiku"] as const;
28
+
29
+ /** The Claude family a model name belongs to (`opus` for `claude-opus-5`), or null. */
30
+ const modelFamily = (model: string): string | null =>
31
+ FAMILIES.find((family) => model.toLowerCase().includes(family)) ?? null;
32
+
33
+ /**
34
+ * The subscription a model runs on: the `provider/` prefix when it has one
35
+ * (OpenCode's `providerID/modelID`, a gateway's `anthropic/claude-…`), else
36
+ * `anthropic` for a Claude name, `openai` for a GPT, o-series or Codex name,
37
+ * else the host's own provider (Claude Code: anthropic, Codex: openai), else null.
38
+ */
39
+ export const providerOf = (model: string, host?: string | null): string | null => {
40
+ const trimmed = model.trim().toLowerCase();
41
+ const slash = trimmed.indexOf("/");
42
+ if (slash > 0) return trimmed.slice(0, slash);
43
+ if (/^(?:claude|sonnet|opus|haiku|fable)\b|(?:^|[.-])claude-/u.test(trimmed)) return "anthropic";
44
+ if (/^(?:gpt|o\d|codex|chatgpt)\b/u.test(trimmed)) return "openai";
45
+ const family = host ? hostFamily(host) : null;
46
+ if (family === "claude_code") return "anthropic";
47
+ if (family === "codex") return "openai";
48
+ return null;
49
+ };
50
+
51
+ const empty = (): Store => ({ exhausted: {}, seen: {}, redispatched: {} });
52
+
53
+ const storePath = (stateDir?: string) => path.join(stateDir ?? resolveStateDir(), LIMITS_FILE);
54
+
55
+ const readStore = (stateDir?: string): Store => {
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(storePath(stateDir), "utf8")) as Partial<Store>;
58
+ const store = empty();
59
+ for (const key of ["exhausted", "seen", "redispatched"] as const)
60
+ if (parsed[key] && typeof parsed[key] === "object") Object.assign(store[key], parsed[key]);
61
+ return store;
62
+ } catch {
63
+ return empty();
64
+ }
65
+ };
66
+
67
+ /** Best effort: a read-only state dir never fails a hook. */
68
+ const writeStore = (store: Store, now: Date, stateDir?: string): void => {
69
+ const cutoff = now.getTime() - KEEP_MS;
70
+ for (const key of ["seen", "redispatched"] as const)
71
+ for (const [id, at] of Object.entries(store[key]))
72
+ if (!(Date.parse(at) > cutoff)) delete store[key][id];
73
+ for (const [id, entry] of Object.entries(store.exhausted))
74
+ if (!(Date.parse(entry.until) > cutoff)) delete store.exhausted[id];
75
+ try {
76
+ const file = storePath(stateDir);
77
+ mkdirSync(path.dirname(file), { recursive: true });
78
+ const temp = `${file}.${process.pid}.tmp`;
79
+ writeFileSync(temp, `${JSON.stringify(store, null, 2)}\n`);
80
+ renameSync(temp, file);
81
+ } catch {
82
+ // The limit is noticed again next time.
83
+ }
84
+ };
85
+
86
+ /**
87
+ * Mark `key` (a provider, or `provider/family` for a per-model limit such as
88
+ * Claude's Opus limit) exhausted until `until`; a later reset already on
89
+ * record is kept.
90
+ */
91
+ export const markExhausted = (
92
+ key: string,
93
+ until: Date,
94
+ reason: string,
95
+ now: Date = new Date(),
96
+ stateDir?: string,
97
+ ): Exhaustion => {
98
+ const store = readStore(stateDir);
99
+ const previous = store.exhausted[key];
100
+ const entry =
101
+ previous && Date.parse(previous.until) >= until.getTime()
102
+ ? previous
103
+ : {
104
+ until: until.toISOString(),
105
+ since: now.toISOString(),
106
+ reason: reason.slice(0, 200),
107
+ };
108
+ store.exhausted[key] = entry;
109
+ writeStore(store, now, stateDir);
110
+ return entry;
111
+ };
112
+
113
+ /** Providers (or `provider/family` keys) exhausted at `now`, soonest reset first. */
114
+ export const exhaustedProviders = (
115
+ now: Date = new Date(),
116
+ stateDir?: string,
117
+ ): Array<Exhaustion & { provider: string }> =>
118
+ Object.entries(readStore(stateDir).exhausted)
119
+ .filter(([, entry]) => Date.parse(entry.until) > now.getTime())
120
+ .map(([provider, entry]) => ({ provider, ...entry }))
121
+ .toSorted((a, b) => Date.parse(a.until) - Date.parse(b.until));
122
+
123
+ /** The exhaustion that blocks `model` on `host` at `now`, or null when it may run. */
124
+ export const exhaustionFor = (
125
+ model: string,
126
+ host: string | null,
127
+ now: Date = new Date(),
128
+ stateDir?: string,
129
+ ): (Exhaustion & { provider: string }) | null => {
130
+ const provider = providerOf(model, host);
131
+ if (!provider) return null;
132
+ const family = modelFamily(model);
133
+ const keys = new Set([provider, ...(family ? [`${provider}/${family}`] : [])]);
134
+ return exhaustedProviders(now, stateDir).find((entry) => keys.has(entry.provider)) ?? null;
135
+ };
136
+
137
+ /** Record a piece of limit evidence once; false when it was already counted. */
138
+ export const firstSighting = (id: string, now: Date = new Date(), stateDir?: string): boolean => {
139
+ const store = readStore(stateDir);
140
+ if (store.seen[id]) return false;
141
+ store.seen[id] = now.toISOString();
142
+ writeStore(store, now, stateDir);
143
+ return true;
144
+ };
145
+
146
+ /** A stable key for a worker's prompt. */
147
+ export const promptKey = (prompt: string): string =>
148
+ createHash("sha256").update(prompt.trim()).digest("hex").slice(0, 16);
149
+
150
+ /**
151
+ * Whether a worker whose prompt is `key` may be re-dispatched after dying on a
152
+ * limit: true the first time (and recorded), false once it was re-dispatched.
153
+ */
154
+ export const claimRedispatch = (
155
+ key: string,
156
+ now: Date = new Date(),
157
+ stateDir?: string,
158
+ ): boolean => {
159
+ const store = readStore(stateDir);
160
+ if (store.redispatched[key]) return false;
161
+ store.redispatched[key] = now.toISOString();
162
+ writeStore(store, now, stateDir);
163
+ return true;
164
+ };
165
+
166
+ const UNITS: Record<string, number> = {
167
+ d: 86_400_000,
168
+ h: 3_600_000,
169
+ m: 60_000,
170
+ s: 1000,
171
+ };
172
+
173
+ /** `5h`, `90m`, `2d`, `1d 12h` or a number of hours as milliseconds; null when unreadable. */
174
+ export const parseWindow = (value: unknown): number | null => {
175
+ if (typeof value === "number") return value > 0 ? value * 3_600_000 : null;
176
+ if (typeof value !== "string" || !value.trim()) return null;
177
+ let total = 0;
178
+ const rest = value
179
+ .trim()
180
+ .toLowerCase()
181
+ .replace(/(\d+(?:\.\d+)?)\s*([dhms])/gu, (_all, amount: string, unit: string) => {
182
+ total += Number(amount) * UNITS[unit];
183
+ return "";
184
+ });
185
+ return rest.trim() === "" && total > 0 ? total : null;
186
+ };
187
+
188
+ /**
189
+ * How long a provider stays exhausted when its limit text names no reset:
190
+ * `models.limitWindow` (nearest workspace, then the global default), else 5h.
191
+ */
192
+ export const limitWindowMs = (cwd: string, dir?: string): number => {
193
+ for (const source of modelSources(cwd, dir)) {
194
+ const models = source.models as Record<string, unknown> | undefined;
195
+ if (models && typeof models === "object" && Object.hasOwn(models, "limitWindow")) {
196
+ const parsed = parseWindow(models.limitWindow);
197
+ if (parsed) return parsed;
198
+ }
199
+ }
200
+ return DEFAULT_LIMIT_WINDOW_MS;
201
+ };
202
+
203
+ /** `2026-11-03 19:00` in the local zone, for notes and `models show`. */
204
+ export const shortTime = (iso: string): string => {
205
+ const at = new Date(iso);
206
+ const pad = (n: number) => String(n).padStart(2, "0");
207
+ return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`;
208
+ };
@@ -0,0 +1,249 @@
1
+ // Finds a Node that can run a Workit hook when the PATH node is below the
2
+ // package's engines floor (a repo pinned to Node 16/18/22 by a version
3
+ // manager). Plain JavaScript with node: builtins only and no syntax newer than
4
+ // Node 16 supports: the host launchers import it (or carry a byte-equal copy,
5
+ // a test holds them equal) and must start on the very Node they are replacing.
6
+ // Side-effect free: importing it runs nothing; no process is spawned here.
7
+ //
8
+ // Order: (1) $WORKIT_NODE; (2) the node `workit setup`/`upgrade` recorded in
9
+ // <state dir>/node.json (workit-paths.mjs); (3) a scan of version-manager installs (fnm, nvm,
10
+ // volta, mise, asdf and the Windows layouts), highest version >= the floor.
11
+ import { spawnSync } from "node:child_process";
12
+ import { readdirSync, readFileSync, statSync } from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { stateDir } from "./workit-paths.mjs";
16
+
17
+ /** The Node major the hooks need; a test holds it equal to every package's engines.node. */
18
+ export const HOOK_NODE_FLOOR = [24, 0, 0];
19
+
20
+ /** Set on the re-executed child so a launcher never re-executes twice. */
21
+ export const HOOK_NODE_REEXEC_ENV = "WORKIT_HOOK_REEXEC";
22
+
23
+ const MAX_ENTRIES_PER_ROOT = 200;
24
+
25
+ /** @param {unknown} text @returns {number[] | null} */
26
+ export const parseNodeVersion = (text) => {
27
+ const found = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(text).trim());
28
+ return found ? [Number(found[1]), Number(found[2] || 0), Number(found[3] || 0)] : null;
29
+ };
30
+
31
+ /** @param {number[]} a @param {number[]} b negative when a < b */
32
+ export const compareVersions = (a, b) => {
33
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
34
+ return 0;
35
+ };
36
+
37
+ /** The `>=X[.Y[.Z]]` minimum of an engines.node range, or null. @param {unknown} range */
38
+ export const floorOfRange = (range) => {
39
+ const min = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(range === undefined ? "" : range));
40
+ return min ? [Number(min[1]), Number(min[2] || 0), Number(min[3] || 0)] : null;
41
+ };
42
+
43
+ /** Workit's state dir (workit-paths.mjs, the one resolver). @param {NodeJS.ProcessEnv} env */
44
+ export const hookStateDir = (env) => stateDir(env);
45
+
46
+ /** @param {string} file */
47
+ const isFile = (file) => {
48
+ try {
49
+ return statSync(file).isFile();
50
+ } catch {
51
+ return false;
52
+ }
53
+ };
54
+
55
+ /** @param {string} file */
56
+ const readJson = (file) => {
57
+ try {
58
+ return JSON.parse(readFileSync(file, "utf8"));
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ };
63
+
64
+ /**
65
+ * The version-manager directories that hold one node per child directory:
66
+ * `{ dir, bin }` where the node is `<dir>/<child>/<bin>`.
67
+ * @param {NodeJS.ProcessEnv} env @param {string} platform
68
+ */
69
+ const scanRoots = (env, platform) => {
70
+ const home = env.HOME || env.USERPROFILE || os.homedir();
71
+ const win = platform === "win32";
72
+ const exe = win ? "node.exe" : "node";
73
+ const bin = win ? exe : path.join("bin", exe);
74
+ /** @type {Array<{ dir: string, bin: string }>} */
75
+ const roots = [];
76
+ const add = (dir, rel) => {
77
+ if (dir) roots.push({ dir, bin: rel });
78
+ };
79
+ // fnm: <dir>/node-versions/<v>/installation/bin/node
80
+ const fnmDirs = [
81
+ env.FNM_DIR,
82
+ win ? path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "fnm") : "",
83
+ win ? path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "fnm") : "",
84
+ env.XDG_DATA_HOME ? path.join(env.XDG_DATA_HOME, "fnm") : "",
85
+ path.join(home, ".local", "share", "fnm"),
86
+ path.join(home, "Library", "Application Support", "fnm"),
87
+ path.join(home, ".fnm"),
88
+ ];
89
+ for (const dir of fnmDirs)
90
+ if (dir) add(path.join(dir, "node-versions"), path.join("installation", bin));
91
+ // nvm: <dir>/versions/node/<v>/bin/node; nvm-windows: <NVM_HOME>/<v>/node.exe
92
+ for (const dir of [env.NVM_DIR, path.join(home, ".nvm")])
93
+ if (dir) add(path.join(dir, "versions", "node"), bin);
94
+ if (env.NVM_HOME) add(env.NVM_HOME, exe);
95
+ // volta: <dir>/tools/image/node/<v>/bin/node
96
+ const volta = env.VOLTA_HOME || (win ? "" : path.join(home, ".volta"));
97
+ if (volta) add(path.join(volta, "tools", "image", "node"), bin);
98
+ if (win)
99
+ add(
100
+ path.join(
101
+ env.LOCALAPPDATA || path.join(home, "AppData", "Local"),
102
+ "Volta",
103
+ "tools",
104
+ "image",
105
+ "node",
106
+ ),
107
+ bin,
108
+ );
109
+ // mise and asdf: <dir>/installs/node[js]/<v>/bin/node
110
+ const mise = env.MISE_DATA_DIR || path.join(home, ".local", "share", "mise");
111
+ add(path.join(mise, "installs", "node"), bin);
112
+ const asdf = env.ASDF_DATA_DIR || path.join(home, ".asdf");
113
+ add(path.join(asdf, "installs", "nodejs"), bin);
114
+ return roots;
115
+ };
116
+
117
+ /**
118
+ * Every installed node a version manager lists, with its version: directory
119
+ * listings only, at most MAX_ENTRIES_PER_ROOT per root.
120
+ * @param {NodeJS.ProcessEnv} env @param {string} [platform]
121
+ * @returns {Array<{ path: string, version: number[], source: string }>}
122
+ */
123
+ export const scanInstalledNodes = (env, platform = process.platform) => {
124
+ /** @type {Array<{ path: string, version: number[], source: string }>} */
125
+ const found = [];
126
+ for (const root of scanRoots(env, platform)) {
127
+ let names;
128
+ try {
129
+ names = readdirSync(root.dir).slice(0, MAX_ENTRIES_PER_ROOT);
130
+ } catch {
131
+ continue;
132
+ }
133
+ for (const name of names) {
134
+ const version = parseNodeVersion(name);
135
+ if (!version || !/^v?\d/.test(name)) continue;
136
+ // volta's layout nests nothing; fnm's, nvm's, mise's and asdf's differ only in `bin`.
137
+ const candidate = path.join(root.dir, name, root.bin);
138
+ if (isFile(candidate)) found.push({ path: candidate, version, source: root.dir });
139
+ }
140
+ }
141
+ return found;
142
+ };
143
+
144
+ /**
145
+ * The node the hooks should run on, or null. `floor` is [major, minor, patch].
146
+ * @param {{ env: NodeJS.ProcessEnv, floor: number[], platform?: string }} options
147
+ * @returns {{ path: string, version: string | null, source: "env" | "recorded" | "scan" } | null}
148
+ */
149
+ export const resolveHookNode = ({ env, floor, platform = process.platform }) => {
150
+ if (env.WORKIT_NODE && isFile(env.WORKIT_NODE))
151
+ return { path: env.WORKIT_NODE, version: null, source: "env" };
152
+ const recorded = readJson(path.join(hookStateDir(env), "node.json"));
153
+ if (recorded && typeof recorded.path === "string" && isFile(recorded.path)) {
154
+ const version = parseNodeVersion(recorded.version);
155
+ if (version && compareVersions(version, floor) >= 0)
156
+ return { path: recorded.path, version: String(recorded.version), source: "recorded" };
157
+ }
158
+ let best = null;
159
+ for (const entry of scanInstalledNodes(env, platform))
160
+ if (
161
+ compareVersions(entry.version, floor) >= 0 &&
162
+ (!best || compareVersions(entry.version, best.version) > 0)
163
+ )
164
+ best = entry;
165
+ return best ? { path: best.path, version: best.version.join("."), source: "scan" } : null;
166
+ };
167
+
168
+ /**
169
+ * The fail-open line for a node below the floor: null when `found` is at or
170
+ * above `floor`, or unparseable (an unreadable version never blocks).
171
+ * @param {string} found @param {number[] | null} floor @param {string} range
172
+ */
173
+ export const floorMessage = (found, floor, range) => {
174
+ if (!floor) return null;
175
+ const have = parseNodeVersion(found);
176
+ if (!have || compareVersions(have, floor) >= 0) return null;
177
+ return `needs Node ${range}, found ${found}`;
178
+ };
179
+
180
+ /** What the agent is told once, at SessionStart, when no Node >= the floor resolves. */
181
+ export const nodeFloorNotice = (found, range) =>
182
+ `Workit hooks are off: the Node on PATH is ${String(found).replace(/^v/, "")}, below the required ${range}, and no Node 24+ was found. ` +
183
+ "Install Node 24 (fnm install 24, nvm install 24) or run `workit setup` from a Node 24 shell so Workit can record it; " +
184
+ "`workit doctor` (check hook_node) shows what the hooks run on.";
185
+
186
+ /**
187
+ * Where setup and upgrade record the Node they run on. A no-op for a non-node
188
+ * binary (bun) or a Node below the floor.
189
+ * @param {{ env: NodeJS.ProcessEnv, execPath: string, version: string, floor: number[] }} options
190
+ * @returns {string | null} the file written
191
+ */
192
+ export const nodeRecord = ({ env, execPath, version, floor }) => {
193
+ const parsed = parseNodeVersion(version);
194
+ if (!parsed || compareVersions(parsed, floor) < 0) return null;
195
+ if (!/^node(?:\.exe)?$/i.test(path.basename(execPath))) return null;
196
+ return path.join(hookStateDir(env), "node.json");
197
+ };
198
+
199
+ /**
200
+ * Run `script args…` on `nodePath` with this process's stdin, stdout and
201
+ * stderr passed through. The child carries the re-exec guard and a real
202
+ * version (WORKIT_HOOK_NODE_VERSION, a test seam, is dropped).
203
+ * @returns {number | null} the child's exit code, or null when it could not start
204
+ */
205
+ export const reexecOnNode = (nodePath, script, args, env) => {
206
+ const child = Object.assign({}, env, { [HOOK_NODE_REEXEC_ENV]: "1" });
207
+ delete child.WORKIT_HOOK_NODE_VERSION;
208
+ const run = spawnSync(nodePath, [script].concat(args), {
209
+ stdio: "inherit",
210
+ env: child,
211
+ windowsHide: true,
212
+ });
213
+ if (run.error) return null;
214
+ return run.status === null ? 1 : run.status;
215
+ };
216
+
217
+ /**
218
+ * What a launcher does on a Node below its floor: re-run itself on a resolved
219
+ * Node (the child's exit code is returned), else fail open: one stderr line,
220
+ * and `{}` on stdout, except that a start event answers with `notice(text)` so
221
+ * the agent is told the hooks are off. Resolves to the exit code.
222
+ * @param {{ host: string, range: string, floor: number[], found: string, script: string,
223
+ * argv: string[], env: NodeJS.ProcessEnv, isStart: (event: unknown) => boolean,
224
+ * notice: (text: string) => unknown }} options
225
+ */
226
+ export const handleBelowFloor = async (options) => {
227
+ if (!options.env[HOOK_NODE_REEXEC_ENV]) {
228
+ const hit = resolveHookNode({ env: options.env, floor: options.floor });
229
+ const code = hit ? reexecOnNode(hit.path, options.script, options.argv, options.env) : null;
230
+ if (code !== null) return code;
231
+ }
232
+ process.stderr.write(
233
+ `[workit] ${options.host} hook unavailable: needs Node ${options.range}, found ${options.found}\n`,
234
+ );
235
+ let early = "";
236
+ for await (const chunk of process.stdin) early += String(chunk);
237
+ let event;
238
+ try {
239
+ event = JSON.parse(early).hook_event_name;
240
+ } catch {
241
+ event = undefined;
242
+ }
243
+ process.stdout.write(
244
+ options.isStart(event)
245
+ ? `${JSON.stringify(options.notice(nodeFloorNotice(options.found, options.range)))}\n`
246
+ : "{}\n",
247
+ );
248
+ return 0;
249
+ };