@brainervirus/workit-core 8.1.1 → 8.3.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.
@@ -0,0 +1,291 @@
1
+ // `workit pr ready|edit|threads|reply` (audit M13): the PR lifecycle steps
2
+ // `pr status` prescribes, so an agent never reaches for gh/glab directly.
3
+ //
4
+ // Every write needs the `pr` grant and an open PR/MR. `edit --base` retargets
5
+ // only to the branch's default target or to a branch the workspace policy does
6
+ // not protect, so a feature PR is never pointed at a production branch by an
7
+ // edit. `reply` and `resolve` act only on a thread `pr status` lists as
8
+ // unresolved on that PR, so a stray id never lands on another PR.
9
+ import { spawnSync } from "node:child_process";
10
+ import { requireGrant } from "../autonomy";
11
+ import { isProtectedTarget } from "../core/branch";
12
+ import { recordedBaseKey, workspaceReleaseTracks } from "../core/release-tracks";
13
+ import { vcsConfig } from "../core/vcs-config";
14
+ import { GIT_TIMEOUTS } from "../git/rev";
15
+ import { listStacks } from "../stack";
16
+ import { checkNames } from "./pr-ops";
17
+ import { selectPr } from "./report";
18
+ import type { ResolvedForge } from "./resolve";
19
+ import {
20
+ failure,
21
+ success,
22
+ type ForgeResult,
23
+ type ForgeThread,
24
+ type PrEdit,
25
+ type PrMeta,
26
+ } from "./types";
27
+
28
+ const label = (resolved: ResolvedForge, number: number): string =>
29
+ `${resolved.forge.kind === "github" ? "PR #" : "MR !"}${number}`;
30
+
31
+ /** The grant, the PR number and its metadata; refuses a closed or merged PR. */
32
+ function openPr(cwd: string, resolved: ResolvedForge, pr: number | null): ForgeResult<PrMeta> {
33
+ const grant = requireGrant(cwd, "pr");
34
+ if (!grant.allowed) return failure("blocked", grant.error, grant.unblock);
35
+ const number = selectPr(cwd, resolved, { pr });
36
+ if (!number.ok) return number;
37
+ const meta = resolved.forge.prMeta(number.data);
38
+ if (!meta.ok) return meta;
39
+ if (meta.data.state !== "open")
40
+ return failure(
41
+ "blocked",
42
+ `${meta.data.state}: ${label(resolved, meta.data.number)} is ${meta.data.state}`,
43
+ `workit pr status --pr ${meta.data.number}`,
44
+ );
45
+ return meta;
46
+ }
47
+
48
+ export type ReadyOutcome = { number: number; url: string; draft: boolean; changed: boolean };
49
+
50
+ /** Mark a draft ready for review, or (`undo`) convert it back to a draft; verified after. */
51
+ export function readyPullRequest(
52
+ cwd: string,
53
+ resolved: ResolvedForge,
54
+ input: { pr: number | null; undo: boolean },
55
+ ): ForgeResult<ReadyOutcome> {
56
+ const meta = openPr(cwd, resolved, input.pr);
57
+ if (!meta.ok) return meta;
58
+ const { number, url } = meta.data;
59
+ const want = input.undo;
60
+ if (meta.data.draft === want) return success({ number, url, draft: want, changed: false });
61
+ const set = resolved.forge.setDraft(number, want);
62
+ if (!set.ok) return set;
63
+ const after = resolved.forge.prMeta(number);
64
+ if (!after.ok) return after;
65
+ if (after.data.draft !== want)
66
+ return failure(
67
+ "failed",
68
+ `ready_unverified: the forge accepted the change but ${label(resolved, number)} still reads ${after.data.draft ? "draft" : "ready"}`,
69
+ `workit pr status --pr ${number}`,
70
+ );
71
+ return success({ number, url, draft: want, changed: true });
72
+ }
73
+
74
+ export type EditOutcome = {
75
+ number: number;
76
+ url: string;
77
+ title: string;
78
+ base: string;
79
+ /** What was asked for, by field (title, body, base, labels, reviewers). */
80
+ changed: string[];
81
+ };
82
+
83
+ const git = (cwd: string, args: string[]): string | null => {
84
+ const run = spawnSync("git", args, {
85
+ cwd,
86
+ encoding: "utf8",
87
+ timeout: GIT_TIMEOUTS.local,
88
+ killSignal: "SIGKILL",
89
+ });
90
+ return run.status === 0 ? run.stdout.trim() || null : null;
91
+ };
92
+
93
+ /**
94
+ * Where `pr edit --base` may point the PR: the head branch's default target
95
+ * (from the release track that owns the PR's current base, else the head's
96
+ * own track; an undetermined line refuses), or the head's stack parent —
97
+ * its recorded `workitBase` or its parent entry in a stack. A protected
98
+ * branch other than the default target never qualifies.
99
+ */
100
+ function checkBase(cwd: string, meta: PrMeta, base: string): ForgeResult<void> {
101
+ const head = meta.headBranch;
102
+ if (!base || /\s/u.test(base) || base.startsWith("refs/") || base.startsWith("-"))
103
+ return failure("invalid_input", `--base must be a plain branch name, not "${base}"`);
104
+ if (base === head) return failure("invalid_input", `${head} cannot be its own base`);
105
+ const read = workspaceReleaseTracks(cwd);
106
+ if (read.error) return failure("blocked", read.error, "fix the workspace's releaseTracks");
107
+ const owners = read.tracks.filter((track) =>
108
+ [
109
+ track.integrationBranch,
110
+ track.productionBranch,
111
+ track.baseBranch,
112
+ track.pullRequestTarget,
113
+ ].includes(meta.base),
114
+ );
115
+ const config = vcsConfig("resolve", cwd, {
116
+ branch: head,
117
+ ...(owners.length === 1 ? { track: owners[0].name } : {}),
118
+ });
119
+ if (config.ok === false)
120
+ return failure("blocked", String(config.error), "fix ~/.config/workit/vcs.json");
121
+ const blocking = config.releaseTrack?.blocking;
122
+ if (blocking)
123
+ return failure(
124
+ "blocked",
125
+ String(blocking),
126
+ "retarget it on the forge yourself, or fix WORKFLOW_RELEASE_TRACK",
127
+ );
128
+ const target = String(config.defaultTargetBranch ?? "") || null;
129
+ if (base === target) return success(undefined);
130
+ const instead = target
131
+ ? `workit pr edit --pr ${meta.number} --base ${target} # or retarget it on the forge yourself`
132
+ : "retarget it on the forge yourself";
133
+ if (isProtectedTarget(cwd, base))
134
+ return failure(
135
+ "blocked",
136
+ `protected_base: ${base} is protected by the workspace branch policy and is not ${head}'s default target${target ? ` (${target})` : ""}; workit never retargets a PR onto it`,
137
+ instead,
138
+ );
139
+ const stacks = listStacks(cwd);
140
+ // Only the branch directly below the head: a descendant or sibling would
141
+ // make a cycle or cross stacks.
142
+ const parents = new Set<string>(
143
+ stacks.ok
144
+ ? stacks.data.flatMap((stack) =>
145
+ stack.branches.filter((entry) => entry.branch === head).map((entry) => entry.parent),
146
+ )
147
+ : [],
148
+ );
149
+ const recorded = git(cwd, ["config", "--get", recordedBaseKey(head)]);
150
+ if (base === recorded || parents.has(base)) return success(undefined);
151
+ return failure(
152
+ "blocked",
153
+ `not_stack_parent: ${base} is neither ${head}'s default target${target ? ` (${target})` : ""} nor its stack parent; workit retargets only to those`,
154
+ instead,
155
+ );
156
+ }
157
+
158
+ export function editPullRequest(
159
+ cwd: string,
160
+ resolved: ResolvedForge,
161
+ input: { pr: number | null; edit: PrEdit },
162
+ ): ForgeResult<EditOutcome> {
163
+ const { edit } = input;
164
+ const changed = [
165
+ ...(edit.title !== undefined ? ["title"] : []),
166
+ ...(edit.body !== undefined ? ["body"] : []),
167
+ ...(edit.base !== undefined ? ["base"] : []),
168
+ ...(edit.addLabels.length || edit.removeLabels.length ? ["labels"] : []),
169
+ ...(edit.addReviewers.length ? ["reviewers"] : []),
170
+ ];
171
+ if (!changed.length) return failure("invalid_input", "nothing to edit");
172
+ if (edit.title !== undefined && !edit.title.trim())
173
+ return failure("invalid_input", "--title must not be empty");
174
+ const bad = checkNames(
175
+ resolved.forge.kind,
176
+ resolved.forge.repo,
177
+ [...edit.addLabels, ...edit.removeLabels],
178
+ edit.addReviewers,
179
+ );
180
+ if (bad) return failure("invalid_input", bad);
181
+
182
+ const meta = openPr(cwd, resolved, input.pr);
183
+ if (!meta.ok) return meta;
184
+ const { number } = meta.data;
185
+ if (edit.base !== undefined) {
186
+ const allowed = checkBase(cwd, meta.data, edit.base);
187
+ if (!allowed.ok) return allowed;
188
+ }
189
+ const done = resolved.forge.editPr(number, edit);
190
+ if (!done.ok) return done;
191
+ const after = resolved.forge.prMeta(number);
192
+ if (!after.ok) return after;
193
+ if (edit.base !== undefined && after.data.base !== edit.base)
194
+ return failure(
195
+ "failed",
196
+ `edit_unverified: ${label(resolved, number)} still targets ${after.data.base}, not ${edit.base}`,
197
+ `workit pr status --pr ${number}`,
198
+ );
199
+ return success({
200
+ number,
201
+ url: after.data.url,
202
+ title: after.data.title,
203
+ base: after.data.base,
204
+ changed,
205
+ });
206
+ }
207
+
208
+ export type ThreadsOutcome = {
209
+ number: number;
210
+ url: string;
211
+ threads: ForgeThread[];
212
+ truncated: boolean;
213
+ };
214
+
215
+ /** The PR's unresolved review threads (the ones `pr status` counts). Read-only. */
216
+ export function listThreads(
217
+ cwd: string,
218
+ resolved: ResolvedForge,
219
+ input: { pr: number | null },
220
+ ): ForgeResult<ThreadsOutcome> {
221
+ const number = selectPr(cwd, resolved, { pr: input.pr });
222
+ if (!number.ok) return number;
223
+ const status = resolved.forge.prStatus(number.data);
224
+ if (!status.ok) return status;
225
+ return success({
226
+ number: status.data.number,
227
+ url: status.data.url,
228
+ threads: status.data.threads,
229
+ truncated: status.data.truncated,
230
+ });
231
+ }
232
+
233
+ export type ReplyOutcome = {
234
+ number: number;
235
+ thread: string;
236
+ replied: boolean;
237
+ replyUrl: string | null;
238
+ resolved: boolean;
239
+ };
240
+
241
+ /** Reply to and/or resolve one unresolved thread of the PR. */
242
+ export function replyToThread(
243
+ cwd: string,
244
+ resolved: ResolvedForge,
245
+ input: { pr: number | null; thread: string; body: string | null; resolve: boolean },
246
+ ): ForgeResult<ReplyOutcome> {
247
+ if (input.body === null && !input.resolve)
248
+ return failure("invalid_input", "pass --body-file <f> and/or --resolve");
249
+ if (input.body !== null && !input.body.trim())
250
+ return failure("invalid_input", "the reply body is empty");
251
+ const meta = openPr(cwd, resolved, input.pr);
252
+ if (!meta.ok) return meta;
253
+ const { number } = meta.data;
254
+ const status = resolved.forge.prStatus(number);
255
+ if (!status.ok) return status;
256
+ const listed = status.data.threads.some((thread) => thread.id === input.thread);
257
+ // A list cut at the page cap may miss the thread: ask for it directly.
258
+ const open =
259
+ listed ||
260
+ (status.data.truncated &&
261
+ (() => {
262
+ const state = resolved.forge.threadState(number, input.thread);
263
+ return state.ok && state.data === "open";
264
+ })());
265
+ if (!open)
266
+ return failure(
267
+ "not_found",
268
+ `thread ${input.thread} is not an unresolved thread of ${label(resolved, number)}`,
269
+ `workit pr threads --pr ${number}`,
270
+ );
271
+ let replyUrl: string | null = null;
272
+ if (input.body !== null) {
273
+ const replied = resolved.forge.replyThread(number, input.thread, input.body);
274
+ if (!replied.ok) return replied;
275
+ replyUrl = replied.data.url;
276
+ }
277
+ if (input.resolve) {
278
+ const done = resolved.forge.resolveThread(number, input.thread);
279
+ if (!done.ok)
280
+ return input.body === null
281
+ ? done
282
+ : failure(done.code, `replied, but not resolved: ${done.error}`, done.unblock);
283
+ }
284
+ return success({
285
+ number,
286
+ thread: input.thread,
287
+ replied: input.body !== null,
288
+ replyUrl,
289
+ resolved: input.resolve,
290
+ });
291
+ }
@@ -59,6 +59,9 @@ export type CreateInput = {
59
59
  body: string;
60
60
  draft: boolean;
61
61
  actor: LedgerActor;
62
+ /** Applied after the PR is opened or found (`--label`, `--reviewer`). */
63
+ labels?: string[];
64
+ reviewers?: string[];
62
65
  };
63
66
 
64
67
  export type CreateOutcome = {
@@ -86,10 +89,14 @@ const createNext = (cwd: string): string => {
86
89
  return `a non-author verifies the head (workit-review); the endpoint is ${endpoint}, so ${AFTER_REVIEW[endpoint]}`;
87
90
  };
88
91
 
92
+ /** Workit's own commit trailer: provenance for the ledger, not PR prose. */
93
+ const SESSION_TRAILER = /^Workit-Session:.*$/gimu;
94
+
89
95
  /**
90
96
  * `--fill`: the title and body from the branch's commits on top of the base
91
97
  * (fetched first). One commit gives its subject and body; several give the
92
- * oldest subject and a list of every subject.
98
+ * oldest subject and every subject with its body, like `gh pr create
99
+ * --fill-verbose`. `Workit-Session:` trailers are left out of the body.
93
100
  */
94
101
  export function fillFromCommits(
95
102
  cwd: string,
@@ -122,7 +129,7 @@ export function fillFromCommits(
122
129
  .filter(Boolean)
123
130
  .map((entry) => {
124
131
  const [subject = "", body = ""] = entry.split("\x1f");
125
- return { subject: subject.trim(), body: body.trim() };
132
+ return { subject: subject.trim(), body: body.replace(SESSION_TRAILER, "").trim() };
126
133
  });
127
134
  if (commits.length === 0)
128
135
  return failure(
@@ -133,10 +140,49 @@ export function fillFromCommits(
133
140
  if (commits.length === 1) return success({ title: commits[0].subject, body: commits[0].body });
134
141
  return success({
135
142
  title: commits[0].subject,
136
- body: commits.map((commit) => `- ${commit.subject}`).join("\n"),
143
+ body: commits
144
+ .map((commit) =>
145
+ [`- ${commit.subject}`, ...(commit.body ? ["", indent(commit.body), ""] : [])].join("\n"),
146
+ )
147
+ .join("\n")
148
+ .trim(),
137
149
  });
138
150
  }
139
151
 
152
+ const indent = (text: string): string =>
153
+ text
154
+ .split("\n")
155
+ .map((line) => (line ? ` ${line}` : line))
156
+ .join("\n");
157
+
158
+ /**
159
+ * Labels and reviewers a PR write may send, checked before any forge call
160
+ * (so `pr create --label/--reviewer` never fails after the PR is open):
161
+ * comma-free names (GitLab joins labels with commas), GitLab reviewers are
162
+ * usernames, and a GitHub `org/team` belongs to the repository's owner.
163
+ */
164
+ export function checkNames(
165
+ kind: "github" | "gitlab",
166
+ repo: string,
167
+ labels: readonly string[],
168
+ reviewers: readonly string[],
169
+ ): string | null {
170
+ const bad = [...labels, ...reviewers].find(
171
+ (value) => !value.trim() || value.includes(",") || value !== value.trim(),
172
+ );
173
+ if (bad !== undefined) return `invalid label or reviewer "${bad}"`;
174
+ const owner = repo.split("/")[0]?.toLowerCase() ?? "";
175
+ for (const reviewer of reviewers) {
176
+ if (!reviewer.includes("/")) continue;
177
+ if (kind === "gitlab") return `GitLab reviewers are usernames, not teams ("${reviewer}")`;
178
+ const [org = "", team = "", ...rest] = reviewer.split("/");
179
+ if (!team || rest.length) return `invalid team reviewer "${reviewer}" (use <org>/<team>)`;
180
+ if (org.toLowerCase() !== owner)
181
+ return `team ${reviewer} is not in ${owner}; GitHub only requests teams of the repository's owner`;
182
+ }
183
+ return null;
184
+ }
185
+
140
186
  /** The pushed head of the current branch, bound before any forge write. */
141
187
  export function boundSource(
142
188
  cwd: string,
@@ -193,6 +239,13 @@ export async function createPullRequest(
193
239
  input: CreateInput,
194
240
  sleep: Sleep,
195
241
  ): Promise<ForgeResult<CreateOutcome>> {
242
+ const names = checkNames(
243
+ resolved.forge.kind,
244
+ resolved.forge.repo,
245
+ input.labels ?? [],
246
+ input.reviewers ?? [],
247
+ );
248
+ if (names) return failure("invalid_input", names);
196
249
  const grant = requireGrant(cwd, "pr");
197
250
  if (!grant.allowed) return grantBlocked(grant);
198
251
  const source = boundSource(cwd, resolved);
@@ -242,6 +295,21 @@ export async function createPullRequest(
242
295
  repo: resolved.forge.repo,
243
296
  created,
244
297
  });
298
+ const labels = input.labels ?? [];
299
+ const reviewers = input.reviewers ?? [];
300
+ if (labels.length || reviewers.length) {
301
+ const edited = resolved.forge.editPr(ref.number, {
302
+ addLabels: labels,
303
+ removeLabels: [],
304
+ addReviewers: reviewers,
305
+ });
306
+ if (!edited.ok)
307
+ return failure(
308
+ edited.code,
309
+ `${noun(resolved)} #${ref.number} is open (${ref.url}), but its labels or reviewers were not set: ${edited.error}`,
310
+ `workit pr edit --pr ${ref.number}${labels.map((name) => ` --add-label ${name}`).join("")}${reviewers.map((name) => ` --add-reviewer ${name}`).join("")}`,
311
+ );
312
+ }
245
313
  return success({
246
314
  number: ref.number,
247
315
  url: ref.url,
@@ -351,19 +419,37 @@ const refuse = (error: string, unblock: string, refusal: MergeRefusal): MergeRes
351
419
  refusal,
352
420
  });
353
421
 
354
- const NEXT_HINTS: Record<string, string> = {
355
- RESOLVE_CONFLICTS: "rebase onto the base, resolve, then workit git push --force-with-lease",
356
- REBASE: "rebase onto the base, then workit git push --force-with-lease",
357
- RESOLVE_THREADS: "address and resolve the open review threads (workit pr status lists them)",
358
- FIX_CI:
359
- "fix the failing checks (workit pr status shows the log tails), push, then workit ci wait",
360
- WAITING_CI: "workit ci wait",
361
- ADDRESS_REVIEW: "address the requested changes",
362
- REVIEW: "get the required review",
363
- MARK_READY: "mark the PR ready for review",
364
- IN_MERGE_QUEUE: "the PR is already in the merge queue",
365
- NOT_MERGEABLE: "workit pr status # the forge reports it cannot merge yet",
366
- };
422
+ /**
423
+ * The command (or step) that clears a `next` blocker, for `pr status` and a
424
+ * `pr merge` refusal; null for READY and the terminal states.
425
+ */
426
+ export function nextHint(next: string, number: number): string | null {
427
+ const pr = `--pr ${number}`;
428
+ switch (next) {
429
+ case "RESOLVE_CONFLICTS":
430
+ return "rebase onto the base, resolve, then workit git push --force-with-lease";
431
+ case "REBASE":
432
+ return "rebase onto the base, then workit git push --force-with-lease";
433
+ case "RESOLVE_THREADS":
434
+ return `workit pr threads ${pr}; fix or answer each: workit pr reply ${pr} --thread <id> --body-file <f> [--resolve]`;
435
+ case "FIX_CI":
436
+ return "fix the failing checks (workit pr status shows the log tails), push, then workit ci wait";
437
+ case "WAITING_CI":
438
+ return "workit ci wait";
439
+ case "ADDRESS_REVIEW":
440
+ return `address the requested changes; answer threads with workit pr reply ${pr} --thread <id> --body-file <f>`;
441
+ case "REVIEW":
442
+ return `get the required review (workit pr edit ${pr} --add-reviewer <login>)`;
443
+ case "MARK_READY":
444
+ return `workit pr ready ${pr}`;
445
+ case "IN_MERGE_QUEUE":
446
+ return "the PR is already in the merge queue";
447
+ case "NOT_MERGEABLE":
448
+ return "workit pr status # the forge reports it cannot merge yet";
449
+ default:
450
+ return null;
451
+ }
452
+ }
367
453
 
368
454
  export async function mergePullRequest(
369
455
  cwd: string,
@@ -406,7 +492,7 @@ export async function mergePullRequest(
406
492
  if (doc.next !== "READY")
407
493
  return refuse(
408
494
  `not_ready: ${label} is ${doc.next} (${doc.blockers.join(", ") || doc.next.toLowerCase()})`,
409
- NEXT_HINTS[doc.next] ?? "workit pr status",
495
+ nextHint(doc.next, doc.number) ?? "workit pr status",
410
496
  { reason: "not_ready", next: doc.next, blockers: doc.blockers },
411
497
  );
412
498
 
@@ -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) => {