@orquentra/codebuddy 2.6.2 → 2.8.0

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 (115) hide show
  1. package/README.md +10 -0
  2. package/dist/adapters-S4HHTWJW.js +24 -0
  3. package/dist/{chunk-IBLJOHJL.js → chunk-3QGW53GD.js} +2 -2
  4. package/dist/{chunk-IMFUYEZA.js → chunk-6DONJW3W.js} +232 -64
  5. package/dist/chunk-6DONJW3W.js.map +1 -0
  6. package/dist/{chunk-JFDRBPYF.js → chunk-6JCKMIQY.js} +2 -2
  7. package/dist/{chunk-S6VEG4S2.js → chunk-6KGYAFG7.js} +28 -5
  8. package/dist/chunk-6KGYAFG7.js.map +1 -0
  9. package/dist/{chunk-OWWCDRXW.js → chunk-6NZEZJYD.js} +9 -7
  10. package/dist/chunk-6NZEZJYD.js.map +1 -0
  11. package/dist/{chunk-GDJH7XJC.js → chunk-AL6VBTNA.js} +77 -3
  12. package/dist/chunk-AL6VBTNA.js.map +1 -0
  13. package/dist/{chunk-XBJQKCLU.js → chunk-CAEPHLHC.js} +13 -9
  14. package/dist/chunk-CAEPHLHC.js.map +1 -0
  15. package/dist/chunk-CG5B3LYG.js +66 -0
  16. package/dist/chunk-CG5B3LYG.js.map +1 -0
  17. package/dist/{chunk-IHQM2ZDV.js → chunk-DFV2SK6C.js} +1 -1
  18. package/dist/{chunk-IHQM2ZDV.js.map → chunk-DFV2SK6C.js.map} +1 -1
  19. package/dist/{chunk-QBDJXGG5.js → chunk-EJQVGFH2.js} +93 -22
  20. package/dist/chunk-EJQVGFH2.js.map +1 -0
  21. package/dist/{chunk-5MQOE4J6.js → chunk-ESZXCZIN.js} +3 -3
  22. package/dist/{chunk-J2EAZ4MA.js → chunk-GKNYMNFM.js} +22 -25
  23. package/dist/chunk-GKNYMNFM.js.map +1 -0
  24. package/dist/{chunk-5FW3PMNP.js → chunk-GXOML4UZ.js} +76 -11
  25. package/dist/chunk-GXOML4UZ.js.map +1 -0
  26. package/dist/{chunk-JPHJBCZB.js → chunk-HPOR6YXA.js} +3 -2
  27. package/dist/chunk-HPOR6YXA.js.map +1 -0
  28. package/dist/chunk-ITQO632W.js +673 -0
  29. package/dist/chunk-ITQO632W.js.map +1 -0
  30. package/dist/chunk-J4T6HHI4.js +1444 -0
  31. package/dist/chunk-J4T6HHI4.js.map +1 -0
  32. package/dist/{chunk-5KZY6OTG.js → chunk-KNR2P2UY.js} +33 -83
  33. package/dist/chunk-KNR2P2UY.js.map +1 -0
  34. package/dist/{chunk-2XDPKFUW.js → chunk-NY2YXP3R.js} +46 -20
  35. package/dist/chunk-NY2YXP3R.js.map +1 -0
  36. package/dist/{chunk-RAJJSCIP.js → chunk-SFMIMLXR.js} +2 -2
  37. package/dist/chunk-TGFTKWOR.js +16 -0
  38. package/dist/chunk-TGFTKWOR.js.map +1 -0
  39. package/dist/{chunk-NERKZSSE.js → chunk-TLWGZVPC.js} +395 -686
  40. package/dist/chunk-TLWGZVPC.js.map +1 -0
  41. package/dist/{chunk-B6CO63SQ.js → chunk-UK6XTS2Y.js} +24 -10
  42. package/dist/chunk-UK6XTS2Y.js.map +1 -0
  43. package/dist/chunk-W7GAXL6C.js +104 -0
  44. package/dist/chunk-W7GAXL6C.js.map +1 -0
  45. package/dist/chunk-XL2HLACE.js +55 -0
  46. package/dist/chunk-XL2HLACE.js.map +1 -0
  47. package/dist/chunk-Y7D7PUBE.js +321 -0
  48. package/dist/chunk-Y7D7PUBE.js.map +1 -0
  49. package/dist/{chunk-R7CD6EXS.js → chunk-ZNLSCV3J.js} +578 -381
  50. package/dist/chunk-ZNLSCV3J.js.map +1 -0
  51. package/dist/cli/index.js +5 -5
  52. package/dist/cli/index.js.map +1 -1
  53. package/dist/{cloud-7WM3YLI4.js → cloud-LXZUR3U2.js} +3 -3
  54. package/dist/{codebuddy-B_pCaQrV.d.ts → codebuddy-DO7ZhoWn.d.ts} +15 -1
  55. package/dist/config-GEZFLXK6.js +12 -0
  56. package/dist/index.d.ts +46 -3
  57. package/dist/index.js +23 -19
  58. package/dist/install-BXM56QCD.js +19 -0
  59. package/dist/langgraph/index.d.ts +1 -1
  60. package/dist/lean-command-7DFW32MA.js +19 -0
  61. package/dist/{local-5MZURLCZ.js → local-ST4WMRCG.js} +3 -3
  62. package/dist/{main-MTBQZGJV.js → main-QUASSFP7.js} +987 -1741
  63. package/dist/main-QUASSFP7.js.map +1 -0
  64. package/dist/{memory-KIC5FJVW.js → memory-TGHVTBME.js} +9 -8
  65. package/dist/memory-TGHVTBME.js.map +1 -0
  66. package/dist/operations-WJR7AZZF.js +26 -0
  67. package/dist/pretool-command-JTKX6G3M.js +13 -0
  68. package/dist/{rerank-5QZ3PNUW.js → rerank-IQAEWLQR.js} +6 -5
  69. package/dist/rerank-IQAEWLQR.js.map +1 -0
  70. package/dist/{runtime-CUBOOPZO.js → runtime-OQQ22VJF.js} +15 -11
  71. package/dist/summaries-ML53R7EL.js +21 -0
  72. package/dist/summaries-ML53R7EL.js.map +1 -0
  73. package/dist/{zvec-MSRKT7IV.js → zvec-XDZNZ2OL.js} +11 -3
  74. package/dist/zvec-XDZNZ2OL.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/db/migrations/0006_fact_idempotency_key.sql +2 -0
  77. package/src/db/migrations/meta/0006_snapshot.json +1258 -0
  78. package/src/db/migrations/meta/_journal.json +7 -0
  79. package/dist/chunk-2XDPKFUW.js.map +0 -1
  80. package/dist/chunk-5FW3PMNP.js.map +0 -1
  81. package/dist/chunk-5KZY6OTG.js.map +0 -1
  82. package/dist/chunk-B6CO63SQ.js.map +0 -1
  83. package/dist/chunk-GDJH7XJC.js.map +0 -1
  84. package/dist/chunk-IMFUYEZA.js.map +0 -1
  85. package/dist/chunk-J2EAZ4MA.js.map +0 -1
  86. package/dist/chunk-JPHJBCZB.js.map +0 -1
  87. package/dist/chunk-NERKZSSE.js.map +0 -1
  88. package/dist/chunk-OWWCDRXW.js.map +0 -1
  89. package/dist/chunk-PQK3GKZH.js +0 -98
  90. package/dist/chunk-PQK3GKZH.js.map +0 -1
  91. package/dist/chunk-QBDJXGG5.js.map +0 -1
  92. package/dist/chunk-R7CD6EXS.js.map +0 -1
  93. package/dist/chunk-S6VEG4S2.js.map +0 -1
  94. package/dist/chunk-XBJQKCLU.js.map +0 -1
  95. package/dist/config-DEVHUDIU.js +0 -11
  96. package/dist/lean-command-HTQCPH76.js +0 -15
  97. package/dist/main-MTBQZGJV.js.map +0 -1
  98. package/dist/memory-KIC5FJVW.js.map +0 -1
  99. package/dist/operations-POCHY3WR.js +0 -25
  100. package/dist/pretool-command-AX6JXP25.js +0 -10
  101. package/dist/rerank-5QZ3PNUW.js.map +0 -1
  102. package/dist/summaries-SB5L2TSW.js +0 -18
  103. /package/dist/{cloud-7WM3YLI4.js.map → adapters-S4HHTWJW.js.map} +0 -0
  104. /package/dist/{chunk-IBLJOHJL.js.map → chunk-3QGW53GD.js.map} +0 -0
  105. /package/dist/{chunk-JFDRBPYF.js.map → chunk-6JCKMIQY.js.map} +0 -0
  106. /package/dist/{chunk-5MQOE4J6.js.map → chunk-ESZXCZIN.js.map} +0 -0
  107. /package/dist/{chunk-RAJJSCIP.js.map → chunk-SFMIMLXR.js.map} +0 -0
  108. /package/dist/{config-DEVHUDIU.js.map → cloud-LXZUR3U2.js.map} +0 -0
  109. /package/dist/{lean-command-HTQCPH76.js.map → config-GEZFLXK6.js.map} +0 -0
  110. /package/dist/{local-5MZURLCZ.js.map → install-BXM56QCD.js.map} +0 -0
  111. /package/dist/{operations-POCHY3WR.js.map → lean-command-7DFW32MA.js.map} +0 -0
  112. /package/dist/{pretool-command-AX6JXP25.js.map → local-ST4WMRCG.js.map} +0 -0
  113. /package/dist/{runtime-CUBOOPZO.js.map → operations-WJR7AZZF.js.map} +0 -0
  114. /package/dist/{summaries-SB5L2TSW.js.map → pretool-command-JTKX6G3M.js.map} +0 -0
  115. /package/dist/{zvec-MSRKT7IV.js.map → runtime-OQQ22VJF.js.map} +0 -0
@@ -3,10 +3,16 @@ import {
3
3
  readHookInput,
4
4
  writeStdout
5
5
  } from "./chunk-FJOTP632.js";
6
+ import {
7
+ recordTrace
8
+ } from "./chunk-XL2HLACE.js";
9
+ import {
10
+ hookRoot
11
+ } from "./chunk-TGFTKWOR.js";
6
12
  import {
7
13
  extractSymbolTable,
8
14
  supportsSymbols
9
- } from "./chunk-PQK3GKZH.js";
15
+ } from "./chunk-Y7D7PUBE.js";
10
16
 
11
17
  // src/hooks/pretool.ts
12
18
  import { open, stat } from "fs/promises";
@@ -39,6 +45,7 @@ async function decidePreTool(input, env = process.env) {
39
45
  const toolInput = input.tool_input ?? {};
40
46
  if (input.tool_name === "Grep") return capGrep(toolInput);
41
47
  if (input.tool_name === "Read") return guardRead(resolve(input.cwd ?? process.cwd()), toolInput);
48
+ if (input.tool_name === "Bash") return guardBash(resolve(input.cwd ?? process.cwd()), toolInput);
42
49
  return null;
43
50
  }
44
51
  function capGrep(toolInput) {
@@ -50,10 +57,7 @@ function capGrep(toolInput) {
50
57
  }
51
58
  };
52
59
  }
53
- async function guardRead(root, toolInput) {
54
- if (toolInput.offset !== void 0 || toolInput.limit !== void 0) return null;
55
- const filePath = toolInput.file_path;
56
- if (typeof filePath !== "string" || !filePath) return null;
60
+ async function largeFile(root, filePath) {
57
61
  const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
58
62
  const rel = relative(root, absolute);
59
63
  if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
@@ -73,21 +77,71 @@ async function guardRead(root, toolInput) {
73
77
  if (lines.at(-1) === "") lines.pop();
74
78
  if (lines.length <= TOOL_GUARD.readLines && size <= TOOL_GUARD.readBytes) return null;
75
79
  const path = rel.replace(/\\/g, "/");
76
- const outline = outlineOf(path, text, lines);
80
+ return { path, lines: lines.length, size, outline: outlineOf(path, text, lines) };
81
+ }
82
+ async function guardRead(root, toolInput) {
83
+ if (toolInput.offset !== void 0 || toolInput.limit !== void 0) return null;
84
+ const filePath = toolInput.file_path;
85
+ if (typeof filePath !== "string" || !filePath) return null;
86
+ const file = await largeFile(root, filePath);
87
+ if (!file) return null;
88
+ return deny(file, [
89
+ "Read only the part you need with offset and limit (offset = first line, limit = line count).",
90
+ "To find specific text, use Grep with output_mode content and -n instead of reading the file.",
91
+ `To read the whole file anyway, call Read again with limit: ${file.lines}.`
92
+ ]);
93
+ }
94
+ function deny(file, advice) {
77
95
  return {
78
96
  hookSpecificOutput: {
79
97
  hookEventName: "PreToolUse",
80
98
  permissionDecision: "deny",
81
99
  permissionDecisionReason: [
82
- `CodeBuddy read guard: ${path} is ${lines.length} lines (~${Math.ceil(size / 4)} tokens), too large to read whole.`,
83
- `Outline: ${outline.join("; ")}`,
84
- "Read only the part you need with offset and limit (offset = first line, limit = line count).",
85
- "To find specific text, use Grep with output_mode content and -n instead of reading the file.",
86
- `To read the whole file anyway, call Read again with limit: ${lines.length}.`
100
+ `CodeBuddy read guard: ${file.path} is ${file.lines} lines (~${Math.ceil(file.size / 4)} tokens), too large to read whole.`,
101
+ `Outline: ${file.outline.join("; ")}`,
102
+ ...advice
87
103
  ].join("\n")
88
104
  }
89
105
  };
90
106
  }
107
+ var WHOLE_FILE_PRINTERS = /* @__PURE__ */ new Set(["cat", "less", "more", "nl", "bat", "batcat"]);
108
+ var SHELL_SYNTAX = /[|;&<>`$(){}*?[\]\n\\]/;
109
+ async function guardBash(root, toolInput) {
110
+ const command = typeof toolInput.command === "string" ? toolInput.command.trim() : "";
111
+ if (!command || SHELL_SYNTAX.test(command)) return null;
112
+ const tokens = (command.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(
113
+ (token) => token.replace(/^(["'])(.*)\1$/, "$2")
114
+ );
115
+ while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
116
+ const program = (tokens.shift() ?? "").split("/").at(-1) ?? "";
117
+ const files = [];
118
+ if (WHOLE_FILE_PRINTERS.has(program)) {
119
+ for (const token of tokens) if (!token.startsWith("-")) files.push(token);
120
+ } else if (program === "head" || program === "tail") {
121
+ let lines = 10;
122
+ for (let index = 0; index < tokens.length; index += 1) {
123
+ const token = tokens[index] ?? "";
124
+ const count = /^-n$|^--lines$/.test(token) ? tokens[++index] : token.match(/^(?:-n|--lines=)\+?(\d+)$/)?.[1] ?? token.match(/^-(\d+)$/)?.[1];
125
+ if (count !== void 0 && /^\+?\d+$/.test(count)) lines = Number(count.replace("+", ""));
126
+ else if (token === "-f" || token === "-F" || token === "--follow") return null;
127
+ else if (!token.startsWith("-")) files.push(token);
128
+ }
129
+ if (lines <= TOOL_GUARD.readLines) return null;
130
+ } else {
131
+ return null;
132
+ }
133
+ for (const filePath of files) {
134
+ const file = await largeFile(root, filePath);
135
+ if (file) {
136
+ return deny(file, [
137
+ "Read only the part you need with the Read tool (offset = first line, limit = line count) or `sed -n 'START,ENDp' FILE`.",
138
+ "To find specific text, use Grep with output_mode content and -n instead of printing the file.",
139
+ `To read the whole file anyway, use Read with limit: ${file.lines}.`
140
+ ]);
141
+ }
142
+ }
143
+ return null;
144
+ }
91
145
  async function readText(path) {
92
146
  const handle = await open(path, "r");
93
147
  try {
@@ -103,8 +157,12 @@ function outlineOf(path, text, lines) {
103
157
  ...entries.slice(0, TOOL_GUARD.outlineEntries),
104
158
  `\u2026 ${entries.length - TOOL_GUARD.outlineEntries} more`
105
159
  ] : entries;
106
- if (supportsSymbols(path)) {
107
- const table = extractSymbolTable(text).filter(
160
+ const all = supportsSymbols(path) ? extractSymbolTable(text, path) : [];
161
+ if (all[0]?.kind === "heading") {
162
+ return cap(all.map((heading) => `${heading.signature.slice(0, 80)} L${heading.startLine}`));
163
+ }
164
+ if (all.length > 0) {
165
+ const table = all.filter(
108
166
  (symbol) => symbol.endLine - symbol.startLine + 1 >= TOOL_GUARD.outlineMinSymbolLines
109
167
  );
110
168
  const topLevel = table.filter((symbol) => symbol.kind !== "method");
@@ -117,12 +175,6 @@ function outlineOf(path, text, lines) {
117
175
  ];
118
176
  }
119
177
  }
120
- if (/\.mdx?$/i.test(path)) {
121
- const headings = lines.flatMap(
122
- (line, i) => /^#{1,4}\s+\S/.test(line) ? [`${line.trim().slice(0, 80)} L${i + 1}`] : []
123
- );
124
- if (headings.length > 0) return cap(headings);
125
- }
126
178
  const blocks = [];
127
179
  for (let start = 1; start <= lines.length; start += TOOL_GUARD.blockLines) {
128
180
  blocks.push(`L${start}-${Math.min(lines.length, start + TOOL_GUARD.blockLines - 1)}`);
@@ -133,12 +185,31 @@ function outlineOf(path, text, lines) {
133
185
  // src/hooks/pretool-command.ts
134
186
  async function runPreToolHookCommand() {
135
187
  await failSoft(async () => {
136
- const decision = await decidePreTool(await readHookInput());
137
- if (decision) await writeStdout(JSON.stringify(decision));
188
+ const input = await readHookInput();
189
+ const decision = await decidePreTool(input);
190
+ if (!decision) return;
191
+ await writeStdout(JSON.stringify(decision));
192
+ const reason = decision.hookSpecificOutput.permissionDecisionReason;
193
+ if (decision.hookSpecificOutput.permissionDecision === "deny" && reason) {
194
+ const full = Number(reason.match(/\(~(\d+) tokens\)/)?.[1] ?? 0);
195
+ const outline = Math.ceil(reason.length / 4);
196
+ await recordTrace(hookRoot(input.cwd), {
197
+ kind: "guard",
198
+ name: input.tool_name ?? "tool",
199
+ ms: 0,
200
+ ok: true,
201
+ tokensOut: outline,
202
+ ...input.session_id ? { session: input.session_id } : {},
203
+ detail: {
204
+ file: reason.match(/read guard: (\S+) is/)?.[1] ?? "",
205
+ avoided: Math.max(0, full - outline)
206
+ }
207
+ });
208
+ }
138
209
  });
139
210
  }
140
211
 
141
212
  export {
142
213
  runPreToolHookCommand
143
214
  };
144
- //# sourceMappingURL=chunk-QBDJXGG5.js.map
215
+ //# sourceMappingURL=chunk-EJQVGFH2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/pretool.ts","../src/hooks/pretool-command.ts"],"sourcesContent":["import { open, stat } from \"node:fs/promises\";\nimport { extname, isAbsolute, relative, resolve, sep } from \"node:path\";\nimport { extractSymbolTable, supportsSymbols } from \"../indexer/symbols.js\";\nimport type { HookInput } from \"./runtime.js\";\n\n/**\n * PreToolUse tool guard (Claude Code): cut tool output that would otherwise\n * stay in context for the rest of the session, without touching permissions.\n *\n * - Read of a large file with no range → denied with an outline (symbols or\n * headings with line ranges) and how to read a range or the whole file.\n * The model sees the deny reason; any Read with offset/limit passes.\n * - Grep in content mode with no head_limit → the same input plus a limit.\n * - Bash that only prints a large file whole (`cat`, `less`, `head -n 5000`) →\n * denied the same way; any other command (pipes, builds, tests) passes.\n *\n * Never emits `permissionDecision: \"allow\"`: Claude Code checks permissions on\n * the (possibly rewritten) input as usual. Verified on Claude Code 2.1.287.\n */\n/**\n * Only files whose full read is clearly expensive: below this, an agent that\n * re-reads the whole file after the outline (observed live) costs a turn for\n * nothing.\n */\nexport const TOOL_GUARD = {\n readLines: 1_000,\n readBytes: 80_000,\n /** Symbols shorter than this are noise in an outline (one-line constants). */\n outlineMinSymbolLines: 3,\n /** Fewer top-level symbols than this: list class methods too. */\n minTopLevelEntries: 10,\n outlineEntries: 40,\n blockLines: 100,\n grepHeadLimit: 100,\n} as const;\n\n/** Read renders these itself (images, PDFs, notebooks); never guard them. */\nconst SPECIAL_READ = new Set([\n \".png\",\n \".jpg\",\n \".jpeg\",\n \".gif\",\n \".webp\",\n \".bmp\",\n \".svg\",\n \".pdf\",\n \".ipynb\",\n]);\nconst MAX_GUARD_BYTES = 2_000_000;\n\nexport type PreToolDecision = {\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\";\n permissionDecision?: \"deny\";\n permissionDecisionReason?: string;\n updatedInput?: Record<string, unknown>;\n };\n} | null;\n\nexport async function decidePreTool(\n input: HookInput,\n env: NodeJS.ProcessEnv = process.env,\n): Promise<PreToolDecision> {\n if (env.CODEBUDDY_TOOL_GUARD === \"0\") return null;\n const toolInput = input.tool_input ?? {};\n if (input.tool_name === \"Grep\") return capGrep(toolInput);\n if (input.tool_name === \"Read\") return guardRead(resolve(input.cwd ?? process.cwd()), toolInput);\n if (input.tool_name === \"Bash\") return guardBash(resolve(input.cwd ?? process.cwd()), toolInput);\n return null;\n}\n\nfunction capGrep(toolInput: Record<string, unknown>): PreToolDecision {\n if (toolInput.output_mode !== \"content\" || toolInput.head_limit !== undefined) return null;\n return {\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\",\n updatedInput: { ...toolInput, head_limit: TOOL_GUARD.grepHeadLimit },\n },\n };\n}\n\n/** A file inside the repository too large to read whole, with its outline; else null. */\nexport async function largeFile(\n root: string,\n filePath: string,\n): Promise<{ path: string; lines: number; size: number; outline: string[] } | null> {\n const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);\n const rel = relative(root, absolute);\n if (rel === \"\" || rel === \"..\" || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;\n if (SPECIAL_READ.has(extname(absolute).toLowerCase())) return null;\n let size: number;\n try {\n const info = await stat(absolute);\n if (!info.isFile()) return null;\n size = info.size;\n } catch {\n return null;\n }\n if (size > MAX_GUARD_BYTES) return null; // Read refuses huge files itself.\n const text = await readText(absolute);\n if (text === null) return null;\n const lines = text.split(/\\r?\\n/);\n if (lines.at(-1) === \"\") lines.pop();\n if (lines.length <= TOOL_GUARD.readLines && size <= TOOL_GUARD.readBytes) return null;\n const path = rel.replace(/\\\\/g, \"/\");\n return { path, lines: lines.length, size, outline: outlineOf(path, text, lines) };\n}\n\nasync function guardRead(\n root: string,\n toolInput: Record<string, unknown>,\n): Promise<PreToolDecision> {\n if (toolInput.offset !== undefined || toolInput.limit !== undefined) return null;\n const filePath = toolInput.file_path;\n if (typeof filePath !== \"string\" || !filePath) return null;\n const file = await largeFile(root, filePath);\n if (!file) return null;\n return deny(file, [\n \"Read only the part you need with offset and limit (offset = first line, limit = line count).\",\n \"To find specific text, use Grep with output_mode content and -n instead of reading the file.\",\n `To read the whole file anyway, call Read again with limit: ${file.lines}.`,\n ]);\n}\n\nfunction deny(\n file: { path: string; lines: number; size: number; outline: string[] },\n advice: string[],\n): PreToolDecision {\n return {\n hookSpecificOutput: {\n hookEventName: \"PreToolUse\",\n permissionDecision: \"deny\",\n permissionDecisionReason: [\n `CodeBuddy read guard: ${file.path} is ${file.lines} lines (~${Math.ceil(file.size / 4)} tokens), too large to read whole.`,\n `Outline: ${file.outline.join(\"; \")}`,\n ...advice,\n ].join(\"\\n\"),\n },\n };\n}\n\n/** Programs that print whole files; `head`/`tail` only when asked for more lines than the guard. */\nconst WHOLE_FILE_PRINTERS = new Set([\"cat\", \"less\", \"more\", \"nl\", \"bat\", \"batcat\"]);\n/**\n * Anything that makes a command more than one plain read: pipes, lists,\n * redirection, substitution, globbing over many files. Such commands are never\n * touched, so builds, tests, pipelines and scripts behave exactly as before.\n */\nconst SHELL_SYNTAX = /[|;&<>`$(){}*?[\\]\\n\\\\]/;\n\n/**\n * Bash that would print a large file whole (`cat src/big.ts`, `head -n 5000 x`).\n * Claude reads files through Bash to get around the Read guard; the output then\n * stays in context for the rest of the session. Only a single plain read\n * command of a file over the Read guard's size is denied, with the same outline\n * and how to read a range; every other command passes untouched.\n */\nasync function guardBash(\n root: string,\n toolInput: Record<string, unknown>,\n): Promise<PreToolDecision> {\n const command = typeof toolInput.command === \"string\" ? toolInput.command.trim() : \"\";\n if (!command || SHELL_SYNTAX.test(command)) return null;\n const tokens = (command.match(/\"[^\"]*\"|'[^']*'|\\S+/g) ?? []).map((token) =>\n token.replace(/^([\"'])(.*)\\1$/, \"$2\"),\n );\n // Leading `VAR=value` assignments do not change what runs.\n while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();\n const program = (tokens.shift() ?? \"\").split(\"/\").at(-1) ?? \"\";\n const files: string[] = [];\n if (WHOLE_FILE_PRINTERS.has(program)) {\n for (const token of tokens) if (!token.startsWith(\"-\")) files.push(token);\n } else if (program === \"head\" || program === \"tail\") {\n let lines = 10;\n for (let index = 0; index < tokens.length; index += 1) {\n const token = tokens[index] ?? \"\";\n const count = /^-n$|^--lines$/.test(token)\n ? tokens[++index]\n : (token.match(/^(?:-n|--lines=)\\+?(\\d+)$/)?.[1] ?? token.match(/^-(\\d+)$/)?.[1]);\n if (count !== undefined && /^\\+?\\d+$/.test(count)) lines = Number(count.replace(\"+\", \"\"));\n else if (token === \"-f\" || token === \"-F\" || token === \"--follow\") return null;\n else if (!token.startsWith(\"-\")) files.push(token);\n }\n if (lines <= TOOL_GUARD.readLines) return null;\n } else {\n return null;\n }\n for (const filePath of files) {\n const file = await largeFile(root, filePath);\n if (file) {\n return deny(file, [\n \"Read only the part you need with the Read tool (offset = first line, limit = line count) or `sed -n 'START,ENDp' FILE`.\",\n \"To find specific text, use Grep with output_mode content and -n instead of printing the file.\",\n `To read the whole file anyway, use Read with limit: ${file.lines}.`,\n ]);\n }\n }\n return null;\n}\n\n/** Whole file as UTF-8 text, or null when it looks binary. */\nasync function readText(path: string): Promise<string | null> {\n const handle = await open(path, \"r\");\n try {\n const content = await handle.readFile();\n if (content.includes(0)) return null;\n return content.toString(\"utf8\");\n } finally {\n await handle.close();\n }\n}\n\nfunction outlineOf(path: string, text: string, lines: string[]): string[] {\n const cap = (entries: string[]) =>\n entries.length > TOOL_GUARD.outlineEntries\n ? [\n ...entries.slice(0, TOOL_GUARD.outlineEntries),\n `… ${entries.length - TOOL_GUARD.outlineEntries} more`,\n ]\n : entries;\n const all = supportsSymbols(path) ? extractSymbolTable(text, path) : [];\n if (all[0]?.kind === \"heading\") {\n return cap(all.map((heading) => `${heading.signature.slice(0, 80)} L${heading.startLine}`));\n }\n if (all.length > 0) {\n const table = all.filter(\n (symbol) => symbol.endLine - symbol.startLine + 1 >= TOOL_GUARD.outlineMinSymbolLines,\n );\n const topLevel = table.filter((symbol) => symbol.kind !== \"method\");\n // One big class says little on its own; then list its methods (`Class.method`).\n const symbols = topLevel.length >= TOOL_GUARD.minTopLevelEntries ? topLevel : table;\n if (symbols.length > 0) {\n // The symbol table is capped, so say where the outline stops covering the file.\n const covered = Math.max(...symbols.map((symbol) => symbol.endLine));\n return [\n ...cap(symbols.map((s) => `${s.name} L${s.startLine}-${s.endLine}`)),\n ...(covered < lines.length ? [`not outlined L${covered + 1}-${lines.length}`] : []),\n ];\n }\n }\n const blocks: string[] = [];\n for (let start = 1; start <= lines.length; start += TOOL_GUARD.blockLines) {\n blocks.push(`L${start}-${Math.min(lines.length, start + TOOL_GUARD.blockLines - 1)}`);\n }\n return cap(blocks);\n}\n","import { recordTrace } from \"../tracing/log.js\";\nimport { decidePreTool } from \"./pretool.js\";\nimport { hookRoot } from \"./root.js\";\nimport { failSoft, readHookInput, writeStdout } from \"./stdio.js\";\n\n/** `codebuddy hooks pretool`: runs on every Read/Grep, so it loads only its own module. */\nexport async function runPreToolHookCommand(): Promise<void> {\n await failSoft(async () => {\n const input = await readHookInput();\n const decision = await decidePreTool(input);\n if (!decision) return;\n await writeStdout(JSON.stringify(decision));\n const reason = decision.hookSpecificOutput.permissionDecisionReason;\n if (decision.hookSpecificOutput.permissionDecision === \"deny\" && reason) {\n // Measured, not modelled: the file the agent did not load whole, minus the\n // outline it got instead. Shown by `codebuddy dashboard`.\n const full = Number(reason.match(/\\(~(\\d+) tokens\\)/)?.[1] ?? 0);\n const outline = Math.ceil(reason.length / 4);\n await recordTrace(hookRoot(input.cwd), {\n kind: \"guard\",\n name: input.tool_name ?? \"tool\",\n ms: 0,\n ok: true,\n tokensOut: outline,\n ...(input.session_id ? { session: input.session_id } : {}),\n detail: {\n file: reason.match(/read guard: (\\S+) is/)?.[1] ?? \"\",\n avoided: Math.max(0, full - outline),\n },\n });\n }\n });\n}\n"],"mappings":"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","names":[]}
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  redactLlmKeys
3
- } from "./chunk-RAJJSCIP.js";
3
+ } from "./chunk-SFMIMLXR.js";
4
4
  import {
5
5
  writeAtomic
6
- } from "./chunk-5KZY6OTG.js";
6
+ } from "./chunk-CG5B3LYG.js";
7
7
 
8
8
  // src/llm/budget.ts
9
9
  import { mkdir, readFile } from "fs/promises";
@@ -196,4 +196,4 @@ export {
196
196
  coolingDown,
197
197
  chatJson
198
198
  };
199
- //# sourceMappingURL=chunk-5MQOE4J6.js.map
199
+ //# sourceMappingURL=chunk-ESZXCZIN.js.map
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  countTokens,
3
3
  generateEntityId
4
- } from "./chunk-XBJQKCLU.js";
4
+ } from "./chunk-CAEPHLHC.js";
5
5
  import {
6
6
  writeAtomic
7
- } from "./chunk-5KZY6OTG.js";
7
+ } from "./chunk-CG5B3LYG.js";
8
8
 
9
9
  // src/savings/tokens.ts
10
10
  function estimateTokens(value) {
@@ -262,32 +262,26 @@ function digest(text) {
262
262
  return createHash("sha256").update(text).digest("hex");
263
263
  }
264
264
 
265
- // src/hooks/root.ts
266
- import { existsSync } from "fs";
267
- import { dirname, join as join2, resolve as resolve2 } from "path";
268
- function hookRoot(cwd, env = process.env) {
269
- if (env.CLAUDE_PROJECT_DIR) return resolve2(env.CLAUDE_PROJECT_DIR);
270
- const start = resolve2(cwd ?? process.cwd());
271
- for (let dir = start; ; dir = dirname(dir)) {
272
- if (existsSync(join2(dir, ".codebuddy", "config.json"))) return dir;
273
- if (dirname(dir) === dir) return start;
274
- }
275
- }
276
-
277
265
  // src/context/tests.ts
278
266
  import { access, readFile as readFile2 } from "fs/promises";
279
- import { basename, dirname as dirname2, extname, join as join3 } from "path";
267
+ import { basename, dirname, extname, join as join2 } from "path";
280
268
  function findCoveringTest(path, paths) {
269
+ return coveringTestCandidates(path).find((candidate) => paths.has(candidate)) ?? null;
270
+ }
271
+ function coveringTestCandidates(path) {
281
272
  const ext = extname(path);
282
273
  const stem = basename(path, ext);
283
- const dir = dirname2(path);
274
+ const dir = dirname(path);
284
275
  const join22 = (...parts) => parts.filter((part) => part && part !== ".").join("/");
285
276
  const mirrored = dir.replace(/^(src|lib|app)(\/|$)/, "");
286
- const candidates = ext === ".py" ? [
277
+ return ext === ".py" ? [
287
278
  join22(dir, `test_${stem}.py`),
288
- join22("tests", `test_${stem}.py`),
279
+ join22(dir, `${stem}_test.py`),
289
280
  join22(dir, "tests", `test_${stem}.py`),
290
- join22(dir, `${stem}_test.py`)
281
+ // `src/pkg/sub/x.py` → `tests/pkg/sub/test_x.py`, `tests/sub/test_x.py`, `tests/test_x.py`.
282
+ join22("tests", mirrored, `test_${stem}.py`),
283
+ join22("tests", mirrored.replace(/^[^/]+\/?/, ""), `test_${stem}.py`),
284
+ join22("tests", `test_${stem}.py`)
291
285
  ] : ext === ".go" ? [join22(dir, `${stem}_test.go`)] : [
292
286
  join22(dir, `${stem}.test${ext}`),
293
287
  join22(dir, `${stem}.spec${ext}`),
@@ -296,20 +290,23 @@ function findCoveringTest(path, paths) {
296
290
  join22("test", mirrored, `${stem}.test${ext}`),
297
291
  join22("tests", mirrored, `${stem}.test${ext}`)
298
292
  ];
299
- return candidates.find((candidate) => paths.has(candidate)) ?? null;
300
293
  }
301
294
  var NPM_PLACEHOLDER = /no test specified/;
302
295
  async function detectTestCommand(repositoryRoot) {
303
- const exists = (file) => access(join3(repositoryRoot, file)).then(
296
+ const exists = (file) => access(join2(repositoryRoot, file)).then(
304
297
  () => true,
305
298
  () => false
306
299
  );
307
300
  try {
308
- const pkg = JSON.parse(await readFile2(join3(repositoryRoot, "package.json"), "utf8"));
301
+ const pkg = JSON.parse(await readFile2(join2(repositoryRoot, "package.json"), "utf8"));
309
302
  const script = pkg.scripts?.test;
310
303
  if (typeof script === "string" && script.trim() && !NPM_PLACEHOLDER.test(script)) {
304
+ const declared = typeof pkg.packageManager === "string" ? pkg.packageManager.split(/[\s@]/, 1)[0] : "";
305
+ if (declared === "bun") return "bun run test";
306
+ if (declared && ["npm", "pnpm", "yarn"].includes(declared)) return `${declared} test`;
311
307
  if (await exists("pnpm-lock.yaml")) return "pnpm test";
312
308
  if (await exists("yarn.lock")) return "yarn test";
309
+ if (await exists("bun.lockb") || await exists("bun.lock")) return "bun run test";
313
310
  return "npm test";
314
311
  }
315
312
  } catch {
@@ -326,7 +323,7 @@ export {
326
323
  estimateTokens,
327
324
  SessionCapsuleLedger,
328
325
  findCoveringTest,
329
- detectTestCommand,
330
- hookRoot
326
+ coveringTestCandidates,
327
+ detectTestCommand
331
328
  };
332
- //# sourceMappingURL=chunk-J2EAZ4MA.js.map
329
+ //# sourceMappingURL=chunk-GKNYMNFM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/savings/tokens.ts","../src/context/session-ledger.ts","../src/context/tests.ts"],"sourcesContent":["import { countTokens } from \"../planner/budget.js\";\n\n/**\n * The one place token counts are computed. Strings use the same deterministic\n * cl100k tokenizer as planner budgets; objects are measured by their JSON\n * serialization, which matches how they travel over MCP.\n */\n\nexport function estimateTokens(value: unknown): number {\n if (value === undefined || value === null) return 0;\n const text = typeof value === \"string\" ? value : JSON.stringify(value);\n if (!text) return 0;\n return countTokens(text);\n}\n","import { createHash } from \"node:crypto\";\nimport { mkdir, readdir, readFile, rm, stat } from \"node:fs/promises\";\nimport { join, resolve } from \"node:path\";\nimport { generateEntityId } from \"../core/ids.js\";\nimport { writeAtomic } from \"../core/markdown-store-fs.js\";\nimport { estimateTokens } from \"../savings/tokens.js\";\n\nconst VERSION = 2;\nconst MAX_CAPSULES = 128;\nconst MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;\nconst MAX_PENDING = 8;\nconst PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1000;\nconst STALE_LOCK_MS = 60_000;\n/** Acknowledged versions kept per section, so switching back to a task reuses them. */\nconst MAX_ACKNOWLEDGED_PER_KEY = 8;\n\ntype LedgerRecord = { hash: string; lastSentAt: string };\n/** A delivery sent to a client that has not yet confirmed receipt. */\ntype PendingDelivery = { createdAt: string; records: Record<string, string> };\n/** One acknowledged version of an `exchange` section: content hash and when it was sent. */\ntype AcknowledgedVersion = { hash: string; sentAt: string };\ntype LedgerFile = {\n version: 2;\n updatedAt: string;\n /** `deliver`/`markSent` capsules (hooks): one current hash per key. */\n records: Record<string, LedgerRecord>;\n pending?: Record<string, PendingDelivery>;\n /** `exchange` sections (MCP): every recently acknowledged version, newest first. */\n acknowledged?: Record<string, AcknowledgedVersion[]>;\n};\n\nexport type ContextCapsule = { key: string; text: string };\nexport type CapsuleDelivery = {\n /** Full text of new or changed capsules, in input order. */\n lines: string[];\n /** Keys of capsules already sent this session and not resent. */\n unchanged: string[];\n full: number;\n referenced: number;\n sentTokens: number;\n avoidedTokens: number;\n};\n\nexport type SectionHash = { key: string; hash: string };\nexport type AcknowledgedExchange = {\n deliveryId: string;\n /** Keys whose content the client confirmed receiving and that have not changed. */\n unchanged: string[];\n ack: \"none\" | \"accepted\" | \"unknown\";\n};\n\nexport class SessionCapsuleLedger {\n private readonly directory: string;\n\n constructor(repositoryRoot = process.cwd()) {\n this.directory = join(resolve(repositoryRoot), \".codebuddy\", \"cache\", \"session\");\n }\n\n async deliver(\n sessionId: string,\n capsules: ContextCapsule[],\n now = new Date(),\n ): Promise<CapsuleDelivery> {\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n const lines: string[] = [];\n const unchanged: string[] = [];\n let full = 0;\n let referenced = 0;\n let sentTokens = 0;\n let avoidedTokens = 0;\n\n for (const capsule of capsules) {\n const hash = digest(capsule.text);\n const existing = ledger.records[capsule.key];\n if (existing?.hash === hash) {\n unchanged.push(capsule.key);\n referenced++;\n avoidedTokens += estimateTokens(capsule.text);\n } else {\n lines.push(capsule.text);\n full++;\n sentTokens += estimateTokens(capsule.text);\n }\n ledger.records[capsule.key] = { hash, lastSentAt: now.toISOString() };\n }\n\n await this.write(path, ledger, now);\n return { lines, unchanged, full, referenced, sentTokens, avoidedTokens };\n } finally {\n await release();\n }\n }\n\n /** Keys of capsules whose current text was already recorded as sent this session. */\n async seen(\n sessionId: string,\n capsules: ContextCapsule[],\n now = new Date(),\n ): Promise<Set<string>> {\n const ledger = await this.read(this.path(sessionId), now);\n return new Set(\n capsules\n .filter((capsule) => ledger.records[capsule.key]?.hash === digest(capsule.text))\n .map((capsule) => capsule.key),\n );\n }\n\n /**\n * Record capsules as delivered. Call only after their text actually reached\n * the client, so capsules that were clipped or failed to write stay unseen\n * and are delivered by a later prompt.\n */\n async markSent(sessionId: string, capsules: ContextCapsule[], now = new Date()): Promise<void> {\n if (capsules.length === 0) return;\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n for (const capsule of capsules) {\n ledger.records[capsule.key] = { hash: digest(capsule.text), lastSentAt: now.toISOString() };\n }\n await this.write(path, ledger, now);\n } finally {\n await release();\n }\n }\n\n /**\n * Acknowledged delivery for clients that can lose context (MCP). Unlike\n * `deliver`, sending marks nothing as seen: the sent hashes are held as a\n * pending delivery until the client echoes its `deliveryId` back as `ack` on\n * a later call. Only acknowledged, unchanged sections sent within `ttlMs` are\n * reported unchanged, so a dropped response or a compacted client context\n * fails safe to a full resend.\n *\n * A section keeps its last few acknowledged versions (by content hash), not\n * just the latest, so returning to an earlier task in the same session does\n * not resend what the client already holds. `full` means the client lost its\n * context: every acknowledged version and unconfirmed delivery is forgotten.\n */\n async exchange(\n sessionId: string,\n sections: SectionHash[],\n options: {\n ack?: string;\n ttlMs: number;\n /** The client lost its context: nothing acknowledged before this call counts. */\n full?: boolean;\n now?: Date;\n /** Finalize the response before recording hashes of the actual sent sections. */\n prepare?: (exchange: AcknowledgedExchange) => SectionHash[];\n },\n ): Promise<AcknowledgedExchange> {\n const now = options.now ?? new Date();\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n let pending = ledger.pending ?? {};\n let acknowledged = ledger.acknowledged ?? {};\n\n let ack: AcknowledgedExchange[\"ack\"] = \"none\";\n if (options.ack !== undefined) {\n const confirmed = pending[options.ack];\n if (confirmed) {\n for (const [key, hash] of Object.entries(confirmed.records)) {\n acknowledged[key] = [\n { hash, sentAt: confirmed.createdAt },\n ...(acknowledged[key] ?? []).filter((version) => version.hash !== hash),\n ]\n .sort((left, right) => right.sentAt.localeCompare(left.sentAt))\n .slice(0, MAX_ACKNOWLEDGED_PER_KEY);\n }\n delete pending[options.ack];\n ack = \"accepted\";\n } else {\n ack = \"unknown\";\n }\n }\n if (options.full) {\n acknowledged = {};\n pending = {};\n }\n\n const unchanged: string[] = [];\n const sent: Record<string, string> = {};\n for (const section of sections) {\n const held = acknowledged[section.key]?.some((version) => {\n const age = now.getTime() - Date.parse(version.sentAt);\n return version.hash === section.hash && age >= 0 && age <= options.ttlMs;\n });\n if (held) {\n unchanged.push(section.key);\n } else {\n sent[section.key] = section.hash;\n }\n }\n\n const deliveryId = generateEntityId(\"dlv\", now.getTime());\n const exchange = { deliveryId, unchanged, ack };\n const delivered = options.prepare?.(exchange);\n pending[deliveryId] = {\n createdAt: now.toISOString(),\n records: delivered\n ? Object.fromEntries(delivered.map(({ key, hash }) => [key, hash]))\n : sent,\n };\n ledger.pending = Object.fromEntries(\n Object.entries(pending)\n .filter(([, item]) => now.getTime() - Date.parse(item.createdAt) <= PENDING_MAX_AGE_MS)\n .sort((left, right) => right[1].createdAt.localeCompare(left[1].createdAt))\n .slice(0, MAX_PENDING),\n );\n ledger.acknowledged = Object.fromEntries(\n Object.entries(acknowledged)\n .map(([key, versions]): [string, AcknowledgedVersion[]] => [\n key,\n versions.filter((version) => now.getTime() - Date.parse(version.sentAt) <= MAX_AGE_MS),\n ])\n .filter(([, versions]) => versions.length > 0)\n .slice(0, MAX_CAPSULES),\n );\n await this.write(path, ledger, now);\n return { deliveryId, unchanged, ack };\n } finally {\n await release();\n }\n }\n\n /** Forget everything sent in a session, e.g. after the client compacted its context. */\n async reset(sessionId: string): Promise<void> {\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n await rm(this.path(sessionId), { force: true });\n } finally {\n await release();\n }\n }\n\n /** Remove expired disposable state; locked sessions are left for the next pass. */\n async pruneExpired(now = new Date()): Promise<number> {\n let removed = 0;\n for (const entry of await readdir(this.directory).catch(() => [])) {\n if (!entry.endsWith(\".json\")) continue;\n const path = join(this.directory, entry);\n try {\n await stat(`${path}.lock`);\n continue;\n } catch {\n // No active lock.\n }\n try {\n const metadata = await stat(path);\n if (now.getTime() - metadata.mtimeMs > MAX_AGE_MS) {\n await rm(path, { force: true });\n removed++;\n }\n } catch {\n // Another cleanup may have removed it already.\n }\n }\n return removed;\n }\n\n private async write(path: string, ledger: LedgerFile, now: Date): Promise<void> {\n ledger.updatedAt = now.toISOString();\n ledger.records = Object.fromEntries(\n Object.entries(ledger.records)\n .sort((left, right) => right[1].lastSentAt.localeCompare(left[1].lastSentAt))\n .slice(0, MAX_CAPSULES),\n );\n await writeAtomic(path, `${JSON.stringify(ledger, null, 2)}\\n`);\n }\n\n private async read(path: string, now: Date): Promise<LedgerFile> {\n try {\n const parsed = JSON.parse(await readFile(path, \"utf8\")) as LedgerFile;\n if (parsed.version !== VERSION || typeof parsed.records !== \"object\") return empty(now);\n const records = Object.fromEntries(\n Object.entries(parsed.records).filter(([, record]) => {\n const age = now.getTime() - Date.parse(record.lastSentAt);\n return Number.isFinite(age) && age <= MAX_AGE_MS;\n }),\n );\n const pending =\n parsed.pending && typeof parsed.pending === \"object\" ? parsed.pending : undefined;\n const acknowledged =\n parsed.acknowledged && typeof parsed.acknowledged === \"object\"\n ? Object.fromEntries(\n Object.entries(parsed.acknowledged).filter(([, versions]) => Array.isArray(versions)),\n )\n : undefined;\n return {\n version: VERSION,\n updatedAt: parsed.updatedAt,\n records,\n ...(pending ? { pending } : {}),\n ...(acknowledged ? { acknowledged } : {}),\n };\n } catch {\n return empty(now);\n }\n }\n\n private path(sessionId: string): string {\n // The full identity participates in the key. Legacy sanitized filenames\n // are intentionally ignored, so a fresh session cannot inherit collisions.\n return join(this.directory, `${digest(sessionId)}.json`);\n }\n\n private async acquire(sessionId: string): Promise<() => Promise<void>> {\n const lock = `${this.path(sessionId)}.lock`;\n for (let attempt = 0; ; attempt++) {\n try {\n await mkdir(lock);\n return async () => rm(lock, { recursive: true, force: true });\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"EEXIST\" || attempt >= 200) throw error;\n try {\n const age = Date.now() - (await stat(lock)).mtimeMs;\n if (age > STALE_LOCK_MS) {\n await rm(lock, { recursive: true, force: true });\n continue;\n }\n } catch {\n // The owner may have released the lock between checks.\n }\n await new Promise((resolveWait) => setTimeout(resolveWait, 10));\n }\n }\n }\n}\n\nfunction empty(now: Date): LedgerFile {\n return { version: VERSION, updatedAt: now.toISOString(), records: {} };\n}\n\nfunction digest(text: string): string {\n return createHash(\"sha256\").update(text).digest(\"hex\");\n}\n","import { access, readFile } from \"node:fs/promises\";\nimport { basename, dirname, extname, join } from \"node:path\";\n\n/**\n * Which test covers a file and how this repo runs its tests, from naming\n * conventions and project markers only. Agents otherwise spend turns finding\n * both; a wrong guess costs one failed command, so first match wins.\n */\nexport function findCoveringTest(path: string, paths: ReadonlySet<string>): string | null {\n return coveringTestCandidates(path).find((candidate) => paths.has(candidate)) ?? null;\n}\n\n/** Conventional covering-test paths for `path`, most specific first. */\nexport function coveringTestCandidates(path: string): string[] {\n const ext = extname(path);\n const stem = basename(path, ext);\n const dir = dirname(path);\n const join2 = (...parts: string[]) => parts.filter((part) => part && part !== \".\").join(\"/\");\n // `src/a/x.ts` mirrors to `test/a/x.test.ts`: drop a leading source root.\n const mirrored = dir.replace(/^(src|lib|app)(\\/|$)/, \"\");\n return ext === \".py\"\n ? [\n join2(dir, `test_${stem}.py`),\n join2(dir, `${stem}_test.py`),\n join2(dir, \"tests\", `test_${stem}.py`),\n // `src/pkg/sub/x.py` → `tests/pkg/sub/test_x.py`, `tests/sub/test_x.py`, `tests/test_x.py`.\n join2(\"tests\", mirrored, `test_${stem}.py`),\n join2(\"tests\", mirrored.replace(/^[^/]+\\/?/, \"\"), `test_${stem}.py`),\n join2(\"tests\", `test_${stem}.py`),\n ]\n : ext === \".go\"\n ? [join2(dir, `${stem}_test.go`)]\n : [\n join2(dir, `${stem}.test${ext}`),\n join2(dir, `${stem}.spec${ext}`),\n join2(dir, \"__tests__\", `${stem}.test${ext}`),\n join2(dir, \"__tests__\", `${stem}${ext}`),\n join2(\"test\", mirrored, `${stem}.test${ext}`),\n join2(\"tests\", mirrored, `${stem}.test${ext}`),\n ];\n}\n\nconst NPM_PLACEHOLDER = /no test specified/;\n\nexport async function detectTestCommand(repositoryRoot: string): Promise<string | null> {\n const exists = (file: string) =>\n access(join(repositoryRoot, file)).then(\n () => true,\n () => false,\n );\n try {\n const pkg = JSON.parse(await readFile(join(repositoryRoot, \"package.json\"), \"utf8\")) as {\n scripts?: Record<string, unknown>;\n packageManager?: unknown;\n };\n const script = pkg.scripts?.test;\n if (typeof script === \"string\" && script.trim() && !NPM_PLACEHOLDER.test(script)) {\n const declared =\n typeof pkg.packageManager === \"string\" ? pkg.packageManager.split(/[\\s@]/, 1)[0] : \"\";\n // `bun test` is Bun's own runner, not the package's test script.\n if (declared === \"bun\") return \"bun run test\";\n if (declared && [\"npm\", \"pnpm\", \"yarn\"].includes(declared)) return `${declared} test`;\n if (await exists(\"pnpm-lock.yaml\")) return \"pnpm test\";\n if (await exists(\"yarn.lock\")) return \"yarn test\";\n if ((await exists(\"bun.lockb\")) || (await exists(\"bun.lock\"))) return \"bun run test\";\n return \"npm test\";\n }\n } catch {\n // No or unreadable package.json: try other ecosystems.\n }\n for (const marker of [\"pyproject.toml\", \"pytest.ini\", \"setup.cfg\"]) {\n if (await exists(marker)) return \"pytest\";\n }\n if (await exists(\"go.mod\")) return \"go test ./...\";\n if (await exists(\"Cargo.toml\")) return \"cargo test\";\n return null;\n}\n"],"mappings":"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","names":["readFile","join","join2"]}
@@ -1,11 +1,16 @@
1
1
  import {
2
- readConfigFile,
2
+ asksForChangelog
3
+ } from "./chunk-TLWGZVPC.js";
4
+ import {
5
+ readConfigFile
6
+ } from "./chunk-KNR2P2UY.js";
7
+ import {
3
8
  writeAtomic
4
- } from "./chunk-5KZY6OTG.js";
9
+ } from "./chunk-CG5B3LYG.js";
5
10
 
6
11
  // src/retrieval/zvec.ts
7
12
  import { execFile, spawn } from "child_process";
8
- import { mkdir, readFile, rm, stat } from "fs/promises";
13
+ import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
9
14
  import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
10
15
  var SUPPORTED = /^0\.2\.\d+$/;
11
16
  var DEFAULT_TIMEOUT_MS = 1500;
@@ -13,7 +18,18 @@ var READY_CACHE_MS = 10 * 6e4;
13
18
  var NOT_READY_CACHE_MS = 3e4;
14
19
  var REFRESH_TIMEOUT_MS = 10 * 6e4;
15
20
  var DEFAULT_ZVEC_MODEL = "local/potion-code-16m-v2";
16
- var DOC_GLOBS = ["!**/*.md", "!**/*.mdx", "!docs/**"];
21
+ var DEFAULT_ZVEC_MIN_SCORE = 0.3;
22
+ var DOC_GLOBS = ["!**/*.md", "!**/*.mdx", "!**/*.rst", "!**/*.adoc", "!docs/**"];
23
+ var CHANGELOG_GLOBS = [
24
+ "!**/CHANGES*",
25
+ "!**/CHANGELOG*",
26
+ "!**/HISTORY*",
27
+ "!**/NEWS*",
28
+ "!**/RELEASE*",
29
+ "!**/changelog*",
30
+ "!**/Changelog*",
31
+ "!**/ChangeLog*"
32
+ ];
17
33
  var TEST_GLOBS = [
18
34
  "!**/*.test.*",
19
35
  "!**/*.spec.*",
@@ -26,6 +42,7 @@ function zvecExcludeGlobs(task) {
26
42
  return [
27
43
  .../\b(docs?|readme|documentation|markdown)\b/i.test(task) ? [] : DOC_GLOBS,
28
44
  .../\b(tests?|spec)\b/i.test(task) ? [] : TEST_GLOBS,
45
+ ...asksForChangelog(task) ? [] : CHANGELOG_GLOBS,
29
46
  ...ALWAYS_EXCLUDED_GLOBS
30
47
  ];
31
48
  }
@@ -34,14 +51,25 @@ async function zvecCandidates(repositoryRoot, task, options = {}) {
34
51
  const root = resolve(repositoryRoot);
35
52
  const config = await zvecConfig(root);
36
53
  if (!config.enabled) return none("disabled", "zvec-grep not enabled");
37
- const run = (args) => runZg(config.binary, args, root, config.timeoutMs);
54
+ const { deadlineAt } = options;
55
+ const run = (args) => {
56
+ const ms = deadlineAt === void 0 ? config.timeoutMs : Math.min(config.timeoutMs, deadlineAt - Date.now());
57
+ if (ms <= 0) return Promise.resolve({ stdout: "", error: deadlineError() });
58
+ return runZg(config.binary, args, root, ms);
59
+ };
60
+ const missedDeadline = (status) => deadlineAt !== void 0 && status === "timeout" && Date.now() >= deadlineAt - 5;
38
61
  const cached = await readCachedReadiness(root, config.binary);
39
62
  if (cached !== "ready") {
40
63
  if (cached) return cached;
41
64
  const readiness = await checkReadiness(run);
42
- await markReadiness(root, config.binary, readiness);
65
+ if (!(readiness && missedDeadline(readiness.status))) {
66
+ await markReadiness(root, config.binary, readiness);
67
+ }
43
68
  if (readiness) return readiness;
44
69
  }
70
+ if (options.requireServer && !await serverRunning(root, config.binary, run)) {
71
+ return none("no-server", "no running `zg server` (this path never starts one)");
72
+ }
45
73
  const limit = Math.min(20, Math.max(1, Math.floor(options.limit ?? 10)));
46
74
  const chunkLimit = limit * 8;
47
75
  const globs = zvecExcludeGlobs(task);
@@ -58,8 +86,9 @@ async function zvecCandidates(repositoryRoot, task, options = {}) {
58
86
  ...globs.flatMap((glob) => ["-g", glob])
59
87
  ]);
60
88
  if (result.error) {
61
- await resetZvecCache(root);
62
- return fromError(result.error, "zvec-grep query");
89
+ const failure = fromError(result.error, "zvec-grep query");
90
+ if (!missedDeadline(failure.status)) await resetZvecCache(root);
91
+ return failure;
63
92
  }
64
93
  const hits = parseHits(result.stdout).slice(0, chunkLimit);
65
94
  if (hits.length === 0 && /\bzg index\b/.test(result.stdout)) {
@@ -103,6 +132,7 @@ async function zvecCandidates(repositoryRoot, task, options = {}) {
103
132
  hits: candidates,
104
133
  stale,
105
134
  changed,
135
+ minScore: config.minScore,
106
136
  reason: `zvec-grep vector search: ${paths.length} file(s)` + (globs.length > 0 ? "; non-source paths excluded" : "") + (stale > 0 ? `, ${stale} stale hit(s) dropped` : "") + (changed > 0 ? `, ${changed} hit(s) changed since the zg index dropped` : "") + (indexedAt === null ? "; index age unknown, hits unverified (run `codebuddy zvec index`)" : "")
107
137
  };
108
138
  }
@@ -191,7 +221,11 @@ async function buildZvecIndex(repositoryRoot, options = {}) {
191
221
  child.on("close", (exit) => resolveRun(exit ?? 1));
192
222
  });
193
223
  await resetZvecCache(root);
194
- if (code === 0) await markIndexed(root, startedAt);
224
+ if (code === 0) {
225
+ await writeFile(join(root, ".zvec-grep", ".gitignore"), "*\n", { flag: "wx" }).catch(() => {
226
+ });
227
+ await markIndexed(root, startedAt);
228
+ }
195
229
  return code;
196
230
  }
197
231
  var NOT_READY = "zvec-grep index missing or not ready; run `codebuddy zvec index`";
@@ -271,7 +305,8 @@ async function zvecConfig(root) {
271
305
  return {
272
306
  enabled: env !== void 0 ? env === "1" || env === "true" : file.zvec?.enabled ?? false,
273
307
  binary: file.zvec?.binary ?? "zg",
274
- timeoutMs: file.zvec?.timeoutMs ?? DEFAULT_TIMEOUT_MS
308
+ timeoutMs: file.zvec?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
309
+ minScore: file.zvec?.minScore ?? DEFAULT_ZVEC_MIN_SCORE
275
310
  };
276
311
  }
277
312
  function runZg(binary, args, cwd, timeoutMs, signal) {
@@ -296,6 +331,35 @@ function runZg(binary, args, cwd, timeoutMs, signal) {
296
331
  );
297
332
  });
298
333
  }
334
+ function deadlineError() {
335
+ return Object.assign(new Error("hook deadline reached"), { code: "ETIMEDOUT" });
336
+ }
337
+ function serverPath(root) {
338
+ return join(root, ".codebuddy", "cache", "zvec-server.json");
339
+ }
340
+ async function serverRunning(root, binary, run) {
341
+ try {
342
+ const cached = JSON.parse(await readFile(serverPath(root), "utf8"));
343
+ const age = Date.now() - (cached.checkedAt ?? 0);
344
+ if (cached.binary === binary && age >= 0 && age <= NOT_READY_CACHE_MS) {
345
+ return cached.running === true;
346
+ }
347
+ } catch {
348
+ }
349
+ const status = await run(["server", "status"]);
350
+ if (status.error?.code === "ETIMEDOUT") return false;
351
+ const running = !status.error && /^Server: ready\b/m.test(status.stdout);
352
+ try {
353
+ await mkdir(dirname(serverPath(root)), { recursive: true, mode: 448 });
354
+ await writeAtomic(
355
+ serverPath(root),
356
+ `${JSON.stringify({ binary, checkedAt: Date.now(), running })}
357
+ `
358
+ );
359
+ } catch {
360
+ }
361
+ return running;
362
+ }
299
363
  function fromError(error, what) {
300
364
  if (error.code === "ENOENT") return none("not-installed", "zvec-grep (`zg`) not found on PATH");
301
365
  if (error.code === "ETIMEDOUT") return none("timeout", `${what} timed out`);
@@ -313,6 +377,7 @@ function toRepoRelative(root, path) {
313
377
 
314
378
  export {
315
379
  DEFAULT_ZVEC_MODEL,
380
+ DEFAULT_ZVEC_MIN_SCORE,
316
381
  zvecExcludeGlobs,
317
382
  zvecCandidates,
318
383
  refreshZvecIndex,
@@ -321,4 +386,4 @@ export {
321
386
  zvecStatus,
322
387
  buildZvecIndex
323
388
  };
324
- //# sourceMappingURL=chunk-5FW3PMNP.js.map
389
+ //# sourceMappingURL=chunk-GXOML4UZ.js.map