@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
@@ -0,0 +1,204 @@
1
+ import { COMPACTION_MARKER } from "./prompts.js";
2
+ /**
3
+ * Detect-and-warn for compaction (P3 context quality): after a prefix has been
4
+ * folded into a synopsis, which of the user's imperative or prohibitive
5
+ * sentences can no longer be found in it?
6
+ *
7
+ * A HEURISTIC, AND SAID TO BE ONE. It costs no tokens, it runs after the fact,
8
+ * and it can be wrong both ways: a paraphrased constraint that survived in
9
+ * spirit is reported lost, and a constraint whose key terms happen to appear
10
+ * elsewhere in the synopsis is reported kept. That is accepted. Before P3 a
11
+ * dropped constraint was SILENT — the user found out when the model touched
12
+ * the migrations. This turns silent loss into visible loss, in the same
13
+ * posture as the P2 verification record: evidence, not enforcement. It does
14
+ * not re-inject, pin, or refuse the synopsis (those need a constraint store,
15
+ * a retirement rule and an extractor, and the last changes the fail-open
16
+ * contract — deferred until a real loss has been observed AND recorded, which
17
+ * is what this makes possible).
18
+ *
19
+ * WHAT IT SCANS: the user's OWN words only — string-content user messages and
20
+ * text blocks in user messages. Tool results (also `role: "user"`) are not
21
+ * the user speaking, and a previous compaction's synthetic user message is
22
+ * not either. WHAT IT DOES NOT DO: infer that a sentence was a constraint
23
+ * from anything but its surface form, or decide what the constraint meant.
24
+ */
25
+ /** Words that mark a sentence as an instruction, a constraint, or a prohibition. */
26
+ const IMPERATIVE = /\b(never|don'?t|do not|must(?: not)?|mustn'?t|always|only|no longer|stop|avoid|keep|without|not allowed|forbidden|prohibited|refrain|ensure|make sure|should(?: not)?|shouldn'?t|can'?not|can'?t|leave .* alone|hands off)\b/i;
27
+ /** Tokens that carry no identity of their own — never a key term. */
28
+ const STOPWORDS = new Set([
29
+ "the",
30
+ "and",
31
+ "this",
32
+ "that",
33
+ "these",
34
+ "those",
35
+ "with",
36
+ "from",
37
+ "you",
38
+ "your",
39
+ "for",
40
+ "are",
41
+ "was",
42
+ "were",
43
+ "have",
44
+ "has",
45
+ "had",
46
+ "but",
47
+ "not",
48
+ "any",
49
+ "all",
50
+ "into",
51
+ "onto",
52
+ "just",
53
+ "also",
54
+ "then",
55
+ "than",
56
+ "when",
57
+ "what",
58
+ "which",
59
+ "who",
60
+ "will",
61
+ "would",
62
+ "should",
63
+ "could",
64
+ "can",
65
+ "may",
66
+ "might",
67
+ "please",
68
+ "let",
69
+ "use",
70
+ "our",
71
+ "its",
72
+ "they",
73
+ "them",
74
+ "there",
75
+ "here",
76
+ "about",
77
+ "after",
78
+ "before",
79
+ "over",
80
+ "under",
81
+ "again",
82
+ "still",
83
+ "very",
84
+ "some",
85
+ "more",
86
+ "most",
87
+ "other",
88
+ "only",
89
+ "never",
90
+ "always",
91
+ "must",
92
+ "don't",
93
+ "dont",
94
+ "avoid",
95
+ "keep",
96
+ "stop",
97
+ "make",
98
+ "sure",
99
+ "ensure",
100
+ "without",
101
+ "longer",
102
+ "allowed",
103
+ "forbidden",
104
+ "prohibited",
105
+ "refrain",
106
+ "cannot",
107
+ "can't",
108
+ "shouldn't",
109
+ "mustn't",
110
+ "want",
111
+ "need",
112
+ "like",
113
+ "thing",
114
+ "things",
115
+ "something",
116
+ "anything",
117
+ "everything",
118
+ "ever",
119
+ "each",
120
+ "every",
121
+ ]);
122
+ /** Most sentences reported per compaction — an index, not a transcript. */
123
+ export const MAX_LOST_SENTENCES = 5;
124
+ /** Each reported sentence is cut here: enough to recognise, never the whole prompt. */
125
+ export const MAX_SENTENCE_CHARS = 200;
126
+ /**
127
+ * The user's imperative sentences in `prefix` whose key terms are NOT found
128
+ * in `synopsis`, bounded and truncated for the record. Empty when nothing
129
+ * looks lost — or when nothing looked like an instruction to begin with.
130
+ */
131
+ export function detectInstructionLoss(prefix, synopsis) {
132
+ const haystack = synopsis.toLowerCase();
133
+ const lost = [];
134
+ const seen = new Set();
135
+ for (const sentence of userSentences(prefix)) {
136
+ if (!IMPERATIVE.test(sentence))
137
+ continue;
138
+ const terms = keyTerms(sentence);
139
+ if (terms.length === 0)
140
+ continue; // nothing to look for → cannot judge
141
+ const found = terms.filter((t) => haystack.includes(t)).length;
142
+ // Half the terms, rounded up, is "survived": a paraphrase that keeps the
143
+ // nouns passes, a synopsis that never mentions the subject does not.
144
+ if (found >= Math.ceil(terms.length / 2))
145
+ continue;
146
+ const cut = truncate(sentence);
147
+ if (seen.has(cut))
148
+ continue;
149
+ seen.add(cut);
150
+ lost.push(cut);
151
+ if (lost.length >= MAX_LOST_SENTENCES)
152
+ break;
153
+ }
154
+ return lost;
155
+ }
156
+ /** Every sentence the USER wrote in `messages`, in order. */
157
+ function userSentences(messages) {
158
+ const out = [];
159
+ for (const m of messages) {
160
+ if (m.role !== "user")
161
+ continue;
162
+ const texts = [];
163
+ if (typeof m.content === "string") {
164
+ if (m.content.startsWith(COMPACTION_MARKER))
165
+ continue;
166
+ texts.push(m.content);
167
+ }
168
+ else {
169
+ for (const b of m.content)
170
+ if (b.type === "text")
171
+ texts.push(b.text);
172
+ }
173
+ for (const text of texts) {
174
+ for (const raw of text.split(/(?<=[.!?])\s+|\n+/)) {
175
+ const s = raw.trim();
176
+ if (s.length >= 8)
177
+ out.push(s);
178
+ }
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+ /** The words in `sentence` worth looking for: lowercased, ≥3 chars, not stopwords. */
184
+ export function keyTerms(sentence) {
185
+ const terms = new Set();
186
+ for (const raw of sentence.toLowerCase().split(/\s+/)) {
187
+ // Strip the quotes and brackets a word is wrapped in, then the punctuation
188
+ // it ends with — `.` and `/` are kept INSIDE a word so `db/migrate` and
189
+ // `parse.ts` survive as the identifiers they are.
190
+ const word = raw
191
+ .replace(/^[^a-z0-9_./-]+|[^a-z0-9_./-]+$/g, "")
192
+ .replace(/[./-]+$/, "");
193
+ if (word.length < 3 || STOPWORDS.has(word))
194
+ continue;
195
+ terms.add(word);
196
+ }
197
+ return [...terms];
198
+ }
199
+ function truncate(s) {
200
+ const flat = s.replace(/\s+/g, " ").trim();
201
+ return flat.length > MAX_SENTENCE_CHARS
202
+ ? `${flat.slice(0, MAX_SENTENCE_CHARS - 1)}…`
203
+ : flat;
204
+ }
@@ -131,17 +131,38 @@ export function buildSystemPrompt(ctx) {
131
131
  return sections.join("\n\n");
132
132
  }
133
133
  /**
134
- * Compact reminder injected after tool results when the loop has run long, to
135
- * keep the model anchored to the original objective and the verify step.
134
+ * The heading under which the synopsis must carry the user's instructions
135
+ * verbatim (P3 context quality). A fixed string so the carry rule is checkable
136
+ * — by a test, and by `detectInstructionLoss` after the fact.
136
137
  */
137
- export const PROGRESS_REMINDER = `Reminder: stay focused on the original task. Before declaring done, verify your change actually works (build/tests/lint), then summarize what changed.`;
138
+ export const STANDING_INSTRUCTIONS_HEADING = "## Standing instructions";
138
139
  /**
139
140
  * System prompt for the side conversation that compacts an over-long history
140
141
  * (see Session.compact). It runs as a standalone, tool-less completion over a
141
142
  * rendered transcript — the goal is a synopsis dense enough that the main loop
142
143
  * can continue without the verbatim prefix.
144
+ *
145
+ * THE CARRY RULE (P3 context quality). Until P3 this prompt ended "omit
146
+ * pleasantries and restated instructions". That was never a decision to drop
147
+ * the user's constraints: C.11 wrote it for density, and "restated" meant the
148
+ * assistant echoing the task back. But a model reads "restated instructions"
149
+ * and applies it to a user's repeated "never touch the migrations" just as
150
+ * readily — so a constraint stated three turns before a compaction was, by
151
+ * the prompt's own wording, the first thing to go. Mid-session constraints
152
+ * live ONLY in the history (CRUXY.md and recalled memory are rebuilt every
153
+ * turn and never compacted; memory is framed as data and cannot carry an
154
+ * instruction by design), so the synopsis is the one place they can survive.
155
+ *
156
+ * The prompt now says the opposite, explicitly: every instruction, constraint
157
+ * or prohibition the user stated goes under a fixed heading, verbatim. A test
158
+ * asserts that we ask for this; nothing can assert that the model complies,
159
+ * which is what `detectInstructionLoss` is for.
143
160
  */
144
- export const SUMMARY_SYSTEM = `You are compacting a coding assistant's conversation to fit within its context window. Summarize the conversation so far into a compact synopsis that preserves: decisions made and their rationale, concrete file paths and identifiers touched, the current state of the work, and any open or pending tasks. Be specific and terse omit pleasantries and restated instructions. Output only the synopsis.`;
161
+ export const SUMMARY_SYSTEM = `You are compacting a coding assistant's conversation to fit within its context window. Summarize the conversation so far into a compact synopsis that preserves: decisions made and their rationale, concrete file paths and identifiers touched, the current state of the work, and any open or pending tasks. Be specific and terse; omit pleasantries.
162
+
163
+ Then, under the heading "${STANDING_INSTRUCTIONS_HEADING}", list every instruction, constraint, or prohibition the user stated at any point, each VERBATIM in the user's own words — never paraphrased, never merged with another, and never dropped as redundant, restated, or obvious. A constraint the user gave once still binds. If the user stated none, write the heading followed by "none".
164
+
165
+ Output only the synopsis.`;
145
166
  /**
146
167
  * Marker embedded in the synthetic messages that replace a compacted prefix, so
147
168
  * they're recognizable in the history (and fold cleanly into a later
@@ -9,6 +9,8 @@ import { resolveTaskModel, } from "../routing/index.js";
9
9
  import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
10
10
  import { Budget } from "./budget.js";
11
11
  import { estimateTokens, findCut } from "./context.js";
12
+ import { detectInstructionLoss } from "./instruction-loss.js";
13
+ import { emptyCompactionTally, } from "../session/index.js";
12
14
  import { runAgent, } from "./loop.js";
13
15
  import { DEFAULT_MODE, modeAutoApproves, modePlans, parseMode, } from "./mode.js";
14
16
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
@@ -41,6 +43,37 @@ export class Session {
41
43
  /** The most recent run's usage record (C.22) — the one-shot path reads it to
42
44
  * print the end-of-run summary. */
43
45
  lastRun;
46
+ /**
47
+ * The most recent run that actually executed this session (P2 verification),
48
+ * or undefined when nothing has — the `lastRun` pattern, for `/status` and
49
+ * the Overview view. Read from the ledger, never re-derived.
50
+ */
51
+ get lastVerification() {
52
+ return this.args.verification?.lastVerification;
53
+ }
54
+ /**
55
+ * What THIS turn recorded (P2 verification) — the one-shot summary's unit:
56
+ * every run that executed, and every write refused because its target
57
+ * moved. Empty lists when nothing did, or when no ledger is wired.
58
+ */
59
+ turnVerification() {
60
+ return (this.args.verification?.turn() ?? {
61
+ verifications: [],
62
+ externalChanges: [],
63
+ });
64
+ }
65
+ /**
66
+ * What compaction has cost this session so far (P3 context quality): a
67
+ * count, what it freed, what the summaries cost, and how many times a user
68
+ * instruction may have been dropped. Kept in memory for the live surfaces
69
+ * (`/context`, the one-shot summary) and written to the log event by event,
70
+ * from which a resume folds the same shape back (`SessionState.compactions`).
71
+ */
72
+ compactionTally = emptyCompactionTally();
73
+ /** The tally, read-only — see {@link compactionTally}. */
74
+ compactions() {
75
+ return this.compactionTally;
76
+ }
44
77
  args;
45
78
  /** Mutable so `/reload` can refresh CRUXY.md mid-session. */
46
79
  projectInstructions;
@@ -105,6 +138,12 @@ export class Session {
105
138
  this.usage.output_tokens = restore.usage.output_tokens;
106
139
  this.recordedCount = restore.messages.length;
107
140
  this.mode = this.resolveMode(restore.mode);
141
+ // The last run the log recorded is adopted, never re-derived: a resumed
142
+ // session's `/status` says what last ran and when, not "none".
143
+ args.verification?.seed(restore.lastVerification);
144
+ if (restore.compactions) {
145
+ Object.assign(this.compactionTally, restore.compactions);
146
+ }
108
147
  }
109
148
  }
110
149
  /**
@@ -221,6 +260,10 @@ export class Session {
221
260
  // model iterations WITHIN this turn — it just never leaks into the next one.
222
261
  for (const tool of this.args.registry.list())
223
262
  tool.onTurnStart?.();
263
+ // The verification record's turn boundary (P2 verification): this turn's
264
+ // runs start empty, so the one-shot summary's "none ran this turn" is
265
+ // about THIS turn. What last ran across the session is untouched.
266
+ this.args.verification?.beginTurn();
224
267
  // Re-read the account's headroom (P9 / cli#212), on EVERY turn and on every
225
268
  // surface — the REPL and headless included, neither of which has a rail to
226
269
  // have justified the probe before. It is fired here rather than after the
@@ -281,36 +324,89 @@ export class Session {
281
324
  // hooks — throws here and aborts the turn before the model is engaged
282
325
  // (fail-closed). No-op when hooks are disabled or none are registered.
283
326
  await this.args.hooks?.fire("before-run", this.args.ctx);
327
+ // The mid-loop seam (build item 3), built ONCE for both branches below: let
328
+ // a long turn compact between iterations, not just once up front, reusing
329
+ // this session's threshold/cut/summarize path over the loop's own history
330
+ // and attributing the summary usage to this run's collector.
331
+ //
332
+ // IT IS ALSO THE FLUSH (P2), which is why it remembers what it returned.
333
+ // `compactLoopHistory` records the history before it compacts, and the
334
+ // loop calls it at the top of every iteration — so the array it hands back
335
+ // is, at that moment, exactly what the log holds: coherent (every tool_use
336
+ // beside its tool_result) and recorded. A turn that throws later has no
337
+ // `result.messages` to adopt, and without this the live history would stay
338
+ // at the user's prompt while the log had moved past it. See the catch.
339
+ const seen = {
340
+ coherent: null,
341
+ };
342
+ const compact = async (messages) => {
343
+ const out = await this.compactLoopHistory(messages, onReq);
344
+ seen.coherent = { messages: out, length: out.length };
345
+ return out;
346
+ };
284
347
  // Plan mode (C.31) delegates the whole turn to the injected runner: propose a
285
348
  // plan, approve/revise, then execute step-by-step. Falls back to the normal
286
349
  // single-shot loop when off or unwired, so existing behavior is untouched.
287
- const result = modePlans(this.mode) && this.args.planRunner
288
- ? await this.args.planRunner({
289
- messages: this.messages,
290
- projectInstructions: this.projectInstructions,
291
- recalledMemory: this.args.recalledMemory ?? null,
292
- renderer,
293
- onRequestUsage: onReq,
294
- })
295
- : await runAgent({
296
- messages: this.messages,
297
- ...this.args, // carries `router` through to the loop
298
- // The per-turn token guard (build item 4). After the spread because
299
- // SessionArgs has no `budget` field — it is a per-turn construction,
300
- // not session state. `undefined` when the cap is off (no-op check).
301
- budget,
302
- taskClass: "main-turn",
303
- // After the spread so a mid-session `/reload` wins over the initial value.
304
- projectInstructions: this.projectInstructions,
305
- planMode: false, // the plan directive belongs only to the runner's propose phase
306
- renderer,
307
- onRequestUsage: onReq,
308
- // Mid-loop compaction (build item 3): let a long autonomous turn
309
- // compact between iterations, not just once up front. Reuses this
310
- // session's threshold/cut/summarize path over the loop's own history
311
- // and attributes the summary usage to this run's collector.
312
- compact: (messages) => this.compactLoopHistory(messages, onReq),
313
- });
350
+ //
351
+ // Both branches get the seam (plan-durability). Before it, the plan branch
352
+ // passed none: a plan turn's propose phase, approval and every step lived
353
+ // only in the runner's local array until the whole turn returned, so a
354
+ // provider error in step 3 left the session holding the prompt alone — in
355
+ // RAM and on disk — while the tree already carried steps 1 and 2.
356
+ let result;
357
+ try {
358
+ result =
359
+ modePlans(this.mode) && this.args.planRunner
360
+ ? await this.args.planRunner({
361
+ messages: this.messages,
362
+ projectInstructions: this.projectInstructions,
363
+ recalledMemory: this.args.recalledMemory ?? null,
364
+ renderer,
365
+ onRequestUsage: onReq,
366
+ compact,
367
+ record: this.args.recorder,
368
+ })
369
+ : await runAgent({
370
+ messages: this.messages,
371
+ ...this.args, // carries `router` through to the loop
372
+ // The per-turn token guard (build item 4). After the spread because
373
+ // SessionArgs has no `budget` field it is a per-turn construction,
374
+ // not session state. `undefined` when the cap is off (no-op check).
375
+ budget,
376
+ taskClass: "main-turn",
377
+ // After the spread so a mid-session `/reload` wins over the initial value.
378
+ projectInstructions: this.projectInstructions,
379
+ planMode: false, // the plan directive belongs only to the runner's propose phase
380
+ renderer,
381
+ onRequestUsage: onReq,
382
+ compact,
383
+ });
384
+ }
385
+ catch (err) {
386
+ // ADOPT WHAT THE LOG ALREADY HOLDS, then fail. The seam flushed every
387
+ // coherent prefix it saw and advanced the recorder's watermark past it;
388
+ // the live array has to catch up or the two diverge — and the divergence
389
+ // is not benign. `flushRecorded` skips while the array is shorter than
390
+ // the watermark, so the NEXT turns would go unrecorded until the array
391
+ // grew past it, and from then on `slice(recordedCount)` would record the
392
+ // wrong messages at those positions. The log would be wrong, not merely
393
+ // short.
394
+ //
395
+ // The loop pushes onto the array the seam returned, so the coherent
396
+ // history is that array up to the length it had when the seam saw it:
397
+ // a half-iteration (an assistant tool_use whose tool threw before its
398
+ // result landed) is past that mark and is dropped, exactly as the log
399
+ // never received it.
400
+ //
401
+ // This was the shape of every plain turn before plan-durability too —
402
+ // the plan branch made it visible by being the one that throws for a
403
+ // step the user can see.
404
+ const { coherent } = seen;
405
+ if (coherent !== null && coherent.length > this.messages.length) {
406
+ this.messages = coherent.messages.slice(0, coherent.length);
407
+ }
408
+ throw err;
409
+ }
314
410
  this.messages = result.messages;
315
411
  this.usage.input_tokens += result.usage.input_tokens;
316
412
  this.usage.output_tokens += result.usage.output_tokens;
@@ -523,10 +619,15 @@ export class Session {
523
619
  return { messages, compacted: null };
524
620
  const prefix = messages.slice(0, cut);
525
621
  const kept = messages.slice(cut);
622
+ // Measured before anything is spent, over the SAME history the trigger
623
+ // measured (P3): the cost record is what the trigger saw, not a re-estimate.
624
+ const estimatedBefore = estimateTokens(messages);
526
625
  let synopsis;
626
+ let summaryUsage;
527
627
  try {
528
628
  const summary = await this.summarize(prefix, onRequestUsage);
529
629
  synopsis = summary.text;
630
+ summaryUsage = summary.reported ? summary.usage : undefined;
530
631
  this.usage.input_tokens += summary.usage.input_tokens;
531
632
  this.usage.output_tokens += summary.usage.output_tokens;
532
633
  }
@@ -547,19 +648,48 @@ export class Session {
547
648
  content: `${COMPACTION_MARKER} Summary of the conversation so far:\n\n${synopsis}`,
548
649
  },
549
650
  ];
651
+ const next = [...summaryMessages, ...kept];
652
+ // What it cost (P3 context quality): the four facts in hand right here,
653
+ // which until P3 were thrown away at this line. Freed is the difference
654
+ // and is derived by every reader, never stored. Tallied in memory for the
655
+ // live surfaces and written to the log for the resumed ones.
656
+ const cost = {
657
+ estimatedBefore,
658
+ estimatedAfter: estimateTokens(next),
659
+ ...(summaryUsage
660
+ ? {
661
+ summaryInputTokens: summaryUsage.input_tokens,
662
+ summaryOutputTokens: summaryUsage.output_tokens,
663
+ }
664
+ : {}),
665
+ };
666
+ this.compactionTally.count++;
667
+ this.compactionTally.measured++;
668
+ this.compactionTally.freedTokens += Math.max(0, cost.estimatedBefore - cost.estimatedAfter);
669
+ this.compactionTally.summaryInputTokens += cost.summaryInputTokens ?? 0;
670
+ this.compactionTally.summaryOutputTokens += cost.summaryOutputTokens ?? 0;
550
671
  // Record the rewrite as an EVENT (P2): the log stays append-only, and the
551
672
  // messages that were folded away remain readable earlier in the file even
552
673
  // though the model can no longer see them. The watermark moves to the
553
674
  // post-compaction length so the next append is measured against the new
554
675
  // array, not the old one.
555
676
  if (this.args.recorder) {
556
- this.args.recorder.compaction(prefix.length, summaryMessages);
677
+ this.args.recorder.compaction(prefix.length, summaryMessages, cost);
557
678
  this.recordedCount = summaryMessages.length + kept.length;
558
679
  }
559
- return {
560
- messages: [...summaryMessages, ...kept],
561
- compacted: prefix.length,
562
- };
680
+ // Detect and warn (P3 context quality): which of the user's instructions
681
+ // in the folded prefix can no longer be found in the synopsis? A
682
+ // heuristic — it says so — and it costs no tokens. It does NOT re-inject
683
+ // or refuse the synopsis; it turns a silent loss into a visible one, and
684
+ // records it so the loss is auditable after the warning has scrolled away.
685
+ const lost = detectInstructionLoss(prefix, synopsis);
686
+ if (lost.length > 0) {
687
+ this.compactionTally.instructionLosses++;
688
+ this.args.recorder?.instructionLoss(lost);
689
+ this.args.ctx.logger.warn(`compaction may have dropped ${lost.length} instruction${lost.length === 1 ? "" : "s"} you gave — ` +
690
+ `restate any that still apply: ${lost.map((s) => `"${s}"`).join("; ")}`);
691
+ }
692
+ return { messages: next, compacted: prefix.length };
563
693
  }
564
694
  /**
565
695
  * Summarize a prefix via a standalone, tool-less provider call over a rendered
@@ -636,7 +766,9 @@ export class Session {
636
766
  });
637
767
  if (!text.trim())
638
768
  throw new Error("summary was empty");
639
- return { text: text.trim(), usage };
769
+ // `reported` is the same honesty pivot the usage record keeps: a provider
770
+ // that streamed no usage event leaves the cost UNKNOWN, not zero.
771
+ return { text: text.trim(), usage, reported: sawUsage };
640
772
  }
641
773
  }
642
774
  /** Render a message list to a compact plain-text transcript for summarization. */
@@ -2,6 +2,7 @@ import { readContext } from "./context.js";
2
2
  import { modeDescription } from "./mode.js";
3
3
  import { globalDir } from "../config/paths.js";
4
4
  import { GLOBAL_DIR_NAME } from "../constants.js";
5
+ import { relativeAge } from "../session/resume.js";
5
6
  /**
6
7
  * Where the disk figures come from: every declared root, plus `~/.cruxy`.
7
8
  *
@@ -63,6 +64,7 @@ disk) {
63
64
  };
64
65
  });
65
66
  const jobs = session.jobs?.list();
67
+ const last = session.lastVerification;
66
68
  const locations = disk
67
69
  ? diskLocations(toolCtx.workspace.roots(), disk)
68
70
  : undefined;
@@ -92,5 +94,21 @@ disk) {
92
94
  }
93
95
  : {}),
94
96
  tools: session.toolRegistry.list().length,
97
+ // The last run that executed (P2 verification), dated. Absent when none
98
+ // has — and the renderer SAYS so, because here absence is the fact worth
99
+ // showing: a session in which nothing ran is what this record exists to
100
+ // make visible. `lastRun`'s pattern: a field read, no probe.
101
+ ...(last
102
+ ? {
103
+ verification: {
104
+ tool: last.tool,
105
+ command: last.command,
106
+ passed: last.passed,
107
+ exitCode: last.exitCode,
108
+ durationMs: last.durationMs,
109
+ age: relativeAge(last.at),
110
+ },
111
+ }
112
+ : {}),
95
113
  };
96
114
  }
@@ -6,6 +6,7 @@ import { authMissingKey, shouldUseColor } from "../../errors/index.js";
6
6
  import { themeForColor } from "../../theme/index.js";
7
7
  import { ApprovalService, defaultPromptIO, InteractivePolicy, SessionAllowlist, } from "../../approval/index.js";
8
8
  import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
9
+ import { latestRecordedRuns, shortId } from "../../session/index.js";
9
10
  import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
10
11
  /**
11
12
  * `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
@@ -48,6 +49,18 @@ export function prCommand() {
48
49
  // resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
49
50
  const forge = createForgeProvider(resolveForgeToken());
50
51
  const guidance = await loadCommitGuidance(cwd);
52
+ // The verification record (P2): this command runs outside any
53
+ // session, so the evidence is the project's latest session log — its
54
+ // runs, each dated, under a header naming that session rather than
55
+ // "this session". The model never writes the section (generate.ts);
56
+ // no session here, or one that ran nothing → no section at all.
57
+ const recorded = latestRecordedRuns(cwd);
58
+ const verification = recorded
59
+ ? {
60
+ runs: recorded.runs,
61
+ from: `in the latest session for this project (${shortId(recorded.sessionId)})`,
62
+ }
63
+ : undefined;
51
64
  // The allowlist here is provably inert, and stated rather than implied
52
65
  // (cli#251). `cruxy pr` submits exactly one kind of action — `vcs` — and
53
66
  // `vcsRequest` gives every one of them `scope: {kind: "none"}` and an
@@ -70,6 +83,7 @@ export function prCommand() {
70
83
  ...i,
71
84
  scopes: guidance.scopes,
72
85
  skillBody: guidance.skillBody,
86
+ ...(verification ? { verification } : {}),
73
87
  }, { model: genModel }),
74
88
  });
75
89
  logger.info(t.muted("generating pull request content…"));