@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.
- package/README.md +40 -13
- package/dist/agent/loop.d.ts +28 -1
- package/dist/agent/loop.js +36 -4
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/approval/classify.js +26 -0
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +51 -0
- package/dist/cli/commands/run.js +10 -2
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +2 -1
- package/dist/cli/repl.js +6 -3
- package/dist/cli/session-factory.d.ts +14 -1
- package/dist/cli/session-factory.js +87 -22
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +86 -0
- package/dist/errors/types.d.ts +7 -0
- package/dist/errors/types.js +16 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.js +5 -1
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/diff.js +27 -0
- package/dist/render/index.d.ts +2 -1
- package/dist/render/index.js +1 -0
- package/dist/render/plain-renderer.d.ts +7 -1
- package/dist/render/plain-renderer.js +26 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +41 -5
- package/dist/render/tty-renderer.js +150 -23
- package/dist/render/types.d.ts +85 -1
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- 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.
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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?.
|
|
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 = [
|
|
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
|
/**
|
package/dist/agent/prompts.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent/prompts.js
CHANGED
|
@@ -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,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>;
|