@cruxy/cli 0.23.0 → 0.24.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.
Files changed (59) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/approval/index.d.ts +1 -0
  4. package/dist/approval/index.js +1 -0
  5. package/dist/approval/mutex.d.ts +45 -0
  6. package/dist/approval/mutex.js +57 -0
  7. package/dist/checkpoint/service.d.ts +9 -0
  8. package/dist/checkpoint/service.js +20 -0
  9. package/dist/cli/commands/run.js +50 -16
  10. package/dist/cli/onboard.js +2 -2
  11. package/dist/cli/repl.js +39 -0
  12. package/dist/cli/session-factory.d.ts +23 -1
  13. package/dist/cli/session-factory.js +137 -47
  14. package/dist/config/schema.d.ts +24 -0
  15. package/dist/config/schema.js +9 -0
  16. package/dist/errors/constructors.d.ts +23 -0
  17. package/dist/errors/constructors.js +38 -0
  18. package/dist/errors/types.d.ts +8 -0
  19. package/dist/errors/types.js +12 -0
  20. package/dist/hooks/index.d.ts +1 -0
  21. package/dist/hooks/index.js +1 -0
  22. package/dist/hooks/router.d.ts +58 -0
  23. package/dist/hooks/router.js +136 -0
  24. package/dist/hooks/runner.d.ts +12 -0
  25. package/dist/hooks/runner.js +23 -1
  26. package/dist/mcp/index.d.ts +1 -0
  27. package/dist/mcp/index.js +1 -0
  28. package/dist/mcp/sibling-banner.d.ts +25 -0
  29. package/dist/mcp/sibling-banner.js +34 -0
  30. package/dist/memory/recall.d.ts +24 -0
  31. package/dist/memory/recall.js +54 -0
  32. package/dist/memory/remember-tool.d.ts +3 -0
  33. package/dist/memory/remember-tool.js +11 -1
  34. package/dist/sandbox/policy.js +14 -5
  35. package/dist/sandbox/service.d.ts +8 -1
  36. package/dist/sandbox/service.js +4 -1
  37. package/dist/subagent/index.d.ts +1 -0
  38. package/dist/subagent/index.js +1 -0
  39. package/dist/subagent/orchestrator.d.ts +67 -2
  40. package/dist/subagent/orchestrator.js +203 -18
  41. package/dist/subagent/registry-scope.d.ts +13 -0
  42. package/dist/subagent/registry-scope.js +28 -2
  43. package/dist/subagent/semaphore.d.ts +27 -0
  44. package/dist/subagent/semaphore.js +56 -0
  45. package/dist/subagent/spawn-tool.d.ts +57 -0
  46. package/dist/subagent/spawn-tool.js +104 -9
  47. package/dist/subagent/types.d.ts +17 -2
  48. package/dist/testing/run-tests-tool.js +1 -1
  49. package/dist/tools/file/paths.d.ts +5 -6
  50. package/dist/tools/file/paths.js +7 -8
  51. package/dist/tools/shell/exec.js +36 -4
  52. package/dist/tools/types.d.ts +16 -5
  53. package/dist/workspace/add-root.d.ts +27 -0
  54. package/dist/workspace/add-root.js +16 -0
  55. package/dist/workspace/index.d.ts +2 -1
  56. package/dist/workspace/index.js +2 -1
  57. package/dist/workspace/workspace.d.ts +9 -4
  58. package/dist/workspace/workspace.js +9 -4
  59. package/package.json +1 -1
@@ -3,17 +3,17 @@ import { createProvider } from "@cruxy/sdk";
3
3
  import { loadProjectInstructions } from "../config/index.js";
4
4
  import { logger } from "../utils/logger.js";
5
5
  import { getGitInfo } from "../utils/git.js";
6
- import { ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, } from "../approval/index.js";
6
+ import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist, classify, defaultPromptIO, serializeGate, } from "../approval/index.js";
7
7
  import { shouldUseColor } from "../errors/index.js";
8
8
  import { buildDefaultRegistry, } from "../tools/index.js";
9
9
  import { Session, } from "../agent/index.js";
10
10
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
11
11
  import { routerForConfig } from "../routing/index.js";
12
- import { MemoryService, rememberTool } from "../memory/index.js";
12
+ import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memory/index.js";
13
13
  import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
14
14
  import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
15
15
  import { appendRun } from "../usage/index.js";
16
- import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
16
+ import { SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
17
17
  /**
18
18
  * Wrap a PromptIO so the live region yields before any prompt text lands
19
19
  * (U.2/U.4): the prompt writes to stderr while the status line owns the last
@@ -146,6 +146,59 @@ function attributeFileTargets(action, ws) {
146
146
  }
147
147
  return byRoot;
148
148
  }
149
+ /**
150
+ * Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
151
+ * order the model sees them: `remember` (memory), the four LSP tools, the two web
152
+ * tools, the trusted MCP tools, then `spawn_subagent`. Factored out of
153
+ * {@link buildAgentSession} for ONE reason beyond tidiness: it makes the *complete
154
+ * runtime* tool set enumerable by a test (JC-B). The default registry only covers
155
+ * the 14 always-on tools; the allowlist test that pins "no tool can add a workspace
156
+ * root" is only sound if it runs against the SAME set the session ships — so the
157
+ * session factory and that test both build the surface through this one seam. A new
158
+ * conditionally-registered tool added here fails the allowlist test until it is
159
+ * consciously listed and audited.
160
+ *
161
+ * Each family is opt-in and inert when its feature flag is off, exactly as before —
162
+ * this function is a pure move of the inline registration, same guards, same order.
163
+ * `spawnTool` is passed in (already depth-bound) because it needs the orchestrator;
164
+ * it registers only when nesting is allowed (`subagent.maxDepth > 0`).
165
+ */
166
+ export function registerRuntimeTools(registry, config, opts = {}) {
167
+ // Persistent memory write tool (C.29) — inert unless enabled.
168
+ if (config.memory.enabled)
169
+ registry.register(rememberTool);
170
+ // Per-language LSP introspection (C.12): four read-only tools that spawn
171
+ // EXTERNAL language servers, so opt-in; none registered when off.
172
+ if (config.lsp.enabled) {
173
+ registry.register(findDefinitionTool);
174
+ registry.register(findReferencesTool);
175
+ registry.register(getDiagnosticsTool);
176
+ registry.register(hoverTool);
177
+ }
178
+ // Web search + fetch (C.20): read-only, reach the PUBLIC internet and inject
179
+ // untrusted data, so opt-in; neither registered (nor a provider constructed)
180
+ // when off.
181
+ if (config.web.enabled) {
182
+ registry.register(createWebSearchTool());
183
+ registry.register(createWebFetchTool());
184
+ }
185
+ // MCP servers (C.27): already connected, trusted, and adapted to bounded,
186
+ // destructive-gated, `mcp__`-namespaced tools before we got here — a plain
187
+ // hand-off. Empty unless mcp is enabled with a trusted server.
188
+ for (const tool of opts.mcpTools ?? [])
189
+ registry.register(tool);
190
+ // Subagent spawn (C.14) + parallel fan-out (C.33): only when nesting depth
191
+ // allows (maxDepth 0 disables the feature structurally). Both are read-only to
192
+ // propose; the plan propose phase filters them out. The plural `spawn_subagents`
193
+ // is registered ONLY here (the main registry) — never on a child's scoped
194
+ // registry — so parallel fan-out stays a depth-0 capability.
195
+ if (config.subagent.maxDepth > 0) {
196
+ if (opts.spawnTool)
197
+ registry.register(opts.spawnTool);
198
+ if (opts.spawnManyTool)
199
+ registry.register(opts.spawnManyTool);
200
+ }
201
+ }
149
202
  /**
150
203
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
151
204
  * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
@@ -191,51 +244,71 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
191
244
  const execRegistry = buildDefaultRegistry();
192
245
  const git = getGitInfo(cwd);
193
246
  const projectInstructions = loadProjectInstructions(cwd);
194
- // Persistent memory (C.29): register the write tool and build the recall block
195
- // ONCE at session start. User memory is always eligible; project memory is
196
- // recalled only when trusted (a cloned repo's notes never inject silently).
197
- // Fully inert when disabled. Recall is best-effort — a load problem degrades to
198
- // "no memory", never a hard failure at session start.
247
+ // Persistent memory (C.29): build the recall block ONCE at session start (the
248
+ // `remember` write tool is registered via registerRuntimeTools below). User
249
+ // memory is always eligible; project memory is recalled only when trusted (a
250
+ // cloned repo's notes never inject silently). Fully inert when disabled. Recall
251
+ // is best-effort — a load problem degrades to "no memory", never a hard failure.
199
252
  let recalledMemory = null;
200
253
  if (config.memory.enabled) {
201
- execRegistry.register(rememberTool);
202
- const recall = new MemoryService({ cwd, config: config.memory }).recall();
203
- recalledMemory = recall.block;
204
- if (recall.projectPresentButUntrusted) {
205
- logger.info("project memory found but not trusted run `cruxy memory trust .` to recall it");
254
+ if (workspace.isMultiRoot) {
255
+ // Recall project memory from EVERY trusted root, each under its own
256
+ // root-labeled block; user memory (global) once. Untrusted roots are not
257
+ // injected and are NAMED — a cloned repo's notes never inject silently,
258
+ // and a root whose notes you've never trusted is called out, not dropped.
259
+ const roots = [];
260
+ let userEntries = [];
261
+ for (const r of workspace.roots()) {
262
+ const svc = new MemoryService({
263
+ cwd: r.absPath,
264
+ config: config.memory,
265
+ });
266
+ const st = svc.status();
267
+ // The label is paired with the store HERE — structurally, a block can
268
+ // never carry a root the entries didn't come from.
269
+ if (r.primary) {
270
+ userEntries = st.user;
271
+ for (const e of st.errors) {
272
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
273
+ }
274
+ }
275
+ else {
276
+ // Siblings share the same global user file — surface only their own
277
+ // project-scope load errors (user errors already reported by primary).
278
+ for (const e of st.errors.filter((e) => e.scope === "project")) {
279
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} in ${r.name} — ${e.message}`);
280
+ }
281
+ }
282
+ if (st.projectTrusted) {
283
+ if (st.project.length > 0)
284
+ roots.push({ name: r.name, entries: st.project });
285
+ }
286
+ else if (st.project.length > 0) {
287
+ logger.info(`memory: root "${r.name}" project memory not trusted — run \`cruxy memory trust ${r.absPath}\` to recall it`);
288
+ }
289
+ }
290
+ recalledMemory = buildMultiRootRecallBlock({
291
+ user: userEntries,
292
+ roots,
293
+ maxTokens: config.memory.maxRecallTokens,
294
+ });
206
295
  }
207
- for (const e of recall.errors) {
208
- logger.warn(`memory: excluded ${e.scope} entry ${e.id} ${e.message}`);
296
+ else {
297
+ const recall = new MemoryService({ cwd, config: config.memory }).recall();
298
+ recalledMemory = recall.block;
299
+ if (recall.projectPresentButUntrusted) {
300
+ logger.info("project memory found but not trusted — run `cruxy memory trust .` to recall it");
301
+ }
302
+ for (const e of recall.errors) {
303
+ logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
304
+ }
209
305
  }
210
306
  }
211
- // Per-language LSP (C.12): register the four read-only introspection tools
212
- // only when enabled. They spawn and manage EXTERNAL language-server processes,
213
- // so like the sandbox — the feature is opt-in; when off, none is registered
214
- // and no server ever spawns. Read-only (no approval), so they bypass the U.3
215
- // gate like search_codebase and are available to subagents and plan proposals.
216
- if (config.lsp.enabled) {
217
- execRegistry.register(findDefinitionTool);
218
- execRegistry.register(findReferencesTool);
219
- execRegistry.register(getDiagnosticsTool);
220
- execRegistry.register(hoverTool);
221
- }
222
- // Web search + fetch (C.20): register the two read-only web tools only when
223
- // enabled. They reach the PUBLIC internet and inject attacker-controllable data
224
- // (results/pages are demarcated as untrusted and never persisted), so — like
225
- // LSP and MCP — the feature is opt-in; when off, neither tool is registered and
226
- // no search provider is ever constructed. Read-only (no approval), so they
227
- // bypass the U.3 gate like search_codebase.
228
- if (config.web.enabled) {
229
- execRegistry.register(createWebSearchTool());
230
- execRegistry.register(createWebFetchTool());
231
- }
232
- // MCP servers (C.27): the caller connected + trusted the servers and produced
233
- // these tools through the single adapter seam BEFORE building the session, so
234
- // registration here is a plain hand-off — every one is destructive-gated,
235
- // demarcated, and bounded by construction. Empty unless `mcp.enabled` and at
236
- // least one trusted server produced tools, so the default path is unchanged.
237
- for (const tool of mcpTools)
238
- execRegistry.register(tool);
307
+ // The conditionally-registered runtime tools (remember / LSP / web / MCP) are
308
+ // added below via registerRuntimeTools the single seam the JC-B allowlist test
309
+ // also builds through, so the audited surface can never drift from the shipped
310
+ // one. spawn_subagent joins them there too (it needs the orchestrator built
311
+ // first). See registerRuntimeTools.
239
312
  // One io shared by every prompt in the session (plan approval, the U.3 gate,
240
313
  // and any gate inside a subagent), so they all coordinate with the same live
241
314
  // region. The full wrapper stack around an ApprovalService is factored here
@@ -247,7 +320,13 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
247
320
  // wired, and it is set on the SAME ctx whose `requestApproval` IS that gate — so
248
321
  // lifting the non-primary-write refusal and capturing the write are one decision.
249
322
  const checkpointsActive = Boolean(checkpoints);
250
- const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace);
323
+ // The C.33 spine: ONE approval mutex shared by the main loop and every subagent
324
+ // gate, so across concurrent siblings only one non-read action is in its gate at
325
+ // a time — one prompt on screen, one checkpoint snapshot/set-write in flight.
326
+ // Read-tier actions bypass it (see serializeGate), so a parallel read fan-out is
327
+ // never stalled behind an unrelated pending prompt.
328
+ const approvalMutex = new ApprovalMutex();
329
+ const gate = (approval) => serializeGate(withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace), approvalMutex, cwd);
251
330
  // Subagent orchestration (C.14): spawn_subagent goes on the main registry
252
331
  // only when depth allows (maxDepth 0 disables the feature structurally).
253
332
  // Registered before the plan wiring so plan-mode execution steps can
@@ -267,9 +346,20 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
267
346
  checkpointsActive,
268
347
  makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
269
348
  });
270
- if (config.subagent.maxDepth > 0) {
271
- execRegistry.register(makeSpawnSubagentTool(orchestrator, 0));
272
- }
349
+ // Now that the orchestrator exists, register every conditionally-enabled tool
350
+ // (remember / LSP / web / MCP / spawn_subagent) through the one seam the JC-B
351
+ // allowlist test also uses — order preserved, behaviour byte-identical.
352
+ const spawnTool = config.subagent.maxDepth > 0
353
+ ? makeSpawnSubagentTool(orchestrator, 0)
354
+ : undefined;
355
+ const spawnManyTool = config.subagent.maxDepth > 0
356
+ ? makeSpawnSubagentsTool(orchestrator, 0)
357
+ : undefined;
358
+ registerRuntimeTools(execRegistry, config, {
359
+ mcpTools,
360
+ spawnTool,
361
+ spawnManyTool,
362
+ });
273
363
  if (planMode) {
274
364
  // One allowlist shared by the plan-approval prompt and the per-action
275
365
  // gate, so a grant recorded during execution is honored by U.3's own check.
@@ -248,6 +248,15 @@ export declare const SubagentConfigSchema: z.ZodObject<{
248
248
  * 1 lets it spawn subagents that cannot themselves spawn (no fork bombs).
249
249
  */
250
250
  maxDepth: z.ZodDefault<z.ZodNumber>;
251
+ /**
252
+ * Parallel fan-out breadth (C.33): the maximum number of subagents that run
253
+ * concurrently across the whole session, enforced by one shared semaphore.
254
+ * Parallel fan-out happens at depth 0 only and everything beneath it is
255
+ * sequential, so this composes with {@link maxDepth} into a LINEAR global
256
+ * ceiling (≤ maxConcurrency × maxDepth live agents) — never an exponential
257
+ * depth×breadth fork bomb. Default 3.
258
+ */
259
+ maxConcurrency: z.ZodDefault<z.ZodNumber>;
251
260
  /** Per-subagent budget ceilings; spawn-time overrides are clamped to these. */
252
261
  defaultBudget: z.ZodDefault<z.ZodObject<{
253
262
  /** Hard cap on the subagent's model turns. */
@@ -267,6 +276,7 @@ export declare const SubagentConfigSchema: z.ZodObject<{
267
276
  }>>;
268
277
  }, "strict", z.ZodTypeAny, {
269
278
  maxDepth: number;
279
+ maxConcurrency: number;
270
280
  defaultBudget: {
271
281
  maxTokens: number;
272
282
  maxIterations: number;
@@ -274,6 +284,7 @@ export declare const SubagentConfigSchema: z.ZodObject<{
274
284
  };
275
285
  }, {
276
286
  maxDepth?: number | undefined;
287
+ maxConcurrency?: number | undefined;
277
288
  defaultBudget?: {
278
289
  timeoutMs?: number | undefined;
279
290
  maxTokens?: number | undefined;
@@ -1027,6 +1038,15 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1027
1038
  * 1 lets it spawn subagents that cannot themselves spawn (no fork bombs).
1028
1039
  */
1029
1040
  maxDepth: z.ZodDefault<z.ZodNumber>;
1041
+ /**
1042
+ * Parallel fan-out breadth (C.33): the maximum number of subagents that run
1043
+ * concurrently across the whole session, enforced by one shared semaphore.
1044
+ * Parallel fan-out happens at depth 0 only and everything beneath it is
1045
+ * sequential, so this composes with {@link maxDepth} into a LINEAR global
1046
+ * ceiling (≤ maxConcurrency × maxDepth live agents) — never an exponential
1047
+ * depth×breadth fork bomb. Default 3.
1048
+ */
1049
+ maxConcurrency: z.ZodDefault<z.ZodNumber>;
1030
1050
  /** Per-subagent budget ceilings; spawn-time overrides are clamped to these. */
1031
1051
  defaultBudget: z.ZodDefault<z.ZodObject<{
1032
1052
  /** Hard cap on the subagent's model turns. */
@@ -1046,6 +1066,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1046
1066
  }>>;
1047
1067
  }, "strict", z.ZodTypeAny, {
1048
1068
  maxDepth: number;
1069
+ maxConcurrency: number;
1049
1070
  defaultBudget: {
1050
1071
  maxTokens: number;
1051
1072
  maxIterations: number;
@@ -1053,6 +1074,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1053
1074
  };
1054
1075
  }, {
1055
1076
  maxDepth?: number | undefined;
1077
+ maxConcurrency?: number | undefined;
1056
1078
  defaultBudget?: {
1057
1079
  timeoutMs?: number | undefined;
1058
1080
  maxTokens?: number | undefined;
@@ -1450,6 +1472,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1450
1472
  };
1451
1473
  subagent: {
1452
1474
  maxDepth: number;
1475
+ maxConcurrency: number;
1453
1476
  defaultBudget: {
1454
1477
  maxTokens: number;
1455
1478
  maxIterations: number;
@@ -1594,6 +1617,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1594
1617
  } | undefined;
1595
1618
  subagent?: {
1596
1619
  maxDepth?: number | undefined;
1620
+ maxConcurrency?: number | undefined;
1597
1621
  defaultBudget?: {
1598
1622
  timeoutMs?: number | undefined;
1599
1623
  maxTokens?: number | undefined;
@@ -181,6 +181,15 @@ export const SubagentConfigSchema = z
181
181
  * 1 lets it spawn subagents that cannot themselves spawn (no fork bombs).
182
182
  */
183
183
  maxDepth: z.number().int().nonnegative().default(1),
184
+ /**
185
+ * Parallel fan-out breadth (C.33): the maximum number of subagents that run
186
+ * concurrently across the whole session, enforced by one shared semaphore.
187
+ * Parallel fan-out happens at depth 0 only and everything beneath it is
188
+ * sequential, so this composes with {@link maxDepth} into a LINEAR global
189
+ * ceiling (≤ maxConcurrency × maxDepth live agents) — never an exponential
190
+ * depth×breadth fork bomb. Default 3.
191
+ */
192
+ maxConcurrency: z.number().int().positive().default(3),
184
193
  /** Per-subagent budget ceilings; spawn-time overrides are clamped to these. */
185
194
  defaultBudget: z
186
195
  .object({
@@ -190,6 +190,29 @@ export declare function checkpointSetPartial(runId: string, restored: string[],
190
190
  * orchestrator seam was driven directly — fail loud, never spawn.
191
191
  */
192
192
  export declare function subagentDepthExceeded(depth: number, maxDepth: number): CruxyError;
193
+ /** One workspace root claimed by two-or-more WRITING children of a single
194
+ * `spawn_subagents` call — the unit of a disjoint-scope violation (C.33). */
195
+ export interface ScopeConflict {
196
+ /** The root name both children declared write scope on. */
197
+ root: string;
198
+ /** The task labels of every writing child that claimed `root` (always ≥2). */
199
+ tasks: string[];
200
+ }
201
+ /**
202
+ * A parallel fan-out (C.33) declared children with overlapping write scope — two
203
+ * or more target the SAME workspace root while both hold mutating tools. The
204
+ * ENTIRE batch is refused BEFORE any child is dispatched, so concurrent siblings
205
+ * can never race on one root's files or checkpoint. Every colliding group is
206
+ * named (the conflicting task pairs, per root) so the model can fix all of them
207
+ * at once. Surfaced as a tool error to correct (assign distinct roots, or drop
208
+ * write tools on all but one child per root).
209
+ *
210
+ * THE HONEST LIMIT, stated in the message: the check is on DECLARED scope — the
211
+ * tools + root each child was granted — NOT the files actually written. Two
212
+ * children could still touch disjoint paths under one root; cruxy refuses anyway
213
+ * rather than let two potential writers race on that root's single checkpoint.
214
+ */
215
+ export declare function subagentScopeOverlap(conflicts: readonly ScopeConflict[]): CruxyError;
193
216
  /**
194
217
  * A subagent run failed outright (provider error, tool crash) before producing
195
218
  * a result. Normally folded into the structured `SubagentResult` the parent
@@ -737,6 +737,44 @@ export function subagentDepthExceeded(depth, maxDepth) {
737
737
  meta: { depth, maxDepth },
738
738
  });
739
739
  }
740
+ /**
741
+ * A parallel fan-out (C.33) declared children with overlapping write scope — two
742
+ * or more target the SAME workspace root while both hold mutating tools. The
743
+ * ENTIRE batch is refused BEFORE any child is dispatched, so concurrent siblings
744
+ * can never race on one root's files or checkpoint. Every colliding group is
745
+ * named (the conflicting task pairs, per root) so the model can fix all of them
746
+ * at once. Surfaced as a tool error to correct (assign distinct roots, or drop
747
+ * write tools on all but one child per root).
748
+ *
749
+ * THE HONEST LIMIT, stated in the message: the check is on DECLARED scope — the
750
+ * tools + root each child was granted — NOT the files actually written. Two
751
+ * children could still touch disjoint paths under one root; cruxy refuses anyway
752
+ * rather than let two potential writers race on that root's single checkpoint.
753
+ */
754
+ export function subagentScopeOverlap(conflicts) {
755
+ const list = conflicts
756
+ .map((c) => `root "${c.root}" ← ${c.tasks.map((t) => `“${t}”`).join(", ")}`)
757
+ .join("; ");
758
+ return new CruxyError({
759
+ code: ErrorCode.SubagentScopeOverlap,
760
+ title: conflicts.length === 1
761
+ ? `parallel subagents overlap on root "${conflicts[0].root}"`
762
+ : `parallel subagents overlap on ${conflicts.length} roots`,
763
+ cause: `these children of one spawn_subagents call declared write scope on the ` +
764
+ `same root — ${list}. The check is on DECLARED scope (the tools and root ` +
765
+ `each child was granted), NOT the files actually written — two children ` +
766
+ `could still touch disjoint paths — but cruxy refuses the whole batch ` +
767
+ `rather than let two writers race on one root's checkpoint.`,
768
+ nextSteps: [
769
+ "give each writing child a distinct `root` (parallel writes require disjoint roots)",
770
+ "or grant write/shell tools to only one child per root and keep the rest read-only",
771
+ "or run the conflicting subtasks sequentially with spawn_subagent",
772
+ ],
773
+ meta: {
774
+ conflicts: conflicts.map((c) => ({ root: c.root, tasks: [...c.tasks] })),
775
+ },
776
+ });
777
+ }
740
778
  /**
741
779
  * A subagent run failed outright (provider error, tool crash) before producing
742
780
  * a result. Normally folded into the structured `SubagentResult` the parent
@@ -52,6 +52,14 @@ export declare const ErrorCode: {
52
52
  /** Carried inside a SubagentResult (informational) — never fatal by itself. */
53
53
  readonly SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET";
54
54
  readonly SubagentFailed: "CRUXY_E_SUBAGENT_FAILED";
55
+ /** Two children of one parallel fan-out (C.33) declare overlapping write scope
56
+ * (same root). Refused pre-dispatch so concurrent siblings can never race on a
57
+ * root — the model corrects it (distinct roots, or read-only). */
58
+ readonly SubagentScopeOverlap: "CRUXY_E_SUBAGENT_SCOPE_OVERLAP";
59
+ /** Carried inside a SubagentResult (informational): the run was cancelled — a
60
+ * fatal sibling failure or Ctrl-C aborted the whole fan-out (C.33). Never a
61
+ * fabricated success; the parent sees exactly which children did not finish. */
62
+ readonly SubagentCancelled: "CRUXY_E_SUBAGENT_CANCELLED";
55
63
  readonly TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND";
56
64
  /** Carried inside a run_tests result (informational) — never fatal by itself. */
57
65
  readonly TestIterationLimit: "CRUXY_E_TEST_ITERATION_LIMIT";
@@ -63,6 +63,14 @@ export const ErrorCode = {
63
63
  /** Carried inside a SubagentResult (informational) — never fatal by itself. */
64
64
  SubagentBudget: "CRUXY_E_SUBAGENT_BUDGET",
65
65
  SubagentFailed: "CRUXY_E_SUBAGENT_FAILED",
66
+ /** Two children of one parallel fan-out (C.33) declare overlapping write scope
67
+ * (same root). Refused pre-dispatch so concurrent siblings can never race on a
68
+ * root — the model corrects it (distinct roots, or read-only). */
69
+ SubagentScopeOverlap: "CRUXY_E_SUBAGENT_SCOPE_OVERLAP",
70
+ /** Carried inside a SubagentResult (informational): the run was cancelled — a
71
+ * fatal sibling failure or Ctrl-C aborted the whole fan-out (C.33). Never a
72
+ * fabricated success; the parent sees exactly which children did not finish. */
73
+ SubagentCancelled: "CRUXY_E_SUBAGENT_CANCELLED",
66
74
  // testing (exit 2 / 11)
67
75
  TestCommandNotFound: "CRUXY_E_TEST_COMMAND_NOT_FOUND",
68
76
  /** Carried inside a run_tests result (informational) — never fatal by itself. */
@@ -230,6 +238,10 @@ const EXIT_CODES = {
230
238
  [ErrorCode.SubagentDepthExceeded]: 2,
231
239
  [ErrorCode.SubagentBudget]: 11,
232
240
  [ErrorCode.SubagentFailed]: 11,
241
+ // Overlapping fan-out scope is a spawn misuse (usage, model-correctable);
242
+ // cancellation surfaces inside a SubagentResult and is never fatal by itself.
243
+ [ErrorCode.SubagentScopeOverlap]: 2,
244
+ [ErrorCode.SubagentCancelled]: 11,
233
245
  // No test command is a configuration gap (usage); the iteration limit
234
246
  // surfaces inside a run_tests result and is never fatal by itself.
235
247
  [ErrorCode.TestCommandNotFound]: 2,
@@ -4,3 +4,4 @@ export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPat
4
4
  export { HookRunner, type HookRunnerDeps, type TrustPromptInfo, } from "./runner.js";
5
5
  export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, type SlashResolution, } from "./slash.js";
6
6
  export { buildHooksService, type BuildHooksServiceOptions, type HooksService, } from "./service.js";
7
+ export { buildHooksRouter, type BuildHooksRouterOptions, type HooksRouterResult, type UntrustedHookRoot, } from "./router.js";
@@ -4,3 +4,4 @@ export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPat
4
4
  export { HookRunner, } from "./runner.js";
5
5
  export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, } from "./slash.js";
6
6
  export { buildHooksService, } from "./service.js";
7
+ export { buildHooksRouter, } from "./router.js";
@@ -0,0 +1,58 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { LifecycleHookRunner } from "../agent/loop.js";
3
+ import type { logger as Logger } from "../utils/logger.js";
4
+ import type { DeclaredRoot, Workspace } from "../workspace/index.js";
5
+ import { type TrustStore } from "./trust.js";
6
+ import type { HookCatalog, SlashCommandSpec } from "./types.js";
7
+ /**
8
+ * Per-root hook dispatch for a multi-root session (C.26 step 5).
9
+ *
10
+ * The isolation here is a PROPERTY OF CONSTRUCTION, not a guard: each root gets
11
+ * its OWN {@link HookRunner} (own project-hook catalog, own trust key), so there
12
+ * is no code path from root A's runner to a root-B action. The router only
13
+ * decides WHICH runner to invoke:
14
+ *
15
+ * - Tool-scoped events (`before-tool` / `after-tool` / `on-file-change`) fire
16
+ * exactly the ONE acting root's runner — the same root the tool resolved via
17
+ * {@link selectRoot}. A's project hooks structurally cannot fire for a
18
+ * B-scoped call (⚖︎10, pinned by test).
19
+ * - Session lifecycle events (`before-run` / `after-run`) have NO acting root,
20
+ * so they fan EVERY trusted root sequentially, in declaration order,
21
+ * root-labeled; a blocking failure in any root aborts the run naming that
22
+ * root (JC-1). Untrusted roots are skipped and named in the startup banner —
23
+ * never silent (a user's pre-run guard in an untrusted root must not vanish
24
+ * without a word).
25
+ *
26
+ * User-scope hooks (`~/.cruxy`) are session-global — authored by you, shared by
27
+ * every root — so they fire EXACTLY ONCE per event through a single shared
28
+ * runner, never once per root.
29
+ */
30
+ /** A root whose project hooks exist but are not trusted → named in the banner. */
31
+ export interface UntrustedHookRoot {
32
+ name: string;
33
+ absPath: string;
34
+ count: number;
35
+ }
36
+ export interface HooksRouterResult {
37
+ /** The lifecycle firing seam (a {@link LifecycleHookRunner}). */
38
+ runner: LifecycleHookRunner;
39
+ /** Custom slash commands (primary root only this release). */
40
+ commands: SlashCommandSpec[];
41
+ /** Roots with untrusted project hooks — named in the banner, never silent. */
42
+ untrustedHookRoots: UntrustedHookRoot[];
43
+ }
44
+ export interface BuildHooksRouterOptions {
45
+ workspace: Workspace;
46
+ config: CruxyConfig;
47
+ logger: typeof Logger;
48
+ trust?: TrustStore;
49
+ /** Per-root catalog loader; defaults to the layered on-disk loader. */
50
+ loadCatalog?: (root: DeclaredRoot) => Promise<HookCatalog>;
51
+ now?: () => string;
52
+ }
53
+ /**
54
+ * Build the multi-root hook router. One project-hook runner per declared root
55
+ * (trust-gated against that root's key) plus one shared user-hook runner. Load
56
+ * errors are surfaced through the logger exactly as the single-root path does.
57
+ */
58
+ export declare function buildHooksRouter(opts: BuildHooksRouterOptions): Promise<HooksRouterResult>;
@@ -0,0 +1,136 @@
1
+ import { selectRoot } from "../workspace/index.js";
2
+ import { defaultHookSources, loadHookCatalog } from "./config.js";
3
+ import { HookRunner } from "./runner.js";
4
+ import { fileTrustStore } from "./trust.js";
5
+ const LIFECYCLE_EVENTS = new Set(["before-run", "after-run"]);
6
+ /**
7
+ * Resolve the ONE acting root for a tool-scoped event from the raw tool-call
8
+ * input, reusing the exact {@link selectRoot} precedence the tool itself uses so
9
+ * the hook's root always matches the tool's root. Bare/ambiguous/unknown → the
10
+ * primary (a hook never crosses into a root the call didn't address).
11
+ */
12
+ function resolveActingRoot(ws, input) {
13
+ if (input && typeof input === "object") {
14
+ const rec = input;
15
+ const root = typeof rec.root === "string" ? rec.root : undefined;
16
+ const p = typeof rec.path === "string" ? rec.path : undefined;
17
+ if (root !== undefined || p !== undefined) {
18
+ try {
19
+ return selectRoot(ws, { root, path: p ?? "." }).root;
20
+ }
21
+ catch {
22
+ // Unknown root name / not-contained absolute path → fall to primary.
23
+ }
24
+ }
25
+ }
26
+ return ws.primary();
27
+ }
28
+ class HookRouter {
29
+ deps;
30
+ constructor(deps) {
31
+ this.deps = deps;
32
+ }
33
+ async fire(event, ctx, hint) {
34
+ if (!this.deps.enabled)
35
+ return;
36
+ if (LIFECYCLE_EVENTS.has(event)) {
37
+ // Session lifecycle: user hooks once, then every TRUSTED root in order.
38
+ await this.deps.userRunner.fire(event, ctx);
39
+ for (const root of this.deps.workspace.roots()) {
40
+ const runner = this.deps.perRoot.get(root.absPath);
41
+ if (!runner || !runner.projectTrusted)
42
+ continue; // skipped; banner-named
43
+ // A blocking failure throws `hookFailed("<root> ▸ <hook>", …)` — the run
44
+ // aborts naming which root refused (the runner carries `label`).
45
+ await runner.fire(event, rootCtx(ctx, root));
46
+ }
47
+ return;
48
+ }
49
+ // Tool-scoped: exactly the ONE acting root (+ the shared user hooks).
50
+ const acting = resolveActingRoot(this.deps.workspace, hint?.input);
51
+ const actingCtx = rootCtx(ctx, acting);
52
+ await this.deps.userRunner.fire(event, actingCtx);
53
+ const runner = this.deps.perRoot.get(acting.absPath);
54
+ if (runner && runner.projectTrusted) {
55
+ await runner.fire(event, actingCtx);
56
+ }
57
+ }
58
+ }
59
+ /** A per-root view of the tool context: the hook command runs in THAT root's
60
+ * directory (`runGatedShell` uses `ctx.cwd`), so a root's hook always executes
61
+ * against its own tree — never the primary's. */
62
+ function rootCtx(ctx, root) {
63
+ return { ...ctx, cwd: root.absPath };
64
+ }
65
+ /**
66
+ * Build the multi-root hook router. One project-hook runner per declared root
67
+ * (trust-gated against that root's key) plus one shared user-hook runner. Load
68
+ * errors are surfaced through the logger exactly as the single-root path does.
69
+ */
70
+ export async function buildHooksRouter(opts) {
71
+ const trust = opts.trust ?? fileTrustStore();
72
+ const enabled = opts.config.hooks.enabled;
73
+ const perRoot = new Map();
74
+ const untrustedHookRoots = [];
75
+ let userHooks = [];
76
+ let primaryCommands = [];
77
+ const loadCatalog = opts.loadCatalog ??
78
+ ((root) => loadHookCatalog(defaultHookSources(root.absPath)));
79
+ for (const root of opts.workspace.roots()) {
80
+ const catalog = await loadCatalog(root);
81
+ for (const err of catalog.errors) {
82
+ opts.logger.warn(`ignoring malformed ${err.source} hook/command "${err.name}" in ${root.name}: ${err.message}`);
83
+ }
84
+ // Each root's runner owns ONLY its project hooks. User hooks are global —
85
+ // loaded once (from the primary, whose `sources.user` is identical for every
86
+ // root) and fired through the shared runner, never per-root.
87
+ const projectHooks = catalog.hooks.filter((h) => h.source === "project");
88
+ if (root.primary) {
89
+ userHooks = catalog.hooks.filter((h) => h.source === "user");
90
+ primaryCommands = catalog.commands;
91
+ }
92
+ const runner = new HookRunner({
93
+ hooks: projectHooks,
94
+ trust,
95
+ enabled,
96
+ // Multi-root never lazy-prompts (a barrage of per-sibling prompts mid-run
97
+ // is worse than a named skip). Untrusted roots are skipped + banner-named;
98
+ // `cruxy hooks trust <path>` is the deliberate opt-in.
99
+ trustPrompt: false,
100
+ interactive: false,
101
+ cwd: root.absPath,
102
+ label: root.name,
103
+ announce: (m) => opts.logger.info(`[${root.name}] ${m}`),
104
+ reportFailure: (m) => opts.logger.warn(`[${root.name}] ${m}`),
105
+ now: opts.now,
106
+ });
107
+ perRoot.set(root.absPath, runner);
108
+ if (!runner.projectTrusted) {
109
+ untrustedHookRoots.push({
110
+ name: root.name,
111
+ absPath: root.absPath,
112
+ count: runner.projectHookCount,
113
+ });
114
+ }
115
+ }
116
+ // Shared user-hook runner: user hooks carry no project source, so its trust
117
+ // gate never triggers (you authored them) — it just fires them once.
118
+ const userRunner = new HookRunner({
119
+ hooks: userHooks,
120
+ trust,
121
+ enabled,
122
+ trustPrompt: false,
123
+ interactive: false,
124
+ cwd: opts.workspace.primary().absPath,
125
+ announce: (m) => opts.logger.info(m),
126
+ reportFailure: (m) => opts.logger.warn(m),
127
+ now: opts.now,
128
+ });
129
+ const runner = new HookRouter({
130
+ workspace: opts.workspace,
131
+ perRoot,
132
+ userRunner,
133
+ enabled,
134
+ });
135
+ return { runner, commands: primaryCommands, untrustedHookRoots };
136
+ }