@swarmvaultai/engine 3.16.1 → 3.18.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 (145) hide show
  1. package/dist/chunk-2CH2WWS4.js +1359 -0
  2. package/dist/chunk-2PN46RDI.js +26846 -0
  3. package/dist/chunk-333AMRSV.js +1056 -0
  4. package/dist/chunk-3GVEUYQZ.js +1641 -0
  5. package/dist/chunk-4MSSM2GH.js +1476 -0
  6. package/dist/chunk-563TZ4TZ.js +26573 -0
  7. package/dist/chunk-5GEPTIZE.js +26010 -0
  8. package/dist/chunk-5HNZ2WQI.js +1341 -0
  9. package/dist/chunk-5Q4IV4O3.js +1336 -0
  10. package/dist/chunk-65IRGGXX.js +27576 -0
  11. package/dist/chunk-6MO57J5C.js +988 -0
  12. package/dist/chunk-6UPHDGEB.js +1073 -0
  13. package/dist/chunk-75BU5TQ6.js +1690 -0
  14. package/dist/chunk-7O2HJSWQ.js +1686 -0
  15. package/dist/chunk-7QHDATCQ.js +1673 -0
  16. package/dist/chunk-B3FC4J3P.js +1214 -0
  17. package/dist/chunk-BTWPJEP2.js +1421 -0
  18. package/dist/chunk-CG67P2HB.js +1420 -0
  19. package/dist/chunk-CSPDMCON.js +26846 -0
  20. package/dist/chunk-CVFY54CF.js +24893 -0
  21. package/dist/chunk-CWLDFLH2.js +1163 -0
  22. package/dist/chunk-DAJAZPPO.js +26865 -0
  23. package/dist/chunk-DCSXDZAF.js +27698 -0
  24. package/dist/chunk-EEWB4WGH.js +1056 -0
  25. package/dist/chunk-EXD4RWT3.js +1131 -0
  26. package/dist/chunk-F7HZZ3VM.js +931 -0
  27. package/dist/chunk-FD3LJQ4T.js +1216 -0
  28. package/dist/chunk-G2TH6ZTA.js +1468 -0
  29. package/dist/chunk-H3CDZYRE.js +1701 -0
  30. package/dist/chunk-HFU5S5NO.js +838 -0
  31. package/dist/chunk-HKU2T5JX.js +25213 -0
  32. package/dist/chunk-HOJ7NSYC.js +937 -0
  33. package/dist/chunk-HORJDLXV.js +27614 -0
  34. package/dist/chunk-HRRPWXRZ.js +1335 -0
  35. package/dist/chunk-HW72C7O2.js +1690 -0
  36. package/dist/chunk-IAEYFTUS.js +1159 -0
  37. package/dist/chunk-IHMJCCXR.js +1146 -0
  38. package/dist/chunk-JEWLYIHN.js +27619 -0
  39. package/dist/chunk-JJDJF2P3.js +27012 -0
  40. package/dist/chunk-JTRE7C7P.js +26062 -0
  41. package/dist/chunk-L7DKPPV4.js +27339 -0
  42. package/dist/chunk-LEUV6TWJ.js +1131 -0
  43. package/dist/chunk-MB7HPUTR.js +1364 -0
  44. package/dist/chunk-MZSUYTSL.js +998 -0
  45. package/dist/chunk-N56FAH4N.js +1404 -0
  46. package/dist/chunk-NCSZ4AKP.js +1057 -0
  47. package/dist/chunk-NECZ4MUE.js +1416 -0
  48. package/dist/chunk-NHGS4LOI.js +1346 -0
  49. package/dist/chunk-NUWZUYE7.js +1701 -0
  50. package/dist/chunk-OK5752AP.js +1325 -0
  51. package/dist/chunk-PXZGMENW.js +27664 -0
  52. package/dist/chunk-QMW7OISM.js +1063 -0
  53. package/dist/chunk-RN56HUXA.js +26972 -0
  54. package/dist/chunk-RSQRF4FV.js +1424 -0
  55. package/dist/chunk-S2E65WRI.js +26062 -0
  56. package/dist/chunk-SRHM3HP4.js +944 -0
  57. package/dist/chunk-U7JO257M.js +25017 -0
  58. package/dist/chunk-UQCF65BN.js +1623 -0
  59. package/dist/chunk-USSP4GVB.js +25064 -0
  60. package/dist/chunk-V7KX3AQD.js +26010 -0
  61. package/dist/chunk-VSDBQVSE.js +27584 -0
  62. package/dist/chunk-WK2JR5H7.js +27694 -0
  63. package/dist/chunk-WOA5LSNB.js +26559 -0
  64. package/dist/chunk-WWP3VPEJ.js +26080 -0
  65. package/dist/chunk-YFKWMXJ6.js +26066 -0
  66. package/dist/chunk-Z552HHPV.js +26846 -0
  67. package/dist/chunk-ZQ5T64AR.js +1365 -0
  68. package/dist/hooks/claude.js +246 -24
  69. package/dist/hooks/codex.js +144 -11
  70. package/dist/hooks/copilot.js +95 -3
  71. package/dist/hooks/gemini.js +153 -5
  72. package/dist/hooks/opencode.js +4 -2
  73. package/dist/index.d.ts +36 -4
  74. package/dist/index.js +252 -12
  75. package/dist/memory-7OSGN7IU.js +32 -0
  76. package/dist/memory-A4VPLUBA.js +32 -0
  77. package/dist/memory-DNSQCDHC.js +32 -0
  78. package/dist/memory-ECS3TSGC.js +32 -0
  79. package/dist/memory-FVIBFROA.js +32 -0
  80. package/dist/memory-G6I3DBW4.js +32 -0
  81. package/dist/memory-GFOW2QWQ.js +32 -0
  82. package/dist/memory-GSCQ6F53.js +32 -0
  83. package/dist/memory-HE6VWUPV.js +32 -0
  84. package/dist/memory-HEA7XNKB.js +32 -0
  85. package/dist/memory-HMP3Y4PQ.js +32 -0
  86. package/dist/memory-JRYTVHNH.js +32 -0
  87. package/dist/memory-K3NL5E3K.js +32 -0
  88. package/dist/memory-KANI73CX.js +32 -0
  89. package/dist/memory-KI5G2A4C.js +32 -0
  90. package/dist/memory-PK55JUKG.js +32 -0
  91. package/dist/memory-PK5JJNAG.js +32 -0
  92. package/dist/memory-PQWSJ4RR.js +32 -0
  93. package/dist/memory-QCVKS3H4.js +32 -0
  94. package/dist/memory-QURNHGEZ.js +32 -0
  95. package/dist/memory-SAQPBIB4.js +32 -0
  96. package/dist/memory-SVGRP5KS.js +32 -0
  97. package/dist/memory-TQ46BGCI.js +32 -0
  98. package/dist/memory-UUPLB6O2.js +32 -0
  99. package/dist/memory-YKQWWIVY.js +32 -0
  100. package/dist/memory-Z7BP5OSC.js +32 -0
  101. package/dist/registry-2QC3VN7M.js +12 -0
  102. package/dist/registry-2REAPKPO.js +12 -0
  103. package/dist/registry-2XHXZDGH.js +12 -0
  104. package/dist/registry-4C55ZCPL.js +12 -0
  105. package/dist/registry-4QRMVAHX.js +12 -0
  106. package/dist/registry-5SYH3Y3U.js +12 -0
  107. package/dist/registry-6KZMA3XM.js +12 -0
  108. package/dist/registry-7QACDJQQ.js +12 -0
  109. package/dist/registry-B7UXRBW3.js +12 -0
  110. package/dist/registry-FKEREVDO.js +12 -0
  111. package/dist/registry-FLSGGY2R.js +12 -0
  112. package/dist/registry-G7NSRYCO.js +12 -0
  113. package/dist/registry-GH4O3A7H.js +12 -0
  114. package/dist/registry-IBH6K2KK.js +12 -0
  115. package/dist/registry-ILDEBNCW.js +12 -0
  116. package/dist/registry-JFEW5RUP.js +12 -0
  117. package/dist/registry-JQYQOZYN.js +12 -0
  118. package/dist/registry-JR5WY22P.js +12 -0
  119. package/dist/registry-KLO5YIHP.js +12 -0
  120. package/dist/registry-KVJAO5DF.js +12 -0
  121. package/dist/registry-MYJX6AEE.js +12 -0
  122. package/dist/registry-NBLIJHZT.js +12 -0
  123. package/dist/registry-NLRWSN5J.js +12 -0
  124. package/dist/registry-NMXDBYIZ.js +12 -0
  125. package/dist/registry-OUB6W3LM.js +12 -0
  126. package/dist/registry-P5KRT66L.js +12 -0
  127. package/dist/registry-PGZWRXMD.js +12 -0
  128. package/dist/registry-QAG2ZYH3.js +12 -0
  129. package/dist/registry-SUXWCWB4.js +12 -0
  130. package/dist/registry-SYCRRA65.js +12 -0
  131. package/dist/registry-TYROWPR5.js +12 -0
  132. package/dist/registry-U23ML76I.js +12 -0
  133. package/dist/registry-U76DBOV3.js +12 -0
  134. package/dist/registry-UA42LQUQ.js +12 -0
  135. package/dist/registry-W6ZFRI73.js +12 -0
  136. package/dist/registry-X5PMZTZY.js +12 -0
  137. package/dist/registry-XIL5F33J.js +12 -0
  138. package/dist/registry-XOPLQNZY.js +12 -0
  139. package/dist/registry-YDXVCE4Q.js +12 -0
  140. package/dist/registry-YGVTLIZH.js +12 -0
  141. package/dist/registry-ZNW3FDED.js +12 -0
  142. package/dist/viewer/assets/{index-Cq5HAlrV.js → index-BZE-2FtS.js} +37 -37
  143. package/dist/viewer/index.html +1 -1
  144. package/dist/viewer/lib.js +29 -1
  145. package/package.json +1 -1
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/hooks/claude.ts
4
+ import { spawn } from "child_process";
5
+
3
6
  // src/hooks/marker-state.ts
4
7
  import crypto from "crypto";
5
8
  import fs from "fs/promises";
@@ -13,6 +16,23 @@ function markerState(cwd, agentKey) {
13
16
  markerPath: path.join(dir, "report-read")
14
17
  };
15
18
  }
19
+ function flagPath(cwd, agentKey, name) {
20
+ const safeName = name.replaceAll(/[^a-z0-9-]/gi, "-");
21
+ return path.join(markerState(cwd, agentKey).dir, safeName);
22
+ }
23
+ async function markFlag(cwd, agentKey, name) {
24
+ const target = flagPath(cwd, agentKey, name);
25
+ await fs.mkdir(path.dirname(target), { recursive: true });
26
+ await fs.writeFile(target, "seen\n", "utf8");
27
+ }
28
+ async function hasFlag(cwd, agentKey, name) {
29
+ try {
30
+ await fs.access(flagPath(cwd, agentKey, name));
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
16
36
  function isReportPath(value, cwd) {
17
37
  if (typeof value !== "string" || value.length === 0) {
18
38
  return false;
@@ -57,6 +77,10 @@ function resolveToolName(input) {
57
77
  const shaped = input ?? {};
58
78
  return String(shaped.toolName ?? shaped.tool_name ?? shaped.tool?.name ?? shaped.name ?? "");
59
79
  }
80
+ function resolveToolInput(input) {
81
+ const shaped = input ?? {};
82
+ return shaped.toolInput ?? shaped.tool_input ?? {};
83
+ }
60
84
  async function hasReport(cwd) {
61
85
  try {
62
86
  await fs.access(reportPath(cwd));
@@ -116,13 +140,18 @@ function collectCommandCandidates(node, acc = []) {
116
140
  return acc;
117
141
  }
118
142
  function commandLooksLikeBroadSearch(command) {
119
- const tokens = command.replace(/[;&|()]/g, " ").split(/\s+/).map((token) => path.basename(token.replace(/^['"]|['"]$/g, ""))).filter(Boolean);
120
- for (let index = 0; index < tokens.length; index += 1) {
121
- const token = tokens[index];
122
- if (["rg", "grep", "find", "fd", "ag", "ack"].includes(token)) {
143
+ const statements = command.split(/;|&&|\|\|/);
144
+ for (const statement of statements) {
145
+ const firstStage = statement.split("|")[0] ?? "";
146
+ const tokens = firstStage.replace(/[()]/g, " ").split(/\s+/).map((token) => path.basename(token.replace(/^['"]|['"]$/g, ""))).filter(Boolean);
147
+ const leading = tokens.find((token) => !token.includes("=") && !token.startsWith("-"));
148
+ if (!leading) {
149
+ continue;
150
+ }
151
+ if (["rg", "grep", "find", "fd", "ag", "ack"].includes(leading)) {
123
152
  return true;
124
153
  }
125
- if (token === "git" && tokens[index + 1] === "grep") {
154
+ if (leading === "git" && tokens[tokens.indexOf(leading) + 1] === "grep") {
126
155
  return true;
127
156
  }
128
157
  }
@@ -135,6 +164,100 @@ function isBroadSearchInput(input) {
135
164
  }
136
165
  return collectCommandCandidates(input).some(commandLooksLikeBroadSearch);
137
166
  }
167
+ var VAULT_ARTIFACT_SEGMENTS = ["wiki", "raw", "state", "agent", "inbox"];
168
+ function isVaultArtifactSearch(input, cwd) {
169
+ const artifactRoot = artifactRootDir(cwd);
170
+ const candidates = [...collectCandidatePaths(input), ...collectCommandCandidates(input)];
171
+ return candidates.some((candidate) => {
172
+ if (typeof candidate !== "string" || candidate.length === 0) {
173
+ return false;
174
+ }
175
+ const normalized = candidate.replaceAll("\\", "/");
176
+ if (VAULT_ARTIFACT_SEGMENTS.some(
177
+ (segment) => normalized.includes(`${segment}/`) && normalized.match(new RegExp(`(^|[\\s'"=/])${segment}/`))
178
+ )) {
179
+ return true;
180
+ }
181
+ const resolved = path.resolve(cwd, candidate);
182
+ return VAULT_ARTIFACT_SEGMENTS.some(
183
+ (segment) => resolved.startsWith(path.join(artifactRoot, segment) + path.sep) || resolved === path.join(artifactRoot, segment)
184
+ );
185
+ });
186
+ }
187
+ async function isNarrowSearch(input) {
188
+ const toolInput = resolveToolInput(input);
189
+ const candidate = toolInput?.path;
190
+ if (typeof candidate !== "string" || candidate.length === 0) {
191
+ return false;
192
+ }
193
+ try {
194
+ const stats = await fs.stat(candidate);
195
+ return stats.isFile();
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+ async function resolveGraphFirstMode(cwd) {
201
+ const fromEnv = process.env.SWARMVAULT_GRAPH_FIRST?.trim().toLowerCase();
202
+ if (fromEnv === "deny" || fromEnv === "context" || fromEnv === "off") {
203
+ return fromEnv;
204
+ }
205
+ try {
206
+ const raw = await fs.readFile(path.join(cwd, "swarmvault.config.json"), "utf8");
207
+ const parsed = JSON.parse(raw);
208
+ const fromConfig = typeof parsed?.hooks?.graphFirst === "string" ? parsed.hooks.graphFirst.toLowerCase() : "";
209
+ if (fromConfig === "deny" || fromConfig === "context" || fromConfig === "off") {
210
+ return fromConfig;
211
+ }
212
+ } catch {
213
+ }
214
+ return "context";
215
+ }
216
+ async function readWatchStaleness(cwd) {
217
+ const watchDir = path.join(artifactRootDir(cwd), "state", "watch");
218
+ let lastRunAt;
219
+ let lastRunSuccess;
220
+ let pendingCount = 0;
221
+ let found = false;
222
+ try {
223
+ const raw = await fs.readFile(path.join(watchDir, "status.json"), "utf8");
224
+ const parsed = JSON.parse(raw);
225
+ lastRunAt = typeof parsed?.lastRun?.finishedAt === "string" ? parsed.lastRun.finishedAt : void 0;
226
+ lastRunSuccess = typeof parsed?.lastRun?.success === "boolean" ? parsed.lastRun.success : void 0;
227
+ found = true;
228
+ } catch {
229
+ }
230
+ try {
231
+ const raw = await fs.readFile(path.join(watchDir, "pending-semantic-refresh.json"), "utf8");
232
+ const parsed = JSON.parse(raw);
233
+ if (Array.isArray(parsed)) {
234
+ pendingCount = parsed.length;
235
+ found = true;
236
+ }
237
+ } catch {
238
+ }
239
+ if (!found) {
240
+ return null;
241
+ }
242
+ return { lastRunAt, lastRunSuccess, pendingSemanticRefreshCount: pendingCount };
243
+ }
244
+ function collectEditedFilePaths(input, cwd) {
245
+ const toolInput = resolveToolInput(input);
246
+ const candidates = [];
247
+ for (const key of ["file_path", "filePath", "path", "notebook_path", "notebookPath"]) {
248
+ const value = toolInput?.[key];
249
+ if (typeof value === "string" && value.length > 0) {
250
+ candidates.push(value);
251
+ }
252
+ }
253
+ const artifactRoot = artifactRootDir(cwd);
254
+ const resolved = candidates.map((candidate) => path.resolve(cwd, candidate)).filter(
255
+ (absolutePath) => !VAULT_ARTIFACT_SEGMENTS.some(
256
+ (segment) => absolutePath === path.join(artifactRoot, segment) || absolutePath.startsWith(path.join(artifactRoot, segment) + path.sep)
257
+ )
258
+ );
259
+ return [...new Set(resolved)];
260
+ }
138
261
  async function readHookInput() {
139
262
  let body = "";
140
263
  for await (const chunk of process.stdin) {
@@ -149,7 +272,52 @@ async function readHookInput() {
149
272
  return {};
150
273
  }
151
274
  }
152
- var REPORT_NOTE = "SwarmVault graph report exists at wiki/graph/report.md, or at $SWARMVAULT_OUT/wiki/graph/report.md when SWARMVAULT_OUT is set. Read it before broad grep/glob searching.";
275
+ var GRAPH_FIRST_COMMANDS = [
276
+ '- `swarmvault graph query "<seed>"` \u2014 top matches with page paths plus an inline excerpt of the best page; usually answers where-is/what-calls in one command',
277
+ '- `swarmvault graph explain "<node>"` \u2014 compact node summary with neighbors and its wiki page',
278
+ "- `swarmvault graph blast <target>` \u2014 reverse-import impact analysis for change-impact questions",
279
+ "- `wiki/graph/report.md` \u2014 orientation report (architecture, communities, key nodes)",
280
+ "Do not add `--json` to these \u2014 the plain output is far smaller and already structured.",
281
+ "Trust the graph/wiki answer for orientation questions; verify in source only when you are about to edit or the evidence conflicts. Answer directly in chat \u2014 do not write answer files unless asked for a durable artifact."
282
+ ];
283
+ function buildGraphFirstNote(staleness) {
284
+ const lines = [
285
+ "This repo has a SwarmVault code graph. To save tokens, answer code-understanding questions (where is X, what calls Y, how is Z structured, impact of changing W) from the graph instead of reading or grepping source files:",
286
+ ...GRAPH_FIRST_COMMANDS,
287
+ "Read source files directly only when you are about to edit them, or when the graph lacks the detail you need.",
288
+ "After your edits the SwarmVault hook refreshes the graph automatically."
289
+ ];
290
+ if (staleness?.pendingSemanticRefreshCount) {
291
+ lines.push(
292
+ `Note: ${staleness.pendingSemanticRefreshCount} non-code change(s) await semantic refresh \u2014 run \`swarmvault compile\` when convenient.`
293
+ );
294
+ }
295
+ if (staleness?.lastRunSuccess === false) {
296
+ lines.push(
297
+ "Note: the last graph refresh failed \u2014 run `swarmvault graph status` then `swarmvault graph update` before relying on the graph."
298
+ );
299
+ }
300
+ return lines.join("\n");
301
+ }
302
+ function extractSearchTerm(input) {
303
+ const toolInput = resolveToolInput(input);
304
+ for (const key of ["pattern", "query", "regex"]) {
305
+ const value = toolInput?.[key];
306
+ if (typeof value === "string" && value.length > 0) {
307
+ return value;
308
+ }
309
+ }
310
+ return "<your term>";
311
+ }
312
+ function buildDenyReason(toolName, input) {
313
+ const term = extractSearchTerm(input).slice(0, 120);
314
+ return [
315
+ `SwarmVault graph-first: this repo has a compiled code graph that answers structure questions in far fewer tokens than ${toolName || "broad search"}.`,
316
+ `Run: swarmvault graph query "${term}" \u2014 it prints the top matches with page paths plus an inline excerpt of the best page, which usually answers the question without reading source. Add --context calls for caller/impact questions. Do not add --json (much larger output).`,
317
+ "Trust that answer for orientation questions instead of re-verifying in source files.",
318
+ "If the graph does not answer, repeat this exact search \u2014 it will be allowed for the rest of the session."
319
+ ].join(" ");
320
+ }
153
321
 
154
322
  // src/hooks/claude.ts
155
323
  var AGENT_KEY = "claude";
@@ -157,38 +325,92 @@ function emit(value) {
157
325
  process.stdout.write(`${JSON.stringify(value)}
158
326
  `);
159
327
  }
160
- async function main() {
161
- const mode = process.argv[2] ?? "";
162
- const input = await readHookInput();
163
- const cwd = resolveInputCwd(input);
164
- if (!await hasReport(cwd)) {
328
+ function denyFlagName(toolName) {
329
+ return `deny-search-${(toolName || "unknown").toLowerCase()}`;
330
+ }
331
+ async function handleSessionStart(cwd) {
332
+ await resetSession(cwd, AGENT_KEY);
333
+ const staleness = await readWatchStaleness(cwd);
334
+ emit({
335
+ hookSpecificOutput: {
336
+ hookEventName: "SessionStart",
337
+ additionalContext: buildGraphFirstNote(staleness)
338
+ }
339
+ });
340
+ }
341
+ async function handlePostEdit(cwd, input) {
342
+ const editedPaths = collectEditedFilePaths(input, cwd);
343
+ if (editedPaths.length > 0) {
344
+ try {
345
+ const child = spawn("swarmvault", ["graph", "update", ...editedPaths.flatMap((p) => ["--file", p]), "--json"], {
346
+ cwd,
347
+ detached: true,
348
+ stdio: "ignore"
349
+ });
350
+ child.unref();
351
+ } catch {
352
+ }
353
+ }
354
+ emit({});
355
+ }
356
+ async function handlePreToolUse(cwd, input) {
357
+ if (collectCandidatePaths(input).some((value) => isReportPath(value, cwd))) {
358
+ await markReportRead(cwd, AGENT_KEY);
165
359
  emit({});
166
- process.exit(0);
360
+ return;
167
361
  }
168
- if (mode === "session-start") {
169
- await resetSession(cwd, AGENT_KEY);
362
+ const mode = await resolveGraphFirstMode(cwd);
363
+ if (mode === "off" || !isBroadSearchInput(input)) {
364
+ emit({});
365
+ return;
366
+ }
367
+ if (isVaultArtifactSearch(input, cwd) || await isNarrowSearch(input)) {
368
+ emit({});
369
+ return;
370
+ }
371
+ const toolName = resolveToolName(input);
372
+ const flag = denyFlagName(toolName);
373
+ if (mode === "deny" && !await hasFlag(cwd, AGENT_KEY, flag)) {
374
+ await markFlag(cwd, AGENT_KEY, flag);
170
375
  emit({
171
376
  hookSpecificOutput: {
172
- hookEventName: "SessionStart",
173
- additionalContext: REPORT_NOTE
377
+ hookEventName: "PreToolUse",
378
+ permissionDecision: "deny",
379
+ permissionDecisionReason: buildDenyReason(toolName, input)
174
380
  }
175
381
  });
176
- process.exit(0);
382
+ return;
177
383
  }
178
- if (collectCandidatePaths(input).some((value) => isReportPath(value, cwd))) {
384
+ if (!await hasSeenReport(cwd, AGENT_KEY)) {
179
385
  await markReportRead(cwd, AGENT_KEY);
180
- emit({});
181
- process.exit(0);
182
- }
183
- if (isBroadSearchInput(input) && !await hasSeenReport(cwd, AGENT_KEY)) {
184
386
  emit({
185
387
  hookSpecificOutput: {
186
388
  hookEventName: "PreToolUse",
187
- additionalContext: REPORT_NOTE
389
+ permissionDecision: "allow",
390
+ additionalContext: buildDenyReason(toolName, input)
188
391
  }
189
392
  });
190
- process.exit(0);
393
+ return;
191
394
  }
192
395
  emit({});
193
396
  }
397
+ async function main() {
398
+ const mode = process.argv[2] ?? "";
399
+ const input = await readHookInput();
400
+ const cwd = resolveInputCwd(input);
401
+ if (!await hasReport(cwd) || await resolveGraphFirstMode(cwd) === "off") {
402
+ emit({});
403
+ process.exit(0);
404
+ }
405
+ if (mode === "session-start") {
406
+ await handleSessionStart(cwd);
407
+ process.exit(0);
408
+ }
409
+ if (mode === "post-edit") {
410
+ await handlePostEdit(cwd, input);
411
+ process.exit(0);
412
+ }
413
+ await handlePreToolUse(cwd, input);
414
+ process.exit(0);
415
+ }
194
416
  await main();
@@ -57,6 +57,10 @@ function resolveToolName(input) {
57
57
  const shaped = input ?? {};
58
58
  return String(shaped.toolName ?? shaped.tool_name ?? shaped.tool?.name ?? shaped.name ?? "");
59
59
  }
60
+ function resolveToolInput(input) {
61
+ const shaped = input ?? {};
62
+ return shaped.toolInput ?? shaped.tool_input ?? {};
63
+ }
60
64
  async function hasReport(cwd) {
61
65
  try {
62
66
  await fs.access(reportPath(cwd));
@@ -116,13 +120,18 @@ function collectCommandCandidates(node, acc = []) {
116
120
  return acc;
117
121
  }
118
122
  function commandLooksLikeBroadSearch(command) {
119
- const tokens = command.replace(/[;&|()]/g, " ").split(/\s+/).map((token) => path.basename(token.replace(/^['"]|['"]$/g, ""))).filter(Boolean);
120
- for (let index = 0; index < tokens.length; index += 1) {
121
- const token = tokens[index];
122
- if (["rg", "grep", "find", "fd", "ag", "ack"].includes(token)) {
123
+ const statements = command.split(/;|&&|\|\|/);
124
+ for (const statement of statements) {
125
+ const firstStage = statement.split("|")[0] ?? "";
126
+ const tokens = firstStage.replace(/[()]/g, " ").split(/\s+/).map((token) => path.basename(token.replace(/^['"]|['"]$/g, ""))).filter(Boolean);
127
+ const leading = tokens.find((token) => !token.includes("=") && !token.startsWith("-"));
128
+ if (!leading) {
129
+ continue;
130
+ }
131
+ if (["rg", "grep", "find", "fd", "ag", "ack"].includes(leading)) {
123
132
  return true;
124
133
  }
125
- if (token === "git" && tokens[index + 1] === "grep") {
134
+ if (leading === "git" && tokens[tokens.indexOf(leading) + 1] === "grep") {
126
135
  return true;
127
136
  }
128
137
  }
@@ -135,6 +144,83 @@ function isBroadSearchInput(input) {
135
144
  }
136
145
  return collectCommandCandidates(input).some(commandLooksLikeBroadSearch);
137
146
  }
147
+ var VAULT_ARTIFACT_SEGMENTS = ["wiki", "raw", "state", "agent", "inbox"];
148
+ function isVaultArtifactSearch(input, cwd) {
149
+ const artifactRoot = artifactRootDir(cwd);
150
+ const candidates = [...collectCandidatePaths(input), ...collectCommandCandidates(input)];
151
+ return candidates.some((candidate) => {
152
+ if (typeof candidate !== "string" || candidate.length === 0) {
153
+ return false;
154
+ }
155
+ const normalized = candidate.replaceAll("\\", "/");
156
+ if (VAULT_ARTIFACT_SEGMENTS.some(
157
+ (segment) => normalized.includes(`${segment}/`) && normalized.match(new RegExp(`(^|[\\s'"=/])${segment}/`))
158
+ )) {
159
+ return true;
160
+ }
161
+ const resolved = path.resolve(cwd, candidate);
162
+ return VAULT_ARTIFACT_SEGMENTS.some(
163
+ (segment) => resolved.startsWith(path.join(artifactRoot, segment) + path.sep) || resolved === path.join(artifactRoot, segment)
164
+ );
165
+ });
166
+ }
167
+ async function isNarrowSearch(input) {
168
+ const toolInput = resolveToolInput(input);
169
+ const candidate = toolInput?.path;
170
+ if (typeof candidate !== "string" || candidate.length === 0) {
171
+ return false;
172
+ }
173
+ try {
174
+ const stats = await fs.stat(candidate);
175
+ return stats.isFile();
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+ async function resolveGraphFirstMode(cwd) {
181
+ const fromEnv = process.env.SWARMVAULT_GRAPH_FIRST?.trim().toLowerCase();
182
+ if (fromEnv === "deny" || fromEnv === "context" || fromEnv === "off") {
183
+ return fromEnv;
184
+ }
185
+ try {
186
+ const raw = await fs.readFile(path.join(cwd, "swarmvault.config.json"), "utf8");
187
+ const parsed = JSON.parse(raw);
188
+ const fromConfig = typeof parsed?.hooks?.graphFirst === "string" ? parsed.hooks.graphFirst.toLowerCase() : "";
189
+ if (fromConfig === "deny" || fromConfig === "context" || fromConfig === "off") {
190
+ return fromConfig;
191
+ }
192
+ } catch {
193
+ }
194
+ return "context";
195
+ }
196
+ async function readWatchStaleness(cwd) {
197
+ const watchDir = path.join(artifactRootDir(cwd), "state", "watch");
198
+ let lastRunAt;
199
+ let lastRunSuccess;
200
+ let pendingCount = 0;
201
+ let found = false;
202
+ try {
203
+ const raw = await fs.readFile(path.join(watchDir, "status.json"), "utf8");
204
+ const parsed = JSON.parse(raw);
205
+ lastRunAt = typeof parsed?.lastRun?.finishedAt === "string" ? parsed.lastRun.finishedAt : void 0;
206
+ lastRunSuccess = typeof parsed?.lastRun?.success === "boolean" ? parsed.lastRun.success : void 0;
207
+ found = true;
208
+ } catch {
209
+ }
210
+ try {
211
+ const raw = await fs.readFile(path.join(watchDir, "pending-semantic-refresh.json"), "utf8");
212
+ const parsed = JSON.parse(raw);
213
+ if (Array.isArray(parsed)) {
214
+ pendingCount = parsed.length;
215
+ found = true;
216
+ }
217
+ } catch {
218
+ }
219
+ if (!found) {
220
+ return null;
221
+ }
222
+ return { lastRunAt, lastRunSuccess, pendingSemanticRefreshCount: pendingCount };
223
+ }
138
224
  async function readHookInput() {
139
225
  let body = "";
140
226
  for await (const chunk of process.stdin) {
@@ -149,7 +235,52 @@ async function readHookInput() {
149
235
  return {};
150
236
  }
151
237
  }
152
- var REPORT_NOTE = "SwarmVault graph report exists at wiki/graph/report.md, or at $SWARMVAULT_OUT/wiki/graph/report.md when SWARMVAULT_OUT is set. Read it before broad grep/glob searching.";
238
+ var GRAPH_FIRST_COMMANDS = [
239
+ '- `swarmvault graph query "<seed>"` \u2014 top matches with page paths plus an inline excerpt of the best page; usually answers where-is/what-calls in one command',
240
+ '- `swarmvault graph explain "<node>"` \u2014 compact node summary with neighbors and its wiki page',
241
+ "- `swarmvault graph blast <target>` \u2014 reverse-import impact analysis for change-impact questions",
242
+ "- `wiki/graph/report.md` \u2014 orientation report (architecture, communities, key nodes)",
243
+ "Do not add `--json` to these \u2014 the plain output is far smaller and already structured.",
244
+ "Trust the graph/wiki answer for orientation questions; verify in source only when you are about to edit or the evidence conflicts. Answer directly in chat \u2014 do not write answer files unless asked for a durable artifact."
245
+ ];
246
+ function buildGraphFirstNote(staleness) {
247
+ const lines = [
248
+ "This repo has a SwarmVault code graph. To save tokens, answer code-understanding questions (where is X, what calls Y, how is Z structured, impact of changing W) from the graph instead of reading or grepping source files:",
249
+ ...GRAPH_FIRST_COMMANDS,
250
+ "Read source files directly only when you are about to edit them, or when the graph lacks the detail you need.",
251
+ "After your edits the SwarmVault hook refreshes the graph automatically."
252
+ ];
253
+ if (staleness?.pendingSemanticRefreshCount) {
254
+ lines.push(
255
+ `Note: ${staleness.pendingSemanticRefreshCount} non-code change(s) await semantic refresh \u2014 run \`swarmvault compile\` when convenient.`
256
+ );
257
+ }
258
+ if (staleness?.lastRunSuccess === false) {
259
+ lines.push(
260
+ "Note: the last graph refresh failed \u2014 run `swarmvault graph status` then `swarmvault graph update` before relying on the graph."
261
+ );
262
+ }
263
+ return lines.join("\n");
264
+ }
265
+ function extractSearchTerm(input) {
266
+ const toolInput = resolveToolInput(input);
267
+ for (const key of ["pattern", "query", "regex"]) {
268
+ const value = toolInput?.[key];
269
+ if (typeof value === "string" && value.length > 0) {
270
+ return value;
271
+ }
272
+ }
273
+ return "<your term>";
274
+ }
275
+ function buildDenyReason(toolName, input) {
276
+ const term = extractSearchTerm(input).slice(0, 120);
277
+ return [
278
+ `SwarmVault graph-first: this repo has a compiled code graph that answers structure questions in far fewer tokens than ${toolName || "broad search"}.`,
279
+ `Run: swarmvault graph query "${term}" \u2014 it prints the top matches with page paths plus an inline excerpt of the best page, which usually answers the question without reading source. Add --context calls for caller/impact questions. Do not add --json (much larger output).`,
280
+ "Trust that answer for orientation questions instead of re-verifying in source files.",
281
+ "If the graph does not answer, repeat this exact search \u2014 it will be allowed for the rest of the session."
282
+ ].join(" ");
283
+ }
153
284
 
154
285
  // src/hooks/codex.ts
155
286
  var AGENT_KEY = "codex";
@@ -157,10 +288,10 @@ function emit(value) {
157
288
  process.stdout.write(`${JSON.stringify(value)}
158
289
  `);
159
290
  }
160
- function note() {
291
+ function note(message) {
161
292
  return {
162
293
  priority: "IMPORTANT",
163
- message: REPORT_NOTE
294
+ message
164
295
  };
165
296
  }
166
297
  async function main() {
@@ -173,7 +304,7 @@ async function main() {
173
304
  }
174
305
  if (mode === "session-start") {
175
306
  await resetSession(cwd, AGENT_KEY);
176
- emit(note());
307
+ emit(note(buildGraphFirstNote(await readWatchStaleness(cwd))));
177
308
  process.exit(0);
178
309
  }
179
310
  if (collectCandidatePaths(input).some((value) => isReportPath(value, cwd))) {
@@ -181,8 +312,10 @@ async function main() {
181
312
  emit({});
182
313
  process.exit(0);
183
314
  }
184
- if (isBroadSearchInput(input) && !await hasSeenReport(cwd, AGENT_KEY)) {
185
- emit(note());
315
+ const graphFirstMode = await resolveGraphFirstMode(cwd);
316
+ if (graphFirstMode !== "off" && isBroadSearchInput(input) && !isVaultArtifactSearch(input, cwd) && !await isNarrowSearch(input) && !await hasSeenReport(cwd, AGENT_KEY)) {
317
+ await markReportRead(cwd, AGENT_KEY);
318
+ emit(note(buildDenyReason(resolveToolName(input), input)));
186
319
  process.exit(0);
187
320
  }
188
321
  emit({});
@@ -57,6 +57,10 @@ function resolveToolName(input) {
57
57
  const shaped = input ?? {};
58
58
  return String(shaped.toolName ?? shaped.tool_name ?? shaped.tool?.name ?? shaped.name ?? "");
59
59
  }
60
+ function resolveToolInput(input) {
61
+ const shaped = input ?? {};
62
+ return shaped.toolInput ?? shaped.tool_input ?? {};
63
+ }
60
64
  async function hasReport(cwd) {
61
65
  try {
62
66
  await fs.access(reportPath(cwd));
@@ -96,6 +100,74 @@ async function resetSession(cwd, agentKey) {
96
100
  function isBroadSearchTool(toolName) {
97
101
  return /grep|glob|search|find/i.test(toolName);
98
102
  }
103
+ function collectCommandCandidates(node, acc = []) {
104
+ if (!node || typeof node !== "object") {
105
+ return acc;
106
+ }
107
+ if (Array.isArray(node)) {
108
+ for (const item of node) {
109
+ collectCommandCandidates(item, acc);
110
+ }
111
+ return acc;
112
+ }
113
+ for (const [key, value] of Object.entries(node)) {
114
+ if (["command", "cmd", "script", "bash", "shell"].includes(key) && typeof value === "string") {
115
+ acc.push(value);
116
+ continue;
117
+ }
118
+ collectCommandCandidates(value, acc);
119
+ }
120
+ return acc;
121
+ }
122
+ var VAULT_ARTIFACT_SEGMENTS = ["wiki", "raw", "state", "agent", "inbox"];
123
+ function isVaultArtifactSearch(input, cwd) {
124
+ const artifactRoot = artifactRootDir(cwd);
125
+ const candidates = [...collectCandidatePaths(input), ...collectCommandCandidates(input)];
126
+ return candidates.some((candidate) => {
127
+ if (typeof candidate !== "string" || candidate.length === 0) {
128
+ return false;
129
+ }
130
+ const normalized = candidate.replaceAll("\\", "/");
131
+ if (VAULT_ARTIFACT_SEGMENTS.some(
132
+ (segment) => normalized.includes(`${segment}/`) && normalized.match(new RegExp(`(^|[\\s'"=/])${segment}/`))
133
+ )) {
134
+ return true;
135
+ }
136
+ const resolved = path.resolve(cwd, candidate);
137
+ return VAULT_ARTIFACT_SEGMENTS.some(
138
+ (segment) => resolved.startsWith(path.join(artifactRoot, segment) + path.sep) || resolved === path.join(artifactRoot, segment)
139
+ );
140
+ });
141
+ }
142
+ async function isNarrowSearch(input) {
143
+ const toolInput = resolveToolInput(input);
144
+ const candidate = toolInput?.path;
145
+ if (typeof candidate !== "string" || candidate.length === 0) {
146
+ return false;
147
+ }
148
+ try {
149
+ const stats = await fs.stat(candidate);
150
+ return stats.isFile();
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+ async function resolveGraphFirstMode(cwd) {
156
+ const fromEnv = process.env.SWARMVAULT_GRAPH_FIRST?.trim().toLowerCase();
157
+ if (fromEnv === "deny" || fromEnv === "context" || fromEnv === "off") {
158
+ return fromEnv;
159
+ }
160
+ try {
161
+ const raw = await fs.readFile(path.join(cwd, "swarmvault.config.json"), "utf8");
162
+ const parsed = JSON.parse(raw);
163
+ const fromConfig = typeof parsed?.hooks?.graphFirst === "string" ? parsed.hooks.graphFirst.toLowerCase() : "";
164
+ if (fromConfig === "deny" || fromConfig === "context" || fromConfig === "off") {
165
+ return fromConfig;
166
+ }
167
+ } catch {
168
+ }
169
+ return "context";
170
+ }
99
171
  async function readHookInput() {
100
172
  let body = "";
101
173
  for await (const chunk of process.stdin) {
@@ -110,7 +182,25 @@ async function readHookInput() {
110
182
  return {};
111
183
  }
112
184
  }
113
- var REPORT_NOTE = "SwarmVault graph report exists at wiki/graph/report.md, or at $SWARMVAULT_OUT/wiki/graph/report.md when SWARMVAULT_OUT is set. Read it before broad grep/glob searching.";
185
+ function extractSearchTerm(input) {
186
+ const toolInput = resolveToolInput(input);
187
+ for (const key of ["pattern", "query", "regex"]) {
188
+ const value = toolInput?.[key];
189
+ if (typeof value === "string" && value.length > 0) {
190
+ return value;
191
+ }
192
+ }
193
+ return "<your term>";
194
+ }
195
+ function buildDenyReason(toolName, input) {
196
+ const term = extractSearchTerm(input).slice(0, 120);
197
+ return [
198
+ `SwarmVault graph-first: this repo has a compiled code graph that answers structure questions in far fewer tokens than ${toolName || "broad search"}.`,
199
+ `Run: swarmvault graph query "${term}" \u2014 it prints the top matches with page paths plus an inline excerpt of the best page, which usually answers the question without reading source. Add --context calls for caller/impact questions. Do not add --json (much larger output).`,
200
+ "Trust that answer for orientation questions instead of re-verifying in source files.",
201
+ "If the graph does not answer, repeat this exact search \u2014 it will be allowed for the rest of the session."
202
+ ].join(" ");
203
+ }
114
204
 
115
205
  // src/hooks/copilot.ts
116
206
  var AGENT_KEY = "copilot";
@@ -139,10 +229,12 @@ async function main() {
139
229
  emit({});
140
230
  process.exit(0);
141
231
  }
142
- if (isBroadSearchTool(toolName) && !await hasSeenReport(cwd, AGENT_KEY)) {
232
+ const graphFirstMode = await resolveGraphFirstMode(cwd);
233
+ if (graphFirstMode === "deny" && isBroadSearchTool(toolName) && !isVaultArtifactSearch(input, cwd) && !await isNarrowSearch(input) && !await hasSeenReport(cwd, AGENT_KEY)) {
234
+ await markReportRead(cwd, AGENT_KEY);
143
235
  emit({
144
236
  permissionDecision: "deny",
145
- permissionDecisionReason: REPORT_NOTE
237
+ permissionDecisionReason: buildDenyReason(toolName, input)
146
238
  });
147
239
  process.exit(0);
148
240
  }