@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.
- package/dist/approval/classify.js +18 -0
- package/dist/approval/prompt.js +11 -0
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +2 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +59 -10
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/session-factory.d.ts +4 -3
- package/dist/cli/session-factory.js +98 -12
- package/dist/errors/constructors.d.ts +28 -0
- package/dist/errors/constructors.js +59 -0
- package/dist/errors/types.d.ts +20 -0
- package/dist/errors/types.js +26 -0
- package/dist/indexing/retriever.d.ts +29 -0
- package/dist/indexing/retriever.js +26 -0
- package/dist/indexing/service.js +3 -1
- package/dist/indexing/types.d.ts +7 -0
- package/dist/lsp/tools/common.d.ts +34 -7
- package/dist/lsp/tools/common.js +33 -11
- package/dist/lsp/tools/find-definition.js +2 -2
- package/dist/lsp/tools/find-references.js +10 -4
- package/dist/lsp/tools/get-diagnostics.js +6 -4
- package/dist/render/diff.js +42 -5
- package/dist/subagent/orchestrator.d.ts +15 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/testing/run-tests-tool.js +3 -0
- package/dist/tools/create-pull-request.d.ts +3 -0
- package/dist/tools/create-pull-request.js +50 -4
- package/dist/tools/file/apply-patch.js +2 -2
- package/dist/tools/file/edit-file.js +2 -2
- package/dist/tools/file/glob.d.ts +9 -2
- package/dist/tools/file/glob.js +73 -19
- package/dist/tools/file/grep-files.d.ts +12 -2
- package/dist/tools/file/grep-files.js +113 -38
- package/dist/tools/file/paths.d.ts +122 -9
- package/dist/tools/file/paths.js +165 -10
- package/dist/tools/file/read-file.js +2 -2
- package/dist/tools/file/write-file.js +2 -2
- package/dist/tools/git-status.d.ts +8 -1
- package/dist/tools/git-status.js +43 -11
- package/dist/tools/list-files.d.ts +9 -3
- package/dist/tools/list-files.js +48 -13
- package/dist/tools/search-codebase.d.ts +10 -0
- package/dist/tools/search-codebase.js +117 -14
- package/dist/tools/shell/exec.js +8 -1
- package/dist/tools/types.d.ts +63 -1
- package/dist/vcs/git.d.ts +8 -0
- package/dist/vcs/git.js +14 -0
- package/dist/vcs/github.d.ts +7 -1
- package/dist/vcs/github.js +10 -1
- package/dist/vcs/service.d.ts +8 -0
- package/dist/vcs/service.js +33 -1
- package/dist/vcs/types.d.ts +18 -2
- package/package.json +1 -1
package/dist/cli/onboard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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 {
|
|
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>,
|
|
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,
|
|
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,
|
|
65
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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,
|
|
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
|
-
|
|
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 = {
|
|
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,
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -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). */
|
package/dist/errors/types.js
CHANGED
|
@@ -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);
|
package/dist/indexing/service.js
CHANGED
|
@@ -52,7 +52,9 @@ class IndexServiceImpl {
|
|
|
52
52
|
store: this.store,
|
|
53
53
|
embedder: this.embedder,
|
|
54
54
|
defaultK: this.config.search.defaultK,
|
|
55
|
-
|
|
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
|
}
|
package/dist/indexing/types.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
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;
|