@bubblebrain-ai/bubble 0.0.43 → 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.js CHANGED
@@ -573,6 +573,10 @@ export class Agent {
573
573
  // the redirected one.
574
574
  hookState.exploredFiles?.clear();
575
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;
576
580
  if (typeof input.content === "string" && input.content.trim()) {
577
581
  hookState.taskType = classifyTask(input.content);
578
582
  }
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
@@ -61,10 +73,12 @@ export declare function formatPrintJson(input: {
61
73
  sessionId?: string;
62
74
  stopReason?: "end_turn" | "error" | "cancelled";
63
75
  compaction?: PrintCompactionStats;
76
+ changes?: PrintChangeSummary;
64
77
  }): string;
65
78
  export declare function formatPrintJsonError(input: {
66
79
  message: string;
67
80
  summary?: PrintRunSummary;
68
81
  sessionId?: string;
69
82
  compaction?: PrintCompactionStats;
83
+ changes?: PrintChangeSummary;
70
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(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.43",
3
+ "version": "0.0.44",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {