@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.
Files changed (80) hide show
  1. package/dist/approval/classify.js +25 -3
  2. package/dist/approval/policy.d.ts +6 -0
  3. package/dist/approval/policy.js +15 -3
  4. package/dist/approval/prompt.js +11 -0
  5. package/dist/approval/types.d.ts +8 -1
  6. package/dist/checkpoint/gate.d.ts +65 -0
  7. package/dist/checkpoint/gate.js +86 -0
  8. package/dist/checkpoint/index.d.ts +3 -0
  9. package/dist/checkpoint/index.js +3 -0
  10. package/dist/checkpoint/set-rollback.d.ts +51 -0
  11. package/dist/checkpoint/set-rollback.js +74 -0
  12. package/dist/checkpoint/set.d.ts +44 -0
  13. package/dist/checkpoint/set.js +142 -0
  14. package/dist/checkpoint/types.d.ts +47 -0
  15. package/dist/cli/commands/rollback.d.ts +11 -6
  16. package/dist/cli/commands/rollback.js +93 -33
  17. package/dist/cli/commands/run.js +59 -10
  18. package/dist/cli/onboard.js +4 -1
  19. package/dist/cli/repl.d.ts +2 -2
  20. package/dist/cli/session-factory.d.ts +4 -3
  21. package/dist/cli/session-factory.js +98 -12
  22. package/dist/errors/constructors.d.ts +65 -0
  23. package/dist/errors/constructors.js +168 -0
  24. package/dist/errors/types.d.ts +46 -0
  25. package/dist/errors/types.js +64 -0
  26. package/dist/indexing/retriever.d.ts +29 -0
  27. package/dist/indexing/retriever.js +26 -0
  28. package/dist/indexing/service.js +3 -1
  29. package/dist/indexing/types.d.ts +7 -0
  30. package/dist/lsp/tools/common.d.ts +34 -7
  31. package/dist/lsp/tools/common.js +33 -11
  32. package/dist/lsp/tools/find-definition.js +2 -2
  33. package/dist/lsp/tools/find-references.js +10 -4
  34. package/dist/lsp/tools/get-diagnostics.js +6 -4
  35. package/dist/render/diff.js +42 -5
  36. package/dist/sandbox/docker-runtime.js +4 -1
  37. package/dist/sandbox/policy.d.ts +12 -3
  38. package/dist/sandbox/policy.js +17 -3
  39. package/dist/sandbox/types.d.ts +10 -1
  40. package/dist/subagent/orchestrator.d.ts +15 -0
  41. package/dist/subagent/orchestrator.js +2 -0
  42. package/dist/testing/run-tests-tool.js +3 -0
  43. package/dist/tools/create-pull-request.d.ts +3 -0
  44. package/dist/tools/create-pull-request.js +50 -4
  45. package/dist/tools/file/apply-patch.js +2 -2
  46. package/dist/tools/file/edit-file.js +2 -2
  47. package/dist/tools/file/glob.d.ts +9 -2
  48. package/dist/tools/file/glob.js +73 -19
  49. package/dist/tools/file/grep-files.d.ts +12 -2
  50. package/dist/tools/file/grep-files.js +113 -38
  51. package/dist/tools/file/paths.d.ts +123 -17
  52. package/dist/tools/file/paths.js +158 -50
  53. package/dist/tools/file/read-file.js +2 -2
  54. package/dist/tools/file/write-file.js +2 -2
  55. package/dist/tools/git-status.d.ts +8 -1
  56. package/dist/tools/git-status.js +43 -11
  57. package/dist/tools/list-files.d.ts +9 -3
  58. package/dist/tools/list-files.js +48 -13
  59. package/dist/tools/search-codebase.d.ts +10 -0
  60. package/dist/tools/search-codebase.js +117 -14
  61. package/dist/tools/shell/exec.js +8 -1
  62. package/dist/tools/types.d.ts +63 -1
  63. package/dist/vcs/git.d.ts +8 -0
  64. package/dist/vcs/git.js +14 -0
  65. package/dist/vcs/github.d.ts +7 -1
  66. package/dist/vcs/github.js +10 -1
  67. package/dist/vcs/service.d.ts +8 -0
  68. package/dist/vcs/service.js +33 -1
  69. package/dist/vcs/types.d.ts +18 -2
  70. package/dist/workspace/index.d.ts +5 -0
  71. package/dist/workspace/index.js +3 -0
  72. package/dist/workspace/resolve.d.ts +54 -0
  73. package/dist/workspace/resolve.js +96 -0
  74. package/dist/workspace/select.d.ts +41 -0
  75. package/dist/workspace/select.js +44 -0
  76. package/dist/workspace/types.d.ts +30 -0
  77. package/dist/workspace/types.js +15 -0
  78. package/dist/workspace/workspace.d.ts +56 -0
  79. package/dist/workspace/workspace.js +180 -0
  80. package/package.json +1 -1
@@ -115,3 +115,50 @@ export interface RollbackApplied {
115
115
  reverted: number;
116
116
  deleted: number;
117
117
  }
118
+ /**
119
+ * One touched root inside a {@link CheckpointSet}: the declared root's name (for
120
+ * attribution in the preview), its absolute path, and the id of the per-root
121
+ * checkpoint that protects it. There is exactly ONE member per root the run
122
+ * mutated — an untouched root has no member and is never opened at rollback.
123
+ */
124
+ export interface CheckpointSetMember {
125
+ /** Declared workspace-root name (shown in the grouped preview). */
126
+ rootName: string;
127
+ /** Absolute path of the root (where its `.cruxy/checkpoints/` live). */
128
+ rootPath: string;
129
+ /** The checkpoint id within that root. */
130
+ checkpointId: string;
131
+ }
132
+ /**
133
+ * A run's rollback unit across N repos (C.26). `cruxy rollback` restores **all
134
+ * members** of a set as one gated operation — exactly the roots the run touched,
135
+ * no more (untouched roots are absent) and no less (a touched root missing its
136
+ * member is a loud `CRUXY_E_CHECKPOINT_SET_INCOMPLETE`, never a silent partial).
137
+ *
138
+ * The set manifest lives under the PRIMARY root's `.cruxy/checkpoints/sets/`, so
139
+ * it travels with the workspace and survives `rm -rf ~/.cruxy`. The member
140
+ * checkpoints themselves live in each root, exactly as in the single-root case.
141
+ */
142
+ export interface CheckpointSet {
143
+ /** Stable run id, e.g. `run-20260708T031500-a4f2`. */
144
+ runId: string;
145
+ /** ISO-8601 creation time (of the set, i.e. the run's first mutation). */
146
+ createdAt: string;
147
+ /** One line describing the run this set protects. */
148
+ runSummary: string;
149
+ /** One entry per TOUCHED root. */
150
+ members: CheckpointSetMember[];
151
+ }
152
+ /**
153
+ * The outcome of a successful all-roots rollback: which roots were restored and
154
+ * what each did. On a mid-apply failure this is NOT returned — a
155
+ * `CRUXY_E_CHECKPOINT_SET_PARTIAL` is thrown instead, carrying the restored-vs-not
156
+ * split (R3), so a partial can never read as success.
157
+ */
158
+ export interface SetRollbackApplied {
159
+ runId: string;
160
+ /** Root names restored, in apply order. */
161
+ restored: string[];
162
+ /** Per-root counts, keyed by root name. */
163
+ perRoot: Record<string, RollbackApplied>;
164
+ }
@@ -1,10 +1,15 @@
1
1
  import { Command } from "commander";
2
2
  /**
3
- * `cruxy rollback [id]` (C.32) — restore the working tree to a checkpoint,
4
- * undoing everything an agent run changed (creates, edits, deletes) in one
5
- * operation. Destructive by definition, so it is preview-first and gated
6
- * through U.3 at the destructive tier, ungrantable; non-TTY is refused with a
7
- * coded error before anything is computed. Out of scope, stated in the
8
- * preview: commits, pushes, and PRs made during the run are not undone.
3
+ * `cruxy rollback [id]` (C.32/C.26) — restore the working tree to a run's
4
+ * checkpoint(s), undoing everything an agent run changed in one operation.
5
+ * Destructive by definition, so it is preview-first and gated through U.3 at the
6
+ * destructive tier, ungrantable; non-TTY is refused with a coded error before
7
+ * anything is computed. Out of scope, stated in the preview: commits, pushes, and
8
+ * PRs made during the run are not undone.
9
+ *
10
+ * Routing:
11
+ * • an explicit `<id>` → single-root rollback of that checkpoint (escape hatch);
12
+ * • no id, a set manifest exists → set-based rollback of the latest run;
13
+ * • no id, no set manifest → JC-F fallback to legacy single-root, logged.
9
14
  */
10
15
  export declare function rollbackCommand(): Command;
@@ -1,7 +1,8 @@
1
+ import path from "node:path";
1
2
  import { Command } from "commander";
2
3
  import { themeForColor } from "../../theme/index.js";
3
4
  import { loadConfig } from "../../config/index.js";
4
- import { CheckpointService } from "../../checkpoint/index.js";
5
+ import { CheckpointService, applySet, buildSetPreview, listSets, setIsNoop, validateSet, } from "../../checkpoint/index.js";
5
6
  import { ApprovalService, defaultPromptIO } from "../../approval/index.js";
6
7
  import { fuzzyFind, selectList } from "../../components/index.js";
7
8
  import { rollbackApprovalRequired, shouldUseColor, } from "../../errors/index.js";
@@ -41,17 +42,88 @@ async function pickCheckpoint(service) {
41
42
  return result.kind === "selected" ? result.value : null;
42
43
  }
43
44
  /**
44
- * `cruxy rollback [id]` (C.32) restore the working tree to a checkpoint,
45
- * undoing everything an agent run changed (creates, edits, deletes) in one
46
- * operation. Destructive by definition, so it is preview-first and gated
47
- * through U.3 at the destructive tier, ungrantable; non-TTY is refused with a
48
- * coded error before anything is computed. Out of scope, stated in the
49
- * preview: commits, pushes, and PRs made during the run are not undone.
45
+ * Legacy single-root rollback (C.32): restore one root's checkpoint. Reached for
46
+ * an explicit `cruxy rollback <id>` (the per-member escape hatch — including when
47
+ * the primary root was removed mid-session and its set index is gone, ⚖︎#7) and as
48
+ * the JC-F back-compat fallback for a pre-set-manifest run.
49
+ */
50
+ async function legacyRollback(root, config, approval, interactive, id, t) {
51
+ const service = new CheckpointService({ root, config });
52
+ if (id === undefined) {
53
+ const picked = await pickCheckpoint(service);
54
+ if (picked === null) {
55
+ logger.print(t.muted("rollback cancelled — nothing was changed"));
56
+ return;
57
+ }
58
+ id = picked?.id;
59
+ }
60
+ const result = await service.rollback(id, {
61
+ requestApproval: (action) => approval.requestApproval(action),
62
+ interactive,
63
+ });
64
+ if (result.kind === "noop") {
65
+ logger.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
66
+ return;
67
+ }
68
+ if (result.kind === "rejected") {
69
+ logger.print(t.muted("rollback declined — nothing was changed"));
70
+ return;
71
+ }
72
+ const { recreated, reverted, deleted } = result.applied;
73
+ logger.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
74
+ `${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
75
+ logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
76
+ }
77
+ /**
78
+ * Set-based rollback (C.26): restore every touched root of the latest run as one
79
+ * gated operation. Validate-ALL members up front (missing/corrupt →
80
+ * `CHECKPOINT_SET_INCOMPLETE`, nothing applied), one combined per-root preview and
81
+ * one U.3 approval, then a sequential apply that stops on first failure
82
+ * (`CHECKPOINT_SET_PARTIAL`, R3). Both coded errors propagate to the boundary.
83
+ */
84
+ async function setRollback(primaryRoot, config, approval, t) {
85
+ const sets = await listSets(primaryRoot); // newest first
86
+ const set = sets[0];
87
+ // Validate-all BEFORE any apply — a missing/corrupt member throws here.
88
+ const members = await validateSet(set, config);
89
+ if (setIsNoop(members)) {
90
+ logger.print(t.muted(`working tree already matches run ${set.runId} — nothing to roll back`));
91
+ return;
92
+ }
93
+ const decision = await approval.requestApproval({
94
+ kind: "rollback",
95
+ preview: buildSetPreview(set, members),
96
+ });
97
+ if (!decision.allow) {
98
+ logger.print(t.muted("rollback declined — nothing was changed"));
99
+ return;
100
+ }
101
+ const applied = await applySet(set, members);
102
+ const parts = applied.restored.map((name) => {
103
+ const counts = applied.perRoot[name];
104
+ return `${name} (${counts.reverted} reverted, ${counts.recreated} recreated, ${counts.deleted} deleted)`;
105
+ });
106
+ logger.print(`${t.success(t.glyph.success)} restored run ${t.accent(set.runId)} across ` +
107
+ `${applied.restored.length} root${applied.restored.length === 1 ? "" : "s"} — ${parts.join("; ")}`);
108
+ logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
109
+ }
110
+ /**
111
+ * `cruxy rollback [id]` (C.32/C.26) — restore the working tree to a run's
112
+ * checkpoint(s), undoing everything an agent run changed in one operation.
113
+ * Destructive by definition, so it is preview-first and gated through U.3 at the
114
+ * destructive tier, ungrantable; non-TTY is refused with a coded error before
115
+ * anything is computed. Out of scope, stated in the preview: commits, pushes, and
116
+ * PRs made during the run are not undone.
117
+ *
118
+ * Routing:
119
+ * • an explicit `<id>` → single-root rollback of that checkpoint (escape hatch);
120
+ * • no id, a set manifest exists → set-based rollback of the latest run;
121
+ * • no id, no set manifest → JC-F fallback to legacy single-root, logged.
50
122
  */
51
123
  export function rollbackCommand() {
52
124
  return new Command("rollback")
53
125
  .description("restore the working tree to a checkpoint, undoing a run's file changes")
54
- .argument("[id]", "checkpoint id (defaults to the most recent)")
126
+ .argument("[id]", "checkpoint id (defaults to the most recent run)")
55
127
  .action(async (id) => {
56
128
  const interactive = Boolean(process.stdin.isTTY);
57
129
  // Refuse before touching anything: rollback is a deliberate, interactive
@@ -60,38 +132,26 @@ export function rollbackCommand() {
60
132
  throw rollbackApprovalRequired();
61
133
  const t = themeForColor(shouldUseColor(process.stdout));
62
134
  const { config } = loadConfig();
63
- const root = process.cwd();
64
- const service = new CheckpointService({ root, config });
135
+ const primaryRoot = process.cwd();
65
136
  const approval = new ApprovalService({
66
- cwd: root,
137
+ cwd: primaryRoot,
67
138
  interactive,
68
139
  io: defaultPromptIO(shouldUseColor()),
69
140
  });
70
- // No id given pick one interactively (U.7). Enter-once still restores
71
- // the newest, exactly as before the picker existed.
72
- if (id === undefined) {
73
- const picked = await pickCheckpoint(service);
74
- if (picked === null) {
75
- logger.print(t.muted("rollback cancelled — nothing was changed"));
76
- return;
77
- }
78
- id = picked?.id;
79
- }
80
- const result = await service.rollback(id, {
81
- requestApproval: (action) => approval.requestApproval(action),
82
- interactive,
83
- });
84
- if (result.kind === "noop") {
85
- logger.print(t.muted(`working tree already matches checkpoint ${result.checkpoint.id} — nothing to roll back`));
141
+ // Explicit id → single-root path (the per-member escape hatch; also the
142
+ // recovery route if the primary root and its set index — was removed).
143
+ if (id !== undefined) {
144
+ await legacyRollback(primaryRoot, config, approval, interactive, id, t);
86
145
  return;
87
146
  }
88
- if (result.kind === "rejected") {
89
- logger.print(t.muted("rollback declined nothing was changed"));
147
+ // No set manifest → JC-F: fall back to legacy single-root, logged (never
148
+ // silent). The primary root name matches single-root workspace naming.
149
+ const sets = await listSets(primaryRoot);
150
+ if (sets.length === 0) {
151
+ logger.info(`no set manifest — single-root rollback against ${path.basename(primaryRoot)}`);
152
+ await legacyRollback(primaryRoot, config, approval, interactive, undefined, t);
90
153
  return;
91
154
  }
92
- const { recreated, reverted, deleted } = result.applied;
93
- logger.print(`${t.success(t.glyph.success)} restored checkpoint ${t.accent(result.checkpoint.id)} — ` +
94
- `${reverted} reverted, ${recreated} recreated, ${deleted} deleted`);
95
- logger.print(t.muted("note: commits, pushes, and PRs made during the run are not undone"));
155
+ await setRollback(primaryRoot, config, approval, t);
96
156
  });
97
157
  }
@@ -5,7 +5,7 @@ import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.
5
5
  import { createRenderer } from "../../render/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { summarizeRuns, renderSummary, } from "../../usage/index.js";
8
- import { CheckpointService } from "../../checkpoint/index.js";
8
+ import { CheckpointGate } from "../../checkpoint/index.js";
9
9
  import { SandboxService } from "../../sandbox/index.js";
10
10
  import { buildHooksService } from "../../hooks/index.js";
11
11
  import { runInteractive } from "../repl.js";
@@ -14,12 +14,27 @@ import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
14
14
  import { resetLspServices } from "../../lsp/index.js";
15
15
  import { connectMcpTools, resetMcpServices } from "../../mcp/index.js";
16
16
  import { defaultPromptIO } from "../../approval/index.js";
17
+ import { buildWorkspace, singleRootWorkspace, } from "../../workspace/index.js";
18
+ /**
19
+ * Parse one repeatable `--root` value into a {@link RootSpec} and append it. Form
20
+ * `name=path` (explicit name) or a bare `path` (basename-named by buildWorkspace).
21
+ * All existence/dir/overlap/name validation is delegated to buildWorkspace, which
22
+ * fails loud — this only splits the flag.
23
+ */
24
+ function collectRoot(value, acc) {
25
+ const eq = value.indexOf("=");
26
+ const spec = eq === -1
27
+ ? { path: value }
28
+ : { name: value.slice(0, eq), path: value.slice(eq + 1) };
29
+ return [...acc, spec];
30
+ }
17
31
  export function runCommand() {
18
32
  return new Command("run")
19
33
  .description("run a task once, or start an interactive session")
20
34
  .argument("[prompt...]", "the task for cruxy to perform (omit for interactive)")
21
35
  .option("--plan", "plan mode: propose a step-by-step plan for approval before executing")
22
36
  .option("--sandbox", "run shell + test commands inside an isolated container (fails loud if no runtime)")
37
+ .option("--root <spec>", "declare a workspace root (repeatable): name=path or path; the first is primary", collectRoot, [])
23
38
  .action(async (promptParts, opts) => {
24
39
  const prompt = promptParts.join(" ").trim();
25
40
  const t = themeForColor(shouldUseColor(process.stdout));
@@ -31,8 +46,40 @@ export function runCommand() {
31
46
  }
32
47
  const { config, sources } = loadConfig();
33
48
  let apiKey = resolveApiKey(config.model.provider);
49
+ // Declared workspace roots (C.26). This is the ONE place `run` reads the
50
+ // process working directory — the invocation directory is the base for
51
+ // resolving `--root` paths and the sole root when none are declared. Every
52
+ // subsystem below derives its cwd from the WORKSPACE (`primaryRoot`), never
53
+ // re-reads the invocation directory, so nothing can silently split-brain to
54
+ // a different dir than the roots the tools see (the guard test pins this).
55
+ const invocationCwd = process.cwd();
56
+ // No --root → a trivial single-root workspace (byte-identical to pre-C.26).
57
+ // --root builds a genuine multi-root Workspace and fails fast HERE — a
58
+ // missing / non-dir / overlapping root (CRUXY_E_ROOT_OVERLAP) throws before
59
+ // onboarding or the session starts, never a half-built session.
60
+ const workspace = opts.root.length
61
+ ? await buildWorkspace(opts.root, { cwd: invocationCwd })
62
+ : singleRootWorkspace(invocationCwd);
63
+ const primaryRoot = workspace.primary().absPath;
34
64
  logger.info(t.muted(`model: ${config.model.provider}/${config.model.model}`));
35
65
  logger.info(t.muted(`config: ${sources.project ?? sources.global ?? "defaults"}`));
66
+ // Multi-root honesty (JC-6/C.26): reads fan every root. With per-root
67
+ // checkpoints active (C.26 step 3), writes fan every root too — each is
68
+ // checkpointed and rollback-able. With checkpoints DISABLED, a non-primary
69
+ // write is still refused (CRUXY_E_MULTIROOT_WRITE_DEFERRED) rather than left
70
+ // un-restorable. Hooks + MCP still scope to the primary this release. Named
71
+ // at start, never a silent primary-default.
72
+ if (workspace.isMultiRoot) {
73
+ const names = workspace
74
+ .roots()
75
+ .map((r) => r.name)
76
+ .join(", ");
77
+ const writes = config.checkpoint.enabled
78
+ ? "writes fan all roots (each checkpointed)"
79
+ : "writes scope to primary (checkpoints disabled)";
80
+ logger.info(t.muted(`roots: ${workspace.roots().length} (${names}); primary ${workspace.primary().name} — ` +
81
+ `reads fan all roots; ${writes}; hooks/MCP scope to primary this release`));
82
+ }
36
83
  // First-run with no key (and a TTY) → guided onboarding instead of the
37
84
  // dead-end auth error. The first-win demo is offered only in the no-prompt
38
85
  // (REPL) path; with a real prompt, that prompt IS the first win.
@@ -40,7 +87,7 @@ export function runCommand() {
40
87
  const onboarding = maybeRunOnboarding(config, {
41
88
  ttyInteractive: Boolean(process.stdin.isTTY),
42
89
  offerFirstWin: interactive,
43
- cwd: process.cwd(),
90
+ cwd: primaryRoot,
44
91
  });
45
92
  if (onboarding === null) {
46
93
  // Not a first run (non-TTY, or onboarded then key removed) → fail loud.
@@ -62,11 +109,13 @@ export function runCommand() {
62
109
  // One renderer for the whole run (U.2): the streaming path and the
63
110
  // approval prompt's status-suspend hook must share the same live region.
64
111
  const renderer = createRenderer();
65
- // Checkpoint-before-first-mutation (C.32): the service is latched per run
66
- // and fires from the approval seam inside the session, so one instance
67
- // covers the one-shot path, every REPL turn, and plan-mode execution.
112
+ // Checkpoint-before-first-mutation (C.32/C.26): the gate owns one
113
+ // CheckpointService PER touched root (lazy) plus the run's CheckpointSet,
114
+ // and fires from the approval seam inside the session so one instance
115
+ // covers the one-shot path, every REPL turn, plan-mode execution, and
116
+ // subagents (they share this gate, so their writes join the run's set).
68
117
  const checkpoints = config.checkpoint.enabled
69
- ? new CheckpointService({ root: process.cwd(), config })
118
+ ? new CheckpointGate({ config, primaryRoot })
70
119
  : undefined;
71
120
  // Sandbox (C.16): opt-in via --sandbox or sandbox.enabled. Resolving the
72
121
  // service probes the runtime and THROWS CRUXY_E_SANDBOX_UNAVAILABLE if it
@@ -77,7 +126,7 @@ export function runCommand() {
77
126
  const sandbox = sandboxEnabled
78
127
  ? await SandboxService.create({
79
128
  config,
80
- cwd: process.cwd(),
129
+ cwd: primaryRoot,
81
130
  reporter: renderer,
82
131
  })
83
132
  : undefined;
@@ -88,7 +137,7 @@ export function runCommand() {
88
137
  // layered catalog and yields the lifecycle runner (threaded into the
89
138
  // session) + the resolved custom slash commands (given to the REPL).
90
139
  const hooksService = await buildHooksService({
91
- cwd: process.cwd(),
140
+ cwd: primaryRoot,
92
141
  config,
93
142
  interactive: Boolean(process.stdin.isTTY),
94
143
  logger,
@@ -98,13 +147,13 @@ export function runCommand() {
98
147
  // default (no servers connect). A non-interactive run with an untrusted
99
148
  // config THROWS CRUXY_E_MCP_UNTRUSTED here — before any server spawns.
100
149
  const mcp = await connectMcpTools({
101
- cwd: process.cwd(),
150
+ cwd: primaryRoot,
102
151
  config,
103
152
  logger,
104
153
  interactive: Boolean(process.stdin.isTTY),
105
154
  io: defaultPromptIO(shouldUseColor()),
106
155
  });
107
- const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner, mcp.tools);
156
+ const session = buildAgentSession(config, apiKey, workspace, Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner, mcp.tools);
108
157
  if (interactive) {
109
158
  try {
110
159
  await runInteractive(session, undefined, renderer, checkpoints, hooksService.commands);
@@ -1,6 +1,7 @@
1
1
  import { resolveApiKey } from "../config/index.js";
2
2
  import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
3
3
  import { createRenderer } from "../render/index.js";
4
+ import { singleRootWorkspace } from "../workspace/index.js";
4
5
  import { buildAgentSession } from "./session-factory.js";
5
6
  /**
6
7
  * CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
@@ -24,7 +25,9 @@ export async function runFirstWinTask(config, cwd, prompt) {
24
25
  if (!apiKey)
25
26
  return; // defensive — the key was just persisted
26
27
  const renderer = createRenderer();
27
- const session = buildAgentSession(config, apiKey, cwd, true, false, renderer);
28
+ // The onboarding first-win is inherently single-root (it runs before any
29
+ // `--root` is parsed), so it acts over a trivial workspace on its cwd.
30
+ const session = buildAgentSession(config, apiKey, singleRootWorkspace(cwd), true, false, renderer);
28
31
  try {
29
32
  await session.send(prompt, renderer);
30
33
  }
@@ -1,6 +1,6 @@
1
1
  import type { Readable, Writable } from "node:stream";
2
2
  import type { Session } from "../agent/index.js";
3
- import type { CheckpointService } from "../checkpoint/index.js";
3
+ import type { CheckpointGate } from "../checkpoint/index.js";
4
4
  import { type SlashCommandSpec } from "../hooks/index.js";
5
5
  import { type StreamRenderer } from "../render/index.js";
6
6
  /**
@@ -24,4 +24,4 @@ export interface ReplIO {
24
24
  * live region, anything else the plain append-only renderer); `cruxy run`
25
25
  * passes its own so the approval prompt's status-suspend hook shares it.
26
26
  */
27
- export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointService, slashCommands?: readonly SlashCommandSpec[]): Promise<void>;
27
+ export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer, checkpoints?: CheckpointGate, slashCommands?: readonly SlashCommandSpec[]): Promise<void>;
@@ -1,10 +1,11 @@
1
1
  import type { CruxyConfig } from "../config/index.js";
2
2
  import type { ApprovalDecision } from "../approval/index.js";
3
- import type { CheckpointService } from "../checkpoint/index.js";
3
+ import type { CheckpointGate } from "../checkpoint/index.js";
4
4
  import type { SandboxService } from "../sandbox/index.js";
5
5
  import type { StreamRenderer } from "../render/index.js";
6
6
  import { type ApproveAction, type Tool } from "../tools/index.js";
7
7
  import { Session, type LifecycleHookRunner } from "../agent/index.js";
8
+ import type { Workspace } from "../workspace/index.js";
8
9
  /**
9
10
  * Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
10
11
  * whole point: a tool mutates only *after* `requestApproval` resolves, so
@@ -14,7 +15,7 @@ import { Session, type LifecycleHookRunner } from "../agent/index.js";
14
15
  * (file actions) or that attribution is lost (shell), for rollback's
15
16
  * external-change detection.
16
17
  */
17
- export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, checkpoints: CheckpointService | undefined, cwd: string): (action: ApproveAction) => Promise<ApprovalDecision>;
18
+ export declare function withCheckpointGate(requestApproval: (action: ApproveAction) => Promise<ApprovalDecision>, gate: CheckpointGate | undefined, ws: Workspace): (action: ApproveAction) => Promise<ApprovalDecision>;
18
19
  /**
19
20
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
20
21
  * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
@@ -24,4 +25,4 @@ export declare function withCheckpointGate(requestApproval: (action: ApproveActi
24
25
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
25
26
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
26
27
  */
27
- export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointService, sandbox?: SandboxService, hooks?: LifecycleHookRunner, mcpTools?: Tool[]): Session;
28
+ export declare function buildAgentSession(config: CruxyConfig, apiKey: string, workspace: Workspace, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer, checkpoints?: CheckpointGate, sandbox?: SandboxService, hooks?: LifecycleHookRunner, mcpTools?: Tool[]): Session;
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { createProvider } from "@cruxy/sdk";
2
3
  import { loadProjectInstructions } from "../config/index.js";
3
4
  import { logger } from "../utils/logger.js";
@@ -61,28 +62,90 @@ function resumeLineAfterApproval(requestApproval, renderer) {
61
62
  * (file actions) or that attribution is lost (shell), for rollback's
62
63
  * external-change detection.
63
64
  */
64
- export function withCheckpointGate(requestApproval, checkpoints, cwd) {
65
- if (!checkpoints)
65
+ export function withCheckpointGate(requestApproval, gate, ws) {
66
+ if (!gate)
66
67
  return requestApproval;
67
68
  return async (action) => {
68
69
  const decision = await requestApproval(action);
69
70
  if (!decision.allow)
70
71
  return decision;
71
- const request = classify(action, cwd);
72
+ const request = classify(action, ws.primary().absPath);
72
73
  if (request.tier === "read")
73
74
  return decision;
74
- await checkpoints.ensureCheckpoint();
75
- // Shell AND test executions (C.13) can mutate files we can't attribute
76
- // (scripts, snapshot writers) — record the lost attribution the same way.
77
75
  if (action.kind === "shell" || action.kind === "test") {
78
- await checkpoints.recordShellMutation();
76
+ // JC-β residual: non-primary shell/test are Step 5, so they are still
77
+ // hard-attributed to the primary root regardless of `action.root` (which
78
+ // those tools populate as the seam). They can mutate files we cannot
79
+ // attribute (scripts, snapshot writers) — record the lost attribution.
80
+ const root = ws.primary();
81
+ const svc = gate.serviceFor(root.name, root.absPath);
82
+ const checkpoint = await svc.ensureCheckpoint();
83
+ await svc.recordShellMutation();
84
+ if (checkpoint) {
85
+ await gate.recordMember(root.name, root.absPath, checkpoint.id);
86
+ }
87
+ return decision;
88
+ }
89
+ if (action.kind === "vcs") {
90
+ // C.26 Step 4: a PR now names its root (⚖︎#11), so the checkpoint is
91
+ // attributed to THAT selected root — its git commit stages/lands in that
92
+ // root's working tree, never the primary's. `recordShellMutation` because a
93
+ // `git add -A` + commit mutates the tree opaquely (no per-file attribution).
94
+ // Fall back to the primary only if a root name is somehow absent (defensive).
95
+ const root = (action.root ? ws.tryRootByName(action.root) : undefined) ??
96
+ ws.primary();
97
+ const svc = gate.serviceFor(root.name, root.absPath);
98
+ const checkpoint = await svc.ensureCheckpoint();
99
+ await svc.recordShellMutation();
100
+ if (checkpoint) {
101
+ await gate.recordMember(root.name, root.absPath, checkpoint.id);
102
+ }
103
+ return decision;
79
104
  }
80
- else {
81
- await checkpoints.recordTouched([...request.targets]);
105
+ // File actions (write/edit/patch): attribute each RESOLVED target to its root
106
+ // (JC-G — post-confinement truth) and checkpoint every touched root. A patch
107
+ // may span roots; each root gets its own checkpoint + set member.
108
+ for (const [rootName, group] of attributeFileTargets(action, ws)) {
109
+ const svc = gate.serviceFor(rootName, group.rootAbsPath);
110
+ const checkpoint = await svc.ensureCheckpoint();
111
+ await svc.recordTouched(group.paths);
112
+ if (checkpoint) {
113
+ await gate.recordMember(rootName, group.rootAbsPath, checkpoint.id);
114
+ }
82
115
  }
83
116
  return decision;
84
117
  };
85
118
  }
119
+ /**
120
+ * Group a file action's resolved absolute targets by the root that contains each
121
+ * (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
122
+ * relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
123
+ * we reconstruct the absolute path from the primary root rather than trusting
124
+ * classify's `targets` — which also correctly handles a patch spanning roots.
125
+ */
126
+ function attributeFileTargets(action, ws) {
127
+ const abs = [];
128
+ if (action.kind === "write" || action.kind === "edit") {
129
+ if (action.path)
130
+ abs.push(action.path);
131
+ }
132
+ else if (action.kind === "patch" && action.preview?.type === "patch") {
133
+ for (const file of action.preview.files) {
134
+ abs.push(path.resolve(ws.primary().absPath, file.path));
135
+ }
136
+ }
137
+ const byRoot = new Map();
138
+ for (const target of abs) {
139
+ const root = ws.rootContaining(target);
140
+ const group = byRoot.get(root.name) ?? {
141
+ rootAbsPath: root.absPath,
142
+ paths: [],
143
+ };
144
+ group.paths.push(target);
145
+ byRoot.set(root.name, group);
146
+ }
147
+ return byRoot;
148
+ }
86
149
  /**
87
150
  * Build a ready-to-run agent {@link Session} from a resolved key — the wiring
88
151
  * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
@@ -92,7 +155,14 @@ export function withCheckpointGate(requestApproval, checkpoints, cwd) {
92
155
  * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
93
156
  * approves → executes. Plan mode is fully opt-in; the default path is unchanged.
94
157
  */
95
- export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks, mcpTools = []) {
158
+ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks, mcpTools = []) {
159
+ // The workspace is the single source of truth for "which roots" (C.26). Every
160
+ // primary-scoped subsystem below (git context, project instructions, memory,
161
+ // checkpoints, subagents, the approval gate) derives its cwd from the primary
162
+ // root, so cwd can never disagree with `workspace.primary()`. The full workspace
163
+ // is threaded onto the ToolContext so the fan tools see every root; the subagent
164
+ // orchestrator receives it too, so a child sees the SAME roots as the main loop.
165
+ const cwd = workspace.primary().absPath;
96
166
  const provider = createProvider({
97
167
  provider: config.model.provider,
98
168
  apiKey,
@@ -173,7 +243,11 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
173
243
  // prompt + same checkpoint hook, but a new (empty) session allowlist — a
174
244
  // grant in the parent never silently widens a child's authority.
175
245
  const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
176
- const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, cwd);
246
+ // The C.26 coupling: `checkpointsActive` is true exactly when a per-root gate is
247
+ // wired, and it is set on the SAME ctx whose `requestApproval` IS that gate — so
248
+ // lifting the non-primary-write refusal and capturing the write are one decision.
249
+ const checkpointsActive = Boolean(checkpoints);
250
+ const gate = (approval) => withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace);
177
251
  // Subagent orchestration (C.14): spawn_subagent goes on the main registry
178
252
  // only when depth allows (maxDepth 0 disables the feature structurally).
179
253
  // Registered before the plan wiring so plan-mode execution steps can
@@ -184,11 +258,13 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
184
258
  router,
185
259
  parentRegistry: execRegistry,
186
260
  cwd,
261
+ workspace,
187
262
  logger,
188
263
  git,
189
264
  projectInstructions,
190
265
  renderer,
191
266
  sandbox,
267
+ checkpointsActive,
192
268
  makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
193
269
  });
194
270
  if (config.subagent.maxDepth > 0) {
@@ -207,9 +283,11 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
207
283
  });
208
284
  const ctx = {
209
285
  cwd,
286
+ workspace,
210
287
  config,
211
288
  logger,
212
289
  requestApproval: gate(approval),
290
+ checkpointsActive,
213
291
  sandbox,
214
292
  };
215
293
  const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
@@ -248,7 +326,15 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
248
326
  interactive: ttyInteractive,
249
327
  io,
250
328
  });
251
- const ctx = { cwd, config, logger, requestApproval: gate(approval), sandbox };
329
+ const ctx = {
330
+ cwd,
331
+ workspace,
332
+ config,
333
+ logger,
334
+ requestApproval: gate(approval),
335
+ checkpointsActive,
336
+ sandbox,
337
+ };
252
338
  return new Session({
253
339
  provider,
254
340
  registry: execRegistry,