@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.
- package/dist/approval/classify.js +25 -3
- package/dist/approval/policy.d.ts +6 -0
- package/dist/approval/policy.js +15 -3
- package/dist/approval/prompt.js +11 -0
- package/dist/approval/types.d.ts +8 -1
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +3 -0
- package/dist/checkpoint/index.js +3 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/checkpoint/set.d.ts +44 -0
- package/dist/checkpoint/set.js +142 -0
- package/dist/checkpoint/types.d.ts +47 -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 +65 -0
- package/dist/errors/constructors.js +168 -0
- package/dist/errors/types.d.ts +46 -0
- package/dist/errors/types.js +64 -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/sandbox/docker-runtime.js +4 -1
- package/dist/sandbox/policy.d.ts +12 -3
- package/dist/sandbox/policy.js +17 -3
- package/dist/sandbox/types.d.ts +10 -1
- 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 +123 -17
- package/dist/tools/file/paths.js +158 -50
- 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/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/resolve.d.ts +54 -0
- package/dist/workspace/resolve.js +96 -0
- package/dist/workspace/select.d.ts +41 -0
- package/dist/workspace/select.js +44 -0
- package/dist/workspace/types.d.ts +30 -0
- package/dist/workspace/types.js +15 -0
- package/dist/workspace/workspace.d.ts +56 -0
- package/dist/workspace/workspace.js +180 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatLocations, runLspTool } from "./common.js";
|
|
2
|
+
import { crossRootNote, formatLocations, runLspTool } from "./common.js";
|
|
3
3
|
const parameters = z.object({
|
|
4
4
|
file: z
|
|
5
5
|
.string()
|
|
@@ -27,14 +27,20 @@ export const findReferencesTool = {
|
|
|
27
27
|
description: "Find all references to a symbol using the project's language server. Give the file and the 1-based line/column of the identifier. Returns use sites as 'path:line:col-endLine:endCol' (capped, with an 'N more' note). Read-only, no approval. Precise where grep is textual — prefer this to see every caller/user of a symbol.",
|
|
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.references(absFile, input.line, input.column);
|
|
32
|
+
// The cross-root note is appended even to an empty result: "(no references)"
|
|
33
|
+
// from a per-root server must not read as "no references anywhere" (⚖︎JC-7).
|
|
32
34
|
if (locations.length === 0) {
|
|
33
|
-
return {
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
output: `(no references found)${crossRootNote(rc)}`,
|
|
38
|
+
};
|
|
34
39
|
}
|
|
35
40
|
return {
|
|
36
41
|
ok: true,
|
|
37
|
-
output: formatLocations(locations, ctx.config.lsp.maxResults)
|
|
42
|
+
output: formatLocations(locations, ctx.config.lsp.maxResults, rc) +
|
|
43
|
+
crossRootNote(rc),
|
|
38
44
|
};
|
|
39
45
|
});
|
|
40
46
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { labelPath } from "../../tools/file/paths.js";
|
|
2
3
|
import { runLspTool } from "./common.js";
|
|
3
4
|
const parameters = z.object({
|
|
4
5
|
file: z
|
|
@@ -17,23 +18,24 @@ export const getDiagnosticsTool = {
|
|
|
17
18
|
description: "Get the language server's diagnostics (errors, warnings) for a file. Give the project-relative path. Returns diagnostics as 'severity path:line:col message'. Read-only, no approval. Use this after an edit to see type errors the compiler/linter reports, without running a build.",
|
|
18
19
|
parameters,
|
|
19
20
|
execute(input, ctx) {
|
|
20
|
-
return runLspTool(ctx, input.file, async (service, absFile) => {
|
|
21
|
+
return runLspTool(ctx, input.file, async (service, absFile, rc) => {
|
|
21
22
|
const diagnostics = await service.diagnostics(absFile);
|
|
22
23
|
if (diagnostics.length === 0) {
|
|
23
24
|
return { ok: true, output: "(no diagnostics)" };
|
|
24
25
|
}
|
|
25
26
|
return {
|
|
26
27
|
ok: true,
|
|
27
|
-
output: formatDiagnostics(diagnostics, ctx.config.lsp.maxResults),
|
|
28
|
+
output: formatDiagnostics(diagnostics, ctx.config.lsp.maxResults, rc),
|
|
28
29
|
};
|
|
29
30
|
});
|
|
30
31
|
},
|
|
31
32
|
};
|
|
32
|
-
function formatDiagnostics(diagnostics, max) {
|
|
33
|
+
function formatDiagnostics(diagnostics, max, rc) {
|
|
33
34
|
const shown = diagnostics.slice(0, max);
|
|
34
35
|
const lines = shown.map((d) => {
|
|
35
36
|
const src = d.source ? ` [${d.source}]` : "";
|
|
36
|
-
|
|
37
|
+
const loc = labelPath(rc.root, d.path, rc.isMultiRoot);
|
|
38
|
+
return `${d.severity} ${loc}:${d.range.startLine}:${d.range.startCol} ${d.message}${src}`;
|
|
37
39
|
});
|
|
38
40
|
const omitted = diagnostics.length - shown.length;
|
|
39
41
|
if (omitted > 0) {
|
package/dist/render/diff.js
CHANGED
|
@@ -36,6 +36,10 @@ function renderPatchFiles(files, c) {
|
|
|
36
36
|
/** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
|
|
37
37
|
function renderPrPreview(preview, c) {
|
|
38
38
|
const out = [];
|
|
39
|
+
// The resolved forge destination (⚖︎JC-4): the human sees the real owner/repo the
|
|
40
|
+
// PR will open against — not just a branch — and it is exactly the target the
|
|
41
|
+
// wrong-repo guard re-checks before the API call.
|
|
42
|
+
out.push(`${c.strong("target")} ${c.accent(`${preview.target.host}/${preview.target.owner}/${preview.target.repo}`)}`);
|
|
39
43
|
out.push(`${c.strong("branch")} ${c.success(preview.branch)} ${c.glyph.arrow} ${preview.base}`);
|
|
40
44
|
out.push("");
|
|
41
45
|
out.push(c.strong("commit"));
|
|
@@ -60,16 +64,46 @@ function renderRollbackPreview(preview, c) {
|
|
|
60
64
|
if (preview.runSummary)
|
|
61
65
|
out.push(c.muted(`run: ${preview.runSummary}`));
|
|
62
66
|
out.push(c.muted("working-tree files only — commits, pushes, and PRs made during the run are not undone"));
|
|
63
|
-
|
|
67
|
+
out.push(...rollbackWarnings(preview.externalPaths, preview.attributionUnknown, c));
|
|
68
|
+
out.push("");
|
|
69
|
+
out.push(...renderPatchFiles(preview.files, c));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The blast-radius warnings for one checkpoint: paths changed outside the tracked
|
|
74
|
+
* run (which rollback will overwrite), and the shell-attribution caveat. Emitted
|
|
75
|
+
* BEFORE the file diffs so the global collapse cap can never hide them.
|
|
76
|
+
*/
|
|
77
|
+
function rollbackWarnings(externalPaths, attributionUnknown, c) {
|
|
78
|
+
const out = [];
|
|
79
|
+
if (externalPaths.length > 0) {
|
|
64
80
|
out.push(c.danger(c.strong("changed outside this run — rollback will overwrite these too:")));
|
|
65
|
-
for (const p of
|
|
81
|
+
for (const p of externalPaths)
|
|
66
82
|
out.push(c.danger(`! ${p}`));
|
|
67
83
|
}
|
|
68
|
-
if (
|
|
84
|
+
if (attributionUnknown) {
|
|
69
85
|
out.push(c.warning("this run executed shell commands; some changes below may not have been made by the run"));
|
|
70
86
|
}
|
|
71
|
-
out
|
|
72
|
-
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Render a multi-root set rollback (C.26): one run header, then each touched root
|
|
91
|
+
* as its own labeled block with its own warnings + diffs. Grouping is the point —
|
|
92
|
+
* a reviewer sees which repo each change lands in before the single approval.
|
|
93
|
+
*/
|
|
94
|
+
function renderRollbackSetPreview(preview, c) {
|
|
95
|
+
const out = [];
|
|
96
|
+
out.push(`${c.strong("restore run")} ${c.accent(preview.runId)} ${c.muted(`(${preview.createdAt})`)}`);
|
|
97
|
+
if (preview.runSummary)
|
|
98
|
+
out.push(c.muted(`run: ${preview.runSummary}`));
|
|
99
|
+
out.push(c.muted(`${preview.roots.length} root${preview.roots.length === 1 ? "" : "s"} — ` +
|
|
100
|
+
"working-tree files only; commits, pushes, and PRs made during the run are not undone"));
|
|
101
|
+
for (const r of preview.roots) {
|
|
102
|
+
out.push("");
|
|
103
|
+
out.push(`${c.strong(`root ${r.rootName}`)} ${c.muted(`checkpoint ${r.checkpointId}`)}`);
|
|
104
|
+
out.push(...rollbackWarnings(r.externalPaths, r.attributionUnknown, c));
|
|
105
|
+
out.push(...renderPatchFiles(r.files, c));
|
|
106
|
+
}
|
|
73
107
|
return out;
|
|
74
108
|
}
|
|
75
109
|
/** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
|
|
@@ -98,6 +132,9 @@ export function renderActionPreview(preview, c) {
|
|
|
98
132
|
else if (preview.type === "rollback") {
|
|
99
133
|
lines = renderRollbackPreview(preview, c);
|
|
100
134
|
}
|
|
135
|
+
else if (preview.type === "rollback-set") {
|
|
136
|
+
lines = renderRollbackSetPreview(preview, c);
|
|
137
|
+
}
|
|
101
138
|
else {
|
|
102
139
|
const header = preview.exists
|
|
103
140
|
? c.warning("OVERWRITE existing")
|
|
@@ -151,7 +151,10 @@ export function buildRunArgs(policy, container, command) {
|
|
|
151
151
|
"--tmpfs",
|
|
152
152
|
`${policy.tmpfs}:rw,nosuid,nodev,size=64m`, // … except an in-memory tmp
|
|
153
153
|
"-v",
|
|
154
|
-
mountSpec(policy.workdir), //
|
|
154
|
+
mountSpec(policy.workdir), // the command's own root, read-write
|
|
155
|
+
// Other declared roots (C.26, R5): read-only unless a per-command escalation
|
|
156
|
+
// flipped one to rw. Ambient cross-root write is never the default.
|
|
157
|
+
...(policy.siblingRoots ?? []).flatMap((m) => ["-v", mountSpec(m)]),
|
|
155
158
|
...policy.mounts.flatMap((m) => ["-v", mountSpec(m)]),
|
|
156
159
|
"-w",
|
|
157
160
|
policy.workdir.target,
|
package/dist/sandbox/policy.d.ts
CHANGED
|
@@ -5,8 +5,12 @@ import type { IsolationPolicy } from "./types.js";
|
|
|
5
5
|
* {@link IsolationPolicy}. This is where the security posture is decided, and
|
|
6
6
|
* every default here is deny/minimal:
|
|
7
7
|
*
|
|
8
|
-
* - the
|
|
9
|
-
* path, so paths stay coherent with the host and the C.32
|
|
8
|
+
* - the DEFAULT read-write mount is the command's own project workdir (at its
|
|
9
|
+
* identical absolute path, so paths stay coherent with the host and the C.32
|
|
10
|
+
* checkpoint);
|
|
11
|
+
* - other declared roots in a multi-repo session (C.26, R5) are mounted READ-ONLY
|
|
12
|
+
* — readable for legit cross-repo builds, never writable unless an explicit
|
|
13
|
+
* per-command escalation names that root in `writableRoots`;
|
|
10
14
|
* - extra mounts come solely from `sandbox.mounts` (explicit by construction),
|
|
11
15
|
* and a mount of the docker socket, the cruxy home, or the user's home root
|
|
12
16
|
* is rejected — those are the escape hatches we refuse to open;
|
|
@@ -14,4 +18,9 @@ import type { IsolationPolicy } from "./types.js";
|
|
|
14
18
|
* writable and never left root-owned;
|
|
15
19
|
* - network defaults to `none`; any widening can only come from explicit config.
|
|
16
20
|
*/
|
|
17
|
-
export declare function buildPolicy(cfg: SandboxConfig, cwd: string
|
|
21
|
+
export declare function buildPolicy(cfg: SandboxConfig, cwd: string, opts?: {
|
|
22
|
+
/** Absolute paths of the OTHER declared roots (this command's siblings). */
|
|
23
|
+
siblingRoots?: readonly string[];
|
|
24
|
+
/** Sibling roots that got an approved cross-root-write escalation (R5). */
|
|
25
|
+
writableRoots?: readonly string[];
|
|
26
|
+
}): IsolationPolicy;
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -7,8 +7,12 @@ import { globalDir } from "../config/paths.js";
|
|
|
7
7
|
* {@link IsolationPolicy}. This is where the security posture is decided, and
|
|
8
8
|
* every default here is deny/minimal:
|
|
9
9
|
*
|
|
10
|
-
* - the
|
|
11
|
-
* path, so paths stay coherent with the host and the C.32
|
|
10
|
+
* - the DEFAULT read-write mount is the command's own project workdir (at its
|
|
11
|
+
* identical absolute path, so paths stay coherent with the host and the C.32
|
|
12
|
+
* checkpoint);
|
|
13
|
+
* - other declared roots in a multi-repo session (C.26, R5) are mounted READ-ONLY
|
|
14
|
+
* — readable for legit cross-repo builds, never writable unless an explicit
|
|
15
|
+
* per-command escalation names that root in `writableRoots`;
|
|
12
16
|
* - extra mounts come solely from `sandbox.mounts` (explicit by construction),
|
|
13
17
|
* and a mount of the docker socket, the cruxy home, or the user's home root
|
|
14
18
|
* is rejected — those are the escape hatches we refuse to open;
|
|
@@ -16,12 +20,21 @@ import { globalDir } from "../config/paths.js";
|
|
|
16
20
|
* writable and never left root-owned;
|
|
17
21
|
* - network defaults to `none`; any widening can only come from explicit config.
|
|
18
22
|
*/
|
|
19
|
-
export function buildPolicy(cfg, cwd) {
|
|
23
|
+
export function buildPolicy(cfg, cwd, opts = {}) {
|
|
20
24
|
const workdir = {
|
|
21
25
|
source: cwd,
|
|
22
26
|
target: cwd,
|
|
23
27
|
readonly: false,
|
|
24
28
|
};
|
|
29
|
+
const writable = new Set((opts.writableRoots ?? []).map((r) => resolvePath(r)));
|
|
30
|
+
const siblingRoots = (opts.siblingRoots ?? [])
|
|
31
|
+
.map((r) => resolvePath(r))
|
|
32
|
+
.filter((r) => r !== resolvePath(cwd)) // the workdir is never a sibling
|
|
33
|
+
.map((source) => ({
|
|
34
|
+
source,
|
|
35
|
+
target: source, // identical path, like the workdir, for path coherence
|
|
36
|
+
readonly: !writable.has(source), // RO unless explicitly escalated (R5)
|
|
37
|
+
}));
|
|
25
38
|
return {
|
|
26
39
|
image: cfg.image,
|
|
27
40
|
network: cfg.network,
|
|
@@ -30,6 +43,7 @@ export function buildPolicy(cfg, cwd) {
|
|
|
30
43
|
pids: cfg.pids,
|
|
31
44
|
cpus: cfg.cpus,
|
|
32
45
|
workdir,
|
|
46
|
+
siblingRoots,
|
|
33
47
|
mounts: cfg.mounts.map((spec) => parseMount(spec, cwd)),
|
|
34
48
|
tmpfs: "/tmp",
|
|
35
49
|
};
|
package/dist/sandbox/types.d.ts
CHANGED
|
@@ -43,8 +43,17 @@ export interface IsolationPolicy {
|
|
|
43
43
|
readonly pids: number;
|
|
44
44
|
/** CPU cap (`--cpus`, fractional allowed). */
|
|
45
45
|
readonly cpus: number;
|
|
46
|
-
/** The
|
|
46
|
+
/** The command's OWN workspace root, mounted read-write at its absolute path. */
|
|
47
47
|
readonly workdir: BindMount;
|
|
48
|
+
/**
|
|
49
|
+
* The OTHER declared workspace roots in a multi-repo session (C.26, R5). Each
|
|
50
|
+
* is mounted **read-only** so a legit build can *read* a sibling (e.g. generated
|
|
51
|
+
* client types) — but never write it. A sibling flips to read-write ONLY when an
|
|
52
|
+
* explicit per-command cross-root-write escalation was approved for that exact
|
|
53
|
+
* root; ambient cross-root write authority is never the default. Empty in a
|
|
54
|
+
* single-root session (identical to the pre-C.26 posture).
|
|
55
|
+
*/
|
|
56
|
+
readonly siblingRoots?: readonly BindMount[];
|
|
48
57
|
/** Extra explicit mounts beyond the workdir (from `sandbox.mounts`). */
|
|
49
58
|
readonly mounts: readonly BindMount[];
|
|
50
59
|
/** Writable in-memory tmp mount point; the rest of the root fs is read-only. */
|
|
@@ -5,6 +5,7 @@ import type { StreamRenderer } from "../render/index.js";
|
|
|
5
5
|
import type { Router } from "../routing/index.js";
|
|
6
6
|
import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
|
|
7
7
|
import type { SandboxService } from "../sandbox/index.js";
|
|
8
|
+
import type { Workspace } from "../workspace/index.js";
|
|
8
9
|
import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
9
10
|
/**
|
|
10
11
|
* Everything a spawn needs from the surrounding session, injected by the
|
|
@@ -24,6 +25,13 @@ export interface SubagentOrchestratorDeps {
|
|
|
24
25
|
/** The parent's registry — the ceiling every child scope derives from. */
|
|
25
26
|
parentRegistry: ToolRegistry;
|
|
26
27
|
cwd: string;
|
|
28
|
+
/**
|
|
29
|
+
* The session's declared workspace (C.26). Threaded onto the child ctx so a
|
|
30
|
+
* subagent sees the SAME roots as the main loop — without it the child would
|
|
31
|
+
* fall back to a single-root workspace over `cwd` and its fan tools would go
|
|
32
|
+
* silently primary-only (a split-brain WITHIN one session).
|
|
33
|
+
*/
|
|
34
|
+
workspace: Workspace;
|
|
27
35
|
logger: ToolContext["logger"];
|
|
28
36
|
git?: {
|
|
29
37
|
branch: string;
|
|
@@ -45,6 +53,13 @@ export interface SubagentOrchestratorDeps {
|
|
|
45
53
|
* one checkpoint, C.32).
|
|
46
54
|
*/
|
|
47
55
|
makeChildApproval(): (action: ApproveAction) => Promise<ApprovalDecision>;
|
|
56
|
+
/**
|
|
57
|
+
* Whether per-root checkpointing is active for the run (C.26 step 3). Threaded
|
|
58
|
+
* onto the child ctx so a subagent's non-primary writes lift the JC-1 refusal on
|
|
59
|
+
* the SAME condition as the parent — and, because `makeChildApproval` wraps the
|
|
60
|
+
* same shared checkpoint gate, those writes join the run's one set (⚖︎JC-δ).
|
|
61
|
+
*/
|
|
62
|
+
checkpointsActive?: boolean;
|
|
48
63
|
}
|
|
49
64
|
/**
|
|
50
65
|
* Spawns subagents (C.14): the existing agent loop re-driven over isolated
|
|
@@ -57,6 +57,7 @@ export class SubagentOrchestrator {
|
|
|
57
57
|
const artifacts = new Set();
|
|
58
58
|
const ctx = {
|
|
59
59
|
cwd: deps.cwd,
|
|
60
|
+
workspace: deps.workspace,
|
|
60
61
|
config: deps.config,
|
|
61
62
|
logger: deps.logger,
|
|
62
63
|
requestApproval: async (action) => {
|
|
@@ -65,6 +66,7 @@ export class SubagentOrchestrator {
|
|
|
65
66
|
recordArtifacts(action, artifacts, deps.cwd);
|
|
66
67
|
return decision;
|
|
67
68
|
},
|
|
69
|
+
checkpointsActive: deps.checkpointsActive,
|
|
68
70
|
sandbox: deps.sandbox,
|
|
69
71
|
};
|
|
70
72
|
const label = taskLabel(spec.task);
|
|
@@ -97,6 +97,9 @@ export function makeRunTestsTool(deps = {}) {
|
|
|
97
97
|
const decision = await ctx.requestApproval({
|
|
98
98
|
kind: "test",
|
|
99
99
|
command: resolved.command,
|
|
100
|
+
// C.26 attribution seam (JC-β): primary root name for Steps 4/5. Tests are
|
|
101
|
+
// primary-only this release; the gate hard-attributes to the primary.
|
|
102
|
+
root: ctx.workspace?.primary().name,
|
|
100
103
|
});
|
|
101
104
|
if (!decision.allow) {
|
|
102
105
|
return {
|
|
@@ -8,14 +8,17 @@ import type { Tool } from "./types.js";
|
|
|
8
8
|
* never prompted for or stored.
|
|
9
9
|
*/
|
|
10
10
|
declare const parameters: z.ZodObject<{
|
|
11
|
+
root: z.ZodOptional<z.ZodString>;
|
|
11
12
|
title: z.ZodOptional<z.ZodString>;
|
|
12
13
|
body: z.ZodOptional<z.ZodString>;
|
|
13
14
|
base: z.ZodOptional<z.ZodString>;
|
|
14
15
|
}, "strip", z.ZodTypeAny, {
|
|
16
|
+
root?: string | undefined;
|
|
15
17
|
body?: string | undefined;
|
|
16
18
|
base?: string | undefined;
|
|
17
19
|
title?: string | undefined;
|
|
18
20
|
}, {
|
|
21
|
+
root?: string | undefined;
|
|
19
22
|
body?: string | undefined;
|
|
20
23
|
base?: string | undefined;
|
|
21
24
|
title?: string | undefined;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { z } from "zod";
|
|
2
|
-
import { CruxyError, ErrorCode } from "../errors/index.js";
|
|
3
|
-
import { createForgeProvider, createPrService, fillContent, loadCommitGuidance, resolveForgeToken, } from "../vcs/index.js";
|
|
3
|
+
import { CruxyError, ErrorCode, vcsCrossRoot } from "../errors/index.js";
|
|
4
|
+
import { createForgeProvider, createPrService, fillContent, gitToplevel, loadCommitGuidance, resolveForgeToken, } from "../vcs/index.js";
|
|
5
|
+
import { contextWorkspace, resolveMutationRoot } from "./file/paths.js";
|
|
4
6
|
/**
|
|
5
7
|
* Open a pull request from the agent's work (C.15): branch → conventional commit
|
|
6
8
|
* → push → open PR, all behind the one U.3 approval. The model authors `title`
|
|
@@ -9,6 +11,12 @@ import { createForgeProvider, createPrService, fillContent, loadCommitGuidance,
|
|
|
9
11
|
* never prompted for or stored.
|
|
10
12
|
*/
|
|
11
13
|
const parameters = z.object({
|
|
14
|
+
root: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Workspace root to open the pull request for, by its declared name. Required " +
|
|
18
|
+
"when the session declares more than one root (a PR targets exactly one " +
|
|
19
|
+
"repository, so it must not be guessed); optional in a single-root session."),
|
|
12
20
|
title: z
|
|
13
21
|
.string()
|
|
14
22
|
.optional()
|
|
@@ -31,11 +39,18 @@ export const createPullRequestTool = {
|
|
|
31
39
|
parameters,
|
|
32
40
|
async execute(input, ctx) {
|
|
33
41
|
try {
|
|
42
|
+
// ⚖︎#11 / JC-1: commit to exactly one root first. Multi-root with no `root`
|
|
43
|
+
// refuses (ROOT_AMBIGUOUS) BEFORE any git runs; single-root defaults to the
|
|
44
|
+
// primary (byte-identical to pre-C.26). A cross-root PR (a sibling root in the
|
|
45
|
+
// same repo) is refused too — a PR is a single-repo artifact (JC-3).
|
|
46
|
+
const selected = resolveMutationRoot(ctx, { root: input.root }, "a pull request");
|
|
47
|
+
assertSingleRepoRoot(contextWorkspace(ctx), selected);
|
|
34
48
|
const token = resolveForgeToken();
|
|
35
49
|
const forge = createForgeProvider(token);
|
|
36
|
-
const guidance = await loadCommitGuidance(
|
|
50
|
+
const guidance = await loadCommitGuidance(selected.absPath);
|
|
37
51
|
const service = createPrService({
|
|
38
|
-
cwd:
|
|
52
|
+
cwd: selected.absPath,
|
|
53
|
+
rootName: selected.name,
|
|
39
54
|
config: ctx.config,
|
|
40
55
|
forge,
|
|
41
56
|
requestApproval: ctx.requestApproval,
|
|
@@ -75,6 +90,37 @@ export const createPullRequestTool = {
|
|
|
75
90
|
}
|
|
76
91
|
},
|
|
77
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Refuse a PR whose selected root shares one git working tree with a SIBLING
|
|
95
|
+
* declared root (JC-3). Filesystem-overlap refusal (ROOT_OVERLAP) only catches
|
|
96
|
+
* nesting; two non-overlapping roots — `packages/a` and `packages/b` under one
|
|
97
|
+
* `.git` — pass that check yet still share a repo. Committing in one would
|
|
98
|
+
* `git add -A` the other's changes, so the PR would span both. We detect it by
|
|
99
|
+
* comparing git top-levels: same top-level ⇒ same repo ⇒ refuse (naming both).
|
|
100
|
+
*
|
|
101
|
+
* If the selected root is not in a git repo, there is nothing to compare — the PR
|
|
102
|
+
* flow fails loudly later at `getRepoInfo`/`currentBranch`. Read-only `rev-parse`
|
|
103
|
+
* only; this runs after selection, so a no-root multi-root call still spawns zero
|
|
104
|
+
* git before its ROOT_AMBIGUOUS refusal.
|
|
105
|
+
*/
|
|
106
|
+
function assertSingleRepoRoot(ws, selected) {
|
|
107
|
+
if (!ws.isMultiRoot)
|
|
108
|
+
return;
|
|
109
|
+
const topSelected = gitToplevel(selected.absPath);
|
|
110
|
+
if (topSelected === null)
|
|
111
|
+
return;
|
|
112
|
+
const canonical = path.resolve(topSelected);
|
|
113
|
+
for (const sibling of ws.roots()) {
|
|
114
|
+
if (sibling.name === selected.name)
|
|
115
|
+
continue;
|
|
116
|
+
const topSibling = gitToplevel(sibling.absPath);
|
|
117
|
+
if (topSibling === null)
|
|
118
|
+
continue;
|
|
119
|
+
if (path.resolve(topSibling) === canonical) {
|
|
120
|
+
throw vcsCrossRoot(selected.name, sibling.name, canonical);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
78
124
|
/** One-line, model-readable rendering of a coded error + its next steps. */
|
|
79
125
|
function formatCoded(err) {
|
|
80
126
|
const head = err.cause ? `${err.title} — ${err.cause}` : err.title;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveToolPath } from "./paths.js";
|
|
5
5
|
import { countOccurrences } from "./edit-file.js";
|
|
6
6
|
/** How many leading lines of a created file the approval preview shows. */
|
|
7
7
|
const PREVIEW_LINES = 20;
|
|
@@ -62,7 +62,7 @@ export const applyPatchTool = {
|
|
|
62
62
|
const op = input.operations[i];
|
|
63
63
|
let abs;
|
|
64
64
|
try {
|
|
65
|
-
abs = await
|
|
65
|
+
({ abs } = await resolveToolPath(ctx, { path: op.path }, { deferNonPrimaryWrite: true }));
|
|
66
66
|
}
|
|
67
67
|
catch (err) {
|
|
68
68
|
return { ok: false, error: opError(i, op, err.message) };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveToolPath } from "./paths.js";
|
|
4
4
|
/** Count non-overlapping exact occurrences of `needle` in `haystack`. */
|
|
5
5
|
export function countOccurrences(haystack, needle) {
|
|
6
6
|
let count = 0;
|
|
@@ -32,7 +32,7 @@ export const editFileTool = {
|
|
|
32
32
|
async execute(input, ctx) {
|
|
33
33
|
let abs;
|
|
34
34
|
try {
|
|
35
|
-
abs = await
|
|
35
|
+
({ abs } = await resolveToolPath(ctx, { path: input.path }, { deferNonPrimaryWrite: true }));
|
|
36
36
|
}
|
|
37
37
|
catch (err) {
|
|
38
38
|
return { ok: false, error: err.message };
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { Tool } from "../types.js";
|
|
3
3
|
/**
|
|
4
|
-
* Find files by glob pattern within the
|
|
5
|
-
* The pattern is constrained to
|
|
4
|
+
* Find files by glob pattern within the workspace. Read-only — no approval.
|
|
5
|
+
* The pattern is constrained to a root (no absolute or `..` patterns) so glob
|
|
6
6
|
* can't reach outside, consistent with the other file tools.
|
|
7
|
+
*
|
|
8
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, glob fans every
|
|
9
|
+
* root — an independent walk rooted at each root's own `absPath` — and labels each
|
|
10
|
+
* match `‹root› ▸ path`. There is no path to confine here; the boundary is which
|
|
11
|
+
* root's `absPath` each walk starts from, so a match can only ever come from the
|
|
12
|
+
* root its label names. A `root` argument scopes the walk to that one root.
|
|
7
13
|
*/
|
|
8
14
|
export declare const globTool: Tool<z.ZodObject<{
|
|
9
15
|
pattern: z.ZodString;
|
|
16
|
+
root: z.ZodOptional<z.ZodString>;
|
|
10
17
|
}>>;
|
package/dist/tools/file/glob.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { glob } from "tinyglobby";
|
|
3
2
|
import { z } from "zod";
|
|
3
|
+
import { contextWorkspace, isEscapingPattern, labelPath, resolveReadRoots, } from "./paths.js";
|
|
4
4
|
/** Cap on returned paths — beyond this we truncate with a notice. */
|
|
5
5
|
const MAX_RESULTS = 200;
|
|
6
6
|
const DEFAULT_IGNORE = ["**/node_modules/**", "**/.git/**"];
|
|
7
7
|
/**
|
|
8
|
-
* Find files by glob pattern within the
|
|
9
|
-
* The pattern is constrained to
|
|
8
|
+
* Find files by glob pattern within the workspace. Read-only — no approval.
|
|
9
|
+
* The pattern is constrained to a root (no absolute or `..` patterns) so glob
|
|
10
10
|
* can't reach outside, consistent with the other file tools.
|
|
11
|
+
*
|
|
12
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, glob fans every
|
|
13
|
+
* root — an independent walk rooted at each root's own `absPath` — and labels each
|
|
14
|
+
* match `‹root› ▸ path`. There is no path to confine here; the boundary is which
|
|
15
|
+
* root's `absPath` each walk starts from, so a match can only ever come from the
|
|
16
|
+
* root its label names. A `root` argument scopes the walk to that one root.
|
|
11
17
|
*/
|
|
12
18
|
export const globTool = {
|
|
13
19
|
name: "glob",
|
|
@@ -16,37 +22,85 @@ export const globTool = {
|
|
|
16
22
|
pattern: z
|
|
17
23
|
.string()
|
|
18
24
|
.describe("Glob pattern, relative to the project root (e.g. 'src/**/*.ts')."),
|
|
25
|
+
root: z
|
|
26
|
+
.string()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe("In a multi-repo session, restrict the search to a single declared root by name. Omit to search every root and label each match with its root."),
|
|
19
29
|
}),
|
|
20
30
|
async execute(input, ctx) {
|
|
21
31
|
const pattern = input.pattern;
|
|
22
|
-
|
|
32
|
+
// Walk-rooted tool: the pattern is not a resolvable path, so confinement is
|
|
33
|
+
// (a) rejecting a pattern that could escape (shared with grep_files) and
|
|
34
|
+
// (b) rooting each walk at a declared root's absPath, never a bare ctx.cwd.
|
|
35
|
+
if (isEscapingPattern(pattern)) {
|
|
23
36
|
return {
|
|
24
37
|
ok: false,
|
|
25
38
|
error: "pattern must be relative to the project root (no '..' or absolute paths)",
|
|
26
39
|
};
|
|
27
40
|
}
|
|
41
|
+
const ws = contextWorkspace(ctx);
|
|
42
|
+
let roots;
|
|
43
|
+
try {
|
|
44
|
+
roots = resolveReadRoots(ctx, { root: input.root });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return { ok: false, error: err.message };
|
|
48
|
+
}
|
|
28
49
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
50
|
+
// Fan: one independent walk per root; each match is attributed to (and, in
|
|
51
|
+
// multi-root, labelled with) the root whose absPath produced it.
|
|
52
|
+
const rows = [];
|
|
53
|
+
const totalByRoot = new Map();
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
const matches = await glob(pattern, {
|
|
56
|
+
cwd: root.absPath,
|
|
57
|
+
ignore: DEFAULT_IGNORE,
|
|
58
|
+
onlyFiles: true,
|
|
59
|
+
dot: false,
|
|
60
|
+
});
|
|
61
|
+
matches.sort();
|
|
62
|
+
totalByRoot.set(root.name, matches.length);
|
|
63
|
+
for (const m of matches) {
|
|
64
|
+
rows.push({
|
|
65
|
+
root: root.name,
|
|
66
|
+
line: labelPath(root, m, ws.isMultiRoot),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (rows.length === 0) {
|
|
37
71
|
return { ok: true, output: "(no matches)" };
|
|
38
72
|
}
|
|
39
|
-
if (
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
73
|
+
if (rows.length > MAX_RESULTS) {
|
|
74
|
+
const shownRows = rows.slice(0, MAX_RESULTS);
|
|
75
|
+
const shown = shownRows.map((r) => r.line).join("\n");
|
|
76
|
+
const footer = ws.isMultiRoot
|
|
77
|
+
? truncationFooter(rows.length, shownRows, totalByRoot)
|
|
78
|
+
: `[truncated: showing ${MAX_RESULTS} of ${rows.length} matches]`;
|
|
79
|
+
return { ok: true, output: `${shown}\n\n${footer}` };
|
|
45
80
|
}
|
|
46
|
-
return { ok: true, output:
|
|
81
|
+
return { ok: true, output: rows.map((r) => r.line).join("\n") };
|
|
47
82
|
}
|
|
48
83
|
catch (err) {
|
|
49
84
|
return { ok: false, error: err.message };
|
|
50
85
|
}
|
|
51
86
|
},
|
|
52
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* Truncation footer for a fanned walk: the overall count plus a per-root note for
|
|
90
|
+
* every root whose matches the cap dropped — so a root cut off by the cap is
|
|
91
|
+
* named, never silently read as having no files.
|
|
92
|
+
*/
|
|
93
|
+
function truncationFooter(total, shownRows, totalByRoot) {
|
|
94
|
+
const shownByRoot = new Map();
|
|
95
|
+
for (const r of shownRows) {
|
|
96
|
+
shownByRoot.set(r.root, (shownByRoot.get(r.root) ?? 0) + 1);
|
|
97
|
+
}
|
|
98
|
+
const lines = [`[truncated: showing ${MAX_RESULTS} of ${total} matches]`];
|
|
99
|
+
for (const [name, count] of totalByRoot) {
|
|
100
|
+
const dropped = count - (shownByRoot.get(name) ?? 0);
|
|
101
|
+
if (dropped > 0) {
|
|
102
|
+
lines.push(`— ${name}: ${dropped} more not shown`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
@@ -6,27 +6,37 @@ declare const parameters: z.ZodObject<{
|
|
|
6
6
|
glob: z.ZodOptional<z.ZodString>;
|
|
7
7
|
ignoreCase: z.ZodOptional<z.ZodBoolean>;
|
|
8
8
|
maxResults: z.ZodOptional<z.ZodNumber>;
|
|
9
|
+
root: z.ZodOptional<z.ZodString>;
|
|
9
10
|
}, "strip", z.ZodTypeAny, {
|
|
10
11
|
pattern: string;
|
|
11
12
|
path?: string | undefined;
|
|
13
|
+
root?: string | undefined;
|
|
12
14
|
maxResults?: number | undefined;
|
|
13
15
|
glob?: string | undefined;
|
|
14
16
|
ignoreCase?: boolean | undefined;
|
|
15
17
|
}, {
|
|
16
18
|
pattern: string;
|
|
17
19
|
path?: string | undefined;
|
|
20
|
+
root?: string | undefined;
|
|
18
21
|
maxResults?: number | undefined;
|
|
19
22
|
glob?: string | undefined;
|
|
20
23
|
ignoreCase?: boolean | undefined;
|
|
21
24
|
}>;
|
|
22
25
|
/**
|
|
23
|
-
* Search file *contents* for a regex within the
|
|
26
|
+
* Search file *contents* for a regex within the workspace. Read-only — no
|
|
24
27
|
* approval — so the model should prefer this over shelling out to grep/rg via
|
|
25
28
|
* run_command (which is platform-dependent and routes through the approval gate).
|
|
26
29
|
*
|
|
27
30
|
* Files are enumerated with the same glob mechanism as the `glob` tool (so
|
|
28
31
|
* node_modules and .git are always ignored), binary files are skipped, and the
|
|
29
|
-
* search is bounded by the
|
|
32
|
+
* search is bounded by the same root boundary as every file tool.
|
|
33
|
+
*
|
|
34
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root and no root-
|
|
35
|
+
* selecting argument, the search FANS every root — a bare relative `path` is
|
|
36
|
+
* applied under each root (⚖︎JC-6) — and labels each match `‹root› ▸ path`. A
|
|
37
|
+
* `root` argument, or an absolute / `‹root›/…`-prefixed `path`, scopes it to that
|
|
38
|
+
* one root. Each walk starts at exactly one confined root directory, so a match
|
|
39
|
+
* can only come from the root its label names.
|
|
30
40
|
*/
|
|
31
41
|
export declare const grepFilesTool: Tool<typeof parameters>;
|
|
32
42
|
export {};
|