@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
@@ -0,0 +1,405 @@
1
+ // "Tune to this repo": the home runs Setup for the repository's profile when
2
+ // the person chooses it (CONTEXT.md: Setup is the survey, the review history
3
+ // and the interview, and its result is the repo profile). Nothing waits on
4
+ // it: a review before it, the first one included, runs on the default
5
+ // profile, and the review button never asks for it.
6
+ //
7
+ // Setup is a conversation, so it runs with the agent the person is already
8
+ // talking to: the home hands the chat the plugin's own Setup brief
9
+ // (`profileSetupBrief`, the one `/qcr-setup profile` sends), over a checkout
10
+ // of the repository on this machine. The agent surveys the checkout with its
11
+ // read tools, reads the review history when it can, interviews the person in
12
+ // the chat, and writes the profile, `.qcr/` at the checkout's root, through
13
+ // `home_tune_write`: the one write the home lends it, which writes only a
14
+ // profile file, only while a tune runs, and only on the person's yes.
15
+ //
16
+ // A profile is several files (profile.toml and one addendum per house rule),
17
+ // so the tune stays open through all of them: it ends when the agent marks a
18
+ // write as the profile's last file (`last`), or with the session. Until the
19
+ // agent writes through the tool, the home also watches the profile (the
20
+ // plugin's own detect: `.qcr/profile.toml` in the checkout) and says when one
21
+ // is written another way. A review reads the profile from the base commit,
22
+ // so it applies once the person commits it there.
23
+ //
24
+ // The checkout is untrusted: its contributors choose what is in it, links
25
+ // included. A write creates its temporary file exclusively, never through a
26
+ // link, and refuses a `.qcr/` or `addenda/` that is a link or resolves
27
+ // outside the checkout, so a yes writes only the file it names.
28
+ //
29
+ // tune a key (t, alt+t) and the `home_tune` tool
30
+ // write the `home_tune_write` tool: the agent's draft, the person's yes
31
+ import { randomBytes } from "node:crypto";
32
+ import {
33
+ closeSync,
34
+ constants,
35
+ existsSync,
36
+ lstatSync,
37
+ mkdirSync,
38
+ openSync,
39
+ readFileSync,
40
+ realpathSync,
41
+ renameSync,
42
+ statSync,
43
+ unlinkSync,
44
+ writeSync,
45
+ type Stats,
46
+ } from "node:fs";
47
+ import { basename, dirname, join, relative, sep } from "node:path";
48
+ import { profileSetupBrief } from "@quickchatai/pi-quickreview/src/plugin/setup.js";
49
+ import { REPO_PROFILE_DIR } from "@quickchatai/pi-quickreview/src/profileoverrides.js";
50
+ import { parse } from "smol-toml";
51
+ import type { ProcessRunner } from "../onboard/engine.js";
52
+ import { known, unknown, type Known, type Outcome } from "./ports.js";
53
+ import { parseRepoInput } from "./setup.js";
54
+ import type { Ask } from "./switches.js";
55
+
56
+ /** Where the repository's Setup stands. */
57
+ export interface TuneStatus {
58
+ /** It has a profile of its own: Setup has run. */
59
+ tuned: boolean;
60
+ /** As the person reads it. */
61
+ detail: string;
62
+ /** When its profile was last written, ms since the epoch; null without one. */
63
+ at: number | null;
64
+ }
65
+
66
+ /** Setup, ready to hand the agent. */
67
+ export interface TuneStart {
68
+ /** The checkout Setup reads and writes the profile into. */
69
+ checkout: string;
70
+ /** The Setup brief. */
71
+ brief: string;
72
+ }
73
+
74
+ export interface TunePort {
75
+ status(repo: string): Promise<Known<TuneStatus>>;
76
+ /** Find the checkout Setup runs on, and its brief. */
77
+ start(repo: string): Promise<Outcome<TuneStart>>;
78
+ /** Write one file of the profile, `file` relative to `.qcr/`. */
79
+ write(checkout: string, file: string, text: string): Promise<Outcome<{ path: string }>>;
80
+ }
81
+
82
+ /** How often the home looks for the profile while a tune runs. */
83
+ export const TUNE_POLL_MS = 5000;
84
+
85
+ /** The files of a repo profile Setup may write: profile.toml, and one
86
+ * addendum per house rule. Null: not one of them. */
87
+ export function profileFile(file: string): string | null {
88
+ const f = file.trim().replace(/^\.qcr\//, "");
89
+ if (f === "profile.toml") return f;
90
+ return /^addenda\/[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(f) && !f.includes("..") ? f : null;
91
+ }
92
+
93
+ /** What the home adds to the plugin's brief: the write it lends, and what
94
+ * the reviewer-only home's agent cannot do. */
95
+ export function homeBriefNote(checkout: string): string {
96
+ return [
97
+ "",
98
+ `In this home, the checkout is ${checkout}: read it with your read tools. Write each file of the profile with the \`home_tune_write\` tool (\`file\` is relative to ${REPO_PROFILE_DIR}/: \`profile.toml\` or \`addenda/<nn>-<rule-id>.md\`); it shows me the file and writes it only on my yes. Pass \`last: true\` with the profile's final file: the tune ends with it. If you cannot reach \`gh\` from here, skip the review history and say so.`,
99
+ ].join("\n");
100
+ }
101
+
102
+ export const WRITE_NO = "No, don't write it";
103
+ export const WRITE_YES = "Yes, write it";
104
+
105
+ const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
106
+
107
+ export interface HomeTuneOptions {
108
+ port?: TunePort;
109
+ /** The repository the home reviews, or null. */
110
+ repo: () => string | null;
111
+ say: (text: string, kind?: "info" | "ok" | "warn") => void;
112
+ /** Run `fn` once after `ms`; the returned function cancels it. */
113
+ after: (ms: number, fn: () => void) => () => void;
114
+ onChange?: () => void;
115
+ }
116
+
117
+ interface Tuning {
118
+ repo: string;
119
+ checkout: string;
120
+ before: TuneStatus | null;
121
+ /** The files written through the tool; once there is one, only `last` ends it. */
122
+ wrote: string[];
123
+ }
124
+
125
+ /** The tune: one at a time, from the person's choice to the profile's last file. */
126
+ export class HomeTune {
127
+ private tuning: Tuning | null = null;
128
+ private cancel: (() => void) | null = null;
129
+ private disposed = false;
130
+
131
+ constructor(private readonly o: HomeTuneOptions) {}
132
+
133
+ /** Is Setup under way? */
134
+ running(): boolean {
135
+ return this.tuning !== null;
136
+ }
137
+
138
+ dispose(): void {
139
+ this.disposed = true;
140
+ this.cancel?.();
141
+ this.cancel = null;
142
+ }
143
+
144
+ /** Run Setup for the repository: hand `tellAgent` the brief, then watch for
145
+ * the profile. Without an agent to tell (outside a session), nothing starts. */
146
+ async tune(tellAgent: ((text: string) => void) | undefined): Promise<string> {
147
+ const port = this.o.port;
148
+ if (!port) return "Setup can't be run from this home";
149
+ const repo = this.o.repo();
150
+ if (repo === null) return "no repository is chosen, so there is nothing to tune to";
151
+ if (this.tuning)
152
+ return `Setup of ${this.tuning.repo} is under way with your agent in the chat (${this.tuning.checkout}); it ends when your agent writes the profile's last file`;
153
+ if (!tellAgent) return "Setup is a conversation with the chat's agent, and there is none here";
154
+ let started: Outcome<TuneStart>;
155
+ try {
156
+ started = await port.start(repo);
157
+ } catch (err) {
158
+ started = { ok: false, why: message(err) };
159
+ }
160
+ if (!started.ok) {
161
+ this.o.say(`Can't tune to ${repo} yet: ${started.why}`, "warn");
162
+ return `Setup did not start: ${started.why}`;
163
+ }
164
+ const before = await this.read(repo);
165
+ this.tuning = {
166
+ repo,
167
+ checkout: started.checkout,
168
+ before: before.kind === "known" ? before.value : null,
169
+ wrote: [],
170
+ };
171
+ tellAgent(`${started.brief}\n${homeBriefNote(started.checkout)}`);
172
+ this.o.say(
173
+ `Tuning to ${repo}: your agent surveys ${started.checkout}, reads its review history and asks you about it, here in the chat. Reviews run on the default profile until it is done.`,
174
+ );
175
+ this.o.onChange?.();
176
+ this.watch();
177
+ return `Setup of ${repo} started with the chat's agent, over ${started.checkout}; the home says when the profile is written`;
178
+ }
179
+
180
+ /** The agent's draft of one profile file, written on the person's yes.
181
+ * `last`: it is the profile's final file, and the tune ends with it. */
182
+ async write(file: string, text: string, ask: Ask | undefined, last = false): Promise<string> {
183
+ const t = this.tuning;
184
+ const port = this.o.port;
185
+ if (!t || !port) return "no tune is running: start one first (home_tune)";
186
+ const f = profileFile(file);
187
+ if (f === null)
188
+ return `${JSON.stringify(file)} is not a profile file: write profile.toml or addenda/<nn>-<rule-id>.md`;
189
+ if (!ask) return "the person answers whether to write it, and no one can answer here";
190
+ const where = join(t.checkout, REPO_PROFILE_DIR, f);
191
+ let yes: string | undefined;
192
+ try {
193
+ const lines = text.split("\n").filter((l) => l.trim() !== "").length;
194
+ yes = await ask.select(`Write ${where} (${lines} lines, as your agent showed it)?`, [
195
+ WRITE_NO,
196
+ WRITE_YES,
197
+ ]);
198
+ } catch {
199
+ yes = undefined;
200
+ }
201
+ if (yes !== WRITE_YES) return `the person said no: ${REPO_PROFILE_DIR}/${f} was not written`;
202
+ let r: Outcome<{ path: string }>;
203
+ try {
204
+ r = await port.write(t.checkout, f, text);
205
+ } catch (err) {
206
+ r = { ok: false, why: message(err) };
207
+ }
208
+ if (!r.ok) return `${REPO_PROFILE_DIR}/${f} was not written: ${r.why}`;
209
+ if (this.tuning !== t) return `wrote ${r.path}`;
210
+ t.wrote.push(f);
211
+ if (!last)
212
+ return `wrote ${r.path}; the tune stays open: write the profile's other files, and pass last: true with the final one`;
213
+ const n = t.wrote.length === 1 ? "1 file" : `${t.wrote.length} files`;
214
+ this.finish(t, `${n} of ${REPO_PROFILE_DIR}/ in ${t.checkout}`);
215
+ return `wrote ${r.path}; Setup is done`;
216
+ }
217
+
218
+ private async read(repo: string): Promise<Known<TuneStatus>> {
219
+ try {
220
+ return await this.o.port!.status(repo);
221
+ } catch (err) {
222
+ return unknown(message(err));
223
+ }
224
+ }
225
+
226
+ private watch(): void {
227
+ if (this.disposed || !this.tuning) return;
228
+ this.cancel = this.o.after(TUNE_POLL_MS, () => {
229
+ this.cancel = null;
230
+ void this.check().then((done) => {
231
+ if (!done) this.watch();
232
+ });
233
+ });
234
+ }
235
+
236
+ /** Has the profile been written another way since the tune started? Says
237
+ * so once. Once the agent writes through the tool, only `last` ends it: a
238
+ * profile.toml is the first file of several, not the end. */
239
+ private async check(): Promise<boolean> {
240
+ const t = this.tuning;
241
+ if (!t || t.wrote.length > 0) return true;
242
+ const s = await this.read(t.repo);
243
+ if (this.tuning !== t || t.wrote.length > 0) return true;
244
+ if (s.kind === "unknown" || !s.value.tuned) return false;
245
+ const b = t.before;
246
+ const fresh = !b?.tuned || (s.value.at ?? 0) > (b.at ?? 0);
247
+ if (!fresh) return false;
248
+ this.finish(t, s.value.detail);
249
+ return true;
250
+ }
251
+
252
+ private finish(t: Tuning, detail: string): void {
253
+ this.tuning = null;
254
+ this.cancel?.();
255
+ this.cancel = null;
256
+ this.o.say(
257
+ `Tuned to ${t.repo}: ${detail}. Reviews use it once it is committed to the base branch.`,
258
+ "ok",
259
+ );
260
+ this.o.onChange?.();
261
+ }
262
+ }
263
+
264
+ // ---- the real port: a checkout on this machine ---------------------------
265
+
266
+ type Env = Readonly<Record<string, string | undefined>>;
267
+
268
+ export interface CheckoutTuneOptions {
269
+ env: Env;
270
+ /** The asset root: the shipped profiles and lenses the brief points at. */
271
+ root: () => string;
272
+ /** The app config, for `[companion] reviewed_repo_dir`. */
273
+ configPath: () => string;
274
+ runner: ProcessRunner;
275
+ read?: (p: string) => string;
276
+ }
277
+
278
+ /** The checkout of `repo` on this machine: QCR_REVIEWED_REPO, the config's
279
+ * `[companion] reviewed_repo_dir`, then the directory the home was opened
280
+ * from, each only when git says its origin is `repo`. */
281
+ export async function findCheckout(
282
+ repo: string,
283
+ o: Pick<CheckoutTuneOptions, "env" | "configPath" | "runner" | "read">,
284
+ ): Promise<string | null> {
285
+ const candidates: string[] = [];
286
+ const add = (v: unknown): void => {
287
+ if (typeof v === "string" && v.trim() !== "") candidates.push(v.trim());
288
+ };
289
+ add(o.env["QCR_REVIEWED_REPO"]);
290
+ try {
291
+ const read = o.read ?? ((p: string) => readFileSync(p, "utf8"));
292
+ const cfg = parse(read(o.configPath())) as Record<string, unknown>;
293
+ const companion = cfg["companion"];
294
+ if (typeof companion === "object" && companion !== null)
295
+ add((companion as Record<string, unknown>)["reviewed_repo_dir"]);
296
+ } catch {
297
+ // no config to read: the other places still count
298
+ }
299
+ add(o.env["PWD"]);
300
+ for (const dir of candidates) {
301
+ const top = await o.runner.run("git", ["-C", dir, "rev-parse", "--show-toplevel"]);
302
+ if (top.code !== 0) continue;
303
+ const root = top.stdout.trim();
304
+ const origin = await o.runner.run("git", ["-C", root, "remote", "get-url", "origin"]);
305
+ const named = origin.code === 0 ? parseRepoInput(origin.stdout.trim()) : null;
306
+ if (named !== null && named.toLowerCase() === repo.toLowerCase()) return root;
307
+ }
308
+ return null;
309
+ }
310
+
311
+ /** The tune over a checkout of the repository on this machine. */
312
+ export function checkoutTune(o: CheckoutTuneOptions): TunePort {
313
+ const profileAt = (top: string): string => join(top, REPO_PROFILE_DIR, "profile.toml");
314
+ const missing = (repo: string): string =>
315
+ `Setup reads a checkout of ${repo}, and there is none here: open the home from inside one (cd into it, then run qcr), or set QCR_REVIEWED_REPO to it`;
316
+ return {
317
+ status: async (repo) => {
318
+ const top = await findCheckout(repo, o);
319
+ if (top === null) return unknown(`no checkout of ${repo} on this machine`);
320
+ const file = profileAt(top);
321
+ if (!existsSync(file))
322
+ return known({ tuned: false, detail: "reviews run on the default profile", at: null });
323
+ let at: number | null = null;
324
+ try {
325
+ at = statSync(file).mtimeMs;
326
+ } catch {
327
+ // it is there: when it was written is all that is not known
328
+ }
329
+ return known({ tuned: true, detail: `${REPO_PROFILE_DIR}/profile.toml in ${top}`, at });
330
+ },
331
+ start: async (repo) => {
332
+ const top = await findCheckout(repo, o);
333
+ if (top === null) return { ok: false, why: missing(repo) };
334
+ return { ok: true, checkout: top, brief: profileSetupBrief({ top, root: o.root() }) };
335
+ },
336
+ write: async (checkout, file, text) => {
337
+ const f = profileFile(file);
338
+ if (f === null) return { ok: false, why: `${file} is not a profile file` };
339
+ const path = join(checkout, REPO_PROFILE_DIR, f);
340
+ try {
341
+ const body = text.endsWith("\n") ? text : `${text}\n`;
342
+ writeInCheckout(checkout, `${REPO_PROFILE_DIR}/${f}`, body);
343
+ } catch (err) {
344
+ return { ok: false, why: message(err) };
345
+ }
346
+ return { ok: true, path };
347
+ },
348
+ };
349
+ }
350
+
351
+ /** Write `rel` (slash-separated, below `checkout`) into an untrusted checkout.
352
+ * Every directory on the way is a real directory, made here when missing,
353
+ * never a link, and resolves inside the checkout; the file itself, when there,
354
+ * is a regular file. The text goes to a fresh temporary file, created
355
+ * exclusively and never through a link, then renamed over the target, so no
356
+ * name a contributor planted is opened for writing. */
357
+ export function writeInCheckout(checkout: string, rel: string, text: string): void {
358
+ const top = realpathSync(checkout);
359
+ const parts = rel.split("/");
360
+ const name = parts.pop()!;
361
+ let dir = top;
362
+ for (const part of parts) {
363
+ dir = join(dir, part);
364
+ let st: Stats;
365
+ try {
366
+ st = lstatSync(dir);
367
+ } catch {
368
+ mkdirSync(dir);
369
+ st = lstatSync(dir);
370
+ }
371
+ if (st.isSymbolicLink()) throw new Error(`${relative(top, dir)} is a link, not a directory`);
372
+ if (!st.isDirectory()) throw new Error(`${relative(top, dir)} is not a directory`);
373
+ }
374
+ const real = realpathSync(dir);
375
+ if (real !== top && !real.startsWith(top + sep))
376
+ throw new Error(`${relative(top, dir)} resolves outside the checkout`);
377
+ const path = join(real, name);
378
+ try {
379
+ const st = lstatSync(path);
380
+ if (!st.isFile()) throw new Error(`${rel} is there and is not a plain file`);
381
+ } catch (err) {
382
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
383
+ }
384
+ const tmp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
385
+ const fd = openSync(
386
+ tmp,
387
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
388
+ 0o644,
389
+ );
390
+ try {
391
+ try {
392
+ writeSync(fd, text);
393
+ } finally {
394
+ closeSync(fd);
395
+ }
396
+ renameSync(tmp, path);
397
+ } catch (err) {
398
+ try {
399
+ unlinkSync(tmp);
400
+ } catch {
401
+ // already renamed, or never there: nothing to clean up
402
+ }
403
+ throw err;
404
+ }
405
+ }
@@ -0,0 +1,8 @@
1
+ // The home's words for an amount: one money and one plural, so the panel, the
2
+ // model's notices and the review message say a price or a count alike.
3
+
4
+ /** "$1.33". */
5
+ export const money = (n: number): string => `$${n.toFixed(2)}`;
6
+
7
+ /** "1 finding", "3 findings". */
8
+ export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
package/src/homes.ts CHANGED
@@ -104,6 +104,23 @@ export function operatorStateDir(
104
104
  return kind === "global" || kind === "local" ? stateHome(env) : join(root, "state");
105
105
  }
106
106
 
107
+ /**
108
+ * The compose project the home's switches and the admin TUI share, with the
109
+ * setup's state under it (`<root>/state`, `<root>/.env`): `QCR_ROOT` when
110
+ * set; a checkout or the image is its own; an app installed by a package
111
+ * manager gets one in its state home, never inside the package — for the
112
+ * same reasons as `operatorStateDir`.
113
+ */
114
+ export function stackRoot(
115
+ env: Readonly<Record<string, string | undefined>>,
116
+ pkgRoot: string,
117
+ ): string {
118
+ const explicit = (env["QCR_ROOT"] ?? "").trim();
119
+ if (explicit !== "") return explicit;
120
+ const kind = detectInstall(pkgRoot, env).kind;
121
+ return kind === "global" || kind === "local" ? join(stateHome(env), "stack") : pkgRoot;
122
+ }
123
+
107
124
  export function locateConfig(opts: LocateOptions = {}): LocatedConfig {
108
125
  return core.locateConfig(optEnv(opts));
109
126
  }
package/src/install.ts CHANGED
@@ -190,7 +190,7 @@ export function migrateReport(outcome: MigrateOutcome): { code: number; lines: s
190
190
  return {
191
191
  code: 0,
192
192
  lines: [
193
- `no config.toml in the checkout or at ${outcome.to} — nothing to move (\`qcr onboard\` writes a new one there)`,
193
+ `no config.toml in the checkout or at ${outcome.to} — nothing to move (a bare \`qcr\` writes one there when you choose a repository)`,
194
194
  ],
195
195
  };
196
196
  case "override":
@@ -43,6 +43,14 @@ export interface WorkerSpec {
43
43
  * in memory, never written back), and where models.json and the built lens
44
44
  * agents are read from. */
45
45
  agent_dir: string;
46
+ /** Where the login is borrowed from instead, when it is not in `agent_dir`:
47
+ * the single box's shared login (src/loginrefresh/supervisor.ts
48
+ * `loginDirOf`). Absent: `agent_dir`. */
49
+ login_dir?: string;
50
+ /** The providers the review's models name (the config's, plugin/login.ts
51
+ * `reviewProviders`): the login's admission checks only theirs. Omitted:
52
+ * every login in it. */
53
+ providers?: string[];
46
54
  /** Where the PR's main JSONL lives (`state/pi-sessions/pr-<n>`). The worker
47
55
  * resumes the newest one under it, or starts a session there at birth. */
48
56
  session_dir: string;
@@ -169,6 +177,13 @@ export function parseWorkerSpec(raw: unknown): WorkerSpec {
169
177
  spec.cap_usd = cap;
170
178
  }
171
179
  if (raw["lens_dir"] !== undefined) spec.lens_dir = abs(raw, "lens_dir");
180
+ if (raw["login_dir"] !== undefined) spec.login_dir = abs(raw, "login_dir");
181
+ if (raw["providers"] !== undefined) {
182
+ const p = raw["providers"];
183
+ if (!Array.isArray(p) || p.some((x) => typeof x !== "string" || x === ""))
184
+ return bad("`providers` must be a list of provider names");
185
+ spec.providers = p as string[];
186
+ }
172
187
  if (raw["compact"] !== undefined) {
173
188
  if (typeof raw["compact"] !== "boolean") return bad("`compact` must be a boolean");
174
189
  spec.compact = raw["compact"];
@@ -11,8 +11,14 @@
11
11
  // its ledger — no review work, no posting, no verdict file. A second stale
12
12
  // detection for the same round never nudges again: it logs
13
13
  // `ledger_stale_unresolved`, the grade that wakes the overseer (the session
14
- // ignored its one nudge). The once-mark is persisted BEFORE the inject, so
15
- // even a crashed inject can never turn into a nudge loop.
14
+ // ignored its one nudge), once per nudge key; later checks at the same key
15
+ // are a quiet `ledger_stale_repeat` event. The once-mark is persisted BEFORE
16
+ // the inject, so even a crashed inject can never turn into a nudge loop.
17
+ //
18
+ // A recheck uphold (round-less) updates the ledger only "if the delta settled
19
+ // or re-opened a finding" (prompts/round-recheck.md), so on that path a header
20
+ // naming the head the PR was last reviewed at, or a head a recheck upheld
21
+ // since, is fresh too.
16
22
  import { readFileSync } from "node:fs";
17
23
  import type { StateStore } from "./state.js";
18
24
  import { wakeOrHold } from "./wakes.js";
@@ -36,15 +42,32 @@ export function classifyLedger(
36
42
  content: string | null,
37
43
  pr: number,
38
44
  headSha: string,
45
+ alsoFresh: readonly string[] = [],
39
46
  ): LedgerFreshness {
40
47
  if (content === null) return "missing";
41
48
  const match = LEDGER_HEADER_RE.exec(content);
42
49
  if (!match) return "unparseable";
43
50
  if (Number(match[1]) !== pr) return "wrong_pr";
44
51
  const written = (match[2] as string).toLowerCase();
45
- const head = headSha.toLowerCase();
46
- const fresh = written === head || (written.length >= MIN_SHA_PREFIX && head.startsWith(written));
47
- return fresh ? "fresh" : "stale_sha";
52
+ const names = (sha: string): boolean => {
53
+ const head = sha.toLowerCase();
54
+ return written === head || (written.length >= MIN_SHA_PREFIX && head.startsWith(written));
55
+ };
56
+ return names(headSha) || alsoFresh.some(names) ? "fresh" : "stale_sha";
57
+ }
58
+
59
+ /** The heads a recheck uphold may find in the header besides its own: the head
60
+ * of the PR's last verdicted round and every head a recheck upheld since. */
61
+ export function upheldLedgerHeads(store: StateStore, pr: number): string[] {
62
+ const reviewed = store
63
+ .roundsFor(pr)
64
+ .filter((r) => r.verdict !== null)
65
+ .sort((a, b) => b.round_no - a.round_no)[0];
66
+ const since = reviewed ? (reviewed.finished_at ?? reviewed.started_at) : null;
67
+ return [
68
+ ...(reviewed?.head_sha ? [reviewed.head_sha] : []),
69
+ ...store.recheckUpheldHeads(pr, since),
70
+ ];
48
71
  }
49
72
 
50
73
  /** The re-nudge wake. Explicitly bookkeeping-only: the rewrite arrives
@@ -99,7 +122,12 @@ export async function enforceLedgerFreshness(
99
122
  } catch {
100
123
  content = null;
101
124
  }
102
- const freshness = classifyLedger(content, pr, headSha);
125
+ const freshness = classifyLedger(
126
+ content,
127
+ pr,
128
+ headSha,
129
+ roundNo === null ? upheldLedgerHeads(store, pr) : [],
130
+ );
103
131
  if (freshness === "fresh") return "fresh";
104
132
 
105
133
  const detail = `ledger ${freshness} at ${path}: expected header "LEDGER PR #${pr} @ ${headSha}"`;
@@ -140,9 +168,17 @@ export async function enforceLedgerFreshness(
140
168
  if (store.ledgerNudgeSent(pr, key)) {
141
169
  // Still stale AFTER the re-nudge: the session ignored its one nudge —
142
170
  // this grade WAKES the overseer (the severity map in src/anomalies.ts), first-detection
143
- // "ledger_stale" stays quiet (the nudge itself is the response).
144
- store.logAnomaly("ledger_stale_unresolved", pr, roundNo, detail);
145
- store.logEvent("ledger_stale_repeat", pr, { round_no: roundNo, head_sha: headSha, freshness });
171
+ // "ledger_stale" stays quiet (the nudge itself is the response). Raised
172
+ // once per key: a later check at the same key has no new nudge to give,
173
+ // so it is the quiet repeat event alone.
174
+ if (!store.ledgerRepeatSeen(pr, key))
175
+ store.logAnomaly("ledger_stale_unresolved", pr, roundNo, detail);
176
+ store.logEvent("ledger_stale_repeat", pr, {
177
+ key,
178
+ round_no: roundNo,
179
+ head_sha: headSha,
180
+ freshness,
181
+ });
146
182
  return "already_nudged";
147
183
  }
148
184
  store.logAnomaly("ledger_stale", pr, roundNo, detail);
@@ -17,7 +17,10 @@ import {
17
17
  progressLogPath,
18
18
  } from "@quickchatai/pi-quickreview/src/round-progress.js";
19
19
  import { readLiveEvents } from "@quickchatai/pi-quickreview/src/live/events.js";
20
- import { RoundProgressProjection, type ProjectionIdentity } from "./projection.js";
20
+ import {
21
+ RoundProgressProjection,
22
+ type ProjectionIdentity,
23
+ } from "@quickchatai/pi-quickreview/src/live/projection.js";
21
24
 
22
25
  /** `<root>/state/round-progress/pr-<n>.json`, where the supervisor reads it. */
23
26
  export function roundProgressPath(root: string, pr: number): string {
package/src/livecli.ts CHANGED
@@ -15,7 +15,12 @@ import {
15
15
  readLiveEvents,
16
16
  type LiveEvent,
17
17
  } from "@quickchatai/pi-quickreview/src/live/events.js";
18
- import { latestLiveRound, lensLogIds, listLiveRounds, type LiveRoundRef } from "./live/read.js";
18
+ import {
19
+ latestLiveRound,
20
+ lensLogIds,
21
+ listLiveRounds,
22
+ type LiveRoundRef,
23
+ } from "@quickchatai/pi-quickreview/src/live/read.js";
19
24
 
20
25
  export const LIVE_USAGE =
21
26
  "usage: qcr live [RUN_ID | DIR] [--lens ID] [--round N] [--follow] [--json]";