@koda-sl/baker-bridge 0.164.0-dev.d9feffe34 → 0.168.0-dev.d78500250

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 (50) hide show
  1. package/README.md +40 -19
  2. package/dist/hono/bridge-job.d.ts +17 -0
  3. package/dist/hono/bridge-job.d.ts.map +1 -1
  4. package/dist/hono/bridge-job.js +110 -19
  5. package/dist/hono/bridge-job.js.map +1 -1
  6. package/dist/hono/bridge-job.test.js +184 -13
  7. package/dist/hono/bridge-job.test.js.map +1 -1
  8. package/dist/hono/effort-route.d.ts +43 -0
  9. package/dist/hono/effort-route.d.ts.map +1 -0
  10. package/dist/hono/effort-route.js +36 -0
  11. package/dist/hono/effort-route.js.map +1 -0
  12. package/dist/hono/effort-route.test.d.ts +2 -0
  13. package/dist/hono/effort-route.test.d.ts.map +1 -0
  14. package/dist/hono/effort-route.test.js +54 -0
  15. package/dist/hono/effort-route.test.js.map +1 -0
  16. package/dist/hono/openrouter-session.d.ts +16 -13
  17. package/dist/hono/openrouter-session.d.ts.map +1 -1
  18. package/dist/hono/openrouter-session.js +16 -13
  19. package/dist/hono/openrouter-session.js.map +1 -1
  20. package/dist/hono/relay.d.ts +8 -0
  21. package/dist/hono/relay.d.ts.map +1 -1
  22. package/dist/hono/relay.js +2 -0
  23. package/dist/hono/relay.js.map +1 -1
  24. package/dist/hono/relay.test.js +24 -0
  25. package/dist/hono/relay.test.js.map +1 -1
  26. package/dist/hono/runtime-files.d.ts +1 -0
  27. package/dist/hono/runtime-files.d.ts.map +1 -1
  28. package/dist/hono/runtime-files.js +3 -0
  29. package/dist/hono/runtime-files.js.map +1 -1
  30. package/dist/hono/tool-result-ledger.d.ts +30 -0
  31. package/dist/hono/tool-result-ledger.d.ts.map +1 -0
  32. package/dist/hono/tool-result-ledger.js +63 -0
  33. package/dist/hono/tool-result-ledger.js.map +1 -0
  34. package/dist/hono/tool-result-ledger.test.d.ts +2 -0
  35. package/dist/hono/tool-result-ledger.test.d.ts.map +1 -0
  36. package/dist/hono/tool-result-ledger.test.js +61 -0
  37. package/dist/hono/tool-result-ledger.test.js.map +1 -0
  38. package/dist/hono/workspace-instructions.d.ts +43 -0
  39. package/dist/hono/workspace-instructions.d.ts.map +1 -0
  40. package/dist/hono/workspace-instructions.js +68 -0
  41. package/dist/hono/workspace-instructions.js.map +1 -0
  42. package/dist/hono/workspace-instructions.test.d.ts +2 -0
  43. package/dist/hono/workspace-instructions.test.d.ts.map +1 -0
  44. package/dist/hono/workspace-instructions.test.js +68 -0
  45. package/dist/hono/workspace-instructions.test.js.map +1 -0
  46. package/dist/review/digest.d.ts +13 -0
  47. package/dist/review/digest.d.ts.map +1 -1
  48. package/dist/review/digest.js +1 -1
  49. package/dist/review/digest.js.map +1 -1
  50. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
@@ -9,11 +9,19 @@ vi.mock("./env.ts", () => ({
9
9
  // The skills allowlist is read off the workspace, so the project root has to be
10
10
  // steerable to a fixture directory. Defaults to the real root for every other test.
11
11
  const projectRootMock = vi.hoisted(() => ({ value: process.cwd() }));
12
+ // Where the per-company instructions import is written; kept out of the real ~/.baker.
13
+ const instructionsDirMock = vi.hoisted(() => ({
14
+ value: `${process.env.TMPDIR ?? "/tmp"}/baker-job-instructions-${process.pid}`,
15
+ }));
12
16
  vi.mock("./runtime-files.ts", async (importOriginal) => {
13
17
  const actual = await importOriginal();
14
18
  return {
15
19
  ...actual,
16
- bridgeRuntimeFiles: { ...actual.bridgeRuntimeFiles, projectRoot: () => projectRootMock.value },
20
+ bridgeRuntimeFiles: {
21
+ ...actual.bridgeRuntimeFiles,
22
+ projectRoot: () => projectRootMock.value,
23
+ instructionsDir: () => instructionsDirMock.value,
24
+ },
17
25
  };
18
26
  });
19
27
  import { AVATAR_CASTING_TOOL_NAME } from "./avatar-casting.js";
@@ -254,6 +262,8 @@ function createExecutor(options) {
254
262
  ...(options.catchUpWithMain ? { catchUpWithMain: options.catchUpWithMain } : {}),
255
263
  ...(options.keepCliInStep ? { keepCliInStep: options.keepCliInStep } : {}),
256
264
  ...(options.holdsNoWork ? { holdsNoWork: options.holdsNoWork } : {}),
265
+ // Never the network: a turn nobody judges runs at the default effort.
266
+ effortJudge: options.effortJudge ?? (async () => null),
257
267
  });
258
268
  }
259
269
  function memoryInFlightTurns(initial) {
@@ -1006,6 +1016,39 @@ describe("BridgeJobExecutor", () => {
1006
1016
  ],
1007
1017
  });
1008
1018
  });
1019
+ // Tool results are most of what a turn writes to the prompt cache, and nothing
1020
+ // recorded their size: the turn now reports it next to its model usage.
1021
+ it("reports how much each tool's results put in the context this turn", async () => {
1022
+ const executor = createExecutor({
1023
+ queryResults: [
1024
+ scriptedMessages({
1025
+ type: "assistant",
1026
+ parent_tool_use_id: null,
1027
+ uuid: "assistant_tool",
1028
+ session_id: "session_tools_size",
1029
+ message: {
1030
+ id: "msg_tool",
1031
+ role: "assistant",
1032
+ content: [{ type: "tool_use", id: "tool_1", name: "Bash", input: { command: "baker ads meta ads" } }],
1033
+ },
1034
+ }, {
1035
+ type: "user",
1036
+ parent_tool_use_id: null,
1037
+ uuid: "user_tool_result",
1038
+ session_id: "session_tools_size",
1039
+ message: {
1040
+ role: "user",
1041
+ content: [{ type: "tool_result", tool_use_id: "tool_1", content: "r".repeat(800) }],
1042
+ },
1043
+ }, resultMessage(0.1, "session_tools_size")),
1044
+ ],
1045
+ });
1046
+ const outcome = await executor.execute({ threadId: "thread_tools_size", turnId: "turn_tools_size", prompt: "How are the ads?", history: history([]) }, activeControls());
1047
+ expect(outcome).toMatchObject({
1048
+ kind: "completed",
1049
+ toolResults: [{ tool: "baker ads meta", agent: "main", calls: 1, bytes: 800, tokens: 200 }],
1050
+ });
1051
+ });
1009
1052
  // OpenRouter answers under its own slug. A CLI resuming a transcript that names
1010
1053
  // the slug drops every earlier thinking block from its first request, which
1011
1054
  // breaks the prompt cache right after the system prompt (BAKER-331).
@@ -1288,16 +1331,19 @@ describe("BridgeJobExecutor", () => {
1288
1331
  "x-session-id: thread_env",
1289
1332
  ].join("\n"));
1290
1333
  });
1291
- // OpenRouter has no Bedrock endpoint for a per-message effort, so while the CLI
1292
- // sends one every Opus 5.5 request skips Bedrock and bills OpenRouter credits.
1293
- it("keeps the agent's model requests servable by Bedrock", async () => {
1334
+ // With the capability on, the CLI restates the effort in its mid-conversation
1335
+ // system message, so a turn on another effort level keeps the conversation's
1336
+ // prompt cache. Without it, a changed top-level effort re-writes everything
1337
+ // after the first tool exchange. Pinned on rather than left to the CLI's own
1338
+ // default, which a remote feature gate can switch off.
1339
+ it("turns on per-turn effort, so a turn on another effort level keeps the prompt cache", async () => {
1294
1340
  const queryInputs = [];
1295
1341
  const executor = createExecutor({
1296
1342
  queryInputs,
1297
1343
  queryResults: [scriptedMessages(resultMessage(0, "session_bedrock"))],
1298
1344
  });
1299
1345
  await executor.execute({ threadId: "thread_bedrock", turnId: "turn_bedrock", prompt: "Build a landing", history: history([]) }, activeControls());
1300
- expect(queryInputs[0]?.options.env).toMatchObject({ CLAUDE_CODE_MODEL_CAPABILITIES: "-per_turn_effort" });
1346
+ expect(queryInputs[0]?.options.env).toMatchObject({ CLAUDE_CODE_MODEL_CAPABILITIES: "per_turn_effort" });
1301
1347
  });
1302
1348
  it("silences the CLI task reminders and ANSI colour in the agent subprocess", async () => {
1303
1349
  const queryInputs = [];
@@ -1397,6 +1443,62 @@ describe("BridgeJobExecutor", () => {
1397
1443
  expect(disallowed).not.toContain("Task");
1398
1444
  expect(disallowed).not.toContain("SendMessage");
1399
1445
  });
1446
+ // Developer tools a marketer's chat never calls (0 calls across 358
1447
+ // production chats, Oct 2026). Each definition rides every request a session
1448
+ // makes — DesignSync's alone is ~9k characters — whenever the CLI does not
1449
+ // defer it.
1450
+ it("removes the developer tools a marketer's chat never calls", async () => {
1451
+ const queryInputs = [];
1452
+ const executor = createExecutor({
1453
+ queryInputs,
1454
+ queryResults: [scriptedMessages(resultMessage(0, "session_dev_tools"))],
1455
+ });
1456
+ await executor.execute({ threadId: "thread_dev_tools", turnId: "turn_dev_tools", prompt: "Build a landing", history: history([]) }, activeControls());
1457
+ const disallowed = queryInputs[0]?.options.disallowedTools ?? [];
1458
+ expect(disallowed).toEqual(expect.arrayContaining(["DesignSync", "ReportFindings", "NotebookEdit"]));
1459
+ });
1460
+ // The scaffold's CLAUDE.md is the same text for every company on a scaffold
1461
+ // version, but loaded as a memory file it lands in the first user message with
1462
+ // no cache breakpoint after it, so every chat wrote its ~24k tokens cold. In the
1463
+ // system prompt it ends on a breakpoint and one chat reads another's copy.
1464
+ it("sends the workspace's shared instructions as the system prompt and loads COMPANY.md on its own", async () => {
1465
+ const workspace = await mkdtemp(join(tmpdir(), "baker-job-instructions-"));
1466
+ await writeFile(join(workspace, "CLAUDE.md"), "# Project Instructions\n\n## Client Context\n\nOne client per repo. Everything known about them:\n\n@COMPANY.md\n\n## Rules\n\nStage, don't ask.\n");
1467
+ projectRootMock.value = workspace;
1468
+ const queryInputs = [];
1469
+ const executor = createExecutor({
1470
+ queryInputs,
1471
+ queryResults: [scriptedMessages(resultMessage(0, "session_instructions"))],
1472
+ });
1473
+ await executor.execute({ threadId: "thread_instructions", turnId: "turn_instructions", prompt: "Build a landing", history: history([]) }, activeControls());
1474
+ const options = queryInputs[0]?.options;
1475
+ expect(options.systemPrompt).toEqual({
1476
+ type: "preset",
1477
+ preset: "claude_code",
1478
+ append: "# Project Instructions\n\n## Rules\n\nStage, don't ask.\n",
1479
+ snapshot: true,
1480
+ });
1481
+ expect(options.settings).toMatchObject({ claudeMdExcludes: [join(workspace, "CLAUDE.md")] });
1482
+ expect(options.additionalDirectories).toEqual([instructionsDirMock.value]);
1483
+ expect(options.env?.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD).toBe("1");
1484
+ expect(await readFile(join(instructionsDirMock.value, "CLAUDE.md"), "utf8")).toBe(`@${join(workspace, "COMPANY.md")}\n`);
1485
+ });
1486
+ it("lets the CLI load a CLAUDE.md it cannot split, exactly as before", async () => {
1487
+ const workspace = await mkdtemp(join(tmpdir(), "baker-job-instructions-old-"));
1488
+ await writeFile(join(workspace, "CLAUDE.md"), "# Project Instructions\n\nNo company import.\n");
1489
+ projectRootMock.value = workspace;
1490
+ const queryInputs = [];
1491
+ const executor = createExecutor({
1492
+ queryInputs,
1493
+ queryResults: [scriptedMessages(resultMessage(0, "session_instructions_old"))],
1494
+ });
1495
+ await executor.execute({ threadId: "thread_instructions_old", turnId: "turn_instructions_old", prompt: "Hi", history: history([]) }, activeControls());
1496
+ const options = queryInputs[0]?.options;
1497
+ expect(options.systemPrompt).toBeUndefined();
1498
+ expect(options.additionalDirectories).toBeUndefined();
1499
+ expect(options.settings ?? {}).not.toHaveProperty("claudeMdExcludes");
1500
+ expect(options.env?.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD).toBeUndefined();
1501
+ });
1400
1502
  it("allowlists the workspace's own skills so the CLI's built-in developer skills stay out of the session", async () => {
1401
1503
  const workspace = await mkdtemp(join(tmpdir(), "baker-job-skills-"));
1402
1504
  for (const skill of ["landing", "brand-build", "docx"]) {
@@ -1575,11 +1677,10 @@ describe("BridgeJobExecutor", () => {
1575
1677
  await executor.execute({ threadId: "thread_nobeta", turnId: "turn_nobeta", prompt: "Hi", history: history([]) }, activeControls());
1576
1678
  expect(queryInputs[0]?.options).not.toHaveProperty("betas");
1577
1679
  });
1578
- // Effort governs thinking depth, preamble and tool-call count, and the API
1579
- // default is `high`. A silent drift back to the default costs money twice:
1580
- // more tokens per turn, and — because the resolved effort is rendered into
1581
- // the prompt — a cache-breakpoint invalidation on the turn it changes.
1582
- it("sends a single constant effort so the prompt cache is never invalidated by it", async () => {
1680
+ // Effort governs thinking depth, preamble and tool-call count. A turn nobody
1681
+ // could judge runs at Opus 5.5's own default, set explicitly so an SDK or
1682
+ // model default change cannot move it.
1683
+ it("runs a turn Jev could not judge at medium effort", async () => {
1583
1684
  const queryInputs = [];
1584
1685
  const executor = createExecutor({
1585
1686
  queryInputs,
@@ -1592,8 +1693,57 @@ describe("BridgeJobExecutor", () => {
1592
1693
  await executor.execute({ threadId: "thread_effort", turnId: "turn_effort_1", prompt: "Hi", history: history([]) }, activeControls());
1593
1694
  await executor.execute({ threadId: "thread_effort", turnId: "turn_effort_2", prompt: "Again", history: history([]) }, activeControls());
1594
1695
  expect(queryInputs[0]?.options.effort).toBe("medium");
1595
- // Constant across turns — a per-turn value is what would break the cache.
1596
- expect(queryInputs[1]?.options.effort).toBe(queryInputs[0]?.options.effort);
1696
+ expect(queryInputs[1]?.options.effort).toBe("medium");
1697
+ });
1698
+ it("runs a turn Jev clearly reads as small at low effort, and reports why", async () => {
1699
+ const queryInputs = [];
1700
+ const asked = [];
1701
+ const executor = createExecutor({
1702
+ queryInputs,
1703
+ effortJudge: async (questions) => {
1704
+ asked.push(...questions);
1705
+ return [0.92, 0.05];
1706
+ },
1707
+ queryResults: [scriptedMessages(resultMessage(0, "session_effort_low"))],
1708
+ });
1709
+ const outcome = await executor.execute({
1710
+ threadId: "thread_effort_low",
1711
+ turnId: "turn_effort_low",
1712
+ prompt: "¿Cuál fue el CPL de ayer?",
1713
+ history: history([
1714
+ { role: "user", text: "Revisa la campaña de verano", createdAt: 1 },
1715
+ { role: "assistant", text: "Listo: la campaña de verano va bien.", createdAt: 2 },
1716
+ ]),
1717
+ }, activeControls());
1718
+ expect(queryInputs[0]?.options.effort).toBe("low");
1719
+ expect(asked[0]).toMatchObject({
1720
+ instructions: { message: "¿Cuál fue el CPL de ayer?", previous_reply: "Listo: la campaña de verano va bien." },
1721
+ });
1722
+ expect(outcome).toMatchObject({
1723
+ kind: "completed",
1724
+ effortDecision: { effort: "low", reason: "small", small: 0.92, complex: 0.05 },
1725
+ });
1726
+ });
1727
+ it("keeps a plan-mode turn at medium without asking Jev", async () => {
1728
+ const queryInputs = [];
1729
+ let asked = false;
1730
+ const executor = createExecutor({
1731
+ queryInputs,
1732
+ effortJudge: async () => {
1733
+ asked = true;
1734
+ return [0.99, 0];
1735
+ },
1736
+ queryResults: [scriptedMessages(resultMessage(0, "session_effort_plan"))],
1737
+ });
1738
+ await executor.execute({
1739
+ threadId: "thread_effort_plan",
1740
+ turnId: "turn_effort_plan",
1741
+ prompt: "Plan Q4",
1742
+ planMode: true,
1743
+ history: history([]),
1744
+ }, activeControls());
1745
+ expect(queryInputs[0]?.options.effort).toBe("medium");
1746
+ expect(asked).toBe(false);
1597
1747
  });
1598
1748
  it("pins the auto-compact window to the context window it resolved for the model", async () => {
1599
1749
  const queryInputs = [];
@@ -3880,6 +4030,8 @@ describe("BridgeJobExecutor stall recovery", () => {
3880
4030
  kind: "completed",
3881
4031
  costUsd: 0.05,
3882
4032
  model: "claude-test",
4033
+ // The tool result the stalled query had already received still counts.
4034
+ toolResults: [expect.objectContaining({ calls: 1 })],
3883
4035
  cleanBoundary: expect.objectContaining({ sessionId: "session_a" }),
3884
4036
  });
3885
4037
  expect(queryInputs).toHaveLength(2);
@@ -4618,6 +4770,9 @@ describe("BridgeJobExecutor turn end with background tasks", () => {
4618
4770
  });
4619
4771
  });
4620
4772
  describe("BridgeJobExecutor review mode", () => {
4773
+ afterEach(() => {
4774
+ projectRootMock.value = process.cwd();
4775
+ });
4621
4776
  const review = {
4622
4777
  reviewId: "rev_1",
4623
4778
  apiKey: "bk_review_key",
@@ -4675,6 +4830,21 @@ describe("BridgeJobExecutor review mode", () => {
4675
4830
  await executor.execute(reviewJob(), activeControls());
4676
4831
  expect(queryInputs[0]?.options.settings).toMatchObject({ permissions: { ask: ["Bash"] } });
4677
4832
  });
4833
+ // The brief has the reviewer read the rulebook as the Session had it
4834
+ // (`git show <sha>:CLAUDE.md`); the working tree's copy may already be newer.
4835
+ // Auto-loading it put ~24k tokens of the marketer's instructions in front of
4836
+ // every review request, written cold and re-read on each one.
4837
+ it("does not load the workspace's CLAUDE.md into the reviewer, as a memory file or as the system prompt", async () => {
4838
+ const workspace = await mkdtemp(join(tmpdir(), "baker-review-instructions-"));
4839
+ await writeFile(join(workspace, "CLAUDE.md"), "# Project Instructions\n\n## Client Context\n\nOne client per repo. Everything known about them:\n\n@COMPANY.md\n\n## Rules\n");
4840
+ projectRootMock.value = workspace;
4841
+ const queryInputs = [];
4842
+ const executor = createExecutor({ queryInputs });
4843
+ await executor.execute(reviewJob(), activeControls());
4844
+ expect(queryInputs[0]?.options.settings).toMatchObject({ claudeMdExcludes: ["**/CLAUDE.md"] });
4845
+ expect(queryInputs[0]?.options.systemPrompt).toBeUndefined();
4846
+ expect(queryInputs[0]?.options.additionalDirectories).toBeUndefined();
4847
+ });
4678
4848
  it("removes every editing tool on top of the list an ordinary turn is denied", async () => {
4679
4849
  const queryInputs = [];
4680
4850
  const executor = createExecutor({ queryInputs });
@@ -4738,6 +4908,7 @@ describe("BridgeJobExecutor review mode", () => {
4738
4908
  expect(options.env?.BAKER_DEBUG_LOG).toBeUndefined();
4739
4909
  expect(options.settings ?? {}).not.toHaveProperty("disableAllHooks");
4740
4910
  expect(options.settings ?? {}).not.toHaveProperty("permissions");
4911
+ expect(options.settings ?? {}).not.toHaveProperty("claudeMdExcludes");
4741
4912
  });
4742
4913
  // BAKER-3462: after an English skill body, the next line went out in English to a Spanish chat.
4743
4914
  const spanishTurn = "Prepara cuatro anuncios nuevos para la campaña de verano y súbelos hoy mismo";