@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,419 @@
1
+ // Cursor: command hooks (hooks/hooks-cursor.json) mapped onto the protocol.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type { HostDescriptor } from "../descriptor";
5
+ import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
6
+ import { existingDirectory, isRecord, isWriteTool, nonEmpty, writeTargets } from "./fields";
7
+
8
+ export type CursorHookEvent =
9
+ | "sessionStart"
10
+ | "preToolUse"
11
+ | "beforeShellExecution"
12
+ | "subagentStart"
13
+ | "subagentStop"
14
+ | "preCompact"
15
+ | "stop";
16
+
17
+ export type CursorHookInput = {
18
+ hook_event_name: CursorHookEvent;
19
+ conversation_id?: string;
20
+ session_id?: string;
21
+ workspace_roots: string[];
22
+ tool_name?: string;
23
+ tool_input?: unknown;
24
+ command?: string;
25
+ cwd?: string;
26
+ subagent_id?: string;
27
+ subagent_type?: string;
28
+ parent_conversation_id?: string;
29
+ task?: string;
30
+ status?: "completed" | "error" | "aborted";
31
+ loop_count?: number;
32
+ transcript_path?: string;
33
+ };
34
+
35
+ export type CursorParseResult = { ok: true; data: CursorHookInput } | { ok: false; error: string };
36
+
37
+ const undocumented = { support: "undocumented", native: null } as const;
38
+ const none = { support: "none", native: null } as const;
39
+
40
+ export const CURSOR_DESCRIPTOR: HostDescriptor = {
41
+ host: "cursor",
42
+ label: "Cursor",
43
+ verifiedAgainst: "Cursor hooks docs; payloads in test/fixtures/hooks/cursor",
44
+ docs: ["https://cursor.com/docs/agent/hooks"],
45
+ transport: "hook-process",
46
+ events: {
47
+ "session.start": { support: "native", native: "sessionStart" },
48
+ "context.turn": none,
49
+ "shell.pre": { support: "native", native: "beforeShellExecution" },
50
+ "tool.pre": { support: "native", native: "preToolUse" },
51
+ // preToolUse write tools; the file path is read from tool_input when present.
52
+ "write.pre": { support: "partial", native: "preToolUse" },
53
+ "shell.post": undocumented,
54
+ "subagent.start": { support: "native", native: "subagentStart" },
55
+ // subagentStop carries no stable child identity.
56
+ "subagent.stop": { support: "partial", native: "subagentStop" },
57
+ "prompt.submit": undocumented,
58
+ // preCompact can only show a user message.
59
+ "compact.pre": { support: "partial", native: "preCompact" },
60
+ // stop (cursor-agent 2026.09.26): `{"followup_message"}` queues one more
61
+ // user turn; the input carries loop_count and transcript_path, not the
62
+ // last message (read from transcript_path). Registered with
63
+ // `loop_limit: 1`, so the continuation's own stop never runs the hook.
64
+ stop: { support: "native", native: "stop" },
65
+ },
66
+ // Fails open like every host: a payload Workit cannot parse (a renamed
67
+ // field in a Cursor release) must not block every shell command.
68
+ shellPolicy: { deny: "native", channel: "exit2+json", failClosed: false, rewrite: "none" },
69
+ context: {
70
+ sessionStart: "native",
71
+ perTurn: "none",
72
+ afterCompact: "partial",
73
+ task: "single-active",
74
+ turnResend: "every-turn",
75
+ },
76
+ subagents: {
77
+ identity: "native",
78
+ parentBinding: "native",
79
+ blockStart: "native",
80
+ worktreeIsolation: "undocumented",
81
+ maxConcurrency: "undocumented",
82
+ agentPrefix: "workit-",
83
+ },
84
+ provenance: {
85
+ sessionId: "native",
86
+ agentIdOnTool: "undocumented",
87
+ postToolObserve: "undocumented",
88
+ },
89
+ interaction: { questions: "none", writeBoundary: "partial" },
90
+ stopControl: "native",
91
+ shellAvailable: "native",
92
+ // Hooks run the plugin's launcher against a local runtime (bundled dist or a
93
+ // global bin); only an install with neither falls back to a pinned,
94
+ // --prefer-offline npx.
95
+ perEventCost: "low",
96
+ capabilities: [
97
+ {
98
+ name: "known_product_writes",
99
+ surface: "preToolUse",
100
+ refs: ["preToolUse"],
101
+ requires: [],
102
+ assurance: "unavailable",
103
+ reason:
104
+ "file writes are host-policy; the Cursor hook no longer gates write tools or shell commands",
105
+ },
106
+ {
107
+ name: "native_subagents",
108
+ surface: "subagentStart/subagentStop",
109
+ refs: ["subagentStart", "subagentStop"],
110
+ requires: ["event:subagent.start", "event:subagent.stop"],
111
+ observed: ["subagent.start", "subagent.stop"],
112
+ assurance: "agent_guided",
113
+ reason:
114
+ "reviewer/investigator starts are bounded; Cursor implementer delegation is unavailable and subagentStop lacks a stable child identity",
115
+ unavailableReason: "Cursor native subagent lifecycle hooks are incomplete",
116
+ },
117
+ {
118
+ name: "native_subagent_start",
119
+ surface: "subagentStart",
120
+ refs: ["subagentStart"],
121
+ requires: ["event:subagent.start", "subagents.blockStart"],
122
+ observed: ["subagent.start"],
123
+ assurance: "enforced",
124
+ reason:
125
+ "Cursor subagentStart enforces explicit reviewer/investigator markers; implementer delegation is unavailable",
126
+ unavailableReason: "Cursor subagentStart is absent",
127
+ },
128
+ {
129
+ name: "fresh-context-review",
130
+ surface: "subagentStart",
131
+ refs: ["subagentStart"],
132
+ requires: ["event:subagent.start"],
133
+ observed: ["subagent.start"],
134
+ assurance: "agent_guided",
135
+ reason:
136
+ "independent review runs as a bounded reviewer subagent; stops lack stable identity, so reviewer exclusivity is evaluated from recorded evidence",
137
+ unavailableReason: "Cursor subagentStart is unavailable for independent review",
138
+ },
139
+ {
140
+ name: "arbitrary_shell_write",
141
+ surface: "unobservable_shell",
142
+ refs: ["beforeShellExecution"],
143
+ requires: [],
144
+ assurance: "unavailable",
145
+ reason:
146
+ "Only explicitly parsed shell targets are interceptable; arbitrary shell writes are not provable",
147
+ },
148
+ {
149
+ name: "compact_context",
150
+ surface: "sessionStart/preCompact",
151
+ refs: ["sessionStart", "preCompact"],
152
+ requires: ["context.sessionStart"],
153
+ observed: ["session.start"],
154
+ assurance: "agent_guided",
155
+ reason: "sessionStart injects context; preCompact can only show a bounded user reminder",
156
+ },
157
+ ],
158
+ };
159
+
160
+ const EVENTS: Record<CursorHookEvent, HookEventKind> = {
161
+ sessionStart: "session.start",
162
+ preToolUse: "tool.pre",
163
+ beforeShellExecution: "shell.pre",
164
+ subagentStart: "subagent.start",
165
+ subagentStop: "subagent.stop",
166
+ preCompact: "compact.pre",
167
+ stop: "stop",
168
+ };
169
+
170
+ /** Validate the keys each Cursor event needs; unknown keys are ignored. */
171
+ export const parseCursorHookInput = (value: unknown): CursorParseResult => {
172
+ if (!isRecord(value) || !Object.hasOwn(EVENTS, String(value.hook_event_name)))
173
+ return { ok: false, error: "hook_event_name is required" };
174
+ const roots = value.workspace_roots;
175
+ if (!Array.isArray(roots) || roots.length === 0 || !roots.every(nonEmpty))
176
+ return { ok: false, error: "a workspace root is required" };
177
+ const canonical = roots.map(existingDirectory);
178
+ if (!canonical.every((root): root is string => root !== null))
179
+ return { ok: false, error: "workspace roots must be existing absolute paths" };
180
+ const event = value.hook_event_name as CursorHookEvent;
181
+ const conversationId = nonEmpty(value.conversation_id) ? value.conversation_id : undefined;
182
+ const sessionId = nonEmpty(value.session_id) ? value.session_id : undefined;
183
+ if (conversationId && sessionId && conversationId !== sessionId)
184
+ return { ok: false, error: "conversation_id and session_id must match" };
185
+ if (event !== "preCompact" && !nonEmpty(conversationId ?? sessionId))
186
+ return { ok: false, error: "conversation/session identity is required" };
187
+ if (
188
+ (event === "preToolUse" || event === "beforeShellExecution") &&
189
+ !nonEmpty(value.tool_name ?? value.command)
190
+ )
191
+ return { ok: false, error: "tool or command is required" };
192
+ if (
193
+ event === "subagentStart" &&
194
+ (!nonEmpty(value.subagent_id) || !nonEmpty(value.parent_conversation_id))
195
+ )
196
+ return { ok: false, error: "subagent identity and parent session are required" };
197
+ return {
198
+ ok: true,
199
+ data: {
200
+ hook_event_name: event,
201
+ workspace_roots: canonical,
202
+ ...(nonEmpty(value.conversation_id) ? { conversation_id: value.conversation_id } : {}),
203
+ ...(nonEmpty(value.session_id) ? { session_id: value.session_id } : {}),
204
+ ...(nonEmpty(value.tool_name) ? { tool_name: value.tool_name } : {}),
205
+ ...(value.tool_input !== undefined ? { tool_input: value.tool_input } : {}),
206
+ ...(nonEmpty(value.command) ? { command: value.command } : {}),
207
+ ...(nonEmpty(value.cwd) ? { cwd: value.cwd } : {}),
208
+ ...(event === "subagentStart" && nonEmpty(value.subagent_id)
209
+ ? { subagent_id: value.subagent_id }
210
+ : {}),
211
+ ...(event === "subagentStart" && nonEmpty(value.subagent_type)
212
+ ? { subagent_type: value.subagent_type }
213
+ : {}),
214
+ ...(event === "subagentStart" && nonEmpty(value.parent_conversation_id)
215
+ ? { parent_conversation_id: value.parent_conversation_id }
216
+ : {}),
217
+ ...(event === "subagentStart" && nonEmpty(value.task) ? { task: value.task } : {}),
218
+ ...(event === "stop"
219
+ ? {
220
+ ...(nonEmpty(value.status)
221
+ ? { status: value.status as CursorHookInput["status"] }
222
+ : {}),
223
+ ...(typeof value.loop_count === "number" ? { loop_count: value.loop_count } : {}),
224
+ ...(nonEmpty(value.transcript_path) ? { transcript_path: value.transcript_path } : {}),
225
+ }
226
+ : {}),
227
+ },
228
+ };
229
+ };
230
+
231
+ const protocolEvent = (input: CursorHookInput): HookEvent => {
232
+ switch (input.hook_event_name) {
233
+ case "sessionStart":
234
+ // Cursor reports no start source; every sessionStart is a fresh conversation.
235
+ return { kind: "session.start", source: "startup" };
236
+ case "beforeShellExecution":
237
+ // Branch policy reads the command alone; the shell's cwd does not change
238
+ // which branch name it targets.
239
+ return { kind: "shell.pre", command: input.command ?? "", toolUseId: null };
240
+ case "preToolUse":
241
+ return isWriteTool(input.tool_name)
242
+ ? {
243
+ kind: "write.pre",
244
+ tool: input.tool_name ?? "",
245
+ paths: writeTargets(input.tool_input),
246
+ toolUseId: null,
247
+ }
248
+ : { kind: "tool.pre", tool: input.tool_name ?? "", toolUseId: null };
249
+ case "subagentStart":
250
+ return {
251
+ kind: "subagent.start",
252
+ agentId: input.subagent_id!,
253
+ agentType: input.subagent_type ?? "",
254
+ task: input.task ?? null,
255
+ };
256
+ case "subagentStop":
257
+ return {
258
+ kind: "subagent.stop",
259
+ agentId: null,
260
+ agentType: null,
261
+ lastMessage: null,
262
+ stopHookActive: false,
263
+ };
264
+ case "preCompact":
265
+ return { kind: "compact.pre", trigger: "auto" };
266
+ case "stop":
267
+ return {
268
+ kind: "stop",
269
+ // Only a completed turn is continued; an aborted or failed one stops.
270
+ lastMessage:
271
+ (input.status ?? "completed") === "completed"
272
+ ? lastAssistantText(input.transcript_path)
273
+ : null,
274
+ // A followup_message turn reports loop_count > 0: one continue per turn.
275
+ stopHookActive: (input.loop_count ?? 0) > 0,
276
+ };
277
+ }
278
+ };
279
+
280
+ /** The transcript tail read for the last assistant message. */
281
+ const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
282
+
283
+ /**
284
+ * The text of the last assistant entry in a Cursor agent transcript
285
+ * (`{"role":"assistant","message":{"content":[{"type":"text","text":…}]}}`
286
+ * per line), or null when it cannot be read.
287
+ */
288
+ export const lastAssistantText = (file: string | null | undefined): string | null => {
289
+ if (!file || !path.isAbsolute(file)) return null;
290
+ try {
291
+ const size = fs.statSync(file).size;
292
+ const start = Math.max(0, size - TRANSCRIPT_TAIL_BYTES);
293
+ const buffer = Buffer.alloc(size - start);
294
+ const fd = fs.openSync(file, "r");
295
+ try {
296
+ fs.readSync(fd, buffer, 0, buffer.length, start);
297
+ } finally {
298
+ fs.closeSync(fd);
299
+ }
300
+ const lines = buffer.toString("utf8").split("\n");
301
+ for (let index = lines.length - 1; index >= 0; index--) {
302
+ let entry: unknown;
303
+ try {
304
+ entry = JSON.parse(lines[index]);
305
+ } catch {
306
+ continue;
307
+ }
308
+ if (!isRecord(entry) || entry.role !== "assistant") continue;
309
+ const content = isRecord(entry.message) ? entry.message.content : entry.content;
310
+ if (typeof content === "string") return content;
311
+ if (!Array.isArray(content)) return null;
312
+ const text = content
313
+ .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
314
+ .map((part) => (part as { text: string }).text)
315
+ .join("\n");
316
+ return text || null;
317
+ }
318
+ return null;
319
+ } catch {
320
+ return null;
321
+ }
322
+ };
323
+
324
+ const within = (root: string, dir: string) =>
325
+ dir === root || dir.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`);
326
+
327
+ /**
328
+ * Where a hook acts. Shell policy follows the command's own `cwd` (in a
329
+ * multi-root workspace each root is its own repository); everything else
330
+ * uses the workspace root that contains `cwd`, else the first root.
331
+ */
332
+ const hookCwd = (input: CursorHookInput): string => {
333
+ const cwd = existingDirectory(input.cwd);
334
+ if (cwd && input.hook_event_name === "beforeShellExecution") return cwd;
335
+ return cursorWorkspaceRoot(input);
336
+ };
337
+
338
+ /** The workspace root that contains the payload `cwd`, else the first root. */
339
+ export const cursorWorkspaceRoot = (input: CursorHookInput): string => {
340
+ const cwd = existingDirectory(input.cwd);
341
+ return (
342
+ (cwd && input.workspace_roots.find((root) => within(root, cwd))) || input.workspace_roots[0]
343
+ );
344
+ };
345
+
346
+ /** Cursor's documented deny payload; blocking events also exit 2. */
347
+ export const cursorDeny = (reason: string) => ({
348
+ permission: "deny" as const,
349
+ user_message: "Workit blocked this action",
350
+ agent_message: reason,
351
+ });
352
+ const BLOCKING = new Set(["preToolUse", "beforeShellExecution", "subagentStart"]);
353
+
354
+ const render = (decision: HookDecision, native: string | null) => {
355
+ if (decision.kind === "deny")
356
+ return {
357
+ json: cursorDeny(decision.reason),
358
+ exitCode: native !== null && BLOCKING.has(native) ? 2 : 0,
359
+ };
360
+ if (decision.kind === "ask" && native !== null && BLOCKING.has(native))
361
+ return {
362
+ json: { permission: "ask", user_message: decision.reason, agent_message: decision.reason },
363
+ exitCode: 0,
364
+ };
365
+ // beforeShellExecution has no context channel: a raw-git nudge rides on
366
+ // agent_message with no permission answer, so Cursor's own permissions
367
+ // decide the command (#635).
368
+ if (decision.kind === "context" && native === "beforeShellExecution")
369
+ return { json: { agent_message: decision.text }, exitCode: 0 };
370
+ if (decision.kind === "context")
371
+ return { json: { additional_context: decision.text }, exitCode: 0 };
372
+ if (decision.kind === "notice")
373
+ return { json: { user_message: decision.userMessage }, exitCode: 0 };
374
+ if (decision.kind === "continue" && native === "stop")
375
+ return { json: { followup_message: decision.reason }, exitCode: 0 };
376
+ // Workit answers only what it decides (deny, ask); anything else omits
377
+ // `permission`, which cursor-agent (2026.09.26) treats like no hook at all
378
+ // and the user's allowlist and auto-run settings decide (#635).
379
+ return { json: {}, exitCode: 0 };
380
+ };
381
+
382
+ export const cursorAdapter: HostAdapter = {
383
+ descriptor: CURSOR_DESCRIPTOR,
384
+ parse(raw) {
385
+ const parsed = parseCursorHookInput(raw);
386
+ if (!parsed.ok) {
387
+ const native =
388
+ isRecord(raw) && Object.hasOwn(EVENTS, String(raw.hook_event_name))
389
+ ? (raw.hook_event_name as CursorHookEvent)
390
+ : null;
391
+ return { ok: false, error: parsed.error, native, event: native ? EVENTS[native] : null };
392
+ }
393
+ const input = parsed.data;
394
+ return {
395
+ ok: true,
396
+ native: input.hook_event_name,
397
+ input: {
398
+ host: "cursor",
399
+ cwd: hookCwd(input),
400
+ session: {
401
+ id: input.session_id ?? input.conversation_id ?? "",
402
+ agentId: input.subagent_id ?? null,
403
+ agentType: input.subagent_type ?? null,
404
+ parentId: input.parent_conversation_id ?? null,
405
+ },
406
+ permissionMode: null,
407
+ transcriptPath: null,
408
+ event: protocolEvent(input),
409
+ },
410
+ };
411
+ },
412
+ render,
413
+ // Cursor's shell carries no conversation id (sessionStart env reaches hooks
414
+ // only), so the agent is told the id its `workit` calls should act as.
415
+ addendum: (input) =>
416
+ input.session.id
417
+ ? `<workit-session>Cursor puts no session id in the shell: start workit commands that record authorship with the global flag \`--session ${input.session.id}\` (e.g. \`workit --session ${input.session.id} git commit -m …\`; a leading env assignment can miss the \`Shell(workit)\` allowlist). A verifier uses its own id, never this one.</workit-session>`
418
+ : null,
419
+ };
@@ -0,0 +1,98 @@
1
+ // Lenient field readers for native hook payloads (D17): unknown keys are
2
+ // ignored, and only the keys a mapping needs are checked.
3
+ import { existsSync, realpathSync, statSync } from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
7
+ value !== null && typeof value === "object" && !Array.isArray(value);
8
+
9
+ export const nonEmpty = (value: unknown): value is string =>
10
+ typeof value === "string" && value.trim() !== "";
11
+
12
+ export const optionalText = (value: unknown): string | null =>
13
+ typeof value === "string" ? value : null;
14
+
15
+ /** The canonical path of an existing absolute directory, or null. */
16
+ export const existingDirectory = (value: unknown): string | null => {
17
+ if (!nonEmpty(value) || !path.isAbsolute(value) || !existsSync(value)) return null;
18
+ try {
19
+ return statSync(value).isDirectory() ? realpathSync(value) : null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "pwsh", "powershell", "cmd"]);
26
+ /** `-c`, `-lc`, `-ec`…, PowerShell `-Command`, and cmd `/c`. */
27
+ const SCRIPT_FLAG = /^(?:-\w*c|-command|\/c)$/i;
28
+ const quoteArg = (arg: string) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : JSON.stringify(arg));
29
+
30
+ /**
31
+ * A shell command as one string. Hosts send either a string or an argv array.
32
+ * For a shell wrapper (`[bash, -e, -c, script]`, `[powershell.exe, -Command,
33
+ * script]`, `[cmd, /c, script]`) the script after the first script flag is
34
+ * the command; other arrays are joined with quoting so one argument never splits.
35
+ */
36
+ export const commandText = (value: unknown): string | null => {
37
+ if (nonEmpty(value)) return value;
38
+ if (!Array.isArray(value) || value.length === 0) return null;
39
+ const argv: unknown[] = value;
40
+ if (!argv.every((arg): arg is string => typeof arg === "string")) return null;
41
+ const shell =
42
+ argv[0]
43
+ .split(/[\\/]/)
44
+ .at(-1)
45
+ ?.replace(/\.exe$/i, "")
46
+ .toLowerCase() ?? "";
47
+ if (SHELLS.has(shell))
48
+ for (let index = 1; index < argv.length - 1; index++) {
49
+ if (SCRIPT_FLAG.test(argv[index])) return nonEmpty(argv[index + 1]) ? argv[index + 1] : null;
50
+ // Other options may precede the script flag; a positional ends the scan.
51
+ if (!/^[-/]/.test(argv[index])) break;
52
+ }
53
+ const joined = argv.map(quoteArg).join(" ");
54
+ return nonEmpty(joined) ? joined : null;
55
+ };
56
+
57
+ const WRITE_TOOLS = new Set([
58
+ "write",
59
+ "edit",
60
+ "multiedit",
61
+ "notebookedit",
62
+ "applypatch",
63
+ "patch",
64
+ "delete",
65
+ "remove",
66
+ "rename",
67
+ "mkdir",
68
+ "mv",
69
+ "cp",
70
+ "touch",
71
+ "strreplace",
72
+ "searchreplace",
73
+ "editfile",
74
+ "writefile",
75
+ ]);
76
+ /** A host tool that writes files (Claude Edit/Write, Cursor Write/Delete, apply_patch…). */
77
+ export const isWriteTool = (name: unknown): boolean =>
78
+ typeof name === "string" && WRITE_TOOLS.has(name.toLowerCase().replace(/[_-]/g, ""));
79
+
80
+ const PATH_KEYS = ["file_path", "filePath", "notebook_path", "path", "target_file", "file"];
81
+ /** apply_patch file headers. Codex trims patch lines, so an indented header still applies. */
82
+ const PATCH_HEADER = /^[ \t]*\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$/gm;
83
+
84
+ /** The files a write tool names: its path fields, or the files of an apply_patch body. */
85
+ export const writePaths = (input: unknown): string[] => {
86
+ if (!isRecord(input)) return [];
87
+ const paths = PATH_KEYS.map((key) => input[key]).filter(nonEmpty);
88
+ for (const value of Object.values(input))
89
+ if (typeof value === "string")
90
+ for (const match of value.matchAll(PATCH_HEADER)) paths.push(match[1].trim());
91
+ return [...new Set(paths)];
92
+ };
93
+
94
+ /** A write tool's targets for the before-write gate: null (unknown, so gated) when none parse. */
95
+ export const writeTargets = (input: unknown): string[] | null => {
96
+ const paths = writePaths(input);
97
+ return paths.length > 0 ? paths : null;
98
+ };
@@ -0,0 +1,79 @@
1
+ // OpenCode V2: an in-process plugin; hooks call core/hooks functions directly.
2
+ import type { HostDescriptor } from "../descriptor";
3
+
4
+ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
5
+ host: "opencode",
6
+ label: "OpenCode",
7
+ verifiedAgainst: "@opencode/plugin 2.0.18",
8
+ docs: ["https://opencode.ai/v2/docs/build/plugins/"],
9
+ transport: "in-process-plugin",
10
+ events: {
11
+ // No separate start event: context is injected on every agent-loop call.
12
+ "session.start": { support: "none", native: null },
13
+ "context.turn": { support: "native", native: 'session.hook("context")' },
14
+ "shell.pre": { support: "native", native: 'permission.hook("evaluate")' },
15
+ "tool.pre": { support: "native", native: 'tool.hook("execute.before")' },
16
+ "write.pre": { support: "native", native: 'permission.hook("evaluate")' },
17
+ "shell.post": { support: "native", native: 'tool.hook("execute.after")' },
18
+ "subagent.start": { support: "native", native: 'tool.hook("execute.before") subagent' },
19
+ "subagent.stop": { support: "native", native: 'tool.hook("execute.after") subagent' },
20
+ "prompt.submit": { support: "undocumented", native: null },
21
+ "compact.pre": { support: "native", native: 'session.hook("compaction")' },
22
+ // No stop hook: the plugin's event subscription sees session.idle and
23
+ // resumes the session with `ctx.session.synthetic({resume: true})`
24
+ // (@opencode/client 2.0.18). Partial: the continuation lands after the
25
+ // session went idle, and resume semantics are inferred from the schema.
26
+ stop: { support: "partial", native: 'event("session.idle")' },
27
+ },
28
+ shellPolicy: { deny: "native", channel: "effect", failClosed: false, rewrite: "native" },
29
+ context: {
30
+ sessionStart: "none",
31
+ perTurn: "native",
32
+ afterCompact: "native",
33
+ task: "session-bound",
34
+ // The context hook rebuilds the system prompt on every agent-loop call.
35
+ turnResend: "every-turn",
36
+ },
37
+ subagents: {
38
+ identity: "native",
39
+ parentBinding: "native",
40
+ blockStart: "native",
41
+ worktreeIsolation: "undocumented",
42
+ maxConcurrency: "undocumented",
43
+ agentPrefix: "workit-",
44
+ },
45
+ provenance: { sessionId: "native", agentIdOnTool: "native", postToolObserve: "native" },
46
+ interaction: { questions: "native", writeBoundary: "partial" },
47
+ stopControl: "partial",
48
+ shellAvailable: "native",
49
+ perEventCost: "low",
50
+ capabilities: [
51
+ {
52
+ name: "known_product_writes",
53
+ surface: "edit/shell",
54
+ refs: ["permission.evaluate"],
55
+ requires: [],
56
+ assurance: "unavailable",
57
+ reason:
58
+ "file writes are host-policy; OpenCode native permissions govern them, workit no longer gates write tools",
59
+ },
60
+ {
61
+ name: "direct_child_workers",
62
+ surface: "subagent",
63
+ refs: ["subagent"],
64
+ requires: ["subagents.parentBinding", "subagents.blockStart"],
65
+ assurance: "enforced",
66
+ reason:
67
+ "nested subagent launches are denied and observed child sessions are parent-bound before they may own a worker",
68
+ },
69
+ {
70
+ name: "fresh-context-review",
71
+ surface: "subagent",
72
+ refs: ["subagent"],
73
+ requires: ["event:subagent.start"],
74
+ assurance: "agent_guided",
75
+ reason:
76
+ "independent review runs as a native child session; evidence evaluation enforces creator and duplicate-reviewer exclusion",
77
+ },
78
+ ],
79
+ };
@@ -0,0 +1,78 @@
1
+ // Pi: an in-process extension; its events call core/hooks functions directly.
2
+ import type { HostDescriptor } from "../descriptor";
3
+
4
+ export const PI_DESCRIPTOR: HostDescriptor = {
5
+ host: "pi",
6
+ label: "Pi",
7
+ verifiedAgainst: "@earendil-works/pi-coding-agent 1.4.0",
8
+ docs: ["https://github.com/earendil-works/pi-coding-agent"],
9
+ transport: "in-process-plugin",
10
+ events: {
11
+ "session.start": { support: "native", native: "session_start" },
12
+ "context.turn": { support: "native", native: "before_agent_start" },
13
+ "shell.pre": { support: "native", native: "tool_call" },
14
+ "tool.pre": { support: "native", native: "tool_call" },
15
+ "write.pre": { support: "native", native: "tool_call" },
16
+ "shell.post": { support: "native", native: "tool_result" },
17
+ // Workers are supervised stock-Pi processes, not host subagents.
18
+ "subagent.start": { support: "none", native: null },
19
+ "subagent.stop": { support: "none", native: null },
20
+ "prompt.submit": { support: "undocumented", native: null },
21
+ "compact.pre": { support: "native", native: "session_before_compact" },
22
+ // agent_end carries the run's messages; pi.sendMessage(…, {triggerTurn})
23
+ // starts one more turn (pi-coding-agent 1.4.0 types.d.ts). The host has
24
+ // no loop guard: the extension continues at most once per run.
25
+ stop: { support: "native", native: "agent_end" },
26
+ },
27
+ shellPolicy: { deny: "native", channel: "block", failClosed: false, rewrite: "native" },
28
+ context: {
29
+ sessionStart: "native",
30
+ // before_agent_start runs every turn; its message stays in the session,
31
+ // so the extension resends the task context only when it changed.
32
+ perTurn: "native",
33
+ afterCompact: "native",
34
+ task: "session-bound",
35
+ turnResend: "on-change",
36
+ },
37
+ subagents: {
38
+ identity: "none",
39
+ parentBinding: "none",
40
+ blockStart: "none",
41
+ worktreeIsolation: "none",
42
+ maxConcurrency: "undocumented",
43
+ agentPrefix: "workit-",
44
+ },
45
+ provenance: { sessionId: "native", agentIdOnTool: "none", postToolObserve: "native" },
46
+ interaction: { questions: "native", writeBoundary: "native" },
47
+ stopControl: "native",
48
+ shellAvailable: "native",
49
+ perEventCost: "low",
50
+ capabilities: [
51
+ {
52
+ name: "product_write_interception",
53
+ surface: "write/edit tool_call",
54
+ refs: ["tool_call"],
55
+ requires: ["interaction.writeBoundary"],
56
+ assurance: "enforced",
57
+ reason:
58
+ "Pi exposes a before-tool boundary for known built-in write tools; it enforces project trust while file targets stay host-policy.",
59
+ },
60
+ {
61
+ name: "arbitrary_shell_write",
62
+ surface: "bash",
63
+ refs: ["tool_call"],
64
+ requires: ["shellAvailable"],
65
+ assurance: "agent_guided",
66
+ reason: "Pi extensions do not sandbox arbitrary shell commands.",
67
+ },
68
+ {
69
+ name: "fresh-context-review",
70
+ surface: "supervised_worker",
71
+ refs: ["worker"],
72
+ requires: [],
73
+ assurance: "agent_guided",
74
+ reason:
75
+ "independent review runs as a supervised stock-Pi process; evidence evaluation enforces reviewer exclusivity",
76
+ },
77
+ ],
78
+ };