@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
@@ -1,7 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import type { Tool } from "./types.js";
3
3
  /**
4
- * Proof-of-concept tool: list the entries of `ctx.cwd`, marking each as a
5
- * directory or a file. Takes no arguments.
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<Record<string, never>>>;
11
+ export declare const listFilesTool: Tool<z.ZodObject<{
12
+ root: z.ZodOptional<z.ZodString>;
13
+ }>>;
@@ -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
- * Proof-of-concept tool: list the entries of `ctx.cwd`, marking each as a
5
- * directory or a file. Takes no arguments.
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
- async execute(_input, ctx) {
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
- const entries = await fs.readdir(ctx.cwd, { withFileTypes: true });
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
- const service = await getIndexService(ctx.cwd, ctx.config, ctx.logger);
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 header = `${hit.path}:${hit.startLine}-${hit.endLine} (score ${hit.score.toFixed(3)})`;
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
+ }
@@ -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
- const decision = await ctx.requestApproval({ kind: "shell", command });
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
  }
@@ -67,12 +74,17 @@ function runBounded(command, ctx) {
67
74
  };
68
75
  child.stdout?.on("data", capture);
69
76
  child.stderr?.on("data", capture);
70
- // A single guard so the timeout-kill and the natural close can't both fire.
77
+ // A single guard so the timeout-kill, an external abort, and the natural
78
+ // close can't all fire. `cleanup` deregisters the abort listener on every
79
+ // settle path so a resolved command never holds a dangling subscription.
71
80
  let settled = false;
81
+ const abortSignal = ctx.signal;
82
+ const cleanup = () => abortSignal?.removeEventListener("abort", onAbort);
72
83
  const timer = setTimeout(() => {
73
84
  if (settled)
74
85
  return;
75
86
  settled = true;
87
+ cleanup();
76
88
  killTree(child.pid);
77
89
  resolve({
78
90
  timedOut: true,
@@ -82,11 +94,37 @@ function runBounded(command, ctx) {
82
94
  truncated,
83
95
  });
84
96
  }, timeoutMs);
97
+ // External cancellation (C.33): a cancelled sibling's in-flight command is
98
+ // kill-tree'd here instead of orphaned. Reports as a `SIGKILL` termination
99
+ // (not a timeout) so the caller can tell cancel apart from a real timeout.
100
+ function onAbort() {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ clearTimeout(timer);
105
+ cleanup();
106
+ killTree(child.pid);
107
+ resolve({
108
+ timedOut: false,
109
+ exitCode: null,
110
+ signal: "SIGKILL",
111
+ output: Buffer.concat(chunks).toString("utf8"),
112
+ truncated,
113
+ });
114
+ }
115
+ if (abortSignal?.aborted) {
116
+ // Already cancelled before the child got going — reap it immediately.
117
+ queueMicrotask(onAbort);
118
+ }
119
+ else {
120
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
121
+ }
85
122
  child.on("error", (err) => {
86
123
  if (settled)
87
124
  return;
88
125
  settled = true;
89
126
  clearTimeout(timer);
127
+ cleanup();
90
128
  resolve({
91
129
  timedOut: false,
92
130
  exitCode: null,
@@ -96,15 +134,16 @@ function runBounded(command, ctx) {
96
134
  spawnError: err.message,
97
135
  });
98
136
  });
99
- child.on("close", (code, signal) => {
137
+ child.on("close", (code, closeSignal) => {
100
138
  if (settled)
101
139
  return;
102
140
  settled = true;
103
141
  clearTimeout(timer);
142
+ cleanup();
104
143
  resolve({
105
144
  timedOut: false,
106
145
  exitCode: code,
107
- signal: signal ?? null,
146
+ signal: closeSignal ?? null,
108
147
  output: Buffer.concat(chunks).toString("utf8"),
109
148
  truncated,
110
149
  });
@@ -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,18 @@ 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. **Required** (C.26 Step 6): a
170
+ * construction site that fails to thread it is a *compile error*, not a runtime
171
+ * primary-default — closing the omission hole the old optional field left. The
172
+ * runtime always sets it; a single-root caller builds one with `sessionWorkspace`.
173
+ * (The type catches *omission*; it cannot catch a *fabricated* workspace threaded
174
+ * through — the `no-split-brain` grep guard covers that. See its header.) `cwd`
175
+ * remains equal to `workspace.primary().absPath`.
176
+ */
177
+ workspace: Workspace;
126
178
  /** Fully-resolved CLI configuration. */
127
179
  config: CruxyConfig;
128
180
  /** Shared leveled logger (diagnostics to stderr, `print` to stdout). */
@@ -137,6 +189,18 @@ export interface ToolContext {
137
189
  * tools never call this.
138
190
  */
139
191
  requestApproval(action: ApproveAction): Promise<ApprovalDecision>;
192
+ /**
193
+ * Whether per-root checkpointing is active for this session (C.26 step 3). It is
194
+ * the single fact that lifts the JC-1 non-primary-write refusal: a write to a
195
+ * non-primary root is permitted **iff** this is true, because a true value means
196
+ * a per-root checkpoint gate is wired that captures the write (get-or-creates the
197
+ * root's service and snapshots it) before it reaches disk. False/absent → a
198
+ * non-primary write is still refused with `CRUXY_E_MULTIROOT_WRITE_DEFERRED`, so
199
+ * a checkpoints-disabled session never widens un-restorable writes to siblings.
200
+ * Set together with `requestApproval` (the gate), so permit and capture are one
201
+ * decision — never lift-then-verify.
202
+ */
203
+ checkpointsActive?: boolean;
140
204
  /**
141
205
  * Isolation substrate for the shell + test tools (C.16). Present ONLY when
142
206
  * the sandbox is enabled; when set, `run_command`/`run_tests` execute the
@@ -146,6 +210,15 @@ export interface ToolContext {
146
210
  * populated). Absent → host execution, unchanged.
147
211
  */
148
212
  sandbox?: SandboxService;
213
+ /**
214
+ * Cooperative cancellation for in-flight tool work (C.33). Set on a subagent's
215
+ * ctx when it runs inside a parallel fan-out: when a fatal sibling failure or
216
+ * Ctrl-C aborts the batch, this signal fires, and a long-running `run_command`
217
+ * kills its whole process tree instead of leaving it orphaned — the C.12/C.16
218
+ * kill-tree discipline extended to N concurrent children. Absent → no external
219
+ * cancellation (the tool's own timeout still applies), unchanged behaviour.
220
+ */
221
+ signal?: AbortSignal;
149
222
  }
150
223
  /**
151
224
  * The one interface every tool implements. `parameters` is a zod schema; it both
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);
@@ -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. */
@@ -8,7 +8,12 @@ export class GitHubProvider {
8
8
  this.token = opts.token;
9
9
  this.fetchImpl = opts.fetchImpl ?? fetch;
10
10
  }
11
- async getRepoInfo(cwd) {
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 };
@@ -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;