@quickchatai/quickreview 1.2.0 → 1.3.2

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 (350) hide show
  1. package/README.md +116 -53
  2. package/bin/qcr.js +1 -1
  3. package/config.example.toml +25 -7
  4. package/deploy/ansible/README.md +20 -12
  5. package/deploy/ansible/inventory.example.ini +1 -1
  6. package/deploy/collect-secrets.sh +1 -1
  7. package/deploy/qcr-update.sh +1 -1
  8. package/dist/extensions/cli/home.js +14 -0
  9. package/dist/extensions/cli/home.js.map +1 -0
  10. package/dist/extensions/cli/selfimprove.js +18 -0
  11. package/dist/extensions/cli/selfimprove.js.map +1 -0
  12. package/dist/extensions/home.js +350 -0
  13. package/dist/extensions/home.js.map +1 -0
  14. package/dist/extensions/login-route.js +1 -1
  15. package/dist/extensions/login-route.js.map +1 -1
  16. package/dist/extensions/onboarding.js +8 -7
  17. package/dist/extensions/onboarding.js.map +1 -1
  18. package/dist/extensions/selfimprove.js +49 -0
  19. package/dist/extensions/selfimprove.js.map +1 -0
  20. package/dist/extensions/setup-scan.js +23 -592
  21. package/dist/extensions/setup-scan.js.map +1 -1
  22. package/dist/extensions/setup.js +2 -47
  23. package/dist/extensions/setup.js.map +1 -1
  24. package/dist/extensions/vendor/openviking-pi/index.js +4 -0
  25. package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
  26. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
  27. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
  28. package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
  29. package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
  30. package/dist/src/archive/restore.js +18 -15
  31. package/dist/src/archive/restore.js.map +1 -1
  32. package/dist/src/botworker.js +13 -2
  33. package/dist/src/botworker.js.map +1 -1
  34. package/dist/src/channelfs.js +3 -26
  35. package/dist/src/channelfs.js.map +1 -1
  36. package/dist/src/cli.js +14 -50
  37. package/dist/src/cli.js.map +1 -1
  38. package/dist/src/config.js +16 -0
  39. package/dist/src/config.js.map +1 -1
  40. package/dist/src/daemon.js +33 -4
  41. package/dist/src/daemon.js.map +1 -1
  42. package/dist/src/debuground.js +1 -0
  43. package/dist/src/debuground.js.map +1 -1
  44. package/dist/src/debugroundpi.js +1 -0
  45. package/dist/src/debugroundpi.js.map +1 -1
  46. package/dist/src/engine/caserunner-sdk.js +15 -251
  47. package/dist/src/engine/caserunner-sdk.js.map +1 -1
  48. package/dist/src/home/adapters.js +349 -0
  49. package/dist/src/home/adapters.js.map +1 -0
  50. package/dist/src/home/advanced.js +318 -0
  51. package/dist/src/home/advanced.js.map +1 -0
  52. package/dist/src/home/advancedtools.js +57 -0
  53. package/dist/src/home/advancedtools.js.map +1 -0
  54. package/dist/src/home/autoreview.js +325 -0
  55. package/dist/src/home/autoreview.js.map +1 -0
  56. package/dist/src/home/daemonstatus.js +32 -0
  57. package/dist/src/home/daemonstatus.js.map +1 -0
  58. package/dist/src/home/devicelogin.js +61 -0
  59. package/dist/src/home/devicelogin.js.map +1 -0
  60. package/dist/src/home/launch.js +202 -0
  61. package/dist/src/home/launch.js.map +1 -0
  62. package/dist/src/home/loginowner.js +205 -0
  63. package/dist/src/home/loginowner.js.map +1 -0
  64. package/dist/src/home/memory.js +122 -0
  65. package/dist/src/home/memory.js.map +1 -0
  66. package/dist/src/home/model.js +811 -0
  67. package/dist/src/home/model.js.map +1 -0
  68. package/dist/src/home/panel.js +520 -0
  69. package/dist/src/home/panel.js.map +1 -0
  70. package/dist/src/home/ports.js +19 -0
  71. package/dist/src/home/ports.js.map +1 -0
  72. package/dist/src/home/review.js +163 -0
  73. package/dist/src/home/review.js.map +1 -0
  74. package/dist/src/home/reviews.js +478 -0
  75. package/dist/src/home/reviews.js.map +1 -0
  76. package/dist/src/home/setup.js +507 -0
  77. package/dist/src/home/setup.js.map +1 -0
  78. package/dist/src/home/setupadapters.js +214 -0
  79. package/dist/src/home/setupadapters.js.map +1 -0
  80. package/dist/src/home/setupcard.js +199 -0
  81. package/dist/src/home/setupcard.js.map +1 -0
  82. package/dist/src/home/setuptools.js +45 -0
  83. package/dist/src/home/setuptools.js.map +1 -0
  84. package/dist/src/home/spend.js +160 -0
  85. package/dist/src/home/spend.js.map +1 -0
  86. package/dist/src/home/switches.js +209 -0
  87. package/dist/src/home/switches.js.map +1 -0
  88. package/dist/src/home/switchon.js +439 -0
  89. package/dist/src/home/switchon.js.map +1 -0
  90. package/dist/src/home/toolnames.js +41 -0
  91. package/dist/src/home/toolnames.js.map +1 -0
  92. package/dist/src/home/tune.js +341 -0
  93. package/dist/src/home/tune.js.map +1 -0
  94. package/dist/src/home/words.js +7 -0
  95. package/dist/src/home/words.js.map +1 -0
  96. package/dist/src/homes.js +14 -0
  97. package/dist/src/homes.js.map +1 -1
  98. package/dist/src/install.js +1 -1
  99. package/dist/src/install.js.map +1 -1
  100. package/dist/src/ipc/workerspec.js +8 -0
  101. package/dist/src/ipc/workerspec.js.map +1 -1
  102. package/dist/src/ledgercheck.js +39 -10
  103. package/dist/src/ledgercheck.js.map +1 -1
  104. package/dist/src/live/publish.js +1 -1
  105. package/dist/src/live/publish.js.map +1 -1
  106. package/dist/src/livecli.js +1 -1
  107. package/dist/src/livecli.js.map +1 -1
  108. package/dist/src/loginrefresh/handover.js +121 -0
  109. package/dist/src/loginrefresh/handover.js.map +1 -0
  110. package/dist/src/loginrefresh/lock.js +54 -3
  111. package/dist/src/loginrefresh/lock.js.map +1 -1
  112. package/dist/src/loginrefresh/refresher.js +33 -5
  113. package/dist/src/loginrefresh/refresher.js.map +1 -1
  114. package/dist/src/loginrefresh/supervisor.js +18 -1
  115. package/dist/src/loginrefresh/supervisor.js.map +1 -1
  116. package/dist/src/npmverbs.js +42 -12
  117. package/dist/src/npmverbs.js.map +1 -1
  118. package/dist/src/onboard/choices.js +6 -3
  119. package/dist/src/onboard/choices.js.map +1 -1
  120. package/dist/src/onboard/engine.js +33 -292
  121. package/dist/src/onboard/engine.js.map +1 -1
  122. package/dist/src/onboard/steps/bringup.js +3 -2
  123. package/dist/src/onboard/steps/bringup.js.map +1 -1
  124. package/dist/src/onboard/steps/configfill.js +18 -7
  125. package/dist/src/onboard/steps/configfill.js.map +1 -1
  126. package/dist/src/onboard/steps/loginshare.js +213 -0
  127. package/dist/src/onboard/steps/loginshare.js.map +1 -0
  128. package/dist/src/onboard/steps/memory.js +14 -2
  129. package/dist/src/onboard/steps/memory.js.map +1 -1
  130. package/dist/src/onboard/steps/repoconfig.js +72 -0
  131. package/dist/src/onboard/steps/repoconfig.js.map +1 -0
  132. package/dist/src/onboard/toolnames.js +2 -2
  133. package/dist/src/onboard/toolnames.js.map +1 -1
  134. package/dist/src/piagentdir.js +13 -0
  135. package/dist/src/piagentdir.js.map +1 -0
  136. package/dist/src/pilogin.js +31 -16
  137. package/dist/src/pilogin.js.map +1 -1
  138. package/dist/src/qcrbin.js +16 -11
  139. package/dist/src/qcrbin.js.map +1 -1
  140. package/dist/src/reviewargs.js +122 -0
  141. package/dist/src/reviewargs.js.map +1 -0
  142. package/dist/src/reviewcli.js +7 -161
  143. package/dist/src/reviewcli.js.map +1 -1
  144. package/dist/src/reviewdetach.js +76 -11
  145. package/dist/src/reviewdetach.js.map +1 -1
  146. package/dist/src/scancli.js +39 -691
  147. package/dist/src/scancli.js.map +1 -1
  148. package/dist/src/selfimprove.js +66 -0
  149. package/dist/src/selfimprove.js.map +1 -0
  150. package/dist/src/session-command-supervisor.js +38 -9
  151. package/dist/src/session-command-supervisor.js.map +1 -1
  152. package/dist/src/setup/adapters.js +5 -223
  153. package/dist/src/setup/adapters.js.map +1 -1
  154. package/dist/src/setup/caserunner.js +15 -249
  155. package/dist/src/setup/caserunner.js.map +1 -1
  156. package/dist/src/setup/engine.js +32 -341
  157. package/dist/src/setup/engine.js.map +1 -1
  158. package/dist/src/setup/facts.js +3 -592
  159. package/dist/src/setup/facts.js.map +1 -1
  160. package/dist/src/setup/findings.js +3 -947
  161. package/dist/src/setup/findings.js.map +1 -1
  162. package/dist/src/setup/presentation.js +3 -8
  163. package/dist/src/setup/presentation.js.map +1 -1
  164. package/dist/src/setup/scan.js +3 -724
  165. package/dist/src/setup/scan.js.map +1 -1
  166. package/dist/src/setup/scanbuild.js +9 -92
  167. package/dist/src/setup/scanbuild.js.map +1 -1
  168. package/dist/src/setup/scanlease.js +3 -250
  169. package/dist/src/setup/scanlease.js.map +1 -1
  170. package/dist/src/setup/scanreport.js +3 -288
  171. package/dist/src/setup/scanreport.js.map +1 -1
  172. package/dist/src/setup/scanrun.js +3 -1732
  173. package/dist/src/setup/scanrun.js.map +1 -1
  174. package/dist/src/setup/scantree.js +3 -256
  175. package/dist/src/setup/scantree.js.map +1 -1
  176. package/dist/src/setup/stage2request.js +3 -258
  177. package/dist/src/setup/stage2request.js.map +1 -1
  178. package/dist/src/setup/unitcut.js +3 -436
  179. package/dist/src/setup/unitcut.js.map +1 -1
  180. package/dist/src/state.js +75 -9
  181. package/dist/src/state.js.map +1 -1
  182. package/dist/src/takeover.js +1 -0
  183. package/dist/src/takeover.js.map +1 -1
  184. package/dist/src/takeoverpi.js +2 -1
  185. package/dist/src/takeoverpi.js.map +1 -1
  186. package/dist/src/tui/alerts.js +4 -1
  187. package/dist/src/tui/alerts.js.map +1 -1
  188. package/dist/src/tui/board.js +2 -2
  189. package/dist/src/tui/board.js.map +1 -1
  190. package/dist/src/tui/companion.js +2 -1
  191. package/dist/src/tui/companion.js.map +1 -1
  192. package/dist/src/tui/deploytab.js +2 -2
  193. package/dist/src/tui/deploytab.js.map +1 -1
  194. package/dist/src/tui/index.js +9 -6
  195. package/dist/src/tui/index.js.map +1 -1
  196. package/dist/src/tui/memory.js +3 -16
  197. package/dist/src/tui/memory.js.map +1 -1
  198. package/dist/src/tui/memoryurl.js +22 -0
  199. package/dist/src/tui/memoryurl.js.map +1 -0
  200. package/dist/src/tui/remote.js +1 -1
  201. package/dist/src/tui/remote.js.map +1 -1
  202. package/dist/src/tui/route.js +2 -2
  203. package/dist/src/tui/route.js.map +1 -1
  204. package/dist/src/tui/setupstate.js +8 -5
  205. package/dist/src/tui/setupstate.js.map +1 -1
  206. package/dist/src/tui/sshtriage.js +1 -1
  207. package/dist/src/tui/sshtriage.js.map +1 -1
  208. package/dist/src/verdicts.js +12 -0
  209. package/dist/src/verdicts.js.map +1 -1
  210. package/dist/src/wire.js +9 -4
  211. package/dist/src/wire.js.map +1 -1
  212. package/dist/src/workerbreaker.js +32 -5
  213. package/dist/src/workerbreaker.js.map +1 -1
  214. package/dist/src/workerworld.js +4 -0
  215. package/dist/src/workerworld.js.map +1 -1
  216. package/docker/README.md +1 -1
  217. package/docker/entrypoint.sh +40 -6
  218. package/docker-compose.yml +16 -1
  219. package/extensions/cli/home.ts +14 -0
  220. package/extensions/cli/selfimprove.ts +18 -0
  221. package/extensions/home.ts +484 -0
  222. package/extensions/login-route.ts +1 -1
  223. package/extensions/onboarding.ts +8 -7
  224. package/extensions/selfimprove.ts +65 -0
  225. package/extensions/setup-scan.ts +79 -766
  226. package/extensions/setup.ts +14 -70
  227. package/extensions/vendor/openviking-pi/NOTICE +5 -0
  228. package/extensions/vendor/openviking-pi/README.md +39 -5
  229. package/extensions/vendor/openviking-pi/index.ts +4 -0
  230. package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
  231. package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
  232. package/extensions/vendor/openviking-pi/sync.ts +80 -0
  233. package/extensions/vendor/openviking-pi/upstream.json +2 -1
  234. package/package.json +6 -4
  235. package/prompts/onboarding.md +8 -7
  236. package/schemas/review-result.v1.json +60 -0
  237. package/scripts/deploy.sh +8 -6
  238. package/scripts/qcr-host.sh +28 -5
  239. package/scripts/vm-ghcr-login.sh +7 -4
  240. package/src/archive/restore.ts +18 -21
  241. package/src/botworker.ts +13 -2
  242. package/src/channelfs.ts +3 -26
  243. package/src/cli.ts +13 -54
  244. package/src/config.ts +38 -0
  245. package/src/daemon.ts +34 -4
  246. package/src/debuground.ts +3 -0
  247. package/src/debugroundpi.ts +1 -0
  248. package/src/engine/caserunner-sdk.ts +29 -314
  249. package/src/home/adapters.ts +418 -0
  250. package/src/home/advanced.ts +421 -0
  251. package/src/home/advancedtools.ts +96 -0
  252. package/src/home/autoreview.ts +385 -0
  253. package/src/home/daemonstatus.ts +55 -0
  254. package/src/home/devicelogin.ts +107 -0
  255. package/src/home/launch.ts +257 -0
  256. package/src/home/loginowner.ts +248 -0
  257. package/src/home/memory.ts +176 -0
  258. package/src/home/model.ts +965 -0
  259. package/src/home/panel.ts +666 -0
  260. package/src/home/ports.ts +338 -0
  261. package/src/home/review.ts +206 -0
  262. package/src/home/reviews.ts +565 -0
  263. package/src/home/setup.ts +689 -0
  264. package/src/home/setupadapters.ts +217 -0
  265. package/src/home/setupcard.ts +288 -0
  266. package/src/home/setuptools.ts +57 -0
  267. package/src/home/spend.ts +194 -0
  268. package/src/home/switches.ts +293 -0
  269. package/src/home/switchon.ts +554 -0
  270. package/src/home/toolnames.ts +46 -0
  271. package/src/home/tune.ts +405 -0
  272. package/src/home/words.ts +8 -0
  273. package/src/homes.ts +17 -0
  274. package/src/install.ts +1 -1
  275. package/src/ipc/workerspec.ts +15 -0
  276. package/src/ledgercheck.ts +45 -9
  277. package/src/live/publish.ts +4 -1
  278. package/src/livecli.ts +6 -1
  279. package/src/loginrefresh/handover.ts +138 -0
  280. package/src/loginrefresh/lock.ts +72 -3
  281. package/src/loginrefresh/refresher.ts +34 -5
  282. package/src/loginrefresh/supervisor.ts +25 -2
  283. package/src/npmverbs.ts +51 -11
  284. package/src/onboard/choices.ts +6 -3
  285. package/src/onboard/engine.ts +64 -434
  286. package/src/onboard/steps/bringup.ts +3 -2
  287. package/src/onboard/steps/configfill.ts +23 -7
  288. package/src/onboard/steps/loginshare.ts +258 -0
  289. package/src/onboard/steps/memory.ts +18 -2
  290. package/src/onboard/steps/repoconfig.ts +85 -0
  291. package/src/onboard/toolnames.ts +2 -2
  292. package/src/piagentdir.ts +12 -0
  293. package/src/pilogin.ts +32 -16
  294. package/src/qcrbin.ts +16 -11
  295. package/src/reviewargs.ts +119 -0
  296. package/src/reviewcli.ts +6 -167
  297. package/src/reviewdetach.ts +107 -13
  298. package/src/scancli.ts +75 -826
  299. package/src/selfimprove.ts +90 -0
  300. package/src/session-command-supervisor.ts +41 -8
  301. package/src/setup/adapters.ts +13 -221
  302. package/src/setup/caserunner.ts +34 -273
  303. package/src/setup/engine.ts +62 -496
  304. package/src/setup/facts.ts +3 -627
  305. package/src/setup/findings.ts +3 -1264
  306. package/src/setup/presentation.ts +3 -18
  307. package/src/setup/scan.ts +3 -1033
  308. package/src/setup/scanbuild.ts +20 -114
  309. package/src/setup/scanlease.ts +3 -297
  310. package/src/setup/scanreport.ts +3 -373
  311. package/src/setup/scanrun.ts +3 -2268
  312. package/src/setup/scantree.ts +3 -259
  313. package/src/setup/stage2request.ts +3 -347
  314. package/src/setup/unitcut.ts +3 -523
  315. package/src/state.ts +126 -11
  316. package/src/takeover.ts +5 -0
  317. package/src/takeoverpi.ts +3 -2
  318. package/src/tui/alerts.ts +9 -2
  319. package/src/tui/board.ts +2 -2
  320. package/src/tui/companion.ts +2 -1
  321. package/src/tui/deploytab.ts +2 -2
  322. package/src/tui/index.ts +9 -6
  323. package/src/tui/memory.ts +3 -15
  324. package/src/tui/memoryurl.ts +22 -0
  325. package/src/tui/remote.ts +1 -1
  326. package/src/tui/route.ts +2 -2
  327. package/src/tui/setupstate.ts +20 -16
  328. package/src/tui/sshtriage.ts +1 -1
  329. package/src/verdicts.ts +13 -0
  330. package/src/wire.ts +9 -4
  331. package/src/workerbreaker.ts +41 -5
  332. package/src/workerworld.ts +9 -1
  333. package/dist/src/live/projection.js +0 -450
  334. package/dist/src/live/projection.js.map +0 -1
  335. package/dist/src/live/read.js +0 -60
  336. package/dist/src/live/read.js.map +0 -1
  337. package/dist/src/onboard/launch.js +0 -75
  338. package/dist/src/onboard/launch.js.map +0 -1
  339. package/dist/src/setup/ledger.js +0 -207
  340. package/dist/src/setup/ledger.js.map +0 -1
  341. package/dist/src/setup/stage2.js +0 -617
  342. package/dist/src/setup/stage2.js.map +0 -1
  343. package/dist/src/setup/textsniff.js +0 -89
  344. package/dist/src/setup/textsniff.js.map +0 -1
  345. package/src/live/projection.ts +0 -491
  346. package/src/live/read.ts +0 -86
  347. package/src/onboard/launch.ts +0 -103
  348. package/src/setup/ledger.ts +0 -324
  349. package/src/setup/stage2.ts +0 -926
  350. package/src/setup/textsniff.ts +0 -95
@@ -1,2268 +1,3 @@
1
- // Scan stage 1's run: preview, start, status, stop — the stateful
2
- // half behind the companion's `scan_*` tools. The rules are scan.ts's; the
3
- // records are ledger.ts's and findings.ts's; every effect goes through a port.
4
- //
5
- // Four promises, each structural:
6
- // * NOTHING RUNS UNSEEN. `start` must quote the key `preview` handed out; a
7
- // plan, sha, price or setting that moved since is refused, so the
8
- // operator always saw the cases and the price of what runs.
9
- // * THE FIRST SCAN CALIBRATES. With no measured price for this lens at
10
- // these settings, a preview offers only the CALIBRATION run — one case
11
- // from each of `[scan] calibration_units` units spread across the size
12
- // range, stopped hard at `calibration_max_usd`. Its cases are real stage-1
13
- // work (their findings and ledger rows count); what it adds is the price.
14
- // Every later preview is priced from the ledger's actual costs.
15
- // * HARD STOP AT THE ESTIMATE. The run's cap is the estimate the operator
16
- // approved (the calibration cap, for a calibration run). A case starts
17
- // only while what is spent, plus the estimates of the cases in flight,
18
- // plus its own estimate stays within the cap; the moment spending reaches
19
- // the cap — read LIVE, from the running cost the case runner reports as
20
- // each case goes, not only when a case ends — every case still in flight
21
- // is aborted and the run ends `capped`. A case that failed or was aborted
22
- // counts what it spent (its estimate when the runner cannot say), so
23
- // failures can never carry a run past the cap. What it did not read stays
24
- // out of the ledger, so the next preview prices exactly the rest — and
25
- // runs it first: stage-1 and delta cases are ordered coverage-first
26
- // (scan.ts readTier: never read or cut short, then changed — stalest
27
- // reading first — then the rest), so the stop never starves the same
28
- // tail run after run. Stage-2 rows keep the proposal's order.
29
- // * STOP AND RESUME BY THE LEDGER. `stop` aborts in-flight cases and starts
30
- // no more. A run the companion was not there to finish (a TUI quit, an
31
- // expired login) reads as `interrupted` — unless another process sharing
32
- // the state home holds the driver lease for it (scanlease.ts: the
33
- // companion, or a `qcr scan` driver), when it reads as running and no
34
- // second run starts beside it. Either way, only FINISHED cases wrote
35
- // ledger rows, so starting again reads only what is left.
36
- //
37
- // A delta re-scan is the same run over the files whose content
38
- // changed since the last scan (scan.ts changedSince) AND every file no
39
- // complete reading covers at its content — never read, cut short, unreadable
40
- // before (the plan's `owed`, read unread-first): priced from the ledger like
41
- // any stage-1 preview — so never before calibration — and
42
- // stopped hard at its estimate. Deep passes never ride a delta; stage 2 runs
43
- // only when asked. Every run, delta or not, ends by RECONCILING the findings
44
- // store (findings.ts): exact matches were already recognised case by case;
45
- // what a re-read file no longer reports is fixed, or handed to the companion
46
- // as a match question when a fresh finding might be the same one.
47
- //
48
- // DEPTH (`[scan] depth`, profile over config.toml): a deep run builds the
49
- // agents deep (scanbuild.ts — the deep suggest and dig addenda), lifts stage
50
- // 1's suggestion cap to `SCAN_DEEP_MAX_SUGGESTIONS`, writes `depth: "deep"` on
51
- // its ledger rows and reads coverage at its depth: a file the ledger holds
52
- // only a standard reading of is read again by a deep run (ledger.ts). It is
53
- // priced from readings at its depth, or — when none prices it — from readings
54
- // at the other depth, and the preview says so (`rate_across_depth`, a row's
55
- // `across_depth`). A stage-2 proposal records the depth it was made at — the
56
- // propose call's `deep`, else `[scan] depth` — and a start re-plans and
57
- // builds at that depth; a deep one adds the floor (stage2.ts). The depth is
58
- // part of a deep plan's key, so a key never starts a run at another depth.
59
- //
60
- // Where a case runs: background pi subagents inside the companion (the case
61
- // runner port), from the scan agent build — `pass-scan`, and the validator
62
- // when `[scan] validator` is on, both built with the scan-mode anchor
63
- // addendum. The tree they read is the reviewed checkout AT THE CHOSEN SHA,
64
- // materialised read-only by the source port; the sha is recorded on every
65
- // ledger row and finding.
66
- //
67
- // Stage 2 is the same run over a different plan: the companion
68
- // PROPOSES rows (file set × deep lens × tier, cost — stage2.ts's rules),
69
- // optionally fitted to a budget; the proposal is stored, and its key starts
70
- // it — quoted by the companion on the operator's word, or by the TUI's click
71
- // through the start channel (stage2request.ts). One case per row, the same
72
- // hard stop at the approved estimate, the same ledger (keyed by the row's
73
- // lens), the same findings store (filed under the unit owning each file).
74
- //
75
- // Persistence: `<state home>/scan/run.json` (the current run and a short
76
- // history), `scan/suggestions.json` (stage 1's deep-lens suggestions, the
77
- // input to stage 2), `scan/stage2.json` (the latest stage-2 proposal),
78
- // beside the ledger and the findings store — and the TUI's stop request
79
- // and its answer (`scan/stop.json`, `scan/stop-result.json` —
80
- // scanreport.ts, which also owns the run record's name and parser).
81
- import { posix } from "node:path";
82
- import { VALIDATE_PASS } from "@quickchatai/pi-quickreview/src/include.js";
83
- import type { LensTuningMap } from "@quickchatai/pi-quickreview/src/lenstuning.js";
84
- import { higherTier, type PassTier } from "@quickchatai/pi-quickreview/src/types.js";
85
- import type { ScanConfig, ScanDepth } from "../config.js";
86
- import type { CaseOutcome, CaseRunner, Refusal, StateFs } from "./engine.js";
87
- import {
88
- FindingsStore,
89
- settled,
90
- type FindingDoubt,
91
- type ReconcileResult,
92
- type ReportedFinding,
93
- type Validity,
94
- } from "./findings.js";
95
- import {
96
- casesFor,
97
- coveredBy,
98
- lastScanned,
99
- Ledger,
100
- measuredRate,
101
- newestRead,
102
- partialRanges,
103
- readings,
104
- type LedgerEntry,
105
- type RateKey,
106
- } from "./ledger.js";
107
- import {
108
- anchoredLines,
109
- calibrationCases,
110
- caseEstimate,
111
- changedSince,
112
- coverKey,
113
- evidenceQuotesAnchor,
114
- findingFingerprint,
115
- normalizeCode,
116
- parseScanAnswer,
117
- parseValidatorAnswer,
118
- planKey,
119
- planStage1,
120
- scanCasePrompt,
121
- scanValidatorPrompt,
122
- survivorSource,
123
- drainLanes,
124
- SCAN_DEEP_MAX_SUGGESTIONS,
125
- SCAN_PASS,
126
- type ExclusionReason,
127
- type PromptContext,
128
- type ScanCandidate,
129
- type ScanCasePlan,
130
- type ScanRate,
131
- type ScanSuggestion,
132
- type SkippedFile,
133
- type TreeFile,
134
- type UnreadableReason,
135
- } from "./scan.js";
136
- import {
137
- planStage2,
138
- priceStage2Row,
139
- stage2Cases,
140
- stage2CasePrompt,
141
- stage2Leads,
142
- stage2Key,
143
- unitOwner,
144
- type Stage2Row,
145
- type Stage2RowRequest,
146
- } from "./stage2.js";
147
- import {
148
- keptRows,
149
- parseStage2File,
150
- parseStartRequest,
151
- parseStartResult,
152
- proposalDepth,
153
- STAGE2_FILE,
154
- STAGE2_RESULT_FILE,
155
- STAGE2_START_FILE,
156
- STAGE2_VERSION,
157
- type Stage2StartResult,
158
- type StoredStage2Proposal,
159
- } from "./stage2request.js";
160
- import type { UnitCut } from "./unitcut.js";
161
- import { LEASE_RENEW_MS, type LeaseRecord, type ScanLease } from "./scanlease.js";
162
- import {
163
- parseRunFile,
164
- parseStopRequest,
165
- parseStopResult,
166
- RUN_FILE,
167
- RUN_VERSION,
168
- SCAN_STOP_FILE,
169
- SCAN_STOP_RESULT_FILE,
170
- type ScanStopResult,
171
- } from "./scanreport.js";
172
-
173
- // ---- did the lens read? ------------------------------------------------------------
174
-
175
- /**
176
- * Why a lens's answer is not a reading of its case, or null when it is. A
177
- * lens can answer NONE without opening a file; such an answer must not
178
- * record the files as read in the ledger, nor price the lens.
179
- *
180
- * `reads` is what the case runner saw the lens's file-read calls name
181
- * (`CaseOutcome.reads`); absent means the runner cannot tell, and unknown is
182
- * never judged as zero. A reading needs at least one focus file opened at
183
- * its path under the tree root. The runner reports each read resolved to the
184
- * file it opened, so a relative read counts exactly when it resolved inside
185
- * the tree: the lens runs outside the tree (first-party cwd), and a relative
186
- * `api/x.py` opened the app root's copy — another repository — which is no
187
- * reading of the case. A read left relative (a runner that cannot resolve)
188
- * is never credited, and a read of some other file (a CLAUDE.md, a caller)
189
- * is context, not the case. Pure.
190
- */
191
- export function unreadFocus(
192
- reads: readonly string[] | undefined,
193
- focus: readonly string[],
194
- root: string,
195
- ): string | null {
196
- if (reads === undefined) return null;
197
- if (openedFocus(reads, focus, root)!.size > 0) return null;
198
- const n = focus.length;
199
- return `the lens read none of its ${n} focus file${n === 1 ? "" : "s"} (${reads.length} file-read call${reads.length === 1 ? "" : "s"} in all)`;
200
- }
201
-
202
- /**
203
- * The focus files a lens opened, by the same reading `unreadFocus` makes: a
204
- * read resolved inside the tree root naming the file. Null when the runner
205
- * cannot tell (`reads` absent). A focus file the lens never opened is no
206
- * reading of THAT file, whatever it read of the others: it gets no ledger
207
- * row, so it is read again and a reconcile never settles its findings fixed
208
- * from a reading that never happened. Pure.
209
- */
210
- export function openedFocus(
211
- reads: readonly string[] | undefined,
212
- focus: readonly string[],
213
- root: string,
214
- ): Set<string> | null {
215
- if (reads === undefined) return null;
216
- const base = posix.normalize(`${root}/`);
217
- const opened = new Set(
218
- reads
219
- // pi strips one leading `@` from a path argument (its `stripAtPrefix`),
220
- // so `@/tree/x.py` opened `/tree/x.py`.
221
- .map((r) => (r.startsWith("@") ? r.slice(1) : r))
222
- .filter((r) => r.startsWith("/"))
223
- .map((r) => posix.normalize(r))
224
- .filter((r) => r.startsWith(base))
225
- .map((r) => r.slice(base.length)),
226
- );
227
- return new Set(focus.filter((f) => opened.has(posix.normalize(f))));
228
- }
229
-
230
- // ---- ports ---------------------------------------------------------------------
231
-
232
- /** The repository at one sha, materialised where the lenses read it. */
233
- export interface ScanTree {
234
- sha: string;
235
- /** Absolute path of the materialised tree — what the lens is told. */
236
- root: string;
237
- files: TreeFile[];
238
- read(path: string): string | null;
239
- lines(path: string): number | null;
240
- /** Why `read` answers null for a listed file (`binary`, `too-big`), or
241
- * null when it reads. Optional: a tree without it reports `unreadable`. */
242
- whyUnreadable?(path: string): UnreadableReason | null;
243
- }
244
-
245
- /** The reviewed checkout as a source of trees (READ-ONLY). */
246
- export interface ScanSource {
247
- readonly location: string;
248
- /** The full commit sha `ref` names, or null (unknown ref, no checkout). */
249
- resolve(ref: string): string | null;
250
- /** The tree at `sha`, materialised; earlier trees other than `keep` may be
251
- * removed to bound the disk. */
252
- open(
253
- sha: string,
254
- keep: readonly string[],
255
- ): { ok: true; tree: ScanTree } | { ok: false; reason: string };
256
- /** The files (and content hashes) at `sha`, listed without materialising
257
- * anything, or null when git cannot list them — a delta's baseline. */
258
- list(sha: string): TreeFile[] | null;
259
- }
260
-
261
- /** The resolved settings a scan runs with: `config.toml [scan]` under the
262
- * profile's `[scan]` (the profile wins). The validator — the skeptic — has
263
- * no tier of its own: it runs at the tier of the lens it judges. */
264
- export interface ScanSettings {
265
- scan: ScanConfig;
266
- /** The profile's per-lens tuning — which deep lenses stage 2 may run, at
267
- * which tiers and effort. */
268
- lenses?: LensTuningMap;
269
- /** `[models] risk_passes` — built at `sol-high` only. */
270
- riskPasses?: readonly string[];
271
- }
272
-
273
- export interface ScanPorts {
274
- state: StateFs;
275
- clock: () => Date;
276
- /** Runs one case as background subagents, from the scan agent build. */
277
- cases: CaseRunner;
278
- source: ScanSource;
279
- /** The SAVED unit cut (the engine's), or why there is none. */
280
- savedCut: () => { ok: true; cut: UnitCut } | Refusal;
281
- settings: () => { ok: true; settings: ScanSettings } | Refusal;
282
- /** (Re)build the scan agents where `cases` reads them. */
283
- buildAgents: (settings: ScanSettings) => { ok: true } | Refusal;
284
- /** After the run's reconcile: close the tickets of promoted findings it
285
- * found fixed (triage.ts `closeFixed`). Absent: nothing closes. */
286
- closeFixedTickets?: () => Promise<{ closed: readonly unknown[]; problems: readonly string[] }>;
287
- /** The driver lease shared with other processes over this state home
288
- * (scanlease.ts). Absent: this runner knows only its own run, and any
289
- * other run recorded as running reads as interrupted. */
290
- lease?: ScanLease;
291
- log?: (line: string) => void;
292
- }
293
-
294
- // ---- records -------------------------------------------------------------------
295
-
296
- export { RUN_FILE, RUN_VERSION };
297
- export const SUGGESTIONS_FILE = "scan/suggestions.json";
298
- /** Under the state home: every case's event stream, one directory per run
299
- * (`caserunner.ts` `transcriptFile`). */
300
- export const SCAN_TRANSCRIPTS_DIR = "scan/transcripts";
301
- /** Summaries of finished runs kept beside the current one. */
302
- export const RUN_HISTORY = 20;
303
-
304
- export type ScanMode = "calibration" | "stage1" | "delta" | "stage2";
305
- export type ScanCaseStatus = "pending" | "running" | "done" | "failed" | "stopped" | "skipped";
306
- export type ScanRunState = "running" | "complete" | "partial" | "stopped" | "capped";
307
-
308
- export interface ScanCaseState {
309
- id: string;
310
- unit: string;
311
- part: number;
312
- parts: number;
313
- files: string[];
314
- /** A file read in line ranges: the range this case reports inside. */
315
- ranges?: Array<{ path: string; start: number; end: number; total: number }>;
316
- lines: number;
317
- estimate_usd: number | null;
318
- status: ScanCaseStatus;
319
- /** Stage 1 plus the validator; null when the runner could not price it. */
320
- cost_usd: number | null;
321
- findings: number;
322
- /** Findings the validator doubted — kept, marked "may not be valid". */
323
- doubted: number;
324
- /** Findings the skeptic confirmed / found likely (absent on records
325
- * written before the tiers). */
326
- confirmed?: number;
327
- likely?: number;
328
- /** Answer items that could not be read or anchored. */
329
- dropped: number;
330
- /** The lens's file-read calls, counted; absent when the runner could not
331
- * tell. */
332
- reads?: number;
333
- /** Focus files the lens never opened — no coverage, read again. Absent
334
- * when it opened every one, or the runner could not tell. */
335
- unopened?: string[];
336
- /** The case's and its validator's event streams, relative to
337
- * `SCAN_TRANSCRIPTS_DIR`; absent when none was kept. */
338
- transcript?: string;
339
- validator_transcript?: string;
340
- suggestions: ScanSuggestion[];
341
- error: string | null;
342
- started_at: string | null;
343
- finished_at: string | null;
344
- /** Stage 2: the row's lens, tier, reason and every unit it spans. */
345
- lens?: string;
346
- tier?: PassTier;
347
- reason?: string;
348
- units?: string[];
349
- }
350
-
351
- export interface ScanRun {
352
- id: string;
353
- mode: ScanMode;
354
- sha: string;
355
- plan_key: string;
356
- started_at: string;
357
- finished_at: string | null;
358
- state: ScanRunState;
359
- /** The hard stop: the approved estimate, or the calibration cap. */
360
- cap_usd: number;
361
- estimate_usd: number | null;
362
- /** Priced cost, plus the estimate of every case that finished unpriced. */
363
- spent_usd: number;
364
- unpriced_cases: number;
365
- settings: {
366
- tier: PassTier;
367
- effort: string;
368
- validator: boolean;
369
- validate_tier: PassTier | null;
370
- /** `"deep"` for a deep run; absent = standard. */
371
- depth?: ScanDepth;
372
- };
373
- cases: ScanCaseState[];
374
- problems: string[];
375
- /** What the run's end did to the stored findings; null until it
376
- * ran, or when it could not. */
377
- matching?: ReconcileResult | null;
378
- /** Tickets of promoted findings this run's end closed as fixed. */
379
- tickets_closed?: number;
380
- }
381
-
382
- export interface ScanRunSummary {
383
- id: string;
384
- mode: ScanMode;
385
- sha: string;
386
- state: ScanRunState | "interrupted";
387
- started_at: string;
388
- finished_at: string | null;
389
- cases_done: number;
390
- cases: number;
391
- findings: number;
392
- spent_usd: number;
393
- }
394
-
395
- export interface RunFile {
396
- version: number;
397
- current: ScanRun | null;
398
- history: ScanRunSummary[];
399
- }
400
-
401
- export interface StoredSuggestion extends ScanSuggestion {
402
- unit: string;
403
- sha: string;
404
- run: string;
405
- at: string;
406
- }
407
-
408
- // ---- views ---------------------------------------------------------------------
409
-
410
- export interface ScanPreview {
411
- key: string;
412
- mode: ScanMode;
413
- sha: string;
414
- cases: Array<ScanCasePlan & { estimate_usd: number | null }>;
415
- files: number;
416
- lines: number;
417
- /** Null for a calibration run: that is what it measures. */
418
- estimate_usd: number | null;
419
- /** The run would stop hard here. */
420
- cap_usd: number;
421
- rate: ScanRate | null;
422
- /** The run's depth (`[scan] depth`). */
423
- depth: ScanDepth;
424
- /** The rate stands on readings at the other depth: nothing at this depth
425
- * is priced at these settings yet. */
426
- rate_across_depth: boolean;
427
- /** Priced cases the rate stands on, and how many calibration wants. */
428
- calibration: { measured: number; wanted: number };
429
- /** Stage-1 cases a calibration run leaves for the next one (0 for any
430
- * other run, which reads every case it plans). */
431
- remaining_cases: number;
432
- excluded: Record<ExclusionReason, number>;
433
- covered: number;
434
- unassigned: string[];
435
- unreadable: number;
436
- /** The unreadable files by path and reason — `skipped: binary` is said,
437
- * never silently absent. */
438
- skipped: SkippedFile[];
439
- settings: ScanRun["settings"] & { exclude: string[]; concurrency: number };
440
- /** A delta: the last scan's sha it compares against, and how many of its
441
- * files are unchanged since but still owed a complete reading (never read,
442
- * cut short, unreadable before). Null for a full preview. `owed_shallow`
443
- * (a deep delta only): how many of those a complete standard reading covers
444
- * at this content — owed only because no deep reading does. */
445
- delta: { since: string; owed: number; owed_shallow?: number } | null;
446
- /** Set when there is nothing to read but the findings still owe a
447
- * reconcile: why. The run reads no case and only settles them. */
448
- reconcile_only?: string;
449
- /** Why a start would be refused whatever the key. */
450
- blockers: string[];
451
- }
452
-
453
- export interface ScanStatus {
454
- ok: boolean;
455
- problems: string[];
456
- run: (ScanRun & { display: ScanRunState | "interrupted" }) | null;
457
- active: boolean;
458
- history: ScanRunSummary[];
459
- /** `files`: distinct paths stage 1 read; `deep_files`: distinct file × lens
460
- * pairs stage 2 read. */
461
- ledger: { files: number; deep_files: number; cost_usd: number } | null;
462
- }
463
-
464
- /** What `proposeStage2` answers. */
465
- export type Stage2Proposal = StoredStage2Proposal;
466
-
467
- const EPS = 1e-9;
468
- const clean = (err: unknown): string => (err instanceof Error ? err.message : String(err));
469
-
470
- /** The refusal while another process holds the driver lease. */
471
- const heldElsewhere = (l: LeaseRecord): string =>
472
- l.run === null
473
- ? `a scan is starting in another process (pid ${l.pid} on ${l.host}) — wait for it, then check the status`
474
- : `scan ${l.run} is running in another process (pid ${l.pid} on ${l.host}) — stop it there or wait for it`;
475
- /** The validator's variant at the tier of the lens it judges — never raised:
476
- * the scan validator is the skeptic, at the same tier. */
477
- const validatorAgent = (tier: PassTier): string => `${VALIDATE_PASS}--${tier}`;
478
-
479
- /** Why a candidate the skeptic's answer never mentioned is doubtful. */
480
- export const UNMENTIONED_REASON =
481
- "the skeptic listed it neither as a survivor nor under DOUBTED — kept, not upheld";
482
-
483
- /** A finding on its way to the store, with the skeptic's judgement. */
484
- interface KeptFinding {
485
- cand: ScanCandidate;
486
- code: string;
487
- quoted: boolean;
488
- validity: Validity | null;
489
- scenario: string | null;
490
- doubt: FindingDoubt | null;
491
- }
492
-
493
- /** One case of a run, whatever the stage: what it reads, with which lens at
494
- * which tier and effort, what it is told, and where its findings are filed. */
495
- interface CaseJob {
496
- plan: ScanCasePlan & { estimate_usd: number | null };
497
- lens: string;
498
- tier: PassTier;
499
- effort: string;
500
- prompt: (ctx: PromptContext) => string;
501
- /** The validator's view of the case, e.g. `stage 1, unit app`. */
502
- label: string;
503
- /** The unit a finding at `path` is filed under. */
504
- unitOf: (path: string) => string;
505
- /** Stage 1 reads and stores deep-lens suggestions; stage 2 does not. */
506
- suggestions: boolean;
507
- /** Stage 2: why the row, and every unit it spans. */
508
- reason?: string;
509
- units?: string[];
510
- }
511
-
512
- type Stage2Planned =
513
- | {
514
- ok: true;
515
- proposal: StoredStage2Proposal;
516
- tree: ScanTree;
517
- settings: ScanSettings;
518
- cut: UnitCut;
519
- }
520
- | Refusal;
521
-
522
- interface Planned {
523
- preview: ScanPreview;
524
- tree: ScanTree;
525
- settings: ScanSettings;
526
- }
527
-
528
- /** The run record, read totally: an unreadable file is the error beside an
529
- * empty record, never a throw (the portrait and the re-tune read it too). */
530
- export function readRunFile(state: StateFs): { file: RunFile; error: string | null } {
531
- const empty: RunFile = { version: RUN_VERSION, current: null, history: [] };
532
- let text: string | null;
533
- try {
534
- text = state.read(RUN_FILE);
535
- } catch (err) {
536
- return { file: empty, error: `${RUN_FILE} in ${state.location} cannot be read: ${clean(err)}` };
537
- }
538
- if (text === null) return { file: empty, error: null };
539
- try {
540
- return { file: parseRunFile(text), error: null };
541
- } catch (err) {
542
- return {
543
- file: empty,
544
- error: `${RUN_FILE} in ${state.location} is not a scan record this version reads (${clean(err)}) — nothing will overwrite it; move it aside to scan again`,
545
- };
546
- }
547
- }
548
-
549
- export function runSummary(run: ScanRun, state: ScanRunSummary["state"]): ScanRunSummary {
550
- return {
551
- id: run.id,
552
- mode: run.mode,
553
- sha: run.sha,
554
- state,
555
- started_at: run.started_at,
556
- finished_at: run.finished_at,
557
- cases_done: run.cases.filter((c) => c.status === "done").length,
558
- cases: run.cases.length,
559
- findings: run.cases.reduce((n, c) => n + c.findings, 0),
560
- spent_usd: run.spent_usd,
561
- };
562
- }
563
-
564
- /**
565
- * The scan runs that finished — complete, partial, stopped or capped —
566
- * newest first (what a portrait refresh and a re-tune stand on). A
567
- * run still `running` in the file is either live or was interrupted; neither
568
- * is a scan whose findings are settled.
569
- */
570
- export function finishedRuns(state: StateFs): { runs: ScanRunSummary[]; error: string | null } {
571
- const { file, error } = readRunFile(state);
572
- const out: ScanRunSummary[] = [];
573
- if (file.current && file.current.state !== "running")
574
- out.push(runSummary(file.current, file.current.state));
575
- for (const h of file.history) {
576
- if (h.state !== "running" && h.state !== "interrupted" && !out.some((r) => r.id === h.id))
577
- out.push(h);
578
- }
579
- out.sort((a, b) => (b.finished_at ?? b.started_at).localeCompare(a.finished_at ?? a.started_at));
580
- return { runs: out, error };
581
- }
582
-
583
- export class ScanRunner {
584
- private active: { id: string; sha: string; abort: AbortController; done: Promise<void> } | null =
585
- null;
586
- private starting = false;
587
- /** The commit a start still planning has opened: its tree is kept, like
588
- * the active run's, from a preview or proposal planned meanwhile. */
589
- private startingSha: string | null = null;
590
- private consuming = false;
591
- /** Renews the driver lease from a start's claim to its run's end. */
592
- private renewTimer: ReturnType<typeof setInterval> | null = null;
593
- /** The run the lease is renewed for (null while a start plans). */
594
- private leaseRun: string | null = null;
595
- /** Runs whose driver lease was lost: stopped, and never written again —
596
- * the run record is the new holder's. */
597
- private readonly fenced = new Set<string>();
598
- private readonly ledger: Ledger;
599
- private readonly findings: FindingsStore;
600
-
601
- constructor(private readonly ports: ScanPorts) {
602
- this.ledger = new Ledger(ports.state);
603
- this.findings = new FindingsStore(ports.state);
604
- }
605
-
606
- // -- the run file --
607
-
608
- private load(): { file: RunFile; error: string | null } {
609
- return readRunFile(this.ports.state);
610
- }
611
-
612
- private store(file: RunFile): void {
613
- // A run that lost the lease owns the record no more.
614
- if (file.current !== null && this.fenced.has(file.current.id)) return;
615
- this.ports.state.write(RUN_FILE, `${JSON.stringify(file, null, 1)}\n`);
616
- }
617
-
618
- private summary(run: ScanRun, state: ScanRunSummary["state"]): ScanRunSummary {
619
- return runSummary(run, state);
620
- }
621
-
622
- // -- the plan --
623
-
624
- /** What every plan reads first: the settings, the saved cut, the commit
625
- * `ref` names (default HEAD), its tree and the ledger. Opening a tree
626
- * prunes the others, never the active run's nor a planning start's;
627
- * `claim` (a start planning) marks this one as the starting start's. */
628
- private prerequisites(
629
- ref: string | undefined,
630
- claim: boolean,
631
- ):
632
- | {
633
- ok: true;
634
- settings: ScanSettings;
635
- cut: UnitCut;
636
- sha: string;
637
- tree: ScanTree;
638
- entries: LedgerEntry[];
639
- }
640
- | Refusal {
641
- const other = this.ports.lease?.other();
642
- if (other) return { ok: false, errors: [heldElsewhere(other)] };
643
- const s = this.ports.settings();
644
- if (!s.ok) return s;
645
- const cut = this.ports.savedCut();
646
- if (!cut.ok) return cut;
647
- const wanted = (ref ?? "").trim() || "HEAD";
648
- const sha = this.ports.source.resolve(wanted);
649
- if (sha === null)
650
- return {
651
- ok: false,
652
- errors: [`${JSON.stringify(wanted)} names no commit in ${this.ports.source.location}`],
653
- };
654
- if (claim) this.startingSha = sha;
655
- const keep = [this.active?.sha, this.startingSha].filter(
656
- (k): k is string => k !== undefined && k !== null,
657
- );
658
- const opened = this.ports.source.open(sha, keep);
659
- if (!opened.ok)
660
- return {
661
- ok: false,
662
- errors: [`cannot read the tree at ${sha.slice(0, 12)}: ${opened.reason}`],
663
- };
664
- const { entries, error } = this.ledger.load();
665
- if (error !== null) return { ok: false, errors: [error] };
666
- return { ok: true, settings: s.settings, cut: cut.cut, sha, tree: opened.tree, entries };
667
- }
668
-
669
- private async plan(
670
- ref: string | undefined,
671
- delta = false,
672
- claim = false,
673
- ): Promise<{ ok: true; planned: Planned } | Refusal> {
674
- const pre = this.prerequisites(ref, claim);
675
- if (!pre.ok) return pre;
676
- const { sha, tree, entries, settings } = pre;
677
- const cfg = settings.scan;
678
- const depth = cfg.depth;
679
- // A delta compares against the last scan: each file's newest content the
680
- // ledger read, and the tree that scan read (listed, not materialised).
681
- let basis: { since: string; read: Map<string, string>; baseline: Map<string, string> } | null =
682
- null;
683
- if (delta) {
684
- const last = lastScanned(entries, SCAN_PASS);
685
- if (last === null)
686
- return {
687
- ok: false,
688
- errors: ["no stage-1 scan to re-scan against — preview and run a full scan first"],
689
- };
690
- const listed = this.ports.source.list(last.sha);
691
- if (listed === null) {
692
- return {
693
- ok: false,
694
- errors: [
695
- `cannot list the tree the last scan read (${last.sha.slice(0, 12)}) — a delta cannot tell which files are new`,
696
- ],
697
- };
698
- }
699
- basis = {
700
- since: last.sha,
701
- read: newestRead(entries, SCAN_PASS),
702
- baseline: new Map(listed.map((f) => [f.path, f.blob])),
703
- };
704
- }
705
- const plan = planStage1({
706
- cut: pre.cut,
707
- files: tree.files,
708
- linesOf: (p) => tree.lines(p),
709
- whyUnreadable: (p) => tree.whyUnreadable?.(p) ?? null,
710
- // Coverage at the run's depth: a deep run reads again what only a
711
- // standard reading covers.
712
- covered: coveredBy(entries, SCAN_PASS, depth),
713
- partial: partialRanges(entries, SCAN_PASS, depth),
714
- // Coverage first: never-read and cut-short files run before changed
715
- // ones, so the hard stop never starves the same tail run after run.
716
- history: new Map(
717
- [...readings(entries, SCAN_PASS, depth)].map(([path, r]) => [
718
- path,
719
- { blob: r.blob, complete: r.complete, at: r.newest.at },
720
- ]),
721
- ),
722
- textOf: (p) => tree.read(p),
723
- exclude: cfg.exclude,
724
- budget: { maxFiles: cfg.case_max_files, maxLines: cfg.case_max_lines },
725
- ...(basis ? { delta: basis } : {}),
726
- });
727
- const key: RateKey = {
728
- lens: SCAN_PASS,
729
- tier: cfg.tier,
730
- effort: cfg.effort,
731
- validator: cfg.validator,
732
- };
733
- // Priced at the run's depth; from readings at the other depth only when
734
- // none at this one is priced yet (and the preview says so).
735
- const own = measuredRate(entries, { ...key, depth });
736
- const rate = own ?? measuredRate(entries, key);
737
- const tally = casesFor(entries, key);
738
- const measured = rate?.cases ?? 0;
739
- const blockers: string[] = [];
740
- // Calibrated once `calibration_units` priced cases stand behind the rate —
741
- // or every case there is, on a repo smaller than that.
742
- const calibrated =
743
- rate !== null && measured >= Math.min(cfg.calibration_units, measured + plan.cases.length);
744
- let mode: ScanMode;
745
- let cases: Array<ScanCasePlan & { estimate_usd: number | null }>;
746
- let cap: number;
747
- let estimate: number | null;
748
- if (delta) {
749
- // Priced like stage 1 — a delta never calibrates, and never guesses.
750
- mode = "delta";
751
- if (rate === null) {
752
- blockers.push(
753
- "no measured price for stage 1 at these settings — run the calibration (an ordinary preview) before a delta re-scan",
754
- );
755
- }
756
- cases = plan.cases.map((c) => ({
757
- ...c,
758
- estimate_usd: rate === null ? null : caseEstimate(c.lines, rate),
759
- }));
760
- estimate = rate === null ? null : cases.reduce((n, c) => n + (c.estimate_usd ?? 0), 0);
761
- cap = estimate ?? 0;
762
- } else if (calibrated) {
763
- mode = "stage1";
764
- cases = plan.cases.map((c) => ({ ...c, estimate_usd: caseEstimate(c.lines, rate) }));
765
- estimate = cases.reduce((n, c) => n + (c.estimate_usd ?? 0), 0);
766
- cap = estimate;
767
- } else {
768
- mode = "calibration";
769
- if (tally.cases > 0 && tally.priced === 0) {
770
- blockers.push(
771
- `${tally.cases} stage-1 case(s) finished at these settings but the case runner priced none of them — ` +
772
- "a scan cannot be estimated, so none is started (check that the companion's pi reports usage cost)",
773
- );
774
- }
775
- cases = calibrationCases(plan.cases, Math.max(1, cfg.calibration_units - measured)).map(
776
- (c) => ({ ...c, estimate_usd: null }),
777
- );
778
- estimate = null;
779
- cap = cfg.calibration_max_usd;
780
- }
781
- // Nothing to read, but the findings still owe a reconcile — a reported
782
- // file deleted since, or a run that ended before its own reconcile: the
783
- // run reads nothing and only settles them, so emptiness is no blocker.
784
- const reconcileOnly = cases.length === 0 ? this.reconcileOwed(tree) : null;
785
- if (cases.length === 0 && reconcileOnly === null) {
786
- blockers.push(
787
- delta
788
- ? `nothing changed to read at ${sha.slice(0, 12)} since the last scan (${basis!.since.slice(0, 12)}), and the ledger covers every other file at its content`
789
- : plan.covered > 0
790
- ? `nothing left to read at ${sha.slice(0, 12)}: the ledger already covers every stage-1 file at this content`
791
- : "nothing to read: no unit of the saved cut owns a readable, non-excluded file",
792
- );
793
- }
794
- // A deep delta owes an unchanged file a standard reading already covers
795
- // only because no deep one does: counted apart, so the preview says so.
796
- const owedShallow = (b: NonNullable<typeof basis>): { owed_shallow?: number } => {
797
- if (depth !== "deep") return {};
798
- const standard = coveredBy(entries, SCAN_PASS, "standard");
799
- const seen = new Set<string>();
800
- for (const c of plan.cases)
801
- for (const f of c.files)
802
- if (!changedSince(f, b) && standard.has(coverKey(f.path, f.blob))) seen.add(f.path);
803
- return { owed_shallow: seen.size };
804
- };
805
- const runSettings: ScanRun["settings"] = {
806
- tier: cfg.tier,
807
- effort: cfg.effort,
808
- validator: cfg.validator,
809
- validate_tier: cfg.validator ? cfg.tier : null,
810
- ...(depth === "deep" ? { depth } : {}),
811
- };
812
- const preview: ScanPreview = {
813
- key: planKey({
814
- mode,
815
- sha,
816
- cases: cases.map((c) => [
817
- c.id,
818
- c.files.map(
819
- (f) => `${f.path}@${f.blob}${f.range ? `:${f.range.start}-${f.range.end}` : ""}`,
820
- ),
821
- ]),
822
- settings: runSettings,
823
- cap: Math.round(cap * 1e6),
824
- }),
825
- mode,
826
- sha,
827
- cases,
828
- files: cases.reduce((n, c) => n + c.files.length, 0),
829
- lines: cases.reduce((n, c) => n + c.lines, 0),
830
- estimate_usd: estimate,
831
- cap_usd: cap,
832
- rate,
833
- depth,
834
- rate_across_depth: own === null && rate !== null,
835
- calibration: { measured, wanted: cfg.calibration_units },
836
- remaining_cases: mode === "calibration" ? plan.cases.length - cases.length : 0,
837
- excluded: plan.excluded,
838
- covered: plan.covered,
839
- unassigned: plan.unassigned,
840
- unreadable: plan.unreadable,
841
- skipped: plan.skipped,
842
- settings: { ...runSettings, exclude: cfg.exclude, concurrency: cfg.concurrency },
843
- delta: basis ? { since: basis.since, owed: plan.owed, ...owedShallow(basis) } : null,
844
- ...(reconcileOnly !== null ? { reconcile_only: reconcileOnly } : {}),
845
- blockers,
846
- };
847
- return { ok: true, planned: { preview, tree, settings } };
848
- }
849
-
850
- /** Why a run that reads nothing must still run, or null: an open finding
851
- * on a file the tree no longer holds (outside the pending match question,
852
- * which waits for the companion's answer), or a last run that ended before
853
- * its own reconcile (interrupted, or its reconcile failed). */
854
- private reconcileOwed(tree: ScanTree): string | null {
855
- const last = this.load().file.current;
856
- if (last !== null && last.id !== this.active?.id && last.matching == null)
857
- return `the last run (${last.id}) ended before its findings were reconciled`;
858
- const { findings, error } = this.findings.load();
859
- if (error !== null) return null;
860
- const asked = new Set(
861
- (this.findings.question().question?.unreported ?? []).map((f) => f.fingerprint),
862
- );
863
- const paths = new Set(tree.files.map((f) => f.path));
864
- const gone = findings.filter(
865
- (f) => !settled(f) && !asked.has(f.fingerprint) && !paths.has(f.path),
866
- ).length;
867
- return gone > 0 ? `${gone} open finding(s) on file(s) no longer in the tree` : null;
868
- }
869
-
870
- // -- the four verbs --
871
-
872
- /** What a scan would read and cost now at `sha` (default the checkout's
873
- * HEAD), and the key a start must quote. */
874
- async preview(
875
- args: { sha?: string; delta?: boolean } = {},
876
- ): Promise<{ ok: true; preview: ScanPreview } | Refusal> {
877
- const p = await this.plan(args.sha, args.delta === true);
878
- return p.ok ? { ok: true, preview: p.planned.preview } : p;
879
- }
880
-
881
- /** Start the previewed run in the background. Returns once it is recorded. */
882
- async start(args: {
883
- key?: unknown;
884
- sha?: string;
885
- delta?: boolean;
886
- }): Promise<{ ok: true; run: ScanRun } | Refusal> {
887
- const busy = this.refuseBusy();
888
- if (busy !== null) return busy;
889
- if (typeof args.key !== "string" || args.key === "") {
890
- return {
891
- ok: false,
892
- errors: [
893
- "no preview key — preview the scan, show the operator the cost, then start with its key",
894
- ],
895
- };
896
- }
897
- const key = args.key;
898
- return this.holdingStart(() => this.startPlanned(key, args.sha, args.delta === true));
899
- }
900
-
901
- /** Why no start may begin now: a run is active, or another start is still
902
- * planning. Two starts in parallel (a model may issue tool calls
903
- * concurrently) must not both pass the active check while the first is
904
- * still planning: that would run — and pay for — the same scan twice. */
905
- private refuseBusy(): Refusal | null {
906
- if (this.active)
907
- return {
908
- ok: false,
909
- errors: [`scan ${this.active.id} is still running — stop it or wait for it`],
910
- };
911
- if (this.starting)
912
- return {
913
- ok: false,
914
- errors: ["a scan is already starting — wait for it, then check scan_status"],
915
- };
916
- return null;
917
- }
918
-
919
- /** Hold the starting slot across a start's planning and launch. */
920
- private async holdingStart<T extends { ok: boolean }>(
921
- body: () => Promise<T | Refusal>,
922
- ): Promise<T | Refusal> {
923
- // The lease is claimed before planning, so of two processes starting at
924
- // once only one plans and runs.
925
- const lease = this.ports.lease;
926
- if (lease && !lease.claim(null)) {
927
- const other = lease.other();
928
- return {
929
- ok: false,
930
- errors: [
931
- other
932
- ? heldElsewhere(other)
933
- : `the scan driver lease could not be claimed${lease.lastError ? `: ${lease.lastError}` : ""}`,
934
- ],
935
- };
936
- }
937
- this.starting = true;
938
- if (lease) this.renewLease(null);
939
- let started = false;
940
- try {
941
- const r = await body();
942
- started = r.ok;
943
- return r;
944
- } finally {
945
- this.starting = false;
946
- this.startingSha = null;
947
- if (!started && !this.active) {
948
- this.stopRenewing();
949
- lease?.release();
950
- }
951
- }
952
- }
953
-
954
- /** Still holding the lease a start claimed, right before it launches? A
955
- * planning that blocked past the lease's life may have lost it. */
956
- private leaseLost(): Refusal | null {
957
- const lease = this.ports.lease;
958
- if (!lease || lease.renew(null)) return null;
959
- const other = lease.other();
960
- return {
961
- ok: false,
962
- errors: [
963
- `the scan driver lease was lost while the start planned${other ? ` (${heldElsewhere(other)})` : ""} — preview again`,
964
- ],
965
- };
966
- }
967
-
968
- /** Renew the lease for `run` every LEASE_RENEW_MS until stopRenewing. */
969
- private renewLease(run: string | null): void {
970
- this.leaseRun = run;
971
- if (this.renewTimer) return;
972
- const timer = setInterval(() => this.renewTick(), LEASE_RENEW_MS);
973
- timer.unref?.();
974
- this.renewTimer = timer;
975
- }
976
-
977
- private stopRenewing(): void {
978
- if (this.renewTimer) clearInterval(this.renewTimer);
979
- this.renewTimer = null;
980
- this.leaseRun = null;
981
- }
982
-
983
- /** One renewal. A lease found lost (this process was suspended past its
984
- * life, and another took it over) stops the run it was held for: its
985
- * in-flight cases are aborted and it writes the run record no more. */
986
- private renewTick(): void {
987
- const lease = this.ports.lease;
988
- if (!lease || lease.renew(this.leaseRun)) return;
989
- const other = lease.other();
990
- const why = `the scan driver lease was lost${other ? ` — ${heldElsewhere(other)}` : ""}`;
991
- const active = this.active;
992
- this.stopRenewing();
993
- if (active && !this.fenced.has(active.id)) {
994
- this.ports.log?.(
995
- `setup: ${why}; stopping scan ${active.id}, which no longer owns the run record`,
996
- );
997
- this.fenced.add(active.id);
998
- active.abort.abort();
999
- } else this.ports.log?.(`setup: ${why}`);
1000
- }
1001
-
1002
- private async startPlanned(
1003
- key: string,
1004
- sha: string | undefined,
1005
- delta: boolean,
1006
- ): Promise<{ ok: true; run: ScanRun } | Refusal> {
1007
- const args = { key, sha };
1008
- const p = await this.plan(args.sha, delta, true);
1009
- if (!p.ok) return p;
1010
- const { preview, tree, settings } = p.planned;
1011
- if (args.key !== preview.key) {
1012
- return {
1013
- ok: false,
1014
- errors: [
1015
- `the plan changed since that preview (key ${args.key}, now ${preview.key}) — preview again and show the operator the new cost`,
1016
- ],
1017
- };
1018
- }
1019
- if (preview.blockers.length > 0) return { ok: false, errors: preview.blockers };
1020
- const { file, error } = this.load();
1021
- if (error !== null) return { ok: false, errors: [error] };
1022
- const built = this.ports.buildAgents(settings);
1023
- if (!built.ok)
1024
- return {
1025
- ok: false,
1026
- errors: built.errors.map((e) => `the scan agents could not be built: ${e}`),
1027
- };
1028
-
1029
- const cfg = settings.scan;
1030
- const jobs: CaseJob[] = preview.cases.map((c) => ({
1031
- plan: c,
1032
- lens: SCAN_PASS,
1033
- tier: cfg.tier,
1034
- effort: cfg.effort,
1035
- prompt: (ctx) => scanCasePrompt(c, ctx),
1036
- label: `stage 1, unit ${c.unit}`,
1037
- unitOf: () => c.unit,
1038
- suggestions: true,
1039
- }));
1040
- const lost = this.leaseLost();
1041
- if (lost !== null) return lost;
1042
- const run = this.launch(
1043
- file,
1044
- {
1045
- mode: preview.mode,
1046
- sha: preview.sha,
1047
- plan_key: preview.key,
1048
- cap_usd: preview.cap_usd,
1049
- estimate_usd: preview.estimate_usd,
1050
- settings: {
1051
- tier: preview.settings.tier,
1052
- effort: preview.settings.effort,
1053
- validator: preview.settings.validator,
1054
- validate_tier: preview.settings.validate_tier,
1055
- ...(preview.settings.depth ? { depth: preview.settings.depth } : {}),
1056
- },
1057
- },
1058
- jobs,
1059
- tree,
1060
- settings,
1061
- );
1062
- return { ok: true, run };
1063
- }
1064
-
1065
- /** Record a new run as current and drive its cases in the background. */
1066
- private launch(
1067
- file: RunFile,
1068
- head: Pick<ScanRun, "mode" | "sha" | "plan_key" | "cap_usd" | "estimate_usd" | "settings">,
1069
- jobs: CaseJob[],
1070
- tree: ScanTree,
1071
- settings: ScanSettings,
1072
- onAccepted?: (run: ScanRun) => void,
1073
- ): ScanRun {
1074
- const now = this.ports.clock();
1075
- const run: ScanRun = {
1076
- id: `scan-${now
1077
- .toISOString()
1078
- .replace(/[-:]/g, "")
1079
- .replace(/\.\d+Z$/, "Z")}`,
1080
- ...head,
1081
- started_at: now.toISOString(),
1082
- finished_at: null,
1083
- state: "running",
1084
- spent_usd: 0,
1085
- unpriced_cases: 0,
1086
- cases: jobs.map(({ plan: c, ...job }) => ({
1087
- id: c.id,
1088
- unit: c.unit,
1089
- part: c.part,
1090
- parts: c.parts,
1091
- files: c.files.map((f) => f.path),
1092
- ...(c.files.some((f) => f.range)
1093
- ? { ranges: c.files.filter((f) => f.range).map((f) => ({ path: f.path, ...f.range! })) }
1094
- : {}),
1095
- lines: c.lines,
1096
- estimate_usd: c.estimate_usd,
1097
- status: "pending" as const,
1098
- cost_usd: null,
1099
- findings: 0,
1100
- doubted: 0,
1101
- dropped: 0,
1102
- suggestions: [],
1103
- error: null,
1104
- started_at: null,
1105
- finished_at: null,
1106
- ...(head.mode === "stage2"
1107
- ? { lens: job.lens, tier: job.tier, reason: job.reason, units: job.units }
1108
- : {}),
1109
- })),
1110
- problems: [],
1111
- matching: null,
1112
- };
1113
- // A run left `running` by a companion that is gone was interrupted.
1114
- if (file.current !== null) {
1115
- const prev = file.current;
1116
- file.history = [
1117
- this.summary(prev, prev.state === "running" ? "interrupted" : prev.state),
1118
- ...file.history,
1119
- ].slice(0, RUN_HISTORY);
1120
- }
1121
- file.current = run;
1122
- this.store(file);
1123
- try {
1124
- onAccepted?.(run);
1125
- } catch (err) {
1126
- const reason = `the scan was not launched: its acceptance checkpoint failed (${clean(err)})`;
1127
- run.problems.push(reason);
1128
- const at = this.ports.clock().toISOString();
1129
- for (const c of run.cases) {
1130
- c.status = "stopped";
1131
- c.error = reason;
1132
- c.finished_at = at;
1133
- }
1134
- this.settle(file, run, "partial");
1135
- throw err;
1136
- }
1137
-
1138
- const abort = new AbortController();
1139
- // Publish the handle before a case can synchronously request stop().
1140
- const done = Promise.resolve()
1141
- .then(() => this.drive(file, run, jobs, tree, settings, abort))
1142
- .catch((err: unknown) => {
1143
- // Belt: drive() records its own failures; this catches a bug in it.
1144
- run.problems.push(`the scan stopped on an internal error: ${clean(err)}`);
1145
- // Cases the failed drive never reached are over with the run.
1146
- const at = this.ports.clock().toISOString();
1147
- for (const c of run.cases) {
1148
- if (c.status !== "pending" && c.status !== "running") continue;
1149
- c.status = "stopped";
1150
- c.finished_at = at;
1151
- }
1152
- this.settle(file, run, "partial");
1153
- this.ports.log?.(`setup: scan ${run.id} failed: ${clean(err)}`);
1154
- })
1155
- .finally(() => {
1156
- if (this.active?.id === run.id) this.active = null;
1157
- });
1158
- this.active = { id: run.id, sha: run.sha, abort, done };
1159
- this.holdLease(run.id);
1160
- return run;
1161
- }
1162
-
1163
- /** Hold the driver lease for `run` while it is driven: renewed on the
1164
- * start's timer, given up when it ends. */
1165
- private holdLease(run: string): void {
1166
- const lease = this.ports.lease;
1167
- if (!lease) return;
1168
- lease.renew(run);
1169
- this.renewLease(run);
1170
- void this.active?.done.finally(() => {
1171
- if (this.active || this.starting) return;
1172
- this.stopRenewing();
1173
- if (!this.fenced.has(run)) lease.release();
1174
- });
1175
- }
1176
-
1177
- /** The latest run, the history and the ledger's totals. */
1178
- status(): ScanStatus {
1179
- const { file, error } = this.load();
1180
- const problems = error === null ? [] : [error];
1181
- const ledger = this.ledger.load();
1182
- if (ledger.error !== null) problems.push(ledger.error);
1183
- const run = file.current;
1184
- return {
1185
- ok: problems.length === 0,
1186
- problems,
1187
- run:
1188
- run === null
1189
- ? null
1190
- : {
1191
- ...run,
1192
- display:
1193
- run.state === "running" &&
1194
- this.active?.id !== run.id &&
1195
- this.ports.lease?.other()?.run !== run.id
1196
- ? "interrupted"
1197
- : run.state,
1198
- },
1199
- active: this.active !== null,
1200
- history: file.history,
1201
- ledger:
1202
- ledger.error !== null
1203
- ? null
1204
- : {
1205
- // Distinct files (and file × lens pairs): a file read at two
1206
- // contents, two depths or in ranges is still one file.
1207
- files: new Set(ledger.entries.filter((e) => e.lens === SCAN_PASS).map((e) => e.path))
1208
- .size,
1209
- deep_files: new Set(
1210
- ledger.entries
1211
- .filter((e) => e.lens !== SCAN_PASS)
1212
- .map((e) => `${e.lens}\0${e.path}`),
1213
- ).size,
1214
- cost_usd: ledger.entries.reduce((n, e) => n + (e.cost_usd ?? 0), 0),
1215
- },
1216
- };
1217
- }
1218
-
1219
- /** Stop the running scan: in-flight cases are aborted, pending ones never
1220
- * start; what finished stays in the ledger, so a later start resumes. */
1221
- stop(): { ok: true; id: string } | Refusal {
1222
- if (!this.active) return { ok: false, errors: ["no scan is running in this companion"] };
1223
- this.active.abort.abort();
1224
- return { ok: true, id: this.active.id };
1225
- }
1226
-
1227
- /** Resolves when no scan is running (tests; a clean shutdown). */
1228
- async idle(): Promise<void> {
1229
- await this.active?.done;
1230
- }
1231
-
1232
- /** Stage 1's stored deep-lens suggestions (stage 2's input). */
1233
- suggestions(): { suggestions: StoredSuggestion[]; error: string | null } {
1234
- try {
1235
- const text = this.ports.state.read(SUGGESTIONS_FILE);
1236
- if (text === null) return { suggestions: [], error: null };
1237
- const raw = JSON.parse(text) as { version?: unknown; suggestions?: unknown };
1238
- if (raw.version !== RUN_VERSION || !Array.isArray(raw.suggestions))
1239
- throw new Error("not a version-1 suggestions file");
1240
- return { suggestions: raw.suggestions as StoredSuggestion[], error: null };
1241
- } catch (err) {
1242
- return { suggestions: [], error: `${SUGGESTIONS_FILE} cannot be read: ${clean(err)}` };
1243
- }
1244
- }
1245
-
1246
- // -- stage 2 --
1247
-
1248
- /** The latest stored stage-2 proposal, none, or why it cannot be read. */
1249
- stage2Proposal(): { proposal: StoredStage2Proposal | null; error: string | null } {
1250
- try {
1251
- const text = this.ports.state.read(STAGE2_FILE);
1252
- return { proposal: text === null ? null : parseStage2File(text).proposal, error: null };
1253
- } catch (err) {
1254
- return { proposal: null, error: `${STAGE2_FILE} cannot be read: ${clean(err)}` };
1255
- }
1256
- }
1257
-
1258
- /** Plan the companion's rows at `deep` (true / false), or at `[scan] depth`
1259
- * when it is undefined; the settings returned carry that depth, so the
1260
- * build and the run are at it too. */
1261
- private async planStage2(
1262
- request: Stage2RowRequest[],
1263
- budgetUsd: number | null,
1264
- ref: string | undefined,
1265
- deep: boolean | undefined,
1266
- claim = false,
1267
- ): Promise<Stage2Planned> {
1268
- const pre = this.prerequisites(ref, claim);
1269
- if (!pre.ok) return pre;
1270
- const { sha, tree, entries } = pre;
1271
- const depth: ScanDepth =
1272
- deep === undefined ? pre.settings.scan.depth : deep ? "deep" : "standard";
1273
- const settings: ScanSettings = { ...pre.settings, scan: { ...pre.settings.scan, depth } };
1274
- const cfg = settings.scan;
1275
- const plan = planStage2({
1276
- rows: request,
1277
- cut: pre.cut,
1278
- files: tree.files,
1279
- linesOf: (p) => tree.lines(p),
1280
- whyUnreadable: (p) => tree.whyUnreadable?.(p) ?? null,
1281
- textOf: (p) => tree.read(p),
1282
- exclude: cfg.exclude,
1283
- budget: { maxFiles: cfg.case_max_files, maxLines: cfg.case_max_lines },
1284
- lenses: settings.lenses,
1285
- riskPasses: settings.riskPasses ?? [],
1286
- defaultTier: cfg.tier,
1287
- covered: (lens) => coveredBy(entries, lens, depth),
1288
- partial: (lens) => partialRanges(entries, lens, depth),
1289
- price: (lens, tier, effort) => priceStage2Row(entries, cfg, lens, tier, effort, depth),
1290
- budgetUsd,
1291
- ...(depth === "deep" ? { floor: cfg.deep_lenses } : {}),
1292
- });
1293
- if (!plan.ok) return { ok: false, errors: plan.errors };
1294
- const runnable = plan.rows.filter((r) => r.status === "run");
1295
- const key = stage2Key({
1296
- sha,
1297
- rows: runnable.map((r) => [
1298
- r.id,
1299
- r.lens,
1300
- r.tier,
1301
- r.effort,
1302
- [...r.files, ...r.tests].map((f) => `${f.path}@${f.blob}`),
1303
- ...(r.ranges ? [r.ranges.map((x) => `${x.start}-${x.end}`)] : []),
1304
- ]),
1305
- validator: cfg.validator,
1306
- cap: Math.round(plan.total_usd * 1e6),
1307
- ...(depth === "deep" ? { depth } : {}),
1308
- });
1309
- const proposal: StoredStage2Proposal = {
1310
- key,
1311
- sha,
1312
- at: this.ports.clock().toISOString(),
1313
- request,
1314
- budget_usd: budgetUsd,
1315
- rows: plan.rows,
1316
- cap_usd: plan.total_usd,
1317
- runnable: plan.runnable,
1318
- over_budget_usd: plan.over_budget_usd,
1319
- validator: cfg.validator,
1320
- depth,
1321
- floor: plan.floor,
1322
- };
1323
- return { ok: true, proposal, tree, settings, cut: pre.cut };
1324
- }
1325
-
1326
- /**
1327
- * Plan and STORE a stage-2 proposal from the companion's rows: checked,
1328
- * tests paired, priced from the ledger, fitted to `budget_usd` when given.
1329
- * `deep` (true / false) sets its depth, else `[scan] depth`; a deep one adds
1330
- * the floor after the companion's rows, which may then be none.
1331
- * Runs nothing. The stored proposal replaces the previous one — it is what
1332
- * the TUI shows and what a start's key must name.
1333
- */
1334
- async proposeStage2(args: {
1335
- rows?: unknown;
1336
- budget_usd?: unknown;
1337
- sha?: string;
1338
- deep?: unknown;
1339
- }): Promise<{ ok: true; proposal: Stage2Proposal } | Refusal> {
1340
- const rows = args.rows ?? [];
1341
- if (!Array.isArray(rows))
1342
- return { ok: false, errors: ["`rows` must be a list of { lens, files, tier?, reason }"] };
1343
- if (args.deep !== undefined && args.deep !== null && typeof args.deep !== "boolean")
1344
- return { ok: false, errors: ["`deep` must be true or false (or left out)"] };
1345
- const deep = typeof args.deep === "boolean" ? args.deep : undefined;
1346
- const request: Stage2RowRequest[] = [];
1347
- for (const [i, r] of rows.entries()) {
1348
- if (typeof r !== "object" || r === null || Array.isArray(r))
1349
- return { ok: false, errors: [`row-${i + 1}: not a { lens, files, tier?, reason } object`] };
1350
- const o = r as Record<string, unknown>;
1351
- request.push({
1352
- lens: typeof o.lens === "string" ? o.lens : "",
1353
- files: Array.isArray(o.files)
1354
- ? o.files.filter((f): f is string => typeof f === "string")
1355
- : typeof o.files === "string"
1356
- ? o.files.split(",")
1357
- : [],
1358
- ...(typeof o.tier === "string" && o.tier.trim() !== "" ? { tier: o.tier.trim() } : {}),
1359
- reason: typeof o.reason === "string" ? o.reason : "",
1360
- });
1361
- }
1362
- let budget: number | null = null;
1363
- if (args.budget_usd !== undefined && args.budget_usd !== null) {
1364
- if (
1365
- typeof args.budget_usd !== "number" ||
1366
- !Number.isFinite(args.budget_usd) ||
1367
- args.budget_usd <= 0
1368
- ) {
1369
- return {
1370
- ok: false,
1371
- errors: ["`budget_usd` must be a positive number of dollars (or left out)"],
1372
- };
1373
- }
1374
- budget = args.budget_usd;
1375
- }
1376
- const p = await this.planStage2(request, budget, args.sha, deep);
1377
- if (!p.ok) return p;
1378
- try {
1379
- this.ports.state.write(
1380
- STAGE2_FILE,
1381
- `${JSON.stringify({ version: STAGE2_VERSION, proposal: p.proposal }, null, 1)}\n`,
1382
- );
1383
- } catch (err) {
1384
- return { ok: false, errors: [`the proposal could not be stored: ${clean(err)}`] };
1385
- }
1386
- return { ok: true, proposal: p.proposal };
1387
- }
1388
-
1389
- /**
1390
- * Start the stored stage-2 proposal whose key is quoted — on the
1391
- * operator's word (the companion) or their click (the TUI, through
1392
- * `consumeStage2Request`). Re-planned first: a proposal that moved since it
1393
- * was shown is refused. Hard stop at its estimate — or, when `rows` keeps
1394
- * only some of its runnable rows (the TUI's plan pane), exactly
1395
- * those rows, stopped hard at their total.
1396
- */
1397
- async startStage2(
1398
- args: {
1399
- key?: unknown;
1400
- rows?: unknown;
1401
- },
1402
- onAccepted?: (run: ScanRun) => void,
1403
- ): Promise<{ ok: true; run: ScanRun } | Refusal> {
1404
- const busy = this.refuseBusy();
1405
- if (busy !== null) return busy;
1406
- if (typeof args.key !== "string" || args.key === "") {
1407
- return {
1408
- ok: false,
1409
- errors: [
1410
- "no proposal key — propose stage 2, show the operator the rows and the cost, then start with its key",
1411
- ],
1412
- };
1413
- }
1414
- let keep: string[] | undefined;
1415
- if (args.rows !== undefined && args.rows !== null) {
1416
- if (
1417
- !Array.isArray(args.rows) ||
1418
- !args.rows.every((r): r is string => typeof r === "string")
1419
- ) {
1420
- return {
1421
- ok: false,
1422
- errors: ["`rows` must be a list of row ids from the proposal (`row-<n>` / `floor-<n>`)"],
1423
- };
1424
- }
1425
- keep = args.rows;
1426
- }
1427
- const key = args.key;
1428
- return this.holdingStart(() => this.startStage2Planned(key, keep, onAccepted));
1429
- }
1430
-
1431
- private async startStage2Planned(
1432
- key: string,
1433
- keep: readonly string[] | undefined,
1434
- onAccepted?: (run: ScanRun) => void,
1435
- ): Promise<{ ok: true; run: ScanRun } | Refusal> {
1436
- const stored = this.stage2Proposal();
1437
- if (stored.error !== null) return { ok: false, errors: [stored.error] };
1438
- if (stored.proposal === null)
1439
- return { ok: false, errors: ["no stage 2 proposal — propose one first"] };
1440
- if (stored.proposal.key !== key) {
1441
- return {
1442
- ok: false,
1443
- errors: [
1444
- `${key} is not the latest stage 2 proposal (that is ${stored.proposal.key}) — show the operator the latest one`,
1445
- ],
1446
- };
1447
- }
1448
- // At the depth it was made at, whatever `[scan] depth` says now.
1449
- const p = await this.planStage2(
1450
- stored.proposal.request,
1451
- stored.proposal.budget_usd,
1452
- stored.proposal.sha,
1453
- proposalDepth(stored.proposal) === "deep",
1454
- true,
1455
- );
1456
- if (!p.ok) return p;
1457
- if (p.proposal.key !== key) {
1458
- return {
1459
- ok: false,
1460
- errors: [
1461
- `the plan changed since the proposal (key ${key}, now ${p.proposal.key}: a file, a price or the settings moved) — propose again and show the operator the new cost`,
1462
- ],
1463
- };
1464
- }
1465
- if (!p.proposal.rows.some((r) => r.status === "run")) {
1466
- return {
1467
- ok: false,
1468
- errors: ["nothing to run: every row is covered by the ledger or over the budget"],
1469
- };
1470
- }
1471
- const kept = keptRows(p.proposal.rows, keep);
1472
- if (!kept.ok) return kept;
1473
- const rows = kept.rows;
1474
- const cap = kept.cap_usd ?? p.proposal.cap_usd;
1475
- const { file, error } = this.load();
1476
- if (error !== null) return { ok: false, errors: [error] };
1477
- const built = this.ports.buildAgents(p.settings);
1478
- if (!built.ok)
1479
- return {
1480
- ok: false,
1481
- errors: built.errors.map((e) => `the scan agents could not be built: ${e}`),
1482
- };
1483
- const unitOf = unitOwner(
1484
- p.cut,
1485
- p.tree.files.map((f) => f.path),
1486
- );
1487
- // What stage 1 pointed each row's lens at in its files rides the case's
1488
- // prompt. An unreadable suggestions file is a problem on the run, never a
1489
- // refused start: the leads are help, not the plan.
1490
- const leadSource = this.suggestions();
1491
- const leadsOf = (row: Stage2Row) => stage2Leads(row, leadSource.suggestions);
1492
- // One case per row — or, for a row over a file too big for one case, one
1493
- // per line range (`<row>#<i>`), its estimate the range's share.
1494
- const jobs: CaseJob[] = rows.flatMap((row: Stage2Row) =>
1495
- stage2Cases(row).map((c) => ({
1496
- plan: {
1497
- unit: row.units.join("+"),
1498
- id: c.id,
1499
- part: c.part,
1500
- parts: c.parts,
1501
- files: c.files,
1502
- lines: c.lines,
1503
- estimate_usd: row.lines > 0 ? (row.estimate_usd * c.lines) / row.lines : row.estimate_usd,
1504
- },
1505
- lens: row.lens,
1506
- tier: row.tier,
1507
- effort: row.effort,
1508
- prompt: (ctx: PromptContext) => stage2CasePrompt(row, ctx, c.files, leadsOf(row)),
1509
- label: `stage 2, ${c.id} (${row.lens})`,
1510
- unitOf,
1511
- suggestions: false,
1512
- reason: row.reason,
1513
- units: row.units,
1514
- })),
1515
- );
1516
- const cfg = p.settings.scan;
1517
- const top = rows.reduce<PassTier>((t, r) => higherTier(t, r.tier), rows[0]!.tier);
1518
- const lost = this.leaseLost();
1519
- if (lost !== null) return lost;
1520
- const run = this.launch(
1521
- file,
1522
- {
1523
- mode: "stage2",
1524
- sha: p.proposal.sha,
1525
- plan_key: key,
1526
- cap_usd: cap,
1527
- estimate_usd: cap,
1528
- settings: {
1529
- tier: top,
1530
- effort: "per row",
1531
- validator: cfg.validator,
1532
- validate_tier: cfg.validator ? top : null,
1533
- ...(cfg.depth === "deep" ? { depth: cfg.depth } : {}),
1534
- },
1535
- },
1536
- jobs,
1537
- p.tree,
1538
- p.settings,
1539
- onAccepted,
1540
- );
1541
- if (leadSource.error !== null) {
1542
- run.problems.push(`stage-1 leads not carried into the cases: ${leadSource.error}`);
1543
- this.store(file);
1544
- }
1545
- return { ok: true, run };
1546
- }
1547
-
1548
- /**
1549
- * Answer the TUI's start request, if there is one not yet answered: start
1550
- * the proposal it names and write the outcome to `STAGE2_RESULT_FILE`.
1551
- * Resolves null when there is nothing to answer. The companion's scan belt
1552
- * polls this; one request is answered once.
1553
- */
1554
- async consumeStage2Request(): Promise<Stage2StartResult | null> {
1555
- if (this.consuming) return null;
1556
- this.consuming = true;
1557
- try {
1558
- let text: string | null;
1559
- try {
1560
- text = this.ports.state.read(STAGE2_START_FILE);
1561
- } catch (err) {
1562
- this.ports.log?.(`setup: ${STAGE2_START_FILE} cannot be read: ${clean(err)}`);
1563
- return null;
1564
- }
1565
- const req = text === null ? null : parseStartRequest(text);
1566
- if (req === null) return null;
1567
- let prev: string | null = null;
1568
- try {
1569
- prev = this.ports.state.read(STAGE2_RESULT_FILE);
1570
- } catch {
1571
- // Unreadable: answer again rather than never.
1572
- }
1573
- if (prev !== null && parseStartResult(prev)?.request === req.id) return null;
1574
- const acceptedFile = `scan/stage2-starts/${encodeURIComponent(req.id)}.json`;
1575
- let accepted: Stage2StartResult | null = null;
1576
- try {
1577
- const saved = this.ports.state.read(acceptedFile);
1578
- if (saved !== null) {
1579
- accepted = parseStartResult(saved);
1580
- if (
1581
- accepted === null ||
1582
- accepted.request !== req.id ||
1583
- accepted.key !== req.key ||
1584
- !accepted.ok
1585
- )
1586
- throw new Error("the accepted start record does not match this request");
1587
- }
1588
- } catch (err) {
1589
- this.ports.log?.(`setup: accepted stage 2 start cannot be read: ${clean(err)}`);
1590
- return null;
1591
- }
1592
- let result: Stage2StartResult;
1593
- if (accepted !== null) result = accepted;
1594
- else {
1595
- try {
1596
- const r = await this.startStage2(
1597
- {
1598
- key: req.key,
1599
- ...(req.rows !== undefined ? { rows: req.rows } : {}),
1600
- },
1601
- (run) => {
1602
- accepted = {
1603
- request: req.id,
1604
- key: req.key,
1605
- at: this.ports.clock().toISOString(),
1606
- ok: true,
1607
- run: run.id,
1608
- errors: [],
1609
- };
1610
- // A successful start is recoverable before the first paid case.
1611
- this.ports.state.write(acceptedFile, `${JSON.stringify(accepted)}\n`);
1612
- },
1613
- );
1614
- result = accepted ?? {
1615
- request: req.id,
1616
- key: req.key,
1617
- at: this.ports.clock().toISOString(),
1618
- ok: r.ok,
1619
- run: r.ok ? r.run.id : null,
1620
- errors: r.ok ? [] : r.errors,
1621
- };
1622
- } catch (err) {
1623
- result = {
1624
- request: req.id,
1625
- key: req.key,
1626
- at: this.ports.clock().toISOString(),
1627
- ok: false,
1628
- run: null,
1629
- errors: [`the start was not launched: ${clean(err)}`],
1630
- };
1631
- }
1632
- }
1633
- try {
1634
- this.ports.state.write(STAGE2_RESULT_FILE, `${JSON.stringify(result)}\n`);
1635
- } catch (err) {
1636
- this.ports.log?.(`setup: the stage 2 start result could not be written: ${clean(err)}`);
1637
- }
1638
- this.ports.log?.(
1639
- `setup: the TUI asked to start stage 2 (${req.key}): ${result.ok ? `started ${result.run}` : result.errors.join("; ")}`,
1640
- );
1641
- return result;
1642
- } finally {
1643
- this.consuming = false;
1644
- }
1645
- }
1646
-
1647
- /**
1648
- * Answer the TUI's stop request (scanreport.ts), if there is one
1649
- * not yet answered: stop the run it names when that is the run in flight
1650
- * here, and write the outcome to `SCAN_STOP_RESULT_FILE`. A request naming
1651
- * another run — or arriving when nothing runs in this companion (a run
1652
- * recorded as running that nobody is driving is already interrupted) — is
1653
- * refused, never aimed at whatever runs now. Answered once.
1654
- */
1655
- consumeStopRequest(): ScanStopResult | null {
1656
- let text: string | null;
1657
- try {
1658
- text = this.ports.state.read(SCAN_STOP_FILE);
1659
- } catch (err) {
1660
- this.ports.log?.(`setup: ${SCAN_STOP_FILE} cannot be read: ${clean(err)}`);
1661
- return null;
1662
- }
1663
- const req = text === null ? null : parseStopRequest(text);
1664
- if (req === null) return null;
1665
- let prev: string | null = null;
1666
- try {
1667
- prev = this.ports.state.read(SCAN_STOP_RESULT_FILE);
1668
- } catch {
1669
- // Unreadable: answer again rather than never.
1670
- }
1671
- if (prev !== null && parseStopResult(prev)?.request === req.id) return null;
1672
- // Another process drives that run: the stop is its holder's to answer.
1673
- if (this.active?.id !== req.run && this.ports.lease?.other()?.run === req.run) return null;
1674
- const inFlight = this.active?.id ?? null;
1675
- const errors: string[] = [];
1676
- if (!this.active)
1677
- errors.push(
1678
- `no scan is running in this companion — ${req.run} is not in flight, so there is nothing to stop (the ledger resumes it when started again)`,
1679
- );
1680
- else if (this.active.id !== req.run)
1681
- errors.push(
1682
- `the scan in flight is ${this.active.id}, not ${req.run} — look at the report again`,
1683
- );
1684
- else this.stop();
1685
- const result: ScanStopResult = {
1686
- request: req.id,
1687
- run: req.run,
1688
- at: this.ports.clock().toISOString(),
1689
- ok: errors.length === 0,
1690
- errors,
1691
- in_flight: inFlight,
1692
- };
1693
- try {
1694
- this.ports.state.write(SCAN_STOP_RESULT_FILE, `${JSON.stringify(result)}\n`);
1695
- } catch (err) {
1696
- this.ports.log?.(`setup: the scan stop result could not be written: ${clean(err)}`);
1697
- }
1698
- this.ports.log?.(
1699
- `setup: the TUI asked to stop ${req.run}: ${result.ok ? "stopped" : errors.join("; ")}`,
1700
- );
1701
- return result;
1702
- }
1703
-
1704
- // -- the background run --
1705
-
1706
- private async drive(
1707
- file: RunFile,
1708
- run: ScanRun,
1709
- jobs: readonly CaseJob[],
1710
- tree: ScanTree,
1711
- settings: ScanSettings,
1712
- abort: AbortController,
1713
- ): Promise<void> {
1714
- const signal = abort.signal;
1715
- let capHit = false;
1716
- let reserved = 0;
1717
- let next = 0;
1718
- // What each in-flight case has spent SO FAR, as its runner reports it.
1719
- // The hard stop reads these live: a case never runs on past the cap just
1720
- // because it has not finished yet.
1721
- const live = new Map<string, number>();
1722
- const liveTotal = (): number => [...live.values()].reduce((n, v) => n + v, 0);
1723
- const onCost = (caseId: string, spentSoFar: number): void => {
1724
- live.set(caseId, spentSoFar);
1725
- if (
1726
- !signal.aborted &&
1727
- (run.cap_usd > EPS
1728
- ? run.spent_usd + liveTotal() >= run.cap_usd - EPS
1729
- : run.spent_usd + liveTotal() > EPS)
1730
- ) {
1731
- capHit = true;
1732
- abort.abort();
1733
- }
1734
- };
1735
- // May a case with this estimate start? Within the cap counting what is in
1736
- // flight; a case with no estimate (calibration) only while the cap is not
1737
- // yet reached. A zero cap is a free run (the runner prices at zero): it admits cases only
1738
- // while nothing has been spent, and stops on the first real spending.
1739
- let unknownSpend = false;
1740
- const within = (extra: number): boolean =>
1741
- !unknownSpend &&
1742
- ((run.cap_usd <= EPS && run.spent_usd + reserved <= EPS) ||
1743
- (run.cap_usd > EPS &&
1744
- (extra > 0
1745
- ? run.spent_usd + reserved + extra <= run.cap_usd + EPS
1746
- : run.spent_usd + reserved < run.cap_usd - EPS)));
1747
- const lane = async (): Promise<void> => {
1748
- while (next < jobs.length && !signal.aborted) {
1749
- const job = jobs[next]!;
1750
- const plan = job.plan;
1751
- const est = plan.estimate_usd ?? 0;
1752
- if (!within(est)) {
1753
- capHit = true;
1754
- return;
1755
- }
1756
- next++;
1757
- reserved += est;
1758
- live.set(plan.id, 0);
1759
- const spent = await this.runCase(file, run, job, tree, settings, signal, (v) =>
1760
- onCost(plan.id, v),
1761
- );
1762
- live.delete(plan.id);
1763
- reserved -= est;
1764
- run.spent_usd += spent;
1765
- // A case with no estimate (calibration) that ended with no total price
1766
- // (a live report is only a floor) spent an UNKNOWN amount — never read
1767
- // as free. Under
1768
- // a cap, nothing more starts: the hard stop cannot be held blind.
1769
- const done = run.cases.find((x) => x.id === plan.id);
1770
- if (
1771
- run.cap_usd > EPS &&
1772
- !signal.aborted &&
1773
- plan.estimate_usd === null &&
1774
- done?.started_at != null &&
1775
- done.cost_usd === null &&
1776
- !unknownSpend
1777
- ) {
1778
- unknownSpend = true;
1779
- capHit = true;
1780
- abort.abort();
1781
- run.problems.push(
1782
- `case ${plan.id} ended with no total price — what it spent is unknown, so no further case starts (the cap cannot be held without prices)`,
1783
- );
1784
- }
1785
- this.store(file);
1786
- // The hard stop: once spending reaches the cap, nothing in flight may
1787
- // add to it.
1788
- if (
1789
- run.cap_usd > EPS &&
1790
- run.spent_usd >= run.cap_usd - EPS &&
1791
- !signal.aborted &&
1792
- run.cases.some((c) => c.status === "running")
1793
- ) {
1794
- capHit = true;
1795
- abort.abort();
1796
- }
1797
- }
1798
- };
1799
- const lanes = Math.max(1, Math.min(settings.scan.concurrency, jobs.length));
1800
- await drainLanes(lanes, lane, abort);
1801
-
1802
- // Never started: `skipped` when the cap kept it out, `stopped` when a stop did.
1803
- for (const c of run.cases)
1804
- if (c.status === "pending") c.status = capHit ? "skipped" : "stopped";
1805
- // A run that lost its lease settles nothing: the findings are the new
1806
- // holder's to reconcile.
1807
- if (!this.fenced.has(run.id)) {
1808
- this.reconcile(run, tree);
1809
- await this.closeFixedTickets(run);
1810
- }
1811
- const allDone = run.cases.every((c) => c.status === "done");
1812
- this.settle(
1813
- file,
1814
- run,
1815
- allDone ? "complete" : capHit ? "capped" : signal.aborted ? "stopped" : "partial",
1816
- );
1817
- }
1818
-
1819
- /** One case, start to finish. Resolves with what it cost (its estimate
1820
- * when the runner could not price it); a runner failure is an outcome, so
1821
- * it rejects only when the run record cannot be written. */
1822
- private async runCase(
1823
- file: RunFile,
1824
- run: ScanRun,
1825
- job: CaseJob,
1826
- tree: ScanTree,
1827
- settings: ScanSettings,
1828
- signal: AbortSignal,
1829
- onCost: (spentSoFar: number) => void = () => {},
1830
- ): Promise<number> {
1831
- const plan = job.plan;
1832
- const c = run.cases.find((x) => x.id === plan.id)!;
1833
- if (signal.aborted) {
1834
- c.status = "stopped";
1835
- return 0;
1836
- }
1837
- c.status = "running";
1838
- c.started_at = this.ports.clock().toISOString();
1839
- this.store(file);
1840
- const ctx = { root: tree.root, sha: run.sha, caseId: `${run.id}/${plan.id}` };
1841
-
1842
- // What the case has spent: every call's cost, a FAILED call's included
1843
- // (it spent money too). Null once any call could not say.
1844
- let cost: number | null = 0;
1845
- // The most the runner has reported so far — the floor of what an
1846
- // unpriced case is counted at.
1847
- let seen = 0;
1848
- const addCost = (o: CaseOutcome): void => {
1849
- const spent = o.cost_usd === undefined ? null : o.cost_usd;
1850
- cost = cost === null || spent === null ? null : cost + spent;
1851
- };
1852
- const report =
1853
- (base: number) =>
1854
- (v: number): void => {
1855
- seen = Math.max(seen, base + v);
1856
- onCost(seen);
1857
- };
1858
- const outcome = await this.runOne(
1859
- {
1860
- id: ctx.caseId,
1861
- unit: plan.unit,
1862
- agent: `${job.lens}--${job.tier}`,
1863
- files: c.files,
1864
- prompt: job.prompt(ctx),
1865
- },
1866
- signal,
1867
- report(0),
1868
- );
1869
- addCost(outcome);
1870
- if (outcome.transcript !== undefined) c.transcript = outcome.transcript;
1871
- if (outcome.ok && outcome.reads !== undefined) c.reads = outcome.reads.length;
1872
- const finish = (status: ScanCaseStatus, error: string | null): number => {
1873
- c.status = status;
1874
- c.error = error;
1875
- c.cost_usd = cost;
1876
- c.finished_at = this.ports.clock().toISOString();
1877
- if (cost === null) run.unpriced_cases++;
1878
- // Unpriced: counted at its estimate (or what the runner saw it spend,
1879
- // if more) — never as free.
1880
- return cost ?? Math.max(seen, plan.estimate_usd ?? 0);
1881
- };
1882
- if (!outcome.ok) return finish(signal.aborted ? "stopped" : "failed", outcome.reason);
1883
- // A lens that opened none of its files did not read the case, whatever it
1884
- // answered: never coverage (no ledger row, so it is read again), never a
1885
- // price sample (the rate comes from ledger rows), and its findings are
1886
- // not stored — they were not drawn from the code. Said on the run, the
1887
- // way a failed validator is, so a cheap empty lens cannot pass as a
1888
- // quiet one.
1889
- const unread = unreadFocus(
1890
- outcome.reads,
1891
- plan.files.map((f) => f.path),
1892
- tree.root,
1893
- );
1894
- if (unread !== null) {
1895
- run.problems.push(
1896
- `case ${plan.id}: ${job.lens} — ${unread}; not a reading (not coverage, not a price sample) — it will be read again`,
1897
- );
1898
- return finish("failed", unread);
1899
- }
1900
- // Opened some of its files and not others: the others are not read.
1901
- const focusPaths = [...new Set(plan.files.map((f) => f.path))];
1902
- const opened = openedFocus(outcome.reads, focusPaths, tree.root);
1903
- const unopened = opened === null ? [] : focusPaths.filter((p) => !opened.has(p));
1904
- if (unopened.length > 0) {
1905
- c.unopened = unopened;
1906
- run.problems.push(
1907
- `case ${plan.id}: ${job.lens} opened ${focusPaths.length - unopened.length} of its ${focusPaths.length} focus files — not opened: ${unopened.join(", ")}; those are not coverage and will be read again`,
1908
- );
1909
- }
1910
-
1911
- const inTree = new Set(tree.files.map((f) => f.path));
1912
- // A file read in line ranges: this case reports only inside its range —
1913
- // the neighbouring ranges are other cases' (scan.ts cutRanges).
1914
- const rangeOf = new Map(plan.files.filter((f) => f.range).map((f) => [f.path, f.range!]));
1915
- const outside = (cand: ScanCandidate): boolean => {
1916
- const r = rangeOf.get(cand.path);
1917
- return r !== undefined && (cand.end < r.start || cand.start > r.end);
1918
- };
1919
- const deep = settings.scan.depth === "deep";
1920
- const answer = parseScanAnswer(outcome.output, {
1921
- root: tree.root,
1922
- ...(deep && job.suggestions ? { maxSuggestions: SCAN_DEEP_MAX_SUGGESTIONS } : {}),
1923
- });
1924
- // Only findings or an explicit NONE is a reading. Prose with neither (a
1925
- // tool error told in words, a broken grammar) is not "nothing found",
1926
- // and must never enter the ledger as clean coverage — that reading is
1927
- // what settles earlier findings fixed.
1928
- if (answer.candidates.length === 0 && !answer.none) {
1929
- return finish("failed", "the lens answered with no finding and no NONE line");
1930
- }
1931
- let dropped = answer.dropped + answer.suggestions_dropped;
1932
- // A candidate — a lens's or a skeptic's survivor — stands on the code at
1933
- // its anchor: null when it falls outside the case's range or its lines
1934
- // are not in the tree (dropped).
1935
- const anchor = (cand: ScanCandidate): { code: string; quoted: boolean } | null => {
1936
- if (outside(cand)) return null;
1937
- // A focus file the lens never opened was not read: nothing it reports
1938
- // there was drawn from the code, whatever else the lens opened.
1939
- if (opened !== null && focusPaths.includes(cand.path) && !opened.has(cand.path)) return null;
1940
- const text = inTree.has(cand.path) ? tree.read(cand.path) : null;
1941
- const lines = text === null ? null : anchoredLines(text, cand.start, cand.end);
1942
- if (lines === null) return null;
1943
- return { code: normalizeCode(lines), quoted: evidenceQuotesAnchor(cand.evidence, lines) };
1944
- };
1945
- const anchored: Array<{ cand: ScanCandidate; code: string; quoted: boolean }> = [];
1946
- for (const cand of answer.candidates) {
1947
- const at = anchor(cand);
1948
- if (at === null) dropped++;
1949
- else anchored.push({ cand, ...at });
1950
- }
1951
-
1952
- if (answer.candidates.length > 0 && anchored.length === 0) {
1953
- c.dropped = dropped;
1954
- return finish("failed", "none of the reported findings could be anchored to this case");
1955
- }
1956
-
1957
- // The validator, when this scan runs one, is the skeptic: at the tier of
1958
- // the lens it judges, it sorts every
1959
- // candidate into a validity tier — and kills none. A survivor is
1960
- // `confirmed` (a traced scenario) or `likely`; a DOUBTED line, a KILLED
1961
- // line and a candidate its answer never mentions are all kept `doubtful`,
1962
- // so no kill path of the frozen validator can make a finding vanish. A
1963
- // validator that failed judges nothing: its findings are kept with no
1964
- // tier (unknown), never a guessed one.
1965
- let validated = false;
1966
- let validatorOpened: Set<string> | null = null;
1967
- let kept: KeptFinding[] = anchored.map((a) => ({
1968
- ...a,
1969
- validity: null,
1970
- scenario: null,
1971
- doubt: null,
1972
- }));
1973
- if (settings.scan.validator && anchored.length > 0) {
1974
- const v = await this.runOne(
1975
- {
1976
- id: `${ctx.caseId}/validate`,
1977
- unit: plan.unit,
1978
- agent: validatorAgent(job.tier),
1979
- files: c.files,
1980
- prompt: scanValidatorPrompt(
1981
- plan,
1982
- anchored.map((a) => a.cand),
1983
- ctx,
1984
- job.lens,
1985
- job.label,
1986
- ),
1987
- },
1988
- signal,
1989
- report(cost ?? seen),
1990
- );
1991
- addCost(v);
1992
- if (v.transcript !== undefined) c.validator_transcript = v.transcript;
1993
- if (!v.ok && signal.aborted) return finish("stopped", v.reason);
1994
- // A skeptic that opened none of the files its candidates name judged
1995
- // nothing from the code: its tiers are not taken — unknown, not guessed.
1996
- const candPaths = [...new Set(anchored.map((a) => a.cand.path))];
1997
- const skepticOpened = v.ok ? openedFocus(v.reads, candPaths, tree.root) : null;
1998
- validatorOpened = skepticOpened;
1999
- const skepticUnread = skepticOpened !== null && skepticOpened.size === 0;
2000
- const read = v.ok && !skepticUnread ? parseValidatorAnswer(v.output, tree.root) : null;
2001
- if (read === null || !read.answered) {
2002
- const why = !v.ok
2003
- ? v.reason
2004
- : skepticUnread
2005
- ? `it opened none of the ${candPaths.length} file(s) its candidates name`
2006
- : "it answered with no survivor, NONE, DOUBTED or KILLED";
2007
- run.problems.push(
2008
- `case ${plan.id}: the validator failed (${why}) — its findings are kept unvalidated`,
2009
- );
2010
- } else {
2011
- validated = true;
2012
- const judged: KeptFinding[] = [];
2013
- // The candidate each survivor stands for — ONE per survivor: the same
2014
- // file and overlapping lines, the same kind first. One survivor must
2015
- // never stand for two candidates, or the one it did not mean (a
2016
- // duplicate on the same lines, a neighbour of another kind) would be
2017
- // neither upheld nor unmentioned — silently dropped, a kill.
2018
- const upheld = new Set<ScanCandidate>();
2019
- const cands = anchored.map((a) => a.cand);
2020
- for (const s of read.survivors) {
2021
- const at = anchor(s);
2022
- if (at === null) {
2023
- dropped++;
2024
- continue;
2025
- }
2026
- const meant = survivorSource(s, cands, upheld);
2027
- if (meant) upheld.add(meant);
2028
- judged.push({
2029
- cand: s,
2030
- ...at,
2031
- validity: s.validity,
2032
- scenario: s.scenario,
2033
- doubt: null,
2034
- });
2035
- }
2036
- // A doubt — or, in a scan, a kill — marks the candidate it names.
2037
- const doubted = new Set<ScanCandidate>();
2038
- for (const d of [...read.doubted, ...read.killed]) {
2039
- const names = (a: (typeof anchored)[number]): boolean =>
2040
- a.cand.path === d.path &&
2041
- (d.line === null || (d.line >= a.cand.start && d.line <= a.cand.end)) &&
2042
- !doubted.has(a.cand);
2043
- // A candidate no survivor stands for first: two candidates on the
2044
- // same lines (a duplicate the skeptic doubts beside the one it
2045
- // upholds) must not have the doubt land on the upheld one — its
2046
- // fingerprint would then turn the confirmed survivor doubtful and
2047
- // leave the duplicate standing as "upheld".
2048
- const hit = anchored.find((a) => names(a) && !upheld.has(a.cand)) ?? anchored.find(names);
2049
- if (!hit) continue;
2050
- doubted.add(hit.cand);
2051
- judged.push({
2052
- ...hit,
2053
- validity: "doubtful",
2054
- scenario: null,
2055
- doubt: { word: d.word, reason: d.reason, class: d.class },
2056
- });
2057
- }
2058
- // Never mentioned — neither upheld nor doubted: kept, doubtful.
2059
- for (const a of anchored) {
2060
- if (upheld.has(a.cand) || doubted.has(a.cand)) continue;
2061
- judged.push({
2062
- ...a,
2063
- validity: "doubtful",
2064
- scenario: null,
2065
- doubt: { word: "not-upheld", reason: UNMENTIONED_REASON, class: null },
2066
- });
2067
- }
2068
- kept =
2069
- skepticOpened === null
2070
- ? judged
2071
- : [
2072
- ...judged.filter((a) => skepticOpened.has(a.cand.path)),
2073
- ...anchored
2074
- .filter((a) => !skepticOpened.has(a.cand.path))
2075
- .map((a) => ({ ...a, validity: null, scenario: null, doubt: null })),
2076
- ];
2077
- if (skepticOpened !== null && skepticOpened.size < candPaths.length)
2078
- run.problems.push(
2079
- `case ${plan.id}: the validator did not open every candidate file — unread findings are kept unvalidated`,
2080
- );
2081
- }
2082
- }
2083
-
2084
- const blobOf = new Map(tree.files.map((f) => [f.path, f.blob]));
2085
- const all: ReportedFinding[] = kept.map(
2086
- ({ cand, code, quoted, validity, scenario, doubt }) => ({
2087
- fingerprint: findingFingerprint({ path: cand.path, lens: job.lens, kind: cand.kind, code }),
2088
- unit: job.unitOf(cand.path),
2089
- path: cand.path,
2090
- blob: blobOf.get(cand.path) ?? null,
2091
- start: cand.start,
2092
- end: cand.end,
2093
- lens: job.lens,
2094
- kind: cand.kind,
2095
- severity: cand.severity,
2096
- confidence: cand.confidence,
2097
- reachability: cand.reachability,
2098
- capped_from: cand.capped_from,
2099
- anchor_quoted: quoted,
2100
- claim: cand.claim,
2101
- evidence: cand.evidence,
2102
- fix: cand.fix,
2103
- detail: cand.detail,
2104
- validated: validated && (validatorOpened === null || validatorOpened.has(cand.path)),
2105
- doubt,
2106
- validity,
2107
- scenario,
2108
- }),
2109
- );
2110
- // One finding per fingerprint: the validator may list a candidate as a
2111
- // survivor AND doubt it — the doubt wins, and it is counted once.
2112
- const byFp = new Map<string, ReportedFinding>();
2113
- for (const r of all) {
2114
- const had = byFp.get(r.fingerprint);
2115
- if (!had || (had.doubt === null && r.doubt !== null)) byFp.set(r.fingerprint, r);
2116
- }
2117
- const reported = [...byFp.values()];
2118
- // Ownership is checked here, not left to the renewal timer: a driver
2119
- // resumed after its lease was taken over publishes nothing.
2120
- if (!this.fenced.has(run.id)) this.renewTick();
2121
- if (this.fenced.has(run.id)) return finish("stopped", "the scan driver lease was lost");
2122
- const at = this.ports.clock().toISOString();
2123
- try {
2124
- this.findings.report(reported, { run: run.id, sha: run.sha, at });
2125
- } catch (err) {
2126
- // Nothing recorded, so nothing enters the ledger: the case is read again.
2127
- return finish("failed", `its findings could not be stored: ${clean(err)}`);
2128
- }
2129
- c.findings = reported.length;
2130
- c.doubted = reported.filter((r) => r.doubt !== null).length;
2131
- c.confirmed = reported.filter((r) => r.validity === "confirmed").length;
2132
- c.likely = reported.filter((r) => r.validity === "likely").length;
2133
-
2134
- if (job.suggestions) {
2135
- const suggestions = answer.suggestions
2136
- .map((s) => ({ ...s, files: s.files.filter((p) => inTree.has(p)) }))
2137
- .filter((s) => s.files.length > 0);
2138
- dropped += answer.suggestions.length - suggestions.length;
2139
- c.suggestions = suggestions;
2140
- if (!this.storeSuggestions(run, plan.unit, suggestions, at)) {
2141
- c.dropped = dropped;
2142
- return finish("failed", "its deep-lens suggestions could not be stored; not coverage");
2143
- }
2144
- }
2145
- c.dropped = dropped;
2146
-
2147
- // Last: the ledger. Only now is the case's reading on record.
2148
- // Each row keeps its share of the WHOLE case's cost, so leaving out a file
2149
- // the lens never opened leaves the rate per line what the case measured.
2150
- const total = plan.files.reduce((n, f) => n + Math.max(f.lines, 1), 0);
2151
- const notRead = new Set(unopened);
2152
- const rows: LedgerEntry[] = plan.files
2153
- .filter((f) => !notRead.has(f.path))
2154
- .map((f) => ({
2155
- path: f.path,
2156
- blob: f.blob,
2157
- lens: job.lens,
2158
- ...(f.range ? { range: f.range } : {}),
2159
- tier: job.tier,
2160
- effort: job.effort,
2161
- validator: settings.scan.validator,
2162
- ...(deep ? { depth: "deep" as const } : {}),
2163
- sha: run.sha,
2164
- lines: f.lines,
2165
- cost_usd: cost === null ? null : (cost * Math.max(f.lines, 1)) / total,
2166
- run: run.id,
2167
- case: plan.id,
2168
- at,
2169
- }));
2170
- try {
2171
- this.ledger.record(rows);
2172
- } catch (err) {
2173
- run.problems.push(
2174
- `case ${plan.id}: the ledger refused its rows (${clean(err)}) — it will be read again`,
2175
- );
2176
- return finish("failed", "not recorded in the coverage ledger");
2177
- }
2178
- return finish("done", null);
2179
- }
2180
-
2181
- private async runOne(
2182
- spec: Parameters<CaseRunner["run"]>[0],
2183
- signal: AbortSignal,
2184
- onCost: (spentSoFar: number) => void,
2185
- ): Promise<CaseOutcome> {
2186
- try {
2187
- return await this.ports.cases.run(spec, signal, onCost);
2188
- } catch (err) {
2189
- return { ok: false, reason: `the case runner threw: ${clean(err)}`, cost_usd: null };
2190
- }
2191
- }
2192
-
2193
- private storeSuggestions(
2194
- run: ScanRun,
2195
- unit: string,
2196
- found: readonly ScanSuggestion[],
2197
- at: string,
2198
- ): boolean {
2199
- if (found.length === 0) return true;
2200
- const { suggestions, error } = this.suggestions();
2201
- if (error !== null) {
2202
- run.problems.push(`deep-lens suggestions not stored: ${error}`);
2203
- return false;
2204
- }
2205
- const key = (s: { unit: string; lens: string; files: string[] }): string =>
2206
- `${s.unit}\0${s.lens}\0${[...s.files].sort().join(",")}`;
2207
- const byKey = new Map(suggestions.map((s) => [key(s), s]));
2208
- for (const s of found) {
2209
- const row: StoredSuggestion = { ...s, unit, sha: run.sha, run: run.id, at };
2210
- byKey.set(key(row), row);
2211
- }
2212
- try {
2213
- this.ports.state.write(
2214
- SUGGESTIONS_FILE,
2215
- `${JSON.stringify({ version: RUN_VERSION, suggestions: [...byKey.values()] }, null, 1)}\n`,
2216
- );
2217
- } catch (err) {
2218
- run.problems.push(`deep-lens suggestions not stored: ${clean(err)}`);
2219
- return false;
2220
- }
2221
- return true;
2222
- }
2223
-
2224
- /** The run's end, whatever ended it: what re-read files no longer report
2225
- * is fixed, or handed to the companion (findings.ts reconcile). Only
2226
- * FINISHED cases wrote ledger rows, so only their reading counts. */
2227
- private reconcile(run: ScanRun, tree: ScanTree): void {
2228
- const { entries, error } = this.ledger.load();
2229
- try {
2230
- if (error !== null) throw new Error(error);
2231
- run.matching = this.findings.reconcile({
2232
- rows: entries,
2233
- tree: new Set(tree.files.map((f) => f.path)),
2234
- blobs: new Map(tree.files.map((f) => [f.path, f.blob])),
2235
- run: { run: run.id, sha: run.sha, at: this.ports.clock().toISOString() },
2236
- });
2237
- } catch (err) {
2238
- run.problems.push(
2239
- `the findings were not reconciled (${clean(err)}) — the next run's end does it`,
2240
- );
2241
- }
2242
- }
2243
-
2244
- /** A promoted finding the reconcile fixed has its ticket closed with the
2245
- * run's sha; a refused close is a problem on the run, retried next time. */
2246
- private async closeFixedTickets(run: ScanRun): Promise<void> {
2247
- if (!this.ports.closeFixedTickets) return;
2248
- try {
2249
- const r = await this.ports.closeFixedTickets();
2250
- run.tickets_closed = r.closed.length;
2251
- for (const p of r.problems) run.problems.push(`ticket not closed: ${p}`);
2252
- } catch (err) {
2253
- run.problems.push(
2254
- `the fixed findings' tickets were not closed (${clean(err)}) — the next run's end retries`,
2255
- );
2256
- }
2257
- }
2258
-
2259
- private settle(file: RunFile, run: ScanRun, state: ScanRunState): void {
2260
- run.state = state;
2261
- run.finished_at = this.ports.clock().toISOString();
2262
- try {
2263
- this.store(file);
2264
- } catch (err) {
2265
- this.ports.log?.(`setup: could not record scan ${run.id}: ${clean(err)}`);
2266
- }
2267
- }
2268
- }
1
+ // Moved to the plugin (packages/pi-plugin/src/scan/scanrun.ts), the scan engine's
2
+ // home: this name stays for the app's importers.
3
+ export * from "@quickchatai/pi-quickreview/src/scan/scanrun.js";