@cruxy/cli 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approval/classify.js +25 -3
- package/dist/approval/policy.d.ts +6 -0
- package/dist/approval/policy.js +15 -3
- package/dist/approval/prompt.js +11 -0
- package/dist/approval/types.d.ts +8 -1
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +3 -0
- package/dist/checkpoint/index.js +3 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -0
- package/dist/checkpoint/set.d.ts +44 -0
- package/dist/checkpoint/set.js +142 -0
- package/dist/checkpoint/types.d.ts +47 -0
- package/dist/cli/commands/rollback.d.ts +11 -6
- package/dist/cli/commands/rollback.js +93 -33
- package/dist/cli/commands/run.js +59 -10
- package/dist/cli/onboard.js +4 -1
- package/dist/cli/repl.d.ts +2 -2
- package/dist/cli/session-factory.d.ts +4 -3
- package/dist/cli/session-factory.js +98 -12
- package/dist/errors/constructors.d.ts +65 -0
- package/dist/errors/constructors.js +168 -0
- package/dist/errors/types.d.ts +46 -0
- package/dist/errors/types.js +64 -0
- package/dist/indexing/retriever.d.ts +29 -0
- package/dist/indexing/retriever.js +26 -0
- package/dist/indexing/service.js +3 -1
- package/dist/indexing/types.d.ts +7 -0
- package/dist/lsp/tools/common.d.ts +34 -7
- package/dist/lsp/tools/common.js +33 -11
- package/dist/lsp/tools/find-definition.js +2 -2
- package/dist/lsp/tools/find-references.js +10 -4
- package/dist/lsp/tools/get-diagnostics.js +6 -4
- package/dist/render/diff.js +42 -5
- package/dist/sandbox/docker-runtime.js +4 -1
- package/dist/sandbox/policy.d.ts +12 -3
- package/dist/sandbox/policy.js +17 -3
- package/dist/sandbox/types.d.ts +10 -1
- package/dist/subagent/orchestrator.d.ts +15 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/testing/run-tests-tool.js +3 -0
- package/dist/tools/create-pull-request.d.ts +3 -0
- package/dist/tools/create-pull-request.js +50 -4
- package/dist/tools/file/apply-patch.js +2 -2
- package/dist/tools/file/edit-file.js +2 -2
- package/dist/tools/file/glob.d.ts +9 -2
- package/dist/tools/file/glob.js +73 -19
- package/dist/tools/file/grep-files.d.ts +12 -2
- package/dist/tools/file/grep-files.js +113 -38
- package/dist/tools/file/paths.d.ts +123 -17
- package/dist/tools/file/paths.js +158 -50
- package/dist/tools/file/read-file.js +2 -2
- package/dist/tools/file/write-file.js +2 -2
- package/dist/tools/git-status.d.ts +8 -1
- package/dist/tools/git-status.js +43 -11
- package/dist/tools/list-files.d.ts +9 -3
- package/dist/tools/list-files.js +48 -13
- package/dist/tools/search-codebase.d.ts +10 -0
- package/dist/tools/search-codebase.js +117 -14
- package/dist/tools/shell/exec.js +8 -1
- package/dist/tools/types.d.ts +63 -1
- package/dist/vcs/git.d.ts +8 -0
- package/dist/vcs/git.js +14 -0
- package/dist/vcs/github.d.ts +7 -1
- package/dist/vcs/github.js +10 -1
- package/dist/vcs/service.d.ts +8 -0
- package/dist/vcs/service.js +33 -1
- package/dist/vcs/types.d.ts +18 -2
- package/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/resolve.d.ts +54 -0
- package/dist/workspace/resolve.js +96 -0
- package/dist/workspace/select.d.ts +41 -0
- package/dist/workspace/select.js +44 -0
- package/dist/workspace/types.d.ts +30 -0
- package/dist/workspace/types.js +15 -0
- package/dist/workspace/workspace.d.ts +56 -0
- package/dist/workspace/workspace.js +180 -0
- package/package.json +1 -1
|
@@ -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
|
|
4
|
-
* undoing everything an agent run changed
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
64
|
-
const service = new CheckpointService({ root, config });
|
|
135
|
+
const primaryRoot = process.cwd();
|
|
65
136
|
const approval = new ApprovalService({
|
|
66
|
-
cwd:
|
|
137
|
+
cwd: primaryRoot,
|
|
67
138
|
interactive,
|
|
68
139
|
io: defaultPromptIO(shouldUseColor()),
|
|
69
140
|
});
|
|
70
|
-
//
|
|
71
|
-
// the
|
|
72
|
-
if (id
|
|
73
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
|
66
|
-
//
|
|
67
|
-
//
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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);
|
package/dist/cli/onboard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
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 {
|
|
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>,
|
|
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,
|
|
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,
|
|
65
|
-
if (!
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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,
|
|
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
|
-
|
|
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 = {
|
|
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,
|