elanous 0.2.25 → 0.2.27

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 (244) 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-37fd16f479f5554a.js → 1454-a9c338f5bb556f42.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/app/approvals/{page-9360b1285b2a346e.js → page-a1ce306364f08da9.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-07110da72c45e68f.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/app/board/page-d2d76d4a7eae91bd.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/app/botlab/page-30007380f2e2a3c1.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/app/bots/page-e5a9302c105725a3.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/ceo/page-b93c04b5311c97ec.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/chat/page-718a49e86bf25f9c.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/consult/{page-44eaad486bc98a45.js → page-20662900d6b4b455.js} +1 -1
  12. package/apps/pwa/out/_next/static/chunks/app/control/{page-97fa6cf3d34c7eb9.js → page-f707a509515ac50c.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/decisions/{page-305cd294e754906e.js → page-6a16a48d1aa6e91f.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/design-check/page-fcb06e305f515527.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/app/editor/page-6acf5ec4e6c6d1af.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/exec/{page-45889e7070d11126.js → page-39f82acf619cca23.js} +1 -1
  17. package/apps/pwa/out/_next/static/chunks/app/field/{page-16a2e893f83ae4d0.js → page-7c2c8f81b185f9c0.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/inside/page-ae7aff07e32676bb.js +1 -0
  19. package/apps/pwa/out/_next/static/chunks/app/intake/{page-56888bc744e08dac.js → page-482c60c1bc540d0a.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/layout-7cb996c1aa08b6f5.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/app/live/{page-952c6ceec6d9de38.js → page-623e08fe4477d606.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/live-run/{page-6d0202af568f4c63.js → page-db460cd8aecb8490.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/loops/page-ff27902d0b11ef4f.js +1 -0
  24. package/apps/pwa/out/_next/static/chunks/app/market/{page-74366ae2693a7d95.js → page-8397a5c6ca569bf4.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-4fb6fc34e20c07b7.js → page-1917ea87ea71ab6b.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/morning/{page-e9502921f4f01b1f.js → page-25a302273b054fd7.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/{not-found-b6169ea2f09cf287.js → not-found-670510eda8b9443f.js} +1 -1
  28. package/apps/pwa/out/_next/static/chunks/app/observatory/page-d73c3f1e00d93986.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-65caacf54389081e.js +1 -0
  30. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-4bb7556e2e97a750.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-77e04b4f8762c666.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/outputs/{page-b9cc8a10bc6078b4.js → page-15c0a4095baddfba.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/{page-3d3fc73dd187ed52.js → page-4eb96fc7241c2456.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/reflection/page-63e272a4cd3b6634.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-9c04dac0831c2bbb.js → page-0d64ff212dc55d03.js} +1 -1
  36. package/apps/pwa/out/_next/static/chunks/app/sessions/page-7f096248993fc2d4.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-04de9ca6d52a3893.js → page-e47fc72049149a38.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/app/settings/{page-62527677bbc2fdc4.js → page-f7d808e31e005895.js} +1 -1
  39. package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-9a2e09c4a311764c.js → page-5c2b24b5496dad73.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/setup/{page-bfeb35d69e8b24bb.js → page-76cdb531668b5191.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/share/page-e2b63345bba1a11f.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/showroom/{page-b008aff96be56654.js → page-d376e0c83edc041a.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/tasks/page-d6db55fe2c04d128.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/term/page-87b88f551f421d8b.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/today/{page-6445ac4cbb8dd869.js → page-dab163b338e3b966.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/trace/{page-852170303c6cd755.js → page-08019efef111ed2e.js} +1 -1
  47. package/apps/pwa/out/_next/static/chunks/app/vault/page-0260ffc7eda50db1.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-97c7361cc2a19319.js → page-980ce6fe36ad95eb.js} +1 -1
  49. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-20162174b77f96cf.js → page-99c4e6c7345a9156.js} +1 -1
  50. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-8c6901833f897496.js → page-0ab42e0f05823013.js} +1 -1
  51. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ad9220422d20ba33.js +1 -0
  52. package/apps/pwa/out/approvals/index.html +1 -1
  53. package/apps/pwa/out/approvals/index.txt +13 -13
  54. package/apps/pwa/out/autopilot/index.html +1 -1
  55. package/apps/pwa/out/autopilot/index.txt +13 -13
  56. package/apps/pwa/out/board/index.html +1 -1
  57. package/apps/pwa/out/board/index.txt +13 -13
  58. package/apps/pwa/out/botlab/index.html +1 -1
  59. package/apps/pwa/out/botlab/index.txt +13 -13
  60. package/apps/pwa/out/bots/index.html +1 -1
  61. package/apps/pwa/out/bots/index.txt +13 -13
  62. package/apps/pwa/out/ceo/index.html +1 -1
  63. package/apps/pwa/out/ceo/index.txt +13 -13
  64. package/apps/pwa/out/chat/index.html +1 -1
  65. package/apps/pwa/out/chat/index.txt +13 -13
  66. package/apps/pwa/out/consult/index.html +1 -1
  67. package/apps/pwa/out/consult/index.txt +13 -13
  68. package/apps/pwa/out/control/index.html +1 -1
  69. package/apps/pwa/out/control/index.txt +13 -13
  70. package/apps/pwa/out/decisions/index.html +1 -1
  71. package/apps/pwa/out/decisions/index.txt +13 -13
  72. package/apps/pwa/out/design-check/index.html +1 -1
  73. package/apps/pwa/out/design-check/index.txt +13 -13
  74. package/apps/pwa/out/editor/index.html +1 -1
  75. package/apps/pwa/out/editor/index.txt +13 -13
  76. package/apps/pwa/out/exec/index.html +1 -1
  77. package/apps/pwa/out/exec/index.txt +13 -13
  78. package/apps/pwa/out/field/index.html +1 -1
  79. package/apps/pwa/out/field/index.txt +13 -13
  80. package/apps/pwa/out/healthz +1 -1
  81. package/apps/pwa/out/index.html +1 -1
  82. package/apps/pwa/out/index.txt +13 -13
  83. package/apps/pwa/out/inside/index.html +1 -1
  84. package/apps/pwa/out/inside/index.txt +13 -13
  85. package/apps/pwa/out/intake/index.html +1 -1
  86. package/apps/pwa/out/intake/index.txt +13 -13
  87. package/apps/pwa/out/live/index.html +1 -1
  88. package/apps/pwa/out/live/index.txt +13 -13
  89. package/apps/pwa/out/live-run/index.html +1 -1
  90. package/apps/pwa/out/live-run/index.txt +13 -13
  91. package/apps/pwa/out/loops/index.html +1 -1
  92. package/apps/pwa/out/loops/index.txt +13 -13
  93. package/apps/pwa/out/market/index.html +1 -1
  94. package/apps/pwa/out/market/index.txt +13 -13
  95. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  96. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  97. package/apps/pwa/out/morning/index.html +1 -1
  98. package/apps/pwa/out/morning/index.txt +13 -13
  99. package/apps/pwa/out/observatory/index.html +1 -1
  100. package/apps/pwa/out/observatory/index.txt +13 -13
  101. package/apps/pwa/out/ops/checklist/index.html +1 -1
  102. package/apps/pwa/out/ops/checklist/index.txt +13 -13
  103. package/apps/pwa/out/ops/release/index.html +1 -1
  104. package/apps/pwa/out/ops/release/index.txt +13 -13
  105. package/apps/pwa/out/ops/seats/index.html +1 -1
  106. package/apps/pwa/out/ops/seats/index.txt +13 -13
  107. package/apps/pwa/out/outputs/index.html +1 -1
  108. package/apps/pwa/out/outputs/index.txt +13 -13
  109. package/apps/pwa/out/reflection/index.html +1 -1
  110. package/apps/pwa/out/reflection/index.txt +14 -14
  111. package/apps/pwa/out/scheduler/index.html +1 -1
  112. package/apps/pwa/out/scheduler/index.txt +13 -13
  113. package/apps/pwa/out/sessions/index.html +1 -1
  114. package/apps/pwa/out/sessions/index.txt +13 -13
  115. package/apps/pwa/out/settings/devices/index.html +1 -1
  116. package/apps/pwa/out/settings/devices/index.txt +13 -13
  117. package/apps/pwa/out/settings/index.html +1 -1
  118. package/apps/pwa/out/settings/index.txt +13 -13
  119. package/apps/pwa/out/setup/done/index.html +1 -1
  120. package/apps/pwa/out/setup/done/index.txt +13 -13
  121. package/apps/pwa/out/setup/index.html +1 -1
  122. package/apps/pwa/out/setup/index.txt +13 -13
  123. package/apps/pwa/out/share/index.html +1 -1
  124. package/apps/pwa/out/share/index.txt +13 -13
  125. package/apps/pwa/out/showroom/index.html +1 -1
  126. package/apps/pwa/out/showroom/index.txt +13 -13
  127. package/apps/pwa/out/tasks/index.html +1 -1
  128. package/apps/pwa/out/tasks/index.txt +13 -13
  129. package/apps/pwa/out/term/index.html +1 -1
  130. package/apps/pwa/out/term/index.txt +13 -13
  131. package/apps/pwa/out/today/index.html +1 -1
  132. package/apps/pwa/out/today/index.txt +13 -13
  133. package/apps/pwa/out/trace/index.html +1 -1
  134. package/apps/pwa/out/trace/index.txt +13 -13
  135. package/apps/pwa/out/vault/index.html +1 -1
  136. package/apps/pwa/out/vault/index.txt +13 -13
  137. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  138. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  139. package/apps/pwa/out/workflows/index.html +1 -1
  140. package/apps/pwa/out/workflows/index.txt +13 -13
  141. package/apps/pwa/out/workspace/index.html +1 -1
  142. package/apps/pwa/out/workspace/index.txt +13 -13
  143. package/apps/pwa/out/worktrees/index.html +1 -1
  144. package/apps/pwa/out/worktrees/index.txt +13 -13
  145. package/package.json +1 -1
  146. package/scripts/ci-typecheck-changed.ts +81 -2
  147. package/scripts/coord-msg-bridge.ts +209 -0
  148. package/scripts/coord-post.sh +40 -7
  149. package/scripts/coord-requests-bridge.ts +10 -5
  150. package/scripts/expose-rubric-suggest.ts +1 -1
  151. package/scripts/measure-tsc-affected-roots.ts +10 -1
  152. package/scripts/release-loop/export-check-node.ts +8 -5
  153. package/scripts/release-loop/node-verdict.ts +7 -2
  154. package/src/agent/task-notification.ts +30 -1
  155. package/src/agent-substrate/review-observation.ts +9 -2
  156. package/src/agent-substrate/self-review-cli.ts +28 -6
  157. package/src/boot/daemon-tools/self-implement.ts +72 -4
  158. package/src/budget/llm-cost.ts +5 -1
  159. package/src/cli/control-cli.ts +29 -0
  160. package/src/cli/doctor-cli.ts +90 -8
  161. package/src/cli/msg-cli.ts +15 -5
  162. package/src/cli/pr-cli.ts +53 -0
  163. package/src/cli/pr-land-overlap-evidence.ts +18 -10
  164. package/src/cli/release-cli.ts +32 -5
  165. package/src/cli/seat-cli.ts +121 -0
  166. package/src/cli/self-utterances-cli.ts +140 -0
  167. package/src/cli/tasks-cli.ts +139 -9
  168. package/src/context-card/cell.ts +40 -0
  169. package/src/dashboard/boot-timing.ts +35 -0
  170. package/src/dashboard/index.ts +86 -24
  171. package/src/dashboard/input/chat-main-plain-turn-runtime.ts +26 -0
  172. package/src/dashboard/slash-runtime/dashboard-handlers.ts +6 -0
  173. package/src/debug/log.ts +1 -1
  174. package/src/domains/release-tool.ts +29 -12
  175. package/src/domains/surface-events.ts +16 -0
  176. package/src/elanous-config-dir.ts +2 -2
  177. package/src/fleet/host-doctor.ts +73 -2
  178. package/src/graph-runner/runner.ts +3 -1
  179. package/src/harness/harness-cli-command.ts +46 -4
  180. package/src/harness/harness-stop.ts +11 -7
  181. package/src/index.ts +35 -4
  182. package/src/instance/current.ts +2 -6
  183. package/src/instance/resolve.ts +5 -6
  184. package/src/llm/openai-sampling-fields.ts +89 -0
  185. package/src/llm/runtime-model-family.ts +61 -0
  186. package/src/llm/zai-coding-plan.ts +27 -1
  187. package/src/llm.ts +13 -89
  188. package/src/machines/machine-ledger.ts +31 -3
  189. package/src/maturity/cli-maturity.ts +13 -3
  190. package/src/msg/msg-store.ts +69 -7
  191. package/src/ops/overview-collect.ts +3 -2
  192. package/src/ops/overview-owner.ts +29 -0
  193. package/src/plugin-hooks/shell-hook.ts +17 -2
  194. package/src/plugin-missions/evaluator-runner.ts +18 -2
  195. package/src/plugins/install/plugin-install.ts +11 -1
  196. package/src/prompt-enhance/enhance.ts +11 -0
  197. package/src/release-loop/checklist.ts +41 -4
  198. package/src/release-loop/feature-store.ts +10 -9
  199. package/src/seat-loop/seat-loop.ts +5 -1
  200. package/src/self-dev/draft-sweep.ts +23 -14
  201. package/src/self-dev/orchestrate.ts +6 -2
  202. package/src/self-dev/run-supervisor.ts +35 -1
  203. package/src/self-implement/goal-author.ts +3 -27
  204. package/src/self-implement/goal-command-execution.ts +26 -0
  205. package/src/self-implement/orchestrator.ts +2 -1
  206. package/src/self-implement/pwa-import-graph.ts +194 -7
  207. package/src/self-implement/self-implement-runtime.ts +126 -21
  208. package/src/session/index.ts +14 -4
  209. package/src/session-runtime/index.ts +55 -11
  210. package/src/task-agent/cover.ts +28 -9
  211. package/src/task-agent/live-moves.ts +14 -6
  212. package/src/task-agent/rejudge-on-head.ts +220 -23
  213. package/src/task-agent/review-result-capture.ts +64 -1
  214. package/src/task-agent/shadow.ts +137 -22
  215. package/src/task-agent/task-hand.ts +18 -2
  216. package/src/task-orchestrator/surfaces/self-implement-pod.ts +1 -0
  217. package/src/tool-runtime/tool-search-runtime.ts +25 -0
  218. package/src/tui.ts +20 -1
  219. package/src/user-config.ts +3 -59
  220. package/src/version/packed-revision.json +1 -1
  221. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-f08f6529d09ffae1.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/app/board/page-f000d54d258f9ef6.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/app/botlab/page-b12149c51f7d04b1.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/app/bots/page-22f674e7e4c2c9d5.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/app/ceo/page-8888733e40cea6d1.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/chat/page-9717ec6964b92276.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/app/design-check/page-dea4b795741126fd.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/app/editor/page-35e05d7450461a67.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/app/inside/page-b2638013c0bf7dfd.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/app/layout-c44f93a41167536f.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/app/loops/page-1b810474507f3702.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/app/observatory/page-6a14333d1b1b3967.js +0 -1
  233. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-8c91a9a66c9ec90a.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-4e634c5b98fd4e93.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-ed3070d637850548.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/app/reflection/page-128458c8d86fbb5f.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/app/sessions/page-cf0c2b0183991d09.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/app/share/page-89cb33bced824e4a.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/app/tasks/page-814394dcbae789b1.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/app/term/page-6a87325c87f09dd2.js +0 -1
  241. package/apps/pwa/out/_next/static/chunks/app/vault/page-087ceeb975ef272b.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-05a4b2c9b91034aa.js +0 -1
  243. /package/apps/pwa/out/_next/static/{dRS6lEhSRRBr1abbzp7fJ → Ev2jivDMTSoEc-5rIXdcy}/_buildManifest.js +0 -0
  244. /package/apps/pwa/out/_next/static/{dRS6lEhSRRBr1abbzp7fJ → Ev2jivDMTSoEc-5rIXdcy}/_ssgManifest.js +0 -0
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env bun
2
+ /** One channel fetch, one recipient's durable cursor, and idempotent msg insertion. */
3
+ import { Database } from 'bun:sqlite';
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { effectiveInstanceRoot } from '../src/instance/resolve.js';
8
+ import { SEAT_ALIASES, canonicalSeatId, defaultMsgStorePath, openMsgStore } from '../src/msg/msg-store.js';
9
+ import trackData from './coord-tracks.json';
10
+ import { fetchCoordChannelComments, type CoordChannelComment } from './coord-requests-bridge.js';
11
+
12
+ const seats = new Set(trackData.tracks.map((track) => track.id));
13
+ const allSeats = [...seats];
14
+ type Options = { seat: string; channel: string; since?: string; dryRun: boolean };
15
+ type Cursor = { lastId: number; createdAt: string; sinceFloor?: string; parserHash?: string };
16
+ type Routed = { sender: string; recipients: string[] };
17
+
18
+ function parseOptions(args: string[]): Options {
19
+ let seat = '', channel = '26146', since: string | undefined;
20
+ let dryRun = false;
21
+ for (let i = 0; i < args.length; i++) {
22
+ const arg = args[i];
23
+ if (arg === '--seat' && args[i + 1]) seat = canonicalSeatId(args[++i]!);
24
+ else if (arg === '--channel' && args[i + 1]) channel = args[++i]!;
25
+ else if (arg === '--since' && args[i + 1]) since = args[++i]!;
26
+ else if (arg === '--dry-run') dryRun = true;
27
+ else if (arg === '--json') continue; // output formatting belongs to the CLI entry point
28
+ else throw new Error(`unknown option or missing value: ${arg}`);
29
+ }
30
+ if (!seats.has(seat)) throw new Error('--seat must name a channel seat');
31
+ if (!/^[1-9]\d*$/.test(channel)) throw new Error('--channel must be a positive integer');
32
+ if (since && (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(since)
33
+ || !Number.isFinite(Date.parse(since)))) throw new Error('--since must be an ISO timestamp');
34
+ return { seat, channel, ...(since ? { since } : {}), dryRun };
35
+ }
36
+
37
+ /** Only the address part of the first line and explicit body mentions route messages. */
38
+ export function routeCoordComment(body: string): Routed | null {
39
+ const [header = '', ...bodyLines] = body.split(/\r?\n/);
40
+ const match = /^\s*(?:#+\s*)?\*\*\[([^\]]+)\]\*\*[^\n]*?→\s*(.+)$/.exec(header);
41
+ if (!match) return null;
42
+ let sender: string;
43
+ try { sender = canonicalSeatId(match[1]!); } catch { return null; }
44
+ const recipients = new Set<string>();
45
+ // The channel also uses `MK(⊕ UX·TC)` for a primary addressee plus copied seats.
46
+ const address = match[2]!.replace(/\(⊕\s*([^)]*)\)/g, ' · cc $1');
47
+ const fields = address.split('·').map((part) => part.trim());
48
+ let inAddress = true;
49
+ for (const field of fields) {
50
+ const cc = /^cc\s+(.+)$/i.exec(field);
51
+ if (!inAddress && !cc) continue;
52
+ const tokens = (cc?.[1] ?? field).split(/[\s,]+/).filter(Boolean);
53
+ // A title may follow the addressee without a middle dot, for example
54
+ // `→ OP 🎓 확인:`. Only the leading seat tokens belong to the address.
55
+ const firstNonSeat = tokens.findIndex((name) => !seats.has(name)
56
+ && name !== '전체' && name !== '전원');
57
+ const names = tokens.slice(0, firstNonSeat < 0 ? tokens.length : firstNonSeat);
58
+ if (!names.length) {
59
+ if (!cc) inAddress = false;
60
+ continue;
61
+ }
62
+ if (names.includes('전체') || names.includes('전원')) {
63
+ for (const seat of allSeats) recipients.add(seat);
64
+ } else for (const name of names) recipients.add(name);
65
+ if (!cc && names.length < tokens.length) inAddress = false;
66
+ }
67
+ for (const match of bodyLines.join('\n').matchAll(/🙋\s*([A-Z][A-Z0-9_-]*)/g)) {
68
+ if (seats.has(match[1]!)) recipients.add(match[1]!);
69
+ }
70
+ recipients.delete(sender);
71
+ return { sender, recipients: [...recipients] };
72
+ }
73
+
74
+ // Include the sender canonicalizer and its aliases: either can change which
75
+ // comments route. A harmless build-only hash change causes one safe replay.
76
+ const parserHash = createHash('sha256').update(routeCoordComment.toString())
77
+ .update(canonicalSeatId.toString()).update(JSON.stringify(SEAT_ALIASES))
78
+ .update(JSON.stringify(allSeats)).digest('hex');
79
+ const REPLAY_WINDOW_MS = 6 * 60 * 60 * 1000;
80
+
81
+ function validComment(value: CoordChannelComment): void {
82
+ if (!value || !Number.isSafeInteger(value.id) || value.id <= 0 || typeof value.body !== 'string'
83
+ || typeof value.created_at !== 'string' || !Number.isFinite(Date.parse(value.created_at))
84
+ || typeof value.html_url !== 'string') throw new Error('invalid channel comment');
85
+ }
86
+
87
+ function readCursor(path: string): Cursor | null {
88
+ if (!existsSync(path)) return null;
89
+ // Each run appends one small record. Concurrent runs cannot replace a newer cursor
90
+ // with an older snapshot; the highest comment ID remains the effective cursor.
91
+ let latest: Cursor | null = null;
92
+ // A process can stop mid-append. The next complete record begins with the
93
+ // stable key prefix and remains readable even when joined to a torn suffix.
94
+ const records = readFileSync(path, 'utf8').replaceAll('{"lastId":', '\n{"lastId":').split('\n').filter(Boolean);
95
+ for (const line of records) {
96
+ let value: Partial<Cursor>;
97
+ try { value = JSON.parse(line) as Partial<Cursor>; }
98
+ catch (error) {
99
+ if (error instanceof SyntaxError) continue;
100
+ throw error;
101
+ }
102
+ if (!Number.isSafeInteger(value.lastId) || value.lastId! < 0 || typeof value.createdAt !== 'string'
103
+ || !Number.isFinite(Date.parse(value.createdAt)) || (value.sinceFloor !== undefined
104
+ && (typeof value.sinceFloor !== 'string' || !Number.isFinite(Date.parse(value.sinceFloor))))
105
+ || (value.parserHash !== undefined && !/^[a-f0-9]{64}$/.test(value.parserHash))) {
106
+ throw new Error('invalid channel cursor');
107
+ }
108
+ // On equal IDs, keep the current parser marker even if an older overlapping
109
+ // process appends its stale cursor afterward. A current marker also replaces
110
+ // an older marker at the same ID after a replay with no newly fetched comment.
111
+ if (!latest || value.lastId! > latest.lastId || (value.lastId === latest.lastId
112
+ && (value.parserHash === parserHash || latest.parserHash !== parserHash))) latest = {
113
+ lastId: value.lastId!, createdAt: value.createdAt,
114
+ ...(value.sinceFloor ? { sinceFloor: value.sinceFloor } : {}),
115
+ ...(value.parserHash ? { parserHash: value.parserHash } : {}),
116
+ };
117
+ }
118
+ return latest;
119
+ }
120
+
121
+ function saveCursor(path: string, cursor: Cursor): void {
122
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
123
+ appendFileSync(path, `${JSON.stringify(cursor)}\n`, { mode: 0o600 });
124
+ }
125
+
126
+ function hasProvenance(db: Database, key: string): boolean {
127
+ return db.query('SELECT 1 FROM msg_provenance WHERE provenance_key = ?').get(key) !== null;
128
+ }
129
+
130
+ export function runBridge(args: string[], fetch = fetchCoordChannelComments): {
131
+ seat: string; read: number; picked: number; added: number; existing: number; cursor: number;
132
+ } {
133
+ return runBridgeWithClock(args, fetch, Date.now);
134
+ }
135
+
136
+ export function runBridgeWithClock(args: string[], fetch: typeof fetchCoordChannelComments, now: () => number): {
137
+ seat: string; read: number; picked: number; added: number; existing: number; cursor: number;
138
+ } {
139
+ const options = parseOptions(args);
140
+ const cursorPath = join(effectiveInstanceRoot(), 'msg', `coord-bridge-${options.channel}-${options.seat}.json`);
141
+ const cursor = readCursor(cursorPath);
142
+ const replayFloor = cursor && cursor.parserHash !== parserHash ? now() - REPLAY_WINDOW_MS : undefined;
143
+ // GitHub filters `since` by updated_at; the creation-time check below limits
144
+ // parser replay to comments created in the last six hours, even if older
145
+ // comments were edited recently. Numeric IDs decide normal progress.
146
+ // GitHub's `since` is exclusive. Fetch one second before the replay floor
147
+ // so a comment created exactly on the six-hour boundary remains visible.
148
+ const since = cursor ? new Date(Math.min(Date.parse(cursor.createdAt) - 1000,
149
+ replayFloor === undefined ? Infinity : replayFloor - 1000)).toISOString() : options.since;
150
+ const comments = fetch(options.channel, since);
151
+ for (const comment of comments) validComment(comment); // no writes if fetching/parsing fails mid-page
152
+ // GitHub's `since` filters updated_at, so a caller's creation-time floor persists across runs.
153
+ const unseen = comments.filter((comment) => comment.id > (cursor?.lastId ?? 0));
154
+ const sinceFloor = cursor?.sinceFloor ?? (cursor ? undefined : options.since);
155
+ const pending = comments.filter((comment) =>
156
+ (comment.id > (cursor?.lastId ?? 0) || (replayFloor !== undefined
157
+ && Date.parse(comment.created_at) >= replayFloor))
158
+ && (!sinceFloor || Date.parse(comment.created_at) >= Date.parse(sinceFloor)));
159
+ const candidate = unseen.reduce<CoordChannelComment | null>((last, comment) =>
160
+ !last || comment.id > last.id ? comment : last, null);
161
+ let picked = 0, added = 0, existing = 0;
162
+ const dbPath = defaultMsgStorePath();
163
+ if (options.dryRun) {
164
+ const db = existsSync(dbPath) ? new Database(dbPath, { readonly: true }) : null;
165
+ try {
166
+ for (const comment of pending) {
167
+ const route = routeCoordComment(comment.body);
168
+ if (!route?.recipients.includes(options.seat)) continue;
169
+ picked++;
170
+ if (db && hasProvenance(db, `coord:${comment.id}:${options.seat}`)) existing++;
171
+ else added++;
172
+ }
173
+ } finally { db?.close(); }
174
+ } else {
175
+ const store = openMsgStore();
176
+ try {
177
+ store.db.transaction(() => {
178
+ for (const comment of pending) {
179
+ const route = routeCoordComment(comment.body);
180
+ if (!route?.recipients.includes(options.seat)) continue;
181
+ picked++;
182
+ const key = `coord:${comment.id}:${options.seat}`;
183
+ if (hasProvenance(store.db, key)) existing++;
184
+ else added++;
185
+ store.appendWithProvenance({ from: route.sender, to: options.seat, body: comment.body, kind: 'coord' }, key);
186
+ }
187
+ }).immediate();
188
+ } finally { store.close(); }
189
+ if (candidate || (!cursor && sinceFloor) || replayFloor !== undefined) {
190
+ const createdAt = candidate?.created_at ?? cursor?.createdAt ?? sinceFloor!;
191
+ saveCursor(cursorPath, { lastId: candidate?.id ?? cursor?.lastId ?? 0,
192
+ createdAt: sinceFloor && Date.parse(createdAt) < Date.parse(sinceFloor) ? sinceFloor : createdAt,
193
+ ...(sinceFloor ? { sinceFloor } : {}), parserHash });
194
+ }
195
+ }
196
+ return { seat: options.seat, read: comments.length, picked, added, existing,
197
+ cursor: options.dryRun ? cursor?.lastId ?? 0 : readCursor(cursorPath)?.lastId ?? 0 };
198
+ }
199
+
200
+ if (import.meta.main) {
201
+ try {
202
+ const result = runBridge(process.argv.slice(2));
203
+ console.log(process.argv.includes('--json') ? JSON.stringify(result)
204
+ : `${result.seat}: read ${result.read}, picked ${result.picked}, added ${result.added}, existing ${result.existing}, cursor ${result.cursor}`);
205
+ } catch (error) {
206
+ console.error(error instanceof Error ? error.message : String(error));
207
+ process.exitCode = 1;
208
+ }
209
+ }
@@ -2,7 +2,10 @@
2
2
  # coord-post.sh — 조율 채널 발신 «한 자리». 시각과 명시적 값을 본문에 안전하게 넣는다.
3
3
  # 사용: scripts/coord-post.sh [--set KEY=VALUE]... <본문파일>
4
4
  set -u
5
- CANON="docs/manual/MANUAL-multi-agent-coordination-channel-2026-07-28.md"
5
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || { echo '⛔ 스크립트 위치를 못 읽었다' >&2; exit 3; }
6
+ REPO_ROOT=$(dirname -- "$SCRIPT_DIR")
7
+ CLI="$REPO_ROOT/bin/elanous.mjs"
8
+ CANON="$REPO_ROOT/docs/manual/MANUAL-multi-agent-coordination-channel-2026-07-28.md"
6
9
 
7
10
  resolve_pr() {
8
11
  [ -f "$CANON" ] || return 0
@@ -40,7 +43,7 @@ BODY="${1:-}"
40
43
  grep -q '{{TS}}' "$BODY" || echo "⚠️ 본문에 {{TS}} 자리표시가 «없다» — 시각을 손으로 적지 않았는지 확인하라" >&2
41
44
 
42
45
  # 트랙 신원은 «정본 한 곳»에서 읽는다 — scripts/coord-tracks.json. 못 읽으면 추측하지 않고 선다.
43
- TRACKS_FILE="$(dirname "$0")/coord-tracks.json"
46
+ TRACKS_FILE="$SCRIPT_DIR/coord-tracks.json"
44
47
  # 신원 = 2글자 id(OP·MK·TC·UX…) ⊕ 옛 한 글자 alias(S·T·O·F — 전환기엔 받되 경고). 목록은 정본에서만.
45
48
  IDS=$(grep -o '"id": *"[A-Z][A-Z]*"' "$TRACKS_FILE" 2>/dev/null | sed 's/.*"\([A-Z]*\)"$/\1/' | tr '\n' ' ')
46
49
  ALIASES=$(grep -o '"alias": *"[A-Z][A-Z]*"' "$TRACKS_FILE" 2>/dev/null | sed 's/.*"\([A-Z]*\)"$/\1/' | tr '\n' ' ')
@@ -192,7 +195,7 @@ if [ "${COORD_DRY_RUN:-}" = "1" ]; then
192
195
  exit 0
193
196
  fi
194
197
 
195
- POST_OUTPUT=$(bun bin/elanous.mjs gh pr comment "$PR" --body-file "$TMP" 2>&1)
198
+ POST_OUTPUT=$(bun "$CLI" gh pr comment "$PR" --body-file "$TMP" 2>&1)
196
199
  RC=$?
197
200
  printf '%s\n' "$POST_OUTPUT" | tail -2
198
201
  VIA=""
@@ -204,11 +207,11 @@ if [ "$RC" -ne 0 ] && printf '%s' "$POST_OUTPUT" | grep -qiE 'rate limit|Somethi
204
207
  FIRSTOUT=$(head -1 "$TMP")
205
208
  # issues 코멘트 목록은 오래된 순이고 정렬 인자를 안 받는다 ⇒ 최근 10분만 since 로 자른다.
206
209
  SINCE10=$(date -u -v-10M +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '-10 min' +%Y-%m-%dT%H:%M:%SZ)
207
- if bun bin/elanous.mjs gh api "repos/{owner}/{repo}/issues/$PR/comments?since=$SINCE10&per_page=100" --jq '.[].body | split("\n")[0]' 2>/dev/null | grep -qxF -- "$FIRSTOUT"; then
210
+ if bun "$CLI" gh api "repos/{owner}/{repo}/issues/$PR/comments?since=$SINCE10&per_page=100" --jq '.[].body | split("\n")[0]' 2>/dev/null | grep -qxF -- "$FIRSTOUT"; then
208
211
  echo "[coord-post] 같은 첫 줄이 이미 채널에 있다 — GraphQL 이 실제로는 올렸다(이중 발신 안 함)" >&2
209
212
  exit 0
210
213
  fi
211
- POST_OUTPUT=$(bun bin/elanous.mjs gh api -X POST "repos/{owner}/{repo}/issues/$PR/comments" -F "body=@$TMP" -q .html_url 2>&1)
214
+ POST_OUTPUT=$(bun "$CLI" gh api -X POST "repos/{owner}/{repo}/issues/$PR/comments" -F "body=@$TMP" -q .html_url 2>&1)
212
215
  RC=$?
213
216
  printf '%s\n' "$POST_OUTPUT" | tail -2
214
217
  VIA=" · REST 폴백"
@@ -224,13 +227,43 @@ POST_URL=$(printf '%s\n' "$POST_OUTPUT" | grep -oE 'https://github\.com/[^[:spac
224
227
  # test universe (OP 10-04 18:00). An explicit ELANOUS_STATE_DIR is the caller's chosen universe and wins;
225
228
  # otherwise prefer the installed CLI (resolves prod from any tree), else pin the source CLI to prod.
226
229
  if [ -n "${ELANOUS_STATE_DIR:-}" ]; then
227
- RECORD=(bun bin/elanous.mjs coord event record)
230
+ RECORD=(bun "$CLI" coord event record)
231
+ MESSAGE=(bun "$CLI" msg post)
228
232
  elif command -v eln >/dev/null 2>&1; then
229
233
  RECORD=(eln coord event record)
234
+ MESSAGE=(eln msg post)
230
235
  else
231
- RECORD=(env ELANOUS_STATE_DIR="$HOME/.elanous" bun bin/elanous.mjs --config-dir "$HOME/.elanous" coord event record)
236
+ RECORD=(env ELANOUS_STATE_DIR="$HOME/.elanous" bun "$CLI" --config-dir "$HOME/.elanous" coord event record)
237
+ MESSAGE=(env ELANOUS_STATE_DIR="$HOME/.elanous" bun "$CLI" --config-dir "$HOME/.elanous" msg post)
232
238
  fi
233
239
  if ! "${RECORD[@]}" --seat "$ID" --header "$(head -1 "$TMP")" --url "$POST_URL" >/dev/null 2>&1; then
234
240
  echo '⚠️ 맥락 원장 기록 실패(발신은 됐다)' >&2
235
241
  fi
242
+ # S1's channel reader and this sender must agree on recipients. Keep the complete sent body
243
+ # for msg, while the provenance key makes a later S1 scan (or a partial retry) idempotent.
244
+ COMMENT_ID=${POST_URL##*#issuecomment-}
245
+ if [ "$COMMENT_ID" = "$POST_URL" ] || ! [[ "$COMMENT_ID" =~ ^[0-9]+$ ]]; then
246
+ echo '⚠️ 채널 댓글 ID를 못 읽어 msg 반영을 건너뛴다(발신은 됐다 · S1 다리가 회수)' >&2
247
+ exit 0
248
+ fi
249
+ ROUTES=$(bun -e '
250
+ import { readFileSync } from "node:fs";
251
+ import { join } from "node:path";
252
+ import { pathToFileURL } from "node:url";
253
+ const { routeCoordComment } = await import(pathToFileURL(join(process.argv[3], "coord-msg-bridge.ts")).href);
254
+ const { canonicalSeatId } = await import(pathToFileURL(join(process.argv[3], "../src/msg/msg-store.ts")).href);
255
+ const route = routeCoordComment(readFileSync(process.argv[2], "utf8"));
256
+ if (!route || route.sender !== canonicalSeatId(process.argv[1])) process.exit(2);
257
+ console.log(route.recipients.join("\n"));
258
+ ' "$ID" "$TMP" "$SCRIPT_DIR") || {
259
+ echo '⚠️ msg 수신자 해석 실패(발신은 됐다 · S1 다리가 회수)' >&2
260
+ exit 0
261
+ }
262
+ while IFS= read -r RECIPIENT; do
263
+ [ -n "$RECIPIENT" ] || continue
264
+ if ! "${MESSAGE[@]}" --from "$ID" --to "$RECIPIENT" --kind coord \
265
+ --origin-key "coord:$COMMENT_ID:$RECIPIENT" < "$TMP" >/dev/null; then
266
+ echo "⚠️ msg 원장 기록 실패: $RECIPIENT(발신은 됐다 · S1 다리가 회수)" >&2
267
+ fi
268
+ done <<< "$ROUTES"
236
269
  exit 0
@@ -7,10 +7,17 @@ import { registerStandaloneLogSink } from '../src/domains/standalone-log-sink.js
7
7
  import { effectiveInstanceRoot } from '../src/instance/resolve.js';
8
8
  import trackData from './coord-tracks.json';
9
9
 
10
- type Comment = { id: number; body: string; created_at: string; html_url: string };
10
+ export type CoordChannelComment = { id: number; body: string; created_at: string; html_url: string };
11
11
  type Options = { seat: string; channel: string; since?: string; dryRun: boolean; json: boolean };
12
12
  const seatIds = new Set(trackData.tracks.map((track) => track.id));
13
13
 
14
+ /** Shared channel retrieval. Callers validate the complete result before changing a ledger. */
15
+ export function fetchCoordChannelComments(channel: string, since?: string): CoordChannelComment[] {
16
+ const endpoint = `repos/{owner}/{repo}/issues/${channel}/comments${since ? `?since=${encodeURIComponent(since)}` : ''}`;
17
+ const output = execFileSync('gh', ['api', endpoint, '--paginate', '--jq', '.[]'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
18
+ return output.split('\n').filter(Boolean).map((line) => JSON.parse(line) as CoordChannelComment);
19
+ }
20
+
14
21
  function options(args: string[]): Options {
15
22
  let seat = '';
16
23
  let channel = '23032';
@@ -98,15 +105,13 @@ async function run(args: string[]): Promise<void> {
98
105
  seat = selected.seat;
99
106
  const { channel, since, dryRun, json } = selected;
100
107
  reason = 'gh';
101
- const endpoint = `repos/{owner}/{repo}/issues/${channel}/comments${since ? `?since=${encodeURIComponent(since)}` : ''}`;
102
- const output = execFileSync('gh', ['api', endpoint, '--paginate', '--jq', '.[]'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
103
- const comments = output.split('\n').filter(Boolean).map((line) => JSON.parse(line) as unknown);
108
+ const comments = fetchCoordChannelComments(channel, since);
104
109
  reason = 'ledger';
105
110
  const path = join(effectiveInstanceRoot(), 'seat-requests', 'requests.jsonl');
106
111
  const seen = existingSeatComments(path);
107
112
  let picked = 0, added = 0, existing = 0;
108
113
  for (const item of comments) {
109
- const comment = item as Partial<Comment>;
114
+ const comment = item as Partial<CoordChannelComment>;
110
115
  if (typeof comment.id !== 'number' || !Number.isSafeInteger(comment.id) || typeof comment.body !== 'string'
111
116
  || typeof comment.created_at !== 'string' || typeof comment.html_url !== 'string') throw new Error('gh api 댓글 필드가 잘못됐다');
112
117
  if (since && Date.parse(comment.created_at) < Date.parse(since)) continue;
@@ -28,7 +28,7 @@ function measure(run: () => Criterion): Criterion {
28
28
  export function suggestExposeRubric(root: string, files?: readonly string[], help?: HelpRunner, rulesPath?: string): Suggestion[] {
29
29
  const names = files ?? readdirSync(join(root, DOCS)).filter((name) => name.endsWith('.md')).map((name) => `${DOCS}/${name}`);
30
30
  const paths = [...new Set(names.map((name) => relative(root, resolve(root, name)).split(sep).join('/')))];
31
- if (paths.some((path) => !/^release\/public\/docs\/[^/]+\.md$/.test(path))) throw new Error('문서 경로는 release/public/docs/*.md 이어야 합니다');
31
+ if (paths.some((path) => !/^(?:release\/public\/docs\/[^/]+|docs\/marketing\/BLOG-[^/]+)\.md$/.test(path))) throw new Error('문서 경로는 release/public/docs/*.md 또는 docs/marketing/BLOG-*.md 이어야 합니다');
32
32
  type HelpResult = ReturnType<HelpRunner> & { failure?: string };
33
33
  let helpRuns = 0;
34
34
  const rootHelp: (args: string[]) => HelpResult = help ?? ((args) => {
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Measures the changed-file gate against the whole program on recently merged PRs.
3
+ * The narrowed run is the gate's default root check (changed .ts ∪ direct importers), so `missed` / `same` judge that mode:
4
+ * the run pins TSC_GATE_ROOTS to a non-`full` value and disables the rollback switch
5
+ * even when the caller's shell requests a full run.
6
+ * `missed > 0` is reverted without a code change by running the gate with env `TSC_GATE_ROOTS=full`.
7
+ */
1
8
  import { spawn } from 'node:child_process';
2
9
  import { mkdtempSync, rmSync } from 'node:fs';
3
10
  import { tmpdir } from 'node:os';
@@ -223,7 +230,9 @@ async function measurePr(repo: string, pr: Pr, run: Runner, platform: NodeJS.Pla
223
230
  const added = compareDiagnostics(current, baseline);
224
231
  if (added.kind === 'unavailable') throw new Error(added.reason);
225
232
  result.addedDiagnostics = added.added;
226
- const gateRun = await timed(run, platform, 'bun', ['scripts/ci-typecheck-changed.ts', '--local'], worktree, { ...process.env, TSC_BASE_REF: base });
233
+ const gateRun = await timed(run, platform, 'bun', ['scripts/ci-typecheck-changed.ts', '--local'], worktree, {
234
+ ...process.env, TSC_BASE_REF: base, TSC_GATE_ROOTS: 'affected', ELANOUS_TSC_AFFECTED_ROOTS: '1',
235
+ });
227
236
  result.narrowed = elapsed(gateRun, platform);
228
237
  result.gateExitCode = gateRun.status;
229
238
  const seen = gateDiagnostics(gateRun, worktree);
@@ -48,11 +48,14 @@ export function runExportCheck(run: CommandRunner = runCommand, repoRoot = proce
48
48
  const publicDir = join(out, 'export');
49
49
  symlinkSync(join(modulesRoot, 'node_modules'), join(publicDir, 'node_modules'), 'dir');
50
50
  const pwaModules = join(modulesRoot, 'apps', 'pwa', 'node_modules');
51
- if (existsSync(pwaModules) && existsSync(join(publicDir, 'apps', 'pwa'))) symlinkSync(pwaModules, join(publicDir, 'apps', 'pwa', 'node_modules'), 'dir');
52
- const built = run('bun', ['bin/elanous.mjs', 'nexus', 'build'], publicDir);
53
- result = built.status === 0 && existsSync(join(publicDir, 'apps', 'pwa', 'out', 'index.html'))
54
- ? { outcome: 'ok', verdict: 'pass', summary: `public export and PWA build ${commit.slice(0, 12)}`, step: 'build', tail: '' }
55
- : failed('build', 'PWA build or out/index.html missing', built);
51
+ if (!existsSync(pwaModules)) result = failed('build', 'PWA dependencies missing; run bun install --cwd apps/pwa in the release-loop repository');
52
+ else {
53
+ if (existsSync(join(publicDir, 'apps', 'pwa'))) symlinkSync(pwaModules, join(publicDir, 'apps', 'pwa', 'node_modules'), 'dir');
54
+ const built = run('bun', ['bin/elanous.mjs', 'nexus', 'build'], publicDir);
55
+ result = built.status === 0 && existsSync(join(publicDir, 'apps', 'pwa', 'out', 'index.html'))
56
+ ? { outcome: 'ok', verdict: 'pass', summary: `public export and PWA build ${commit.slice(0, 12)}`, step: 'build', tail: '' }
57
+ : failed('build', 'PWA build or out/index.html missing', built);
58
+ }
56
59
  }
57
60
  }
58
61
  }
@@ -48,9 +48,14 @@ export function lastResult(run: CommandResult): Record<string, unknown> | undefi
48
48
  } catch { return undefined; }
49
49
  }
50
50
 
51
- export function finishNode(node: string, version: string, result: Record<string, unknown> & { outcome: NodeOutcome; verdict: NodeVerdict; summary: string }): number {
51
+ export function finishNode(node: string, version: string, result: Record<string, unknown> & { outcome: NodeOutcome; verdict: NodeVerdict; summary: string; tail?: string }): number {
52
52
  debug.log(`release-loop.${node}`, 'result', { version, outcome: result.outcome });
53
- emitNodeResult(result);
53
+ if (result.outcome === 'fail') {
54
+ const summary = result.summary.replace(/[\r\n]+/g, ' ').trim();
55
+ const tail = result.tail?.trim() ? result.tail : summary;
56
+ debug.log('release.node', 'failed', { node, summary, tail });
57
+ emitNodeResult({ ...result, summary, tail });
58
+ } else emitNodeResult(result);
54
59
  return result.outcome === 'ok' ? 0 : result.outcome === 'fail' ? 1 : 2;
55
60
  }
56
61
 
@@ -62,11 +62,39 @@ export class TaskNotificationQueue {
62
62
  * listens to both runner events and a separate `onTaskDone` hook.
63
63
  * Keyed by taskId. */
64
64
  private seen = new Set<string>();
65
+ /** Wake listeners — fired once per REAL enqueue (not on seen-dup).
66
+ * TUI-SEAT-READY ③ first shard: one completion event = one wake
67
+ * signal so an idle parent can open the next turn. Opening the turn
68
+ * itself is deliberately NOT wired here (next shard). */
69
+ private wakeListeners = new Set<(n: PendingTaskNotification) => void>();
70
+
71
+ /** Register a wake listener; returns an unsubscribe function.
72
+ * Listeners survive `clear()` — they belong to the wiring, not the
73
+ * queue contents. */
74
+ onEnqueue(listener: (n: PendingTaskNotification) => void): () => void {
75
+ this.wakeListeners.add(listener);
76
+ return () => { this.wakeListeners.delete(listener); };
77
+ }
65
78
 
66
79
  enqueue(n: PendingTaskNotification): void {
67
80
  if (this.seen.has(n.taskId)) return;
68
81
  this.seen.add(n.taskId);
69
82
  this.queue.push(n);
83
+ debug.log('agent.task-notification', 'wake', { taskId: n.taskId, size: this.queue.length });
84
+ for (const listener of this.wakeListeners) {
85
+ try {
86
+ listener(n);
87
+ } catch (error) {
88
+ // fail-soft — a throwing wake listener must not lose the
89
+ // queued notification or break sibling listeners.
90
+ try {
91
+ debug.log('agent.task-notification', 'wake-listener-failed', {
92
+ taskId: n.taskId,
93
+ reason: error instanceof Error ? error.message : String(error),
94
+ });
95
+ } catch { /* */ }
96
+ }
97
+ }
70
98
  }
71
99
 
72
100
  /** Return all pending notifications AND clear the queue. Parents
@@ -85,7 +113,8 @@ export class TaskNotificationQueue {
85
113
  return this.queue.length;
86
114
  }
87
115
 
88
- /** Forget everything — tests use this for isolation. */
116
+ /** Forget everything — tests use this for isolation. Listeners are
117
+ * preserved: clear() empties queue + seen, not the wake wiring. */
89
118
  clear(): void {
90
119
  this.queue = [];
91
120
  this.seen.clear();
@@ -60,8 +60,11 @@ interface ReviewObservationInput {
60
60
  readonly fallbackAttempted?: boolean;
61
61
  /** 폴백이 실제로 시도한 백엔드. */
62
62
  readonly fallbackBackend?: string;
63
+ /** Explicitly requested primary model and the ACP backend that actually completed review. */
64
+ readonly requestedModel?: string;
65
+ readonly actualBackend?: string;
63
66
  /** 폴백을 시도하지 않은 이유. */
64
- readonly fallbackNotAttemptedReason?: 'explicit-acp' | 'primary-review-succeeded';
67
+ readonly fallbackNotAttemptedReason?: 'explicit-acp' | 'primary-review-succeeded' | 'explicit-model';
65
68
  /** 1차가 왜 못 돌았나 — 폴백의 «사인». */
66
69
  readonly fallbackTriggerReason?: string;
67
70
  /** Each failed ACP fallback attempt, retained in order for diagnosis. */
@@ -80,7 +83,9 @@ interface ReviewObservation {
80
83
  readonly reviewRoute?: 'api' | 'acp' | 'acp-fallback';
81
84
  readonly fallbackAttempted?: boolean;
82
85
  readonly fallbackBackend?: string;
83
- readonly fallbackNotAttemptedReason?: 'explicit-acp' | 'primary-review-succeeded';
86
+ readonly requestedModel?: string;
87
+ readonly actualBackend?: string;
88
+ readonly fallbackNotAttemptedReason?: 'explicit-acp' | 'primary-review-succeeded' | 'explicit-model';
84
89
  readonly fallbackTriggerReason?: string;
85
90
  readonly fallbackFailures?: readonly { backend: string; reason: string }[];
86
91
  readonly skippedAcpFallbackBackends?: readonly { backend: string; reason: string }[];
@@ -168,6 +173,8 @@ export function buildReviewObservation(input: ReviewObservationInput): ReviewObs
168
173
  ...(input.reviewRoute ? { reviewRoute: input.reviewRoute } : {}),
169
174
  ...(input.fallbackAttempted !== undefined ? { fallbackAttempted: input.fallbackAttempted } : {}),
170
175
  ...(input.fallbackBackend ? { fallbackBackend: safeLogText(input.fallbackBackend) } : {}),
176
+ ...(input.requestedModel ? { requestedModel: safeLogText(input.requestedModel) } : {}),
177
+ ...(input.actualBackend ? { actualBackend: safeLogText(input.actualBackend) } : {}),
171
178
  ...(input.fallbackNotAttemptedReason ? { fallbackNotAttemptedReason: input.fallbackNotAttemptedReason } : {}),
172
179
  ...(input.fallbackTriggerReason ? { fallbackTriggerReason: safeLogText(input.fallbackTriggerReason) } : {}),
173
180
  ...(input.fallbackFailures?.length ? {
@@ -24,7 +24,7 @@ import { ACP_BACKENDS, canonicalizeBackendId } from '../acp/backend-registry.js'
24
24
  import { buildManualReviewIntent, intentFromPr, prIntentSectionCoverage, reviewIntentTruncationFromPr, reviewIntentTruncationObservation } from './review-intent.js';
25
25
  import { budgetReviewerContext, reviewDiffBudgetObservation, splitDiffByFile, type ReviewImage, type ReviewInput, type ReviewerContextItem, type ReviewResult } from './pr-reviewer.js';
26
26
  import type { ReferencedFileReader } from '../self-implement/goal-file-reader.js';
27
- import type { SubscriptionReviewerSpec } from '../user-config.js';
27
+ import { getUserConfig, type SubscriptionReviewerSpec } from '../user-config.js';
28
28
  import { listRunLedgers, lookupPrGoalAcceptance, lookupRunLedger, type PrGoalAcceptanceLookup, type RunLedgerEntry, type RunLedgerLookup } from '../self-implement/run-ledger.js';
29
29
 
30
30
  /** SUBSCRIPTION-EXHAUST-REROUTE — API reviewer when the configured `roleLlm.reviewer` lane is unavailable (off codex). */
@@ -37,6 +37,8 @@ export interface SelfReviewCliOpts {
37
37
  acpModel?: string | undefined;
38
38
  acpBackend?: string | undefined;
39
39
  configuredAcpBackend?: string | undefined;
40
+ /** ACP review backends excluded from automatic fallback by `acp.reviewDenyBackends`. */
41
+ reviewDenyBackends?: string[] | undefined;
40
42
  /** `roleLlm.reviewer` (same resolution as the harness). Used only when no explicit reviewer flag is given. */
41
43
  roleReviewer?: SubscriptionReviewerSpec | undefined;
42
44
  acpTimeout?: string | undefined;
@@ -1120,10 +1122,11 @@ function roleAcpErrorReason(error: unknown): 'acp-handshake-timeout' | 'acp-erro
1120
1122
 
1121
1123
  type AcpFallbackSkip = { backend: string; reason: string };
1122
1124
 
1123
- function orderedAcpReviewFallbackBackends(configuredBackend: string | undefined): {
1125
+ function orderedAcpReviewFallbackBackends(configuredBackend: string | undefined, denyBackends: readonly string[] = []): {
1124
1126
  backends: string[];
1125
1127
  skipped: AcpFallbackSkip[];
1126
1128
  } {
1129
+ const denied = new Set(denyBackends.map((backend) => backend.trim().toLowerCase()).filter(Boolean));
1127
1130
  const candidates = [configuredBackend, ...Object.keys(ACP_BACKENDS)]
1128
1131
  .map((backend) => backend?.trim())
1129
1132
  .filter((backend): backend is string => Boolean(backend));
@@ -1135,6 +1138,10 @@ function orderedAcpReviewFallbackBackends(configuredBackend: string | undefined)
1135
1138
  const spec = ACP_BACKENDS[backend];
1136
1139
  if (!spec || seen.has(backend)) continue;
1137
1140
  seen.add(backend);
1141
+ if (denied.has(backend)) {
1142
+ skipped.push({ backend, reason: 'denied-by-acp.reviewDenyBackends' });
1143
+ continue;
1144
+ }
1138
1145
  if (spec.unsupportedReason) {
1139
1146
  skipped.push({ backend, reason: spec.unsupportedReason });
1140
1147
  continue;
@@ -1277,7 +1284,12 @@ async function runSelfReviewCliCommandPinned(
1277
1284
  ? { backend: opts.configuredAcpBackend, source: 'config' as const }
1278
1285
  : { backend: DEFAULT_REVIEW_BACKEND, source: 'default' as const };
1279
1286
  const acpBackend = acpBackendResolution.backend;
1280
- const automaticFallbacks = orderedAcpReviewFallbackBackends(opts.configuredAcpBackend);
1287
+ const requestedModel = opts.model?.trim() || undefined;
1288
+ const rawReviewDenyBackends = (getUserConfig().raw?.acp as { reviewDenyBackends?: unknown } | undefined)?.reviewDenyBackends;
1289
+ const configuredReviewDenyBackends = Array.isArray(rawReviewDenyBackends)
1290
+ ? rawReviewDenyBackends.filter((backend): backend is string => typeof backend === 'string')
1291
+ : [];
1292
+ const automaticFallbacks = orderedAcpReviewFallbackBackends(opts.configuredAcpBackend, opts.reviewDenyBackends ?? configuredReviewDenyBackends);
1281
1293
  const fallbackAcpBackends = opts.acpBackend
1282
1294
  ? [canonicalizeBackendId(opts.acpBackend)]
1283
1295
  : automaticFallbacks.backends;
@@ -1300,6 +1312,14 @@ async function runSelfReviewCliCommandPinned(
1300
1312
  const envModel = deps.envModel();
1301
1313
  const roleFallbackModel = !useAcp && !opts.model && !envModel && reviewerSource === 'role-config'
1302
1314
  ? deps.roleFallbackModel?.()?.trim() || undefined : undefined;
1315
+ if (requestedModel && /^glm-\d+(?:\.\d+)?$/i.test(requestedModel)) {
1316
+ const canonical = requestedModel.toLowerCase() === 'glm-5.3' ? 'openrouter/z-ai/glm-5.3' : undefined;
1317
+ const suggestion = canonical ? ` 정본 모델 ID 후보: ${canonical}` : ' `local:<model>` 또는 공급자 정본 ID를 지정하세요.';
1318
+ const message = `모델 ID가 모호합니다: ${requestedModel}.${suggestion} 짧은 별칭을 공급자 정본 ID로 바꿔 다시 시도하세요.`;
1319
+ deps.log('model-rejected', { requestedModel: safeLogText(requestedModel), reason: 'ambiguous-model-id', ...(canonical ? { canonicalModelCandidate: canonical } : {}) }, { level: 'warn' });
1320
+ deps.error(message);
1321
+ return { results: opts.json ? [{ error: message, requestedModel }] : [], failed: 1 };
1322
+ }
1303
1323
  const model = useAcp ? `acp:${acpBackend}${acpModel ? `/${acpModel}` : ''}` : (opts.model || envModel || roleFallbackModel || tierModel('better'));
1304
1324
  let roleReviewer: { llm: (prompt: string, images?: readonly ReviewImage[]) => Promise<string>; model: string } | undefined;
1305
1325
  let roleReviewerUnavailableReason: string | undefined;
@@ -1558,9 +1578,9 @@ async function runSelfReviewCliCommandPinned(
1558
1578
  let fallbackAttempted = false;
1559
1579
  let fallbackBackend: string | undefined;
1560
1580
  let fallbackTriggerReason: string | undefined;
1561
- let fallbackNotAttemptedReason: 'explicit-acp' | 'primary-review-succeeded' | undefined;
1581
+ let fallbackNotAttemptedReason: 'explicit-acp' | 'primary-review-succeeded' | 'explicit-model' | undefined;
1562
1582
  const fallbackFailures: Array<{ backend: string; reason: string }> = [];
1563
- if (!useAcp && !roleReview && review.reviewed !== true) {
1583
+ if (!useAcp && !roleReview && !opts.acpModel?.trim() && (!requestedModel || Boolean(opts.acpBackend)) && review.reviewed !== true) {
1564
1584
  fallbackAttempted = true;
1565
1585
  fallbackTriggerReason = safeLogText(review.failureReason || 'reviewed=false', 400);
1566
1586
  for (const backend of fallbackAcpBackends) {
@@ -1569,6 +1589,7 @@ async function runSelfReviewCliCommandPinned(
1569
1589
  pr: safeLogText(pr),
1570
1590
  failureReason: fallbackTriggerReason,
1571
1591
  fallbackBackend: safeLogText(backend),
1592
+ requestedModel: safeLogText(primaryModel),
1572
1593
  }, { level: 'warn' });
1573
1594
  try {
1574
1595
  const fallbackReview = await deps.reviewPullRequest(
@@ -1598,12 +1619,13 @@ async function runSelfReviewCliCommandPinned(
1598
1619
  ...(reviewCwdPinFallbackReason ? { reviewCwdPinFallbackReason } : {}),
1599
1620
  };
1600
1621
  if (!fallbackAttempted) {
1601
- fallbackNotAttemptedReason = useAcp ? 'explicit-acp' : 'primary-review-succeeded';
1622
+ fallbackNotAttemptedReason = useAcp ? 'explicit-acp' : requestedModel && review.reviewed !== true ? 'explicit-model' : 'primary-review-succeeded';
1602
1623
  }
1603
1624
  const reviewProvenance = {
1604
1625
  reviewRoute,
1605
1626
  fallbackAttempted,
1606
1627
  ...(fallbackBackend ? { fallbackBackend } : {}),
1628
+ ...(fallbackBackend ? { requestedModel: primaryModel, actualBackend: `acp:${fallbackBackend}` } : {}),
1607
1629
  ...(fallbackNotAttemptedReason ? { fallbackNotAttemptedReason } : {}),
1608
1630
  ...(fallbackTriggerReason ? { fallbackTriggerReason } : {}),
1609
1631
  ...(fallbackFailures.length ? { fallbackFailures } : {}),