@cruxy/cli 1.11.2 → 1.11.4

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 (38) hide show
  1. package/dist/agent/instruction-loss.js +204 -0
  2. package/dist/agent/prompts.js +25 -4
  3. package/dist/agent/session.js +165 -33
  4. package/dist/agent/status.js +18 -0
  5. package/dist/cli/commands/pr.js +14 -0
  6. package/dist/cli/commands/run.js +44 -8
  7. package/dist/cli/session-commands.js +3 -1
  8. package/dist/cli/session-factory.js +54 -6
  9. package/dist/config/schema.js +9 -0
  10. package/dist/mcp/bounds.js +8 -1
  11. package/dist/plan/execute.js +4 -1
  12. package/dist/plan/service.js +42 -5
  13. package/dist/plan/step-message.js +49 -0
  14. package/dist/render/context-view.js +44 -1
  15. package/dist/render/status-view.js +13 -0
  16. package/dist/session/index.js +6 -3
  17. package/dist/session/log.js +97 -2
  18. package/dist/session/recorded-runs.js +56 -0
  19. package/dist/session/replay.js +75 -1
  20. package/dist/session/resume.js +88 -0
  21. package/dist/session/types.js +158 -0
  22. package/dist/testing/run-tests-tool.js +3 -1
  23. package/dist/tools/create-pull-request.js +8 -1
  24. package/dist/tools/file/apply-patch.js +6 -2
  25. package/dist/tools/file/edit-file.js +6 -2
  26. package/dist/tools/file/snapshot.js +9 -4
  27. package/dist/tools/file/write-file.js +7 -2
  28. package/dist/tools/registry.js +39 -8
  29. package/dist/tools/schema-depth.js +79 -6
  30. package/dist/tools/shell/exec.js +7 -0
  31. package/dist/tools/shell/run-command.js +45 -21
  32. package/dist/tui/renderer.js +59 -8
  33. package/dist/vcs/generate.js +48 -6
  34. package/dist/verification/index.js +15 -0
  35. package/dist/verification/ledger.js +99 -0
  36. package/dist/verification/types.js +26 -0
  37. package/dist/verification/view.js +87 -0
  38. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { formatBytes } from "../utils/disk.js";
5
5
  import { sessionFile } from "./paths.js";
6
6
  import { claimSession, describeHolder, releaseSession, sessionHeldBy, } from "./owner.js";
7
7
  import { pruneSessions } from "./prune.js";
8
+ import { MAX_FAILURE_NAMES } from "../verification/types.js";
8
9
  import { SESSION_FILE_VERSION, } from "./types.js";
9
10
  /**
10
11
  * How many pruned sessions is worth telling the user about unprompted.
@@ -124,14 +125,44 @@ export class SessionLog {
124
125
  messages,
125
126
  });
126
127
  }
127
- /** An older prefix of `replaced` messages was folded into `summary`. */
128
- compaction(replaced, summary) {
128
+ /**
129
+ * An older prefix of `replaced` messages was folded into `summary`. `cost`
130
+ * (P3 context quality) is what the compaction cost — the estimates either
131
+ * side of it and the summarize call's reported usage; the usage halves are
132
+ * omitted, not zeroed, when the provider reported nothing.
133
+ */
134
+ compaction(replaced, summary, cost) {
129
135
  this.write({
130
136
  kind: "compaction",
131
137
  at: new Date().toISOString(),
132
138
  ...this.runId(),
133
139
  replaced,
134
140
  summary,
141
+ ...(cost
142
+ ? {
143
+ estimatedBefore: cost.estimatedBefore,
144
+ estimatedAfter: cost.estimatedAfter,
145
+ ...(cost.summaryInputTokens !== undefined
146
+ ? { summaryInputTokens: cost.summaryInputTokens }
147
+ : {}),
148
+ ...(cost.summaryOutputTokens !== undefined
149
+ ? { summaryOutputTokens: cost.summaryOutputTokens }
150
+ : {}),
151
+ }
152
+ : {}),
153
+ });
154
+ }
155
+ /**
156
+ * A compaction after which the user's instructions could not all be found
157
+ * in the synopsis (P3 context quality) — the heuristic's finding, recorded
158
+ * so it can be audited after the fact. Bounded by the detector, not here.
159
+ */
160
+ instructionLoss(sentences) {
161
+ this.write({
162
+ kind: "instruction-loss",
163
+ at: new Date().toISOString(),
164
+ ...this.runId(),
165
+ sentences,
135
166
  });
136
167
  }
137
168
  /** `/clear` — history dropped, session kept. */
@@ -146,6 +177,30 @@ export class SessionLog {
146
177
  mode(mode) {
147
178
  this.write({ kind: "mode", at: new Date().toISOString(), mode });
148
179
  }
180
+ /**
181
+ * The user approved a plan (plan-durability): the decision kind and the
182
+ * steps as approved. A fact about the session, dated — see the schema for
183
+ * why it is recorded and why a resume never acts on it.
184
+ */
185
+ planApproved(decision, steps) {
186
+ this.write({
187
+ kind: "plan-approved",
188
+ at: new Date().toISOString(),
189
+ ...this.runId(),
190
+ decision,
191
+ steps: steps.map((s) => ({ id: s.id, title: s.title, kind: s.kind })),
192
+ });
193
+ }
194
+ /** One step of the approved plan changed status (plan-durability). */
195
+ planStep(stepId, status) {
196
+ this.write({
197
+ kind: "plan-step",
198
+ at: new Date().toISOString(),
199
+ ...this.runId(),
200
+ stepId,
201
+ status,
202
+ });
203
+ }
149
204
  /**
150
205
  * One turn's token usage. Copied here rather than referenced, because the
151
206
  * usage store keeps only its newest 50 runs while a session keeps its own
@@ -182,6 +237,46 @@ export class SessionLog {
182
237
  count,
183
238
  });
184
239
  }
240
+ /**
241
+ * One observation (P2 verification): a run that actually executed, or a
242
+ * write refused because its target moved. Written as its own event kind so
243
+ * the fold treats the two apart, and stamped with the turn's run id like
244
+ * every other per-turn event, so "what ran in the turn `cruxy rollback <id>`
245
+ * would undo" is one join, not a guess.
246
+ *
247
+ * The failure NAMES are bounded and the output is not copied: the `append`
248
+ * event already holds the tool_result the model saw. This is an index over
249
+ * what happened.
250
+ */
251
+ observe(obs) {
252
+ const at = new Date().toISOString();
253
+ if (obs.kind === "verification") {
254
+ this.write({
255
+ kind: "verification",
256
+ at,
257
+ ...this.runId(),
258
+ tool: obs.tool,
259
+ command: obs.command,
260
+ ...(obs.source !== undefined ? { source: obs.source } : {}),
261
+ passed: obs.passed,
262
+ exitCode: obs.exitCode,
263
+ durationMs: obs.durationMs,
264
+ ...(obs.total !== undefined ? { total: obs.total } : {}),
265
+ failureCount: obs.failureCount,
266
+ failureNames: obs.failureNames.slice(0, MAX_FAILURE_NAMES),
267
+ outputTruncated: obs.outputTruncated,
268
+ substrate: obs.substrate,
269
+ });
270
+ return;
271
+ }
272
+ this.write({
273
+ kind: "external-change",
274
+ at,
275
+ ...this.runId(),
276
+ path: obs.path,
277
+ what: obs.what,
278
+ });
279
+ }
185
280
  /**
186
281
  * Enforce retention for this project, once, as this session opens.
187
282
  *
@@ -0,0 +1,56 @@
1
+ import path from "node:path";
2
+ import { sessionFilesByRecency } from "./list.js";
3
+ import { readEvents, readMeta } from "./replay.js";
4
+ /**
5
+ * The verification record of a session that has ENDED, read back from its log
6
+ * (P2 verification). `cruxy pr` runs outside any session, so there is no
7
+ * ledger in the process; the log is the durable side of the same record, and
8
+ * the runs it holds are the evidence there is for a PR opened from here.
9
+ *
10
+ * Every `verification` event is returned, in the order it was written — the
11
+ * fold in `replay.ts` keeps only the LAST run (what `/status` shows after a
12
+ * resume); a PR body lists them all. Nothing is filtered by content or age:
13
+ * each run carries its timestamp, and the reader judges whether a run from
14
+ * before the last edit still counts.
15
+ */
16
+ export function recordedRuns(file) {
17
+ const runs = [];
18
+ for (const event of readEvents(file).events) {
19
+ if (event.kind !== "verification")
20
+ continue;
21
+ runs.push({
22
+ at: event.at,
23
+ tool: event.tool,
24
+ command: event.command,
25
+ ...(event.source !== undefined ? { source: event.source } : {}),
26
+ passed: event.passed,
27
+ exitCode: event.exitCode,
28
+ durationMs: event.durationMs,
29
+ ...(event.total !== undefined ? { total: event.total } : {}),
30
+ failureCount: event.failureCount,
31
+ failureNames: event.failureNames,
32
+ outputTruncated: event.outputTruncated,
33
+ substrate: event.substrate,
34
+ });
35
+ }
36
+ return runs;
37
+ }
38
+ /**
39
+ * The NEWEST session for `cwd` and the runs it recorded, or null when the
40
+ * project has no session whose meta names this directory (`projectKey` can
41
+ * collide — `a-b` and `a/b` — so meta.cwd is compared, as resume does).
42
+ *
43
+ * The newest session, not the newest session that ran something: if the last
44
+ * thing done here recorded no runs, the honest answer is no section, not the
45
+ * runs of an older session cherry-picked because it has some.
46
+ */
47
+ export function latestRecordedRuns(cwd) {
48
+ const here = path.resolve(cwd);
49
+ for (const ref of sessionFilesByRecency(cwd)) {
50
+ const meta = readMeta(ref.file);
51
+ if (!meta || path.resolve(meta.cwd) !== here)
52
+ continue;
53
+ return { sessionId: meta.sessionId, runs: recordedRuns(ref.file) };
54
+ }
55
+ return null;
56
+ }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { modeFromFlags } from "../agent/mode.js";
3
3
  import { redactMessages } from "./redact.js";
4
- import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, } from "./types.js";
4
+ import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, emptyCompactionTally, } from "./types.js";
5
5
  /**
6
6
  * Replay: fold an append-only event log back into the state a session needs to
7
7
  * resume (P2).
@@ -109,6 +109,10 @@ export function foldEvents(events, counts = {}) {
109
109
  let planMode = false;
110
110
  let mode = null;
111
111
  let redactions = 0;
112
+ let lastVerification;
113
+ let externalChanges = 0;
114
+ let plan;
115
+ const compactions = emptyCompactionTally();
112
116
  const resumes = [];
113
117
  const usage = { input_tokens: 0, output_tokens: 0 };
114
118
  for (const event of events) {
@@ -123,6 +127,22 @@ export function foldEvents(events, counts = {}) {
123
127
  ...asMessages(event.summary),
124
128
  ...messages.slice(event.replaced),
125
129
  ];
130
+ // And tallied (P3): the count is the events; the sums cover only the
131
+ // events that carry a cost, so a pre-P3 compaction counts as one that
132
+ // happened and not as one that freed nothing.
133
+ compactions.count++;
134
+ if (event.estimatedBefore !== undefined &&
135
+ event.estimatedAfter !== undefined) {
136
+ compactions.measured++;
137
+ compactions.freedTokens += Math.max(0, event.estimatedBefore - event.estimatedAfter);
138
+ compactions.summaryInputTokens += event.summaryInputTokens ?? 0;
139
+ compactions.summaryOutputTokens += event.summaryOutputTokens ?? 0;
140
+ }
141
+ break;
142
+ case "instruction-loss":
143
+ // Counted, not folded: the synopsis is already in the history as the
144
+ // model sees it. This is the fact that it may be missing something.
145
+ compactions.instructionLosses++;
126
146
  break;
127
147
  case "clear":
128
148
  messages = [];
@@ -156,6 +176,56 @@ export function foldEvents(events, counts = {}) {
156
176
  // could be added without touching `meta` or the message array.
157
177
  resumes.push({ at: event.at, cwd: event.cwd });
158
178
  break;
179
+ case "verification":
180
+ // Last-writer-wins, and never folded into the messages: a run is a
181
+ // fact ABOUT the turn, not a turn in it. The tool_result the model saw
182
+ // is already in the `append` events; this is the structured claim.
183
+ lastVerification = {
184
+ at: event.at,
185
+ tool: event.tool,
186
+ command: event.command,
187
+ ...(event.source !== undefined ? { source: event.source } : {}),
188
+ passed: event.passed,
189
+ exitCode: event.exitCode,
190
+ durationMs: event.durationMs,
191
+ ...(event.total !== undefined ? { total: event.total } : {}),
192
+ failureCount: event.failureCount,
193
+ failureNames: event.failureNames,
194
+ outputTruncated: event.outputTruncated,
195
+ substrate: event.substrate,
196
+ };
197
+ break;
198
+ case "external-change":
199
+ // Counted, not folded: the refusal wrote nothing, so there is nothing
200
+ // in the history to adjust — only a fact for the resume notice.
201
+ externalChanges++;
202
+ break;
203
+ case "plan-approved":
204
+ // A new approval replaces the last: every step starts `pending`, as
205
+ // the executor had it, and the `plan-step` events that follow move
206
+ // them. Never folded into the messages — the plan's text is already
207
+ // there as the model's own `submit_plan` call.
208
+ plan = {
209
+ approvedAt: event.at,
210
+ decision: event.decision,
211
+ steps: event.steps.map((s) => ({
212
+ id: s.id,
213
+ title: s.title,
214
+ kind: s.kind,
215
+ status: "pending",
216
+ })),
217
+ };
218
+ break;
219
+ case "plan-step": {
220
+ // A transition for a step the last approval did not name is skipped,
221
+ // not an error: a torn `plan-approved` line, or a newer build's
222
+ // vocabulary. Position matters — a step left `running` is one whose
223
+ // outcome the log never received.
224
+ const step = plan?.steps.find((s) => s.id === event.stepId);
225
+ if (step)
226
+ step.status = event.status;
227
+ break;
228
+ }
159
229
  case "meta":
160
230
  break;
161
231
  }
@@ -172,6 +242,10 @@ export function foldEvents(events, counts = {}) {
172
242
  unknownEvents: counts.unknownEvents ?? 0,
173
243
  resumes,
174
244
  redactions,
245
+ ...(lastVerification ? { lastVerification } : {}),
246
+ externalChanges,
247
+ compactions,
248
+ ...(plan ? { plan } : {}),
175
249
  };
176
250
  }
177
251
  /** Read and parse a session file into its events, counting unusable lines. */
@@ -1,3 +1,4 @@
1
+ import { formatTokens } from "../render/state.js";
1
2
  import { selectList } from "../components/index.js";
2
3
  import { usageError } from "../errors/index.js";
3
4
  import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
@@ -127,8 +128,95 @@ export function loadResume(session, cwd) {
127
128
  warnings.push(`this session was redacted ${state.redactions === 1 ? "once" : `${state.redactions} times`} — ` +
128
129
  `secrets are masked in the restored history, but the original text is still in ${session.file}`);
129
130
  }
131
+ if (state.externalChanges > 0) {
132
+ // A refused write is a fact about the user's TREE, not about cruxy: while
133
+ // they were being asked to approve a change, something else wrote to the
134
+ // file. It reached the model as an error at the time; on resume it is
135
+ // said once more, because "was my tree written to by something else"
136
+ // is a question people ask after the fact.
137
+ warnings.push(`${state.externalChanges} write${state.externalChanges === 1 ? " was" : "s were"} refused in this session ` +
138
+ `because the file changed on disk while awaiting approval — nothing was written by cruxy; ` +
139
+ `the session log names each path`);
140
+ }
141
+ if (state.compactions.count > 0) {
142
+ // What compaction has cost this conversation (P3 context quality), said
143
+ // once on resume because it is the moment a user decides whether to
144
+ // continue in a history that has been summarised N times. The freed and
145
+ // spent figures cover only the compactions that recorded them; the clause
146
+ // saying so is not optional when they differ.
147
+ warnings.push(describeCompactions(state.compactions));
148
+ }
149
+ if (state.compactions.instructionLosses > 0) {
150
+ // The heuristic's finding, restated where it can still be acted on: the
151
+ // sentences are in the session log; the remedy is to say them again.
152
+ const n = state.compactions.instructionLosses;
153
+ warnings.push(`${n} compaction${n === 1 ? "" : "s"} in this session may have dropped an instruction you gave — ` +
154
+ `the session log names the sentences; restate anything that still applies`);
155
+ }
156
+ if (state.lastVerification) {
157
+ // Not a warning in spirit, but this is the one channel a resume has, and
158
+ // the sentence is the same one `/status` shows: what last ran and how it
159
+ // exited, dated. A resumed session's "the tests passed" is a claim about
160
+ // the tree as it was THEN; the age is what keeps it honest.
161
+ const v = state.lastVerification;
162
+ const exit = v.exitCode === null ? "no exit code" : `exit ${v.exitCode}`;
163
+ warnings.push(`last verification ${relativeAge(v.at)}: ${v.tool} \`${v.command}\` → ${exit}`);
164
+ }
165
+ if (state.plan) {
166
+ // The last approved plan and where it got to (plan-durability). Same
167
+ // channel, same reasoning as the verification line: a fact the user
168
+ // decides on before continuing. The sentence ends by saying the approval
169
+ // is not carried over, because the one thing a reader of "approved" might
170
+ // reasonably expect is that the consent came back with the session.
171
+ warnings.push(describePlan(state.plan));
172
+ }
130
173
  return { session, state, warnings };
131
174
  }
175
+ /**
176
+ * One sentence for the last approved plan (plan-durability): when it was
177
+ * approved and how, how many steps finished, which failed, and — the fact a
178
+ * resume exists to surface — whether a step was still running when the
179
+ * session ended. Says in so many words that the approval is not carried over:
180
+ * `[g]`'s grants lived in the allowlist of the process that asked, and this
181
+ * process has not asked.
182
+ */
183
+ export function describePlan(p, now = Date.now()) {
184
+ const n = p.steps.length;
185
+ const count = (status) => p.steps.filter((s) => s.status === status).length;
186
+ const done = count("done");
187
+ const failed = count("failed");
188
+ const pending = count("pending");
189
+ const running = p.steps.find((s) => s.status === "running");
190
+ const how = p.decision === "approve-grant"
191
+ ? "with safe steps auto-allowed"
192
+ : "step by step";
193
+ const parts = [`${done}/${n} step${n === 1 ? "" : "s"} done`];
194
+ if (failed > 0)
195
+ parts.push(`${failed} failed`);
196
+ if (running) {
197
+ parts.push(`step ${running.id} (${running.title}) was still running when the session ended`);
198
+ }
199
+ if (pending > 0)
200
+ parts.push(`${pending} never started`);
201
+ return (`last plan approved ${relativeAge(p.approvedAt, now)} ${how}: ${parts.join(", ")} — ` +
202
+ `that approval is not carried into this session; every action asks again`);
203
+ }
204
+ /**
205
+ * One sentence for what compaction has cost a session (P3 context quality).
206
+ * Shared by the resume notice and nothing else; `/context` and the one-shot
207
+ * summary render the same tally as lines (`render/context-view.ts`).
208
+ */
209
+ export function describeCompactions(c) {
210
+ const times = `compacted ${c.count === 1 ? "once" : `${c.count} times`} in this session`;
211
+ if (c.measured === 0) {
212
+ return `${times} — cost not recorded (written before it was measured)`;
213
+ }
214
+ const scope = c.measured < c.count
215
+ ? ` (${c.count - c.measured} of them recorded no cost)`
216
+ : "";
217
+ return (`${times} — freed ~${formatTokens(c.freedTokens)} tokens; ` +
218
+ `the summaries cost ~${formatTokens(c.summaryInputTokens)} in / ~${formatTokens(c.summaryOutputTokens)} out${scope}`);
219
+ }
132
220
  /**
133
221
  * VALIDATE `--resume <id>` — which session does this name? — without loading it.
134
222
  *
@@ -154,6 +154,43 @@ export const CompactionEventSchema = z
154
154
  replaced: z.number().int().nonnegative(),
155
155
  /** What replaced them (the synthetic pair). */
156
156
  summary: z.array(MessageSchema),
157
+ /**
158
+ * What the compaction COST (P3 context quality) — the four facts in hand
159
+ * at the write site and, until P3, thrown away there. All optional: a log
160
+ * written before P3 carries none, and absence means "not measured", never
161
+ * zero. Freed tokens is `estimatedBefore - estimatedAfter` and is NOT
162
+ * stored — one derivation, in the fold, rather than a fifth field that
163
+ * could disagree with the other two.
164
+ *
165
+ * The estimates are `estimateTokens` over the history — the same chars/4
166
+ * heuristic the trigger uses — WITHOUT the fixed reserve, which is a
167
+ * setting and would cancel out of the difference anyway.
168
+ */
169
+ estimatedBefore: z.number().int().nonnegative().optional(),
170
+ estimatedAfter: z.number().int().nonnegative().optional(),
171
+ /**
172
+ * The summarize call's own usage, as the provider reported it. Absent
173
+ * when the provider reported nothing — the same honesty rule as the usage
174
+ * record: unknown is not zero.
175
+ */
176
+ summaryInputTokens: z.number().int().nonnegative().optional(),
177
+ summaryOutputTokens: z.number().int().nonnegative().optional(),
178
+ })
179
+ .passthrough();
180
+ /**
181
+ * A compaction after which the user's instructions could not all be found in
182
+ * the synopsis (P3 context quality). Written by the heuristic in
183
+ * `agent/instruction-loss.ts`, which can be wrong both ways and says so; the
184
+ * event is what makes a suspected loss auditable after the fact instead of a
185
+ * warning that scrolled away. `sentences` are the user's own words, bounded
186
+ * and truncated — enough to recognise what may need restating.
187
+ */
188
+ export const InstructionLossEventSchema = z
189
+ .object({
190
+ kind: z.literal("instruction-loss"),
191
+ at: z.string(),
192
+ runId: z.string().optional(),
193
+ sentences: z.array(z.string()),
157
194
  })
158
195
  .passthrough();
159
196
  /** `/clear`: history dropped, session kept. Replay resets to an empty array. */
@@ -283,6 +320,111 @@ export const ResumedEventSchema = z
283
320
  cliVersion: z.string().optional(),
284
321
  })
285
322
  .passthrough();
323
+ /**
324
+ * One run that ACTUALLY EXECUTED (P2 verification): `run_tests`, or a
325
+ * `run_command` the model chose. An index over what happened, keyed to the
326
+ * turn it happened in — NOT a copy of the run's output. The `append` event
327
+ * already holds the tool_result the model saw, as the string built for the
328
+ * model; this is the structured claim beside it, written from the same
329
+ * object the tool built rather than re-parsed from that string (the same
330
+ * reasoning as the `run_tests` renderer side channel, P3).
331
+ *
332
+ * `passed` is the exit code and nothing else. `tool` and `source` are plain
333
+ * strings so a newer build's tool or provenance parses here instead of
334
+ * poisoning the line. A refused attempt, a denied approval or a spawn failure
335
+ * ran nothing, so no event is written for it — writing one would be a
336
+ * fabrication.
337
+ *
338
+ * Evidence, not enforcement: nothing reads this to decide whether a turn may
339
+ * end. See `verification/types.ts` for why that decision stands.
340
+ */
341
+ export const VerificationEventSchema = z
342
+ .object({
343
+ kind: z.literal("verification"),
344
+ at: z.string(),
345
+ runId: z.string().optional(),
346
+ tool: z.string(),
347
+ command: z.string(),
348
+ source: z.string().optional(),
349
+ passed: z.boolean(),
350
+ exitCode: z.number().int().nullable(),
351
+ durationMs: z.number().nonnegative(),
352
+ total: z.number().int().nonnegative().optional(),
353
+ failureCount: z.number().int().nonnegative().default(0),
354
+ failureNames: z.array(z.string()).default([]),
355
+ outputTruncated: z.boolean().default(false),
356
+ substrate: z.string(),
357
+ })
358
+ .passthrough();
359
+ /**
360
+ * A write refused because its target moved during the approval wait (P1).
361
+ *
362
+ * The refusal itself is an error string handed to the model; before this
363
+ * event the user learned of it only by opening a rollback preview later.
364
+ * Something else wrote to a file in their tree while they were being asked to
365
+ * approve a change to it, and that is recorded here as a fact about the
366
+ * session — separate from `verification` so the fold never mistakes one for
367
+ * the other. No content is recorded: the path and what moved are the whole
368
+ * fact.
369
+ */
370
+ export const ExternalChangeEventSchema = z
371
+ .object({
372
+ kind: z.literal("external-change"),
373
+ at: z.string(),
374
+ runId: z.string().optional(),
375
+ path: z.string(),
376
+ what: z.string(),
377
+ })
378
+ .passthrough();
379
+ /**
380
+ * The user approved a plan (plan-durability).
381
+ *
382
+ * Recorded as a FACT — the decision kind and the steps as approved, dated —
383
+ * because it is stronger than the mode toggle, which was already journaled,
384
+ * and it used to evaporate with the process: the plan lived in a local holder
385
+ * inside one `runPlanSession` call and nowhere else. The plan's text was
386
+ * already on disk as the model's own `submit_plan` tool_use; this is the
387
+ * typed record beside it, the one a reader can fold without parsing a tool
388
+ * call.
389
+ *
390
+ * IT IS NEVER RE-ARMED ON RESUME. `decision: "approve-grant"` says the user
391
+ * chose `[g]`, which wrote grants into the session allowlist — RAM, in the
392
+ * process that asked. A resumed session reading this event and restoring
393
+ * those grants would pre-authorize writes the user consented to in a
394
+ * different process, against a tree that has since changed. The fold hands
395
+ * back a description; nothing on the consent side reads it.
396
+ *
397
+ * `decision` and `kind` are plain strings so a log written by a newer build
398
+ * parses here rather than poisoning the line.
399
+ */
400
+ export const PlanApprovedEventSchema = z
401
+ .object({
402
+ kind: z.literal("plan-approved"),
403
+ at: z.string(),
404
+ runId: z.string().optional(),
405
+ /** `approve` or `approve-grant`, as the user chose. */
406
+ decision: z.string(),
407
+ steps: z.array(z
408
+ .object({ id: z.string(), title: z.string(), kind: z.string() })
409
+ .passthrough()),
410
+ })
411
+ .passthrough();
412
+ /**
413
+ * One step of the last approved plan changed status (plan-durability):
414
+ * `running` when its turn began, `done` or `failed` when it ended. A step
415
+ * whose last event is `running` is the signature of an interrupted plan —
416
+ * the process died before the step could report either outcome — and that
417
+ * is exactly the fact the resume notice needs.
418
+ */
419
+ export const PlanStepEventSchema = z
420
+ .object({
421
+ kind: z.literal("plan-step"),
422
+ at: z.string(),
423
+ runId: z.string().optional(),
424
+ stepId: z.string(),
425
+ status: z.string(),
426
+ })
427
+ .passthrough();
286
428
  /** Every event, discriminated on `kind`. */
287
429
  export const SessionEventSchema = z.discriminatedUnion("kind", [
288
430
  SessionMetaSchema,
@@ -294,6 +436,11 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
294
436
  UsageEventSchema,
295
437
  RedactEventSchema,
296
438
  ResumedEventSchema,
439
+ VerificationEventSchema,
440
+ ExternalChangeEventSchema,
441
+ InstructionLossEventSchema,
442
+ PlanApprovedEventSchema,
443
+ PlanStepEventSchema,
297
444
  ]);
298
445
  /**
299
446
  * Every `kind` this build understands, derived from the union itself so the two
@@ -304,3 +451,14 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
304
451
  * they used to be counted as one.
305
452
  */
306
453
  export const KNOWN_EVENT_KINDS = new Set(SessionEventSchema.options.map((option) => option.shape.kind.value));
454
+ /** A tally with nothing in it — a fresh session, or a log with no compaction. */
455
+ export function emptyCompactionTally() {
456
+ return {
457
+ count: 0,
458
+ measured: 0,
459
+ freedTokens: 0,
460
+ summaryInputTokens: 0,
461
+ summaryOutputTokens: 0,
462
+ instructionLosses: 0,
463
+ };
464
+ }
@@ -147,7 +147,9 @@ export function makeRunTestsTool(deps = {}) {
147
147
  // The structured result goes to the renderer here, from the same object
148
148
  // the payload below is built from — never from re-reading that payload.
149
149
  try {
150
- deps.onResult?.(result, resolved);
150
+ deps.onResult?.(result, resolved, {
151
+ substrate: ctx.sandbox ? "sandbox" : "host",
152
+ });
151
153
  }
152
154
  catch {
153
155
  // A renderer problem is not a test-run problem.
@@ -25,7 +25,8 @@ const parameters = z.object({
25
25
  body: z
26
26
  .string()
27
27
  .optional()
28
- .describe("PR body in markdown (what changed · why · verification). Omit to auto-generate."),
28
+ .describe("PR body in markdown (what changed · why). Omit to auto-generate. " +
29
+ "Do not write a Verification section: it is added from the record of what actually ran this session, and one you write is replaced by it."),
29
30
  base: z
30
31
  .string()
31
32
  .optional()
@@ -58,6 +59,12 @@ export const createPullRequestTool = {
58
59
  ...i,
59
60
  scopes: guidance.scopes,
60
61
  skillBody: guidance.skillBody,
62
+ // The PR body's Verification section is the record's (P2): the
63
+ // runs this session actually executed, with their exit codes —
64
+ // or no such section at all. The model's own body keeps its
65
+ // prose; a Verification section it wrote is replaced by this.
66
+ // Never a manufactured "ran typecheck + lint + test".
67
+ verification: { runs: ctx.verification?.sessionRuns() ?? [] },
61
68
  }),
62
69
  });
63
70
  const outcome = await service.openPullRequest({
@@ -172,8 +172,12 @@ export const applyPatchTool = {
172
172
  const drifted = [];
173
173
  for (const p of planned) {
174
174
  const moved = await changedSince(p.abs, p.approved, p.rel);
175
- if (moved)
176
- drifted.push(moved);
175
+ if (moved) {
176
+ drifted.push(moved.message);
177
+ // One record per path that moved (P2 verification): the user learns
178
+ // which files something else wrote to, not just that the patch failed.
179
+ ctx.verification?.record({ kind: "external-change", ...moved });
180
+ }
177
181
  }
178
182
  if (drifted.length > 0)
179
183
  return { ok: false, error: drifted.join("\n") };
@@ -76,8 +76,12 @@ export const editFileTool = {
76
76
  // changed the file during the wait makes `updated` a splice of stale
77
77
  // content — refuse rather than overwrite what is there now (P1).
78
78
  const moved = await changedSince(abs, approvedState, input.path);
79
- if (moved)
80
- return { ok: false, error: moved };
79
+ if (moved) {
80
+ // Told to the model as the error, and recorded for the user (P2
81
+ // verification) — see write-file.ts.
82
+ ctx.verification?.record({ kind: "external-change", ...moved });
83
+ return { ok: false, error: moved.message };
84
+ }
81
85
  try {
82
86
  await fs.writeFile(abs, updated, "utf8");
83
87
  return { ok: true, output: `edited ${input.path}` };
@@ -28,8 +28,9 @@ export async function snapshotFile(abs) {
28
28
  /**
29
29
  * Re-read `abs` and compare it with the state the approval was granted
30
30
  * against. Returns `null` when the file is exactly as it was, otherwise the
31
- * refusal to hand back as the tool's error one sentence on what moved, and
32
- * the same next step every time: read it again and retry.
31
+ * refusal its `message` is the tool's error (one sentence on what moved,
32
+ * and the same next step every time: read it again and retry), and its
33
+ * `path`/`what` are what the tool records so the user learns of it too.
33
34
  *
34
35
  * Call this AFTER approval and IMMEDIATELY before the write, with nothing
35
36
  * awaited in between — the point is to make the gap as small as the platform
@@ -58,6 +59,10 @@ export async function changedSince(abs, approved, rel) {
58
59
  }
59
60
  /** The one refusal shape: what moved, that nothing was written, what to do. */
60
61
  function refusal(rel, what) {
61
- return (`${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
62
- `covers this write; nothing was written — read the file again and retry`);
62
+ return {
63
+ path: rel,
64
+ what,
65
+ message: `${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
66
+ `covers this write; nothing was written — read the file again and retry`,
67
+ };
63
68
  }