@cruxy/cli 0.23.0 → 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 (59) hide show
  1. package/dist/agent/loop.d.ts +21 -2
  2. package/dist/agent/loop.js +21 -5
  3. package/dist/approval/index.d.ts +1 -0
  4. package/dist/approval/index.js +1 -0
  5. package/dist/approval/mutex.d.ts +45 -0
  6. package/dist/approval/mutex.js +57 -0
  7. package/dist/checkpoint/service.d.ts +9 -0
  8. package/dist/checkpoint/service.js +20 -0
  9. package/dist/cli/commands/run.js +50 -16
  10. package/dist/cli/onboard.js +2 -2
  11. package/dist/cli/repl.js +39 -0
  12. package/dist/cli/session-factory.d.ts +23 -1
  13. package/dist/cli/session-factory.js +137 -47
  14. package/dist/config/schema.d.ts +24 -0
  15. package/dist/config/schema.js +9 -0
  16. package/dist/errors/constructors.d.ts +23 -0
  17. package/dist/errors/constructors.js +38 -0
  18. package/dist/errors/types.d.ts +8 -0
  19. package/dist/errors/types.js +12 -0
  20. package/dist/hooks/index.d.ts +1 -0
  21. package/dist/hooks/index.js +1 -0
  22. package/dist/hooks/router.d.ts +58 -0
  23. package/dist/hooks/router.js +136 -0
  24. package/dist/hooks/runner.d.ts +12 -0
  25. package/dist/hooks/runner.js +23 -1
  26. package/dist/mcp/index.d.ts +1 -0
  27. package/dist/mcp/index.js +1 -0
  28. package/dist/mcp/sibling-banner.d.ts +25 -0
  29. package/dist/mcp/sibling-banner.js +34 -0
  30. package/dist/memory/recall.d.ts +24 -0
  31. package/dist/memory/recall.js +54 -0
  32. package/dist/memory/remember-tool.d.ts +3 -0
  33. package/dist/memory/remember-tool.js +11 -1
  34. package/dist/sandbox/policy.js +14 -5
  35. package/dist/sandbox/service.d.ts +8 -1
  36. package/dist/sandbox/service.js +4 -1
  37. package/dist/subagent/index.d.ts +1 -0
  38. package/dist/subagent/index.js +1 -0
  39. package/dist/subagent/orchestrator.d.ts +67 -2
  40. package/dist/subagent/orchestrator.js +203 -18
  41. package/dist/subagent/registry-scope.d.ts +13 -0
  42. package/dist/subagent/registry-scope.js +28 -2
  43. package/dist/subagent/semaphore.d.ts +27 -0
  44. package/dist/subagent/semaphore.js +56 -0
  45. package/dist/subagent/spawn-tool.d.ts +57 -0
  46. package/dist/subagent/spawn-tool.js +104 -9
  47. package/dist/subagent/types.d.ts +17 -2
  48. package/dist/testing/run-tests-tool.js +1 -1
  49. package/dist/tools/file/paths.d.ts +5 -6
  50. package/dist/tools/file/paths.js +7 -8
  51. package/dist/tools/shell/exec.js +36 -4
  52. package/dist/tools/types.d.ts +16 -5
  53. package/dist/workspace/add-root.d.ts +27 -0
  54. package/dist/workspace/add-root.js +16 -0
  55. package/dist/workspace/index.d.ts +2 -1
  56. package/dist/workspace/index.js +2 -1
  57. package/dist/workspace/workspace.d.ts +9 -4
  58. package/dist/workspace/workspace.js +9 -4
  59. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { CruxyError, ErrorCode } from "../errors/index.js";
3
- import { SPAWN_SUBAGENT_TOOL_NAME } from "./registry-scope.js";
3
+ import { SPAWN_SUBAGENT_TOOL_NAME, SPAWN_SUBAGENTS_TOOL_NAME, } from "./registry-scope.js";
4
4
  /**
5
5
  * The `spawn_subagent` tool (C.14): the parent-facing seam for delegation. A
6
6
  * normal tool on the same loop as everything else — no hidden control flow.
@@ -34,9 +34,9 @@ const parameters = z.object({
34
34
  .optional()
35
35
  .describe("Cap on the subagent's total tokens (clamped to the configured ceiling)."),
36
36
  });
37
- /** The compact wire shape fed back to the parent model. */
38
- function renderResult(result) {
39
- return JSON.stringify({
37
+ /** The compact, transcript-free object fed back to the parent model. */
38
+ function resultPayload(result) {
39
+ return {
40
40
  status: result.status,
41
41
  summary: result.summary,
42
42
  ...(result.artifacts ? { artifacts: result.artifacts } : {}),
@@ -46,7 +46,18 @@ function renderResult(result) {
46
46
  input: result.usage.input_tokens,
47
47
  output: result.usage.output_tokens,
48
48
  },
49
- });
49
+ };
50
+ }
51
+ /** The compact wire shape fed back to the parent model. */
52
+ function renderResult(result) {
53
+ return JSON.stringify(resultPayload(result));
54
+ }
55
+ /** A coded, actionable tool-error string for an error the MODEL should correct
56
+ * (scope overlap, depth exceeded, tool scoping) — never the raw stack. */
57
+ function toolErrorMessage(err) {
58
+ return CruxyError.is(err)
59
+ ? `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`
60
+ : err.message;
50
61
  }
51
62
  /** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
52
63
  export function makeSpawnSubagentTool(orchestrator, depth) {
@@ -79,10 +90,7 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
79
90
  }
80
91
  // Depth-exceed and scope violations are the model's to correct: feed
81
92
  // the coded, actionable message back as a tool error.
82
- const message = CruxyError.is(err)
83
- ? `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`
84
- : err.message;
85
- return { ok: false, error: message };
93
+ return { ok: false, error: toolErrorMessage(err) };
86
94
  }
87
95
  // A failed child is an is_error result (strong signal), still structured;
88
96
  // budget-exceeded is informational — a partial result, not an error.
@@ -92,3 +100,90 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
92
100
  },
93
101
  };
94
102
  }
103
+ // ── parallel fan-out (C.33) ───────────────────────────────────────────────────
104
+ /** One child's spec inside a `spawn_subagents` batch. Mirrors the singular
105
+ * tool's shape plus `root` — the C.33 disjoint-scope unit. */
106
+ const batchChild = z.object({
107
+ task: z
108
+ .string()
109
+ .min(1)
110
+ .describe("The complete, self-contained subtask. The subagent starts with NO context " +
111
+ "beyond this text — include every path, constraint, and expected output."),
112
+ tools: z
113
+ .array(z.string().min(1))
114
+ .nonempty()
115
+ .optional()
116
+ .describe("Tool names to grant this child — a subset of your own. Omit for read-only. " +
117
+ "Two children that both hold write/shell tools MUST target different `root`s."),
118
+ root: z
119
+ .string()
120
+ .min(1)
121
+ .optional()
122
+ .describe("Workspace root name (from your Environment) to scope this child to: its " +
123
+ "writes are confined there. Required to make two writing children disjoint; " +
124
+ "omit for read-only children or a single-root workspace."),
125
+ maxIterations: z.number().int().positive().optional(),
126
+ maxTokens: z.number().int().positive().optional(),
127
+ });
128
+ const batchParameters = z.object({
129
+ tasks: z
130
+ .array(batchChild)
131
+ .nonempty()
132
+ .describe("The independent subtasks to run CONCURRENTLY. Results come back in this " +
133
+ "same order. Bounded by subagent.maxConcurrency; excess children queue."),
134
+ });
135
+ /** Map a validated batch child to a {@link SubagentSpec}. */
136
+ function toSpec(child) {
137
+ const budget = {
138
+ ...(child.maxIterations !== undefined
139
+ ? { maxIterations: child.maxIterations }
140
+ : {}),
141
+ ...(child.maxTokens !== undefined ? { maxTokens: child.maxTokens } : {}),
142
+ };
143
+ return {
144
+ task: child.task,
145
+ ...(child.tools ? { tools: child.tools } : {}),
146
+ ...(child.root !== undefined ? { root: child.root } : {}),
147
+ budget,
148
+ };
149
+ }
150
+ /**
151
+ * Build the `spawn_subagents` tool (C.33) — the PARALLEL fan-out seam, bound to
152
+ * `depth`. One tool call dispatches N independent, internally-sequential children
153
+ * concurrently (JC-A) under the shared concurrency semaphore, and returns their
154
+ * results IN REQUEST ORDER. A DEPTH-0 capability: it is never granted to a child,
155
+ * so fan-out never nests (which keeps the semaphore deadlock-free).
156
+ */
157
+ export function makeSpawnSubagentsTool(orchestrator, depth) {
158
+ return {
159
+ name: SPAWN_SUBAGENTS_TOOL_NAME,
160
+ description: "Delegate SEVERAL independent subtasks to scoped subagents that run in PARALLEL, " +
161
+ "each with its own fresh context, restricted toolset, and hard budget. Returns a " +
162
+ "structured result per child, in the same order as `tasks` — transcripts discarded. " +
163
+ "Use when subtasks don't depend on each other (e.g. investigate N areas at once). " +
164
+ "For children that WRITE, give each a distinct `root`; overlapping write scope is " +
165
+ "refused. For a single subtask, use spawn_subagent instead.",
166
+ parameters: batchParameters,
167
+ async execute(input) {
168
+ let results;
169
+ try {
170
+ results = await orchestrator.spawnMany(input.tasks.map(toSpec), depth);
171
+ }
172
+ catch (err) {
173
+ // Non-interactive default-deny propagates to the boundary (U.3).
174
+ if (CruxyError.is(err) && err.code === ErrorCode.ApprovalRequired) {
175
+ throw err;
176
+ }
177
+ // Scope overlap / depth-exceed are the model's to correct — coded error.
178
+ return { ok: false, error: toolErrorMessage(err) };
179
+ }
180
+ // Partial results are honest, not an error: the array carries each child's
181
+ // own status (done / failed / budget-exceeded / cancelled). The batch call
182
+ // succeeds as long as it was dispatched — the parent reasons over the mix.
183
+ return {
184
+ ok: true,
185
+ output: JSON.stringify(results.map(resultPayload)),
186
+ };
187
+ },
188
+ };
189
+ }
@@ -7,8 +7,13 @@ import type { TaskClass } from "../routing/index.js";
7
7
  * result and discards its transcript. The parent reasons over the result only;
8
8
  * context isolation is the whole point.
9
9
  */
10
- /** Why a subagent run ended. Every path returns a result — never a hang. */
11
- export type SubagentStatus = "done" | "budget-exceeded" | "failed";
10
+ /**
11
+ * Why a subagent run ended. Every path returns a result — never a hang.
12
+ * `cancelled` (C.33) is a parallel-fan-out outcome: a fatal sibling failure or
13
+ * Ctrl-C aborted this child before it finished — recorded honestly, never
14
+ * dressed up as `done`.
15
+ */
16
+ export type SubagentStatus = "done" | "budget-exceeded" | "failed" | "cancelled";
12
17
  /**
13
18
  * Hard caps a subagent runs under. `maxIterations` and `maxTokens` are always
14
19
  * finite — a subagent is bounded by construction; `timeoutMs` is an optional
@@ -41,6 +46,16 @@ export interface SubagentSpec {
41
46
  * declaration at the spawn call site — not something the router guesses.
42
47
  */
43
48
  taskClass?: TaskClass;
49
+ /**
50
+ * The workspace root (by exact name, C.26) this child is scoped to (C.33).
51
+ * When set, the child's cwd and confinement narrow to that ONE root, so its
52
+ * writes land there and nowhere else — the unit of the parallel fan-out's
53
+ * disjoint-scope guarantee: two writing children must name distinct roots or
54
+ * the batch is refused pre-dispatch. Omitted → the child inherits the full
55
+ * session workspace (read-only fan-out, or a single-root session), unchanged
56
+ * from C.14.
57
+ */
58
+ root?: string;
44
59
  }
45
60
  /**
46
61
  * What the parent gets back — compact structured data, never the transcript.
@@ -99,7 +99,7 @@ export function makeRunTestsTool(deps = {}) {
99
99
  command: resolved.command,
100
100
  // C.26 attribution seam (JC-β): primary root name for Steps 4/5. Tests are
101
101
  // primary-only this release; the gate hard-attributes to the primary.
102
- root: ctx.workspace?.primary().name,
102
+ root: ctx.workspace.primary().name,
103
103
  });
104
104
  if (!decision.allow) {
105
105
  return {
@@ -9,12 +9,11 @@ import type { ToolContext } from "../types.js";
9
9
  */
10
10
  export { PathEscapeError };
11
11
  /**
12
- * The workspace this context acts in. Runtime always sets `ctx.workspace`; the
13
- * fallback to a trivial single-root workspace over `ctx.cwd` exists only for the
14
- * many test contexts (and any not-yet-migrated caller) that don't set one. The
15
- * fallback is byte-identical to the legacy single-root behaviour by construction:
16
- * its one root's `absPath` is `path.resolve(ctx.cwd)`, the same root the old
17
- * `resolveInRoot` confined to.
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.
18
17
  */
19
18
  export declare function contextWorkspace(ctx: ToolContext): Workspace;
20
19
  /**
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { multirootWriteDeferred, rootAmbiguous } from "../../errors/index.js";
3
- import { confineToRoot, PathEscapeError, selectRoot, singleRootWorkspace, } from "../../workspace/index.js";
3
+ import { confineToRoot, PathEscapeError, selectRoot, } from "../../workspace/index.js";
4
4
  /**
5
5
  * Path confinement for file tools (C.26). The confinement kernel itself lives in
6
6
  * `src/workspace` ({@link confineToRoot}); this module is the tool-facing funnel
@@ -10,15 +10,14 @@ import { confineToRoot, PathEscapeError, selectRoot, singleRootWorkspace, } from
10
10
  */
11
11
  export { PathEscapeError };
12
12
  /**
13
- * The workspace this context acts in. Runtime always sets `ctx.workspace`; the
14
- * fallback to a trivial single-root workspace over `ctx.cwd` exists only for the
15
- * many test contexts (and any not-yet-migrated caller) that don't set one. The
16
- * fallback is byte-identical to the legacy single-root behaviour by construction:
17
- * its one root's `absPath` is `path.resolve(ctx.cwd)`, the same root the old
18
- * `resolveInRoot` confined to.
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.
19
18
  */
20
19
  export function contextWorkspace(ctx) {
21
- return ctx.workspace ?? singleRootWorkspace(ctx.cwd);
20
+ return ctx.workspace;
22
21
  }
23
22
  /**
24
23
  * Resolve a tool-supplied path against the workspace and prove it stays inside the
@@ -13,7 +13,7 @@ export async function runGatedShell(command, ctx) {
13
13
  const decision = await ctx.requestApproval({
14
14
  kind: "shell",
15
15
  command,
16
- root: ctx.workspace?.primary().name,
16
+ root: ctx.workspace.primary().name,
17
17
  });
18
18
  if (!decision.allow) {
19
19
  return { approved: false, rejection: decision.feedback };
@@ -74,12 +74,17 @@ function runBounded(command, ctx) {
74
74
  };
75
75
  child.stdout?.on("data", capture);
76
76
  child.stderr?.on("data", capture);
77
- // 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.
78
80
  let settled = false;
81
+ const abortSignal = ctx.signal;
82
+ const cleanup = () => abortSignal?.removeEventListener("abort", onAbort);
79
83
  const timer = setTimeout(() => {
80
84
  if (settled)
81
85
  return;
82
86
  settled = true;
87
+ cleanup();
83
88
  killTree(child.pid);
84
89
  resolve({
85
90
  timedOut: true,
@@ -89,11 +94,37 @@ function runBounded(command, ctx) {
89
94
  truncated,
90
95
  });
91
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
+ }
92
122
  child.on("error", (err) => {
93
123
  if (settled)
94
124
  return;
95
125
  settled = true;
96
126
  clearTimeout(timer);
127
+ cleanup();
97
128
  resolve({
98
129
  timedOut: false,
99
130
  exitCode: null,
@@ -103,15 +134,16 @@ function runBounded(command, ctx) {
103
134
  spawnError: err.message,
104
135
  });
105
136
  });
106
- child.on("close", (code, signal) => {
137
+ child.on("close", (code, closeSignal) => {
107
138
  if (settled)
108
139
  return;
109
140
  settled = true;
110
141
  clearTimeout(timer);
142
+ cleanup();
111
143
  resolve({
112
144
  timedOut: false,
113
145
  exitCode: code,
114
- signal: signal ?? null,
146
+ signal: closeSignal ?? null,
115
147
  output: Buffer.concat(chunks).toString("utf8"),
116
148
  truncated,
117
149
  });
@@ -166,13 +166,15 @@ export interface ToolContext {
166
166
  /**
167
167
  * The declared workspace root set for this session (C.26). Path-taking tools
168
168
  * resolve *through* this rather than through `cwd`, so a multi-root session
169
- * confines each call to the one root it selects. Optional only so the many test
170
- * contexts that predate multi-root stay valid: the runtime always populates it,
171
- * and when absent the file resolver falls back to a trivial single-root
172
- * workspace over `cwd` (byte-identical to legacy single-root behaviour). `cwd`
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`
173
175
  * remains equal to `workspace.primary().absPath`.
174
176
  */
175
- workspace?: Workspace;
177
+ workspace: Workspace;
176
178
  /** Fully-resolved CLI configuration. */
177
179
  config: CruxyConfig;
178
180
  /** Shared leveled logger (diagnostics to stderr, `print` to stdout). */
@@ -208,6 +210,15 @@ export interface ToolContext {
208
210
  * populated). Absent → host execution, unchanged.
209
211
  */
210
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;
211
222
  }
212
223
  /**
213
224
  * The one interface every tool implements. `parameters` is a zod schema; it both
@@ -0,0 +1,27 @@
1
+ import type { RootSpec } from "./types.js";
2
+ import { Workspace } from "./workspace.js";
3
+ /**
4
+ * The interactive add-root path (C.26 step 5). Adding a workspace root is an
5
+ * EXPLICIT HUMAN act — it grows the trust surface (a new root can carry its own
6
+ * hooks / MCP / project memory), so it is:
7
+ *
8
+ * - reachable ONLY from the REPL command and the CLI, never as a model tool.
9
+ * The model can act only through the tool registry (`registry.get(name)`),
10
+ * and no tool is registered for this — the allowlist argument is unchanged.
11
+ * - TTY-only: refused (coded) when there is no interactive human to vouch.
12
+ * - held to the SAME validation as `--root`: the new root must exist, be a
13
+ * directory, have a unique name, and NOT nest/overlap an existing root
14
+ * (CRUXY_E_ROOT_OVERLAP) — reusing {@link buildWorkspace}, the single
15
+ * Workspace constructor from user input.
16
+ *
17
+ * Returns the NEW immutable Workspace (the root set only ever grows by
18
+ * constructing a new one). A freshly added root starts UNTRUSTED; its hooks and
19
+ * project memory stay inert until explicitly trusted.
20
+ */
21
+ export interface AddRootOptions {
22
+ /** Base dir for resolving a relative `path` (the session's primary root). */
23
+ cwd: string;
24
+ /** Whether stdin is a TTY — add-root is refused without an interactive human. */
25
+ tty: boolean;
26
+ }
27
+ export declare function addRootToWorkspace(current: Workspace, spec: RootSpec, opts: AddRootOptions): Promise<Workspace>;
@@ -0,0 +1,16 @@
1
+ import { usageError } from "../errors/index.js";
2
+ import { buildWorkspace } from "./workspace.js";
3
+ export async function addRootToWorkspace(current, spec, opts) {
4
+ if (!opts.tty) {
5
+ throw usageError("adding a workspace root is an interactive action and needs a TTY", ["declare it up front with `--root name=path` instead"]);
6
+ }
7
+ // Rebuild from the existing roots (as specs) + the new one, so EVERY
8
+ // buildWorkspace guard — existence, directory, name uniqueness, and the
9
+ // nested/overlap refusal — applies to the addition. Existing roots keep their
10
+ // names, absolute paths, and order (the primary stays first).
11
+ const specs = [
12
+ ...current.roots().map((r) => ({ name: r.name, path: r.absPath })),
13
+ spec,
14
+ ];
15
+ return buildWorkspace(specs, { cwd: opts.cwd });
16
+ }
@@ -1,5 +1,6 @@
1
1
  export type { DeclaredRoot, RootSpec } from "./types.js";
2
- export { Workspace, buildWorkspace, singleRootWorkspace } from "./workspace.js";
2
+ export { Workspace, buildWorkspace, sessionWorkspace } from "./workspace.js";
3
3
  export { PathEscapeError, confineToRoot, isInside, resolveInWorkspace, } from "./resolve.js";
4
4
  export { selectRoot } from "./select.js";
5
5
  export type { RootRef, SelectedRoot } from "./select.js";
6
+ export { addRootToWorkspace, type AddRootOptions } from "./add-root.js";
@@ -1,3 +1,4 @@
1
- export { Workspace, buildWorkspace, singleRootWorkspace } from "./workspace.js";
1
+ export { Workspace, buildWorkspace, sessionWorkspace } from "./workspace.js";
2
2
  export { PathEscapeError, confineToRoot, isInside, resolveInWorkspace, } from "./resolve.js";
3
3
  export { selectRoot } from "./select.js";
4
+ export { addRootToWorkspace } from "./add-root.js";
@@ -49,8 +49,13 @@ export declare function buildWorkspace(specs: readonly RootSpec[], opts?: {
49
49
  cwd?: string;
50
50
  }): Promise<Workspace>;
51
51
  /**
52
- * Build a trivial single-root workspace from one absolute path — the back-compat
53
- * bridge for the many call sites that still pass a single `cwd`. The one root is
54
- * primary and named by its basename.
52
+ * Build THE session's workspace from a single absolute path — the trivial
53
+ * single-root case. This is a *session construction* primitive, not a per-call
54
+ * fallback: after C.26 Step 6 it is called ONLY at the two CLI entry points that
55
+ * turn argv into a session (`cli/commands/run.ts`, `cli/onboard.ts`), never in the
56
+ * middle of a subsystem. Synthesizing one elsewhere would silently scope that
57
+ * subsystem to its own cwd while the fan tools see N roots (a split-brain) — the
58
+ * `no-split-brain` guard fails on any `sessionWorkspace(` outside those two files.
59
+ * The one root is primary and named by its basename.
55
60
  */
56
- export declare function singleRootWorkspace(absPath: string): Workspace;
61
+ export declare function sessionWorkspace(absPath: string): Workspace;
@@ -168,11 +168,16 @@ export async function buildWorkspace(specs, opts = {}) {
168
168
  return new Workspace(declared);
169
169
  }
170
170
  /**
171
- * Build a trivial single-root workspace from one absolute path — the back-compat
172
- * bridge for the many call sites that still pass a single `cwd`. The one root is
173
- * primary and named by its basename.
171
+ * Build THE session's workspace from a single absolute path — the trivial
172
+ * single-root case. This is a *session construction* primitive, not a per-call
173
+ * fallback: after C.26 Step 6 it is called ONLY at the two CLI entry points that
174
+ * turn argv into a session (`cli/commands/run.ts`, `cli/onboard.ts`), never in the
175
+ * middle of a subsystem. Synthesizing one elsewhere would silently scope that
176
+ * subsystem to its own cwd while the fan tools see N roots (a split-brain) — the
177
+ * `no-split-brain` guard fails on any `sessionWorkspace(` outside those two files.
178
+ * The one root is primary and named by its basename.
174
179
  */
175
- export function singleRootWorkspace(absPath) {
180
+ export function sessionWorkspace(absPath) {
176
181
  const abs = path.resolve(absPath);
177
182
  return new Workspace([
178
183
  { name: path.basename(abs) || "root", absPath: abs, primary: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {