@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.
- package/.codex-plugin/plugin.json +32 -0
- package/.mcp.json +9 -0
- package/README.md +29 -2
- package/agents/workit-implementer.toml +52 -0
- package/agents/workit-reviewer.toml +32 -0
- package/agents/workit-verifier.toml +38 -0
- package/bin/launch-mcp.mjs +42 -0
- package/bin/node-resolve.mjs +249 -0
- package/bin/workit-paths.mjs +242 -0
- package/dist/launch-mcp.js +31422 -0
- package/dist/workit-hook-2hj7pbaj.js +23598 -0
- package/dist/workit-hook-3g6cv3hd.js +10 -0
- package/dist/workit-hook-3qaf6adn.js +10 -0
- package/dist/workit-hook-6n3h8wh7.js +104 -0
- package/dist/workit-hook-am9gge2k.js +583 -0
- package/dist/workit-hook-avy6vsn6.js +88 -0
- package/dist/workit-hook-b8nrcfzr.js +151 -0
- package/dist/workit-hook-ehy7rps6.js +200 -0
- package/dist/workit-hook-jy7y0gdx.js +70 -0
- package/dist/workit-hook-mk64s4fk.js +19 -0
- package/dist/workit-hook-y0ga088a.js +9 -0
- package/dist/workit-hook-zyzsv436.js +43 -0
- package/dist/workit-hook.js +94 -0
- package/hooks/hooks.json +46 -0
- package/package.json +48 -4
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- 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
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @workit-dev/codex
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/BrainerVirus/workit/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
+
};
|