@workit-dev/claude-code 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +19 -0
  2. package/README.md +35 -31
  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 +45 -6
  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,552 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code hook launcher (hooks/hooks.json runs `node <this file>`, exec
3
+ // form, so no shell is involved on any OS). Two layouts:
4
+ // - local pin (`claude --plugin-dir <checkout>/packages/workit-claude-code`):
5
+ // the monorepo sources sit next to this package, so the TypeScript entry
6
+ // runs from source with bun: edits apply without a rebuild;
7
+ // - installed package (npm/marketplace): dist/workit-hook.js is imported.
8
+ // WORKIT_CLAUDE_RUNTIME=source|dist forces one.
9
+ // Fail-open, never silent: when the runtime cannot start (a Node below the
10
+ // floor, no bun for the pin, a missing or unloadable dist/, a crash), the
11
+ // launcher exits 0 with one `[workit] Claude Code hook unavailable: …` line on
12
+ // stderr and records a failure row (`<state>/hook-runs/claude_code__<event>__failed.json`,
13
+ // the format of workit-core hooks/hook-runs.ts) that `workit doctor` reads.
14
+ // SessionStart, UserPromptSubmit and SubagentStart answer with a systemMessage
15
+ // (shown to the user) and additionalContext (read by the agent) naming the
16
+ // cause and the fix; every other event answers `{}`, so Claude proceeds as if
17
+ // no Workit hook were installed: a broken hook must never brick the host.
18
+ import { spawnSync } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import {
21
+ appendFileSync,
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ rmSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import path from "node:path";
30
+ import { fileURLToPath, pathToFileURL } from "node:url";
31
+ import { floorMessage, floorOfRange, handleBelowFloor } from "./node-resolve.mjs";
32
+ import {
33
+ configDir as workitConfigDir,
34
+ migrateLegacyDirs,
35
+ stateDir as workitStateDir,
36
+ } from "./workit-paths.mjs";
37
+
38
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
39
+ const source = path.join(root, "src", "run.ts");
40
+ const dist = path.join(root, "dist", "workit-hook.js");
41
+ const mode = process.env.WORKIT_CLAUDE_RUNTIME;
42
+ const fromSource =
43
+ mode === "source" ||
44
+ (mode !== "dist" &&
45
+ existsSync(source) &&
46
+ existsSync(path.join(root, "..", "workit-core", "src", "hooks", "index.ts")));
47
+
48
+ const stateDir = () => workitStateDir(process.env);
49
+
50
+ /** A hook-runs failure row, as workit-core hooks/hook-runs.ts recordHookFailure writes it. */
51
+ const recordFailure = (event, cause) => {
52
+ try {
53
+ const name = typeof event === "string" && event ? event : "unknown";
54
+ const safe = (part) => part.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64);
55
+ const file = path.join(stateDir(), "hook-runs", `claude_code__${safe(name)}__failed.json`);
56
+ let count = 0;
57
+ try {
58
+ const previous = JSON.parse(readFileSync(file, "utf8")).count;
59
+ if (typeof previous === "number" && Number.isFinite(previous)) count = previous;
60
+ } catch {
61
+ // First failure, or a torn file.
62
+ }
63
+ mkdirSync(path.dirname(file), { recursive: true });
64
+ writeFileSync(
65
+ file,
66
+ JSON.stringify({
67
+ host: "claude_code",
68
+ event: name,
69
+ at: new Date().toISOString(),
70
+ count: count + 1,
71
+ failure: String(cause).slice(0, 500),
72
+ }),
73
+ );
74
+ } catch {
75
+ // The answer still goes out; only the doctor row is lost.
76
+ }
77
+ };
78
+
79
+ const LOUD_EVENTS = new Set(["SessionStart", "UserPromptSubmit", "SubagentStart"]);
80
+ const SUBAGENT_NOTE =
81
+ " This subagent got no Workit session: a verifier or reviewer cannot record a verdict (it fails with no_session) until the hook runs; fix the hook, run `workit doctor`, then spawn a fresh one.";
82
+
83
+ /** The answer a hook that could not run gives `event`: loud where Claude shows it, else `{}`. */
84
+ const failedAnswer = (event, text) => {
85
+ if (!LOUD_EVENTS.has(event)) return {};
86
+ const message = event === "SubagentStart" ? `${text}${SUBAGENT_NOTE}` : text;
87
+ return {
88
+ systemMessage: message,
89
+ hookSpecificOutput: { hookEventName: event, additionalContext: message },
90
+ };
91
+ };
92
+
93
+ const failOpen = (reason) => {
94
+ process.stderr.write(`[workit] Claude Code hook unavailable: ${reason}\n`);
95
+ const event = parsed?.hook_event_name;
96
+ recordFailure(event, reason);
97
+ process.stdout.write(
98
+ `${JSON.stringify(
99
+ failedAnswer(
100
+ event,
101
+ `Workit hooks are off: the Claude Code hook could not run (${reason}). Branch policy, task context and verifier sessions are off until it does. Run \`workit upgrade\` to reinstall the plugin, then \`workit doctor\` (check hook_node runs each installed hook).`,
102
+ ),
103
+ )}\n`,
104
+ );
105
+ };
106
+
107
+ // One-time move of a pre-XDG state or config dir (macOS ~/Library/Application
108
+ // Support/workit), before anything reads or writes the new one.
109
+ migrateLegacyDirs();
110
+
111
+ // A Node below this package's own engines minimum cannot load dist. The launcher
112
+ // then looks for a Node that can (bin/node-resolve.mjs: $WORKIT_NODE, the node
113
+ // `workit setup` recorded, a version-manager scan) and re-runs itself on it with
114
+ // stdin, stdout, stderr and the exit code passed through; WORKIT_HOOK_REEXEC
115
+ // stops a second hop. With none found it fails open with one clear line, and a
116
+ // SessionStart tells the agent once that the hooks are off.
117
+ // WORKIT_HOOK_NODE_VERSION stands in for process.versions.node (tests). An
118
+ // unreadable minimum never blocks.
119
+ const nodeFloor = () => {
120
+ try {
121
+ const range = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).engines?.node;
122
+ const floor = floorOfRange(range);
123
+ return floor ? { range, floor } : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ };
128
+ const hookNode = nodeFloor();
129
+ const foundNode = process.env.WORKIT_HOOK_NODE_VERSION || process.versions.node;
130
+ const tooOld = hookNode ? floorMessage(foundNode, hookNode.floor, hookNode.range) : null;
131
+ // isStart runs only when no Node >= the floor was found: it records the failure row.
132
+ let floorEvent;
133
+ if (tooOld && hookNode)
134
+ process.exit(
135
+ await handleBelowFloor({
136
+ host: "Claude Code",
137
+ range: hookNode.range,
138
+ floor: hookNode.floor,
139
+ found: foundNode,
140
+ script: fileURLToPath(import.meta.url),
141
+ argv: process.argv.slice(2),
142
+ env: process.env,
143
+ isStart: (event) => {
144
+ floorEvent = event;
145
+ recordFailure(event, tooOld);
146
+ return LOUD_EVENTS.has(event);
147
+ },
148
+ notice: (text) => failedAnswer(floorEvent, text),
149
+ }),
150
+ );
151
+
152
+ // Every Bash/PowerShell call and every file edit reaches this hook (branch
153
+ // policy, raw git/forge steering and the before-write gate). Fast paths
154
+ // answer `{}` before the runtime loads: a shell command with no git/gh/glab,
155
+ // no redirect and no writing verb can need none of them; with no Workit task
156
+ // store for the checkout there is no task to gate a non-git edit on; and a
157
+ // PostToolUse only matters after a `git commit`. A workit delivery verb
158
+ // (`workit pr create`, `workit ci wait`) may get the workit-ship nudge. A
159
+ // command that may be heavy (a catalog word or a pattern's first word, the
160
+ // copy of core/heavy-words.ts below) goes to the heavy-command gate.
161
+ let payload = "";
162
+ for await (const chunk of process.stdin) payload += String(chunk);
163
+ const RAW_TOOLS = /\b(?:git|gh|glab)\b/;
164
+ const WORKIT_DELIVERY =
165
+ /(?:^|[\s;&|(])(?:\S*[\\/])?workit(?:-cli)?\s+(?:pr (?:create|merge)|ci wait|git push)\b/;
166
+ // The runtime moves the verb first (`cd <dir> && workit …`, `workit --cwd
167
+ // <dir> <verb>`) so Workit's enumerated allow rules match (hooks/cwd-normalize.ts).
168
+ const WORKIT_REORDER = /^\s*(?:cd\s[^;|&]*&&\s*workit\s|workit\s+(?:--json\s+)*--cwd[\s=])/;
169
+ // A Workit call that may carry a bypass flag, however quoted, goes to the
170
+ // runtime, which asks the user (hooks/delivery-verbs.ts workitBypassFlag).
171
+ const mayBypass = (command) =>
172
+ /\bworkit\b/.test(command) &&
173
+ (/\$['"]/.test(command) ||
174
+ /--unverified|--overwrite-unintegrated/.test(command.replace(/['"\\`]/g, "")));
175
+ const MAYBE_GATED =
176
+ /\b(?:git|gh|glab)\b|>|\b(?:tee|touch|mkdir|rm|rmdir|mv|cp|truncate|install|ln|patch|dd|sed|perl|set-content|add-content|out-file|new-item|ni|remove-item|del|copy-item|move-item)\b/i;
177
+ // What an implementer's confinement also judges (hooks/worktree-confine.ts):
178
+ // writers the edit analysis cannot see, `-c` scripts, `$(…)` and Workit's
179
+ // lead-owned fanout verbs.
180
+ const IMPLEMENTER_GATED =
181
+ /\b(?:unlink|chmod|chown|chgrp|shred|chattr|setfacl|rsync|find|xargs|g?awk|python[\d.]*|node|bun|deno|ruby|php|sh|bash|zsh|dash|ksh|workit)\b|\$\(|`/i;
182
+ const SHELLS = new Set(["Bash", "PowerShell"]);
183
+ const HEAVY_WORDS = new Set(
184
+ (
185
+ "npm npx pnpm pnpx yarn bun bunx deno corepack make gmake docker podman nerdctl " +
186
+ "python python3 py uv poetry pdm pipenv hatch rye bundle rake nx " +
187
+ "jest vitest mocha ava tap karma jasmine nyc c8 tsc vue-tsc svelte-check eslint stylelint " +
188
+ "tslint webpack webpack-cli rollup parcel tsup vite next nuxt nuxi astro ng react-scripts " +
189
+ "playwright cypress wdio testcafe turbo lerna sonar-scanner sonarless sonarless-mcp go " +
190
+ "golangci-lint gotestsum staticcheck cargo mvn mvnw gradle gradlew ant sbt bazel bazelisk " +
191
+ "buck2 dotnet msbuild pytest py.test tox nox mypy pyright pylint coverage maturin pip pip3 " +
192
+ "rspec rails phpunit pest phpstan psalm composer mix swift xcodebuild flutter dart pod cmake " +
193
+ "ninja meson ctest scons docker-compose buildah earthly"
194
+ ).split(" "),
195
+ );
196
+ const wordsOf = (command) =>
197
+ command
198
+ .split(/[\s;&|()<>`'"]+/u)
199
+ .map((word) =>
200
+ (word.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.(?:exe|cmd|bat)$/u, ""),
201
+ )
202
+ .filter(Boolean);
203
+ const readJson = (file) => {
204
+ try {
205
+ return JSON.parse(readFileSync(file, "utf8"));
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ };
210
+ /** Does the user's workspaces.json name role models? A doubt answers yes (core/workit-paths.mjs configDir). */
211
+ const roleModelsConfigured = () => {
212
+ const env = process.env;
213
+ const dir = workitConfigDir(env);
214
+ let text;
215
+ try {
216
+ text = readFileSync(path.join(dir, "workspaces.json"), "utf8");
217
+ } catch (error) {
218
+ return error?.code !== "ENOENT";
219
+ }
220
+ return /"(?:roles|tierModels)"\s*:/u.test(text);
221
+ };
222
+ const REPO_CHECKS = ".workit/checks.json";
223
+ const LEGACY_REPO_CHECKS = "workit.checks.json";
224
+ /** Could the command be heavy? A doubt answers yes (it only costs a runtime load). */
225
+ const maybeHeavy = (command, cwd) => {
226
+ try {
227
+ const words = wordsOf(command);
228
+ if (words.some((word) => HEAVY_WORDS.has(word) || /^python[\d.]*$/u.test(word))) return true;
229
+ const patterns = [];
230
+ const machine = readJson(path.join(stateDir(), "heavy-commands.json"));
231
+ if (Array.isArray(machine?.patterns)) patterns.push(...machine.patterns);
232
+ // The repo's checks config at the git top: keep REPO_CHECKS and
233
+ // LEGACY_REPO_CHECKS equal to workit-core/src/check-config.ts (a test holds them).
234
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
235
+ if (existsSync(path.join(dir, ".git"))) {
236
+ const repo =
237
+ readJson(path.join(dir, REPO_CHECKS)) ?? readJson(path.join(dir, LEGACY_REPO_CHECKS));
238
+ if (Array.isArray(repo?.resources?.heavyCommands))
239
+ patterns.push(...repo.resources.heavyCommands);
240
+ break;
241
+ }
242
+ if (path.dirname(dir) === dir) break;
243
+ }
244
+ for (const pattern of patterns) {
245
+ if (typeof pattern !== "string") continue;
246
+ const first = wordsOf(pattern)[0];
247
+ if (!first || first.includes("*") || words.includes(first)) return true;
248
+ }
249
+ return false;
250
+ } catch {
251
+ return true;
252
+ }
253
+ };
254
+ const EDITS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
255
+ const READS = new Set(["Read", "Grep", "Glob"]);
256
+
257
+ /** `<git-common-dir>/workit` (or `<dir>/.workit` outside git) holds tasks. */
258
+ const hasTaskStore = (cwd) => {
259
+ try {
260
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
261
+ const dotGit = path.join(dir, ".git");
262
+ if (existsSync(dotGit)) {
263
+ let gitDir = dotGit;
264
+ if (statSync(dotGit).isFile()) {
265
+ const target = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1]?.trim();
266
+ if (!target) return true;
267
+ gitDir = path.resolve(dir, target);
268
+ const common = path.join(gitDir, "commondir");
269
+ if (existsSync(common))
270
+ gitDir = path.resolve(gitDir, readFileSync(common, "utf8").trim());
271
+ }
272
+ const store = path.join(gitDir, "workit");
273
+ return (
274
+ existsSync(path.join(store, "tasks")) || existsSync(path.join(store, "task-index.json"))
275
+ );
276
+ }
277
+ if (existsSync(path.join(dir, ".workit"))) return true;
278
+ if (path.dirname(dir) === dir) return false;
279
+ }
280
+ } catch {
281
+ return true;
282
+ }
283
+ };
284
+
285
+ /** Text that may name the issued-session registries (the guard in write-gate.ts judges it). */
286
+ const mentionsIssued = (text) => {
287
+ const flat = (value) => value.replaceAll("\\", "/").toLowerCase();
288
+ return /issued/i.test(text) || flat(text).includes(flat(stateDir()));
289
+ };
290
+
291
+ // A standing order the lead added or cleared since this agent last looked
292
+ // (`<store>/standing/rev` against its `standing/seen/<key>.json`) rides the
293
+ // next tool call, so that call loads the runtime. A copy of
294
+ // workit-core/src/hooks/standing-signal.ts (a test holds them equal).
295
+ const gitStoreDir = (cwd) => {
296
+ try {
297
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
298
+ const dotGit = path.join(dir, ".git");
299
+ if (existsSync(dotGit)) {
300
+ let gitDir = dotGit;
301
+ if (statSync(dotGit).isFile()) {
302
+ const target = /^gitdir:\s*(.+)$/mu.exec(readFileSync(dotGit, "utf8"))?.[1]?.trim();
303
+ if (!target) return null;
304
+ gitDir = path.resolve(dir, target);
305
+ const common = path.join(gitDir, "commondir");
306
+ if (existsSync(common))
307
+ gitDir = path.resolve(gitDir, readFileSync(common, "utf8").trim());
308
+ }
309
+ return path.join(gitDir, "workit");
310
+ }
311
+ if (path.dirname(dir) === dir) return null;
312
+ }
313
+ } catch {
314
+ return null;
315
+ }
316
+ };
317
+ const standingPending = (cwd, session, agentId) => {
318
+ const store = gitStoreDir(cwd);
319
+ if (!store || typeof session !== "string" || !session) return false;
320
+ let rev;
321
+ try {
322
+ rev = readFileSync(path.join(store, "standing", "rev"), "utf8").trim();
323
+ } catch {
324
+ return false;
325
+ }
326
+ if (!rev) return false;
327
+ const key = createHash("sha256")
328
+ .update(`${session}\0${typeof agentId === "string" ? agentId : ""}`)
329
+ .digest("hex")
330
+ .slice(0, 32);
331
+ return readJson(path.join(store, "standing", "seen", `${key}.json`))?.rev !== rev;
332
+ };
333
+
334
+ // The worker tool-call budget: a copy of spawnBudget, noteSpawnBudget and
335
+ // tickBudget in workit-core/src/hooks/budget-signal.ts (a test holds them
336
+ // equal). A spawn whose brief names `N tool calls` queues N for the next
337
+ // subagent of its type; each pre-tool call of a budgeted agent appends one
338
+ // byte to its counter, and only the call that reaches the next multiple of N
339
+ // loads the runtime, which tells the agent once.
340
+ const budgetHash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 32);
341
+ const spawnBudget = (prompt) => {
342
+ const found = /^TIMEBOX:[^\n]*?\b(\d+)\s+tool calls\b/mu.exec(prompt);
343
+ const n = found ? Number(found[1]) : NaN;
344
+ return Number.isInteger(n) && n > 0 ? n : null;
345
+ };
346
+ const noteSpawnBudget = (cwd, session, type, prompt) => {
347
+ try {
348
+ const store = session ? gitStoreDir(cwd) : null;
349
+ if (!store) return;
350
+ mkdirSync(path.join(store, "budget"), { recursive: true });
351
+ appendFileSync(
352
+ path.join(store, "budget", `spawns-${budgetHash(session)}.jsonl`),
353
+ `${JSON.stringify({ type: type ?? "", n: spawnBudget(prompt) })}\n`,
354
+ );
355
+ } catch {
356
+ // Without the note the worker runs unbudgeted.
357
+ }
358
+ };
359
+ const tickBudget = (cwd, session, agentId) => {
360
+ try {
361
+ const store = session ? gitStoreDir(cwd) : null;
362
+ if (!store) return false;
363
+ const base = path.join(store, "budget", budgetHash(`${session}\0${agentId ?? ""}`));
364
+ const budget = readJson(`${base}.json`);
365
+ if (typeof budget?.limit !== "number" || budget.limit <= 0) return false;
366
+ const told = typeof budget.told === "number" ? budget.told : 0;
367
+ appendFileSync(`${base}.calls`, ".");
368
+ return statSync(`${base}.calls`).size >= budget.limit * (told + 1);
369
+ } catch {
370
+ return false;
371
+ }
372
+ };
373
+ const parsed = (() => {
374
+ try {
375
+ return JSON.parse(payload);
376
+ } catch {
377
+ return undefined;
378
+ }
379
+ })();
380
+ // Counted before any fast path, so a call the fast path answers counts too.
381
+ const budgetDue = (() => {
382
+ try {
383
+ const value = parsed;
384
+ if (value?.hook_event_name !== "PreToolUse" || typeof value.session_id !== "string")
385
+ return false;
386
+ return typeof value.agent_id === "string" && value.agent_id !== ""
387
+ ? tickBudget(String(value.cwd ?? "."), value.session_id, value.agent_id)
388
+ : false;
389
+ } catch {
390
+ return false;
391
+ }
392
+ })();
393
+
394
+ // The plugin dir this hook runs from (`<config>/plugins/cache/<mkt>/workit/<version>`)
395
+ // against the newest version the registry lists beside it: while the line has not
396
+ // been given this session, the runtime runs (src/stale-plugin.ts holds the same test).
397
+ const parts = (version) => /^(\d+)\.(\d+)\.(\d+)/.exec(version)?.slice(1).map(Number);
398
+ const staleUnannounced = (session) => {
399
+ try {
400
+ const versions = path.dirname(root);
401
+ if (path.basename(path.dirname(path.dirname(versions))) !== "cache") return false;
402
+ const running = parts(path.basename(root));
403
+ if (!running || typeof session !== "string") return false;
404
+ const registry = readJson(
405
+ path.join(path.dirname(path.dirname(path.dirname(versions))), "installed_plugins.json"),
406
+ );
407
+ for (const entries of Object.values(registry?.plugins ?? {}))
408
+ for (const entry of Array.isArray(entries) ? entries : []) {
409
+ const at = typeof entry?.version === "string" ? parts(entry.version) : undefined;
410
+ if (
411
+ at &&
412
+ path.dirname(String(entry.installPath)) === versions &&
413
+ (at[0] - running[0] || at[1] - running[1] || at[2] - running[2]) > 0
414
+ )
415
+ return !existsSync(markerFor(session, entry.version));
416
+ }
417
+ } catch {
418
+ // an unreadable registry is not a reason to load the runtime
419
+ }
420
+ return false;
421
+ };
422
+ const markerFor = (session, installed) =>
423
+ path.join(
424
+ stateDir(),
425
+ "stale-plugin",
426
+ createHash("sha256").update(`${session}\0${installed}`).digest("hex").slice(0, 32),
427
+ );
428
+
429
+ const fastAllow = (() => {
430
+ try {
431
+ if (parsed === undefined) return false;
432
+ const value = parsed;
433
+ const command = value?.tool_input?.command;
434
+ if (
435
+ value?.hook_event_name === "PreToolUse" &&
436
+ !value.agent_id &&
437
+ staleUnannounced(value.session_id)
438
+ )
439
+ return false;
440
+ if (value?.hook_event_name === "PostToolUse")
441
+ return typeof command === "string" && !/\bcommit\b/.test(command);
442
+ if (value?.hook_event_name !== "PreToolUse") return false;
443
+ if (budgetDue) return false;
444
+ if (mentionsIssued(JSON.stringify(value.tool_input ?? {}))) return false;
445
+ if (standingPending(String(value.cwd ?? "."), value.session_id, value.agent_id)) return false;
446
+ // An implementer's writes are confined to its Workit worktree with or
447
+ // without a task store (hooks/worktree-confine.ts).
448
+ const implementer = value.agent_type === "workit:implementer";
449
+ if (SHELLS.has(value.tool_name) && typeof command === "string") {
450
+ if (WORKIT_DELIVERY.test(command) || WORKIT_REORDER.test(command) || mayBypass(command))
451
+ return false;
452
+ if (maybeHeavy(command, String(value.cwd ?? "."))) return false;
453
+ if (!MAYBE_GATED.test(command) && !(implementer && IMPLEMENTER_GATED.test(command)))
454
+ return true;
455
+ return !implementer && !RAW_TOOLS.test(command) && !hasTaskStore(String(value.cwd ?? "."));
456
+ }
457
+ // A spawn with isolation (any type) or an implementer without a brief meets the
458
+ // spawn gate (hooks/spawn-gate.ts, the same WORKTREE test). One that names its
459
+ // model has no default to fill (hooks/agent-model.ts) unless the user's
460
+ // workspaces.json configures role models, which win over a named model.
461
+ if (value.tool_name === "Agent" || value.tool_name === "Task") {
462
+ const spawn = value.tool_input ?? {};
463
+ if (spawn.isolation !== undefined) return false;
464
+ if (
465
+ spawn.subagent_type === "workit:implementer" &&
466
+ (typeof spawn.prompt !== "string" ||
467
+ !/^WORKTREE:[ \t]*(?!not made yet)\S(?![^\n]*not made yet)/mu.test(spawn.prompt))
468
+ )
469
+ return false;
470
+ return (
471
+ typeof spawn.model === "string" && spawn.model.trim() !== "" && !roleModelsConfigured()
472
+ );
473
+ }
474
+ // Read, Grep and Glob reach this hook only to be counted against a budget.
475
+ if (READS.has(value.tool_name)) return true;
476
+ return EDITS.has(value.tool_name) && !implementer && !hasTaskStore(String(value.cwd ?? "."));
477
+ } catch {
478
+ return false;
479
+ }
480
+ })();
481
+
482
+ // A lead's spawn the fast path allows queues its budget or none (the runtime
483
+ // queues one its gate lets through); a stopped subagent's budget files go.
484
+ // Copies of handle.ts noteClaudeSpawn and budget-signal.ts dropBudget.
485
+ const settleBudget = () => {
486
+ try {
487
+ const value = parsed;
488
+ const session = value?.session_id;
489
+ if (typeof session !== "string" || !session) return;
490
+ const cwd = String(value.cwd ?? ".");
491
+ if (value.hook_event_name === "SubagentStop") {
492
+ const store = gitStoreDir(cwd);
493
+ if (!store || typeof value.agent_id !== "string" || !value.agent_id) return;
494
+ const base = path.join(store, "budget", budgetHash(`${session}\0${value.agent_id}`));
495
+ rmSync(`${base}.json`, { force: true });
496
+ rmSync(`${base}.calls`, { force: true });
497
+ return;
498
+ }
499
+ const spawn = value.tool_input;
500
+ if (fastAllow && !value.agent_id && (value.tool_name === "Agent" || value.tool_name === "Task"))
501
+ noteSpawnBudget(
502
+ cwd,
503
+ session,
504
+ typeof spawn?.subagent_type === "string" && spawn.subagent_type
505
+ ? spawn.subagent_type
506
+ : "general-purpose",
507
+ typeof spawn?.prompt === "string" ? spawn.prompt : "",
508
+ );
509
+ } catch {
510
+ // Budgets fail open.
511
+ }
512
+ };
513
+ settleBudget();
514
+
515
+ // A SubagentStop loads the runtime only when its last message may be a
516
+ // provider usage limit (W2.19: hooks/handle.ts marks the provider exhausted).
517
+ const LIMIT_HINT = /\blimit\b|\bquota\b|\b429\b|rate_limit/iu;
518
+ const quietStop =
519
+ parsed?.hook_event_name === "SubagentStop" &&
520
+ !(
521
+ typeof parsed.last_assistant_message === "string" &&
522
+ parsed.last_assistant_message.length <= 1000 &&
523
+ LIMIT_HINT.test(parsed.last_assistant_message)
524
+ );
525
+
526
+ process.env.WORKIT_PLUGIN_ROOT = root;
527
+ if (quietStop || fastAllow) {
528
+ process.stdout.write("{}\n");
529
+ } else if (fromSource) {
530
+ const run = spawnSync(process.env.WORKIT_BUN ?? "bun", [source], {
531
+ input: payload,
532
+ stdio: ["pipe", "inherit", "inherit"],
533
+ windowsHide: true,
534
+ });
535
+ if (run.error) failOpen(`bun is required for the local pin (${run.error.message})`);
536
+ else process.exitCode = run.status ?? 0;
537
+ } else if (existsSync(dist)) {
538
+ let runClaudeHook;
539
+ try {
540
+ ({ runClaudeHook } = await import(pathToFileURL(dist).href));
541
+ } catch (error) {
542
+ failOpen(`cannot load ${dist} (${error instanceof Error ? error.message : String(error)})`);
543
+ }
544
+ if (runClaudeHook)
545
+ try {
546
+ process.exitCode = await runClaudeHook([payload], process.stdout);
547
+ } catch (error) {
548
+ failOpen(`crashed (${error instanceof Error ? error.message : String(error)})`);
549
+ }
550
+ } else {
551
+ failOpen(`${dist} is missing; run \`bun scripts/build.ts\` in ${root}`);
552
+ }