@workit-dev/codex 0.0.0-stage → 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 (55) hide show
  1. package/.codex-plugin/plugin.json +32 -0
  2. package/.mcp.json +9 -0
  3. package/README.md +29 -2
  4. package/agents/workit-implementer.toml +52 -0
  5. package/agents/workit-reviewer.toml +32 -0
  6. package/agents/workit-verifier.toml +38 -0
  7. package/bin/launch-mcp.mjs +42 -0
  8. package/bin/node-resolve.mjs +249 -0
  9. package/bin/workit-paths.mjs +242 -0
  10. package/dist/launch-mcp.js +31422 -0
  11. package/dist/workit-hook-2hj7pbaj.js +23598 -0
  12. package/dist/workit-hook-3g6cv3hd.js +10 -0
  13. package/dist/workit-hook-3qaf6adn.js +10 -0
  14. package/dist/workit-hook-6n3h8wh7.js +104 -0
  15. package/dist/workit-hook-am9gge2k.js +583 -0
  16. package/dist/workit-hook-avy6vsn6.js +88 -0
  17. package/dist/workit-hook-b8nrcfzr.js +151 -0
  18. package/dist/workit-hook-ehy7rps6.js +200 -0
  19. package/dist/workit-hook-jy7y0gdx.js +70 -0
  20. package/dist/workit-hook-mk64s4fk.js +19 -0
  21. package/dist/workit-hook-y0ga088a.js +9 -0
  22. package/dist/workit-hook-zyzsv436.js +43 -0
  23. package/dist/workit-hook.js +94 -0
  24. package/hooks/hooks.json +46 -0
  25. package/package.json +48 -4
  26. package/skills/workit-architecture/SKILL.md +72 -0
  27. package/skills/workit-architecture/agents/openai.yaml +3 -0
  28. package/skills/workit-architecture/references/instruction-files.md +52 -0
  29. package/skills/workit-architecture/references/vocabulary.md +53 -0
  30. package/skills/workit-bdd/SKILL.md +53 -0
  31. package/skills/workit-continue/SKILL.md +54 -0
  32. package/skills/workit-debug/SKILL.md +45 -0
  33. package/skills/workit-deslop/SKILL.md +58 -0
  34. package/skills/workit-fanout/SKILL.md +71 -0
  35. package/skills/workit-fanout/references/brief.md +141 -0
  36. package/skills/workit-fanout/references/orchestration.md +169 -0
  37. package/skills/workit-implement/SKILL.md +71 -0
  38. package/skills/workit-retro/SKILL.md +72 -0
  39. package/skills/workit-retro/agents/openai.yaml +3 -0
  40. package/skills/workit-retro/references/sources.md +35 -0
  41. package/skills/workit-retro/references/steering.md +39 -0
  42. package/skills/workit-review/SKILL.md +66 -0
  43. package/skills/workit-review/references/impact.md +24 -0
  44. package/skills/workit-shape/SKILL.md +71 -0
  45. package/skills/workit-shape/references/diagrams.md +17 -0
  46. package/skills/workit-shape/references/knowledge.md +61 -0
  47. package/skills/workit-shape/references/mockups.md +15 -0
  48. package/skills/workit-shape/references/slicing.md +44 -0
  49. package/skills/workit-ship/SKILL.md +71 -0
  50. package/skills/workit-ship/references/changelog.md +44 -0
  51. package/skills/workit-ship/references/pr-body.md +32 -0
  52. package/skills/workit-ship/references/release.md +39 -0
  53. package/skills/workit-test-audit/SKILL.md +54 -0
  54. package/skills/workit-verify-app/SKILL.md +68 -0
  55. package/skills/workit-verify-app/references/template.md +51 -0
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "workit",
3
+ "version": "1.0.0",
4
+ "description": "Adaptive engineering workflow rails for Codex",
5
+ "author": {
6
+ "name": "BrainerVirus"
7
+ },
8
+ "repository": "https://github.com/BrainerVirus/workit",
9
+ "license": "MIT",
10
+ "keywords": [
11
+ "agent",
12
+ "codex",
13
+ "mcp",
14
+ "workflow"
15
+ ],
16
+ "skills": "./skills/",
17
+ "mcpServers": "./.mcp.json",
18
+ "interface": {
19
+ "displayName": "Workit",
20
+ "shortDescription": "Adaptive engineering workflow rails for Codex",
21
+ "longDescription": "Shared task policy, evidence, and truthful host capability reporting for Codex CLI and desktop.",
22
+ "developerName": "BrainerVirus",
23
+ "category": "Developer Tools",
24
+ "capabilities": [
25
+ "MCP",
26
+ "Task continuity"
27
+ ],
28
+ "defaultPrompt": [
29
+ "Inspect the current Workit task state."
30
+ ]
31
+ }
32
+ }
package/.mcp.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "mcpServers": {
3
+ "workit": {
4
+ "command": "node",
5
+ "args": ["bin/launch-mcp.mjs"],
6
+ "cwd": "."
7
+ }
8
+ }
9
+ }
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @workit-dev/codex
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/BrainerVirus/workit/actions/workflows/ci.yml/badge.svg)](https://github.com/BrainerVirus/workit/actions/workflows/ci.yml)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/BrainerVirus/workit/blob/main/LICENSE)
5
+
6
+ [Workit](https://github.com/BrainerVirus/workit) for Codex CLI and desktop: native plugin manifest,
7
+ documented lifecycle hooks, the shared MCP server and the thirteen method skills.
8
+ Requires Node.js 24+.
9
+
10
+ ```bash
11
+ codex plugin marketplace add https://github.com/BrainerVirus/workit.git
12
+ codex plugin add workit@workit
13
+ ```
14
+
15
+ Update with `workit upgrade` (native fallback: `codex plugin marketplace upgrade workit && codex plugin add workit@workit`), then start a new Codex session; uninstall with `workit uninstall`.
16
+
17
+ Reads run over MCP (`workit-codex-mcp`); mutations run through the `workit`
18
+ CLI because MCP is read-only for unattested callers. Invoke skills as
19
+ `$workit-<name>` or from `/skills`. Codex permissions and sandbox stay
20
+ authoritative.
21
+
22
+ The plugin ships the `workit-verifier`, `workit-reviewer` and
23
+ `workit-implementer` custom agents. Codex plugins cannot register agents, so
24
+ the MCP server copies them into `~/.codex/agents/` when it starts, and Codex
25
+ offers them from the next session. Trust the plugin hooks with `/hooks`;
26
+ `workit doctor` warns when hooks are untrusted or the agents are missing, and
27
+ prints the command that fixes it. Codex PreToolUse does not see `apply_patch` edits, so the
28
+ [before-write gate](https://github.com/BrainerVirus/workit/blob/main/docs/guides/verification.md) is advisory here (the session
29
+ context says so). Delivery limits come from the workspace
30
+ [grants](https://github.com/BrainerVirus/workit/blob/main/docs/guides/grants.md).
@@ -0,0 +1,52 @@
1
+ # Generated by Workit from packages/workit-claude-code/agents/implementer.md; edits are overwritten on the next plugin start.
2
+ name = "workit-implementer"
3
+ description = "Implements one scoped workit brief in the git worktree its lead made with `workit fanout worktree create`, and reports what changed and how it was checked. Use to delegate a well-defined slice (goal, scope, acceptance, verify, forbidden, report, worktree) so it can run in parallel with other work."
4
+ developer_instructions = '''You are a workit implementer. You work only in the git worktree your brief
5
+ names (its WORKTREE line), which the lead made with
6
+ `workit fanout worktree create <slice>` before spawning you; never in the
7
+ lead's checkout.
8
+
9
+ Refuse to start without a complete brief. It must state: goal, scope (files
10
+ or areas you may touch), acceptance (observable outcomes), verify (exact
11
+ commands), timebox, forbidden (what you must not touch or do), report (what to
12
+ hand back), standing orders, mode (`new` or `resume`) and the worktree path.
13
+ If a field is missing, report which and stop.
14
+
15
+ 1. In a Workit worktree (the default), run each git and check command against
16
+ it, one verb per call:
17
+ `workit git --cwd <worktree> commit …`, `workit check --cwd <worktree> <name>`
18
+ (verb first, so the host's allow rules match).
19
+ Never `cd <worktree> && …` chains, `$VAR` paths or compound commands. Edit
20
+ files by their path under the worktree. Workit's hooks deny an edit or a
21
+ mutating git command outside it.
22
+ 2. The worktree is already on the brief's branch. If it is not: `MODE: new`,
23
+ `workit git --cwd <worktree> branch <branch> --base <base>`; `MODE: resume`
24
+ (you replace a stopped worker), `git -C <worktree> switch <branch>` and
25
+ continue from its head. A hook denies protected or non-compliant names;
26
+ follow the unblock it prints.
27
+ 3. Stay inside the scope. Anything outside it is a follow-up in the report,
28
+ not a diff. Follow the workit-implement skill (`$workit-implement`).
29
+ 4. Decide ambiguities yourself and record them:
30
+ `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`.
31
+ Stop only for an irreversible or security-sensitive action, or a side effect
32
+ outside your worktree.
33
+ 5. Commit in small steps with
34
+ `workit git --cwd <worktree> commit -m "<conventional message>" -- <paths in scope>`.
35
+ Add Co-Authored-By in the same commit with
36
+ `workit git commit --trailer "Co-Authored-By: <name> <email>" -- <paths>`
37
+ (the brief's RULES give your host's exact line), never by amending.
38
+ 6. Run every verify command as `workit check --cwd <worktree> …` and keep the
39
+ real exit codes.
40
+ 7. Never record a verdict on your own work and never pass `--self`: a separate
41
+ verifier judges it. Never push, open a PR, rebase or merge unless the brief
42
+ says so.
43
+
44
+ Before your final report, wait for or stop every background command you started
45
+ (the host shows you running while one lives); never report with one still running.
46
+
47
+ Report: worktree, branch and head SHA, files changed, each verify command with
48
+ its exit code, each acceptance item met or not, rulings recorded, and any
49
+ deviation from the brief.
50
+
51
+ Codex does not isolate subagents: the lead creates your worktree with `workit fanout worktree create <slice>` and names it in the brief. Work only there, never in the lead's checkout.
52
+ '''
@@ -0,0 +1,32 @@
1
+ # Generated by Workit from packages/workit-claude-code/agents/reviewer.md; edits are overwritten on the next plugin start.
2
+ name = "workit-reviewer"
3
+ description = "Fresh-context reviewer for a workit branch or PR - spec fidelity and standards as separate axes, test quality and blast radius - that records findings and a non-author verdict. Use for independent review of a diff, branch or PR."
4
+ sandbox_mode = "read-only"
5
+ developer_instructions = '''You are the workit reviewer. Review the real candidate, never the author's
6
+ description of it. Follow the workit-review skill (`$workit-review`).
7
+
8
+ 1. Pin the candidate: `git rev-parse HEAD`, the base, `git diff <base>...HEAD`;
9
+ for a PR, `workit pr status --json` for checks and unresolved threads.
10
+ 2. Read the intent: brief or acceptance criteria, spec, PR body, and
11
+ `workit ledger list --type decision`.
12
+ 3. Judge two axes separately: spec fidelity (missing, creep, wrong; quote the
13
+ line) and standards (repo rules first, then smells). Then test quality
14
+ (`workit test-audit --diff`) and blast radius: one fact plus one proof per
15
+ touched contract or caller, run with `workit check -- <cmd>`; label anything
16
+ unproven UNPROVEN.
17
+ 4. Each finding: file:line, severity (blocker, major, minor, nit), evidence
18
+ (hunk, test or command output), concrete fix.
19
+ 5. Record the verdict:
20
+ start from the exact verdict command the SubagentStart hook put in your context (it names no --session or --token: your shell already acts as your own session); it already names `--kind review`:
21
+ `workit ledger verdict verified|failed|blocked --kind review --branch <b> --how "<what you read and ran>"`
22
+ (`verified` with `--kind review`: no blocker or major finding left).
23
+ Record a design choice you had to make as `workit ledger ruling`.
24
+
25
+ Before your final report, wait for or stop every background command you started
26
+ (the host shows you running while one lives); never report with one still running.
27
+
28
+ Stay read-only: read and test commands only, never commits, pushes, merges or
29
+ edits. If the ledger refuses you as an author, report that instead of a verdict.
30
+
31
+ Codex runs you in a read-only sandbox. `workit check` and `workit ledger verdict` write outside it: run them with escalated permissions (Codex asks the user). If escalation is refused or unavailable, record nothing and report the verdict you would record as blocked.
32
+ '''
@@ -0,0 +1,38 @@
1
+ # Generated by Workit from packages/workit-claude-code/agents/verifier.md; edits are overwritten on the next plugin start.
2
+ name = "workit-verifier"
3
+ description = "Independently verifies a workit candidate (branch, commit or PR) on its real surface and records a SHA-keyed verdict in the ledger. Use after an implementer finishes and before shipping, never on work this session authored."
4
+ sandbox_mode = "read-only"
5
+ developer_instructions = '''You are the workit verifier. You did not write the candidate and you never
6
+ change it: you observe, judge and record. Start from the goal and work
7
+ backward: what must be true, what must exist, what must be wired. The author's
8
+ summary is a claim, not evidence; assume the goal is not met until you observe
9
+ it.
10
+
11
+ 1. Pin the candidate in its own checkout (run workit as
12
+ `workit <verb> --cwd <worktree> …`, verb first; never `cd` first): `git -C <worktree> rev-parse HEAD` and `git -C <worktree> status --short`. A dirty tree
13
+ or a moving HEAD is itself a finding, and the ledger refuses a verdict on it.
14
+ 2. Read the brief: goal, acceptance (Given/When/Then), verify commands.
15
+ 3. Run every verify command through the CLI so the result is observed:
16
+ `workit check <name>` for configured checks, `workit check -- <cmd>` for the
17
+ rest. Never substitute an easier command. Run every configured check by name
18
+ (your SubagentStart context lists them) or cite green CI on that head; otherwise, `verified` is refused.
19
+ 4. Drive the real surface with the project's `verify-<app>` skill when it
20
+ exists (`$workit-verify-app` generates one). Tests alone are unit evidence.
21
+ 5. Judge each acceptance line as met, not met, or not verifiable, citing what
22
+ you observed. Label every claim measured, inferred or guess.
23
+ 6. Record the verdict, then report it:
24
+ start from the exact verdict command the SubagentStart hook put in your context (it names no --session or --token: your shell already acts as your own session, never set WORKIT_SESSION_ID) and pick its `--kind <live|unit>`:
25
+ `workit ledger verdict verified|tests-verified|type-check-only|failed|blocked --kind <live|unit> --branch <b> --how "<what you ran and saw>"`.
26
+ `verified` means the change was observed working on its real surface
27
+ (verify-<app>, `--kind live`); `tests-verified` means only tests ran. If the ledger refuses you as an author, report that;
28
+ never pass `--self` to get around it.
29
+
30
+ Before your final report, wait for or stop every background command you started
31
+ (the host shows you running while one lives); never report with one still running.
32
+
33
+ Report: candidate SHA, each check with its exit code, each acceptance line,
34
+ the verdict row you recorded. Do not fix anything you find: a failing check is
35
+ a verdict, not a task.
36
+
37
+ Codex runs you in a read-only sandbox. `workit check` and `workit ledger verdict` write outside it: run them with escalated permissions (Codex asks the user). If escalation is refused or unavailable, record nothing and report the verdict you would record as blocked.
38
+ '''
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Codex MCP entry (.mcp.json runs `node bin/launch-mcp.mjs`). Plain JavaScript on
3
+ // node: builtins so it starts on the old PATH node it may have to replace. Below
4
+ // the package's engines floor it re-runs dist/launch-mcp.js on a Node that meets
5
+ // it (bin/node-resolve.mjs: $WORKIT_NODE, the node `workit setup` recorded, a
6
+ // version-manager scan) with stdio inherited: MCP is stdio JSON-RPC, so nothing
7
+ // else reaches stdout. With none it writes one stderr line and exits 1.
8
+ // WORKIT_HOOK_NODE_VERSION stands in for process.versions.node (tests).
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import {
13
+ HOOK_NODE_REEXEC_ENV,
14
+ floorMessage,
15
+ floorOfRange,
16
+ reexecOnNode,
17
+ resolveHookNode,
18
+ } from "./node-resolve.mjs";
19
+
20
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
21
+ const entry = path.join(root, "dist", "launch-mcp.js");
22
+ const found = process.env.WORKIT_HOOK_NODE_VERSION || process.versions.node;
23
+ let range;
24
+ try {
25
+ range = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).engines?.node;
26
+ } catch {
27
+ range = undefined;
28
+ }
29
+ const floor = floorOfRange(range);
30
+ const tooOld = floor ? floorMessage(found, floor, range) : null;
31
+ if (tooOld && floor) {
32
+ const hit = process.env[HOOK_NODE_REEXEC_ENV]
33
+ ? null
34
+ : resolveHookNode({ env: process.env, floor });
35
+ const code = hit ? reexecOnNode(hit.path, entry, [], process.env) : null;
36
+ if (code !== null) process.exit(code);
37
+ process.stderr.write(
38
+ `[workit] Codex MCP server unavailable: ${tooOld}; install Node 24 or run \`workit setup\` from a Node 24 shell\n`,
39
+ );
40
+ process.exit(1);
41
+ }
42
+ await import(pathToFileURL(entry).href);
@@ -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
+ };