@brainervirus/workit-core 8.1.1 → 8.4.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.
@@ -140,6 +140,28 @@ export type MergeResult = {
140
140
  mergeSha: string | null;
141
141
  };
142
142
 
143
+ /** The editable fields of a PR/MR (`workit pr ready|edit`). */
144
+ export type PrMeta = {
145
+ number: number;
146
+ url: string;
147
+ state: PrState;
148
+ draft: boolean;
149
+ title: string;
150
+ base: string;
151
+ headBranch: string;
152
+ };
153
+
154
+ /** What `workit pr edit` changes; unset fields are left alone. */
155
+ export type PrEdit = {
156
+ title?: string;
157
+ body?: string;
158
+ base?: string;
159
+ addLabels: string[];
160
+ removeLabels: string[];
161
+ /** Logins; on GitHub `org/team` requests a team. */
162
+ addReviewers: string[];
163
+ };
164
+
143
165
  /** A repository as the forge sees it: id and the repo it was forked from. */
144
166
  export type RepoInfo = { id: number | null; parent: string | null };
145
167
 
@@ -163,4 +185,14 @@ export interface Forge {
163
185
  merge(n: number, options: { sha: string; method: MergeMethod }): ForgeResult<MergeResult>;
164
186
  /** Retarget a PR/MR to another base branch (S12 stacks). */
165
187
  updateBase(n: number, base: string): ForgeResult<void>;
188
+ /** Title, draft flag, base and state of one PR/MR. */
189
+ prMeta(n: number): ForgeResult<PrMeta>;
190
+ /** Mark a draft ready (`draft: false`) or convert it back to a draft. */
191
+ setDraft(n: number, draft: boolean): ForgeResult<void>;
192
+ editPr(n: number, edit: PrEdit): ForgeResult<void>;
193
+ /** Reply to a review thread (GitHub review thread id, GitLab discussion id). */
194
+ replyThread(n: number, thread: string, body: string): ForgeResult<{ url: string | null }>;
195
+ resolveThread(n: number, thread: string): ForgeResult<void>;
196
+ /** One review thread of PR `n` read directly: null when it is not on that PR. */
197
+ threadState(n: number, thread: string): ForgeResult<"open" | "resolved" | null>;
166
198
  }
@@ -2,6 +2,14 @@
2
2
  import { sessionContextText, turnContextText } from "./context";
3
3
  import type { HostDescriptor, Support } from "./descriptor";
4
4
  import { shellPolicy } from "./policy";
5
+ import {
6
+ callKey,
7
+ NEXT_COMMAND,
8
+ noteRawCommit,
9
+ rawGitPost,
10
+ rawGitPre,
11
+ settlePendingCommit,
12
+ } from "./raw-git";
5
13
  import { shellWrites, writeGate } from "./write-gate";
6
14
  import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
7
15
 
@@ -74,13 +82,32 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
74
82
  return text ? { kind: "context", text } : NONE;
75
83
  }
76
84
  case "shell.pre": {
77
- const policy = usable(descriptor.shellPolicy.deny)
78
- ? shellPolicy(input.cwd, event.command)
79
- : NONE;
80
- if (policy.kind !== "none" || !usable(descriptor.events["write.pre"].support)) return policy;
81
- const writes = shellWrites(event.command);
82
- return writes.writes ? writeGate(input.cwd, writes.targets) : NONE;
85
+ const canDeny = usable(descriptor.shellPolicy.deny);
86
+ const policy = canDeny ? shellPolicy(input.cwd, event.command) : NONE;
87
+ if (policy.kind !== "none") return policy;
88
+ // Without a post-tool event, the previous raw commit is recorded now.
89
+ const postTool = usable(descriptor.events["shell.post"].support);
90
+ if (!postTool) settlePendingCommit(input);
91
+ const raw = canDeny ? rawGitPre(input, event.command) : NONE;
92
+ if (raw.kind === "deny") return raw;
93
+ const writes = usable(descriptor.events["write.pre"].support)
94
+ ? shellWrites(event.command)
95
+ : { writes: false, targets: null };
96
+ const gate = writes.writes ? writeGate(input.cwd, writes.targets) : NONE;
97
+ if (gate.kind === "deny") return gate;
98
+ // The command will run: note HEAD so the post-tool hook (or the next
99
+ // command, without one) can tell whether a raw commit moved it.
100
+ noteRawCommit(
101
+ input,
102
+ event.command,
103
+ postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
104
+ );
105
+ return raw;
83
106
  }
107
+ case "shell.post":
108
+ if (usable(descriptor.events["shell.post"].support))
109
+ rawGitPost(input, event.command, callKey(event.toolUseId, event.command));
110
+ return NONE;
84
111
  case "write.pre":
85
112
  return usable(descriptor.events["write.pre"].support)
86
113
  ? writeGate(input.cwd, event.paths)
@@ -99,7 +126,6 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
99
126
  // Host permission policy owns other tools; attestation, prompt and stop
100
127
  // control arrive with the CLI-observed evidence model.
101
128
  case "tool.pre":
102
- case "shell.post":
103
129
  case "subagent.stop":
104
130
  case "prompt.submit":
105
131
  case "stop":
@@ -135,6 +135,8 @@ type Parsed = { ok: true; event: HookEvent } | { ok: false; error: string };
135
135
 
136
136
  /** Claude's shell tools: Bash everywhere, PowerShell on Windows. */
137
137
  const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
138
+ const dialectOf = (value: Record<string, unknown>) =>
139
+ value.tool_name === "PowerShell" ? ("powershell" as const) : ("posix" as const);
138
140
  const toolCommand = (value: Record<string, unknown>): string | null =>
139
141
  isRecord(value.tool_input) ? commandText(value.tool_input.command) : null;
140
142
 
@@ -163,7 +165,7 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
163
165
  return { ok: true, event: { kind: "tool.pre", tool: value.tool_name, toolUseId } };
164
166
  const command = toolCommand(value);
165
167
  return command
166
- ? { ok: true, event: { kind: "shell.pre", command, toolUseId } }
168
+ ? { ok: true, event: { kind: "shell.pre", command, toolUseId, dialect: dialectOf(value) } }
167
169
  : { ok: false, error: `tool_input.command is required for ${value.tool_name}` };
168
170
  }
169
171
  case "PostToolUse": {
@@ -179,6 +181,7 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
179
181
  stdout: optionalText(response.stdout) ?? "",
180
182
  exitCode: typeof response.exit_code === "number" ? response.exit_code : null,
181
183
  toolUseId,
184
+ dialect: dialectOf(value),
182
185
  },
183
186
  };
184
187
  }
@@ -222,7 +225,14 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
222
225
  }
223
226
  };
224
227
 
225
- const CONTEXT_EVENTS = new Set(["SessionStart", "UserPromptSubmit", "SubagentStart"]);
228
+ /** Events whose hookSpecificOutput takes additionalContext (PreToolUse: a raw-git nudge). */
229
+ const CONTEXT_EVENTS = new Set([
230
+ "SessionStart",
231
+ "UserPromptSubmit",
232
+ "SubagentStart",
233
+ "PreToolUse",
234
+ "PostToolUse",
235
+ ]);
226
236
 
227
237
  /** Never emits `allow`: that would bypass the user's own permission prompt. */
228
238
  const render = (decision: HookDecision, native: string | null) => {
@@ -4,7 +4,12 @@ import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../pro
4
4
  import { commandText, existingDirectory, isRecord, nonEmpty, optionalText } from "./fields";
5
5
 
6
6
  export type CodexHost = "codex_cli" | "codex_desktop";
7
- export type CodexHookEvent = "SessionStart" | "PreToolUse" | "SubagentStart" | "SubagentStop";
7
+ export type CodexHookEvent =
8
+ | "SessionStart"
9
+ | "PreToolUse"
10
+ | "PostToolUse"
11
+ | "SubagentStart"
12
+ | "SubagentStop";
8
13
  type SessionSource = "startup" | "resume" | "clear" | "compact";
9
14
 
10
15
  export type CodexHookInput = {
@@ -21,6 +26,8 @@ export type CodexHookInput = {
21
26
  /** The shell command as one string; argv arrays are joined. */
22
27
  command?: string;
23
28
  tool_use_id?: string;
29
+ /** PostToolUse: the model-facing tool output. */
30
+ tool_response?: string;
24
31
  agent_id?: string;
25
32
  agent_type?: string;
26
33
  agent_transcript_path?: string | null;
@@ -53,7 +60,7 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
53
60
  // Codex PreToolUse intercepts shell calls, not apply_patch edits: the
54
61
  // before-write gate stays advisory rather than half-enforced.
55
62
  "write.pre": { support: "undocumented", native: null },
56
- "shell.post": undocumented,
63
+ "shell.post": { support: "native", native: "PostToolUse" },
57
64
  "subagent.start": { support: "native", native: "SubagentStart" },
58
65
  "subagent.stop": { support: "native", native: "SubagentStop" },
59
66
  "prompt.submit": undocumented,
@@ -74,7 +81,7 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
74
81
  worktreeIsolation: "undocumented",
75
82
  maxConcurrency: "undocumented",
76
83
  },
77
- provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "undocumented" },
84
+ provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "native" },
78
85
  interaction: { questions: "none", writeBoundary: "partial" },
79
86
  stopControl: "undocumented",
80
87
  shellAvailable: "native",
@@ -146,6 +153,7 @@ export const codexDescriptor = (host: CodexHost): HostDescriptor => ({ ...CODEX_
146
153
  const EVENTS: Record<CodexHookEvent, HookEventKind> = {
147
154
  SessionStart: "session.start",
148
155
  PreToolUse: "shell.pre",
156
+ PostToolUse: "shell.post",
149
157
  SubagentStart: "subagent.start",
150
158
  SubagentStop: "subagent.stop",
151
159
  };
@@ -166,10 +174,10 @@ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
166
174
  const event = value.hook_event_name as CodexHookEvent;
167
175
  const cwd = existingDirectory(value.cwd);
168
176
  if (!cwd) return { ok: false, error: "cwd must be an existing absolute directory" };
169
- if (event === "PreToolUse" && !nonEmpty(value.tool_name))
170
- return { ok: false, error: "tool_name is required" };
177
+ const toolEvent = event === "PreToolUse" || event === "PostToolUse";
178
+ if (toolEvent && !nonEmpty(value.tool_name)) return { ok: false, error: "tool_name is required" };
171
179
  const command = isRecord(value.tool_input) ? commandText(value.tool_input.command) : null;
172
- if (event === "PreToolUse" && isShellTool(value.tool_name) && !command)
180
+ if (toolEvent && isShellTool(value.tool_name) && !command)
173
181
  return { ok: false, error: "tool_input.command is required for shell tools" };
174
182
  if (event === "SubagentStart" && !nonEmpty(value.agent_id))
175
183
  return { ok: false, error: "agent_id is required" };
@@ -188,7 +196,10 @@ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
188
196
  : {}),
189
197
  ...(nonEmpty(value.turn_id) ? { turn_id: value.turn_id } : {}),
190
198
  ...(nonEmpty(value.tool_name) ? { tool_name: value.tool_name } : {}),
191
- ...(event === "PreToolUse" ? { tool_input: value.tool_input } : {}),
199
+ ...(toolEvent ? { tool_input: value.tool_input } : {}),
200
+ ...(event === "PostToolUse" && typeof value.tool_response === "string"
201
+ ? { tool_response: value.tool_response }
202
+ : {}),
192
203
  ...(command ? { command } : {}),
193
204
  ...(nonEmpty(value.tool_use_id) ? { tool_use_id: value.tool_use_id } : {}),
194
205
  ...(nonEmpty(value.agent_id) ? { agent_id: value.agent_id } : {}),
@@ -218,6 +229,20 @@ const protocolEvent = (input: CodexHookInput): HookEvent => {
218
229
  }
219
230
  : { kind: "tool.pre", tool: input.tool_name!, toolUseId };
220
231
  }
232
+ case "PostToolUse": {
233
+ const toolUseId = input.tool_use_id ?? null;
234
+ return isShellTool(input.tool_name)
235
+ ? {
236
+ kind: "shell.post",
237
+ command: input.command ?? "",
238
+ stdout: input.tool_response ?? "",
239
+ // Codex reports the model-facing output, not an exit code.
240
+ exitCode: null,
241
+ toolUseId,
242
+ }
243
+ : // A non-shell tool result carries nothing workit observes.
244
+ { kind: "tool.pre", tool: input.tool_name!, toolUseId };
245
+ }
221
246
  case "SubagentStart":
222
247
  return {
223
248
  kind: "subagent.start",
@@ -288,5 +313,5 @@ export const codexAdapter: HostAdapter = {
288
313
  },
289
314
  render,
290
315
  addendum: (input) =>
291
- `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. A verifier or reviewer records workit ledger verdict under a session the lead assigns (WORKIT_SESSION_ID=<lead>-v<n>), never the author's. Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
316
+ `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. workit acts as this thread (CODEX_THREAD_ID${input.session.id ? `=${input.session.id}` : ""}) unless WORKIT_SESSION_ID is set. A verifier or reviewer records workit ledger verdict under a session the lead assigns (WORKIT_HOST=codex_cli WORKIT_SESSION_ID=<lead>-v<n>), never the author's. Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
292
317
  };
@@ -77,8 +77,10 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
77
77
  interaction: { questions: "none", writeBoundary: "partial" },
78
78
  stopControl: "undocumented",
79
79
  shellAvailable: "native",
80
- // Every hook spawns `npx -y --prefer-online …@latest`: a registry round-trip per event.
81
- perEventCost: "npx-network",
80
+ // Hooks run the plugin's launcher against a local runtime (bundled dist or a
81
+ // global bin); only an install with neither falls back to a pinned,
82
+ // --prefer-offline npx.
83
+ perEventCost: "low",
82
84
  capabilities: [
83
85
  {
84
86
  name: "known_product_writes",
@@ -278,6 +280,10 @@ const render = (decision: HookDecision, native: string | null) => {
278
280
  json: cursorDeny(decision.reason),
279
281
  exitCode: native !== null && BLOCKING.has(native) ? 2 : 0,
280
282
  };
283
+ // beforeShellExecution has no context channel: a raw-git nudge rides on
284
+ // agent_message next to Cursor's required permission answer.
285
+ if (decision.kind === "context" && native === "beforeShellExecution")
286
+ return { json: { permission: "allow", agent_message: decision.text }, exitCode: 0 };
281
287
  if (decision.kind === "context")
282
288
  return { json: { additional_context: decision.text }, exitCode: 0 };
283
289
  if (decision.kind === "notice")
@@ -319,4 +325,10 @@ export const cursorAdapter: HostAdapter = {
319
325
  };
320
326
  },
321
327
  render,
328
+ // Cursor's shell carries no conversation id (sessionStart env reaches hooks
329
+ // only), so the agent is told the id its `workit` calls should act as.
330
+ addendum: (input) =>
331
+ input.session.id
332
+ ? `<workit-session>Cursor puts no session id in the shell: prefix workit commands that record authorship with WORKIT_SESSION_ID=${input.session.id} (e.g. \`WORKIT_SESSION_ID=${input.session.id} workit git commit -m …\`). A verifier uses its own id, never this one.</workit-session>`
333
+ : null,
322
334
  };
@@ -18,6 +18,7 @@ export {
18
18
  } from "./context";
19
19
  export { shellPolicy } from "./policy";
20
20
  export { shellWrites, writeGate } from "./write-gate";
21
+ export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
21
22
  export { isWriteTool, writePaths } from "./hosts/fields";
22
23
  export { dispatchHook, failureDecision, handleHook, type HookDeps } from "./handle";
23
24
  export { HOOK_ADAPTERS, runHookProcess } from "./run";
@@ -17,7 +17,13 @@ export type HookEvent =
17
17
  | { kind: "session.start"; source: SessionSource }
18
18
  /** Per-turn injection (OpenCode session context, Pi before_agent_start, Claude UserPromptSubmit). */
19
19
  | { kind: "context.turn" }
20
- | { kind: "shell.pre"; command: string; toolUseId: string | null }
20
+ | {
21
+ kind: "shell.pre";
22
+ command: string;
23
+ toolUseId: string | null;
24
+ /** The shell that runs `command`; posix when absent. */
25
+ dialect?: "posix" | "powershell";
26
+ }
21
27
  /** A pre-tool gate for a non-shell tool. Host permission policy owns these. */
22
28
  | { kind: "tool.pre"; tool: string; toolUseId: string | null }
23
29
  /** A file-writing tool (Edit, Write, apply_patch…): the before-write gate (S17). */
@@ -28,6 +34,7 @@ export type HookEvent =
28
34
  stdout: string;
29
35
  exitCode: number | null;
30
36
  toolUseId: string | null;
37
+ dialect?: "posix" | "powershell";
31
38
  }
32
39
  | { kind: "subagent.start"; agentId: string; agentType: string; task: string | null }
33
40
  | {