@brainervirus/workit-core 8.27.0 → 8.28.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.27.0",
3
+ "version": "8.28.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": [
@@ -207,3 +207,30 @@ export const roleModelIssues = (cwd: string, dir?: string): string[] => {
207
207
  }
208
208
  return issues;
209
209
  };
210
+
211
+ /**
212
+ * The role a Claude Code subagent type runs as: the Workit agents by their
213
+ * namespaced name, the built-in `Explore`. Plan, general-purpose and every
214
+ * other type map to none (their model is not Workit's to choose). An
215
+ * implementer spawn names its brief tier on a `TIER: <tier>` line in its
216
+ * prompt; without one it is `standard`.
217
+ */
218
+ export const roleForAgentType = (agentType: unknown, prompt?: unknown): RoleSelector | null => {
219
+ switch (agentType) {
220
+ case "workit:implementer": {
221
+ const tier =
222
+ typeof prompt === "string"
223
+ ? /^[ \t]*TIER:[ \t]*(mundane|standard|hard)\b/im.exec(prompt)?.[1]
224
+ : undefined;
225
+ return { role: "implementer", tier: (tier?.toLowerCase() as ModelTier) ?? "standard" };
226
+ }
227
+ case "workit:verifier":
228
+ return { role: "verifier" };
229
+ case "workit:reviewer":
230
+ return { role: "reviewer" };
231
+ case "Explore":
232
+ return { role: "explore" };
233
+ default:
234
+ return null;
235
+ }
236
+ };
@@ -0,0 +1,40 @@
1
+ // Role models on a Claude Code subagent spawn (#420): a spawn that names no
2
+ // `model` gets its role's model (and effort) from the role config, through
3
+ // PreToolUse `updatedInput` on the Agent tool. An explicit `model` always
4
+ // wins and a spawn is never denied over its model: a type with no role, or a
5
+ // role with nothing usable configured, runs as the caller wrote it.
6
+ import { resolveRoleModel, roleForAgentType } from "../core/role-models";
7
+ import type { HookDecision } from "./protocol";
8
+
9
+ /** The Agent tool's `model` and `effort` enums (claude 2.1.309); anything else fails its schema. */
10
+ const MODELS = new Set(["sonnet", "opus", "haiku", "fable"]);
11
+ const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
12
+
13
+ const given = (value: unknown): boolean => typeof value === "string" && value.trim() !== "";
14
+
15
+ export const agentModelDecision = (
16
+ cwd: string,
17
+ tool: Record<string, unknown>,
18
+ env: NodeJS.ProcessEnv = process.env,
19
+ ): HookDecision => {
20
+ // The host fixes the model itself: nothing to fill, and `model` is not in its schema.
21
+ if (given(tool.model) || env.CLAUDE_CODE_SUBAGENT_MODEL_FORCE) return { kind: "none" };
22
+ const selector = roleForAgentType(tool.subagent_type, tool.prompt);
23
+ if (!selector) return { kind: "none" };
24
+ const resolved = resolveRoleModel(cwd, selector, "claude_code");
25
+ if (!resolved) return { kind: "none" };
26
+ const model = resolved.model.toLowerCase();
27
+ const effort = resolved.effort?.toLowerCase() ?? null;
28
+ const input: Record<string, unknown> = { ...tool };
29
+ if (MODELS.has(model)) input.model = model;
30
+ if (effort && EFFORTS.has(effort) && !given(tool.effort)) input.effort = effort;
31
+ if (input.model === undefined && input.effort === tool.effort) return { kind: "none" };
32
+ const label = selector.tier ? `${selector.role} ${selector.tier}` : selector.role;
33
+ return {
34
+ kind: "update-input",
35
+ input,
36
+ text: `Workit set ${String(input.model ?? "no model")}${
37
+ input.effort !== tool.effort ? ` (effort ${String(input.effort)})` : ""
38
+ } on this ${String(tool.subagent_type)} spawn: role ${label}, ${resolved.source}. Name a model to choose another.`,
39
+ };
40
+ };
@@ -9,6 +9,7 @@ import type { HostDescriptor, Support } from "./descriptor";
9
9
  import { branchGuidance } from "../core/branch";
10
10
  import { workspaceTrailers } from "../core/workspaces";
11
11
  import { judgeBranch, recordIssuedSession, verdictCommand } from "../host-session";
12
+ import { agentModelDecision } from "./agent-model";
12
13
  import { cwdRewrite, verbFirst } from "./cwd-normalize";
13
14
  import { workitBypassFlag } from "./delivery-verbs";
14
15
  import { heavyDenial, heavyGate, rewriteNote, splitDenial } from "./heavy-gate";
@@ -159,7 +160,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
159
160
  return decision;
160
161
  const delta = standingDelta(input);
161
162
  if (!delta) return decision;
162
- return decision.kind === "rewrite"
163
+ return decision.kind === "rewrite" || decision.kind === "update-input"
163
164
  ? { ...decision, text: [decision.text, delta].filter(Boolean).join("\n") }
164
165
  : withContextLine(decision, delta);
165
166
  }
@@ -307,6 +308,8 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
307
308
  case "tool.pre":
308
309
  // Host permission policy owns other tools; a skill load is recorded.
309
310
  if (event.skill) recordSkillLoad(input, event.skill, "tool");
311
+ if (event.agentInput && input.host === "claude_code")
312
+ return agentModelDecision(input.cwd, event.agentInput);
310
313
  return NONE;
311
314
  case "stop":
312
315
  return usable(descriptor.events.stop.support) ? stopDecision(input, descriptor) : NONE;
@@ -186,6 +186,17 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
186
186
  skill: isRecord(value.tool_input) ? optionalText(value.tool_input.skill) : null,
187
187
  },
188
188
  };
189
+ // `Task` is the Agent tool's legacy name.
190
+ if (value.tool_name === "Agent" || value.tool_name === "Task")
191
+ return {
192
+ ok: true,
193
+ event: {
194
+ kind: "tool.pre",
195
+ tool: value.tool_name,
196
+ toolUseId,
197
+ ...(isRecord(value.tool_input) ? { agentInput: value.tool_input } : {}),
198
+ },
199
+ };
189
200
  if (!SHELL_TOOLS.has(value.tool_name))
190
201
  return { ok: true, event: { kind: "tool.pre", tool: value.tool_name, toolUseId } };
191
202
  const command = toolCommand(value);
@@ -276,7 +287,11 @@ const CONTEXT_EVENTS = new Set([
276
287
  * yielded as the input to run).
277
288
  */
278
289
  const render = (decision: HookDecision, native: string | null) => {
279
- if (decision.kind === "rewrite" && native === "PreToolUse" && decision.input)
290
+ if (
291
+ (decision.kind === "rewrite" || decision.kind === "update-input") &&
292
+ native === "PreToolUse" &&
293
+ decision.input
294
+ )
280
295
  return {
281
296
  json: {
282
297
  hookSpecificOutput: {
@@ -37,6 +37,8 @@ export type HookEvent =
37
37
  toolUseId: string | null;
38
38
  /** The skill a skill-loading tool loads (Claude Code's Skill tool). */
39
39
  skill?: string | null;
40
+ /** A subagent spawn's whole tool input (Claude Code's Agent tool), where a default is filled in. */
41
+ agentInput?: Record<string, unknown>;
40
42
  }
41
43
  /** A file-writing tool (Edit, Write, apply_patch…): the before-write gate (S17). */
42
44
  | {
@@ -105,6 +107,12 @@ export type HookDecision =
105
107
  * host takes the whole input back; `text` is model-visible context.
106
108
  */
107
109
  | { kind: "rewrite"; command: string; input: Record<string, unknown> | null; text: string | null }
110
+ /**
111
+ * tool.pre: run the tool with `input` (the host's whole tool input, with
112
+ * defaults filled in) instead; `text` is model-visible context. Never a
113
+ * permission decision.
114
+ */
115
+ | { kind: "update-input"; input: Record<string, unknown>; text: string | null }
108
116
  /** Stop/SubagentStop: keep working. */
109
117
  | { kind: "continue"; reason: string }
110
118
  | { kind: "notice"; userMessage: string };