@cruxy/cli 1.2.0 → 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.
Files changed (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -1,39 +1,19 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadProjectInstructions } from "../config/index.js";
3
- import { resolveTaskModel } from "../routing/index.js";
3
+ import { resolveTaskModel, } from "../routing/index.js";
4
4
  import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
5
5
  import { Budget } from "./budget.js";
6
+ import { estimateTokens, findCut } from "./context.js";
6
7
  import { runAgent, } from "./loop.js";
8
+ import { DEFAULT_MODE, modeAutoApproves, modePlans, nextMode, parseMode, } from "./mode.js";
7
9
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
8
10
  /**
9
- * Estimate the token footprint of a message list with a cheap chars/4 heuristic
10
- * no tokenizer dependency. Good enough to decide *when* to compact; exact
11
- * counts are deferred to a later phase. Counts only textual payload (block
12
- * structure and role labels are negligible and ignored).
11
+ * Re-exported from `agent/context.ts`, where the estimate now lives beside the
12
+ * cut-point search and the budget reading (P6 track 3) one module for
13
+ * everything that measures the window, so the seam that acts on the numbers and
14
+ * the surfaces that explain them cannot drift.
13
15
  */
14
- export function estimateTokens(messages) {
15
- let chars = 0;
16
- for (const msg of messages) {
17
- if (typeof msg.content === "string") {
18
- chars += msg.content.length;
19
- continue;
20
- }
21
- for (const block of msg.content) {
22
- switch (block.type) {
23
- case "text":
24
- chars += block.text.length;
25
- break;
26
- case "tool_use":
27
- chars += block.name.length + JSON.stringify(block.input).length;
28
- break;
29
- case "tool_result":
30
- chars += block.content.length;
31
- break;
32
- }
33
- }
34
- }
35
- return Math.ceil(chars / 4);
36
- }
16
+ export { estimateTokens } from "./context.js";
37
17
  /**
38
18
  * Owns the state of one multi-turn conversation: the running message history and
39
19
  * the usage accumulated across turns. Each `send` continues from the prior
@@ -52,43 +32,150 @@ export class Session {
52
32
  usage = { input_tokens: 0, output_tokens: 0 };
53
33
  /** Stable id for this session (C.22), so a run's usage record groups with the
54
34
  * other runs of the same interactive session (`cruxy usage --session`). */
55
- sessionId = randomUUID();
35
+ sessionId;
56
36
  /** The most recent run's usage record (C.22) — the one-shot path reads it to
57
37
  * print the end-of-run summary. */
58
38
  lastRun;
59
39
  args;
60
40
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
61
41
  projectInstructions;
62
- /** Mutable so `/plan` can toggle plan mode mid-session. */
63
- planMode;
42
+ /**
43
+ * The session's mode (P5 track 3) — the ONE piece of state saying how much a
44
+ * turn does without asking. Mutable so `/mode` and Shift+Tab can change it
45
+ * mid-session.
46
+ *
47
+ * Plan mode is DERIVED from this, not stored beside it. Before P5 the session
48
+ * held a `planMode` boolean while auto-approve was a (dead) config key, which
49
+ * is two sources of truth for one question and exactly the shape of bug the
50
+ * tier work had just finished removing.
51
+ */
52
+ mode;
53
+ /**
54
+ * How many messages of the CURRENT history the recorder already holds (P2).
55
+ *
56
+ * This is what lets an append-only log track an array that is rewritten in
57
+ * place. The invariant: a replay of the log so far reproduces exactly the
58
+ * first `recordedCount` messages of the live history. So an append is
59
+ * `slice(recordedCount)`, and a compaction updates the watermark to the
60
+ * post-compaction length rather than re-emitting anything.
61
+ *
62
+ * It is only ever advanced at points where the history is COHERENT — a
63
+ * tool_use and its tool_result are never split across a flush — because the
64
+ * loop calls the compaction seam at the top of an iteration, after the
65
+ * previous iteration fully resolved its tool calls.
66
+ */
67
+ recordedCount = 0;
64
68
  constructor(args) {
65
69
  this.args = args;
66
70
  this.projectInstructions = args.projectInstructions ?? null;
67
- // Plan mode requires a wired runner; without one it stays off (no half-on
68
- // state where the plan directive is injected but nothing orchestrates it).
69
- this.planMode = (args.planMode ?? false) && args.planRunner !== undefined;
71
+ this.mode = this.resolveMode(args.mode ?? DEFAULT_MODE);
72
+ // Resume (P2): adopt the replayed state verbatim. The history is already
73
+ // recorded in the log we are continuing, so the watermark starts at its
74
+ // full length — otherwise the first flush would append the whole restored
75
+ // conversation a second time.
76
+ const restore = args.restore;
77
+ this.sessionId = restore?.sessionId ?? randomUUID();
78
+ if (restore) {
79
+ this.messages = restore.messages;
80
+ this.usage.input_tokens = restore.usage.input_tokens;
81
+ this.usage.output_tokens = restore.usage.output_tokens;
82
+ this.recordedCount = restore.messages.length;
83
+ this.mode = this.resolveMode(restore.mode);
84
+ }
85
+ }
86
+ /**
87
+ * Resolve a requested mode to one this session can actually honour.
88
+ *
89
+ * A planning mode needs a wired `planRunner`; without one it degrades to the
90
+ * non-planning mode with the same approval behaviour rather than half-engaging
91
+ * (plan directive injected, nothing orchestrating it). Auto-approve is
92
+ * unaffected — it needs nothing wired.
93
+ *
94
+ * Unknown strings (a journal from a newer build, a hand-edited log) fall back
95
+ * to the default. Failing closed matters here specifically: the fallback must
96
+ * be the mode that asks MORE, never one that asks less.
97
+ */
98
+ resolveMode(want) {
99
+ const mode = parseMode(String(want)) ?? DEFAULT_MODE;
100
+ if (!modePlans(mode) || this.args.planRunner !== undefined)
101
+ return mode;
102
+ return mode === "full-auto" ? "auto-approve" : "manual";
103
+ }
104
+ /**
105
+ * Record everything appended to `current` since the last flush.
106
+ *
107
+ * Callers must only pass a history whose first `recordedCount` messages are
108
+ * unchanged — every call site satisfies this because the only operation that
109
+ * rewrites the head is compaction, which updates the watermark itself.
110
+ */
111
+ flushRecorded(current) {
112
+ if (!this.args.recorder)
113
+ return;
114
+ if (current.length <= this.recordedCount)
115
+ return;
116
+ this.args.recorder.append(current.slice(this.recordedCount));
117
+ this.recordedCount = current.length;
70
118
  }
71
119
  /** The ambient tool capabilities (gate + sandbox + cwd/config). Exposed so a
72
120
  * shell-bound custom slash command (C.19) runs through the SAME gated path. */
73
121
  get toolContext() {
74
122
  return this.args.ctx;
75
123
  }
124
+ /**
125
+ * The tool catalogue advertised to the model this session — the one
126
+ * `registerRuntimeTools` built, so it reflects which optional families
127
+ * (memory, LSP, web, MCP, subagents, jobs) are actually enabled. Exposed
128
+ * read-only, for `/status` to count.
129
+ */
130
+ get toolRegistry() {
131
+ return this.args.registry;
132
+ }
76
133
  /** The background-job manager (C.28), or undefined when jobs are disabled.
77
134
  * The REPL uses it to service paused-job approvals and drive `/jobs`; `cruxy
78
135
  * run` uses it to cancel every live job on exit. */
79
136
  get jobs() {
80
137
  return this.args.jobs;
81
138
  }
82
- /** Whether plan mode is currently on. */
83
- getPlanMode() {
84
- return this.planMode;
139
+ /**
140
+ * The session's live model choice (P6 track 1), or undefined when tiers do not
141
+ * apply — a bring-your-own provider has no cruxy tiers to choose between, and
142
+ * `/model` says so rather than offering a menu that could not take effect.
143
+ *
144
+ * Exposed as the object rather than proxied through `getModel`/`setModel`
145
+ * accessors, because the renderer needs to SUBSCRIBE to it, not just read it:
146
+ * the model panel and the status line have to react to a change within the
147
+ * same paint, and a getter pair cannot carry that.
148
+ */
149
+ get model() {
150
+ return this.args.model;
151
+ }
152
+ /** The session's current mode. */
153
+ getMode() {
154
+ return this.mode;
85
155
  }
86
156
  /**
87
- * Toggle plan mode. Only takes effect when a `planRunner` was wired (built by
88
- * the session factory); without one, plan mode stays off.
157
+ * Set the mode. Returns the EFFECTIVE mode, which differs from the request
158
+ * when no plan runner is wired so a caller reports what happened rather
159
+ * than what it asked for.
89
160
  */
90
- setPlanMode(enabled) {
91
- this.planMode = enabled && this.args.planRunner !== undefined;
161
+ setMode(mode) {
162
+ this.mode = this.resolveMode(mode);
163
+ // Record the effective value, not the request: the log must say what the
164
+ // session actually did.
165
+ this.args.recorder?.mode(this.mode);
166
+ return this.mode;
167
+ }
168
+ /** Advance one step around the mode ring (Shift+Tab). Returns the new mode. */
169
+ cycleMode() {
170
+ return this.setMode(nextMode(this.mode));
171
+ }
172
+ /** Whether this session proposes a plan before executing (C.31). */
173
+ getPlanMode() {
174
+ return modePlans(this.mode);
175
+ }
176
+ /** Whether gated actions run without a prompt in this session. */
177
+ getAutoApprove() {
178
+ return modeAutoApproves(this.mode);
92
179
  }
93
180
  /**
94
181
  * Run one user turn: append the prompt, compact if the history has grown past
@@ -107,6 +194,9 @@ export class Session {
107
194
  for (const tool of this.args.registry.list())
108
195
  tool.onTurnStart?.();
109
196
  this.messages.push({ role: "user", content: userPrompt });
197
+ // Record the user's turn before anything can fail (P2): a turn that dies in
198
+ // the provider still leaves what the user asked for on disk.
199
+ this.flushRecorded(this.messages);
110
200
  // Usage telemetry (C.22): one collector per run. `onReq` is threaded into
111
201
  // every real model request this turn drives — the main loop, compaction, and
112
202
  // (in plan mode) the propose + execution steps — so usage is captured exactly
@@ -137,7 +227,7 @@ export class Session {
137
227
  // Plan mode (C.31) delegates the whole turn to the injected runner: propose a
138
228
  // plan, approve/revise, then execute step-by-step. Falls back to the normal
139
229
  // single-shot loop when off or unwired, so existing behavior is untouched.
140
- const result = this.planMode && this.args.planRunner
230
+ const result = modePlans(this.mode) && this.args.planRunner
141
231
  ? await this.args.planRunner({
142
232
  messages: this.messages,
143
233
  projectInstructions: this.projectInstructions,
@@ -167,6 +257,15 @@ export class Session {
167
257
  this.messages = result.messages;
168
258
  this.usage.input_tokens += result.usage.input_tokens;
169
259
  this.usage.output_tokens += result.usage.output_tokens;
260
+ // Persist what the turn produced (P2). The loop's mid-iteration flushes
261
+ // (via the compaction seam) already recorded most of it; this catches the
262
+ // tail after the final iteration. Usage is copied into the session log
263
+ // rather than referenced, because the usage store keeps only its newest 50
264
+ // runs while sessions are kept indefinitely.
265
+ this.flushRecorded(this.messages);
266
+ if (result.usage.input_tokens > 0 || result.usage.output_tokens > 0) {
267
+ this.args.recorder?.usage(result.usage.input_tokens, result.usage.output_tokens);
268
+ }
170
269
  // Publish the run's usage record (C.22): stash it for the one-shot summary
171
270
  // and hand it to the persistence sink. Building the record never touches the
172
271
  // network and never blocks the turn's result.
@@ -191,6 +290,11 @@ export class Session {
191
290
  /** Drop the conversation history but keep the session (for `/clear`). */
192
291
  clear() {
193
292
  this.messages = [];
293
+ // The log keeps every earlier message — `clear` is an event, not an
294
+ // erasure. Replay honours it, so a resumed session starts empty exactly as
295
+ // the live one did, while the transcript of what was said survives.
296
+ this.args.recorder?.clear();
297
+ this.recordedCount = 0;
194
298
  }
195
299
  /**
196
300
  * Compact `this.messages` only when it has grown past threshold, adopting the
@@ -212,6 +316,16 @@ export class Session {
212
316
  * unchanged — cheap, no model call.
213
317
  */
214
318
  async compactLoopHistory(messages, onRequestUsage) {
319
+ // Flush BEFORE compacting (P2). The loop owns this array and only ever
320
+ // appends to it between calls, so everything since the last flush is a pure
321
+ // append — and it has to be on disk before a compaction event can refer to
322
+ // a prefix length, or the recorded `replaced` count would be measured
323
+ // against a shorter history than the one being compacted.
324
+ //
325
+ // The loop calls this at the TOP of each iteration, after the previous
326
+ // iteration resolved all its tool calls, so the history flushed here is
327
+ // always coherent: no tool_use is ever recorded without its tool_result.
328
+ this.flushRecorded(messages);
215
329
  const result = await this.compactIfOverThreshold(messages, onRequestUsage);
216
330
  return result.messages;
217
331
  }
@@ -257,7 +371,12 @@ export class Session {
257
371
  * the summary's usage into the session total.
258
372
  */
259
373
  async runCompaction(messages, onRequestUsage) {
260
- const cut = this.findCut(messages);
374
+ // Self-contained sync (P2): whatever array is about to be rewritten must be
375
+ // fully recorded first, so the `replaced` count in the compaction event is
376
+ // measured against the same history a replay will have reconstructed. A
377
+ // no-op when the caller already flushed, which every caller does.
378
+ this.flushRecorded(messages);
379
+ const cut = findCut(messages, this.args.config.context.keepRecentMessages);
261
380
  if (cut === null)
262
381
  return { messages, compacted: null };
263
382
  const prefix = messages.slice(0, cut);
@@ -286,37 +405,20 @@ export class Session {
286
405
  content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
287
406
  },
288
407
  ];
408
+ // Record the rewrite as an EVENT (P2): the log stays append-only, and the
409
+ // messages that were folded away remain readable earlier in the file even
410
+ // though the model can no longer see them. The watermark moves to the
411
+ // post-compaction length so the next append is measured against the new
412
+ // array, not the old one.
413
+ if (this.args.recorder) {
414
+ this.args.recorder.compaction(prefix.length, summaryMessages);
415
+ this.recordedCount = summaryMessages.length + kept.length;
416
+ }
289
417
  return {
290
418
  messages: [...summaryMessages, ...kept],
291
419
  compacted: prefix.length,
292
420
  };
293
421
  }
294
- /**
295
- * Choose the boundary between the summarized prefix and the kept-recent tail.
296
- *
297
- * Tool-call integrity is the constraint: a `tool_use` (assistant) and its
298
- * matching `tool_result` (the next user message) must never straddle the cut,
299
- * or the next provider call breaks. A real user *prompt* (`role:"user"` with
300
- * string content) only occurs at a completed turn boundary, where every prior
301
- * tool exchange is already resolved — so the kept region must begin there. The
302
- * synthetic compaction-summary user message is also string content, so a
303
- * repeat compaction always finds at least the previous summary as a clean cut.
304
- *
305
- * Start from `length - keepRecentMessages` and walk *backwards* to the nearest
306
- * such prompt: this keeps at least the recent floor and lands clean. Returns
307
- * the cut index, or `null` if no safe boundary leaves a non-empty prefix
308
- * (e.g. a single long in-progress turn — nothing safe to compact).
309
- */
310
- findCut(messages) {
311
- const { keepRecentMessages } = this.args.config.context;
312
- const start = messages.length - keepRecentMessages;
313
- for (let i = start; i >= 1; i--) {
314
- const msg = messages[i];
315
- if (msg.role === "user" && typeof msg.content === "string")
316
- return i;
317
- }
318
- return null;
319
- }
320
422
  /**
321
423
  * Summarize a prefix via a standalone, tool-less provider call over a rendered
322
424
  * transcript. Throws on a stream error or empty output so callers fail open.
@@ -331,14 +433,22 @@ export class Session {
331
433
  ? resolveTaskModel(this.args.router, "summarize")
332
434
  : null;
333
435
  // Per-request usage capture for telemetry (C.22), same honesty pivot as the
334
- // main loop: unknown unless a usage event actually arrives.
436
+ // main loop: unknown unless a usage event actually arrives. The served tier
437
+ // is held across the stream for the same reason it is in the loop — it
438
+ // arrives on the opening frame, and is reported after the close.
335
439
  let sawUsage = false;
440
+ let servedTier;
441
+ let routingMode;
336
442
  for await (const ev of this.args.provider.stream({
337
443
  system: SUMMARY_SYSTEM,
338
444
  messages: [{ role: "user", content: transcript }],
339
445
  ...(routed ? { model: routed.model } : {}),
340
446
  })) {
341
447
  switch (ev.type) {
448
+ case "routing":
449
+ servedTier = ev.routing.tier;
450
+ routingMode = ev.routing.mode;
451
+ break;
342
452
  case "text_delta":
343
453
  text += ev.text;
344
454
  break;
@@ -354,9 +464,12 @@ export class Session {
354
464
  break;
355
465
  }
356
466
  }
357
- // Attribute this compaction request to the `summarize` tier honestly.
467
+ // Attribute this compaction request to the tier that served it — the
468
+ // gateway's answer over the `summarize` tier this run asked for, same
469
+ // precedence and same reasoning as the main loop.
358
470
  onRequestUsage?.({
359
- tier: routed?.tier,
471
+ tier: servedTier ?? routed?.tier,
472
+ ...(routingMode !== undefined ? { routingMode } : {}),
360
473
  usage: sawUsage ? { ...usage } : undefined,
361
474
  });
362
475
  if (!text.trim())
@@ -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). */