@bubblebrain-ai/bubble 0.0.42 → 0.0.44

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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Git-ground-truth change tracking for a single agent run.
3
+ *
4
+ * Tool-metadata bookkeeping (kind === "write" | "edit" | "patch") misses every
5
+ * other mutation channel: bash heredocs/sed -i, scripts, subagent worktree
6
+ * merges. Git sees them all. A baseline snapshot is taken at run start; the
7
+ * delta at completion time is the run's actual footprint.
8
+ *
9
+ * Everything here degrades to null outside a git repo or on any git error —
10
+ * callers fall back to metadata-based signals.
11
+ */
12
+ export interface GitChangeBaseline {
13
+ /** Paths already dirty (or untracked) before the run started. */
14
+ dirtyAtStart: Set<string>;
15
+ }
16
+ export interface ModifiedExistingTest {
17
+ path: string;
18
+ deletedLines: number;
19
+ }
20
+ export interface RunChangeSummary {
21
+ /** Paths that became dirty during the run (not dirty at baseline). */
22
+ changedFiles: string[];
23
+ /**
24
+ * Pre-existing (tracked) test files that were modified during the run,
25
+ * with how many lines the working tree deletes from them vs HEAD. New test
26
+ * files created by the run are NOT listed — creating tests is not suspect.
27
+ */
28
+ modifiedExistingTests: ModifiedExistingTest[];
29
+ }
30
+ export declare function isTestFilePath(path: string): boolean;
31
+ export declare function captureGitBaseline(cwd: string): Promise<GitChangeBaseline | null>;
32
+ /**
33
+ * Diff the current working tree against the run-start baseline.
34
+ *
35
+ * Known honest limits: a file already dirty at baseline that the run edited
36
+ * further is not attributed to the run (indistinguishable without content
37
+ * snapshots), and deleted-line counts for such files would mix user and agent
38
+ * edits — so they are excluded entirely.
39
+ */
40
+ export declare function detectRunChanges(cwd: string, baseline: GitChangeBaseline | null): Promise<RunChangeSummary | null>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Git-ground-truth change tracking for a single agent run.
3
+ *
4
+ * Tool-metadata bookkeeping (kind === "write" | "edit" | "patch") misses every
5
+ * other mutation channel: bash heredocs/sed -i, scripts, subagent worktree
6
+ * merges. Git sees them all. A baseline snapshot is taken at run start; the
7
+ * delta at completion time is the run's actual footprint.
8
+ *
9
+ * Everything here degrades to null outside a git repo or on any git error —
10
+ * callers fall back to metadata-based signals.
11
+ */
12
+ import { execFile } from "node:child_process";
13
+ const GIT_TIMEOUT_MS = 3_000;
14
+ const TEST_PATH_PATTERN = /(^|\/)(tests?|__tests__|spec)(\/|$)|(\.|_|-)(test|tests|spec)\.[^/]+$|(^|\/)test_[^/]+$/i;
15
+ export function isTestFilePath(path) {
16
+ return TEST_PATH_PATTERN.test(path);
17
+ }
18
+ function git(cwd, args) {
19
+ return new Promise((resolve) => {
20
+ execFile("git", args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
21
+ resolve(error ? null : stdout);
22
+ });
23
+ });
24
+ }
25
+ /** `git status --porcelain -z` → set of paths with any dirty/untracked state. */
26
+ async function dirtyPaths(cwd) {
27
+ const out = await git(cwd, ["status", "--porcelain", "-uall", "-z"]);
28
+ if (out === null)
29
+ return null;
30
+ const paths = new Set();
31
+ for (const entry of out.split("\0")) {
32
+ if (entry.length < 4)
33
+ continue;
34
+ // "XY path" (rename entries "XY new\0old" — the extra segment lacks the
35
+ // status prefix and is shorter than 4 chars only when degenerate; treat
36
+ // every segment with the status shape as an entry).
37
+ const path = entry.slice(3);
38
+ if (path)
39
+ paths.add(path);
40
+ }
41
+ return paths;
42
+ }
43
+ export async function captureGitBaseline(cwd) {
44
+ const dirty = await dirtyPaths(cwd);
45
+ if (dirty === null)
46
+ return null;
47
+ return { dirtyAtStart: dirty };
48
+ }
49
+ /**
50
+ * Diff the current working tree against the run-start baseline.
51
+ *
52
+ * Known honest limits: a file already dirty at baseline that the run edited
53
+ * further is not attributed to the run (indistinguishable without content
54
+ * snapshots), and deleted-line counts for such files would mix user and agent
55
+ * edits — so they are excluded entirely.
56
+ */
57
+ export async function detectRunChanges(cwd, baseline) {
58
+ if (!baseline)
59
+ return null;
60
+ const dirtyNow = await dirtyPaths(cwd);
61
+ if (dirtyNow === null)
62
+ return null;
63
+ const changedFiles = [...dirtyNow].filter((path) => !baseline.dirtyAtStart.has(path)).sort();
64
+ if (changedFiles.length === 0) {
65
+ return { changedFiles, modifiedExistingTests: [] };
66
+ }
67
+ // Untracked paths are new files; only tracked files count as "existing".
68
+ const untrackedOut = await git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
69
+ const untracked = new Set((untrackedOut ?? "").split("\0").filter(Boolean));
70
+ const modifiedExistingTests = [];
71
+ const candidates = changedFiles.filter((path) => isTestFilePath(path) && !untracked.has(path));
72
+ if (candidates.length > 0) {
73
+ const numstat = await git(cwd, ["diff", "HEAD", "--numstat", "-z", "--", ...candidates]);
74
+ if (numstat !== null) {
75
+ // -z numstat entries: "added\tdeleted\tpath\0" (renames add a segment).
76
+ for (const entry of numstat.split("\0")) {
77
+ const parts = entry.split("\t");
78
+ if (parts.length < 3)
79
+ continue;
80
+ const deleted = Number(parts[1]);
81
+ const path = parts[2];
82
+ if (!path || Number.isNaN(deleted))
83
+ continue;
84
+ modifiedExistingTests.push({ path, deletedLines: deleted });
85
+ }
86
+ }
87
+ }
88
+ return { changedFiles, modifiedExistingTests };
89
+ }
@@ -155,7 +155,7 @@ export function builtinAgentProfiles() {
155
155
  "You are an implementer subagent working in your own isolated git worktree — the parent tree is never touched.",
156
156
  "Your worktree forks from the last commit; changes the parent made after that commit are NOT present unless included in your briefing.",
157
157
  "Make the smallest coherent edits that complete your assigned group. Stay strictly inside the file set you were given; if a needed change falls outside it, report that instead of editing.",
158
- "Verify your changes narrowly (targeted tests or typecheck) when possible, and end with a concise summary of files changed and verification results — the parent reviews and applies your worktree.",
158
+ "Verify your changes when possible — run the affected module's existing tests, not only the cases you added — and end with a concise summary of files changed and verification results; the parent reviews and applies your worktree.",
159
159
  ].join("\n"),
160
160
  },
161
161
  ];
package/dist/agent.d.ts CHANGED
@@ -261,8 +261,10 @@ export declare class Agent {
261
261
  /** Snapshot of how often each compaction path rewrote history this run. */
262
262
  getCompactionStats(): {
263
263
  resident: number;
264
+ subturn: number;
264
265
  llm: number;
265
266
  overflow: number;
267
+ fired: number;
266
268
  droppedMessages: number;
267
269
  };
268
270
  /**
package/dist/agent.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Agent - The core decision loop.
3
3
  * It maintains message state, calls the LLM, executes tools, and auto-continues.
4
4
  */
5
- import { compactMessages } from "./context/compact.js";
5
+ import { compactCurrentTurnToolGroups, compactMessages, isRealUserMessage } from "./context/compact.js";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { mkdirSync, writeFileSync } from "node:fs";
8
8
  import { join } from "node:path";
@@ -175,9 +175,10 @@ export class Agent {
175
175
  // How often each compaction path actually rewrote history this run.
176
176
  // Surfaced via getCompactionStats() for print-mode telemetry: without it,
177
177
  // "did the model ever stop seeing the original instruction?" is
178
- // unanswerable from the outside (only counts real compactions, not
179
- // byte-saving projection rewrites).
180
- compactionStats = { resident: 0, llm: 0, overflow: 0, droppedMessages: 0 };
178
+ // unanswerable from the outside. `fired` counts successful compaction
179
+ // computations including ones whose rewrite was later rejected — a
180
+ // fired-but-never-written gap is the churn signal.
181
+ compactionStats = { resident: 0, subturn: 0, llm: 0, overflow: 0, fired: 0, droppedMessages: 0 };
181
182
  constructor(options) {
182
183
  this.provider = options.provider;
183
184
  this.sessionID = options.sessionID;
@@ -572,6 +573,10 @@ export class Agent {
572
573
  // the redirected one.
573
574
  hookState.exploredFiles?.clear();
574
575
  hookState.largeTaskCheckpointDone = false;
576
+ // A steer is a new ask: the completion gate must be able to fire
577
+ // again for the follow-up's closing pass, or appended requirements
578
+ // never get a self-check (codeChanged stays — prior edits are real).
579
+ hookState.completionGateFired = false;
575
580
  if (typeof input.content === "string" && input.content.trim()) {
576
581
  hookState.taskType = classifyTask(input.content);
577
582
  }
@@ -1002,6 +1007,7 @@ export class Agent {
1002
1007
  const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries);
1003
1008
  consecutiveOverflowRecoveries += 1;
1004
1009
  this.compactionStats.overflow += 1;
1010
+ this.compactionStats.fired += 1;
1005
1011
  this.compactionStats.droppedMessages += droppedMessages;
1006
1012
  traceEvent("compaction_fired", { path: "overflow", droppedMessages });
1007
1013
  yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
@@ -1520,6 +1526,7 @@ export class Agent {
1520
1526
  this.lastAnchorMessageCount = null;
1521
1527
  this.fileStateTracker?.invalidateReadHistory();
1522
1528
  this.compactionStats.llm += 1;
1529
+ this.compactionStats.fired += 1;
1523
1530
  traceEvent("compaction_fired", { path: "llm" });
1524
1531
  }
1525
1532
  // If LLM compaction failed for any reason, leave this.messages alone —
@@ -2551,23 +2558,43 @@ export class Agent {
2551
2558
  if (shouldAggressivelyPrune) {
2552
2559
  candidate = aggressivePruneMessages(candidate);
2553
2560
  }
2561
+ let compactedPath;
2554
2562
  if (shouldCompact) {
2555
2563
  const compacted = compactMessages(candidate, { keepRecentTurns });
2556
2564
  if (compacted.compacted && compacted.messages) {
2557
2565
  candidate = compacted.messages;
2558
- this.compactionStats.resident += 1;
2566
+ compactedPath = "resident";
2559
2567
  traceEvent("compaction_fired", { path: "resident", droppedEntries: compacted.droppedEntries });
2560
2568
  }
2569
+ else {
2570
+ // Single-instruction runs (one real user turn) never satisfy the
2571
+ // turn-level compactor; fold old tool groups within the turn instead.
2572
+ const subturn = compactCurrentTurnToolGroups(candidate, { keepRecentGroups: 3 });
2573
+ if (subturn.compacted && subturn.messages) {
2574
+ candidate = subturn.messages;
2575
+ compactedPath = "subturn";
2576
+ traceEvent("compaction_fired", { path: "subturn", droppedEntries: subturn.droppedEntries });
2577
+ }
2578
+ }
2579
+ if (compactedPath)
2580
+ this.compactionStats.fired += 1;
2561
2581
  }
2562
2582
  const afterChars = estimateResidentChars(candidate);
2563
2583
  const afterToolChars = estimateToolPayloadChars(candidate);
2564
- if (afterChars < beforeChars
2565
- || afterToolChars < beforeToolChars
2566
- || candidate.length < before.length) {
2584
+ // Chars-not-increasing is a NECESSARY condition: the old OR gate let a
2585
+ // fewer-but-bigger candidate (summary stacking) rewrite history.
2586
+ if (afterChars <= beforeChars
2587
+ && (afterChars < beforeChars
2588
+ || afterToolChars < beforeToolChars
2589
+ || candidate.length < before.length)) {
2567
2590
  this.messages = candidate;
2568
2591
  this.lastInputTokens = null;
2569
2592
  this.lastAnchorMessageCount = null;
2570
2593
  this.fileStateTracker?.invalidateReadHistory();
2594
+ if (compactedPath === "resident")
2595
+ this.compactionStats.resident += 1;
2596
+ if (compactedPath === "subturn")
2597
+ this.compactionStats.subturn += 1;
2571
2598
  }
2572
2599
  }
2573
2600
  appendMessage(message) {
@@ -2946,7 +2973,10 @@ function estimateToolPayloadChars(messages) {
2946
2973
  }, 0);
2947
2974
  }
2948
2975
  function countUserTurns(messages) {
2949
- return messages.reduce((count, message) => count + (message.role === "user" ? 1 : 0), 0);
2976
+ // Real user turns only: projected reminders and summary envelopes are
2977
+ // user-ROLE but not user TURNS — counting them used to shrink the keep
2978
+ // window and fire compaction almost every turn.
2979
+ return messages.reduce((count, message) => count + (isRealUserMessage(message) ? 1 : 0), 0);
2950
2980
  }
2951
2981
  function getCurrentHeapUsed() {
2952
2982
  try {
@@ -6,7 +6,7 @@
6
6
  * message. Falls back to the heuristic `compactMessages` if the LLM call
7
7
  * fails.
8
8
  */
9
- import { compactMessages as compactMessagesHeuristic } from "./compact.js";
9
+ import { buildCompactionSummaryMessage, clonePinnedUserMessage, compactMessages as compactMessagesHeuristic, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, } from "./compact.js";
10
10
  export const COMPACT_SYSTEM_PROMPT = `You are a conversation summarizer. Your job is to produce a structured
11
11
  summary of an earlier portion of a software-engineering assistant's
12
12
  conversation so that the assistant can continue working without the full
@@ -44,10 +44,13 @@ content, write "None".
44
44
  - The single most natural next action, if obvious.`;
45
45
  export async function compactMessagesWithLLM(messages, options) {
46
46
  const keepRecentTurns = options.keepRecentTurns ?? 2;
47
- const preservedContextMessages = messages.filter((m) => m.role === "system" || m.role === "meta");
48
- const conversationalMessages = messages.filter((m) => m.role !== "system" && m.role !== "meta");
49
- const turnStartIndexes = conversationalMessages
50
- .map((m, i) => (m.role === "user" ? i : -1))
47
+ const { leading, body } = splitLeadingContext(messages);
48
+ // Replace semantics: prior summaries (any form) are folded into the new
49
+ // summary as INPUT (the LLM merges them semantically) and removed.
50
+ const priorSummaries = body.filter(isCompactionSummaryMessage);
51
+ const bodyMessages = body.filter((m) => !isCompactionSummaryMessage(m));
52
+ const turnStartIndexes = bodyMessages
53
+ .map((m, i) => (isRealUserMessage(m) ? i : -1))
51
54
  .filter((i) => i >= 0);
52
55
  if (turnStartIndexes.length <= keepRecentTurns) {
53
56
  return { compacted: false };
@@ -56,11 +59,22 @@ export async function compactMessagesWithLLM(messages, options) {
56
59
  if (keepStartIndex <= 0) {
57
60
  return { compacted: false };
58
61
  }
59
- const oldMessages = conversationalMessages.slice(0, keepStartIndex);
60
- const keptMessages = conversationalMessages.slice(keepStartIndex);
62
+ const oldMessages = bodyMessages.slice(0, keepStartIndex);
63
+ const keptMessages = bodyMessages.slice(keepStartIndex);
64
+ // Pin the original instruction verbatim (P0.5: this path used to lack the
65
+ // pin the heuristic and llm-compactor paths already had).
66
+ const pinnedIndex = findFirstRealUserIndex(oldMessages);
67
+ const pinnedMessage = pinnedIndex >= 0 ? oldMessages[pinnedIndex] : undefined;
68
+ const summarizable = [
69
+ ...priorSummaries.map((m) => ({
70
+ role: "user",
71
+ content: `[Prior compaction summary]\n${typeof m.content === "string" ? m.content : ""}`,
72
+ })),
73
+ ...oldMessages.filter((_, index) => index !== pinnedIndex),
74
+ ];
61
75
  let summary;
62
76
  try {
63
- summary = await generateSummary(oldMessages, options);
77
+ summary = await generateSummary(summarizable, options);
64
78
  }
65
79
  catch {
66
80
  return compactMessagesHeuristic(messages, { keepRecentTurns, maxSummaryItems: options.maxSummaryItems });
@@ -72,8 +86,9 @@ export async function compactMessagesWithLLM(messages, options) {
72
86
  compacted: true,
73
87
  summary,
74
88
  messages: [
75
- ...preservedContextMessages,
76
- { role: "system", content: `Previous conversation summary:\n${summary}` },
89
+ ...leading,
90
+ ...(pinnedMessage ? [clonePinnedUserMessage(pinnedMessage)] : []),
91
+ buildCompactionSummaryMessage(summary),
77
92
  ...keptMessages,
78
93
  ],
79
94
  droppedEntries: oldMessages.length,
@@ -4,17 +4,41 @@ import type { SessionLogEntry } from "../session-types.js";
4
4
  * Compaction must never summarize away what the user actually asked for:
5
5
  * requirements that appear late in a long instruction ("commit when done",
6
6
  * "write the report in French") would otherwise vanish once the heuristic
7
- * summary truncates the goal line. Messages larger than this cap are exempt
8
- * from pinning (e.g. a pasted megabyte of logs as the opening message) —
9
- * summarization is the lesser evil there.
7
+ * summary truncates the goal line. Messages larger than this cap are pinned
8
+ * in truncated form (e.g. a pasted megabyte of logs as the opening message).
10
9
  */
11
10
  export declare const PINNED_INSTRUCTION_MAX_CHARS = 8192;
11
+ export declare const COMPACTION_SUMMARY_PREFIX = "Previous conversation summary:";
12
+ export declare const SUBTURN_SUMMARY_PREFIX = "Earlier in this turn (compacted to free context):";
12
13
  /**
13
- * Index of the first message the user actually wrote: user-role, not an
14
- * internal reminder/context block re-rolled into user role by the projector,
15
- * and small enough to keep verbatim.
14
+ * Identity check for compaction summaries across every form they can take:
15
+ * the first-class meta form, the legacy raw-system form, and the projected
16
+ * user-role internal-block forms (the resident path rewrites projections back
17
+ * into history, so role/position are not conserved).
16
18
  */
19
+ export declare function isCompactionSummaryMessage(message: Message): boolean;
20
+ /** A message the user actually typed (not a projected reminder, not a summary). */
21
+ export declare function isRealUserMessage(message: Message): boolean;
22
+ /**
23
+ * Split history into the leading system/meta context prefix (system prompt,
24
+ * startup reminders) and the conversational body. Positional, mirroring the
25
+ * projector's leading-prefix semantics: mid-history system/meta messages
26
+ * belong to the body and are foldable — preserving them by role forever is
27
+ * how stale reminders and relocated summaries used to pile up at the head.
28
+ */
29
+ export declare function splitLeadingContext(messages: Message[]): {
30
+ leading: Message[];
31
+ body: Message[];
32
+ };
33
+ /** Index of the first message the user actually wrote. */
17
34
  export declare function findFirstRealUserIndex(messages: Message[]): number;
35
+ /**
36
+ * Clone a message for pinning; oversized content is truncated rather than
37
+ * losing the pin entirely (P0.5: a truncated original beats a 140-char
38
+ * summary line).
39
+ */
40
+ export declare function clonePinnedUserMessage(message: Message): Message;
41
+ export declare function buildCompactionSummaryMessage(summary: string): Message;
18
42
  export interface CompactOptions {
19
43
  keepRecentTurns?: number;
20
44
  maxSummaryItems?: number;
@@ -3,12 +3,27 @@ import { isInternalBlockContent } from "../agent/internal-reminder-sanitizer.js"
3
3
  * Compaction must never summarize away what the user actually asked for:
4
4
  * requirements that appear late in a long instruction ("commit when done",
5
5
  * "write the report in French") would otherwise vanish once the heuristic
6
- * summary truncates the goal line. Messages larger than this cap are exempt
7
- * from pinning (e.g. a pasted megabyte of logs as the opening message) —
8
- * summarization is the lesser evil there.
6
+ * summary truncates the goal line. Messages larger than this cap are pinned
7
+ * in truncated form (e.g. a pasted megabyte of logs as the opening message).
9
8
  */
10
9
  export const PINNED_INSTRUCTION_MAX_CHARS = 8192;
11
- function userMessageText(message) {
10
+ export const COMPACTION_SUMMARY_PREFIX = "Previous conversation summary:";
11
+ export const SUBTURN_SUMMARY_PREFIX = "Earlier in this turn (compacted to free context):";
12
+ // llm-compactor.ts's LLM_SUMMARY_PREFIX must start with this literal — a unit
13
+ // test locks the two together (importing it here would create a module cycle).
14
+ const LLM_ENVELOPE_PREFIX = "Another language model previously worked on this task";
15
+ // Projected forms: the projector wraps meta messages into user-role internal
16
+ // blocks, and the resident-history path writes that projected form back, so a
17
+ // summary emitted as meta can come back around as one of these.
18
+ const PROJECTED_SUMMARY_PREFIXES = [
19
+ `<bubble_internal_context kind="compaction-summary">`,
20
+ `<bubble_internal_context kind="subturn-compaction-summary">`,
21
+ // Legacy: summaries emitted as raw system messages by ≤0.0.42 got projected
22
+ // into runtime-system blocks.
23
+ `<bubble_internal_context kind="runtime-system">\n${COMPACTION_SUMMARY_PREFIX}`,
24
+ `<bubble_internal_context kind="runtime-system">\n${SUBTURN_SUMMARY_PREFIX}`,
25
+ ];
26
+ function messageText(message) {
12
27
  if (typeof message.content === "string")
13
28
  return message.content;
14
29
  if (Array.isArray(message.content)) {
@@ -20,24 +35,86 @@ function userMessageText(message) {
20
35
  return "";
21
36
  }
22
37
  /**
23
- * Index of the first message the user actually wrote: user-role, not an
24
- * internal reminder/context block re-rolled into user role by the projector,
25
- * and small enough to keep verbatim.
38
+ * Identity check for compaction summaries across every form they can take:
39
+ * the first-class meta form, the legacy raw-system form, and the projected
40
+ * user-role internal-block forms (the resident path rewrites projections back
41
+ * into history, so role/position are not conserved).
26
42
  */
43
+ export function isCompactionSummaryMessage(message) {
44
+ if (message.role === "meta") {
45
+ return message.kind === "compaction-summary" || message.kind === "subturn-compaction-summary";
46
+ }
47
+ const text = messageText(message);
48
+ if (message.role === "system") {
49
+ return text.startsWith(COMPACTION_SUMMARY_PREFIX) || text.startsWith(SUBTURN_SUMMARY_PREFIX);
50
+ }
51
+ if (message.role === "user") {
52
+ const trimmed = text.trimStart();
53
+ if (trimmed.startsWith(LLM_ENVELOPE_PREFIX))
54
+ return true;
55
+ return PROJECTED_SUMMARY_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
56
+ }
57
+ return false;
58
+ }
59
+ /** A message the user actually typed (not a projected reminder, not a summary). */
60
+ export function isRealUserMessage(message) {
61
+ if (message.role !== "user")
62
+ return false;
63
+ const text = messageText(message);
64
+ if (!text.trim() || isInternalBlockContent(text))
65
+ return false;
66
+ if (text.trimStart().startsWith(LLM_ENVELOPE_PREFIX))
67
+ return false;
68
+ return true;
69
+ }
70
+ /**
71
+ * Split history into the leading system/meta context prefix (system prompt,
72
+ * startup reminders) and the conversational body. Positional, mirroring the
73
+ * projector's leading-prefix semantics: mid-history system/meta messages
74
+ * belong to the body and are foldable — preserving them by role forever is
75
+ * how stale reminders and relocated summaries used to pile up at the head.
76
+ */
77
+ export function splitLeadingContext(messages) {
78
+ let split = 0;
79
+ while (split < messages.length) {
80
+ const message = messages[split];
81
+ if ((message.role === "system" || message.role === "meta") && !isCompactionSummaryMessage(message)) {
82
+ split += 1;
83
+ continue;
84
+ }
85
+ break;
86
+ }
87
+ return { leading: messages.slice(0, split), body: messages.slice(split) };
88
+ }
89
+ /** Index of the first message the user actually wrote. */
27
90
  export function findFirstRealUserIndex(messages) {
28
91
  for (let index = 0; index < messages.length; index++) {
29
- const message = messages[index];
30
- if (message.role !== "user")
31
- continue;
32
- const text = userMessageText(message);
33
- if (!text.trim() || isInternalBlockContent(text))
34
- continue;
35
- if (text.length > PINNED_INSTRUCTION_MAX_CHARS)
36
- return -1;
37
- return index;
92
+ if (isRealUserMessage(messages[index]))
93
+ return index;
38
94
  }
39
95
  return -1;
40
96
  }
97
+ /**
98
+ * Clone a message for pinning; oversized content is truncated rather than
99
+ * losing the pin entirely (P0.5: a truncated original beats a 140-char
100
+ * summary line).
101
+ */
102
+ export function clonePinnedUserMessage(message) {
103
+ const text = messageText(message);
104
+ if (text.length <= PINNED_INSTRUCTION_MAX_CHARS)
105
+ return cloneMessage(message);
106
+ return {
107
+ role: "user",
108
+ content: `${text.slice(0, PINNED_INSTRUCTION_MAX_CHARS)}\n[...original message truncated for context management...]`,
109
+ };
110
+ }
111
+ export function buildCompactionSummaryMessage(summary) {
112
+ return {
113
+ role: "meta",
114
+ kind: "compaction-summary",
115
+ content: `${COMPACTION_SUMMARY_PREFIX}\n${summary}`,
116
+ };
117
+ }
41
118
  export function planSessionCompaction(entries, options = {}) {
42
119
  const keepRecentTurns = options.keepRecentTurns ?? 2;
43
120
  const metadataEntries = entries.filter((entry) => entry.type === "metadata");
@@ -100,10 +177,17 @@ export function compactSessionEntries(entries, options = {}) {
100
177
  export function compactMessages(messages, options = {}) {
101
178
  const keepRecentTurns = options.keepRecentTurns ?? 2;
102
179
  const maxSummaryItems = options.maxSummaryItems ?? 4;
103
- const preservedContextMessages = messages.filter((message) => message.role === "system" || message.role === "meta");
104
- const conversationalMessages = messages.filter((message) => message.role !== "system" && message.role !== "meta");
105
- const turnStartIndexes = conversationalMessages
106
- .map((message, index) => (message.role === "user" ? index : -1))
180
+ // Replace semantics: prior summaries (any form, any position) die here —
181
+ // at most one summary exists after every compaction. Their content is
182
+ // template-grade (goal truncated to 140 chars, constant next-steps line);
183
+ // the pinned original instruction is the durable record, not old summaries.
184
+ const { leading, body } = splitLeadingContext(messages);
185
+ const bodyMessages = body.filter((message) => !isCompactionSummaryMessage(message));
186
+ // Turn boundaries anchor on REAL user messages only. Projected reminders
187
+ // and summary envelopes used to inflate the turn count, shrinking the keep
188
+ // window to nothing and firing compaction almost every turn.
189
+ const turnStartIndexes = bodyMessages
190
+ .map((message, index) => (isRealUserMessage(message) ? index : -1))
107
191
  .filter((index) => index >= 0);
108
192
  if (turnStartIndexes.length <= keepRecentTurns) {
109
193
  return { compacted: false };
@@ -112,26 +196,25 @@ export function compactMessages(messages, options = {}) {
112
196
  if (keepStartIndex <= 0) {
113
197
  return { compacted: false };
114
198
  }
115
- const oldMessages = conversationalMessages.slice(0, keepStartIndex);
116
- const keptMessages = conversationalMessages.slice(keepStartIndex);
199
+ const oldMessages = bodyMessages.slice(0, keepStartIndex);
200
+ const keptMessages = bodyMessages.slice(keepStartIndex);
117
201
  // Pin the original instruction: it stays verbatim above the summary instead
118
202
  // of being crushed into the summary's 140-char goal line.
119
203
  const pinnedIndex = findFirstRealUserIndex(oldMessages);
120
204
  const pinnedMessage = pinnedIndex >= 0 ? oldMessages[pinnedIndex] : undefined;
121
- const summaryInput = pinnedIndex >= 0
122
- ? oldMessages.filter((_, index) => index !== pinnedIndex)
123
- : oldMessages;
205
+ // Summary fodder: real conversational content only — no pinned original,
206
+ // no projected reminder blocks (they used to pollute Goal/Progress lines).
207
+ const summaryInput = oldMessages.filter((message, index) => index !== pinnedIndex
208
+ && message.role !== "meta"
209
+ && !(message.role === "user" && !isRealUserMessage(message)));
124
210
  const summary = buildMessageSummary(summaryInput, maxSummaryItems);
125
211
  if (!summary) {
126
212
  return { compacted: false };
127
213
  }
128
214
  const compactedMessages = [
129
- ...preservedContextMessages.map((message) => cloneMessage(message)),
130
- ...(pinnedMessage ? [cloneMessage(pinnedMessage)] : []),
131
- {
132
- role: "system",
133
- content: `Previous conversation summary:\n${summary}`,
134
- },
215
+ ...leading.map((message) => cloneMessage(message)),
216
+ ...(pinnedMessage ? [clonePinnedUserMessage(pinnedMessage)] : []),
217
+ buildCompactionSummaryMessage(summary),
135
218
  ...keptMessages.map((message) => cloneMessage(message)),
136
219
  ];
137
220
  return {
@@ -144,11 +227,12 @@ export function compactMessages(messages, options = {}) {
144
227
  export function compactCurrentTurnToolGroups(messages, options = {}) {
145
228
  const keepRecentGroups = options.keepRecentGroups ?? 2;
146
229
  const maxSummaryItems = options.maxSummaryItems ?? 8;
147
- const preserved = messages.filter((m) => m.role === "system" || m.role === "meta");
148
- const body = messages.filter((m) => m.role !== "system" && m.role !== "meta");
230
+ const { leading, body } = splitLeadingContext(messages);
231
+ // Anchor the "current turn" on the last REAL user message — a projected
232
+ // reminder must not shrink the turn to a sliver.
149
233
  let lastUserIndex = -1;
150
234
  for (let i = body.length - 1; i >= 0; i--) {
151
- if (body[i].role === "user") {
235
+ if (isRealUserMessage(body[i])) {
152
236
  lastUserIndex = i;
153
237
  break;
154
238
  }
@@ -156,7 +240,11 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
156
240
  if (lastUserIndex < 0)
157
241
  return { compacted: false };
158
242
  const preTurn = body.slice(0, lastUserIndex + 1);
159
- const turnBody = body.slice(lastUserIndex + 1);
243
+ const rawTurnBody = body.slice(lastUserIndex + 1);
244
+ // Replace semantics within the turn: fold prior sub-turn summaries' lines
245
+ // into the new one instead of stacking marker messages.
246
+ const priorSubturnSummaries = rawTurnBody.filter((m) => m.role === "meta" ? m.kind === "subturn-compaction-summary" : isCompactionSummaryMessage(m) && messageText(m).includes(SUBTURN_SUMMARY_PREFIX.slice(0, 20)));
247
+ const turnBody = rawTurnBody.filter((m) => !priorSubturnSummaries.includes(m));
160
248
  const groups = [];
161
249
  let current = null;
162
250
  for (const msg of turnBody) {
@@ -179,9 +267,10 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
179
267
  .filter((g) => g.assistant.role === "assistant" && (g.assistant.toolCalls?.length ?? 0) > 0);
180
268
  if (evictable.length === 0)
181
269
  return { compacted: false };
182
- const summary = buildToolGroupsSummary(evictable, maxSummaryItems);
183
- if (!summary)
270
+ const freshSummary = buildToolGroupsSummary(evictable, maxSummaryItems);
271
+ if (!freshSummary)
184
272
  return { compacted: false };
273
+ const summary = mergeSubturnSummaryLines(priorSubturnSummaries, freshSummary, maxSummaryItems);
185
274
  const survivingGroups = groups.filter((g) => !evictable.includes(g));
186
275
  const flatSurvivors = [];
187
276
  for (const g of survivingGroups) {
@@ -190,11 +279,12 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
190
279
  flatSurvivors.push(cloneMessage(t));
191
280
  }
192
281
  const compactedMessages = [
193
- ...preserved.map(cloneMessage),
282
+ ...leading.map(cloneMessage),
194
283
  ...preTurn.map(cloneMessage),
195
284
  {
196
- role: "system",
197
- content: `Earlier in this turn (compacted to free context):\n${summary}`,
285
+ role: "meta",
286
+ kind: "subturn-compaction-summary",
287
+ content: `${SUBTURN_SUMMARY_PREFIX}\n${summary}`,
198
288
  },
199
289
  ...flatSurvivors,
200
290
  ];
@@ -205,6 +295,35 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
205
295
  droppedEntries: evictable.length,
206
296
  };
207
297
  }
298
+ /**
299
+ * Merge prior sub-turn summary lines with the fresh one: line-level dedupe,
300
+ * prior lines first (chronological), capped. Structured merge of tool-name /
301
+ * file lists — not freetext concatenation.
302
+ */
303
+ function mergeSubturnSummaryLines(priorSummaries, freshSummary, maxItems) {
304
+ if (priorSummaries.length === 0)
305
+ return freshSummary;
306
+ const seen = new Set();
307
+ const merged = [];
308
+ const push = (line) => {
309
+ const normalized = line.trim();
310
+ if (!normalized || seen.has(normalized))
311
+ return;
312
+ seen.add(normalized);
313
+ merged.push(line);
314
+ };
315
+ for (const prior of priorSummaries) {
316
+ for (const line of messageText(prior).split("\n")) {
317
+ if (line.startsWith(SUBTURN_SUMMARY_PREFIX) || line.startsWith("<bubble_internal_"))
318
+ continue;
319
+ push(line);
320
+ }
321
+ }
322
+ for (const line of freshSummary.split("\n"))
323
+ push(line);
324
+ const cap = Math.max(maxItems * 4, 24);
325
+ return merged.slice(-cap).join("\n");
326
+ }
208
327
  function buildToolGroupsSummary(groups, maxItems) {
209
328
  const toolCounts = new Map();
210
329
  const fileSet = new Set();
@@ -10,7 +10,7 @@
10
10
  // Failure modes are explicit: returns { compacted: false, reason } so the
11
11
  // caller can fall back to algorithmic compaction without an exception.
12
12
  import { estimateContextTokens } from "./budget.js";
13
- import { findFirstRealUserIndex } from "./compact.js";
13
+ import { buildCompactionSummaryMessage, clonePinnedUserMessage, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, } from "./compact.js";
14
14
  export const LLM_COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
15
15
 
16
16
  Include:
@@ -25,11 +25,17 @@ export async function compactWithLLM(messages, options) {
25
25
  const { provider, modelId, abortSignal } = options;
26
26
  const maxInputTokens = options.maxInputTokens ?? 100_000;
27
27
  const keepRecentGroups = options.keepRecentGroups ?? 2;
28
- const preserved = messages.filter((m) => m.role === "system" || m.role === "meta");
29
- const body = messages.filter((m) => m.role !== "system" && m.role !== "meta");
28
+ // Positional leading prefix, not role-global filtering: filtering by role
29
+ // used to relocate mid-history system messages (including prior summaries)
30
+ // to the head, where the projector then fused them into the system prompt.
31
+ const { leading, body: rawBody } = splitLeadingContext(messages);
32
+ // Replace semantics: prior summaries become summarization INPUT (semantic
33
+ // rolling — the compactor model merges them), never preserved copies.
34
+ const priorSummaries = rawBody.filter(isCompactionSummaryMessage);
35
+ const body = rawBody.filter((m) => !isCompactionSummaryMessage(m));
30
36
  let lastUserIndex = -1;
31
37
  for (let i = body.length - 1; i >= 0; i--) {
32
- if (body[i].role === "user") {
38
+ if (isRealUserMessage(body[i])) {
33
39
  lastUserIndex = i;
34
40
  break;
35
41
  }
@@ -73,10 +79,14 @@ export async function compactWithLLM(messages, options) {
73
79
  const keptGroupCount = Math.min(keepRecentGroups, groups.length);
74
80
  const evictedGroups = groups.slice(0, groups.length - keptGroupCount);
75
81
  const keptGroups = groups.slice(groups.length - keptGroupCount);
76
- // What we'll send to the model to summarize: prior turns + the older groups
77
- // in the current turn (everything we're about to evict). The pinned first
78
- // instruction is excluded — it survives verbatim, not as summary fodder.
82
+ // What we'll send to the model to summarize: prior summaries (semantic
83
+ // rolling), prior turns, and the older groups in the current turn. The
84
+ // pinned first instruction is excluded — it survives verbatim.
79
85
  const toSummarize = [
86
+ ...priorSummaries.map((m) => ({
87
+ role: "user",
88
+ content: `[Prior compaction summary]\n${typeof m.content === "string" ? m.content : ""}`,
89
+ })),
80
90
  ...summarizablePriorTurns,
81
91
  ...evictedGroups.flatMap((g) => [g.assistant, ...g.toolResults]),
82
92
  ];
@@ -118,12 +128,9 @@ export async function compactWithLLM(messages, options) {
118
128
  flatKept.push(cloneMessage(t));
119
129
  }
120
130
  const compacted = [
121
- ...preserved.map(cloneMessage),
122
- ...(pinnedFirstUser ? [cloneMessage(pinnedFirstUser)] : []),
123
- {
124
- role: "user",
125
- content: `${LLM_SUMMARY_PREFIX}\n${summaryText.trim()}`,
126
- },
131
+ ...leading.map(cloneMessage),
132
+ ...(pinnedFirstUser ? [clonePinnedUserMessage(pinnedFirstUser)] : []),
133
+ buildCompactionSummaryMessage(`${LLM_SUMMARY_PREFIX}\n${summaryText.trim()}`),
127
134
  cloneMessage(lastUser),
128
135
  ...flatKept,
129
136
  ];
@@ -85,7 +85,13 @@ export function projectMessages(messages, options = {}) {
85
85
  if (!after.shouldCompact)
86
86
  break;
87
87
  }
88
- return repairToolCallChains(working);
88
+ // Compaction passes emit summaries as meta messages (first-class identity);
89
+ // the provider payload must stay provider-safe, so render them here the
90
+ // same way body meta messages are rendered above.
91
+ const providerSafe = working.map((message) => message.role === "meta"
92
+ ? { role: "user", content: formatMetaMessage(message) }
93
+ : message);
94
+ return repairToolCallChains(providerSafe);
89
95
  }
90
96
  return repaired;
91
97
  }
package/dist/main.js CHANGED
@@ -575,6 +575,26 @@ async function main() {
575
575
  // mode is byte-for-byte the previous behavior.
576
576
  const jsonOutput = args.outputFormat === "json";
577
577
  const collector = new PrintRunCollector();
578
+ // Harness-side change footprint (git ground truth) — printed by the
579
+ // harness so the run cannot omit or misstate what it touched.
580
+ const changeBaseline = jsonOutput
581
+ ? await (await import("./agent/change-tracker.js")).captureGitBaseline(args.cwd)
582
+ : null;
583
+ const collectChanges = async () => {
584
+ if (!changeBaseline)
585
+ return undefined;
586
+ const { detectRunChanges } = await import("./agent/change-tracker.js");
587
+ const detected = await detectRunChanges(args.cwd, changeBaseline).catch(() => null);
588
+ if (!detected)
589
+ return undefined;
590
+ return {
591
+ changed_files: detected.changedFiles.length,
592
+ modified_existing_tests: detected.modifiedExistingTests.map((t) => ({
593
+ path: t.path,
594
+ deleted_lines: t.deletedLines,
595
+ })),
596
+ };
597
+ };
578
598
  const printSessionId = sessionManager
579
599
  ? basename(sessionManager.getSessionFile())
580
600
  : undefined;
@@ -617,6 +637,7 @@ async function main() {
617
637
  summary: collector.summary(),
618
638
  sessionId: printSessionId,
619
639
  compaction: agent.getCompactionStats(),
640
+ changes: await collectChanges(),
620
641
  }) + "\n");
621
642
  }
622
643
  else {
@@ -633,6 +654,7 @@ async function main() {
633
654
  summary: collector.summary(),
634
655
  sessionId: printSessionId,
635
656
  compaction: agent.getCompactionStats(),
657
+ changes: await collectChanges(),
636
658
  }) + "\n");
637
659
  process.exitCode = 1;
638
660
  }
@@ -6,6 +6,7 @@ import { DiscoveryBarrier } from "../agent/discovery-barrier.js";
6
6
  import { arbitrateToolCall } from "../agent/tool-arbiter.js";
7
7
  import { buildCompletionSelfCheckReminder, buildEditRetryEscalationReminder, buildSmallTaskHint, buildWorkflowPhaseReminder, } from "../prompt/reminders.js";
8
8
  import { largeImplementationTaskReminder, orchestrationRequestReminder, reminderForTaskType, userNamedModelReminder } from "../prompt/task-reminders.js";
9
+ import { captureGitBaseline, detectRunChanges } from "../agent/change-tracker.js";
9
10
  import { formatCoverageSummary, resolveWorkflowPhase } from "./workflow.js";
10
11
  import { resolve as resolvePath } from "node:path";
11
12
  import { traceEvent } from "../debug-trace.js";
@@ -14,7 +15,13 @@ import { buildSubagentLifecycleReminder } from "../agent/subagent-lifecycle-remi
14
15
  export function createDefaultHooks() {
15
16
  return [
16
17
  {
17
- beforeTurn(ctx) {
18
+ async beforeTurn(ctx) {
19
+ // Git ground truth for the completion gate: tool-metadata bookkeeping
20
+ // misses bash-written files and subagent worktree merges; a dirty-state
21
+ // baseline at run start makes the run's real footprint observable.
22
+ if (ctx.state.gitBaseline === undefined) {
23
+ ctx.state.gitBaseline = await captureGitBaseline(ctx.cwd);
24
+ }
18
25
  const taskType = classifyTask(ctx.input);
19
26
  ctx.state.taskType = taskType;
20
27
  ctx.state.governor = new ExecutionGovernor(taskType);
@@ -99,6 +106,7 @@ export function createDefaultHooks() {
99
106
  },
100
107
  {
101
108
  beforeToolCall(ctx) {
109
+ ctx.state.toolUsed = true;
102
110
  const arbitration = arbitrateToolCall(ctx.toolCall);
103
111
  const toolCall = { ...arbitration.toolCall, ...(arbitration.note ? { arbiterNote: arbitration.note } : {}) };
104
112
  ctx.replaceToolCall(toolCall);
@@ -220,7 +228,7 @@ export function createDefaultHooks() {
220
228
  }
221
229
  // Verification reminders intentionally removed. See afterToolCall.
222
230
  },
223
- afterTurn(ctx) {
231
+ async afterTurn(ctx) {
224
232
  // Verification force-continuation removed. The model decides whether
225
233
  // verification is meaningful for the task, per the system prompt.
226
234
  //
@@ -232,21 +240,37 @@ export function createDefaultHooks() {
232
240
  // "prove it" spiral that killed the old design cannot start.
233
241
  if (ctx.state.completionGateFired)
234
242
  return;
235
- if (!ctx.state.codeChanged)
236
- return;
237
243
  // Tools are frozen: the model could not act on anything the check
238
244
  // surfaces, so another turn is pure token burn.
239
245
  if (ctx.state.forceTextOnlyReason)
240
246
  return;
241
247
  // Subagents hand off to a parent that does its own closing pass, and
242
248
  // worktree profiles explicitly forbid the very actions a completion
243
- // check would suggest.
249
+ // check would suggest. The parent's gate still covers their merged
250
+ // changes via the git baseline below.
244
251
  if (ctx.agent.role === "subagent")
245
252
  return;
253
+ // Change detection: tool metadata first (cheap), then git ground
254
+ // truth — which also catches bash-written files and subagent
255
+ // worktree merges. Gate only runs git when the run actually used
256
+ // tools, so external repo churn cannot hijack a pure Q&A turn.
257
+ let changes = null;
258
+ if (ctx.state.toolUsed && ctx.state.gitBaseline) {
259
+ changes = await detectRunChanges(ctx.cwd, ctx.state.gitBaseline);
260
+ }
261
+ const changedSomething = !!ctx.state.codeChanged || (changes !== null && changes.changedFiles.length > 0);
262
+ if (!changedSomething)
263
+ return;
246
264
  ctx.state.completionGateFired = true;
247
265
  ctx.state.forceContinuationReason = "completion_self_check";
248
- ctx.queueReminder(buildCompletionSelfCheckReminder());
249
- traceEvent("completion_gate_fired", { turnCount: ctx.state.turnCount });
266
+ ctx.queueReminder(buildCompletionSelfCheckReminder({
267
+ modifiedExistingTests: changes?.modifiedExistingTests,
268
+ }));
269
+ traceEvent("completion_gate_fired", {
270
+ turnCount: ctx.state.turnCount,
271
+ changedFiles: changes?.changedFiles.length,
272
+ modifiedExistingTests: changes?.modifiedExistingTests.length,
273
+ });
250
274
  },
251
275
  },
252
276
  ];
@@ -1,5 +1,6 @@
1
1
  import type { Agent } from "../agent.js";
2
2
  import type { ContentPart, ParsedToolCall, ToolRegistryEntry, ToolResult } from "../types.js";
3
+ import type { GitChangeBaseline } from "../agent/change-tracker.js";
3
4
  import type { TaskType } from "../agent/task-classifier.js";
4
5
  import type { ExecutionGovernor } from "../agent/execution-governor.js";
5
6
  import type { EvidenceTracker } from "../agent/evidence-tracker.js";
@@ -18,6 +19,10 @@ export interface TurnHookState {
18
19
  codeChanged?: boolean;
19
20
  /** Completion self-check gate already fired this run (fires at most once). */
20
21
  completionGateFired?: boolean;
22
+ /** Git dirty-state snapshot at run start (null = not a git repo / git failed). */
23
+ gitBaseline?: GitChangeBaseline | null;
24
+ /** Any tool executed this run — guards git-change attribution for pure Q&A turns. */
25
+ toolUsed?: boolean;
21
26
  smallTaskHintSent?: boolean;
22
27
  recentEditFailures?: string[];
23
28
  editNoMatchReminderPaths?: string[];
@@ -42,6 +42,18 @@ export declare class PrintRunCollector {
42
42
  private addUsage;
43
43
  summary(): PrintRunSummary;
44
44
  }
45
+ /**
46
+ * Harness-observed change footprint of the run (git ground truth). Printed
47
+ * by the harness, not the model — a run cannot omit or misstate what it
48
+ * touched ("no breaking changes" next to deleted test assertions).
49
+ */
50
+ export interface PrintChangeSummary {
51
+ changed_files: number;
52
+ modified_existing_tests: Array<{
53
+ path: string;
54
+ deleted_lines: number;
55
+ }>;
56
+ }
45
57
  /**
46
58
  * Per-path counts of context compactions during the run. Lets benchmark
47
59
  * harnesses correlate outcomes with "did the model ever lose sight of the
@@ -49,8 +61,11 @@ export declare class PrintRunCollector {
49
61
  */
50
62
  export interface PrintCompactionStats {
51
63
  resident: number;
64
+ subturn: number;
52
65
  llm: number;
53
66
  overflow: number;
67
+ /** Successful compaction computations incl. rejected rewrites (churn signal). */
68
+ fired: number;
54
69
  droppedMessages: number;
55
70
  }
56
71
  export declare function formatPrintJson(input: {
@@ -58,10 +73,12 @@ export declare function formatPrintJson(input: {
58
73
  sessionId?: string;
59
74
  stopReason?: "end_turn" | "error" | "cancelled";
60
75
  compaction?: PrintCompactionStats;
76
+ changes?: PrintChangeSummary;
61
77
  }): string;
62
78
  export declare function formatPrintJsonError(input: {
63
79
  message: string;
64
80
  summary?: PrintRunSummary;
65
81
  sessionId?: string;
66
82
  compaction?: PrintCompactionStats;
83
+ changes?: PrintChangeSummary;
67
84
  }): string;
@@ -84,6 +84,7 @@ export function formatPrintJson(input) {
84
84
  usage: input.summary.usage,
85
85
  usage_reported: input.summary.usage_reported,
86
86
  ...(input.compaction ? { compaction: input.compaction } : {}),
87
+ ...(input.changes ? { changes: input.changes } : {}),
87
88
  });
88
89
  }
89
90
  export function formatPrintJsonError(input) {
@@ -101,5 +102,6 @@ export function formatPrintJsonError(input) {
101
102
  }
102
103
  : {}),
103
104
  ...(input.compaction ? { compaction: input.compaction } : {}),
105
+ ...(input.changes ? { changes: input.changes } : {}),
104
106
  });
105
107
  }
@@ -23,7 +23,14 @@ export declare function buildInvestigationReminder(): string;
23
23
  export declare function buildLoopWarningReminder(reason: string): string;
24
24
  export declare function buildSearchFreezeReminder(reason: string): string;
25
25
  export declare function buildExplorationFreezeReminder(reason: string): string;
26
- export declare function buildCompletionSelfCheckReminder(): string;
26
+ export interface CompletionSelfCheckOptions {
27
+ /** Pre-existing test files this run modified, with deleted-line counts. */
28
+ modifiedExistingTests?: Array<{
29
+ path: string;
30
+ deletedLines: number;
31
+ }>;
32
+ }
33
+ export declare function buildCompletionSelfCheckReminder(options?: CompletionSelfCheckOptions): string;
27
34
  export declare function buildToolFreezeReminder(reason: string): string;
28
35
  export declare function buildWorkflowPhaseReminder(input: {
29
36
  phase: "investigate" | "correlate" | "conclude";
@@ -116,15 +116,30 @@ You have enough context to act. Do not continue reading, searching, or delegatin
116
116
  Choose one of:
117
117
  1. Use edit/write to make the requested change.
118
118
  2. If no safe change can be made from the gathered context, explain the concrete blocker.
119
- 3. If files were already changed, run the narrowest meaningful verification or finish with the result.
119
+ 3. If files were already changed, verify the change and run the affected module's existing tests, or finish with the result.
120
120
  `);
121
121
  }
122
- export function buildCompletionSelfCheckReminder() {
123
- return wrapInSystemReminder(`
124
- You appear to be finishing. Before giving your final answer, re-read the user's original request in this conversation and check whether any explicit requirement in it remains unfulfilled — including requirements about how the work should be delivered or reported, not only whether the implementation works.
122
+ export function buildCompletionSelfCheckReminder(options = {}) {
123
+ const sections = [];
124
+ const modifiedTests = options.modifiedExistingTests ?? [];
125
+ if (modifiedTests.length > 0) {
126
+ // Disclosure, not accusation: legitimate test updates (requirement
127
+ // changes, fixing a broken test, refactors) are normal engineering — the
128
+ // rule is that they must be declared, never silent. Confrontational
129
+ // wording risks a model rolling back a user-requested test update.
130
+ const listed = modifiedTests
131
+ .slice(0, 8)
132
+ .map((t) => `- ${t.path}${t.deletedLines > 0 ? ` (${t.deletedLines} line${t.deletedLines === 1 ? "" : "s"} removed)` : ""}`)
133
+ .join("\n");
134
+ sections.push(`This run modified pre-existing test files:
135
+ ${listed}${modifiedTests.length > 8 ? `\n- …and ${modifiedTests.length - 8} more` : ""}
136
+
137
+ If a change you made caused an existing test to fail and you edited the test so the suite passes, restore the original behavior instead — weakening a test to make it pass is never acceptable. If the test changes are intentional (the request requires new expected behavior, or the test itself was wrong), keep them and state each modified test and the reason in your final summary.`);
138
+ }
139
+ sections.push(`You appear to be finishing. Before giving your final answer, re-read the user's original request in this conversation and check whether any explicit requirement in it remains unfulfilled — including requirements about how the work should be delivered or reported, not only whether the implementation works.
125
140
 
126
- If something is missing, complete it now. If everything is done, give your final answer directly — do not repeat this check or re-verify work you have already verified.
127
- `);
141
+ If something is missing, complete it now. If everything is done, give your final answer directly — do not repeat this check or re-verify work you have already verified.`);
142
+ return wrapInSystemReminder(`\n${sections.join("\n\n")}\n`);
128
143
  }
129
144
  export function buildToolFreezeReminder(reason) {
130
145
  return wrapInSystemReminder(`
@@ -24,7 +24,7 @@ Implementation workflow:
24
24
  - Do not stop at a proposal when the user asked for a change.
25
25
  - Inspect the relevant files first, then make the smallest coherent edit.
26
26
  - Keep unrelated files and behavior out of scope.
27
- - Run a narrow verification command or explain why it cannot be run.
27
+ - Verify the change, then run the existing tests of the affected module — not only the cases you added — or explain why they cannot be run.
28
28
  `);
29
29
  case "code_review":
30
30
  return wrapInSystemReminder(`
@@ -157,7 +157,13 @@ const TURN_SUMMARY_PREFIX = /^Earlier in this turn \(compacted to free context\)
157
157
  export function latestCompactionSummary(agentMessages) {
158
158
  for (let index = agentMessages.length - 1; index >= 0; index--) {
159
159
  const message = agentMessages[index];
160
- if (!message || message.role !== "system" || typeof message.content !== "string")
160
+ if (!message || typeof message.content !== "string")
161
+ continue;
162
+ // Summaries are meta messages since 0.0.43; raw system form is legacy.
163
+ const isSummaryCarrier = message.role === "system"
164
+ || (message.role === "meta"
165
+ && (message.kind === "compaction-summary" || message.kind === "subturn-compaction-summary"));
166
+ if (!isSummaryCarrier)
161
167
  continue;
162
168
  const previousMatch = message.content.match(PREVIOUS_SUMMARY_PREFIX);
163
169
  if (previousMatch?.[1]?.trim())
package/dist/types.d.ts CHANGED
@@ -64,7 +64,7 @@ export interface SystemMessage {
64
64
  role: "system";
65
65
  content: string;
66
66
  }
67
- export type MetaMessageKind = "system-reminder" | "runtime-context";
67
+ export type MetaMessageKind = "system-reminder" | "runtime-context" | "compaction-summary" | "subturn-compaction-summary";
68
68
  export interface MetaMessage {
69
69
  role: "meta";
70
70
  kind: MetaMessageKind;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {