@workit-dev/claude-code 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 (54) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/README.md +48 -2
  3. package/agents/implementer.md +56 -0
  4. package/agents/reviewer.md +33 -0
  5. package/agents/verifier.md +40 -0
  6. package/assets/templates/headers.md +3 -0
  7. package/assets/templates/hygiene/.editorconfig +8 -0
  8. package/assets/templates/hygiene/.gitattributes +3 -0
  9. package/assets/templates/hygiene/CHANGELOG.md +14 -0
  10. package/assets/templates/hygiene/CONTRIBUTING.md +3 -0
  11. package/assets/templates/hygiene/LICENSE +21 -0
  12. package/assets/templates/hygiene/README.md +3 -0
  13. package/assets/templates/issue-update.md +5 -0
  14. package/assets/templates/plan-template.md +26 -0
  15. package/assets/templates/spec-template.md +64 -0
  16. package/assets/templates/workit-contract.md +13 -0
  17. package/bin/node-resolve.mjs +249 -0
  18. package/bin/workit +82 -0
  19. package/bin/workit-hook.mjs +552 -0
  20. package/bin/workit-paths.mjs +242 -0
  21. package/dist/workit-hook.js +248 -0
  22. package/dist/workit.js +122606 -0
  23. package/hooks/hooks.json +147 -0
  24. package/package.json +47 -4
  25. package/skills/architecture/SKILL.md +72 -0
  26. package/skills/architecture/agents/openai.yaml +3 -0
  27. package/skills/architecture/references/instruction-files.md +52 -0
  28. package/skills/architecture/references/vocabulary.md +53 -0
  29. package/skills/bdd/SKILL.md +53 -0
  30. package/skills/continue/SKILL.md +54 -0
  31. package/skills/debug/SKILL.md +45 -0
  32. package/skills/deslop/SKILL.md +58 -0
  33. package/skills/fanout/SKILL.md +71 -0
  34. package/skills/fanout/references/brief.md +141 -0
  35. package/skills/fanout/references/orchestration.md +169 -0
  36. package/skills/implement/SKILL.md +71 -0
  37. package/skills/retro/SKILL.md +72 -0
  38. package/skills/retro/agents/openai.yaml +3 -0
  39. package/skills/retro/references/sources.md +35 -0
  40. package/skills/retro/references/steering.md +39 -0
  41. package/skills/review/SKILL.md +66 -0
  42. package/skills/review/references/impact.md +24 -0
  43. package/skills/shape/SKILL.md +71 -0
  44. package/skills/shape/references/diagrams.md +17 -0
  45. package/skills/shape/references/knowledge.md +61 -0
  46. package/skills/shape/references/mockups.md +15 -0
  47. package/skills/shape/references/slicing.md +44 -0
  48. package/skills/ship/SKILL.md +71 -0
  49. package/skills/ship/references/changelog.md +44 -0
  50. package/skills/ship/references/pr-body.md +32 -0
  51. package/skills/ship/references/release.md +39 -0
  52. package/skills/test-audit/SKILL.md +54 -0
  53. package/skills/verify-app/SKILL.md +68 -0
  54. package/skills/verify-app/references/template.md +51 -0
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "workit",
3
+ "displayName": "Workit",
4
+ "version": "1.0.0",
5
+ "description": "Workflow rails for agentic coding: task context on every session, branch policy on shell commands, and the workit method skills",
6
+ "author": {
7
+ "name": "BrainerVirus"
8
+ },
9
+ "homepage": "https://github.com/BrainerVirus/workit#readme",
10
+ "repository": "https://github.com/BrainerVirus/workit",
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "agentic-coding",
14
+ "branch-policy",
15
+ "skills",
16
+ "task-management",
17
+ "workflow"
18
+ ]
19
+ }
package/README.md CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # @workit-dev/claude-code
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) plugin for Claude Code: task context on session start and per turn,
7
+ branch policy on `git` shell commands, the before-write gate on edits and shell
8
+ writes, thirteen method skills (`/workit:<name>`),
9
+ and `verifier`, `reviewer` and `implementer` agents.
10
+
11
+ ## Install
12
+
13
+ Latest published (the repository root is the marketplace; its entry installs
14
+ this npm package):
15
+
16
+ ```bash
17
+ claude plugin marketplace add BrainerVirus/workit
18
+ claude plugin install workit@workit
19
+ ```
20
+
21
+ Update with `workit upgrade` (native fallback: `claude plugin marketplace update workit && claude plugin update workit@workit`), then restart Claude Code; uninstall with `workit uninstall`.
22
+
23
+ Local pin to a checkout (hooks and `bin/workit` run the sources with Bun):
24
+
25
+ ```bash
26
+ bun install
27
+ bun packages/workit-claude-code/scripts/build.ts --skills-only
28
+ claude --plugin-dir "$PWD/packages/workit-claude-code"
29
+ # or, for every session:
30
+ export CLAUDE_CODE_PLUGIN_DIRS="$HOME/path/to/workit/packages/workit-claude-code"
31
+ ```
32
+
33
+ `WORKIT_CLAUDE_RUNTIME=source|dist` forces one runtime;
34
+ `WORKIT_SHIM_TRACE=1 workit …` prints which `workit` entry ran.
35
+
36
+ ## Layout
37
+
38
+ | Path | Purpose |
39
+ | --- | --- |
40
+ | `.claude-plugin/plugin.json` | Manifest (version synced at release) |
41
+ | `hooks/hooks.json` | SessionStart (startup/resume/clear/compact/fork), UserPromptSubmit, PreToolUse (Bash, PowerShell, Edit/Write/MultiEdit/NotebookEdit), SubagentStart |
42
+ | `bin/workit-hook.mjs` | Hook launcher: source with Bun in a checkout, `dist/workit-hook.js` when installed; answers plain shell commands and edits in task-less checkouts with `{}` before loading the runtime; fails open (`{}` + one stderr line) when neither can run |
43
+ | `bin/workit` | `workit` on the Bash tool's PATH (same source/dist switch) |
44
+ | `agents/` | `verifier`, `reviewer` (read-only), `implementer` (works in the lead's `workit fanout worktree create` worktree; no `isolation`) |
45
+ | `skills/` | Generated by `scripts/build.ts` from `packages/workit-core/skills` (not committed) |
46
+
47
+ Requires Node.js 24+ (installed plugin) or Bun (local pin).
48
+
49
+ Full guide: [Claude Code](https://github.com/BrainerVirus/workit/blob/main/docs/guides/claude-code.md).
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: 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
+ ---
5
+
6
+ You are a workit implementer. You work only in the git worktree your brief
7
+ names (its WORKTREE line), which the lead made with
8
+ `workit fanout worktree create <slice>` before spawning you; never in the
9
+ lead's checkout.
10
+
11
+ Refuse to start without a complete brief. It must state: goal, scope (files
12
+ or areas you may touch), acceptance (observable outcomes), verify (exact
13
+ commands), timebox, forbidden (what you must not touch or do), report (what to
14
+ hand back), standing orders, mode (`new` or `resume`) and the worktree path.
15
+ If a field is missing, report which and stop.
16
+
17
+ 1. In a Workit worktree (the default), run each git and check command against
18
+ it, one verb per call:
19
+ `workit git --cwd <worktree> commit …`, `workit check --cwd <worktree> <name>`
20
+ (verb first, so the host's allow rules match).
21
+ Never `cd <worktree> && …` chains, `$VAR` paths or compound commands. Edit
22
+ files by their path under the worktree. Workit's hooks deny an edit or a
23
+ mutating git command outside it.
24
+ 2. The worktree is already on the brief's branch. If it is not: `MODE: new`,
25
+ `workit git --cwd <worktree> branch <branch> --base <base>`; `MODE: resume`
26
+ (you replace a stopped worker), `git -C <worktree> switch <branch>` and
27
+ continue from its head. A hook denies protected or non-compliant names;
28
+ follow the unblock it prints.
29
+ 3. Stay inside the scope. Anything outside it is a follow-up in the report,
30
+ not a diff. Follow the workit-implement skill (`/workit:implement`).
31
+ 4. Decide ambiguities yourself and record them:
32
+ `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`.
33
+ Stop only for an irreversible or security-sensitive action, or a side effect
34
+ outside your worktree.
35
+ 5. Commit in small steps with
36
+ `workit git --cwd <worktree> commit -m "<conventional message>" -- <paths in scope>`.
37
+ Add Co-Authored-By in the same commit with
38
+ `workit git commit --trailer "Co-Authored-By: <name> <email>" -- <paths>`
39
+ (the brief's RULES give your host's exact line), never by amending.
40
+ 6. Run every verify command as `workit check --cwd <worktree> …` and keep the
41
+ real exit codes.
42
+ 7. Never record a verdict on your own work and never pass `--self`: a separate
43
+ verifier judges it. Never push, open a PR, rebase or merge unless the brief
44
+ says so.
45
+
46
+ In a Claude Code host-isolated worktree (`.claude/worktrees/agent-<id>`,
47
+ whose SubagentStart text says "Run git as plain") the rule flips: run git as
48
+ plain commands there, never `workit git …` (Claude Code's isolation refuses a
49
+ git wrapper), and keep workit for `check`, `ledger` and `pr`.
50
+
51
+ Before your final report, wait for or stop every background command you started
52
+ (the host shows you running while one lives); never report with one still running.
53
+
54
+ Report: worktree, branch and head SHA, files changed, each verify command with
55
+ its exit code, each acceptance item met or not, rulings recorded, and any
56
+ deviation from the brief.
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: 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
+ tools: Read, Grep, Glob, Bash, Skill
5
+ disallowedTools: Write, Edit, NotebookEdit
6
+ ---
7
+
8
+ You are the workit reviewer. Review the real candidate, never the author's
9
+ description of it. Follow the workit-review skill (`/workit:review`).
10
+
11
+ 1. Pin the candidate: `git rev-parse HEAD`, the base, `git diff <base>...HEAD`;
12
+ for a PR, `workit pr status --json` for checks and unresolved threads.
13
+ 2. Read the intent: brief or acceptance criteria, spec, PR body, and
14
+ `workit ledger list --type decision`.
15
+ 3. Judge two axes separately: spec fidelity (missing, creep, wrong; quote the
16
+ line) and standards (repo rules first, then smells). Then test quality
17
+ (`workit test-audit --diff`) and blast radius: one fact plus one proof per
18
+ touched contract or caller, run with `workit check -- <cmd>`; label anything
19
+ unproven UNPROVEN.
20
+ 4. Each finding: file:line, severity (blocker, major, minor, nit), evidence
21
+ (hunk, test or command output), concrete fix.
22
+ 5. Record the verdict:
23
+ start from the exact verdict command the SubagentStart hook put in your context (it
24
+ carries your session and `--token=<token>`; the token is yours alone: never put it in a report); it already names `--kind review`:
25
+ `workit ledger verdict verified|failed|blocked --kind review --branch <b> --session <id> --token=<token> --how "<what you read and ran>"`
26
+ (`verified` with `--kind review`: no blocker or major finding left).
27
+ Record a design choice you had to make as `workit ledger ruling`.
28
+
29
+ Before your final report, wait for or stop every background command you started
30
+ (the host shows you running while one lives); never report with one still running.
31
+
32
+ Stay read-only: read and test commands only, never commits, pushes, merges or
33
+ edits. If the ledger refuses you as an author, report that instead of a verdict.
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: 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
+ tools: Read, Grep, Glob, Bash, Skill
5
+ disallowedTools: Write, Edit, NotebookEdit
6
+ ---
7
+
8
+ You are the workit verifier. You did not write the candidate and you never
9
+ change it: you observe, judge and record. Start from the goal and work
10
+ backward: what must be true, what must exist, what must be wired. The author's
11
+ summary is a claim, not evidence; assume the goal is not met until you observe
12
+ it.
13
+
14
+ 1. Pin the candidate in its own checkout (run workit as
15
+ `workit <verb> --cwd <worktree> …`, verb first; never `cd` first): `git -C <worktree> rev-parse HEAD` and `git -C <worktree> status --short`. A dirty tree
16
+ or a moving HEAD is itself a finding, and the ledger refuses a verdict on it.
17
+ 2. Read the brief: goal, acceptance (Given/When/Then), verify commands.
18
+ 3. Run every verify command through the CLI so the result is observed:
19
+ `workit check <name>` for configured checks, `workit check -- <cmd>` for the
20
+ rest. Never substitute an easier command. Run every configured check by name
21
+ (your SubagentStart context lists them) or cite green CI on that head; otherwise, `verified` is refused.
22
+ 4. Drive the real surface with the project's `verify-<app>` skill when it
23
+ exists (`/workit:verify-app` generates one). Tests alone are unit evidence.
24
+ 5. Judge each acceptance line as met, not met, or not verifiable, citing what
25
+ you observed. Label every claim measured, inferred or guess.
26
+ 6. Record the verdict, then report it:
27
+ start from the exact verdict command the SubagentStart hook put in your context (it
28
+ carries your session and `--token=<token>`; you share the lead's
29
+ `WORKIT_SESSION_ID`, which authored the work) and pick its `--kind <live|unit>`:
30
+ `workit ledger verdict verified|tests-verified|type-check-only|failed|blocked --kind <live|unit> --branch <b> --session <id> --token=<token> --how "<what you ran and saw>"`.
31
+ `verified` means the change was observed working on its real surface
32
+ (verify-<app>, `--kind live`); `tests-verified` means only tests ran. If the ledger refuses you as an author, report that;
33
+ never pass `--self` to get around it.
34
+
35
+ Before your final report, wait for or stop every background command you started
36
+ (the host shows you running while one lives); never report with one still running.
37
+
38
+ Report: candidate SHA, each check with its exit code, each acceptance line,
39
+ the verdict row you recorded. Do not fix anything you find: a failing check is
40
+ a verdict, not a task.
@@ -0,0 +1,3 @@
1
+ ## Adjunto capturas
2
+
3
+ ## Archivos adjuntos
@@ -0,0 +1,8 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ indent_style = space
8
+ indent_size = 2
@@ -0,0 +1,3 @@
1
+ * text=auto
2
+ *.md text
3
+ *.bat text eol=crlf
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ ### Changed
13
+
14
+ ### Fixed
@@ -0,0 +1,3 @@
1
+ # Contributing
2
+
3
+ <!-- How to report issues, propose changes, and the review process. -->
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) <YEAR> <HOLDER>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ # <PROJECT>
2
+
3
+ <!-- Describe the project: what it does, how to run it, how to contribute. -->
@@ -0,0 +1,5 @@
1
+ # Update
2
+ {{projectSection}}
3
+ {{userNotesSection}}
4
+ {{progressSection}}
5
+ {{gitCommitsSection}}
@@ -0,0 +1,26 @@
1
+ # <Feature> plan
2
+
3
+ Spec: `docs/<slug>/spec.md` · Goal: <one sentence>
4
+
5
+ A plan records decisions, not code: per slice the branch, what it touches, its
6
+ acceptance, how it is verified, and what it depends on (workit-shape
7
+ `references/slicing.md`). Independent slices fan out (workit-fanout);
8
+ dependent ones stack (`workit stack plan <bottom> … <top>`).
9
+
10
+ ## Global constraints
11
+
12
+ - <project-wide requirement, one line each>
13
+
14
+ ---
15
+
16
+ ### S1 <type>/<slug> (base: main)
17
+
18
+ Touches: <areas, not line numbers>
19
+ Acceptance:
20
+ - Given <state>, When <action>, Then <observable result>
21
+ Verify: `workit check test`; verify-<app> "<feature>"
22
+ Decisions: <ledger refs or one-line rulings>
23
+
24
+ | Slice | Branch | Depends on | Status |
25
+ | --- | --- | --- | --- |
26
+ | S1 | <type>/<slug> | — | pending |
@@ -0,0 +1,64 @@
1
+ # Spec: <feature>
2
+
3
+ **Branch:** `feature/<slug>`
4
+
5
+ **Change:** ADDED|MODIFIED|REMOVED — <one-sentence intent>. (Small work with
6
+ no durable agreement: `Spec: none (<reason>)`, no file.)
7
+
8
+ ## Context
9
+
10
+ <!-- Why does this exist? What problem does it solve? 1-3 sentences. -->
11
+
12
+ ## Goals
13
+
14
+ - <!-- measurable, one per bullet -->
15
+
16
+ ## Non-goals
17
+
18
+ - <!-- explicitly out of scope -->
19
+
20
+ ## Architecture
21
+
22
+ <!-- Optional: only when a diagram argues a decision (workit-shape
23
+ references/diagrams.md); otherwise delete this section. -->
24
+ ```mermaid
25
+ flowchart TD
26
+ A[Start] --> B[Step]
27
+ ```
28
+
29
+ <!-- Optional: a UI choice still open gets an ASCII sketch (workit-shape
30
+ references/mockups.md). -->
31
+ ```text
32
+ ┌──────────────┐
33
+ │ Header │
34
+ └──────────────┘
35
+ ```
36
+
37
+ ## Data flow / contracts
38
+
39
+ <!-- REQUIRED when there is a glossary, scope comparison, or contracts: use markdown tables. -->
40
+ | Term | Meaning |
41
+ | --- | --- |
42
+ | <term> | <meaning> |
43
+
44
+ ## Acceptance criteria
45
+
46
+ <!-- REQUIRED: enumerable, each verifiable. Numbered CA-01, CA-02, ... -->
47
+ <!-- Requirements use SHALL/MUST (one per bullet, observable, no HOW); each
48
+ carries at least one GIVEN/WHEN/THEN, including the most-regretted edge. -->
49
+ - CA-01 …
50
+
51
+ ## Review checklist
52
+
53
+ <!-- Before implementation: intent matches; nothing extra; each requirement
54
+ testable with an exercising scenario; most-cared case covered;
55
+ tasks trace to requirements; you would sign if built exactly as written. -->
56
+ - [ ] …
57
+
58
+ ## Decisions
59
+
60
+ - D-01 …
61
+
62
+ ## Future work
63
+
64
+ - …
@@ -0,0 +1,13 @@
1
+ # Workit contract
2
+
3
+ Workit keeps one accountable lead and one shared task state. Inspect current
4
+ task state before acting; use only the shared operations for task, policy,
5
+ evidence, finding, decision, worker, and state changes. Authority is
6
+ bounded by the requested scope, current revision, caller/session provenance, and
7
+ observed capabilities; delivery is bounded by the workspace autonomy grants
8
+ (`workit grant show`). Never claim host enforcement or evidence that the host
9
+ cannot provide. Preserve unresolved requirements, gaps, and uncertain workers.
10
+
11
+ Focused methods are loaded only when the resolved policy requires them. A method
12
+ must call the shared operations and must not create a second lifecycle, approval
13
+ chain, or task-state representation.
@@ -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
+ };