@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
package/dist/tools/git-status.js
CHANGED
|
@@ -1,26 +1,58 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getGitStatus } from "../utils/git.js";
|
|
3
|
+
import { contextWorkspace, resolveReadRoots } from "./file/paths.js";
|
|
3
4
|
/**
|
|
4
5
|
* Report the current git branch and working-tree status (`git status
|
|
5
6
|
* --porcelain`) for the project root. Read-only — no approval, like read_file
|
|
6
7
|
* and glob.
|
|
8
|
+
*
|
|
9
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
|
|
10
|
+
* root — each `getGitStatus(root.absPath)` spawns git with that root as its cwd,
|
|
11
|
+
* so a section reports exactly the working tree its label names. A non-git root
|
|
12
|
+
* is NAMED (not fatal), and a `root` argument scopes to one root.
|
|
7
13
|
*/
|
|
8
14
|
export const gitStatusTool = {
|
|
9
15
|
name: "git_status",
|
|
10
16
|
description: "Show the current git branch and the working-tree status (porcelain format: ' M file' modified, '?? file' untracked, etc.). Read-only and requires no approval.",
|
|
11
|
-
parameters: z.object({
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
parameters: z.object({
|
|
18
|
+
root: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("In a multi-repo session, restrict the report to a single declared root by name. Omit to report every root."),
|
|
22
|
+
}),
|
|
23
|
+
async execute(input, ctx) {
|
|
24
|
+
const ws = contextWorkspace(ctx);
|
|
25
|
+
let roots;
|
|
26
|
+
try {
|
|
27
|
+
roots = resolveReadRoots(ctx, { root: input.root });
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return { ok: false, error: err.message };
|
|
31
|
+
}
|
|
32
|
+
// Single-root: byte-identical with the pre-C.26 tool.
|
|
33
|
+
if (!ws.isMultiRoot) {
|
|
34
|
+
const info = getGitStatus(roots[0].absPath);
|
|
35
|
+
if (info === null) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: "not a git repository (or git is unavailable)",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const body = info.status.trim();
|
|
15
42
|
return {
|
|
16
|
-
ok:
|
|
17
|
-
|
|
43
|
+
ok: true,
|
|
44
|
+
output: `branch ${info.branch}\n${body === "" ? "working tree clean" : body}`,
|
|
18
45
|
};
|
|
19
46
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
47
|
+
// Multi-root: one section per root; a non-git root is named, never fatal.
|
|
48
|
+
const sections = roots.map((root) => {
|
|
49
|
+
const info = getGitStatus(root.absPath);
|
|
50
|
+
if (info === null) {
|
|
51
|
+
return `${root.name}: not a git repository`;
|
|
52
|
+
}
|
|
53
|
+
const body = info.status.trim();
|
|
54
|
+
return `${root.name}: branch ${info.branch}\n${body === "" ? "working tree clean" : body}`;
|
|
55
|
+
});
|
|
56
|
+
return { ok: true, output: sections.join("\n\n") };
|
|
25
57
|
},
|
|
26
58
|
};
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { Tool } from "./types.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* List the entries of a workspace root, marking each as a directory or a file.
|
|
5
|
+
*
|
|
6
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
|
|
7
|
+
* root — each `fs.readdir(root.absPath)` reads exactly the root its section
|
|
8
|
+
* header names. A per-root read error is NAMED (not fatal), and a `root` argument
|
|
9
|
+
* scopes to one root.
|
|
6
10
|
*/
|
|
7
|
-
export declare const listFilesTool: Tool<z.ZodObject<
|
|
11
|
+
export declare const listFilesTool: Tool<z.ZodObject<{
|
|
12
|
+
root: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}>>;
|
package/dist/tools/list-files.js
CHANGED
|
@@ -1,27 +1,62 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { contextWorkspace, resolveReadRoots } from "./file/paths.js";
|
|
4
|
+
/** Render one directory's entries (sorted, dir/file marked) or an empty note. */
|
|
5
|
+
async function listDir(absDir) {
|
|
6
|
+
const entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
7
|
+
if (entries.length === 0) {
|
|
8
|
+
return "(empty directory)";
|
|
9
|
+
}
|
|
10
|
+
return entries
|
|
11
|
+
.slice()
|
|
12
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
13
|
+
.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`)
|
|
14
|
+
.join("\n");
|
|
15
|
+
}
|
|
3
16
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
17
|
+
* List the entries of a workspace root, marking each as a directory or a file.
|
|
18
|
+
*
|
|
19
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
|
|
20
|
+
* root — each `fs.readdir(root.absPath)` reads exactly the root its section
|
|
21
|
+
* header names. A per-root read error is NAMED (not fatal), and a `root` argument
|
|
22
|
+
* scopes to one root.
|
|
6
23
|
*/
|
|
7
24
|
export const listFilesTool = {
|
|
8
25
|
name: "list_files",
|
|
9
26
|
description: "List the files and directories in the current working directory.",
|
|
10
|
-
parameters: z.object({
|
|
11
|
-
|
|
27
|
+
parameters: z.object({
|
|
28
|
+
root: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("In a multi-repo session, restrict the listing to a single declared root by name. Omit to list every root."),
|
|
32
|
+
}),
|
|
33
|
+
async execute(input, ctx) {
|
|
34
|
+
const ws = contextWorkspace(ctx);
|
|
35
|
+
let roots;
|
|
12
36
|
try {
|
|
13
|
-
|
|
14
|
-
if (entries.length === 0) {
|
|
15
|
-
return { ok: true, output: "(empty directory)" };
|
|
16
|
-
}
|
|
17
|
-
const lines = entries
|
|
18
|
-
.slice()
|
|
19
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
20
|
-
.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`);
|
|
21
|
-
return { ok: true, output: lines.join("\n") };
|
|
37
|
+
roots = resolveReadRoots(ctx, { root: input.root });
|
|
22
38
|
}
|
|
23
39
|
catch (err) {
|
|
24
40
|
return { ok: false, error: err.message };
|
|
25
41
|
}
|
|
42
|
+
// Single-root: byte-identical with the pre-C.26 tool.
|
|
43
|
+
if (!ws.isMultiRoot) {
|
|
44
|
+
try {
|
|
45
|
+
return { ok: true, output: await listDir(roots[0].absPath) };
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
return { ok: false, error: err.message };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Multi-root: one section per root; a per-root read error is named, not fatal.
|
|
52
|
+
const sections = await Promise.all(roots.map(async (root) => {
|
|
53
|
+
try {
|
|
54
|
+
return `${root.name}:\n${await listDir(root.absPath)}`;
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return `${root.name}: ${err.message}`;
|
|
58
|
+
}
|
|
59
|
+
}));
|
|
60
|
+
return { ok: true, output: sections.join("\n\n") };
|
|
26
61
|
},
|
|
27
62
|
};
|
|
@@ -4,12 +4,15 @@ declare const parameters: z.ZodObject<{
|
|
|
4
4
|
query: z.ZodString;
|
|
5
5
|
k: z.ZodOptional<z.ZodNumber>;
|
|
6
6
|
pathGlob: z.ZodOptional<z.ZodString>;
|
|
7
|
+
root: z.ZodOptional<z.ZodString>;
|
|
7
8
|
}, "strip", z.ZodTypeAny, {
|
|
8
9
|
query: string;
|
|
10
|
+
root?: string | undefined;
|
|
9
11
|
k?: number | undefined;
|
|
10
12
|
pathGlob?: string | undefined;
|
|
11
13
|
}, {
|
|
12
14
|
query: string;
|
|
15
|
+
root?: string | undefined;
|
|
13
16
|
k?: number | undefined;
|
|
14
17
|
pathGlob?: string | undefined;
|
|
15
18
|
}>;
|
|
@@ -18,6 +21,13 @@ declare const parameters: z.ZodObject<{
|
|
|
18
21
|
* approval — like read_file and grep_files. The index is built/refreshed lazily
|
|
19
22
|
* on first use; results are ranked by cosine similarity and token-budgeted.
|
|
20
23
|
*
|
|
24
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, the search fans
|
|
25
|
+
* every root, gets each root's own index (`getIndexService(root.absPath)` — the
|
|
26
|
+
* per-cwd cache is the per-root map), and merges into one globally-ranked list.
|
|
27
|
+
* Each hit is labelled with its source root, `k` and the token budget are applied
|
|
28
|
+
* AFTER the merge (global rank, ⚖︎JC-I), and any root whose index failed — or whose
|
|
29
|
+
* matches the cap dropped — is NAMED, never silently implied to have no matches.
|
|
30
|
+
*
|
|
21
31
|
* Complements `grep_files`: prefer this for conceptual "where / how does X work"
|
|
22
32
|
* questions, and grep for exact strings or symbols.
|
|
23
33
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { getIndexService } from "../indexing/index.js";
|
|
2
|
+
import { getIndexService, mergeRankedHits } from "../indexing/index.js";
|
|
3
|
+
import { contextWorkspace, labelName, resolveReadRoots } from "./file/paths.js";
|
|
3
4
|
/** Hard cap on `k`, mirroring the retriever. */
|
|
4
5
|
const MAX_K = 50;
|
|
5
6
|
const parameters = z.object({
|
|
@@ -18,12 +19,23 @@ const parameters = z.object({
|
|
|
18
19
|
.string()
|
|
19
20
|
.optional()
|
|
20
21
|
.describe("Optional glob to restrict results by path, e.g. 'src/**/*.ts' or 'packages/cli/**'."),
|
|
22
|
+
root: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("In a multi-repo session, restrict the search to a single declared root by name. Omit to search every root and label each hit with the root it came from."),
|
|
21
26
|
});
|
|
22
27
|
/**
|
|
23
28
|
* Semantic search over the project's local code index (C.17). Read-only — no
|
|
24
29
|
* approval — like read_file and grep_files. The index is built/refreshed lazily
|
|
25
30
|
* on first use; results are ranked by cosine similarity and token-budgeted.
|
|
26
31
|
*
|
|
32
|
+
* Multi-repo (C.26, Funnel B): with more than one declared root, the search fans
|
|
33
|
+
* every root, gets each root's own index (`getIndexService(root.absPath)` — the
|
|
34
|
+
* per-cwd cache is the per-root map), and merges into one globally-ranked list.
|
|
35
|
+
* Each hit is labelled with its source root, `k` and the token budget are applied
|
|
36
|
+
* AFTER the merge (global rank, ⚖︎JC-I), and any root whose index failed — or whose
|
|
37
|
+
* matches the cap dropped — is NAMED, never silently implied to have no matches.
|
|
38
|
+
*
|
|
27
39
|
* Complements `grep_files`: prefer this for conceptual "where / how does X work"
|
|
28
40
|
* questions, and grep for exact strings or symbols.
|
|
29
41
|
*/
|
|
@@ -38,28 +50,79 @@ export const searchCodebaseTool = {
|
|
|
38
50
|
error: "codebase indexing is disabled (set index.enabled = true to use search_codebase)",
|
|
39
51
|
};
|
|
40
52
|
}
|
|
53
|
+
const ws = contextWorkspace(ctx);
|
|
54
|
+
let roots;
|
|
41
55
|
try {
|
|
42
|
-
|
|
43
|
-
const hits = await service.search({
|
|
44
|
-
query: input.query,
|
|
45
|
-
k: input.k,
|
|
46
|
-
pathGlob: input.pathGlob,
|
|
47
|
-
});
|
|
48
|
-
if (hits.length === 0) {
|
|
49
|
-
return { ok: true, output: "(no matches in the codebase index)" };
|
|
50
|
-
}
|
|
51
|
-
return { ok: true, output: formatHits(hits) };
|
|
56
|
+
roots = resolveReadRoots(ctx, { root: input.root });
|
|
52
57
|
}
|
|
53
58
|
catch (err) {
|
|
59
|
+
// Unknown root name (R1) — fail loud rather than silently searching primary.
|
|
54
60
|
return { ok: false, error: err.message };
|
|
55
61
|
}
|
|
62
|
+
// Single-root: byte-identical with the pre-C.26 tool (no label, no footer).
|
|
63
|
+
if (!ws.isMultiRoot) {
|
|
64
|
+
try {
|
|
65
|
+
const only = roots[0];
|
|
66
|
+
const service = await getIndexService(only.absPath, ctx.config, ctx.logger);
|
|
67
|
+
const hits = await service.search({
|
|
68
|
+
query: input.query,
|
|
69
|
+
k: input.k,
|
|
70
|
+
pathGlob: input.pathGlob,
|
|
71
|
+
root: only.name,
|
|
72
|
+
});
|
|
73
|
+
if (hits.length === 0) {
|
|
74
|
+
return { ok: true, output: "(no matches in the codebase index)" };
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, output: formatHits(hits, false) };
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return { ok: false, error: err.message };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Multi-root fan: one independent search per root, per-root failures isolated
|
|
83
|
+
// (one root's broken index must not blank the others — but it IS named).
|
|
84
|
+
const perRoot = [];
|
|
85
|
+
const rawByRoot = new Map();
|
|
86
|
+
const searched = [];
|
|
87
|
+
const failed = [];
|
|
88
|
+
for (const root of roots) {
|
|
89
|
+
try {
|
|
90
|
+
const service = await getIndexService(root.absPath, ctx.config, ctx.logger);
|
|
91
|
+
const hits = await service.search({
|
|
92
|
+
query: input.query,
|
|
93
|
+
k: input.k,
|
|
94
|
+
pathGlob: input.pathGlob,
|
|
95
|
+
root: root.name,
|
|
96
|
+
// Unbudgeted per root; the real budget is applied once after the merge.
|
|
97
|
+
tokenBudget: Number.POSITIVE_INFINITY,
|
|
98
|
+
});
|
|
99
|
+
perRoot.push(hits);
|
|
100
|
+
rawByRoot.set(root.name, hits.length);
|
|
101
|
+
searched.push(root.name);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
failed.push({ name: root.name, reason: err.message });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Global rank + budget + cap AFTER the merge (⚖︎JC-I): a strong hit in one root
|
|
108
|
+
// is never crowded out by a per-root cap in another, and the token budget is
|
|
109
|
+
// honoured once for the whole result.
|
|
110
|
+
const merged = mergeRankedHits(perRoot, {
|
|
111
|
+
k: input.k ?? ctx.config.index.search.defaultK,
|
|
112
|
+
tokenBudget: ctx.config.index.search.tokenBudget,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
output: renderFanned(merged, { searched, failed, rawByRoot }),
|
|
117
|
+
};
|
|
56
118
|
},
|
|
57
119
|
};
|
|
58
|
-
/** Render hits as a compact, model-readable block. */
|
|
59
|
-
function formatHits(hits) {
|
|
120
|
+
/** Render hits as a compact, model-readable block; label by root when multi-root. */
|
|
121
|
+
function formatHits(hits, multiRoot) {
|
|
60
122
|
return hits
|
|
61
123
|
.map((hit) => {
|
|
62
|
-
const
|
|
124
|
+
const loc = multiRoot ? labelName(hit.root, hit.path) : hit.path;
|
|
125
|
+
const header = `${loc}:${hit.startLine}-${hit.endLine} (score ${hit.score.toFixed(3)})`;
|
|
63
126
|
const body = hit.snippet
|
|
64
127
|
.split("\n")
|
|
65
128
|
.map((line) => ` ${line}`)
|
|
@@ -68,3 +131,43 @@ function formatHits(hits) {
|
|
|
68
131
|
})
|
|
69
132
|
.join("\n\n");
|
|
70
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Render a fanned multi-root result with an honest footer: which roots were
|
|
136
|
+
* searched, which had matches dropped by the global cap, and which roots' indexes
|
|
137
|
+
* were unavailable — the last kept DISTINCT from "no matches" so an unsearched
|
|
138
|
+
* root is never read as an empty one (⚖︎6/JC-I).
|
|
139
|
+
*/
|
|
140
|
+
function renderFanned(merged, ctx) {
|
|
141
|
+
const shownByRoot = new Map();
|
|
142
|
+
for (const hit of merged) {
|
|
143
|
+
shownByRoot.set(hit.root, (shownByRoot.get(hit.root) ?? 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
const notes = [];
|
|
146
|
+
// Truncation: a root returned matches that the global cap/budget dropped.
|
|
147
|
+
for (const name of ctx.searched) {
|
|
148
|
+
const raw = ctx.rawByRoot.get(name) ?? 0;
|
|
149
|
+
const shown = shownByRoot.get(name) ?? 0;
|
|
150
|
+
if (raw > shown) {
|
|
151
|
+
const n = raw - shown;
|
|
152
|
+
notes.push(`${name}: ${n} more match${n === 1 ? "" : "es"} not shown (result cap)`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Per-root index failures — named, and never conflated with "no matches".
|
|
156
|
+
for (const f of ctx.failed) {
|
|
157
|
+
notes.push(`${f.name}: index unavailable — ${f.reason}`);
|
|
158
|
+
}
|
|
159
|
+
const scope = ctx.searched.length
|
|
160
|
+
? `searched ${ctx.searched.length} root${ctx.searched.length === 1 ? "" : "s"}: ${ctx.searched.join(", ")}`
|
|
161
|
+
: "no roots could be searched";
|
|
162
|
+
const lines = [];
|
|
163
|
+
if (merged.length) {
|
|
164
|
+
lines.push(formatHits(merged, true), "");
|
|
165
|
+
}
|
|
166
|
+
else if (ctx.searched.length) {
|
|
167
|
+
lines.push("(no matches in the codebase index)", "");
|
|
168
|
+
}
|
|
169
|
+
lines.push(scope);
|
|
170
|
+
for (const note of notes)
|
|
171
|
+
lines.push(`— ${note}`);
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
package/dist/tools/shell/exec.js
CHANGED
|
@@ -7,7 +7,14 @@ import { spawn } from "node:child_process";
|
|
|
7
7
|
* sandbox that can't run throws its coded error (fail loud, no host fallback).
|
|
8
8
|
*/
|
|
9
9
|
export async function runGatedShell(command, ctx) {
|
|
10
|
-
|
|
10
|
+
// `root` is the C.26 attribution seam (JC-β): populated with the primary root
|
|
11
|
+
// name for Steps 4/5. Shell is primary-only this release, so the checkpoint gate
|
|
12
|
+
// hard-attributes it to the primary regardless of this value.
|
|
13
|
+
const decision = await ctx.requestApproval({
|
|
14
|
+
kind: "shell",
|
|
15
|
+
command,
|
|
16
|
+
root: ctx.workspace?.primary().name,
|
|
17
|
+
});
|
|
11
18
|
if (!decision.allow) {
|
|
12
19
|
return { approved: false, rejection: decision.feedback };
|
|
13
20
|
}
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { z, ZodTypeAny } from "zod";
|
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
3
|
import type { ApprovalDecision } from "../approval/types.js";
|
|
4
4
|
import type { SandboxService } from "../sandbox/index.js";
|
|
5
|
+
import type { Workspace } from "../workspace/index.js";
|
|
5
6
|
import type { logger } from "../utils/logger.js";
|
|
6
7
|
/** The leveled logger instance shared across the CLI. */
|
|
7
8
|
type Logger = typeof logger;
|
|
@@ -67,7 +68,11 @@ export type ActionPreview =
|
|
|
67
68
|
/**
|
|
68
69
|
* The whole publish plan for a `vcs` action (C.15): the feature branch, the
|
|
69
70
|
* conventional commit, and the PR title/body — shown as one block so the user
|
|
70
|
-
* approves the entire branch → commit → push → open-PR sequence at once.
|
|
71
|
+
* approves the entire branch → commit → push → open-PR sequence at once. C.26
|
|
72
|
+
* Step 4 adds `target` (the resolved `host/owner/repo` the PR will open against)
|
|
73
|
+
* so the human sees the real API destination — not just the root name — before
|
|
74
|
+
* approving (⚖︎JC-4); it is re-resolved and re-checked immediately before the API
|
|
75
|
+
* call, so what is shown here is what the guard holds them to.
|
|
71
76
|
*/
|
|
72
77
|
| {
|
|
73
78
|
type: "pr";
|
|
@@ -77,6 +82,12 @@ export type ActionPreview =
|
|
|
77
82
|
commitBody: string;
|
|
78
83
|
prTitle: string;
|
|
79
84
|
prBody: string;
|
|
85
|
+
/** The resolved forge target parsed from `origin` at approval time. */
|
|
86
|
+
target: {
|
|
87
|
+
host: string;
|
|
88
|
+
owner: string;
|
|
89
|
+
repo: string;
|
|
90
|
+
};
|
|
80
91
|
}
|
|
81
92
|
/**
|
|
82
93
|
* The full blast radius of a checkpoint restore (C.32): every file rollback
|
|
@@ -95,6 +106,25 @@ export type ActionPreview =
|
|
|
95
106
|
externalPaths: string[];
|
|
96
107
|
/** The run executed shell commands, so per-file attribution is impossible. */
|
|
97
108
|
attributionUnknown: boolean;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A multi-root rollback set (C.26 step 3): one combined preview grouped by root,
|
|
112
|
+
* each root carrying its own file diffs and external-change warnings, so a single
|
|
113
|
+
* U.3 approval covers restoring every touched root of a run at once (⚖︎JC-ι). One
|
|
114
|
+
* `roots` entry per member of the run's `CheckpointSet`.
|
|
115
|
+
*/
|
|
116
|
+
| {
|
|
117
|
+
type: "rollback-set";
|
|
118
|
+
runId: string;
|
|
119
|
+
createdAt: string;
|
|
120
|
+
runSummary: string;
|
|
121
|
+
roots: {
|
|
122
|
+
rootName: string;
|
|
123
|
+
checkpointId: string;
|
|
124
|
+
files: PatchFilePreview[];
|
|
125
|
+
externalPaths: string[];
|
|
126
|
+
attributionUnknown: boolean;
|
|
127
|
+
}[];
|
|
98
128
|
};
|
|
99
129
|
/**
|
|
100
130
|
* A side-effecting action a tool wants to take, passed to `ctx.approve`. The
|
|
@@ -112,6 +142,16 @@ export interface ApproveAction {
|
|
|
112
142
|
* covers another — and a server can never mark its own tool low-risk. */
|
|
113
143
|
server?: string;
|
|
114
144
|
tool?: string;
|
|
145
|
+
/**
|
|
146
|
+
* The declared workspace root this action acts in (C.26). `vcs` sets it to the
|
|
147
|
+
* root a pull request was selected for (C.26 Step 4): it may be non-primary, and
|
|
148
|
+
* the checkpoint gate attributes the vcs checkpoint to exactly this root. `shell`/
|
|
149
|
+
* `test` still set it to the primary as an attribution seam — their non-primary
|
|
150
|
+
* support is Step 5 (JC-β residual), so the gate hard-attributes them to the
|
|
151
|
+
* primary regardless of this value. File actions carry their root in the resolved
|
|
152
|
+
* absolute `path`/preview instead, so they leave this unset.
|
|
153
|
+
*/
|
|
154
|
+
root?: string;
|
|
115
155
|
/** Exact-change preview rendered above the prompt (write/edit/patch/vcs/rollback). */
|
|
116
156
|
preview?: ActionPreview;
|
|
117
157
|
}
|
|
@@ -123,6 +163,16 @@ export interface ApproveAction {
|
|
|
123
163
|
export interface ToolContext {
|
|
124
164
|
/** Absolute path the tool should treat as its working root. */
|
|
125
165
|
cwd: string;
|
|
166
|
+
/**
|
|
167
|
+
* The declared workspace root set for this session (C.26). Path-taking tools
|
|
168
|
+
* resolve *through* this rather than through `cwd`, so a multi-root session
|
|
169
|
+
* confines each call to the one root it selects. Optional only so the many test
|
|
170
|
+
* contexts that predate multi-root stay valid: the runtime always populates it,
|
|
171
|
+
* and when absent the file resolver falls back to a trivial single-root
|
|
172
|
+
* workspace over `cwd` (byte-identical to legacy single-root behaviour). `cwd`
|
|
173
|
+
* remains equal to `workspace.primary().absPath`.
|
|
174
|
+
*/
|
|
175
|
+
workspace?: Workspace;
|
|
126
176
|
/** Fully-resolved CLI configuration. */
|
|
127
177
|
config: CruxyConfig;
|
|
128
178
|
/** Shared leveled logger (diagnostics to stderr, `print` to stdout). */
|
|
@@ -137,6 +187,18 @@ export interface ToolContext {
|
|
|
137
187
|
* tools never call this.
|
|
138
188
|
*/
|
|
139
189
|
requestApproval(action: ApproveAction): Promise<ApprovalDecision>;
|
|
190
|
+
/**
|
|
191
|
+
* Whether per-root checkpointing is active for this session (C.26 step 3). It is
|
|
192
|
+
* the single fact that lifts the JC-1 non-primary-write refusal: a write to a
|
|
193
|
+
* non-primary root is permitted **iff** this is true, because a true value means
|
|
194
|
+
* a per-root checkpoint gate is wired that captures the write (get-or-creates the
|
|
195
|
+
* root's service and snapshots it) before it reaches disk. False/absent → a
|
|
196
|
+
* non-primary write is still refused with `CRUXY_E_MULTIROOT_WRITE_DEFERRED`, so
|
|
197
|
+
* a checkpoints-disabled session never widens un-restorable writes to siblings.
|
|
198
|
+
* Set together with `requestApproval` (the gate), so permit and capture are one
|
|
199
|
+
* decision — never lift-then-verify.
|
|
200
|
+
*/
|
|
201
|
+
checkpointsActive?: boolean;
|
|
140
202
|
/**
|
|
141
203
|
* Isolation substrate for the shell + test tools (C.16). Present ONLY when
|
|
142
204
|
* the sandbox is enabled; when set, `run_command`/`run_tests` execute the
|
package/dist/vcs/git.d.ts
CHANGED
|
@@ -6,6 +6,14 @@ export interface GitResult {
|
|
|
6
6
|
}
|
|
7
7
|
/** Run `git <args>` in `cwd`, capturing stdout/stderr/exit. Never throws. */
|
|
8
8
|
export declare function runGitCapture(args: string[], cwd: string): GitResult;
|
|
9
|
+
/**
|
|
10
|
+
* The absolute path of the git working tree that contains `cwd` (its
|
|
11
|
+
* `--show-toplevel`), or `null` when `cwd` is not inside a git repository. Git
|
|
12
|
+
* returns a canonical (symlink-resolved) path. Used by the C.26 cross-root guard
|
|
13
|
+
* to detect two declared roots that share one repository (a commit for one would
|
|
14
|
+
* `git add -A` the other's changes too).
|
|
15
|
+
*/
|
|
16
|
+
export declare function gitToplevel(cwd: string): string | null;
|
|
9
17
|
/** The current branch name, or `null` when detached / not a repo. */
|
|
10
18
|
export declare function currentBranch(cwd: string): string | null;
|
|
11
19
|
/** The default set of branch names cruxy will never write to directly. */
|
package/dist/vcs/git.js
CHANGED
|
@@ -35,6 +35,20 @@ export function runGitCapture(args, cwd) {
|
|
|
35
35
|
}
|
|
36
36
|
return { ok: res.status === 0, stdout, stderr, code: res.status };
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The absolute path of the git working tree that contains `cwd` (its
|
|
40
|
+
* `--show-toplevel`), or `null` when `cwd` is not inside a git repository. Git
|
|
41
|
+
* returns a canonical (symlink-resolved) path. Used by the C.26 cross-root guard
|
|
42
|
+
* to detect two declared roots that share one repository (a commit for one would
|
|
43
|
+
* `git add -A` the other's changes too).
|
|
44
|
+
*/
|
|
45
|
+
export function gitToplevel(cwd) {
|
|
46
|
+
const res = runGitCapture(["rev-parse", "--show-toplevel"], cwd);
|
|
47
|
+
if (!res.ok)
|
|
48
|
+
return null;
|
|
49
|
+
const top = res.stdout.trim();
|
|
50
|
+
return top === "" ? null : top;
|
|
51
|
+
}
|
|
38
52
|
/** The current branch name, or `null` when detached / not a repo. */
|
|
39
53
|
export function currentBranch(cwd) {
|
|
40
54
|
const res = runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
package/dist/vcs/github.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ForgeProvider, PullRequestResult, PullRequestSpec, RepoInfo } from "./types.js";
|
|
1
|
+
import type { ForgeProvider, PullRequestResult, PullRequestSpec, RepoIdentity, RepoInfo } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* GitHub implementation of {@link ForgeProvider} (C.15) — the only forge that
|
|
4
4
|
* ships today. It talks to the REST API directly (no octokit dependency) and
|
|
@@ -16,6 +16,12 @@ export declare class GitHubProvider implements ForgeProvider {
|
|
|
16
16
|
private readonly token;
|
|
17
17
|
private readonly fetchImpl;
|
|
18
18
|
constructor(opts: GitHubProviderOptions);
|
|
19
|
+
/**
|
|
20
|
+
* The LOCAL half of {@link getRepoInfo}: read `origin` and parse it, no network.
|
|
21
|
+
* Shared by the full lookup and by the Step-4 wrong-repo re-check so both derive
|
|
22
|
+
* the target from the exact same rule.
|
|
23
|
+
*/
|
|
24
|
+
resolveRepoIdentity(cwd: string): RepoIdentity;
|
|
19
25
|
getRepoInfo(cwd: string): Promise<RepoInfo>;
|
|
20
26
|
createPullRequest(repo: RepoInfo, spec: PullRequestSpec): Promise<PullRequestResult>;
|
|
21
27
|
/** Look up an open PR for `owner:head`, or `null` if there isn't one. */
|
package/dist/vcs/github.js
CHANGED
|
@@ -8,7 +8,12 @@ export class GitHubProvider {
|
|
|
8
8
|
this.token = opts.token;
|
|
9
9
|
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The LOCAL half of {@link getRepoInfo}: read `origin` and parse it, no network.
|
|
13
|
+
* Shared by the full lookup and by the Step-4 wrong-repo re-check so both derive
|
|
14
|
+
* the target from the exact same rule.
|
|
15
|
+
*/
|
|
16
|
+
resolveRepoIdentity(cwd) {
|
|
12
17
|
const remote = runGitCapture(["remote", "get-url", "origin"], cwd);
|
|
13
18
|
if (!remote.ok || remote.stdout.trim() === "") {
|
|
14
19
|
throw usageError("no `origin` remote is configured for this repository", [
|
|
@@ -20,6 +25,10 @@ export class GitHubProvider {
|
|
|
20
25
|
if (!parsed) {
|
|
21
26
|
throw usageError(`could not parse the origin remote as a GitHub repository: ${remote.stdout.trim()}`, ["ensure `origin` points at a github.com (or GitHub Enterprise) repo"]);
|
|
22
27
|
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
async getRepoInfo(cwd) {
|
|
31
|
+
const parsed = this.resolveRepoIdentity(cwd);
|
|
23
32
|
// Best-effort: resolve the default branch so the PR base can default to it.
|
|
24
33
|
const defaultBranch = await this.fetchDefaultBranch(parsed).catch(() => undefined);
|
|
25
34
|
return { ...parsed, defaultBranch };
|
package/dist/vcs/service.d.ts
CHANGED
|
@@ -21,6 +21,14 @@ export type GenerateContent = (input: {
|
|
|
21
21
|
}) => Promise<GeneratedContent>;
|
|
22
22
|
export interface PrServiceDeps {
|
|
23
23
|
cwd: string;
|
|
24
|
+
/**
|
|
25
|
+
* The name of the declared workspace root this PR acts in (C.26 Step 4). It is
|
|
26
|
+
* the exact root the caller selected — the U.3 attribution and the wrong-repo
|
|
27
|
+
* error name it verbatim, rather than re-deriving a basename from `cwd`. Defaults
|
|
28
|
+
* to `basename(cwd)` for callers that don't select a root (the single-root `cruxy
|
|
29
|
+
* pr` command), which is byte-identical to the pre-Step-4 attribution.
|
|
30
|
+
*/
|
|
31
|
+
rootName?: string;
|
|
24
32
|
config: CruxyConfig;
|
|
25
33
|
forge: ForgeProvider;
|
|
26
34
|
generate: GenerateContent;
|
package/dist/vcs/service.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { usageError, vcsRemoteChanged } from "../errors/index.js";
|
|
2
3
|
import { commit, currentBranch, diffAgainst, ensureFeatureBranch, hasChanges, isProtectedBranch, push, stageAll, } from "./git.js";
|
|
3
4
|
/** Build a PR service from its dependencies. */
|
|
4
5
|
export function createPrService(deps) {
|
|
@@ -8,6 +9,7 @@ export function createPrService(deps) {
|
|
|
8
9
|
}
|
|
9
10
|
async function openPullRequest(deps, opts) {
|
|
10
11
|
const { cwd, config, forge, generate, requestApproval } = deps;
|
|
12
|
+
const rootName = deps.rootName ?? path.basename(cwd);
|
|
11
13
|
const protectedExtra = config.git.protectedBranches;
|
|
12
14
|
const branchNow = currentBranch(cwd);
|
|
13
15
|
if (branchNow === null) {
|
|
@@ -36,8 +38,14 @@ async function openPullRequest(deps, opts) {
|
|
|
36
38
|
body: opts.body,
|
|
37
39
|
});
|
|
38
40
|
// ── the one gate: show the whole publish plan before anything mutates ──────────
|
|
41
|
+
// `repo` is the target parsed from `origin` NOW; it is both shown to the human
|
|
42
|
+
// (⚖︎JC-4 — root name AND resolved owner/repo) and captured as the approved
|
|
43
|
+
// target that the wrong-repo guard re-checks before the API call.
|
|
39
44
|
const decision = await requestApproval({
|
|
40
45
|
kind: "vcs",
|
|
46
|
+
// C.26 Step 4: the acting root, named exactly as the caller selected it — the
|
|
47
|
+
// checkpoint gate attributes the run's git side effects to this root.
|
|
48
|
+
root: rootName,
|
|
41
49
|
preview: {
|
|
42
50
|
type: "pr",
|
|
43
51
|
branch: content.branchName,
|
|
@@ -46,6 +54,7 @@ async function openPullRequest(deps, opts) {
|
|
|
46
54
|
commitBody: content.commitBody,
|
|
47
55
|
prTitle: content.prTitle,
|
|
48
56
|
prBody: content.prBody,
|
|
57
|
+
target: { host: repo.host, owner: repo.owner, repo: repo.repo },
|
|
49
58
|
},
|
|
50
59
|
});
|
|
51
60
|
if (!decision.allow) {
|
|
@@ -61,6 +70,18 @@ async function openPullRequest(deps, opts) {
|
|
|
61
70
|
commit(cwd, content.commitSubject, content.commitBody, protectedExtra);
|
|
62
71
|
}
|
|
63
72
|
push(cwd, head, protectedExtra);
|
|
73
|
+
// ── wrong-repo guard (⚖︎JC-2 take A): re-resolve the origin identity LOCALLY and
|
|
74
|
+
// compare it to what the human approved, immediately before the API call. A
|
|
75
|
+
// concurrent `git remote set-url` on the mutable .git/config would otherwise open
|
|
76
|
+
// the PR against a repo the user never saw. Mismatch → refuse (never open); we do
|
|
77
|
+
// NOT re-gate or warn-and-proceed. The tiny window between this re-check and the
|
|
78
|
+
// HTTP send is an unclosable residual without a lock — named, not chased. (Git
|
|
79
|
+
// pushed to `origin` by name above; the guarded surface here is the API's
|
|
80
|
+
// owner/repo target, which is the value the human actually approved.)
|
|
81
|
+
const current = forge.resolveRepoIdentity(cwd);
|
|
82
|
+
if (!sameIdentity(repo, current)) {
|
|
83
|
+
throw vcsRemoteChanged(repo, current, rootName);
|
|
84
|
+
}
|
|
64
85
|
const pr = await forge.createPullRequest(repo, {
|
|
65
86
|
title: content.prTitle,
|
|
66
87
|
body: content.prBody,
|
|
@@ -77,3 +98,14 @@ async function openPullRequest(deps, opts) {
|
|
|
77
98
|
alreadyExists: pr.alreadyExists,
|
|
78
99
|
};
|
|
79
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Are two forge identities the same repository? Host/owner/repo compared
|
|
103
|
+
* case-insensitively, because GitHub treats them so — a case-only remote rewrite
|
|
104
|
+
* is the same repo and must not trip the wrong-repo guard with a false positive.
|
|
105
|
+
*/
|
|
106
|
+
function sameIdentity(a, b) {
|
|
107
|
+
const norm = (s) => s.toLowerCase();
|
|
108
|
+
return (norm(a.host) === norm(b.host) &&
|
|
109
|
+
norm(a.owner) === norm(b.owner) &&
|
|
110
|
+
norm(a.repo) === norm(b.repo));
|
|
111
|
+
}
|