@cruxy/cli 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/context.js +178 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.js +20 -1
- package/dist/agent/mode.js +103 -0
- package/dist/agent/prompts.js +1 -1
- package/dist/agent/session.js +171 -69
- package/dist/approval/classify.js +204 -0
- package/dist/approval/policy.js +41 -3
- package/dist/approval/prompt.js +49 -22
- package/dist/checkpoint/gate.js +12 -0
- package/dist/cli/commands/run.js +374 -227
- package/dist/cli/commands/usage.js +45 -45
- package/dist/cli/onboard.js +2 -1
- package/dist/cli/program.js +60 -18
- package/dist/cli/repl.js +67 -249
- package/dist/cli/session-commands.js +755 -0
- package/dist/cli/session-factory.js +198 -76
- package/dist/cli/suggest.js +77 -0
- package/dist/components/fuzzy.js +3 -3
- package/dist/components/input.js +17 -2
- package/dist/components/keys.js +27 -3
- package/dist/components/select.js +3 -3
- package/dist/config/project.js +53 -1
- package/dist/config/schema.js +49 -16
- package/dist/jobs/log-renderer.js +47 -0
- package/dist/onboarding/steps.js +13 -22
- package/dist/plan/approve.js +36 -24
- package/dist/plan/execute.js +9 -7
- package/dist/plan/render.js +10 -23
- package/dist/plan/service.js +4 -1
- package/dist/render/capabilities.js +30 -1
- package/dist/render/context-view.js +106 -0
- package/dist/render/diff.js +198 -12
- package/dist/render/index.js +31 -5
- package/dist/render/plain-renderer.js +38 -2
- package/dist/render/plan-view.js +108 -0
- package/dist/render/resize.js +7 -2
- package/dist/render/status-view.js +66 -0
- package/dist/render/test-view.js +89 -0
- package/dist/render/tty-renderer.js +40 -0
- package/dist/routing/index.js +1 -0
- package/dist/routing/router.js +13 -4
- package/dist/routing/session-model.js +109 -0
- package/dist/routing/types.js +14 -0
- package/dist/session/export.js +88 -0
- package/dist/session/index.js +20 -0
- package/dist/session/list.js +137 -0
- package/dist/session/log.js +137 -0
- package/dist/session/paths.js +73 -0
- package/dist/session/replay.js +169 -0
- package/dist/session/resume.js +128 -0
- package/dist/session/types.js +223 -0
- package/dist/subagent/orchestrator.js +23 -0
- package/dist/testing/run-tests-tool.js +8 -0
- package/dist/tools/registry.js +3 -3
- package/dist/tui/app.js +385 -0
- package/dist/tui/approval-overlay.js +160 -0
- package/dist/tui/context-gauge.js +48 -0
- package/dist/tui/git-status.js +63 -0
- package/dist/tui/index.js +10 -0
- package/dist/tui/layout.js +269 -0
- package/dist/tui/overlay.js +105 -0
- package/dist/tui/palette.js +73 -0
- package/dist/tui/panels.js +235 -0
- package/dist/tui/renderer.js +776 -0
- package/dist/tui/supports.js +20 -0
- package/dist/tui/tool-versions.js +129 -0
- package/dist/usage/collect.js +6 -6
- package/dist/usage/index.js +10 -2
- package/dist/usage/report.js +76 -0
- package/dist/usage/summary.js +106 -17
- package/dist/usage/types.js +5 -2
- package/dist/usage/weighted.js +77 -0
- package/dist/utils/git.js +50 -4
- package/package.json +1 -1
- package/dist/usage/cost.js +0 -29
|
@@ -4,6 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
* {@link Scope}. The cardinal rule: **anything unrecognized is `destructive`**
|
|
5
5
|
* (most-restrictive) so a future/unknown action can never slip through as
|
|
6
6
|
* read-only or low-risk.
|
|
7
|
+
*
|
|
8
|
+
* The same pass sets `irreversible` — the separate, stricter axis that bounds
|
|
9
|
+
* auto-approve. See {@link ApprovalRequest.irreversible} for why the tier cannot
|
|
10
|
+
* do that job, and the `── reversibility` section below for the rules.
|
|
7
11
|
*/
|
|
8
12
|
export function classify(action, cwd) {
|
|
9
13
|
const root = path.resolve(cwd);
|
|
@@ -32,9 +36,182 @@ export function classify(action, cwd) {
|
|
|
32
36
|
summary: `perform an unrecognized action (${describeKind(action)})`,
|
|
33
37
|
targets: [],
|
|
34
38
|
cwd: root,
|
|
39
|
+
irreversible: "this action kind is unrecognized, so nothing can bound what it changes",
|
|
35
40
|
};
|
|
36
41
|
}
|
|
37
42
|
}
|
|
43
|
+
// ── reversibility (P5 track 3) ────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* What the checkpoint actually captures, because every rule below is derived
|
|
46
|
+
* from it rather than from intuition about which verbs sound scary:
|
|
47
|
+
*
|
|
48
|
+
* • `capture.ts` enumerates with `git ls-files --cached --others
|
|
49
|
+
* --exclude-standard` — tracked + untracked-non-ignored files **under one
|
|
50
|
+
* root**. So: nothing outside a declared root, and nothing gitignored
|
|
51
|
+
* (`node_modules/`, build output, `.env`).
|
|
52
|
+
* • `git-store.ts` states it outright: "no ref is ever created or moved, and
|
|
53
|
+
* HEAD / the index / the stash are never written." `.git/` is never
|
|
54
|
+
* enumerated at all. So: **no git history, refs, branches, or stash.**
|
|
55
|
+
* • Nothing off-disk. A checkpoint cannot un-send a push, un-publish a
|
|
56
|
+
* package, or un-call an HTTP endpoint.
|
|
57
|
+
*
|
|
58
|
+
* Everything a checkpoint DOES cover, it covers completely: `withCheckpointGate`
|
|
59
|
+
* snapshots the whole root before the first mutation of a run, so arbitrary
|
|
60
|
+
* in-root file damage — including `rm -rf` of the workspace — is restorable.
|
|
61
|
+
* That is why the rules key on *escape* (out of the root, into `.git`, onto the
|
|
62
|
+
* network) rather than on how destructive an in-root command looks.
|
|
63
|
+
*
|
|
64
|
+
* ── Residual risk, stated plainly ──
|
|
65
|
+
* No static analysis can prove a shell command's effects. {@link ESCAPES_ROOT}
|
|
66
|
+
* is a denylist, and denylists lose: `node deploy.js` or an unlisted CLI can
|
|
67
|
+
* still reach the network. It is a belt. The braces are the two rules that fail
|
|
68
|
+
* *closed* — an unprovable command (any shell metacharacter) and any argument
|
|
69
|
+
* resolving outside the root are both irreversible without consulting any list
|
|
70
|
+
* — plus the pre-command checkpoint, which bounds the on-disk half absolutely.
|
|
71
|
+
* Auto-approve is a deliberate, per-session, visibly-announced choice; this is
|
|
72
|
+
* the ceiling on that choice, not a sandbox.
|
|
73
|
+
*/
|
|
74
|
+
/**
|
|
75
|
+
* `git` subcommands that only read. Everything else `git` does is irreversible
|
|
76
|
+
* by construction — it writes refs, HEAD, the index, or the stash, none of which
|
|
77
|
+
* the checkpoint captures. An allowlist, not a denylist, so `push --force`,
|
|
78
|
+
* `reset --hard`, `rebase`, `filter-branch`, `branch -D`, `tag -d`, and every
|
|
79
|
+
* future ref-mutating subcommand are caught without being enumerated.
|
|
80
|
+
*/
|
|
81
|
+
const GIT_READ_ONLY = new Set([
|
|
82
|
+
"blame",
|
|
83
|
+
"cat-file",
|
|
84
|
+
"describe",
|
|
85
|
+
"diff",
|
|
86
|
+
"for-each-ref",
|
|
87
|
+
"grep",
|
|
88
|
+
"log",
|
|
89
|
+
"ls-files",
|
|
90
|
+
"rev-list",
|
|
91
|
+
"rev-parse",
|
|
92
|
+
"shortlog",
|
|
93
|
+
"show",
|
|
94
|
+
"status",
|
|
95
|
+
]);
|
|
96
|
+
/**
|
|
97
|
+
* Package-manager subcommands whose effects stay in the checkpointed tree.
|
|
98
|
+
* `run`/`test` execute project scripts — arbitrary code, but code whose on-disk
|
|
99
|
+
* damage the pre-command checkpoint restores. `install` is NOT here: it writes
|
|
100
|
+
* `node_modules/`, which is gitignored and therefore never captured, and reaches
|
|
101
|
+
* a registry. `publish`, `link`, `login`, `config` escape outright.
|
|
102
|
+
*/
|
|
103
|
+
const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
|
|
104
|
+
const PACKAGE_MANAGER_LOCAL = new Set(["run", "test"]);
|
|
105
|
+
/**
|
|
106
|
+
* Programs whose primary purpose is to reach something the checkpoint cannot
|
|
107
|
+
* restore — the network, another host, a registry, cloud state, or the machine
|
|
108
|
+
* itself. Not exhaustive by construction (see the residual-risk note above);
|
|
109
|
+
* these are the ones an agent loop actually reaches for.
|
|
110
|
+
*/
|
|
111
|
+
const ESCAPES_ROOT = new Map([
|
|
112
|
+
["curl", "it sends network requests, which no checkpoint can recall"],
|
|
113
|
+
["wget", "it sends network requests, which no checkpoint can recall"],
|
|
114
|
+
["ssh", "it runs commands on another host, outside any checkpoint"],
|
|
115
|
+
["scp", "it copies to another host, outside any checkpoint"],
|
|
116
|
+
["sftp", "it transfers to another host, outside any checkpoint"],
|
|
117
|
+
["rsync", "it can write outside the workspace and to other hosts"],
|
|
118
|
+
["gh", "it acts on GitHub under your identity; remote state is not captured"],
|
|
119
|
+
[
|
|
120
|
+
"glab",
|
|
121
|
+
"it acts on GitLab under your identity; remote state is not captured",
|
|
122
|
+
],
|
|
123
|
+
["docker", "it changes container and image state outside the workspace"],
|
|
124
|
+
["kubectl", "it changes live cluster state, which is not captured"],
|
|
125
|
+
["helm", "it changes live cluster state, which is not captured"],
|
|
126
|
+
["terraform", "it changes real infrastructure, which is not captured"],
|
|
127
|
+
["ansible", "it changes remote hosts, which are not captured"],
|
|
128
|
+
["aws", "it changes cloud account state, which is not captured"],
|
|
129
|
+
["gcloud", "it changes cloud account state, which is not captured"],
|
|
130
|
+
["az", "it changes cloud account state, which is not captured"],
|
|
131
|
+
["sudo", "it runs with elevated privileges, outside every guarantee here"],
|
|
132
|
+
["doas", "it runs with elevated privileges, outside every guarantee here"],
|
|
133
|
+
["su", "it runs as another user, outside every guarantee here"],
|
|
134
|
+
["systemctl", "it changes system service state, which is not captured"],
|
|
135
|
+
["launchctl", "it changes system service state, which is not captured"],
|
|
136
|
+
["shutdown", "it halts the machine"],
|
|
137
|
+
["reboot", "it restarts the machine"],
|
|
138
|
+
["dd", "it writes raw devices, which no checkpoint can restore"],
|
|
139
|
+
["mkfs", "it formats a filesystem, which no checkpoint can restore"],
|
|
140
|
+
["diskutil", "it changes disk state, which no checkpoint can restore"],
|
|
141
|
+
]);
|
|
142
|
+
/**
|
|
143
|
+
* Why the checkpoint cannot restore `command`, or `null` when it can.
|
|
144
|
+
*
|
|
145
|
+
* Three gates, in order of how hard they fail closed. The first two need no
|
|
146
|
+
* list to be correct; the third is the belt described above.
|
|
147
|
+
*/
|
|
148
|
+
function shellIrreversibility(command, root) {
|
|
149
|
+
const tokens = commandTokens(command);
|
|
150
|
+
if (tokens === null) {
|
|
151
|
+
// Deny-by-default, and the same proof `commandTokens` already gates session
|
|
152
|
+
// grants on: with a pipe, redirect, substitution, glob, or `~`, we cannot
|
|
153
|
+
// say what runs, so we cannot say what it touches.
|
|
154
|
+
return "the command uses shell features we cannot analyze, so its effects cannot be bounded";
|
|
155
|
+
}
|
|
156
|
+
const program = path.basename(tokens[0]);
|
|
157
|
+
const args = tokens.slice(1);
|
|
158
|
+
// The first argument that is not an option — the subcommand, for the
|
|
159
|
+
// multiplexers below.
|
|
160
|
+
const subcommand = args.find((a) => !a.startsWith("-"));
|
|
161
|
+
if (program === "git" && !(subcommand && GIT_READ_ONLY.has(subcommand))) {
|
|
162
|
+
return "git writes refs, HEAD, the index, or the stash — none of which the checkpoint captures";
|
|
163
|
+
}
|
|
164
|
+
if (PACKAGE_MANAGERS.has(program) &&
|
|
165
|
+
!(subcommand && PACKAGE_MANAGER_LOCAL.has(subcommand))) {
|
|
166
|
+
return `\`${program}${subcommand ? ` ${subcommand}` : ""}\` reaches a registry or gitignored install state, which the checkpoint does not capture`;
|
|
167
|
+
}
|
|
168
|
+
const escapes = ESCAPES_ROOT.get(program);
|
|
169
|
+
if (escapes)
|
|
170
|
+
return `\`${program}\` is not checkpointable — ${escapes}`;
|
|
171
|
+
const outside = args.find((a) => escapesRoot(a, root));
|
|
172
|
+
if (outside) {
|
|
173
|
+
return `\`${outside}\` resolves outside the workspace root, which the checkpoint does not capture`;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Whether a command argument names a path the checkpoint would not cover.
|
|
179
|
+
*
|
|
180
|
+
* Handles `--flag=<path>` as well as a bare path, because the flag form is how
|
|
181
|
+
* an out-of-root target most often sneaks past a naive check. Absolute paths and
|
|
182
|
+
* anything containing `..` are resolved against the root and tested; a plain
|
|
183
|
+
* relative token cannot escape, so it is left alone. (`~` and `*` never reach
|
|
184
|
+
* here — {@link commandTokens} already rejects the whole command for those.)
|
|
185
|
+
*/
|
|
186
|
+
function escapesRoot(arg, root) {
|
|
187
|
+
const eq = arg.indexOf("=");
|
|
188
|
+
const candidate = eq === -1 ? arg : arg.slice(eq + 1);
|
|
189
|
+
if (candidate === "")
|
|
190
|
+
return false;
|
|
191
|
+
const looksLikePath = candidate.startsWith("/") || candidate.split("/").includes("..");
|
|
192
|
+
if (!looksLikePath)
|
|
193
|
+
return false;
|
|
194
|
+
return !isInside(root, path.resolve(root, candidate));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Why the checkpoint cannot restore a file action, or `null` when it can.
|
|
198
|
+
*
|
|
199
|
+
* A delete is not special here — `patchHasDelete` raises the *tier* because a
|
|
200
|
+
* delete deserves a louder prompt, but a deleted file inside the root is
|
|
201
|
+
* restored from the snapshot exactly like an edited one. What matters is
|
|
202
|
+
* containment, and that every target is known: an action carrying no resolved
|
|
203
|
+
* target cannot be shown to be covered, so it is not.
|
|
204
|
+
*/
|
|
205
|
+
function fileIrreversibility(targets, root) {
|
|
206
|
+
if (targets.length === 0) {
|
|
207
|
+
return "the action names no resolvable target, so the checkpoint cannot be shown to cover it";
|
|
208
|
+
}
|
|
209
|
+
const outside = targets.find((t) => !isInside(root, t));
|
|
210
|
+
if (outside) {
|
|
211
|
+
return `\`${outside}\` is outside the workspace root, which the checkpoint does not capture`;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
38
215
|
// ── shell ─────────────────────────────────────────────────────────────────────
|
|
39
216
|
// Any shell control/expansion character means we cannot prove the command is a
|
|
40
217
|
// single simple invocation. Covers && || ; | ` $ ( ) { } * ? ~ ! # ' " \ < > and
|
|
@@ -71,6 +248,7 @@ function shellRequest(action, root) {
|
|
|
71
248
|
summary: `run: ${command}`,
|
|
72
249
|
targets: [],
|
|
73
250
|
cwd: root,
|
|
251
|
+
irreversible: shellIrreversibility(command, root),
|
|
74
252
|
};
|
|
75
253
|
}
|
|
76
254
|
// ── test (run the project's test suite, C.13) ──────────────────────────────────
|
|
@@ -92,6 +270,16 @@ function testRequest(action, root) {
|
|
|
92
270
|
summary: `run tests: ${command}`,
|
|
93
271
|
targets: [],
|
|
94
272
|
cwd: root,
|
|
273
|
+
// A test command comes from the project's own package.json and runs after
|
|
274
|
+
// the pre-command checkpoint, so its on-disk effects are restorable — this
|
|
275
|
+
// is the one destructive-tier action auto-approve is meant to cover, and
|
|
276
|
+
// what keeps `full-auto` able to verify the code it just wrote. It is still
|
|
277
|
+
// held to the SAME shell rules: a test script that force-pushes or curls is
|
|
278
|
+
// no more restorable for being called "test". An empty command names
|
|
279
|
+
// nothing to reason about.
|
|
280
|
+
irreversible: command === ""
|
|
281
|
+
? "the test action names no command, so its effects cannot be bounded"
|
|
282
|
+
: shellIrreversibility(command, root),
|
|
95
283
|
};
|
|
96
284
|
}
|
|
97
285
|
// ── vcs (open pull request) ─────────────────────────────────────────────────────
|
|
@@ -110,6 +298,7 @@ function vcsRequest(action, root) {
|
|
|
110
298
|
summary: `open PR: ${title}`,
|
|
111
299
|
targets: [],
|
|
112
300
|
cwd: root,
|
|
301
|
+
irreversible: "opening a PR commits and pushes under your identity; the checkpoint captures working-tree files, never commits, refs, or anything already on the remote",
|
|
113
302
|
};
|
|
114
303
|
}
|
|
115
304
|
// ── rollback (restore checkpoint) ──────────────────────────────────────────────
|
|
@@ -137,6 +326,7 @@ function rollbackRequest(action, root) {
|
|
|
137
326
|
`(${fileCount} file${fileCount === 1 ? "" : "s"})`,
|
|
138
327
|
targets: [],
|
|
139
328
|
cwd: root,
|
|
329
|
+
irreversible: ROLLBACK_IRREVERSIBLE,
|
|
140
330
|
};
|
|
141
331
|
}
|
|
142
332
|
const preview = action.preview?.type === "rollback" ? action.preview : undefined;
|
|
@@ -152,8 +342,17 @@ function rollbackRequest(action, root) {
|
|
|
152
342
|
? preview.files.map((f) => path.resolve(root, f.path))
|
|
153
343
|
: [],
|
|
154
344
|
cwd: root,
|
|
345
|
+
irreversible: ROLLBACK_IRREVERSIBLE,
|
|
155
346
|
};
|
|
156
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* A rollback is the one action the checkpoint system cannot protect you from:
|
|
350
|
+
* `withCheckpointGate` takes no snapshot for `kind: "rollback"`, so restoring a
|
|
351
|
+
* checkpoint discards whatever the tree currently holds with nothing to undo it
|
|
352
|
+
* with. It is never auto-approved — the mode whose safety net IS the checkpoint
|
|
353
|
+
* must not be able to silently spend it.
|
|
354
|
+
*/
|
|
355
|
+
const ROLLBACK_IRREVERSIBLE = "a rollback overwrites the working tree and is not itself checkpointed, so the current state would be unrecoverable";
|
|
157
356
|
// ── mcp (call an external MCP server's tool, C.27) ──────────────────────────────
|
|
158
357
|
/**
|
|
159
358
|
* An MCP tool call. Always `destructive` — the tool is arbitrary code in a
|
|
@@ -176,6 +375,10 @@ function mcpRequest(action, root) {
|
|
|
176
375
|
summary: `call MCP tool ${tool || "(unknown)"} on server ${server || "(unknown)"}`,
|
|
177
376
|
targets: [],
|
|
178
377
|
cwd: root,
|
|
378
|
+
// Same reasoning as the tier: the server cannot vouch for itself. A
|
|
379
|
+
// `readOnlyHint` is a claim by the thing being gated, and the checkpoint
|
|
380
|
+
// cannot reach whatever it did anyway.
|
|
381
|
+
irreversible: "an MCP tool runs unsandboxed in an external server, so its effects are outside the checkpoint entirely",
|
|
179
382
|
};
|
|
180
383
|
}
|
|
181
384
|
// ── file (write / edit / patch) ────────────────────────────────────────────────
|
|
@@ -188,6 +391,7 @@ function fileRequest(action, tier, root) {
|
|
|
188
391
|
summary: fileSummary(action, targets, root),
|
|
189
392
|
targets,
|
|
190
393
|
cwd: root,
|
|
394
|
+
irreversible: fileIrreversibility(targets, root),
|
|
191
395
|
};
|
|
192
396
|
}
|
|
193
397
|
/** Absolute target paths for a file action (write/edit: one; patch: many). */
|
package/dist/approval/policy.js
CHANGED
|
@@ -75,20 +75,58 @@ export function scopeCovers(scope, request) {
|
|
|
75
75
|
export class InteractivePolicy {
|
|
76
76
|
allowlist;
|
|
77
77
|
io;
|
|
78
|
-
|
|
78
|
+
autoApprove;
|
|
79
|
+
constructor(allowlist, io,
|
|
80
|
+
/**
|
|
81
|
+
* Whether the session is in an auto-approving mode (P5 track 3).
|
|
82
|
+
*
|
|
83
|
+
* A THUNK, not a boolean: the mode is runtime state the user can change at
|
|
84
|
+
* any point, including between two actions of the same turn. Reading it
|
|
85
|
+
* once at construction would let a session that has been switched back to
|
|
86
|
+
* `manual` keep auto-approving for the rest of its life — a stale copy of
|
|
87
|
+
* exactly the fact this must never be wrong about.
|
|
88
|
+
*
|
|
89
|
+
* Omitted → never auto-approves, which is every non-interactive caller.
|
|
90
|
+
*/
|
|
91
|
+
autoApprove = () => false) {
|
|
79
92
|
this.allowlist = allowlist;
|
|
80
93
|
this.io = io;
|
|
94
|
+
this.autoApprove = autoApprove;
|
|
81
95
|
}
|
|
82
96
|
async decide(request) {
|
|
97
|
+
// A grant the allowlist answers shows the user nothing (P3) — left
|
|
98
|
+
// unflagged so a caller can render the change instead.
|
|
83
99
|
if (this.allowlist.allows(request))
|
|
84
100
|
return { allow: true };
|
|
101
|
+
// Auto-approve: allowed without a prompt, and deliberately NOT flagged as
|
|
102
|
+
// `prompted` — so `previewSilentApprovals` renders the diff. An unattended
|
|
103
|
+
// mode that showed nothing would let a session's whole body of work land
|
|
104
|
+
// invisibly, which is a different failure from the one it is meant to save
|
|
105
|
+
// the user: skipping the QUESTION is the point, skipping the RECORD is not.
|
|
106
|
+
//
|
|
107
|
+
// THE CEILING (P5 track 3). Auto-approve suppresses the prompt for
|
|
108
|
+
// reversible actions ONLY. An action the run's checkpoint cannot restore —
|
|
109
|
+
// a delete outside the workspace, a force-push, a history rewrite, an MCP
|
|
110
|
+
// call, a rollback, anything unprovable — falls through to the prompt even
|
|
111
|
+
// here, and even unattended.
|
|
112
|
+
//
|
|
113
|
+
// This is the line the product position rests on. "Only change what was
|
|
114
|
+
// asked, no silent deletions" is not a claim a mode may switch off:
|
|
115
|
+
// suppressing prompts is a convenience, and suppressing the last gate before
|
|
116
|
+
// irreversible damage is a different thing wearing the same word. Note the
|
|
117
|
+
// gate is `request.irreversible`, NOT `request.tier` — the tier is an
|
|
118
|
+
// unprovability signal (every shell command is `destructive`, `ls`
|
|
119
|
+
// included), so gating on it would make the mode either useless or
|
|
120
|
+
// dishonest. See `ApprovalRequest.irreversible`.
|
|
121
|
+
if (this.autoApprove() && !request.irreversible)
|
|
122
|
+
return { allow: true };
|
|
85
123
|
const choice = await promptForApproval(request, this.io);
|
|
86
124
|
switch (choice.kind) {
|
|
87
125
|
case "once":
|
|
88
|
-
return { allow: true };
|
|
126
|
+
return { allow: true, prompted: true };
|
|
89
127
|
case "session":
|
|
90
128
|
this.allowlist.grant(request);
|
|
91
|
-
return { allow: true };
|
|
129
|
+
return { allow: true, prompted: true };
|
|
92
130
|
case "reject":
|
|
93
131
|
return choice.reason
|
|
94
132
|
? {
|
package/dist/approval/prompt.js
CHANGED
|
@@ -10,29 +10,39 @@ import { themeForColor } from "../theme/index.js";
|
|
|
10
10
|
* is a reject.
|
|
11
11
|
*/
|
|
12
12
|
export async function promptForApproval(request, io) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
? { kind: "
|
|
31
|
-
|
|
13
|
+
// The terminal is about to belong to this prompt: yield the live region
|
|
14
|
+
// BEFORE the question lands, or it would be painted into a region the next
|
|
15
|
+
// repaint erases (U.2/U.4). `finally` gives it back on every path, including
|
|
16
|
+
// the default-deny ones.
|
|
17
|
+
io.beginPrompt?.();
|
|
18
|
+
try {
|
|
19
|
+
io.write(render(request, io.color, io.columns ?? resolveColumns()));
|
|
20
|
+
const key = (await io.readKey()).toLowerCase();
|
|
21
|
+
io.write("\n");
|
|
22
|
+
switch (key) {
|
|
23
|
+
case "y":
|
|
24
|
+
return { kind: "once" };
|
|
25
|
+
case "a":
|
|
26
|
+
return { kind: "session" };
|
|
27
|
+
case "n": {
|
|
28
|
+
io.write(" reason (optional, sent to the agent): ");
|
|
29
|
+
const reason = (await io.readLine()).trim();
|
|
30
|
+
return reason ? { kind: "reject", reason } : { kind: "reject" };
|
|
31
|
+
}
|
|
32
|
+
case "t": {
|
|
33
|
+
io.write(" what should the agent do instead? ");
|
|
34
|
+
const instruction = (await io.readLine()).trim();
|
|
35
|
+
return instruction
|
|
36
|
+
? { kind: "instruct", instruction }
|
|
37
|
+
: { kind: "reject" };
|
|
38
|
+
}
|
|
39
|
+
default:
|
|
40
|
+
// n/a key, empty, EOF, Ctrl-C → default-deny.
|
|
41
|
+
return { kind: "reject" };
|
|
32
42
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
io.endPrompt?.();
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
@@ -48,10 +58,27 @@ export function render(request, color, columns = resolveColumns()) {
|
|
|
48
58
|
const label = tierLabel(request.tier, t);
|
|
49
59
|
const lines = [];
|
|
50
60
|
lines.push(...header(request.summary, mark, label, t, columns));
|
|
61
|
+
lines.push(...whyAsking(request.irreversible, t, columns));
|
|
51
62
|
lines.push(detail(request, t, columns));
|
|
52
63
|
lines.push(choices(request.scope, t));
|
|
53
64
|
return lines.filter((l) => l !== "").join("\n") + " ";
|
|
54
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The one-clause reason the checkpoint cannot restore this action, when there is
|
|
68
|
+
* one. Rendered for every mode, not just the auto ones — the prompt has no way
|
|
69
|
+
* to know the session's mode, and a user in `manual` benefits from the same
|
|
70
|
+
* sentence.
|
|
71
|
+
*
|
|
72
|
+
* It earns its line in auto-approve, where an unexplained prompt is actively
|
|
73
|
+
* misleading: the user asked not to be asked, so the only useful thing to say is
|
|
74
|
+
* why this one is different. Reflowed, never truncated — a half-sentence about
|
|
75
|
+
* irreversibility is worse than none.
|
|
76
|
+
*/
|
|
77
|
+
function whyAsking(irreversible, t, width) {
|
|
78
|
+
if (!irreversible)
|
|
79
|
+
return [];
|
|
80
|
+
return reflow(`no undo: ${irreversible}`, Math.max(1, width - 2)).map((l) => t.muted(` ${l}`));
|
|
81
|
+
}
|
|
55
82
|
/**
|
|
56
83
|
* The header, width-aware (U.12). Wide: the one inline line `! cruxy wants to
|
|
57
84
|
* <summary> (destructive)`. Narrow: the risk (mark + tier label) stands on its
|
package/dist/checkpoint/gate.js
CHANGED
|
@@ -81,6 +81,18 @@ export class CheckpointGate {
|
|
|
81
81
|
});
|
|
82
82
|
await writeSet(this.primaryRoot, this.set);
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The current run's id, or undefined before the first {@link beginRun}.
|
|
86
|
+
*
|
|
87
|
+
* This is the ONE per-turn id in the CLI, and the session log (P2) borrows it
|
|
88
|
+
* rather than minting a second: `cruxy rollback <id>` reverts a turn's
|
|
89
|
+
* mutations and the session log records that turn's messages, so if the two
|
|
90
|
+
* used different ids the undo unit would mean two different things depending
|
|
91
|
+
* on which surface you asked. Exposed read-only — only `beginRun` sets it.
|
|
92
|
+
*/
|
|
93
|
+
currentRunId() {
|
|
94
|
+
return this.runId ?? undefined;
|
|
95
|
+
}
|
|
84
96
|
/** Root names that got a per-root service this process (inspection/tests). */
|
|
85
97
|
get touchedRoots() {
|
|
86
98
|
return [...this.services.keys()];
|