@cruxy/cli 0.22.1 → 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 (103) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/approval/classify.js +18 -0
  4. package/dist/approval/index.d.ts +1 -0
  5. package/dist/approval/index.js +1 -0
  6. package/dist/approval/mutex.d.ts +45 -0
  7. package/dist/approval/mutex.js +57 -0
  8. package/dist/approval/prompt.js +11 -0
  9. package/dist/checkpoint/gate.d.ts +65 -0
  10. package/dist/checkpoint/gate.js +86 -0
  11. package/dist/checkpoint/index.d.ts +2 -0
  12. package/dist/checkpoint/index.js +2 -0
  13. package/dist/checkpoint/service.d.ts +9 -0
  14. package/dist/checkpoint/service.js +20 -0
  15. package/dist/checkpoint/set-rollback.d.ts +51 -0
  16. package/dist/checkpoint/set-rollback.js +74 -0
  17. package/dist/cli/commands/rollback.d.ts +11 -6
  18. package/dist/cli/commands/rollback.js +93 -33
  19. package/dist/cli/commands/run.js +104 -21
  20. package/dist/cli/onboard.js +4 -1
  21. package/dist/cli/repl.d.ts +2 -2
  22. package/dist/cli/repl.js +39 -0
  23. package/dist/cli/session-factory.d.ts +27 -4
  24. package/dist/cli/session-factory.js +234 -58
  25. package/dist/config/schema.d.ts +24 -0
  26. package/dist/config/schema.js +9 -0
  27. package/dist/errors/constructors.d.ts +51 -0
  28. package/dist/errors/constructors.js +97 -0
  29. package/dist/errors/types.d.ts +28 -0
  30. package/dist/errors/types.js +38 -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/indexing/retriever.d.ts +29 -0
  38. package/dist/indexing/retriever.js +26 -0
  39. package/dist/indexing/service.js +3 -1
  40. package/dist/indexing/types.d.ts +7 -0
  41. package/dist/lsp/tools/common.d.ts +34 -7
  42. package/dist/lsp/tools/common.js +33 -11
  43. package/dist/lsp/tools/find-definition.js +2 -2
  44. package/dist/lsp/tools/find-references.js +10 -4
  45. package/dist/lsp/tools/get-diagnostics.js +6 -4
  46. package/dist/mcp/index.d.ts +1 -0
  47. package/dist/mcp/index.js +1 -0
  48. package/dist/mcp/sibling-banner.d.ts +25 -0
  49. package/dist/mcp/sibling-banner.js +34 -0
  50. package/dist/memory/recall.d.ts +24 -0
  51. package/dist/memory/recall.js +54 -0
  52. package/dist/memory/remember-tool.d.ts +3 -0
  53. package/dist/memory/remember-tool.js +11 -1
  54. package/dist/render/diff.js +42 -5
  55. package/dist/sandbox/policy.js +14 -5
  56. package/dist/sandbox/service.d.ts +8 -1
  57. package/dist/sandbox/service.js +4 -1
  58. package/dist/subagent/index.d.ts +1 -0
  59. package/dist/subagent/index.js +1 -0
  60. package/dist/subagent/orchestrator.d.ts +81 -1
  61. package/dist/subagent/orchestrator.js +204 -17
  62. package/dist/subagent/registry-scope.d.ts +13 -0
  63. package/dist/subagent/registry-scope.js +28 -2
  64. package/dist/subagent/semaphore.d.ts +27 -0
  65. package/dist/subagent/semaphore.js +56 -0
  66. package/dist/subagent/spawn-tool.d.ts +57 -0
  67. package/dist/subagent/spawn-tool.js +104 -9
  68. package/dist/subagent/types.d.ts +17 -2
  69. package/dist/testing/run-tests-tool.js +3 -0
  70. package/dist/tools/create-pull-request.d.ts +3 -0
  71. package/dist/tools/create-pull-request.js +50 -4
  72. package/dist/tools/file/apply-patch.js +2 -2
  73. package/dist/tools/file/edit-file.js +2 -2
  74. package/dist/tools/file/glob.d.ts +9 -2
  75. package/dist/tools/file/glob.js +73 -19
  76. package/dist/tools/file/grep-files.d.ts +12 -2
  77. package/dist/tools/file/grep-files.js +113 -38
  78. package/dist/tools/file/paths.d.ts +121 -9
  79. package/dist/tools/file/paths.js +164 -10
  80. package/dist/tools/file/read-file.js +2 -2
  81. package/dist/tools/file/write-file.js +2 -2
  82. package/dist/tools/git-status.d.ts +8 -1
  83. package/dist/tools/git-status.js +43 -11
  84. package/dist/tools/list-files.d.ts +9 -3
  85. package/dist/tools/list-files.js +48 -13
  86. package/dist/tools/search-codebase.d.ts +10 -0
  87. package/dist/tools/search-codebase.js +117 -14
  88. package/dist/tools/shell/exec.js +43 -4
  89. package/dist/tools/types.d.ts +74 -1
  90. package/dist/vcs/git.d.ts +8 -0
  91. package/dist/vcs/git.js +14 -0
  92. package/dist/vcs/github.d.ts +7 -1
  93. package/dist/vcs/github.js +10 -1
  94. package/dist/vcs/service.d.ts +8 -0
  95. package/dist/vcs/service.js +33 -1
  96. package/dist/vcs/types.d.ts +18 -2
  97. package/dist/workspace/add-root.d.ts +27 -0
  98. package/dist/workspace/add-root.js +16 -0
  99. package/dist/workspace/index.d.ts +2 -1
  100. package/dist/workspace/index.js +2 -1
  101. package/dist/workspace/workspace.d.ts +9 -4
  102. package/dist/workspace/workspace.js +9 -4
  103. package/package.json +1 -1
@@ -440,6 +440,50 @@ export function forgeApi(title, underlying, meta) {
440
440
  meta,
441
441
  });
442
442
  }
443
+ /**
444
+ * The `origin` remote of the acting root resolved to a DIFFERENT `owner/repo`
445
+ * between the U.3 preview (what the human approved) and the moment the pull-request
446
+ * API call is made — a concurrent `git remote set-url` on a mutable `.git/config`
447
+ * (C.26 Step 4). The PR is NOT opened: we refuse rather than warn-and-proceed,
448
+ * because opening it would target a repo the human never saw. Both targets are
449
+ * named so the discrepancy is legible. `owner/repo` are already-parsed identifiers
450
+ * (never an upstream model id), so the message is gag-safe by construction (U.8).
451
+ */
452
+ export function vcsRemoteChanged(approved, current, rootName) {
453
+ const fmt = (r) => `${r.host}/${r.owner}/${r.repo}`;
454
+ return new CruxyError({
455
+ code: ErrorCode.VcsRemoteChanged,
456
+ title: "refusing to open the pull request — the target repository changed",
457
+ cause: `you approved a PR against ${fmt(approved)}, but the "${rootName}" root's ` +
458
+ `origin remote now resolves to ${fmt(current)}`,
459
+ nextSteps: [
460
+ "check `git remote get-url origin` in that root — it was changed mid-run",
461
+ "re-run `cruxy pr` (or the tool) so the approval matches the current remote",
462
+ ],
463
+ meta: { approved, current, root: rootName },
464
+ });
465
+ }
466
+ /**
467
+ * A pull request was requested for one declared root while a SIBLING declared root
468
+ * shares the same git working tree — two non-overlapping roots inside one repo
469
+ * (e.g. `packages/a` + `packages/b` under one `.git`), which filesystem-overlap
470
+ * refusal does not catch (C.26 Step 4). A commit there `git add -A`s the sibling's
471
+ * changes too, so the PR would span both roots. A PR is a single-repo artifact, so
472
+ * we refuse (naming both) rather than silently PR one half.
473
+ */
474
+ export function vcsCrossRoot(root, sibling, repoPath) {
475
+ return new CruxyError({
476
+ code: ErrorCode.VcsCrossRoot,
477
+ title: `refusing to open a pull request that would span two roots`,
478
+ cause: `the "${root}" and "${sibling}" roots share one git repository (${repoPath}), ` +
479
+ "so a commit for one would sweep in the other's changes",
480
+ nextSteps: [
481
+ "open the pull request from that repository directly, outside cruxy's multi-root session",
482
+ "or declare only one root inside that repository (the roots share a single `.git`)",
483
+ ],
484
+ meta: { root, sibling, repoPath },
485
+ });
486
+ }
443
487
  /**
444
488
  * `git push` failed — most often the husky `pre-push` verify hook (build ·
445
489
  * typecheck · lint · test) or a rejected non-fast-forward. We never `--force` or
@@ -596,6 +640,21 @@ export function rootAmbiguous(ref, candidates) {
596
640
  * session start — overlap makes "which root owns this path" ambiguous and lets two
597
641
  * checkpoints/grants fight over the same bytes.
598
642
  */
643
+ export function multirootWriteDeferred(root, primary) {
644
+ return new CruxyError({
645
+ code: ErrorCode.MultirootWriteDeferred,
646
+ title: `writes to non-primary root "${root}" are deferred until per-root ` +
647
+ `checkpoints ship in the next step — write to the primary root ` +
648
+ `"${primary}", or run single-root (omit --root) to edit this repo now`,
649
+ cause: "per-root checkpoints are not yet wired, so a write outside the primary " +
650
+ "root could not be rolled back — it is refused rather than left un-restorable",
651
+ nextSteps: [
652
+ `write to the primary root "${primary}" instead`,
653
+ "or run single-root (omit --root) if you need to edit this repo now",
654
+ ],
655
+ meta: { root, primary },
656
+ });
657
+ }
599
658
  export function rootOverlap(a, b) {
600
659
  return new CruxyError({
601
660
  code: ErrorCode.RootOverlap,
@@ -678,6 +737,44 @@ export function subagentDepthExceeded(depth, maxDepth) {
678
737
  meta: { depth, maxDepth },
679
738
  });
680
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
+ }
681
778
  /**
682
779
  * A subagent run failed outright (provider error, tool crash) before producing
683
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";
@@ -137,6 +145,12 @@ export declare const ErrorCode: {
137
145
  * the confirm/trust prompt. The root set only ever grows by an explicit human
138
146
  * act — never by the model or a repo-local config. */
139
147
  readonly RootAddRefused: "CRUXY_E_ROOT_ADD_REFUSED";
148
+ /** A write targeted a NON-PRIMARY root while per-root checkpoints are not yet
149
+ * wired (C.26 Step 2b). Refused so "every write cruxy makes is checkpointed and
150
+ * rollback-able" stays true — an un-checkpointed write to a sibling root would be
151
+ * silently un-restorable when rollback reverts the primary. Lifted in Step 3 when
152
+ * per-root checkpoints ship. Reads to any root are unaffected. */
153
+ readonly MultirootWriteDeferred: "CRUXY_E_MULTIROOT_WRITE_DEFERRED";
140
154
  /** A multi-root rollback set references a member checkpoint that is missing or
141
155
  * corrupt, or a touched root has no member. Loud — a partial rollback must never
142
156
  * masquerade as success. */
@@ -145,6 +159,20 @@ export declare const ErrorCode: {
145
159
  * and which were not. The set is left recoverable by an idempotent re-run and is
146
160
  * NEVER reported as success. */
147
161
  readonly CheckpointSetPartial: "CRUXY_E_CHECKPOINT_SET_PARTIAL";
162
+ /** VCS target-integrity guard (C.26 Step 4): the `origin` remote of the acting
163
+ * root resolved to a DIFFERENT `owner/repo` between the U.3 preview (what the
164
+ * human approved) and the moment the pull-request API call is made (a concurrent
165
+ * `git remote set-url` on a mutable `.git/config`). The PR is NOT opened —
166
+ * refused, never warn-and-proceed — and both the approved and current targets are
167
+ * named. Reachable single-root too; introduced with the multi-root VCS hardening. */
168
+ readonly VcsRemoteChanged: "CRUXY_E_VCS_REMOTE_CHANGED";
169
+ /** A pull request was requested for one declared root while a SIBLING declared
170
+ * root shares the same git working tree (two non-overlapping roots inside one
171
+ * repo, e.g. `packages/a` + `packages/b` under one `.git`). A commit there would
172
+ * `git add -A` the sibling's changes too, so the PR would span both roots — a PR
173
+ * is a single-repo artifact, so it is refused (naming both) rather than silently
174
+ * PR one half. */
175
+ readonly VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT";
148
176
  };
149
177
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
150
178
  /** 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. */
@@ -157,6 +165,12 @@ export const ErrorCode = {
157
165
  * the confirm/trust prompt. The root set only ever grows by an explicit human
158
166
  * act — never by the model or a repo-local config. */
159
167
  RootAddRefused: "CRUXY_E_ROOT_ADD_REFUSED",
168
+ /** A write targeted a NON-PRIMARY root while per-root checkpoints are not yet
169
+ * wired (C.26 Step 2b). Refused so "every write cruxy makes is checkpointed and
170
+ * rollback-able" stays true — an un-checkpointed write to a sibling root would be
171
+ * silently un-restorable when rollback reverts the primary. Lifted in Step 3 when
172
+ * per-root checkpoints ship. Reads to any root are unaffected. */
173
+ MultirootWriteDeferred: "CRUXY_E_MULTIROOT_WRITE_DEFERRED",
160
174
  /** A multi-root rollback set references a member checkpoint that is missing or
161
175
  * corrupt, or a touched root has no member. Loud — a partial rollback must never
162
176
  * masquerade as success. */
@@ -165,6 +179,20 @@ export const ErrorCode = {
165
179
  * and which were not. The set is left recoverable by an idempotent re-run and is
166
180
  * NEVER reported as success. */
167
181
  CheckpointSetPartial: "CRUXY_E_CHECKPOINT_SET_PARTIAL",
182
+ /** VCS target-integrity guard (C.26 Step 4): the `origin` remote of the acting
183
+ * root resolved to a DIFFERENT `owner/repo` between the U.3 preview (what the
184
+ * human approved) and the moment the pull-request API call is made (a concurrent
185
+ * `git remote set-url` on a mutable `.git/config`). The PR is NOT opened —
186
+ * refused, never warn-and-proceed — and both the approved and current targets are
187
+ * named. Reachable single-root too; introduced with the multi-root VCS hardening. */
188
+ VcsRemoteChanged: "CRUXY_E_VCS_REMOTE_CHANGED",
189
+ /** A pull request was requested for one declared root while a SIBLING declared
190
+ * root shares the same git working tree (two non-overlapping roots inside one
191
+ * repo, e.g. `packages/a` + `packages/b` under one `.git`). A commit there would
192
+ * `git add -A` the sibling's changes too, so the PR would span both roots — a PR
193
+ * is a single-repo artifact, so it is refused (naming both) rather than silently
194
+ * PR one half. */
195
+ VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT",
168
196
  };
169
197
  /**
170
198
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -210,6 +238,10 @@ const EXIT_CODES = {
210
238
  [ErrorCode.SubagentDepthExceeded]: 2,
211
239
  [ErrorCode.SubagentBudget]: 11,
212
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,
213
245
  // No test command is a configuration gap (usage); the iteration limit
214
246
  // surfaces inside a run_tests result and is never fatal by itself.
215
247
  [ErrorCode.TestCommandNotFound]: 2,
@@ -264,8 +296,14 @@ const EXIT_CODES = {
264
296
  [ErrorCode.RootAmbiguous]: 18,
265
297
  [ErrorCode.RootOverlap]: 18,
266
298
  [ErrorCode.RootAddRefused]: 18,
299
+ [ErrorCode.MultirootWriteDeferred]: 18,
267
300
  [ErrorCode.CheckpointSetIncomplete]: 18,
268
301
  [ErrorCode.CheckpointSetPartial]: 18,
302
+ // VCS multi-root safety (C.26 Step 4). A remote that changed under the acting
303
+ // root, and a PR that would span two same-repo roots, are both target-integrity
304
+ // refusals kin to the other cross-root guards — they share the greppable code.
305
+ [ErrorCode.VcsRemoteChanged]: 18,
306
+ [ErrorCode.VcsCrossRoot]: 18,
269
307
  };
270
308
  /** The process exit code for an error code (defaults to 1 for safety). */
271
309
  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"}`);
@@ -8,6 +8,21 @@ export interface SearchOptions {
8
8
  k?: number;
9
9
  /** Optional glob restricting results by path, e.g. `src/**\/*.ts`. */
10
10
  pathGlob?: string;
11
+ /**
12
+ * The declared name of the root being searched (C.26). Stamped onto every
13
+ * returned hit so a fanned multi-root search can attribute each hit to its
14
+ * source root. Required: this retriever runs against exactly one root's store,
15
+ * so the caller that selected that store names it here — no hit is unattributed.
16
+ */
17
+ root: string;
18
+ /**
19
+ * Per-call override of the snippet token budget (C.26). A fanned multi-root
20
+ * search passes `Infinity` so each root returns its top-k by score *unbudgeted*,
21
+ * then applies the real budget ONCE over the merged set ({@link mergeRankedHits},
22
+ * ⚖︎JC-I) — a true global budget, not the budget applied N× (once per root).
23
+ * Omitted by single-root callers, who keep the config budget (byte-identical).
24
+ */
25
+ tokenBudget?: number;
11
26
  }
12
27
  /** Collaborators + budget knobs for the retriever. */
13
28
  export interface RetrieverDeps {
@@ -30,3 +45,17 @@ export interface RetrieverDeps {
30
45
  * context window.
31
46
  */
32
47
  export declare function searchCodebase(deps: RetrieverDeps, opts: SearchOptions): Promise<SearchHit[]>;
48
+ /**
49
+ * Merge per-root hit lists into one globally-ranked, budgeted, k-capped list —
50
+ * the cross-root half of a fanned {@link searchCodebase}. The cap and the token
51
+ * budget are applied **after** the merge (⚖︎JC-I), so `k` is a global top-k across
52
+ * all roots (a strong hit in root B is never crowded out by weaker hits in root A
53
+ * that a per-root cap happened to keep), and the combined snippet budget is
54
+ * honored once for the whole result — not N× as it would be if each root budgeted
55
+ * independently. Hits already carry their `root`, so the merge never loses
56
+ * attribution. Mirrors the single-root budget rule: always keep the top hit.
57
+ */
58
+ export declare function mergeRankedHits(perRoot: readonly (readonly SearchHit[])[], opts: {
59
+ k: number;
60
+ tokenBudget: number;
61
+ }): SearchHit[];
@@ -23,6 +23,7 @@ export async function searchCodebase(deps, opts) {
23
23
  break;
24
24
  remaining -= cost;
25
25
  hits.push({
26
+ root: opts.root,
26
27
  path: record.path,
27
28
  startLine: record.startLine,
28
29
  endLine: record.endLine,
@@ -32,6 +33,31 @@ export async function searchCodebase(deps, opts) {
32
33
  }
33
34
  return hits;
34
35
  }
36
+ /**
37
+ * Merge per-root hit lists into one globally-ranked, budgeted, k-capped list —
38
+ * the cross-root half of a fanned {@link searchCodebase}. The cap and the token
39
+ * budget are applied **after** the merge (⚖︎JC-I), so `k` is a global top-k across
40
+ * all roots (a strong hit in root B is never crowded out by weaker hits in root A
41
+ * that a per-root cap happened to keep), and the combined snippet budget is
42
+ * honored once for the whole result — not N× as it would be if each root budgeted
43
+ * independently. Hits already carry their `root`, so the merge never loses
44
+ * attribution. Mirrors the single-root budget rule: always keep the top hit.
45
+ */
46
+ export function mergeRankedHits(perRoot, opts) {
47
+ const all = perRoot.flat().sort((a, b) => b.score - a.score);
48
+ const kept = [];
49
+ let remaining = opts.tokenBudget;
50
+ for (const hit of all) {
51
+ if (kept.length >= opts.k)
52
+ break;
53
+ const cost = estimateTokens(hit.snippet);
54
+ if (kept.length > 0 && cost > remaining)
55
+ break;
56
+ remaining -= cost;
57
+ kept.push(hit);
58
+ }
59
+ return kept;
60
+ }
35
61
  /** Build a path predicate from a glob (compiled once). */
36
62
  function buildPathFilter(glob) {
37
63
  const re = globToRegExp(glob);
@@ -52,7 +52,9 @@ class IndexServiceImpl {
52
52
  store: this.store,
53
53
  embedder: this.embedder,
54
54
  defaultK: this.config.search.defaultK,
55
- tokenBudget: this.config.search.tokenBudget,
55
+ // A fanned multi-root search overrides this with Infinity so the budget
56
+ // is applied once globally after the merge, not once per root (⚖︎JC-I).
57
+ tokenBudget: opts.tokenBudget ?? this.config.search.tokenBudget,
56
58
  maxSnippetLines: this.config.search.maxSnippetLines,
57
59
  }, opts);
58
60
  }
@@ -31,6 +31,13 @@ export interface ScoredRecord {
31
31
  }
32
32
  /** One ranked search result handed back to the agent. */
33
33
  export interface SearchHit {
34
+ /**
35
+ * The declared name of the root this hit was indexed from (C.26). Required —
36
+ * so a hit can never be un-attributed — and stamped at construction from the
37
+ * same root whose store produced it, which is what lets a fanned multi-root
38
+ * search label each hit with its true source (the honesty pin).
39
+ */
40
+ root: string;
34
41
  path: string;
35
42
  startLine: number;
36
43
  endLine: number;