@brainervirus/workit-core 8.35.2 → 8.37.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.35.2",
3
+ "version": "8.37.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": [
@@ -23,7 +23,7 @@ description: Build a requested change in small verified steps - follow local pat
23
23
  (`workit check test`, plus `lint` and `typecheck` when the repo has them),
24
24
  not after every edit. Heavy work runs under one machine-wide lease: an ad-hoc
25
25
  suite is `workit check --heavy -- <cmd>`, never a hand-made flock; a busy
26
- lease waits and names its holder (`workit lock status`).
26
+ lease waits and names its holder (`workit lock status`). A tool warning is a finding: fix it in this PR in the right layer, or record the false positive with evidence; pre-existing ones get their own PR.
27
27
  5. Prove the feature on its real surface with the project's `verify-<app>`
28
28
  skill; none yet? Write one (workit-verify-app). Tests show branch behavior,
29
29
  not that the feature works. The report carries what you observed: a UI
@@ -49,7 +49,7 @@ the same check. Fix round and recheck both failed: a decider (workit-fanout).
49
49
  5. **CI.** Local gates mirror CI: push and continue; `workit ci wait` once on
50
50
  the final head, in the background where the host allows. Red: read `logTail`, classify. Flake or infra: one
51
51
  `workit ci rerun --failed --reason flake|infra` per head. Real: reproduce
52
- with `workit check`, fix the root cause, batch every fix in one push.
52
+ with `workit check`, fix the root cause, batch every fix in one push. A tool warning is a finding: fix it in this PR in the right layer, or record the false positive with evidence; pre-existing ones get their own PR.
53
53
  6. **Verified.** The lane in `workit pr status` decides: none, no verdict; self, your `--self` verdict; focused or full (or `verification: self|independent`), one non-author verifier per PR after the last push, never per push (workit-review), on Claude Code with the `model` and `effort` of `workit models show --role verifier`. A docs-only push (or test-only under a unit verdict) keeps the verdict (`carried`); `reverify: focused` re-checks only the new diff, reusing green CI; one verdict at a stack's top covers its unchanged lower PRs. Land only when
54
54
  granted: `workit stack land` or `workit pr merge`; `--unverified --reason` if user-asked.
55
55
  7. **Observe it landed:** `workit verify-delivery pr` or `merge`. After a failed
@@ -64,6 +64,15 @@ export type BriefOutcome = {
64
64
 
65
65
  export const DEFAULT_TIMEBOX = "30 minutes";
66
66
 
67
+ /** The TIMEBOX line's tool-call budget when the slice names none (hooks/budget-signal.ts). */
68
+ export const DEFAULT_TOOL_CALLS = 120;
69
+
70
+ /** The slice's TIMEBOX text plus a tool-call budget unless it already names one. */
71
+ const timeboxText = (timebox: string | null): string => {
72
+ const text = timebox ?? DEFAULT_TIMEBOX;
73
+ return /\b\d+\s+tool calls\b/u.test(text) ? text : `${text}, ${DEFAULT_TOOL_CALLS} tool calls`;
74
+ };
75
+
67
76
  const REPORT = [
68
77
  "REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,",
69
78
  " each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),",
@@ -151,7 +160,12 @@ function workerSession(lead: string, slice: string, attempt: number | null): str
151
160
  export function renderBrief(
152
161
  cwd: string,
153
162
  plan: FanoutFile,
154
- input: { slice: string; mode: BriefMode | null; attempt: number | null; actor: LedgerActor },
163
+ input: {
164
+ slice: string;
165
+ mode: BriefMode | null;
166
+ attempt: number | null;
167
+ actor: LedgerActor;
168
+ },
155
169
  ): FanoutResult<BriefOutcome> {
156
170
  const found = findSlice(plan, input.slice);
157
171
  if (!found.ok) return found;
@@ -250,7 +264,7 @@ export function renderBrief(
250
264
  ` verifier: model ${verifier.model}${verifier.effort ? ` effort ${verifier.effort}` : ""} (${verifier.source})`,
251
265
  ]
252
266
  : []),
253
- `TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
267
+ `TIMEBOX: ${timeboxText(slice.timebox)}; past it without a new commit you will be replaced`,
254
268
  scratchLine,
255
269
  `FORBIDDEN: ${slice.forbidden.join("; ")}`,
256
270
  ...fanIn,
@@ -92,10 +92,15 @@ export type StatusOptions = {
92
92
  host?: string | null;
93
93
  };
94
94
 
95
- /** A TIMEBOX such as `45 minutes`, `2h` or `1h30m`, in ms; null without a unit or otherwise. */
95
+ /**
96
+ * A TIMEBOX such as `45 minutes`, `2h` or `1h30m`, in ms; null without a unit
97
+ * or otherwise. A tool-call budget beside it (`45 minutes, 80 tool calls`) is
98
+ * not time: only the rest is parsed, so `80 tool calls` alone is null.
99
+ */
96
100
  export function timeboxMs(text: string | null): number | null {
97
- if (!text || /^\s*\d+(?:\.\d+)?\s*$/u.test(text)) return null;
98
- const ms = parseDuration(text);
101
+ const time = text?.replace(/[\s,;]*\b\d+\s+tool calls\b[\s,;]*/gu, " ").trim();
102
+ if (!time || /^\d+(?:\.\d+)?$/u.test(time)) return null;
103
+ const ms = parseDuration(time);
99
104
  return ms && ms > 0 ? ms : null;
100
105
  }
101
106
 
@@ -0,0 +1,284 @@
1
+ // The worker tool-call budget (retro P9, ruling mvrhg6lc). A brief's TIMEBOX
2
+ // names `N tool calls`; the spawn that carries it records N for the next
3
+ // subagent of that type (`<store>/budget/spawns-<lead>.jsonl`), SubagentStart
4
+ // binds it to the agent (`<store>/budget/<agent>.json`: limit and the last
5
+ // multiple told), and every pre-tool hook of that agent appends one byte to
6
+ // `<store>/budget/<agent>.calls`. At N calls (and 2N, 3N…) one line rides the
7
+ // standing-order channel; the call is never denied. An agent without a
8
+ // budget file is never counted.
9
+ // fs, path and crypto only: the Codex launcher imports this before the
10
+ // runtime, and the Claude Code launcher (bin/workit-hook.mjs) keeps a copy of
11
+ // spawnBudget, noteSpawnBudget and tickBudget that
12
+ // test/workit-core/hooks/budget-signal.test.ts holds equal.
13
+ import { createHash } from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { gitStoreDir } from "./standing-signal";
17
+ import type { HookInput } from "./protocol";
18
+
19
+ const hash = (text: string): string => createHash("sha256").update(text).digest("hex").slice(0, 32);
20
+
21
+ const budgetDir = (store: string): string => path.join(store, "budget");
22
+
23
+ /** The same key as the standing seen file: one per host session or subagent. */
24
+ const agentBase = (store: string, session: string, agentId: string | null): string =>
25
+ path.join(budgetDir(store), hash(`${session}\0${agentId ?? ""}`));
26
+
27
+ const spawnsFile = (store: string, session: string): string =>
28
+ path.join(budgetDir(store), `spawns-${hash(session)}.jsonl`);
29
+
30
+ /** `N tool calls` on a brief's TIMEBOX line, else null. */
31
+ export const spawnBudget = (prompt: string): number | null => {
32
+ const found = /^TIMEBOX:[^\n]*?\b(\d+)\s+tool calls\b/mu.exec(prompt);
33
+ const n = found ? Number(found[1]) : NaN;
34
+ return Number.isInteger(n) && n > 0 ? n : null;
35
+ };
36
+
37
+ /**
38
+ * A lead's spawn that will run: queue its budget, or an explicit none, for
39
+ * the next subagent of `type`. Every spawn of a type queues one entry, so the
40
+ * FIFO stays aligned with the SubagentStart order. Call it only once the
41
+ * spawn is allowed: a denied spawn must leave no entry.
42
+ */
43
+ export const noteSpawnBudget = (
44
+ cwd: string,
45
+ session: string,
46
+ type: string | null,
47
+ prompt: string,
48
+ ): void => {
49
+ try {
50
+ const store = session ? gitStoreDir(cwd) : null;
51
+ if (!store) return;
52
+ fs.mkdirSync(budgetDir(store), { recursive: true });
53
+ fs.appendFileSync(
54
+ spawnsFile(store, session),
55
+ `${JSON.stringify({ type: type ?? "", n: spawnBudget(prompt) })}\n`,
56
+ );
57
+ } catch {
58
+ // Without the note the worker runs unbudgeted.
59
+ }
60
+ };
61
+
62
+ type Budget = { limit: number; told: number };
63
+
64
+ /** Small files written whole; a torn read fails open as "no budget". */
65
+ const readBudget = (file: string): Budget | null => {
66
+ try {
67
+ const { limit, told } = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Budget>;
68
+ return typeof limit === "number" && limit > 0 ? { limit, told: Number(told) || 0 } : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ };
73
+
74
+ /**
75
+ * SubagentStart: take the head entry queued for this agent's type and bind
76
+ * its budget to the agent. Returns the limit, or null when the entry says
77
+ * none or nothing was queued.
78
+ */
79
+ export const claimSpawnBudget = (
80
+ cwd: string,
81
+ session: string,
82
+ agentId: string,
83
+ agentType: string | null,
84
+ ): number | null => {
85
+ try {
86
+ const store = session ? gitStoreDir(cwd) : null;
87
+ if (!store) return null;
88
+ const file = spawnsFile(store, session);
89
+ const entries = fs
90
+ .readFileSync(file, "utf8")
91
+ .split("\n")
92
+ .filter(Boolean)
93
+ .map((line) => JSON.parse(line) as { type?: unknown; n?: unknown });
94
+ const at = entries.findIndex((entry) => entry.type === (agentType ?? ""));
95
+ if (at < 0) return null;
96
+ const [{ n }] = entries.splice(at, 1);
97
+ fs.writeFileSync(file, entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
98
+ if (typeof n !== "number" || n <= 0) return null;
99
+ startBudget(store, session, agentId, n);
100
+ return n;
101
+ } catch {
102
+ return null;
103
+ }
104
+ };
105
+
106
+ /** Starts a budget; first ages out files of agents that never stopped, older than 7 days. */
107
+ const startBudget = (store: string, session: string, agentId: string | null, n: number): void => {
108
+ const dir = budgetDir(store);
109
+ fs.mkdirSync(dir, { recursive: true });
110
+ const cutoff = Date.now() - 6048e5;
111
+ for (const name of fs.readdirSync(dir))
112
+ try {
113
+ if (fs.statSync(path.join(dir, name)).mtimeMs < cutoff) fs.rmSync(path.join(dir, name));
114
+ } catch {
115
+ // Another hook removed it first.
116
+ }
117
+ const base = agentBase(store, session, agentId);
118
+ fs.writeFileSync(`${base}.json`, JSON.stringify({ limit: n, told: 0 }));
119
+ fs.rmSync(`${base}.calls`, { force: true });
120
+ };
121
+
122
+ /**
123
+ * A host that launches the worker itself (Pi) binds the budget its prompt
124
+ * names straight to the worker's session. Returns the limit, else null.
125
+ */
126
+ export const bindBudget = (
127
+ cwd: string,
128
+ session: string,
129
+ agentId: string | null,
130
+ prompt: string,
131
+ ): number | null => {
132
+ try {
133
+ const n = spawnBudget(prompt);
134
+ const store = n && session ? gitStoreDir(cwd) : null;
135
+ if (!n || !store) return null;
136
+ startBudget(store, session, agentId, n);
137
+ return n;
138
+ } catch {
139
+ return null;
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Count one tool call of a budgeted agent. True when the count reached the
145
+ * next untold multiple of the budget, so the runtime must load to tell it.
146
+ */
147
+ export const tickBudget = (cwd: string, session: string, agentId: string | null): boolean => {
148
+ try {
149
+ const store = session ? gitStoreDir(cwd) : null;
150
+ if (!store) return false;
151
+ const base = agentBase(store, session, agentId);
152
+ const budget = readBudget(`${base}.json`);
153
+ if (!budget) return false;
154
+ fs.appendFileSync(`${base}.calls`, ".");
155
+ return fs.statSync(`${base}.calls`).size >= budget.limit * (budget.told + 1);
156
+ } catch {
157
+ return false;
158
+ }
159
+ };
160
+
161
+ const lineFor = (calls: number): string =>
162
+ `Tool-call budget reached (${calls}): commit your work, write your report, stop.`;
163
+
164
+ /** The agent's base path, its budget, and how many multiples of it its calls reached. */
165
+ const reachedOf = (cwd: string, session: string, agentId: string | null) => {
166
+ const store = session ? gitStoreDir(cwd) : null;
167
+ const base = store && agentBase(store, session, agentId);
168
+ const budget = base && readBudget(`${base}.json`);
169
+ if (!budget) return null;
170
+ return { base, budget, reached: Math.floor(fs.statSync(`${base}.calls`).size / budget.limit) };
171
+ };
172
+
173
+ /** SubagentStop: the agent's budget and counter go. */
174
+ export const dropBudget = (cwd: string, session: string, agentId: string | null): void => {
175
+ const store = session ? gitStoreDir(cwd) : null;
176
+ if (store)
177
+ for (const ext of [".json", ".calls"])
178
+ fs.rmSync(agentBase(store, session, agentId) + ext, { force: true });
179
+ };
180
+
181
+ /** The one line for a budget reached and not yet told (marked told), else null. */
182
+ export const budgetLine = (cwd: string, session: string, agentId: string | null): string | null => {
183
+ try {
184
+ const at = reachedOf(cwd, session, agentId);
185
+ if (!at || at.reached <= at.budget.told) return null;
186
+ fs.writeFileSync(`${at.base}.json`, JSON.stringify({ ...at.budget, told: at.reached }));
187
+ return lineFor(at.budget.limit * at.reached);
188
+ } catch {
189
+ return null;
190
+ }
191
+ };
192
+
193
+ /**
194
+ * For a host that rebuilds its context on every model call (OpenCode): the
195
+ * line while the budget stands reached, else null. Never marks it told.
196
+ */
197
+ export const budgetStanding = (
198
+ cwd: string,
199
+ session: string,
200
+ agentId: string | null,
201
+ ): string | null => {
202
+ try {
203
+ const at = reachedOf(cwd, session, agentId);
204
+ return at && at.reached > 0 ? lineFor(at.budget.limit * at.reached) : null;
205
+ } catch {
206
+ return null;
207
+ }
208
+ };
209
+
210
+ /** Codex tools Workit may deny; any other PreToolUse is never denied there. */
211
+ const CODEX_GATED = new Set(["bash", "unifiedexec", "shell", "applypatch", "edit", "write"]);
212
+
213
+ /**
214
+ * One native Codex PreToolUse payload: a lead's spawn (a tool input naming an
215
+ * agent type, or carrying a brief) queues its budget or an explicit none, and
216
+ * a budgeted subagent's call is counted. A spawn is a tool Workit never denies
217
+ * on Codex, so queueing here cannot outlive a denied call. True when the
218
+ * count reached the budget, so the runtime tells it.
219
+ */
220
+ export const observeHookBudget = (payload: string): boolean => {
221
+ try {
222
+ const value = JSON.parse(payload) as Record<string, unknown>;
223
+ if (value.hook_event_name !== "PreToolUse" || typeof value.session_id !== "string")
224
+ return false;
225
+ const cwd = typeof value.cwd === "string" ? value.cwd : ".";
226
+ const agentId =
227
+ typeof value.agent_id === "string" && value.agent_id.trim() ? value.agent_id : null;
228
+ if (agentId) return tickBudget(cwd, value.session_id, agentId);
229
+ const tool = typeof value.tool_name === "string" ? value.tool_name : "";
230
+ const spawn = value.tool_input;
231
+ if (
232
+ CODEX_GATED.has(tool.toLowerCase().replace(/[_-]/g, "")) ||
233
+ !spawn ||
234
+ typeof spawn !== "object"
235
+ )
236
+ return false;
237
+ const fields = spawn as Record<string, unknown>;
238
+ const prompt = Object.values(fields).find(
239
+ (field): field is string => typeof field === "string" && field.includes("TIMEBOX:"),
240
+ );
241
+ const type = [fields.agent_type, fields.subagent_type].find(
242
+ (field): field is string => typeof field === "string",
243
+ );
244
+ if (prompt !== undefined || type !== undefined)
245
+ noteSpawnBudget(cwd, value.session_id, type ?? null, prompt ?? "");
246
+ return false;
247
+ } catch {
248
+ return false;
249
+ }
250
+ };
251
+
252
+ /** What handleHook does with a budget, for the hosts whose entry passes it (HookDeps.budget). */
253
+ export type BudgetHooks = {
254
+ /** A pre-tool answer that lets the call run: the line once reached. */
255
+ line: (input: HookInput) => string | null;
256
+ /** SubagentStart: bind the queued budget. */
257
+ start: (input: HookInput, agentId: string, agentType: string) => void;
258
+ /** A lead's spawn the gate let through (Claude Code: untyped is general-purpose). */
259
+ spawn: (input: HookInput, spawn: Record<string, unknown>) => void;
260
+ /** SubagentStop: drop the agent's files. */
261
+ stop: (input: HookInput, agentId: string) => void;
262
+ };
263
+
264
+ export const budgetHooks: BudgetHooks = {
265
+ line: (input) => budgetLine(input.cwd, input.session.id ?? "", input.session.agentId),
266
+ start: (input, agentId, agentType) =>
267
+ void claimSpawnBudget(input.cwd, input.session.id ?? "", agentId, agentType),
268
+ spawn: (input, spawn) => {
269
+ if (!input.session.agentId)
270
+ noteSpawnBudget(
271
+ input.cwd,
272
+ input.session.id ?? "",
273
+ String(spawn.subagent_type || "general-purpose"),
274
+ String(spawn.prompt ?? ""),
275
+ );
276
+ },
277
+ stop: (input, agentId) => {
278
+ try {
279
+ dropBudget(input.cwd, input.session.id ?? "", agentId);
280
+ } catch {
281
+ // Aged out by the next startBudget.
282
+ }
283
+ },
284
+ };
@@ -30,6 +30,7 @@ import {
30
30
  } from "./skill-nudge";
31
31
  import { spawnDecision } from "./spawn-gate";
32
32
  import { seedStanding, standingDelta } from "./standing";
33
+ import type { BudgetHooks } from "./budget-signal";
33
34
  import { modelSwitchedOnDisk, trackJudge } from "./session-marker";
34
35
  import { invariantBootstrap } from "../core/methods";
35
36
  import { stopDecision } from "./stop";
@@ -39,7 +40,16 @@ import { confineImplementer, confineImplementerShell } from "./worktree-confine"
39
40
  import { agentSteering, workitRole } from "./steering";
40
41
  import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
41
42
 
42
- export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
43
+ /**
44
+ * `budget`: the worker tool-call budget (budget-signal.ts budgetHooks), given
45
+ * only by the hosts that count calls in their launcher (Claude Code, Codex),
46
+ * so other hook bundles do not carry it.
47
+ */
48
+ export type HookDeps = {
49
+ descriptor: HostDescriptor;
50
+ addendum: string | null;
51
+ budget?: BudgetHooks;
52
+ };
43
53
 
44
54
  const usable = (value: Support) => value === "native" || value === "partial";
45
55
  const NONE: HookDecision = { kind: "none" };
@@ -125,9 +135,11 @@ const subagentStartText = (
125
135
  const PRE_TOOL = new Set<HookEventKind>(["shell.pre", "write.pre", "tool.pre"]);
126
136
 
127
137
  /**
128
- * A running worker's standing-order delta rides any pre-tool answer that lets
129
- * the call run. In-process hosts deliver it on their own channel (OpenCode's
130
- * system context, Pi's tool result), so it is never spent here for them.
138
+ * A running worker's standing-order delta, and its tool-call budget line once
139
+ * the launcher counted it reached (budget-signal.ts), ride any pre-tool answer
140
+ * that lets the call run. In-process hosts deliver them on their own channel
141
+ * (OpenCode's system context, Pi's tool result), so they are never spent here
142
+ * for them.
131
143
  */
132
144
  export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
133
145
  const decision = decide(input, deps);
@@ -138,7 +150,8 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
138
150
  decision.kind === "ask"
139
151
  )
140
152
  return decision;
141
- const delta = standingDelta(input);
153
+ const delta =
154
+ [standingDelta(input), deps.budget?.line(input) ?? null].filter(Boolean).join("\n") || null;
142
155
  if (!delta) return decision;
143
156
  return decision.kind === "rewrite" || decision.kind === "update-input"
144
157
  ? { ...decision, text: [decision.text, delta].filter(Boolean).join("\n") }
@@ -285,7 +298,11 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
285
298
  case "subagent.start":
286
299
  if (workitRole(descriptor, event.agentType) === "judge")
287
300
  trackJudge(input, event.agentId, true);
288
- seedStanding({ ...input, session: { ...input.session, agentId: event.agentId } });
301
+ seedStanding({
302
+ ...input,
303
+ session: { ...input.session, agentId: event.agentId },
304
+ });
305
+ deps.budget?.start(input, event.agentId, event.agentType);
289
306
  return {
290
307
  kind: "context",
291
308
  text: subagentStartText(input, descriptor, event),
@@ -299,8 +316,13 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
299
316
  case "tool.pre":
300
317
  // Host permission policy owns other tools; a skill load is recorded.
301
318
  if (event.skill) recordSkillLoad(input, event.skill, "tool");
302
- if (event.agentInput && input.host === "claude_code")
303
- return spawnDecision(descriptor, input.cwd, event.agentInput);
319
+ if (event.agentInput && input.host === "claude_code") {
320
+ const spawn = spawnDecision(descriptor, input.cwd, event.agentInput);
321
+ // A spawn the gate lets through queues its tool-call budget, or
322
+ // none; the launcher does so for one its fast path allows.
323
+ if (spawn.kind !== "deny") deps.budget?.spawn(input, event.agentInput);
324
+ return spawn;
325
+ }
304
326
  return NONE;
305
327
  case "stop":
306
328
  return usable(descriptor.events.stop.support) ? stopDecision(input, descriptor) : NONE;
@@ -308,6 +330,7 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
308
330
  // arrive with the CLI-observed evidence model.
309
331
  case "subagent.stop":
310
332
  trackJudge(input, event.agentId, false);
333
+ if (event.agentId) deps.budget?.stop(input, event.agentId);
311
334
  return NONE;
312
335
  case "prompt.submit":
313
336
  return NONE;
@@ -335,7 +358,7 @@ export const failureDecision = (
335
358
 
336
359
  /** Parse, handle, and render one native payload. `error` is set on any failure. */
337
360
  export const dispatchHook = (
338
- adapter: HostAdapter,
361
+ adapter: HostAdapter & { budget?: BudgetHooks },
339
362
  raw: unknown,
340
363
  env: NodeJS.ProcessEnv = process.env,
341
364
  ): RenderedHook & { error: string | null } => {
@@ -353,6 +376,7 @@ export const dispatchHook = (
353
376
  const decision = handleHook(parsed.input, {
354
377
  descriptor,
355
378
  addendum: adapter.addendum?.(parsed.input) ?? null,
379
+ budget: adapter.budget,
356
380
  });
357
381
  return { ...adapter.render(decision, parsed.native), error: null };
358
382
  } catch (error) {
@@ -44,11 +44,12 @@ 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 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
- // SessionStart source=compact.
51
- "subagent.stop": { support: "none", native: null },
47
+ // SubagentStop is never blocked: the launcher answers it itself, dropping
48
+ // the stopped agent's tool-call budget files (hooks/budget-signal.ts).
49
+ // Claude Code has PreCompact, but the plugin does not register it: events
50
+ // list what workit registers (docs/agents/hosts.md, Host parity);
51
+ // compaction restore runs on SessionStart source=compact.
52
+ "subagent.stop": { support: "native", native: "SubagentStop" },
52
53
  "compact.pre": { support: "none", native: null },
53
54
  // `{"decision":"block","reason"}` continues the turn; the input carries
54
55
  // `stop_hook_active` and `last_assistant_message` (claude 2.1.295).