@cruxy/cli 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/approval/classify.js +25 -3
  2. package/dist/approval/policy.d.ts +6 -0
  3. package/dist/approval/policy.js +15 -3
  4. package/dist/approval/prompt.js +11 -0
  5. package/dist/approval/types.d.ts +8 -1
  6. package/dist/checkpoint/gate.d.ts +65 -0
  7. package/dist/checkpoint/gate.js +86 -0
  8. package/dist/checkpoint/index.d.ts +3 -0
  9. package/dist/checkpoint/index.js +3 -0
  10. package/dist/checkpoint/set-rollback.d.ts +51 -0
  11. package/dist/checkpoint/set-rollback.js +74 -0
  12. package/dist/checkpoint/set.d.ts +44 -0
  13. package/dist/checkpoint/set.js +142 -0
  14. package/dist/checkpoint/types.d.ts +47 -0
  15. package/dist/cli/commands/rollback.d.ts +11 -6
  16. package/dist/cli/commands/rollback.js +93 -33
  17. package/dist/cli/commands/run.js +59 -10
  18. package/dist/cli/onboard.js +4 -1
  19. package/dist/cli/repl.d.ts +2 -2
  20. package/dist/cli/session-factory.d.ts +4 -3
  21. package/dist/cli/session-factory.js +98 -12
  22. package/dist/errors/constructors.d.ts +65 -0
  23. package/dist/errors/constructors.js +168 -0
  24. package/dist/errors/types.d.ts +46 -0
  25. package/dist/errors/types.js +64 -0
  26. package/dist/indexing/retriever.d.ts +29 -0
  27. package/dist/indexing/retriever.js +26 -0
  28. package/dist/indexing/service.js +3 -1
  29. package/dist/indexing/types.d.ts +7 -0
  30. package/dist/lsp/tools/common.d.ts +34 -7
  31. package/dist/lsp/tools/common.js +33 -11
  32. package/dist/lsp/tools/find-definition.js +2 -2
  33. package/dist/lsp/tools/find-references.js +10 -4
  34. package/dist/lsp/tools/get-diagnostics.js +6 -4
  35. package/dist/render/diff.js +42 -5
  36. package/dist/sandbox/docker-runtime.js +4 -1
  37. package/dist/sandbox/policy.d.ts +12 -3
  38. package/dist/sandbox/policy.js +17 -3
  39. package/dist/sandbox/types.d.ts +10 -1
  40. package/dist/subagent/orchestrator.d.ts +15 -0
  41. package/dist/subagent/orchestrator.js +2 -0
  42. package/dist/testing/run-tests-tool.js +3 -0
  43. package/dist/tools/create-pull-request.d.ts +3 -0
  44. package/dist/tools/create-pull-request.js +50 -4
  45. package/dist/tools/file/apply-patch.js +2 -2
  46. package/dist/tools/file/edit-file.js +2 -2
  47. package/dist/tools/file/glob.d.ts +9 -2
  48. package/dist/tools/file/glob.js +73 -19
  49. package/dist/tools/file/grep-files.d.ts +12 -2
  50. package/dist/tools/file/grep-files.js +113 -38
  51. package/dist/tools/file/paths.d.ts +123 -17
  52. package/dist/tools/file/paths.js +158 -50
  53. package/dist/tools/file/read-file.js +2 -2
  54. package/dist/tools/file/write-file.js +2 -2
  55. package/dist/tools/git-status.d.ts +8 -1
  56. package/dist/tools/git-status.js +43 -11
  57. package/dist/tools/list-files.d.ts +9 -3
  58. package/dist/tools/list-files.js +48 -13
  59. package/dist/tools/search-codebase.d.ts +10 -0
  60. package/dist/tools/search-codebase.js +117 -14
  61. package/dist/tools/shell/exec.js +8 -1
  62. package/dist/tools/types.d.ts +63 -1
  63. package/dist/vcs/git.d.ts +8 -0
  64. package/dist/vcs/git.js +14 -0
  65. package/dist/vcs/github.d.ts +7 -1
  66. package/dist/vcs/github.js +10 -1
  67. package/dist/vcs/service.d.ts +8 -0
  68. package/dist/vcs/service.js +33 -1
  69. package/dist/vcs/types.d.ts +18 -2
  70. package/dist/workspace/index.d.ts +5 -0
  71. package/dist/workspace/index.js +3 -0
  72. package/dist/workspace/resolve.d.ts +54 -0
  73. package/dist/workspace/resolve.js +96 -0
  74. package/dist/workspace/select.d.ts +41 -0
  75. package/dist/workspace/select.js +44 -0
  76. package/dist/workspace/types.d.ts +30 -0
  77. package/dist/workspace/types.js +15 -0
  78. package/dist/workspace/workspace.d.ts +56 -0
  79. package/dist/workspace/workspace.js +180 -0
  80. package/package.json +1 -1
@@ -62,7 +62,7 @@ function shellRequest(action, root) {
62
62
  // scope is the program token. Complex commands get `none` (approve-once only).
63
63
  const tokens = commandTokens(command);
64
64
  const scope = tokens
65
- ? { kind: "shell-prefix", token: tokens[0] }
65
+ ? { kind: "shell-prefix", token: tokens[0], root }
66
66
  : { kind: "none" };
67
67
  return {
68
68
  action,
@@ -86,7 +86,9 @@ function testRequest(action, root) {
86
86
  return {
87
87
  action,
88
88
  tier: "destructive",
89
- scope: command === "" ? { kind: "none" } : { kind: "shell-exact", command },
89
+ scope: command === ""
90
+ ? { kind: "none" }
91
+ : { kind: "shell-exact", command, root },
90
92
  summary: `run tests: ${command}`,
91
93
  targets: [],
92
94
  cwd: root,
@@ -119,6 +121,24 @@ function vcsRequest(action, root) {
119
121
  * carries the full blast radius; the summary names the checkpoint.
120
122
  */
121
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
+ }
122
142
  const preview = action.preview?.type === "rollback" ? action.preview : undefined;
123
143
  const summary = preview
124
144
  ? `rollback: restore checkpoint ${preview.checkpointId} (${preview.files.length} file${preview.files.length === 1 ? "" : "s"})`
@@ -150,7 +170,9 @@ function mcpRequest(action, root) {
150
170
  return {
151
171
  action,
152
172
  tier: "destructive",
153
- scope: grantable ? { kind: "mcp-tool", server, tool } : { kind: "none" },
173
+ scope: grantable
174
+ ? { kind: "mcp-tool", server, tool, root }
175
+ : { kind: "none" },
154
176
  summary: `call MCP tool ${tool || "(unknown)"} on server ${server || "(unknown)"}`,
155
177
  targets: [],
156
178
  cwd: root,
@@ -20,6 +20,12 @@ export declare class SessionAllowlist {
20
20
  * ({@link commandTokens}) and its program token must equal the granted token —
21
21
  * so a `git` grant never matches `git push && rm -rf /`. File: every target must
22
22
  * resolve inside the granted subtree.
23
+ *
24
+ * Multi-repo (C.26): `shell-prefix`, `shell-exact`, and `mcp-tool` grants are
25
+ * additionally **bound to the root they were taken in** — the grant only covers a
26
+ * request whose `cwd` is the same root. So "allow `git` this session" in repo A
27
+ * never auto-approves `git` in repo B. `file-subtree` needs no such check: it is
28
+ * an absolute path, so a different root is already a different subtree.
23
29
  */
24
30
  export declare function scopeCovers(scope: Exclude<Scope, {
25
31
  kind: "none";
@@ -30,28 +30,40 @@ export class SessionAllowlist {
30
30
  * ({@link commandTokens}) and its program token must equal the granted token —
31
31
  * so a `git` grant never matches `git push && rm -rf /`. File: every target must
32
32
  * resolve inside the granted subtree.
33
+ *
34
+ * Multi-repo (C.26): `shell-prefix`, `shell-exact`, and `mcp-tool` grants are
35
+ * additionally **bound to the root they were taken in** — the grant only covers a
36
+ * request whose `cwd` is the same root. So "allow `git` this session" in repo A
37
+ * never auto-approves `git` in repo B. `file-subtree` needs no such check: it is
38
+ * an absolute path, so a different root is already a different subtree.
33
39
  */
34
40
  export function scopeCovers(scope, request) {
35
41
  if (scope.kind === "shell-prefix") {
36
42
  if (request.action.kind !== "shell")
37
43
  return false;
44
+ if (scope.root !== request.cwd)
45
+ return false; // C.26: same root only
38
46
  const tokens = commandTokens(request.action.command ?? "");
39
47
  return tokens !== null && tokens[0] === scope.token;
40
48
  }
41
49
  if (scope.kind === "shell-exact") {
42
50
  // Test grants (C.13): the exact command string, test actions only — a
43
- // grant for `pnpm test` can never cover run_command or any other command.
51
+ // grant for `pnpm test` can never cover run_command or any other command,
52
+ // and (C.26) never a test in a different root.
44
53
  return (request.action.kind === "test" &&
54
+ scope.root === request.cwd &&
45
55
  (request.action.command ?? "").trim() === scope.command);
46
56
  }
47
57
  if (scope.kind === "mcp-tool") {
48
58
  // MCP grants (C.27): the exact server+tool pair, mcp actions only — a grant
49
- // for one server's tool can never cover another tool or another server.
59
+ // for one server's tool can never cover another tool, another server, or
60
+ // (C.26) the same tool invoked from a different root.
50
61
  return (request.action.kind === "mcp" &&
62
+ scope.root === request.cwd &&
51
63
  request.action.server === scope.server &&
52
64
  request.action.tool === scope.tool);
53
65
  }
54
- // file-subtree
66
+ // file-subtree — absolute path, inherently root-scoped.
55
67
  return (request.targets.length > 0 &&
56
68
  request.targets.every((t) => isInside(scope.root, t)));
57
69
  }
@@ -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. */
@@ -17,20 +17,26 @@ export type RiskTier = "read" | "mutate" | "destructive";
17
17
  * The tight scope a session grant is keyed by. Never blanket.
18
18
  * - `shell-prefix` — a command's leading program token (e.g. `git`); only ever
19
19
  * matches commands we can *positively* prove are simple (no shell features).
20
+ * **Bound to `root`** (C.26): a `git` grant in repo A never covers `git` in B.
20
21
  * - `shell-exact` — one exact command string, for `test` actions only (C.13):
21
22
  * a grant covers re-runs of precisely that test command, nothing else.
23
+ * **Bound to `root`**: a `pnpm test` grant in A never covers B.
22
24
  * - `file-subtree` — an absolute directory (or, under the root-cap, an exact
23
- * file path); matches targets that resolve inside it.
25
+ * file path); matches targets that resolve inside it. Inherently root-safe —
26
+ * different roots are different absolute subtrees, so it needs no `root` field.
24
27
  * - `mcp-tool` — one exact MCP server+tool pair (C.27): a grant covers re-calls
25
28
  * of precisely that tool on that server, and never any other MCP tool.
29
+ * **Bound to `root`**: the same server+tool in another root still prompts.
26
30
  * - `none` — nothing safe to grant (e.g. a multi-file patch spanning the root).
27
31
  */
28
32
  export type Scope = {
29
33
  readonly kind: "shell-prefix";
30
34
  readonly token: string;
35
+ readonly root: string;
31
36
  } | {
32
37
  readonly kind: "shell-exact";
33
38
  readonly command: string;
39
+ readonly root: string;
34
40
  } | {
35
41
  readonly kind: "file-subtree";
36
42
  readonly root: string;
@@ -38,6 +44,7 @@ export type Scope = {
38
44
  readonly kind: "mcp-tool";
39
45
  readonly server: string;
40
46
  readonly tool: string;
47
+ readonly root: string;
41
48
  } | {
42
49
  readonly kind: "none";
43
50
  };
@@ -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
+ }
@@ -4,3 +4,6 @@ export * from "./git-store.js";
4
4
  export * from "./shadow-store.js";
5
5
  export * from "./restore.js";
6
6
  export * from "./service.js";
7
+ export * from "./set.js";
8
+ export * from "./set-rollback.js";
9
+ export * from "./gate.js";
@@ -4,3 +4,6 @@ export * from "./git-store.js";
4
4
  export * from "./shadow-store.js";
5
5
  export * from "./restore.js";
6
6
  export * from "./service.js";
7
+ export * from "./set.js";
8
+ export * from "./set-rollback.js";
9
+ export * from "./gate.js";
@@ -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
+ }
@@ -0,0 +1,44 @@
1
+ import type { CheckpointSet, RollbackApplied, SetRollbackApplied } from "./types.js";
2
+ /**
3
+ * Multi-root rollback sets (C.26). A run that mutates N repos produces one
4
+ * per-root checkpoint plus a {@link CheckpointSet} that ties them together, so
5
+ * `cruxy rollback` restores exactly the touched roots as one gated operation.
6
+ *
7
+ * Two guarantees live here:
8
+ * • **exactly the touched roots** — the applier iterates ONLY `set.members`, so
9
+ * an untouched root (absent from the set) is never opened;
10
+ * • **stop-and-report, never silent partial** (R3) — validate every member up
11
+ * front (missing/corrupt → `CHECKPOINT_SET_INCOMPLETE`), then apply
12
+ * sequentially and STOP on the first failure, throwing
13
+ * `CHECKPOINT_SET_PARTIAL` with the exact restored-vs-not split.
14
+ */
15
+ /** `run-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
16
+ export declare function newRunId(): string;
17
+ /** The set-manifest directory under the PRIMARY root (⚖︎#7). */
18
+ export declare function setDir(primaryRoot: string): string;
19
+ /** Persist a set manifest (atomic temp-then-rename), self-ignoring from git. */
20
+ export declare function writeSet(primaryRoot: string, set: CheckpointSet): Promise<void>;
21
+ /** Read a set manifest, or throw `CHECKPOINT_SET_INCOMPLETE` if missing/corrupt. */
22
+ export declare function readSet(primaryRoot: string, runId: string): Promise<CheckpointSet>;
23
+ /** List all set manifests under the primary root, newest first. */
24
+ export declare function listSets(primaryRoot: string): Promise<CheckpointSet[]>;
25
+ /**
26
+ * One root's rollback, pre-validated (its plan already computed): `restore`
27
+ * actually applies it. The orchestrator builds one per member.
28
+ */
29
+ export interface MemberRollback {
30
+ rootName: string;
31
+ restore: () => Promise<RollbackApplied>;
32
+ }
33
+ /**
34
+ * Apply an all-roots rollback: run each member's `restore` in order and STOP on
35
+ * the first failure (R3). On success, returns the restored-vs-per-root summary.
36
+ * On failure, throws `CHECKPOINT_SET_PARTIAL` naming the roots restored (before
37
+ * the failure) and those not restored (the failing one + all not-yet-attempted)
38
+ * — so a partial rollback can never be reported as success, and re-running (which
39
+ * recomputes each root from disk) safely finishes the job.
40
+ *
41
+ * The applier only ever touches roots present in `rollbacks`; an untouched root
42
+ * (never added to the set) is structurally impossible to open here.
43
+ */
44
+ export declare function applySetRollback(runId: string, rollbacks: readonly MemberRollback[]): Promise<SetRollbackApplied>;
@@ -0,0 +1,142 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { promises as fsp } from "node:fs";
3
+ import path from "node:path";
4
+ import { checkpointSetIncomplete, checkpointSetPartial, } from "../errors/index.js";
5
+ /**
6
+ * Multi-root rollback sets (C.26). A run that mutates N repos produces one
7
+ * per-root checkpoint plus a {@link CheckpointSet} that ties them together, so
8
+ * `cruxy rollback` restores exactly the touched roots as one gated operation.
9
+ *
10
+ * Two guarantees live here:
11
+ * • **exactly the touched roots** — the applier iterates ONLY `set.members`, so
12
+ * an untouched root (absent from the set) is never opened;
13
+ * • **stop-and-report, never silent partial** (R3) — validate every member up
14
+ * front (missing/corrupt → `CHECKPOINT_SET_INCOMPLETE`), then apply
15
+ * sequentially and STOP on the first failure, throwing
16
+ * `CHECKPOINT_SET_PARTIAL` with the exact restored-vs-not split.
17
+ */
18
+ /** `run-<utc-stamp>-<rand>` — sortable, collision-safe enough for a local CLI. */
19
+ export function newRunId() {
20
+ const stamp = new Date()
21
+ .toISOString()
22
+ .replace(/[-:]/g, "")
23
+ .replace(/\..+$/, "");
24
+ return `run-${stamp}-${randomBytes(2).toString("hex")}`;
25
+ }
26
+ /** The set-manifest directory under the PRIMARY root (⚖︎#7). */
27
+ export function setDir(primaryRoot) {
28
+ return path.join(primaryRoot, ".cruxy", "checkpoints", "sets");
29
+ }
30
+ /** Persist a set manifest (atomic temp-then-rename), self-ignoring from git. */
31
+ export async function writeSet(primaryRoot, set) {
32
+ const dir = setDir(primaryRoot);
33
+ await fsp.mkdir(dir, { recursive: true });
34
+ // The parent checkpoints/ dir already carries a `*` .gitignore; add one here
35
+ // too so a set manifest is never seen by git even if the layout changes.
36
+ const ignoreFile = path.join(dir, ".gitignore");
37
+ try {
38
+ await fsp.access(ignoreFile);
39
+ }
40
+ catch {
41
+ await fsp.writeFile(ignoreFile, "*\n");
42
+ }
43
+ const file = path.join(dir, `${set.runId}.json`);
44
+ const tmp = `${file}.tmp-${process.pid}`;
45
+ await fsp.writeFile(tmp, `${JSON.stringify(set, null, 2)}\n`);
46
+ await fsp.rename(tmp, file);
47
+ }
48
+ /** Read a set manifest, or throw `CHECKPOINT_SET_INCOMPLETE` if missing/corrupt. */
49
+ export async function readSet(primaryRoot, runId) {
50
+ const file = path.join(setDir(primaryRoot), `${runId}.json`);
51
+ let raw;
52
+ try {
53
+ raw = await fsp.readFile(file, "utf8");
54
+ }
55
+ catch {
56
+ throw checkpointSetIncomplete(runId, `the set manifest is missing or unreadable (${file})`);
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ }
62
+ catch {
63
+ throw checkpointSetIncomplete(runId, "the set manifest is not valid JSON");
64
+ }
65
+ if (!isSetShape(parsed)) {
66
+ throw checkpointSetIncomplete(runId, "the set manifest is malformed");
67
+ }
68
+ return parsed;
69
+ }
70
+ /** List all set manifests under the primary root, newest first. */
71
+ export async function listSets(primaryRoot) {
72
+ const dir = setDir(primaryRoot);
73
+ let names;
74
+ try {
75
+ names = await fsp.readdir(dir);
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ const sets = [];
81
+ for (const name of names) {
82
+ if (!name.endsWith(".json"))
83
+ continue;
84
+ try {
85
+ const parsed = JSON.parse(await fsp.readFile(path.join(dir, name), "utf8"));
86
+ if (isSetShape(parsed))
87
+ sets.push(parsed);
88
+ }
89
+ catch {
90
+ // A corrupt individual manifest is skipped in a listing (it fails loud
91
+ // only when that specific run is rolled back).
92
+ }
93
+ }
94
+ return sets.sort((a, b) => b.runId.localeCompare(a.runId));
95
+ }
96
+ /**
97
+ * Apply an all-roots rollback: run each member's `restore` in order and STOP on
98
+ * the first failure (R3). On success, returns the restored-vs-per-root summary.
99
+ * On failure, throws `CHECKPOINT_SET_PARTIAL` naming the roots restored (before
100
+ * the failure) and those not restored (the failing one + all not-yet-attempted)
101
+ * — so a partial rollback can never be reported as success, and re-running (which
102
+ * recomputes each root from disk) safely finishes the job.
103
+ *
104
+ * The applier only ever touches roots present in `rollbacks`; an untouched root
105
+ * (never added to the set) is structurally impossible to open here.
106
+ */
107
+ export async function applySetRollback(runId, rollbacks) {
108
+ const restored = [];
109
+ const perRoot = {};
110
+ for (let i = 0; i < rollbacks.length; i++) {
111
+ const { rootName, restore } = rollbacks[i];
112
+ try {
113
+ perRoot[rootName] = await restore();
114
+ restored.push(rootName);
115
+ }
116
+ catch (err) {
117
+ const notRestored = rollbacks.slice(i).map((r) => r.rootName);
118
+ throw checkpointSetPartial(runId, restored, notRestored, err);
119
+ }
120
+ }
121
+ return { runId, restored, perRoot };
122
+ }
123
+ /** Structural check for a parsed set manifest — enough to fail loud on corruption. */
124
+ function isSetShape(value) {
125
+ if (typeof value !== "object" || value === null)
126
+ return false;
127
+ const v = value;
128
+ if (typeof v.runId !== "string" ||
129
+ typeof v.createdAt !== "string" ||
130
+ typeof v.runSummary !== "string" ||
131
+ !Array.isArray(v.members)) {
132
+ return false;
133
+ }
134
+ return v.members.every((m) => {
135
+ if (typeof m !== "object" || m === null)
136
+ return false;
137
+ const mm = m;
138
+ return (typeof mm.rootName === "string" &&
139
+ typeof mm.rootPath === "string" &&
140
+ typeof mm.checkpointId === "string");
141
+ });
142
+ }