@brainervirus/workit-core 8.7.0 → 8.9.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.
@@ -22,6 +22,7 @@ import {
22
22
  skillReadIn,
23
23
  withContextLine,
24
24
  } from "./skill-nudge";
25
+ import { stopDecision } from "./stop";
25
26
  import { shellWrites, writeGate } from "./write-gate";
26
27
  import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
27
28
 
@@ -67,21 +68,35 @@ const subagentSession = (session: string | null, agentId: string): string =>
67
68
  .replace(/[^A-Za-z0-9_.:@/+-]/g, "-")
68
69
  .slice(0, 128);
69
70
 
71
+ /** A Claude Code worktree path: `<repo>/.claude/worktrees/<name>`. */
72
+ const CLAUDE_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]/;
73
+
74
+ /**
75
+ * Claude Code's worktree isolation refuses any command that wraps git
76
+ * (`workit git …` included) and cannot be turned off, so an isolated agent
77
+ * runs git itself and workit for everything else.
78
+ */
79
+ const ISOLATED_GIT =
80
+ 'Run git as plain, separate commands inside your worktree (`git switch -c <branch> <base>`, `git commit --trailer "Workit-Session=<your session>"`, `git push`; `workit doctor --fix` can install a commit-msg hook that adds the trailer), 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`).';
81
+
70
82
  const subagentStartText = (
71
- session: string | null,
83
+ input: HookInput,
72
84
  descriptor: HostDescriptor,
73
85
  event: Extract<HookInput["event"], { kind: "subagent.start" }>,
74
86
  ): string => {
87
+ const session = input.session.id ?? null;
75
88
  const role = workitRole(descriptor, event.agentType);
76
89
  const who = `${descriptor.label} subagent ${event.agentId} (${event.agentType})`;
77
- if (role === "implementer") {
78
- // Only a host that isolates subagents in worktrees has already put it in one.
79
- const where =
80
- descriptor.subagents.worktreeIsolation === "native"
81
- ? "working in its own git worktree: it may edit and commit there"
82
- : "as an implementer: it edits and commits only in its own git worktree (the lead makes one with `workit fanout worktree create <slice>`), never in the lead's checkout";
83
- return `Workit observed ${who} ${where}, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
84
- }
90
+ const isolated =
91
+ descriptor.subagents.worktreeIsolation === "native" &&
92
+ (role === "implementer" || CLAUDE_WORKTREE.test(input.cwd));
93
+ if (role === "implementer" && isolated)
94
+ return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${ISOLATED_GIT} Use a policy-compliant branch (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
95
+ if (isolated)
96
+ return `${ISOLATED_GIT}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
97
+ if (role === "implementer")
98
+ // No host isolation: the lead makes the worktree, and workit git works there.
99
+ return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
85
100
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
86
101
  if (role !== "judge" || !event.agentId) return readOnly;
87
102
  const own = subagentSession(session, event.agentId);
@@ -125,7 +140,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
125
140
  // Without a post-tool event, the previous raw commit is recorded now.
126
141
  const postTool = usable(descriptor.events["shell.post"].support);
127
142
  if (!postTool) settlePendingCommit(input);
128
- const raw = canDeny ? rawGitPre(input, event.command) : NONE;
143
+ const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
129
144
  if (raw.kind === "deny") return raw;
130
145
  const writes = usable(descriptor.events["write.pre"].support)
131
146
  ? shellWrites(event.command)
@@ -139,7 +154,13 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
139
154
  event.command,
140
155
  postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
141
156
  );
142
- return withContextLine(raw, shipNudge(input, event.command));
157
+ // An in-process plugin shows nudges on the tool result (shellNudge):
158
+ // spending the once-per-session nudge here would lose it.
159
+ if (descriptor.transport !== "hook-process") return NONE;
160
+ return withContextLine(
161
+ canDeny ? rawGitPre(input, event.command) : NONE,
162
+ shipNudge(input, event.command),
163
+ );
143
164
  }
144
165
  case "shell.post":
145
166
  if (usable(descriptor.events["shell.post"].support))
@@ -152,7 +173,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
152
173
  case "subagent.start":
153
174
  return {
154
175
  kind: "context",
155
- text: subagentStartText(input.session.id ?? null, descriptor, event),
176
+ text: subagentStartText(input, descriptor, event),
156
177
  };
157
178
  case "compact.pre":
158
179
  return {
@@ -164,11 +185,12 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
164
185
  // Host permission policy owns other tools; a skill load is recorded.
165
186
  if (event.skill) recordSkillLoad(input, event.skill, "tool");
166
187
  return NONE;
167
- // Attestation, prompt and stop control arrive with the CLI-observed
168
- // evidence model.
188
+ case "stop":
189
+ return usable(descriptor.events.stop.support) ? stopDecision(input, descriptor) : NONE;
190
+ // A subagent's stop is never blocked; attestation and prompt control
191
+ // arrive with the CLI-observed evidence model.
169
192
  case "subagent.stop":
170
193
  case "prompt.submit":
171
- case "stop":
172
194
  return NONE;
173
195
  }
174
196
  }
@@ -44,13 +44,15 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
44
44
  // SubagentStart output is additionalContext only: it cannot block or bind.
45
45
  "subagent.start": { support: "native", native: "SubagentStart" },
46
46
  "prompt.submit": { support: "native", native: "UserPromptSubmit" },
47
- // Claude Code has SubagentStop, PreCompact and Stop, but the plugin
48
- // registers none of them: events list what workit registers
49
- // (docs/agents/hosts.md, Host parity). Compaction restore runs on
47
+ // Claude Code has SubagentStop and PreCompact, but the plugin registers
48
+ // neither: events list what workit registers (docs/agents/hosts.md, Host
49
+ // parity). A subagent's stop is never blocked; compaction restore runs on
50
50
  // SessionStart source=compact.
51
51
  "subagent.stop": { support: "none", native: null },
52
52
  "compact.pre": { support: "none", native: null },
53
- stop: { support: "none", native: null },
53
+ // `{"decision":"block","reason"}` continues the turn; the input carries
54
+ // `stop_hook_active` and `last_assistant_message` (claude 2.1.295).
55
+ stop: { support: "native", native: "Stop" },
54
56
  },
55
57
  shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
56
58
  context: {
@@ -18,7 +18,8 @@ export type CodexHookEvent =
18
18
  | "PreToolUse"
19
19
  | "PostToolUse"
20
20
  | "SubagentStart"
21
- | "SubagentStop";
21
+ | "SubagentStop"
22
+ | "Stop";
22
23
  // The session-start.command.input schema in codex-cli 0.160.1 lists fork as a source.
23
24
  type SessionSource = "startup" | "resume" | "clear" | "compact" | "fork";
24
25
 
@@ -80,10 +81,12 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
80
81
  "subagent.start": { support: "native", native: "SubagentStart" },
81
82
  "subagent.stop": { support: "native", native: "SubagentStop" },
82
83
  "prompt.submit": { support: "native", native: "UserPromptSubmit" },
83
- // Codex has PreCompact and Stop, but workit registers neither yet: events
84
- // list what workit registers (docs/agents/hosts.md, Host parity).
84
+ // Codex has PreCompact, but workit does not register it: events list what
85
+ // workit registers (docs/agents/hosts.md, Host parity).
85
86
  "compact.pre": { support: "none", native: null },
86
- stop: { support: "none", native: null },
87
+ // stop.command.input (codex-cli 0.160.1): stop_hook_active,
88
+ // last_assistant_message, turn_id; output `{"decision":"block","reason"}`.
89
+ stop: { support: "native", native: "Stop" },
87
90
  },
88
91
  shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
89
92
  context: {
@@ -104,7 +107,7 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
104
107
  },
105
108
  provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "native" },
106
109
  interaction: { questions: "none", writeBoundary: "partial" },
107
- stopControl: "undocumented",
110
+ stopControl: "native",
108
111
  shellAvailable: "native",
109
112
  perEventCost: "low",
110
113
  capabilities: [
@@ -178,6 +181,7 @@ const EVENTS: Record<CodexHookEvent, HookEventKind> = {
178
181
  PostToolUse: "shell.post",
179
182
  SubagentStart: "subagent.start",
180
183
  SubagentStop: "subagent.stop",
184
+ Stop: "stop",
181
185
  };
182
186
  const SHELL_TOOLS = new Set(["bash", "unified-exec"]);
183
187
  const isShellTool = (name: unknown) => SHELL_TOOLS.has(String(name).toLowerCase());
@@ -227,6 +231,12 @@ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
227
231
  ...(nonEmpty(value.tool_use_id) ? { tool_use_id: value.tool_use_id } : {}),
228
232
  ...(nonEmpty(value.agent_id) ? { agent_id: value.agent_id } : {}),
229
233
  ...(nonEmpty(value.agent_type) ? { agent_type: value.agent_type } : {}),
234
+ ...(event === "Stop"
235
+ ? {
236
+ last_assistant_message: optionalText(value.last_assistant_message),
237
+ stop_hook_active: value.stop_hook_active === true,
238
+ }
239
+ : {}),
230
240
  ...(event === "SubagentStop"
231
241
  ? {
232
242
  agent_transcript_path: optionalText(value.agent_transcript_path),
@@ -287,6 +297,12 @@ const protocolEvent = (input: CodexHookInput): HookEvent => {
287
297
  lastMessage: input.last_assistant_message ?? null,
288
298
  stopHookActive: input.stop_hook_active ?? false,
289
299
  };
300
+ case "Stop":
301
+ return {
302
+ kind: "stop",
303
+ lastMessage: input.last_assistant_message ?? null,
304
+ stopHookActive: input.stop_hook_active ?? false,
305
+ };
290
306
  }
291
307
  };
292
308
 
@@ -295,7 +311,11 @@ const output = (event: string, extra: Record<string, unknown> = {}) => ({
295
311
  });
296
312
 
297
313
  const render = (decision: HookDecision, native: string | null) => {
298
- if (native === "SubagentStop" || native === null) return { json: {}, exitCode: 0 };
314
+ // stop.command.output takes no hookSpecificOutput: a block with a reason continues.
315
+ if (native === "Stop" && decision.kind === "continue")
316
+ return { json: { decision: "block", reason: decision.reason }, exitCode: 0 };
317
+ if (native === "SubagentStop" || native === "Stop" || native === null)
318
+ return { json: {}, exitCode: 0 };
299
319
  if (decision.kind === "deny")
300
320
  return {
301
321
  json: output(native, {
@@ -1,4 +1,5 @@
1
1
  // Cursor: command hooks (hooks/hooks-cursor.json) mapped onto the protocol.
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import type { HostDescriptor } from "../descriptor";
4
5
  import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
@@ -10,7 +11,8 @@ export type CursorHookEvent =
10
11
  | "beforeShellExecution"
11
12
  | "subagentStart"
12
13
  | "subagentStop"
13
- | "preCompact";
14
+ | "preCompact"
15
+ | "stop";
14
16
 
15
17
  export type CursorHookInput = {
16
18
  hook_event_name: CursorHookEvent;
@@ -26,6 +28,8 @@ export type CursorHookInput = {
26
28
  parent_conversation_id?: string;
27
29
  task?: string;
28
30
  status?: "completed" | "error" | "aborted";
31
+ loop_count?: number;
32
+ transcript_path?: string;
29
33
  };
30
34
 
31
35
  export type CursorParseResult = { ok: true; data: CursorHookInput } | { ok: false; error: string };
@@ -53,8 +57,11 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
53
57
  "prompt.submit": undocumented,
54
58
  // preCompact can only show a user message.
55
59
  "compact.pre": { support: "partial", native: "preCompact" },
56
- // Cursor has a stop hook (followup_message), but workit registers none yet.
57
- stop: none,
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" },
58
65
  },
59
66
  shellPolicy: { deny: "native", channel: "exit2+json", failClosed: true },
60
67
  context: {
@@ -78,7 +85,7 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
78
85
  postToolObserve: "undocumented",
79
86
  },
80
87
  interaction: { questions: "none", writeBoundary: "partial" },
81
- stopControl: "undocumented",
88
+ stopControl: "native",
82
89
  shellAvailable: "native",
83
90
  // Hooks run the plugin's launcher against a local runtime (bundled dist or a
84
91
  // global bin); only an install with neither falls back to a pinned,
@@ -155,6 +162,7 @@ const EVENTS: Record<CursorHookEvent, HookEventKind> = {
155
162
  subagentStart: "subagent.start",
156
163
  subagentStop: "subagent.stop",
157
164
  preCompact: "compact.pre",
165
+ stop: "stop",
158
166
  };
159
167
 
160
168
  /** Validate the keys each Cursor event needs; unknown keys are ignored. */
@@ -205,6 +213,15 @@ export const parseCursorHookInput = (value: unknown): CursorParseResult => {
205
213
  ? { parent_conversation_id: value.parent_conversation_id }
206
214
  : {}),
207
215
  ...(event === "subagentStart" && nonEmpty(value.task) ? { task: value.task } : {}),
216
+ ...(event === "stop"
217
+ ? {
218
+ ...(nonEmpty(value.status)
219
+ ? { status: value.status as CursorHookInput["status"] }
220
+ : {}),
221
+ ...(typeof value.loop_count === "number" ? { loop_count: value.loop_count } : {}),
222
+ ...(nonEmpty(value.transcript_path) ? { transcript_path: value.transcript_path } : {}),
223
+ }
224
+ : {}),
208
225
  },
209
226
  };
210
227
  };
@@ -244,6 +261,61 @@ const protocolEvent = (input: CursorHookInput): HookEvent => {
244
261
  };
245
262
  case "preCompact":
246
263
  return { kind: "compact.pre", trigger: "auto" };
264
+ case "stop":
265
+ return {
266
+ kind: "stop",
267
+ // Only a completed turn is continued; an aborted or failed one stops.
268
+ lastMessage:
269
+ (input.status ?? "completed") === "completed"
270
+ ? lastAssistantText(input.transcript_path)
271
+ : null,
272
+ // A followup_message turn reports loop_count > 0: one continue per turn.
273
+ stopHookActive: (input.loop_count ?? 0) > 0,
274
+ };
275
+ }
276
+ };
277
+
278
+ /** The transcript tail read for the last assistant message. */
279
+ const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
280
+
281
+ /**
282
+ * The text of the last assistant entry in a Cursor agent transcript
283
+ * (`{"role":"assistant","message":{"content":[{"type":"text","text":…}]}}`
284
+ * per line), or null when it cannot be read.
285
+ */
286
+ export const lastAssistantText = (file: string | null | undefined): string | null => {
287
+ if (!file || !path.isAbsolute(file)) return null;
288
+ try {
289
+ const size = fs.statSync(file).size;
290
+ const start = Math.max(0, size - TRANSCRIPT_TAIL_BYTES);
291
+ const buffer = Buffer.alloc(size - start);
292
+ const fd = fs.openSync(file, "r");
293
+ try {
294
+ fs.readSync(fd, buffer, 0, buffer.length, start);
295
+ } finally {
296
+ fs.closeSync(fd);
297
+ }
298
+ const lines = buffer.toString("utf8").split("\n");
299
+ for (let index = lines.length - 1; index >= 0; index--) {
300
+ let entry: unknown;
301
+ try {
302
+ entry = JSON.parse(lines[index]);
303
+ } catch {
304
+ continue;
305
+ }
306
+ if (!isRecord(entry) || entry.role !== "assistant") continue;
307
+ const content = isRecord(entry.message) ? entry.message.content : entry.content;
308
+ if (typeof content === "string") return content;
309
+ if (!Array.isArray(content)) return null;
310
+ const text = content
311
+ .filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
312
+ .map((part) => (part as { text: string }).text)
313
+ .join("\n");
314
+ return text || null;
315
+ }
316
+ return null;
317
+ } catch {
318
+ return null;
247
319
  }
248
320
  };
249
321
 
@@ -291,6 +363,8 @@ const render = (decision: HookDecision, native: string | null) => {
291
363
  return { json: { additional_context: decision.text }, exitCode: 0 };
292
364
  if (decision.kind === "notice")
293
365
  return { json: { user_message: decision.userMessage }, exitCode: 0 };
366
+ if (decision.kind === "continue" && native === "stop")
367
+ return { json: { followup_message: decision.reason }, exitCode: 0 };
294
368
  return {
295
369
  json: native !== null && BLOCKING.has(native) ? { permission: "allow" } : {},
296
370
  exitCode: 0,
@@ -19,8 +19,11 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
19
19
  "subagent.stop": { support: "native", native: 'tool.hook("execute.after") subagent' },
20
20
  "prompt.submit": { support: "undocumented", native: null },
21
21
  "compact.pre": { support: "native", native: 'session.hook("compaction")' },
22
- // OpenCode reports session.idle, but the plugin registers no stop hook yet.
23
- stop: { support: "none", native: null },
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")' },
24
27
  },
25
28
  shellPolicy: { deny: "native", channel: "effect", failClosed: false },
26
29
  context: {
@@ -19,7 +19,10 @@ export const PI_DESCRIPTOR: HostDescriptor = {
19
19
  "subagent.stop": { support: "none", native: null },
20
20
  "prompt.submit": { support: "undocumented", native: null },
21
21
  "compact.pre": { support: "native", native: "session_before_compact" },
22
- stop: { support: "none", native: null },
22
+ // agent_end carries the run's messages; pi.sendMessage(…, {triggerTurn})
23
+ // starts one more turn (pi-coding-agent 0.85.1 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" },
23
26
  },
24
27
  shellPolicy: { deny: "native", channel: "block", failClosed: false },
25
28
  context: {
@@ -41,7 +44,7 @@ export const PI_DESCRIPTOR: HostDescriptor = {
41
44
  },
42
45
  provenance: { sessionId: "native", agentIdOnTool: "none", postToolObserve: "native" },
43
46
  interaction: { questions: "native", writeBoundary: "native" },
44
- stopControl: "none",
47
+ stopControl: "native",
45
48
  shellAvailable: "native",
46
49
  perEventCost: "low",
47
50
  capabilities: [
@@ -20,6 +20,7 @@ export {
20
20
  } from "./context";
21
21
  export { shellPolicy } from "./policy";
22
22
  export { shellWrites, writeGate } from "./write-gate";
23
+ export { asksUser, stopDecision, stopObligation, type StopObligation } from "./stop";
23
24
  export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
24
25
  export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
25
26
  export { isWriteTool, writePaths } from "./hosts/fields";
@@ -6,7 +6,8 @@
6
6
  // remote onto a protected branch or the default target, naming the
7
7
  // workit command;
8
8
  // - lets routine raw commands run (`git commit`, a feature-branch push,
9
- // `gh pr create|view|checks`, `glab mr create|view`) with a short nudge.
9
+ // `gh pr create|view|checks`, `glab mr create|view`) with a short nudge,
10
+ // once per kind per session, and none on a command that runs workit.
10
11
  // A raw `git commit` is recorded as the session's `commit.recorded` row so
11
12
  // the author's own verdict never reads as independent. The pre-tool hook
12
13
  // notes HEAD before the command (a marker in the commit's own repository);
@@ -29,6 +30,7 @@ import { pushRemoteName } from "../git/rev";
29
30
  import { appendHookObserved, readLedger } from "../ledger";
30
31
  import { resolveStore, resolveTaskKey, type StoreLocation } from "../store/paths";
31
32
  import type { HookDecision, HookInput } from "./protocol";
33
+ import { readSessionMarker, sessionMarkerFile, writeSessionMarker } from "./session-marker";
32
34
  import { segmentsOf, type ShellDialect } from "./shell-words";
33
35
 
34
36
  const NONE: HookDecision = { kind: "none" };
@@ -176,6 +178,47 @@ export function rawInvocations(
176
178
  return found;
177
179
  }
178
180
 
181
+ const LAUNCHERS = new Set([
182
+ "npx",
183
+ "bunx",
184
+ "pnpx",
185
+ "-y",
186
+ "--yes",
187
+ "pnpm",
188
+ "dlx",
189
+ "exec",
190
+ "env",
191
+ "sudo",
192
+ "command",
193
+ ]);
194
+ const WORKIT_BIN = /(?:^|[\\/])workit(?:\.exe)?$|^@brainervirus\/workit-cli$/;
195
+
196
+ /**
197
+ * The arguments of every workit CLI invocation in a shell command: past env
198
+ * assignments and package runners (`npx -y @brainervirus/workit-cli …`), and
199
+ * inside `bash -c '…'` scripts.
200
+ */
201
+ export function workitInvocations(
202
+ command: string,
203
+ dialect: ShellDialect = "posix",
204
+ depth = 0,
205
+ ): string[][] {
206
+ const found: string[][] = [];
207
+ for (const { words } of segmentsOf(command, dialect)) {
208
+ const at = words.findIndex((word) => !/^\w+=/.test(word) && !LAUNCHERS.has(word));
209
+ if (at < 0) continue;
210
+ if (WORKIT_BIN.test(words[at])) {
211
+ found.push(words.slice(at + 1));
212
+ continue;
213
+ }
214
+ const shell = SHELLS.has(commandName(words[at]));
215
+ const script = shell && words[at + 1] === "-c" ? words[at + 2] : undefined;
216
+ if (depth < 3 && script !== undefined)
217
+ found.push(...workitInvocations(script, "posix", depth + 1));
218
+ }
219
+ return found;
220
+ }
221
+
179
222
  /** What one raw invocation means for workit. */
180
223
  export type RawAction =
181
224
  | { kind: "commit"; amend: boolean }
@@ -336,7 +379,7 @@ const isWorkitWorkspace = (repo: Repo): boolean => {
336
379
  };
337
380
 
338
381
  /** Protected by the branch policy, or the workspace's default target. */
339
- const guardedBranch = (dir: string, branch: string): boolean => {
382
+ export const guardedBranch = (dir: string, branch: string): boolean => {
340
383
  try {
341
384
  const policy = resolveBranchPolicyFor(dir);
342
385
  return (
@@ -459,16 +502,53 @@ const dialectOf = (input: HookInput): ShellDialect =>
459
502
  ? input.event.dialect
460
503
  : undefined) ?? "posix";
461
504
 
505
+ /** The nudge kind an action shows: each is shown once per session (agent). */
506
+ const nudgeKind = (action: RawAction): string =>
507
+ action.kind === "commit" && action.amend
508
+ ? "commit-amend"
509
+ : action.kind === "push" && action.blindForce
510
+ ? "push-force"
511
+ : action.kind;
512
+
513
+ /**
514
+ * The nudges a session (or one of its subagents) has not been shown yet,
515
+ * recorded as shown in the per-session marker. Without a session or a
516
+ * marker, every nudge shows: the throttle fails open to repeating.
517
+ */
518
+ const unseenNudges = (input: HookInput, nudges: Map<string, string>, store: string): string[] => {
519
+ const session = input.session.id;
520
+ const file = session ? sessionMarkerFile(input, session, store) : null;
521
+ if (!file) return [...nudges.values()];
522
+ const marker = readSessionMarker(file);
523
+ const agent = input.session.agentId ? `${input.session.agentId}:` : "";
524
+ const fresh = [...nudges.keys()].filter((kind) => !marker.raw.includes(agent + kind));
525
+ if (fresh.length)
526
+ writeSessionMarker(file, {
527
+ ...marker,
528
+ raw: [...marker.raw, ...fresh.map((kind) => agent + kind)],
529
+ });
530
+ return fresh.map((kind) => nudges.get(kind) as string);
531
+ };
532
+
462
533
  /**
463
534
  * The pre-tool decision for a shell command: deny a gate bypass inside a
464
535
  * Workit workspace, else a nudge (as context) for routine raw delivery
465
- * commands, else nothing. Never throws, never writes.
536
+ * commands, else nothing. Each nudge kind is shown once per session, and a
537
+ * command that runs a workit verb itself gets none (denies still apply).
538
+ * `nudge: false` asks for the deny alone and spends no nudge. Never throws.
466
539
  */
467
- export function rawGitPre(input: HookInput, command: string): HookDecision {
540
+ export function rawGitPre(
541
+ input: HookInput,
542
+ command: string,
543
+ options: { nudge?: boolean } = {},
544
+ ): HookDecision {
468
545
  try {
469
546
  if (!mentionsRawTool(command)) return NONE;
470
- const nudges: string[] = [];
471
- for (const { action, repo } of actionsIn(input.cwd, command, dialectOf(input))) {
547
+ const dialect = dialectOf(input);
548
+ const nudges = new Map<string, string>();
549
+ let store: string | null = null;
550
+ for (const { action, repo } of actionsIn(input.cwd, command, dialect)) {
551
+ store ??= repo.location.dir;
472
552
  if (action.kind === "merge") return deny(refusal(action, null));
473
553
  if (action.kind === "push")
474
554
  for (const target of action.targets) {
@@ -477,9 +557,12 @@ export function rawGitPre(input: HookInput, command: string): HookDecision {
477
557
  return deny(refusal(action, branch));
478
558
  }
479
559
  const nudge = nudgeFor(action, input.host, input.session.id);
480
- if (nudge && !nudges.includes(nudge)) nudges.push(nudge);
560
+ if (nudge && !nudges.has(nudgeKind(action))) nudges.set(nudgeKind(action), nudge);
481
561
  }
482
- return nudges.length ? { kind: "context", text: nudges.join("\n") } : NONE;
562
+ if (options.nudge === false || !store || nudges.size === 0) return NONE;
563
+ if (workitInvocations(command, dialect).length > 0) return NONE;
564
+ const shown = unseenNudges(input, nudges, store);
565
+ return shown.length ? { kind: "context", text: shown.join("\n") } : NONE;
483
566
  } catch {
484
567
  return NONE;
485
568
  }
@@ -0,0 +1,53 @@
1
+ // The per-session nudge marker in the workspace store
2
+ // (`<store>/hooks/skills-<session hash>.json`): which skills a session was
3
+ // nudged about or loaded, and which raw-git nudge kinds it was shown, so each
4
+ // nudge fires once per session. Each hook is a fresh process, so the marker
5
+ // lives on disk. Every failure reads as an empty marker: hooks fail open.
6
+ import { createHash } from "node:crypto";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { resolveStore } from "../store/paths";
10
+ import type { HookInput } from "./protocol";
11
+
12
+ export type SessionMarker = { nudged: string[]; loaded: string[]; raw: string[] };
13
+
14
+ /**
15
+ * The marker in the workspace's store (null outside one: nothing is nudged or
16
+ * recorded there). `store` names the store directly when the command acts in
17
+ * another checkout than the hook's cwd.
18
+ */
19
+ export const sessionMarkerFile = (
20
+ input: HookInput,
21
+ session: string,
22
+ store?: string,
23
+ ): string | null => {
24
+ let dir = store;
25
+ if (dir === undefined) {
26
+ const location = resolveStore(input.cwd);
27
+ if (location instanceof Error || !location.shared) return null;
28
+ dir = location.dir;
29
+ }
30
+ const name = createHash("sha256").update(`${input.host}\0${session}`).digest("hex").slice(0, 32);
31
+ return path.join(dir, "hooks", `skills-${name}.json`);
32
+ };
33
+
34
+ const list = (value: unknown): string[] =>
35
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
36
+
37
+ export const readSessionMarker = (file: string): SessionMarker => {
38
+ try {
39
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
40
+ return { nudged: list(value.nudged), loaded: list(value.loaded), raw: list(value.raw) };
41
+ } catch {
42
+ return { nudged: [], loaded: [], raw: [] };
43
+ }
44
+ };
45
+
46
+ export const writeSessionMarker = (file: string, marker: SessionMarker): void => {
47
+ try {
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ fs.writeFileSync(file, JSON.stringify(marker));
50
+ } catch {
51
+ // Fail open: without a marker a later nudge repeats.
52
+ }
53
+ };