@cruxy/cli 0.22.0 → 0.23.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 (80) hide show
  1. package/dist/approval/classify.js +25 -3
  2. package/dist/approval/policy.d.ts +6 -0
  3. package/dist/approval/policy.js +15 -3
  4. package/dist/approval/prompt.js +11 -0
  5. package/dist/approval/types.d.ts +8 -1
  6. package/dist/checkpoint/gate.d.ts +65 -0
  7. package/dist/checkpoint/gate.js +86 -0
  8. package/dist/checkpoint/index.d.ts +3 -0
  9. package/dist/checkpoint/index.js +3 -0
  10. package/dist/checkpoint/set-rollback.d.ts +51 -0
  11. package/dist/checkpoint/set-rollback.js +74 -0
  12. package/dist/checkpoint/set.d.ts +44 -0
  13. package/dist/checkpoint/set.js +142 -0
  14. package/dist/checkpoint/types.d.ts +47 -0
  15. package/dist/cli/commands/rollback.d.ts +11 -6
  16. package/dist/cli/commands/rollback.js +93 -33
  17. package/dist/cli/commands/run.js +59 -10
  18. package/dist/cli/onboard.js +4 -1
  19. package/dist/cli/repl.d.ts +2 -2
  20. package/dist/cli/session-factory.d.ts +4 -3
  21. package/dist/cli/session-factory.js +98 -12
  22. package/dist/errors/constructors.d.ts +65 -0
  23. package/dist/errors/constructors.js +168 -0
  24. package/dist/errors/types.d.ts +46 -0
  25. package/dist/errors/types.js +64 -0
  26. package/dist/indexing/retriever.d.ts +29 -0
  27. package/dist/indexing/retriever.js +26 -0
  28. package/dist/indexing/service.js +3 -1
  29. package/dist/indexing/types.d.ts +7 -0
  30. package/dist/lsp/tools/common.d.ts +34 -7
  31. package/dist/lsp/tools/common.js +33 -11
  32. package/dist/lsp/tools/find-definition.js +2 -2
  33. package/dist/lsp/tools/find-references.js +10 -4
  34. package/dist/lsp/tools/get-diagnostics.js +6 -4
  35. package/dist/render/diff.js +42 -5
  36. package/dist/sandbox/docker-runtime.js +4 -1
  37. package/dist/sandbox/policy.d.ts +12 -3
  38. package/dist/sandbox/policy.js +17 -3
  39. package/dist/sandbox/types.d.ts +10 -1
  40. package/dist/subagent/orchestrator.d.ts +15 -0
  41. package/dist/subagent/orchestrator.js +2 -0
  42. package/dist/testing/run-tests-tool.js +3 -0
  43. package/dist/tools/create-pull-request.d.ts +3 -0
  44. package/dist/tools/create-pull-request.js +50 -4
  45. package/dist/tools/file/apply-patch.js +2 -2
  46. package/dist/tools/file/edit-file.js +2 -2
  47. package/dist/tools/file/glob.d.ts +9 -2
  48. package/dist/tools/file/glob.js +73 -19
  49. package/dist/tools/file/grep-files.d.ts +12 -2
  50. package/dist/tools/file/grep-files.js +113 -38
  51. package/dist/tools/file/paths.d.ts +123 -17
  52. package/dist/tools/file/paths.js +158 -50
  53. package/dist/tools/file/read-file.js +2 -2
  54. package/dist/tools/file/write-file.js +2 -2
  55. package/dist/tools/git-status.d.ts +8 -1
  56. package/dist/tools/git-status.js +43 -11
  57. package/dist/tools/list-files.d.ts +9 -3
  58. package/dist/tools/list-files.js +48 -13
  59. package/dist/tools/search-codebase.d.ts +10 -0
  60. package/dist/tools/search-codebase.js +117 -14
  61. package/dist/tools/shell/exec.js +8 -1
  62. package/dist/tools/types.d.ts +63 -1
  63. package/dist/vcs/git.d.ts +8 -0
  64. package/dist/vcs/git.js +14 -0
  65. package/dist/vcs/github.d.ts +7 -1
  66. package/dist/vcs/github.js +10 -1
  67. package/dist/vcs/service.d.ts +8 -0
  68. package/dist/vcs/service.js +33 -1
  69. package/dist/vcs/types.d.ts +18 -2
  70. package/dist/workspace/index.d.ts +5 -0
  71. package/dist/workspace/index.js +3 -0
  72. package/dist/workspace/resolve.d.ts +54 -0
  73. package/dist/workspace/resolve.js +96 -0
  74. package/dist/workspace/select.d.ts +41 -0
  75. package/dist/workspace/select.js +44 -0
  76. package/dist/workspace/types.d.ts +30 -0
  77. package/dist/workspace/types.js +15 -0
  78. package/dist/workspace/workspace.d.ts +56 -0
  79. package/dist/workspace/workspace.js +180 -0
  80. package/package.json +1 -1
@@ -90,6 +90,33 @@ export declare function forgeAuth(host?: string): CruxyError;
90
90
  export declare function gitProtectedBranch(branch: string): CruxyError;
91
91
  /** The forge REST API returned an error (non-auth) while opening a PR. */
92
92
  export declare function forgeApi(title: string, underlying?: unknown, meta?: Record<string, unknown>): CruxyError;
93
+ /**
94
+ * The `origin` remote of the acting root resolved to a DIFFERENT `owner/repo`
95
+ * between the U.3 preview (what the human approved) and the moment the pull-request
96
+ * API call is made — a concurrent `git remote set-url` on a mutable `.git/config`
97
+ * (C.26 Step 4). The PR is NOT opened: we refuse rather than warn-and-proceed,
98
+ * because opening it would target a repo the human never saw. Both targets are
99
+ * named so the discrepancy is legible. `owner/repo` are already-parsed identifiers
100
+ * (never an upstream model id), so the message is gag-safe by construction (U.8).
101
+ */
102
+ export declare function vcsRemoteChanged(approved: {
103
+ host: string;
104
+ owner: string;
105
+ repo: string;
106
+ }, current: {
107
+ host: string;
108
+ owner: string;
109
+ repo: string;
110
+ }, rootName: string): CruxyError;
111
+ /**
112
+ * A pull request was requested for one declared root while a SIBLING declared root
113
+ * shares the same git working tree — two non-overlapping roots inside one repo
114
+ * (e.g. `packages/a` + `packages/b` under one `.git`), which filesystem-overlap
115
+ * refusal does not catch (C.26 Step 4). A commit there `git add -A`s the sibling's
116
+ * changes too, so the PR would span both roots. A PR is a single-repo artifact, so
117
+ * we refuse (naming both) rather than silently PR one half.
118
+ */
119
+ export declare function vcsCrossRoot(root: string, sibling: string, repoPath: string): CruxyError;
93
120
  /**
94
121
  * `git push` failed — most often the husky `pre-push` verify hook (build ·
95
122
  * typecheck · lint · test) or a rejected non-fast-forward. We never `--force` or
@@ -119,6 +146,44 @@ export declare function checkpointNotFound(id?: string): CruxyError;
119
146
  * Restoring is destructive and deliberate — there is no auto-rollback path, ever.
120
147
  */
121
148
  export declare function rollbackApprovalRequired(): CruxyError;
149
+ /**
150
+ * A path/selector names a workspace root that is not in the declared set — an
151
+ * unknown root name, or an absolute path that lands in no declared root. Fail-loud
152
+ * by design and NEVER fuzzy-matched to a nearby root (R1): a silent near-match is
153
+ * a cross-root misfire. Refused before any FS access.
154
+ */
155
+ export declare function rootUnknown(ref: string, known: readonly string[]): CruxyError;
156
+ /**
157
+ * A path is ambiguous across the declared root set: an absolute path inside ≥2
158
+ * declared roots, or a mutating tool given no root when there is no unambiguous
159
+ * choice. Writes fail closed rather than guess which repo to touch.
160
+ */
161
+ export declare function rootAmbiguous(ref: string, candidates: string[]): CruxyError;
162
+ /**
163
+ * Declaration-time: a declared root nests inside / overlaps another. Refused at
164
+ * session start — overlap makes "which root owns this path" ambiguous and lets two
165
+ * checkpoints/grants fight over the same bytes.
166
+ */
167
+ export declare function multirootWriteDeferred(root: string, primary: string): CruxyError;
168
+ export declare function rootOverlap(a: string, b: string): CruxyError;
169
+ /**
170
+ * An interactive add-root was refused: no TTY to confirm, or the user declined the
171
+ * confirm/trust prompt. The root set only ever grows by an explicit human act —
172
+ * never the model, never a repo-local config.
173
+ */
174
+ export declare function rootAddRefused(reason: string): CruxyError;
175
+ /**
176
+ * A multi-root rollback set references a member checkpoint that is missing or
177
+ * corrupt, or a touched root has no member. Loud — a partial rollback must never
178
+ * masquerade as success.
179
+ */
180
+ export declare function checkpointSetIncomplete(runId: string, reason: string): CruxyError;
181
+ /**
182
+ * A multi-root rollback failed mid-apply (R3): it restored some roots and not
183
+ * others, and it STOPPED rather than continue best-effort. Carries the exact
184
+ * restored-vs-not split; re-running rollback is idempotent and safe.
185
+ */
186
+ export declare function checkpointSetPartial(runId: string, restored: string[], notRestored: string[], underlying?: unknown): CruxyError;
122
187
  /**
123
188
  * A subagent spawn was attempted past the configured nesting cap (C.14). The
124
189
  * spawn tool is structurally withheld at the cap, so reaching this means the
@@ -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
@@ -551,6 +595,130 @@ export function rollbackApprovalRequired() {
551
595
  ],
552
596
  });
553
597
  }
598
+ // ── multi-repo / workspace (exit 18) — C.26 ───────────────────────────────────
599
+ /**
600
+ * A path/selector names a workspace root that is not in the declared set — an
601
+ * unknown root name, or an absolute path that lands in no declared root. Fail-loud
602
+ * by design and NEVER fuzzy-matched to a nearby root (R1): a silent near-match is
603
+ * a cross-root misfire. Refused before any FS access.
604
+ */
605
+ export function rootUnknown(ref, known) {
606
+ return new CruxyError({
607
+ code: ErrorCode.RootUnknown,
608
+ title: `no workspace root named "${ref}"`,
609
+ cause: "the root set is fixed at session start and matched exactly — never by prefix or nearest-name",
610
+ nextSteps: [
611
+ known.length
612
+ ? `declared roots: ${known.join(", ")}`
613
+ : "no additional roots are declared this session",
614
+ "pass --root <name>=<path> at startup, or add one interactively",
615
+ ],
616
+ meta: { ref, known: [...known] },
617
+ });
618
+ }
619
+ /**
620
+ * A path is ambiguous across the declared root set: an absolute path inside ≥2
621
+ * declared roots, or a mutating tool given no root when there is no unambiguous
622
+ * choice. Writes fail closed rather than guess which repo to touch.
623
+ */
624
+ export function rootAmbiguous(ref, candidates) {
625
+ return new CruxyError({
626
+ code: ErrorCode.RootAmbiguous,
627
+ title: `"${ref}" is ambiguous across the declared roots`,
628
+ cause: candidates.length > 1
629
+ ? `it resolves inside more than one declared root: ${candidates.join(", ")}`
630
+ : "a mutating action must name exactly one root",
631
+ nextSteps: [
632
+ "name the root explicitly with the `root` argument",
633
+ "declare either the monorepo root OR its packages, never both (overlap is refused)",
634
+ ],
635
+ meta: { ref, candidates },
636
+ });
637
+ }
638
+ /**
639
+ * Declaration-time: a declared root nests inside / overlaps another. Refused at
640
+ * session start — overlap makes "which root owns this path" ambiguous and lets two
641
+ * checkpoints/grants fight over the same bytes.
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
+ }
658
+ export function rootOverlap(a, b) {
659
+ return new CruxyError({
660
+ code: ErrorCode.RootOverlap,
661
+ title: "declared workspace roots overlap",
662
+ cause: `"${a}" nests inside or equals "${b}"`,
663
+ nextSteps: [
664
+ "declare the monorepo root OR specific package roots, never both",
665
+ "remove one of the overlapping --root entries",
666
+ ],
667
+ meta: { a, b },
668
+ });
669
+ }
670
+ /**
671
+ * An interactive add-root was refused: no TTY to confirm, or the user declined the
672
+ * confirm/trust prompt. The root set only ever grows by an explicit human act —
673
+ * never the model, never a repo-local config.
674
+ */
675
+ export function rootAddRefused(reason) {
676
+ return new CruxyError({
677
+ code: ErrorCode.RootAddRefused,
678
+ title: "adding a workspace root was refused",
679
+ cause: reason,
680
+ nextSteps: [
681
+ "declare roots up front with --root at startup",
682
+ "add a root only from an interactive terminal, where it can be confirmed and trusted",
683
+ ],
684
+ meta: { reason },
685
+ });
686
+ }
687
+ /**
688
+ * A multi-root rollback set references a member checkpoint that is missing or
689
+ * corrupt, or a touched root has no member. Loud — a partial rollback must never
690
+ * masquerade as success.
691
+ */
692
+ export function checkpointSetIncomplete(runId, reason) {
693
+ return new CruxyError({
694
+ code: ErrorCode.CheckpointSetIncomplete,
695
+ title: `rollback set "${runId}" is incomplete`,
696
+ cause: reason,
697
+ nextSteps: [
698
+ "run `cruxy checkpoint list` to inspect each root's checkpoints",
699
+ "roll back an individual root's checkpoint with `cruxy rollback <id>` if needed",
700
+ ],
701
+ meta: { runId, reason },
702
+ });
703
+ }
704
+ /**
705
+ * A multi-root rollback failed mid-apply (R3): it restored some roots and not
706
+ * others, and it STOPPED rather than continue best-effort. Carries the exact
707
+ * restored-vs-not split; re-running rollback is idempotent and safe.
708
+ */
709
+ export function checkpointSetPartial(runId, restored, notRestored, underlying) {
710
+ return new CruxyError({
711
+ code: ErrorCode.CheckpointSetPartial,
712
+ title: `rollback of set "${runId}" stopped partway`,
713
+ cause: `restored: ${restored.join(", ") || "none"}; not restored: ${notRestored.join(", ") || "none"}`,
714
+ nextSteps: [
715
+ "re-run `cruxy rollback` — it recomputes each root from disk and is safe to retry",
716
+ "the not-restored roots are unchanged; no root is left half-applied silently",
717
+ ],
718
+ underlying,
719
+ meta: { runId, restored, notRestored },
720
+ });
721
+ }
554
722
  // ── subagent (exit 2 / 11) ────────────────────────────────────────────────────
555
723
  /**
556
724
  * A subagent spawn was attempted past the configured nesting cap (C.14). The
@@ -119,6 +119,52 @@ export declare const ErrorCode: {
119
119
  * (SSRF guard — e.g. 127.0.0.1, 169.254.169.254, 10.x, internal DNS). A
120
120
  * security stop, kept distinct from an ordinary fetch failure for grep-ability. */
121
121
  readonly WebBlockedHost: "CRUXY_E_WEB_BLOCKED_HOST";
122
+ /** A path/selector names a workspace root that is not in the declared set (or an
123
+ * absolute path that lands in no declared root). Fail-loud and NEVER fuzzy- or
124
+ * prefix-matched to a nearby root — a silent near-match is a cross-root misfire.
125
+ * Refused before any FS access; never falls through to the host filesystem. */
126
+ readonly RootUnknown: "CRUXY_E_ROOT_UNKNOWN";
127
+ /** A path is ambiguous across the declared root set: an absolute path that falls
128
+ * inside ≥2 declared roots, or a mutating tool given no root when there's no
129
+ * unambiguous choice. Writes fail closed rather than guess a root. */
130
+ readonly RootAmbiguous: "CRUXY_E_ROOT_AMBIGUOUS";
131
+ /** Declaration-time: a declared root nests inside / overlaps another. Refused at
132
+ * session start — overlap makes "which root owns this path" ambiguous and lets
133
+ * two checkpoints/grants fight over the same bytes. Declare the monorepo root OR
134
+ * its packages, never both. */
135
+ readonly RootOverlap: "CRUXY_E_ROOT_OVERLAP";
136
+ /** An interactive add-root was refused: no TTY to confirm, or the user declined
137
+ * the confirm/trust prompt. The root set only ever grows by an explicit human
138
+ * act — never by the model or a repo-local config. */
139
+ readonly RootAddRefused: "CRUXY_E_ROOT_ADD_REFUSED";
140
+ /** A write targeted a NON-PRIMARY root while per-root checkpoints are not yet
141
+ * wired (C.26 Step 2b). Refused so "every write cruxy makes is checkpointed and
142
+ * rollback-able" stays true — an un-checkpointed write to a sibling root would be
143
+ * silently un-restorable when rollback reverts the primary. Lifted in Step 3 when
144
+ * per-root checkpoints ship. Reads to any root are unaffected. */
145
+ readonly MultirootWriteDeferred: "CRUXY_E_MULTIROOT_WRITE_DEFERRED";
146
+ /** A multi-root rollback set references a member checkpoint that is missing or
147
+ * corrupt, or a touched root has no member. Loud — a partial rollback must never
148
+ * masquerade as success. */
149
+ readonly CheckpointSetIncomplete: "CRUXY_E_CHECKPOINT_SET_INCOMPLETE";
150
+ /** A multi-root rollback failed mid-apply (R3): carries which roots were restored
151
+ * and which were not. The set is left recoverable by an idempotent re-run and is
152
+ * NEVER reported as success. */
153
+ readonly CheckpointSetPartial: "CRUXY_E_CHECKPOINT_SET_PARTIAL";
154
+ /** VCS target-integrity guard (C.26 Step 4): the `origin` remote of the acting
155
+ * root resolved to a DIFFERENT `owner/repo` between the U.3 preview (what the
156
+ * human approved) and the moment the pull-request API call is made (a concurrent
157
+ * `git remote set-url` on a mutable `.git/config`). The PR is NOT opened —
158
+ * refused, never warn-and-proceed — and both the approved and current targets are
159
+ * named. Reachable single-root too; introduced with the multi-root VCS hardening. */
160
+ readonly VcsRemoteChanged: "CRUXY_E_VCS_REMOTE_CHANGED";
161
+ /** A pull request was requested for one declared root while a SIBLING declared
162
+ * root shares the same git working tree (two non-overlapping roots inside one
163
+ * repo, e.g. `packages/a` + `packages/b` under one `.git`). A commit there would
164
+ * `git add -A` the sibling's changes too, so the PR would span both roots — a PR
165
+ * is a single-repo artifact, so it is refused (naming both) rather than silently
166
+ * PR one half. */
167
+ readonly VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT";
122
168
  };
123
169
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
124
170
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -138,6 +138,53 @@ export const ErrorCode = {
138
138
  * (SSRF guard — e.g. 127.0.0.1, 169.254.169.254, 10.x, internal DNS). A
139
139
  * security stop, kept distinct from an ordinary fetch failure for grep-ability. */
140
140
  WebBlockedHost: "CRUXY_E_WEB_BLOCKED_HOST",
141
+ // multi-repo / workspace (exit 18) — C.26
142
+ /** A path/selector names a workspace root that is not in the declared set (or an
143
+ * absolute path that lands in no declared root). Fail-loud and NEVER fuzzy- or
144
+ * prefix-matched to a nearby root — a silent near-match is a cross-root misfire.
145
+ * Refused before any FS access; never falls through to the host filesystem. */
146
+ RootUnknown: "CRUXY_E_ROOT_UNKNOWN",
147
+ /** A path is ambiguous across the declared root set: an absolute path that falls
148
+ * inside ≥2 declared roots, or a mutating tool given no root when there's no
149
+ * unambiguous choice. Writes fail closed rather than guess a root. */
150
+ RootAmbiguous: "CRUXY_E_ROOT_AMBIGUOUS",
151
+ /** Declaration-time: a declared root nests inside / overlaps another. Refused at
152
+ * session start — overlap makes "which root owns this path" ambiguous and lets
153
+ * two checkpoints/grants fight over the same bytes. Declare the monorepo root OR
154
+ * its packages, never both. */
155
+ RootOverlap: "CRUXY_E_ROOT_OVERLAP",
156
+ /** An interactive add-root was refused: no TTY to confirm, or the user declined
157
+ * the confirm/trust prompt. The root set only ever grows by an explicit human
158
+ * act — never by the model or a repo-local config. */
159
+ RootAddRefused: "CRUXY_E_ROOT_ADD_REFUSED",
160
+ /** A write targeted a NON-PRIMARY root while per-root checkpoints are not yet
161
+ * wired (C.26 Step 2b). Refused so "every write cruxy makes is checkpointed and
162
+ * rollback-able" stays true — an un-checkpointed write to a sibling root would be
163
+ * silently un-restorable when rollback reverts the primary. Lifted in Step 3 when
164
+ * per-root checkpoints ship. Reads to any root are unaffected. */
165
+ MultirootWriteDeferred: "CRUXY_E_MULTIROOT_WRITE_DEFERRED",
166
+ /** A multi-root rollback set references a member checkpoint that is missing or
167
+ * corrupt, or a touched root has no member. Loud — a partial rollback must never
168
+ * masquerade as success. */
169
+ CheckpointSetIncomplete: "CRUXY_E_CHECKPOINT_SET_INCOMPLETE",
170
+ /** A multi-root rollback failed mid-apply (R3): carries which roots were restored
171
+ * and which were not. The set is left recoverable by an idempotent re-run and is
172
+ * NEVER reported as success. */
173
+ CheckpointSetPartial: "CRUXY_E_CHECKPOINT_SET_PARTIAL",
174
+ /** VCS target-integrity guard (C.26 Step 4): the `origin` remote of the acting
175
+ * root resolved to a DIFFERENT `owner/repo` between the U.3 preview (what the
176
+ * human approved) and the moment the pull-request API call is made (a concurrent
177
+ * `git remote set-url` on a mutable `.git/config`). The PR is NOT opened —
178
+ * refused, never warn-and-proceed — and both the approved and current targets are
179
+ * named. Reachable single-root too; introduced with the multi-root VCS hardening. */
180
+ VcsRemoteChanged: "CRUXY_E_VCS_REMOTE_CHANGED",
181
+ /** A pull request was requested for one declared root while a SIBLING declared
182
+ * root shares the same git working tree (two non-overlapping roots inside one
183
+ * repo, e.g. `packages/a` + `packages/b` under one `.git`). A commit there would
184
+ * `git add -A` the sibling's changes too, so the PR would span both roots — a PR
185
+ * is a single-repo artifact, so it is refused (naming both) rather than silently
186
+ * PR one half. */
187
+ VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT",
141
188
  };
142
189
  /**
143
190
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -228,6 +275,23 @@ const EXIT_CODES = {
228
275
  [ErrorCode.WebSearch]: 17,
229
276
  [ErrorCode.WebFetch]: 17,
230
277
  [ErrorCode.WebBlockedHost]: 17,
278
+ // Multi-repo / workspace (C.26). Declaration-time root-set problems
279
+ // (unknown/ambiguous/overlap/add-refused) and multi-root rollback failures
280
+ // (incomplete/partial) share a greppable exit code. A cross-root *path* is NOT
281
+ // here — it reuses CRUXY_E_PATH_ESCAPE (a cross-root path is an escape from the
282
+ // acting root; a distinct code would wrongly imply "less bad").
283
+ [ErrorCode.RootUnknown]: 18,
284
+ [ErrorCode.RootAmbiguous]: 18,
285
+ [ErrorCode.RootOverlap]: 18,
286
+ [ErrorCode.RootAddRefused]: 18,
287
+ [ErrorCode.MultirootWriteDeferred]: 18,
288
+ [ErrorCode.CheckpointSetIncomplete]: 18,
289
+ [ErrorCode.CheckpointSetPartial]: 18,
290
+ // VCS multi-root safety (C.26 Step 4). A remote that changed under the acting
291
+ // root, and a PR that would span two same-repo roots, are both target-integrity
292
+ // refusals kin to the other cross-root guards — they share the greppable code.
293
+ [ErrorCode.VcsRemoteChanged]: 18,
294
+ [ErrorCode.VcsCrossRoot]: 18,
231
295
  };
232
296
  /** The process exit code for an error code (defaults to 1 for safety). */
233
297
  export function exitCodeFor(code) {
@@ -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;
@@ -1,23 +1,50 @@
1
1
  import type { ToolContext, ToolResult } from "../../tools/types.js";
2
+ import type { DeclaredRoot } from "../../workspace/index.js";
2
3
  import { type LspService } from "../service.js";
3
4
  import type { LspLocation } from "../types.js";
4
5
  /**
5
- * The shared spine of every LSP tool (C.12): enforce the master switch, validate
6
- * the target path stays in the project root, prove it exists, get the per-cwd
7
- * service, and run `query`. Read-only throughout — no `ctx.requestApproval`, so
8
- * these bypass the U.3 gate exactly like `search_codebase` and `grep_files`.
6
+ * The root a query ran against, threaded to the tool so it can label locations by
7
+ * their source root and for references name the per-root limitation (C.26).
8
+ */
9
+ export interface LspRootContext {
10
+ /** The declared root whose language-server pool answered the query. */
11
+ root: DeclaredRoot;
12
+ /** Whether this is a genuine multi-root session (labels render only then). */
13
+ isMultiRoot: boolean;
14
+ }
15
+ /**
16
+ * The shared spine of every LSP tool (C.12): enforce the master switch, resolve
17
+ * the target file to the ONE declared root that contains it, prove it exists, get
18
+ * THAT root's language-server pool, and run `query`. Read-only throughout — no
19
+ * `ctx.requestApproval`, so these bypass the U.3 gate exactly like `search_codebase`
20
+ * and `grep_files`.
21
+ *
22
+ * Multi-repo (C.26, Funnel A→pool): the file is resolved through the shared
23
+ * `resolveToolPath`, so it commits to exactly one root, and the pool is keyed by
24
+ * THAT root's `absPath` (`getLspService` caches per resolved cwd → a per-root map
25
+ * for free). A file in root B is answered by B's server, never A's — the
26
+ * `(file → root)` selection and the `(root → pool)` key are the same value.
9
27
  *
10
28
  * Errors are surfaced as `{ ok:false }` text the agent can act on: a coded LSP
11
29
  * failure (no server / timeout / crash) is rendered WITH its next step, so the
12
30
  * agent can reroute (e.g. to grep) or the user can install the server. A genuine
13
31
  * empty answer never reaches here as an error — `query` returns it as `ok:true`.
14
32
  */
15
- export declare function runLspTool(ctx: ToolContext, file: string, query: (service: LspService, absFile: string) => Promise<ToolResult>): Promise<ToolResult>;
33
+ export declare function runLspTool(ctx: ToolContext, file: string, query: (service: LspService, absFile: string, rc: LspRootContext) => Promise<ToolResult>): Promise<ToolResult>;
34
+ /**
35
+ * A note stating that a per-root language server only sees its own root, so
36
+ * references/definitions in sibling roots are NOT searched (⚖︎JC-7) — returned only
37
+ * in a multi-root session, so a references list is never read as complete when it
38
+ * silently could not span roots. Empty string in single-root (byte-identical).
39
+ */
40
+ export declare function crossRootNote(rc: LspRootContext): string;
16
41
  /**
17
42
  * Render up to `max` locations as `path:line:col-endLine:endCol`, one per line,
18
43
  * with a trailing "N more" note when capped — the same bounded-honest pattern as
19
- * grep_files and search_codebase (never silently drop the overflow).
44
+ * grep_files and search_codebase (never silently drop the overflow). In a
45
+ * multi-root session each path is labelled `‹root› ▸ path` (the query ran against
46
+ * one root, so every location carries that root's label).
20
47
  */
21
- export declare function formatLocations(locations: LspLocation[], max: number): string;
48
+ export declare function formatLocations(locations: LspLocation[], max: number, rc: LspRootContext): string;
22
49
  /** Relative label for a validated absolute path, for tool messages. */
23
50
  export declare function relLabel(cwd: string, absFile: string): string;
@@ -1,13 +1,20 @@
1
1
  import { promises as fsp } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { CruxyError } from "../../errors/index.js";
4
- import { resolveInRoot } from "../../tools/file/paths.js";
4
+ import { contextWorkspace, labelPath, resolveToolPath, } from "../../tools/file/paths.js";
5
5
  import { getLspService } from "../service.js";
6
6
  /**
7
- * The shared spine of every LSP tool (C.12): enforce the master switch, validate
8
- * the target path stays in the project root, prove it exists, get the per-cwd
9
- * service, and run `query`. Read-only throughout — no `ctx.requestApproval`, so
10
- * these bypass the U.3 gate exactly like `search_codebase` and `grep_files`.
7
+ * The shared spine of every LSP tool (C.12): enforce the master switch, resolve
8
+ * the target file to the ONE declared root that contains it, prove it exists, get
9
+ * THAT root's language-server pool, and run `query`. Read-only throughout — no
10
+ * `ctx.requestApproval`, so these bypass the U.3 gate exactly like `search_codebase`
11
+ * and `grep_files`.
12
+ *
13
+ * Multi-repo (C.26, Funnel A→pool): the file is resolved through the shared
14
+ * `resolveToolPath`, so it commits to exactly one root, and the pool is keyed by
15
+ * THAT root's `absPath` (`getLspService` caches per resolved cwd → a per-root map
16
+ * for free). A file in root B is answered by B's server, never A's — the
17
+ * `(file → root)` selection and the `(root → pool)` key are the same value.
11
18
  *
12
19
  * Errors are surfaced as `{ ok:false }` text the agent can act on: a coded LSP
13
20
  * failure (no server / timeout / crash) is rendered WITH its next step, so the
@@ -21,9 +28,11 @@ export async function runLspTool(ctx, file, query) {
21
28
  error: "LSP tools are disabled (set lsp.enabled = true to use language-server features)",
22
29
  };
23
30
  }
31
+ const ws = contextWorkspace(ctx);
32
+ let root;
24
33
  let absFile;
25
34
  try {
26
- absFile = await resolveInRoot(ctx, file);
35
+ ({ root, abs: absFile } = await resolveToolPath(ctx, { path: file }));
27
36
  }
28
37
  catch (err) {
29
38
  return { ok: false, error: err.message };
@@ -38,8 +47,8 @@ export async function runLspTool(ctx, file, query) {
38
47
  return { ok: false, error: `file not found: ${file}` };
39
48
  }
40
49
  try {
41
- const service = getLspService(ctx.cwd, ctx.config, ctx.logger);
42
- return await query(service, absFile);
50
+ const service = getLspService(root.absPath, ctx.config, ctx.logger);
51
+ return await query(service, absFile, { root, isMultiRoot: ws.isMultiRoot });
43
52
  }
44
53
  catch (err) {
45
54
  return { ok: false, error: describeError(err) };
@@ -54,14 +63,27 @@ function describeError(err) {
54
63
  }
55
64
  return err.message;
56
65
  }
66
+ /**
67
+ * A note stating that a per-root language server only sees its own root, so
68
+ * references/definitions in sibling roots are NOT searched (⚖︎JC-7) — returned only
69
+ * in a multi-root session, so a references list is never read as complete when it
70
+ * silently could not span roots. Empty string in single-root (byte-identical).
71
+ */
72
+ export function crossRootNote(rc) {
73
+ return rc.isMultiRoot
74
+ ? `\n(note: only root ‹${rc.root.name}› was searched — a per-root language server does not resolve symbols across roots)`
75
+ : "";
76
+ }
57
77
  /**
58
78
  * Render up to `max` locations as `path:line:col-endLine:endCol`, one per line,
59
79
  * with a trailing "N more" note when capped — the same bounded-honest pattern as
60
- * grep_files and search_codebase (never silently drop the overflow).
80
+ * grep_files and search_codebase (never silently drop the overflow). In a
81
+ * multi-root session each path is labelled `‹root› ▸ path` (the query ran against
82
+ * one root, so every location carries that root's label).
61
83
  */
62
- export function formatLocations(locations, max) {
84
+ export function formatLocations(locations, max, rc) {
63
85
  const shown = locations.slice(0, max);
64
- const lines = shown.map((l) => `${l.path}:${l.startLine}:${l.startCol}-${l.endLine}:${l.endCol}`);
86
+ const lines = shown.map((l) => `${labelPath(rc.root, l.path, rc.isMultiRoot)}:${l.startLine}:${l.startCol}-${l.endLine}:${l.endCol}`);
65
87
  const omitted = locations.length - shown.length;
66
88
  if (omitted > 0) {
67
89
  lines.push(`… [${omitted} more location(s) omitted]`);
@@ -27,14 +27,14 @@ export const findDefinitionTool = {
27
27
  description: "Resolve where a symbol is defined using the project's language server (go-to-definition). Give the file and the 1-based line/column of the identifier. Returns definition locations as 'path:line:col-endLine:endCol'. Read-only, no approval. Precise where grep is textual — prefer this to jump to a symbol's definition.",
28
28
  parameters,
29
29
  execute(input, ctx) {
30
- return runLspTool(ctx, input.file, async (service, absFile) => {
30
+ return runLspTool(ctx, input.file, async (service, absFile, rc) => {
31
31
  const locations = await service.definition(absFile, input.line, input.column);
32
32
  if (locations.length === 0) {
33
33
  return { ok: true, output: "(no definition found)" };
34
34
  }
35
35
  return {
36
36
  ok: true,
37
- output: formatLocations(locations, ctx.config.lsp.maxResults),
37
+ output: formatLocations(locations, ctx.config.lsp.maxResults, rc),
38
38
  };
39
39
  });
40
40
  },