@cruxy/cli 0.8.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 (66) hide show
  1. package/README.md +40 -13
  2. package/dist/agent/loop.d.ts +28 -1
  3. package/dist/agent/loop.js +36 -4
  4. package/dist/agent/prompts.d.ts +2 -0
  5. package/dist/agent/prompts.js +8 -0
  6. package/dist/approval/classify.js +26 -0
  7. package/dist/checkpoint/capture.d.ts +17 -0
  8. package/dist/checkpoint/capture.js +73 -0
  9. package/dist/checkpoint/git-store.d.ts +61 -0
  10. package/dist/checkpoint/git-store.js +171 -0
  11. package/dist/checkpoint/index.d.ts +6 -0
  12. package/dist/checkpoint/index.js +6 -0
  13. package/dist/checkpoint/restore.d.ts +23 -0
  14. package/dist/checkpoint/restore.js +195 -0
  15. package/dist/checkpoint/service.d.ts +80 -0
  16. package/dist/checkpoint/service.js +276 -0
  17. package/dist/checkpoint/shadow-store.d.ts +23 -0
  18. package/dist/checkpoint/shadow-store.js +93 -0
  19. package/dist/checkpoint/types.d.ts +117 -0
  20. package/dist/checkpoint/types.js +18 -0
  21. package/dist/cli/commands/checkpoint.d.ts +7 -0
  22. package/dist/cli/commands/checkpoint.js +31 -0
  23. package/dist/cli/commands/rollback.d.ts +10 -0
  24. package/dist/cli/commands/rollback.js +51 -0
  25. package/dist/cli/commands/run.js +10 -2
  26. package/dist/cli/program.js +4 -0
  27. package/dist/cli/repl.d.ts +2 -1
  28. package/dist/cli/repl.js +6 -3
  29. package/dist/cli/session-factory.d.ts +14 -1
  30. package/dist/cli/session-factory.js +87 -22
  31. package/dist/config/schema.d.ts +133 -0
  32. package/dist/config/schema.js +40 -0
  33. package/dist/errors/constructors.d.ts +25 -0
  34. package/dist/errors/constructors.js +86 -0
  35. package/dist/errors/types.d.ts +7 -0
  36. package/dist/errors/types.js +16 -0
  37. package/dist/indexing/walker.d.ts +11 -0
  38. package/dist/indexing/walker.js +11 -6
  39. package/dist/plan/execute.d.ts +8 -0
  40. package/dist/plan/execute.js +36 -22
  41. package/dist/plan/service.js +5 -1
  42. package/dist/plan/submit-plan.d.ts +4 -4
  43. package/dist/render/diff.js +27 -0
  44. package/dist/render/index.d.ts +2 -1
  45. package/dist/render/index.js +1 -0
  46. package/dist/render/plain-renderer.d.ts +7 -1
  47. package/dist/render/plain-renderer.js +26 -0
  48. package/dist/render/state.d.ts +31 -0
  49. package/dist/render/state.js +83 -0
  50. package/dist/render/tty-renderer.d.ts +41 -5
  51. package/dist/render/tty-renderer.js +150 -23
  52. package/dist/render/types.d.ts +85 -1
  53. package/dist/subagent/budget.d.ts +34 -0
  54. package/dist/subagent/budget.js +57 -0
  55. package/dist/subagent/index.d.ts +5 -0
  56. package/dist/subagent/index.js +5 -0
  57. package/dist/subagent/orchestrator.d.ts +67 -0
  58. package/dist/subagent/orchestrator.js +241 -0
  59. package/dist/subagent/registry-scope.d.ts +28 -0
  60. package/dist/subagent/registry-scope.js +63 -0
  61. package/dist/subagent/spawn-tool.d.ts +29 -0
  62. package/dist/subagent/spawn-tool.js +94 -0
  63. package/dist/subagent/types.d.ts +55 -0
  64. package/dist/subagent/types.js +1 -0
  65. package/dist/tools/types.d.ts +20 -2
  66. package/package.json +1 -1
package/README.md CHANGED
@@ -116,6 +116,33 @@ before anything runs. On a protected branch (`main`/`master`/configured via
116
116
  `git.protectedBranches`) cruxy branches off first; the base defaults to
117
117
  `git.defaultBase`, then the repo's default branch, then `main`.
118
118
 
119
+ ### Checkpoints & rollback
120
+
121
+ Before an agent run's first file mutation, cruxy snapshots the working tree
122
+ (tracked + untracked non-ignored files; gitignored paths and the secrets
123
+ denylist are never captured). `cruxy rollback` undoes the whole run — creates,
124
+ edits, deletes — in one operation:
125
+
126
+ ```bash
127
+ cruxy checkpoint list # saved checkpoints, newest first
128
+ cruxy rollback # restore the most recent checkpoint
129
+ cruxy rollback <id> # restore a specific one
130
+ ```
131
+
132
+ Rollback is destructive, so it previews exactly what will change (including
133
+ anything that changed _outside_ the run — surfaced, never silently clobbered)
134
+ and always asks for approval; it cannot be session-granted and refuses to run
135
+ non-interactively (`CRUXY_E_ROLLBACK_APPROVAL_REQUIRED`).
136
+
137
+ In a git repo, snapshots go into the git object database via a temporary index
138
+ — HEAD, your index, the stash, and every ref are untouched, and nothing shows
139
+ up in `git status`. Outside a repo, a content-addressed shadow copy under
140
+ `.cruxy/checkpoints/` is used. Retention is bounded (`checkpoint.retention`,
141
+ default 10; disable with `checkpoint.enabled = false`).
142
+
143
+ **Boundary:** checkpoints cover working-tree files only. Commits, pushes, and
144
+ PRs made during a run are never undone — the rollback preview says so.
145
+
119
146
  ## Errors & exit codes
120
147
 
121
148
  Every user-facing error prints a title, the cause (when known), concrete next
@@ -124,19 +151,19 @@ steps, and a stable code (e.g. `CRUXY_E_GATEWAY_UNREACHABLE`). Pass `--verbose`
124
151
  `NO_COLOR` disables color. Exit codes are stable per category, so scripts can
125
152
  branch on them:
126
153
 
127
- | Exit | Category | Example codes |
128
- | ---- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
129
- | `0` | success | — |
130
- | `1` | internal | `CRUXY_E_INTERNAL` |
131
- | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT` |
132
- | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
133
- | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
134
- | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
135
- | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
136
- | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE` |
137
- | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
138
- | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
139
- | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED` |
154
+ | Exit | Category | Example codes |
155
+ | ---- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
156
+ | `0` | success | — |
157
+ | `1` | internal | `CRUXY_E_INTERNAL` |
158
+ | `2` | usage | `CRUXY_E_USAGE`, `CRUXY_E_CONFIG_KEY_UNKNOWN`, `CRUXY_E_PROVIDER_UNSUPPORTED`, `CRUXY_E_GIT_PROTECTED_BRANCH`, `CRUXY_E_PLAN_INVALID`, `CRUXY_E_PLAN_REVISION_LIMIT`, `CRUXY_E_CHECKPOINT_NOT_FOUND` |
159
+ | `3` | config | `CRUXY_E_CONFIG_PARSE`, `CRUXY_E_CONFIG_INVALID` |
160
+ | `4` | auth | `CRUXY_E_AUTH_MISSING_KEY`, `CRUXY_E_AUTH_INVALID`, `CRUXY_E_FORGE_AUTH` |
161
+ | `5` | network | `CRUXY_E_GATEWAY_UNREACHABLE`, `CRUXY_E_GIT_PUSH_FAILED` |
162
+ | `6` | api | `CRUXY_E_API`, `CRUXY_E_API_RATE_LIMIT`, `CRUXY_E_API_OVERLOADED`, `CRUXY_E_BUDGET_EXHAUSTED`, `CRUXY_E_FORGE_API` |
163
+ | `7` | filesystem | `CRUXY_E_FILE_NOT_FOUND`, `CRUXY_E_PERMISSION_DENIED`, `CRUXY_E_PATH_ESCAPE`, `CRUXY_E_CHECKPOINT_FAILED` |
164
+ | `8` | index | `CRUXY_E_INDEX_EMBEDDER_UNAVAILABLE`, `CRUXY_E_INDEX_STORE_UNAVAILABLE`, `CRUXY_E_INDEX_FAILED` |
165
+ | `9` | skill | `CRUXY_E_SKILL_INVALID`, `CRUXY_E_SKILL_NOT_FOUND` |
166
+ | `10` | approval | `CRUXY_E_APPROVAL_REQUIRED`, `CRUXY_E_PLAN_APPROVAL_REQUIRED`, `CRUXY_E_ROLLBACK_APPROVAL_REQUIRED` |
140
167
 
141
168
  The LLM client is [`@cruxy/sdk`](https://www.npmjs.com/package/@cruxy/sdk) —
142
169
  provider-agnostic, built over `fetch`, with no vendor SDKs.
@@ -34,6 +34,31 @@ export interface RunAgentArgs {
34
34
  projectInstructions?: string | null;
35
35
  /** Plan mode's propose phase (C.31): inject the plan-first system directive. */
36
36
  planMode?: boolean;
37
+ /** Subagent runs (C.14): inject the bounded-subtask system directive. */
38
+ subagent?: boolean;
39
+ /**
40
+ * Optional hard budget (C.14): checked before every model turn; a non-null
41
+ * reason stops the loop with `stop: "budget"` and the partial history. The
42
+ * in-flight turn (model call + its tool executions) always completes, so
43
+ * histories stay coherent — overshoot is bounded by one turn.
44
+ */
45
+ budget?: LoopBudget;
46
+ }
47
+ /**
48
+ * The budget seam for {@link runAgent}: implementations track their own caps
49
+ * (iterations, tokens, wall clock — see `subagent/budget.ts`); the loop only
50
+ * asks "may I start another turn?". Kept a one-method interface so future
51
+ * callers (C.22 cost tracking) can slot in without touching the loop again.
52
+ */
53
+ export interface LoopBudget {
54
+ /**
55
+ * Return a human-readable reason to stop *before* the next model turn, or
56
+ * `null` to continue. `iterations` = model turns completed so far.
57
+ */
58
+ exceeded(state: {
59
+ iterations: number;
60
+ usage: Usage;
61
+ }): string | null;
37
62
  }
38
63
  export interface AgentResult {
39
64
  /** The full conversation, including assistant tool calls and tool results. */
@@ -41,7 +66,9 @@ export interface AgentResult {
41
66
  /** Number of model turns consumed. */
42
67
  iterations: number;
43
68
  /** Why the loop ended. */
44
- stop: "completed" | "max_iterations";
69
+ stop: "completed" | "max_iterations" | "budget";
70
+ /** Which cap tripped, when `stop === "budget"` (from {@link LoopBudget}). */
71
+ stopReason?: string;
45
72
  /** Accumulated token usage (stashed for cost tracking in C.22). */
46
73
  usage: Usage;
47
74
  }
@@ -46,9 +46,23 @@ async function driveLoop(args, renderer) {
46
46
  git: args.git ?? null,
47
47
  projectInstructions: args.projectInstructions ?? null,
48
48
  planMode: args.planMode ?? false,
49
+ subagent: args.subagent ?? false,
49
50
  });
50
51
  let iterations = 0;
51
52
  for (let i = 0; i < maxIterations; i++) {
53
+ // Budget check before committing to another model turn (C.14): a tripped
54
+ // cap returns the history as it stands — always at a clean turn boundary,
55
+ // because the previous iteration fully resolved its tool calls.
56
+ const budgetReason = args.budget?.exceeded({ iterations, usage }) ?? null;
57
+ if (budgetReason !== null) {
58
+ return {
59
+ messages,
60
+ iterations,
61
+ stop: "budget",
62
+ stopReason: budgetReason,
63
+ usage,
64
+ };
65
+ }
52
66
  iterations = i + 1;
53
67
  const tools = registry.toToolSpecs();
54
68
  // ── Consume one model turn ──────────────────────────────────────────────
@@ -56,7 +70,14 @@ async function driveLoop(args, renderer) {
56
70
  const pending = new Map();
57
71
  const toolUses = [];
58
72
  // Live progress while waiting on the model; dismissed by the first delta.
59
- renderer?.status("thinking…");
73
+ // Token context is whatever the loop has actually accumulated (U.4): zero
74
+ // on the first turn → no figure shown, never a fabricated number.
75
+ renderer?.setPhase({
76
+ kind: "thinking",
77
+ tokens: usage.input_tokens + usage.output_tokens > 0
78
+ ? { input: usage.input_tokens, output: usage.output_tokens }
79
+ : undefined,
80
+ });
60
81
  for await (const ev of provider.stream({
61
82
  system,
62
83
  messages,
@@ -121,9 +142,12 @@ async function driveLoop(args, renderer) {
121
142
  const toolResults = [];
122
143
  for (const call of toolUses) {
123
144
  const label = describeToolCall(call);
124
- renderer?.status(`${label}…`);
145
+ // Semantic lifecycle (U.4): start paints the live state (+ elapsed on
146
+ // long calls), end commits the ✓/✗ trail note. Same information as the
147
+ // old status/note pair, now typed and duration-aware.
148
+ renderer?.toolLifecycle({ event: "start", label });
125
149
  const result = await runToolCall(call, registry, ctx);
126
- renderer?.note(`${result.is_error ? "" : "✓"} ${label}`);
150
+ renderer?.toolLifecycle({ event: "end", label, ok: !result.is_error });
127
151
  toolResults.push(result);
128
152
  }
129
153
  messages.push({ role: "user", content: toolResults });
@@ -132,7 +156,15 @@ async function driveLoop(args, renderer) {
132
156
  return { messages, iterations, stop: "max_iterations", usage };
133
157
  }
134
158
  /** Input keys worth surfacing in tool-call chrome, in preference order. */
135
- const HINT_KEYS = ["path", "file_path", "command", "pattern", "query", "url"];
159
+ const HINT_KEYS = [
160
+ "path",
161
+ "file_path",
162
+ "command",
163
+ "pattern",
164
+ "query",
165
+ "url",
166
+ "task",
167
+ ];
136
168
  /** Longest hint shown before truncation — chrome, not information of record. */
137
169
  const HINT_MAX = 60;
138
170
  /**
@@ -30,6 +30,8 @@ export interface PromptContext {
30
30
  projectInstructions?: string | null;
31
31
  /** Plan mode's propose phase (C.31): inject the plan-first directive. */
32
32
  planMode?: boolean;
33
+ /** Subagent run (C.14): inject the bounded-subtask directive. */
34
+ subagent?: boolean;
33
35
  }
34
36
  /** Assemble the full system prompt for a session. */
35
37
  export declare function buildSystemPrompt(ctx: PromptContext): string;
@@ -10,6 +10,12 @@
10
10
  const PLAN_MODE_SECTION = `## Plan mode
11
11
  You are in plan mode. Do NOT edit files, run commands, or take any side-effecting action yet.
12
12
  First investigate with the read-only tools if you need to, then call \`submit_plan\` with an ordered list of steps — each with a title, a one-line rationale, and a kind (read | mutate | destructive). Cover the whole task; keep steps concrete and minimal. After you call \`submit_plan\`, stop and end your turn — the user reviews and approves the plan before you execute it.`;
13
+ /** The subagent directive (C.14), injected only into a spawned subagent's runs. */
14
+ const SUBAGENT_SECTION = `## Subagent context
15
+ You are a subagent: a scoped worker handling ONE bounded subtask for a parent agent, under a hard iteration and token budget. Only your final message is returned to the parent — its transcript does not include your intermediate steps. Therefore:
16
+ - Stay strictly within the given subtask; do not expand scope or start follow-on work.
17
+ - Work efficiently — prefer few, well-chosen tool calls over exhaustive exploration.
18
+ - End with a concise, self-contained summary of what you found or changed (concrete file paths, identifiers, outcomes). That summary IS your deliverable.`;
13
19
  /**
14
20
  * The static core of cruxy's behaviour. Phrased as direct instruction to the
15
21
  * model. Keep this tight — every line earns its place; vague prose dilutes the
@@ -80,6 +86,8 @@ export function buildSystemPrompt(ctx) {
80
86
  const sections = [core, renderEnvironment(ctx), renderTools(ctx.tools)];
81
87
  if (ctx.planMode)
82
88
  sections.push(PLAN_MODE_SECTION);
89
+ if (ctx.subagent)
90
+ sections.push(SUBAGENT_SECTION);
83
91
  if (ctx.projectInstructions?.trim()) {
84
92
  sections.push(`## Project instructions\nThe following came from this project's configuration; honor it unless it conflicts with the rules above:\n\n${ctx.projectInstructions.trim()}`);
85
93
  }
@@ -18,6 +18,8 @@ export function classify(action, cwd) {
18
18
  return shellRequest(action, root);
19
19
  case "vcs":
20
20
  return vcsRequest(action, root);
21
+ case "rollback":
22
+ return rollbackRequest(action, root);
21
23
  default:
22
24
  return {
23
25
  action,
@@ -85,6 +87,30 @@ function vcsRequest(action, root) {
85
87
  cwd: root,
86
88
  };
87
89
  }
90
+ // ── rollback (restore checkpoint) ──────────────────────────────────────────────
91
+ /**
92
+ * A checkpoint restore (C.32): overwrite the working tree with a pre-run
93
+ * snapshot. Always `destructive` (it reverts edits, deletes created files, and
94
+ * recreates deleted ones in one operation) and never session-grantable — scope
95
+ * `none`, so every rollback is a deliberate, one-off approval. The preview
96
+ * carries the full blast radius; the summary names the checkpoint.
97
+ */
98
+ function rollbackRequest(action, root) {
99
+ const preview = action.preview?.type === "rollback" ? action.preview : undefined;
100
+ const summary = preview
101
+ ? `rollback: restore checkpoint ${preview.checkpointId} (${preview.files.length} file${preview.files.length === 1 ? "" : "s"})`
102
+ : "rollback: restore a checkpoint";
103
+ return {
104
+ action,
105
+ tier: "destructive",
106
+ scope: { kind: "none" },
107
+ summary,
108
+ targets: preview
109
+ ? preview.files.map((f) => path.resolve(root, f.path))
110
+ : [],
111
+ cwd: root,
112
+ };
113
+ }
88
114
  // ── file (write / edit / patch) ────────────────────────────────────────────────
89
115
  function fileRequest(action, tier, root) {
90
116
  const targets = fileTargets(action, root);
@@ -0,0 +1,17 @@
1
+ import type { CaptureFile } from "./types.js";
2
+ /**
3
+ * Snapshot-scope enumeration (C.32): every regular file the agent could touch —
4
+ * tracked + untracked-non-ignored — and nothing it must never see:
5
+ * • gitignored paths (they are not the run's undo unit and may be huge),
6
+ * • the C.17 secrets denylist ({@link isSecretPath} — a checkpoint must never
7
+ * copy a secret into `.cruxy/` or the git object DB),
8
+ * • `.cruxy/` itself (a checkpoint of the checkpoints would recurse),
9
+ * • symlinks and other non-regular files (restore writes plain files only).
10
+ *
11
+ * In a git repo the file list comes from `git ls-files` (read-only), which
12
+ * honors `.gitignore`, `.git/info/exclude`, and the user's global excludes
13
+ * exactly. Outside a repo, the indexing walker enumerates with its gitignore
14
+ * emulation — with binaries included and no size cap, because a snapshot that
15
+ * skips files cannot restore them.
16
+ */
17
+ export declare function captureFiles(root: string, gitWorkTree: boolean): Promise<CaptureFile[]>;
@@ -0,0 +1,73 @@
1
+ import { promises as fsp } from "node:fs";
2
+ import path from "node:path";
3
+ import { runGitCapture } from "../vcs/git.js";
4
+ import { isSecretPath, walkRepo } from "../indexing/walker.js";
5
+ import { GLOBAL_DIR_NAME } from "../constants.js";
6
+ /**
7
+ * Snapshot-scope enumeration (C.32): every regular file the agent could touch —
8
+ * tracked + untracked-non-ignored — and nothing it must never see:
9
+ * • gitignored paths (they are not the run's undo unit and may be huge),
10
+ * • the C.17 secrets denylist ({@link isSecretPath} — a checkpoint must never
11
+ * copy a secret into `.cruxy/` or the git object DB),
12
+ * • `.cruxy/` itself (a checkpoint of the checkpoints would recurse),
13
+ * • symlinks and other non-regular files (restore writes plain files only).
14
+ *
15
+ * In a git repo the file list comes from `git ls-files` (read-only), which
16
+ * honors `.gitignore`, `.git/info/exclude`, and the user's global excludes
17
+ * exactly. Outside a repo, the indexing walker enumerates with its gitignore
18
+ * emulation — with binaries included and no size cap, because a snapshot that
19
+ * skips files cannot restore them.
20
+ */
21
+ export async function captureFiles(root, gitWorkTree) {
22
+ const absRoot = path.resolve(root);
23
+ const candidates = gitWorkTree
24
+ ? await gitCandidates(absRoot)
25
+ : await walkerCandidates(absRoot);
26
+ const files = [];
27
+ for (const relPath of candidates) {
28
+ if (relPath === "" || isExcluded(relPath))
29
+ continue;
30
+ const absPath = path.join(absRoot, ...relPath.split("/"));
31
+ // lstat: a symlink must be seen as a symlink, not its target.
32
+ let stat;
33
+ try {
34
+ stat = await fsp.lstat(absPath);
35
+ }
36
+ catch {
37
+ continue; // listed but gone (e.g. tracked file deleted from the worktree)
38
+ }
39
+ if (!stat.isFile())
40
+ continue;
41
+ files.push({ path: relPath, absPath });
42
+ }
43
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
44
+ return files;
45
+ }
46
+ /** Never capture cruxy's own state dir or a secret-bearing path. */
47
+ function isExcluded(relPath) {
48
+ return (relPath === GLOBAL_DIR_NAME ||
49
+ relPath.startsWith(`${GLOBAL_DIR_NAME}/`) ||
50
+ isSecretPath(relPath));
51
+ }
52
+ /** Tracked + untracked-non-ignored, straight from git (paths relative to root). */
53
+ async function gitCandidates(absRoot) {
54
+ const res = runGitCapture(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], absRoot);
55
+ if (!res.ok) {
56
+ // The caller decided this is a work tree; a failing ls-files means git is
57
+ // in a state we can't reason about — let the store fallback handle it.
58
+ throw new Error(`git ls-files failed: ${res.stderr.trim()}`);
59
+ }
60
+ // -z output: NUL-separated, no quoting, trailing NUL yields one empty entry.
61
+ return [...new Set(res.stdout.split("\0"))];
62
+ }
63
+ /** Walker enumeration for non-git dirs: gitignore-style ignores, no content filters. */
64
+ async function walkerCandidates(absRoot) {
65
+ const out = [];
66
+ for await (const entry of walkRepo(absRoot, {
67
+ maxFileBytes: Number.MAX_SAFE_INTEGER,
68
+ includeBinary: true,
69
+ })) {
70
+ out.push(entry.relPath);
71
+ }
72
+ return out;
73
+ }
@@ -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>;