@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
@@ -16,6 +16,8 @@ import { apiKeyEnvVar, classifyCredentialLifetime, configSourceFile, globalDir,
16
16
  import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
17
17
  import { createRenderer } from "../../render/index.js";
18
18
  import { themeForColor } from "../../theme/index.js";
19
+ import { verificationTurnLines } from "../../verification/index.js";
20
+ import { compactionTallyLines } from "../../render/context-view.js";
19
21
  import { summarizeRuns, renderSummary, } from "../../usage/index.js";
20
22
  import { CheckpointGate } from "../../checkpoint/index.js";
21
23
  import { SandboxService } from "../../sandbox/index.js";
@@ -357,15 +359,16 @@ export async function executeRun(promptParts, opts) {
357
359
  }) ?? undefined;
358
360
  // The TUI sidebar lists the same tree the `--resume` picker reads (P2).
359
361
  //
360
- // A FRESH SESSION IS NOT IN ITS OWN SIDEBAR UNTIL TURN 1 (#257). This used to
361
- // be populated after the log was opened precisely so the running session
362
- // listed itself; the meta line is buffered now, so there is nothing on disk
363
- // to list until something is said. That is the more honest reading the
364
- // sidebar and the `--resume` picker show the same tree, and a session with no
365
- // conversation in it is not one the picker would offer either. The active id
366
- // is still passed, so the row marks itself the moment it appears.
362
+ // A PROBE, NOT A LIST (cli#300). A fresh session is not on disk until its
363
+ // first turn the meta line is buffered (#257) — so a list read here could
364
+ // never contain the session the user is in, and with no later caller the
365
+ // sidebar stayed exactly as stale as that first read for the whole run. The
366
+ // renderer re-runs this probe after every turn, off its paint path, so the
367
+ // running session appears the moment it lands and the tree keeps following
368
+ // what this and any other cruxy write. The active id marks the row when it
369
+ // shows; the sidebar and the picker still read the same ten.
367
370
  if (renderer instanceof TuiRenderer) {
368
- renderer.setSessions(listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
371
+ renderer.attachSessions(() => listSessions(primaryRoot, SIDEBAR_SESSIONS), sessionId);
369
372
  }
370
373
  // ONE key reader for the whole TUI session (P5): the input loop and the
371
374
  // approval prompt's modal both lease it, so the two can never be live on
@@ -383,6 +386,19 @@ export async function executeRun(promptParts, opts) {
383
386
  usage: restore.usage,
384
387
  sessionId: restore.meta.sessionId,
385
388
  mode: restore.mode,
389
+ // The last run the log recorded, so `/status` after a resume
390
+ // says what last ran instead of "none". Not re-derived.
391
+ ...(restore.lastVerification
392
+ ? { lastVerification: restore.lastVerification }
393
+ : {}),
394
+ // And what compaction cost it so far (P3), so `/context`
395
+ // continues the tally instead of restarting at zero.
396
+ compactions: restore.compactions,
397
+ // `restore.plan` is deliberately NOT passed (plan-durability).
398
+ // The resume notice has already described it. Handing it to
399
+ // the session would invite exactly the thing the record must
400
+ // not do: treat a past `approve-grant` as consent in this
401
+ // process. The allowlist starts empty; every action asks.
386
402
  },
387
403
  }
388
404
  : {}),
@@ -543,6 +559,12 @@ export async function executeRun(promptParts, opts) {
543
559
  if (config.usage.enabled && session.lastRun) {
544
560
  printRunUsage(session.lastRun);
545
561
  }
562
+ // The verification record (P2 verification), next to the exit code CI
563
+ // already trusts: what ran this turn and how it exited — or that nothing
564
+ // did. Printed for BOTH a completed run and one that gave up below, and
565
+ // whether or not usage is on, so "completed" is never read on its own.
566
+ // The record is evidence; the exit code below is still decided by `stop`.
567
+ printTurnVerification(session);
546
568
  // Fail loud on a non-completed stop (#3/#5): a one-shot run that hit the
547
569
  // iteration cap or a token budget (or was cancelled) MUST exit non-zero —
548
570
  // otherwise CI reads a gave-up run as success. The partial history already
@@ -561,6 +583,20 @@ export async function executeRun(promptParts, opts) {
561
583
  }
562
584
  }
563
585
  /** Render the just-finished run's usage as a single themed line (C.22). */
586
+ /** The one-shot summary's verification block — see `verification/view.ts`. */
587
+ function printTurnVerification(session) {
588
+ const t = themeForColor(shouldUseColor(process.stdout));
589
+ for (const line of verificationTurnLines(session.turnVerification(), t)) {
590
+ logger.print(line);
591
+ }
592
+ // What compaction cost this run (P3 context quality), next to the
593
+ // verification block and for the same reason: a one-shot run that
594
+ // compacted three times and may have dropped an instruction is a fact CI
595
+ // reads nowhere else. Silent when nothing compacted.
596
+ for (const line of compactionTallyLines(session.compactions(), t)) {
597
+ logger.print(line);
598
+ }
599
+ }
564
600
  function printRunUsage(record) {
565
601
  if (record.entries.length === 0)
566
602
  return;
@@ -680,7 +680,9 @@ function handleExport(input, ctx) {
680
680
  function handleContext(ctx) {
681
681
  const { out, session } = ctx;
682
682
  const report = contextReport(session.messages, session.toolContext.config.context);
683
- for (const line of contextReportLines(report, out.theme)) {
683
+ // Plus what compaction has already cost (P3) — the one figure the
684
+ // preview above cannot give, because it is a record, not an estimate.
685
+ for (const line of contextReportLines(report, out.theme, Infinity, session.compactions())) {
684
686
  out.print(out.fit(line));
685
687
  }
686
688
  }
@@ -25,6 +25,7 @@ import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memo
25
25
  import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
26
26
  import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
27
27
  import { appendRun } from "../usage/index.js";
28
+ import { VerificationLedger } from "../verification/index.js";
28
29
  import { Semaphore, SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
29
30
  import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/index.js";
30
31
  /**
@@ -253,13 +254,26 @@ opts = {}) {
253
254
  logger.warn(`${error.code}: ${error.title} — ${error.cause}`);
254
255
  }
255
256
  : undefined;
257
+ // The verification record (P2 verification): ONE ledger for the session,
258
+ // fed by the exec tools' side channels below and by the file tools through
259
+ // `ctx.verification`, and written through to the session log as its own
260
+ // event kinds. The in-memory side is what `/status` and the one-shot
261
+ // summary read; the log is what a resume and the transcript keep.
262
+ const verification = new VerificationLedger({
263
+ sink: opts.recorder ? (obs) => opts.recorder.observe(obs) : undefined,
264
+ });
256
265
  // The `run_tests` side channel (P3): the structured outcome the tool already
257
266
  // built, handed to the renderer to draw. Every field it needs is copied
258
267
  // across as-is — nothing is derived here, so an absent `total` stays absent
259
- // rather than becoming a number the parsers refused to claim.
268
+ // rather than becoming a number the parsers refused to claim. The record
269
+ // takes the same object, from the same call — never re-parsed from the
270
+ // string the model gets.
260
271
  const execRegistry = buildDefaultRegistry({
261
- onTestResult: renderer
262
- ? (result, command) => renderer.testResult({
272
+ // `tools.fileEdit` / `tools.shell` (P4): wired here, the one place the
273
+ // default registry is built for a session.
274
+ tools: config.tools,
275
+ onTestResult: (result, command, run) => {
276
+ renderer?.testResult({
263
277
  passed: result.passed,
264
278
  command: command.command,
265
279
  durationMs: result.durationMs,
@@ -270,8 +284,38 @@ opts = {}) {
270
284
  ...(f.line !== undefined ? { line: f.line } : {}),
271
285
  })),
272
286
  outputTruncated: result.outputTruncated,
273
- })
274
- : undefined,
287
+ });
288
+ verification.record({
289
+ kind: "verification",
290
+ tool: "run_tests",
291
+ command: command.command,
292
+ source: command.source,
293
+ passed: result.passed,
294
+ exitCode: result.exitCode,
295
+ durationMs: result.durationMs,
296
+ ...(result.total !== undefined ? { total: result.total } : {}),
297
+ failureCount: result.failures.length,
298
+ failureNames: result.failures.map((f) => f.name),
299
+ outputTruncated: result.outputTruncated,
300
+ substrate: run.substrate,
301
+ });
302
+ },
303
+ // `run_command` records what ran and how it exited — and NOTHING about
304
+ // what it was for. Whether "pnpm typecheck" was a typecheck is the
305
+ // reader's call; classifying it here from its text is the inference the
306
+ // record refuses to make.
307
+ onCommandResult: (result) => verification.record({
308
+ kind: "verification",
309
+ tool: "run_command",
310
+ command: result.command,
311
+ passed: result.exitCode === 0,
312
+ exitCode: result.exitCode,
313
+ durationMs: result.durationMs,
314
+ failureCount: 0,
315
+ failureNames: [],
316
+ outputTruncated: result.outputTruncated,
317
+ substrate: result.substrate,
318
+ }),
275
319
  });
276
320
  const git = getGitInfo(cwd);
277
321
  const projectInstructions = loadProjectInstructions(cwd);
@@ -508,8 +552,9 @@ opts = {}) {
508
552
  requestApproval: gate(approval),
509
553
  checkpointsActive,
510
554
  sandbox,
555
+ verification,
511
556
  };
512
- const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
557
+ const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, compact, record, }) => runPlanSession({
513
558
  provider,
514
559
  config,
515
560
  ctx,
@@ -524,6 +569,8 @@ opts = {}) {
524
569
  renderer: turnRenderer,
525
570
  router,
526
571
  onRequestUsage,
572
+ compact,
573
+ record,
527
574
  });
528
575
  holder.session = new Session({
529
576
  provider,
@@ -548,6 +595,7 @@ opts = {}) {
548
595
  // not a second list that agrees with them only by luck.
549
596
  allowlist,
550
597
  recorder: opts.recorder,
598
+ verification,
551
599
  restore: opts.restore,
552
600
  });
553
601
  return holder.session;
@@ -78,9 +78,18 @@ export const AgentConfigSchema = z
78
78
  planMode: z.boolean().default(false),
79
79
  })
80
80
  .strict();
81
+ /**
82
+ * Which built-in tool families the model is handed (P4). Declared in the C.0
83
+ * scaffold and consumed by nothing until P4 — see `buildDefaultRegistry` for
84
+ * why they were wired rather than removed (`initConfig` writes them into every
85
+ * generated config, and the schema is `.strict()`).
86
+ */
81
87
  export const ToolsConfigSchema = z
82
88
  .object({
89
+ /** `false` withholds `write_file`, `edit_file` and `apply_patch`. Reads stay. */
83
90
  fileEdit: z.boolean().default(true),
91
+ /** `false` withholds `run_command` and `run_tests` — no command execution
92
+ * by the model at all. `create_pull_request` (git, own approval) stays. */
84
93
  shell: z.boolean().default(true),
85
94
  })
86
95
  .strict();
@@ -1,4 +1,4 @@
1
- import { MAX_SCHEMA_DEPTH, schemaDepth } from "../tools/schema-depth.js";
1
+ import { MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES, schemaDepth, schemaNodes, } from "../tools/schema-depth.js";
2
2
  const PERMISSIVE_SCHEMA = {
3
3
  type: "object",
4
4
  additionalProperties: true,
@@ -48,6 +48,13 @@ export function boundToolList(tools, bounds) {
48
48
  inputSchema = { ...PERMISSIVE_SCHEMA };
49
49
  notes.push(`input schema nests ${depth} levels deep, at or over the ${MAX_SCHEMA_DEPTH}-level provider limit, and was replaced with a permissive one`);
50
50
  }
51
+ // Same order, same reasoning: nodes are counted on the post-cap value, and
52
+ // the gateway's bound is inclusive (P4) — 400 passes, 401 fails the request.
53
+ const nodes = schemaNodes(inputSchema);
54
+ if (nodes > MAX_SCHEMA_NODES) {
55
+ inputSchema = { ...PERMISSIVE_SCHEMA };
56
+ notes.push(`input schema has ${nodes} nodes, over the ${MAX_SCHEMA_NODES}-node provider limit, and was replaced with a permissive one`);
57
+ }
51
58
  return { name: t.name, description, inputSchema, notes };
52
59
  });
53
60
  return { tools: bounded, droppedCount };
@@ -1,7 +1,7 @@
1
1
  import { CruxyError } from "../errors/index.js";
2
2
  import { promptContinueAfterFailure } from "./approve.js";
3
3
  export async function executePlan(plan, deps) {
4
- const { runStep, io, renderer } = deps;
4
+ const { runStep, io, renderer, record } = deps;
5
5
  try {
6
6
  for (const [index, step] of plan.steps.entries()) {
7
7
  step.status = "running";
@@ -13,14 +13,17 @@ export async function executePlan(plan, deps) {
13
13
  });
14
14
  renderer?.setPhase({ kind: "executing-step" });
15
15
  renderer?.setPlan(plan.steps);
16
+ record?.(step);
16
17
  try {
17
18
  await runStep(step);
18
19
  step.status = "done";
19
20
  renderer?.setPlan(plan.steps);
21
+ record?.(step);
20
22
  }
21
23
  catch (err) {
22
24
  step.status = "failed";
23
25
  renderer?.setPlan(plan.steps);
26
+ record?.(step);
24
27
  // Surface the failure via the U.5 shape when we have it.
25
28
  const detail = err instanceof CruxyError
26
29
  ? `${err.title}${err.cause ? ` — ${err.cause}` : ""}`
@@ -3,6 +3,7 @@ import { ToolRegistry } from "../tools/index.js";
3
3
  import { runAgent } from "../agent/loop.js";
4
4
  import { promptPlanDecision } from "./approve.js";
5
5
  import { executePlan } from "./execute.js";
6
+ import { stepInstruction } from "./step-message.js";
6
7
  import { makeSubmitPlanTool } from "./submit-plan.js";
7
8
  /**
8
9
  * Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
@@ -13,6 +14,32 @@ import { makeSubmitPlanTool } from "./submit-plan.js";
13
14
  *
14
15
  * A propose phase that ends with NO plan is a completed conversational turn, not
15
16
  * an error — see the `!holder.plan` branch below.
17
+ *
18
+ * WHAT SURVIVES THE PROCESS, AND WHAT DOES NOT (plan-durability).
19
+ *
20
+ * The plan object lives in `holder` below for exactly one call of this
21
+ * function. Three things outlive it: the transcript (every step's messages
22
+ * reach the log as they happen, through `args.compact`), the typed record
23
+ * (`args.record` — the approval as a fact, and each step transition), and the
24
+ * checkpoint (see `execute.ts` for what that one actually covers). After a
25
+ * crash, a closed terminal, or Ctrl-C — which is a real SIGINT here, because
26
+ * the TUI releases stdin for the duration of a turn — `--resume` restores the
27
+ * mode and the history and DESCRIBES the plan; it does not continue it.
28
+ *
29
+ * NOT BUILT: a resumable executor. Re-entering `executePlan` at step N on
30
+ * resume needs three things this turn does not have: a re-entry point that
31
+ * rebuilds the plan from the record rather than from a `submit_plan` call; a
32
+ * fresh-consent decision, because the approval was given in another process
33
+ * against a tree that has since changed (and `[g]`'s grants are gone with
34
+ * that process, rightly); and a second checkpoint latch, which would split one
35
+ * undo unit into two. Once the transcript is durable the model can continue
36
+ * from step N+1 on a one-line nudge — it can see its own plan and every "Do
37
+ * ONLY step" message that ran — and there is no recorded interruption where
38
+ * that has failed.
39
+ *
40
+ * REVISIT TRIGGER: one observed case where transcript-driven continuation
41
+ * fails — a resumed session that, told to carry on, redoes a finished step or
42
+ * cannot tell where it stopped. Until then the record is the deliverable.
16
43
  */
17
44
  /** Default cap on plan revisions before failing loud. */
18
45
  export const MAX_PLAN_REVISIONS = 3;
@@ -82,6 +109,7 @@ export async function runPlanSession(args) {
82
109
  router: args.router,
83
110
  taskClass: "plan",
84
111
  onRequestUsage: args.onRequestUsage,
112
+ compact: args.compact,
85
113
  }));
86
114
  if (!holder.plan) {
87
115
  // NO PLAN IS NOT A FAILURE. The propose phase's registry is read-only plus
@@ -121,14 +149,18 @@ export async function runPlanSession(args) {
121
149
  if (decision.kind === "approve-grant") {
122
150
  args.planPolicy.enableSafeStepGrants();
123
151
  }
152
+ // The approval, recorded as a fact (plan-durability): which choice, and
153
+ // the steps it covered. Written before the first step runs, so a plan
154
+ // that dies in step 1 still has its approval on disk.
155
+ args.record?.planApproved(decision.kind, plan.steps.map((s) => ({ id: s.id, title: s.title, kind: s.kind })));
124
156
  // Execute step-by-step, driving one agent turn per step against the full
125
157
  // registry. Each step's actions still pass through the U.3 gate.
126
158
  const runStep = async (step) => {
127
- messages.push({
128
- role: "user",
129
- content: `The plan is approved. Do ONLY step ${step.id}: ${step.title}. ${step.rationale} ` +
130
- "Do not start any other step. When this step is complete, stop.",
131
- });
159
+ // The scoping instruction plus the whole plan's current statuses
160
+ // (plan-durability): the executor has already marked this step
161
+ // `running` and every earlier one `done` or `failed`, so the message
162
+ // says which see `step-message.ts` for why the failed case matters.
163
+ messages.push({ role: "user", content: stepInstruction(plan, step) });
132
164
  accumulate(await runAgent({
133
165
  messages,
134
166
  provider: args.provider,
@@ -142,12 +174,17 @@ export async function runPlanSession(args) {
142
174
  router: args.router,
143
175
  taskClass: "main-turn",
144
176
  onRequestUsage: args.onRequestUsage,
177
+ // The step message pushed above reaches the log at this loop's
178
+ // first iteration, BEFORE the step's first model call — so a step
179
+ // that dies still leaves on disk which step it was.
180
+ compact: args.compact,
145
181
  }));
146
182
  };
147
183
  await executePlan(plan, {
148
184
  runStep,
149
185
  io: args.io,
150
186
  renderer: args.renderer,
187
+ record: (step) => args.record?.planStep(step.id, step.status),
151
188
  });
152
189
  return finish();
153
190
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The per-step instruction the executor sends the model (plan-durability).
3
+ *
4
+ * The first paragraph is the C.31 scoping instruction, unchanged: do this one
5
+ * step, nothing else, then stop. What follows it is new — the whole plan with
6
+ * each step's CURRENT status — and it exists because of what the model could
7
+ * not see without it.
8
+ *
9
+ * The model already has the plan: its own `submit_plan` call sits in the
10
+ * history with every step as the tool input. What it never had was status. A
11
+ * step that failed and that the user chose to continue past (`[c]` at the
12
+ * failure prompt) was followed by exactly the same message a successful step
13
+ * was — "Do ONLY step N+1" — so the model walked into step N+1 believing step N
14
+ * had landed, and built on work that was not there. The block below makes a
15
+ * failed step read as failed, in the message that asks for the next one.
16
+ *
17
+ * This is a prompt change, not a tool: no schema, no new tool, and no change to
18
+ * the tool set between phases (the prefix-cache argument in cli#150). It is
19
+ * appended to EVERY step message, a one-step plan included, so the shape the
20
+ * model learns is the same shape every time rather than one that appears from
21
+ * step 2 on. Plain words rather than the renderer's glyphs, because the reader
22
+ * here is the model and the status has to survive as text.
23
+ */
24
+ export function stepInstruction(plan, step) {
25
+ const head = `The plan is approved. Do ONLY step ${step.id}: ${step.title}. ${step.rationale} ` +
26
+ "Do not start any other step. When this step is complete, stop.";
27
+ const n = plan.steps.length;
28
+ const lines = plan.steps.map((s) => ` ${s.id}. ${statusLabel(s, step)} — ${s.title}`);
29
+ return `${head}\n\nPlan status (${n} step${n === 1 ? "" : "s"}):\n${lines.join("\n")}`;
30
+ }
31
+ /**
32
+ * One step's status as the model should read it. `failed` is the label that
33
+ * matters: it names the user's decision, so the model knows the step was not
34
+ * skipped by accident and is not going to be retried by the executor.
35
+ */
36
+ function statusLabel(s, current) {
37
+ if (s.id === current.id)
38
+ return "this step";
39
+ switch (s.status) {
40
+ case "done":
41
+ return "done";
42
+ case "failed":
43
+ return "FAILED — the user chose to continue past it; do not assume its work exists";
44
+ case "running":
45
+ return "running";
46
+ case "pending":
47
+ return "pending";
48
+ }
49
+ }
@@ -49,7 +49,9 @@ function share(tokens, of) {
49
49
  * a table into a misaligned mess; the default is unbounded for callers that
50
50
  * reflow themselves (the TUI's main column).
51
51
  */
52
- export function contextReportLines(report, t, width = Infinity) {
52
+ export function contextReportLines(report, t, width = Infinity,
53
+ /** What compaction has cost so far (P3); omitted → the section is left out. */
54
+ tally) {
53
55
  const { reading, compaction } = report;
54
56
  const lines = [t.heading("context")];
55
57
  // The headline, worded exactly as the panel words it — same estimate, same
@@ -88,6 +90,13 @@ export function contextReportLines(report, t, width = Infinity) {
88
90
  lines.push(`${head}${c.excerpt === "" ? "" : t.muted(` — ${c.excerpt}`)}`);
89
91
  }
90
92
  }
93
+ // ── what compaction has cost so far (P3) ──────────────────────────────────
94
+ if (tally) {
95
+ lines.push("");
96
+ lines.push(t.strong("compaction so far"));
97
+ const so = compactionTallyLines(tally, t, " ");
98
+ lines.push(...(so.length > 0 ? so : [t.muted(" none yet this session")]));
99
+ }
91
100
  // ── what compaction would do ──────────────────────────────────────────────
92
101
  lines.push("");
93
102
  lines.push(t.strong("if you compact now"));
@@ -113,3 +122,37 @@ export function contextReportLines(report, t, width = Infinity) {
113
122
  ? lines.map((l) => fit(l, width, t.glyph.ellipsis))
114
123
  : lines;
115
124
  }
125
+ /**
126
+ * What compaction has cost a session (P3 context quality), as lines — the
127
+ * one renderer behind `/context`'s "compaction so far" and the one-shot
128
+ * summary, so both describe the tally the same way. Empty when nothing has
129
+ * compacted: the one-shot prints nothing, `/context` says "none yet".
130
+ *
131
+ * The sums cover only the compactions that recorded a cost; when that is
132
+ * fewer than the count (a log written before P3), the line says so rather
133
+ * than presenting a partial sum as the whole. A suspected instruction loss is
134
+ * a WARNING line: it is the one figure here a user should act on.
135
+ */
136
+ export function compactionTallyLines(tally, t, indent = "") {
137
+ if (tally.count === 0)
138
+ return [];
139
+ const lines = [];
140
+ const key = t.strong("compaction");
141
+ const times = `${tally.count} time${tally.count === 1 ? "" : "s"}`;
142
+ if (tally.measured === 0) {
143
+ lines.push(`${indent}${key} ${times} ${t.muted("(cost not recorded — written before it was measured)")}`);
144
+ }
145
+ else {
146
+ const scope = tally.measured < tally.count
147
+ ? t.muted(` (${tally.count - tally.measured} recorded no cost)`)
148
+ : "";
149
+ lines.push(`${indent}${key} ${times}${t.sep}freed ${approx(tally.freedTokens)}${t.sep}` +
150
+ t.muted(`summaries cost ${approx(tally.summaryInputTokens)} in / ${approx(tally.summaryOutputTokens)} out`) +
151
+ scope);
152
+ }
153
+ if (tally.instructionLosses > 0) {
154
+ const n = tally.instructionLosses;
155
+ lines.push(`${indent}${t.warning(`${n} compaction${n === 1 ? "" : "s"} may have dropped an instruction you gave — restate any that still apply`)}`);
156
+ }
157
+ return lines;
158
+ }
@@ -1,6 +1,7 @@
1
1
  import { capacityLevel, formatCapacity, } from "../utils/disk.js";
2
2
  import { fit } from "./layout.js";
3
3
  import { formatTokens } from "./state.js";
4
+ import { formatDuration } from "./test-view.js";
4
5
  /** `key value`, aligned on a fixed gutter so the column is scannable. */
5
6
  function row(key, value, t) {
6
7
  return ` ${t.muted(key.padEnd(11))} ${value}`;
@@ -96,6 +97,18 @@ export function sessionStatusLines(status, t, width = Infinity) {
96
97
  lines.push(row("jobs", detail, t));
97
98
  }
98
99
  lines.push(row("tools", t.muted(`${status.tools} available to the model`), t));
100
+ // What last ran and how it exited, dated — the record's answer to "was
101
+ // this verified", which is the reader's question to settle, not this
102
+ // row's. "none recorded" is said out loud rather than omitted.
103
+ if (status.verification) {
104
+ const v = status.verification;
105
+ const exit = v.exitCode === null ? "no exit code" : `exit ${v.exitCode}`;
106
+ lines.push(row("verify", `${t.muted(v.tool)} ${v.command} ${t.glyph.arrow} ${v.passed ? t.muted(exit) : t.danger(exit)} ` +
107
+ t.muted(`(${formatDuration(v.durationMs)}) ${t.glyph.sep} ${v.age}`), t));
108
+ }
109
+ else {
110
+ lines.push(row("verify", t.warning("none recorded this session"), t));
111
+ }
99
112
  // Roots last: one line each, so a multi-root session shows which repo each
100
113
  // change lands in — the fact the startup banner states once and then loses.
101
114
  lines.push("");
@@ -7,18 +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";
17
19
  export { claimSession, describeHolder, ownerFile, readOwner, releaseSession, removeOwnerFile, sessionHeldBy, } from "./owner.js";
18
20
  export { defaultExportName, exportMarkdown, } from "./export.js";
19
21
  export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
22
+ export { latestRecordedRuns, recordedRuns } from "./recorded-runs.js";
20
23
  export { redactMessages } from "./redact.js";
21
24
  export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, sessionFilesByRecency, summarizeSession, } from "./list.js";
22
25
  export { pruneSessions, } from "./prune.js";
23
- export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
24
- 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";