@cruxy/cli 0.23.0 → 0.25.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 (84) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/agent/session.d.ts +13 -0
  4. package/dist/agent/session.js +6 -0
  5. package/dist/approval/index.d.ts +1 -0
  6. package/dist/approval/index.js +1 -0
  7. package/dist/approval/mutex.d.ts +45 -0
  8. package/dist/approval/mutex.js +57 -0
  9. package/dist/checkpoint/gate-hook.d.ts +28 -0
  10. package/dist/checkpoint/gate-hook.js +98 -0
  11. package/dist/checkpoint/gate.d.ts +7 -1
  12. package/dist/checkpoint/gate.js +8 -2
  13. package/dist/checkpoint/index.d.ts +1 -0
  14. package/dist/checkpoint/index.js +1 -0
  15. package/dist/checkpoint/service.d.ts +9 -0
  16. package/dist/checkpoint/service.js +20 -0
  17. package/dist/cli/commands/rollback.d.ts +4 -1
  18. package/dist/cli/commands/rollback.js +16 -9
  19. package/dist/cli/commands/run.js +62 -16
  20. package/dist/cli/onboard.js +2 -2
  21. package/dist/cli/repl.d.ts +1 -1
  22. package/dist/cli/repl.js +145 -0
  23. package/dist/cli/session-factory.d.ts +24 -10
  24. package/dist/cli/session-factory.js +179 -135
  25. package/dist/config/schema.d.ts +110 -0
  26. package/dist/config/schema.js +50 -0
  27. package/dist/errors/constructors.d.ts +41 -0
  28. package/dist/errors/constructors.js +87 -0
  29. package/dist/errors/types.d.ts +21 -0
  30. package/dist/errors/types.js +33 -0
  31. package/dist/hooks/index.d.ts +1 -0
  32. package/dist/hooks/index.js +1 -0
  33. package/dist/hooks/router.d.ts +58 -0
  34. package/dist/hooks/router.js +136 -0
  35. package/dist/hooks/runner.d.ts +12 -0
  36. package/dist/hooks/runner.js +23 -1
  37. package/dist/jobs/approval-queue.d.ts +85 -0
  38. package/dist/jobs/approval-queue.js +96 -0
  39. package/dist/jobs/dispatch-tool.d.ts +34 -0
  40. package/dist/jobs/dispatch-tool.js +96 -0
  41. package/dist/jobs/index.d.ts +6 -0
  42. package/dist/jobs/index.js +6 -0
  43. package/dist/jobs/log-buffer.d.ts +31 -0
  44. package/dist/jobs/log-buffer.js +30 -0
  45. package/dist/jobs/log-renderer.d.ts +32 -0
  46. package/dist/jobs/log-renderer.js +70 -0
  47. package/dist/jobs/manager.d.ts +139 -0
  48. package/dist/jobs/manager.js +397 -0
  49. package/dist/jobs/types.d.ts +81 -0
  50. package/dist/jobs/types.js +10 -0
  51. package/dist/mcp/index.d.ts +1 -0
  52. package/dist/mcp/index.js +1 -0
  53. package/dist/mcp/sibling-banner.d.ts +25 -0
  54. package/dist/mcp/sibling-banner.js +34 -0
  55. package/dist/memory/recall.d.ts +24 -0
  56. package/dist/memory/recall.js +54 -0
  57. package/dist/memory/remember-tool.d.ts +3 -0
  58. package/dist/memory/remember-tool.js +11 -1
  59. package/dist/sandbox/policy.js +14 -5
  60. package/dist/sandbox/service.d.ts +8 -1
  61. package/dist/sandbox/service.js +4 -1
  62. package/dist/subagent/index.d.ts +1 -0
  63. package/dist/subagent/index.js +1 -0
  64. package/dist/subagent/orchestrator.d.ts +76 -2
  65. package/dist/subagent/orchestrator.js +208 -18
  66. package/dist/subagent/registry-scope.d.ts +13 -0
  67. package/dist/subagent/registry-scope.js +28 -2
  68. package/dist/subagent/semaphore.d.ts +56 -0
  69. package/dist/subagent/semaphore.js +53 -0
  70. package/dist/subagent/spawn-tool.d.ts +57 -0
  71. package/dist/subagent/spawn-tool.js +104 -9
  72. package/dist/subagent/types.d.ts +17 -2
  73. package/dist/testing/run-tests-tool.js +1 -1
  74. package/dist/tools/file/paths.d.ts +5 -6
  75. package/dist/tools/file/paths.js +7 -8
  76. package/dist/tools/shell/exec.js +36 -4
  77. package/dist/tools/types.d.ts +16 -5
  78. package/dist/workspace/add-root.d.ts +27 -0
  79. package/dist/workspace/add-root.js +16 -0
  80. package/dist/workspace/index.d.ts +2 -1
  81. package/dist/workspace/index.js +2 -1
  82. package/dist/workspace/workspace.d.ts +9 -4
  83. package/dist/workspace/workspace.js +9 -4
  84. package/package.json +1 -1
@@ -190,12 +190,53 @@ 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
196
219
  * reasons over; thrown only when the orchestrator itself cannot proceed.
197
220
  */
198
221
  export declare function subagentFailed(underlying?: unknown): CruxyError;
222
+ /**
223
+ * A `run_in_background` dispatch was refused because the session already holds
224
+ * `jobs.maxJobs` live jobs (queued + running + paused). The MODEL corrects it, so
225
+ * this is a coded tool error, not a silent drop: wait for a job to finish (or
226
+ * cancel one) and retry, or run the work in the foreground.
227
+ */
228
+ export declare function jobLimitExceeded(maxJobs: number, live: number): CruxyError;
229
+ /**
230
+ * `cruxy cancel/logs/rollback <id>` named a job that does not exist in this
231
+ * session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
232
+ * is legitimately gone — fail loud with the id rather than a silent no-op.
233
+ */
234
+ export declare function jobNotFound(id: string): CruxyError;
235
+ /**
236
+ * A background-jobs command was used while the feature is disabled. Surfaced with
237
+ * how to enable it rather than pretending there are simply no jobs.
238
+ */
239
+ export declare function jobsDisabled(): CruxyError;
199
240
  /**
200
241
  * No test command could be detected and none is configured (C.13). cruxy never
201
242
  * invents a test command — the fix is always to declare one.
@@ -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
@@ -754,6 +792,55 @@ export function subagentFailed(underlying) {
754
792
  underlying,
755
793
  });
756
794
  }
795
+ // ── background jobs (exit 2 / 19) — C.28 ──────────────────────────────────────
796
+ /**
797
+ * A `run_in_background` dispatch was refused because the session already holds
798
+ * `jobs.maxJobs` live jobs (queued + running + paused). The MODEL corrects it, so
799
+ * this is a coded tool error, not a silent drop: wait for a job to finish (or
800
+ * cancel one) and retry, or run the work in the foreground.
801
+ */
802
+ export function jobLimitExceeded(maxJobs, live) {
803
+ return new CruxyError({
804
+ code: ErrorCode.JobLimit,
805
+ title: `too many background jobs: ${live} live, limit ${maxJobs}`,
806
+ cause: "the count of queued + running + paused jobs is at `jobs.maxJobs`; a new " +
807
+ "dispatch would exceed the ceiling on OUTSTANDING jobs (distinct from the " +
808
+ "shared execution cap `subagent.maxConcurrency`)",
809
+ nextSteps: [
810
+ "wait for a running job to finish, or `cruxy cancel <id>` one you no longer need",
811
+ "raise `jobs.maxJobs` in config if more concurrent jobs are intended",
812
+ "or run this task in the foreground instead of the background",
813
+ ],
814
+ meta: { maxJobs, live },
815
+ });
816
+ }
817
+ /**
818
+ * `cruxy cancel/logs/rollback <id>` named a job that does not exist in this
819
+ * session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
820
+ * is legitimately gone — fail loud with the id rather than a silent no-op.
821
+ */
822
+ export function jobNotFound(id) {
823
+ return new CruxyError({
824
+ code: ErrorCode.JobNotFound,
825
+ title: `no background job with id "${id}" in this session`,
826
+ cause: "background jobs live only for the session that dispatched them; an id from " +
827
+ "a previous session, or a mistyped one, has no live job",
828
+ nextSteps: ["run `cruxy jobs` to list the live jobs and their ids"],
829
+ meta: { id },
830
+ });
831
+ }
832
+ /**
833
+ * A background-jobs command was used while the feature is disabled. Surfaced with
834
+ * how to enable it rather than pretending there are simply no jobs.
835
+ */
836
+ export function jobsDisabled() {
837
+ return new CruxyError({
838
+ code: ErrorCode.JobsDisabled,
839
+ title: "background jobs are disabled",
840
+ cause: "`jobs.enabled` is false, so no jobs can be dispatched or listed",
841
+ nextSteps: ["enable it: `cruxy config set jobs.enabled true`"],
842
+ });
843
+ }
757
844
  // ── testing (exit 2) ──────────────────────────────────────────────────────────
758
845
  /**
759
846
  * No test command could be detected and none is configured (C.13). cruxy never
@@ -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";
@@ -165,6 +173,19 @@ export declare const ErrorCode: {
165
173
  * is a single-repo artifact, so it is refused (naming both) rather than silently
166
174
  * PR one half. */
167
175
  readonly VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT";
176
+ /** A `run_in_background` dispatch was refused because the live job count
177
+ * (queued + running + paused) already sits at `jobs.maxJobs`. The MODEL corrects
178
+ * it — wait for a job to finish (or cancel one) and retry — so it is a usage-tier
179
+ * coded error, never a silently-dropped dispatch. */
180
+ readonly JobLimit: "CRUXY_E_JOB_LIMIT";
181
+ /** `cruxy cancel/logs/rollback <id>` named a job id that does not exist in this
182
+ * session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
183
+ * is legitimately unknown — fail loud with the id rather than a silent no-op. */
184
+ readonly JobNotFound: "CRUXY_E_JOB_NOT_FOUND";
185
+ /** A background-jobs command (`cruxy jobs/logs/cancel`) was used while
186
+ * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
187
+ * rather than pretending there are simply no jobs. */
188
+ readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
168
189
  };
169
190
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
170
191
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -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. */
@@ -185,6 +193,20 @@ export const ErrorCode = {
185
193
  * is a single-repo artifact, so it is refused (naming both) rather than silently
186
194
  * PR one half. */
187
195
  VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT",
196
+ // session-scoped background jobs (exit 2 / 19) — C.28
197
+ /** A `run_in_background` dispatch was refused because the live job count
198
+ * (queued + running + paused) already sits at `jobs.maxJobs`. The MODEL corrects
199
+ * it — wait for a job to finish (or cancel one) and retry — so it is a usage-tier
200
+ * coded error, never a silently-dropped dispatch. */
201
+ JobLimit: "CRUXY_E_JOB_LIMIT",
202
+ /** `cruxy cancel/logs/rollback <id>` named a job id that does not exist in this
203
+ * session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
204
+ * is legitimately unknown — fail loud with the id rather than a silent no-op. */
205
+ JobNotFound: "CRUXY_E_JOB_NOT_FOUND",
206
+ /** A background-jobs command (`cruxy jobs/logs/cancel`) was used while
207
+ * `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
208
+ * rather than pretending there are simply no jobs. */
209
+ JobsDisabled: "CRUXY_E_JOBS_DISABLED",
188
210
  };
189
211
  /**
190
212
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -230,6 +252,10 @@ const EXIT_CODES = {
230
252
  [ErrorCode.SubagentDepthExceeded]: 2,
231
253
  [ErrorCode.SubagentBudget]: 11,
232
254
  [ErrorCode.SubagentFailed]: 11,
255
+ // Overlapping fan-out scope is a spawn misuse (usage, model-correctable);
256
+ // cancellation surfaces inside a SubagentResult and is never fatal by itself.
257
+ [ErrorCode.SubagentScopeOverlap]: 2,
258
+ [ErrorCode.SubagentCancelled]: 11,
233
259
  // No test command is a configuration gap (usage); the iteration limit
234
260
  // surfaces inside a run_tests result and is never fatal by itself.
235
261
  [ErrorCode.TestCommandNotFound]: 2,
@@ -292,6 +318,13 @@ const EXIT_CODES = {
292
318
  // refusals kin to the other cross-root guards — they share the greppable code.
293
319
  [ErrorCode.VcsRemoteChanged]: 18,
294
320
  [ErrorCode.VcsCrossRoot]: 18,
321
+ // Background jobs (C.28). The dispatch-limit refusal is model-correctable, so
322
+ // it shares the usage exit code (2) with the other model-facing coded errors
323
+ // (subagent depth/scope). The CLI-facing ones — an unknown job id, the feature
324
+ // disabled — get a distinct greppable category (19).
325
+ [ErrorCode.JobLimit]: 2,
326
+ [ErrorCode.JobNotFound]: 19,
327
+ [ErrorCode.JobsDisabled]: 19,
295
328
  };
296
329
  /** The process exit code for an error code (defaults to 1 for safety). */
297
330
  export function exitCodeFor(code) {
@@ -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
+ }
@@ -36,6 +36,10 @@ export interface HookRunnerDeps {
36
36
  interactive: boolean;
37
37
  /** Project root — the trust key and fingerprint scope. */
38
38
  cwd: string;
39
+ /** Root label for multi-root fan-out (C.26 step 5). When set, a blocking
40
+ * failure names its root (`<label> ▸ <hook>`) so an aborted run says which
41
+ * root refused. Undefined in the single-root path → messages unchanged. */
42
+ label?: string;
39
43
  /** Interactive trust prompt (returns true to trust). Required only when a
40
44
  * project defines hooks and `trustPrompt` + `interactive` are both on. */
41
45
  promptTrust?: (info: TrustPromptInfo) => Promise<boolean>;
@@ -51,6 +55,14 @@ export declare class HookRunner {
51
55
  constructor(deps: HookRunnerDeps);
52
56
  /** The project hooks — the trust-gated subset. */
53
57
  private get projectHooks();
58
+ /** Prefix a hook name with the root label (multi-root), else leave it. */
59
+ private qualify;
60
+ /** Whether this root's project hooks are trusted for their current
61
+ * fingerprint. Read-only (never prompts/records) — the router uses it to skip
62
+ * untrusted roots and to name them in the banner. */
63
+ get projectTrusted(): boolean;
64
+ /** Count of project (trust-gated) hooks — for the untrusted-root banner. */
65
+ get projectHookCount(): number;
54
66
  /**
55
67
  * Fire every hook registered for `event`, in catalog order. Resolves normally
56
68
  * when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
@@ -10,12 +10,33 @@ export class HookRunner {
10
10
  get projectHooks() {
11
11
  return this.deps.hooks.filter((h) => h.source === "project");
12
12
  }
13
+ /** Prefix a hook name with the root label (multi-root), else leave it. */
14
+ qualify(name) {
15
+ return this.deps.label ? `${this.deps.label} ▸ ${name}` : name;
16
+ }
17
+ /** Whether this root's project hooks are trusted for their current
18
+ * fingerprint. Read-only (never prompts/records) — the router uses it to skip
19
+ * untrusted roots and to name them in the banner. */
20
+ get projectTrusted() {
21
+ const projectHooks = this.projectHooks;
22
+ if (projectHooks.length === 0)
23
+ return true;
24
+ return isTrusted(this.deps.trust, this.deps.cwd, fingerprintHooks(projectHooks));
25
+ }
26
+ /** Count of project (trust-gated) hooks — for the untrusted-root banner. */
27
+ get projectHookCount() {
28
+ return this.projectHooks.length;
29
+ }
13
30
  /**
14
31
  * Fire every hook registered for `event`, in catalog order. Resolves normally
15
32
  * when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
16
33
  * when a blocking hook fails, or `CRUXY_E_HOOK_UNTRUSTED` when a project's
17
34
  * hooks are not trusted. A no-op when hooks are disabled or none match.
18
35
  */
36
+ // The LifecycleHookRunner seam passes an optional acting-root hint as a 3rd
37
+ // arg; the single-root runner owns exactly one root and ignores it (a method
38
+ // with fewer params is still assignable to the interface), so single-root
39
+ // firing stays byte-identical.
19
40
  async fire(event, ctx) {
20
41
  if (!this.deps.enabled)
21
42
  return;
@@ -33,7 +54,8 @@ export class HookRunner {
33
54
  continue;
34
55
  if (hook.blocking) {
35
56
  // Fail-closed: stop, do not run the remaining hooks, abort the action.
36
- throw hookFailed(hook.name, verdict.reason ?? "hook failed");
57
+ // `label` (multi-root) names the refusing root in the aborted-run error.
58
+ throw hookFailed(this.qualify(hook.name), verdict.reason ?? "hook failed");
37
59
  }
38
60
  // Advisory: the action already happened (or proceeds) — report, continue.
39
61
  this.deps.reportFailure?.(`hook "${hook.name}" failed (advisory): ${verdict.reason ?? "unknown"}`);
@@ -0,0 +1,85 @@
1
+ import type { ApprovalDecision, RiskTier } from "../approval/index.js";
2
+ /**
3
+ * The single foreground pending-approval queue (C.28) — the seam that lets a
4
+ * NON-interactive background job get a gated action approved by the ONE
5
+ * interactive foreground human, without becoming a second interactive context.
6
+ *
7
+ * The §0 model: a background job is not a competing consumer of the terminal —
8
+ * it is a PRODUCER on this queue. When a job hits a gated action it `submit`s the
9
+ * request here and blocks on the returned promise; it holds no terminal, no
10
+ * mutex, and (having released its execution slot) no compute while it waits. The
11
+ * foreground drains the queue when it is idle (between turns): {@link serviceAll}
12
+ * runs each request's `finalize` — the REAL U.3 prompt + checkpoint, serialized
13
+ * through the shared approval mutex INSIDE `finalize` — and settles the producer
14
+ * with the human's decision. Because the prompt happens on the foreground under
15
+ * the shared mutex, a background job can never paint a second prompt over a
16
+ * foreground one, and the action never executes until a human has decided.
17
+ *
18
+ * This queue is pure transport: it holds pending entries and settles them. It
19
+ * knows nothing about checkpoints, the mutex, or how a decision is reached — the
20
+ * manager composes that into each entry's `finalize`.
21
+ */
22
+ /** A read-only view of one pending request (for `/jobs` display + tests). */
23
+ export interface PendingApproval {
24
+ /** Queue-unique id. */
25
+ readonly id: string;
26
+ /** The job that is blocked on this decision. */
27
+ readonly jobId: string;
28
+ /** One plain line describing the action (e.g. "run: rm -rf build"). */
29
+ readonly summary: string;
30
+ /** Risk tier (mutate | destructive — reads never reach the queue). */
31
+ readonly tier: RiskTier;
32
+ }
33
+ /** What a producing job submits: how to describe it, and how to actually decide. */
34
+ export interface ApprovalSubmission {
35
+ jobId: string;
36
+ summary: string;
37
+ tier: RiskTier;
38
+ /**
39
+ * Run the REAL decision: the interactive U.3 prompt + the job's checkpoint
40
+ * snapshot, serialized on the shared approval mutex. Composed by the manager so
41
+ * this queue stays decoupled. Called by {@link serviceAll} on the foreground;
42
+ * its resolved decision settles the producer's `submit` promise. A throw
43
+ * (e.g. a checkpoint failure) rejects the producer's promise so the job fails
44
+ * loud rather than hanging.
45
+ */
46
+ finalize: () => Promise<ApprovalDecision>;
47
+ }
48
+ export declare class ApprovalQueue {
49
+ private readonly queue;
50
+ private seq;
51
+ /**
52
+ * A job submits a gated action and awaits the decision. Resolves when the
53
+ * foreground services it ({@link serviceAll}) or a caller {@link withdraw}s it;
54
+ * rejects if the finalize throws. The action does NOT execute here — the caller
55
+ * only proceeds after this resolves `{allow:true}`.
56
+ */
57
+ submit(sub: ApprovalSubmission): Promise<ApprovalDecision>;
58
+ /** Whether any request is waiting to be serviced. */
59
+ hasPending(): boolean;
60
+ /** Count of requests waiting. */
61
+ get size(): number;
62
+ /** Read-only snapshot of every pending request, FIFO order. */
63
+ pending(): PendingApproval[];
64
+ /** The pending request for one job (a job has at most one at a time), if any. */
65
+ pendingFor(jobId: string): PendingApproval | undefined;
66
+ /**
67
+ * Foreground drain: service every currently-pending request, FIFO, ONE AT A
68
+ * TIME — each `finalize` fully settles (including the human's keypress) before
69
+ * the next begins, so two background prompts never overlap and the shared mutex
70
+ * inside `finalize` also serializes them against any foreground action. Returns
71
+ * the number serviced. Requests that arrive AFTER draining starts wait for the
72
+ * next drain (bounded work per idle window). A finalize throw rejects that one
73
+ * producer and drains on — one job's checkpoint failure never wedges the queue.
74
+ */
75
+ serviceAll(): Promise<number>;
76
+ /**
77
+ * Withdraw a job's pending request WITHOUT prompting — used when the job is
78
+ * cancelled (or the session exits) while it is paused: settle the producer with
79
+ * `decision` (a deny) so its `submit` promise resolves and the job can finish
80
+ * tearing down instead of blocking forever. Returns true if one was withdrawn.
81
+ */
82
+ withdraw(jobId: string, decision: ApprovalDecision): boolean;
83
+ /** Settle an entry exactly once (double-settle is a defensive no-op). */
84
+ private settle;
85
+ }