@cruxy/cli 0.7.0 → 0.9.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 (78) hide show
  1. package/README.md +46 -13
  2. package/dist/agent/loop.d.ts +35 -6
  3. package/dist/agent/loop.js +84 -10
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +8 -0
  6. package/dist/agent/session.d.ts +6 -4
  7. package/dist/agent/session.js +6 -5
  8. package/dist/approval/classify.js +26 -0
  9. package/dist/approval/prompt.d.ts +9 -0
  10. package/dist/approval/prompt.js +2 -77
  11. package/dist/checkpoint/capture.d.ts +17 -0
  12. package/dist/checkpoint/capture.js +73 -0
  13. package/dist/checkpoint/git-store.d.ts +61 -0
  14. package/dist/checkpoint/git-store.js +171 -0
  15. package/dist/checkpoint/index.d.ts +6 -0
  16. package/dist/checkpoint/index.js +6 -0
  17. package/dist/checkpoint/restore.d.ts +23 -0
  18. package/dist/checkpoint/restore.js +195 -0
  19. package/dist/checkpoint/service.d.ts +80 -0
  20. package/dist/checkpoint/service.js +276 -0
  21. package/dist/checkpoint/shadow-store.d.ts +23 -0
  22. package/dist/checkpoint/shadow-store.js +93 -0
  23. package/dist/checkpoint/types.d.ts +117 -0
  24. package/dist/checkpoint/types.js +18 -0
  25. package/dist/cli/commands/checkpoint.d.ts +7 -0
  26. package/dist/cli/commands/checkpoint.js +31 -0
  27. package/dist/cli/commands/rollback.d.ts +10 -0
  28. package/dist/cli/commands/rollback.js +51 -0
  29. package/dist/cli/commands/run.js +24 -10
  30. package/dist/cli/onboard.js +9 -4
  31. package/dist/cli/program.js +4 -0
  32. package/dist/cli/repl.d.ts +10 -4
  33. package/dist/cli/repl.js +26 -12
  34. package/dist/cli/session-factory.d.ts +15 -1
  35. package/dist/cli/session-factory.js +104 -18
  36. package/dist/config/schema.d.ts +133 -0
  37. package/dist/config/schema.js +40 -0
  38. package/dist/errors/constructors.d.ts +25 -0
  39. package/dist/errors/constructors.js +86 -0
  40. package/dist/errors/types.d.ts +7 -0
  41. package/dist/errors/types.js +16 -0
  42. package/dist/indexing/walker.d.ts +11 -0
  43. package/dist/indexing/walker.js +11 -6
  44. package/dist/plan/execute.d.ts +8 -0
  45. package/dist/plan/execute.js +36 -22
  46. package/dist/plan/service.d.ts +2 -1
  47. package/dist/plan/service.js +7 -3
  48. package/dist/plan/submit-plan.d.ts +4 -4
  49. package/dist/render/capabilities.d.ts +12 -0
  50. package/dist/render/capabilities.js +27 -0
  51. package/dist/render/diff.d.ts +19 -0
  52. package/dist/render/diff.js +107 -0
  53. package/dist/render/highlight.d.ts +47 -0
  54. package/dist/render/highlight.js +265 -0
  55. package/dist/render/index.d.ts +15 -0
  56. package/dist/render/index.js +21 -0
  57. package/dist/render/plain-renderer.d.ts +38 -0
  58. package/dist/render/plain-renderer.js +87 -0
  59. package/dist/render/state.d.ts +31 -0
  60. package/dist/render/state.js +83 -0
  61. package/dist/render/tty-renderer.d.ts +83 -0
  62. package/dist/render/tty-renderer.js +276 -0
  63. package/dist/render/types.d.ts +160 -0
  64. package/dist/render/types.js +1 -0
  65. package/dist/subagent/budget.d.ts +34 -0
  66. package/dist/subagent/budget.js +57 -0
  67. package/dist/subagent/index.d.ts +5 -0
  68. package/dist/subagent/index.js +5 -0
  69. package/dist/subagent/orchestrator.d.ts +67 -0
  70. package/dist/subagent/orchestrator.js +241 -0
  71. package/dist/subagent/registry-scope.d.ts +28 -0
  72. package/dist/subagent/registry-scope.js +63 -0
  73. package/dist/subagent/spawn-tool.d.ts +29 -0
  74. package/dist/subagent/spawn-tool.js +94 -0
  75. package/dist/subagent/types.d.ts +55 -0
  76. package/dist/subagent/types.js +1 -0
  77. package/dist/tools/types.d.ts +20 -2
  78. package/package.json +1 -1
@@ -0,0 +1,61 @@
1
+ import type { CaptureFile, CheckpointStore, FileEntry } from "./types.js";
2
+ /**
3
+ * Git-object-backed checkpoint content store (C.32 primary substrate).
4
+ *
5
+ * How a snapshot works — and exactly why it can never disturb the user's git
6
+ * state. Every git invocation is one of these five, and nothing else:
7
+ *
8
+ * 1. `git rev-parse --absolute-git-dir` (read-only)
9
+ * 2. `git update-index --add -z --stdin`, with `GIT_INDEX_FILE` pointing at a
10
+ * **temporary index file in os.tmpdir()**. Git hashes each captured file
11
+ * into `.git/objects` as loose blobs and stages them *in the temp index*.
12
+ * The user's `.git/index` is never opened for writing.
13
+ * 3. `git write-tree`, same `GIT_INDEX_FILE` — writes tree objects, returns
14
+ * the root tree oid.
15
+ * 4. `git ls-files --stage -z`, same `GIT_INDEX_FILE` — reads back
16
+ * `mode oid stage\tpath` per file for the manifest. The temp index is then
17
+ * deleted.
18
+ * 5. `git cat-file blob <oid>` on restore/preview (read-only)
19
+ *
20
+ * No `commit-tree`, no `update-ref`, no `stash`, no branch: **no ref is ever
21
+ * created or moved, and HEAD / the index / the stash are never written.** The
22
+ * blobs and trees are deliberately *dangling* — referenced only by the manifest
23
+ * JSON under `.cruxy/checkpoints/`.
24
+ *
25
+ * Enforced, not assumed: {@link snapshot} fingerprints the user-visible state
26
+ * (raw `.git/HEAD` bytes, a hash of `.git/index`, `git for-each-ref`, and
27
+ * `git stash list`) before and after, and throws CRUXY_E_CHECKPOINT_FAILED
28
+ * naming what moved if anything drifted.
29
+ *
30
+ * Accepted trade-off: dangling objects are subject to `git gc --prune`. The
31
+ * default two-week grace plus retention pruning makes that a non-issue, but a
32
+ * user running `git gc --prune=now` can orphan a manifest — then rollback fails
33
+ * loudly (never restores partial state), and the shadow store remains the
34
+ * fallback substrate for new checkpoints.
35
+ */
36
+ export declare class GitCheckpointStore implements CheckpointStore {
37
+ private readonly root;
38
+ readonly kind: "git";
39
+ constructor(root: string);
40
+ /** Git blob sha-1: `sha1("blob <len>\0" + content)` — matches `git hash-object`. */
41
+ hashContent(content: Buffer): string;
42
+ snapshot(files: CaptureFile[]): Promise<FileEntry[]>;
43
+ readContent(entry: FileEntry): Promise<Buffer>;
44
+ /** Dangling objects belong to git's own gc; nothing for us to sweep. */
45
+ collect(): Promise<void>;
46
+ /** Parse `git ls-files --stage -z` from the temp index into manifest entries. */
47
+ private readStagedEntries;
48
+ /**
49
+ * Run git with binary-safe stdout (restore must round-trip arbitrary bytes),
50
+ * optional stdin, and optional extra env. Local to this store on purpose —
51
+ * `runGitCapture` is text-mode and env-less, and the rest of the CLI should
52
+ * stay that way.
53
+ */
54
+ private git;
55
+ /**
56
+ * Everything a user can observe of their git state, byte-for-byte: HEAD file,
57
+ * index contents, every ref (branches, tags, remotes, stash tip — packed or
58
+ * loose, via for-each-ref), and the full stash reflog (stash list).
59
+ */
60
+ private fingerprint;
61
+ }
@@ -0,0 +1,171 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { promises as fsp, readFileSync, existsSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { checkpointFailed } from "../errors/index.js";
7
+ import { runGitCapture } from "../vcs/git.js";
8
+ /**
9
+ * Git-object-backed checkpoint content store (C.32 primary substrate).
10
+ *
11
+ * How a snapshot works — and exactly why it can never disturb the user's git
12
+ * state. Every git invocation is one of these five, and nothing else:
13
+ *
14
+ * 1. `git rev-parse --absolute-git-dir` (read-only)
15
+ * 2. `git update-index --add -z --stdin`, with `GIT_INDEX_FILE` pointing at a
16
+ * **temporary index file in os.tmpdir()**. Git hashes each captured file
17
+ * into `.git/objects` as loose blobs and stages them *in the temp index*.
18
+ * The user's `.git/index` is never opened for writing.
19
+ * 3. `git write-tree`, same `GIT_INDEX_FILE` — writes tree objects, returns
20
+ * the root tree oid.
21
+ * 4. `git ls-files --stage -z`, same `GIT_INDEX_FILE` — reads back
22
+ * `mode oid stage\tpath` per file for the manifest. The temp index is then
23
+ * deleted.
24
+ * 5. `git cat-file blob <oid>` on restore/preview (read-only)
25
+ *
26
+ * No `commit-tree`, no `update-ref`, no `stash`, no branch: **no ref is ever
27
+ * created or moved, and HEAD / the index / the stash are never written.** The
28
+ * blobs and trees are deliberately *dangling* — referenced only by the manifest
29
+ * JSON under `.cruxy/checkpoints/`.
30
+ *
31
+ * Enforced, not assumed: {@link snapshot} fingerprints the user-visible state
32
+ * (raw `.git/HEAD` bytes, a hash of `.git/index`, `git for-each-ref`, and
33
+ * `git stash list`) before and after, and throws CRUXY_E_CHECKPOINT_FAILED
34
+ * naming what moved if anything drifted.
35
+ *
36
+ * Accepted trade-off: dangling objects are subject to `git gc --prune`. The
37
+ * default two-week grace plus retention pruning makes that a non-issue, but a
38
+ * user running `git gc --prune=now` can orphan a manifest — then rollback fails
39
+ * loudly (never restores partial state), and the shadow store remains the
40
+ * fallback substrate for new checkpoints.
41
+ */
42
+ export class GitCheckpointStore {
43
+ root;
44
+ kind = "git";
45
+ constructor(root) {
46
+ this.root = root;
47
+ }
48
+ /** Git blob sha-1: `sha1("blob <len>\0" + content)` — matches `git hash-object`. */
49
+ hashContent(content) {
50
+ return createHash("sha1")
51
+ .update(`blob ${content.length}\0`)
52
+ .update(content)
53
+ .digest("hex");
54
+ }
55
+ async snapshot(files) {
56
+ const before = this.fingerprint();
57
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "cruxy-checkpoint-"));
58
+ const indexFile = path.join(tmpDir, "index");
59
+ try {
60
+ const env = { GIT_INDEX_FILE: indexFile };
61
+ // Stage every captured path into the temp index (writes blobs).
62
+ const stdin = files.map((f) => `${f.path}\0`).join("");
63
+ const add = this.git(["update-index", "--add", "-z", "--stdin"], {
64
+ env,
65
+ input: stdin,
66
+ });
67
+ if (!add.ok) {
68
+ throw checkpointFailed(`staging files into the temporary index failed: ${add.stderr.trim()}`);
69
+ }
70
+ // Persist the tree objects. The oid itself isn't stored — per-file blob
71
+ // oids from ls-files --stage are what restore needs — but write-tree is
72
+ // what makes the whole snapshot a single connected object graph.
73
+ const tree = this.git(["write-tree"], { env });
74
+ if (!tree.ok) {
75
+ throw checkpointFailed(`writing the snapshot tree failed: ${tree.stderr.trim()}`);
76
+ }
77
+ const entries = this.readStagedEntries(env);
78
+ const after = this.fingerprint();
79
+ assertGitStateUnchanged(before, after);
80
+ return entries;
81
+ }
82
+ finally {
83
+ await fsp.rm(tmpDir, { recursive: true, force: true });
84
+ }
85
+ }
86
+ async readContent(entry) {
87
+ const res = this.git(["cat-file", "blob", entry.oid]);
88
+ if (!res.ok) {
89
+ throw checkpointFailed(`checkpoint content for ${entry.path} is gone from the git object database ` +
90
+ `(blob ${entry.oid}) — most likely pruned by \`git gc --prune\``);
91
+ }
92
+ return res.stdout;
93
+ }
94
+ /** Dangling objects belong to git's own gc; nothing for us to sweep. */
95
+ async collect() {
96
+ /* no-op by design */
97
+ }
98
+ // ── internals ───────────────────────────────────────────────────────────────
99
+ /** Parse `git ls-files --stage -z` from the temp index into manifest entries. */
100
+ readStagedEntries(env) {
101
+ const res = this.git(["ls-files", "--stage", "-z"], { env });
102
+ if (!res.ok) {
103
+ throw checkpointFailed(`reading back the staged snapshot failed: ${res.stderr.trim()}`);
104
+ }
105
+ const entries = [];
106
+ for (const record of res.stdout.toString("utf8").split("\0")) {
107
+ if (record === "")
108
+ continue;
109
+ // "<mode> <oid> <stage>\t<path>"
110
+ const tab = record.indexOf("\t");
111
+ const [mode, oid] = record.slice(0, tab).split(" ");
112
+ const relPath = record.slice(tab + 1);
113
+ if (mode !== "100644" && mode !== "100755") {
114
+ // Capture filters to regular files, so anything else staging here is a
115
+ // bug or a race — refuse to write a manifest we can't faithfully restore.
116
+ throw checkpointFailed(`unsupported file mode ${mode} for ${relPath} in the snapshot`);
117
+ }
118
+ entries.push({ path: relPath, mode, oid });
119
+ }
120
+ return entries;
121
+ }
122
+ /**
123
+ * Run git with binary-safe stdout (restore must round-trip arbitrary bytes),
124
+ * optional stdin, and optional extra env. Local to this store on purpose —
125
+ * `runGitCapture` is text-mode and env-less, and the rest of the CLI should
126
+ * stay that way.
127
+ */
128
+ git(args, opts = {}) {
129
+ const res = spawnSync("git", args, {
130
+ cwd: this.root,
131
+ windowsHide: true,
132
+ maxBuffer: 1024 * 1024 * 1024,
133
+ input: opts.input,
134
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
135
+ });
136
+ const stdout = Buffer.isBuffer(res.stdout) ? res.stdout : Buffer.alloc(0);
137
+ const stderr = (res.stderr?.toString("utf8") ?? "") ||
138
+ (res.error ? res.error.message : "");
139
+ return { ok: res.status === 0 && !res.error, stdout, stderr };
140
+ }
141
+ /**
142
+ * Everything a user can observe of their git state, byte-for-byte: HEAD file,
143
+ * index contents, every ref (branches, tags, remotes, stash tip — packed or
144
+ * loose, via for-each-ref), and the full stash reflog (stash list).
145
+ */
146
+ fingerprint() {
147
+ const gitDirRes = runGitCapture(["rev-parse", "--absolute-git-dir"], this.root);
148
+ const gitDir = gitDirRes.ok ? gitDirRes.stdout.trim() : "";
149
+ return {
150
+ head: readFileOr(path.join(gitDir, "HEAD")),
151
+ index: hashFileOr(path.join(gitDir, "index")),
152
+ refs: runGitCapture(["for-each-ref", "--format=%(refname) %(objectname)"], this.root).stdout,
153
+ stash: runGitCapture(["stash", "list"], this.root).stdout,
154
+ };
155
+ }
156
+ }
157
+ function readFileOr(file, fallback = "<absent>") {
158
+ return existsSync(file) ? readFileSync(file, "utf8") : fallback;
159
+ }
160
+ function hashFileOr(file, fallback = "<absent>") {
161
+ if (!existsSync(file))
162
+ return fallback;
163
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
164
+ }
165
+ /** The runtime self-check: any drift in user-visible git state is a hard failure. */
166
+ function assertGitStateUnchanged(before, after) {
167
+ const drifted = Object.keys(before).filter((k) => before[k] !== after[k]);
168
+ if (drifted.length > 0) {
169
+ throw checkpointFailed(`snapshot invariant violated: user-visible git state changed (${drifted.join(", ")}) — refusing to continue`);
170
+ }
171
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./capture.js";
3
+ export * from "./git-store.js";
4
+ export * from "./shadow-store.js";
5
+ export * from "./restore.js";
6
+ export * from "./service.js";
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./capture.js";
3
+ export * from "./git-store.js";
4
+ export * from "./shadow-store.js";
5
+ export * from "./restore.js";
6
+ export * from "./service.js";
@@ -0,0 +1,23 @@
1
+ import type { ActionPreview } from "../tools/types.js";
2
+ import type { Checkpoint, CheckpointStore, RollbackApplied, RollbackPlan } from "./types.js";
3
+ /**
4
+ * Diff the current working tree against a checkpoint by content address.
5
+ * External attribution: a differing path the tracked run never touched is
6
+ * flagged `external` — unless the run executed shell commands, in which case
7
+ * attribution is unknowable and the plan says so instead of guessing.
8
+ */
9
+ export declare function computeRollbackPlan(root: string, checkpoint: Checkpoint, store: CheckpointStore, gitWorkTree: boolean): Promise<RollbackPlan>;
10
+ /**
11
+ * Build the U.2 preview for a plan: patch-style entries (byte-identical with
12
+ * apply_patch previews) plus the external-change and boundary annotations that
13
+ * `renderActionPreview` places *above* the diff.
14
+ */
15
+ export declare function buildRollbackPreview(root: string, plan: RollbackPlan, store: CheckpointStore): Promise<Extract<ActionPreview, {
16
+ type: "rollback";
17
+ }>>;
18
+ /**
19
+ * Apply a plan: recreate deleted files, revert edited ones (content and mode),
20
+ * then remove run-created files (and any directories that emptied out). Any
21
+ * failure is a loud CRUXY_E_CHECKPOINT_FAILED; re-running rollback resumes.
22
+ */
23
+ export declare function applyRollback(root: string, plan: RollbackPlan, store: CheckpointStore): Promise<RollbackApplied>;
@@ -0,0 +1,195 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import path from "node:path";
3
+ import { checkpointFailed, CruxyError } from "../errors/index.js";
4
+ import { isInside } from "../approval/classify.js";
5
+ import { isBinary } from "../indexing/util.js";
6
+ import { captureFiles } from "./capture.js";
7
+ /**
8
+ * Rollback planning + application (C.32). The plan is recomputed from disk on
9
+ * every invocation — never cached — which is what makes rollback idempotent:
10
+ * a partially-applied rollback re-run picks up exactly the remaining work.
11
+ */
12
+ /** Per-file cap on preview content lines (the renderer collapses globally too). */
13
+ const FILE_PREVIEW_LINES = 20;
14
+ /**
15
+ * Diff the current working tree against a checkpoint by content address.
16
+ * External attribution: a differing path the tracked run never touched is
17
+ * flagged `external` — unless the run executed shell commands, in which case
18
+ * attribution is unknowable and the plan says so instead of guessing.
19
+ */
20
+ export async function computeRollbackPlan(root, checkpoint, store, gitWorkTree) {
21
+ const current = await captureFiles(root, gitWorkTree);
22
+ const currentPaths = new Set(current.map((f) => f.path));
23
+ const manifestPaths = new Set(checkpoint.files.map((e) => e.path));
24
+ const touched = new Set(checkpoint.touchedPaths);
25
+ const attributionUnknown = checkpoint.hasShellMutations;
26
+ const isExternal = (p) => !attributionUnknown && !touched.has(p);
27
+ const entries = [];
28
+ for (const entry of checkpoint.files) {
29
+ const absPath = path.join(root, ...entry.path.split("/"));
30
+ if (!currentPaths.has(entry.path)) {
31
+ entries.push({
32
+ path: entry.path,
33
+ action: "recreate",
34
+ external: isExternal(entry.path),
35
+ entry,
36
+ });
37
+ continue;
38
+ }
39
+ const content = await fsp.readFile(absPath);
40
+ const mode = await fileMode(absPath);
41
+ if (store.hashContent(content) !== entry.oid || mode !== entry.mode) {
42
+ entries.push({
43
+ path: entry.path,
44
+ action: "revert",
45
+ external: isExternal(entry.path),
46
+ entry,
47
+ });
48
+ }
49
+ }
50
+ for (const file of current) {
51
+ if (!manifestPaths.has(file.path)) {
52
+ entries.push({
53
+ path: file.path,
54
+ action: "delete",
55
+ external: isExternal(file.path),
56
+ });
57
+ }
58
+ }
59
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
60
+ return {
61
+ checkpoint,
62
+ entries,
63
+ externalPaths: entries.filter((e) => e.external).map((e) => e.path),
64
+ attributionUnknown,
65
+ };
66
+ }
67
+ /**
68
+ * Build the U.2 preview for a plan: patch-style entries (byte-identical with
69
+ * apply_patch previews) plus the external-change and boundary annotations that
70
+ * `renderActionPreview` places *above* the diff.
71
+ */
72
+ export async function buildRollbackPreview(root, plan, store) {
73
+ const files = [];
74
+ for (const entry of plan.entries) {
75
+ if (entry.action === "delete") {
76
+ files.push({ op: "delete", path: entry.path });
77
+ continue;
78
+ }
79
+ // entry.entry is always present for recreate/revert (see RollbackEntry).
80
+ const restored = await store.readContent(entry.entry);
81
+ if (entry.action === "recreate") {
82
+ const { lines, omitted } = previewLines(restored);
83
+ files.push({
84
+ op: "create",
85
+ path: entry.path,
86
+ lines,
87
+ omittedLines: omitted,
88
+ });
89
+ continue;
90
+ }
91
+ const currentPath = path.join(root, ...entry.path.split("/"));
92
+ const current = await fsp.readFile(currentPath);
93
+ files.push({
94
+ op: "update",
95
+ path: entry.path,
96
+ hunks: [
97
+ {
98
+ oldStr: previewText(current),
99
+ newStr: previewText(restored),
100
+ },
101
+ ],
102
+ });
103
+ }
104
+ return {
105
+ type: "rollback",
106
+ checkpointId: plan.checkpoint.id,
107
+ createdAt: plan.checkpoint.createdAt,
108
+ runSummary: plan.checkpoint.runSummary,
109
+ files,
110
+ externalPaths: plan.externalPaths,
111
+ attributionUnknown: plan.attributionUnknown,
112
+ };
113
+ }
114
+ /**
115
+ * Apply a plan: recreate deleted files, revert edited ones (content and mode),
116
+ * then remove run-created files (and any directories that emptied out). Any
117
+ * failure is a loud CRUXY_E_CHECKPOINT_FAILED; re-running rollback resumes.
118
+ */
119
+ export async function applyRollback(root, plan, store) {
120
+ const applied = { recreated: 0, reverted: 0, deleted: 0 };
121
+ for (const entry of plan.entries) {
122
+ const absPath = safeAbsPath(root, entry.path);
123
+ try {
124
+ if (entry.action === "delete") {
125
+ await fsp.rm(absPath, { force: true });
126
+ await removeEmptyDirs(root, path.dirname(absPath));
127
+ applied.deleted += 1;
128
+ continue;
129
+ }
130
+ const content = await store.readContent(entry.entry);
131
+ await fsp.mkdir(path.dirname(absPath), { recursive: true });
132
+ await fsp.writeFile(absPath, content);
133
+ await fsp.chmod(absPath, entry.entry.mode === "100755" ? 0o755 : 0o644);
134
+ applied[entry.action === "recreate" ? "recreated" : "reverted"] += 1;
135
+ }
136
+ catch (err) {
137
+ if (CruxyError.is(err))
138
+ throw err;
139
+ throw checkpointFailed(`restoring ${entry.path} failed (${entry.action})`, err);
140
+ }
141
+ }
142
+ return applied;
143
+ }
144
+ // ── helpers ─────────────────────────────────────────────────────────────────
145
+ /** Resolve a manifest path and refuse anything that escapes the project root. */
146
+ function safeAbsPath(root, relPath) {
147
+ const abs = path.resolve(root, ...relPath.split("/"));
148
+ if (!isInside(path.resolve(root), abs) || abs === path.resolve(root)) {
149
+ throw checkpointFailed(`manifest path escapes the project root: ${relPath}`);
150
+ }
151
+ return abs;
152
+ }
153
+ /** Remove now-empty directories from `dir` up to (never including) `root`. */
154
+ async function removeEmptyDirs(root, dir) {
155
+ const absRoot = path.resolve(root);
156
+ let current = path.resolve(dir);
157
+ while (current !== absRoot && isInside(absRoot, current)) {
158
+ try {
159
+ await fsp.rmdir(current); // fails on non-empty — exactly the stop signal
160
+ }
161
+ catch {
162
+ return;
163
+ }
164
+ current = path.dirname(current);
165
+ }
166
+ }
167
+ async function fileMode(absPath) {
168
+ const stat = await fsp.lstat(absPath);
169
+ return stat.mode & 0o100 ? "100755" : "100644";
170
+ }
171
+ /** First lines of restored content for a `create` preview; binary → placeholder. */
172
+ function previewLines(content) {
173
+ if (isBinary(content)) {
174
+ return { lines: [binaryPlaceholder(content)], omitted: 0 };
175
+ }
176
+ const all = content.toString("utf8").replace(/\n$/, "").split("\n");
177
+ return {
178
+ lines: all.slice(0, FILE_PREVIEW_LINES),
179
+ omitted: Math.max(0, all.length - FILE_PREVIEW_LINES),
180
+ };
181
+ }
182
+ /** Capped text for an `update` hunk side; binary → placeholder. */
183
+ function previewText(content) {
184
+ if (isBinary(content))
185
+ return binaryPlaceholder(content);
186
+ const all = content.toString("utf8").replace(/\n$/, "").split("\n");
187
+ if (all.length <= FILE_PREVIEW_LINES)
188
+ return all.join("\n");
189
+ const shown = all.slice(0, FILE_PREVIEW_LINES);
190
+ shown.push(`… (+${all.length - FILE_PREVIEW_LINES} more lines)`);
191
+ return shown.join("\n");
192
+ }
193
+ function binaryPlaceholder(content) {
194
+ return `«binary file, ${content.length} bytes»`;
195
+ }
@@ -0,0 +1,80 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { ApprovalDecision } from "../approval/types.js";
3
+ import type { ApproveAction } from "../tools/types.js";
4
+ import type { Checkpoint, CheckpointStore, CheckpointStoreKind, RollbackApplied } from "./types.js";
5
+ /** Is `root` inside a git working tree? (Decides the checkpoint substrate.) */
6
+ export declare function isGitWorkTree(root: string): boolean;
7
+ /**
8
+ * The substrate factory: git-object store inside a repo, shadow store outside.
9
+ * Pass `kind` to pin the substrate (rollback must read a checkpoint back with
10
+ * the store that wrote it, recorded in its manifest).
11
+ */
12
+ export declare function createCheckpointStore(root: string, kind?: CheckpointStoreKind): CheckpointStore;
13
+ export interface CheckpointServiceOptions {
14
+ root: string;
15
+ config: CruxyConfig;
16
+ /** Test seam: pin the content store (disables the git→shadow fallback). */
17
+ store?: CheckpointStore;
18
+ }
19
+ /** What `rollback()` needs from the caller: the U.3 gate and TTY interactivity. */
20
+ export interface RollbackDeps {
21
+ requestApproval(action: ApproveAction): Promise<ApprovalDecision>;
22
+ interactive: boolean;
23
+ }
24
+ export type RollbackResult =
25
+ /** The working tree already matches the checkpoint — nothing to do. */
26
+ {
27
+ kind: "noop";
28
+ checkpoint: Checkpoint;
29
+ }
30
+ /** The user saw the preview and declined; nothing was applied. */
31
+ | {
32
+ kind: "rejected";
33
+ feedback?: string;
34
+ } | {
35
+ kind: "applied";
36
+ checkpoint: Checkpoint;
37
+ applied: RollbackApplied;
38
+ };
39
+ /**
40
+ * Checkpoint lifecycle (C.32): create once before a run's first mutation,
41
+ * record what the run touches, list/prune, and drive the gated rollback.
42
+ * Manifests are JSON under `.cruxy/checkpoints/` — they survive process exit,
43
+ * so `cruxy rollback` works in a later invocation.
44
+ */
45
+ export declare class CheckpointService {
46
+ private readonly root;
47
+ private readonly config;
48
+ private readonly pinnedStore?;
49
+ private runSummary;
50
+ private active;
51
+ constructor(opts: CheckpointServiceOptions);
52
+ /** Start a new undo unit: reset the once-per-run latch and name the run. */
53
+ beginRun(summary: string): void;
54
+ /**
55
+ * The auto-checkpoint hook, called from the approval seam after every allowed
56
+ * mutating action and latched to fire once per run — before the first
57
+ * mutation ever reaches disk. Returns the run's checkpoint, or `null` when
58
+ * the feature is disabled. Fail-loud: if a checkpoint cannot be written by
59
+ * either substrate, the run must not mutate without its undo protection.
60
+ */
61
+ ensureCheckpoint(): Promise<Checkpoint | null>;
62
+ /** Attribute mutated paths to the current run (persisted for later rollback). */
63
+ recordTouched(absPaths: string[]): Promise<void>;
64
+ /** The run ran a shell command: per-path attribution is no longer possible. */
65
+ recordShellMutation(): Promise<void>;
66
+ /** All checkpoints, newest first. Corrupt manifests are warned about, not fatal. */
67
+ list(): Promise<Checkpoint[]>;
68
+ /** One checkpoint by id, or the newest when `id` is omitted. Fail-loud. */
69
+ read(id?: string): Promise<Checkpoint>;
70
+ /**
71
+ * The whole gated restore: preview → U.3 destructive approval → apply.
72
+ * Non-interactive callers are refused up front — there is no auto-rollback.
73
+ */
74
+ rollback(id: string | undefined, deps: RollbackDeps): Promise<RollbackResult>;
75
+ /** Enforce `checkpoint.retention`: drop oldest manifests, then GC content. */
76
+ prune(): Promise<void>;
77
+ private dir;
78
+ private writeManifest;
79
+ private readManifestFile;
80
+ }