@cruxy/cli 0.22.1 → 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 (60) hide show
  1. package/dist/approval/classify.js +18 -0
  2. package/dist/approval/prompt.js +11 -0
  3. package/dist/checkpoint/gate.d.ts +65 -0
  4. package/dist/checkpoint/gate.js +86 -0
  5. package/dist/checkpoint/index.d.ts +2 -0
  6. package/dist/checkpoint/index.js +2 -0
  7. package/dist/checkpoint/set-rollback.d.ts +51 -0
  8. package/dist/checkpoint/set-rollback.js +74 -0
  9. package/dist/cli/commands/rollback.d.ts +11 -6
  10. package/dist/cli/commands/rollback.js +93 -33
  11. package/dist/cli/commands/run.js +59 -10
  12. package/dist/cli/onboard.js +4 -1
  13. package/dist/cli/repl.d.ts +2 -2
  14. package/dist/cli/session-factory.d.ts +4 -3
  15. package/dist/cli/session-factory.js +98 -12
  16. package/dist/errors/constructors.d.ts +28 -0
  17. package/dist/errors/constructors.js +59 -0
  18. package/dist/errors/types.d.ts +20 -0
  19. package/dist/errors/types.js +26 -0
  20. package/dist/indexing/retriever.d.ts +29 -0
  21. package/dist/indexing/retriever.js +26 -0
  22. package/dist/indexing/service.js +3 -1
  23. package/dist/indexing/types.d.ts +7 -0
  24. package/dist/lsp/tools/common.d.ts +34 -7
  25. package/dist/lsp/tools/common.js +33 -11
  26. package/dist/lsp/tools/find-definition.js +2 -2
  27. package/dist/lsp/tools/find-references.js +10 -4
  28. package/dist/lsp/tools/get-diagnostics.js +6 -4
  29. package/dist/render/diff.js +42 -5
  30. package/dist/subagent/orchestrator.d.ts +15 -0
  31. package/dist/subagent/orchestrator.js +2 -0
  32. package/dist/testing/run-tests-tool.js +3 -0
  33. package/dist/tools/create-pull-request.d.ts +3 -0
  34. package/dist/tools/create-pull-request.js +50 -4
  35. package/dist/tools/file/apply-patch.js +2 -2
  36. package/dist/tools/file/edit-file.js +2 -2
  37. package/dist/tools/file/glob.d.ts +9 -2
  38. package/dist/tools/file/glob.js +73 -19
  39. package/dist/tools/file/grep-files.d.ts +12 -2
  40. package/dist/tools/file/grep-files.js +113 -38
  41. package/dist/tools/file/paths.d.ts +122 -9
  42. package/dist/tools/file/paths.js +165 -10
  43. package/dist/tools/file/read-file.js +2 -2
  44. package/dist/tools/file/write-file.js +2 -2
  45. package/dist/tools/git-status.d.ts +8 -1
  46. package/dist/tools/git-status.js +43 -11
  47. package/dist/tools/list-files.d.ts +9 -3
  48. package/dist/tools/list-files.js +48 -13
  49. package/dist/tools/search-codebase.d.ts +10 -0
  50. package/dist/tools/search-codebase.js +117 -14
  51. package/dist/tools/shell/exec.js +8 -1
  52. package/dist/tools/types.d.ts +63 -1
  53. package/dist/vcs/git.d.ts +8 -0
  54. package/dist/vcs/git.js +14 -0
  55. package/dist/vcs/github.d.ts +7 -1
  56. package/dist/vcs/github.js +10 -1
  57. package/dist/vcs/service.d.ts +8 -0
  58. package/dist/vcs/service.js +33 -1
  59. package/dist/vcs/types.d.ts +18 -2
  60. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { resolveApiKey } from "../config/index.js";
2
2
  import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
3
3
  import { createRenderer } from "../render/index.js";
4
+ import { singleRootWorkspace } from "../workspace/index.js";
4
5
  import { buildAgentSession } from "./session-factory.js";
5
6
  /**
6
7
  * CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
@@ -24,7 +25,9 @@ export async function runFirstWinTask(config, cwd, prompt) {
24
25
  if (!apiKey)
25
26
  return; // defensive — the key was just persisted
26
27
  const renderer = createRenderer();
27
- const session = buildAgentSession(config, apiKey, cwd, true, false, renderer);
28
+ // The onboarding first-win is inherently single-root (it runs before any
29
+ // `--root` is parsed), so it acts over a trivial workspace on its cwd.
30
+ const session = buildAgentSession(config, apiKey, singleRootWorkspace(cwd), true, false, renderer);
28
31
  try {
29
32
  await session.send(prompt, renderer);
30
33
  }
@@ -1,6 +1,6 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
2
  import type { Session } from "../agent/index.js";
3
- import type { CheckpointService } from "../checkpoint/index.js";
3
+ import type { CheckpointGate } from "../checkpoint/index.js";
4
4
  import { type SlashCommandSpec } from "../hooks/index.js";
5
5
  import { type StreamRenderer } from "../render/index.js";
6
6
  /**
@@ -24,4 +24,4 @@ export interface ReplIO {
24
24
  * live region, anything else the plain append-only renderer); `cruxy run`
25
25
  * passes its own so the approval prompt's status-suspend hook shares it.
26
26
  */
27
- export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointService, slashCommands?: readonly SlashCommandSpec[]): Promise<void>;
27
+ export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointGate, slashCommands?: readonly SlashCommandSpec[]): Promise<void>;
@@ -1,10 +1,11 @@
1
1
  import type { CruxyConfig } from "../config/index.js";
2
2
  import type { ApprovalDecision } from "../approval/index.js";
3
- import type { CheckpointService } from "../checkpoint/index.js";
3
+ import type { CheckpointGate } from "../checkpoint/index.js";
4
4
  import type { SandboxService } from "../sandbox/index.js";
5
5
  import type { StreamRenderer } from "../render/index.js";
6
6
  import { type ApproveAction, type Tool } from "../tools/index.js";
7
7
  import { Session, type LifecycleHookRunner } from "../agent/index.js";
8
+ import type { Workspace } from "../workspace/index.js";
8
9
  /**
9
10
  * Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
10
11
  * whole point: a tool mutates only *after* `requestApproval` resolves, so
@@ -14,7 +15,7 @@ import { Session, type LifecycleHookRunner } from "../agent/index.js";
14
15
  * (file actions) or that attribution is lost (shell), for rollback's
15
16
  * external-change detection.
16
17
  */
17
- export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, checkpoints: CheckpointService | undefined, cwd: string): (action: ApproveAction) => Promise<ApprovalDecision>;
18
+ export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, gate: CheckpointGate | undefined, ws: Workspace): (action: ApproveAction) => Promise<ApprovalDecision>;
18
19
  /**
19
20
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
20
21
  * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
@@ -24,4 +25,4 @@ export declare function withCheckpointGate(requestApproval: (action: ApproveActi
24
25
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
25
26
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
26
27
  */
27
- export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService, hooks?: LifecycleHookRunner, mcpTools?: Tool[]): Session;
28
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, workspace: Workspace, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointGate, sandbox?: SandboxService, hooks?: LifecycleHookRunner, mcpTools?: Tool[]): Session;
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { createProvider } from "@cruxy/sdk";
2
3
  import { loadProjectInstructions } from "../config/index.js";
3
4
  import { logger } from "../utils/logger.js";
@@ -61,28 +62,90 @@ function resumeLineAfterApproval(requestApproval, renderer) {
61
62
  * (file actions) or that attribution is lost (shell), for rollback's
62
63
  * external-change detection.
63
64
  */
64
- export function withCheckpointGate(requestApproval, checkpoints, cwd) {
65
- if (!checkpoints)
65
+ export function withCheckpointGate(requestApproval, gate, ws) {
66
+ if (!gate)
66
67
  return requestApproval;
67
68
  return async (action) => {
68
69
  const decision = await requestApproval(action);
69
70
  if (!decision.allow)
70
71
  return decision;
71
- const request = classify(action, cwd);
72
+ const request = classify(action, ws.primary().absPath);
72
73
  if (request.tier === "read")
73
74
  return decision;
74
- await checkpoints.ensureCheckpoint();
75
- // Shell AND test executions (C.13) can mutate files we can't attribute
76
- // (scripts, snapshot writers) — record the lost attribution the same way.
77
75
  if (action.kind === "shell" || action.kind === "test") {
78
- await checkpoints.recordShellMutation();
76
+ // JC-β residual: non-primary shell/test are Step 5, so they are still
77
+ // hard-attributed to the primary root regardless of `action.root` (which
78
+ // those tools populate as the seam). They can mutate files we cannot
79
+ // attribute (scripts, snapshot writers) — record the lost attribution.
80
+ const root = ws.primary();
81
+ const svc = gate.serviceFor(root.name, root.absPath);
82
+ const checkpoint = await svc.ensureCheckpoint();
83
+ await svc.recordShellMutation();
84
+ if (checkpoint) {
85
+ await gate.recordMember(root.name, root.absPath, checkpoint.id);
86
+ }
87
+ return decision;
88
+ }
89
+ if (action.kind === "vcs") {
90
+ // C.26 Step 4: a PR now names its root (⚖︎#11), so the checkpoint is
91
+ // attributed to THAT selected root — its git commit stages/lands in that
92
+ // root's working tree, never the primary's. `recordShellMutation` because a
93
+ // `git add -A` + commit mutates the tree opaquely (no per-file attribution).
94
+ // Fall back to the primary only if a root name is somehow absent (defensive).
95
+ const root = (action.root ? ws.tryRootByName(action.root) : undefined) ??
96
+ ws.primary();
97
+ const svc = gate.serviceFor(root.name, root.absPath);
98
+ const checkpoint = await svc.ensureCheckpoint();
99
+ await svc.recordShellMutation();
100
+ if (checkpoint) {
101
+ await gate.recordMember(root.name, root.absPath, checkpoint.id);
102
+ }
103
+ return decision;
79
104
  }
80
- else {
81
- await checkpoints.recordTouched([...request.targets]);
105
+ // File actions (write/edit/patch): attribute each RESOLVED target to its root
106
+ // (JC-G — post-confinement truth) and checkpoint every touched root. A patch
107
+ // may span roots; each root gets its own checkpoint + set member.
108
+ for (const [rootName, group] of attributeFileTargets(action, ws)) {
109
+ const svc = gate.serviceFor(rootName, group.rootAbsPath);
110
+ const checkpoint = await svc.ensureCheckpoint();
111
+ await svc.recordTouched(group.paths);
112
+ if (checkpoint) {
113
+ await gate.recordMember(rootName, group.rootAbsPath, checkpoint.id);
114
+ }
82
115
  }
83
116
  return decision;
84
117
  };
85
118
  }
119
+ /**
120
+ * Group a file action's resolved absolute targets by the root that contains each
121
+ * (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
122
+ * relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
123
+ * we reconstruct the absolute path from the primary root rather than trusting
124
+ * classify's `targets` — which also correctly handles a patch spanning roots.
125
+ */
126
+ function attributeFileTargets(action, ws) {
127
+ const abs = [];
128
+ if (action.kind === "write" || action.kind === "edit") {
129
+ if (action.path)
130
+ abs.push(action.path);
131
+ }
132
+ else if (action.kind === "patch" && action.preview?.type === "patch") {
133
+ for (const file of action.preview.files) {
134
+ abs.push(path.resolve(ws.primary().absPath, file.path));
135
+ }
136
+ }
137
+ const byRoot = new Map();
138
+ for (const target of abs) {
139
+ const root = ws.rootContaining(target);
140
+ const group = byRoot.get(root.name) ?? {
141
+ rootAbsPath: root.absPath,
142
+ paths: [],
143
+ };
144
+ group.paths.push(target);
145
+ byRoot.set(root.name, group);
146
+ }
147
+ return byRoot;
148
+ }
86
149
  /**
87
150
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
88
151
  * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
@@ -92,7 +155,14 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
92
155
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
93
156
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
94
157
  */
95
- export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks, mcpTools = []) {
158
+ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks, mcpTools = []) {
159
+ // The workspace is the single source of truth for "which roots" (C.26). Every
160
+ // primary-scoped subsystem below (git context, project instructions, memory,
161
+ // checkpoints, subagents, the approval gate) derives its cwd from the primary
162
+ // root, so cwd can never disagree with `workspace.primary()`. The full workspace
163
+ // is threaded onto the ToolContext so the fan tools see every root; the subagent
164
+ // orchestrator receives it too, so a child sees the SAME roots as the main loop.
165
+ const cwd = workspace.primary().absPath;
96
166
  const provider = createProvider({
97
167
  provider: config.model.provider,
98
168
  apiKey,
@@ -173,7 +243,11 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
173
243
  // prompt + same checkpoint hook, but a new (empty) session allowlist — a
174
244
  // grant in the parent never silently widens a child's authority.
175
245
  const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
176
- const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, cwd);
246
+ // The C.26 coupling: `checkpointsActive` is true exactly when a per-root gate is
247
+ // wired, and it is set on the SAME ctx whose `requestApproval` IS that gate — so
248
+ // lifting the non-primary-write refusal and capturing the write are one decision.
249
+ const checkpointsActive = Boolean(checkpoints);
250
+ const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace);
177
251
  // Subagent orchestration (C.14): spawn_subagent goes on the main registry
178
252
  // only when depth allows (maxDepth 0 disables the feature structurally).
179
253
  // Registered before the plan wiring so plan-mode execution steps can
@@ -184,11 +258,13 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
184
258
  router,
185
259
  parentRegistry: execRegistry,
186
260
  cwd,
261
+ workspace,
187
262
  logger,
188
263
  git,
189
264
  projectInstructions,
190
265
  renderer,
191
266
  sandbox,
267
+ checkpointsActive,
192
268
  makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
193
269
  });
194
270
  if (config.subagent.maxDepth > 0) {
@@ -207,9 +283,11 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
207
283
  });
208
284
  const ctx = {
209
285
  cwd,
286
+ workspace,
210
287
  config,
211
288
  logger,
212
289
  requestApproval: gate(approval),
290
+ checkpointsActive,
213
291
  sandbox,
214
292
  };
215
293
  const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
@@ -248,7 +326,15 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
248
326
  interactive: ttyInteractive,
249
327
  io,
250
328
  });
251
- const ctx = { cwd, config, logger, requestApproval: gate(approval), sandbox };
329
+ const ctx = {
330
+ cwd,
331
+ workspace,
332
+ config,
333
+ logger,
334
+ requestApproval: gate(approval),
335
+ checkpointsActive,
336
+ sandbox,
337
+ };
252
338
  return new Session({
253
339
  provider,
254
340
  registry: execRegistry,
@@ -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
@@ -137,6 +164,7 @@ export declare function rootAmbiguous(ref: string, candidates: string[]): CruxyE
137
164
  * session start — overlap makes "which root owns this path" ambiguous and lets two
138
165
  * checkpoints/grants fight over the same bytes.
139
166
  */
167
+ export declare function multirootWriteDeferred(root: string, primary: string): CruxyError;
140
168
  export declare function rootOverlap(a: string, b: string): CruxyError;
141
169
  /**
142
170
  * An interactive add-root was refused: no TTY to confirm, or the user declined 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
@@ -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,
@@ -137,6 +137,12 @@ export declare const ErrorCode: {
137
137
  * the confirm/trust prompt. The root set only ever grows by an explicit human
138
138
  * act — never by the model or a repo-local config. */
139
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";
140
146
  /** A multi-root rollback set references a member checkpoint that is missing or
141
147
  * corrupt, or a touched root has no member. Loud — a partial rollback must never
142
148
  * masquerade as success. */
@@ -145,6 +151,20 @@ export declare const ErrorCode: {
145
151
  * and which were not. The set is left recoverable by an idempotent re-run and is
146
152
  * NEVER reported as success. */
147
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";
148
168
  };
149
169
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
150
170
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -157,6 +157,12 @@ export const ErrorCode = {
157
157
  * the confirm/trust prompt. The root set only ever grows by an explicit human
158
158
  * act — never by the model or a repo-local config. */
159
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",
160
166
  /** A multi-root rollback set references a member checkpoint that is missing or
161
167
  * corrupt, or a touched root has no member. Loud — a partial rollback must never
162
168
  * masquerade as success. */
@@ -165,6 +171,20 @@ export const ErrorCode = {
165
171
  * and which were not. The set is left recoverable by an idempotent re-run and is
166
172
  * NEVER reported as success. */
167
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",
168
188
  };
169
189
  /**
170
190
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -264,8 +284,14 @@ const EXIT_CODES = {
264
284
  [ErrorCode.RootAmbiguous]: 18,
265
285
  [ErrorCode.RootOverlap]: 18,
266
286
  [ErrorCode.RootAddRefused]: 18,
287
+ [ErrorCode.MultirootWriteDeferred]: 18,
267
288
  [ErrorCode.CheckpointSetIncomplete]: 18,
268
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,
269
295
  };
270
296
  /** The process exit code for an error code (defaults to 1 for safety). */
271
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;