@cruxy/cli 0.22.1 → 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 +18 -0
- package/dist/approval/prompt.js +11 -0
- package/dist/checkpoint/gate.d.ts +65 -0
- package/dist/checkpoint/gate.js +86 -0
- package/dist/checkpoint/index.d.ts +2 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/set-rollback.d.ts +51 -0
- package/dist/checkpoint/set-rollback.js +74 -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 +28 -0
- package/dist/errors/constructors.js +59 -0
- package/dist/errors/types.d.ts +20 -0
- package/dist/errors/types.js +26 -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/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 +122 -9
- package/dist/tools/file/paths.js +165 -10
- 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/package.json +1 -1
|
@@ -121,6 +121,24 @@ function vcsRequest(action, root) {
|
|
|
121
121
|
* carries the full blast radius; the summary names the checkpoint.
|
|
122
122
|
*/
|
|
123
123
|
function rollbackRequest(action, root) {
|
|
124
|
+
// A multi-root set rollback (C.26): one destructive, ungrantable approval over
|
|
125
|
+
// every touched root of a run. Never session-grantable (scope `none`), and its
|
|
126
|
+
// targets span roots so they are not resolved here — the grouped preview carries
|
|
127
|
+
// the full per-root blast radius.
|
|
128
|
+
if (action.preview?.type === "rollback-set") {
|
|
129
|
+
const preview = action.preview;
|
|
130
|
+
const fileCount = preview.roots.reduce((n, r) => n + r.files.length, 0);
|
|
131
|
+
return {
|
|
132
|
+
action,
|
|
133
|
+
tier: "destructive",
|
|
134
|
+
scope: { kind: "none" },
|
|
135
|
+
summary: `rollback: restore run ${preview.runId} across ${preview.roots.length} ` +
|
|
136
|
+
`root${preview.roots.length === 1 ? "" : "s"} ` +
|
|
137
|
+
`(${fileCount} file${fileCount === 1 ? "" : "s"})`,
|
|
138
|
+
targets: [],
|
|
139
|
+
cwd: root,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
124
142
|
const preview = action.preview?.type === "rollback" ? action.preview : undefined;
|
|
125
143
|
const summary = preview
|
|
126
144
|
? `rollback: restore checkpoint ${preview.checkpointId} (${preview.files.length} file${preview.files.length === 1 ? "" : "s"})`
|
package/dist/approval/prompt.js
CHANGED
|
@@ -74,6 +74,17 @@ function detail(request, t) {
|
|
|
74
74
|
// point of the call, not just at trust time.
|
|
75
75
|
return ` ${t.muted(`external MCP server "${request.action.server ?? ""}" — runs unsandboxed with your privileges`)}`;
|
|
76
76
|
}
|
|
77
|
+
if (request.action.kind === "vcs" && request.action.root) {
|
|
78
|
+
// C.26 Step 4 (⚖︎JC-4): name the acting root alongside the resolved owner/repo
|
|
79
|
+
// (rendered inside the preview), so the human sees BOTH which declared root the
|
|
80
|
+
// PR acts in and the real API destination before approving.
|
|
81
|
+
return [
|
|
82
|
+
` ${t.muted(`root ${request.action.root}`)}`,
|
|
83
|
+
renderActionPreview(request.action.preview, t),
|
|
84
|
+
]
|
|
85
|
+
.filter((l) => l !== "")
|
|
86
|
+
.join("\n");
|
|
87
|
+
}
|
|
77
88
|
return renderActionPreview(request.action.preview, t);
|
|
78
89
|
}
|
|
79
90
|
/** The choices line, including a short label of what an `a` grant would cover. */
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import { CheckpointService } from "./service.js";
|
|
3
|
+
import type { CheckpointSet } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Per-root checkpoint gate (C.26 step 3). One run may mutate several declared
|
|
6
|
+
* roots; this owns one {@link CheckpointService} PER touched root (created lazily
|
|
7
|
+
* the first time a mutation is gated for that root) plus the run's
|
|
8
|
+
* {@link CheckpointSet} accumulator, so `cruxy rollback` can restore exactly the
|
|
9
|
+
* roots the run touched — no more, no less.
|
|
10
|
+
*
|
|
11
|
+
* Two invariants live here:
|
|
12
|
+
* • **exactly-touched** — {@link serviceFor} is the ONLY construction site for a
|
|
13
|
+
* per-root service, and it is called only when a mutation is actually gated for
|
|
14
|
+
* that root, so an untouched root's service is never constructed and never
|
|
15
|
+
* appears in the set.
|
|
16
|
+
* • **one set per run, shared across subagents** — the parent and every subagent
|
|
17
|
+
* share this one gate (C.14 wires the child gate over the same object), so a
|
|
18
|
+
* subagent's writes join the run's single set and are covered by the run's
|
|
19
|
+
* rollback (⚖︎JC-δ). The set manifest lives under the PRIMARY root (⚖︎#7).
|
|
20
|
+
*/
|
|
21
|
+
export type CreateCheckpointService = (root: string, config: CruxyConfig) => CheckpointService;
|
|
22
|
+
export interface CheckpointGateOptions {
|
|
23
|
+
config: CruxyConfig;
|
|
24
|
+
/** Absolute path of the primary root — home of the set manifest (⚖︎#7). */
|
|
25
|
+
primaryRoot: string;
|
|
26
|
+
/**
|
|
27
|
+
* Test seam: the sole factory for per-root services. A constructor spy passed
|
|
28
|
+
* here proves an untouched root's service is never built.
|
|
29
|
+
*/
|
|
30
|
+
createService?: CreateCheckpointService;
|
|
31
|
+
}
|
|
32
|
+
export declare class CheckpointGate {
|
|
33
|
+
private readonly config;
|
|
34
|
+
private readonly primaryRoot;
|
|
35
|
+
private readonly create;
|
|
36
|
+
private readonly services;
|
|
37
|
+
private runId;
|
|
38
|
+
private summary;
|
|
39
|
+
private set;
|
|
40
|
+
constructor(opts: CheckpointGateOptions);
|
|
41
|
+
/**
|
|
42
|
+
* Start a new undo unit for the WHOLE run (every root + the set). Resets each
|
|
43
|
+
* existing per-root service's once-per-run latch and clears the set so the next
|
|
44
|
+
* mutation begins a fresh run. The set is materialized lazily on the first member
|
|
45
|
+
* (so its `createdAt` marks the run's first mutation, and a no-mutation run
|
|
46
|
+
* writes no manifest).
|
|
47
|
+
*/
|
|
48
|
+
beginRun(summary: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Get-or-create the per-root service. The first time a root is touched this
|
|
51
|
+
* process, its service is constructed and joined to the current run; an untouched
|
|
52
|
+
* root's service is never built.
|
|
53
|
+
*/
|
|
54
|
+
serviceFor(rootName: string, rootAbsPath: string): CheckpointService;
|
|
55
|
+
/**
|
|
56
|
+
* Record that `rootName` was checkpointed this run: append its member to the set
|
|
57
|
+
* and persist the manifest under the primary root. Idempotent — later mutations
|
|
58
|
+
* to the same root this run are no-ops (the root already has exactly one member).
|
|
59
|
+
*/
|
|
60
|
+
recordMember(rootName: string, rootAbsPath: string, checkpointId: string): Promise<void>;
|
|
61
|
+
/** Root names that got a per-root service this process (inspection/tests). */
|
|
62
|
+
get touchedRoots(): readonly string[];
|
|
63
|
+
/** The current run's set, or null before its first mutation (inspection/tests). */
|
|
64
|
+
get currentSet(): CheckpointSet | null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { CheckpointService } from "./service.js";
|
|
2
|
+
import { newRunId, writeSet } from "./set.js";
|
|
3
|
+
/** Trim a run summary to one line ≤80 chars, matching CheckpointService.beginRun. */
|
|
4
|
+
function trimSummary(summary) {
|
|
5
|
+
const firstLine = summary.split("\n", 1)[0].trim();
|
|
6
|
+
return firstLine.length > 80
|
|
7
|
+
? `${firstLine.slice(0, 79)}…`
|
|
8
|
+
: firstLine || "agent run";
|
|
9
|
+
}
|
|
10
|
+
export class CheckpointGate {
|
|
11
|
+
config;
|
|
12
|
+
primaryRoot;
|
|
13
|
+
create;
|
|
14
|
+
services = new Map();
|
|
15
|
+
runId = null;
|
|
16
|
+
summary = "agent run";
|
|
17
|
+
set = null;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.config = opts.config;
|
|
20
|
+
this.primaryRoot = opts.primaryRoot;
|
|
21
|
+
this.create =
|
|
22
|
+
opts.createService ??
|
|
23
|
+
((root, config) => new CheckpointService({ root, config }));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Start a new undo unit for the WHOLE run (every root + the set). Resets each
|
|
27
|
+
* existing per-root service's once-per-run latch and clears the set so the next
|
|
28
|
+
* mutation begins a fresh run. The set is materialized lazily on the first member
|
|
29
|
+
* (so its `createdAt` marks the run's first mutation, and a no-mutation run
|
|
30
|
+
* writes no manifest).
|
|
31
|
+
*/
|
|
32
|
+
beginRun(summary) {
|
|
33
|
+
this.summary = summary;
|
|
34
|
+
this.runId = newRunId();
|
|
35
|
+
this.set = null;
|
|
36
|
+
for (const svc of this.services.values())
|
|
37
|
+
svc.beginRun(summary);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get-or-create the per-root service. The first time a root is touched this
|
|
41
|
+
* process, its service is constructed and joined to the current run; an untouched
|
|
42
|
+
* root's service is never built.
|
|
43
|
+
*/
|
|
44
|
+
serviceFor(rootName, rootAbsPath) {
|
|
45
|
+
let svc = this.services.get(rootName);
|
|
46
|
+
if (!svc) {
|
|
47
|
+
svc = this.create(rootAbsPath, this.config);
|
|
48
|
+
svc.beginRun(this.summary);
|
|
49
|
+
this.services.set(rootName, svc);
|
|
50
|
+
}
|
|
51
|
+
return svc;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Record that `rootName` was checkpointed this run: append its member to the set
|
|
55
|
+
* and persist the manifest under the primary root. Idempotent — later mutations
|
|
56
|
+
* to the same root this run are no-ops (the root already has exactly one member).
|
|
57
|
+
*/
|
|
58
|
+
async recordMember(rootName, rootAbsPath, checkpointId) {
|
|
59
|
+
if (!this.runId)
|
|
60
|
+
return; // no run in progress — defensive
|
|
61
|
+
if (this.set?.members.some((m) => m.rootName === rootName))
|
|
62
|
+
return;
|
|
63
|
+
if (!this.set) {
|
|
64
|
+
this.set = {
|
|
65
|
+
runId: this.runId,
|
|
66
|
+
createdAt: new Date().toISOString(),
|
|
67
|
+
runSummary: trimSummary(this.summary),
|
|
68
|
+
members: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
this.set.members.push({
|
|
72
|
+
rootName,
|
|
73
|
+
rootPath: rootAbsPath,
|
|
74
|
+
checkpointId,
|
|
75
|
+
});
|
|
76
|
+
await writeSet(this.primaryRoot, this.set);
|
|
77
|
+
}
|
|
78
|
+
/** Root names that got a per-root service this process (inspection/tests). */
|
|
79
|
+
get touchedRoots() {
|
|
80
|
+
return [...this.services.keys()];
|
|
81
|
+
}
|
|
82
|
+
/** The current run's set, or null before its first mutation (inspection/tests). */
|
|
83
|
+
get currentSet() {
|
|
84
|
+
return this.set;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/checkpoint/index.js
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
3
|
+
import { CheckpointService } from "./service.js";
|
|
4
|
+
import type { CheckpointSet, CheckpointStore, RollbackPlan, SetRollbackApplied } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Set-based rollback orchestration (C.26 step 3). Restore every member of a run's
|
|
7
|
+
* {@link CheckpointSet} as one gated operation, with the R3/⚖︎#8 guarantees:
|
|
8
|
+
* 1. **validate-all before any apply** — every member's checkpoint must load and
|
|
9
|
+
* plan cleanly first ({@link validateSet}); a missing/corrupt one throws
|
|
10
|
+
* `CRUXY_E_CHECKPOINT_SET_INCOMPLETE` and NOTHING is applied.
|
|
11
|
+
* 2. **one combined preview** grouped by root ({@link buildSetPreview}), each with
|
|
12
|
+
* its own external-change warnings, behind one U.3 approval (⚖︎JC-ι).
|
|
13
|
+
* 3. **sequential apply, stop on first failure** ({@link applySet}) →
|
|
14
|
+
* `CRUXY_E_CHECKPOINT_SET_PARTIAL` carrying restored-vs-not; the plan is
|
|
15
|
+
* recomputed from disk each run, so an idempotent re-run finishes the job.
|
|
16
|
+
*/
|
|
17
|
+
export type CreateService = (root: string, config: CruxyConfig) => CheckpointService;
|
|
18
|
+
/** A member whose rollback has been fully validated + planned, ready to apply. */
|
|
19
|
+
export interface ValidatedMember {
|
|
20
|
+
rootName: string;
|
|
21
|
+
rootPath: string;
|
|
22
|
+
checkpointId: string;
|
|
23
|
+
plan: RollbackPlan;
|
|
24
|
+
store: CheckpointStore;
|
|
25
|
+
preview: Extract<ActionPreview, {
|
|
26
|
+
type: "rollback";
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validate + plan EVERY member up front (R3 gate #1). Loads each root's checkpoint
|
|
31
|
+
* and computes its rollback plan/preview; the first member that cannot be loaded
|
|
32
|
+
* throws `CRUXY_E_CHECKPOINT_SET_INCOMPLETE` — before any filesystem apply — so a
|
|
33
|
+
* partial rollback can never masquerade as success.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validateSet(set: CheckpointSet, config: CruxyConfig, createService?: CreateService): Promise<ValidatedMember[]>;
|
|
36
|
+
/**
|
|
37
|
+
* One combined preview grouped by root (⚖︎JC-ι): each root keeps its own file diffs
|
|
38
|
+
* and external-change warnings, so a single U.3 approval covers the whole set.
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildSetPreview(set: CheckpointSet, members: ValidatedMember[]): Extract<ActionPreview, {
|
|
41
|
+
type: "rollback-set";
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Apply the validated set sequentially, stopping on the first failure (R3/⚖︎#8).
|
|
45
|
+
* On any member's failure this throws `CRUXY_E_CHECKPOINT_SET_PARTIAL` with the
|
|
46
|
+
* restored-vs-not split; re-running (which recomputes each plan from disk) safely
|
|
47
|
+
* finishes the job.
|
|
48
|
+
*/
|
|
49
|
+
export declare function applySet(set: CheckpointSet, members: ValidatedMember[]): Promise<SetRollbackApplied>;
|
|
50
|
+
/** Is a validated set a no-op (every member's plan is empty)? */
|
|
51
|
+
export declare function setIsNoop(members: ValidatedMember[]): boolean;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { checkpointSetIncomplete } from "../errors/index.js";
|
|
2
|
+
import { applyRollback, buildRollbackPreview, computeRollbackPlan, } from "./restore.js";
|
|
3
|
+
import { CheckpointService, createCheckpointStore, isGitWorkTree, } from "./service.js";
|
|
4
|
+
import { applySetRollback } from "./set.js";
|
|
5
|
+
function defaultCreate(root, config) {
|
|
6
|
+
return new CheckpointService({ root, config });
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate + plan EVERY member up front (R3 gate #1). Loads each root's checkpoint
|
|
10
|
+
* and computes its rollback plan/preview; the first member that cannot be loaded
|
|
11
|
+
* throws `CRUXY_E_CHECKPOINT_SET_INCOMPLETE` — before any filesystem apply — so a
|
|
12
|
+
* partial rollback can never masquerade as success.
|
|
13
|
+
*/
|
|
14
|
+
export async function validateSet(set, config, createService = defaultCreate) {
|
|
15
|
+
const validated = [];
|
|
16
|
+
for (const member of set.members) {
|
|
17
|
+
const svc = createService(member.rootPath, config);
|
|
18
|
+
let checkpoint;
|
|
19
|
+
try {
|
|
20
|
+
checkpoint = await svc.read(member.checkpointId);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
throw checkpointSetIncomplete(set.runId, `root "${member.rootName}" checkpoint ${member.checkpointId} is missing or unreadable ` +
|
|
24
|
+
`(${err.message})`);
|
|
25
|
+
}
|
|
26
|
+
const store = createCheckpointStore(member.rootPath, checkpoint.store);
|
|
27
|
+
const plan = await computeRollbackPlan(member.rootPath, checkpoint, store, isGitWorkTree(member.rootPath));
|
|
28
|
+
const preview = await buildRollbackPreview(member.rootPath, plan, store);
|
|
29
|
+
validated.push({
|
|
30
|
+
rootName: member.rootName,
|
|
31
|
+
rootPath: member.rootPath,
|
|
32
|
+
checkpointId: member.checkpointId,
|
|
33
|
+
plan,
|
|
34
|
+
store,
|
|
35
|
+
preview,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return validated;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One combined preview grouped by root (⚖︎JC-ι): each root keeps its own file diffs
|
|
42
|
+
* and external-change warnings, so a single U.3 approval covers the whole set.
|
|
43
|
+
*/
|
|
44
|
+
export function buildSetPreview(set, members) {
|
|
45
|
+
return {
|
|
46
|
+
type: "rollback-set",
|
|
47
|
+
runId: set.runId,
|
|
48
|
+
createdAt: set.createdAt,
|
|
49
|
+
runSummary: set.runSummary,
|
|
50
|
+
roots: members.map((m) => ({
|
|
51
|
+
rootName: m.rootName,
|
|
52
|
+
checkpointId: m.checkpointId,
|
|
53
|
+
files: m.preview.files,
|
|
54
|
+
externalPaths: m.preview.externalPaths,
|
|
55
|
+
attributionUnknown: m.preview.attributionUnknown,
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Apply the validated set sequentially, stopping on the first failure (R3/⚖︎#8).
|
|
61
|
+
* On any member's failure this throws `CRUXY_E_CHECKPOINT_SET_PARTIAL` with the
|
|
62
|
+
* restored-vs-not split; re-running (which recomputes each plan from disk) safely
|
|
63
|
+
* finishes the job.
|
|
64
|
+
*/
|
|
65
|
+
export async function applySet(set, members) {
|
|
66
|
+
return applySetRollback(set.runId, members.map((m) => ({
|
|
67
|
+
rootName: m.rootName,
|
|
68
|
+
restore: () => applyRollback(m.rootPath, m.plan, m.store),
|
|
69
|
+
})));
|
|
70
|
+
}
|
|
71
|
+
/** Is a validated set a no-op (every member's plan is empty)? */
|
|
72
|
+
export function setIsNoop(members) {
|
|
73
|
+
return members.every((m) => m.plan.entries.length === 0);
|
|
74
|
+
}
|
|
@@ -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);
|