@brainervirus/workit-claude-code 8.45.3 → 8.45.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "workit",
3
3
  "displayName": "Workit",
4
- "version": "8.45.3",
4
+ "version": "8.45.5",
5
5
  "description": "Workflow rails for agentic coding: task context on every session, branch policy on shell commands, and the workit method skills",
6
6
  "author": {
7
7
  "name": "BrainerVirus"
@@ -0,0 +1,257 @@
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; (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
+
16
+ /** The Node major the hooks need; a test holds it equal to every package's engines.node. */
17
+ export const HOOK_NODE_FLOOR = [24, 0, 0];
18
+
19
+ /** Set on the re-executed child so a launcher never re-executes twice. */
20
+ export const HOOK_NODE_REEXEC_ENV = "WORKIT_HOOK_REEXEC";
21
+
22
+ const MAX_ENTRIES_PER_ROOT = 200;
23
+
24
+ /** @param {unknown} text @returns {number[] | null} */
25
+ export const parseNodeVersion = (text) => {
26
+ const found = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(text).trim());
27
+ return found ? [Number(found[1]), Number(found[2] || 0), Number(found[3] || 0)] : null;
28
+ };
29
+
30
+ /** @param {number[]} a @param {number[]} b negative when a < b */
31
+ export const compareVersions = (a, b) => {
32
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
33
+ return 0;
34
+ };
35
+
36
+ /** The `>=X[.Y[.Z]]` minimum of an engines.node range, or null. @param {unknown} range */
37
+ export const floorOfRange = (range) => {
38
+ const min = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(range === undefined ? "" : range));
39
+ return min ? [Number(min[1]), Number(min[2] || 0), Number(min[3] || 0)] : null;
40
+ };
41
+
42
+ /** @param {NodeJS.ProcessEnv} env */
43
+ export const hookStateDir = (env) => {
44
+ if (env.WORKFLOW_TOOLKIT_STATE) return env.WORKFLOW_TOOLKIT_STATE;
45
+ if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "workit");
46
+ const home = env.HOME || os.homedir();
47
+ if (process.platform === "darwin")
48
+ return path.join(home, "Library", "Application Support", "workit");
49
+ if (process.platform === "win32")
50
+ return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "workit");
51
+ return path.join(home, ".local", "state", "workit");
52
+ };
53
+
54
+ /** @param {string} file */
55
+ const isFile = (file) => {
56
+ try {
57
+ return statSync(file).isFile();
58
+ } catch {
59
+ return false;
60
+ }
61
+ };
62
+
63
+ /** @param {string} file */
64
+ const readJson = (file) => {
65
+ try {
66
+ return JSON.parse(readFileSync(file, "utf8"));
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ };
71
+
72
+ /**
73
+ * The version-manager directories that hold one node per child directory:
74
+ * `{ dir, bin }` where the node is `<dir>/<child>/<bin>`.
75
+ * @param {NodeJS.ProcessEnv} env @param {string} platform
76
+ */
77
+ const scanRoots = (env, platform) => {
78
+ const home = env.HOME || env.USERPROFILE || os.homedir();
79
+ const win = platform === "win32";
80
+ const exe = win ? "node.exe" : "node";
81
+ const bin = win ? exe : path.join("bin", exe);
82
+ /** @type {Array<{ dir: string, bin: string }>} */
83
+ const roots = [];
84
+ const add = (dir, rel) => {
85
+ if (dir) roots.push({ dir, bin: rel });
86
+ };
87
+ // fnm: <dir>/node-versions/<v>/installation/bin/node
88
+ const fnmDirs = [
89
+ env.FNM_DIR,
90
+ win ? path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "fnm") : "",
91
+ win ? path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "fnm") : "",
92
+ env.XDG_DATA_HOME ? path.join(env.XDG_DATA_HOME, "fnm") : "",
93
+ path.join(home, ".local", "share", "fnm"),
94
+ path.join(home, "Library", "Application Support", "fnm"),
95
+ path.join(home, ".fnm"),
96
+ ];
97
+ for (const dir of fnmDirs)
98
+ if (dir) add(path.join(dir, "node-versions"), path.join("installation", bin));
99
+ // nvm: <dir>/versions/node/<v>/bin/node; nvm-windows: <NVM_HOME>/<v>/node.exe
100
+ for (const dir of [env.NVM_DIR, path.join(home, ".nvm")])
101
+ if (dir) add(path.join(dir, "versions", "node"), bin);
102
+ if (env.NVM_HOME) add(env.NVM_HOME, exe);
103
+ // volta: <dir>/tools/image/node/<v>/bin/node
104
+ const volta = env.VOLTA_HOME || (win ? "" : path.join(home, ".volta"));
105
+ if (volta) add(path.join(volta, "tools", "image", "node"), bin);
106
+ if (win)
107
+ add(
108
+ path.join(
109
+ env.LOCALAPPDATA || path.join(home, "AppData", "Local"),
110
+ "Volta",
111
+ "tools",
112
+ "image",
113
+ "node",
114
+ ),
115
+ bin,
116
+ );
117
+ // mise and asdf: <dir>/installs/node[js]/<v>/bin/node
118
+ const mise = env.MISE_DATA_DIR || path.join(home, ".local", "share", "mise");
119
+ add(path.join(mise, "installs", "node"), bin);
120
+ const asdf = env.ASDF_DATA_DIR || path.join(home, ".asdf");
121
+ add(path.join(asdf, "installs", "nodejs"), bin);
122
+ return roots;
123
+ };
124
+
125
+ /**
126
+ * Every installed node a version manager lists, with its version: directory
127
+ * listings only, at most MAX_ENTRIES_PER_ROOT per root.
128
+ * @param {NodeJS.ProcessEnv} env @param {string} [platform]
129
+ * @returns {Array<{ path: string, version: number[], source: string }>}
130
+ */
131
+ export const scanInstalledNodes = (env, platform = process.platform) => {
132
+ /** @type {Array<{ path: string, version: number[], source: string }>} */
133
+ const found = [];
134
+ for (const root of scanRoots(env, platform)) {
135
+ let names;
136
+ try {
137
+ names = readdirSync(root.dir).slice(0, MAX_ENTRIES_PER_ROOT);
138
+ } catch {
139
+ continue;
140
+ }
141
+ for (const name of names) {
142
+ const version = parseNodeVersion(name);
143
+ if (!version || !/^v?\d/.test(name)) continue;
144
+ // volta's layout nests nothing; fnm's, nvm's, mise's and asdf's differ only in `bin`.
145
+ const candidate = path.join(root.dir, name, root.bin);
146
+ if (isFile(candidate)) found.push({ path: candidate, version, source: root.dir });
147
+ }
148
+ }
149
+ return found;
150
+ };
151
+
152
+ /**
153
+ * The node the hooks should run on, or null. `floor` is [major, minor, patch].
154
+ * @param {{ env: NodeJS.ProcessEnv, floor: number[], platform?: string }} options
155
+ * @returns {{ path: string, version: string | null, source: "env" | "recorded" | "scan" } | null}
156
+ */
157
+ export const resolveHookNode = ({ env, floor, platform = process.platform }) => {
158
+ if (env.WORKIT_NODE && isFile(env.WORKIT_NODE))
159
+ return { path: env.WORKIT_NODE, version: null, source: "env" };
160
+ const recorded = readJson(path.join(hookStateDir(env), "node.json"));
161
+ if (recorded && typeof recorded.path === "string" && isFile(recorded.path)) {
162
+ const version = parseNodeVersion(recorded.version);
163
+ if (version && compareVersions(version, floor) >= 0)
164
+ return { path: recorded.path, version: String(recorded.version), source: "recorded" };
165
+ }
166
+ let best = null;
167
+ for (const entry of scanInstalledNodes(env, platform))
168
+ if (
169
+ compareVersions(entry.version, floor) >= 0 &&
170
+ (!best || compareVersions(entry.version, best.version) > 0)
171
+ )
172
+ best = entry;
173
+ return best ? { path: best.path, version: best.version.join("."), source: "scan" } : null;
174
+ };
175
+
176
+ /**
177
+ * The fail-open line for a node below the floor: null when `found` is at or
178
+ * above `floor`, or unparseable (an unreadable version never blocks).
179
+ * @param {string} found @param {number[] | null} floor @param {string} range
180
+ */
181
+ export const floorMessage = (found, floor, range) => {
182
+ if (!floor) return null;
183
+ const have = parseNodeVersion(found);
184
+ if (!have || compareVersions(have, floor) >= 0) return null;
185
+ return `needs Node ${range}, found ${found}`;
186
+ };
187
+
188
+ /** What the agent is told once, at SessionStart, when no Node >= the floor resolves. */
189
+ export const nodeFloorNotice = (found, range) =>
190
+ `Workit hooks are off: the Node on PATH is ${String(found).replace(/^v/, "")}, below the required ${range}, and no Node 24+ was found. ` +
191
+ "Install Node 24 (fnm install 24, nvm install 24) or run `workit setup` from a Node 24 shell so Workit can record it; " +
192
+ "`workit doctor` (check hook_node) shows what the hooks run on.";
193
+
194
+ /**
195
+ * Where setup and upgrade record the Node they run on. A no-op for a non-node
196
+ * binary (bun) or a Node below the floor.
197
+ * @param {{ env: NodeJS.ProcessEnv, execPath: string, version: string, floor: number[] }} options
198
+ * @returns {string | null} the file written
199
+ */
200
+ export const nodeRecord = ({ env, execPath, version, floor }) => {
201
+ const parsed = parseNodeVersion(version);
202
+ if (!parsed || compareVersions(parsed, floor) < 0) return null;
203
+ if (!/^node(?:\.exe)?$/i.test(path.basename(execPath))) return null;
204
+ return path.join(hookStateDir(env), "node.json");
205
+ };
206
+
207
+ /**
208
+ * Run `script args…` on `nodePath` with this process's stdin, stdout and
209
+ * stderr passed through. The child carries the re-exec guard and a real
210
+ * version (WORKIT_HOOK_NODE_VERSION, a test seam, is dropped).
211
+ * @returns {number | null} the child's exit code, or null when it could not start
212
+ */
213
+ export const reexecOnNode = (nodePath, script, args, env) => {
214
+ const child = Object.assign({}, env, { [HOOK_NODE_REEXEC_ENV]: "1" });
215
+ delete child.WORKIT_HOOK_NODE_VERSION;
216
+ const run = spawnSync(nodePath, [script].concat(args), {
217
+ stdio: "inherit",
218
+ env: child,
219
+ windowsHide: true,
220
+ });
221
+ if (run.error) return null;
222
+ return run.status === null ? 1 : run.status;
223
+ };
224
+
225
+ /**
226
+ * What a launcher does on a Node below its floor: re-run itself on a resolved
227
+ * Node (the child's exit code is returned), else fail open: one stderr line,
228
+ * and `{}` on stdout, except that a start event answers with `notice(text)` so
229
+ * the agent is told the hooks are off. Resolves to the exit code.
230
+ * @param {{ host: string, range: string, floor: number[], found: string, script: string,
231
+ * argv: string[], env: NodeJS.ProcessEnv, isStart: (event: unknown) => boolean,
232
+ * notice: (text: string) => unknown }} options
233
+ */
234
+ export const handleBelowFloor = async (options) => {
235
+ if (!options.env[HOOK_NODE_REEXEC_ENV]) {
236
+ const hit = resolveHookNode({ env: options.env, floor: options.floor });
237
+ const code = hit ? reexecOnNode(hit.path, options.script, options.argv, options.env) : null;
238
+ if (code !== null) return code;
239
+ }
240
+ process.stderr.write(
241
+ `[workit] ${options.host} hook unavailable: needs Node ${options.range}, found ${options.found}\n`,
242
+ );
243
+ let early = "";
244
+ for await (const chunk of process.stdin) early += String(chunk);
245
+ let event;
246
+ try {
247
+ event = JSON.parse(early).hook_event_name;
248
+ } catch {
249
+ event = undefined;
250
+ }
251
+ process.stdout.write(
252
+ options.isStart(event)
253
+ ? `${JSON.stringify(options.notice(nodeFloorNotice(options.found, options.range)))}\n`
254
+ : "{}\n",
255
+ );
256
+ return 0;
257
+ };
package/bin/workit CHANGED
@@ -4,6 +4,10 @@
4
4
  # package: run the bundled dist/workit.js with node.
5
5
  # WORKIT_CLAUDE_RUNTIME=source|dist forces one; WORKIT_SHIM_TRACE=1 prints
6
6
  # the resolved entry on stderr.
7
+ # When the PATH node is below the floor (24), the dist runs on a Node that is:
8
+ # $WORKIT_NODE, the node `workit setup` recorded in <state dir>/node.json, then
9
+ # the highest fnm/nvm/volta/mise/asdf install (same order as hooks, see
10
+ # bin/node-resolve.mjs; a test holds the floor equal).
7
11
  set -e
8
12
  here=$(dirname "$0")
9
13
  root=$(cd "$here/.." && pwd)
@@ -20,5 +24,53 @@ if [ -n "$entry" ]; then
20
24
  [ "${WORKIT_SHIM_TRACE:-}" = "1" ] && echo "workit-shim: source $entry" >&2
21
25
  exec bun "$entry" "$@"
22
26
  fi
23
- [ "${WORKIT_SHIM_TRACE:-}" = "1" ] && echo "workit-shim: dist $root/dist/workit.js" >&2
24
- exec node "$root/dist/workit.js" "$@"
27
+ node_floor=24
28
+ node_bin=node
29
+ node_major=$(node -v 2>/dev/null | sed 's/^v//; s/\..*$//')
30
+ case "$node_major" in
31
+ ''|*[!0-9]*) node_major=0 ;;
32
+ esac
33
+ if [ "$node_major" -lt "$node_floor" ]; then
34
+ found=""
35
+ if [ -n "${WORKIT_NODE:-}" ] && [ -x "$WORKIT_NODE" ]; then
36
+ found="$WORKIT_NODE"
37
+ fi
38
+ if [ -z "$found" ]; then
39
+ if [ -n "${WORKFLOW_TOOLKIT_STATE:-}" ]; then state="$WORKFLOW_TOOLKIT_STATE"
40
+ elif [ -n "${XDG_STATE_HOME:-}" ]; then state="$XDG_STATE_HOME/workit"
41
+ elif [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then state="$HOME/Library/Application Support/workit"
42
+ else state="$HOME/.local/state/workit"; fi
43
+ recorded=$(sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$state/node.json" 2>/dev/null | head -n 1)
44
+ rv=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"v\{0,1\}\([0-9]*\).*/\1/p' "$state/node.json" 2>/dev/null | head -n 1)
45
+ if [ -n "$recorded" ] && [ -x "$recorded" ] && [ "${rv:-0}" -ge "$node_floor" ]; then
46
+ found="$recorded"
47
+ fi
48
+ fi
49
+ if [ -z "$found" ]; then
50
+ candidates=""
51
+ for spec in \
52
+ "${FNM_DIR:-$HOME/.local/share/fnm}/node-versions|installation/bin/node" \
53
+ "$HOME/Library/Application Support/fnm/node-versions|installation/bin/node" \
54
+ "${NVM_DIR:-$HOME/.nvm}/versions/node|bin/node" \
55
+ "${VOLTA_HOME:-$HOME/.volta}/tools/image/node|bin/node" \
56
+ "${MISE_DATA_DIR:-$HOME/.local/share/mise}/installs/node|bin/node" \
57
+ "${ASDF_DATA_DIR:-$HOME/.asdf}/installs/nodejs|bin/node"; do
58
+ base=${spec%%|*}
59
+ rel=${spec#*|}
60
+ for dir in "$base"/*; do
61
+ name=${dir##*/}
62
+ v=${name#v}
63
+ major=${v%%.*}
64
+ case "$major" in ''|*[!0-9]*) continue ;; esac
65
+ if [ "$major" -ge "$node_floor" ] && [ -x "$dir/$rel" ]; then
66
+ candidates="$candidates
67
+ $v $dir/$rel"
68
+ fi
69
+ done
70
+ done
71
+ found=$(printf '%s\n' "$candidates" | sed '/^$/d' | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1 | sed 's/^[^ ]* //')
72
+ fi
73
+ if [ -n "$found" ]; then node_bin="$found"; fi
74
+ fi
75
+ [ "${WORKIT_SHIM_TRACE:-}" = "1" ] && echo "workit-shim: dist $root/dist/workit.js (node $node_bin)" >&2
76
+ exec "$node_bin" "$root/dist/workit.js" "$@"
@@ -17,6 +17,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync }
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { floorMessage, floorOfRange, handleBelowFloor } from "./node-resolve.mjs";
20
21
 
21
22
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
22
23
  const source = path.join(root, "src", "run.ts");
@@ -33,32 +34,42 @@ const failOpen = (reason) => {
33
34
  process.stdout.write("{}\n");
34
35
  };
35
36
 
36
- // A Node below this package's own engines minimum cannot load dist: fail open
37
- // with one clear line instead of a syntax error. WORKIT_HOOK_NODE_VERSION
38
- // stands in for process.versions.node (tests). An unreadable minimum never blocks.
39
- const nodeFloorMessage = () => {
37
+ // A Node below this package's own engines minimum cannot load dist. The launcher
38
+ // then looks for a Node that can (bin/node-resolve.mjs: $WORKIT_NODE, the node
39
+ // `workit setup` recorded, a version-manager scan) and re-runs itself on it with
40
+ // stdin, stdout, stderr and the exit code passed through; WORKIT_HOOK_REEXEC
41
+ // stops a second hop. With none found it fails open with one clear line, and a
42
+ // SessionStart tells the agent once that the hooks are off.
43
+ // WORKIT_HOOK_NODE_VERSION stands in for process.versions.node (tests). An
44
+ // unreadable minimum never blocks.
45
+ const nodeFloor = () => {
40
46
  try {
41
- const found = process.env.WORKIT_HOOK_NODE_VERSION || process.versions.node;
42
47
  const range = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).engines?.node;
43
- const min = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(String(range ?? ""));
44
- if (!min) return null;
45
- const need = [min[1], min[2] ?? 0, min[3] ?? 0].map(Number);
46
- const have = String(found).replace(/^v/, "").split(".").map(Number);
47
- for (let i = 0; i < 3; i++) {
48
- const a = have[i] ?? 0;
49
- if (Number.isNaN(a)) return null;
50
- if (a !== need[i]) return a < need[i] ? `needs Node ${range}, found ${found}` : null;
51
- }
52
- return null;
48
+ const floor = floorOfRange(range);
49
+ return floor ? { range, floor } : null;
53
50
  } catch {
54
51
  return null;
55
52
  }
56
53
  };
57
- const tooOld = nodeFloorMessage();
58
- if (tooOld) {
59
- failOpen(tooOld);
60
- process.exit(0);
61
- }
54
+ const hookNode = nodeFloor();
55
+ const foundNode = process.env.WORKIT_HOOK_NODE_VERSION || process.versions.node;
56
+ const tooOld = hookNode ? floorMessage(foundNode, hookNode.floor, hookNode.range) : null;
57
+ if (tooOld && hookNode)
58
+ process.exit(
59
+ await handleBelowFloor({
60
+ host: "Claude Code",
61
+ range: hookNode.range,
62
+ floor: hookNode.floor,
63
+ found: foundNode,
64
+ script: fileURLToPath(import.meta.url),
65
+ argv: process.argv.slice(2),
66
+ env: process.env,
67
+ isStart: (event) => event === "SessionStart",
68
+ notice: (additionalContext) => ({
69
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext },
70
+ }),
71
+ }),
72
+ );
62
73
 
63
74
  // Every Bash/PowerShell call and every file edit reaches this hook (branch
64
75
  // policy, raw git/forge steering and the before-write gate). Fast paths