@selvakumaresra/specship 0.1.1 → 0.1.3

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 (575) hide show
  1. package/package.json +8 -7
  2. package/scripts/sync-shim-version.mjs +64 -0
  3. package/dist/bin/node-version-check.d.ts +0 -37
  4. package/dist/bin/node-version-check.d.ts.map +0 -1
  5. package/dist/bin/node-version-check.js +0 -79
  6. package/dist/bin/node-version-check.js.map +0 -1
  7. package/dist/bin/specship.d.ts +0 -25
  8. package/dist/bin/specship.d.ts.map +0 -1
  9. package/dist/bin/specship.js +0 -2018
  10. package/dist/bin/specship.js.map +0 -1
  11. package/dist/bin/uninstall.d.ts +0 -13
  12. package/dist/bin/uninstall.d.ts.map +0 -1
  13. package/dist/bin/uninstall.js +0 -35
  14. package/dist/bin/uninstall.js.map +0 -1
  15. package/dist/context/formatter.d.ts +0 -30
  16. package/dist/context/formatter.d.ts.map +0 -1
  17. package/dist/context/formatter.js +0 -263
  18. package/dist/context/formatter.js.map +0 -1
  19. package/dist/context/index.d.ts +0 -119
  20. package/dist/context/index.d.ts.map +0 -1
  21. package/dist/context/index.js +0 -1289
  22. package/dist/context/index.js.map +0 -1
  23. package/dist/context/markers.d.ts +0 -19
  24. package/dist/context/markers.d.ts.map +0 -1
  25. package/dist/context/markers.js +0 -22
  26. package/dist/context/markers.js.map +0 -1
  27. package/dist/db/index.d.ts +0 -101
  28. package/dist/db/index.d.ts.map +0 -1
  29. package/dist/db/index.js +0 -276
  30. package/dist/db/index.js.map +0 -1
  31. package/dist/db/migrations.d.ts +0 -44
  32. package/dist/db/migrations.d.ts.map +0 -1
  33. package/dist/db/migrations.js +0 -427
  34. package/dist/db/migrations.js.map +0 -1
  35. package/dist/db/queries.d.ts +0 -357
  36. package/dist/db/queries.d.ts.map +0 -1
  37. package/dist/db/queries.js +0 -1504
  38. package/dist/db/queries.js.map +0 -1
  39. package/dist/db/schema.sql +0 -410
  40. package/dist/db/spec-queries.d.ts +0 -101
  41. package/dist/db/spec-queries.d.ts.map +0 -1
  42. package/dist/db/spec-queries.js +0 -675
  43. package/dist/db/spec-queries.js.map +0 -1
  44. package/dist/db/sqlite-adapter.d.ts +0 -65
  45. package/dist/db/sqlite-adapter.d.ts.map +0 -1
  46. package/dist/db/sqlite-adapter.js +0 -214
  47. package/dist/db/sqlite-adapter.js.map +0 -1
  48. package/dist/directory.d.ts +0 -57
  49. package/dist/directory.d.ts.map +0 -1
  50. package/dist/directory.js +0 -253
  51. package/dist/directory.js.map +0 -1
  52. package/dist/errors.d.ts +0 -136
  53. package/dist/errors.d.ts.map +0 -1
  54. package/dist/errors.js +0 -219
  55. package/dist/errors.js.map +0 -1
  56. package/dist/extraction/dfm-extractor.d.ts +0 -31
  57. package/dist/extraction/dfm-extractor.d.ts.map +0 -1
  58. package/dist/extraction/dfm-extractor.js +0 -151
  59. package/dist/extraction/dfm-extractor.js.map +0 -1
  60. package/dist/extraction/generated-detection.d.ts +0 -30
  61. package/dist/extraction/generated-detection.d.ts.map +0 -1
  62. package/dist/extraction/generated-detection.js +0 -80
  63. package/dist/extraction/generated-detection.js.map +0 -1
  64. package/dist/extraction/grammars.d.ts +0 -100
  65. package/dist/extraction/grammars.d.ts.map +0 -1
  66. package/dist/extraction/grammars.js +0 -426
  67. package/dist/extraction/grammars.js.map +0 -1
  68. package/dist/extraction/index.d.ts +0 -138
  69. package/dist/extraction/index.d.ts.map +0 -1
  70. package/dist/extraction/index.js +0 -1394
  71. package/dist/extraction/index.js.map +0 -1
  72. package/dist/extraction/languages/c-cpp.d.ts +0 -4
  73. package/dist/extraction/languages/c-cpp.d.ts.map +0 -1
  74. package/dist/extraction/languages/c-cpp.js +0 -171
  75. package/dist/extraction/languages/c-cpp.js.map +0 -1
  76. package/dist/extraction/languages/csharp.d.ts +0 -3
  77. package/dist/extraction/languages/csharp.d.ts.map +0 -1
  78. package/dist/extraction/languages/csharp.js +0 -73
  79. package/dist/extraction/languages/csharp.js.map +0 -1
  80. package/dist/extraction/languages/dart.d.ts +0 -3
  81. package/dist/extraction/languages/dart.d.ts.map +0 -1
  82. package/dist/extraction/languages/dart.js +0 -192
  83. package/dist/extraction/languages/dart.js.map +0 -1
  84. package/dist/extraction/languages/go.d.ts +0 -3
  85. package/dist/extraction/languages/go.d.ts.map +0 -1
  86. package/dist/extraction/languages/go.js +0 -74
  87. package/dist/extraction/languages/go.js.map +0 -1
  88. package/dist/extraction/languages/index.d.ts +0 -10
  89. package/dist/extraction/languages/index.d.ts.map +0 -1
  90. package/dist/extraction/languages/index.js +0 -51
  91. package/dist/extraction/languages/index.js.map +0 -1
  92. package/dist/extraction/languages/java.d.ts +0 -3
  93. package/dist/extraction/languages/java.d.ts.map +0 -1
  94. package/dist/extraction/languages/java.js +0 -70
  95. package/dist/extraction/languages/java.js.map +0 -1
  96. package/dist/extraction/languages/javascript.d.ts +0 -3
  97. package/dist/extraction/languages/javascript.d.ts.map +0 -1
  98. package/dist/extraction/languages/javascript.js +0 -90
  99. package/dist/extraction/languages/javascript.js.map +0 -1
  100. package/dist/extraction/languages/kotlin.d.ts +0 -3
  101. package/dist/extraction/languages/kotlin.d.ts.map +0 -1
  102. package/dist/extraction/languages/kotlin.js +0 -259
  103. package/dist/extraction/languages/kotlin.js.map +0 -1
  104. package/dist/extraction/languages/lua.d.ts +0 -3
  105. package/dist/extraction/languages/lua.d.ts.map +0 -1
  106. package/dist/extraction/languages/lua.js +0 -150
  107. package/dist/extraction/languages/lua.js.map +0 -1
  108. package/dist/extraction/languages/luau.d.ts +0 -3
  109. package/dist/extraction/languages/luau.d.ts.map +0 -1
  110. package/dist/extraction/languages/luau.js +0 -37
  111. package/dist/extraction/languages/luau.js.map +0 -1
  112. package/dist/extraction/languages/objc.d.ts +0 -3
  113. package/dist/extraction/languages/objc.d.ts.map +0 -1
  114. package/dist/extraction/languages/objc.js +0 -133
  115. package/dist/extraction/languages/objc.js.map +0 -1
  116. package/dist/extraction/languages/pascal.d.ts +0 -3
  117. package/dist/extraction/languages/pascal.d.ts.map +0 -1
  118. package/dist/extraction/languages/pascal.js +0 -66
  119. package/dist/extraction/languages/pascal.js.map +0 -1
  120. package/dist/extraction/languages/php.d.ts +0 -3
  121. package/dist/extraction/languages/php.d.ts.map +0 -1
  122. package/dist/extraction/languages/php.js +0 -107
  123. package/dist/extraction/languages/php.js.map +0 -1
  124. package/dist/extraction/languages/python.d.ts +0 -3
  125. package/dist/extraction/languages/python.d.ts.map +0 -1
  126. package/dist/extraction/languages/python.js +0 -56
  127. package/dist/extraction/languages/python.js.map +0 -1
  128. package/dist/extraction/languages/ruby.d.ts +0 -3
  129. package/dist/extraction/languages/ruby.d.ts.map +0 -1
  130. package/dist/extraction/languages/ruby.js +0 -114
  131. package/dist/extraction/languages/ruby.js.map +0 -1
  132. package/dist/extraction/languages/rust.d.ts +0 -3
  133. package/dist/extraction/languages/rust.d.ts.map +0 -1
  134. package/dist/extraction/languages/rust.js +0 -109
  135. package/dist/extraction/languages/rust.js.map +0 -1
  136. package/dist/extraction/languages/scala.d.ts +0 -3
  137. package/dist/extraction/languages/scala.d.ts.map +0 -1
  138. package/dist/extraction/languages/scala.js +0 -139
  139. package/dist/extraction/languages/scala.js.map +0 -1
  140. package/dist/extraction/languages/swift.d.ts +0 -3
  141. package/dist/extraction/languages/swift.d.ts.map +0 -1
  142. package/dist/extraction/languages/swift.js +0 -91
  143. package/dist/extraction/languages/swift.js.map +0 -1
  144. package/dist/extraction/languages/typescript.d.ts +0 -3
  145. package/dist/extraction/languages/typescript.d.ts.map +0 -1
  146. package/dist/extraction/languages/typescript.js +0 -129
  147. package/dist/extraction/languages/typescript.js.map +0 -1
  148. package/dist/extraction/liquid-extractor.d.ts +0 -52
  149. package/dist/extraction/liquid-extractor.d.ts.map +0 -1
  150. package/dist/extraction/liquid-extractor.js +0 -313
  151. package/dist/extraction/liquid-extractor.js.map +0 -1
  152. package/dist/extraction/mybatis-extractor.d.ts +0 -48
  153. package/dist/extraction/mybatis-extractor.d.ts.map +0 -1
  154. package/dist/extraction/mybatis-extractor.js +0 -198
  155. package/dist/extraction/mybatis-extractor.js.map +0 -1
  156. package/dist/extraction/parse-worker.d.ts +0 -8
  157. package/dist/extraction/parse-worker.d.ts.map +0 -1
  158. package/dist/extraction/parse-worker.js +0 -94
  159. package/dist/extraction/parse-worker.js.map +0 -1
  160. package/dist/extraction/specs/markdown-spec-extractor.d.ts +0 -59
  161. package/dist/extraction/specs/markdown-spec-extractor.d.ts.map +0 -1
  162. package/dist/extraction/specs/markdown-spec-extractor.js +0 -327
  163. package/dist/extraction/specs/markdown-spec-extractor.js.map +0 -1
  164. package/dist/extraction/specs/types.d.ts +0 -39
  165. package/dist/extraction/specs/types.d.ts.map +0 -1
  166. package/dist/extraction/specs/types.js +0 -8
  167. package/dist/extraction/specs/types.js.map +0 -1
  168. package/dist/extraction/svelte-extractor.d.ts +0 -56
  169. package/dist/extraction/svelte-extractor.d.ts.map +0 -1
  170. package/dist/extraction/svelte-extractor.js +0 -272
  171. package/dist/extraction/svelte-extractor.js.map +0 -1
  172. package/dist/extraction/tree-sitter-helpers.d.ts +0 -28
  173. package/dist/extraction/tree-sitter-helpers.d.ts.map +0 -1
  174. package/dist/extraction/tree-sitter-helpers.js +0 -103
  175. package/dist/extraction/tree-sitter-helpers.js.map +0 -1
  176. package/dist/extraction/tree-sitter-types.d.ts +0 -193
  177. package/dist/extraction/tree-sitter-types.d.ts.map +0 -1
  178. package/dist/extraction/tree-sitter-types.js +0 -10
  179. package/dist/extraction/tree-sitter-types.js.map +0 -1
  180. package/dist/extraction/tree-sitter.d.ts +0 -317
  181. package/dist/extraction/tree-sitter.d.ts.map +0 -1
  182. package/dist/extraction/tree-sitter.js +0 -3092
  183. package/dist/extraction/tree-sitter.js.map +0 -1
  184. package/dist/extraction/vue-extractor.d.ts +0 -51
  185. package/dist/extraction/vue-extractor.d.ts.map +0 -1
  186. package/dist/extraction/vue-extractor.js +0 -251
  187. package/dist/extraction/vue-extractor.js.map +0 -1
  188. package/dist/extraction/wasm/tree-sitter-lua.wasm +0 -0
  189. package/dist/extraction/wasm/tree-sitter-luau.wasm +0 -0
  190. package/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  191. package/dist/extraction/wasm/tree-sitter-scala.wasm +0 -0
  192. package/dist/extraction/wasm-runtime-flags.d.ts +0 -38
  193. package/dist/extraction/wasm-runtime-flags.d.ts.map +0 -1
  194. package/dist/extraction/wasm-runtime-flags.js +0 -106
  195. package/dist/extraction/wasm-runtime-flags.js.map +0 -1
  196. package/dist/graph/index.d.ts +0 -8
  197. package/dist/graph/index.d.ts.map +0 -1
  198. package/dist/graph/index.js +0 -13
  199. package/dist/graph/index.js.map +0 -1
  200. package/dist/graph/queries.d.ts +0 -106
  201. package/dist/graph/queries.d.ts.map +0 -1
  202. package/dist/graph/queries.js +0 -366
  203. package/dist/graph/queries.js.map +0 -1
  204. package/dist/graph/traversal.d.ts +0 -127
  205. package/dist/graph/traversal.d.ts.map +0 -1
  206. package/dist/graph/traversal.js +0 -531
  207. package/dist/graph/traversal.js.map +0 -1
  208. package/dist/index.d.ts +0 -551
  209. package/dist/index.d.ts.map +0 -1
  210. package/dist/index.js +0 -1165
  211. package/dist/index.js.map +0 -1
  212. package/dist/installer/config-writer.d.ts +0 -28
  213. package/dist/installer/config-writer.d.ts.map +0 -1
  214. package/dist/installer/config-writer.js +0 -91
  215. package/dist/installer/config-writer.js.map +0 -1
  216. package/dist/installer/index.d.ts +0 -87
  217. package/dist/installer/index.d.ts.map +0 -1
  218. package/dist/installer/index.js +0 -409
  219. package/dist/installer/index.js.map +0 -1
  220. package/dist/installer/instructions-template.d.ts +0 -18
  221. package/dist/installer/instructions-template.d.ts.map +0 -1
  222. package/dist/installer/instructions-template.js +0 -21
  223. package/dist/installer/instructions-template.js.map +0 -1
  224. package/dist/installer/targets/claude.d.ts +0 -88
  225. package/dist/installer/targets/claude.d.ts.map +0 -1
  226. package/dist/installer/targets/claude.js +0 -582
  227. package/dist/installer/targets/claude.js.map +0 -1
  228. package/dist/installer/targets/registry.d.ts +0 -19
  229. package/dist/installer/targets/registry.d.ts.map +0 -1
  230. package/dist/installer/targets/registry.js +0 -31
  231. package/dist/installer/targets/registry.js.map +0 -1
  232. package/dist/installer/targets/shared.d.ts +0 -62
  233. package/dist/installer/targets/shared.d.ts.map +0 -1
  234. package/dist/installer/targets/shared.js +0 -207
  235. package/dist/installer/targets/shared.js.map +0 -1
  236. package/dist/installer/targets/types.d.ts +0 -76
  237. package/dist/installer/targets/types.d.ts.map +0 -1
  238. package/dist/installer/targets/types.js +0 -12
  239. package/dist/installer/targets/types.js.map +0 -1
  240. package/dist/isolation/worktree.d.ts +0 -65
  241. package/dist/isolation/worktree.d.ts.map +0 -1
  242. package/dist/isolation/worktree.js +0 -231
  243. package/dist/isolation/worktree.js.map +0 -1
  244. package/dist/mcp/daemon-paths.d.ts +0 -46
  245. package/dist/mcp/daemon-paths.d.ts.map +0 -1
  246. package/dist/mcp/daemon-paths.js +0 -125
  247. package/dist/mcp/daemon-paths.js.map +0 -1
  248. package/dist/mcp/daemon.d.ts +0 -161
  249. package/dist/mcp/daemon.d.ts.map +0 -1
  250. package/dist/mcp/daemon.js +0 -403
  251. package/dist/mcp/daemon.js.map +0 -1
  252. package/dist/mcp/engine.d.ts +0 -105
  253. package/dist/mcp/engine.d.ts.map +0 -1
  254. package/dist/mcp/engine.js +0 -270
  255. package/dist/mcp/engine.js.map +0 -1
  256. package/dist/mcp/index.d.ts +0 -112
  257. package/dist/mcp/index.d.ts.map +0 -1
  258. package/dist/mcp/index.js +0 -477
  259. package/dist/mcp/index.js.map +0 -1
  260. package/dist/mcp/proxy.d.ts +0 -81
  261. package/dist/mcp/proxy.d.ts.map +0 -1
  262. package/dist/mcp/proxy.js +0 -510
  263. package/dist/mcp/proxy.js.map +0 -1
  264. package/dist/mcp/server-instructions.d.ts +0 -18
  265. package/dist/mcp/server-instructions.d.ts.map +0 -1
  266. package/dist/mcp/server-instructions.js +0 -77
  267. package/dist/mcp/server-instructions.js.map +0 -1
  268. package/dist/mcp/session.d.ts +0 -77
  269. package/dist/mcp/session.d.ts.map +0 -1
  270. package/dist/mcp/session.js +0 -294
  271. package/dist/mcp/session.js.map +0 -1
  272. package/dist/mcp/spec-tools.d.ts +0 -39
  273. package/dist/mcp/spec-tools.d.ts.map +0 -1
  274. package/dist/mcp/spec-tools.js +0 -326
  275. package/dist/mcp/spec-tools.js.map +0 -1
  276. package/dist/mcp/tools.d.ts +0 -404
  277. package/dist/mcp/tools.d.ts.map +0 -1
  278. package/dist/mcp/tools.js +0 -3066
  279. package/dist/mcp/tools.js.map +0 -1
  280. package/dist/mcp/transport.d.ts +0 -188
  281. package/dist/mcp/transport.d.ts.map +0 -1
  282. package/dist/mcp/transport.js +0 -343
  283. package/dist/mcp/transport.js.map +0 -1
  284. package/dist/mcp/version.d.ts +0 -19
  285. package/dist/mcp/version.d.ts.map +0 -1
  286. package/dist/mcp/version.js +0 -71
  287. package/dist/mcp/version.js.map +0 -1
  288. package/dist/resolution/callback-synthesizer.d.ts +0 -10
  289. package/dist/resolution/callback-synthesizer.d.ts.map +0 -1
  290. package/dist/resolution/callback-synthesizer.js +0 -1300
  291. package/dist/resolution/callback-synthesizer.js.map +0 -1
  292. package/dist/resolution/frameworks/cargo-workspace.d.ts +0 -18
  293. package/dist/resolution/frameworks/cargo-workspace.d.ts.map +0 -1
  294. package/dist/resolution/frameworks/cargo-workspace.js +0 -225
  295. package/dist/resolution/frameworks/cargo-workspace.js.map +0 -1
  296. package/dist/resolution/frameworks/csharp.d.ts +0 -8
  297. package/dist/resolution/frameworks/csharp.d.ts.map +0 -1
  298. package/dist/resolution/frameworks/csharp.js +0 -241
  299. package/dist/resolution/frameworks/csharp.js.map +0 -1
  300. package/dist/resolution/frameworks/drupal.d.ts +0 -51
  301. package/dist/resolution/frameworks/drupal.d.ts.map +0 -1
  302. package/dist/resolution/frameworks/drupal.js +0 -367
  303. package/dist/resolution/frameworks/drupal.js.map +0 -1
  304. package/dist/resolution/frameworks/expo-modules.d.ts +0 -3
  305. package/dist/resolution/frameworks/expo-modules.d.ts.map +0 -1
  306. package/dist/resolution/frameworks/expo-modules.js +0 -143
  307. package/dist/resolution/frameworks/expo-modules.js.map +0 -1
  308. package/dist/resolution/frameworks/express.d.ts +0 -8
  309. package/dist/resolution/frameworks/express.d.ts.map +0 -1
  310. package/dist/resolution/frameworks/express.js +0 -308
  311. package/dist/resolution/frameworks/express.js.map +0 -1
  312. package/dist/resolution/frameworks/fabric.d.ts +0 -3
  313. package/dist/resolution/frameworks/fabric.d.ts.map +0 -1
  314. package/dist/resolution/frameworks/fabric.js +0 -354
  315. package/dist/resolution/frameworks/fabric.js.map +0 -1
  316. package/dist/resolution/frameworks/go.d.ts +0 -8
  317. package/dist/resolution/frameworks/go.d.ts.map +0 -1
  318. package/dist/resolution/frameworks/go.js +0 -161
  319. package/dist/resolution/frameworks/go.js.map +0 -1
  320. package/dist/resolution/frameworks/index.d.ts +0 -48
  321. package/dist/resolution/frameworks/index.d.ts.map +0 -1
  322. package/dist/resolution/frameworks/index.js +0 -161
  323. package/dist/resolution/frameworks/index.js.map +0 -1
  324. package/dist/resolution/frameworks/java.d.ts +0 -8
  325. package/dist/resolution/frameworks/java.d.ts.map +0 -1
  326. package/dist/resolution/frameworks/java.js +0 -504
  327. package/dist/resolution/frameworks/java.js.map +0 -1
  328. package/dist/resolution/frameworks/laravel.d.ts +0 -13
  329. package/dist/resolution/frameworks/laravel.d.ts.map +0 -1
  330. package/dist/resolution/frameworks/laravel.js +0 -257
  331. package/dist/resolution/frameworks/laravel.js.map +0 -1
  332. package/dist/resolution/frameworks/nestjs.d.ts +0 -26
  333. package/dist/resolution/frameworks/nestjs.d.ts.map +0 -1
  334. package/dist/resolution/frameworks/nestjs.js +0 -698
  335. package/dist/resolution/frameworks/nestjs.js.map +0 -1
  336. package/dist/resolution/frameworks/play.d.ts +0 -19
  337. package/dist/resolution/frameworks/play.d.ts.map +0 -1
  338. package/dist/resolution/frameworks/play.js +0 -111
  339. package/dist/resolution/frameworks/play.js.map +0 -1
  340. package/dist/resolution/frameworks/python.d.ts +0 -10
  341. package/dist/resolution/frameworks/python.d.ts.map +0 -1
  342. package/dist/resolution/frameworks/python.js +0 -396
  343. package/dist/resolution/frameworks/python.js.map +0 -1
  344. package/dist/resolution/frameworks/react-native.d.ts +0 -3
  345. package/dist/resolution/frameworks/react-native.d.ts.map +0 -1
  346. package/dist/resolution/frameworks/react-native.js +0 -360
  347. package/dist/resolution/frameworks/react-native.js.map +0 -1
  348. package/dist/resolution/frameworks/react.d.ts +0 -8
  349. package/dist/resolution/frameworks/react.d.ts.map +0 -1
  350. package/dist/resolution/frameworks/react.js +0 -365
  351. package/dist/resolution/frameworks/react.js.map +0 -1
  352. package/dist/resolution/frameworks/ruby.d.ts +0 -8
  353. package/dist/resolution/frameworks/ruby.d.ts.map +0 -1
  354. package/dist/resolution/frameworks/ruby.js +0 -302
  355. package/dist/resolution/frameworks/ruby.js.map +0 -1
  356. package/dist/resolution/frameworks/rust.d.ts +0 -8
  357. package/dist/resolution/frameworks/rust.d.ts.map +0 -1
  358. package/dist/resolution/frameworks/rust.js +0 -304
  359. package/dist/resolution/frameworks/rust.js.map +0 -1
  360. package/dist/resolution/frameworks/svelte.d.ts +0 -9
  361. package/dist/resolution/frameworks/svelte.d.ts.map +0 -1
  362. package/dist/resolution/frameworks/svelte.js +0 -249
  363. package/dist/resolution/frameworks/svelte.js.map +0 -1
  364. package/dist/resolution/frameworks/swift-objc.d.ts +0 -37
  365. package/dist/resolution/frameworks/swift-objc.d.ts.map +0 -1
  366. package/dist/resolution/frameworks/swift-objc.js +0 -252
  367. package/dist/resolution/frameworks/swift-objc.js.map +0 -1
  368. package/dist/resolution/frameworks/swift.d.ts +0 -10
  369. package/dist/resolution/frameworks/swift.d.ts.map +0 -1
  370. package/dist/resolution/frameworks/swift.js +0 -400
  371. package/dist/resolution/frameworks/swift.js.map +0 -1
  372. package/dist/resolution/frameworks/vue.d.ts +0 -9
  373. package/dist/resolution/frameworks/vue.d.ts.map +0 -1
  374. package/dist/resolution/frameworks/vue.js +0 -306
  375. package/dist/resolution/frameworks/vue.js.map +0 -1
  376. package/dist/resolution/go-module.d.ts +0 -26
  377. package/dist/resolution/go-module.d.ts.map +0 -1
  378. package/dist/resolution/go-module.js +0 -78
  379. package/dist/resolution/go-module.js.map +0 -1
  380. package/dist/resolution/import-resolver.d.ts +0 -68
  381. package/dist/resolution/import-resolver.d.ts.map +0 -1
  382. package/dist/resolution/import-resolver.js +0 -1275
  383. package/dist/resolution/import-resolver.js.map +0 -1
  384. package/dist/resolution/index.d.ts +0 -117
  385. package/dist/resolution/index.d.ts.map +0 -1
  386. package/dist/resolution/index.js +0 -895
  387. package/dist/resolution/index.js.map +0 -1
  388. package/dist/resolution/lru-cache.d.ts +0 -24
  389. package/dist/resolution/lru-cache.d.ts.map +0 -1
  390. package/dist/resolution/lru-cache.js +0 -62
  391. package/dist/resolution/lru-cache.js.map +0 -1
  392. package/dist/resolution/name-matcher.d.ts +0 -32
  393. package/dist/resolution/name-matcher.d.ts.map +0 -1
  394. package/dist/resolution/name-matcher.js +0 -596
  395. package/dist/resolution/name-matcher.js.map +0 -1
  396. package/dist/resolution/path-aliases.d.ts +0 -68
  397. package/dist/resolution/path-aliases.d.ts.map +0 -1
  398. package/dist/resolution/path-aliases.js +0 -238
  399. package/dist/resolution/path-aliases.js.map +0 -1
  400. package/dist/resolution/spec-link-resolver.d.ts +0 -103
  401. package/dist/resolution/spec-link-resolver.d.ts.map +0 -1
  402. package/dist/resolution/spec-link-resolver.js +0 -259
  403. package/dist/resolution/spec-link-resolver.js.map +0 -1
  404. package/dist/resolution/strip-comments.d.ts +0 -27
  405. package/dist/resolution/strip-comments.d.ts.map +0 -1
  406. package/dist/resolution/strip-comments.js +0 -441
  407. package/dist/resolution/strip-comments.js.map +0 -1
  408. package/dist/resolution/swift-objc-bridge.d.ts +0 -134
  409. package/dist/resolution/swift-objc-bridge.d.ts.map +0 -1
  410. package/dist/resolution/swift-objc-bridge.js +0 -256
  411. package/dist/resolution/swift-objc-bridge.js.map +0 -1
  412. package/dist/resolution/types.d.ts +0 -216
  413. package/dist/resolution/types.d.ts.map +0 -1
  414. package/dist/resolution/types.js +0 -8
  415. package/dist/resolution/types.js.map +0 -1
  416. package/dist/resolution/workspace-packages.d.ts +0 -48
  417. package/dist/resolution/workspace-packages.d.ts.map +0 -1
  418. package/dist/resolution/workspace-packages.js +0 -208
  419. package/dist/resolution/workspace-packages.js.map +0 -1
  420. package/dist/search/query-parser.d.ts +0 -57
  421. package/dist/search/query-parser.d.ts.map +0 -1
  422. package/dist/search/query-parser.js +0 -177
  423. package/dist/search/query-parser.js.map +0 -1
  424. package/dist/search/query-utils.d.ts +0 -71
  425. package/dist/search/query-utils.d.ts.map +0 -1
  426. package/dist/search/query-utils.js +0 -380
  427. package/dist/search/query-utils.js.map +0 -1
  428. package/dist/server/cli.js +0 -152
  429. package/dist/server/index.js +0 -12
  430. package/dist/server/ingest/index.js +0 -18
  431. package/dist/server/ingest/ingestor.js +0 -406
  432. package/dist/server/ingest/parser.js +0 -104
  433. package/dist/server/ingest/pricing.js +0 -78
  434. package/dist/server/ingest/types.js +0 -9
  435. package/dist/server/ingest/watcher.js +0 -77
  436. package/dist/server/package.json +0 -3
  437. package/dist/server/project-registry.js +0 -101
  438. package/dist/server/routes/claude.js +0 -480
  439. package/dist/server/routes/graph.js +0 -149
  440. package/dist/server/routes/memory.js +0 -272
  441. package/dist/server/routes/projects.js +0 -197
  442. package/dist/server/routes/spec.js +0 -105
  443. package/dist/server/routes/status.js +0 -35
  444. package/dist/server/routes/workflow.js +0 -184
  445. package/dist/server/server.js +0 -202
  446. package/dist/sync/git-hooks.d.ts +0 -45
  447. package/dist/sync/git-hooks.d.ts.map +0 -1
  448. package/dist/sync/git-hooks.js +0 -225
  449. package/dist/sync/git-hooks.js.map +0 -1
  450. package/dist/sync/index.d.ts +0 -19
  451. package/dist/sync/index.d.ts.map +0 -1
  452. package/dist/sync/index.js +0 -35
  453. package/dist/sync/index.js.map +0 -1
  454. package/dist/sync/watch-policy.d.ts +0 -48
  455. package/dist/sync/watch-policy.d.ts.map +0 -1
  456. package/dist/sync/watch-policy.js +0 -124
  457. package/dist/sync/watch-policy.js.map +0 -1
  458. package/dist/sync/watcher.d.ts +0 -283
  459. package/dist/sync/watcher.d.ts.map +0 -1
  460. package/dist/sync/watcher.js +0 -606
  461. package/dist/sync/watcher.js.map +0 -1
  462. package/dist/sync/worktree.d.ts +0 -54
  463. package/dist/sync/worktree.d.ts.map +0 -1
  464. package/dist/sync/worktree.js +0 -137
  465. package/dist/sync/worktree.js.map +0 -1
  466. package/dist/types.d.ts +0 -623
  467. package/dist/types.d.ts.map +0 -1
  468. package/dist/types.js +0 -108
  469. package/dist/types.js.map +0 -1
  470. package/dist/ui/glyphs.d.ts +0 -42
  471. package/dist/ui/glyphs.d.ts.map +0 -1
  472. package/dist/ui/glyphs.js +0 -78
  473. package/dist/ui/glyphs.js.map +0 -1
  474. package/dist/ui/shimmer-progress.d.ts +0 -11
  475. package/dist/ui/shimmer-progress.d.ts.map +0 -1
  476. package/dist/ui/shimmer-progress.js +0 -90
  477. package/dist/ui/shimmer-progress.js.map +0 -1
  478. package/dist/ui/shimmer-worker.d.ts +0 -2
  479. package/dist/ui/shimmer-worker.d.ts.map +0 -1
  480. package/dist/ui/shimmer-worker.js +0 -118
  481. package/dist/ui/shimmer-worker.js.map +0 -1
  482. package/dist/ui/types.d.ts +0 -17
  483. package/dist/ui/types.d.ts.map +0 -1
  484. package/dist/ui/types.js +0 -3
  485. package/dist/ui/types.js.map +0 -1
  486. package/dist/utils.d.ts +0 -205
  487. package/dist/utils.d.ts.map +0 -1
  488. package/dist/utils.js +0 -549
  489. package/dist/utils.js.map +0 -1
  490. package/dist/web/chunk-2YZXEHZ2.js +0 -1
  491. package/dist/web/chunk-3GIC555L.js +0 -18
  492. package/dist/web/chunk-3IIIGRMT.js +0 -1
  493. package/dist/web/chunk-47QYKLE5.js +0 -1
  494. package/dist/web/chunk-4LHBWWP7.js +0 -1
  495. package/dist/web/chunk-4OAZLD5W.js +0 -1
  496. package/dist/web/chunk-5OQKAJAE.js +0 -1
  497. package/dist/web/chunk-7B525GKQ.js +0 -1
  498. package/dist/web/chunk-BPDXCOOZ.js +0 -1
  499. package/dist/web/chunk-DT37HTZB.js +0 -1
  500. package/dist/web/chunk-EIMUHJND.js +0 -1
  501. package/dist/web/chunk-FTESTUEO.js +0 -1
  502. package/dist/web/chunk-GLJZV6MU.js +0 -1
  503. package/dist/web/chunk-I7LS67U5.js +0 -1
  504. package/dist/web/chunk-L4TVIPSR.js +0 -1
  505. package/dist/web/chunk-MASCULC2.js +0 -1
  506. package/dist/web/chunk-MW7ICSRM.js +0 -1
  507. package/dist/web/chunk-OI5VP2A3.js +0 -1
  508. package/dist/web/chunk-RA6EBF6I.js +0 -1
  509. package/dist/web/chunk-RP3WU5Y6.js +0 -1
  510. package/dist/web/chunk-RQDRMTXN.js +0 -1
  511. package/dist/web/chunk-TQMT6UDU.js +0 -1
  512. package/dist/web/chunk-U7IYOV7T.js +0 -1
  513. package/dist/web/chunk-UE227MWF.js +0 -1
  514. package/dist/web/chunk-WV573J4K.js +0 -1
  515. package/dist/web/chunk-WVCKOJZL.js +0 -4
  516. package/dist/web/chunk-XZKLVPHE.js +0 -1
  517. package/dist/web/chunk-ZABKKHJ3.js +0 -1
  518. package/dist/web/favicon-16.png +0 -0
  519. package/dist/web/favicon-180.png +0 -0
  520. package/dist/web/favicon-32.png +0 -0
  521. package/dist/web/favicon-512.png +0 -0
  522. package/dist/web/favicon-small.svg +0 -15
  523. package/dist/web/favicon.ico +0 -0
  524. package/dist/web/favicon.svg +0 -20
  525. package/dist/web/index.html +0 -145
  526. package/dist/web/main-RI5CO5Z4.js +0 -1
  527. package/dist/web/styles-CYN7IKT4.css +0 -1
  528. package/dist/workflows/condition-evaluator.d.ts +0 -75
  529. package/dist/workflows/condition-evaluator.d.ts.map +0 -1
  530. package/dist/workflows/condition-evaluator.js +0 -282
  531. package/dist/workflows/condition-evaluator.js.map +0 -1
  532. package/dist/workflows/defaults/index.d.ts +0 -26
  533. package/dist/workflows/defaults/index.d.ts.map +0 -1
  534. package/dist/workflows/defaults/index.js +0 -94
  535. package/dist/workflows/defaults/index.js.map +0 -1
  536. package/dist/workflows/defaults/spec-fix.yaml +0 -110
  537. package/dist/workflows/defaults/spec-implement.yaml +0 -150
  538. package/dist/workflows/defaults/spec-relink.yaml +0 -81
  539. package/dist/workflows/defaults/spec-verify.yaml +0 -51
  540. package/dist/workflows/discovery.d.ts +0 -46
  541. package/dist/workflows/discovery.d.ts.map +0 -1
  542. package/dist/workflows/discovery.js +0 -193
  543. package/dist/workflows/discovery.js.map +0 -1
  544. package/dist/workflows/executor.d.ts +0 -83
  545. package/dist/workflows/executor.d.ts.map +0 -1
  546. package/dist/workflows/executor.js +0 -623
  547. package/dist/workflows/executor.js.map +0 -1
  548. package/dist/workflows/runners/approval.d.ts +0 -18
  549. package/dist/workflows/runners/approval.d.ts.map +0 -1
  550. package/dist/workflows/runners/approval.js +0 -34
  551. package/dist/workflows/runners/approval.js.map +0 -1
  552. package/dist/workflows/runners/bash.d.ts +0 -13
  553. package/dist/workflows/runners/bash.d.ts.map +0 -1
  554. package/dist/workflows/runners/bash.js +0 -143
  555. package/dist/workflows/runners/bash.js.map +0 -1
  556. package/dist/workflows/runners/cancel.d.ts +0 -10
  557. package/dist/workflows/runners/cancel.d.ts.map +0 -1
  558. package/dist/workflows/runners/cancel.js +0 -19
  559. package/dist/workflows/runners/cancel.js.map +0 -1
  560. package/dist/workflows/runners/prompt.d.ts +0 -28
  561. package/dist/workflows/runners/prompt.d.ts.map +0 -1
  562. package/dist/workflows/runners/prompt.js +0 -212
  563. package/dist/workflows/runners/prompt.js.map +0 -1
  564. package/dist/workflows/runners/script.d.ts +0 -17
  565. package/dist/workflows/runners/script.d.ts.map +0 -1
  566. package/dist/workflows/runners/script.js +0 -155
  567. package/dist/workflows/runners/script.js.map +0 -1
  568. package/dist/workflows/runners/types.d.ts +0 -51
  569. package/dist/workflows/runners/types.d.ts.map +0 -1
  570. package/dist/workflows/runners/types.js +0 -13
  571. package/dist/workflows/runners/types.js.map +0 -1
  572. package/dist/workflows/schemas/workflow.d.ts +0 -166
  573. package/dist/workflows/schemas/workflow.d.ts.map +0 -1
  574. package/dist/workflows/schemas/workflow.js +0 -437
  575. package/dist/workflows/schemas/workflow.js.map +0 -1
package/dist/mcp/tools.js DELETED
@@ -1,3066 +0,0 @@
1
- "use strict";
2
- /**
3
- * MCP Tool Definitions
4
- *
5
- * Defines the tools exposed by the SpecShip MCP server.
6
- */
7
- Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.ToolHandler = exports.tools = void 0;
9
- exports.getExploreBudget = getExploreBudget;
10
- exports.getExploreOutputBudget = getExploreOutputBudget;
11
- exports.formatStaleBanner = formatStaleBanner;
12
- exports.formatStaleFooter = formatStaleFooter;
13
- exports.getStaticTools = getStaticTools;
14
- const directory_1 = require("../directory");
15
- // Lazy-load the heavy SpecShip chain off the MCP startup path — see the same
16
- // helper in engine.ts. ToolHandler must load to answer tools/list (static
17
- // schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
18
- // SpecShip is pulled in only when a tool actually opens a project. require() is
19
- // sync + cached (CommonJS build).
20
- const loadSpecShip = () => require('../index').default;
21
- const worktree_1 = require("../sync/worktree");
22
- const query_utils_1 = require("../search/query-utils");
23
- const fs_1 = require("fs");
24
- const utils_1 = require("../utils");
25
- const generated_detection_1 = require("../extraction/generated-detection");
26
- const path_1 = require("path");
27
- /** Maximum output length to prevent context bloat (characters) */
28
- const MAX_OUTPUT_LENGTH = 15000;
29
- /**
30
- * Maximum length for free-form string inputs (query, task, symbol).
31
- * Bounds memory and CPU when a buggy or hostile MCP client sends a
32
- * huge payload — without this an attacker could ship a 100MB string
33
- * and force a full FTS5 scan / OOM the server. 10 000 characters is
34
- * far beyond any realistic legitimate query.
35
- */
36
- const MAX_INPUT_LENGTH = 10_000;
37
- /**
38
- * Maximum length for path-like string inputs (projectPath, path
39
- * filter, glob pattern). Paths beyond a few thousand chars are
40
- * never legitimate and signal abuse or a bug upstream.
41
- */
42
- const MAX_PATH_LENGTH = 4_096;
43
- /**
44
- * Rust path roots that have no file-system equivalent — `crate` is the
45
- * current crate, `super` is the parent module, `self` is the current
46
- * module. Used by `matchesSymbol` to strip these before file-path
47
- * matching so `crate::configurator::stage_apply::run` resolves the
48
- * same as `configurator::stage_apply::run`.
49
- */
50
- const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']);
51
- /**
52
- * Node kinds that contain other symbols. For these, `specship_node` with
53
- * `includeCode=true` returns a structural outline (member names + signatures
54
- * + line numbers) instead of the full body, which for a large class is a
55
- * multi-thousand-character wall of source that bloats the agent's context.
56
- */
57
- const CONTAINER_NODE_KINDS = new Set([
58
- 'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'namespace', 'module',
59
- ]);
60
- /** Last `::` / `.` / `/`-separated segment of a qualified symbol. */
61
- function lastQualifierPart(symbol) {
62
- const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
63
- return parts[parts.length - 1] ?? symbol;
64
- }
65
- /**
66
- * Calculate the recommended number of specship_explore calls based on project size.
67
- * Larger codebases need more exploration calls to cover their surface area,
68
- * but smaller ones should use fewer to avoid unnecessary overhead.
69
- */
70
- function getExploreBudget(fileCount) {
71
- if (fileCount < 500)
72
- return 1;
73
- if (fileCount < 5000)
74
- return 2;
75
- if (fileCount < 15000)
76
- return 3;
77
- if (fileCount < 25000)
78
- return 4;
79
- return 5;
80
- }
81
- function getExploreOutputBudget(fileCount) {
82
- // Tiered budget, scaled to project size. The budget is a CEILING (relevance
83
- // still gates WHAT is included), and it MUST stay under the agent's INLINE
84
- // tool-result cap (~25K chars). Above that, the host externalizes the result
85
- // to a file the agent then Reads back — re-introducing a read AND the
86
- // cache-write cost — which is exactly what a 35K vscode explore did in the
87
- // n=4 README A/B. So even large repos cap at ~24K: the answer is the handful
88
- // of ~100-line flow windows the agent would have grep-located and read (it
89
- // natively reads ~6–9 files, median 100-line ranges), NOT a sprawl of 12
90
- // files. Concentration onto the flow emerges from this cap + the named-file-
91
- // first sort dropping peripheral files. Invariant: a larger tier must never
92
- // get a smaller `maxCharsPerFile` than a smaller tier.
93
- if (fileCount < 150) {
94
- return {
95
- // ITER3: revert iter2's aggressive body shrink (forced Read fallback —
96
- // the per-file 2.5K cap pushed the agent to Read instead of node).
97
- // Back to the iter1 shape (13K/4/3.8K) but keep the test-file
98
- // hard-exclude. The cost lever for this tier lives in steering the
99
- // agent to stop after 1-2 calls, not in this budget.
100
- maxOutputChars: 13000,
101
- defaultMaxFiles: 4,
102
- maxCharsPerFile: 3800,
103
- gapThreshold: 7,
104
- maxSymbolsInFileHeader: 5,
105
- maxEdgesPerRelationshipKind: 4,
106
- includeRelationships: false,
107
- includeAdditionalFiles: false,
108
- includeCompletenessSignal: false,
109
- includeBudgetNote: false,
110
- excludeLowValueFiles: true,
111
- };
112
- }
113
- if (fileCount < 500) {
114
- return {
115
- // ITER3: same revert/keep-filter pattern as <150.
116
- maxOutputChars: 18000,
117
- defaultMaxFiles: 5,
118
- maxCharsPerFile: 3800,
119
- gapThreshold: 8,
120
- maxSymbolsInFileHeader: 6,
121
- maxEdgesPerRelationshipKind: 6,
122
- includeRelationships: false,
123
- includeAdditionalFiles: false,
124
- includeCompletenessSignal: false,
125
- includeBudgetNote: false,
126
- excludeLowValueFiles: true,
127
- };
128
- }
129
- if (fileCount < 5000) {
130
- return {
131
- // ~150-line per-file window (the native read unit) × ~6 files, capped at
132
- // the ~24K inline ceiling so the response is never externalized. Per-file
133
- // stays ≥ the <500 tier (3800) — monotonic.
134
- maxOutputChars: 24000,
135
- defaultMaxFiles: 8,
136
- maxCharsPerFile: 6500,
137
- gapThreshold: 12,
138
- maxSymbolsInFileHeader: 10,
139
- maxEdgesPerRelationshipKind: 10,
140
- includeRelationships: true,
141
- includeAdditionalFiles: true,
142
- includeCompletenessSignal: true,
143
- includeBudgetNote: true,
144
- excludeLowValueFiles: false,
145
- };
146
- }
147
- // Large + very-large repos: SAME ~24K inline ceiling (a bigger response just
148
- // externalizes — see vscode). More files indexed → more CALLS via
149
- // getExploreBudget, not a bigger single response. Per-file 7000 (≥ smaller
150
- // tiers) gives the central file a ~180-line orientation window.
151
- if (fileCount < 15000) {
152
- return {
153
- maxOutputChars: 24000,
154
- defaultMaxFiles: 8,
155
- maxCharsPerFile: 7000,
156
- gapThreshold: 15,
157
- maxSymbolsInFileHeader: 15,
158
- maxEdgesPerRelationshipKind: 15,
159
- includeRelationships: true,
160
- includeAdditionalFiles: true,
161
- includeCompletenessSignal: true,
162
- includeBudgetNote: true,
163
- excludeLowValueFiles: false,
164
- };
165
- }
166
- return {
167
- maxOutputChars: 24000,
168
- defaultMaxFiles: 8,
169
- maxCharsPerFile: 7000,
170
- gapThreshold: 15,
171
- maxSymbolsInFileHeader: 15,
172
- maxEdgesPerRelationshipKind: 15,
173
- includeRelationships: true,
174
- includeAdditionalFiles: true,
175
- includeCompletenessSignal: true,
176
- includeBudgetNote: true,
177
- excludeLowValueFiles: false,
178
- };
179
- }
180
- /**
181
- * Whether `specship_explore` should prefix source lines with their line
182
- * numbers (cat -n style: `<num>\t<code>`).
183
- *
184
- * Line numbers let the agent cite `file:line` straight from the explore
185
- * payload instead of re-Reading the file just to find a line number — the
186
- * dominant residual cost on precise-tracing questions (#185 follow-up).
187
- *
188
- * Defaults ON. Set `SPECSHIP_EXPLORE_LINENUMS=0` to disable (used by the
189
- * A/B harness to measure the payload-cost vs. read-savings tradeoff).
190
- */
191
- function exploreLineNumbersEnabled() {
192
- return process.env.SPECSHIP_EXPLORE_LINENUMS !== '0';
193
- }
194
- /**
195
- * Adaptive explore sizing (default ON). `specship_explore` skeletonizes OFF-SPINE
196
- * polymorphic-sibling files — a file whose class is one of ≥3 interchangeable
197
- * implementations of a shared interface (e.g. OkHttp's `: Interceptor` classes) —
198
- * to class + member signatures (bodies elided), keeping the on-spine exemplar full.
199
- * This sizes the response to the answer instead of the budget cap on sibling-heavy
200
- * flows (OkHttp interceptor-chain explore 28.5k→16.6k, ~28% cheaper than native
201
- * search, reads flat). It is PROVABLY INERT elsewhere: distinct pipeline steps (no
202
- * ≥3-implementer supertype, e.g. Excalidraw's `renderStaticScene`) and on-spine
203
- * files keep full source — output is byte-identical to shipped on excalidraw /
204
- * tokio / django / vscode / gin. Set `SPECSHIP_ADAPTIVE_EXPLORE=0` to disable.
205
- */
206
- function adaptiveExploreEnabled() {
207
- return process.env.SPECSHIP_ADAPTIVE_EXPLORE !== '0' && process.env.SPECSHIP_ADAPTIVE_EXPLORE !== 'false';
208
- }
209
- /**
210
- * Prefix each line of a source slice with its 1-based line number, matching
211
- * the Read tool's `cat -n` convention (number + tab) so the agent treats it
212
- * the same way it treats Read output.
213
- *
214
- * @param slice contiguous source text (already extracted from the file)
215
- * @param firstLineNumber the 1-based line number of the slice's first line
216
- */
217
- function numberSourceLines(slice, firstLineNumber) {
218
- const out = [];
219
- const split = slice.split('\n');
220
- for (let i = 0; i < split.length; i++) {
221
- out.push(`${firstLineNumber + i}\t${split[i]}`);
222
- }
223
- return out.join('\n');
224
- }
225
- /**
226
- * Per-file staleness banner emitted at the top of a tool response when the
227
- * file watcher has pending events for files referenced by the response.
228
- * The agent uses this to fall back to Read for those specific files
229
- * without waiting for the debounced sync (issue #403).
230
- */
231
- function formatStaleBanner(stale) {
232
- const now = Date.now();
233
- const lines = stale.map((p) => {
234
- const ageMs = Math.max(0, now - p.lastSeenMs);
235
- const label = p.indexing ? 'indexing in progress' : 'pending sync';
236
- return ` - ${p.path} (edited ${ageMs}ms ago, ${label})`;
237
- });
238
- return ('⚠️ Some files referenced below were edited since the last index sync — ' +
239
- 'their specship entries may be stale:\n' +
240
- lines.join('\n') +
241
- '\nFor accurate content of those specific files, Read them directly. ' +
242
- 'The rest of this response is fresh.');
243
- }
244
- /**
245
- * Compact footer listing pending files that are NOT referenced in this
246
- * response. Gives the agent a complete project-wide freshness picture
247
- * without bloating the main banner.
248
- */
249
- function formatStaleFooter(stale) {
250
- const MAX = 5;
251
- const now = Date.now();
252
- const shown = stale.slice(0, MAX);
253
- const lines = shown.map((p) => {
254
- const ageMs = Math.max(0, now - p.lastSeenMs);
255
- return ` - ${p.path} (edited ${ageMs}ms ago)`;
256
- });
257
- const more = stale.length > MAX ? `\n - …and ${stale.length - MAX} more` : '';
258
- return (`(Note: ${stale.length} file(s) elsewhere in this project are pending index ` +
259
- `sync but were not referenced above:\n${lines.join('\n')}${more})`);
260
- }
261
- /**
262
- * Common projectPath property for cross-project queries
263
- */
264
- const projectPathProperty = {
265
- type: 'string',
266
- description: 'Path to a different project with .specship/ initialized. If omitted, uses current project. Use this to query other codebases.',
267
- };
268
- /**
269
- * All SpecShip MCP tools
270
- *
271
- * Designed for minimal context usage - use specship_explore as the primary tool
272
- * (one call usually answers the whole question), and only use other tools for
273
- * targeted follow-up queries.
274
- *
275
- * All tools support cross-project queries via the optional `projectPath` parameter.
276
- */
277
- const spec_tools_1 = require("./spec-tools");
278
- exports.tools = [
279
- {
280
- name: 'specship_search',
281
- description: 'Quick symbol search by name. Returns locations only (no code). Use specship_explore instead to get the actual source / understand an area in one call.',
282
- inputSchema: {
283
- type: 'object',
284
- properties: {
285
- query: {
286
- type: 'string',
287
- description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")',
288
- },
289
- kind: {
290
- type: 'string',
291
- description: 'Filter by node kind',
292
- enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
293
- },
294
- limit: {
295
- type: 'number',
296
- description: 'Maximum results (default: 10)',
297
- default: 10,
298
- },
299
- projectPath: projectPathProperty,
300
- },
301
- required: ['query'],
302
- },
303
- },
304
- {
305
- name: 'specship_callers',
306
- description: 'List functions that call <symbol>. For the full flow, use specship_explore.',
307
- inputSchema: {
308
- type: 'object',
309
- properties: {
310
- symbol: {
311
- type: 'string',
312
- description: 'Name of the function, method, or class to find callers for',
313
- },
314
- limit: {
315
- type: 'number',
316
- description: 'Maximum number of callers to return (default: 20)',
317
- default: 20,
318
- },
319
- projectPath: projectPathProperty,
320
- },
321
- required: ['symbol'],
322
- },
323
- },
324
- {
325
- name: 'specship_callees',
326
- description: 'List functions that <symbol> calls. For the full flow, use specship_explore.',
327
- inputSchema: {
328
- type: 'object',
329
- properties: {
330
- symbol: {
331
- type: 'string',
332
- description: 'Name of the function, method, or class to find callees for',
333
- },
334
- limit: {
335
- type: 'number',
336
- description: 'Maximum number of callees to return (default: 20)',
337
- default: 20,
338
- },
339
- projectPath: projectPathProperty,
340
- },
341
- required: ['symbol'],
342
- },
343
- },
344
- {
345
- name: 'specship_impact',
346
- description: 'List symbols affected by changing <symbol>. Use before a refactor.',
347
- inputSchema: {
348
- type: 'object',
349
- properties: {
350
- symbol: {
351
- type: 'string',
352
- description: 'Name of the symbol to analyze impact for',
353
- },
354
- depth: {
355
- type: 'number',
356
- description: 'How many levels of dependencies to traverse (default: 2)',
357
- default: 2,
358
- },
359
- projectPath: projectPathProperty,
360
- },
361
- required: ['symbol'],
362
- },
363
- },
364
- {
365
- name: 'specship_node',
366
- description: 'SECONDARY (after specship_explore): get ONE symbol in full — its location, signature, callers/callees trail, and verbatim body (includeCode=true). When the name is AMBIGUOUS (an overloaded method, or the same method name on different types), it returns EVERY matching definition\'s full body in a single call — so you never need to Read a file to find the specific overload you want. For a heavily-overloaded name, pass `file` (and/or `line`) to pin the exact definition — e.g. the `file:line` a trail or another tool already showed you. Reach for this when explore trimmed a body you need. Use specship_explore for several related symbols or the full flow.',
367
- inputSchema: {
368
- type: 'object',
369
- properties: {
370
- symbol: {
371
- type: 'string',
372
- description: 'Name of the symbol to get details for',
373
- },
374
- includeCode: {
375
- type: 'boolean',
376
- description: 'Include full source code (default: false to minimize context)',
377
- default: false,
378
- },
379
- file: {
380
- type: 'string',
381
- description: 'Optional: disambiguate an overloaded name to the definition in this file (path or basename, e.g. "harness.rs").',
382
- },
383
- line: {
384
- type: 'number',
385
- description: 'Optional: disambiguate to the definition at/around this line (use with the file:line a trail showed you).',
386
- },
387
- projectPath: projectPathProperty,
388
- },
389
- required: ['symbol'],
390
- },
391
- },
392
- {
393
- name: 'specship_explore',
394
- description: 'PRIMARY TOOL — call FIRST for almost any question: how does X work, architecture, a bug, where/what is X, or surveying an area. Returns the verbatim source of the relevant symbols grouped by file in ONE capped call (Read-equivalent — do NOT re-open shown files). Query can be a natural-language question OR a bag of symbol/file names. Usually the ONLY call you need — answers without further search/node/Read/Grep.',
395
- inputSchema: {
396
- type: 'object',
397
- properties: {
398
- query: {
399
- type: 'string',
400
- description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use specship_search first to find relevant names.',
401
- },
402
- maxFiles: {
403
- type: 'number',
404
- description: 'Maximum number of files to include source code from (default: 12)',
405
- default: 12,
406
- },
407
- projectPath: projectPathProperty,
408
- },
409
- required: ['query'],
410
- },
411
- },
412
- {
413
- name: 'specship_status',
414
- description: 'Index health check (files / nodes / edges). Skip unless debugging.',
415
- inputSchema: {
416
- type: 'object',
417
- properties: {
418
- projectPath: projectPathProperty,
419
- },
420
- },
421
- },
422
- {
423
- name: 'specship_files',
424
- description: 'Indexed file tree with language + symbol counts. Faster than Glob for project layout.',
425
- inputSchema: {
426
- type: 'object',
427
- properties: {
428
- path: {
429
- type: 'string',
430
- description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.',
431
- },
432
- pattern: {
433
- type: 'string',
434
- description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")',
435
- },
436
- format: {
437
- type: 'string',
438
- description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)',
439
- enum: ['tree', 'flat', 'grouped'],
440
- default: 'tree',
441
- },
442
- includeMetadata: {
443
- type: 'boolean',
444
- description: 'Include file metadata like language and symbol count (default: true)',
445
- default: true,
446
- },
447
- maxDepth: {
448
- type: 'number',
449
- description: 'Maximum directory depth to show (default: unlimited)',
450
- },
451
- projectPath: projectPathProperty,
452
- },
453
- },
454
- },
455
- // Spec-layer tools (v5): see ./spec-tools.ts for handlers.
456
- ...spec_tools_1.specToolDefinitions,
457
- ];
458
- /**
459
- * Allowlist-filtered tool definitions WITHOUT an engine — the static surface the
460
- * proxy answers `tools/list` with before any project is open. Mirrors
461
- * `ToolHandler.getTools()` in the no-SpecShip case (the dynamic per-repo budget
462
- * note in a description only adds once `cg` is loaded; the schemas are static).
463
- */
464
- function getStaticTools() {
465
- const raw = process.env.SPECSHIP_MCP_TOOLS;
466
- if (!raw || !raw.trim())
467
- return exports.tools;
468
- const allow = new Set(raw.split(',').map(s => s.trim().replace(/^specship_/, '')).filter(Boolean));
469
- return allow.size ? exports.tools.filter(t => allow.has(t.name.replace(/^specship_/, ''))) : exports.tools;
470
- }
471
- /**
472
- * Tool handler that executes tools against a SpecShip instance
473
- *
474
- * Supports cross-project queries via the projectPath parameter.
475
- * Other projects are opened on-demand and cached for performance.
476
- */
477
- class ToolHandler {
478
- cg;
479
- // Cache of opened SpecShip instances for cross-project queries
480
- projectCache = new Map();
481
- // The directory the server last searched for a default project. Surfaced in
482
- // the "not initialized" error so users can see why detection missed.
483
- defaultProjectHint = null;
484
- // Per-start-path cache of the git worktree/index mismatch (issue #155). The
485
- // mismatch is a fixed property of (where the request came from → which
486
- // .specship/ it resolves to), so the up-to-two `git rev-parse` spawns run
487
- // once and every later tool call reuses the result — never shelling out to
488
- // git on the hot path. `undefined` = not computed yet; `null` = no mismatch.
489
- worktreeMismatchCache = new Map();
490
- // Gate that the MCP engine pokes after `cg.open()` so the first tool call
491
- // blocks on the post-open filesystem reconcile (catch-up sync). Without
492
- // this, a tool call that races past `catchUpSync()` serves rows for files
493
- // that were deleted (or edited) while no MCP server was running — and the
494
- // per-file staleness banner can't help, because `getPendingFiles()` is
495
- // populated by the watcher, not by catch-up. Cleared on first await so
496
- // subsequent calls don't pay any cost.
497
- catchUpGate = null;
498
- constructor(cg) {
499
- this.cg = cg;
500
- }
501
- /**
502
- * Update the default SpecShip instance (e.g. after lazy initialization)
503
- */
504
- setDefaultSpecShip(cg) {
505
- this.cg = cg;
506
- }
507
- /**
508
- * Engine-only: register the catch-up sync promise so the next `execute()`
509
- * call awaits it before serving. The handler swallows rejections (the
510
- * engine logs them) so a sync failure never propagates as a tool error;
511
- * we still want to serve a best-effort result over the same potentially-
512
- * stale data, which is what would have happened without the gate.
513
- */
514
- setCatchUpGate(p) {
515
- this.catchUpGate = p;
516
- }
517
- /**
518
- * Record the directory the server tried to resolve the default project from.
519
- * Used only to make the "no default project" error actionable.
520
- */
521
- setDefaultProjectHint(searchedPath) {
522
- this.defaultProjectHint = searchedPath;
523
- }
524
- /**
525
- * Whether a default SpecShip instance is available
526
- */
527
- hasDefaultSpecShip() {
528
- return this.cg !== null;
529
- }
530
- /**
531
- * Optional allowlist of exposed tools, parsed from the SPECSHIP_MCP_TOOLS
532
- * env var (comma-separated short names, e.g. "trace,search,node,context").
533
- * Unset/empty → every tool is exposed. Lets an operator (or an A/B harness)
534
- * trim the tool surface without rebuilding the client config; the ablated
535
- * tool is then truly absent from ListTools rather than merely denied on call.
536
- * Matching is on the short form, so "node" and "specship_node" both work.
537
- */
538
- toolAllowlist() {
539
- const raw = process.env.SPECSHIP_MCP_TOOLS;
540
- if (!raw || !raw.trim())
541
- return null;
542
- const short = (s) => s.trim().replace(/^specship_/, '');
543
- const set = new Set(raw.split(',').map(short).filter(Boolean));
544
- return set.size ? set : null;
545
- }
546
- /** Whether a tool name passes the SPECSHIP_MCP_TOOLS allowlist (if any). */
547
- isToolAllowed(name) {
548
- const allow = this.toolAllowlist();
549
- return !allow || allow.has(name.replace(/^specship_/, ''));
550
- }
551
- /**
552
- * Get tool definitions with dynamic descriptions based on project size.
553
- * The specship_explore tool description includes a budget recommendation
554
- * scaled to the number of indexed files. Honors the SPECSHIP_MCP_TOOLS
555
- * allowlist so a trimmed surface is reflected in ListTools.
556
- */
557
- getTools() {
558
- const allow = this.toolAllowlist();
559
- let visible = allow
560
- ? exports.tools.filter(t => allow.has(t.name.replace(/^specship_/, '')))
561
- : exports.tools;
562
- if (!this.cg)
563
- return visible;
564
- try {
565
- const stats = this.cg.getStats();
566
- const budget = getExploreBudget(stats.fileCount);
567
- // Tiny-repo tool gating: on projects under TINY_REPO_FILE_THRESHOLD
568
- // files, only expose the 5 core tools (search, context, node,
569
- // explore, trace). The 5 omitted tools (callers, callees, impact,
570
- // status, files) reduce to one grep at this scale.
571
- //
572
- // n=2 audits ruled out cutting below 5 tools:
573
- // - 3-tool gate (search + context + trace): cost regressed on
574
- // cobra/ky/sinatra. The agent fell back to raw Reads to cover
575
- // what specship_node + specship_explore would have answered.
576
- // - 1-tool gate (search only): catastrophic regression — express
577
- // went from -43% WIN to +107% LOSS. With only search, the agent
578
- // can't navigate the call graph structurally and reads everything.
579
- //
580
- // 5 is the empirical lower bound. Tools beyond search/context/
581
- // node/explore/trace pay overhead that the agent doesn't recoup
582
- // on tiny-repo flow questions.
583
- // ITER4: raise threshold 150 → 500 so single-file frameworks
584
- // (sinatra at 159, slim_framework around 200) also get the
585
- // 5-tool surface. The empirical 5-tool floor was set on <150
586
- // probes; iter3 measurement showed sinatra is structurally the
587
- // SAME problem as cobra (single-file WITHOUT-arm Read wins),
588
- // so it deserves the same gating.
589
- const TINY_REPO_FILE_THRESHOLD = 500;
590
- const TINY_REPO_CORE_TOOLS = new Set([
591
- 'specship_explore',
592
- 'specship_search',
593
- 'specship_node',
594
- ]);
595
- if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
596
- visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
597
- }
598
- return visible.map(tool => {
599
- if (tool.name === 'specship_explore') {
600
- return {
601
- ...tool,
602
- description: `${tool.description} Budget: make at most ${budget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).`,
603
- };
604
- }
605
- return tool;
606
- });
607
- }
608
- catch {
609
- return visible;
610
- }
611
- }
612
- /**
613
- * Get SpecShip instance for a project
614
- *
615
- * If projectPath is provided, opens that project's SpecShip (cached).
616
- * Otherwise returns the default SpecShip instance.
617
- *
618
- * Walks up parent directories to find the nearest .specship/ folder,
619
- * similar to how git finds .git/ directories.
620
- */
621
- getSpecShip(projectPath) {
622
- if (!projectPath) {
623
- if (!this.cg) {
624
- const searched = this.defaultProjectHint ?? process.cwd();
625
- throw new Error('No SpecShip project is loaded for this session.\n' +
626
- `Searched for a .specship/ directory starting from: ${searched}\n` +
627
- 'The index is likely fine — this is a working-directory detection issue: ' +
628
- "the MCP client launched the server outside your project and didn't report the " +
629
- 'workspace root. Fix it either way:\n' +
630
- ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
631
- ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]');
632
- }
633
- return this.cg;
634
- }
635
- // Check cache first (using original path as key)
636
- if (this.projectCache.has(projectPath)) {
637
- return this.projectCache.get(projectPath);
638
- }
639
- // Reject sensitive system directories before opening. Only validate a
640
- // path that actually exists — a nested or not-yet-created sub-path of a
641
- // real project must still be allowed to resolve UP to its .specship/
642
- // root below (issue #238), so we don't run the existence-checking
643
- // validator on paths that are meant to walk up.
644
- if ((0, fs_1.existsSync)(projectPath)) {
645
- const pathError = (0, utils_1.validateProjectPath)(projectPath);
646
- if (pathError) {
647
- throw new Error(pathError);
648
- }
649
- }
650
- // Walk up parent directories to find nearest .specship/
651
- const resolvedRoot = (0, directory_1.findNearestSpecShipRoot)(projectPath);
652
- if (!resolvedRoot) {
653
- throw new Error(`SpecShip not initialized in ${projectPath}. Run 'specship init' in that project first.`);
654
- }
655
- // If the path resolves to the default project, reuse the already-open
656
- // default instance rather than opening a SECOND connection to the same DB.
657
- // A duplicate connection serializes reads against the watcher's auto-sync
658
- // writes; on the wasm backend (no WAL) that surfaces as intermittent
659
- // "database is locked" on concurrent tool calls. See issue #238. Deliberately
660
- // not cached under projectPath — the server owns and closes the default
661
- // instance, so routing it through projectCache.closeAll() would double-close it.
662
- if (this.cg && this.cg.getProjectRoot() === resolvedRoot) {
663
- return this.cg;
664
- }
665
- // Check if we already have this resolved root cached (different path, same project)
666
- if (this.projectCache.has(resolvedRoot)) {
667
- const cg = this.projectCache.get(resolvedRoot);
668
- // Cache under original path too for faster future lookups
669
- this.projectCache.set(projectPath, cg);
670
- return cg;
671
- }
672
- // Open and cache under both paths
673
- const cg = loadSpecShip().openSync(resolvedRoot);
674
- this.projectCache.set(resolvedRoot, cg);
675
- if (projectPath !== resolvedRoot) {
676
- this.projectCache.set(projectPath, cg);
677
- }
678
- return cg;
679
- }
680
- /**
681
- * Close all cached project connections
682
- */
683
- closeAll() {
684
- for (const cg of this.projectCache.values()) {
685
- cg.close();
686
- }
687
- this.projectCache.clear();
688
- this.worktreeMismatchCache.clear();
689
- }
690
- /**
691
- * Validate that a value is a non-empty string within length bounds.
692
- *
693
- * The `maxLength` cap protects against MCP clients that ship huge
694
- * payloads (10MB+ query strings either by accident or maliciously).
695
- * Without this, a single oversized input can pin the FTS5 index or
696
- * exhaust memory before any real work runs.
697
- */
698
- validateString(value, name, maxLength = MAX_INPUT_LENGTH) {
699
- if (typeof value !== 'string' || value.length === 0) {
700
- return this.errorResult(`${name} must be a non-empty string`);
701
- }
702
- if (value.length > maxLength) {
703
- return this.errorResult(`${name} exceeds maximum length of ${maxLength} characters (got ${value.length})`);
704
- }
705
- return value;
706
- }
707
- /**
708
- * Validate an optional path-like string input. Returns the value if
709
- * valid (or undefined), or a ToolResult with the error.
710
- */
711
- validateOptionalPath(value, name) {
712
- if (value === undefined || value === null)
713
- return undefined;
714
- if (typeof value !== 'string') {
715
- return this.errorResult(`${name} must be a string`);
716
- }
717
- if (value.length > MAX_PATH_LENGTH) {
718
- return this.errorResult(`${name} exceeds maximum length of ${MAX_PATH_LENGTH} characters (got ${value.length})`);
719
- }
720
- return value;
721
- }
722
- /**
723
- * Cached git worktree/index mismatch for a tool call's effective project.
724
- *
725
- * The "effective project" is what the request targets: an explicit
726
- * `projectPath` arg, else the directory the server resolved its default
727
- * project from (`defaultProjectHint`), else cwd. Memoized per start path —
728
- * see `worktreeMismatchCache`. Best-effort: if the project can't be resolved
729
- * (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never
730
- * broken by this check.
731
- */
732
- worktreeMismatchFor(projectPath) {
733
- const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd();
734
- const cached = this.worktreeMismatchCache.get(startPath);
735
- if (cached !== undefined)
736
- return cached;
737
- let mismatch = null;
738
- try {
739
- mismatch = (0, worktree_1.detectWorktreeIndexMismatch)(startPath, this.getSpecShip(projectPath).getProjectRoot());
740
- }
741
- catch {
742
- // No resolvable project (or any other resolution error) → nothing to warn.
743
- mismatch = null;
744
- }
745
- this.worktreeMismatchCache.set(startPath, mismatch);
746
- return mismatch;
747
- }
748
- /**
749
- * Prefix a successful read-tool result with a compact worktree-mismatch
750
- * notice when the resolved index belongs to a different git working tree than
751
- * the caller's (issue #155). Without this, an agent in a nested worktree
752
- * silently trusts main-branch results. No-op on error results and when there
753
- * is no mismatch. `specship_status` is excluded — it embeds its own verbose
754
- * warning — so it stays out of this path.
755
- */
756
- withWorktreeNotice(result, projectPath) {
757
- if (result.isError)
758
- return result;
759
- const mismatch = this.worktreeMismatchFor(projectPath);
760
- if (!mismatch)
761
- return result;
762
- const notice = (0, worktree_1.worktreeMismatchNotice)(mismatch);
763
- const [first, ...rest] = result.content;
764
- if (first && first.type === 'text') {
765
- return { ...result, content: [{ type: 'text', text: `${notice}\n\n${first.text}` }, ...rest] };
766
- }
767
- return result;
768
- }
769
- /**
770
- * Annotate a successful read-tool result with per-file staleness — the
771
- * non-blocking answer to issue #403. The file watcher tracks every event
772
- * it sees per path; here we intersect "files referenced in this response"
773
- * against that pending set and prepend a compact banner so the agent can
774
- * fall back to Read for those *specific* files without waiting for the
775
- * debounced sync to fire. Other pending files in the project (not
776
- * referenced by this response) get a small footer so the agent has a
777
- * complete picture without bloating the banner.
778
- *
779
- * Cost when nothing is pending — the common case — is one boolean check.
780
- * No I/O, no parsing of markdown beyond a per-pending-file substring scan.
781
- */
782
- withStalenessNotice(result, projectPath) {
783
- if (result.isError)
784
- return result;
785
- let cg;
786
- try {
787
- cg = this.getSpecShip(projectPath);
788
- }
789
- catch {
790
- return result; // no default project — leave as is
791
- }
792
- // Cross-project `projectPath` calls open a cached SpecShip WITHOUT a
793
- // watcher (watchers are only attached to the default session project).
794
- // When the cross-project path happens to be the same project as the
795
- // default cg, the cached instance is the wrong one — its pendingFiles is
796
- // permanently empty. Detect the equal-path case and prefer the default
797
- // cg so the staleness signal still fires when an agent passes the
798
- // explicit projectPath form of its own project.
799
- if (this.cg && cg !== this.cg) {
800
- try {
801
- const sameProject = (0, path_1.resolve)(this.cg.getProjectRoot()) === (0, path_1.resolve)(cg.getProjectRoot());
802
- if (sameProject)
803
- cg = this.cg;
804
- }
805
- catch {
806
- /* getProjectRoot may throw on a closed instance — leave cg as is */
807
- }
808
- }
809
- // Defensive: some test fakes inject a partial SpecShip stub without the
810
- // newer pending-files API. Treat missing/throwing as "no pending files."
811
- let pending = [];
812
- try {
813
- pending = cg.getPendingFiles?.() ?? [];
814
- }
815
- catch {
816
- return result;
817
- }
818
- if (pending.length === 0)
819
- return result;
820
- const [first, ...rest] = result.content;
821
- if (!first || first.type !== 'text')
822
- return result;
823
- const text = first.text;
824
- const inResponse = [];
825
- const elsewhere = [];
826
- for (const p of pending) {
827
- // Substring match against the project-relative POSIX path — that's
828
- // exactly the format both the watcher and every specship response
829
- // emit, so a plain includes() is sufficient and avoids regex pitfalls.
830
- if (text.includes(p.path))
831
- inResponse.push(p);
832
- else
833
- elsewhere.push(p);
834
- }
835
- let banner = '';
836
- if (inResponse.length > 0) {
837
- banner = formatStaleBanner(inResponse);
838
- }
839
- let footer = '';
840
- if (elsewhere.length > 0) {
841
- footer = formatStaleFooter(elsewhere);
842
- }
843
- if (!banner && !footer)
844
- return result;
845
- const composed = [banner, text, footer].filter(Boolean).join('\n\n');
846
- return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
847
- }
848
- /**
849
- * Execute a tool by name
850
- */
851
- async execute(toolName, args) {
852
- try {
853
- // Block the first tool call on the engine's post-open reconcile so we
854
- // never serve rows for files deleted/edited while no MCP server was
855
- // running. The gate is cleared after first await — subsequent calls
856
- // pay nothing. Catch-up failures are logged by the engine; we
857
- // proceed regardless so a transient sync error never breaks tools.
858
- if (this.catchUpGate) {
859
- const gate = this.catchUpGate;
860
- this.catchUpGate = null;
861
- try {
862
- await gate;
863
- }
864
- catch { /* engine already logged */ }
865
- }
866
- // Honor the optional tool allowlist (SPECSHIP_MCP_TOOLS): a trimmed
867
- // surface rejects ablated tools defensively even if a client cached them.
868
- if (!this.isToolAllowed(toolName)) {
869
- return this.errorResult(`Tool ${toolName} is disabled via SPECSHIP_MCP_TOOLS`);
870
- }
871
- // Cross-cutting input validation. All tools accept an optional
872
- // `projectPath` and most accept either `query`, `task`, or
873
- // `symbol` — bound their lengths centrally so individual handlers
874
- // can stay focused on tool-specific logic.
875
- const pathCheck = this.validateOptionalPath(args.projectPath, 'projectPath');
876
- if (typeof pathCheck === 'object' && pathCheck !== undefined) {
877
- return pathCheck;
878
- }
879
- // The `path` and `pattern` properties used by specship_files are
880
- // also path-shaped — apply the same cap.
881
- if (args.path !== undefined) {
882
- const check = this.validateOptionalPath(args.path, 'path');
883
- if (typeof check === 'object' && check !== undefined)
884
- return check;
885
- }
886
- if (args.pattern !== undefined) {
887
- const check = this.validateOptionalPath(args.pattern, 'pattern');
888
- if (typeof check === 'object' && check !== undefined)
889
- return check;
890
- }
891
- // Read tools resolve through a single result variable so cross-cutting
892
- // notices — worktree-index mismatch (issue #155) and per-file
893
- // staleness (issue #403) — can be applied in one place. status embeds
894
- // its own verbose worktree warning but still flows through the
895
- // staleness wrapper so its pending-files section stays consistent
896
- // with what the read tools surface.
897
- let result;
898
- switch (toolName) {
899
- case 'specship_search':
900
- result = await this.handleSearch(args);
901
- break;
902
- case 'specship_callers':
903
- result = await this.handleCallers(args);
904
- break;
905
- case 'specship_callees':
906
- result = await this.handleCallees(args);
907
- break;
908
- case 'specship_impact':
909
- result = await this.handleImpact(args);
910
- break;
911
- case 'specship_explore':
912
- result = await this.handleExplore(args);
913
- break;
914
- case 'specship_node':
915
- result = await this.handleNode(args);
916
- break;
917
- case 'specship_status':
918
- // status embeds the pending-files list as a first-class section
919
- // (see handleStatus), so we skip the auto-banner wrapper here to
920
- // avoid duplicating the same info at the top of the response.
921
- return await this.handleStatus(args);
922
- case 'specship_files':
923
- result = await this.handleFiles(args);
924
- break;
925
- case 'specship_spec':
926
- result = await (0, spec_tools_1.handleSpecshipSpec)(this.getSpecShip(args.projectPath), args);
927
- break;
928
- case 'specship_link_assert':
929
- result = await (0, spec_tools_1.handleSpecshipLinkAssert)(this.getSpecShip(args.projectPath), args);
930
- break;
931
- case 'specship_link_verify':
932
- result = await (0, spec_tools_1.handleSpecshipLinkVerify)(this.getSpecShip(args.projectPath), args);
933
- break;
934
- case 'specship_drifted':
935
- result = await (0, spec_tools_1.handleSpecshipDrifted)(this.getSpecShip(args.projectPath), args);
936
- break;
937
- default:
938
- return this.errorResult(`Unknown tool: ${toolName}`);
939
- }
940
- const withWorktree = this.withWorktreeNotice(result, args.projectPath);
941
- return this.withStalenessNotice(withWorktree, args.projectPath);
942
- }
943
- catch (err) {
944
- return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
945
- }
946
- }
947
- /**
948
- * Handle specship_search
949
- */
950
- async handleSearch(args) {
951
- const query = this.validateString(args.query, 'query');
952
- if (typeof query !== 'string')
953
- return query;
954
- const cg = this.getSpecShip(args.projectPath);
955
- const kind = args.kind;
956
- const rawLimit = Number(args.limit) || 10;
957
- const limit = (0, utils_1.clamp)(rawLimit, 1, 100);
958
- const results = cg.searchNodes(query, {
959
- limit,
960
- kinds: kind ? [kind] : undefined,
961
- });
962
- if (results.length === 0) {
963
- return this.textResult(`No results found for "${query}"`);
964
- }
965
- // Down-rank generated files within the FTS-returned set so a search
966
- // for "Send" surfaces the hand-written keeper before .pb.go stubs
967
- // that share the name. Stable: only reorders generated vs. not.
968
- const ranked = [...results].sort((a, b) => {
969
- const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
970
- const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
971
- return aGen - bGen;
972
- });
973
- const formatted = this.formatSearchResults(ranked);
974
- return this.textResult(this.truncateOutput(formatted));
975
- }
976
- /**
977
- * Handle specship_callers
978
- */
979
- async handleCallers(args) {
980
- const symbol = this.validateString(args.symbol, 'symbol');
981
- if (typeof symbol !== 'string')
982
- return symbol;
983
- const cg = this.getSpecShip(args.projectPath);
984
- const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
985
- const allMatches = this.findAllSymbols(cg, symbol);
986
- if (allMatches.nodes.length === 0) {
987
- return this.textResult(`Symbol "${symbol}" not found in the codebase`);
988
- }
989
- // Aggregate callers across all matching symbols
990
- const seen = new Set();
991
- const allCallers = [];
992
- for (const node of allMatches.nodes) {
993
- for (const c of cg.getCallers(node.id)) {
994
- if (!seen.has(c.node.id)) {
995
- seen.add(c.node.id);
996
- allCallers.push(c.node);
997
- }
998
- }
999
- }
1000
- if (allCallers.length === 0) {
1001
- return this.textResult(`No callers found for "${symbol}"${allMatches.note}`);
1002
- }
1003
- const formatted = this.formatNodeList(allCallers.slice(0, limit), `Callers of ${symbol}`) + allMatches.note;
1004
- return this.textResult(this.truncateOutput(formatted));
1005
- }
1006
- /**
1007
- * Handle specship_callees
1008
- */
1009
- async handleCallees(args) {
1010
- const symbol = this.validateString(args.symbol, 'symbol');
1011
- if (typeof symbol !== 'string')
1012
- return symbol;
1013
- const cg = this.getSpecShip(args.projectPath);
1014
- const limit = (0, utils_1.clamp)(args.limit || 20, 1, 100);
1015
- const allMatches = this.findAllSymbols(cg, symbol);
1016
- if (allMatches.nodes.length === 0) {
1017
- return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1018
- }
1019
- // Aggregate callees across all matching symbols
1020
- const seen = new Set();
1021
- const allCallees = [];
1022
- for (const node of allMatches.nodes) {
1023
- for (const c of cg.getCallees(node.id)) {
1024
- if (!seen.has(c.node.id)) {
1025
- seen.add(c.node.id);
1026
- allCallees.push(c.node);
1027
- }
1028
- }
1029
- }
1030
- if (allCallees.length === 0) {
1031
- return this.textResult(`No callees found for "${symbol}"${allMatches.note}`);
1032
- }
1033
- const formatted = this.formatNodeList(allCallees.slice(0, limit), `Callees of ${symbol}`) + allMatches.note;
1034
- return this.textResult(this.truncateOutput(formatted));
1035
- }
1036
- /**
1037
- * Handle specship_impact
1038
- */
1039
- async handleImpact(args) {
1040
- const symbol = this.validateString(args.symbol, 'symbol');
1041
- if (typeof symbol !== 'string')
1042
- return symbol;
1043
- const cg = this.getSpecShip(args.projectPath);
1044
- const depth = (0, utils_1.clamp)(args.depth || 2, 1, 10);
1045
- const allMatches = this.findAllSymbols(cg, symbol);
1046
- if (allMatches.nodes.length === 0) {
1047
- return this.textResult(`Symbol "${symbol}" not found in the codebase`);
1048
- }
1049
- // Aggregate impact across all matching symbols
1050
- const mergedNodes = new Map();
1051
- const mergedEdges = [];
1052
- const seenEdges = new Set();
1053
- for (const node of allMatches.nodes) {
1054
- const impact = cg.getImpactRadius(node.id, depth);
1055
- for (const [id, n] of impact.nodes) {
1056
- mergedNodes.set(id, n);
1057
- }
1058
- for (const e of impact.edges) {
1059
- const key = `${e.source}->${e.target}:${e.kind}`;
1060
- if (!seenEdges.has(key)) {
1061
- seenEdges.add(key);
1062
- mergedEdges.push(e);
1063
- }
1064
- }
1065
- }
1066
- const mergedImpact = {
1067
- nodes: mergedNodes,
1068
- edges: mergedEdges,
1069
- roots: allMatches.nodes.map(n => n.id),
1070
- };
1071
- const formatted = this.formatImpact(symbol, mergedImpact) + allMatches.note;
1072
- return this.textResult(this.truncateOutput(formatted));
1073
- }
1074
- /**
1075
- * Describe a synthesized (dynamic-dispatch) edge for human output: how the
1076
- * callback was wired up — the bridge static parsing can't see. Returns null
1077
- * for ordinary static edges. Used by trace + the node trail so a synthesized
1078
- * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
1079
- */
1080
- synthEdgeNote(edge) {
1081
- if (!edge || edge.provenance !== 'heuristic')
1082
- return null;
1083
- const m = edge.metadata;
1084
- const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined;
1085
- const at = registeredAt ? ` @${registeredAt}` : '';
1086
- if (m?.synthesizedBy === 'callback') {
1087
- const via = m.via ? `\`${String(m.via)}\`` : 'a registrar';
1088
- const field = m.field ? ` on .${String(m.field)}` : '';
1089
- return {
1090
- label: `callback — registered via ${via}${field} (dynamic dispatch)`,
1091
- compact: `dynamic: callback via ${via}${at}`,
1092
- registeredAt,
1093
- };
1094
- }
1095
- if (m?.synthesizedBy === 'event-emitter') {
1096
- const ev = m.event ? `\`${String(m.event)}\`` : 'an event';
1097
- return {
1098
- label: `event ${ev} — emit → handler (dynamic dispatch)`,
1099
- compact: `dynamic: event ${ev}${at}`,
1100
- registeredAt,
1101
- };
1102
- }
1103
- if (m?.synthesizedBy === 'react-render') {
1104
- return {
1105
- label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`,
1106
- compact: `dynamic: React re-render via setState${at}`,
1107
- registeredAt,
1108
- };
1109
- }
1110
- if (m?.synthesizedBy === 'jsx-render') {
1111
- const child = m.via ? `<${String(m.via)}>` : 'a child component';
1112
- return {
1113
- label: `renders ${child} (JSX child — dynamic dispatch)`,
1114
- compact: `dynamic: renders ${child}`,
1115
- registeredAt,
1116
- };
1117
- }
1118
- if (m?.synthesizedBy === 'vue-handler') {
1119
- const ev = m.event ? `@${String(m.event)}` : 'a template event';
1120
- return {
1121
- label: `Vue template handler — bound to ${ev} (dynamic dispatch)`,
1122
- compact: `dynamic: Vue ${ev} handler`,
1123
- registeredAt,
1124
- };
1125
- }
1126
- if (m?.synthesizedBy === 'interface-impl') {
1127
- return {
1128
- label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`,
1129
- compact: `dynamic: interface → impl${at}`,
1130
- registeredAt,
1131
- };
1132
- }
1133
- if (m?.synthesizedBy === 'closure-collection') {
1134
- const field = m.field ? `\`${String(m.field)}\`` : 'a collection';
1135
- return {
1136
- label: `closure collection — runs handlers appended to ${field} (dynamic dispatch)`,
1137
- compact: `dynamic: runs ${field} handlers${at}`,
1138
- registeredAt,
1139
- };
1140
- }
1141
- return null;
1142
- }
1143
- /**
1144
- * Flow-from-named-symbols: an agent's specship_explore query is a bag of
1145
- * symbol names that usually spans the flow it's investigating (e.g.
1146
- * "PmsProductController getList PmsProductService list PmsProductServiceImpl").
1147
- * Surface the longest call chain AMONG those named symbols — scoped to what the
1148
- * agent explicitly named, so (unlike a fuzzy relevance set) there's no
1149
- * wrong-feature wandering. Rides synthesized edges, so controller→service-
1150
- * interface→impl shows up. Returns '' if no chain of >=3 nodes exists.
1151
- *
1152
- * Ambiguous tokens (Java `list` → dozens of nodes) are disambiguated by
1153
- * CO-NAMING: the agent names the class too, so we keep only `list` candidates
1154
- * whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
1155
- * dropping unrelated `OmsOrderService::list`.
1156
- */
1157
- buildFlowFromNamedSymbols(cg, query) {
1158
- const EMPTY = { text: '', pathNodeIds: new Set(), namedNodeIds: new Set(), uniqueNamedNodeIds: new Set() };
1159
- try {
1160
- const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
1161
- // Strip only a REAL file extension (Create.cs → Create); KEEP qualified
1162
- // names (Class.method / Class::method) — the agent's most precise input,
1163
- // resolved exactly by findAllSymbols. (The old strip mangled Class.method
1164
- // into Class, throwing the method away.)
1165
- const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte)$/i;
1166
- const tokens = [...new Set(query.split(/[\s,()[\]]+/)
1167
- .map((t) => t.replace(FILE_EXT, '').trim())
1168
- .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
1169
- if (tokens.length < 2)
1170
- return EMPTY;
1171
- // Pool of name SEGMENTS (Class + method from every token) used to
1172
- // disambiguate an ambiguous SIMPLE name: keep a candidate only if its
1173
- // CONTAINER class is itself named in the query.
1174
- const segPool = new Set();
1175
- for (const t of tokens)
1176
- for (const s of t.toLowerCase().split(/::|\./))
1177
- if (s)
1178
- segPool.add(s);
1179
- const named = new Map();
1180
- // Nodes whose token is SPECIFIC — a (near-)unique callable name (<=3 defs in
1181
- // the whole graph). These are safe to SPARE a file on: the agent named THIS
1182
- // method (`getResponseWithInterceptorChain`, 1 def). A hyper-polymorphic name
1183
- // (`as_sql`, 110 defs across every Expression/Compiler subclass) is NOT here,
1184
- // so naming it doesn't keep every backend variant full and flood the budget.
1185
- const uniqueNamedNodeIds = new Set();
1186
- for (const t of tokens) {
1187
- const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
1188
- // A qualified or otherwise-specific name (<=3 hits) keeps all; an
1189
- // ambiguous simple name keeps only candidates whose container is named.
1190
- const specific = cands.length <= 3;
1191
- const pick = specific
1192
- ? cands
1193
- : cands.filter((n) => {
1194
- const segs = (n.qualifiedName || '').toLowerCase().split(/::|\./).filter(Boolean);
1195
- const container = segs.length >= 2 ? segs[segs.length - 2] : '';
1196
- return !!container && segPool.has(container);
1197
- });
1198
- for (const n of pick.slice(0, 6)) {
1199
- named.set(n.id, n);
1200
- if (specific)
1201
- uniqueNamedNodeIds.add(n.id);
1202
- }
1203
- if (named.size > 40)
1204
- break;
1205
- }
1206
- if (named.size < 2)
1207
- return EMPTY;
1208
- const MAX_HOPS = 7;
1209
- let best = null;
1210
- // BFS the full call graph (incl. synth edges) from each named seed, but
1211
- // only ACCEPT a sink that is also named — both ends anchored to symbols the
1212
- // agent named, so the chain stays on-topic while bridging intermediates
1213
- // (e.g. the exact interface overload) that the token resolution missed.
1214
- for (const seed of [...named.values()].slice(0, 8)) {
1215
- const parent = new Map();
1216
- parent.set(seed.id, { prev: null, edge: null, node: seed });
1217
- const q = [{ id: seed.id, depth: 0, streak: 0 }];
1218
- let deep = null, deepDepth = 0;
1219
- const MAX_BRIDGE = 1; // ≤1 consecutive UNNAMED hop: bridge one missing intermediate, never wander a god-function's fan-out
1220
- for (let h = 0; h < q.length && parent.size < 1500; h++) {
1221
- const { id, depth, streak } = q[h];
1222
- if (id !== seed.id && named.has(id) && depth > deepDepth) {
1223
- deep = id;
1224
- deepDepth = depth;
1225
- }
1226
- if (depth >= MAX_HOPS - 1)
1227
- continue;
1228
- for (const c of cg.getCallees(id)) {
1229
- if (c.edge.kind !== 'calls' || parent.has(c.node.id))
1230
- continue;
1231
- const newStreak = named.has(c.node.id) ? 0 : streak + 1;
1232
- if (newStreak > MAX_BRIDGE)
1233
- continue;
1234
- parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
1235
- q.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
1236
- }
1237
- }
1238
- if (!deep)
1239
- continue;
1240
- const chain = [];
1241
- let cur = deep;
1242
- while (cur) {
1243
- const p = parent.get(cur);
1244
- if (!p)
1245
- break;
1246
- chain.push({ node: p.node, edge: p.edge });
1247
- cur = p.prev;
1248
- }
1249
- chain.reverse();
1250
- if (!best || chain.length > best.length)
1251
- best = chain;
1252
- }
1253
- const hasMain = !!best && best.length >= 3;
1254
- const pathIds = new Set((best ?? []).map((s) => s.node.id));
1255
- // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
1256
- // symbol — the indirect hops an agent would otherwise grep/Read to
1257
- // reconstruct ("where do the appended `validators` actually run?"). The
1258
- // synth edge IS that answer, so surface it even when the OTHER end wasn't
1259
- // named (e.g. the agent names `validate` but not the `didCompleteTask`
1260
- // that drains the collection). On-topic by construction: only heuristic
1261
- // edges touching a symbol the agent named; skipped when the hop already
1262
- // shows in the main chain.
1263
- const synthLines = [];
1264
- const synthSeen = new Set();
1265
- for (const n of named.values()) {
1266
- if (synthLines.length >= 6)
1267
- break;
1268
- for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
1269
- if (synthLines.length >= 6)
1270
- break;
1271
- if (edge.provenance !== 'heuristic' || other.id === n.id)
1272
- continue;
1273
- if (pathIds.has(edge.source) && pathIds.has(edge.target))
1274
- continue; // already in the main chain
1275
- const src = edge.source === n.id ? n : other;
1276
- const tgt = edge.source === n.id ? other : n;
1277
- const key = `${src.name}>${tgt.name}`;
1278
- if (synthSeen.has(key))
1279
- continue;
1280
- synthSeen.add(key);
1281
- const note = this.synthEdgeNote(edge);
1282
- synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
1283
- }
1284
- }
1285
- if (!hasMain && synthLines.length === 0)
1286
- return EMPTY;
1287
- const out = [];
1288
- if (hasMain) {
1289
- out.push('## Flow (call path among the symbols you queried)', '');
1290
- for (let i = 0; i < best.length; i++) {
1291
- const step = best[i];
1292
- if (step.edge) {
1293
- const sy = this.synthEdgeNote(step.edge);
1294
- out.push(` ↓ ${sy ? sy.compact : step.edge.kind}`);
1295
- }
1296
- out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
1297
- }
1298
- out.push('');
1299
- }
1300
- if (synthLines.length) {
1301
- out.push('## Dynamic-dispatch links among your symbols', '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)', '', ...synthLines, '');
1302
- }
1303
- out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
1304
- // namedNodeIds = every callable the agent explicitly named (a superset of
1305
- // the spine). A file holding one is something the agent asked to SEE, so it
1306
- // must keep full source even if it's an off-spine polymorphic sibling — the
1307
- // agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
1308
- // as the mechanism, not as an interchangeable leaf. See the skeleton gate.
1309
- return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
1310
- }
1311
- catch {
1312
- return EMPTY;
1313
- }
1314
- }
1315
- /**
1316
- * Compact "blast radius" for the entry symbols of an explore result: who
1317
- * depends on each (callers) and which test files cover it — LOCATIONS ONLY,
1318
- * no source, so the agent knows what to update / re-verify before editing
1319
- * without reaching for a separate impact call. Always-on, but skips symbols
1320
- * that have no dependents (nothing to warn about), and returns '' when none
1321
- * qualify so a leaf-only exploration stays clean.
1322
- */
1323
- buildBlastRadiusSection(cg, subgraph) {
1324
- const ROOT_CAP = 5; // only the symbols the query actually targeted
1325
- const FILE_CAP = 4; // caller files listed per symbol before "+N more"
1326
- const MEANINGFUL = new Set([
1327
- 'function', 'method', 'class', 'interface', 'struct', 'trait', 'protocol',
1328
- 'enum', 'type_alias', 'component', 'constant', 'variable', 'property', 'field',
1329
- ]);
1330
- const rel = (p) => p.replace(/\\/g, '/');
1331
- const roots = subgraph.roots
1332
- .map((id) => subgraph.nodes.get(id))
1333
- .filter((n) => !!n && MEANINGFUL.has(n.kind))
1334
- .slice(0, ROOT_CAP);
1335
- if (roots.length === 0)
1336
- return '';
1337
- const entries = [];
1338
- for (const root of roots) {
1339
- let callers = [];
1340
- try {
1341
- callers = cg.getCallers(root.id);
1342
- }
1343
- catch { /* skip this root */ }
1344
- const seen = new Set();
1345
- const uniq = [];
1346
- for (const c of callers) {
1347
- if (c?.node && !seen.has(c.node.id)) {
1348
- seen.add(c.node.id);
1349
- uniq.push(c.node);
1350
- }
1351
- }
1352
- if (uniq.length === 0)
1353
- continue; // no blast radius → nothing to flag
1354
- const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
1355
- const testFiles = callerFiles.filter((f) => (0, query_utils_1.isTestFile)(f));
1356
- const nonTest = callerFiles.filter((f) => !(0, query_utils_1.isTestFile)(f));
1357
- const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
1358
- const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
1359
- const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
1360
- const tests = testFiles.length > 0
1361
- ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
1362
- : '; ⚠️ no covering tests found';
1363
- entries.push(`- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`);
1364
- }
1365
- if (entries.length === 0)
1366
- return '';
1367
- return [
1368
- '### Blast radius — what depends on these (update/verify before editing)',
1369
- '',
1370
- ...entries,
1371
- '',
1372
- ].join('\n');
1373
- }
1374
- /**
1375
- * Graph-connectivity relevance via Random-Walk-with-Restart (personalized
1376
- * PageRank) from the query's matched SEED nodes over the call/reference graph.
1377
- *
1378
- * This is the ranking signal text search (FTS/bm25) CANNOT provide, and it's
1379
- * specship's home turf: relevance by STRUCTURE, not words. A file whose
1380
- * symbols are call-connected to the matched cluster accrues walk mass and
1381
- * ranks high; a lone TEXT match — e.g. `LensSwitcher.swift` matched the word
1382
- * "switch" from `switchOrganization`, but calls none of `setUser`/`fetchUser`
1383
- * — gets only its own restart probability and ranks ~0. Immune to the
1384
- * tokenization trap that fools term matching, deterministic, no embeddings.
1385
- *
1386
- * Undirected adjacency (reachability both ways), restart α=0.25 to the seeds,
1387
- * power iteration to convergence. Bounded to the already-relevant subgraph, so
1388
- * it's a few hundred nodes × ~25 iterations — negligible cost.
1389
- */
1390
- computeGraphRelevance(nodeIds, edges, seedIds) {
1391
- const out = new Map();
1392
- const n = nodeIds.length;
1393
- if (n === 0)
1394
- return out;
1395
- const idx = new Map();
1396
- for (let i = 0; i < n; i++)
1397
- idx.set(nodeIds[i], i);
1398
- const RANK_EDGES = new Set([
1399
- 'calls', 'references', 'extends', 'implements', 'overrides',
1400
- 'instantiates', 'returns', 'type_of', 'imports',
1401
- ]);
1402
- const adj = Array.from({ length: n }, () => []);
1403
- for (const e of edges) {
1404
- if (!RANK_EDGES.has(e.kind))
1405
- continue;
1406
- const i = idx.get(e.source);
1407
- const j = idx.get(e.target);
1408
- if (i === undefined || j === undefined || i === j)
1409
- continue;
1410
- adj[i].push(j);
1411
- adj[j].push(i); // undirected — reachable either direction
1412
- }
1413
- // Restart vector: uniform over seeds present in the candidate set. (Falls
1414
- // back to uniform-over-all if no seed landed in the set, so we never return
1415
- // all-zero.)
1416
- const r = new Array(n).fill(0);
1417
- let rsum = 0;
1418
- for (const id of seedIds) {
1419
- const i = idx.get(id);
1420
- if (i !== undefined) {
1421
- r[i] = 1;
1422
- rsum += 1;
1423
- }
1424
- }
1425
- if (rsum === 0) {
1426
- for (let i = 0; i < n; i++)
1427
- r[i] = 1;
1428
- rsum = n;
1429
- }
1430
- for (let i = 0; i < n; i++)
1431
- r[i] /= rsum;
1432
- const alpha = 0.25;
1433
- let s = r.slice();
1434
- for (let iter = 0; iter < 25; iter++) {
1435
- const next = new Array(n).fill(0);
1436
- for (let i = 0; i < n; i++) {
1437
- const si = s[i];
1438
- if (si === 0)
1439
- continue;
1440
- const d = adj[i].length;
1441
- if (d === 0) {
1442
- next[i] += si;
1443
- continue;
1444
- } // dangling: keep its mass
1445
- const share = si / d;
1446
- for (const j of adj[i])
1447
- next[j] += share;
1448
- }
1449
- for (let i = 0; i < n; i++)
1450
- s[i] = (1 - alpha) * next[i] + alpha * r[i];
1451
- }
1452
- for (let i = 0; i < n; i++)
1453
- out.set(nodeIds[i], s[i]);
1454
- return out;
1455
- }
1456
- /**
1457
- * Handle specship_explore — deep exploration in a single call
1458
- *
1459
- * Strategy: find relevant symbols via graph traversal, group by file,
1460
- * then read contiguous file sections covering all symbols per file.
1461
- * This replaces multiple specship_node + Read calls.
1462
- *
1463
- * Output size is adaptive to project file count via
1464
- * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a
1465
- * tax on small projects while earning its keep on large ones.
1466
- */
1467
- async handleExplore(args) {
1468
- const query = this.validateString(args.query, 'query');
1469
- if (typeof query !== 'string')
1470
- return query;
1471
- const cg = this.getSpecShip(args.projectPath);
1472
- const projectRoot = cg.getProjectRoot();
1473
- // Resolve adaptive output budget from project size. Falls back to the
1474
- // largest-tier defaults if stats aren't available, which preserves
1475
- // pre-#185 behavior for callers that hit the rare stats failure.
1476
- let budget;
1477
- try {
1478
- budget = getExploreOutputBudget(cg.getStats().fileCount);
1479
- }
1480
- catch {
1481
- budget = getExploreOutputBudget(Infinity);
1482
- }
1483
- const maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
1484
- // Step 1: Find relevant context with generous parameters.
1485
- // Use a large maxNodes budget — explore has its own 35k char output limit
1486
- // that prevents context bloat, so more nodes just means better coverage
1487
- // across entry points (especially for large files like Svelte components).
1488
- const subgraph = await cg.findRelevantContext(query, {
1489
- searchLimit: 8,
1490
- traversalDepth: 3,
1491
- maxNodes: 200,
1492
- minScore: 0.2,
1493
- });
1494
- if (subgraph.nodes.size === 0) {
1495
- return this.textResult(`No relevant code found for "${query}"`);
1496
- }
1497
- // Graph-aware glue: findRelevantContext builds the subgraph from name/text
1498
- // search, so a method that BRIDGES named symbols — e.g. App.tsx's
1499
- // triggerRender, which calls the named triggerUpdate — is never a search hit
1500
- // and gets missed, forcing the agent to Read the file to trace it. Pull in
1501
- // the callers/callees of the entry (root) nodes, but ONLY those that live in
1502
- // files the subgraph already surfaces (where the agent reads to fill gaps),
1503
- // so we add wiring without dragging in unrelated files. These get an
1504
- // importance boost below so they survive the per-file cluster budget.
1505
- const glueNodeIds = new Set();
1506
- const subgraphFiles = new Set();
1507
- for (const n of subgraph.nodes.values())
1508
- subgraphFiles.add(n.filePath);
1509
- const GLUE_NODE_CAP = 60;
1510
- for (const rootId of subgraph.roots) {
1511
- if (glueNodeIds.size >= GLUE_NODE_CAP)
1512
- break;
1513
- let neighbors = [];
1514
- try {
1515
- neighbors = [
1516
- ...cg.getCallers(rootId).map(c => c.node),
1517
- ...cg.getCallees(rootId).map(c => c.node),
1518
- ];
1519
- }
1520
- catch {
1521
- continue;
1522
- }
1523
- for (const nb of neighbors) {
1524
- if (glueNodeIds.size >= GLUE_NODE_CAP)
1525
- break;
1526
- if (subgraph.nodes.has(nb.id))
1527
- continue;
1528
- if (!subgraphFiles.has(nb.filePath))
1529
- continue;
1530
- subgraph.nodes.set(nb.id, nb);
1531
- glueNodeIds.add(nb.id);
1532
- }
1533
- }
1534
- // Named-symbol seeding: findRelevantContext is an FTS/text rank, so a query
1535
- // that's a BAG of symbol names skewed toward one phase (Alamofire: 5 build
1536
- // terms, each a high-frequency name, vs 3 validate terms) lets the
1537
- // lower-frequency names fall below the search cut — their definitions, and
1538
- // whole files (Validation.swift), never get gathered, so they can never
1539
- // render and the agent Reads them. Resolve EACH named token to its
1540
- // substantive definition (skip empty stubs + test files, same relevance the
1541
- // trace endpoint picker uses) and inject it as an entry, so every symbol the
1542
- // agent explicitly named is in the subgraph and its file is scored.
1543
- const namedSeedIds = new Set();
1544
- {
1545
- const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte)$/i;
1546
- const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
1547
- const isTestPath = (p) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
1548
- const bodyLines = (n) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
1549
- const tokens = [...new Set(query.split(/[\s,()[\]]+/)
1550
- .map((t) => t.replace(FILE_EXT, '').trim())
1551
- .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)))].slice(0, 16);
1552
- // PascalCase tokens in the query are type/file disambiguators — when the
1553
- // agent writes "DataRequest task validate", the `task`/`validate` it wants
1554
- // are DataRequest's, NOT the same-named overloads in Validation.swift /
1555
- // Concurrency.swift / the abstract base. Used below to bias overloaded
1556
- // names toward the file/class the query also names.
1557
- const typeTokens = tokens.filter((o) => /^[A-Z][A-Za-z0-9]{3,}/.test(o));
1558
- const inNamedContext = (n) => typeTokens.some((ct) => {
1559
- const lc = ct.toLowerCase();
1560
- return n.filePath.toLowerCase().includes(lc) || n.qualifiedName.toLowerCase().includes(lc);
1561
- });
1562
- for (const t of tokens) {
1563
- // Enumerate ALL defs of a bare token via the direct index, not FTS — a
1564
- // 50+-overload name (tokio `poll`) ranks the wanted def (`Harness::poll`)
1565
- // below the FTS cut, so findAllSymbols would never see it and the
1566
- // type-token bias below couldn't pick the harness.rs one. (Same fix as
1567
- // specship_node's findSymbolMatches.) Qualified tokens keep findAllSymbols.
1568
- const isQual = /[.\/]|::/.test(t);
1569
- const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
1570
- const cands = raw
1571
- .filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath))
1572
- .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
1573
- // A specific name (<=3 defs) injects all its defs. An overloaded name
1574
- // (`validate` = 10, `request` = 44) would flood the subgraph, so inject
1575
- // only: the overloads whose file/class the query ALSO names (the agent
1576
- // told us which one it wants — DataRequest's, not Validation.swift's),
1577
- // capped; else fall back to the single most-substantive def. This is the
1578
- // explore-side mirror of specship_node's overload disambiguation.
1579
- let picks;
1580
- if (cands.length <= 3) {
1581
- picks = cands;
1582
- }
1583
- else {
1584
- const ctx = cands.filter(inNamedContext);
1585
- picks = ctx.length > 0 ? ctx.slice(0, 4) : cands.slice(0, 1);
1586
- }
1587
- for (const n of picks) {
1588
- if (!subgraph.nodes.has(n.id))
1589
- subgraph.nodes.set(n.id, n);
1590
- // Mark as a named seed EVEN IF the FTS gather already had it — being
1591
- // "named by the agent" is independent of whether search happened to
1592
- // surface it, and it drives the +50 score, the gate, and the
1593
- // named-file sort below. (Previously only NEW injections were marked,
1594
- // so a named symbol FTS already gathered never sorted to the top.)
1595
- namedSeedIds.add(n.id);
1596
- }
1597
- }
1598
- }
1599
- // Step 2: Group nodes by file, score by relevance
1600
- const fileGroups = new Map();
1601
- const entryNodeIds = new Set([...subgraph.roots, ...namedSeedIds]);
1602
- // Build a set of nodes directly connected to entry points (depth 1)
1603
- const connectedToEntry = new Set();
1604
- for (const edge of subgraph.edges) {
1605
- if (entryNodeIds.has(edge.source))
1606
- connectedToEntry.add(edge.target);
1607
- if (entryNodeIds.has(edge.target))
1608
- connectedToEntry.add(edge.source);
1609
- }
1610
- for (const node of subgraph.nodes.values()) {
1611
- // Skip import/export nodes — they add noise without information
1612
- if (node.kind === 'import' || node.kind === 'export')
1613
- continue;
1614
- const group = fileGroups.get(node.filePath) || { nodes: [], score: 0 };
1615
- group.nodes.push(node);
1616
- // Score: a NAMED-SEED node (a symbol the agent named that FTS missed, now
1617
- // injected) is worth far more than a mere reference — its file is where the
1618
- // answer lives. Without this, an incidental file that name-drops the flow
1619
- // (Combine.swift references request/task → score 23 from connected nodes)
1620
- // outranks the file that DEFINES a named symbol (Validation.swift's
1621
- // `validate` → 10) and steals its render slot. Definition ≫ reference.
1622
- if (namedSeedIds.has(node.id)) {
1623
- group.score += 50;
1624
- }
1625
- else if (entryNodeIds.has(node.id)) {
1626
- group.score += 10;
1627
- }
1628
- else if (connectedToEntry.has(node.id)) {
1629
- group.score += 3;
1630
- }
1631
- else {
1632
- group.score += 1;
1633
- }
1634
- fileGroups.set(node.filePath, group);
1635
- }
1636
- // Only include files that have entry points or nodes directly connected to entry points
1637
- let relevantFiles = [...fileGroups.entries()].filter(([, group]) => group.score >= 3);
1638
- // Extract query terms for relevance checking
1639
- const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
1640
- // Test/spec/icon/i18n file detector — used both for the pre-sort hard
1641
- // filter (tiny tier) and the comparator deprioritization (all tiers).
1642
- const isLowValue = (p) => {
1643
- const lp = p.toLowerCase();
1644
- return (/\/(tests?|__tests?__|spec)\//.test(lp) ||
1645
- /_test\.go$/.test(lp) ||
1646
- /(?:^|\/)test_[^/]+\.py$/.test(lp) ||
1647
- /_test\.py$/.test(lp) ||
1648
- /_spec\.rb$/.test(lp) ||
1649
- /_test\.rb$/.test(lp) ||
1650
- /\.(test|spec)\.[jt]sx?$/.test(lp) ||
1651
- /(test|spec|tests)\.(java|kt|scala)$/.test(lp) ||
1652
- /(tests?|spec)\.cs$/.test(lp) ||
1653
- /tests?\.swift$/.test(lp) ||
1654
- /_test\.dart$/.test(lp) ||
1655
- /\bicons?\b/.test(lp) ||
1656
- /\bi18n\b/.test(lp));
1657
- };
1658
- // Hard-exclude test/spec files (ALL tiers, not just tiny). One slipped test
1659
- // file dominates the per-file budget on small repos (cobra's `command_test.go`
1660
- // displaced `args.go`) AND wastes budget on large ones (Django's
1661
- // `custom_lookups/tests.py` ate ~2.3 KB of the 28 KB cap, crowding out the
1662
- // SQLCompiler mechanism the agent then Read). A test file almost never answers
1663
- // an architecture question. Skip when the query itself is about tests — the
1664
- // legitimate "explore the tests" case — and only cut if ≥2 non-test candidates
1665
- // remain (else tests are the only signal for this area).
1666
- {
1667
- const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(query);
1668
- if (!queryMentionsTests) {
1669
- const nonLow = relevantFiles.filter(([p]) => !isLowValue(p));
1670
- if (nonLow.length >= 2) {
1671
- relevantFiles = nonLow;
1672
- }
1673
- }
1674
- }
1675
- // Secondary signal: how many DISTINCT query terms each file matches (path +
1676
- // symbol names). Kept only as a tiebreak — the PRIMARY relevance is graph
1677
- // connectivity below. (Term counting alone tied the real central file with
1678
- // incidental same-word matches; it's a weak text signal, not the ranker.)
1679
- const uniqueQueryTerms = [...new Set(queryTerms)].filter(t => t.length >= 3);
1680
- const fileTermHits = new Map();
1681
- for (const [fp, group] of relevantFiles) {
1682
- const hay = fp.toLowerCase() + ' ' + group.nodes.map(n => n.name.toLowerCase()).join(' ');
1683
- let hits = 0;
1684
- for (const t of uniqueQueryTerms)
1685
- if (hay.includes(t))
1686
- hits++;
1687
- fileTermHits.set(fp, hits);
1688
- }
1689
- // PRIMARY relevance: graph connectivity (Random-Walk-with-Restart from the
1690
- // matched seeds — see computeGraphRelevance). Aggregate each file's nodes'
1691
- // walk mass. This is the signal text search lacks: the real cluster
1692
- // (org-user.storage.ts, call-connected to the matches) accrues mass; a lone
1693
- // text match (LensSwitcher.swift, matched "switch" but calls nothing in the
1694
- // flow) gets only its restart probability → ~0, and is dropped by the gate.
1695
- const nodeRwr = this.computeGraphRelevance([...subgraph.nodes.keys()], subgraph.edges, entryNodeIds);
1696
- const fileGraphScore = new Map();
1697
- for (const node of subgraph.nodes.values()) {
1698
- fileGraphScore.set(node.filePath, (fileGraphScore.get(node.filePath) ?? 0) + (nodeRwr.get(node.id) ?? 0));
1699
- }
1700
- const maxGraph = Math.max(0, ...fileGraphScore.values());
1701
- // Central file(s): the 1-2 most graph-central files that also match the
1702
- // query textually (so a connected hub-utility with no term match isn't
1703
- // mistaken for the subject). The heart of the answer — they earn the larger
1704
- // WHOLE-FILE ceiling below (a god-file central file still exceeds it and
1705
- // falls to generous full-method sectioning — never a whole dump).
1706
- const centralFiles = new Set([...fileGraphScore.entries()]
1707
- .filter(([fp, g]) => g > 0 && (fileTermHits.get(fp) ?? 0) >= 1)
1708
- .sort((a, b) => b[1] - a[1] || (fileTermHits.get(b[0]) ?? 0) - (fileTermHits.get(a[0]) ?? 0))
1709
- .slice(0, 2)
1710
- .map(([f]) => f));
1711
- // Files that DEFINE a symbol the agent named (or a subgraph root). These are
1712
- // the highest-relevance files there are — the agent asked for them by name —
1713
- // so the connectivity gate below must never drop them, even when their RWR
1714
- // mass is low (a leaf family file like codec.ts is call-connected to little
1715
- // but is exactly what the agent queried). Without this protection the gate
1716
- // prunes a named file and the agent Reads it back.
1717
- const entryFiles = new Set();
1718
- for (const id of entryNodeIds) {
1719
- const n = subgraph.nodes.get(id);
1720
- if (n)
1721
- entryFiles.add(n.filePath);
1722
- }
1723
- // Relevance gate (so the generous budget is a CEILING, not a target): keep a
1724
- // file only if it is STRUCTURALLY relevant by ANY of:
1725
- // - graph score within a fraction of the top (it's on/near the flow), OR
1726
- // - central (a query entry-point lives here), OR
1727
- // - it DEFINES a symbol the agent named (entryFiles), OR
1728
- // - it matches >= 2 DISTINCT named query terms — a strong text signal that
1729
- // the agent is asking about this file even when nothing calls it (codec.ts:
1730
- // the agent named `encode`/`Codec`/`JsonCodec`, all leaf classes with zero
1731
- // RWR mass — graph alone wrongly drops it).
1732
- // A lone text match on one shared word (LensSwitcher: term=1, g~0) is still
1733
- // dropped, so the budget never fills with incidental files. Guarded so it
1734
- // never prunes below 2.
1735
- if (maxGraph > 0) {
1736
- const gated = relevantFiles.filter(([fp]) => (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
1737
- || centralFiles.has(fp)
1738
- || entryFiles.has(fp)
1739
- || (fileTermHits.get(fp) ?? 0) >= 2);
1740
- if (gated.length >= 2)
1741
- relevantFiles = gated;
1742
- }
1743
- // Sort files: graph-central first, then distinct-term match, then the
1744
- // existing low-value/generated/score tiebreaks.
1745
- // Files that DEFINE a symbol the agent NAMED. These sort first — ahead of
1746
- // graph connectivity — because the agent asked for them by name. Without
1747
- // this, a named leaf override reached only by dynamic dispatch (Alamofire's
1748
- // `DataRequest.task`/`validate`, low RWR mass) sorts below the high-
1749
- // connectivity abstract base (`Request.swift`) and the same-named overloads
1750
- // in other files (`Validation.swift`), falls outside the budget, and the
1751
- // agent Reads it. The named file is the answer — rank it at the top.
1752
- const namedSeedFiles = new Set();
1753
- for (const id of namedSeedIds) {
1754
- const n = subgraph.nodes.get(id);
1755
- if (n)
1756
- namedSeedFiles.add(n.filePath);
1757
- }
1758
- const sortedFiles = relevantFiles.sort((a, b) => {
1759
- const aPath = a[0].toLowerCase();
1760
- const bPath = b[0].toLowerCase();
1761
- // Agent-named files first (it asked for a symbol defined here by name).
1762
- const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
1763
- const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
1764
- if (aNamed !== bNamed)
1765
- return bNamed - aNamed;
1766
- // Graph connectivity is the next key (small epsilon so near-ties fall
1767
- // through to the text signal rather than coin-flipping on float noise).
1768
- const aG = fileGraphScore.get(a[0]) ?? 0;
1769
- const bG = fileGraphScore.get(b[0]) ?? 0;
1770
- if (Math.abs(aG - bG) > maxGraph * 0.01)
1771
- return bG - aG;
1772
- const aHits = fileTermHits.get(a[0]) ?? 0;
1773
- const bHits = fileTermHits.get(b[0]) ?? 0;
1774
- if (aHits !== bHits)
1775
- return bHits - aHits;
1776
- const aLow = isLowValue(aPath);
1777
- const bLow = isLowValue(bPath);
1778
- if (aLow !== bLow)
1779
- return aLow ? 1 : -1;
1780
- // Deprioritize generated source (.pb.go / .pulsar.go / _mocks.go / …) —
1781
- // the agent rarely needs to see the protobuf scaffold or gomock output
1782
- // when asking about the actual flow, and dumping their bodies inflates
1783
- // the response (the cosmos Q3 explore otherwise leads with
1784
- // `expected_keepers_mocks.go`, displacing the real `tally.go` content
1785
- // and forcing the agent to Read tally.go anyway).
1786
- const aGen = (0, generated_detection_1.isGeneratedFile)(a[0]);
1787
- const bGen = (0, generated_detection_1.isGeneratedFile)(b[0]);
1788
- if (aGen !== bGen)
1789
- return aGen ? 1 : -1;
1790
- if (a[1].score !== b[1].score)
1791
- return b[1].score - a[1].score;
1792
- return b[1].nodes.length - a[1].nodes.length;
1793
- });
1794
- // Step 3: Build relationship map
1795
- const lines = [
1796
- `## Exploration: ${query}`,
1797
- '',
1798
- `Found ${subgraph.nodes.size} symbols across ${fileGroups.size} files.`,
1799
- '',
1800
- ];
1801
- // Blast radius (always-on, compact): for the entry symbols, who depends on
1802
- // them + which tests cover them — locations only, no source — so the agent
1803
- // knows what to update/verify before editing without a separate call.
1804
- const blastRadius = this.buildBlastRadiusSection(cg, subgraph);
1805
- if (blastRadius)
1806
- lines.push(blastRadius);
1807
- // Relationship map — show how symbols connect
1808
- const significantEdges = subgraph.edges.filter(e => e.kind !== 'contains' // skip contains — it's implied by file grouping
1809
- );
1810
- if (budget.includeRelationships && significantEdges.length > 0) {
1811
- lines.push('### Relationships');
1812
- lines.push('');
1813
- // Group edges by kind for readability
1814
- const byKind = new Map();
1815
- for (const edge of significantEdges) {
1816
- const sourceNode = subgraph.nodes.get(edge.source);
1817
- const targetNode = subgraph.nodes.get(edge.target);
1818
- if (!sourceNode || !targetNode)
1819
- continue;
1820
- const group = byKind.get(edge.kind) || [];
1821
- group.push({ source: sourceNode.name, target: targetNode.name });
1822
- byKind.set(edge.kind, group);
1823
- }
1824
- for (const [kind, edges] of byKind) {
1825
- const cap = budget.maxEdgesPerRelationshipKind;
1826
- const shown = edges.slice(0, cap);
1827
- lines.push(`**${kind}:**`);
1828
- for (const e of shown) {
1829
- lines.push(`- ${e.source} → ${e.target}`);
1830
- }
1831
- if (edges.length > cap) {
1832
- lines.push(`- ... and ${edges.length - cap} more`);
1833
- }
1834
- lines.push('');
1835
- }
1836
- }
1837
- // Step 4: Read contiguous file sections
1838
- // Compute the flow spine once — used both to prepend the Flow section (below)
1839
- // and to gate adaptive source sizing: files on the spine get full source,
1840
- // off-spine peers skeletonize.
1841
- const flow = this.buildFlowFromNamedSymbols(cg, query);
1842
- // Polymorphic-sibling detector for adaptive sizing. A class that implements/
1843
- // extends a supertype shared by >= MIN_SIBLINGS classes is one of many
1844
- // INTERCHANGEABLE implementations (OkHttp's 14 `: Interceptor` classes —
1845
- // showing one + the rest as signatures is enough), as opposed to a DISTINCT
1846
- // pipeline step (Excalidraw's `renderStaticScene`, which shares no supertype and
1847
- // must stay full or the agent loses real content). Only off-spine sibling files
1848
- // skeletonize; distinct steps and on-spine files keep full source. Cache
1849
- // supertype→(has ≥N implementers) so this stays a handful of edge queries.
1850
- const MIN_SIBLINGS = 3;
1851
- const siblingSuper = new Map();
1852
- const isPolymorphicSibling = (nodes) => {
1853
- for (const n of nodes) {
1854
- for (const e of cg.getOutgoingEdges(n.id)) {
1855
- if (e.kind !== 'implements' && e.kind !== 'extends')
1856
- continue;
1857
- let many = siblingSuper.get(e.target);
1858
- if (many === undefined) {
1859
- many = cg.getIncomingEdges(e.target)
1860
- .filter((x) => x.kind === 'implements' || x.kind === 'extends').length >= MIN_SIBLINGS;
1861
- siblingSuper.set(e.target, many);
1862
- }
1863
- if (many)
1864
- return true;
1865
- }
1866
- }
1867
- return false;
1868
- };
1869
- // A file that DEFINES a polymorphic supertype (a class/interface with ≥
1870
- // MIN_SIBLINGS implementers) AND co-locates its subclasses is a redundant
1871
- // "family" file — Django's compiler.py holds `SQLCompiler` + its 4 subclasses
1872
- // (SQLInsert/Update/Delete/AggregateCompiler) in 2,266 lines. Such files are
1873
- // huge and read-anyway, so they should STILL skeletonize even when the agent
1874
- // named a method in them: a full one eats ~6.5K of the explore budget (Django
1875
- // is pinned at the 28K cap, truncating), starving the sibling files the agent
1876
- // then Reads. This flag OVERRIDES the named-callable spare below — it does NOT
1877
- // by itself spare a file. (OkHttp's RealCall implements the `Lockable` mixin
1878
- // but defines no ≥3-impl supertype, so the named spare keeps it full.)
1879
- const superMany = new Map();
1880
- const definesPolymorphicSupertype = (nodes) => {
1881
- for (const n of nodes) {
1882
- if (n.kind !== 'class' && n.kind !== 'interface' && n.kind !== 'struct'
1883
- && n.kind !== 'trait' && n.kind !== 'protocol' && n.kind !== 'type_alias')
1884
- continue;
1885
- let many = superMany.get(n.id);
1886
- if (many === undefined) {
1887
- many = cg.getIncomingEdges(n.id)
1888
- .filter((x) => x.kind === 'implements' || x.kind === 'extends').length >= MIN_SIBLINGS;
1889
- superMany.set(n.id, many);
1890
- }
1891
- if (many)
1892
- return true;
1893
- }
1894
- return false;
1895
- };
1896
- lines.push('### Source Code');
1897
- lines.push('');
1898
- lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.');
1899
- lines.push('');
1900
- let totalChars = lines.join('\n').length;
1901
- let filesIncluded = 0;
1902
- let anyFileTrimmed = false;
1903
- for (const [filePath, group] of sortedFiles) {
1904
- if (filesIncluded >= maxFiles)
1905
- break;
1906
- // A file DEFINES a named/spine symbol (the answer) vs merely references the
1907
- // flow. Past 90% budget, stop pulling INCIDENTAL files — but keep scanning
1908
- // for necessary ones, which render even past the cap (bounded by maxFiles).
1909
- // Without this `continue` (was an unconditional `break`), the loop stopped
1910
- // after the build + validators-exec files and never reached the ranked-in
1911
- // validate-logic file (Alamofire's Validation.swift).
1912
- const fileNecessary = group.nodes.some(n => entryNodeIds.has(n.id) || flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id));
1913
- if (!fileNecessary && totalChars > budget.maxOutputChars * 0.9)
1914
- continue;
1915
- const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, filePath);
1916
- if (!absPath || !(0, fs_1.existsSync)(absPath))
1917
- continue;
1918
- let fileContent;
1919
- try {
1920
- fileContent = (0, fs_1.readFileSync)(absPath, 'utf-8');
1921
- }
1922
- catch {
1923
- continue;
1924
- }
1925
- const fileLines = fileContent.split('\n');
1926
- const lang = group.nodes[0]?.language || '';
1927
- // Adaptive sizing (SPECSHIP_ADAPTIVE_EXPLORE, default on): collapse a file
1928
- // to a per-symbol view when it's a redundant member of a polymorphic family.
1929
- // Engages iff ALL hold:
1930
- // 1. a flow spine exists,
1931
- // 2. no symbol in the file is on that spine (it's not the mechanism path),
1932
- // 3. it IS a polymorphic sibling (≥ MIN_SIBLINGS impls of a shared supertype),
1933
- // 4. it is NOT SPARED, where a file is spared iff the agent named a
1934
- // (near-)UNIQUE callable in it (`getResponseWithInterceptorChain`, 1 def →
1935
- // keep RealCall.kt full) UNLESS the file DEFINES the family supertype (a
1936
- // base+subclasses "family" file like Django's compiler.py — collapse it).
1937
- // Uniqueness matters: `as_sql` has 110 defs across every Compiler/Expression
1938
- // subclass; naming it must NOT keep every backend variant + test file full
1939
- // and flood the budget. That's why the spare reads uniqueNamedNodeIds.
1940
- // Within a collapsed file the render is PER-SYMBOL (condition B): a method the
1941
- // agent NAMED or that's on the spine is shown with its FULL body (so the agent
1942
- // doesn't Read the file back for it — Django's SQLCompiler.execute_sql/as_sql);
1943
- // every other symbol is just its signature. So the base mechanism survives while
1944
- // the file's other ~80 symbols + the redundant subclasses collapse to one line each.
1945
- const spareNamed = group.nodes.some(n => flow.uniqueNamedNodeIds.has(n.id));
1946
- const fileDefinesSuper = definesPolymorphicSupertype(group.nodes);
1947
- const spared = spareNamed && !fileDefinesSuper;
1948
- const CALLABLE_BODY = new Set(['method', 'function', 'constructor', 'component']);
1949
- const hasSpineNode = group.nodes.some(n => flow.pathNodeIds.has(n.id));
1950
- // On-spine god-file: the flow path runs THROUGH this file, but it also holds
1951
- // many OTHER named methods, and rendering all of them in full blows the
1952
- // per-file budget and starves the other flow files (Alamofire: the agent
1953
- // names ~7 Session.swift methods — the build spine PLUS off-path
1954
- // task/didCompleteTask — far past the whole response budget). Engage the
1955
- // per-symbol view to keep the SPINE full and collapse the off-path named
1956
- // methods to signatures. Only when there IS off-path content to shed —
1957
- // otherwise the spine is irreducible (a sequential flow has no redundancy),
1958
- // so leave it to the normal full render.
1959
- const namedBodyChars = group.nodes
1960
- .filter(n => CALLABLE_BODY.has(n.kind) && (flow.pathNodeIds.has(n.id) || flow.uniqueNamedNodeIds.has(n.id)))
1961
- .reduce((s, n) => s + fileLines.slice(n.startLine - 1, n.endLine).join('\n').length, 0);
1962
- const onSpineGodFile = hasSpineNode
1963
- && namedBodyChars > budget.maxCharsPerFile
1964
- && group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
1965
- if (adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
1966
- && (onSpineGodFile || (!hasSpineNode && isPolymorphicSibling(group.nodes) && !spared))) {
1967
- const syms = group.nodes
1968
- .filter(n => n.kind !== 'import' && n.kind !== 'export' && n.startLine > 0)
1969
- .sort((a, b) => a.startLine - b.startLine);
1970
- // Pass 1: choose which symbols get a FULL body, by priority, greedily within
1971
- // a per-file body cap — so one huge family file can't body every named method
1972
- // and crowd out the other flow files (Django's query.py). A symbol earns a
1973
- // body if it's on-spine, or UNIQUELY named (`SQLCompiler.execute_sql`), or a
1974
- // co-named method WHEN this file DEFINES the family supertype (so the base
1975
- // `SQLCompiler.as_sql` body shows, but the 110 leaf `as_sql` overrides — and
1976
- // OkHttp's 5 `intercept`s if the agent names `intercept` — stay signatures).
1977
- const prio = (n) => !CALLABLE_BODY.has(n.kind) ? 99
1978
- : flow.pathNodeIds.has(n.id) ? 0
1979
- : flow.uniqueNamedNodeIds.has(n.id) ? 1
1980
- : (fileDefinesSuper && flow.namedNodeIds.has(n.id)) ? 2 : 99;
1981
- // One ~250-line WINDOW per file. syms are taken by priority (spine first,
1982
- // then uniquely-named, then family-base), and the cap applies to ALL of
1983
- // them — including the spine — so a big-spine god-file (tokio's worker.rs:
1984
- // run→run_task→next_task→steal_work) can't eat the whole response and
1985
- // starve the co-flow file (harness.rs's poll). The native agent windows
1986
- // such a file too (~190 lines at a time), so this mimics, not truncates.
1987
- // Always emit ≥1 (never an empty section).
1988
- const bodyCap = budget.maxCharsPerFile * 1.5;
1989
- const bodyIds = new Set();
1990
- let bodyChars = 0;
1991
- for (const n of syms.filter(n => prio(n) < 99 && n.endLine >= n.startLine).sort((a, b) => prio(a) - prio(b))) {
1992
- const sz = fileLines.slice(n.startLine - 1, n.endLine).join('\n').length;
1993
- if (bodyChars + sz > bodyCap && bodyIds.size > 0)
1994
- continue;
1995
- bodyIds.add(n.id);
1996
- bodyChars += sz;
1997
- }
1998
- // Pass 2: render in line order — full body for chosen symbols, else the
1999
- // signature line (capped, with a "+N more" tail so the structure map of a
2000
- // god-file doesn't itself bloat the budget).
2001
- const skel = [];
2002
- let coveredUntil = 0; // skip symbols already inside an emitted body
2003
- let sigCount = 0, sigDropped = 0;
2004
- const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2);
2005
- for (const n of syms) {
2006
- if (n.startLine <= coveredUntil)
2007
- continue;
2008
- if (bodyIds.has(n.id)) {
2009
- const end = n.endLine;
2010
- const body = fileLines.slice(n.startLine - 1, end).join('\n');
2011
- skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
2012
- coveredUntil = end;
2013
- }
2014
- else {
2015
- // Elide the body, emit the signature. node.startLine can point at a
2016
- // decorator/annotation, so scan forward for the line that names the symbol.
2017
- let lineNo = n.startLine;
2018
- for (let k = 0; k < 4; k++) {
2019
- if ((fileLines[n.startLine - 1 + k] || '').includes(n.name)) {
2020
- lineNo = n.startLine + k;
2021
- break;
2022
- }
2023
- }
2024
- if (lineNo <= coveredUntil)
2025
- continue;
2026
- if (sigCount >= SIG_MAX) {
2027
- sigDropped++;
2028
- continue;
2029
- }
2030
- const sig = (fileLines[lineNo - 1] || '').trim();
2031
- if (sig) {
2032
- skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig);
2033
- sigCount++;
2034
- }
2035
- }
2036
- }
2037
- if (sigDropped > 0)
2038
- skel.push(`… +${sigDropped} more (signatures elided)`);
2039
- if (skel.length > 0) {
2040
- const names = [...new Set(group.nodes.filter(n => n.kind !== 'import' && n.kind !== 'export').map(n => n.name))]
2041
- .slice(0, budget.maxSymbolsInFileHeader).join(', ');
2042
- // Steer the agent to specship_explore for an elided body — NEVER to
2043
- // Read. The old "Read for more" / "Read for a full body" tags invited
2044
- // a Read of the very file just skeletonized; on a central, wanted file
2045
- // (Session.swift, DataRequest.swift) that fired an over-investigation
2046
- // spiral (the agent Read the skeletonized file, then kept digging).
2047
- // CLAUDE.md: explore output must never tell the agent to Read.
2048
- const tag = bodyIds.size > 0
2049
- ? 'focused (the methods you named in full, the rest as signatures — specship_explore a signature by name for its body; do NOT Read)'
2050
- : 'skeleton (signatures only — specship_explore a name for its full body; do NOT Read)';
2051
- lines.push(`#### ${filePath} — ${names} · ${tag}`, '', '```' + lang, skel.join('\n'), '```', '');
2052
- totalChars += skel.join('\n').length + 120;
2053
- filesIncluded++;
2054
- continue;
2055
- }
2056
- }
2057
- // Whole-file rule: if a relevant file is small enough to afford, return it
2058
- // ENTIRELY instead of clustering. Clustering exists to tame god-files
2059
- // (App.tsx ~13k lines); on a ~134-line component a cluster is a lossy
2060
- // subset of a file the agent will just Read in full anyway — costing a
2061
- // round-trip and a re-read every later turn. Reserve clustering for files
2062
- // too big to ship whole. Still bounded by the total maxOutputChars check.
2063
- //
2064
- // CENTRAL files (where the query's entry points live) get a larger — but
2065
- // bounded — ceiling: they're the heart of the answer, the file(s) the agent
2066
- // would Read whole, so a genuinely small one comes back whole rather than as
2067
- // thin clusters. A LARGE central file (the 791-line org-user store) exceeds
2068
- // the ceiling and falls through to sectioning/clustering below — full method
2069
- // bodies + signatures — so we never dump (or overflow on) a whole god-file.
2070
- const isCentralFile = centralFiles.has(filePath);
2071
- // Central files get a slightly larger whole-file window than peripheral ones,
2072
- // but a TIGHT one (~1.5× the per-file cap): the native read of a central file
2073
- // is a ~150–250 line orientation window, NOT the whole file. A flat "whole
2074
- // central file" both overflowed the inline cap AND starved the co-flow files
2075
- // (worker.rs ate the budget, dropping harness.rs's poll). A larger central
2076
- // file falls through to per-method windowing/clustering below.
2077
- const WHOLE_FILE_MAX_LINES = isCentralFile ? 280 : 220;
2078
- const WHOLE_FILE_MAX_CHARS = isCentralFile
2079
- ? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
2080
- : budget.maxCharsPerFile * 3;
2081
- if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
2082
- const body = fileContent.replace(/\n+$/, '');
2083
- let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
2084
- const uniqSymbols = [...new Set(group.nodes
2085
- .filter(n => n.kind !== 'import' && n.kind !== 'export')
2086
- .map(n => `${n.name}(${n.kind})`))];
2087
- const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
2088
- const omitted = uniqSymbols.length - headerNames.length;
2089
- const wholeHeader = `#### ${filePath} — ${omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')}`;
2090
- if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
2091
- // Don't slice a whole file mid-method: an incidental file that doesn't
2092
- // fit is skipped; a necessary one (below) renders in full. Half a file
2093
- // forces the Read this is meant to prevent.
2094
- anyFileTrimmed = true;
2095
- continue;
2096
- }
2097
- lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
2098
- totalChars += wholeSection.length + 200;
2099
- filesIncluded++;
2100
- continue;
2101
- }
2102
- // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
2103
- // Sort by start line, then merge overlapping/adjacent ranges (within the
2104
- // adaptive gap threshold). Include both node ranges AND edge source
2105
- // locations so template sections with component usages/calls are
2106
- // covered (not just script block symbols).
2107
- //
2108
- // Each range carries an `importance` score so we can rank clusters
2109
- // when the per-file budget forces us to drop some: entry-point nodes
2110
- // are worth 10, directly-connected nodes 3, peripheral nodes 1, and
2111
- // bare edge-source lines 2 (less than a connected node but more than
2112
- // a peripheral one — they hint at a reference but aren't a definition).
2113
- // Container kinds whose body can span most/all of a file. When such a
2114
- // node covers most of the file we drop it from the ranges: keeping it
2115
- // would merge every method inside it into one giant cluster spanning
2116
- // the whole file, which then tail-trims down to just the container's
2117
- // opening lines (its header/declarations) and buries the methods the
2118
- // query actually asked about (#185 follow-up — Session.swift in
2119
- // Alamofire is the canonical case: the `Session` class spans ~1,400
2120
- // lines). We want the granular symbols inside, not the envelope.
2121
- const ENVELOPE_KINDS = new Set(['file', 'module', 'class', 'struct', 'interface', 'enum', 'namespace', 'protocol', 'trait', 'component']);
2122
- // Cluster from this file's gathered nodes PLUS any callable the agent NAMED that
2123
- // lives here. Explore's relevance gather can miss a named method def in a huge
2124
- // non-sibling file — Django's query.py is 3,040 lines and `_fetch_all` (L2237)
2125
- // was gathered only as call-reference edges, never as a def, so it formed no
2126
- // cluster and the agent Read it back. Inject named defs directly and rank them
2127
- // ABOVE connected/glue nodes (importance 9) so their cluster wins the per-file
2128
- // budget — the agent explicitly asked for these symbols.
2129
- const rangeNodes = new Map();
2130
- for (const n of group.nodes)
2131
- if (n.startLine > 0 && n.endLine > 0)
2132
- rangeNodes.set(n.id, n);
2133
- for (const id of flow.namedNodeIds) {
2134
- if (rangeNodes.has(id))
2135
- continue;
2136
- const n = cg.getNode(id);
2137
- if (n && n.filePath === filePath && n.startLine > 0 && n.endLine > 0)
2138
- rangeNodes.set(id, n);
2139
- }
2140
- const ranges = [...rangeNodes.values()]
2141
- // Drop whole-file envelope nodes (containers covering >50% of the file).
2142
- .filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
2143
- .map(n => {
2144
- let importance = 1;
2145
- if (entryNodeIds.has(n.id))
2146
- importance = 10;
2147
- else if (flow.namedNodeIds.has(n.id))
2148
- importance = 9; // agent named it → keep its cluster
2149
- else if (glueNodeIds.has(n.id))
2150
- importance = 6; // bridging caller/callee of an entry
2151
- else if (connectedToEntry.has(n.id))
2152
- importance = 3;
2153
- return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
2154
- });
2155
- // Add edge source locations in this file — captures template references
2156
- // (component usages, event handlers) that aren't nodes themselves.
2157
- // Query edges directly from the DB (not just the subgraph) because BFS
2158
- // traversal may have pruned template reference targets due to node budget.
2159
- const edgeLines = new Set(); // dedup by "line:name"
2160
- for (const node of group.nodes) {
2161
- const outgoing = cg.getOutgoingEdges(node.id);
2162
- for (const edge of outgoing) {
2163
- if (!edge.line || edge.line <= 0 || edge.kind === 'contains')
2164
- continue;
2165
- const key = `${edge.line}:${edge.target}`;
2166
- if (edgeLines.has(key))
2167
- continue;
2168
- edgeLines.add(key);
2169
- // Look up target name from subgraph first, fall back to edge kind
2170
- const targetNode = subgraph.nodes.get(edge.target);
2171
- const targetName = targetNode?.name ?? edge.kind;
2172
- ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
2173
- }
2174
- }
2175
- ranges.sort((a, b) => a.start - b.start);
2176
- if (ranges.length === 0)
2177
- continue;
2178
- const gapThreshold = budget.gapThreshold;
2179
- const clusters = [];
2180
- let current = {
2181
- start: ranges[0].start,
2182
- end: ranges[0].end,
2183
- symbols: [`${ranges[0].name}(${ranges[0].kind})`],
2184
- score: ranges[0].importance,
2185
- maxImportance: ranges[0].importance,
2186
- };
2187
- for (let i = 1; i < ranges.length; i++) {
2188
- const r = ranges[i];
2189
- if (r.start <= current.end + gapThreshold) {
2190
- current.end = Math.max(current.end, r.end);
2191
- current.symbols.push(`${r.name}(${r.kind})`);
2192
- current.score += r.importance;
2193
- current.maxImportance = Math.max(current.maxImportance, r.importance);
2194
- }
2195
- else {
2196
- clusters.push(current);
2197
- current = {
2198
- start: r.start,
2199
- end: r.end,
2200
- symbols: [`${r.name}(${r.kind})`],
2201
- score: r.importance,
2202
- maxImportance: r.importance,
2203
- };
2204
- }
2205
- }
2206
- clusters.push(current);
2207
- // Build file section output from clusters, capped by per-file budget.
2208
- // The pathological case (#185): a file like Session.swift where every
2209
- // method is adjacent collapses into one cluster spanning the whole
2210
- // file, and dumping that into the agent's context is most of the
2211
- // token cost on small projects. We pick clusters in priority order
2212
- // until the per-file char cap is hit. Truly enormous single clusters
2213
- // get tail-trimmed with a marker.
2214
- const contextPadding = 3;
2215
- const withLineNumbers = exploreLineNumbersEnabled();
2216
- const buildSection = (c) => {
2217
- const startIdx = Math.max(0, c.start - 1 - contextPadding);
2218
- const endIdx = Math.min(fileLines.length, c.end + contextPadding);
2219
- const slice = fileLines.slice(startIdx, endIdx).join('\n');
2220
- // startIdx is 0-based, so the slice's first line is line startIdx + 1.
2221
- return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
2222
- };
2223
- // Language-neutral separator (no `//` — not a comment in Python, Ruby,
2224
- // etc.). With line numbers on, the line-number jump also signals the gap.
2225
- const GAP_MARKER = '\n\n... (gap) ...\n\n';
2226
- // Rank clusters for inclusion under the per-file cap. Entry-point
2227
- // clusters come first: a cluster containing a query entry point
2228
- // (importance 10) must outrank a dense block of mere declarations,
2229
- // otherwise on a large file like Session.swift the top-of-file class
2230
- // header + property list (many adjacent low-importance nodes, high
2231
- // density) wins the budget and buries the actual methods the query
2232
- // asked about (perform/didCreateURLRequest/task live deep in the
2233
- // file). Within the same importance tier, prefer density (score per
2234
- // line) so we still favor focused clusters over sprawling ones, then
2235
- // smaller span as a cheap-to-include tiebreak.
2236
- const rankedClusters = clusters
2237
- .map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
2238
- .sort((a, b) => {
2239
- if (b.c.maxImportance !== a.c.maxImportance)
2240
- return b.c.maxImportance - a.c.maxImportance;
2241
- const densityA = a.c.score / a.span;
2242
- const densityB = b.c.score / b.span;
2243
- if (densityB !== densityA)
2244
- return densityB - densityA;
2245
- if (b.c.score !== a.c.score)
2246
- return b.c.score - a.c.score;
2247
- return a.span - b.span;
2248
- });
2249
- // Per-file budget is the SMALLER of the per-file cap and what's left of the
2250
- // total output cap — so selection (which ranks by importance) keeps the
2251
- // high-importance clusters and drops peripheral ones, instead of the
2252
- // downstream source-order trim slicing off whatever comes last in the file.
2253
- // That source-order slice is what cut Django's `_fetch_all` (L2237, importance
2254
- // 9 — agent-named) when query.py was the last of four big files to be emitted.
2255
- const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
2256
- const chosenIndices = new Set();
2257
- let projectedChars = 0;
2258
- for (const rc of rankedClusters) {
2259
- const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
2260
- // Always take the top-ranked cluster, even if oversize, so we don't
2261
- // return an empty file section (agent would then re-Read the file,
2262
- // negating the savings).
2263
- if (chosenIndices.size === 0) {
2264
- chosenIndices.add(rc.idx);
2265
- projectedChars += sectionLen;
2266
- continue;
2267
- }
2268
- if (projectedChars + sectionLen > fileBudget)
2269
- continue;
2270
- chosenIndices.add(rc.idx);
2271
- projectedChars += sectionLen;
2272
- }
2273
- // Emit chosen clusters in source order so the file reads top-to-bottom.
2274
- let fileSection = '';
2275
- const allSymbols = [];
2276
- for (let i = 0; i < clusters.length; i++) {
2277
- if (!chosenIndices.has(i))
2278
- continue;
2279
- const cluster = clusters[i];
2280
- const section = buildSection(cluster);
2281
- if (fileSection.length > 0)
2282
- fileSection += GAP_MARKER;
2283
- fileSection += section;
2284
- allSymbols.push(...cluster.symbols);
2285
- }
2286
- // A chosen cluster is a COMPLETE method-range — we never cut through a body.
2287
- // An oversize single cluster (a long monolithic function) renders in FULL:
2288
- // half a method is useless (the agent just Reads the rest for the other half),
2289
- // which is the very fallback explore exists to prevent. A pathological file is
2290
- // bounded by the per-file cluster SELECTION above + the total hard ceiling.
2291
- if (chosenIndices.size < clusters.length) {
2292
- anyFileTrimmed = true;
2293
- }
2294
- // Dedupe + cap the symbols list shown in the per-file header. Some
2295
- // files (Session.swift in Alamofire) produced 3.4KB symbol lists
2296
- // from cluster scoring + edge-source lines, dwarfing the per-file
2297
- // body cap. Show top names by frequency, with a "+N more" tail.
2298
- const symbolCounts = new Map();
2299
- for (const s of allSymbols) {
2300
- symbolCounts.set(s, (symbolCounts.get(s) ?? 0) + 1);
2301
- }
2302
- const sortedSymbols = [...symbolCounts.entries()]
2303
- .sort((a, b) => b[1] - a[1])
2304
- .map(([name]) => name);
2305
- const headerCap = budget.maxSymbolsInFileHeader;
2306
- const headerSymbols = sortedSymbols.slice(0, headerCap);
2307
- const omittedCount = sortedSymbols.length - headerSymbols.length;
2308
- const headerSuffix = omittedCount > 0
2309
- ? `${headerSymbols.join(', ')}, +${omittedCount} more`
2310
- : headerSymbols.join(', ');
2311
- const fileHeader = `#### ${filePath} — ${headerSuffix}`;
2312
- // The total cap bounds INCIDENTAL files only. A file that DEFINES a symbol
2313
- // the agent named (or that's on the flow spine) renders even when the
2314
- // nominal total is used up — it's the answer, and the set is bounded by
2315
- // maxFiles AND by true-spine/named-seeding having already trimmed each file
2316
- // to its necessary content. A file that merely REFERENCES the flow
2317
- // (Combine.swift name-drops request/task) is incidental → still capped, so
2318
- // freed budget never leaks into noise. This is the last god-file layer:
2319
- // build (Session, true-spined) + validators-exec (Request) + validate
2320
- // (DataRequest/Validation) all render, instead of the cap dropping whichever
2321
- // phase the file order happened to put last.
2322
- if (!fileNecessary && totalChars + fileSection.length + 200 > budget.maxOutputChars) {
2323
- // Incidental file that doesn't fit: SKIP it whole — never slice mid-method.
2324
- // Keep scanning for necessary files (which bypass this cap and render in
2325
- // full, bounded by the hard ceiling).
2326
- anyFileTrimmed = true;
2327
- continue;
2328
- }
2329
- lines.push(fileHeader);
2330
- lines.push('');
2331
- lines.push('```' + lang);
2332
- lines.push(fileSection);
2333
- lines.push('```');
2334
- lines.push('');
2335
- totalChars += fileSection.length + 200;
2336
- filesIncluded++;
2337
- }
2338
- // Add remaining files as references (from both relevant and peripheral files).
2339
- // Small projects (per budget) skip this — the relevant story already fits
2340
- // in the source section, and a trailing pointer list is pure overhead.
2341
- if (budget.includeAdditionalFiles) {
2342
- const remainingRelevant = sortedFiles.slice(filesIncluded);
2343
- const peripheralFiles = [...fileGroups.entries()]
2344
- .filter(([, group]) => group.score < 3)
2345
- .sort((a, b) => b[1].score - a[1].score);
2346
- const remainingFiles = [...remainingRelevant, ...peripheralFiles];
2347
- if (remainingFiles.length > 0) {
2348
- lines.push('### Not shown above — explore these names for their source');
2349
- lines.push('');
2350
- for (const [filePath, group] of remainingFiles.slice(0, 10)) {
2351
- const symbols = group.nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
2352
- lines.push(`- ${filePath}: ${symbols}`);
2353
- }
2354
- if (remainingFiles.length > 10) {
2355
- lines.push(`- ... and ${remainingFiles.length - 10} more files`);
2356
- }
2357
- }
2358
- }
2359
- // Add completeness signal so agents know they don't need to re-read these files.
2360
- // On small projects the budget gates this off — but if we actually had to
2361
- // trim or drop clusters, surface a brief note so the agent knows it can
2362
- // still Read for more detail.
2363
- if (budget.includeCompletenessSignal) {
2364
- lines.push('');
2365
- lines.push('---');
2366
- lines.push(`> **Complete source for ${filesIncluded} files is included above — do NOT re-read them.** If your question also needs files/symbols listed under "Not shown above" (or any area this call didn't cover), make ANOTHER specship_explore targeting those names — it returns the same source with line numbers and is cheaper and more complete than reading. Reserve Read for a single specific line range explore can't surface.`);
2367
- }
2368
- else if (anyFileTrimmed) {
2369
- lines.push('');
2370
- lines.push(`> Some file sections were trimmed for size. For a specific symbol you still need, run another \`specship_explore\` (or \`specship_node\`) with its exact name — line-numbered source, cheaper and more complete than Read.`);
2371
- }
2372
- // Add explore budget note based on project size
2373
- if (budget.includeBudgetNote) {
2374
- try {
2375
- const stats = cg.getStats();
2376
- const callBudget = getExploreBudget(stats.fileCount);
2377
- lines.push('');
2378
- lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
2379
- }
2380
- catch {
2381
- // Stats unavailable — skip budget note
2382
- }
2383
- }
2384
- // Final ceiling — an ABSOLUTE inline cap, not a multiple of the budget. The
2385
- // render loop renders necessary (named/spine) files even a bit past
2386
- // maxOutputChars and caps only incidental ones, so this is the last safety.
2387
- // It MUST stay under the host's inline tool-result limit (~25K chars): above
2388
- // that the result is externalized to a file the agent Reads back (a 35K
2389
- // vscode explore did exactly this in the n=4 A/B). So allow a little
2390
- // necessary overflow above the 24K budget, but hard-stop at 25K — never into
2391
- // externalize territory.
2392
- const output = flow.text + lines.join('\n');
2393
- const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
2394
- if (output.length > hardCeiling) {
2395
- // Cut at a FILE-SECTION boundary (the last `#### ` header before the
2396
- // ceiling) so we drop whole trailing file-sections rather than slicing
2397
- // through a method body — a half-rendered method just forces the Read this
2398
- // tool exists to prevent. Fall back to a line boundary only if no section
2399
- // header sits in the back half (degenerate single-giant-section case).
2400
- const cut = output.slice(0, hardCeiling);
2401
- const lastSection = cut.lastIndexOf('\n#### ');
2402
- const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
2403
- const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
2404
- return this.textResult(safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For any area not covered, run another specship_explore with the specific names — do NOT Read these files.)');
2405
- }
2406
- return this.textResult(output);
2407
- }
2408
- /**
2409
- * Handle specship_node
2410
- */
2411
- async handleNode(args) {
2412
- const symbol = this.validateString(args.symbol, 'symbol');
2413
- if (typeof symbol !== 'string')
2414
- return symbol;
2415
- const cg = this.getSpecShip(args.projectPath);
2416
- // Default to false to minimize context usage
2417
- const includeCode = args.includeCode === true;
2418
- const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined;
2419
- const lineHint = typeof args.line === 'number' && args.line > 0 ? args.line : undefined;
2420
- let matches = this.findSymbolMatches(cg, symbol);
2421
- if (matches.length === 0) {
2422
- return this.textResult(`Symbol "${symbol}" not found in the codebase`);
2423
- }
2424
- // Disambiguate a heavily-overloaded name to a specific definition the caller
2425
- // pinned by file/line (the `file:line` a trail or another tool showed it) —
2426
- // so it can fetch e.g. `Harness::poll` at harness.rs:153 out of 50+ `poll`s
2427
- // instead of Reading. file matches by path suffix/substring; line prefers the
2428
- // def whose body contains it, else the nearest start. Only narrows (never
2429
- // empties — if a hint matches nothing it's ignored).
2430
- if (matches.length > 1 && (fileHint || lineHint !== undefined)) {
2431
- const norm = (p) => p.replace(/\\/g, '/').toLowerCase();
2432
- let narrowed = matches;
2433
- if (fileHint) {
2434
- const fh = norm(fileHint);
2435
- const byFile = narrowed.filter((n) => norm(n.filePath).endsWith(fh) || norm(n.filePath).includes(fh));
2436
- if (byFile.length > 0)
2437
- narrowed = byFile;
2438
- }
2439
- if (lineHint !== undefined && narrowed.length > 1) {
2440
- const containing = narrowed.filter((n) => n.startLine <= lineHint && (n.endLine ?? n.startLine) >= lineHint);
2441
- narrowed = containing.length > 0
2442
- ? containing
2443
- : [...narrowed].sort((a, b) => Math.abs(a.startLine - lineHint) - Math.abs(b.startLine - lineHint)).slice(0, 1);
2444
- }
2445
- if (narrowed.length > 0)
2446
- matches = narrowed;
2447
- }
2448
- // Single definition — the common case.
2449
- if (matches.length === 1) {
2450
- return this.textResult(this.truncateOutput(await this.renderNodeSection(cg, matches[0], includeCode)));
2451
- }
2452
- // Multiple definitions share this name — overloads, or same-named methods on
2453
- // different types (Alamofire `didCompleteTask`/`task`/`validate`, gin
2454
- // `reset`). Returning ONE forces the agent to guess, and when it guesses
2455
- // wrong it READS the file to find the right overload — the dominant
2456
- // specship_node read cause on Swift/Go. So return them ALL: pack as many
2457
- // FULL bodies as fit a char budget (the agent gets the one it needs in this
2458
- // one call, no follow-up parameter to learn), and list any remainder by
2459
- // file:line so a large overload set can't overflow the per-tool cap.
2460
- const header = `**${matches.length} definitions named "${symbol}"**`;
2461
- if (!includeCode) {
2462
- const list = matches.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`);
2463
- return this.textResult(this.truncateOutput([header, '', 'Re-query with `includeCode: true` to get every body in one call — no need to pick one first.', '', ...list].join('\n')));
2464
- }
2465
- const BODY_BUDGET = 12000; // leaves room under MAX_OUTPUT_LENGTH for the header + list
2466
- // The CHAR budget is the real limiter — keep the count cap high so a set of
2467
- // SHORT overloads (Alamofire's 10 `validate` variants, each a few lines) all
2468
- // render in full rather than relegating the one the agent wanted to a
2469
- // bodiless list. Only a set of many LARGE bodies hits the char budget first.
2470
- const HARD_CAP = 16;
2471
- const rendered = [];
2472
- const listed = [];
2473
- let used = 0;
2474
- for (const n of matches) {
2475
- if (rendered.length >= HARD_CAP) {
2476
- listed.push(n);
2477
- continue;
2478
- }
2479
- const section = await this.renderNodeSection(cg, n, true);
2480
- // Always emit the first; emit the rest only while within the char budget.
2481
- if (rendered.length === 0 || used + section.length <= BODY_BUDGET) {
2482
- rendered.push(section);
2483
- used += section.length;
2484
- }
2485
- else {
2486
- listed.push(n);
2487
- }
2488
- }
2489
- const out = [
2490
- header,
2491
- `Returning ${rendered.length} in full${listed.length ? `; ${listed.length} more listed below` : ''} — pick the one you need (no Read required).`,
2492
- '',
2493
- rendered.join('\n\n---\n\n'),
2494
- ];
2495
- if (listed.length) {
2496
- const LIST_CAP = 20;
2497
- const shownList = listed.slice(0, LIST_CAP);
2498
- out.push('', '### Other definitions', ...shownList.map((n) => `- \`${n.name}\` (${n.kind}) — ${n.filePath}:${n.startLine}`));
2499
- if (listed.length > LIST_CAP)
2500
- out.push(`- … +${listed.length - LIST_CAP} more`);
2501
- out.push('', `> Need one of these in full? Call specship_node again with \`file\` (e.g. \`"${listed[0].filePath.split('/').pop()}"\`) or \`line\` — do NOT Read it.`);
2502
- }
2503
- return this.textResult(this.truncateOutput(out.join('\n')));
2504
- }
2505
- /** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
2506
- async renderNodeSection(cg, node, includeCode) {
2507
- let code = null;
2508
- let outline = null;
2509
- if (includeCode) {
2510
- // For container symbols (class/interface/struct/…), the full body is the
2511
- // sum of every method body — a wall of source. Return a structural outline
2512
- // (members + signatures + line numbers) instead; leaf symbols return their
2513
- // full body.
2514
- if (CONTAINER_NODE_KINDS.has(node.kind)) {
2515
- outline = this.buildContainerOutline(cg, node);
2516
- }
2517
- if (!outline) {
2518
- code = await cg.getCode(node.id);
2519
- }
2520
- }
2521
- return (this.formatNodeDetails(node, code, outline) +
2522
- this.formatTrail(cg, node) +
2523
- (0, spec_tools_1.renderLinkedSpecsForNode)(cg, node.id));
2524
- }
2525
- /**
2526
- * Build the "trail" for a symbol: its direct callees (what it calls) and
2527
- * callers (what calls it), each with file:line — so specship_node doubles as
2528
- * the structural Grep→Read→expand primitive: a spot PLUS where to go next.
2529
- * Capped to stay cheap. Walk the graph by calling specship_node on a trail
2530
- * entry; no Read needed for covered hops. Empty edges on a non-leaf often mean
2531
- * dynamic dispatch the static graph couldn't resolve — that absence is itself
2532
- * a signal (read that one hop) rather than a dead end.
2533
- */
2534
- formatTrail(cg, node) {
2535
- const TRAIL_CAP = 12;
2536
- const fmt = (e) => {
2537
- const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`;
2538
- const synth = this.synthEdgeNote(e.edge);
2539
- return synth ? `${base} [${synth.compact}]` : base;
2540
- };
2541
- const collect = (edges) => {
2542
- const seen = new Set([node.id]);
2543
- const out = [];
2544
- for (const e of edges) {
2545
- if (seen.has(e.node.id))
2546
- continue;
2547
- seen.add(e.node.id);
2548
- out.push(e);
2549
- }
2550
- return out;
2551
- };
2552
- const callees = collect(cg.getCallees(node.id));
2553
- const callers = collect(cg.getCallers(node.id));
2554
- if (callees.length === 0 && callers.length === 0)
2555
- return '';
2556
- const lines = ['', '### Trail — specship_node any of these to follow it (no Read needed)'];
2557
- if (callees.length > 0) {
2558
- lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`);
2559
- }
2560
- if (callers.length > 0) {
2561
- lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`);
2562
- }
2563
- return lines.join('\n');
2564
- }
2565
- /**
2566
- * Handle specship_status
2567
- */
2568
- async handleStatus(args) {
2569
- let cg = this.getSpecShip(args.projectPath);
2570
- // Same trick as withStalenessNotice — when an explicit projectPath
2571
- // resolves to the same project as the default session cg, prefer the
2572
- // default so getPendingFiles() (only populated by the default's watcher)
2573
- // is non-empty when there are pending edits.
2574
- if (this.cg && cg !== this.cg) {
2575
- try {
2576
- if ((0, path_1.resolve)(this.cg.getProjectRoot()) === (0, path_1.resolve)(cg.getProjectRoot())) {
2577
- cg = this.cg;
2578
- }
2579
- }
2580
- catch { /* closed instance — leave as is */ }
2581
- }
2582
- const stats = cg.getStats();
2583
- // Warn when this index actually belongs to a different git working tree
2584
- // (e.g. the server resolved up from a nested worktree to the main checkout).
2585
- // Queries then reflect that tree's branch, not the worktree being edited.
2586
- // status shows the verbose, multi-line form; the read tools get the compact
2587
- // one-liner via withWorktreeNotice. Both share the cached detection.
2588
- const mismatch = this.worktreeMismatchFor(args.projectPath);
2589
- const lines = [
2590
- '## SpecShip Status',
2591
- '',
2592
- ];
2593
- if (mismatch) {
2594
- lines.push(`> ⚠ ${(0, worktree_1.worktreeMismatchWarning)(mismatch).replace(/\n/g, '\n> ')}`, '');
2595
- }
2596
- lines.push(`**Files indexed:** ${stats.fileCount}`, `**Total nodes:** ${stats.nodeCount}`, `**Total edges:** ${stats.edgeCount}`, `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`);
2597
- // Surface the active SQLite backend (node:sqlite, Node's built-in real
2598
- // SQLite — full WAL + FTS5, no native build).
2599
- lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`);
2600
- // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer;
2601
- // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL
2602
- // everywhere, so a non-wal mode means the filesystem can't (network/
2603
- // virtualized mounts, WSL2 /mnt). See issue #238.
2604
- const journalMode = cg.getJournalMode();
2605
- if (journalMode === 'wal') {
2606
- lines.push(`**Journal mode:** wal (concurrent reads safe)`);
2607
- }
2608
- else {
2609
- lines.push(`**Journal mode:** ⚠ ${journalMode || 'unknown'} — WAL not active, so reads ` +
2610
- `can block on a concurrent write (WAL appears unsupported on this filesystem)`);
2611
- }
2612
- lines.push('', '### Nodes by Kind:');
2613
- for (const [kind, count] of Object.entries(stats.nodesByKind)) {
2614
- if (count > 0) {
2615
- lines.push(`- ${kind}: ${count}`);
2616
- }
2617
- }
2618
- lines.push('', '### Languages:');
2619
- for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
2620
- if (count > 0) {
2621
- lines.push(`- ${lang}: ${count}`);
2622
- }
2623
- }
2624
- // Per-file freshness — the inverse of the auto-prepended staleness banner
2625
- // (issue #403). Surfacing it inside `status` gives the agent a single
2626
- // place to ask "is the index caught up?" rather than inferring from
2627
- // banners on other tool calls.
2628
- const pending = cg.getPendingFiles();
2629
- if (pending.length > 0) {
2630
- lines.push('', '### Pending sync:');
2631
- const now = Date.now();
2632
- for (const p of pending) {
2633
- const ageMs = Math.max(0, now - p.lastSeenMs);
2634
- const label = p.indexing ? 'indexing in progress' : 'pending sync';
2635
- lines.push(`- ${p.path} (edited ${ageMs}ms ago, ${label})`);
2636
- }
2637
- }
2638
- return this.textResult(lines.join('\n'));
2639
- }
2640
- /**
2641
- * Handle specship_files - get project file structure from the index
2642
- */
2643
- async handleFiles(args) {
2644
- const cg = this.getSpecShip(args.projectPath);
2645
- const pathFilter = args.path;
2646
- const pattern = args.pattern;
2647
- const format = args.format || 'tree';
2648
- const includeMetadata = args.includeMetadata !== false;
2649
- const maxDepth = args.maxDepth != null ? (0, utils_1.clamp)(args.maxDepth, 1, 20) : undefined;
2650
- // Get all files from the index
2651
- const allFiles = cg.getFiles();
2652
- if (allFiles.length === 0) {
2653
- return this.textResult('No files indexed. Run `specship index` first.');
2654
- }
2655
- // Filter by path prefix. Stored paths are project-relative POSIX (e.g.
2656
- // "src/foo.ts"), but agents commonly pass project-root variants like "/",
2657
- // ".", "./", "" or Windows-style "src\foo" — and prefixes with leading
2658
- // "/", "./" or "\". Normalize all of those before matching so the agent
2659
- // gets results instead of falling back to Read/Glob (see #426).
2660
- const normalizedFilter = pathFilter
2661
- ? pathFilter
2662
- .replace(/\\/g, '/')
2663
- .replace(/^(?:\.?\/+)+/, '')
2664
- .replace(/^\.$/, '')
2665
- .replace(/\/+$/, '')
2666
- : '';
2667
- let files = normalizedFilter
2668
- ? allFiles.filter(f => f.path === normalizedFilter || f.path.startsWith(normalizedFilter + '/'))
2669
- : allFiles;
2670
- // Filter by glob pattern
2671
- if (pattern) {
2672
- const regex = this.globToRegex(pattern);
2673
- files = files.filter(f => regex.test(f.path));
2674
- }
2675
- if (files.length === 0) {
2676
- return this.textResult(`No files found matching the criteria.`);
2677
- }
2678
- // Format output
2679
- let output;
2680
- switch (format) {
2681
- case 'flat':
2682
- output = this.formatFilesFlat(files, includeMetadata);
2683
- break;
2684
- case 'grouped':
2685
- output = this.formatFilesGrouped(files, includeMetadata);
2686
- break;
2687
- case 'tree':
2688
- default:
2689
- output = this.formatFilesTree(files, includeMetadata, maxDepth);
2690
- break;
2691
- }
2692
- return this.textResult(this.truncateOutput(output));
2693
- }
2694
- /**
2695
- * Convert glob pattern to regex
2696
- */
2697
- globToRegex(pattern) {
2698
- const escaped = pattern
2699
- .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars except * and ?
2700
- .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
2701
- .replace(/\*/g, '[^/]*') // * matches anything except /
2702
- .replace(/\?/g, '[^/]') // ? matches single char except /
2703
- .replace(/\{\{GLOBSTAR\}\}/g, '.*'); // ** matches anything including /
2704
- return new RegExp(escaped);
2705
- }
2706
- /**
2707
- * Format files as a flat list
2708
- */
2709
- formatFilesFlat(files, includeMetadata) {
2710
- const lines = [`## Files (${files.length})`, ''];
2711
- for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
2712
- if (includeMetadata) {
2713
- lines.push(`- ${file.path} (${file.language}, ${file.nodeCount} symbols)`);
2714
- }
2715
- else {
2716
- lines.push(`- ${file.path}`);
2717
- }
2718
- }
2719
- return lines.join('\n');
2720
- }
2721
- /**
2722
- * Format files grouped by language
2723
- */
2724
- formatFilesGrouped(files, includeMetadata) {
2725
- const byLang = new Map();
2726
- for (const file of files) {
2727
- const existing = byLang.get(file.language) || [];
2728
- existing.push(file);
2729
- byLang.set(file.language, existing);
2730
- }
2731
- const lines = [`## Files by Language (${files.length} total)`, ''];
2732
- // Sort languages by file count (descending)
2733
- const sortedLangs = [...byLang.entries()].sort((a, b) => b[1].length - a[1].length);
2734
- for (const [lang, langFiles] of sortedLangs) {
2735
- lines.push(`### ${lang} (${langFiles.length})`);
2736
- for (const file of langFiles.sort((a, b) => a.path.localeCompare(b.path))) {
2737
- if (includeMetadata) {
2738
- lines.push(`- ${file.path} (${file.nodeCount} symbols)`);
2739
- }
2740
- else {
2741
- lines.push(`- ${file.path}`);
2742
- }
2743
- }
2744
- lines.push('');
2745
- }
2746
- return lines.join('\n');
2747
- }
2748
- /**
2749
- * Format files as a tree structure
2750
- */
2751
- formatFilesTree(files, includeMetadata, maxDepth) {
2752
- const root = { name: '', children: new Map() };
2753
- for (const file of files) {
2754
- const parts = file.path.split('/');
2755
- let current = root;
2756
- for (let i = 0; i < parts.length; i++) {
2757
- const part = parts[i];
2758
- if (!part)
2759
- continue;
2760
- if (!current.children.has(part)) {
2761
- current.children.set(part, { name: part, children: new Map() });
2762
- }
2763
- current = current.children.get(part);
2764
- // If this is the last part, it's a file
2765
- if (i === parts.length - 1) {
2766
- current.file = { language: file.language, nodeCount: file.nodeCount };
2767
- }
2768
- }
2769
- }
2770
- // Render tree
2771
- const lines = [`## Project Structure (${files.length} files)`, ''];
2772
- const renderNode = (node, prefix, isLast, depth) => {
2773
- if (maxDepth !== undefined && depth > maxDepth)
2774
- return;
2775
- const connector = isLast ? '└── ' : '├── ';
2776
- const childPrefix = isLast ? ' ' : '│ ';
2777
- if (node.name) {
2778
- let line = prefix + connector + node.name;
2779
- if (node.file && includeMetadata) {
2780
- line += ` (${node.file.language}, ${node.file.nodeCount} symbols)`;
2781
- }
2782
- lines.push(line);
2783
- }
2784
- const children = [...node.children.values()];
2785
- // Sort: directories first, then files, both alphabetically
2786
- children.sort((a, b) => {
2787
- const aIsDir = a.children.size > 0 && !a.file;
2788
- const bIsDir = b.children.size > 0 && !b.file;
2789
- if (aIsDir !== bIsDir)
2790
- return aIsDir ? -1 : 1;
2791
- return a.name.localeCompare(b.name);
2792
- });
2793
- for (let i = 0; i < children.length; i++) {
2794
- const child = children[i];
2795
- const nextPrefix = node.name ? prefix + childPrefix : prefix;
2796
- renderNode(child, nextPrefix, i === children.length - 1, depth + 1);
2797
- }
2798
- };
2799
- renderNode(root, '', true, 0);
2800
- return lines.join('\n');
2801
- }
2802
- // =========================================================================
2803
- // Symbol resolution helpers
2804
- // =========================================================================
2805
- /**
2806
- * Find a symbol by name, handling disambiguation when multiple matches exist.
2807
- * Returns the best match and a note about alternatives if any.
2808
- */
2809
- /**
2810
- * Check if a node matches a symbol query.
2811
- *
2812
- * Accepts simple names (`run`) and three flavors of qualifier:
2813
- * - dotted `Session.request` (TS/JS/Python)
2814
- * - colon-pair `stage_apply::run` (Rust, C++, Ruby)
2815
- * - slash `configurator/stage_apply` (path-ish)
2816
- *
2817
- * Multi-level qualifiers compose: `crate::configurator::stage_apply::run`
2818
- * works. Rust path prefixes (`crate`, `super`, `self`) are stripped so
2819
- * the canonical `crate::module::symbol` form resolves.
2820
- *
2821
- * Resolution order, last part must always equal `node.name`:
2822
- * 1. Suffix-match against `qualifiedName` (handles class-scoped methods
2823
- * where the extractor builds the qualified name from the AST stack)
2824
- * 2. File-path containment (handles file-derived modules in Rust/
2825
- * Python — `stage_apply::run` matches a `run` in `stage_apply.rs`)
2826
- */
2827
- matchesSymbol(node, symbol) {
2828
- // Simple name match
2829
- if (node.name === symbol)
2830
- return true;
2831
- // File basename match (e.g., "product-card" matches "product-card.liquid")
2832
- if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol)
2833
- return true;
2834
- // Qualified-name lookups: split on any supported separator. `\w` keeps
2835
- // identifier chars (incl. `_`) intact; everything else is treated as
2836
- // a separator we tolerate.
2837
- if (!/[.\/]|::/.test(symbol))
2838
- return false;
2839
- const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
2840
- if (parts.length < 2)
2841
- return false;
2842
- const lastPart = parts[parts.length - 1];
2843
- if (node.name !== lastPart)
2844
- return false;
2845
- // Stage 1: qualified-name suffix match. The extractor joins the
2846
- // semantic hierarchy with `::`, so `Session.request` and
2847
- // `Session::request` both become `Session::request` here.
2848
- const colonSuffix = parts.join('::');
2849
- if (node.qualifiedName.includes(colonSuffix))
2850
- return true;
2851
- // Stage 2: file-path containment. Rust modules and Python packages
2852
- // are not in `qualifiedName` — they're encoded in the file path. So
2853
- // `stage_apply::run` matches a `run` in any file whose path
2854
- // contains a `stage_apply` segment (with or without an extension).
2855
- //
2856
- // Filter out Rust path prefixes that have no file-system equivalent.
2857
- const containerHints = parts.slice(0, -1).filter((p) => !RUST_PATH_PREFIXES.has(p));
2858
- if (containerHints.length === 0)
2859
- return false;
2860
- const segments = node.filePath.split('/').filter((s) => s.length > 0);
2861
- return containerHints.every((hint) => segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint));
2862
- }
2863
- /**
2864
- * Find ALL definitions matching a name, ranked, so specship_node can return
2865
- * every overload instead of guessing one (the wrong guess → a Read). Keepers
2866
- * rank before generated stubs (.pb.go etc.); stable within a group preserves
2867
- * FTS order. Returns [] when nothing matches; a qualified lookup that finds no
2868
- * exact match returns [] rather than a misleading fuzzy file hit (#173); a
2869
- * bare name with no exact match falls back to the single top fuzzy result.
2870
- */
2871
- findSymbolMatches(cg, symbol) {
2872
- const isQualified = /[.\/]|::/.test(symbol);
2873
- // For a bare name, enumerate EVERY exact-name definition via the direct index
2874
- // (not FTS, which caps + ranks): tokio's `poll` has 50+ defs and the one the
2875
- // caller wants (`Harness::poll` at harness.rs:153) ranks below any search cut,
2876
- // so it could be neither rendered nor pinned by the file/line disambiguator —
2877
- // and the agent Read it. With the full set, the multi-overload render + the
2878
- // file/line filter can both reach it.
2879
- if (!isQualified) {
2880
- const exact = cg.getNodesByName(symbol);
2881
- if (exact.length > 0) {
2882
- return [...exact].sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.filePath) ? 1 : 0));
2883
- }
2884
- // No exact match — use the single top fuzzy result (e.g. a file basename).
2885
- const fuzzy = cg.searchNodes(symbol, { limit: 10 });
2886
- return fuzzy[0] ? [fuzzy[0].node] : [];
2887
- }
2888
- // Qualified lookup (`Session.request`, `stage_apply::run`): FTS + matchesSymbol.
2889
- const limit = 50;
2890
- let results = cg.searchNodes(symbol, { limit });
2891
- // FTS strips colons, so `stage_apply::run` searches the literal
2892
- // `stage_applyrun` and finds nothing. Re-search by the bare last part and
2893
- // let `matchesSymbol` filter by qualifier.
2894
- if (isQualified && results.length === 0) {
2895
- const tail = lastQualifierPart(symbol);
2896
- if (tail && tail !== symbol)
2897
- results = cg.searchNodes(tail, { limit });
2898
- }
2899
- if (results.length === 0)
2900
- return [];
2901
- const exactMatches = results.filter((r) => this.matchesSymbol(r.node, symbol));
2902
- if (exactMatches.length === 0) {
2903
- // No exact match — a qualified lookup must not fall back to a fuzzy file
2904
- // hit (#173); a bare name may use the single top fuzzy result.
2905
- return isQualified ? [] : results[0] ? [results[0].node] : [];
2906
- }
2907
- // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …) so a flow
2908
- // query prefers the keeper implementation over the protobuf-generated stub.
2909
- return [...exactMatches]
2910
- .sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0))
2911
- .map((r) => r.node);
2912
- }
2913
- /**
2914
- * Find ALL symbols matching a name. Used by callers/callees/impact to aggregate
2915
- * results across all matching symbols (e.g., multiple classes with an `execute` method).
2916
- */
2917
- findAllSymbols(cg, symbol) {
2918
- let results = cg.searchNodes(symbol, { limit: 50 });
2919
- // Mirror the fallback in `findSymbol` for qualified queries — FTS
2920
- // strips colons, so a module-qualified lookup needs a second pass
2921
- // by the bare last part.
2922
- if (results.length === 0 && /[.\/]|::/.test(symbol)) {
2923
- const tail = lastQualifierPart(symbol);
2924
- if (tail && tail !== symbol)
2925
- results = cg.searchNodes(tail, { limit: 50 });
2926
- }
2927
- if (results.length === 0) {
2928
- return { nodes: [], note: '' };
2929
- }
2930
- const exactMatches = results.filter(r => this.matchesSymbol(r.node, symbol));
2931
- if (exactMatches.length <= 1) {
2932
- const node = exactMatches[0]?.node ?? results[0].node;
2933
- return { nodes: [node], note: '' };
2934
- }
2935
- // Same generated-file down-rank as findSymbol — keeps callers/callees
2936
- // /impact aggregation aligned (a query against "Send" returns the
2937
- // hand-written implementations before the protobuf scaffold).
2938
- const ranked = [...exactMatches].sort((a, b) => {
2939
- const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
2940
- const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
2941
- return aGen - bGen;
2942
- });
2943
- const locations = ranked.map(r => `${r.node.kind} at ${r.node.filePath}:${r.node.startLine}`);
2944
- const note = `\n\n> **Note:** Aggregated results across ${ranked.length} symbols named "${symbol}": ${locations.join(', ')}`;
2945
- return { nodes: ranked.map(r => r.node), note };
2946
- }
2947
- /**
2948
- * Truncate output if it exceeds the maximum length
2949
- */
2950
- truncateOutput(text) {
2951
- if (text.length <= MAX_OUTPUT_LENGTH)
2952
- return text;
2953
- const truncated = text.slice(0, MAX_OUTPUT_LENGTH);
2954
- const lastNewline = truncated.lastIndexOf('\n');
2955
- const cutPoint = lastNewline > MAX_OUTPUT_LENGTH * 0.8 ? lastNewline : MAX_OUTPUT_LENGTH;
2956
- return truncated.slice(0, cutPoint) + '\n\n... (output truncated)';
2957
- }
2958
- // =========================================================================
2959
- // Formatting helpers (compact by default to reduce context usage)
2960
- // =========================================================================
2961
- formatSearchResults(results) {
2962
- const lines = [`## Search Results (${results.length} found)`, ''];
2963
- for (const result of results) {
2964
- const { node } = result;
2965
- const location = node.startLine ? `:${node.startLine}` : '';
2966
- // Compact format: one line per result with key info
2967
- lines.push(`### ${node.name} (${node.kind})`);
2968
- lines.push(`${node.filePath}${location}`);
2969
- if (node.signature)
2970
- lines.push(`\`${node.signature}\``);
2971
- lines.push('');
2972
- }
2973
- return lines.join('\n');
2974
- }
2975
- formatNodeList(nodes, title) {
2976
- const lines = [`## ${title} (${nodes.length} found)`, ''];
2977
- for (const node of nodes) {
2978
- const location = node.startLine ? `:${node.startLine}` : '';
2979
- // Compact: just name, kind, location
2980
- lines.push(`- ${node.name} (${node.kind}) - ${node.filePath}${location}`);
2981
- }
2982
- return lines.join('\n');
2983
- }
2984
- formatImpact(symbol, impact) {
2985
- const nodeCount = impact.nodes.size;
2986
- // Compact format: just list affected symbols grouped by file
2987
- const lines = [
2988
- `## Impact: "${symbol}" affects ${nodeCount} symbols`,
2989
- '',
2990
- ];
2991
- // Group by file
2992
- const byFile = new Map();
2993
- for (const node of impact.nodes.values()) {
2994
- const existing = byFile.get(node.filePath) || [];
2995
- existing.push(node);
2996
- byFile.set(node.filePath, existing);
2997
- }
2998
- for (const [file, nodes] of byFile) {
2999
- lines.push(`**${file}:**`);
3000
- // Compact: inline list
3001
- const nodeList = nodes.map(n => `${n.name}:${n.startLine}`).join(', ');
3002
- lines.push(nodeList);
3003
- lines.push('');
3004
- }
3005
- return lines.join('\n');
3006
- }
3007
- /**
3008
- * Build a compact structural outline of a container symbol from its
3009
- * indexed children (methods, fields, properties, …) — name, kind,
3010
- * line number, and signature — so the agent gets the shape of a class
3011
- * without the full source of every method. Returns '' when the container
3012
- * has no indexed children, so the caller can fall back to full source.
3013
- */
3014
- buildContainerOutline(cg, node) {
3015
- const children = cg.getChildren(node.id)
3016
- .filter(c => c.kind !== 'import' && c.kind !== 'export')
3017
- .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0));
3018
- if (children.length === 0)
3019
- return '';
3020
- const lines = [`**Members (${children.length}):**`, ''];
3021
- for (const c of children) {
3022
- const loc = c.startLine ? `:${c.startLine}` : '';
3023
- const sig = c.signature ? ` — \`${c.signature}\`` : '';
3024
- lines.push(`- ${c.name} (${c.kind})${loc}${sig}`);
3025
- }
3026
- return lines.join('\n');
3027
- }
3028
- formatNodeDetails(node, code, outline) {
3029
- const location = node.startLine ? `:${node.startLine}` : '';
3030
- const lines = [
3031
- `## ${node.name} (${node.kind})`,
3032
- '',
3033
- `**Location:** ${node.filePath}${location}`,
3034
- ];
3035
- if (node.signature) {
3036
- lines.push(`**Signature:** \`${node.signature}\``);
3037
- }
3038
- // Only include docstring if it's short and useful
3039
- if (node.docstring && node.docstring.length < 200) {
3040
- lines.push('', node.docstring);
3041
- }
3042
- if (outline) {
3043
- lines.push('', outline, '', `> Structural outline only. Read \`${node.filePath}\` or call specship_node on a specific member for its body.`);
3044
- }
3045
- else if (code) {
3046
- // Line-numbered (cat -n style, like specship_explore and Read) so the
3047
- // agent can cite/edit exact lines without re-Reading the file for them.
3048
- const numbered = node.startLine ? numberSourceLines(code, node.startLine) : code;
3049
- lines.push('', '```' + node.language, numbered, '```');
3050
- }
3051
- return lines.join('\n');
3052
- }
3053
- textResult(text) {
3054
- return {
3055
- content: [{ type: 'text', text }],
3056
- };
3057
- }
3058
- errorResult(message) {
3059
- return {
3060
- content: [{ type: 'text', text: `Error: ${message}` }],
3061
- isError: true,
3062
- };
3063
- }
3064
- }
3065
- exports.ToolHandler = ToolHandler;
3066
- //# sourceMappingURL=tools.js.map