@cruxy/cli 1.11.1 → 1.11.3

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 (48) 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/checkpoint/service.js +44 -3
  6. package/dist/cli/commands/pr.js +14 -0
  7. package/dist/cli/commands/run.js +35 -0
  8. package/dist/cli/commands/sessions.js +8 -0
  9. package/dist/cli/session-commands.js +3 -1
  10. package/dist/cli/session-factory.js +54 -6
  11. package/dist/config/schema.js +9 -0
  12. package/dist/errors/constructors.js +15 -6
  13. package/dist/errors/types.js +7 -0
  14. package/dist/indexing/embedder.js +34 -11
  15. package/dist/indexing/model-cache.js +399 -0
  16. package/dist/mcp/bounds.js +8 -1
  17. package/dist/plan/execute.js +4 -1
  18. package/dist/plan/service.js +42 -5
  19. package/dist/plan/step-message.js +49 -0
  20. package/dist/render/context-view.js +44 -1
  21. package/dist/render/status-view.js +13 -0
  22. package/dist/session/index.js +7 -3
  23. package/dist/session/log.js +163 -2
  24. package/dist/session/owner.js +123 -0
  25. package/dist/session/prune.js +11 -0
  26. package/dist/session/recorded-runs.js +56 -0
  27. package/dist/session/replay.js +75 -1
  28. package/dist/session/resume.js +110 -3
  29. package/dist/session/types.js +158 -0
  30. package/dist/subagent/orchestrator.js +2 -2
  31. package/dist/subagent/registry-scope.js +28 -5
  32. package/dist/testing/run-tests-tool.js +3 -1
  33. package/dist/tools/create-pull-request.js +8 -1
  34. package/dist/tools/file/apply-patch.js +53 -23
  35. package/dist/tools/file/edit-file.js +19 -1
  36. package/dist/tools/file/snapshot.js +68 -0
  37. package/dist/tools/file/write-file.js +31 -5
  38. package/dist/tools/registry.js +39 -8
  39. package/dist/tools/schema-depth.js +79 -6
  40. package/dist/tools/shell/exec.js +7 -0
  41. package/dist/tools/shell/run-command.js +45 -21
  42. package/dist/utils/process-owner.js +107 -0
  43. package/dist/vcs/generate.js +48 -6
  44. package/dist/verification/index.js +15 -0
  45. package/dist/verification/ledger.js +99 -0
  46. package/dist/verification/types.js +26 -0
  47. package/dist/verification/view.js +87 -0
  48. package/package.json +3 -2
@@ -7,17 +7,21 @@
7
7
  * cruxy's fields never make an older one discard a session;
8
8
  * - `log.ts` — the writer: one line per event, `0600`, non-fatal on failure;
9
9
  * - `replay.ts` — the fold back to state, tolerant of torn/unknown lines;
10
+ * - `recorded-runs.ts` — an ended session's verification record, for
11
+ * `cruxy pr` (P2 verification);
10
12
  * - `list.ts` — what the picker and the TUI sidebar both read;
11
13
  * - `prune.ts` — retention: what the tree is allowed to keep (#257);
12
14
  * - `resume.ts` — `--resume <id>` and the bare-`--resume` picker;
13
15
  * - `paths.ts` — the layout, including the subtrees reserved for P3+.
14
16
  */
15
17
  export { PROJECTS_DIR_NAME, RESERVED_SUBDIRS, SESSION_FILE_EXT, projectDir, projectKey, projectsDir, reservedDir, sessionFile, } from "./paths.js";
16
- export { SessionLog } from "./log.js";
18
+ export { SessionLog, } from "./log.js";
19
+ export { claimSession, describeHolder, ownerFile, readOwner, releaseSession, removeOwnerFile, sessionHeldBy, } from "./owner.js";
17
20
  export { defaultExportName, exportMarkdown, } from "./export.js";
18
21
  export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
22
+ export { latestRecordedRuns, recordedRuns } from "./recorded-runs.js";
19
23
  export { redactMessages } from "./redact.js";
20
24
  export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, sessionFilesByRecency, summarizeSession, } from "./list.js";
21
25
  export { pruneSessions, } from "./prune.js";
22
- export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
23
- export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
26
+ export { cwdMismatchWarning, describeCompactions, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
27
+ export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, emptyCompactionTally, ExternalChangeEventSchema, InstructionLossEventSchema, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, VerificationEventSchema, } from "./types.js";
@@ -3,7 +3,9 @@ import path from "node:path";
3
3
  import { APP_VERSION } from "../constants.js";
4
4
  import { formatBytes } from "../utils/disk.js";
5
5
  import { sessionFile } from "./paths.js";
6
+ import { claimSession, describeHolder, releaseSession, sessionHeldBy, } from "./owner.js";
6
7
  import { pruneSessions } from "./prune.js";
8
+ import { MAX_FAILURE_NAMES } from "../verification/types.js";
7
9
  import { SESSION_FILE_VERSION, } from "./types.js";
8
10
  /**
9
11
  * How many pruned sessions is worth telling the user about unprompted.
@@ -20,6 +22,8 @@ export class SessionLog {
20
22
  currentRunId;
21
23
  /** Set once a write fails: the log goes inert rather than warning per turn. */
22
24
  broken = false;
25
+ /** Whether this process has stamped itself as the file's owner (P1). */
26
+ claimed = false;
23
27
  /**
24
28
  * A NEW session's `meta` line, held until the session records something.
25
29
  * Null on a reopen (the file already has its meta) and null again the moment
@@ -77,6 +81,14 @@ export class SessionLog {
77
81
  const log = new SessionLog(file, opts);
78
82
  log.pruneOnce(opts);
79
83
  if (hasContent(file)) {
84
+ // OWNERSHIP (P1). A reopen is the moment a second process would start
85
+ // interleaving its turns into this file, so the stamp is taken here,
86
+ // eagerly, like the `resumed` event below. `loadResume` has already
87
+ // refused a file another live cruxy holds; this is the claim that makes
88
+ // the NEXT resume see us. A fresh session claims at its first flush
89
+ // instead — see {@link write} — for the same reason `meta` is buffered:
90
+ // a conversation with nothing in it should leave nothing on disk.
91
+ log.claim();
80
92
  log.write({
81
93
  kind: "resumed",
82
94
  at: new Date().toISOString(),
@@ -113,14 +125,44 @@ export class SessionLog {
113
125
  messages,
114
126
  });
115
127
  }
116
- /** An older prefix of `replaced` messages was folded into `summary`. */
117
- 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) {
118
135
  this.write({
119
136
  kind: "compaction",
120
137
  at: new Date().toISOString(),
121
138
  ...this.runId(),
122
139
  replaced,
123
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,
124
166
  });
125
167
  }
126
168
  /** `/clear` — history dropped, session kept. */
@@ -135,6 +177,30 @@ export class SessionLog {
135
177
  mode(mode) {
136
178
  this.write({ kind: "mode", at: new Date().toISOString(), mode });
137
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
+ }
138
204
  /**
139
205
  * One turn's token usage. Copied here rather than referenced, because the
140
206
  * usage store keeps only its newest 50 runs while a session keeps its own
@@ -171,6 +237,46 @@ export class SessionLog {
171
237
  count,
172
238
  });
173
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
+ }
174
280
  /**
175
281
  * Enforce retention for this project, once, as this session opens.
176
282
  *
@@ -232,9 +338,46 @@ export class SessionLog {
232
338
  this.pendingMeta = null;
233
339
  if (!this.writeLine(meta))
234
340
  return false;
341
+ this.claim(); // the file now exists — so can a second `--resume` of it
235
342
  }
236
343
  return this.writeLine(event);
237
344
  }
345
+ /**
346
+ * Stamp this process as the file's owner (P1 — see `owner.ts`). Idempotent.
347
+ * The stamp is released on process exit; a crash leaves it behind, and that
348
+ * is fine — a stamp is pid + start-time, so the next reader sees a dead
349
+ * owner and ignores it. Never fatal: an unwritable stamp is the pre-P1
350
+ * behaviour (no ownership), and a session must still run without one.
351
+ */
352
+ claim() {
353
+ if (this.claimed)
354
+ return;
355
+ // Never overwrite a LIVE owner's stamp. `loadResume` refuses that file
356
+ // before we get here, so this is the guard for any other writer that
357
+ // opens a log — and for the race two resumes in the same instant would
358
+ // be. Recording continues (the format tolerates it); ownership does not
359
+ // move, so the third process to come along still sees the real holder.
360
+ const holder = sessionHeldBy(this.file);
361
+ if (holder) {
362
+ this.logger?.warn(`session is open in another cruxy (${describeHolder(holder)}) — this process is not taking it over`);
363
+ return;
364
+ }
365
+ this.claimed = claimSession(this.file);
366
+ if (this.claimed)
367
+ releaseOnExit(this.file);
368
+ }
369
+ /**
370
+ * Give the file up: remove our stamp. The exit hook does this for the
371
+ * normal case; this is for a caller that ends a session while the process
372
+ * lives on (tests, and any future in-process session switch).
373
+ */
374
+ close() {
375
+ if (!this.claimed)
376
+ return;
377
+ this.claimed = false;
378
+ releaseSession(this.file);
379
+ claimedFiles.delete(this.file);
380
+ }
238
381
  /**
239
382
  * Append one event as a single line. Returns whether it landed. The first
240
383
  * failure warns and latches `broken`, so a persistent problem (a full disk)
@@ -255,6 +398,24 @@ export class SessionLog {
255
398
  }
256
399
  }
257
400
  }
401
+ /**
402
+ * Every session file this process has stamped, released together on exit.
403
+ * ONE listener for the process rather than one per log: a test opens dozens
404
+ * of logs, and `process.on` warns past ten listeners.
405
+ */
406
+ const claimedFiles = new Set();
407
+ let exitHookInstalled = false;
408
+ function releaseOnExit(file) {
409
+ claimedFiles.add(file);
410
+ if (exitHookInstalled)
411
+ return;
412
+ exitHookInstalled = true;
413
+ process.on("exit", () => {
414
+ for (const f of claimedFiles)
415
+ releaseSession(f);
416
+ claimedFiles.clear();
417
+ });
418
+ }
258
419
  /**
259
420
  * Whether `file` is an existing log with content — i.e. this is a reopen.
260
421
  *
@@ -0,0 +1,123 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { describeOwner, selfStamp, } from "../utils/process-owner.js";
5
+ import { SESSION_FILE_EXT } from "./paths.js";
6
+ /**
7
+ * Session ownership across processes (P1).
8
+ *
9
+ * A session log is one append-only file, and `SessionLog` was built so that
10
+ * concurrent appenders interleave whole lines rather than tearing them. That
11
+ * keeps the file PARSEABLE under two writers; it does not keep it MEANINGFUL.
12
+ * Two processes that `--resume` the same id each replay the same history,
13
+ * each append their own turns, and the result is one transcript with two
14
+ * conversations shuffled into it — replayable, and wrong.
15
+ *
16
+ * The fix is ownership, and the minimum that prevents the observed failure is
17
+ * a stamp, not a lock:
18
+ *
19
+ * - `<id>.owner.json` beside the log holds the {@link ProcessStamp} of the
20
+ * cruxy that has it open. It is written when the session first lands on
21
+ * disk (a new session) or on reopen (a resume), and removed on exit.
22
+ * - `--resume` reads it BEFORE replaying. A stamp whose process is still
23
+ * running is a refusal; a stamp whose process is gone — or whose pid has
24
+ * been recycled onto something else — is ignored and overwritten.
25
+ *
26
+ * WHY A STAMP AND NOT A LOCK. A lock file that must be deleted to be released
27
+ * outlives every crash, and a lock that outlives a crash is worse than no
28
+ * lock: the next `--resume` is refused with nothing to refuse it for, and the
29
+ * user learns to `rm` it — at which point it stops meaning anything. A pid +
30
+ * start-time stamp is self-invalidating: liveness is decided by asking the OS,
31
+ * never by whether cleanup ran. See `utils/process-owner.ts` for why the pid
32
+ * marker refused for JOB logs is category-correct for sessions.
33
+ *
34
+ * WHY NOT A PROJECT-LEVEL LOCK. Several cruxy processes in one project is the
35
+ * multi-agent case, not a misuse of it. What must not happen is two of them
36
+ * on ONE session; the stamp is scoped to exactly that.
37
+ */
38
+ const OwnerSchema = z.object({
39
+ pid: z.number().int().positive(),
40
+ token: z.string().min(1),
41
+ startedAt: z.string(),
42
+ /** When the stamp was written — for messages. */
43
+ claimedAt: z.string(),
44
+ });
45
+ /** `<id>.owner.json` next to `<id>.jsonl`. */
46
+ export function ownerFile(sessionFile) {
47
+ const dir = path.dirname(sessionFile);
48
+ const id = path.basename(sessionFile, SESSION_FILE_EXT);
49
+ return path.join(dir, `${id}.owner.json`);
50
+ }
51
+ /** The recorded owner, or null when there is none (absent, unreadable, malformed). */
52
+ export function readOwner(sessionFile) {
53
+ let raw;
54
+ try {
55
+ raw = readFileSync(ownerFile(sessionFile), "utf8");
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ try {
61
+ const parsed = OwnerSchema.safeParse(JSON.parse(raw));
62
+ return parsed.success ? parsed.data : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Who has the session open right now, if anyone other than this process.
70
+ * A stale stamp is not a holder; neither is our own.
71
+ */
72
+ export function sessionHeldBy(sessionFile) {
73
+ const owner = readOwner(sessionFile);
74
+ if (!owner)
75
+ return null;
76
+ return describeOwner(owner) === "live" ? owner : null;
77
+ }
78
+ /**
79
+ * Record this process as the session's owner. Temp-then-rename so a reader
80
+ * never sees a half-written stamp; `0600` like everything else in `~/.cruxy`.
81
+ * Never throws: a stamp that cannot be written degrades to the pre-P1
82
+ * behaviour (no ownership), and the session must still run.
83
+ */
84
+ export function claimSession(sessionFile) {
85
+ const file = ownerFile(sessionFile);
86
+ const me = selfStamp();
87
+ const record = { ...me, claimedAt: new Date().toISOString() };
88
+ try {
89
+ mkdirSync(path.dirname(file), { recursive: true });
90
+ const tmp = `${file}.${process.pid}.tmp`;
91
+ writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
92
+ renameSync(tmp, file);
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ /** Remove our stamp — only ours; a later owner's is left alone. Never throws. */
100
+ export function releaseSession(sessionFile) {
101
+ const owner = readOwner(sessionFile);
102
+ if (!owner || describeOwner(owner) !== "self")
103
+ return;
104
+ try {
105
+ unlinkSync(ownerFile(sessionFile));
106
+ }
107
+ catch {
108
+ // already gone, or unwritable — nothing to do either way
109
+ }
110
+ }
111
+ /** Drop a session's stamp unconditionally — for deleting the session itself. */
112
+ export function removeOwnerFile(sessionFile) {
113
+ try {
114
+ unlinkSync(ownerFile(sessionFile));
115
+ }
116
+ catch {
117
+ // no stamp to remove
118
+ }
119
+ }
120
+ /** One line for a refusal or a picker row: which process, since when. */
121
+ export function describeHolder(stamp) {
122
+ return `pid ${stamp.pid}, started ${stamp.startedAt}`;
123
+ }
@@ -4,6 +4,7 @@ import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKe
4
4
  import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
5
5
  import { sessionFilesByRecency } from "./list.js";
6
6
  import { SESSION_FILE_EXT } from "./paths.js";
7
+ import { removeOwnerFile, sessionHeldBy } from "./owner.js";
7
8
  /** `<sessionId>.jsonl` → `<sessionId>`. */
8
9
  function idOf(file) {
9
10
  return path.basename(file, SESSION_FILE_EXT);
@@ -59,6 +60,15 @@ export function pruneSessions(cwd, opts) {
59
60
  result.kept++;
60
61
  continue;
61
62
  }
63
+ // A session ANOTHER live cruxy has open is spared for the same reason our
64
+ // own is (P1): its writer would recreate the file on its next append with
65
+ // no `meta` line, and that session would be unloadable from then on. The
66
+ // stamp is pid + start-time, so a crashed owner's file is prunable again
67
+ // the moment it is looked at — nothing outlives the crash.
68
+ if (sessionHeldBy(ref.file)) {
69
+ result.kept++;
70
+ continue;
71
+ }
62
72
  const tooOld = ref.mtimeMs < cutoff;
63
73
  const beyondCap = index >= retention;
64
74
  if (!tooOld && !beyondCap) {
@@ -67,6 +77,7 @@ export function pruneSessions(cwd, opts) {
67
77
  }
68
78
  try {
69
79
  unlinkSync(ref.file);
80
+ removeOwnerFile(ref.file); // a stale stamp has nothing left to own
70
81
  result.removed.push({ ...ref, sessionId });
71
82
  result.bytesFreed += ref.size;
72
83
  }
@@ -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. */