@brainervirus/workit-core 8.4.1 → 8.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.4.1",
3
+ "version": "8.5.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -15,8 +15,8 @@ proposes and stops. Nothing changes until the user approves.
15
15
  `retro:` row in `workit ledger list --type decision`), else the last ~10
16
16
  sessions. State the window in one line.
17
17
  2. **Read through workit, cheapest first.** Every finding cites these:
18
- - `workit ledger list --last 200`: rulings (ambiguities the agent had to
19
- settle), failed or self verdicts, handoffs, check runs, CI reruns.
18
+ - `workit ledger list --last 200`: rulings, failed/self verdicts, handoffs,
19
+ check runs, CI reruns, `skill.loaded` rows (skipped skills: routing).
20
20
  - `workit pr status --pr <n>` for each recent PR: threads, failing checks.
21
21
  - `git log --oneline -50`, plus reverts and fixups after review.
22
22
  - `workit knowledge lint`: today's AGENTS.md and need-based files.
@@ -1,7 +1,10 @@
1
1
  // Session context shared by every host: the contract bootstrap, the current
2
2
  // task's compact context, and the one-time offer of unfinished tasks.
3
+ import { createHash } from "node:crypto";
4
+ import fs from "node:fs";
3
5
  import path from "node:path";
4
6
  import { invariantBootstrap } from "../core/methods";
7
+ import { resolveStore } from "../store/paths";
5
8
  import { canonicalJson } from "../core/task-contract";
6
9
  import { isObservedCheck, type Freshness } from "../core/task-evaluation";
7
10
  import { worktreeSignal } from "../git/rev";
@@ -287,3 +290,99 @@ export const turnContextText = (input: HookInput, descriptor: HostDescriptor): s
287
290
  return null;
288
291
  }
289
292
  };
293
+
294
+ // Change-only per-turn context. A hook-process host (Claude Code, Codex)
295
+ // keeps each injected additionalContext in the transcript, so the task
296
+ // context is resent only when it changed since the last injection for the
297
+ // session. Each hook is a fresh process: the last digest lives in the
298
+ // workspace store (`hooks/turn-<session hash>.json`). Every cache failure
299
+ // answers "changed", so context is resent rather than lost.
300
+
301
+ const TURN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
302
+
303
+ const digestOf = (text: string): string => createHash("sha256").update(text).digest("hex");
304
+
305
+ /** The session's last-injected digest file, or null when it cannot be told apart or stored. */
306
+ const turnMarker = (input: HookInput): string | null => {
307
+ if (!input.session.id) return null;
308
+ try {
309
+ const location = resolveStore(input.cwd);
310
+ if (location instanceof Error) return null;
311
+ const session = [input.host, input.session.id, input.session.agentId ?? ""].join("\0");
312
+ return path.join(location.dir, "hooks", `turn-${digestOf(session).slice(0, 32)}.json`);
313
+ } catch {
314
+ return null;
315
+ }
316
+ };
317
+
318
+ const writeTurnDigest = (file: string, digest: string): void => {
319
+ try {
320
+ fs.mkdirSync(path.dirname(file), { recursive: true });
321
+ fs.writeFileSync(file, `${JSON.stringify({ digest })}\n`);
322
+ } catch {
323
+ // An unwritable store only means the context is sent every turn.
324
+ }
325
+ };
326
+
327
+ const clearTurnDigest = (file: string): void => {
328
+ try {
329
+ fs.rmSync(file, { force: true });
330
+ } catch {
331
+ // A stale digest only withholds one context equal to it.
332
+ }
333
+ };
334
+
335
+ /** Drops digests untouched for a week (sessions that ended). */
336
+ const pruneTurnDigests = (dir: string, now: number): void => {
337
+ try {
338
+ for (const name of fs.readdirSync(dir)) {
339
+ if (!name.startsWith("turn-")) continue;
340
+ const file = path.join(dir, name);
341
+ try {
342
+ if (now - fs.statSync(file).mtimeMs > TURN_TTL_MS) fs.rmSync(file, { force: true });
343
+ } catch {
344
+ // Raced with another session: nothing to prune.
345
+ }
346
+ }
347
+ } catch {
348
+ // No hooks dir yet.
349
+ }
350
+ };
351
+
352
+ /**
353
+ * `text` when it differs from the last per-turn context injected for this
354
+ * session (recording it), else null.
355
+ */
356
+ export const changedTurnContext = (input: HookInput, text: string | null): string | null => {
357
+ const file = turnMarker(input);
358
+ if (!text) {
359
+ // No context now: the next one is a change, even if it repeats an old one.
360
+ if (file) clearTurnDigest(file);
361
+ return null;
362
+ }
363
+ if (!file) return text;
364
+ const digest = digestOf(text);
365
+ try {
366
+ const previous = JSON.parse(fs.readFileSync(file, "utf8")) as { digest?: unknown };
367
+ if (previous.digest === digest) return null;
368
+ } catch {
369
+ // Missing or unreadable digest: treat as changed.
370
+ }
371
+ writeTurnDigest(file, digest);
372
+ return text;
373
+ };
374
+
375
+ /**
376
+ * A session start already injects the task context (inside the contract), so
377
+ * the per-turn digest is seeded with what the next prompt would carry: the
378
+ * first turn after a start, resume or compaction does not resend it. A
379
+ * session without task context clears its digest instead.
380
+ */
381
+ export const seedTurnContext = (input: HookInput, descriptor: HostDescriptor): void => {
382
+ const file = turnMarker(input);
383
+ if (!file) return;
384
+ pruneTurnDigests(path.dirname(file), Date.now());
385
+ const text = turnContextText(input, descriptor);
386
+ if (text) writeTurnDigest(file, digestOf(text));
387
+ else clearTurnDigest(file);
388
+ };
@@ -27,6 +27,12 @@ export type HostDescriptor = {
27
27
  /** How the session's current task is chosen: by a session bound to the task
28
28
  * record, or the workspace's single active task when sessions never bind. */
29
29
  task: "session-bound" | "single-active";
30
+ /** `on-change`: the host keeps each injected context in the transcript, so
31
+ * the core resends per-turn context only when it changed; `every-turn`:
32
+ * the core sends it on every turn (an in-process system-prompt hook needs
33
+ * it each time, or the host plugin dedups itself); `per-session`: the
34
+ * host plugin sends it once per session and again after compaction. */
35
+ turnResend: "on-change" | "every-turn" | "per-session";
30
36
  };
31
37
  subagents: {
32
38
  identity: Support;
@@ -34,6 +40,9 @@ export type HostDescriptor = {
34
40
  blockStart: Support;
35
41
  worktreeIsolation: Support;
36
42
  maxConcurrency: number | "undocumented";
43
+ /** How a Workit agent type is spelled: plugin-namespaced (`workit:verifier`)
44
+ * on hosts that namespace plugin agents, else a plain `workit-verifier`. */
45
+ agentPrefix: "workit:" | "workit-";
37
46
  };
38
47
  provenance: { sessionId: Support; agentIdOnTool: Support; postToolObserve: Support };
39
48
  /** Host-native interaction boundaries workit can observe. */
@@ -1,5 +1,10 @@
1
1
  // The one host-hook implementation: every host maps its native events here.
2
- import { sessionContextText, turnContextText } from "./context";
2
+ import {
3
+ changedTurnContext,
4
+ seedTurnContext,
5
+ sessionContextText,
6
+ turnContextText,
7
+ } from "./context";
3
8
  import type { HostDescriptor, Support } from "./descriptor";
4
9
  import { shellPolicy } from "./policy";
5
10
  import {
@@ -10,6 +15,13 @@ import {
10
15
  rawGitPre,
11
16
  settlePendingCommit,
12
17
  } from "./raw-git";
18
+ import {
19
+ promptNudge,
20
+ recordSkillLoad,
21
+ shipNudge,
22
+ skillReadIn,
23
+ withContextLine,
24
+ } from "./skill-nudge";
13
25
  import { shellWrites, writeGate } from "./write-gate";
14
26
  import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
15
27
 
@@ -18,16 +30,34 @@ export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
18
30
  const usable = (value: Support) => value === "native" || value === "partial";
19
31
  const NONE: HookDecision = { kind: "none" };
20
32
 
33
+ const resendsOnChange = (descriptor: HostDescriptor): boolean =>
34
+ descriptor.context.turnResend === "on-change" && usable(descriptor.context.perTurn);
35
+
21
36
  /** Sessions already offered unfinished tasks in this process. */
22
37
  const offered = new Set<string>();
23
38
 
24
- /** The Workit plugin's worktree-isolated implementer is the one Claude Code
25
- * subagent that writes. Claude namespaces plugin agents, so only the exact
26
- * `workit:implementer` is ours: a bare or other-plugin `implementer` is not. */
27
- const CLAUDE_WORKTREE_IMPLEMENTER = "workit:implementer";
39
+ const ROLES: Readonly<Record<string, "implementer" | "judge">> = {
40
+ implementer: "implementer",
41
+ verifier: "judge",
42
+ reviewer: "judge",
43
+ };
28
44
 
29
- /** The plugin's read-only judges: each records verdicts as its own session. */
30
- const CLAUDE_JUDGES: ReadonlySet<string> = new Set(["workit:verifier", "workit:reviewer"]);
45
+ /**
46
+ * The Workit agent role an agent type names, spelled the host's way
47
+ * (`descriptor.subagents.agentPrefix`): Claude Code namespaces plugin agents
48
+ * (`workit:implementer`, so a bare `workit-implementer` is someone else's);
49
+ * other hosts name custom agents freely (`workit-implementer`). The
50
+ * implementer is the one subagent that writes; verifier and reviewer are
51
+ * read-only judges that record verdicts as their own session.
52
+ */
53
+ const workitRole = (
54
+ descriptor: HostDescriptor,
55
+ agentType: string,
56
+ ): "implementer" | "judge" | null => {
57
+ const prefix = descriptor.subagents.agentPrefix;
58
+ const name = agentType.startsWith(prefix) ? agentType.slice(prefix.length) : "";
59
+ return Object.hasOwn(ROLES, name) ? ROLES[name] : null;
60
+ };
31
61
 
32
62
  /** `<lead session>:<agent id>`, safe for the ledger's session grammar. */
33
63
  const subagentSession = (session: string | null, agentId: string): string =>
@@ -38,22 +68,24 @@ const subagentSession = (session: string | null, agentId: string): string =>
38
68
  .slice(0, 128);
39
69
 
40
70
  const subagentStartText = (
41
- host: HookInput["host"],
42
71
  session: string | null,
43
72
  descriptor: HostDescriptor,
44
73
  event: Extract<HookInput["event"], { kind: "subagent.start" }>,
45
74
  ): string => {
46
- if (host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER)
47
- return `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) working in its own git worktree: it may edit and commit there, 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.`;
48
- const readOnly = `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided.`;
49
- if (
50
- host === "claude_code" &&
51
- event.agentType &&
52
- CLAUDE_JUDGES.has(event.agentType) &&
53
- event.agentId
54
- )
55
- return `${readOnly} Its own Workit session is ${subagentSession(session, event.agentId)}: record verdicts with \`workit ledger verdict <result> --session ${subagentSession(session, event.agentId)} ...\` so the ledger tells it apart from the author.`;
56
- return readOnly;
75
+ const role = workitRole(descriptor, event.agentType);
76
+ 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
+ }
85
+ const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
86
+ if (role !== "judge" || !event.agentId) return readOnly;
87
+ const own = subagentSession(session, event.agentId);
88
+ return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> --session ${own} ...\` so the ledger tells it apart from the author.`;
57
89
  };
58
90
 
59
91
  export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
@@ -71,17 +103,22 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
71
103
  ? null
72
104
  : `Workit cannot gate file writes on ${descriptor.label} (no pre-write hook): before-write requirements (an open product choice, a missing plan) are advisory here, so settle them before editing.`;
73
105
  const addendum = [deps.addendum, advisory].filter(Boolean).join("\n") || null;
74
- return {
75
- kind: "context",
76
- text: sessionContextText(input, descriptor, { offer, addendum }),
77
- };
106
+ const text = sessionContextText(input, descriptor, { offer, addendum });
107
+ if (resendsOnChange(descriptor)) seedTurnContext(input, descriptor);
108
+ return { kind: "context", text };
78
109
  }
79
110
  case "context.turn": {
80
111
  if (!usable(descriptor.context.perTurn)) return NONE;
81
- const text = turnContextText(input, descriptor);
82
- return text ? { kind: "context", text } : NONE;
112
+ const current = turnContextText(input, descriptor);
113
+ const text = resendsOnChange(descriptor) ? changedTurnContext(input, current) : current;
114
+ return withContextLine(
115
+ text ? { kind: "context", text } : NONE,
116
+ promptNudge(input, event.prompt),
117
+ );
83
118
  }
84
119
  case "shell.pre": {
120
+ const skillRead = skillReadIn(event.command, event.dialect);
121
+ if (skillRead) recordSkillLoad(input, skillRead, "read");
85
122
  const canDeny = usable(descriptor.shellPolicy.deny);
86
123
  const policy = canDeny ? shellPolicy(input.cwd, event.command) : NONE;
87
124
  if (policy.kind !== "none") return policy;
@@ -102,7 +139,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
102
139
  event.command,
103
140
  postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
104
141
  );
105
- return raw;
142
+ return withContextLine(raw, shipNudge(input, event.command));
106
143
  }
107
144
  case "shell.post":
108
145
  if (usable(descriptor.events["shell.post"].support))
@@ -115,7 +152,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
115
152
  case "subagent.start":
116
153
  return {
117
154
  kind: "context",
118
- text: subagentStartText(input.host, input.session.id ?? null, descriptor, event),
155
+ text: subagentStartText(input.session.id ?? null, descriptor, event),
119
156
  };
120
157
  case "compact.pre":
121
158
  return {
@@ -123,9 +160,12 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
123
160
  userMessage:
124
161
  "Workit context may be stale after compaction; re-run inspection or resume before acting.",
125
162
  };
126
- // Host permission policy owns other tools; attestation, prompt and stop
127
- // control arrive with the CLI-observed evidence model.
128
163
  case "tool.pre":
164
+ // Host permission policy owns other tools; a skill load is recorded.
165
+ if (event.skill) recordSkillLoad(input, event.skill, "tool");
166
+ return NONE;
167
+ // Attestation, prompt and stop control arrive with the CLI-observed
168
+ // evidence model.
129
169
  case "subagent.stop":
130
170
  case "prompt.submit":
131
171
  case "stop":
@@ -15,7 +15,7 @@ import {
15
15
  isWriteTool,
16
16
  nonEmpty,
17
17
  optionalText,
18
- writePaths,
18
+ writeTargets,
19
19
  } from "./fields";
20
20
 
21
21
  type ClaudeHookEvent =
@@ -43,11 +43,14 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
43
43
  "shell.post": { support: "native", native: "PostToolUse" },
44
44
  // SubagentStart output is additionalContext only: it cannot block or bind.
45
45
  "subagent.start": { support: "native", native: "SubagentStart" },
46
- "subagent.stop": { support: "native", native: "SubagentStop" },
47
46
  "prompt.submit": { support: "native", native: "UserPromptSubmit" },
48
- // PreCompact cannot inject context (only a systemMessage); restore runs on SessionStart source=compact.
49
- "compact.pre": { support: "partial", native: "PreCompact" },
50
- stop: { support: "native", native: "Stop" },
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
50
+ // SessionStart source=compact.
51
+ "subagent.stop": { support: "none", native: null },
52
+ "compact.pre": { support: "none", native: null },
53
+ stop: { support: "none", native: null },
51
54
  },
52
55
  shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
53
56
  context: {
@@ -55,6 +58,9 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
55
58
  perTurn: "native",
56
59
  afterCompact: "native",
57
60
  task: "session-bound",
61
+ // The plugin's src/hook.ts dedups per-turn context itself (its own cache)
62
+ // until it moves onto the core's on-change resend.
63
+ turnResend: "every-turn",
58
64
  },
59
65
  subagents: {
60
66
  identity: "native",
@@ -62,6 +68,8 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
62
68
  blockStart: "none",
63
69
  worktreeIsolation: "native",
64
70
  maxConcurrency: "undocumented",
71
+ // Plugin agents are namespaced: `workit:implementer`, never a bare `workit-implementer`.
72
+ agentPrefix: "workit:",
65
73
  },
66
74
  provenance: { sessionId: "native", agentIdOnTool: "native", postToolObserve: "native" },
67
75
  interaction: { questions: "undocumented", writeBoundary: "partial" },
@@ -79,13 +87,14 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
79
87
  },
80
88
  {
81
89
  name: "native_subagents",
82
- surface: "SubagentStart/SubagentStop",
83
- refs: ["SubagentStart", "SubagentStop"],
84
- requires: ["event:subagent.start", "event:subagent.stop"],
85
- observed: ["subagent.start", "subagent.stop"],
90
+ // SubagentStop is not registered: identity and guidance come from SubagentStart.
91
+ surface: "SubagentStart",
92
+ refs: ["SubagentStart"],
93
+ requires: ["event:subagent.start"],
94
+ observed: ["subagent.start"],
86
95
  assurance: "agent_guided",
87
96
  reason: "Claude Code reports stable agent identities, but SubagentStart cannot block or bind",
88
- unavailableReason: "Claude Code subagent lifecycle hooks are unavailable",
97
+ unavailableReason: "Claude Code SubagentStart is unavailable",
89
98
  },
90
99
  {
91
100
  name: "fresh-context-review",
@@ -148,7 +157,7 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
148
157
  ? { ok: true, event: { kind: "session.start", source: value.source as SessionSource } }
149
158
  : { ok: false, error: "SessionStart source is required" };
150
159
  case "UserPromptSubmit":
151
- return { ok: true, event: { kind: "context.turn" } };
160
+ return { ok: true, event: { kind: "context.turn", prompt: optionalText(value.prompt) } };
152
161
  case "PreToolUse": {
153
162
  if (!nonEmpty(value.tool_name)) return { ok: false, error: "tool_name is required" };
154
163
  if (isWriteTool(value.tool_name))
@@ -157,8 +166,18 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
157
166
  event: {
158
167
  kind: "write.pre",
159
168
  tool: value.tool_name,
160
- paths: writePaths(value.tool_input),
169
+ paths: writeTargets(value.tool_input),
170
+ toolUseId,
171
+ },
172
+ };
173
+ if (value.tool_name === "Skill")
174
+ return {
175
+ ok: true,
176
+ event: {
177
+ kind: "tool.pre",
178
+ tool: value.tool_name,
161
179
  toolUseId,
180
+ skill: isRecord(value.tool_input) ? optionalText(value.tool_input.skill) : null,
162
181
  },
163
182
  };
164
183
  if (!SHELL_TOOLS.has(value.tool_name))
@@ -1,16 +1,26 @@
1
1
  // Codex CLI and Desktop: command hooks (hooks/hooks.json) mapped onto the protocol.
2
2
  import type { HostDescriptor } from "../descriptor";
3
3
  import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
4
- import { commandText, existingDirectory, isRecord, nonEmpty, optionalText } from "./fields";
4
+ import {
5
+ commandText,
6
+ existingDirectory,
7
+ isRecord,
8
+ isWriteTool,
9
+ nonEmpty,
10
+ optionalText,
11
+ writeTargets,
12
+ } from "./fields";
5
13
 
6
14
  export type CodexHost = "codex_cli" | "codex_desktop";
7
15
  export type CodexHookEvent =
8
16
  | "SessionStart"
17
+ | "UserPromptSubmit"
9
18
  | "PreToolUse"
10
19
  | "PostToolUse"
11
20
  | "SubagentStart"
12
21
  | "SubagentStop";
13
- type SessionSource = "startup" | "resume" | "clear" | "compact";
22
+ // The session-start.command.input schema in codex-cli 0.160.1 lists fork as a source.
23
+ type SessionSource = "startup" | "resume" | "clear" | "compact" | "fork";
14
24
 
15
25
  export type CodexHookInput = {
16
26
  hook_event_name: CodexHookEvent;
@@ -21,6 +31,8 @@ export type CodexHookInput = {
21
31
  transcript_path: string | null;
22
32
  source?: SessionSource;
23
33
  turn_id?: string;
34
+ /** UserPromptSubmit: the prompt about to be sent. */
35
+ prompt?: string | null;
24
36
  tool_name?: string;
25
37
  tool_input?: unknown;
26
38
  /** The shell command as one string; argv arrays are joined. */
@@ -44,35 +56,43 @@ export function detectCodexSurface(env: NodeJS.ProcessEnv): CodexHost {
44
56
  : "codex_cli";
45
57
  }
46
58
 
47
- const undocumented = { support: "undocumented", native: null } as const;
48
-
49
59
  export const CODEX_DESCRIPTOR: HostDescriptor = {
50
60
  host: "codex_cli",
51
61
  label: "Codex",
52
- verifiedAgainst: "codex-cli 0.153.4; Codex Desktop 26.901.20858",
62
+ // The hook JSON schemas embedded in the codex-cli 0.160.1 binary
63
+ // (pre-tool-use, user-prompt-submit, subagent-start .command.input/output)
64
+ // and the hooks page: "PreToolUse can intercept Bash, file edits performed
65
+ // through apply_patch, MCP tool calls…"; apply_patch arrives as tool_name
66
+ // "apply_patch" with the patch in tool_input.command (Edit and Write are
67
+ // matcher aliases only).
68
+ verifiedAgainst:
69
+ "codex-cli 0.160.1 (hook JSON schemas in the binary); Codex Desktop 26.901.20858",
53
70
  docs: ["https://developers.openai.com/codex/hooks"],
54
71
  transport: "hook-process",
55
72
  events: {
56
73
  "session.start": { support: "native", native: "SessionStart" },
57
- "context.turn": undocumented,
74
+ "context.turn": { support: "native", native: "UserPromptSubmit" },
58
75
  "shell.pre": { support: "native", native: "PreToolUse" },
59
76
  "tool.pre": { support: "native", native: "PreToolUse" },
60
- // Codex PreToolUse intercepts shell calls, not apply_patch edits: the
61
- // before-write gate stays advisory rather than half-enforced.
62
- "write.pre": { support: "undocumented", native: null },
77
+ // PreToolUse covers apply_patch; the files come from the patch body.
78
+ "write.pre": { support: "native", native: "PreToolUse" },
63
79
  "shell.post": { support: "native", native: "PostToolUse" },
64
80
  "subagent.start": { support: "native", native: "SubagentStart" },
65
81
  "subagent.stop": { support: "native", native: "SubagentStop" },
66
- "prompt.submit": undocumented,
82
+ "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).
67
85
  "compact.pre": { support: "none", native: null },
68
- stop: undocumented,
86
+ stop: { support: "none", native: null },
69
87
  },
70
88
  shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
71
89
  context: {
72
90
  sessionStart: "native",
73
- perTurn: "undocumented",
91
+ perTurn: "native",
74
92
  afterCompact: "native",
75
93
  task: "single-active",
94
+ // UserPromptSubmit additionalContext stays in the thread as developer context.
95
+ turnResend: "on-change",
76
96
  },
77
97
  subagents: {
78
98
  identity: "native",
@@ -80,6 +100,7 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
80
100
  blockStart: "none",
81
101
  worktreeIsolation: "undocumented",
82
102
  maxConcurrency: "undocumented",
103
+ agentPrefix: "workit-",
83
104
  },
84
105
  provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "native" },
85
106
  interaction: { questions: "none", writeBoundary: "partial" },
@@ -152,6 +173,7 @@ export const codexDescriptor = (host: CodexHost): HostDescriptor => ({ ...CODEX_
152
173
 
153
174
  const EVENTS: Record<CodexHookEvent, HookEventKind> = {
154
175
  SessionStart: "session.start",
176
+ UserPromptSubmit: "context.turn",
155
177
  PreToolUse: "shell.pre",
156
178
  PostToolUse: "shell.post",
157
179
  SubagentStart: "subagent.start",
@@ -159,7 +181,7 @@ const EVENTS: Record<CodexHookEvent, HookEventKind> = {
159
181
  };
160
182
  const SHELL_TOOLS = new Set(["bash", "unified-exec"]);
161
183
  const isShellTool = (name: unknown) => SHELL_TOOLS.has(String(name).toLowerCase());
162
- const SOURCES = new Set<string>(["startup", "resume", "clear", "compact"]);
184
+ const SOURCES = new Set<string>(["startup", "resume", "clear", "compact", "fork"]);
163
185
 
164
186
  /**
165
187
  * Read a Codex payload, checking only what each mapping needs (D17): the
@@ -195,6 +217,7 @@ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
195
217
  ? { source: SOURCES.has(String(value.source)) ? (value.source as SessionSource) : "resume" }
196
218
  : {}),
197
219
  ...(nonEmpty(value.turn_id) ? { turn_id: value.turn_id } : {}),
220
+ ...(event === "UserPromptSubmit" ? { prompt: optionalText(value.prompt) } : {}),
198
221
  ...(nonEmpty(value.tool_name) ? { tool_name: value.tool_name } : {}),
199
222
  ...(toolEvent ? { tool_input: value.tool_input } : {}),
200
223
  ...(event === "PostToolUse" && typeof value.tool_response === "string"
@@ -219,15 +242,21 @@ const protocolEvent = (input: CodexHookInput): HookEvent => {
219
242
  switch (input.hook_event_name) {
220
243
  case "SessionStart":
221
244
  return { kind: "session.start", source: input.source! };
245
+ case "UserPromptSubmit":
246
+ return { kind: "context.turn", prompt: input.prompt ?? null };
222
247
  case "PreToolUse": {
223
248
  const toolUseId = input.tool_use_id ?? null;
224
- return isShellTool(input.tool_name)
225
- ? {
226
- kind: "shell.pre",
227
- command: input.command ?? "",
228
- toolUseId,
229
- }
230
- : { kind: "tool.pre", tool: input.tool_name!, toolUseId };
249
+ if (isShellTool(input.tool_name))
250
+ return { kind: "shell.pre", command: input.command ?? "", toolUseId };
251
+ // apply_patch: the files are the patch body's `*** … File:` headers.
252
+ if (isWriteTool(input.tool_name))
253
+ return {
254
+ kind: "write.pre",
255
+ tool: input.tool_name!,
256
+ paths: writeTargets(input.tool_input),
257
+ toolUseId,
258
+ };
259
+ return { kind: "tool.pre", tool: input.tool_name!, toolUseId };
231
260
  }
232
261
  case "PostToolUse": {
233
262
  const toolUseId = input.tool_use_id ?? null;
@@ -313,5 +342,5 @@ export const codexAdapter: HostAdapter = {
313
342
  },
314
343
  render,
315
344
  addendum: (input) =>
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>`,
345
+ `<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 its own session, never the author's: a workit-verifier or workit-reviewer agent uses the --session its SubagentStart context names; any other uses one the lead assigns (WORKIT_HOST=codex_cli WORKIT_SESSION_ID=<lead>-v<n>). Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
317
346
  };
@@ -2,7 +2,7 @@
2
2
  import path from "node:path";
3
3
  import type { HostDescriptor } from "../descriptor";
4
4
  import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
5
- import { existingDirectory, isRecord, isWriteTool, nonEmpty, writePaths } from "./fields";
5
+ import { existingDirectory, isRecord, isWriteTool, nonEmpty, writeTargets } from "./fields";
6
6
 
7
7
  export type CursorHookEvent =
8
8
  | "sessionStart"
@@ -53,7 +53,8 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
53
53
  "prompt.submit": undocumented,
54
54
  // preCompact can only show a user message.
55
55
  "compact.pre": { support: "partial", native: "preCompact" },
56
- stop: undocumented,
56
+ // Cursor has a stop hook (followup_message), but workit registers none yet.
57
+ stop: none,
57
58
  },
58
59
  shellPolicy: { deny: "native", channel: "exit2+json", failClosed: true },
59
60
  context: {
@@ -61,6 +62,7 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
61
62
  perTurn: "none",
62
63
  afterCompact: "partial",
63
64
  task: "single-active",
65
+ turnResend: "every-turn",
64
66
  },
65
67
  subagents: {
66
68
  identity: "native",
@@ -68,6 +70,7 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
68
70
  blockStart: "native",
69
71
  worktreeIsolation: "undocumented",
70
72
  maxConcurrency: "undocumented",
73
+ agentPrefix: "workit-",
71
74
  },
72
75
  provenance: {
73
76
  sessionId: "native",
@@ -220,7 +223,7 @@ const protocolEvent = (input: CursorHookInput): HookEvent => {
220
223
  ? {
221
224
  kind: "write.pre",
222
225
  tool: input.tool_name ?? "",
223
- paths: writePaths(input.tool_input),
226
+ paths: writeTargets(input.tool_input),
224
227
  toolUseId: null,
225
228
  }
226
229
  : { kind: "tool.pre", tool: input.tool_name ?? "", toolUseId: null };
@@ -78,13 +78,21 @@ export const isWriteTool = (name: unknown): boolean =>
78
78
  typeof name === "string" && WRITE_TOOLS.has(name.toLowerCase().replace(/[_-]/g, ""));
79
79
 
80
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
+
81
84
  /** The files a write tool names: its path fields, or the files of an apply_patch body. */
82
85
  export const writePaths = (input: unknown): string[] => {
83
86
  if (!isRecord(input)) return [];
84
87
  const paths = PATH_KEYS.map((key) => input[key]).filter(nonEmpty);
85
88
  for (const value of Object.values(input))
86
89
  if (typeof value === "string")
87
- for (const match of value.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm))
88
- paths.push(match[1].trim());
90
+ for (const match of value.matchAll(PATCH_HEADER)) paths.push(match[1].trim());
89
91
  return [...new Set(paths)];
90
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
+ };
@@ -19,7 +19,8 @@ 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
- stop: { support: "partial", native: "session.idle" },
22
+ // OpenCode reports session.idle, but the plugin registers no stop hook yet.
23
+ stop: { support: "none", native: null },
23
24
  },
24
25
  shellPolicy: { deny: "native", channel: "effect", failClosed: false },
25
26
  context: {
@@ -27,6 +28,8 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
27
28
  perTurn: "native",
28
29
  afterCompact: "native",
29
30
  task: "session-bound",
31
+ // The context hook rebuilds the system prompt on every agent-loop call.
32
+ turnResend: "every-turn",
30
33
  },
31
34
  subagents: {
32
35
  identity: "native",
@@ -34,6 +37,7 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
34
37
  blockStart: "native",
35
38
  worktreeIsolation: "undocumented",
36
39
  maxConcurrency: "undocumented",
40
+ agentPrefix: "workit-",
37
41
  },
38
42
  provenance: { sessionId: "native", agentIdOnTool: "native", postToolObserve: "native" },
39
43
  interaction: { questions: "native", writeBoundary: "partial" },
@@ -24,9 +24,12 @@ export const PI_DESCRIPTOR: HostDescriptor = {
24
24
  shellPolicy: { deny: "native", channel: "block", failClosed: false },
25
25
  context: {
26
26
  sessionStart: "native",
27
- perTurn: "native",
27
+ // before_agent_start runs every turn, but the extension injects the task
28
+ // context once per session and again after compaction, never on change.
29
+ perTurn: "partial",
28
30
  afterCompact: "native",
29
31
  task: "session-bound",
32
+ turnResend: "per-session",
30
33
  },
31
34
  subagents: {
32
35
  identity: "none",
@@ -34,6 +37,7 @@ export const PI_DESCRIPTOR: HostDescriptor = {
34
37
  blockStart: "none",
35
38
  worktreeIsolation: "none",
36
39
  maxConcurrency: "undocumented",
40
+ agentPrefix: "workit-",
37
41
  },
38
42
  provenance: { sessionId: "native", agentIdOnTool: "none", postToolObserve: "native" },
39
43
  interaction: { questions: "native", writeBoundary: "native" },
@@ -19,6 +19,7 @@ export {
19
19
  export { shellPolicy } from "./policy";
20
20
  export { shellWrites, writeGate } from "./write-gate";
21
21
  export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
22
+ export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
22
23
  export { isWriteTool, writePaths } from "./hosts/fields";
23
24
  export { dispatchHook, failureDecision, handleHook, type HookDeps } from "./handle";
24
25
  export { HOOK_ADAPTERS, runHookProcess } from "./run";
@@ -16,7 +16,11 @@ export type SessionSource = "startup" | "resume" | "clear" | "compact" | "fork";
16
16
  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
- | { kind: "context.turn" }
19
+ | {
20
+ kind: "context.turn";
21
+ /** The submitted prompt, on hosts whose per-turn event carries it. */
22
+ prompt?: string | null;
23
+ }
20
24
  | {
21
25
  kind: "shell.pre";
22
26
  command: string;
@@ -25,9 +29,21 @@ export type HookEvent =
25
29
  dialect?: "posix" | "powershell";
26
30
  }
27
31
  /** A pre-tool gate for a non-shell tool. Host permission policy owns these. */
28
- | { kind: "tool.pre"; tool: string; toolUseId: string | null }
32
+ | {
33
+ kind: "tool.pre";
34
+ tool: string;
35
+ toolUseId: string | null;
36
+ /** The skill a skill-loading tool loads (Claude Code's Skill tool). */
37
+ skill?: string | null;
38
+ }
29
39
  /** A file-writing tool (Edit, Write, apply_patch…): the before-write gate (S17). */
30
- | { kind: "write.pre"; tool: string; paths: string[]; toolUseId: string | null }
40
+ | {
41
+ kind: "write.pre";
42
+ tool: string;
43
+ /** The files written; null when they cannot be read (unknown targets, gated). */
44
+ paths: string[] | null;
45
+ toolUseId: string | null;
46
+ }
31
47
  | {
32
48
  kind: "shell.post";
33
49
  command: string;
@@ -438,6 +438,19 @@ const actionsIn = (cwd: string, command: string, dialect: ShellDialect): Located
438
438
  return out;
439
439
  };
440
440
 
441
+ /** Is `dir` in a repository Workit manages? */
442
+ export const inWorkitWorkspace = (dir: string): boolean => {
443
+ const repo = repoAt(dir);
444
+ return repo !== null && isWorkitWorkspace(repo);
445
+ };
446
+
447
+ /** A raw push, PR/MR create or merge in a Workit workspace (help and dry runs excluded). */
448
+ export const rawDelivery = (cwd: string, command: string, dialect: ShellDialect): boolean =>
449
+ mentionsRawTool(command) &&
450
+ actionsIn(cwd, command, dialect).some(
451
+ ({ action }) => action.kind !== "commit" && action.kind !== "pr-read",
452
+ );
453
+
441
454
  /** Cheap pre-filter: no git/gh/glab word, nothing to do. */
442
455
  const mentionsRawTool = (command: string) => /\b(?:git|gh|glab)\b/i.test(command);
443
456
 
@@ -0,0 +1,333 @@
1
+ // Skill routing at the moment it matters. The session-start contract names a
2
+ // trigger for each skill, but in a long or compacted session that table loses
3
+ // weight and agents act without loading the skill. Three deterministic aids,
4
+ // inside a Workit workspace only:
5
+ // - a prompt that asks for a skill's work in so many words gets one
6
+ // advisory line naming the skill;
7
+ // - a delivery command (push, PR create or merge, CI wait) gets one line
8
+ // naming workit-ship;
9
+ // - each skill load is recorded as a `skill.loaded` ledger row, so a nudge
10
+ // stops once the skill is loaded and workit-retro can count skill use.
11
+ // A session is nudged about one skill at most once (a marker per session
12
+ // in the workspace store), never in a subagent, never once it loaded the skill.
13
+ // Every failure answers "no nudge": hooks fail open.
14
+ import { createHash } from "node:crypto";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import {
18
+ skillLoadWording,
19
+ WORKIT_METHOD_SKILLS,
20
+ WORKIT_SKILL_ALIASES,
21
+ type SkillHost,
22
+ type WorkitSkill,
23
+ } from "../core/skill-manifests";
24
+ import { appendHookObserved, readLedger } from "../ledger";
25
+ import { resolveStore } from "../store/paths";
26
+ import type { HookDecision, HookInput, HostId } from "./protocol";
27
+ import { inWorkitWorkspace, rawDelivery, rawGitPre } from "./raw-git";
28
+ import { segmentsOf, type ShellDialect } from "./shell-words";
29
+
30
+ /** Skills the user starts; a nudge says to offer them, never to load them. */
31
+ const USER_INVOKED: ReadonlySet<WorkitSkill> = new Set(["workit-retro", "workit-architecture"]);
32
+
33
+ const SKILL_HOST: Record<HostId, SkillHost> = {
34
+ claude_code: "claude-code",
35
+ opencode: "opencode",
36
+ codex_cli: "codex",
37
+ codex_desktop: "codex",
38
+ cursor: "cursor",
39
+ pi: "pi",
40
+ };
41
+
42
+ /**
43
+ * Prompt phrasings that ask for a skill's work. The contract's single-word
44
+ * triggers (plan, build, merge, CI) are for the model's judgment; a hook
45
+ * cannot tell "plan the cache" from "what's in the plan?", so it matches
46
+ * phrases only. Ordered by precedence: a broken thing is debug work before
47
+ * it is a feature or a plan.
48
+ */
49
+ const PROMPT_INTENTS: ReadonlyArray<readonly [WorkitSkill, RegExp]> = [
50
+ [
51
+ "workit-debug",
52
+ /\b(flaky|regression|(?:is|are|got|looks) broken|debug (?:this|it|the|why)|fix (?:the|this|a) bug)\b/i,
53
+ ],
54
+ [
55
+ "workit-continue",
56
+ /\b(pick up where|where (?:were|did) we|resume (?:the|this|my|our|work|where)|hand ?off)\b/i,
57
+ ],
58
+ [
59
+ "workit-review",
60
+ /\b(blast radius|code review|review (?:the|this|my|PR|pr|#\d+|branch|diff|changes))\b/i,
61
+ ],
62
+ [
63
+ "workit-ship",
64
+ /\b(ship (?:it|this|the)|babysit|open (?:a|the) (?:PR|pr|MR|mr)|merge (?:the|this|my) (?:PR|pr|MR|mr|branch)|(?:get|until|make) CI (?:is )?green)\b/i,
65
+ ],
66
+ ["workit-bdd", /\b(BDD|TDD|acceptance criteria|Given\/When\/Then)(?![\w/])/],
67
+ ["workit-test-audit", /\b(test audit|audit the tests|tautolog\w*|weak tests)\b/i],
68
+ ["workit-deslop", /\b(deslop|slop|dead code)\b/i],
69
+ ["workit-fanout", /\b(fan(?: |-)?out|parallelize|parallel agents|swarm)\b/i],
70
+ ["workit-verify-app", /\b(verify the app|smoke test|prove it works)\b/i],
71
+ ["workit-retro", /\b(retro|retrospective)\b(?!\.\w)/i],
72
+ ["workit-architecture", /\b(deepen (?:the )?modules|architecture (?:review|pass|audit))\b/i],
73
+ [
74
+ "workit-shape",
75
+ /\b(brainstorm\w*|grill me|should we|let'?s plan|plan (?:out|how|the|a|for)|(?:write|draft) (?:a|the) spec)\b/i,
76
+ ],
77
+ [
78
+ "workit-implement",
79
+ /\b(implement (?:the|this|a|it)|add (?:a|the) feature|build (?:a|an|the) (?:new )?(?:feature|command|verb|page|endpoint|component|cli|tool))\b/i,
80
+ ],
81
+ ];
82
+
83
+ /** The workit skill a loaded name refers to (`workit:shape`, `workit-shape`, `wk-shape`). */
84
+ export const workitSkillOf = (name: string): WorkitSkill | null => {
85
+ const bare = name.trim().replace(/^\//, "").toLowerCase();
86
+ const alias = (WORKIT_SKILL_ALIASES as Record<string, WorkitSkill>)[bare];
87
+ if (alias) return alias;
88
+ const skill = bare.replace(/^workit:/, "workit-");
89
+ return (WORKIT_METHOD_SKILLS as readonly string[]).includes(skill)
90
+ ? (skill as WorkitSkill)
91
+ : null;
92
+ };
93
+
94
+ /** The skill a prompt asks for, by precedence, with the phrase that did. */
95
+ export const promptTrigger = (prompt: string): { skill: WorkitSkill; trigger: string } | null => {
96
+ // Code blocks and quoted output carry words the user did not ask with.
97
+ const text = prompt.replace(/```[\s\S]*?```/g, " ").slice(0, 4000);
98
+ for (const [skill, pattern] of PROMPT_INTENTS) {
99
+ const match = pattern.exec(text);
100
+ if (match) return { skill, trigger: match[1] ?? match[0] };
101
+ }
102
+ return null;
103
+ };
104
+
105
+ /** Per-session nudge state: skills nudged or loaded, so each nudges once. */
106
+ type Marker = { nudged: string[]; loaded: string[] };
107
+
108
+ /** The marker in the workspace's store (null outside one: nothing is nudged or recorded there). */
109
+ const markerFile = (input: HookInput, session: string): string | null => {
110
+ const location = resolveStore(input.cwd);
111
+ if (location instanceof Error || !location.shared) return null;
112
+ const name = createHash("sha256").update(`${input.host}\0${session}`).digest("hex").slice(0, 32);
113
+ return path.join(location.dir, "hooks", `skills-${name}.json`);
114
+ };
115
+
116
+ const readMarker = (file: string): Marker => {
117
+ try {
118
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Marker>;
119
+ return { nudged: value.nudged ?? [], loaded: value.loaded ?? [] };
120
+ } catch {
121
+ return { nudged: [], loaded: [] };
122
+ }
123
+ };
124
+
125
+ const writeMarker = (file: string, marker: Marker): void => {
126
+ try {
127
+ fs.mkdirSync(path.dirname(file), { recursive: true });
128
+ fs.writeFileSync(file, JSON.stringify(marker));
129
+ } catch {
130
+ // Fail open: without a marker a later nudge repeats.
131
+ }
132
+ };
133
+
134
+ /** Skills this session already loaded, from its `skill.loaded` rows. */
135
+ const loadedSkills = (cwd: string, session: string): Set<string> => {
136
+ const ledger = readLedger(cwd);
137
+ const loaded = new Set<string>();
138
+ if (!ledger.ok) return loaded;
139
+ for (const row of ledger.value.rows)
140
+ if (
141
+ row.type === "skill.loaded" &&
142
+ row.actor.session === session &&
143
+ typeof row.skill === "string"
144
+ )
145
+ loaded.add(row.skill);
146
+ return loaded;
147
+ };
148
+
149
+ const SKILL_FILE = /(?:^|[\\/])(workit-[a-z-]+)[\\/]SKILL\.md$/;
150
+
151
+ /** The workit skill whose SKILL.md a path names (Pi's read tool). */
152
+ export const skillFileIn = (file: string): string | null => SKILL_FILE.exec(file)?.[1] ?? null;
153
+
154
+ /** Commands that read a file for the agent: a shell-loaded skill is one of these on its SKILL.md. */
155
+ const READERS = new Set([
156
+ "cat",
157
+ "sed",
158
+ "head",
159
+ "tail",
160
+ "less",
161
+ "more",
162
+ "bat",
163
+ "nl",
164
+ "get-content",
165
+ "gc",
166
+ "type",
167
+ ]);
168
+
169
+ /** The workit skill whose SKILL.md a shell command reads (Codex, Cursor); never git, grep or an editor. */
170
+ export const skillReadIn = (command: string, dialect: ShellDialect = "posix"): string | null => {
171
+ for (const { words } of segmentsOf(command, dialect)) {
172
+ if (!READERS.has((words[0]?.split(/[\\/]/).at(-1) ?? "").toLowerCase())) continue;
173
+ for (const word of words.slice(1)) {
174
+ const skill = skillFileIn(word);
175
+ if (skill) return skill;
176
+ }
177
+ }
178
+ return null;
179
+ };
180
+
181
+ /** Record that the session loaded a workit skill (`via`: the tool, a slash command, a file read). */
182
+ export function recordSkillLoad(
183
+ input: HookInput,
184
+ name: string,
185
+ via: "tool" | "slash" | "read",
186
+ ): void {
187
+ try {
188
+ const skill = workitSkillOf(name);
189
+ const session = input.session.id;
190
+ if (!skill || !session) return;
191
+ if (!inWorkitWorkspace(input.cwd)) return;
192
+ const file = markerFile(input, session);
193
+ if (!file) return;
194
+ const marker = readMarker(file);
195
+ if (marker.loaded.includes(skill)) return;
196
+ writeMarker(file, { ...marker, loaded: [...marker.loaded, skill] });
197
+ if (loadedSkills(input.cwd, session).has(skill)) return;
198
+ appendHookObserved(input.cwd, {
199
+ type: "skill.loaded",
200
+ actor: { host: input.host, session, agentId: input.session.agentId },
201
+ skill,
202
+ via,
203
+ });
204
+ } catch {
205
+ // Fail open: a lost row only means a later nudge or a retro miscount.
206
+ }
207
+ }
208
+
209
+ /** The nudge for `skill` once per session: null when it was nudged or loaded. */
210
+ const nudgeOnce = (
211
+ input: HookInput,
212
+ session: string,
213
+ skill: WorkitSkill,
214
+ line: string,
215
+ ): string | null => {
216
+ const file = markerFile(input, session);
217
+ if (!file) return null;
218
+ const marker = readMarker(file);
219
+ if (marker.nudged.includes(skill) || marker.loaded.includes(skill)) return null;
220
+ if (loadedSkills(input.cwd, session).has(skill)) return null;
221
+ writeMarker(file, { ...marker, nudged: [...marker.nudged, skill] });
222
+ return line;
223
+ };
224
+
225
+ const nudgeText = (input: HookInput, skill: WorkitSkill, why: string): string =>
226
+ USER_INVOKED.has(skill)
227
+ ? `Workit: ${why} looks like ${skill} work, which the user starts; offer it (/${skill.replace(/^workit-/, "wk-")}) rather than loading it.`
228
+ : `Workit: ${why} looks like ${skill} work; if it is, ${skillLoadWording(SKILL_HOST[input.host], skill)} first.`;
229
+
230
+ /** A skill the prompt already loads: `/wk-shape`, `/workit:shape`, Pi's expanded
231
+ * `/skill:` block, or OpenCode's rewritten `/wk-*` command. */
232
+ const promptLoads = (prompt: string): string | null =>
233
+ /^\s*\/(?:skill:)?([\w:-]+)/.exec(prompt)?.[1] ??
234
+ /^\s*<skill name="([\w:-]+)"/.exec(prompt)?.[1] ??
235
+ /^\s*Load the (workit-[a-z-]+) skill with the skill tool\b/.exec(prompt)?.[1] ??
236
+ null;
237
+
238
+ /** A main session's prompt in a Workit workspace: one advisory line naming the skill it asks for. */
239
+ export function promptNudge(input: HookInput, prompt: string | null | undefined): string | null {
240
+ try {
241
+ const session = input.session.id;
242
+ if (!prompt || !session || input.session.agentId) return null;
243
+ const loads = promptLoads(prompt);
244
+ if (loads) {
245
+ // The prompt already loads its skill; record it, never nudge.
246
+ recordSkillLoad(input, loads, "slash");
247
+ return null;
248
+ }
249
+ const hit = promptTrigger(prompt);
250
+ if (!hit || !inWorkitWorkspace(input.cwd)) return null;
251
+ return nudgeOnce(
252
+ input,
253
+ session,
254
+ hit.skill,
255
+ nudgeText(input, hit.skill, `this request ("${hit.trigger}")`),
256
+ );
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ const WORKIT_DELIVERY = /^(?:pr (?:create|merge)|ci wait|git push)\b/;
263
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
264
+ const LAUNCHERS = new Set([
265
+ "npx",
266
+ "bunx",
267
+ "pnpx",
268
+ "-y",
269
+ "--yes",
270
+ "pnpm",
271
+ "dlx",
272
+ "exec",
273
+ "env",
274
+ "sudo",
275
+ "command",
276
+ ]);
277
+
278
+ /** Does a segment list run a workit delivery verb (also inside `bash -c '…'`)? */
279
+ const runsWorkitDelivery = (command: string, dialect: ShellDialect, depth = 0): boolean =>
280
+ segmentsOf(command, dialect).some(({ words }) => {
281
+ // The command word, past env assignments and package runners (`npx -y`).
282
+ const at = words.findIndex((word) => !/^\w+=/.test(word) && !LAUNCHERS.has(word));
283
+ if (at >= 0 && /(?:^|[\\/])workit(?:\.exe)?$|^@brainervirus\/workit-cli$/.test(words[at])) {
284
+ const args = words.slice(at + 1);
285
+ if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--dry-run")) return false;
286
+ return WORKIT_DELIVERY.test(args.join(" "));
287
+ }
288
+ const shell = SHELLS.has(words[at]?.split(/[\\/]/).at(-1) ?? "");
289
+ const script = shell && words[at + 1] === "-c" ? words[at + 2] : undefined;
290
+ return depth < 3 && script !== undefined && runsWorkitDelivery(script, "posix", depth + 1);
291
+ });
292
+
293
+ /**
294
+ * Does the command deliver in a Workit workspace: a push, a PR/MR create or
295
+ * merge, or a CI wait (never help or a dry run)?
296
+ */
297
+ export const isDeliveryCommand = (
298
+ cwd: string,
299
+ command: string,
300
+ dialect: ShellDialect = "posix",
301
+ ): boolean =>
302
+ rawDelivery(cwd, command, dialect) ||
303
+ (runsWorkitDelivery(command, dialect) && inWorkitWorkspace(cwd));
304
+
305
+ /** A delivery command in a main session that has not loaded workit-ship. */
306
+ export function shipNudge(input: HookInput, command: string): string | null {
307
+ try {
308
+ const session = input.session.id;
309
+ if (!session || input.session.agentId) return null;
310
+ const dialect = input.event.kind === "shell.pre" ? (input.event.dialect ?? "posix") : "posix";
311
+ if (!isDeliveryCommand(input.cwd, command, dialect)) return null;
312
+ return nudgeOnce(
313
+ input,
314
+ session,
315
+ "workit-ship",
316
+ nudgeText(input, "workit-ship", "this delivery step"),
317
+ );
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ /** `decision` with `line` added: a context grows by a line, `none` becomes one. */
324
+ export const withContextLine = (decision: HookDecision, line: string | null): HookDecision => {
325
+ if (!line) return decision;
326
+ if (decision.kind === "none") return { kind: "context", text: line };
327
+ if (decision.kind === "context") return { kind: "context", text: `${decision.text}\n${line}` };
328
+ return decision;
329
+ };
330
+
331
+ /** Hosts whose shell nudge rides on the tool result: the raw-git nudge plus the ship line. */
332
+ export const shellNudge = (input: HookInput, command: string): HookDecision =>
333
+ withContextLine(rawGitPre(input, command), shipNudge(input, command));
@@ -207,13 +207,15 @@ const gateFor = (cwd: string): Gate | null => {
207
207
  /**
208
208
  * Deny a working-tree edit while the branch task has unmet before-write
209
209
  * requirements. `paths` are the files the tool writes, or null when unknown
210
- * (shell); an edit touching only exempt files is always allowed.
210
+ * (a shell write, a patch whose headers cannot be read); unknown or empty
211
+ * targets are gated, and an edit touching only exempt files is always allowed.
211
212
  */
212
213
  export function writeGate(cwd: string, paths: readonly string[] | null): HookDecision {
213
214
  try {
214
215
  const gate = gateFor(cwd);
215
216
  if (!gate || gate.blockers.length === 0) return NONE;
216
- if (paths && paths.every((file) => exempt(cwd, gate.root, file, gate.plans))) return NONE;
217
+ if (paths?.length && paths.every((file) => exempt(cwd, gate.root, file, gate.plans)))
218
+ return NONE;
217
219
  const reasons = gate.blockers.map((item) => `${item.ruleId}: ${item.reason}`).join("; ");
218
220
  return {
219
221
  kind: "deny",
package/src/ledger.ts CHANGED
@@ -1784,6 +1784,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
1784
1784
  case "standing.cleared":
1785
1785
  summary = `cleared ${text("target") || "every standing order"} (fanout ${text("fanout")})`;
1786
1786
  break;
1787
+ case "skill.loaded":
1788
+ summary = `${text("skill")} (${text("via") || "tool"})${row.actor.session ? "" : " (no session)"}`;
1789
+ break;
1787
1790
  case "commit.recorded":
1788
1791
  summary = `${(row.head ?? "?").slice(0, 12)} ${text("subject")}${row.actor.session ? "" : " (no session)"}`;
1789
1792
  break;