@workit-dev/core 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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,433 @@
1
+ // The one host-hook implementation: every host maps its native events here.
2
+ import {
3
+ changedTurnContext,
4
+ seedTurnContext,
5
+ sessionContextText,
6
+ turnContextText,
7
+ } from "./context";
8
+ import type { HostDescriptor, Support } from "./descriptor";
9
+ import { branchGuidance } from "../core/branch";
10
+ import { workspaceTrailers } from "../core/workspaces";
11
+ import { judgeBranch, recordIssuedSession, verdictCommand } from "../host-session";
12
+ import { cwdRewrite, verbFirst } from "./cwd-normalize";
13
+ import { userOnlyWorkitCall, workitBypassFlag } from "./delivery-verbs";
14
+ import { heavyDenial, heavyGate, rewriteNote, splitDenial } from "./heavy-gate";
15
+ import { shellPolicy } from "./policy";
16
+ import {
17
+ callKey,
18
+ NEXT_COMMAND,
19
+ noteRawCommit,
20
+ rawGitPost,
21
+ rawGitPre,
22
+ settlePendingCommit,
23
+ } from "./raw-git";
24
+ import {
25
+ promptNudge,
26
+ recordSkillLoad,
27
+ shipNudge,
28
+ skillReadIn,
29
+ withContextLine,
30
+ } from "./skill-nudge";
31
+ import { spawnDecision } from "./spawn-gate";
32
+ import { seedStanding, standingDelta } from "./standing";
33
+ import type { BudgetHooks } from "./budget-signal";
34
+ import { modelSwitchedOnDisk, trackJudge } from "./session-marker";
35
+ import { parseUsageLimit, recordUsageLimit } from "./model-limit";
36
+ import { firstSighting } from "../core/model-fallback";
37
+ import { invariantBootstrap } from "../core/methods";
38
+ import { stopDecision } from "./stop";
39
+ import { liveOwner } from "../core/live-session";
40
+ import { liveTargets, shellGate, writeGate } from "./write-gate";
41
+ import { confineImplementer, confineImplementerShell } from "./worktree-confine";
42
+ import { agentSteering, judgeChecksLine, workitRole } from "./steering";
43
+ import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
44
+
45
+ /**
46
+ * `budget`: the worker tool-call budget (budget-signal.ts budgetHooks), given
47
+ * only by the hosts that count calls in their launcher (Claude Code, Codex),
48
+ * so other hook bundles do not carry it.
49
+ */
50
+ export type HookDeps = {
51
+ descriptor: HostDescriptor;
52
+ addendum: string | null;
53
+ budget?: BudgetHooks;
54
+ };
55
+
56
+ const usable = (value: Support) => value === "native" || value === "partial";
57
+ const NONE: HookDecision = { kind: "none" };
58
+
59
+ const resendsOnChange = (descriptor: HostDescriptor): boolean =>
60
+ descriptor.context.turnResend === "on-change" && usable(descriptor.context.perTurn);
61
+
62
+ /** Sessions already offered unfinished tasks in this process. */
63
+ const offered = new Set<string>();
64
+
65
+ /** A tool call made by a Workit implementer subagent (the host names its agent type). */
66
+ const implementerAgent = (input: HookInput, descriptor: HostDescriptor): boolean =>
67
+ Boolean(input.session.agentId) &&
68
+ input.session.agentType !== null &&
69
+ workitRole(descriptor, input.session.agentType) === "implementer";
70
+
71
+ /**
72
+ * W2.19: a subagent that stops on a provider usage limit marks that provider
73
+ * exhausted at once, so the next spawn of its role already runs on the next
74
+ * model in the role's list (model-fallback). The stop is never blocked.
75
+ */
76
+ const LIMIT_TEXT_MAX = 1000;
77
+
78
+ const usageLimitOnStop = (input: HookInput, agentId: string | null, text: string): void => {
79
+ try {
80
+ // A provider error is one short line; a worker's report that discusses limits is not one.
81
+ if (text.length > LIMIT_TEXT_MAX || !parseUsageLimit(text)) return;
82
+ if (!firstSighting(`subagent-stop:${input.session.id}:${agentId ?? ""}`)) return;
83
+ recordUsageLimit(text, { cwd: input.cwd, host: input.host, model: input.model ?? null });
84
+ } catch {
85
+ // The transcript scan on the next spawn notices the limit instead.
86
+ }
87
+ };
88
+
89
+ /** `<lead session>:<agent id>`, safe for the ledger's session grammar. */
90
+ const subagentSession = (session: string | null, agentId: string): string =>
91
+ [session, agentId]
92
+ .filter((part): part is string => Boolean(part))
93
+ .join(":")
94
+ .replace(/[^A-Za-z0-9_.:@/+-]/g, "-")
95
+ .slice(0, 128);
96
+
97
+ /**
98
+ * Claude Code's worktree isolation refuses any command that wraps git
99
+ * (`workit git …` included) and cannot be turned off, so an isolated agent
100
+ * runs git itself and workit for everything else.
101
+ */
102
+ const isolatedGit = (cwd: string): string =>
103
+ `Run git as plain, separate commands inside your worktree (\`git switch -c <branch> <base>\`, ${
104
+ workspaceTrailers(cwd).enabled
105
+ ? '`git commit --trailer "Workit-Session=<your session>"`, `git push`; `workit doctor --fix` can install a prepare-commit-msg hook that adds the trailer'
106
+ : "`git commit` (this workspace sets trailers: false: add no Workit-Session trailer), `git push`"
107
+ }; add Co-Authored-By with \`git commit --trailer\` in the same commit, never by amending), never \`workit git …\`: Claude Code's worktree isolation refuses a command that wraps git and cannot be turned off (https://code.claude.com/docs/en/worktrees#how-claude-code-enforces-isolation). Never dodge that check by renaming or re-wrapping git. Use workit for the non-git verbs (\`workit check\`, \`workit ledger\`, \`workit pr\`).`;
108
+
109
+ const subagentStartText = (
110
+ input: HookInput,
111
+ descriptor: HostDescriptor,
112
+ event: Extract<HookInput["event"], { kind: "subagent.start" }>,
113
+ ): string => {
114
+ const session = input.session.id ?? null;
115
+ const { role, isolated, git } = agentSteering(descriptor, input.cwd, {
116
+ type: event.agentType,
117
+ id: event.agentId,
118
+ });
119
+ const who = `${descriptor.label} subagent ${event.agentId} (${event.agentType})`;
120
+ const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
121
+ if (git === "plain")
122
+ return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${isolatedGit(input.cwd)} Plain git is expected here, and the branch name is still policy-checked: a raw \`git switch -c\`, \`git checkout -b\`, \`git branch\` or \`git worktree add -b\` onto a protected or non-compliant name is denied, so use a policy-compliant branch.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
123
+ if (isolated)
124
+ return `${isolatedGit(input.cwd)}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
125
+ if (git === "workit")
126
+ // No host isolation: the lead makes the worktree, Workit's hooks confine
127
+ // the implementer to it (worktree-confine.ts), and workit git works there.
128
+ return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree, the one its brief names (the lead makes it with \`workit fanout worktree create <slice>\` before spawning), never in the lead's checkout, within its brief's scope. Workit binds it to the first Workit worktree it changes: from then on an edit or a mutating git command outside that worktree is denied, and before then so is one outside every Workit worktree. Run Workit against the worktree one verb per call, never \`cd … &&\` chains or \`$VAR\` paths: \`workit git --cwd <worktree> commit -m "<message>" -- <paths>\`, \`workit check --cwd <worktree> <name>\` (verb first, so host allow rules match). The worktree is on the brief's branch once the lead made it; otherwise switch with \`workit git --cwd <worktree> branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
129
+ const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
130
+ if (role !== "judge" || !event.agentId) return readOnly;
131
+ const own = subagentSession(session, event.agentId);
132
+ const cwd = input.cwd || process.cwd();
133
+ const judge = event.agentType.endsWith("reviewer") ? "reviewer" : "verifier";
134
+ const pick = judge === "verifier" ? ", --kind live when you drove the real surface" : "";
135
+ const branch = judgeBranch(cwd);
136
+ const checks = judgeChecksLine(cwd);
137
+ const checksText = checks ? ` ${checks}` : "";
138
+ // Codex runs a subagent's shell with its own CODEX_THREAD_ID: the CLI already
139
+ // acts as that session, so the line names no --session and no --token (a
140
+ // token on a command line lands in the rollout, #427) and nothing is issued.
141
+ if (descriptor.host === "codex_cli" || descriptor.host === "codex_desktop")
142
+ return `${readOnly} Its shell already acts as its own Workit session, so the ledger tells its verdicts apart from the author's. Record the verdict with exactly this command (pick the result${pick}, name the candidate branch if it differs, say what you ran): \`${verdictCommand({ branch, judge })}\`. Add no session or token option to it, and never set WORKIT_SESSION_ID by hand.${checksText}`;
143
+ // `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it,
144
+ // and only with the token it hands to this subagent alone. Claude Code
145
+ // subagents share the lead's environment, so no per-agent env var or file
146
+ // can carry it: the token stays on the line (docs/agents/hosts.md, #427).
147
+ // (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
148
+ // Issued once: a replayed SubagentStart for the same id gets no token.
149
+ const token = session ? recordIssuedSession(cwd, session, own) : null;
150
+ // Already issued: without its token the id is refused (unbound_session).
151
+ if (session && !token)
152
+ return `${readOnly} ${own} was already issued to an earlier subagent, so no token is handed out again and a verdict under it would be refused (unbound_session). Record no verdict as ${own}: spawn a fresh verifier or reviewer, which gets its own session and token.`;
153
+ const line = verdictCommand({ branch, judge, session: own, token });
154
+ return `${readOnly} Its own Workit session is ${own}, so the ledger tells its verdicts apart from the author's. Record the verdict with exactly this command (pick the result${pick}, name the candidate branch if it differs, say what you ran): \`${line}\`.${token ? " The token is this subagent's alone: never write it into a report, a file or a commit. --token needs a workit CLI at least as new as this plugin; if the CLI answers that --token is an unknown option, update the workit CLI (workit upgrade --cli) and record again." : ""}${checksText}`;
155
+ };
156
+
157
+ const PRE_TOOL = new Set<HookEventKind>(["shell.pre", "write.pre", "tool.pre"]);
158
+
159
+ /**
160
+ * A running worker's standing-order delta, and its tool-call budget line once
161
+ * the launcher counted it reached (budget-signal.ts), ride any pre-tool answer
162
+ * that lets the call run. In-process hosts deliver them on their own channel
163
+ * (OpenCode's system context, Pi's tool result), so they are never spent here
164
+ * for them.
165
+ */
166
+ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
167
+ const decision = decide(input, deps);
168
+ if (
169
+ deps.descriptor.transport !== "hook-process" ||
170
+ !PRE_TOOL.has(input.event.kind) ||
171
+ decision.kind === "deny" ||
172
+ decision.kind === "ask"
173
+ )
174
+ return decision;
175
+ const delta =
176
+ [standingDelta(input), deps.budget?.line(input) ?? null].filter(Boolean).join("\n") || null;
177
+ if (!delta) return decision;
178
+ return decision.kind === "rewrite" || decision.kind === "update-input"
179
+ ? { ...decision, text: [decision.text, delta].filter(Boolean).join("\n") }
180
+ : withContextLine(decision, delta);
181
+ }
182
+
183
+ function decide(input: HookInput, deps: HookDeps): HookDecision {
184
+ const { descriptor } = deps;
185
+ const event = input.event;
186
+ switch (event.kind) {
187
+ case "session.start": {
188
+ if (!usable(descriptor.context.sessionStart)) return NONE;
189
+ // A session without an id cannot be told apart from another one, so it
190
+ // is offered on every startup and never recorded.
191
+ const key = input.session.id ? `${input.host}\0${input.session.id}` : null;
192
+ const offer = event.source === "startup" && (key === null || !offered.has(key));
193
+ if (offer && key !== null) offered.add(key);
194
+ const advisory = usable(descriptor.events["write.pre"].support)
195
+ ? null
196
+ : `Workit cannot gate file writes on ${descriptor.label} (no pre-write hook): before-write requirements (an open product choice, a missing plan) are advisory here, so settle them before editing.`;
197
+ const addendum = [deps.addendum, advisory].filter(Boolean).join("\n") || null;
198
+ const text = sessionContextText(input, descriptor, { offer, addendum });
199
+ if (resendsOnChange(descriptor)) seedTurnContext(input, descriptor);
200
+ return { kind: "context", text };
201
+ }
202
+ case "context.turn": {
203
+ if (!usable(descriptor.context.perTurn)) return NONE;
204
+ const current = turnContextText(input, descriptor);
205
+ const turn = resendsOnChange(descriptor) ? changedTurnContext(input, current) : current;
206
+ // A model switch (#435): the compact contract again, once.
207
+ const text =
208
+ input.model && modelSwitchedOnDisk(input)
209
+ ? [invariantBootstrap({ cwd: input.cwd, host: input.host }), turn]
210
+ .filter(Boolean)
211
+ .join("\n")
212
+ : turn;
213
+ return withContextLine(
214
+ text ? { kind: "context", text } : NONE,
215
+ promptNudge(input, event.prompt),
216
+ );
217
+ }
218
+ case "shell.pre": {
219
+ const skillRead = skillReadIn(event.command, event.dialect);
220
+ if (skillRead) recordSkillLoad(input, skillRead, "read");
221
+ const canDeny = usable(descriptor.shellPolicy.deny);
222
+ const policy = canDeny ? shellPolicy(input.cwd, event.command, event.dialect) : NONE;
223
+ if (policy.kind !== "none") return policy;
224
+ // Without a post-tool event, the previous raw commit is recorded now.
225
+ const postTool = usable(descriptor.events["shell.post"].support);
226
+ if (!postTool) settlePendingCommit(input);
227
+ const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
228
+ if (raw.kind === "deny") return raw;
229
+ // Only the user runs these (#615): a host rule cannot see a global
230
+ // option before the verb, so the words are read here.
231
+ const userOnly = userOnlyWorkitCall(event.command, event.dialect);
232
+ if (userOnly) {
233
+ if (input.host === "claude_code")
234
+ return {
235
+ kind: "ask",
236
+ reason: `Only the user runs \`workit ${userOnly.join(" ")}\`: the user decides this call.`,
237
+ };
238
+ if (canDeny && ["cursor", "codex_cli", "codex_desktop", "pi"].includes(input.host))
239
+ return {
240
+ kind: "deny",
241
+ reason: `Only the user runs \`workit ${userOnly.join(" ")}\`: run it in your own terminal.`,
242
+ unblock: null,
243
+ };
244
+ }
245
+ // An implementer subagent stays in its Workit worktree (#310).
246
+ if (canDeny && implementerAgent(input, descriptor)) {
247
+ const confined = confineImplementerShell(input, event.command, event.dialect);
248
+ if (confined.kind === "deny") return confined;
249
+ }
250
+ // The before-write gate and the live-session guard (#274).
251
+ const gate = usable(descriptor.events["write.pre"].support)
252
+ ? shellGate(input.cwd, event.command, liveOwner(input, descriptor.transport), event.dialect)
253
+ : NONE;
254
+ if (gate.kind === "deny") return gate;
255
+ // A heavy command runs under the machine lease: rewritten where the
256
+ // host can replace the command, else denied with the exact rewrite.
257
+ // Every gate above passed on the original command (ruling mvid5onk).
258
+ const plan = heavyGate(input.cwd, event.command, {
259
+ host: input.host,
260
+ dialect: event.dialect,
261
+ permissionMode: input.permissionMode,
262
+ });
263
+ if (plan.kind === "split" && canDeny) return splitDenial(plan);
264
+ const rewrites = usable(descriptor.shellPolicy.rewrite);
265
+ if (plan.kind === "rewrite" && !rewrites && canDeny) return heavyDenial(event.command, plan);
266
+ const lease = plan.kind === "rewrite" && rewrites ? plan : null;
267
+ // `cd <dir> && workit …` becomes `workit <verb> --cwd <dir> …` (issue
268
+ // #388), and `workit --cwd <dir> <verb>` puts the verb first (#297), so
269
+ // Workit's enumerated allow rules match. A workit command is never heavy
270
+ // itself, so the lease plan wins when set.
271
+ const cwdCommand =
272
+ !lease && rewrites && input.host === "claude_code"
273
+ ? (cwdRewrite(event.command) ?? verbFirst(event.command))
274
+ : null;
275
+ const aside = plan.kind === "passthrough" ? plan.note : null;
276
+ // The command will run: note HEAD so the post-tool hook (or the next
277
+ // command, without one) can tell whether a raw commit moved it.
278
+ noteRawCommit(
279
+ input,
280
+ event.command,
281
+ postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
282
+ );
283
+ // Claude Code: a Workit bypass flag always reaches the user, whatever
284
+ // the allow rules match (issue #297). Words, not text, so quoting
285
+ // cannot hide it; a deny above still wins. A leased command runs under
286
+ // `workit lock run`, which no Workit rule allows.
287
+ const bypass =
288
+ !lease && input.host === "claude_code"
289
+ ? workitBypassFlag(event.command, event.dialect)
290
+ : null;
291
+ if (bypass) return { kind: "ask", reason: bypass };
292
+ const rewrittenTo = lease?.command ?? cwdCommand;
293
+ const rewritten = (text: string | null): HookDecision =>
294
+ rewrittenTo
295
+ ? {
296
+ kind: "rewrite",
297
+ command: rewrittenTo,
298
+ input: event.toolInput ? { ...event.toolInput, command: rewrittenTo } : null,
299
+ text,
300
+ }
301
+ : NONE;
302
+ // An in-process plugin shows nudges on the tool result (shellNudge):
303
+ // spending the once-per-session nudge here would lose it.
304
+ if (descriptor.transport !== "hook-process") return rewritten(null);
305
+ // Only an agent no start text told how to run git is nudged (#428).
306
+ const { nudges } = agentSteering(descriptor, input.cwd, {
307
+ type: input.session.agentType,
308
+ id: input.session.agentId,
309
+ });
310
+ const nudge = canDeny ? rawGitPre(input, event.command, { nudge: nudges }) : NONE;
311
+ const nudged = withContextLine(
312
+ withContextLine(nudge, aside),
313
+ shipNudge(input, event.command),
314
+ );
315
+ // A deny always wins over the cwd rewrite.
316
+ if (!lease)
317
+ return cwdCommand && nudged.kind !== "deny"
318
+ ? rewritten(nudged.kind === "context" ? nudged.text : null)
319
+ : nudged;
320
+ return rewritten(
321
+ [rewriteNote(lease), nudged.kind === "context" ? nudged.text : null]
322
+ .filter(Boolean)
323
+ .join("\n"),
324
+ );
325
+ }
326
+ case "shell.post":
327
+ if (usable(descriptor.events["shell.post"].support))
328
+ rawGitPost(input, event.command, callKey(event.toolUseId, event.command));
329
+ return NONE;
330
+ case "write.pre": {
331
+ if (!usable(descriptor.events["write.pre"].support)) return NONE;
332
+ if (implementerAgent(input, descriptor)) {
333
+ const confined = confineImplementer(input, liveTargets(input.cwd, event.paths));
334
+ if (confined.kind === "deny") return confined;
335
+ }
336
+ return writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport));
337
+ }
338
+ case "subagent.start":
339
+ if (workitRole(descriptor, event.agentType) === "judge")
340
+ trackJudge(input, event.agentId, true);
341
+ seedStanding({
342
+ ...input,
343
+ session: { ...input.session, agentId: event.agentId, agentType: event.agentType },
344
+ });
345
+ deps.budget?.start(input, event.agentId, event.agentType);
346
+ return {
347
+ kind: "context",
348
+ text: subagentStartText(input, descriptor, event),
349
+ };
350
+ case "compact.pre":
351
+ return {
352
+ kind: "notice",
353
+ userMessage:
354
+ "Workit context may be stale after compaction; re-run inspection or resume before acting.",
355
+ };
356
+ case "tool.pre":
357
+ // Host permission policy owns other tools; a skill load is recorded.
358
+ if (event.skill) recordSkillLoad(input, event.skill, "tool");
359
+ if (event.agentInput && input.host === "claude_code") {
360
+ const spawn = spawnDecision(descriptor, input.cwd, event.agentInput, input.permissionMode);
361
+ // A spawn the gate lets through queues its tool-call budget, or
362
+ // none; the launcher does so for one its fast path allows.
363
+ if (spawn.kind !== "deny") deps.budget?.spawn(input, event.agentInput);
364
+ return spawn;
365
+ }
366
+ return NONE;
367
+ case "stop":
368
+ return usable(descriptor.events.stop.support) ? stopDecision(input, descriptor) : NONE;
369
+ // A subagent's stop is never blocked; attestation and prompt control
370
+ // arrive with the CLI-observed evidence model.
371
+ case "subagent.stop":
372
+ trackJudge(input, event.agentId, false);
373
+ if (event.agentId) deps.budget?.stop(input, event.agentId);
374
+ if (event.lastMessage) usageLimitOnStop(input, event.agentId, event.lastMessage);
375
+ return NONE;
376
+ case "prompt.submit":
377
+ return NONE;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Fail policy for a hook that cannot be parsed or handled: a pre-tool gate
383
+ * denies only on hosts whose descriptor declares it fail-closed; start events
384
+ * keep a visible diagnostic; everything else fails open.
385
+ */
386
+ export const failureDecision = (
387
+ descriptor: HostDescriptor,
388
+ event: HookEventKind | null,
389
+ error: string,
390
+ ): HookDecision => {
391
+ if (event === "shell.pre" || event === "tool.pre" || event === "write.pre")
392
+ return descriptor.shellPolicy.failClosed
393
+ ? { kind: "deny", reason: error, unblock: null }
394
+ : NONE;
395
+ if (event === "session.start" || event === "subagent.start")
396
+ return { kind: "context", text: `[workit diagnostic: ${error}]` };
397
+ return NONE;
398
+ };
399
+
400
+ /** Parse, handle, and render one native payload. `error` is set on any failure. */
401
+ export const dispatchHook = (
402
+ adapter: HostAdapter & { budget?: BudgetHooks },
403
+ raw: unknown,
404
+ env: NodeJS.ProcessEnv = process.env,
405
+ ): RenderedHook & { error: string | null } => {
406
+ const parsed = adapter.parse(raw, env);
407
+ if (!parsed.ok)
408
+ return {
409
+ ...adapter.render(
410
+ failureDecision(adapter.descriptor, parsed.event, parsed.error),
411
+ parsed.native,
412
+ ),
413
+ error: parsed.error,
414
+ };
415
+ const descriptor = { ...adapter.descriptor, host: parsed.input.host };
416
+ try {
417
+ const decision = handleHook(parsed.input, {
418
+ descriptor,
419
+ addendum: adapter.addendum?.(parsed.input) ?? null,
420
+ budget: adapter.budget,
421
+ });
422
+ return { ...adapter.render(decision, parsed.native), error: null };
423
+ } catch (error) {
424
+ const message = `hook failure: ${String(error)}`;
425
+ return {
426
+ ...adapter.render(
427
+ failureDecision(descriptor, parsed.input.event.kind, message),
428
+ parsed.native,
429
+ ),
430
+ error: message,
431
+ };
432
+ }
433
+ };
@@ -0,0 +1,99 @@
1
+ // The heavy-command gate (issue #325, ruling mvid5onk): after every Workit
2
+ // gate passed on the original command, a raw heavy command (tests, builds,
3
+ // lint, typecheck, installs; core/heavy.ts) runs under the machine-wide
4
+ // heavy-check lease:
5
+ // - one simple heavy command: rewritten to `workit check --heavy -- <cmd>`
6
+ // where the host can replace it (Claude Code, OpenCode, Pi), else denied
7
+ // with that exact command (Codex, Cursor);
8
+ // - a chain with heavy parts: denied, naming each part's lease command
9
+ // (never `bash -c`);
10
+ // - a command a host permission rule names, or one whose wrapper only an
11
+ // allow for `workit` would let through: left alone, with a note;
12
+ // - one formatter or file-scoped linter (check-config.ts lightCommand):
13
+ // left alone, since `workit check` would run it without the lease anyway.
14
+ import {
15
+ claudeRules,
16
+ codexRules,
17
+ cursorRules,
18
+ opencodeRules,
19
+ type RuleRead,
20
+ } from "../core/heavy-rules";
21
+ import { lightCommand } from "../check-config";
22
+ import { heavyPlan, workitOnPath, type HeavyPlan } from "../core/heavy";
23
+ import { maybeHeavy } from "../core/heavy-words";
24
+ import { simpleCommand } from "./raw-git";
25
+ import type { HookDecision, HostId } from "./protocol";
26
+ import type { ShellDialect } from "./shell-words";
27
+
28
+ /** The host's readable permission rules (null where it keeps none the hook could read). */
29
+ const rulesFor = (host: HostId, cwd: string, env: NodeJS.ProcessEnv): RuleRead | null => {
30
+ switch (host) {
31
+ case "claude_code":
32
+ return claudeRules(cwd, env);
33
+ case "opencode":
34
+ return opencodeRules(cwd, env);
35
+ case "codex_cli":
36
+ case "codex_desktop":
37
+ return codexRules(cwd, env);
38
+ case "cursor":
39
+ return cursorRules(cwd, env);
40
+ case "pi":
41
+ return null;
42
+ }
43
+ };
44
+
45
+ export type GateOptions = {
46
+ host: HostId;
47
+ dialect?: ShellDialect;
48
+ /** Claude `permission_mode`: bypassPermissions skips the allowlist check. */
49
+ permissionMode?: string | null;
50
+ env?: NodeJS.ProcessEnv;
51
+ };
52
+
53
+ /**
54
+ * The gate's plan for `command`, run after the Workit gates allowed it.
55
+ * Nothing when no `workit` is on PATH (the wrapper could not run) or the
56
+ * host sets WORKIT_HEAVY_GATE=0, and nothing for one formatter or
57
+ * file-scoped linter. Never throws.
58
+ */
59
+ export function heavyGate(cwd: string, command: string, options: GateOptions): HeavyPlan {
60
+ const env = options.env ?? process.env;
61
+ try {
62
+ // The launchers' cheap word check first: most commands stop here.
63
+ if (env.WORKIT_HEAVY_GATE === "0" || !maybeHeavy(command, cwd, env) || !workitOnPath(env))
64
+ return { kind: "none" };
65
+ const dialect = options.dialect ?? "posix";
66
+ const single = dialect === "posix" ? simpleCommand(command, dialect, 0, true) : null;
67
+ if (single && lightCommand(single, cwd)) return { kind: "none" };
68
+ return heavyPlan(command, {
69
+ cwd,
70
+ env,
71
+ dialect,
72
+ rules: rulesFor(options.host, cwd, env),
73
+ bypass: options.permissionMode === "bypassPermissions",
74
+ });
75
+ } catch {
76
+ return { kind: "none" };
77
+ }
78
+ }
79
+
80
+ /** The model-visible line after a rewrite. */
81
+ export const rewriteNote = (plan: Extract<HeavyPlan, { kind: "rewrite" }>): string =>
82
+ `workit: this heavy command (${plan.reason}) runs under the machine-wide heavy-check lease as \`${plan.command}\`: it waits while another heavy check holds the lease (\`workit lock status\` shows who). Run heavy commands that way yourself; \`workit help lock\` explains the lease.`;
83
+
84
+ /** The deny on hosts that cannot rewrite: the exact command to run instead. */
85
+ export const heavyDenial = (
86
+ original: string,
87
+ plan: Extract<HeavyPlan, { kind: "rewrite" }>,
88
+ ): HookDecision => ({
89
+ kind: "deny",
90
+ reason: `heavy_command: \`${original.trim()}\` is heavy (${plan.reason}) and must run under the machine-wide heavy-check lease, so parallel agents cannot exhaust this machine's memory and CPU. Run exactly: ${plan.command}`,
91
+ unblock: plan.command,
92
+ });
93
+
94
+ /** The deny for a chain with heavy parts. */
95
+ export const splitDenial = (plan: Extract<HeavyPlan, { kind: "split" }>): HookDecision => ({
96
+ kind: "deny",
97
+ reason: plan.reason,
98
+ unblock: plan.unblock,
99
+ });
@@ -0,0 +1,130 @@
1
+ // A tiny record of which hook events each host ran (#433): without it "zero
2
+ // hook events" cannot tell a host that never fires its hooks from one that
3
+ // fires them silently. One small file per host and native event in the state
4
+ // dir (`hook-runs/<host>__<event>.json`: last seen time and a count), written
5
+ // best effort: one read and one atomic-enough write, no spawn, and a
6
+ // read-only state dir never changes the hook's answer. `workit doctor`
7
+ // (`hook_runs`) reads it. Light imports only: the Codex launcher's quiet path
8
+ // calls it before the runtime loads.
9
+ //
10
+ // A hook that could not answer (a Node below the floor, a bundle that throws on
11
+ // import, a runtime crash) records a failure row beside its runs
12
+ // (`<host>__<event>__failed.json`, with the cause). The plain-JavaScript
13
+ // launchers (Claude Code bin/workit-hook.mjs, Cursor hooks/launch-runtime.mjs)
14
+ // write the same file format; a test holds them equal.
15
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { stateDir } from "../core/heavy-words";
18
+
19
+ export const HOOK_RUNS_DIR = "hook-runs";
20
+
21
+ export type HookRun = {
22
+ host: string;
23
+ event: string;
24
+ at: string;
25
+ count: number;
26
+ /** Set on a failure row: why the hook could not answer. */
27
+ failure?: string;
28
+ };
29
+
30
+ export const HOOK_FAILURE_SUFFIX = "__failed";
31
+
32
+ const safe = (part: string): string => part.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 64);
33
+
34
+ const runFile = (dir: string, host: string, event: string, failed = false): string =>
35
+ path.join(
36
+ dir,
37
+ HOOK_RUNS_DIR,
38
+ `${safe(host)}__${safe(event)}${failed ? HOOK_FAILURE_SUFFIX : ""}.json`,
39
+ );
40
+
41
+ /** Counts one run of the native `event` on `host`; never throws. */
42
+ export const recordHookRun = (
43
+ host: string,
44
+ event: string | null | undefined,
45
+ env: NodeJS.ProcessEnv = process.env,
46
+ now: () => number = Date.now,
47
+ ): void => {
48
+ if (event) writeRun(host, event, undefined, env, now);
49
+ };
50
+
51
+ /** Counts one run of `event` on `host` that could not answer, with its cause; never throws. */
52
+ export const recordHookFailure = (
53
+ host: string,
54
+ event: string | null | undefined,
55
+ cause: string,
56
+ env: NodeJS.ProcessEnv = process.env,
57
+ now: () => number = Date.now,
58
+ ): void => writeRun(host, event || "unknown", cause.slice(0, 500), env, now);
59
+
60
+ const writeRun = (
61
+ host: string,
62
+ event: string,
63
+ failure: string | undefined,
64
+ env: NodeJS.ProcessEnv,
65
+ now: () => number,
66
+ ): void => {
67
+ try {
68
+ const dir = stateDir(env);
69
+ const file = runFile(dir, host, event, failure !== undefined);
70
+ let count = 0;
71
+ try {
72
+ const previous = JSON.parse(readFileSync(file, "utf8")) as {
73
+ count?: unknown;
74
+ };
75
+ if (typeof previous.count === "number" && Number.isFinite(previous.count))
76
+ count = previous.count;
77
+ } catch {
78
+ // First run, or a torn file: start again.
79
+ }
80
+ const row: HookRun = {
81
+ host,
82
+ event,
83
+ at: new Date(now()).toISOString(),
84
+ count: count + 1,
85
+ ...(failure === undefined ? {} : { failure }),
86
+ };
87
+ try {
88
+ writeFileSync(file, JSON.stringify(row));
89
+ } catch {
90
+ mkdirSync(path.dirname(file), { recursive: true });
91
+ writeFileSync(file, JSON.stringify(row));
92
+ }
93
+ } catch {
94
+ // The hook still answers the host; only the doctor line is lost.
95
+ }
96
+ };
97
+
98
+ /** Every recorded run under `stateDir`; unreadable or malformed files are skipped. */
99
+ export const readHookRuns = (dir: string): HookRun[] => {
100
+ const runs: HookRun[] = [];
101
+ let names: string[] = [];
102
+ try {
103
+ names = readdirSync(path.join(dir, HOOK_RUNS_DIR));
104
+ } catch {
105
+ return runs;
106
+ }
107
+ for (const name of names) {
108
+ try {
109
+ const row = JSON.parse(
110
+ readFileSync(path.join(dir, HOOK_RUNS_DIR, name), "utf8"),
111
+ ) as Partial<HookRun>;
112
+ if (
113
+ typeof row.host === "string" &&
114
+ typeof row.event === "string" &&
115
+ typeof row.at === "string" &&
116
+ typeof row.count === "number"
117
+ )
118
+ runs.push({
119
+ host: row.host,
120
+ event: row.event,
121
+ at: row.at,
122
+ count: row.count,
123
+ ...(typeof row.failure === "string" ? { failure: row.failure } : {}),
124
+ });
125
+ } catch {
126
+ // Not a run record.
127
+ }
128
+ }
129
+ return runs;
130
+ };