@cruxy/cli 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/approval/classify.js +18 -0
  4. package/dist/approval/index.d.ts +1 -0
  5. package/dist/approval/index.js +1 -0
  6. package/dist/approval/mutex.d.ts +45 -0
  7. package/dist/approval/mutex.js +57 -0
  8. package/dist/approval/prompt.js +11 -0
  9. package/dist/checkpoint/gate.d.ts +65 -0
  10. package/dist/checkpoint/gate.js +86 -0
  11. package/dist/checkpoint/index.d.ts +2 -0
  12. package/dist/checkpoint/index.js +2 -0
  13. package/dist/checkpoint/service.d.ts +9 -0
  14. package/dist/checkpoint/service.js +20 -0
  15. package/dist/checkpoint/set-rollback.d.ts +51 -0
  16. package/dist/checkpoint/set-rollback.js +74 -0
  17. package/dist/cli/commands/rollback.d.ts +11 -6
  18. package/dist/cli/commands/rollback.js +93 -33
  19. package/dist/cli/commands/run.js +104 -21
  20. package/dist/cli/onboard.js +4 -1
  21. package/dist/cli/repl.d.ts +2 -2
  22. package/dist/cli/repl.js +39 -0
  23. package/dist/cli/session-factory.d.ts +27 -4
  24. package/dist/cli/session-factory.js +234 -58
  25. package/dist/config/schema.d.ts +24 -0
  26. package/dist/config/schema.js +9 -0
  27. package/dist/errors/constructors.d.ts +51 -0
  28. package/dist/errors/constructors.js +97 -0
  29. package/dist/errors/types.d.ts +28 -0
  30. package/dist/errors/types.js +38 -0
  31. package/dist/hooks/index.d.ts +1 -0
  32. package/dist/hooks/index.js +1 -0
  33. package/dist/hooks/router.d.ts +58 -0
  34. package/dist/hooks/router.js +136 -0
  35. package/dist/hooks/runner.d.ts +12 -0
  36. package/dist/hooks/runner.js +23 -1
  37. package/dist/indexing/retriever.d.ts +29 -0
  38. package/dist/indexing/retriever.js +26 -0
  39. package/dist/indexing/service.js +3 -1
  40. package/dist/indexing/types.d.ts +7 -0
  41. package/dist/lsp/tools/common.d.ts +34 -7
  42. package/dist/lsp/tools/common.js +33 -11
  43. package/dist/lsp/tools/find-definition.js +2 -2
  44. package/dist/lsp/tools/find-references.js +10 -4
  45. package/dist/lsp/tools/get-diagnostics.js +6 -4
  46. package/dist/mcp/index.d.ts +1 -0
  47. package/dist/mcp/index.js +1 -0
  48. package/dist/mcp/sibling-banner.d.ts +25 -0
  49. package/dist/mcp/sibling-banner.js +34 -0
  50. package/dist/memory/recall.d.ts +24 -0
  51. package/dist/memory/recall.js +54 -0
  52. package/dist/memory/remember-tool.d.ts +3 -0
  53. package/dist/memory/remember-tool.js +11 -1
  54. package/dist/render/diff.js +42 -5
  55. package/dist/sandbox/policy.js +14 -5
  56. package/dist/sandbox/service.d.ts +8 -1
  57. package/dist/sandbox/service.js +4 -1
  58. package/dist/subagent/index.d.ts +1 -0
  59. package/dist/subagent/index.js +1 -0
  60. package/dist/subagent/orchestrator.d.ts +81 -1
  61. package/dist/subagent/orchestrator.js +204 -17
  62. package/dist/subagent/registry-scope.d.ts +13 -0
  63. package/dist/subagent/registry-scope.js +28 -2
  64. package/dist/subagent/semaphore.d.ts +27 -0
  65. package/dist/subagent/semaphore.js +56 -0
  66. package/dist/subagent/spawn-tool.d.ts +57 -0
  67. package/dist/subagent/spawn-tool.js +104 -9
  68. package/dist/subagent/types.d.ts +17 -2
  69. package/dist/testing/run-tests-tool.js +3 -0
  70. package/dist/tools/create-pull-request.d.ts +3 -0
  71. package/dist/tools/create-pull-request.js +50 -4
  72. package/dist/tools/file/apply-patch.js +2 -2
  73. package/dist/tools/file/edit-file.js +2 -2
  74. package/dist/tools/file/glob.d.ts +9 -2
  75. package/dist/tools/file/glob.js +73 -19
  76. package/dist/tools/file/grep-files.d.ts +12 -2
  77. package/dist/tools/file/grep-files.js +113 -38
  78. package/dist/tools/file/paths.d.ts +121 -9
  79. package/dist/tools/file/paths.js +164 -10
  80. package/dist/tools/file/read-file.js +2 -2
  81. package/dist/tools/file/write-file.js +2 -2
  82. package/dist/tools/git-status.d.ts +8 -1
  83. package/dist/tools/git-status.js +43 -11
  84. package/dist/tools/list-files.d.ts +9 -3
  85. package/dist/tools/list-files.js +48 -13
  86. package/dist/tools/search-codebase.d.ts +10 -0
  87. package/dist/tools/search-codebase.js +117 -14
  88. package/dist/tools/shell/exec.js +43 -4
  89. package/dist/tools/types.d.ts +74 -1
  90. package/dist/vcs/git.d.ts +8 -0
  91. package/dist/vcs/git.js +14 -0
  92. package/dist/vcs/github.d.ts +7 -1
  93. package/dist/vcs/github.js +10 -1
  94. package/dist/vcs/service.d.ts +8 -0
  95. package/dist/vcs/service.js +33 -1
  96. package/dist/vcs/types.d.ts +18 -2
  97. package/dist/workspace/add-root.d.ts +27 -0
  98. package/dist/workspace/add-root.js +16 -0
  99. package/dist/workspace/index.d.ts +2 -1
  100. package/dist/workspace/index.js +2 -1
  101. package/dist/workspace/workspace.d.ts +9 -4
  102. package/dist/workspace/workspace.js +9 -4
  103. package/package.json +1 -1
@@ -2,7 +2,8 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { glob } from "tinyglobby";
4
4
  import { z } from "zod";
5
- import { resolveInRoot } from "./paths.js";
5
+ import { confineToRoot } from "../../workspace/index.js";
6
+ import { contextWorkspace, isEscapingPattern, labelPath, resolveToolPath, } from "./paths.js";
6
7
  /** Default cap on returned match lines; beyond it we report the overflow. */
7
8
  const DEFAULT_MAX_RESULTS = 100;
8
9
  /** Trim point for a single matched line so long lines don't flood the output. */
@@ -32,15 +33,31 @@ const parameters = z.object({
32
33
  .positive()
33
34
  .optional()
34
35
  .describe(`Maximum number of matching lines to return (default ${DEFAULT_MAX_RESULTS}).`),
36
+ root: z
37
+ .string()
38
+ .optional()
39
+ .describe("In a multi-repo session, restrict the search to a single declared root by name. Omit to search every root (a relative `path` is applied under each) and label each match with its root."),
35
40
  });
41
+ /** True when `p`'s first segment names a declared root (a `‹root›/rest` selector). */
42
+ function firstSegmentIsRoot(ws, p) {
43
+ const seg = p.split(/[/\\]/, 1)[0];
44
+ return seg.length > 0 && seg !== p && ws.tryRootByName(seg) !== undefined;
45
+ }
36
46
  /**
37
- * Search file *contents* for a regex within the project root. Read-only — no
47
+ * Search file *contents* for a regex within the workspace. Read-only — no
38
48
  * approval — so the model should prefer this over shelling out to grep/rg via
39
49
  * run_command (which is platform-dependent and routes through the approval gate).
40
50
  *
41
51
  * Files are enumerated with the same glob mechanism as the `glob` tool (so
42
52
  * node_modules and .git are always ignored), binary files are skipped, and the
43
- * search is bounded by the project-root path boundary shared by every file tool.
53
+ * search is bounded by the same root boundary as every file tool.
54
+ *
55
+ * Multi-repo (C.26, Funnel B): with more than one declared root and no root-
56
+ * selecting argument, the search FANS every root — a bare relative `path` is
57
+ * applied under each root (⚖︎JC-6) — and labels each match `‹root› ▸ path`. A
58
+ * `root` argument, or an absolute / `‹root›/…`-prefixed `path`, scopes it to that
59
+ * one root. Each walk starts at exactly one confined root directory, so a match
60
+ * can only come from the root its label names.
44
61
  */
45
62
  export const grepFilesTool = {
46
63
  name: "grep_files",
@@ -55,59 +72,117 @@ export const grepFilesTool = {
55
72
  catch (err) {
56
73
  return { ok: false, error: `invalid regex: ${err.message}` };
57
74
  }
58
- const root = path.resolve(ctx.cwd);
59
- let scope;
75
+ // The `glob` filter is not a resolvable path, so — like the glob tool — it is
76
+ // confined by rejecting a pattern that could walk outside the scope (G2).
77
+ if (input.glob && isEscapingPattern(input.glob)) {
78
+ return {
79
+ ok: false,
80
+ error: "glob must be relative to the search scope (no '..' or absolute paths)",
81
+ };
82
+ }
83
+ const ws = contextWorkspace(ctx);
84
+ let targets;
60
85
  try {
61
- scope = input.path ? await resolveInRoot(ctx, input.path) : root;
86
+ if (input.root || !ws.isMultiRoot) {
87
+ // Explicit named root, or a single-root session: confine the path within
88
+ // that one root exactly as the pre-C.26 tool did (byte-identical output).
89
+ const { root, abs } = await resolveToolPath(ctx, {
90
+ root: input.root,
91
+ path: input.path ?? ".",
92
+ });
93
+ targets = [{ root, scope: abs }];
94
+ }
95
+ else if (input.path &&
96
+ (path.isAbsolute(input.path) || firstSegmentIsRoot(ws, input.path))) {
97
+ // A `path` that itself points into / names one root selects that root.
98
+ const { root, abs } = await resolveToolPath(ctx, { path: input.path });
99
+ targets = [{ root, scope: abs }];
100
+ }
101
+ else {
102
+ // Fan: apply the bare-relative `path` (or none) under EACH root (⚖︎JC-6).
103
+ targets = await Promise.all(ws.roots().map(async (root) => ({
104
+ root,
105
+ scope: input.path
106
+ ? await confineToRoot(root.absPath, input.path)
107
+ : root.absPath,
108
+ })));
109
+ }
62
110
  }
63
111
  catch (err) {
64
112
  return { ok: false, error: err.message };
65
113
  }
66
114
  const maxResults = input.maxResults ?? DEFAULT_MAX_RESULTS;
67
115
  try {
68
- const files = await glob(input.glob ?? "**/*", {
69
- cwd: scope,
70
- ignore: DEFAULT_IGNORE,
71
- onlyFiles: true,
72
- dot: false,
73
- });
74
- files.sort();
75
- const lines = [];
116
+ const rows = []; // shown (capped) lines
76
117
  let total = 0; // total matches found, including those past the cap
77
- for (const rel of files) {
78
- const abs = path.join(scope, rel);
79
- const buf = await fs.readFile(abs);
80
- if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0))
81
- continue; // binary
82
- const display = path.relative(root, abs);
83
- const fileLines = buf.toString("utf8").split("\n");
84
- for (let i = 0; i < fileLines.length; i++) {
85
- if (!regex.test(fileLines[i]))
86
- continue;
87
- total++;
88
- if (lines.length < maxResults) {
89
- let text = fileLines[i].trim();
90
- if (text.length > MAX_LINE_LENGTH) {
91
- text = `${text.slice(0, MAX_LINE_LENGTH)}…`;
118
+ const totalByRoot = new Map();
119
+ for (const target of targets) {
120
+ const files = await glob(input.glob ?? "**/*", {
121
+ cwd: target.scope,
122
+ ignore: DEFAULT_IGNORE,
123
+ onlyFiles: true,
124
+ dot: false,
125
+ });
126
+ files.sort();
127
+ for (const rel of files) {
128
+ const abs = path.join(target.scope, rel);
129
+ const buf = await fs.readFile(abs);
130
+ if (buf.subarray(0, BINARY_SNIFF_BYTES).includes(0))
131
+ continue; // binary
132
+ const display = labelPath(target.root, path.relative(target.root.absPath, abs), ws.isMultiRoot);
133
+ const fileLines = buf.toString("utf8").split("\n");
134
+ for (let i = 0; i < fileLines.length; i++) {
135
+ if (!regex.test(fileLines[i]))
136
+ continue;
137
+ total++;
138
+ totalByRoot.set(target.root.name, (totalByRoot.get(target.root.name) ?? 0) + 1);
139
+ if (rows.length < maxResults) {
140
+ let text = fileLines[i].trim();
141
+ if (text.length > MAX_LINE_LENGTH) {
142
+ text = `${text.slice(0, MAX_LINE_LENGTH)}…`;
143
+ }
144
+ rows.push({
145
+ root: target.root.name,
146
+ line: `${display}:${i + 1}: ${text}`,
147
+ });
92
148
  }
93
- lines.push(`${display}:${i + 1}: ${text}`);
94
149
  }
95
150
  }
96
151
  }
97
152
  if (total === 0) {
98
153
  return { ok: true, output: "(no matches)" };
99
154
  }
100
- const omitted = total - lines.length;
101
- const body = lines.join("\n");
102
- return {
103
- ok: true,
104
- output: omitted > 0
105
- ? `${body}\n\n[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`
106
- : body,
107
- };
155
+ const omitted = total - rows.length;
156
+ const body = rows.map((r) => r.line).join("\n");
157
+ if (omitted === 0) {
158
+ return { ok: true, output: body };
159
+ }
160
+ const footer = ws.isMultiRoot
161
+ ? grepTruncationFooter(omitted, rows, totalByRoot)
162
+ : `[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`;
163
+ return { ok: true, output: `${body}\n\n${footer}` };
108
164
  }
109
165
  catch (err) {
110
166
  return { ok: false, error: err.message };
111
167
  }
112
168
  },
113
169
  };
170
+ /**
171
+ * Truncation footer for a fanned grep: the overall overflow count plus a per-root
172
+ * note for every root whose matches the cap dropped — so a root cut off by the cap
173
+ * is named, never silently read as having no matches.
174
+ */
175
+ function grepTruncationFooter(omitted, shownRows, totalByRoot) {
176
+ const shownByRoot = new Map();
177
+ for (const r of shownRows) {
178
+ shownByRoot.set(r.root, (shownByRoot.get(r.root) ?? 0) + 1);
179
+ }
180
+ const lines = [`[${omitted} more match${omitted === 1 ? "" : "es"} omitted]`];
181
+ for (const [name, count] of totalByRoot) {
182
+ const dropped = count - (shownByRoot.get(name) ?? 0);
183
+ if (dropped > 0) {
184
+ lines.push(`— ${name}: ${dropped} more omitted`);
185
+ }
186
+ }
187
+ return lines.join("\n");
188
+ }
@@ -1,19 +1,131 @@
1
- import { PathEscapeError } from "../../workspace/index.js";
1
+ import { PathEscapeError, type DeclaredRoot, type RootRef, type Workspace } from "../../workspace/index.js";
2
2
  import type { ToolContext } from "../types.js";
3
3
  /**
4
- * Path confinement for file tools. The confinement kernel now lives in
5
- * `src/workspace` so multi-root (C.26) and single-root callers share ONE
6
- * implementation. This module keeps the single-root `resolveInRoot` entry point
7
- * (and re-exports {@link PathEscapeError}) so existing call sites are unchanged:
8
- * they confine to `ctx.cwd`, which is the workspace's primary root.
4
+ * Path confinement for file tools (C.26). The confinement kernel itself lives in
5
+ * `src/workspace` ({@link confineToRoot}); this module is the tool-facing funnel
6
+ * that every path-taking tool calls instead of touching the filesystem directly.
7
+ * Keeping ONE resolver here is what makes Funnel A ("a path tool cannot reach fs
8
+ * outside the selected root") a structural property rather than a per-tool habit.
9
9
  */
10
10
  export { PathEscapeError };
11
11
  /**
12
- * Resolve a tool-supplied path against the project root (`ctx.cwd`) and prove it
13
- * stays insidethe single-root funnel. Delegates to {@link confineToRoot}; see
14
- * there for the 2-layer (lexical + symlink) confinement logic.
12
+ * The workspace this context acts in. `ctx.workspace` is a required field (C.26
13
+ * Step 6), so this is a plain accessor there is no single-root fallback to
14
+ * synthesize, because no code path can reach a path-taking tool without a
15
+ * workspace (the type forbids the omission; the `no-split-brain` guard forbids a
16
+ * mid-subsystem synthesis). Kept as the one named seam every funnel calls.
17
+ */
18
+ export declare function contextWorkspace(ctx: ToolContext): Workspace;
19
+ /**
20
+ * Resolve a tool-supplied path against the workspace and prove it stays inside the
21
+ * one root it selects — the single funnel for every path-taking file tool.
22
+ *
23
+ * Single-root sessions short-circuit straight to `confineToRoot(primary, path)`,
24
+ * which is exactly the legacy `resolveInRoot` behaviour (same result, same
25
+ * error type + message for every case: relative, absolute-inside, absolute-outside,
26
+ * outward symlink). Multi-root sessions run the two-step select-then-confine:
27
+ * {@link selectRoot} commits to exactly one declared root FIRST, then
28
+ * {@link confineToRoot} validates against only that root — so a `../otherRoot/x`
29
+ * that lands in a sibling is refused as an ordinary `PATH_ESCAPE` (R2), never
30
+ * rebound to the sibling.
31
+ *
32
+ * `deferNonPrimaryWrite` (set by the mutating file tools) refuses a write whose
33
+ * selected root is NOT the primary in a multi-root session, with
34
+ * `CRUXY_E_MULTIROOT_WRITE_DEFERRED` — UNLESS `ctx.checkpointsActive` is true.
35
+ * That flag is the C.26 step-3 coupling: it is set exactly when a per-root
36
+ * checkpoint gate is wired, which captures the write (snapshots its root) before
37
+ * it reaches disk — so the refusal lifts precisely when the write becomes
38
+ * rollback-able, never before (not step-wide, not lift-then-verify). With
39
+ * checkpoints disabled the flag is false, so a non-primary write is still refused
40
+ * rather than left un-restorable (⚖︎JC-γ: the opt-out covers the user's own cwd,
41
+ * not every declared sibling). It is a no-op in single-root (there is only the
42
+ * primary) and for reads (which never set it). The refusal is raised BEFORE any
43
+ * filesystem access.
44
+ *
45
+ * @throws {PathEscapeError} if the path escapes the selected root.
46
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN / CRUXY_E_ROOT_AMBIGUOUS on bad selection,
47
+ * or CRUXY_E_MULTIROOT_WRITE_DEFERRED on a non-primary write while deferred.
48
+ */
49
+ export declare function resolveToolPath(ctx: ToolContext, ref: RootRef, opts?: {
50
+ requireExplicit?: boolean;
51
+ deferNonPrimaryWrite?: boolean;
52
+ }): Promise<{
53
+ root: DeclaredRoot;
54
+ abs: string;
55
+ }>;
56
+ /**
57
+ * Legacy single-root entry point, retained as a thin shim over
58
+ * {@link resolveToolPath} so callers that pass only a bare path (e.g. the LSP
59
+ * tools) keep working and stay byte-identical. New path tools should call
60
+ * {@link resolveToolPath} so they can carry an explicit root selector.
15
61
  *
16
62
  * @returns the resolved absolute path (lexical, not realpath'd).
17
63
  * @throws {PathEscapeError} if the path escapes the root.
18
64
  */
19
65
  export declare function resolveInRoot(ctx: ToolContext, p: string): Promise<string>;
66
+ /**
67
+ * True when a glob pattern could walk outside its root — an absolute pattern or one
68
+ * containing a `..` segment. The ONE implementation of "does this pattern escape",
69
+ * shared by `glob` and `grep_files` so the two walk-rooted tools cannot diverge
70
+ * (closing G1/G2). Path *arguments* go through {@link resolveToolPath}; glob
71
+ * *patterns* are not resolvable paths, so this predicate guards the walk instead.
72
+ */
73
+ export declare function isEscapingPattern(pattern: string): boolean;
74
+ /**
75
+ * The separator between a root's name and a path within it (`‹root› ▸ rel`) — one
76
+ * constant so every no-path tool that fans across roots renders the same label.
77
+ */
78
+ export declare const ROOT_LABEL_SEP = " \u25B8 ";
79
+ /**
80
+ * Prefix a root-relative path with its root name — but ONLY in a genuine
81
+ * multi-root session. In single-root the label is dropped so output stays
82
+ * byte-identical with the pre-C.26 tools. The `root`/`rel` pair always comes from
83
+ * the same fan iteration that produced the bytes, so a rendered label can never
84
+ * point at a different root than the one that was walked (the honesty pin).
85
+ */
86
+ export declare function labelPath(root: DeclaredRoot, rel: string, isMultiRoot: boolean): string;
87
+ /** As {@link labelPath} but from a bare root name (for hits that carry only a name). */
88
+ export declare function labelName(rootName: string, rel: string): string;
89
+ /**
90
+ * The Funnel-B selector for **no-path** tools (`glob`, `grep_files`, `list_files`,
91
+ * `git_status`, `search_codebase`) — the counterpart to {@link resolveToolPath}.
92
+ * A no-path tool has no path to confine; its boundary is *which root's `absPath`*
93
+ * it threads as the walk-start / index-key / spawn-cwd. This returns that root
94
+ * set, and the tool commits to exactly one root per read by iterating it:
95
+ *
96
+ * - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
97
+ * - single-root session → the primary root (so output is byte-identical);
98
+ * - multi-root, unscoped → **fan** every declared root.
99
+ *
100
+ * The fan is N independent single-root reads (one `root.absPath` each), never one
101
+ * walk over a union — so each read is Funnel-B-confined and the cross-root merge
102
+ * only ever happens on already-attributed *results*, never on the filesystem.
103
+ * Reads leave `requireExplicit` false (that gate is for mutations, ⚖︎#3).
104
+ *
105
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if a named root is not declared.
106
+ */
107
+ export declare function resolveReadRoots(ctx: ToolContext, ref?: {
108
+ root?: string;
109
+ }): readonly DeclaredRoot[];
110
+ /**
111
+ * The Funnel-B selector for a **no-path, whole-root MUTATION** — today
112
+ * `create_pull_request` (and the future commit/branch/push tools). Unlike
113
+ * {@link resolveReadRoots} it commits to exactly ONE root and, in a multi-root
114
+ * session, refuses to default (⚖︎#11, JC-1 take B):
115
+ *
116
+ * - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
117
+ * - single-root session → the primary root (byte-identical to pre-C.26 — a
118
+ * single root is unambiguous, so no `root` is required);
119
+ * - multi-root, unscoped → refuse `ROOT_AMBIGUOUS` (a defaulted PR is the
120
+ * "opened the wrong repo" accident; ambiguity only exists when N > 1).
121
+ *
122
+ * `what` names the action in the refusal message (e.g. `"a pull request"`). This
123
+ * throws BEFORE the caller runs any git, so a no-root multi-root call performs
124
+ * zero git spawns.
125
+ *
126
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN (unknown name) / CRUXY_E_ROOT_AMBIGUOUS
127
+ * (multi-root, no root).
128
+ */
129
+ export declare function resolveMutationRoot(ctx: ToolContext, ref: {
130
+ root?: string;
131
+ }, what: string): DeclaredRoot;
@@ -1,20 +1,174 @@
1
- import { confineToRoot, PathEscapeError } from "../../workspace/index.js";
1
+ import path from "node:path";
2
+ import { multirootWriteDeferred, rootAmbiguous } from "../../errors/index.js";
3
+ import { confineToRoot, PathEscapeError, selectRoot, } from "../../workspace/index.js";
2
4
  /**
3
- * Path confinement for file tools. The confinement kernel now lives in
4
- * `src/workspace` so multi-root (C.26) and single-root callers share ONE
5
- * implementation. This module keeps the single-root `resolveInRoot` entry point
6
- * (and re-exports {@link PathEscapeError}) so existing call sites are unchanged:
7
- * they confine to `ctx.cwd`, which is the workspace's primary root.
5
+ * Path confinement for file tools (C.26). The confinement kernel itself lives in
6
+ * `src/workspace` ({@link confineToRoot}); this module is the tool-facing funnel
7
+ * that every path-taking tool calls instead of touching the filesystem directly.
8
+ * Keeping ONE resolver here is what makes Funnel A ("a path tool cannot reach fs
9
+ * outside the selected root") a structural property rather than a per-tool habit.
8
10
  */
9
11
  export { PathEscapeError };
10
12
  /**
11
- * Resolve a tool-supplied path against the project root (`ctx.cwd`) and prove it
12
- * stays insidethe single-root funnel. Delegates to {@link confineToRoot}; see
13
- * there for the 2-layer (lexical + symlink) confinement logic.
13
+ * The workspace this context acts in. `ctx.workspace` is a required field (C.26
14
+ * Step 6), so this is a plain accessor there is no single-root fallback to
15
+ * synthesize, because no code path can reach a path-taking tool without a
16
+ * workspace (the type forbids the omission; the `no-split-brain` guard forbids a
17
+ * mid-subsystem synthesis). Kept as the one named seam every funnel calls.
18
+ */
19
+ export function contextWorkspace(ctx) {
20
+ return ctx.workspace;
21
+ }
22
+ /**
23
+ * Resolve a tool-supplied path against the workspace and prove it stays inside the
24
+ * one root it selects — the single funnel for every path-taking file tool.
25
+ *
26
+ * Single-root sessions short-circuit straight to `confineToRoot(primary, path)`,
27
+ * which is exactly the legacy `resolveInRoot` behaviour (same result, same
28
+ * error type + message for every case: relative, absolute-inside, absolute-outside,
29
+ * outward symlink). Multi-root sessions run the two-step select-then-confine:
30
+ * {@link selectRoot} commits to exactly one declared root FIRST, then
31
+ * {@link confineToRoot} validates against only that root — so a `../otherRoot/x`
32
+ * that lands in a sibling is refused as an ordinary `PATH_ESCAPE` (R2), never
33
+ * rebound to the sibling.
34
+ *
35
+ * `deferNonPrimaryWrite` (set by the mutating file tools) refuses a write whose
36
+ * selected root is NOT the primary in a multi-root session, with
37
+ * `CRUXY_E_MULTIROOT_WRITE_DEFERRED` — UNLESS `ctx.checkpointsActive` is true.
38
+ * That flag is the C.26 step-3 coupling: it is set exactly when a per-root
39
+ * checkpoint gate is wired, which captures the write (snapshots its root) before
40
+ * it reaches disk — so the refusal lifts precisely when the write becomes
41
+ * rollback-able, never before (not step-wide, not lift-then-verify). With
42
+ * checkpoints disabled the flag is false, so a non-primary write is still refused
43
+ * rather than left un-restorable (⚖︎JC-γ: the opt-out covers the user's own cwd,
44
+ * not every declared sibling). It is a no-op in single-root (there is only the
45
+ * primary) and for reads (which never set it). The refusal is raised BEFORE any
46
+ * filesystem access.
47
+ *
48
+ * @throws {PathEscapeError} if the path escapes the selected root.
49
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN / CRUXY_E_ROOT_AMBIGUOUS on bad selection,
50
+ * or CRUXY_E_MULTIROOT_WRITE_DEFERRED on a non-primary write while deferred.
51
+ */
52
+ export async function resolveToolPath(ctx, ref, opts = {}) {
53
+ const ws = contextWorkspace(ctx);
54
+ // Single-root: byte-identical with the legacy single-root funnel. Skipping
55
+ // selectRoot here also means a bare path segment that happens to equal the sole
56
+ // root's name is never mis-stripped as a root prefix. (A single-root session has
57
+ // only the primary, so the non-primary-write guard can never fire here.)
58
+ if (!ws.isMultiRoot) {
59
+ const root = ws.primary();
60
+ return { root, abs: await confineToRoot(root.absPath, ref.path) };
61
+ }
62
+ const selected = selectRoot(ws, ref, opts);
63
+ if (opts.deferNonPrimaryWrite &&
64
+ selected.root.name !== ws.primary().name &&
65
+ !ctx.checkpointsActive) {
66
+ // Refuse BEFORE confineToRoot touches the filesystem — no partial write. The
67
+ // lift is conditioned on the per-root checkpoint gate being wired
68
+ // (ctx.checkpointsActive): permitting the write and capturing it are the same
69
+ // decision, so a permitted non-primary write is always checkpointed.
70
+ throw multirootWriteDeferred(selected.root.name, ws.primary().name);
71
+ }
72
+ return {
73
+ root: selected.root,
74
+ abs: await confineToRoot(selected.root.absPath, selected.relPath),
75
+ };
76
+ }
77
+ /**
78
+ * Legacy single-root entry point, retained as a thin shim over
79
+ * {@link resolveToolPath} so callers that pass only a bare path (e.g. the LSP
80
+ * tools) keep working and stay byte-identical. New path tools should call
81
+ * {@link resolveToolPath} so they can carry an explicit root selector.
14
82
  *
15
83
  * @returns the resolved absolute path (lexical, not realpath'd).
16
84
  * @throws {PathEscapeError} if the path escapes the root.
17
85
  */
18
86
  export async function resolveInRoot(ctx, p) {
19
- return confineToRoot(ctx.cwd, p);
87
+ return (await resolveToolPath(ctx, { path: p })).abs;
88
+ }
89
+ /**
90
+ * True when a glob pattern could walk outside its root — an absolute pattern or one
91
+ * containing a `..` segment. The ONE implementation of "does this pattern escape",
92
+ * shared by `glob` and `grep_files` so the two walk-rooted tools cannot diverge
93
+ * (closing G1/G2). Path *arguments* go through {@link resolveToolPath}; glob
94
+ * *patterns* are not resolvable paths, so this predicate guards the walk instead.
95
+ */
96
+ export function isEscapingPattern(pattern) {
97
+ return path.isAbsolute(pattern) || pattern.split(/[/\\]/).includes("..");
98
+ }
99
+ /**
100
+ * The separator between a root's name and a path within it (`‹root› ▸ rel`) — one
101
+ * constant so every no-path tool that fans across roots renders the same label.
102
+ */
103
+ export const ROOT_LABEL_SEP = " ▸ ";
104
+ /**
105
+ * Prefix a root-relative path with its root name — but ONLY in a genuine
106
+ * multi-root session. In single-root the label is dropped so output stays
107
+ * byte-identical with the pre-C.26 tools. The `root`/`rel` pair always comes from
108
+ * the same fan iteration that produced the bytes, so a rendered label can never
109
+ * point at a different root than the one that was walked (the honesty pin).
110
+ */
111
+ export function labelPath(root, rel, isMultiRoot) {
112
+ return isMultiRoot ? labelName(root.name, rel) : rel;
113
+ }
114
+ /** As {@link labelPath} but from a bare root name (for hits that carry only a name). */
115
+ export function labelName(rootName, rel) {
116
+ return `${rootName}${ROOT_LABEL_SEP}${rel}`;
117
+ }
118
+ /**
119
+ * The Funnel-B selector for **no-path** tools (`glob`, `grep_files`, `list_files`,
120
+ * `git_status`, `search_codebase`) — the counterpart to {@link resolveToolPath}.
121
+ * A no-path tool has no path to confine; its boundary is *which root's `absPath`*
122
+ * it threads as the walk-start / index-key / spawn-cwd. This returns that root
123
+ * set, and the tool commits to exactly one root per read by iterating it:
124
+ *
125
+ * - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
126
+ * - single-root session → the primary root (so output is byte-identical);
127
+ * - multi-root, unscoped → **fan** every declared root.
128
+ *
129
+ * The fan is N independent single-root reads (one `root.absPath` each), never one
130
+ * walk over a union — so each read is Funnel-B-confined and the cross-root merge
131
+ * only ever happens on already-attributed *results*, never on the filesystem.
132
+ * Reads leave `requireExplicit` false (that gate is for mutations, ⚖︎#3).
133
+ *
134
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN if a named root is not declared.
135
+ */
136
+ export function resolveReadRoots(ctx, ref = {}) {
137
+ const ws = contextWorkspace(ctx);
138
+ if (ref.root !== undefined) {
139
+ return [ws.rootByName(ref.root)];
140
+ }
141
+ if (!ws.isMultiRoot) {
142
+ return [ws.primary()];
143
+ }
144
+ return ws.roots();
145
+ }
146
+ /**
147
+ * The Funnel-B selector for a **no-path, whole-root MUTATION** — today
148
+ * `create_pull_request` (and the future commit/branch/push tools). Unlike
149
+ * {@link resolveReadRoots} it commits to exactly ONE root and, in a multi-root
150
+ * session, refuses to default (⚖︎#11, JC-1 take B):
151
+ *
152
+ * - an explicit `root` name → that one declared root (fail-loud on unknown, R1);
153
+ * - single-root session → the primary root (byte-identical to pre-C.26 — a
154
+ * single root is unambiguous, so no `root` is required);
155
+ * - multi-root, unscoped → refuse `ROOT_AMBIGUOUS` (a defaulted PR is the
156
+ * "opened the wrong repo" accident; ambiguity only exists when N > 1).
157
+ *
158
+ * `what` names the action in the refusal message (e.g. `"a pull request"`). This
159
+ * throws BEFORE the caller runs any git, so a no-root multi-root call performs
160
+ * zero git spawns.
161
+ *
162
+ * @throws {CruxyError} CRUXY_E_ROOT_UNKNOWN (unknown name) / CRUXY_E_ROOT_AMBIGUOUS
163
+ * (multi-root, no root).
164
+ */
165
+ export function resolveMutationRoot(ctx, ref, what) {
166
+ const ws = contextWorkspace(ctx);
167
+ if (ref.root !== undefined) {
168
+ return ws.rootByName(ref.root);
169
+ }
170
+ if (!ws.isMultiRoot) {
171
+ return ws.primary();
172
+ }
173
+ throw rootAmbiguous(what, ws.roots().map((r) => r.name));
20
174
  }
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
- import { resolveInRoot } from "./paths.js";
3
+ import { resolveToolPath } from "./paths.js";
4
4
  /** Files larger than this are truncated rather than dumped in full. */
5
5
  const MAX_BYTES = 256 * 1024;
6
6
  /**
@@ -16,7 +16,7 @@ export const readFileTool = {
16
16
  }),
17
17
  async execute(input, ctx) {
18
18
  try {
19
- const abs = await resolveInRoot(ctx, input.path);
19
+ const { abs } = await resolveToolPath(ctx, { path: input.path });
20
20
  const stat = await fs.stat(abs);
21
21
  if (stat.isDirectory()) {
22
22
  return {
@@ -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 { resolveInRoot } from "./paths.js";
4
+ import { resolveToolPath } from "./paths.js";
5
5
  /** How many leading lines of new content the approval preview shows. */
6
6
  const PREVIEW_LINES = 20;
7
7
  /**
@@ -20,7 +20,7 @@ export const writeFileTool = {
20
20
  async execute(input, ctx) {
21
21
  let abs;
22
22
  try {
23
- abs = await resolveInRoot(ctx, input.path);
23
+ ({ abs } = await resolveToolPath(ctx, { path: input.path }, { deferNonPrimaryWrite: true }));
24
24
  }
25
25
  catch (err) {
26
26
  return { ok: false, error: err.message };
@@ -4,5 +4,12 @@ import type { Tool } from "./types.js";
4
4
  * Report the current git branch and working-tree status (`git status
5
5
  * --porcelain`) for the project root. Read-only — no approval, like read_file
6
6
  * and glob.
7
+ *
8
+ * Multi-repo (C.26, Funnel B): with more than one declared root, it fans every
9
+ * root — each `getGitStatus(root.absPath)` spawns git with that root as its cwd,
10
+ * so a section reports exactly the working tree its label names. A non-git root
11
+ * is NAMED (not fatal), and a `root` argument scopes to one root.
7
12
  */
8
- export declare const gitStatusTool: Tool<z.ZodObject<Record<string, never>>>;
13
+ export declare const gitStatusTool: Tool<z.ZodObject<{
14
+ root: z.ZodOptional<z.ZodString>;
15
+ }>>;
@@ -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
- async execute(_input, ctx) {
13
- const info = getGitStatus(ctx.cwd);
14
- if (info === null) {
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: false,
17
- error: "not a git repository (or git is unavailable)",
43
+ ok: true,
44
+ output: `branch ${info.branch}\n${body === "" ? "working tree clean" : body}`,
18
45
  };
19
46
  }
20
- const body = info.status.trim();
21
- return {
22
- ok: true,
23
- output: `branch ${info.branch}\n${body === "" ? "working tree clean" : body}`,
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
  };