elanous 0.2.27 → 0.2.28

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 (285) hide show
  1. package/apps/pwa/out/404/index.html +1 -1
  2. package/apps/pwa/out/404.html +1 -1
  3. package/apps/pwa/out/_next/static/chunks/{1454-a9c338f5bb556f42.js → 1454-29d1d773d5996a49.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/{599-a914cc0468f56143.js → 599-56f2c0a5cc4a7295.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/app/approvals/page-5ab1e47978577c5e.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-07110da72c45e68f.js → page-24453f6a302816b4.js} +1 -1
  7. package/apps/pwa/out/_next/static/chunks/app/board/{page-d2d76d4a7eae91bd.js → page-6c131d30bb1cf871.js} +1 -1
  8. package/apps/pwa/out/_next/static/chunks/app/botlab/page-fa46d4588e8442c2.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/bots/{page-e5a9302c105725a3.js → page-dc0772b11861af51.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/app/ceo/page-66ad46ed0279feb9.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/chat/page-5159dc8752c6cd29.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/consult/page-c5a2478861c08b87.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/app/control/page-f722a9dec084e08b.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/app/decisions/{page-6a16a48d1aa6e91f.js → page-256ffd365d32e100.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/design-check/page-e0de78ae7b99f01c.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/editor/page-cfbdc44b708d9801.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/exec/{page-39f82acf619cca23.js → page-4c741eb760f2f77c.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/field/{page-7c2c8f81b185f9c0.js → page-e9ba98f5f1440a21.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/inside/{page-ae7aff07e32676bb.js → page-dafc901bb1b72b5f.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/intake/{page-482c60c1bc540d0a.js → page-2f6f303fa114ec24.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/{layout-7cb996c1aa08b6f5.js → layout-1634081c5272006c.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/live/{page-623e08fe4477d606.js → page-ced0bcfc6542b3fc.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/live-run/{page-db460cd8aecb8490.js → page-bb1f05cd51c38944.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/loops/{page-ff27902d0b11ef4f.js → page-646b50f63b6fe732.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/app/market/page-db069b48a59c8c00.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-1917ea87ea71ab6b.js → page-f736324afa2117c1.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/morning/{page-25a302273b054fd7.js → page-6f131b51193a36f9.js} +1 -1
  28. package/apps/pwa/out/_next/static/chunks/app/{not-found-670510eda8b9443f.js → not-found-435f5e2faf02bc01.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/observatory/page-497e0eedf20e7890.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-a8d28fdf995e16ea.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/ops/release/{page-4bb7556e2e97a750.js → page-8966452228753c5e.js} +1 -1
  32. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fd5d18656cb432b6.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/app/outputs/page-5663779cb1a37609.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/app/{page-4eb96fc7241c2456.js → page-66a67eb19ebd976b.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/reflection/page-3b492aa78cf3c4a6.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-0d64ff212dc55d03.js → page-c71267e7681da982.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-7f096248993fc2d4.js → page-a21d1e1797172692.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-e47fc72049149a38.js → page-cb249e81ed339318.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/settings/{page-f7d808e31e005895.js → page-781cfc5114e097da.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-5c2b24b5496dad73.js → page-478157214db6a023.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/{page-76cdb531668b5191.js → page-86141f12cf090b7f.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/app/share/page-7256c73bb1c36751.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/showroom/page-5c738ca7adef3de5.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/tasks/page-0138c8091ec17224.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/term/page-cc2f24f16d85e735.js +1 -0
  46. package/apps/pwa/out/_next/static/chunks/app/today/{page-dab163b338e3b966.js → page-979d318a534e92da.js} +1 -1
  47. package/apps/pwa/out/_next/static/chunks/app/trace/page-182d394f7e6e096b.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/vault/{page-0260ffc7eda50db1.js → page-21050928a697ed7a.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-44b9d807005a9030.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-99c4e6c7345a9156.js → page-601fb77340e63c9a.js} +1 -1
  51. package/apps/pwa/out/_next/static/chunks/app/workspace/page-75d3fb5d47af0edf.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-11bf9e5725a72c22.js +1 -0
  53. package/apps/pwa/out/approvals/index.html +1 -1
  54. package/apps/pwa/out/approvals/index.txt +13 -13
  55. package/apps/pwa/out/autopilot/index.html +1 -1
  56. package/apps/pwa/out/autopilot/index.txt +13 -13
  57. package/apps/pwa/out/board/index.html +1 -1
  58. package/apps/pwa/out/board/index.txt +13 -13
  59. package/apps/pwa/out/botlab/index.html +1 -1
  60. package/apps/pwa/out/botlab/index.txt +13 -13
  61. package/apps/pwa/out/bots/index.html +1 -1
  62. package/apps/pwa/out/bots/index.txt +13 -13
  63. package/apps/pwa/out/ceo/index.html +1 -1
  64. package/apps/pwa/out/ceo/index.txt +13 -13
  65. package/apps/pwa/out/chat/index.html +1 -1
  66. package/apps/pwa/out/chat/index.txt +13 -13
  67. package/apps/pwa/out/consult/index.html +1 -1
  68. package/apps/pwa/out/consult/index.txt +13 -13
  69. package/apps/pwa/out/control/index.html +1 -1
  70. package/apps/pwa/out/control/index.txt +13 -13
  71. package/apps/pwa/out/decisions/index.html +1 -1
  72. package/apps/pwa/out/decisions/index.txt +13 -13
  73. package/apps/pwa/out/design-check/index.html +1 -1
  74. package/apps/pwa/out/design-check/index.txt +13 -13
  75. package/apps/pwa/out/editor/index.html +1 -1
  76. package/apps/pwa/out/editor/index.txt +13 -13
  77. package/apps/pwa/out/exec/index.html +1 -1
  78. package/apps/pwa/out/exec/index.txt +13 -13
  79. package/apps/pwa/out/field/index.html +1 -1
  80. package/apps/pwa/out/field/index.txt +13 -13
  81. package/apps/pwa/out/healthz +1 -1
  82. package/apps/pwa/out/index.html +1 -1
  83. package/apps/pwa/out/index.txt +13 -13
  84. package/apps/pwa/out/inside/index.html +1 -1
  85. package/apps/pwa/out/inside/index.txt +13 -13
  86. package/apps/pwa/out/intake/index.html +1 -1
  87. package/apps/pwa/out/intake/index.txt +13 -13
  88. package/apps/pwa/out/live/index.html +1 -1
  89. package/apps/pwa/out/live/index.txt +13 -13
  90. package/apps/pwa/out/live-run/index.html +1 -1
  91. package/apps/pwa/out/live-run/index.txt +13 -13
  92. package/apps/pwa/out/loops/index.html +1 -1
  93. package/apps/pwa/out/loops/index.txt +13 -13
  94. package/apps/pwa/out/market/index.html +1 -1
  95. package/apps/pwa/out/market/index.txt +13 -13
  96. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  97. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  98. package/apps/pwa/out/morning/index.html +1 -1
  99. package/apps/pwa/out/morning/index.txt +13 -13
  100. package/apps/pwa/out/observatory/index.html +1 -1
  101. package/apps/pwa/out/observatory/index.txt +13 -13
  102. package/apps/pwa/out/ops/checklist/index.html +1 -1
  103. package/apps/pwa/out/ops/checklist/index.txt +13 -13
  104. package/apps/pwa/out/ops/release/index.html +1 -1
  105. package/apps/pwa/out/ops/release/index.txt +13 -13
  106. package/apps/pwa/out/ops/seats/index.html +1 -1
  107. package/apps/pwa/out/ops/seats/index.txt +13 -13
  108. package/apps/pwa/out/outputs/index.html +1 -1
  109. package/apps/pwa/out/outputs/index.txt +13 -13
  110. package/apps/pwa/out/reflection/index.html +1 -1
  111. package/apps/pwa/out/reflection/index.txt +14 -14
  112. package/apps/pwa/out/scheduler/index.html +1 -1
  113. package/apps/pwa/out/scheduler/index.txt +13 -13
  114. package/apps/pwa/out/sessions/index.html +1 -1
  115. package/apps/pwa/out/sessions/index.txt +13 -13
  116. package/apps/pwa/out/settings/devices/index.html +1 -1
  117. package/apps/pwa/out/settings/devices/index.txt +13 -13
  118. package/apps/pwa/out/settings/index.html +1 -1
  119. package/apps/pwa/out/settings/index.txt +13 -13
  120. package/apps/pwa/out/setup/done/index.html +1 -1
  121. package/apps/pwa/out/setup/done/index.txt +13 -13
  122. package/apps/pwa/out/setup/index.html +1 -1
  123. package/apps/pwa/out/setup/index.txt +13 -13
  124. package/apps/pwa/out/share/index.html +1 -1
  125. package/apps/pwa/out/share/index.txt +13 -13
  126. package/apps/pwa/out/showroom/index.html +1 -1
  127. package/apps/pwa/out/showroom/index.txt +13 -13
  128. package/apps/pwa/out/tasks/index.html +1 -1
  129. package/apps/pwa/out/tasks/index.txt +13 -13
  130. package/apps/pwa/out/term/index.html +1 -1
  131. package/apps/pwa/out/term/index.txt +13 -13
  132. package/apps/pwa/out/today/index.html +1 -1
  133. package/apps/pwa/out/today/index.txt +13 -13
  134. package/apps/pwa/out/trace/index.html +1 -1
  135. package/apps/pwa/out/trace/index.txt +13 -13
  136. package/apps/pwa/out/vault/index.html +1 -1
  137. package/apps/pwa/out/vault/index.txt +13 -13
  138. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  139. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  140. package/apps/pwa/out/workflows/index.html +1 -1
  141. package/apps/pwa/out/workflows/index.txt +13 -13
  142. package/apps/pwa/out/workspace/index.html +1 -1
  143. package/apps/pwa/out/workspace/index.txt +13 -13
  144. package/apps/pwa/out/worktrees/index.html +1 -1
  145. package/apps/pwa/out/worktrees/index.txt +13 -13
  146. package/graphs/implement-loop.yaml +38 -1
  147. package/graphs/steward/recipes.yaml +6 -0
  148. package/graphs/steward/release-day.yaml +23 -0
  149. package/graphs/steward/steward.yaml +9 -1
  150. package/graphs/work/code-pr.yaml +23 -0
  151. package/package.json +1 -1
  152. package/scripts/browser-observe-cycle.ts +76 -2
  153. package/scripts/ci-typecheck-changed.ts +50 -17
  154. package/scripts/detect-test-interference.ts +31 -1
  155. package/scripts/release-loop/docs-node.ts +51 -22
  156. package/scripts/release-loop/gate-node.ts +37 -4
  157. package/scripts/release-loop/node-verdict.ts +3 -1
  158. package/scripts/release-loop/publish-node.ts +3 -0
  159. package/scripts/release-loop/tui-node-checks.ts +5 -2
  160. package/scripts/skills/public-check.ts +5 -2
  161. package/scripts/test-deterministic.ts +520 -37
  162. package/scripts/ux-sim.ts +7 -1
  163. package/scripts/verify-kpack6-live.ts +230 -0
  164. package/src/acp/codex-plugins.ts +17 -0
  165. package/src/agent/task-notification.ts +2 -1
  166. package/src/agent-mission/in-agent-plugin.ts +8 -3
  167. package/src/agent-substrate/self-review-cli.ts +34 -10
  168. package/src/briefing/brief-items.ts +151 -35
  169. package/src/budget/llm-cost.ts +25 -2
  170. package/src/budget/usage-outcomes.ts +32 -0
  171. package/src/claims/claims-ledger.ts +23 -0
  172. package/src/claims/claims-render.ts +22 -2
  173. package/src/cli/brief-cli.ts +131 -10
  174. package/src/cli/claims-cli.ts +24 -4
  175. package/src/cli/msg-cli.ts +165 -9
  176. package/src/cli/plugin-cli.ts +11 -3
  177. package/src/cli/pr-cli.ts +80 -18
  178. package/src/cli/pr-land-refusal.ts +20 -0
  179. package/src/cli/release-cli.ts +2 -0
  180. package/src/cli/rfc-cli.ts +24 -0
  181. package/src/cli/tailscale-serve.ts +5 -8
  182. package/src/cli/tasks-cli.ts +393 -26
  183. package/src/cli/usage-cli.ts +9 -4
  184. package/src/dashboard/first-screen-band.ts +63 -13
  185. package/src/dashboard/index.ts +8 -3
  186. package/src/dashboard/skill-detection-runtime.ts +24 -4
  187. package/src/domains/morning-report.ts +3 -1
  188. package/src/github/pr-labels.ts +10 -4
  189. package/src/graph-runner/runner.ts +101 -14
  190. package/src/harness/harness-clean.ts +21 -3
  191. package/src/harness/harness-cli-command.ts +22 -6
  192. package/src/harness/harness-pod-dispatch.ts +11 -4
  193. package/src/hq/seats.ts +17 -3
  194. package/src/index.ts +34 -5
  195. package/src/intelligence-map/model-bench-scores.ts +102 -0
  196. package/src/loops/orchestrator/inbox.ts +73 -0
  197. package/src/loops/orchestrator/resync-shadow.ts +100 -12
  198. package/src/loops/orchestrator/tick.ts +73 -2
  199. package/src/maturity/feature-maturity.ts +1 -1
  200. package/src/msg/msg-sse-server.ts +141 -0
  201. package/src/msg/msg-store.ts +84 -14
  202. package/src/patents/ledger.ts +128 -0
  203. package/src/plugins/install/market-fetch.ts +32 -1
  204. package/src/pod-lease/admission.ts +116 -41
  205. package/src/release-loop/checklist.ts +11 -0
  206. package/src/release-loop/merged-pr-checklist.ts +24 -5
  207. package/src/release-loop/rubric.ts +70 -0
  208. package/src/rfc-registry/registry.ts +88 -0
  209. package/src/seat-dispatch/seat-request-ledger.ts +4 -0
  210. package/src/seat-loop/knowhow-lint.ts +233 -0
  211. package/src/seat-loop/seat-pack.ts +16 -4
  212. package/src/seat-loop/traffic-signal.ts +7 -0
  213. package/src/self-dev/ask-launch-flow.ts +48 -8
  214. package/src/self-dev/ask-path-candidates.ts +69 -14
  215. package/src/self-dev/decompose-proposal.ts +43 -1
  216. package/src/self-dev/graph-journey-nodes.ts +2 -0
  217. package/src/self-dev/launch-preflight-text.ts +5 -1
  218. package/src/self-dev/launch-preflight.ts +143 -10
  219. package/src/self-implement/gate-cli.ts +6 -2
  220. package/src/self-implement/goal-author.ts +2 -1
  221. package/src/self-implement/goal-new-paths.ts +4 -5
  222. package/src/self-implement/graph-authority.ts +17 -3
  223. package/src/self-implement/graph-yaml.ts +15 -7
  224. package/src/self-implement/headless-elanous-driver.ts +16 -1
  225. package/src/self-implement/host-regate.ts +44 -9
  226. package/src/self-implement/orchestrator.ts +3 -10
  227. package/src/self-implement/rework-policy.ts +80 -2
  228. package/src/self-implement/speed-claims.ts +145 -6
  229. package/src/skills/tools/persistent-grounding.ts +59 -4
  230. package/src/skills/tools/shell.ts +79 -1
  231. package/src/steward/rejudge-tick.ts +12 -1
  232. package/src/steward/release-day.ts +158 -0
  233. package/src/steward/resync-tick.ts +123 -0
  234. package/src/task-agent/actions.ts +1 -0
  235. package/src/task-agent/adopt-pr.ts +140 -0
  236. package/src/task-agent/board.ts +11 -1
  237. package/src/task-agent/card-evidence.ts +37 -5
  238. package/src/task-agent/cover.ts +84 -17
  239. package/src/task-agent/failure-notify.ts +61 -0
  240. package/src/task-agent/green-sweep.ts +170 -0
  241. package/src/task-agent/land-worktree.ts +52 -5
  242. package/src/task-agent/live-move-history.ts +1 -0
  243. package/src/task-agent/live-moves.ts +43 -12
  244. package/src/task-agent/parent-host.ts +1 -1
  245. package/src/task-agent/postland-card.ts +139 -0
  246. package/src/task-agent/rejudge-on-head.ts +106 -7
  247. package/src/task-agent/remote-rejudge-bind.ts +62 -0
  248. package/src/task-agent/repair-branch-cas.ts +182 -0
  249. package/src/task-agent/repair-cycle.ts +275 -0
  250. package/src/task-agent/repair-next-move.ts +31 -0
  251. package/src/task-agent/repair-review.ts +24 -0
  252. package/src/task-agent/repair-worker.ts +87 -0
  253. package/src/task-agent/review-result-capture.ts +2 -1
  254. package/src/task-agent/shadow.ts +13 -7
  255. package/src/task-agent/task-hand.ts +115 -6
  256. package/src/task-orchestrator/surfaces/pod-repair-commit.ts +96 -0
  257. package/src/task-orchestrator/surfaces/self-implement-pod.ts +70 -12
  258. package/src/telegram.ts +30 -1
  259. package/src/user-config.ts +8 -1
  260. package/src/ux-sim/fault-sqlite-busy.ts +50 -0
  261. package/src/version/packed-revision.json +1 -1
  262. package/apps/pwa/out/_next/static/chunks/app/approvals/page-a1ce306364f08da9.js +0 -1
  263. package/apps/pwa/out/_next/static/chunks/app/botlab/page-30007380f2e2a3c1.js +0 -1
  264. package/apps/pwa/out/_next/static/chunks/app/ceo/page-b93c04b5311c97ec.js +0 -1
  265. package/apps/pwa/out/_next/static/chunks/app/chat/page-718a49e86bf25f9c.js +0 -1
  266. package/apps/pwa/out/_next/static/chunks/app/consult/page-20662900d6b4b455.js +0 -1
  267. package/apps/pwa/out/_next/static/chunks/app/control/page-f707a509515ac50c.js +0 -1
  268. package/apps/pwa/out/_next/static/chunks/app/design-check/page-fcb06e305f515527.js +0 -1
  269. package/apps/pwa/out/_next/static/chunks/app/editor/page-6acf5ec4e6c6d1af.js +0 -1
  270. package/apps/pwa/out/_next/static/chunks/app/market/page-8397a5c6ca569bf4.js +0 -1
  271. package/apps/pwa/out/_next/static/chunks/app/observatory/page-d73c3f1e00d93986.js +0 -1
  272. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-65caacf54389081e.js +0 -1
  273. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-77e04b4f8762c666.js +0 -1
  274. package/apps/pwa/out/_next/static/chunks/app/outputs/page-15c0a4095baddfba.js +0 -1
  275. package/apps/pwa/out/_next/static/chunks/app/reflection/page-63e272a4cd3b6634.js +0 -1
  276. package/apps/pwa/out/_next/static/chunks/app/share/page-e2b63345bba1a11f.js +0 -1
  277. package/apps/pwa/out/_next/static/chunks/app/showroom/page-d376e0c83edc041a.js +0 -1
  278. package/apps/pwa/out/_next/static/chunks/app/tasks/page-d6db55fe2c04d128.js +0 -1
  279. package/apps/pwa/out/_next/static/chunks/app/term/page-87b88f551f421d8b.js +0 -1
  280. package/apps/pwa/out/_next/static/chunks/app/trace/page-08019efef111ed2e.js +0 -1
  281. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-980ce6fe36ad95eb.js +0 -1
  282. package/apps/pwa/out/_next/static/chunks/app/workspace/page-0ab42e0f05823013.js +0 -1
  283. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ad9220422d20ba33.js +0 -1
  284. /package/apps/pwa/out/_next/static/{Ev2jivDMTSoEc-5rIXdcy → y1ltt7oCZxVpC6zVPhZ2O}/_buildManifest.js +0 -0
  285. /package/apps/pwa/out/_next/static/{Ev2jivDMTSoEc-5rIXdcy → y1ltt7oCZxVpC6zVPhZ2O}/_ssgManifest.js +0 -0
@@ -0,0 +1,230 @@
1
+ // KPACK6 — 선택 실행 운영 스모크 (읽기 전용)
2
+ //
3
+ // #25780 으로 두 공개 표본 팩의 서명 설치·CLI KnowledgeQuery·그래프 검색 인용은 착지했다.
4
+ // 이 스크립트는 그 다음 칸이다: 설치된 두 팩에 대해 실제 CLI 채팅 LLM 답변이
5
+ // 팩 출처(`slug@version#cardId`)를 인용하는지 확인한다.
6
+ //
7
+ // 팩 미설치 / LLM 호출 불가 / 스토어 오류 → 미측정 (실패 아님)
8
+ // 답변은 왔으나 팩 출처 인용 없음 → 실패
9
+ // 두 팩 모두 인용 답변 실물 확보 전까지 → green 금지
10
+ //
11
+ // 실행: KPACK6_LIVE=1 bun scripts/verify-kpack6-live.ts
12
+ // CLI 명령은 KPACK6_AGENT_CMD 로 바꿀 수 있다(기본 `bun bin/elanous.mjs agent --new --json`).
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { dirname, join } from 'node:path';
17
+ import { listInstalledPacks, queryInstalledPack } from '../src/knowledge/query.js';
18
+
19
+ export const KPACK6_TARGET_SLUGS = ['semiconductor-process-public', 'b2b-sales-public'] as const;
20
+
21
+ export type Kpack6PackStatus =
22
+ | 'pack-not-installed'
23
+ | 'pack-unreadable'
24
+ | 'llm-unavailable'
25
+ | 'llm-timeout'
26
+ | 'cli-contract-error'
27
+ | 'citation-missing'
28
+ | 'cited';
29
+
30
+ export type Kpack6PackResult = {
31
+ slug: string;
32
+ packId: string | null;
33
+ status: Kpack6PackStatus;
34
+ measured: boolean;
35
+ question?: string;
36
+ reply?: string;
37
+ ref?: string;
38
+ reason?: string;
39
+ };
40
+
41
+ export type Kpack6SmokeStatus = 'green' | 'fail' | 'not-measured' | 'skipped';
42
+
43
+ export type Kpack6SmokeReport = {
44
+ status: Kpack6SmokeStatus;
45
+ reason?: string;
46
+ packs: Kpack6PackResult[];
47
+ generatedAt: string;
48
+ };
49
+
50
+ export type Kpack6Deps = {
51
+ installedPacks?: () => Array<{ id: string; title: string }>;
52
+ queryTopCard?: (packId: string) => { id: string; title: string; ref: string };
53
+ ask?: (question: string) => Promise<{ reply: string }>;
54
+ env?: Record<string, string | undefined>;
55
+ };
56
+
57
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
58
+
59
+ function escapeRegExp(text: string): string {
60
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
61
+ }
62
+
63
+ /** 설치된 팩에서 조회한 바로 그 카드의 ref만 인용으로 인정한다. */
64
+ export function packCitationIn(reply: string, expectedRef: string): string | null {
65
+ const exactRef = new RegExp(`(?<![\\w./@#-])${escapeRegExp(expectedRef)}(?=$|[^\\w./@#-])`);
66
+ return exactRef.test(reply) ? expectedRef : null;
67
+ }
68
+
69
+ function defaultQueryTopCard(packId: string): { id: string; title: string; ref: string } {
70
+ const hits = queryInstalledPack(packId, '');
71
+ if (hits.length === 0) throw new Error(`팩에 카드가 없다: ${packId}`);
72
+ return { id: hits[0].id, title: hits[0].title, ref: hits[0].ref };
73
+ }
74
+
75
+ export class Kpack6CliContractError extends Error {}
76
+ export class Kpack6LlmTimeoutError extends Error {}
77
+
78
+ function isProviderUnavailable(stderr: string): boolean {
79
+ return /No LLM provider available|usage_limit_reached|(?:^|\W)429(?:\W|$)/i.test(stderr);
80
+ }
81
+
82
+ export async function askViaCli(question: string, env: Record<string, string | undefined>): Promise<{ reply: string }> {
83
+ const argv = (env.KPACK6_AGENT_CMD ?? 'bun bin/elanous.mjs agent --new --json').split(/\s+/);
84
+ const timeoutMs = Number(env.KPACK6_TIMEOUT_MS ?? 180_000);
85
+ return await new Promise((resolve, reject) => {
86
+ const child = spawn(argv[0], [...argv.slice(1), question], {
87
+ cwd: REPO_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'],
88
+ });
89
+ let stdout = '';
90
+ let stderr = '';
91
+ let timedOut = false;
92
+ const timer = setTimeout(() => {
93
+ timedOut = true;
94
+ child.kill('SIGKILL');
95
+ }, timeoutMs);
96
+ child.stdout.on('data', (chunk) => { stdout += String(chunk); });
97
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
98
+ child.on('error', (err) => {
99
+ clearTimeout(timer);
100
+ reject(new Kpack6CliContractError(`agent CLI 실행 실패 — ${err.message}`));
101
+ });
102
+ child.on('close', (code) => {
103
+ clearTimeout(timer);
104
+ if (timedOut) {
105
+ reject(new Kpack6LlmTimeoutError(`agent CLI LLM 응답 시간 초과 (${timeoutMs}ms)`));
106
+ return;
107
+ }
108
+ const jsonLine = stdout.split('\n').filter((line) => line.trim().startsWith('{')).pop();
109
+ if (code !== 0) {
110
+ const message = `agent CLI exit=${code} stderr=${stderr.slice(0, 300)}`;
111
+ reject(isProviderUnavailable(stderr) ? new Error(message) : new Kpack6CliContractError(message));
112
+ return;
113
+ }
114
+ if (!jsonLine) {
115
+ reject(new Kpack6CliContractError('agent CLI 성공 출력에 JSON 객체가 없다'));
116
+ return;
117
+ }
118
+ try {
119
+ const parsed = JSON.parse(jsonLine) as { reply?: unknown };
120
+ if (typeof parsed.reply !== 'string' || parsed.reply.trim() === '') {
121
+ reject(new Kpack6CliContractError('agent CLI 응답에 reply 문자열이 없다'));
122
+ return;
123
+ }
124
+ resolve({ reply: parsed.reply });
125
+ } catch (err) {
126
+ reject(err instanceof Kpack6CliContractError
127
+ ? err : new Kpack6CliContractError(`agent CLI 출력 JSON 파싱 실패 — ${(err as Error).message}`));
128
+ }
129
+ });
130
+ });
131
+ }
132
+
133
+ export function kpack6ExitCode(report: Kpack6SmokeReport): number {
134
+ switch (report.status) {
135
+ case 'green': return 0;
136
+ case 'fail': return 1;
137
+ case 'skipped': return 3;
138
+ default: return 4;
139
+ }
140
+ }
141
+
142
+ export async function runKpack6LiveSmoke(deps: Kpack6Deps = {}): Promise<Kpack6SmokeReport> {
143
+ const env = deps.env ?? process.env;
144
+ const generatedAt = new Date().toISOString();
145
+
146
+ if (env.KPACK6_LIVE !== '1') {
147
+ return {
148
+ status: 'skipped',
149
+ reason: '선택 실행 스모크다: KPACK6_LIVE=1 없이는 돌지 않는다 (두 팩 인용 답변 실물 1회 전까지 green 금지)',
150
+ packs: [],
151
+ generatedAt,
152
+ };
153
+ }
154
+
155
+ const ask = deps.ask ?? ((question: string) => askViaCli(question, env));
156
+ let installed: Array<{ id: string; title: string }>;
157
+ try {
158
+ installed = deps.installedPacks?.() ?? listInstalledPacks();
159
+ } catch (err) {
160
+ return {
161
+ status: 'not-measured',
162
+ reason: `KGS 스토어 조회 불가 — ${(err as Error).message}`,
163
+ packs: KPACK6_TARGET_SLUGS.map((slug) => ({
164
+ slug, packId: null, status: 'pack-unreadable' as const, measured: false,
165
+ reason: `스토어 오류: ${(err as Error).message}`,
166
+ })),
167
+ generatedAt,
168
+ };
169
+ }
170
+
171
+ const packs: Kpack6PackResult[] = [];
172
+ for (const slug of KPACK6_TARGET_SLUGS) {
173
+ const match = installed.find((pack) => pack.id === slug || pack.id.startsWith(`${slug}@`));
174
+ if (!match) {
175
+ packs.push({
176
+ slug, packId: null, status: 'pack-not-installed', measured: false,
177
+ reason: 'KGS 스토어에 설치된 팩이 없다 (미측정)',
178
+ });
179
+ continue;
180
+ }
181
+ const packId = match.id;
182
+
183
+ let top: { id: string; title: string; ref: string };
184
+ try {
185
+ top = deps.queryTopCard?.(packId) ?? defaultQueryTopCard(packId);
186
+ } catch (err) {
187
+ packs.push({
188
+ slug, packId, status: 'pack-unreadable', measured: false,
189
+ reason: `설치됐으나 카드 조회 불가 — ${(err as Error).message}`,
190
+ });
191
+ continue;
192
+ }
193
+
194
+ const question = `설치된 ${packId} (${match.title}) 팩의 «${top.title}» 카드를 지식 조회 도구로 읽고 요약하라. 답변에 카드 제목을 쓰고, 도구가 반환한 출처를 그대로 인용해 붙여라. 출처를 추측하지 마라.`;
195
+ let reply: string;
196
+ try {
197
+ reply = (await ask(question)).reply;
198
+ } catch (err) {
199
+ const contractError = err instanceof Kpack6CliContractError;
200
+ const timeoutError = err instanceof Kpack6LlmTimeoutError;
201
+ packs.push({
202
+ slug, packId, status: contractError ? 'cli-contract-error' : timeoutError ? 'llm-timeout' : 'llm-unavailable',
203
+ measured: false, question,
204
+ reason: `${contractError ? 'CLI JSON 계약 오류' : timeoutError ? 'LLM 응답 시간 초과' : 'CLI 채팅 LLM 호출 불가'} — ${(err as Error).message}`,
205
+ });
206
+ continue;
207
+ }
208
+
209
+ const ref = packCitationIn(reply, top.ref);
210
+ if (ref && reply.includes(top.title)) {
211
+ packs.push({ slug, packId, status: 'cited', measured: true, question, reply, ref });
212
+ } else {
213
+ packs.push({
214
+ slug, packId, status: 'citation-missing', measured: true, question, reply,
215
+ reason: '답변은 왔으나 조회한 설치 카드의 제목과 정확한 출처 인용이 함께 없다 — 실패',
216
+ });
217
+ }
218
+ }
219
+
220
+ const failed = packs.some((pack) => pack.status === 'citation-missing' || pack.status === 'cli-contract-error');
221
+ const allCited = packs.length > 0 && packs.every((pack) => pack.status === 'cited');
222
+ const status: Kpack6SmokeStatus = failed ? 'fail' : allCited ? 'green' : 'not-measured';
223
+ return { status, packs, generatedAt };
224
+ }
225
+
226
+ if (import.meta.main) {
227
+ const report = await runKpack6LiveSmoke();
228
+ console.log(JSON.stringify(report, null, 2));
229
+ process.exit(kpack6ExitCode(report));
230
+ }
@@ -43,6 +43,23 @@ export interface FetchCodexPluginsOpts {
43
43
  noCache?: boolean;
44
44
  }
45
45
 
46
+ /** Read the full inventory for an explicit package import. Unlike the chip projector, this keeps
47
+ * the package location and never substitutes a stale cached inventory after an RPC failure. */
48
+ export async function readCodexPluginList(
49
+ spawn: typeof spawnCodexAppServer = spawnCodexAppServer,
50
+ ): Promise<unknown> {
51
+ const server = spawn({ requestTimeoutMs: 5000 });
52
+ try {
53
+ await server.client.request('initialize', {
54
+ clientInfo: { name: 'elanous', version: '0.x' }, capabilities: { experimentalApi: true },
55
+ });
56
+ return await server.client.request<Record<string, never>, unknown>('plugin/list', {});
57
+ } finally {
58
+ try { await server.client.close(); }
59
+ finally { server.child.kill(); }
60
+ }
61
+ }
62
+
46
63
  /** Uncached inventory probe: distinguish no session / RPC failure from an empty installed list. */
47
64
  export async function readCodexPluginsObservation(client: CodexAppServerClient | null | undefined): Promise<
48
65
  { status: 'ok'; plugins: ReadonlyArray<CodexPlugin> } | { status: 'unknown'; plugins: readonly [] }
@@ -223,7 +223,8 @@ function escapeXml(s: string): string {
223
223
  return s
224
224
  .replace(/&/g, '&amp;')
225
225
  .replace(/</g, '&lt;')
226
- .replace(/>/g, '&gt;');
226
+ .replace(/>/g, '&gt;')
227
+ .replace(/"/g, '&quot;');
227
228
  }
228
229
 
229
230
  function formatDuration(ms: number): string {
@@ -2,7 +2,8 @@ import type { PtyHandle } from '../pty-shell/registry.js';
2
2
  import { classifierFrameLines } from '../capture/frame-state-detect.js';
3
3
  import { emitDecision, type DecisionEvent } from '../live/detail-switch.js';
4
4
  import { debug } from '../debug/log.js';
5
- import { streamLLM, type LLMMessage } from '../llm.js';
5
+ import * as llm from '../llm.js';
6
+ import { resolveRoleLlm } from '../user-config.js';
6
7
 
7
8
  export interface PluginRequest { agent: 'codex' | 'claude'; plugin: string; marketplace: string }
8
9
  export type PluginResult = { outcome: 'installed' } | { outcome: 'escalate'; reason: 'plugins-disabled' | 'unknown-menu' | 'timeout' | 'wrong-target' | 'credentials' };
@@ -103,11 +104,15 @@ export async function installPluginInsideAgent(request: PluginRequest, pty: Pick
103
104
  ? ['[credential screen]']
104
105
  : lines(screen).slice(-10).map(line => line.slice(0, 160));
105
106
  const brain = deps.brain ?? (async (screen: string, target: string) => {
106
- const messages: LLMMessage[] = [
107
+ const messages: llm.LLMMessage[] = [
107
108
  { role: 'system', content: 'You navigate a plugin menu using only up/down/tab/enter/wait/unknown. Return exactly one of those words. Do not choose enter unless the requested plugin@marketplace is visibly selected. Never enter credentials.' },
108
109
  { role: 'user', content: `Target: ${target}\nScreen:\n${screen.slice(-3500)}` },
109
110
  ];
110
- const answer = (await streamLLM(messages, () => {}, { maxTokens: 20 })).trim().toLowerCase();
111
+ const resolved = resolveRoleLlm('classify');
112
+ const provider = llm.PROVIDERS[resolved.provider];
113
+ if (!provider) throw new Error(`Unknown classify provider: ${resolved.provider}`);
114
+ debug.log('llm.role-pin', 'resolved', { site: 'in-agent-plugin', role: 'classify', model: resolved.model, provider: resolved.provider, source: resolved.source });
115
+ const answer = (await llm.streamLLM(messages, () => {}, { model: resolved.model, provider, usageRole: 'classify', maxTokens: 20 })).trim().toLowerCase();
111
116
  return /^(up|down|tab|enter|wait)$/.test(answer) ? answer as 'up' | 'down' | 'tab' | 'enter' | 'wait' : 'unknown';
112
117
  });
113
118
  const target = `${request.plugin}@${request.marketplace}`;
@@ -14,9 +14,10 @@
14
14
  //
15
15
  // index.ts 의 액션은 옵션 파싱과 실제 의존 배선만 남는다(얇은 껍데기).
16
16
 
17
- import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
18
  import { tmpdir } from 'node:os';
19
- import { join } from 'node:path';
19
+ import { dirname, join } from 'node:path';
20
+ import { effectiveInstanceRoot } from '../instance/resolve.js';
20
21
  import { tierModel } from '../llm/model-defaults.js';
21
22
  import { buildReviewObservation, safeLogText } from './review-observation.js';
22
23
  import { DEFAULT_REVIEW_BACKEND } from './acp-reviewer.js';
@@ -46,6 +47,8 @@ export interface SelfReviewCliOpts {
46
47
  contextText?: string[] | undefined;
47
48
  contextOrder?: Array<{ kind: 'file' | 'text'; value: string }> | undefined;
48
49
  json?: boolean | undefined;
50
+ /** Save completed reviews under the task-agent state's review-results directory. */
51
+ save?: string | undefined;
49
52
  }
50
53
 
51
54
  /** 셸/네트워크/로그 경계 — 테스트는 전부 대역으로 채운다. */
@@ -1276,6 +1279,11 @@ async function runSelfReviewCliCommandPinned(
1276
1279
  return { results: [], failed: 0 };
1277
1280
  }
1278
1281
 
1282
+ if (opts.save !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(opts.save)) {
1283
+ deps.error('--save 이름은 영문자·숫자로 시작하고 영문자·숫자·점·밑줄·하이픈만 포함해야 합니다.');
1284
+ return { results: [], failed: 1 };
1285
+ }
1286
+
1279
1287
  const useAcp = opts.acp === true;
1280
1288
  const acpModel = opts.acpModel?.trim() || undefined;
1281
1289
  const acpBackendResolution = opts.acpBackend
@@ -1431,8 +1439,8 @@ async function runSelfReviewCliCommandPinned(
1431
1439
  { pr: safeLogText(pr), exit: diff.status, error: safeLogText(diff.stderr || err, 160) },
1432
1440
  { level: 'warn' });
1433
1441
  const contextOutput = reviewerContextOutput(reviewerContext, decompositionContext);
1434
- if (opts.json) results.push({ pr, error: err, ...contextOutput, ...comparisonData, ...(mismatchWarning ?? {}) });
1435
- else deps.error(`PR ${pr}: ${err}\n${reviewerContextStatus}`);
1442
+ if (opts.json || opts.save !== undefined) results.push({ pr, error: err, ...contextOutput, ...comparisonData, ...(mismatchWarning ?? {}) });
1443
+ if (!opts.json) deps.error(`PR ${pr}: ${err}\n${reviewerContextStatus}`);
1436
1444
  failed += 1;
1437
1445
  continue;
1438
1446
  }
@@ -1492,8 +1500,8 @@ async function runSelfReviewCliCommandPinned(
1492
1500
  pr, model: primaryModel, verdict: null, mustFix: [], shouldFix: [], reviewed: false,
1493
1501
  refusal: 'stale-head', ...provenance, ...reviewerSourceObservation(), ...contextOutput,
1494
1502
  };
1495
- if (opts.json) results.push(refused);
1496
- else deps.print(`\n━━ PR ${pr} (${primaryModel}) ━━\n${renderStaleRefusal(diffRead)}\n${reviewerContextStatus}`);
1503
+ if (opts.json || opts.save !== undefined) results.push(refused);
1504
+ if (!opts.json) deps.print(`\n━━ PR ${pr} (${primaryModel}) ━━\n${renderStaleRefusal(diffRead)}\n${reviewerContextStatus}`);
1497
1505
  continue;
1498
1506
  }
1499
1507
  // ⭐ 머지된 PR 만 머지 커밋 detached worktree 에서 리뷰한다 — main 최신이 아니라 그 PR 이 머지된 시점의 코드를 본다.
@@ -1666,17 +1674,33 @@ async function runSelfReviewCliCommandPinned(
1666
1674
  if (useAcp && review.reviewed === false) {
1667
1675
  const err = 'ACP 리뷰 실행 실패(reviewed=false) — 관측: elanous logs --category acp-review';
1668
1676
  const contextOutput = reviewerContextOutput(reviewerContext, decompositionContext);
1669
- if (opts.json) results.push({ pr, error: err, ...review, verdict: null, ...reviewProvenance, ...respondedOutput, ...reviewerSelection, ...reviewCwdOutput, ...provenance, ...contextOutput });
1670
- else deps.error(`PR ${pr}: ${err}\n${renderProvenance(diffRead)}\n${reviewerContextStatus}`);
1677
+ if (opts.json || opts.save !== undefined) results.push({ pr, error: err, ...review, verdict: null, ...reviewProvenance, ...respondedOutput, ...reviewerSelection, ...reviewCwdOutput, ...provenance, ...contextOutput });
1678
+ if (!opts.json) deps.error(`PR ${pr}: ${err}\n${renderProvenance(diffRead)}\n${reviewerContextStatus}`);
1671
1679
  failed += 1;
1672
1680
  continue;
1673
1681
  }
1674
1682
  const contextOutput = reviewerContextOutput(reviewerContext, decompositionContext);
1675
1683
  const jsonReview = review.reviewed === true ? review : { ...review, verdict: null };
1676
- if (opts.json) results.push({ pr, model: selectedModel, ...jsonReview, ...reviewProvenance, ...respondedOutput, ...reviewerSelection, ...reviewCwdOutput, ...provenance, ...contextOutput, evidence: evidence.evidence, prDiffFiles: evidence.prDiffFiles, ...(evidence.authoredFiles !== undefined ? { authoredFiles: evidence.authoredFiles } : {}) });
1677
- else deps.print(`\n━━ PR ${pr} (${selectedModel}) ━━\n${renderProvenance(diffRead)}\n${evidenceLine}\n${reviewerContextStatus}\n${deps.renderReview(review)}`);
1684
+ if (opts.json || opts.save !== undefined) results.push({ pr, model: selectedModel, ...jsonReview, ...reviewProvenance, ...respondedOutput, ...reviewerSelection, ...reviewCwdOutput, ...provenance, ...contextOutput, evidence: evidence.evidence, prDiffFiles: evidence.prDiffFiles, ...(evidence.authoredFiles !== undefined ? { authoredFiles: evidence.authoredFiles } : {}) });
1685
+ if (!opts.json) deps.print(`\n━━ PR ${pr} (${selectedModel}) ━━\n${renderProvenance(diffRead)}\n${evidenceLine}\n${reviewerContextStatus}\n${deps.renderReview(review)}`);
1678
1686
  // REVIEW-ERROR-RC — 기본(API)·role 경로가 1차·폴백 모두 실패해 reviewed≠true 로 끝나도 «못 리뷰한 PR» 이다(verdict:null).
1679
1687
  if (review.reviewed !== true) failed += 1;
1688
+ if (opts.save !== undefined) {
1689
+ if (review.reviewed !== true || diffRead.headCommitState !== 'known' || diffRead.currentHeadCommitState !== 'known' || diffRead.currentHeadCommit !== diffRead.headCommit || diffRead.headCommit.length !== 40) {
1690
+ deps.error(`PR ${pr}: --save 불가 — 완료된 리뷰와 확인된 40자 PR head가 필요합니다.`);
1691
+ if (review.reviewed === true) failed += 1;
1692
+ } else {
1693
+ const path = join(effectiveInstanceRoot(), 'review-results', `${opts.save}-${pr}-${diffRead.headCommit.slice(0, 12)}.json`);
1694
+ const result = results.at(-1);
1695
+ try {
1696
+ mkdirSync(dirname(path), { recursive: true });
1697
+ writeFileSync(path, selfReviewJsonLine([result]));
1698
+ } catch (error) {
1699
+ failed += 1;
1700
+ deps.error(`PR ${pr}: --save 실패: ${error instanceof Error ? error.message : String(error)}`);
1701
+ }
1702
+ }
1703
+ }
1680
1704
  }
1681
1705
  return { results, failed };
1682
1706
  }
@@ -49,6 +49,15 @@ export interface AddBriefItem {
49
49
  dedupeWithinMs?: number;
50
50
  }
51
51
 
52
+ export interface BriefTelegramFeedback {
53
+ botId: string;
54
+ chatId: number;
55
+ messageId: number;
56
+ updateId: string;
57
+ reaction: BriefReaction;
58
+ itemId?: number;
59
+ }
60
+
52
61
  export interface BriefItemsOptions {
53
62
  stateDir?: string;
54
63
  now?: () => Date;
@@ -112,7 +121,11 @@ export function composeBriefMarkdown(items: readonly BriefItem[], slot: BriefSlo
112
121
  }
113
122
 
114
123
  /** The ids are exactly the rows represented by the composed page, after deduplication. */
115
- export function composeBriefPage(items: readonly BriefItem[], slot: BriefSendSlot, now: Date): { markdown: string; ids: number[] } {
124
+ export function composeBriefPage(
125
+ items: readonly BriefItem[], slot: BriefSendSlot, now: Date,
126
+ actions?: BriefWeeklyActions,
127
+ observedKinds?: ReadonlySet<BriefDomain>,
128
+ ): { markdown: string; ids: number[] } {
116
129
  const today = dateKey(now, { timeZone: KST });
117
130
  // A duplicate left out of a delivered page stays unsent in the ledger, but must not reappear on a later page.
118
131
  // A new undeliverable observation after a sent page is a new occurrence, not an old duplicate.
@@ -138,11 +151,22 @@ export function composeBriefPage(items: readonly BriefItem[], slot: BriefSendSlo
138
151
  for (const item of decisions) lines.push(itemLine(item));
139
152
  lines.push('');
140
153
  }
141
- for (const domain of BRIEF_DOMAINS) {
154
+ const domains = [...BRIEF_DOMAINS];
155
+ // Measured action leads. An unobserved kind keeps its normal position; a sent-but-unobserved
156
+ // item is not evidence of being ignored. Keep every domain heading in this first slice.
157
+ if (actions && Object.values(actions.byKind).some(count => count.acted > 0)) {
158
+ domains.sort((a, b) => {
159
+ const x = actions.byKind[a], y = actions.byKind[b];
160
+ const xRate = observedKinds?.has(a) ? x.rate : 0;
161
+ const yRate = observedKinds?.has(b) ? y.rate : 0;
162
+ return yRate - xRate || BRIEF_DOMAINS.indexOf(a) - BRIEF_DOMAINS.indexOf(b);
163
+ });
164
+ }
165
+ for (const domain of domains) {
142
166
  const group = unique.filter(item => item.domain === domain && !decisionIds.has(item.id));
143
167
  if (group.length === 0) continue;
144
168
  lines.push(`## ${domain}`, '');
145
- for (const item of group.sort((a, b) => a.id - b.id)) lines.push(itemLine(item));
169
+ for (const item of group) lines.push(itemLine(item));
146
170
  lines.push('');
147
171
  }
148
172
  return { markdown: `${lines.join('\n').trimEnd()}\n`, ids: unique.map(item => item.id) };
@@ -203,12 +227,12 @@ export class BriefItemsLedger {
203
227
  this.requirementFunnel = options.requirementFunnel ?? ((now) => readRequirementFunnel({ stateDir: options.stateDir, now }));
204
228
  }
205
229
 
206
- private using<T>(work: (db: Database) => T): T {
230
+ private using<T>(work: (db: Database) => T, busyTimeoutMs = 10000): T {
207
231
  mkdirSync(dirname(this.path), { recursive: true });
208
232
  const db = new Database(this.path, { create: true, strict: true });
209
233
  try {
210
234
  chmodSync(this.path, 0o600);
211
- db.exec('PRAGMA busy_timeout = 10000');
235
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
212
236
  migrateLegacyItems(db, this.log);
213
237
  db.exec(`CREATE TABLE IF NOT EXISTS items (
214
238
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -237,7 +261,20 @@ export class BriefItemsLedger {
237
261
  reacted_at TEXT NOT NULL
238
262
  );
239
263
  CREATE INDEX IF NOT EXISTS reactions_item_time ON reactions(item_id, reacted_at);
240
- CREATE INDEX IF NOT EXISTS sends_time ON sends(sent_at);`);
264
+ CREATE INDEX IF NOT EXISTS sends_time ON sends(sent_at);
265
+ CREATE TABLE IF NOT EXISTS telegram_sends (
266
+ bot_id TEXT NOT NULL,
267
+ chat_id INTEGER NOT NULL,
268
+ message_id INTEGER NOT NULL,
269
+ item_id INTEGER NOT NULL REFERENCES items(id),
270
+ PRIMARY KEY (bot_id, chat_id, message_id, item_id)
271
+ );
272
+ CREATE TABLE IF NOT EXISTS telegram_feedback_events (
273
+ bot_id TEXT NOT NULL,
274
+ chat_id INTEGER NOT NULL,
275
+ update_id TEXT NOT NULL,
276
+ PRIMARY KEY (bot_id, chat_id, update_id)
277
+ );`);
241
278
  if (!(db.query("SELECT name FROM pragma_table_info('items')").all() as { name: string }[]).some(column => column.name === 'dedupe_key')) {
242
279
  db.exec('ALTER TABLE items ADD COLUMN dedupe_key TEXT');
243
280
  }
@@ -318,34 +355,72 @@ export class BriefItemsLedger {
318
355
  this.log('briefing.items', 'reaction-recorded', { itemId, reaction });
319
356
  }
320
357
 
358
+ /** Attribute one update to an item in its delivered Telegram message, without rewriting send history. */
359
+ recordTelegramFeedback(event: BriefTelegramFeedback): boolean {
360
+ if (!event.botId || !Number.isSafeInteger(event.chatId) || !Number.isSafeInteger(event.messageId)
361
+ || event.messageId < 1 || !event.updateId || !(BRIEF_REACTIONS as readonly string[]).includes(event.reaction)) {
362
+ throw new BriefItemsInputError('invalid telegram feedback');
363
+ }
364
+ if (event.itemId !== undefined && (!Number.isSafeInteger(event.itemId) || event.itemId < 1)) {
365
+ throw new BriefItemsInputError('invalid item id');
366
+ }
367
+ const recorded = this.using(db => {
368
+ // Wait for an in-flight brief send before looking up its coordinates. A deferred read
369
+ // could see the pre-send snapshot and discard an early callback as unmatched.
370
+ db.exec('BEGIN IMMEDIATE');
371
+ try {
372
+ const linked = db.query('SELECT item_id FROM telegram_sends WHERE bot_id = ? AND chat_id = ? AND message_id = ? ORDER BY item_id')
373
+ .all(event.botId, event.chatId, event.messageId) as Array<{ item_id: number }>;
374
+ const targets = event.itemId === undefined ? (linked.length === 1 ? linked : []) : linked.filter(row => row.item_id === event.itemId);
375
+ if (targets.length !== 1) { db.exec('COMMIT'); return false; }
376
+ if (!db.query('INSERT OR IGNORE INTO telegram_feedback_events (bot_id, chat_id, update_id) VALUES (?, ?, ?) RETURNING update_id')
377
+ .get(event.botId, event.chatId, event.updateId)) { db.exec('COMMIT'); return false; }
378
+ db.query('INSERT INTO reactions (item_id, reaction, reacted_at) VALUES (?, ?, ?)')
379
+ .run(targets[0]!.item_id, event.reaction, this.now().toISOString());
380
+ db.exec('COMMIT');
381
+ return targets[0]!.item_id;
382
+ } catch (cause) {
383
+ try { db.exec('ROLLBACK'); } catch { /* connection closes next */ }
384
+ throw cause;
385
+ }
386
+ }, 120000);
387
+ if (recorded) this.log('briefing.items', 'reaction-recorded', { itemId: recorded, reaction: event.reaction, source: 'telegram' });
388
+ return recorded !== false;
389
+ }
390
+
321
391
  /** KST Monday through now: distinct sent items acted on (button/reply/correction), grouped by domain. */
322
392
  weeklyActions(): BriefWeeklyActions {
323
- const now = this.now();
393
+ return this.using(db => this.weeklyActionsFromDb(db, this.now()));
394
+ }
395
+
396
+ private weekStart(now: Date): string {
324
397
  const today = dateKey(now, { timeZone: KST });
325
398
  const day = new Date(`${today}T00:00:00Z`);
326
399
  day.setUTCDate(day.getUTCDate() - ((day.getUTCDay() + 6) % 7));
327
- const since = new Date(`${day.toISOString().slice(0, 10)}T00:00:00+09:00`).toISOString();
400
+ return new Date(`${day.toISOString().slice(0, 10)}T00:00:00+09:00`).toISOString();
401
+ }
402
+
403
+ private weeklyActionsFromDb(db: Database, now: Date): BriefWeeklyActions {
404
+ const since = this.weekStart(now);
328
405
  const until = now.toISOString();
329
- return this.using(db => {
330
- const rows = db.query(`SELECT items.domain AS kind,
331
- COUNT(*) AS total,
332
- SUM(CASE WHEN EXISTS (
333
- SELECT 1 FROM reactions r WHERE r.item_id = items.id
334
- AND r.reaction IN ('버튼', '답장', '정정') AND r.reacted_at >= ? AND r.reacted_at <= ?
335
- ) THEN 1 ELSE 0 END) AS acted
336
- FROM items WHERE EXISTS (
337
- SELECT 1 FROM sends s WHERE s.item_id = items.id AND s.sent_at >= ? AND s.sent_at <= ?
338
- ) GROUP BY items.domain`).all(since, until, since, until) as Array<{ kind: BriefDomain; total: number; acted: number }>;
339
- const byKind = Object.fromEntries(BRIEF_DOMAINS.map(kind => [kind, { acted: 0, total: 0, rate: 0 }])) as Record<BriefDomain, BriefActionCount>;
340
- let acted = 0;
341
- let total = 0;
342
- for (const row of rows) {
343
- byKind[row.kind] = { acted: row.acted, total: row.total, rate: row.acted / row.total };
344
- acted += row.acted;
345
- total += row.total;
346
- }
347
- return { acted, total, rate: total === 0 ? 0 : acted / total, byKind };
348
- });
406
+ const rows = db.query(`SELECT items.domain AS kind,
407
+ COUNT(*) AS total,
408
+ SUM(CASE WHEN EXISTS (
409
+ SELECT 1 FROM reactions r WHERE r.item_id = items.id
410
+ AND r.reaction IN ('버튼', '답장', '정정') AND r.reacted_at >= ? AND r.reacted_at <= ?
411
+ ) THEN 1 ELSE 0 END) AS acted
412
+ FROM items WHERE EXISTS (
413
+ SELECT 1 FROM sends s WHERE s.item_id = items.id AND s.sent_at >= ? AND s.sent_at <= ?
414
+ ) GROUP BY items.domain`).all(since, until, since, until) as Array<{ kind: BriefDomain; total: number; acted: number }>;
415
+ const byKind = Object.fromEntries(BRIEF_DOMAINS.map(kind => [kind, { acted: 0, total: 0, rate: 0 }])) as Record<BriefDomain, BriefActionCount>;
416
+ let acted = 0;
417
+ let total = 0;
418
+ for (const row of rows) {
419
+ byKind[row.kind] = { acted: row.acted, total: row.total, rate: row.acted / row.total };
420
+ acted += row.acted;
421
+ total += row.total;
422
+ }
423
+ return { acted, total, rate: total === 0 ? 0 : acted / total, byKind };
349
424
  }
350
425
 
351
426
  /** Insertion order. Optional domain filter. Item text is the stored original. */
@@ -388,7 +463,14 @@ export class BriefItemsLedger {
388
463
  this.log('briefing.items', 'requirement-funnel-failed', { slot, error: error instanceof Error ? error.message : String(error) });
389
464
  }
390
465
  }
391
- const composed = composeBriefPage([...items, ...funnelItems], slot, now);
466
+ const since = this.weekStart(now);
467
+ const until = now.toISOString();
468
+ const observedKinds = new Set((db.query(`SELECT DISTINCT i.domain FROM items i
469
+ JOIN reactions r ON r.item_id = i.id
470
+ WHERE r.reacted_at >= ? AND r.reacted_at <= ? AND EXISTS (
471
+ SELECT 1 FROM sends s WHERE s.item_id = i.id AND s.sent_at >= ? AND s.sent_at <= ?
472
+ )`).all(since, until, since, until) as Array<{ domain: BriefDomain }>).map(row => row.domain));
473
+ const composed = composeBriefPage([...items, ...funnelItems], slot, now, this.weeklyActionsFromDb(db, now), observedKinds);
392
474
  // Ephemeral funnel lines appear on the page but have no ledger row to mark sent.
393
475
  const page = { markdown: composed.markdown, ids: composed.ids.filter(id => id > 0) };
394
476
  const candidates = (slot === 'realtime' ? pendingRealtime(items, now) : items.filter(item => item.sent_at === null && beforeSlot(item.created_at, slot, now))).length;
@@ -403,24 +485,58 @@ export class BriefItemsLedger {
403
485
  }
404
486
 
405
487
  /** Hold the ledger's SQLite writer lock from selecting unsent rows through delivery and marking.
406
- * A failed delivery rolls back and releases the lock; the next sender can retry.
488
+ * A delivery that fails before anything was sent rolls back and releases the lock; the next
489
+ * sender can retry. A delivery that fails after some messages already went out commits what
490
+ * the 대표 actually received (sends + telegram_sends) so a retry never re-sends them.
407
491
  */
408
- withSendLock<T>(slot: string, work: (page: { markdown: string; ids: number[] }, markSent: (ids: readonly number[], slot: BriefSendSlot) => number) => T): T {
492
+ withSendLock<T>(slot: string, work: (page: { markdown: string; ids: number[]; itemLines: ReadonlyMap<string, readonly number[]> }, markSent: (ids: readonly number[], slot: BriefSendSlot, telegram?: { botId: string; chatId: number; messageId: number } | ReadonlyArray<{ botId: string; chatId: number; messageId: number; itemId: number }>) => number) => T): T {
409
493
  if (slot !== 'realtime' && !(BRIEF_SLOTS as readonly string[]).includes(slot)) throw new BriefItemsInputError('invalid slot');
410
494
  return this.using(db => {
411
495
  db.exec('BEGIN IMMEDIATE');
496
+ let marked = false;
412
497
  try {
413
498
  const page = this.pageFromDb(db, slot as BriefSendSlot);
414
- const result = work(page, (ids, sentSlot) => {
415
- if (sentSlot !== slot || ids.length !== page.ids.length || ids.some((id, index) => id !== page.ids[index])) {
499
+ const itemIds = new Set(page.ids);
500
+ const itemLines = new Map<string, number[]>();
501
+ for (const item of this.itemsFromDb(db).filter(item => itemIds.has(item.id))) {
502
+ const line = itemLine(item);
503
+ itemLines.set(line, [...(itemLines.get(line) ?? []), item.id]);
504
+ }
505
+ const result = work({ ...page, itemLines }, (ids, sentSlot, telegram) => {
506
+ const pageIdSet = new Set(page.ids);
507
+ if (sentSlot !== slot || ids.length === 0 || new Set(ids).size !== ids.length
508
+ || ids.some(id => !pageIdSet.has(id))) {
416
509
  throw new BriefItemsInputError('marked ids must match composed page');
417
510
  }
418
- return this.markSentInDb(db, ids, sentSlot);
511
+ const coordinates = telegram === undefined ? [] : Array.isArray(telegram)
512
+ ? telegram : ids.map(itemId => ({ ...telegram, itemId }));
513
+ if (telegram && (coordinates.some(row => !row.botId || !Number.isSafeInteger(row.chatId)
514
+ || !Number.isSafeInteger(row.messageId) || row.messageId < 1 || !ids.includes(row.itemId))
515
+ || new Set(coordinates.map(row => row.itemId)).size !== ids.length)) {
516
+ throw new BriefItemsInputError('invalid telegram send');
517
+ }
518
+ db.exec('SAVEPOINT brief_mark');
519
+ try {
520
+ const count = this.markSentInDb(db, ids, sentSlot);
521
+ if (telegram) {
522
+ const insert = db.query('INSERT OR IGNORE INTO telegram_sends (bot_id, chat_id, message_id, item_id) VALUES (?, ?, ?, ?)');
523
+ for (const row of coordinates) insert.run(row.botId, row.chatId, row.messageId, row.itemId);
524
+ }
525
+ db.exec('RELEASE brief_mark');
526
+ marked = true;
527
+ return count;
528
+ } catch (cause) {
529
+ db.exec('ROLLBACK TO brief_mark');
530
+ db.exec('RELEASE brief_mark');
531
+ throw cause;
532
+ }
419
533
  });
420
534
  db.exec('COMMIT');
421
535
  return result;
422
536
  } catch (cause) {
423
- db.exec('ROLLBACK');
537
+ // The only writes inside this lock are the marks; committing on failure preserves what
538
+ // the 대표 already received, so a retry sends only the unsent remainder.
539
+ try { db.exec(marked ? 'COMMIT' : 'ROLLBACK'); } catch { /* the connection closes next */ }
424
540
  throw cause;
425
541
  }
426
542
  });