scoutline 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/README.md +108 -26
  2. package/dist/capabilities/investigation.d.ts +134 -0
  3. package/dist/capabilities/investigation.d.ts.map +1 -0
  4. package/dist/capabilities/investigation.js +278 -0
  5. package/dist/capabilities/investigation.js.map +1 -0
  6. package/dist/capabilities/search.d.ts +18 -4
  7. package/dist/capabilities/search.d.ts.map +1 -1
  8. package/dist/commands/config.d.ts.map +1 -1
  9. package/dist/commands/config.js +14 -5
  10. package/dist/commands/config.js.map +1 -1
  11. package/dist/commands/crawl.d.ts.map +1 -1
  12. package/dist/commands/crawl.js +2 -1
  13. package/dist/commands/crawl.js.map +1 -1
  14. package/dist/commands/doctor.d.ts.map +1 -1
  15. package/dist/commands/doctor.js +2 -1
  16. package/dist/commands/doctor.js.map +1 -1
  17. package/dist/commands/init.d.ts +42 -1
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +42 -5
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/investigate.d.ts +232 -0
  22. package/dist/commands/investigate.d.ts.map +1 -0
  23. package/dist/commands/investigate.js +838 -0
  24. package/dist/commands/investigate.js.map +1 -0
  25. package/dist/commands/map.d.ts.map +1 -1
  26. package/dist/commands/map.js +2 -1
  27. package/dist/commands/map.js.map +1 -1
  28. package/dist/commands/quota.d.ts.map +1 -1
  29. package/dist/commands/quota.js +2 -2
  30. package/dist/commands/quota.js.map +1 -1
  31. package/dist/commands/read.d.ts.map +1 -1
  32. package/dist/commands/read.js +2 -1
  33. package/dist/commands/read.js.map +1 -1
  34. package/dist/commands/repo.js +1 -1
  35. package/dist/commands/research.d.ts.map +1 -1
  36. package/dist/commands/research.js +2 -1
  37. package/dist/commands/research.js.map +1 -1
  38. package/dist/commands/search.d.ts +54 -6
  39. package/dist/commands/search.d.ts.map +1 -1
  40. package/dist/commands/search.js +273 -25
  41. package/dist/commands/search.js.map +1 -1
  42. package/dist/commands/vision.d.ts.map +1 -1
  43. package/dist/commands/vision.js +15 -2
  44. package/dist/commands/vision.js.map +1 -1
  45. package/dist/index.d.ts +93 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +737 -14
  48. package/dist/index.js.map +1 -1
  49. package/dist/lib/code-mode.d.ts +12 -0
  50. package/dist/lib/code-mode.d.ts.map +1 -1
  51. package/dist/lib/code-mode.js +18 -10
  52. package/dist/lib/code-mode.js.map +1 -1
  53. package/dist/lib/config-store.d.ts +45 -1
  54. package/dist/lib/config-store.d.ts.map +1 -1
  55. package/dist/lib/config-store.js +70 -1
  56. package/dist/lib/config-store.js.map +1 -1
  57. package/dist/lib/config.d.ts.map +1 -1
  58. package/dist/lib/config.js +5 -1
  59. package/dist/lib/config.js.map +1 -1
  60. package/dist/lib/errors.d.ts +7 -1
  61. package/dist/lib/errors.d.ts.map +1 -1
  62. package/dist/lib/errors.js +8 -2
  63. package/dist/lib/errors.js.map +1 -1
  64. package/dist/lib/execution.d.ts +14 -5
  65. package/dist/lib/execution.d.ts.map +1 -1
  66. package/dist/lib/execution.js +25 -6
  67. package/dist/lib/execution.js.map +1 -1
  68. package/dist/lib/investigate-claims.d.ts +90 -0
  69. package/dist/lib/investigate-claims.d.ts.map +1 -0
  70. package/dist/lib/investigate-claims.js +188 -0
  71. package/dist/lib/investigate-claims.js.map +1 -0
  72. package/dist/lib/investigate-extract.d.ts +35 -0
  73. package/dist/lib/investigate-extract.d.ts.map +1 -0
  74. package/dist/lib/investigate-extract.js +101 -0
  75. package/dist/lib/investigate-extract.js.map +1 -0
  76. package/dist/lib/investigate-planner.d.ts +58 -0
  77. package/dist/lib/investigate-planner.d.ts.map +1 -0
  78. package/dist/lib/investigate-planner.js +141 -0
  79. package/dist/lib/investigate-planner.js.map +1 -0
  80. package/dist/lib/mcp-client.d.ts +7 -0
  81. package/dist/lib/mcp-client.d.ts.map +1 -1
  82. package/dist/lib/mcp-client.js +11 -1
  83. package/dist/lib/mcp-client.js.map +1 -1
  84. package/dist/lib/parse-zoned-instant.d.ts +13 -0
  85. package/dist/lib/parse-zoned-instant.d.ts.map +1 -0
  86. package/dist/lib/parse-zoned-instant.js +17 -0
  87. package/dist/lib/parse-zoned-instant.js.map +1 -0
  88. package/dist/lib/quota-mapping.d.ts +4 -2
  89. package/dist/lib/quota-mapping.d.ts.map +1 -1
  90. package/dist/lib/quota-mapping.js +25 -2
  91. package/dist/lib/quota-mapping.js.map +1 -1
  92. package/dist/lib/redact.d.ts +3 -2
  93. package/dist/lib/redact.d.ts.map +1 -1
  94. package/dist/lib/redact.js +90 -24
  95. package/dist/lib/redact.js.map +1 -1
  96. package/dist/lib/timeout.d.ts +28 -0
  97. package/dist/lib/timeout.d.ts.map +1 -0
  98. package/dist/lib/timeout.js +30 -0
  99. package/dist/lib/timeout.js.map +1 -0
  100. package/dist/lib/url.d.ts +10 -4
  101. package/dist/lib/url.d.ts.map +1 -1
  102. package/dist/lib/url.js +21 -6
  103. package/dist/lib/url.js.map +1 -1
  104. package/dist/providers/arxiv/client.d.ts +4 -1
  105. package/dist/providers/arxiv/client.d.ts.map +1 -1
  106. package/dist/providers/arxiv/client.js +11 -3
  107. package/dist/providers/arxiv/client.js.map +1 -1
  108. package/dist/providers/bocha/adapter.d.ts +43 -0
  109. package/dist/providers/bocha/adapter.d.ts.map +1 -0
  110. package/dist/providers/bocha/adapter.js +197 -0
  111. package/dist/providers/bocha/adapter.js.map +1 -0
  112. package/dist/providers/bocha/client.d.ts +51 -0
  113. package/dist/providers/bocha/client.d.ts.map +1 -0
  114. package/dist/providers/bocha/client.js +104 -0
  115. package/dist/providers/bocha/client.js.map +1 -0
  116. package/dist/providers/bocha/credentials.d.ts +12 -0
  117. package/dist/providers/bocha/credentials.d.ts.map +1 -0
  118. package/dist/providers/bocha/credentials.js +29 -0
  119. package/dist/providers/bocha/credentials.js.map +1 -0
  120. package/dist/providers/bocha/diagnostics.d.ts +17 -0
  121. package/dist/providers/bocha/diagnostics.d.ts.map +1 -0
  122. package/dist/providers/bocha/diagnostics.js +33 -0
  123. package/dist/providers/bocha/diagnostics.js.map +1 -0
  124. package/dist/providers/brave/adapter.d.ts.map +1 -1
  125. package/dist/providers/brave/adapter.js +4 -3
  126. package/dist/providers/brave/adapter.js.map +1 -1
  127. package/dist/providers/brave/client.d.ts +1 -0
  128. package/dist/providers/brave/client.d.ts.map +1 -1
  129. package/dist/providers/brave/client.js +4 -6
  130. package/dist/providers/brave/client.js.map +1 -1
  131. package/dist/providers/brave/credentials.d.ts +2 -0
  132. package/dist/providers/brave/credentials.d.ts.map +1 -1
  133. package/dist/providers/brave/credentials.js +2 -0
  134. package/dist/providers/brave/credentials.js.map +1 -1
  135. package/dist/providers/catalog.d.ts +34 -0
  136. package/dist/providers/catalog.d.ts.map +1 -0
  137. package/dist/providers/catalog.js +57 -0
  138. package/dist/providers/catalog.js.map +1 -0
  139. package/dist/providers/crossref/adapter.js +2 -2
  140. package/dist/providers/crossref/adapter.js.map +1 -1
  141. package/dist/providers/crossref/client.d.ts +4 -1
  142. package/dist/providers/crossref/client.d.ts.map +1 -1
  143. package/dist/providers/crossref/client.js +11 -3
  144. package/dist/providers/crossref/client.js.map +1 -1
  145. package/dist/providers/crossref/diagnostics.d.ts +1 -1
  146. package/dist/providers/crossref/diagnostics.js +1 -1
  147. package/dist/providers/crossref/diagnostics.js.map +1 -1
  148. package/dist/providers/europepmc/adapter.js +2 -2
  149. package/dist/providers/europepmc/adapter.js.map +1 -1
  150. package/dist/providers/europepmc/client.d.ts +4 -1
  151. package/dist/providers/europepmc/client.d.ts.map +1 -1
  152. package/dist/providers/europepmc/client.js +11 -3
  153. package/dist/providers/europepmc/client.js.map +1 -1
  154. package/dist/providers/exa/client.d.ts +1 -0
  155. package/dist/providers/exa/client.d.ts.map +1 -1
  156. package/dist/providers/exa/client.js +3 -2
  157. package/dist/providers/exa/client.js.map +1 -1
  158. package/dist/providers/exa/credentials.d.ts +2 -0
  159. package/dist/providers/exa/credentials.d.ts.map +1 -1
  160. package/dist/providers/exa/credentials.js +2 -0
  161. package/dist/providers/exa/credentials.js.map +1 -1
  162. package/dist/providers/firecrawl/adapter.d.ts.map +1 -1
  163. package/dist/providers/firecrawl/adapter.js +2 -1
  164. package/dist/providers/firecrawl/adapter.js.map +1 -1
  165. package/dist/providers/firecrawl/client.d.ts +1 -0
  166. package/dist/providers/firecrawl/client.d.ts.map +1 -1
  167. package/dist/providers/firecrawl/client.js +3 -2
  168. package/dist/providers/firecrawl/client.js.map +1 -1
  169. package/dist/providers/firecrawl/credentials.d.ts +2 -0
  170. package/dist/providers/firecrawl/credentials.d.ts.map +1 -1
  171. package/dist/providers/firecrawl/credentials.js +2 -0
  172. package/dist/providers/firecrawl/credentials.js.map +1 -1
  173. package/dist/providers/firecrawl/quota.d.ts.map +1 -1
  174. package/dist/providers/firecrawl/quota.js +5 -2
  175. package/dist/providers/firecrawl/quota.js.map +1 -1
  176. package/dist/providers/jina/adapter.d.ts.map +1 -1
  177. package/dist/providers/jina/adapter.js +6 -4
  178. package/dist/providers/jina/adapter.js.map +1 -1
  179. package/dist/providers/jina/client.d.ts +2 -0
  180. package/dist/providers/jina/client.d.ts.map +1 -1
  181. package/dist/providers/jina/client.js +6 -5
  182. package/dist/providers/jina/client.js.map +1 -1
  183. package/dist/providers/jina/credentials.d.ts +2 -0
  184. package/dist/providers/jina/credentials.d.ts.map +1 -1
  185. package/dist/providers/jina/credentials.js +2 -0
  186. package/dist/providers/jina/credentials.js.map +1 -1
  187. package/dist/providers/kagi/adapter.d.ts +44 -0
  188. package/dist/providers/kagi/adapter.d.ts.map +1 -0
  189. package/dist/providers/kagi/adapter.js +188 -0
  190. package/dist/providers/kagi/adapter.js.map +1 -0
  191. package/dist/providers/kagi/client.d.ts +41 -0
  192. package/dist/providers/kagi/client.d.ts.map +1 -0
  193. package/dist/providers/kagi/client.js +100 -0
  194. package/dist/providers/kagi/client.js.map +1 -0
  195. package/dist/providers/kagi/credentials.d.ts +15 -0
  196. package/dist/providers/kagi/credentials.d.ts.map +1 -0
  197. package/dist/providers/kagi/credentials.js +35 -0
  198. package/dist/providers/kagi/credentials.js.map +1 -0
  199. package/dist/providers/kagi/diagnostics.d.ts +17 -0
  200. package/dist/providers/kagi/diagnostics.d.ts.map +1 -0
  201. package/dist/providers/kagi/diagnostics.js +33 -0
  202. package/dist/providers/kagi/diagnostics.js.map +1 -0
  203. package/dist/providers/linkup/client.d.ts +1 -0
  204. package/dist/providers/linkup/client.d.ts.map +1 -1
  205. package/dist/providers/linkup/client.js +3 -2
  206. package/dist/providers/linkup/client.js.map +1 -1
  207. package/dist/providers/linkup/credentials.d.ts +2 -0
  208. package/dist/providers/linkup/credentials.d.ts.map +1 -1
  209. package/dist/providers/linkup/credentials.js +2 -0
  210. package/dist/providers/linkup/credentials.js.map +1 -1
  211. package/dist/providers/minimax/adapter.d.ts.map +1 -1
  212. package/dist/providers/minimax/adapter.js +2 -2
  213. package/dist/providers/minimax/adapter.js.map +1 -1
  214. package/dist/providers/minimax/coding-plan-client.d.ts +1 -0
  215. package/dist/providers/minimax/coding-plan-client.d.ts.map +1 -1
  216. package/dist/providers/minimax/coding-plan-client.js +3 -2
  217. package/dist/providers/minimax/coding-plan-client.js.map +1 -1
  218. package/dist/providers/minimax/quota-client.d.ts +1 -0
  219. package/dist/providers/minimax/quota-client.d.ts.map +1 -1
  220. package/dist/providers/minimax/quota-client.js +3 -2
  221. package/dist/providers/minimax/quota-client.js.map +1 -1
  222. package/dist/providers/openalex/client.d.ts +2 -0
  223. package/dist/providers/openalex/client.d.ts.map +1 -1
  224. package/dist/providers/openalex/client.js +10 -3
  225. package/dist/providers/openalex/client.js.map +1 -1
  226. package/dist/providers/openalex/diagnostics.d.ts +1 -1
  227. package/dist/providers/openalex/diagnostics.js +1 -1
  228. package/dist/providers/openalex/diagnostics.js.map +1 -1
  229. package/dist/providers/parallel/client.d.ts +1 -0
  230. package/dist/providers/parallel/client.d.ts.map +1 -1
  231. package/dist/providers/parallel/client.js +3 -2
  232. package/dist/providers/parallel/client.js.map +1 -1
  233. package/dist/providers/parallel/credentials.d.ts +2 -0
  234. package/dist/providers/parallel/credentials.d.ts.map +1 -1
  235. package/dist/providers/parallel/credentials.js +2 -0
  236. package/dist/providers/parallel/credentials.js.map +1 -1
  237. package/dist/providers/perplexity/client.d.ts +2 -0
  238. package/dist/providers/perplexity/client.d.ts.map +1 -1
  239. package/dist/providers/perplexity/client.js +5 -4
  240. package/dist/providers/perplexity/client.js.map +1 -1
  241. package/dist/providers/perplexity/credentials.d.ts +2 -0
  242. package/dist/providers/perplexity/credentials.d.ts.map +1 -1
  243. package/dist/providers/perplexity/credentials.js +2 -0
  244. package/dist/providers/perplexity/credentials.js.map +1 -1
  245. package/dist/providers/pubmed/client.d.ts +2 -0
  246. package/dist/providers/pubmed/client.d.ts.map +1 -1
  247. package/dist/providers/pubmed/client.js +10 -3
  248. package/dist/providers/pubmed/client.js.map +1 -1
  249. package/dist/providers/registry.d.ts.map +1 -1
  250. package/dist/providers/registry.js +10 -1
  251. package/dist/providers/registry.js.map +1 -1
  252. package/dist/providers/searchapi/adapter.d.ts +54 -0
  253. package/dist/providers/searchapi/adapter.d.ts.map +1 -0
  254. package/dist/providers/searchapi/adapter.js +310 -0
  255. package/dist/providers/searchapi/adapter.js.map +1 -0
  256. package/dist/providers/searchapi/client.d.ts +73 -0
  257. package/dist/providers/searchapi/client.d.ts.map +1 -0
  258. package/dist/providers/searchapi/client.js +196 -0
  259. package/dist/providers/searchapi/client.js.map +1 -0
  260. package/dist/providers/searchapi/credentials.d.ts +46 -0
  261. package/dist/providers/searchapi/credentials.d.ts.map +1 -0
  262. package/dist/providers/searchapi/credentials.js +71 -0
  263. package/dist/providers/searchapi/credentials.js.map +1 -0
  264. package/dist/providers/searchapi/diagnostics.d.ts +48 -0
  265. package/dist/providers/searchapi/diagnostics.d.ts.map +1 -0
  266. package/dist/providers/searchapi/diagnostics.js +70 -0
  267. package/dist/providers/searchapi/diagnostics.js.map +1 -0
  268. package/dist/providers/searchapi/quota.d.ts +60 -0
  269. package/dist/providers/searchapi/quota.d.ts.map +1 -0
  270. package/dist/providers/searchapi/quota.js +125 -0
  271. package/dist/providers/searchapi/quota.js.map +1 -0
  272. package/dist/providers/spider/client.d.ts +2 -0
  273. package/dist/providers/spider/client.d.ts.map +1 -1
  274. package/dist/providers/spider/client.js +16 -6
  275. package/dist/providers/spider/client.js.map +1 -1
  276. package/dist/providers/spider/credentials.d.ts +2 -0
  277. package/dist/providers/spider/credentials.d.ts.map +1 -1
  278. package/dist/providers/spider/credentials.js +2 -0
  279. package/dist/providers/spider/credentials.js.map +1 -1
  280. package/dist/providers/tavily/client.d.ts +1 -0
  281. package/dist/providers/tavily/client.d.ts.map +1 -1
  282. package/dist/providers/tavily/client.js +3 -2
  283. package/dist/providers/tavily/client.js.map +1 -1
  284. package/dist/providers/tavily/credentials.d.ts +2 -0
  285. package/dist/providers/tavily/credentials.d.ts.map +1 -1
  286. package/dist/providers/tavily/credentials.js +2 -0
  287. package/dist/providers/tavily/credentials.js.map +1 -1
  288. package/dist/providers/types.d.ts +31 -2
  289. package/dist/providers/types.d.ts.map +1 -1
  290. package/dist/providers/types.js +3 -0
  291. package/dist/providers/types.js.map +1 -1
  292. package/dist/providers/you/client.d.ts +4 -0
  293. package/dist/providers/you/client.d.ts.map +1 -1
  294. package/dist/providers/you/client.js +4 -5
  295. package/dist/providers/you/client.js.map +1 -1
  296. package/dist/providers/you/credentials.d.ts +2 -0
  297. package/dist/providers/you/credentials.d.ts.map +1 -1
  298. package/dist/providers/you/credentials.js +3 -3
  299. package/dist/providers/you/credentials.js.map +1 -1
  300. package/dist/providers/zai/adapter.d.ts.map +1 -1
  301. package/dist/providers/zai/adapter.js +219 -4
  302. package/dist/providers/zai/adapter.js.map +1 -1
  303. package/dist/providers/zai/credentials.d.ts +2 -0
  304. package/dist/providers/zai/credentials.d.ts.map +1 -1
  305. package/dist/providers/zai/credentials.js +2 -0
  306. package/dist/providers/zai/credentials.js.map +1 -1
  307. package/dist/providers/zai/layout-parsing.d.ts +76 -0
  308. package/dist/providers/zai/layout-parsing.d.ts.map +1 -0
  309. package/dist/providers/zai/layout-parsing.js +151 -0
  310. package/dist/providers/zai/layout-parsing.js.map +1 -0
  311. package/dist/providers/zai/media.d.ts +19 -0
  312. package/dist/providers/zai/media.d.ts.map +1 -1
  313. package/dist/providers/zai/media.js +61 -0
  314. package/dist/providers/zai/media.js.map +1 -1
  315. package/dist/providers/zai/monitor-client.d.ts +1 -0
  316. package/dist/providers/zai/monitor-client.d.ts.map +1 -1
  317. package/dist/providers/zai/monitor-client.js +3 -2
  318. package/dist/providers/zai/monitor-client.js.map +1 -1
  319. package/dist/providers/zai/quota.d.ts +4 -0
  320. package/dist/providers/zai/quota.d.ts.map +1 -1
  321. package/dist/providers/zai/quota.js +16 -1
  322. package/dist/providers/zai/quota.js.map +1 -1
  323. package/package.json +1 -1
  324. package/skills/scoutline/SKILL.md +163 -45
  325. package/skills/scoutline/references/advanced.md +12 -10
@@ -0,0 +1,838 @@
1
+ /**
2
+ * Investigation orchestrator (investigate-pipeline lane, Ticket T4;
3
+ * docs/plans/investigate-pipeline DESIGN.md D3, PRD AC-3/AC-4/AC-9/
4
+ * AC-10; ADR-0013).
5
+ *
6
+ * Thin, data-returning composition of the seams `search` and `read`
7
+ * already export — no bespoke merge fork, no local fan-out copy, no
8
+ * transport of its own:
9
+ *
10
+ * 1. planSubQueries (T2, injected loadContextText — no filesystem).
11
+ * 2. resolveFanoutPlan over the resolved provider pin (AC-1 tiers,
12
+ * the same inputs handleSearch passes).
13
+ * 3. Grid execution through the exported seams: fan-out mode runs
14
+ * executeFanoutPlan; single mode runs the exported search() with
15
+ * {merge: N > 1} over the escaped-pipe join of the sub-queries
16
+ * (the exact context-mode join precedent, index.ts handleSearch).
17
+ * 4. Top --sources distinct sources = the first K rows of the merged
18
+ * FormattedResult[] (mergeResults already collapsed near-dup
19
+ * clusters to representatives — a near-dup pair IS one row).
20
+ * 5. Reads: bounded-concurrency pool (default 4) over the reader
21
+ * capability seam via executeReaderOperation, one client per
22
+ * read (descriptor.create per read), closed in `finally`.
23
+ * 6. extractPassages (T3) per read result.
24
+ * 7. EvidencePack assembly per the T1 types.
25
+ *
26
+ * `--synthesize` (T7, PRD AC-7, DESIGN D6, ADR-0013 §2) is the explicit
27
+ * Z.AI-only escape hatch and is ADDITIVE-ONLY BY CONSTRUCTION: the pack
28
+ * is fully assembled (searches, reads, extraction, coverage) BEFORE the
29
+ * synthesis dep is ever called, so the brief can only ever be ADDED as
30
+ * the LAST key — a failed synthesis is the invocation's terminal error
31
+ * and never degrades, shrinks, or reorders the pack. The command holds
32
+ * no transport: construction is handler-seam wiring, exactly like every
33
+ * other capability, and the dep is injected.
34
+ *
35
+ * Reader supplier selection mirrors handleRead: the FIRST descriptor
36
+ * in registry order whose injected `readerCapabilityFor` resolves a
37
+ * ReaderCapability serves every read (the same first-configured-capable
38
+ * order read uses; cross-provider fallback per failed read is the
39
+ * index.ts handler seam's business, T6). A supplier that rejects a
40
+ * source terminally classifies as `reader-failed:<code>`; a supplier
41
+ * that cannot serve the capability at all classifies as
42
+ * `no-reader-supplier`. Unread rows carry reason codes only
43
+ * (redacted, house rule); the pool continues past them;
44
+ * `sourcesRead` counts successes only.
45
+ *
46
+ * Consumption linearity: every billable arm and read attempt records
47
+ * exactly one event (N×M + K) because the shared executors emit per
48
+ * invoke and the orchestrator never double-reads a URL.
49
+ *
50
+ * `--isolated` is ACCEPTED, never rejected: the pid-segment cache
51
+ * behavior is the main() handler seam's job (T6) — the command has no
52
+ * cache-directory logic of its own; the injected cache IS the
53
+ * (possibly isolated) production cache. Journal entry/marker WRITING
54
+ * lives at the index.ts descriptor seam (journalingDescriptors /
55
+ * captureServingDescriptors): this command consumes the injected
56
+ * descriptor list verbatim, so capture-wrapped descriptors keep
57
+ * stamping servedFrom/cacheKey cells the journal hook consumes — the
58
+ * T4-level guarantee (asserted by the seam-passthrough test); journal
59
+ * wiring itself is deferred to T6 (journal.ts / index.ts untouched).
60
+ */
61
+ import { createHash } from "node:crypto";
62
+ import { buildProviderCacheKey } from "../lib/cache.js";
63
+ import { executeReaderOperation } from "../lib/execution.js";
64
+ import { UnsupportedCapabilityError, ValidationError } from "../lib/errors.js";
65
+ import { redactSecrets } from "../lib/redact.js";
66
+ import { applyBudget } from "../lib/output-budget.js";
67
+ import { persistCompaction } from "../lib/output-budget-persistence.js";
68
+ import { executeFanoutPlan, resolveFanoutPlan, search, } from "./search.js";
69
+ import { deriveTemplateTopic, planSubQueries } from "../lib/investigate-planner.js";
70
+ import { extractPassages } from "../lib/investigate-extract.js";
71
+ import { splitClaims, matchClaimsToEvidence, MAX_VERIFY_CLAIMS } from "../lib/investigate-claims.js";
72
+ import { SHARED_PROVIDER_FLAG_IDS } from "../providers/catalog.js";
73
+ /**
74
+ * Passage-quote cap for the brief prompt. Bounded so the prompt cannot
75
+ * grow with the read pool: 20 quotes ≈ the first few sources' passages,
76
+ * well inside the Z.AI chat context even at the passage cap (5 per
77
+ * source). Deterministic (first N in pack order), never sampled.
78
+ */
79
+ export const SYNTHESIS_QUOTE_CAP = 20;
80
+ /**
81
+ * `investigate --help` (T6/T7). The command's rejected-flag contract is
82
+ * part of the surface: --depth/--arms/--budget-tokens DO NOT EXIST (PRD
83
+ * AC-1 — the rejection is the feature), and --context-stdin is
84
+ * deliberately not investigate's (search-only spelling; pipes and
85
+ * --context cover the sub-query sources).
86
+ */
87
+ export const INVESTIGATE_HELP = `
88
+ Investigate Command - Local investigation pipeline (EvidencePack)
89
+
90
+ Usage: scoutline investigate <question> [options]
91
+
92
+ Plans sub-queries, fans out search, merges by fusion, reads the top
93
+ sources, and extracts deterministic passages into an EvidencePack
94
+ (schemaVersion 1). The pack is data, not prose: agents consume it
95
+ directly; text output modes fall back to JSON. Warm re-runs replay the
96
+ response cache (coverage.cacheHits reflects it).
97
+
98
+ Provider selection (precedence: explicit flag, then SCOUTLINE_PROVIDER, then zai):
99
+ --provider <${SHARED_PROVIDER_FLAG_IDS}> Select the search provider. A
100
+ comma-list or \`all\` fans out over every listed arm (search's
101
+ activation tiers, verbatim); a single id runs one arm;
102
+ \`scoutline config set fanout true\` (no pin) is a standing fan-out.
103
+
104
+ Cost: the run bills N sub-queries × M arms searches + up to K sources
105
+ (per-source reader supplier attempts can bill more than one read) —
106
+ one stderr notice states the exact arithmetic before any billable work.
107
+
108
+ Sub-query planning (precedence: pipes > --context > template):
109
+ Pipes An unescaped \`|\` in the question splits it into explicit
110
+ sub-queries (the --merge grammar; escape with \\| for a
111
+ literal pipe). Capped at 8 — a longer split fails loud
112
+ with VALIDATION_ERROR (never silently truncated). Wins over
113
+ --context with a stderr notice.
114
+ --context <path> Read a local notes file and derive up to 8
115
+ sub-queries (headings/questions), exactly like search.
116
+ Template Deterministic transforms of the bare question (original,
117
+ key terms, overview/evidence/criticism), deduped, capped at 5.
118
+
119
+ Verify mode (--verify, ADR-0013 follow-up):
120
+ The statement splits into sentence-claims (the extract grammar's
121
+ terminators; '|' is literal text); each claim is searched VERBATIM
122
+ (no per-claim controls); deterministic term matching links claims to
123
+ the extracted passages. Verdicts per claim:
124
+ corroborated >= 1 matching passage, 0 negation cues
125
+ contradicted >= 1 matching passage carrying a negation cue
126
+ unresolved no matching passages
127
+ HINT-GRADE DISCLOSURE: 'contradicted' is a negation-cue heuristic
128
+ (fixed, versioned cue list) — NOT semantic contradiction. Semantic
129
+ judgment stays with the calling agent; the per-claim negationCues
130
+ count exists for exactly that re-judgment. Claim text and verdicts
131
+ are never cut by --max-chars (evidence pointers drop last, whole).
132
+
133
+ Options:
134
+ --provider <ids> Comma-list, \`all\`, or a single id (fan-out tiers
135
+ above).
136
+ --context <path> Local notes file deriving sub-queries (max 256 KiB;
137
+ never leaves the machine — only the derived
138
+ sub-query strings are searched).
139
+ --sources <n> How many distinct post-cluster sources to read
140
+ (positive integer; default 5).
141
+ --max-chars <n> Fit the pack in ~<n> chars (passages trim first —
142
+ quotes truncate, charRange adjusts — then late
143
+ sources drop; question/subQueries/coverage are
144
+ never cut; the full untrimmed pack is saved to the
145
+ artifacts store — recover with
146
+ "scoutline history show").
147
+ --verify Claim-corroboration mode: the positional becomes a
148
+ STATEMENT; sentence-claims (<= 8, fail-loud) are the
149
+ sub-query grid verbatim; the pack gains a verify
150
+ block (per claim: verdict + evidence pointers +
151
+ negation-cue count). Cannot combine with --context
152
+ (verify owns planning).
153
+ --synthesize Attach an ADDITIVE \`brief\` (Z.AI chat) to the pack.
154
+ Absent by default. Z.AI-only: it ignores --provider
155
+ (a stderr notice fires when another provider is
156
+ pinned) and always synthesizes through Z.AI. The
157
+ pack is assembled first, so the brief only ever
158
+ ADDS a key — a synthesis failure is this run's
159
+ terminal error, never a degraded pack.
160
+ --no-cache Skip the response cache for this run's searches
161
+ and reads.
162
+ --no-journal Skip the research journal entries for this run's
163
+ underlying search/read ops.
164
+ --save [<path>] Save the pack as a clean report (global flag;
165
+ master copy + optional export; refuses an
166
+ existing target without --save-force).
167
+ --isolated Process-isolated state (accepted; no stateful
168
+ directory exists — pure cache replay on re-run).
169
+
170
+ Not investigate's flags (rejected with VALIDATION_ERROR — by design):
171
+ --depth There is no depth axis; planning is deterministic
172
+ (pipes > context > template).
173
+ --arms The arm set IS the provider pin (--provider
174
+ comma-list / all); there is no separate control.
175
+ --budget-tokens --max-chars is the budget (chars, not tokens).
176
+ --context-stdin Search-only spelling; use --context <path> (or
177
+ pipes in the question) instead.
178
+
179
+ Standard global options apply (--output-format/-O, --save-format,
180
+ --save-force, --provider before the command, --no-fallback,
181
+ --isolated).
182
+
183
+ Output formats (--output-format / -O):
184
+ data Raw EvidencePack JSON (default)
185
+ json Envelope-wrapped {success, data, timestamp}
186
+ pretty Pretty-printed json
187
+ compact / markdown / refs / tty Fall back to JSON — the pack is
188
+ data, not prose.
189
+
190
+ Examples:
191
+ scoutline investigate "rust async runtime benchmarks"
192
+ scoutline investigate "rust async | rust tokio" # explicit pipes
193
+ scoutline --provider tavily,exa investigate "alpha | beta"
194
+ scoutline investigate "vector dbs" --context notes.md --sources 3
195
+ scoutline investigate "k8s cost" --max-chars 4000 # budgeted pack
196
+ scoutline investigate "wasm runtimes" --synthesize # + Z.AI brief
197
+ scoutline investigate "X is fast. Y lags." --verify # claim verdicts
198
+
199
+ Default JSON shape (EvidencePack, schemaVersion 1):
200
+ {
201
+ "schemaVersion": 1,
202
+ "question": "...",
203
+ "subQueries": ["..."],
204
+ "sources": [
205
+ {
206
+ "url": "https://...",
207
+ "finalUrl": "https://...",
208
+ "title": "Page title",
209
+ "fetchedAt": "2026-09-20T00:00:00.000Z",
210
+ "provider": "tavily",
211
+ "contentFormat": "markdown",
212
+ "contentSha256": "<sha256 of the utf-8 content>",
213
+ "passages": [{ "quote": "...", "charRange": [0, 42] }]
214
+ }
215
+ ],
216
+ "coverage": {
217
+ "subQueries": 2,
218
+ "armsUsed": 2,
219
+ "sourcesConsidered": 5,
220
+ "sourcesRead": 5,
221
+ "cacheHits": 0,
222
+ "unread": [{ "url": "https://...", "reason": "reader-failed:API_ERROR" }]
223
+ }
224
+ }
225
+ `.trim();
226
+ // ---------------------------------------------------------------------------
227
+ // Output Budget ladder (T5, DESIGN D7, PRD AC-8)
228
+ // ---------------------------------------------------------------------------
229
+ /**
230
+ * One passage-trim step: truncate every quote FROM THE END to half
231
+ * its length and ADJUST charRange to the truncated slice so the
232
+ * round-trip pin `content.slice(...charRange) === quote` survives
233
+ * every pass. NO omission marker is appended: the pin demands quote
234
+ * be an exact slice of the paired content, so any added `…` would
235
+ * break it (the read ladder's marker idiom does not apply here).
236
+ * A quote too short to halve stays unchanged (rule exhausts; the
237
+ * source-drop rule takes over) — and empty quotes never exist, so a
238
+ * budgeted pack still decodes. url/title/fetchedAt/provider/hashes
239
+ * are never touched. `ponytail:` marker-less trim is the pin-driven
240
+ * minimum; a marker would need pinless budgeted quotes (schema
241
+ * change) — revisit only if budgeted-quote readability ever matters.
242
+ */
243
+ const trimPassagesRule = {
244
+ name: "trim-passages",
245
+ apply: (envelope) => {
246
+ const pack = envelope;
247
+ return {
248
+ ...pack,
249
+ sources: pack.sources.map((source) => ({
250
+ ...source,
251
+ passages: source.passages.map((passage) => {
252
+ const half = Math.floor(passage.quote.length / 2);
253
+ if (half <= 0)
254
+ return passage;
255
+ return {
256
+ quote: passage.quote.slice(0, half),
257
+ // charRange adjusts to the truncated slice — the pin holds.
258
+ charRange: [passage.charRange[0], passage.charRange[0] + half],
259
+ };
260
+ }),
261
+ })),
262
+ };
263
+ },
264
+ };
265
+ /**
266
+ * One late-source drop step: the LAST source drops whole (search's
267
+ * drop-lowest-rank analog). Fix-round M1: when a verify block rides
268
+ * the pack, evidence pointers at the dropped index are scrubbed HERE
269
+ * (per claim, whole) — otherwise the pack ships dangling pointers
270
+ * (decode's index guard checks non-negative only) that only the later
271
+ * pointer-drop rule would remove, leaving a window of budgets with
272
+ * unrecoverable references. Question-mode packs (no verify block)
273
+ * take the byte-identical pre-fix path — the scrub is verify-only.
274
+ */
275
+ const dropLastSourceRule = {
276
+ name: "drop-late-source",
277
+ apply: (envelope) => {
278
+ const pack = envelope;
279
+ if (pack.sources.length <= 0)
280
+ return pack;
281
+ const droppedIndex = pack.sources.length - 1;
282
+ const sources = pack.sources.slice(0, -1);
283
+ if (pack.verify === undefined) {
284
+ return { ...pack, sources };
285
+ }
286
+ return {
287
+ ...pack,
288
+ sources,
289
+ verify: {
290
+ ...pack.verify,
291
+ claims: pack.verify.claims.map((claim) => claim.evidence.some((p) => p.sourceIndex === droppedIndex)
292
+ ? {
293
+ ...claim,
294
+ evidence: claim.evidence.filter((p) => p.sourceIndex !== droppedIndex),
295
+ }
296
+ : claim),
297
+ },
298
+ };
299
+ },
300
+ };
301
+ /**
302
+ * investigate-verify lane (DESIGN D6, PRD AC-7): the LATE pointer
303
+ * drop — evidence pointers drop per claim, WHOLE (a claim keeps its
304
+ * full pointer set or none; never a truncated list). Claim text,
305
+ * verdicts, and cue counts are never cut (expressed by omission —
306
+ * this rule touches only `evidence`). Runs after the source drop so
307
+ * pointer elimination is the last loss before the floor.
308
+ *
309
+ * ponytail: near the floor this rule is COARSE — it clears every
310
+ * claim's pointers in one step rather than shedding one claim's at a
311
+ * time, so budgets just above the floor can overshoot down to a
312
+ * pointer-free pack. Fine-grained per-claim shedding (or per-pointer,
313
+ * cheapest-first) is the upgrade path if a consumer ever needs to
314
+ * keep SOME pointers at extreme budgets; nothing today reads pointers
315
+ * partially.
316
+ */
317
+ const dropEvidencePointersRule = {
318
+ name: "drop-evidence-pointers",
319
+ apply: (envelope) => {
320
+ const pack = envelope;
321
+ if (pack.verify === undefined)
322
+ return pack;
323
+ if (pack.verify.claims.every((claim) => claim.evidence.length === 0))
324
+ return pack;
325
+ return {
326
+ ...pack,
327
+ verify: {
328
+ ...pack.verify,
329
+ claims: pack.verify.claims.map((claim) => ({ ...claim, evidence: [] })),
330
+ },
331
+ };
332
+ },
333
+ };
334
+ /**
335
+ * INVESTIGATE_LADDER (D7 budget order; verify lane D6 extension):
336
+ * passages trim FIRST (quote truncate, charRange adjusts — the
337
+ * round-trip pin survives), then LATE sources drop whole, then —
338
+ * verify packs only — evidence pointers drop per claim (whole).
339
+ * question/subQueries/coverage and verify claim text/verdicts/cue
340
+ * counts are never cut — expressed by omission (no rule touches
341
+ * them).
342
+ */
343
+ export const INVESTIGATE_LADDER = [
344
+ trimPassagesRule,
345
+ dropLastSourceRule,
346
+ dropEvidencePointersRule,
347
+ ];
348
+ const DEFAULT_SOURCES = 5;
349
+ const DEFAULT_READ_CONCURRENCY = 4;
350
+ // ---------------------------------------------------------------------------
351
+ // Option validation (trust boundary)
352
+ // ---------------------------------------------------------------------------
353
+ function validateOptions(options) {
354
+ // Review fix #4: every provided field validates UNCONDITIONALLY —
355
+ // no early return may shield a later field's check.
356
+ if (typeof options.sources === "number") {
357
+ if (!Number.isInteger(options.sources) || options.sources <= 0) {
358
+ throw new ValidationError(`--sources must be a positive integer (got ${options.sources}).`);
359
+ }
360
+ }
361
+ // T5 (D7): strict positive-integer --max-chars (parseBriefMaxChars
362
+ // class) — validated at the boundary so a bad value is
363
+ // VALIDATION_ERROR regardless of provider state.
364
+ if (options.maxChars !== undefined) {
365
+ const value = options.maxChars;
366
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
367
+ throw new ValidationError("--max-chars must be a positive integer");
368
+ }
369
+ }
370
+ return typeof options.sources === "number" ? options.sources : DEFAULT_SOURCES;
371
+ }
372
+ /**
373
+ * Output Budget seam (T5, D7): walk INVESTIGATE_LADDER over the pack;
374
+ * on a fired budget persist the FULL untrimmed pack through
375
+ * persistCompaction (mirrored save shape — post-redaction,
376
+ * pre-compaction `result`, MANDATORY log entry with
377
+ * presentation-flag-free args) and stamp `compaction {budget, ref}`
378
+ * into the returned payload. No flag → identity (the zero-diff
379
+ * invariant). Returns a NEW CommandResult; never mutates the input.
380
+ */
381
+ async function applyInvestigateOutputBudget(result, maxChars, options) {
382
+ if (maxChars === undefined || result.kind !== "data")
383
+ return result;
384
+ const outcome = applyBudget(result.data, maxChars, INVESTIGATE_LADDER);
385
+ if (outcome.compaction === undefined)
386
+ return result;
387
+ const redactedEnvelope = redactSecrets(result.data, options.deps.secrets);
388
+ const compaction = await persistCompaction(redactedEnvelope, outcome.compaction, {
389
+ command: "investigate",
390
+ args: options.args,
391
+ provider: options.providerRouting,
392
+ outputFormat: "data",
393
+ }, {
394
+ env: options.deps.env,
395
+ now: options.deps.now ?? Date.now,
396
+ onNotice: options.context.notice,
397
+ });
398
+ options.context.notice(`output budget: ${maxChars} chars — full untrimmed envelope saved (${compaction.ref})`);
399
+ return {
400
+ kind: "data",
401
+ data: {
402
+ ...outcome.projection,
403
+ compaction,
404
+ },
405
+ };
406
+ }
407
+ /**
408
+ * Serve one read through the shared cache, trying suppliers in
409
+ * registry order (review fix #2 — AC-4 "provider fallback"). One
410
+ * client per ATTEMPT: `deps.readerCapabilityFor` resolves the
411
+ * supplier's capability per call (`descriptor.create` per attempt —
412
+ * `create` is side-effect-free metadata capture; the operation's
413
+ * invoke owns and closes its transport inside executeReaderOperation,
414
+ * so no transport outlives the call).
415
+ *
416
+ * Advance-to-next-supplier on ANY thrown error — an
417
+ * UnsupportedCapabilityError (supplier cannot serve the capability)
418
+ * and terminal failures alike (executeReaderOperation has already
419
+ * exhausted its internal retry for transient classes by the time the
420
+ * error escapes, so every escape is supplier-exhausted). ALL suppliers
421
+ * exhausted → the LAST supplier's reason code surfaces (most
422
+ * informative: the deepest attempt). Every attempt bills exactly one
423
+ * consumption event (executor behavior — fallback attempts are
424
+ * billable reads, consistent with the usage-ledger "retries count as
425
+ * attempts" doctrine; K in the N×M+K notice counts attempts).
426
+ *
427
+ * cacheHits instrumentation: a warm serve is a read-only cache `get()`
428
+ * on the FIRST supplier's partition key that decodes non-null through
429
+ * the operation's own decoder. ponytail: conservative undercount when
430
+ * a fallback supplier serves warm (first-supplier probe only); widen
431
+ * to per-attempt probes if a fallback-heavy workload needs exact hits.
432
+ * Boundary: legacy read-through candidates are miss-then-set serves
433
+ * and count as misses (honest warm-serve count only).
434
+ */
435
+ async function serveRead(url, suppliers, deps, noCache, cacheHits) {
436
+ if (suppliers.length === 0) {
437
+ return { url, reason: "no-reader-supplier", warm: false };
438
+ }
439
+ let lastReason = "no-reader-supplier";
440
+ for (const descriptor of suppliers) {
441
+ const capability = deps.readerCapabilityFor(descriptor);
442
+ if (capability === undefined) {
443
+ // Not a reader supplier at all — selection pre-filters these;
444
+ // kept as a guard for hand-built descriptor lists.
445
+ lastReason = "no-reader-supplier";
446
+ continue;
447
+ }
448
+ const wasWarm = await readerCacheWasWarm(capability, url, deps, noCache);
449
+ try {
450
+ const result = await executeReaderOperation(capability.fetch, { url }, { noCache, ...(deps.retryPolicy !== undefined ? { retryPolicy: deps.retryPolicy } : {}) }, {
451
+ cache: deps.cache,
452
+ sleep: deps.sleep,
453
+ random: deps.random,
454
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
455
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
456
+ });
457
+ if (wasWarm)
458
+ cacheHits.count += 1;
459
+ return { url, result, warm: wasWarm };
460
+ }
461
+ catch (error) {
462
+ // Reason CODE only, redacted — no error prose crossing the
463
+ // interface (house rule, D5). Advances to the next supplier;
464
+ // this code surfaces only if every later supplier also fails.
465
+ const code = typeof error === "object" && error !== null && "code" in error
466
+ ? String(error.code)
467
+ : "UNKNOWN_ERROR";
468
+ lastReason =
469
+ error instanceof UnsupportedCapabilityError
470
+ ? "no-reader-supplier"
471
+ : `reader-failed:${code}`;
472
+ }
473
+ }
474
+ return { url, reason: lastReason, warm: false };
475
+ }
476
+ /**
477
+ * Read-only warm probe on the reader partition key the executor will
478
+ * use — the operation's own cacheIdentity + decoder, never a
479
+ * fabricated key. Never seeds an entry; shared execution remains the
480
+ * sole read/write authority.
481
+ */
482
+ async function readerCacheWasWarm(capability, url, deps, noCache) {
483
+ if (noCache)
484
+ return false;
485
+ try {
486
+ const identity = capability.fetch.cacheIdentity({ url });
487
+ const key = buildProviderCacheKey({
488
+ provider: identity.provider,
489
+ capability: `${identity.capability}-${identity.operation}`,
490
+ credentialFingerprint: identity.credentialFingerprint,
491
+ request: identity.request,
492
+ });
493
+ const raw = await deps.cache.get(key);
494
+ if (raw === null)
495
+ return false;
496
+ return capability.fetch.decodeCached(raw) !== null;
497
+ }
498
+ catch {
499
+ return false;
500
+ }
501
+ }
502
+ /**
503
+ * Bounded-concurrency map over the selected sources (D3 #5). A read
504
+ * slot is reused as soon as its previous read settles (start-aligned
505
+ * workers over a shared index — no per-chunk scheduling, no timers).
506
+ * `suppliers` is the run's ordered reader supplier list (review fix
507
+ * #2); serveRead falls through it per source.
508
+ */
509
+ async function readPool(rows, suppliers, deps, noCache, cacheHits) {
510
+ const limit = Math.max(1, deps.readConcurrency ?? DEFAULT_READ_CONCURRENCY);
511
+ const outcomes = [];
512
+ let next = 0;
513
+ const worker = async () => {
514
+ for (;;) {
515
+ const index = next;
516
+ next += 1;
517
+ const row = rows[index];
518
+ if (row === undefined)
519
+ return;
520
+ outcomes.push(await serveRead(row.url, suppliers, deps, noCache, cacheHits));
521
+ }
522
+ };
523
+ await Promise.all(Array.from({ length: Math.min(limit, rows.length) }, worker));
524
+ return outcomes;
525
+ }
526
+ /**
527
+ * The run's reader supplier LIST (review fix #2): every descriptor in
528
+ * registry order whose injected `readerCapabilityFor` resolves a
529
+ * capability — the same first-configured-capable order handleRead's
530
+ * provider selection walks. serveRead falls through the list per
531
+ * source; an empty list keeps the legacy all-unread
532
+ * `no-reader-supplier` behavior.
533
+ */
534
+ function selectReaderSuppliers(deps) {
535
+ return deps.descriptors.filter((descriptor) => deps.readerCapabilityFor(descriptor) !== undefined);
536
+ }
537
+ // ---------------------------------------------------------------------------
538
+ // Escaped-pipe join (the handleSearch context-mode precedent verbatim:
539
+ // trim trailing backslashes, then escape pipes, join on "|")
540
+ // ---------------------------------------------------------------------------
541
+ function joinSubQueries(subQueries) {
542
+ return subQueries.map((s) => s.replace(/\\+$/, "").replace(/\|/g, "\\|")).join("|");
543
+ }
544
+ // ---------------------------------------------------------------------------
545
+ // Handler
546
+ // ---------------------------------------------------------------------------
547
+ export async function investigate(question, options = {}, deps, context) {
548
+ if (typeof question !== "string" || question.trim().length === 0) {
549
+ throw new ValidationError("investigate requires a question.");
550
+ }
551
+ const sourcesCap = validateOptions(options);
552
+ const noCache = options.noCache === true;
553
+ // 1. Plan (T2) — injected loadContextText; no filesystem here.
554
+ // investigate-verify lane (D3): in verify mode the planner is NOT
555
+ // invoked — the claims ARE the grid (one verbatim sub-query per
556
+ // sentence-claim; no contextFile can be present, the pair is
557
+ // rejected at the index.ts parse seam). Question mode is the
558
+ // untouched else-branch (byte-identity pin).
559
+ let subQueries;
560
+ let verifyClaims;
561
+ if (options.verify === true) {
562
+ verifyClaims = splitClaims(question);
563
+ if (verifyClaims.length === 0) {
564
+ throw new ValidationError("investigate --verify requires at least one valid claim sentence.", "Split the statement into sentences terminated by '.', '!' or '?'.");
565
+ }
566
+ if (verifyClaims.length > MAX_VERIFY_CLAIMS) {
567
+ throw new ValidationError(`investigate --verify exceeds the ${MAX_VERIFY_CLAIMS}-claim cap (${verifyClaims.length} claims).`, "Split fewer claims, or investigate the statement in parts.");
568
+ }
569
+ subQueries = verifyClaims;
570
+ }
571
+ else {
572
+ const plan = await planSubQueries({
573
+ query: question,
574
+ ...(options.contextFile !== undefined ? { contextFile: options.contextFile } : {}),
575
+ }, { loadContextText: deps.loadContextText });
576
+ subQueries = [...plan.subQueries];
577
+ // The planner's explicit-tier notice ("--context ignored") only
578
+ // means something when a context file was actually in play; without
579
+ // --context it would be a misleading stderr line on every pipe
580
+ // question.
581
+ if (plan.notice !== undefined && options.contextFile !== undefined) {
582
+ context?.notice(plan.notice);
583
+ }
584
+ }
585
+ const N = subQueries.length;
586
+ // 2. resolveFanoutPlan over the resolved provider pin (AC-1 tiers,
587
+ // the same inputs handleSearch passes).
588
+ const fanoutPlan = resolveFanoutPlan({
589
+ explicitProviderRaw: options.provider,
590
+ env: deps.env,
591
+ configFanout: deps.configFanout,
592
+ ...(deps.routing !== undefined ? { routing: deps.routing } : {}),
593
+ descriptors: deps.descriptors,
594
+ });
595
+ const M = fanoutPlan.arms.length;
596
+ // Cost notice (AC-3; PR #264 F3 wording): the arithmetic stated
597
+ // literally, N/M/K spelled out, BEFORE any billable work runs. K
598
+ // counts SOURCES — the per-read supplier fallthrough can bill more
599
+ // than one reader attempt per source, so the notice names sources
600
+ // and discloses the attempt semantics instead of understating.
601
+ // Verify mode (PRD-3) names CLAIMS — the grid unit is the claim.
602
+ context?.notice(options.verify === true
603
+ ? `investigate: ${N} claims × ${M} arms = ${N * M} billable searches + up to ${sourcesCap} sources (per-source supplier attempts apply)`
604
+ : `investigate: ${N} sub-queries × ${M} arms = ${N * M} billable searches + up to ${sourcesCap} sources (per-source supplier attempts apply)`);
605
+ if (fanoutPlan.suppress)
606
+ context?.notice(fanoutPlan.suppress);
607
+ const searchDepsBase = {
608
+ cache: deps.cache,
609
+ sleep: deps.sleep,
610
+ random: deps.random,
611
+ ...(deps.retryPolicy !== undefined ? { retryPolicy: deps.retryPolicy } : {}),
612
+ ...(deps.consume !== undefined ? { consume: deps.consume } : {}),
613
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
614
+ };
615
+ // Search-stage warm-serve count. MUST run BEFORE the grid executes:
616
+ // the grid seeds exactly these keys on a cold run, so a post-grid
617
+ // probe would count its own writes (the recorded trap this ordering
618
+ // exists to avoid). Read and search partitions are disjoint
619
+ // (`reader-reader-fetch` vs `search`), so later reads cannot pollute
620
+ // these probes either.
621
+ const searchHits = await countSearchCacheHits(fanoutPlan, subQueries, deps, noCache);
622
+ // 3. Grid execution through the exported seams only. Both paths emit
623
+ // FormattedResult[] (rank-merged rows); the fan-out path carries
624
+ // mergedFrom provenance, the single path carries rows verbatim.
625
+ let merged;
626
+ let armsUsed;
627
+ let singleArmProviderId;
628
+ if (fanoutPlan.mode === "fanout") {
629
+ const fanoutResult = await executeFanoutPlan(fanoutPlan, {
630
+ descriptors: deps.descriptors,
631
+ env: deps.env,
632
+ query: joinSubQueries(subQueries),
633
+ searchOptions: {
634
+ // The (arm × sub-query) merge grid: merge=true makes every
635
+ // arm run every sub-query — the search seam's own grammar.
636
+ merge: N > 1,
637
+ ...(noCache ? { noCache: true } : {}),
638
+ },
639
+ fusionMode: deps.fusionMode ?? "rrf",
640
+ dependencies: searchDepsBase,
641
+ }, context);
642
+ if (fanoutResult.kind !== "data" || !Array.isArray(fanoutResult.data)) {
643
+ throw new Error("investigate: fan-out returned a non-grid result");
644
+ }
645
+ merged = fanoutResult.data;
646
+ armsUsed = M;
647
+ }
648
+ else {
649
+ singleArmProviderId = fanoutPlan.arms[0];
650
+ // Single mode: the exported search() with the escaped-pipe join —
651
+ // the exact context-mode join precedent (index.ts handleSearch).
652
+ // One arm executes every sub-query (search --merge semantics);
653
+ // count stays undefined (search default 10 per AC-3, applied after
654
+ // normalization by shared execution).
655
+ const singleResult = await search(joinSubQueries(subQueries), { merge: N > 1, ...(noCache ? { noCache: true } : {}) }, {
656
+ capability: singleArmCapability(fanoutPlan, deps),
657
+ ...searchDepsBase,
658
+ fusionMode: deps.fusionMode ?? "rrf",
659
+ }, context);
660
+ if (singleResult.kind !== "data" || !Array.isArray(singleResult.data)) {
661
+ throw new Error("investigate: single-arm search returned a non-grid result");
662
+ }
663
+ merged = singleResult.data;
664
+ armsUsed = 1;
665
+ }
666
+ // 4. Top --sources distinct sources (post-cluster representatives —
667
+ // mergeResults already collapsed near-dups; a pair IS one row).
668
+ const sourcesConsidered = merged.length;
669
+ const selected = merged.slice(0, sourcesCap);
670
+ // 5. Reads — bounded-concurrency pool over the reader capability
671
+ // seam; per-source terminal failures continue the pool; suppliers
672
+ // fall through in registry order (review fix #2).
673
+ const suppliers = selectReaderSuppliers(deps);
674
+ const readerHits = { count: 0 };
675
+ const outcomes = await readPool(selected, suppliers, deps, noCache, readerHits);
676
+ const byUrl = new Map(outcomes.map((o) => [o.url, o]));
677
+ // 6 + 7. Extraction (T3) + pack assembly (T1 types). Terms = union of
678
+ // the question + sub-queries key-term derivations (deriveTemplateTopic
679
+ // per member — the T2-exposed term shape), case-folded and
680
+ // stopword-filtered by normalizeTerms inside extractPassages.
681
+ const terms = [
682
+ ...new Set([question, ...subQueries].flatMap((q) => deriveTemplateTopic(q).split(" "))),
683
+ ].filter((t) => t.length > 0);
684
+ const nowWall = deps.nowWall ?? (() => new Date());
685
+ const sources = [];
686
+ const unread = [];
687
+ for (const row of selected) {
688
+ const outcome = byUrl.get(row.url);
689
+ if (outcome === undefined || outcome.result === undefined) {
690
+ unread.push({ url: row.url, reason: outcome?.reason ?? "no-reader-supplier" });
691
+ continue;
692
+ }
693
+ const result = outcome.result;
694
+ // Provider provenance: the merged row's surfaced provider — first
695
+ // mergedFrom (fan-out) or the resolved arm (single mode). NOT the
696
+ // reader supplier: the row says who FOUND it, not who read it.
697
+ const surfacedProvider = row.mergedFrom?.[0] ?? singleArmProviderId ?? suppliers[0]?.id ?? "";
698
+ sources.push({
699
+ url: row.url,
700
+ finalUrl: result.finalUrl,
701
+ title: result.title,
702
+ fetchedAt: nowWall().toISOString(),
703
+ provider: surfacedProvider,
704
+ contentFormat: result.contentFormat,
705
+ contentSha256: createHash("sha256").update(result.content, "utf8").digest("hex"),
706
+ passages: extractPassages({ content: result.content, terms }),
707
+ });
708
+ }
709
+ const pack = {
710
+ schemaVersion: 1,
711
+ question,
712
+ subQueries,
713
+ sources,
714
+ coverage: {
715
+ subQueries: N,
716
+ armsUsed,
717
+ sourcesConsidered,
718
+ sourcesRead: sources.length,
719
+ cacheHits: searchHits + readerHits.count,
720
+ unread,
721
+ },
722
+ // investigate-verify lane (D3 step 3): the ONLY assembly
723
+ // difference — matchClaimsToEvidence over the read sources.
724
+ // Absent on question-mode packs by construction (the fork above).
725
+ ...(verifyClaims !== undefined
726
+ ? { verify: matchClaimsToEvidence({ statement: question, claims: verifyClaims, sources }) }
727
+ : {}),
728
+ };
729
+ // 8. `--synthesize` (T7): the pack is COMPLETE above — every search,
730
+ // every read, every passage — so the brief is attached here by
731
+ // construction and can only ever ADD a trailing key. A throwing
732
+ // dep propagates as this invocation's terminal error (house error
733
+ // contract) and the assembled pack is NOT emitted: a flag-bearing
734
+ // failure is loud, never a silent degradation to agent-synthesis.
735
+ let payload = pack;
736
+ if (options.synthesize === true) {
737
+ if (deps.synthesize === undefined) {
738
+ // Wiring bug, not a user error: the flag is documented and
739
+ // parsed, so a missing dep means the handler seam forgot to
740
+ // inject the transport. Fail loud — never silently skip.
741
+ throw new Error("investigate: --synthesize was requested but no synthesis dep is wired");
742
+ }
743
+ const quotes = [];
744
+ for (const source of sources) {
745
+ for (const passage of source.passages) {
746
+ if (quotes.length >= SYNTHESIS_QUOTE_CAP)
747
+ break;
748
+ quotes.push(passage.quote);
749
+ }
750
+ if (quotes.length >= SYNTHESIS_QUOTE_CAP)
751
+ break;
752
+ }
753
+ const brief = await deps.synthesize({ question, subQueries, quotes });
754
+ payload = { ...pack, brief };
755
+ }
756
+ // 9. Output Budget (T5, D7): the pack is fully assembled first — the
757
+ // compaction artifact is the FULL untrimmed pack, so the budget
758
+ // rides AFTER assembly by construction.
759
+ const investigateArgs = {
760
+ ...(options.provider !== undefined ? { provider: options.provider } : {}),
761
+ ...(options.sources !== undefined ? { sources: options.sources } : {}),
762
+ ...(noCache ? { "no-cache": true } : {}),
763
+ ...(options.isolated ? { isolated: true } : {}),
764
+ };
765
+ return applyInvestigateOutputBudget({ kind: "data", data: payload }, options.maxChars, {
766
+ context: context ?? { stdinIsTTY: false, readStdin: async () => "", notice: () => { } },
767
+ deps,
768
+ args: investigateArgs,
769
+ providerRouting: {
770
+ mode: fanoutPlan.mode,
771
+ ...(fanoutPlan.mode === "fanout"
772
+ ? { arms: fanoutPlan.arms }
773
+ : { effective: singleArmProviderId ?? "" }),
774
+ ...(options.provider !== undefined ? { requested: options.provider } : {}),
775
+ },
776
+ });
777
+ }
778
+ /**
779
+ * Resolve the single-arm search capability. The resolver's single mode
780
+ * always names the arm (`arms[0]`); a descriptor MUST exist for it —
781
+ * an unknown id means the pin never matched the registry, which is
782
+ * the capability seam's typed error to raise.
783
+ */
784
+ function singleArmCapability(fanoutPlan, deps) {
785
+ const armId = fanoutPlan.arms[0];
786
+ const descriptor = deps.descriptors.find((d) => d.id === armId);
787
+ if (descriptor === undefined) {
788
+ throw new UnsupportedCapabilityError(String(armId ?? "(none)"), "search");
789
+ }
790
+ const adapter = descriptor.create({ env: deps.env });
791
+ const capability = adapter.search;
792
+ if (capability === undefined) {
793
+ throw new UnsupportedCapabilityError(descriptor.id, "search");
794
+ }
795
+ return capability;
796
+ }
797
+ /**
798
+ * Search-stage warm-serve count. Mirrors the read-stage boundary: a
799
+ * warm serve is a cache get() on the arm's exact partition key —
800
+ * resolved through the injected descriptors' own cacheIdentity, never
801
+ * a fabricated key — consulted once per (arm × sub-query) BEFORE the
802
+ * grid runs (see the ordering note at the call site). executeSearch
803
+ * treats any non-null raw value on this key as a hit (no decoder), so
804
+ * the probe matches: non-null = warm.
805
+ */
806
+ async function countSearchCacheHits(fanoutPlan, subQueries, deps, noCache) {
807
+ if (noCache)
808
+ return 0;
809
+ const arms = fanoutPlan.mode === "fanout" ? fanoutPlan.arms : fanoutPlan.arms.slice(0, 1);
810
+ let hits = 0;
811
+ for (const armId of arms) {
812
+ const descriptor = deps.descriptors.find((d) => d.id === armId);
813
+ if (descriptor === undefined)
814
+ continue;
815
+ const capability = descriptor.create({ env: deps.env }).search;
816
+ if (capability === undefined)
817
+ continue;
818
+ for (const query of subQueries) {
819
+ try {
820
+ const identity = capability.cacheIdentity({ query });
821
+ const key = buildProviderCacheKey({
822
+ provider: identity.provider,
823
+ capability: identity.capability,
824
+ credentialFingerprint: identity.credentialFingerprint,
825
+ request: identity.request,
826
+ });
827
+ if ((await deps.cache.get(key)) !== null)
828
+ hits += 1;
829
+ }
830
+ catch {
831
+ // A capability whose cacheIdentity cannot be probed counts
832
+ // nothing — never guess a hit.
833
+ }
834
+ }
835
+ }
836
+ return hits;
837
+ }
838
+ //# sourceMappingURL=investigate.js.map