elanous 0.2.12 → 0.2.14

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 (534) 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/{7334.a6679d9168f37aa8.js → 1268.0840ff91e083fcbd.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/1307-6e7e0377c1db0bdc.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/1826-5d279da64bbbc2de.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/{9656-c1540088f5859c80.js → 1844-9ec4b1d12f6e80eb.js} +1 -1
  7. package/apps/pwa/out/_next/static/chunks/1866-e7a6999f25548b30.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/{390.c373e31b3b9d0c41.js → 1948.15f356b15a625597.js} +2 -2
  9. package/apps/pwa/out/_next/static/chunks/211-9c9090c59e2c5907.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/2196-4700653d12a44b40.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/224-798346143007ff65.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/2699-63b73fa4d3bcf6ff.js +1 -0
  13. package/apps/pwa/out/_next/static/chunks/2957-96ffab3d521fb08f.js +1 -0
  14. package/apps/pwa/out/_next/static/chunks/311.e685738f92e6e2ec.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/3194-807b212fb8eae065.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/3324-d1d2979821dfa845.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/3558-bbb5ab553a0471d3.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/3916-3d02dd1fc257dd3c.js +1 -0
  19. package/apps/pwa/out/_next/static/chunks/4144-a0041c8dc3be57ac.js +1 -0
  20. package/apps/pwa/out/_next/static/chunks/4554-3565eea1f1495ba7.js +1 -0
  21. package/apps/pwa/out/_next/static/chunks/4562-071d8bac44a01069.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/{4685-c3c1c625b71accd1.js → 4614-21403aac730d4586.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/{8039-df92e08fede5de8f.js → 4795-f372490b178583fc.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/5135-44008fe8b1f2aa41.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/5233-bf28bd6bd7a32099.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/5360-be1f1d8f9fcc9755.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/5977-2df489622a52a33a.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/5994-ae92c0de046ab9ba.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/{1452.93700483b9d4fc61.js → 6110.1297d4093fdfbb0d.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/6158.9c0449a9a8d0064b.js +2 -0
  31. package/apps/pwa/out/_next/static/chunks/6877-7c6208705c284124.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/726.83532d69452ee24b.js +1 -0
  33. package/apps/pwa/out/_next/static/chunks/7290-f09c928a06c52e2d.js +1 -0
  34. package/apps/pwa/out/_next/static/chunks/{1829-b453a1b22d3999bb.js → 7327-2d69fb321ba73389.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/7395-164e238f471b5d7b.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/7787-02a1c0b04e25e811.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/{9906-482c6619b3fdda20.js → 8060-cc008cc6fedfa818.js} +1 -1
  38. package/apps/pwa/out/_next/static/chunks/8085-d065d0393c1760f5.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/8682-7061dd4fa7bd62ed.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/8827-e2f5b09a8d37e83b.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/8868-10a3a4440fb0ccd8.js +25 -0
  42. package/apps/pwa/out/_next/static/chunks/{8421-1c4acfb1395a0edf.js → 8949-ecb4128a0858b911.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/9070.067c106528ec1ae6.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/9103-7a3ae7f60af3b99f.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/{3869-3cd46526879f454c.js → 9247-29d889b809288e65.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/935.4ddaa99fc1d1126c.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/{79f8be9e-957d68913b590b08.js → a9f2b54d-7865aba0f4d2ee49.js} +1 -1
  48. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-c1a96669158471ec.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/approvals/page-74383f890f075511.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-7b2b4f378354869a.js +1 -0
  51. package/apps/pwa/out/_next/static/chunks/app/board/page-c496bb16da85385f.js +1 -0
  52. package/apps/pwa/out/_next/static/chunks/app/botlab/page-1abfe3aec3be6bdc.js +1 -0
  53. package/apps/pwa/out/_next/static/chunks/app/bots/page-1512d91c7491ed62.js +1 -0
  54. package/apps/pwa/out/_next/static/chunks/app/chat/page-a627db94ec6904a0.js +1 -0
  55. package/apps/pwa/out/_next/static/chunks/app/consult/page-fb260b9283dbf646.js +1 -0
  56. package/apps/pwa/out/_next/static/chunks/app/control/page-e6b84ab07d3567d5.js +1 -0
  57. package/apps/pwa/out/_next/static/chunks/app/design-check/page-61ec1d2e92bdd50a.js +1 -0
  58. package/apps/pwa/out/_next/static/chunks/app/editor/page-fed69f9759d248d0.js +1 -0
  59. package/apps/pwa/out/_next/static/chunks/app/exec/{page-a67e11ba962337b5.js → page-15374909d8f1aebd.js} +1 -1
  60. package/apps/pwa/out/_next/static/chunks/app/field/page-012a742f81218b01.js +1 -0
  61. package/apps/pwa/out/_next/static/chunks/app/healthz/route-c1a96669158471ec.js +1 -0
  62. package/apps/pwa/out/_next/static/chunks/app/inside/page-fd7e200d4d83fbd7.js +1 -0
  63. package/apps/pwa/out/_next/static/chunks/app/intake/page-99effd9d893a43dc.js +1 -0
  64. package/apps/pwa/out/_next/static/chunks/app/layout-938de57ae0315e46.js +1 -0
  65. package/apps/pwa/out/_next/static/chunks/app/live/page-d47266766766d227.js +1 -0
  66. package/apps/pwa/out/_next/static/chunks/app/loops/page-160089123a40dd91.js +1 -0
  67. package/apps/pwa/out/_next/static/chunks/app/market/page-629e1ede482ec6eb.js +1 -0
  68. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-793fa536b3a6ef37.js → page-2ce5471926761fdd.js} +1 -1
  69. package/apps/pwa/out/_next/static/chunks/app/morning/page-27e99b4e54a849d4.js +1 -0
  70. package/apps/pwa/out/_next/static/chunks/app/not-found-e995d30f6800b6bf.js +1 -0
  71. package/apps/pwa/out/_next/static/chunks/app/observatory/page-39f89c53038a51d4.js +1 -0
  72. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-32cde713be47cddb.js +1 -0
  73. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-e7e10cd3386222e5.js +1 -0
  74. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-5a753bb4ec0cc511.js +1 -0
  75. package/apps/pwa/out/_next/static/chunks/app/outputs/page-04250f0e2c0d03df.js +1 -0
  76. package/apps/pwa/out/_next/static/chunks/app/page-341480ed8e3c1ea8.js +1 -0
  77. package/apps/pwa/out/_next/static/chunks/app/reflection/page-e64caff084f7ce21.js +1 -0
  78. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-db07eaffba0df7e3.js +1 -0
  79. package/apps/pwa/out/_next/static/chunks/app/sessions/page-8ac8d71c2adf9c8a.js +1 -0
  80. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-e1e47d8d940aba3d.js +1 -0
  81. package/apps/pwa/out/_next/static/chunks/app/settings/page-86f91034bd5cabe5.js +1 -0
  82. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-94dc383d3d54db1a.js +1 -0
  83. package/apps/pwa/out/_next/static/chunks/app/setup/layout-c1a96669158471ec.js +1 -0
  84. package/apps/pwa/out/_next/static/chunks/app/setup/page-b3759cc14da80908.js +1 -0
  85. package/apps/pwa/out/_next/static/chunks/app/share/page-ce4d8a1215ce53a3.js +1 -0
  86. package/apps/pwa/out/_next/static/chunks/app/showroom/page-58043db78ba519ae.js +1 -0
  87. package/apps/pwa/out/_next/static/chunks/app/tasks/page-357750dfb91bf960.js +1 -0
  88. package/apps/pwa/out/_next/static/chunks/app/term/{page-e297866051595d81.js → page-274703bc41b71333.js} +1 -1
  89. package/apps/pwa/out/_next/static/chunks/app/today/page-194dce4a83143fce.js +1 -0
  90. package/apps/pwa/out/_next/static/chunks/app/trace/page-35bdeae2e2c17147.js +1 -0
  91. package/apps/pwa/out/_next/static/chunks/app/vault/page-3b7e821dc492bdfd.js +1 -0
  92. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-16bef3da05a900c3.js +1 -0
  93. package/apps/pwa/out/_next/static/chunks/app/workflows/page-f8da5dc6f3752276.js +1 -0
  94. package/apps/pwa/out/_next/static/chunks/app/workspace/page-2f810b18d89ecba6.js +1 -0
  95. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-134c1bc762aee62c.js +1 -0
  96. package/apps/pwa/out/_next/static/chunks/{5cd7e3a9-404fd6b8d0ecd44c.js → f6231284-45726550aba08fca.js} +1 -1
  97. package/apps/pwa/out/_next/static/chunks/{f48170cc-d05d28bf87a43bf4.js → f6b7c9ba.64a895b8b2fedbdc.js} +1 -1
  98. package/apps/pwa/out/_next/static/chunks/framework-5c169757551134ed.js +1 -0
  99. package/apps/pwa/out/_next/static/chunks/main-59fe95fed40c7a01.js +1 -0
  100. package/apps/pwa/out/_next/static/chunks/main-app-4ca74dbbee7057c6.js +1 -0
  101. package/apps/pwa/out/_next/static/chunks/pages/_app-13a0a886df4d97a1.js +1 -0
  102. package/apps/pwa/out/_next/static/chunks/pages/_error-6af8591cd3a3f038.js +1 -0
  103. package/apps/pwa/out/_next/static/chunks/webpack-539cde99554be20f.js +1 -0
  104. package/apps/pwa/out/_next/static/css/2e33713c444d5b39.css +1 -0
  105. package/apps/pwa/out/_next/static/x6mjI6vqOkVVu0jFuDkz1/_buildManifest.js +1 -0
  106. package/apps/pwa/out/approvals/index.html +1 -1
  107. package/apps/pwa/out/approvals/index.txt +23 -23
  108. package/apps/pwa/out/autopilot/index.html +1 -1
  109. package/apps/pwa/out/autopilot/index.txt +23 -23
  110. package/apps/pwa/out/board/index.html +1 -1
  111. package/apps/pwa/out/board/index.txt +23 -23
  112. package/apps/pwa/out/botlab/index.html +1 -1
  113. package/apps/pwa/out/botlab/index.txt +22 -22
  114. package/apps/pwa/out/bots/index.html +1 -1
  115. package/apps/pwa/out/bots/index.txt +22 -22
  116. package/apps/pwa/out/chat/index.html +1 -1
  117. package/apps/pwa/out/chat/index.txt +22 -22
  118. package/apps/pwa/out/consult/index.html +1 -1
  119. package/apps/pwa/out/consult/index.txt +23 -23
  120. package/apps/pwa/out/control/index.html +1 -1
  121. package/apps/pwa/out/control/index.txt +23 -23
  122. package/apps/pwa/out/design-check/index.html +1 -1
  123. package/apps/pwa/out/design-check/index.txt +23 -23
  124. package/apps/pwa/out/editor/index.html +1 -1
  125. package/apps/pwa/out/editor/index.txt +23 -23
  126. package/apps/pwa/out/exec/index.html +1 -1
  127. package/apps/pwa/out/exec/index.txt +23 -23
  128. package/apps/pwa/out/field/index.html +1 -1
  129. package/apps/pwa/out/field/index.txt +23 -23
  130. package/apps/pwa/out/healthz +1 -1
  131. package/apps/pwa/out/index.html +1 -1
  132. package/apps/pwa/out/index.txt +22 -22
  133. package/apps/pwa/out/inside/index.html +1 -1
  134. package/apps/pwa/out/inside/index.txt +22 -22
  135. package/apps/pwa/out/intake/index.html +1 -1
  136. package/apps/pwa/out/intake/index.txt +23 -23
  137. package/apps/pwa/out/live/index.html +1 -1
  138. package/apps/pwa/out/live/index.txt +23 -23
  139. package/apps/pwa/out/loops/index.html +1 -0
  140. package/apps/pwa/out/loops/index.txt +30 -0
  141. package/apps/pwa/out/market/index.html +1 -1
  142. package/apps/pwa/out/market/index.txt +22 -22
  143. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  144. package/apps/pwa/out/missions/placeholder/index.txt +22 -22
  145. package/apps/pwa/out/morning/index.html +1 -1
  146. package/apps/pwa/out/morning/index.txt +23 -23
  147. package/apps/pwa/out/observatory/index.html +1 -1
  148. package/apps/pwa/out/observatory/index.txt +23 -23
  149. package/apps/pwa/out/ops/checklist/index.html +1 -1
  150. package/apps/pwa/out/ops/checklist/index.txt +22 -22
  151. package/apps/pwa/out/ops/release/index.html +1 -1
  152. package/apps/pwa/out/ops/release/index.txt +22 -22
  153. package/apps/pwa/out/ops/seats/index.html +1 -1
  154. package/apps/pwa/out/ops/seats/index.txt +22 -22
  155. package/apps/pwa/out/outputs/index.html +1 -1
  156. package/apps/pwa/out/outputs/index.txt +22 -22
  157. package/apps/pwa/out/reflection/index.html +1 -1
  158. package/apps/pwa/out/reflection/index.txt +23 -23
  159. package/apps/pwa/out/scheduler/index.html +1 -1
  160. package/apps/pwa/out/scheduler/index.txt +23 -23
  161. package/apps/pwa/out/sessions/index.html +1 -1
  162. package/apps/pwa/out/sessions/index.txt +22 -22
  163. package/apps/pwa/out/settings/devices/index.html +1 -1
  164. package/apps/pwa/out/settings/devices/index.txt +23 -23
  165. package/apps/pwa/out/settings/index.html +1 -1
  166. package/apps/pwa/out/settings/index.txt +23 -23
  167. package/apps/pwa/out/setup/done/index.html +1 -1
  168. package/apps/pwa/out/setup/done/index.txt +23 -23
  169. package/apps/pwa/out/setup/index.html +1 -1
  170. package/apps/pwa/out/setup/index.txt +23 -23
  171. package/apps/pwa/out/share/index.html +1 -1
  172. package/apps/pwa/out/share/index.txt +23 -23
  173. package/apps/pwa/out/showroom/index.html +1 -1
  174. package/apps/pwa/out/showroom/index.txt +22 -22
  175. package/apps/pwa/out/tasks/index.html +1 -1
  176. package/apps/pwa/out/tasks/index.txt +23 -23
  177. package/apps/pwa/out/term/index.html +1 -1
  178. package/apps/pwa/out/term/index.txt +23 -23
  179. package/apps/pwa/out/today/index.html +1 -1
  180. package/apps/pwa/out/today/index.txt +23 -23
  181. package/apps/pwa/out/trace/index.html +1 -1
  182. package/apps/pwa/out/trace/index.txt +23 -23
  183. package/apps/pwa/out/vault/index.html +1 -1
  184. package/apps/pwa/out/vault/index.txt +23 -23
  185. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  186. package/apps/pwa/out/workflows/chat-ui/index.txt +23 -23
  187. package/apps/pwa/out/workflows/index.html +1 -1
  188. package/apps/pwa/out/workflows/index.txt +23 -23
  189. package/apps/pwa/out/workspace/index.html +1 -1
  190. package/apps/pwa/out/workspace/index.txt +23 -23
  191. package/apps/pwa/out/worktrees/index.html +1 -1
  192. package/apps/pwa/out/worktrees/index.txt +23 -23
  193. package/examples/teams/personas/backend.yaml +4 -0
  194. package/examples/teams/personas/pm.yaml +4 -0
  195. package/examples/teams/personas/qa.yaml +4 -0
  196. package/examples/teams/small-product.yaml +20 -0
  197. package/graphs/lecture/lecture-note.yaml +28 -0
  198. package/graphs/lecture/recipes.yaml +17 -0
  199. package/graphs/orchestrator/orchestrator.yaml +59 -0
  200. package/graphs/orchestrator/recipes.yaml +18 -0
  201. package/graphs/release/recipes.yaml +3 -0
  202. package/graphs/release/release-loop.yaml +4 -1
  203. package/graphs/rhythm/daily.yaml +38 -0
  204. package/graphs/rhythm/recipes.yaml +18 -0
  205. package/graphs/rhythm/weekly.yaml +39 -0
  206. package/graphs/video/recipes.yaml +3 -0
  207. package/graphs/video/youtube-summary.yaml +17 -0
  208. package/package.json +2 -1
  209. package/scripts/audit-test-state-writes.ts +72 -17
  210. package/scripts/brand/check.ts +186 -0
  211. package/scripts/context-hooks/emit.ts +36 -14
  212. package/scripts/coord-channel-watch.sh +1 -0
  213. package/scripts/coord-post.sh +11 -1
  214. package/scripts/discord-seat-probe.ts +144 -0
  215. package/scripts/docs-cli-check.ts +23 -6
  216. package/scripts/expose-rubric-check.ts +118 -0
  217. package/scripts/expose-rubric-suggest.ts +163 -0
  218. package/scripts/heal/run-role.ts +14 -0
  219. package/scripts/hq/hq-standby.sh +65 -0
  220. package/scripts/hq/standby-promote.ts +173 -0
  221. package/scripts/hq/standby-role.ts +59 -0
  222. package/scripts/hq/standby-snapshot.ts +226 -0
  223. package/scripts/hq/standby-verify.ts +223 -0
  224. package/scripts/lecture-note/node.ts +256 -0
  225. package/scripts/lessons/handbook-page.ts +85 -0
  226. package/scripts/release-loop/gate-node.ts +62 -15
  227. package/scripts/release-loop/ops-upgrade-node.ts +17 -5
  228. package/scripts/release-loop/publish-node.ts +14 -1
  229. package/scripts/release-loop/unattended-release.ts +23 -1
  230. package/scripts/release-loop/vault-note-node.ts +196 -0
  231. package/scripts/release-story/draft.ts +172 -0
  232. package/scripts/rhythm/daily.ts +383 -0
  233. package/scripts/rhythm/weekly.ts +294 -0
  234. package/scripts/skills/public-check.ts +169 -0
  235. package/scripts/test-deterministic.ts +57 -11
  236. package/scripts/test-diet/effectiveness.ts +195 -0
  237. package/scripts/test-diet/lib.ts +16 -8
  238. package/scripts/test-diet/node.ts +77 -9
  239. package/scripts/ux/demo-rehearsal.ts +2 -2
  240. package/scripts/ux/lib/rehearsal-verdict.ts +1 -0
  241. package/scripts/video/youtube-summary.ts +133 -0
  242. package/skills/diagram-master/SKILL.md +42 -49
  243. package/skills/diagram-master/references/pyproject.toml +4 -1
  244. package/skills/diagram-master/references/routing-guide.md +4 -2
  245. package/skills/grill-me/SKILL.md +1 -0
  246. package/skills/omni-crawl/SKILL.md +16 -16
  247. package/skills/omni-crawl/package-lock.json +1 -544
  248. package/skills/omni-crawl/package.json +3 -4
  249. package/skills/omni-crawl/scripts/main.ts +13 -13
  250. package/skills/omni-crawl/scripts/monitor.ts +8 -8
  251. package/skills/omni-crawl/src/apify.ts +13 -2
  252. package/skills/omni-crawl/src/capture.ts +6 -0
  253. package/skills/omni-crawl/src/registry.ts +2 -2
  254. package/skills/omni-crawl/src/stdout-json.ts +4 -0
  255. package/skills/omni-digest/SKILL.md +10 -11
  256. package/skills/omni-digest/package-lock.json +0 -545
  257. package/skills/omni-digest/package.json +2 -5
  258. package/skills/omni-digest/scripts/main.ts +3 -3
  259. package/skills/omni-digest/src/delegate.ts +5 -5
  260. package/skills/omni-digest/src/fetch.ts +2 -2
  261. package/skills/omni-digest/src/media.ts +25 -11
  262. package/skills/omni-digest/src/visual.ts +3 -2
  263. package/skills/omni-market/scripts/main.ts +1 -1
  264. package/skills/omni-market/scripts/stdout-json.ts +4 -0
  265. package/skills/youtube-master/SKILL.md +13 -13
  266. package/skills/youtube-master/package-lock.json +0 -545
  267. package/skills/youtube-master/package.json +2 -5
  268. package/skills/youtube-master/scripts/main.ts +2 -2
  269. package/skills/youtube-master/scripts/transcribe-local.ts +1 -1
  270. package/skills/youtube-master/src/util.ts +30 -7
  271. package/src/agent/identity-env.ts +11 -0
  272. package/src/agent-mission/driver.ts +15 -10
  273. package/src/agent-mission/duo.ts +94 -0
  274. package/src/agent-mission/pty-decision.ts +1 -1
  275. package/src/autopilot/build/llm-conflict-merge.ts +45 -8
  276. package/src/autopilot/build/merge-intent.ts +53 -0
  277. package/src/autopilot/track-agent-shadow.ts +2 -1
  278. package/src/autopilot/track-agent.ts +4 -1
  279. package/src/boot/daemon-prompt-request.ts +3 -0
  280. package/src/boot/daemon-prompt-turn.ts +6 -2
  281. package/src/boot/daemon-runtime.ts +48 -16
  282. package/src/chat/index.ts +2 -0
  283. package/src/claims/claims-ledger.ts +232 -0
  284. package/src/claims/claims-render.ts +157 -0
  285. package/src/cli/claims-cli.ts +94 -0
  286. package/src/cli/decisions-cli.ts +84 -20
  287. package/src/cli/docs-cli.ts +2 -0
  288. package/src/cli/flow-cli.ts +11 -0
  289. package/src/cli/freeze-cli.ts +45 -0
  290. package/src/cli/lesson-cli.ts +108 -0
  291. package/src/cli/logs-cli.ts +104 -2
  292. package/src/cli/logs-model-input.ts +131 -0
  293. package/src/cli/outputs-cli.ts +92 -0
  294. package/src/cli/persona-cli.ts +29 -0
  295. package/src/cli/pod-cli.ts +10 -3
  296. package/src/cli/pr-cli.ts +44 -2
  297. package/src/cli/private-ledger-commands.ts +54 -4
  298. package/src/cli/pty-drive-verdict.ts +11 -2
  299. package/src/cli/release-cli.ts +248 -45
  300. package/src/cli/rfc-status.ts +91 -0
  301. package/src/connectors/linear.ts +5 -3
  302. package/src/context-bus/condense.ts +189 -0
  303. package/src/context-bus/context-day.ts +82 -0
  304. package/src/context-bus/context-now-public.ts +86 -0
  305. package/src/context-bus/context-now-surfaces.ts +70 -4
  306. package/src/context-bus/context-now.ts +4 -3
  307. package/src/context-bus/long-term-memory.ts +201 -24
  308. package/src/context-bus/nightly-condense.ts +1 -1
  309. package/src/dashboard/index.ts +75 -22
  310. package/src/dashboard/slash-runtime/chat-persona.ts +66 -0
  311. package/src/dashboard/slash-runtime/dashboard-handlers.ts +99 -0
  312. package/src/dashboard/slash-runtime/loops-table.ts +77 -0
  313. package/src/decisions/decision-cards.ts +13 -7
  314. package/src/decisions/decision-ledger.ts +83 -18
  315. package/src/discord/sprint21-runtime.ts +16 -0
  316. package/src/discord-self-message.ts +8 -0
  317. package/src/discord-slash-wire.ts +31 -2
  318. package/src/domains/outbound-alert.ts +1 -1
  319. package/src/elanous-config-dir.ts +29 -4
  320. package/src/execution-loop/launch-gate.ts +29 -14
  321. package/src/flow/cards.ts +239 -0
  322. package/src/graph-kinds/registry.ts +2 -2
  323. package/src/graph-runner/graph-approval-card.ts +2 -2
  324. package/src/graph-runner/graph-grow.ts +64 -11
  325. package/src/graph-runner/graph-tick.ts +13 -2
  326. package/src/graph-runner/runner.ts +111 -22
  327. package/src/harness/harness-cli-command.ts +178 -27
  328. package/src/harness/harness-incidents.ts +43 -12
  329. package/src/harness/harness-pod-dispatch.ts +2 -0
  330. package/src/harness/harness-queue-child.ts +37 -0
  331. package/src/harness/harness-queue.ts +494 -0
  332. package/src/harness/helper-scan.ts +139 -0
  333. package/src/harness/model-input-observation.ts +37 -0
  334. package/src/hooks/github-shadow.ts +72 -2
  335. package/src/hooks/receiver.ts +4 -2
  336. package/src/hq/hq.ts +318 -0
  337. package/src/hq/lease.ts +210 -0
  338. package/src/hq/ledger-write-target.ts +41 -0
  339. package/src/hq/promote.ts +301 -0
  340. package/src/index.ts +281 -27
  341. package/src/intake-plane/discord-seat-work.ts +13 -6
  342. package/src/intake-plane/linear-wish.ts +72 -0
  343. package/src/intake-plane/persona-answer.ts +5 -0
  344. package/src/intake-plane/telegram-seat-work.ts +13 -6
  345. package/src/intake-plane/wish-benchmark-ledger.ts +129 -0
  346. package/src/intake-plane/wish-card.ts +53 -2
  347. package/src/intake-plane/wish-folder.ts +5 -2
  348. package/src/intake-plane/wish-reply.ts +72 -0
  349. package/src/lessons/lesson-import.ts +106 -0
  350. package/src/lessons/lesson-ledger.ts +206 -0
  351. package/src/loops/activity.ts +203 -0
  352. package/src/loops/budget.ts +32 -0
  353. package/src/loops/checker.ts +180 -0
  354. package/src/loops/contract/examples/orchestrator.loop.yaml +1 -1
  355. package/src/loops/loop-cli.ts +72 -6
  356. package/src/loops/neighbors.ts +40 -0
  357. package/src/loops/observe.ts +59 -0
  358. package/src/loops/orchestrator/author-depth.ts +164 -0
  359. package/src/loops/orchestrator/author-input-check.ts +77 -0
  360. package/src/loops/orchestrator/author-ledger.ts +230 -0
  361. package/src/loops/orchestrator/finish-rate.ts +175 -0
  362. package/src/loops/orchestrator/overlap.ts +180 -0
  363. package/src/loops/orchestrator/resync-shadow.ts +227 -0
  364. package/src/loops/orchestrator/tick.ts +302 -0
  365. package/src/loops/orchestrator/traffic.ts +165 -0
  366. package/src/loops/package/verify.ts +125 -0
  367. package/src/loops/registry.ts +454 -9
  368. package/src/loops/status-rows.ts +118 -0
  369. package/src/loops/verdict.ts +34 -0
  370. package/src/maturity/bot-slash-catalog.ts +66 -0
  371. package/src/maturity/feature-maturity.ts +8 -5
  372. package/src/memory.ts +37 -4
  373. package/src/nexus/api/card-followup-route.ts +118 -0
  374. package/src/nexus/api/dashboard.ts +6 -1
  375. package/src/nexus/api/decisions-route.ts +68 -0
  376. package/src/nexus/api/http-server.ts +65 -2
  377. package/src/nexus/api/merge-approvals-gate.ts +1 -1
  378. package/src/nexus/api/personas.ts +2 -1
  379. package/src/nexus/api/schedules-read.ts +5 -1
  380. package/src/nexus/api/skill-exec-route.ts +97 -0
  381. package/src/nexus/api/task-cards-api.ts +44 -5
  382. package/src/nexus/api/workflows.ts +2 -1
  383. package/src/persona/loader.ts +18 -0
  384. package/src/persona/persona-todo.ts +14 -9
  385. package/src/persona/presets.ts +2 -1
  386. package/src/persona/team.ts +105 -0
  387. package/src/persona/types.ts +4 -0
  388. package/src/pod-lease/host-lease.ts +130 -20
  389. package/src/release-loop/auto-start.ts +48 -0
  390. package/src/release-loop/checklist.ts +13 -4
  391. package/src/release-loop/feature-store.ts +28 -16
  392. package/src/release-loop/landed-but-yellow.ts +12 -0
  393. package/src/release-loop/landing-freeze.ts +121 -0
  394. package/src/release-loop/placement.ts +242 -0
  395. package/src/release-loop/release-schedule.ts +3 -1
  396. package/src/seat-dispatch/ceo-commands.ts +2 -2
  397. package/src/seat-dispatch/seat-ask.ts +73 -15
  398. package/src/seat-dispatch/seat-questions.ts +58 -0
  399. package/src/seat-loop/seat-loop.ts +495 -46
  400. package/src/seat-loop/seat-pack.ts +127 -0
  401. package/src/self-dev/ask-launch-flow.ts +3 -0
  402. package/src/self-dev/dev-pipeline.ts +6 -2
  403. package/src/self-dev/draft-sweep.ts +76 -0
  404. package/src/self-dev/run-store.ts +73 -1
  405. package/src/self-dev/run-supervisor.ts +8 -0
  406. package/src/self-dev/self-orchestrate-runtime.ts +5 -1
  407. package/src/self-implement/budget-gate.ts +29 -0
  408. package/src/self-implement/expose-gate.ts +47 -0
  409. package/src/self-implement/frozen-merges.ts +294 -0
  410. package/src/self-implement/gate-cli.ts +10 -4
  411. package/src/self-implement/gate-scope.ts +46 -9
  412. package/src/self-implement/goal-archive.ts +222 -0
  413. package/src/self-implement/goal-lookup.ts +147 -0
  414. package/src/self-implement/heal-intake.ts +79 -0
  415. package/src/self-implement/host-regate.ts +25 -2
  416. package/src/self-implement/l8-merge-queue.ts +157 -1
  417. package/src/self-implement/orchestrator.ts +114 -10
  418. package/src/self-implement/revert-guard.ts +62 -0
  419. package/src/self-implement/run-ledger.ts +1 -1
  420. package/src/self-implement/seams.ts +28 -8
  421. package/src/session/index.ts +2 -0
  422. package/src/steward/launch.ts +5 -1
  423. package/src/steward/readiness.ts +125 -0
  424. package/src/steward/rescue.ts +75 -0
  425. package/src/steward/triage.ts +48 -9
  426. package/src/task-cards/card-cli.ts +86 -0
  427. package/src/task-cards/card-store.ts +10 -6
  428. package/src/task-orchestrator/surfaces/pod-lease.ts +23 -8
  429. package/src/task-orchestrator/surfaces/pod-ledger-collect.ts +46 -6
  430. package/src/task-orchestrator/surfaces/pod-pool.ts +27 -10
  431. package/src/task-orchestrator/surfaces/self-implement-pod.ts +112 -18
  432. package/src/telegram-commands.ts +27 -3
  433. package/src/telegram-dispatch-forward.ts +1 -1
  434. package/src/telegram-fabric-plan.ts +90 -0
  435. package/src/telegram-loops-command.ts +44 -0
  436. package/src/telegram-project-command.ts +10 -4
  437. package/src/telegram.ts +5 -0
  438. package/src/tool-runtime/elanous-control-runtimes.ts +2 -1
  439. package/src/user-config.ts +204 -16
  440. package/src/version/packed-revision.json +1 -1
  441. package/src/workflow-runtime/executor.ts +4 -0
  442. package/src/workflow-runtime/node-catalog.ts +13 -0
  443. package/src/workflow-runtime/nodes/knowledge.ts +38 -0
  444. package/src/workflow-runtime/schema.ts +17 -1
  445. package/src/workflow-runtime/storage.ts +31 -13
  446. package/src/workflow-runtime/types.ts +13 -1
  447. package/apps/pwa/out/_next/static/-6XrAoD3eU7NyMHAE4xPS/_buildManifest.js +0 -1
  448. package/apps/pwa/out/_next/static/chunks/1147-466ec15afacc1a65.js +0 -1
  449. package/apps/pwa/out/_next/static/chunks/1159-0c5ef8bc21cb6afb.js +0 -1
  450. package/apps/pwa/out/_next/static/chunks/1394-ee3795b112907122.js +0 -1
  451. package/apps/pwa/out/_next/static/chunks/2176-c379e3ebce874151.js +0 -1
  452. package/apps/pwa/out/_next/static/chunks/2426.8c6ee0c668d53def.js +0 -1
  453. package/apps/pwa/out/_next/static/chunks/3170-7ec60a56fa0e4728.js +0 -1
  454. package/apps/pwa/out/_next/static/chunks/3278-ae6ef084769f6195.js +0 -1
  455. package/apps/pwa/out/_next/static/chunks/3454-988a53dac60a1763.js +0 -1
  456. package/apps/pwa/out/_next/static/chunks/3638-aa80b4641ea439db.js +0 -1
  457. package/apps/pwa/out/_next/static/chunks/3735-fe3402553ac1c9c6.js +0 -1
  458. package/apps/pwa/out/_next/static/chunks/3992-2e522a8ac5f2afde.js +0 -25
  459. package/apps/pwa/out/_next/static/chunks/4215-c9d91dd644ec1719.js +0 -1
  460. package/apps/pwa/out/_next/static/chunks/4508-5d0161d1e724d735.js +0 -1
  461. package/apps/pwa/out/_next/static/chunks/4733-623a79345b171e1c.js +0 -1
  462. package/apps/pwa/out/_next/static/chunks/4883.3bd969ea1d3aaaff.js +0 -2
  463. package/apps/pwa/out/_next/static/chunks/5532.6899a12958a89b72.js +0 -1
  464. package/apps/pwa/out/_next/static/chunks/5845-e6c003ce8d5fd36a.js +0 -1
  465. package/apps/pwa/out/_next/static/chunks/5978-b27004c677119115.js +0 -1
  466. package/apps/pwa/out/_next/static/chunks/6048-4c5c33579bc4ef78.js +0 -1
  467. package/apps/pwa/out/_next/static/chunks/6303-422dbf0f67c72aac.js +0 -1
  468. package/apps/pwa/out/_next/static/chunks/6751-bb3595948b2e311b.js +0 -1
  469. package/apps/pwa/out/_next/static/chunks/7274-19b7279f18b47ab3.js +0 -1
  470. package/apps/pwa/out/_next/static/chunks/7277-0b2fc7c16ba2d64b.js +0 -1
  471. package/apps/pwa/out/_next/static/chunks/7551-a0e0707b79164ddd.js +0 -1
  472. package/apps/pwa/out/_next/static/chunks/7758-0b018a9e4fc56200.js +0 -1
  473. package/apps/pwa/out/_next/static/chunks/7777-79f845045aa1ed86.js +0 -1
  474. package/apps/pwa/out/_next/static/chunks/8025-cc24ee5d8f2fb6f8.js +0 -1
  475. package/apps/pwa/out/_next/static/chunks/8206-c6684dcde12be721.js +0 -1
  476. package/apps/pwa/out/_next/static/chunks/8605-0259994816604c49.js +0 -1
  477. package/apps/pwa/out/_next/static/chunks/8725.bf46bb1395dd0fa2.js +0 -1
  478. package/apps/pwa/out/_next/static/chunks/8734-252d511cd276c0dc.js +0 -1
  479. package/apps/pwa/out/_next/static/chunks/8834-ca1cb09c1cb1a6bf.js +0 -1
  480. package/apps/pwa/out/_next/static/chunks/9269.d2a4795bc6812717.js +0 -1
  481. package/apps/pwa/out/_next/static/chunks/9538-2816b90923606459.js +0 -1
  482. package/apps/pwa/out/_next/static/chunks/9920-5cfdc5e8fd209825.js +0 -1
  483. package/apps/pwa/out/_next/static/chunks/app/_not-found/page-d16993889b125c42.js +0 -1
  484. package/apps/pwa/out/_next/static/chunks/app/approvals/page-cb169620945d1952.js +0 -1
  485. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-cefe8169e36cf62a.js +0 -1
  486. package/apps/pwa/out/_next/static/chunks/app/board/page-d2e35228bc497119.js +0 -1
  487. package/apps/pwa/out/_next/static/chunks/app/botlab/page-5c05b0da4bc1392c.js +0 -1
  488. package/apps/pwa/out/_next/static/chunks/app/bots/page-e0ccdb576812b523.js +0 -1
  489. package/apps/pwa/out/_next/static/chunks/app/chat/page-813c4a52c9d05ca8.js +0 -1
  490. package/apps/pwa/out/_next/static/chunks/app/consult/page-4731f2c215358937.js +0 -1
  491. package/apps/pwa/out/_next/static/chunks/app/control/page-a022b257950f39e6.js +0 -1
  492. package/apps/pwa/out/_next/static/chunks/app/design-check/page-a2ffc717e04b8b0a.js +0 -1
  493. package/apps/pwa/out/_next/static/chunks/app/editor/page-b59ee4e70fc18fcf.js +0 -1
  494. package/apps/pwa/out/_next/static/chunks/app/field/page-c172dabd796eb7a3.js +0 -1
  495. package/apps/pwa/out/_next/static/chunks/app/healthz/route-d16993889b125c42.js +0 -1
  496. package/apps/pwa/out/_next/static/chunks/app/inside/page-1efd0cb9e0ed9d13.js +0 -1
  497. package/apps/pwa/out/_next/static/chunks/app/intake/page-d5dc7ff69a37b4cf.js +0 -1
  498. package/apps/pwa/out/_next/static/chunks/app/layout-e71c84189e8c683e.js +0 -1
  499. package/apps/pwa/out/_next/static/chunks/app/live/page-1d6a533ce5a6efcb.js +0 -1
  500. package/apps/pwa/out/_next/static/chunks/app/market/page-dfca9b7d2de0b135.js +0 -1
  501. package/apps/pwa/out/_next/static/chunks/app/morning/page-6dd3bfbbde2a5c2c.js +0 -1
  502. package/apps/pwa/out/_next/static/chunks/app/not-found-490653e8d4cc8852.js +0 -1
  503. package/apps/pwa/out/_next/static/chunks/app/observatory/page-77447356baad75d2.js +0 -1
  504. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-29e961191f6057df.js +0 -1
  505. package/apps/pwa/out/_next/static/chunks/app/ops/release/page-c822f55b884d9104.js +0 -1
  506. package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fa4ccfc432424e52.js +0 -1
  507. package/apps/pwa/out/_next/static/chunks/app/outputs/page-6d604227e1664b71.js +0 -1
  508. package/apps/pwa/out/_next/static/chunks/app/page-9c7e8f744a765daf.js +0 -1
  509. package/apps/pwa/out/_next/static/chunks/app/reflection/page-4048543b4b7ad5e0.js +0 -1
  510. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-4c3a3126abed551a.js +0 -1
  511. package/apps/pwa/out/_next/static/chunks/app/sessions/page-64b0aecb1ca75e75.js +0 -1
  512. package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4292d0149020e8a7.js +0 -1
  513. package/apps/pwa/out/_next/static/chunks/app/settings/page-db14d0be7f1c460c.js +0 -1
  514. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-e5d8072cd7cfef73.js +0 -1
  515. package/apps/pwa/out/_next/static/chunks/app/setup/layout-d16993889b125c42.js +0 -1
  516. package/apps/pwa/out/_next/static/chunks/app/setup/page-9547b5cec7cf76bf.js +0 -1
  517. package/apps/pwa/out/_next/static/chunks/app/share/page-e6dcb83265530636.js +0 -1
  518. package/apps/pwa/out/_next/static/chunks/app/showroom/page-45248f08d6aecae5.js +0 -1
  519. package/apps/pwa/out/_next/static/chunks/app/tasks/page-2cddeb9e2aeedc55.js +0 -1
  520. package/apps/pwa/out/_next/static/chunks/app/today/page-baba2e047872fb0f.js +0 -1
  521. package/apps/pwa/out/_next/static/chunks/app/trace/page-e895da4ec9cac345.js +0 -1
  522. package/apps/pwa/out/_next/static/chunks/app/vault/page-4344faf20216233b.js +0 -1
  523. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-698bb29f11864b89.js +0 -1
  524. package/apps/pwa/out/_next/static/chunks/app/workflows/page-bd6a6dc1ddc273f2.js +0 -1
  525. package/apps/pwa/out/_next/static/chunks/app/workspace/page-2e9e2fb01c9da1c9.js +0 -1
  526. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-d62ceb13411ebba4.js +0 -1
  527. package/apps/pwa/out/_next/static/chunks/framework-172630e6aa5e2d7f.js +0 -1
  528. package/apps/pwa/out/_next/static/chunks/main-app-f0fe2d7477845181.js +0 -1
  529. package/apps/pwa/out/_next/static/chunks/main-fc98ac70b44eccb0.js +0 -1
  530. package/apps/pwa/out/_next/static/chunks/pages/_app-5bd7f10271f72413.js +0 -1
  531. package/apps/pwa/out/_next/static/chunks/pages/_error-97d0d85ea05fef9b.js +0 -1
  532. package/apps/pwa/out/_next/static/chunks/webpack-9e4d02d567b98240.js +0 -1
  533. package/apps/pwa/out/_next/static/css/128fb7fb670b2fbf.css +0 -1
  534. /package/apps/pwa/out/_next/static/{-6XrAoD3eU7NyMHAE4xPS → x6mjI6vqOkVVu0jFuDkz1}/_ssgManifest.js +0 -0
@@ -0,0 +1,210 @@
1
+ // HQ lease (본부 임대) — single writer across mbp · node-b · cloud-vm with a 2-of-3 quorum (OP 10-04 08:33 · 08:40).
2
+ // The arbiter (cloud-vm) keeps one record; only the arbiter promotes the standby, and only after two consecutive
3
+ // checks in which neither the arbiter nor the standby can reach the holder. Fenced work carries the generation.
4
+ import { createHash } from 'node:crypto';
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ export const DEFAULT_TTL_SECONDS = 1500;
10
+ /** A standby view older than this is not evidence (two heartbeats at the 10-minute cadence). */
11
+ export const VIEW_FRESH_SECONDS = 1500;
12
+ /** Consecutive dual-unreachable checks before the arbiter promotes the standby. */
13
+ export const PROMOTE_AFTER_CHECKS = 2;
14
+
15
+ export interface HostView { target: string; reachable: boolean; at: number }
16
+ export interface LeaseRecord {
17
+ holder: string;
18
+ generation: number;
19
+ acquiredAt: number;
20
+ renewedAt: number;
21
+ ttlSeconds: number;
22
+ /** Each observer's latest view of the holder (arbiter and standby). */
23
+ views?: Record<string, HostView>;
24
+ /** Consecutive arbiter checks in which both arbiter and standby could not reach the holder. */
25
+ dualUnreachableStreak?: number;
26
+ lastCheckAt?: number;
27
+ promotedFrom?: string;
28
+ }
29
+
30
+ export function parseLease(raw: string | null): LeaseRecord | null {
31
+ if (!raw || !raw.trim()) return null;
32
+ const data = JSON.parse(raw) as LeaseRecord;
33
+ if (typeof data.holder !== 'string' || !Number.isSafeInteger(data.generation) || typeof data.renewedAt !== 'number') throw new Error('invalid lease record');
34
+ return data;
35
+ }
36
+ export const serializeLease = (record: LeaseRecord): string => `${JSON.stringify(record)}\n`;
37
+ export const leaseExpired = (record: LeaseRecord, now: number): boolean => record.renewedAt + record.ttlSeconds < now;
38
+
39
+ /** acquire: only when there is no lease or it has expired; a new holder gets the next generation. */
40
+ export function decideAcquire(record: LeaseRecord | null, me: string, now: number, ttlSeconds = DEFAULT_TTL_SECONDS):
41
+ { ok: true; next: LeaseRecord } | { ok: false; reason: string } {
42
+ if (record && record.holder === me && !leaseExpired(record, now)) return { ok: true, next: { ...record, renewedAt: now, ttlSeconds } };
43
+ if (record && !leaseExpired(record, now)) return { ok: false, reason: `held by ${record.holder} (generation ${record.generation})` };
44
+ return { ok: true, next: { holder: me, generation: (record?.generation ?? 0) + 1, acquiredAt: now, renewedAt: now, ttlSeconds, views: {}, dualUnreachableStreak: 0 } };
45
+ }
46
+
47
+ export function decideRenew(record: LeaseRecord | null, me: string, generation: number | undefined, now: number):
48
+ { ok: true; next: LeaseRecord } | { ok: false; reason: string } {
49
+ if (!record) return { ok: false, reason: 'no lease' };
50
+ if (record.holder !== me) return { ok: false, reason: `held by ${record.holder} (generation ${record.generation})` };
51
+ if (generation !== undefined && generation < record.generation) return { ok: false, reason: `stale generation ${generation} < ${record.generation}` };
52
+ return { ok: true, next: { ...record, renewedAt: now, dualUnreachableStreak: 0 } };
53
+ }
54
+
55
+ export function decideRelease(record: LeaseRecord | null, me: string): { ok: true; next: LeaseRecord } | { ok: false; reason: string } {
56
+ if (!record || record.holder !== me) return { ok: false, reason: record ? `held by ${record.holder}` : 'no lease' };
57
+ return { ok: true, next: { ...record, renewedAt: 0 } };
58
+ }
59
+
60
+ /** The standby reports whether it can reach the holder (rule ②: both views go into the record). */
61
+ export function recordView(record: LeaseRecord, observer: string, reachable: boolean, now: number): LeaseRecord {
62
+ return { ...record, views: { ...(record.views ?? {}), [observer]: { target: record.holder, reachable, at: now } } };
63
+ }
64
+
65
+ /**
66
+ * Arbiter check (rule ②). Records the arbiter's own view; counts a dual-unreachable check only when the standby's
67
+ * fresh view of the same holder also says unreachable; promotes the standby after PROMOTE_AFTER_CHECKS in a row.
68
+ */
69
+ export function decideArbiterCheck(record: LeaseRecord, input: { arbiter: string; standby: string; arbiterReachesHolder: boolean; now: number }):
70
+ { next: LeaseRecord; promoted: boolean; streak: number } {
71
+ let next = recordView(record, input.arbiter, input.arbiterReachesHolder, input.now);
72
+ const view = next.views?.[input.standby];
73
+ const standbyDown = !!view && view.target === record.holder && !view.reachable && input.now - view.at <= VIEW_FRESH_SECONDS;
74
+ const dual = !input.arbiterReachesHolder && standbyDown && record.holder !== input.standby;
75
+ const streak = dual ? (record.dualUnreachableStreak ?? 0) + 1 : 0;
76
+ next = { ...next, dualUnreachableStreak: streak, lastCheckAt: input.now };
77
+ if (streak < PROMOTE_AFTER_CHECKS) return { next, promoted: false, streak };
78
+ return {
79
+ next: { holder: input.standby, generation: record.generation + 1, acquiredAt: input.now, renewedAt: input.now, ttlSeconds: record.ttlSeconds, views: {}, dualUnreachableStreak: 0, lastCheckAt: input.now, promotedFrom: record.holder },
80
+ promoted: true, streak,
81
+ };
82
+ }
83
+
84
+ /** What this host remembers between runs: its last confirmed generation and when quorum last confirmed it. */
85
+ export interface LocalHqState { holder?: string; generation?: number; confirmedAt?: number; ttlSeconds?: number }
86
+
87
+ export type FenceReason = 'holder' | 'quorum-standby' | 'within-ttl' | 'not-holder' | 'stale-generation' | 'no-quorum-expired' | 'no-lease' | 'no-lease-after-seen' | 'fail-open';
88
+ /**
89
+ * Fence (rules ① · ③). Arbiter reachable → run only as the current holder at the current generation.
90
+ * Arbiter unreachable → the holder keeps running while it reaches the standby or its own confirmation is within TTL;
91
+ * a standby that reports a higher generation always blocks (a returning old HQ stays standby).
92
+ */
93
+ export function decideFence(input: {
94
+ me: string; now: number; local: LocalHqState;
95
+ record: LeaseRecord | null | 'unreachable';
96
+ standbyReachable?: boolean; standbyGeneration?: number; failOpen?: boolean;
97
+ /** Highest generation this host ever saw — a vanished record after that is «unknown», not «no lease yet». */
98
+ seenGeneration?: number;
99
+ }): { run: boolean; reason: FenceReason; generation?: number; holder?: string } {
100
+ const { me, now, local, record } = input;
101
+ if (record !== 'unreachable') {
102
+ if (!record) return { run: false, reason: input.seenGeneration !== undefined ? 'no-lease-after-seen' : 'no-lease', ...(input.seenGeneration !== undefined ? { generation: input.seenGeneration } : {}) };
103
+ if (record.holder !== me) return { run: false, reason: 'not-holder', holder: record.holder, generation: record.generation };
104
+ if (local.generation !== undefined && local.generation > record.generation) return { run: false, reason: 'stale-generation', holder: record.holder, generation: record.generation };
105
+ return { run: true, reason: 'holder', generation: record.generation, holder: me };
106
+ }
107
+ if (local.holder !== me || local.generation === undefined) return { run: false, reason: 'not-holder', holder: local.holder, generation: local.generation };
108
+ if (input.standbyGeneration !== undefined && input.standbyGeneration > local.generation) return { run: false, reason: 'stale-generation', generation: input.standbyGeneration };
109
+ if (input.standbyReachable) return { run: true, reason: 'quorum-standby', generation: local.generation, holder: me };
110
+ const ttl = local.ttlSeconds ?? DEFAULT_TTL_SECONDS;
111
+ if (local.confirmedAt !== undefined && now - local.confirmedAt <= ttl) return { run: true, reason: 'within-ttl', generation: local.generation, holder: me };
112
+ if (input.failOpen) return { run: true, reason: 'fail-open', generation: local.generation, holder: me };
113
+ return { run: false, reason: 'no-quorum-expired', generation: local.generation, holder: me };
114
+ }
115
+
116
+ // ── stores ────────────────────────────────────────────────────────────────────
117
+ export interface LeaseStore {
118
+ /** null raw = no record · throws when the store cannot be reached. */
119
+ read(): { now: number; raw: string | null };
120
+ /** Compare-and-swap on the raw bytes; false when someone else wrote first. */
121
+ cas(expected: string | null, next: string): boolean;
122
+ }
123
+ export const sha = (raw: string | null): string => raw === null ? 'none' : createHash('sha256').update(raw).digest('hex');
124
+
125
+ /** Arbiter-local store (the arbiter's own check runs next to the file). mkdir is the lock. */
126
+ export function fileLeaseStore(path: string, clock: () => number = () => Math.floor(Date.now() / 1000)): LeaseStore {
127
+ const lock = `${path}.lock`;
128
+ return {
129
+ read: () => ({ now: clock(), raw: existsSync(path) ? readFileSync(path, 'utf8') : null }),
130
+ cas(expected, next) {
131
+ mkdirSync(dirname(path), { recursive: true });
132
+ for (let i = 0; ; i++) {
133
+ try { mkdirSync(lock); break; } catch {
134
+ try { if (Date.now() - statSync(lock).mtimeMs > 30_000) { rmdirSync(lock); continue; } } catch { /* raced */ }
135
+ if (i > 100) throw new Error('lease lock busy');
136
+ Bun.sleepSync(50);
137
+ }
138
+ }
139
+ try {
140
+ const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
141
+ if (sha(current) !== sha(expected)) return false;
142
+ const tmp = `${path}.${process.pid}.tmp`;
143
+ writeFileSync(tmp, next, { mode: 0o600 });
144
+ renameSync(tmp, path);
145
+ return true;
146
+ } finally { try { rmdirSync(lock); } catch { /* already gone */ } }
147
+ },
148
+ };
149
+ }
150
+
151
+ export type SshRunner = (host: string, script: string, input?: string) => { status: number | null; stdout: string; stderr: string };
152
+ export const defaultSshRunner: SshRunner = (host, script, input) => {
153
+ // ssh joins its arguments into one remote shell line — hand bash a single-quoted script.
154
+ const quoted = `'${script.replaceAll("'", "'\\''")}'`;
155
+ const r = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', host, `bash -c ${quoted}`],
156
+ { input: input ?? '', encoding: 'utf8', timeout: 30_000 });
157
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
158
+ };
159
+
160
+ /** The arbiter itself (cloud-vm) runs the same lease script locally — `hq.arbiter: "local"` (drill 10-04: gcp has no ssh to itself). */
161
+ export const localShellRunner: SshRunner = (_host, script, input) => {
162
+ const r = spawnSync('bash', ['-c', script], { input: input ?? '', encoding: 'utf8', timeout: 30_000 });
163
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
164
+ };
165
+
166
+ const REMOTE_DIR = '$HOME/.elanous-hq';
167
+ /** Remote store over ssh: the remote side only compares a sha and renames under a mkdir lock — all decisions stay here. */
168
+ export function sshLeaseStore(host: string, run: SshRunner = defaultSshRunner): LeaseStore {
169
+ return {
170
+ read() {
171
+ const r = run(host, `date +%s && echo ---- && { cat ${REMOTE_DIR}/lease.json 2>/dev/null || true; }`);
172
+ if (r.status !== 0) throw new Error(`arbiter ${host} unreachable: ${r.stderr.trim().split('\n')[0] ?? r.status}`);
173
+ const [head, ...rest] = r.stdout.split('----\n');
174
+ const now = Number.parseInt((head ?? '').trim(), 10);
175
+ if (!Number.isFinite(now)) throw new Error(`arbiter ${host} gave no clock`);
176
+ const raw = rest.join('----\n');
177
+ return { now, raw: raw.length ? raw : null };
178
+ },
179
+ cas(expected, next) {
180
+ const script = [
181
+ `set -e; d=${REMOTE_DIR}; f=$d/lease.json; mkdir -p "$d"`,
182
+ 'i=0; until mkdir "$d/lock" 2>/dev/null; do i=$((i+1)); [ $i -gt 100 ] && { echo BUSY; exit 3; }; sleep 0.05; done',
183
+ 'trap \'rmdir "$d/lock" 2>/dev/null || true\' EXIT',
184
+ 'if [ -f "$f" ]; then cur=$( (sha256sum "$f" 2>/dev/null || shasum -a 256 "$f") | cut -d" " -f1); else cur=none; fi',
185
+ `[ "$cur" = "${sha(expected)}" ] || { echo CONFLICT; exit 0; }`,
186
+ 'cat > "$f.tmp.$$"; chmod 600 "$f.tmp.$$"; mv "$f.tmp.$$" "$f"; echo OK',
187
+ ].join('\n');
188
+ const r = run(host, script, next);
189
+ if (r.status !== 0) throw new Error(`arbiter ${host} write failed: ${r.stdout.trim() || r.stderr.trim().split('\n')[0]}`);
190
+ return r.stdout.trim().endsWith('OK');
191
+ },
192
+ };
193
+ }
194
+
195
+ /** Home-to-home reachability (ssh true) — used only by the holder to reach the standby, never by the arbiter. */
196
+ export const sshReachable = (host: string, run: SshRunner = defaultSshRunner): boolean => {
197
+ try { return run(host, 'true').status === 0; } catch { return false; }
198
+ };
199
+
200
+ /**
201
+ * Tailnet probe (OP 10-04 09:04): liveness = `tailscale ping`, application = nexus health over the tailnet.
202
+ * One «unreachable» observation needs BOTH to fail. No ssh — the arbiter holds no shell on home machines.
203
+ */
204
+ export interface HostProbe { ping(host: string): boolean; health(host: string): boolean }
205
+ export function probeReachable(host: string, probe: HostProbe): { reachable: boolean; ping: boolean; health: boolean } {
206
+ let ping = false; let health = false;
207
+ try { ping = probe.ping(host); } catch { ping = false; }
208
+ try { health = probe.health(host); } catch { health = false; }
209
+ return { reachable: ping || health, ping, health };
210
+ }
@@ -0,0 +1,41 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
3
+ import { effectiveInstanceRoot, prodInstanceRoot } from '../instance/resolve.js';
4
+
5
+ function physicalPath(path: string): string {
6
+ // Resolve one component at a time: lexical normalization of `link/..` before
7
+ // realpathSync would discard the link even though the filesystem follows it.
8
+ const raw = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
9
+ let current: string = sep;
10
+ for (const component of raw.split(sep)) {
11
+ if (!component || component === '.') continue;
12
+ if (component === '..') {
13
+ current = dirname(current);
14
+ continue;
15
+ }
16
+ const candidate = join(current, component);
17
+ try { current = realpathSync(candidate); }
18
+ catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
20
+ current = candidate;
21
+ }
22
+ }
23
+ return current;
24
+ }
25
+
26
+ function within(root: string, target: string): boolean {
27
+ const suffix = relative(root, target);
28
+ return suffix === '' || (suffix !== '..' && !suffix.startsWith(`..${sep}`));
29
+ }
30
+
31
+ /** Only writes inside this process's non-operational instance universe bypass the HQ ledger lease. */
32
+ export function isIsolatedLedgerWriteRoot(
33
+ ledgerRoot: string,
34
+ instanceRoot = effectiveInstanceRoot(),
35
+ operationalRoot = prodInstanceRoot(),
36
+ ): boolean {
37
+ const target = physicalPath(ledgerRoot);
38
+ const instance = physicalPath(instanceRoot);
39
+ const prod = physicalPath(operationalRoot);
40
+ return instance !== prod && within(instance, target) && !within(prod, target);
41
+ }
@@ -0,0 +1,301 @@
1
+ // HQ promotion runbook: read-only preview by default; all remote mutations are behind --apply.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { getUserConfig } from '../user-config.js';
4
+ import { parseLease, sshLeaseStore, defaultSshRunner, localShellRunner } from './lease.js';
5
+
6
+ export interface PromoteRunner {
7
+ lease(): { holder: string | null; generation: number | null; expired: boolean };
8
+ run(host: string, script: string): { status: number | null; stdout: string; stderr: string };
9
+ }
10
+
11
+ export interface PromoteLine { step: string; status: 'ready' | 'done' | 'failed'; measurement: string; command: string }
12
+ export interface PromoteResult { ok: boolean; apply: boolean; host: string; lines: PromoteLine[] }
13
+
14
+ const install = '"$HOME/.local/share/elanous/current/node_modules/elanous"';
15
+ const state = '"$HOME/.elanous-hqstate"';
16
+ const cli = 'elanous';
17
+ // A bind to the same address as nexus distinguishes an available socket from an
18
+ // occupied or uninspectable one; lsof's exit 1 alone cannot make that distinction.
19
+ const portProbe = `command -v lsof >/dev/null || { echo 'lsof unavailable' >&2; exit 1; }
20
+ python3 -c 'import os,socket,subprocess,sys; s=socket.socket(); s.bind(("127.0.0.1",0)); s.listen(); r=subprocess.run(["lsof","-nP","-t","-iTCP:"+str(s.getsockname()[1]),"-sTCP:LISTEN"],capture_output=True,text=True); s.close(); sys.exit(0 if r.returncode == 0 and r.stdout.strip() == str(os.getpid()) else "lsof control listener unreadable: "+r.stderr.strip())' || exit 1
21
+ port_detail=$(lsof -nP -t -iTCP:31416 -sTCP:LISTEN 2>&1); port_rc=$?
22
+ if [ "$port_rc" -ne 1 ] || [ -n "$port_detail" ]; then echo "port=31416 occupied or unreadable: $port_detail (lsof=$port_rc)" >&2; exit 1; fi
23
+ python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 31416)); s.close(); print("port=31416 available")'`;
24
+ // The drill uses short SSH aliases; host-local hq.hostName may use either the short or full name.
25
+ const hostNamesFor = (host: string): string[] => host === 'node-b' ? ['node-b', 'MacStudioB1']
26
+ : host === 'mbp' ? ['mbp', 'MacBookProM5'] : [host];
27
+ const checks: Array<[string, string]> = [
28
+ ['PWA build', `test -f "$HOME/.local/share/elanous/current/node_modules/elanous/apps/pwa/out/index.html" && echo 'index.html present'`],
29
+ ['MCP list', `set -o pipefail; codex mcp list --json | python3 -c 'import json,sys; data=json.load(sys.stdin); print("MCP entries="+str(len(data)))'`],
30
+ ['installed hq', `${cli} hq promote --help >/dev/null && python3 -c 'import json,pathlib; p=pathlib.Path.home()/".elanous-hqcfg/config.json"; print("host="+json.loads(p.read_text())["hq"]["hostName"])'`],
31
+ ];
32
+ const stages: Array<[string, string]> = [
33
+ ['standby checksums', `cd ${install} && bun -e 'import("node:fs/promises").then(async ({ readlink }) => { const root = process.env.HOME + "/.elanous-standby"; const generations = { core: await readlink(root + "/core/latest"), big: await readlink(root + "/big/latest") }; const { verifyStandby } = await import("./scripts/hq/standby-verify.ts"); const result = await verifyStandby({ root, tiers: ["core", "big"], generations }); console.log(JSON.stringify(result)); if (!result.ok) process.exitCode = 1 }).catch(error => { console.error(error); process.exitCode = 1 })'`],
34
+ ['copy to promotion universe', `python3 - <<'PY'
35
+ import hashlib, json, os, pathlib, shutil, tempfile
36
+ home = pathlib.Path.home()
37
+ standby = home / '.elanous-standby'
38
+ target = home / '.elanous-hqstate'
39
+ parent = target.parent
40
+ stage = pathlib.Path(tempfile.mkdtemp(prefix='.elanous-hqstate-promote-', dir=parent))
41
+ backup = None
42
+ try:
43
+ copied = 0
44
+ for tier in ('core', 'big'):
45
+ generation = os.environ['HQ_PROMOTE_' + tier.upper()]
46
+ source = standby / tier / generation
47
+ if not source.is_dir() or source.is_symlink():
48
+ raise ValueError('missing or symlinked pinned generation: ' + tier)
49
+ manifest_bytes = (source / 'MANIFEST.json').read_bytes()
50
+ expected_manifest = os.environ['HQ_PROMOTE_' + tier.upper() + '_MANIFEST_SHA256']
51
+ if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
52
+ raise ValueError('verified manifest changed: ' + tier)
53
+ manifest = json.loads(manifest_bytes)
54
+ if manifest['tier'] != tier or manifest['generation'] != generation:
55
+ raise ValueError('generation changed: ' + tier)
56
+ for entry in manifest['entries']:
57
+ name = entry['path']
58
+ rel = pathlib.PurePosixPath(name)
59
+ if not name or rel.is_absolute() or '..' in rel.parts or name.startswith('./') or chr(92) in name or name in ('MANIFEST.json', 'SHA256SUMS'):
60
+ raise ValueError('invalid manifest path: ' + name)
61
+ src = source
62
+ for part in rel.parts:
63
+ src = src / part
64
+ if src.is_symlink():
65
+ raise ValueError('symlinked source: ' + name)
66
+ if not src.is_file():
67
+ raise ValueError('missing source: ' + name)
68
+ dest = stage / name
69
+ if dest.parent.is_symlink():
70
+ raise ValueError('symlinked destination parent: ' + name)
71
+ dest.parent.mkdir(parents=True, exist_ok=True)
72
+ if dest.is_symlink():
73
+ raise ValueError('symlinked destination: ' + name)
74
+ shutil.copy2(src, dest)
75
+ digest = hashlib.sha256(dest.read_bytes()).hexdigest()
76
+ if digest != entry['sha256']:
77
+ raise ValueError('checksum changed during copy: ' + name)
78
+ copied += 1
79
+ if hashlib.sha256((source / 'MANIFEST.json').read_bytes()).hexdigest() != expected_manifest:
80
+ raise ValueError('verified manifest changed during copy: ' + tier)
81
+ for tier in ('core', 'big'):
82
+ source = standby / tier / os.environ['HQ_PROMOTE_' + tier.upper()]
83
+ expected_manifest = os.environ['HQ_PROMOTE_' + tier.upper() + '_MANIFEST_SHA256']
84
+ if hashlib.sha256((source / 'MANIFEST.json').read_bytes()).hexdigest() != expected_manifest:
85
+ raise ValueError('verified manifest changed before promotion: ' + tier)
86
+ if not (stage / 'release/features.sqlite').is_file() or not (stage / 'config.json').is_file():
87
+ raise ValueError('core promotion files missing')
88
+ if target.is_symlink() or (target.exists() and not target.is_dir()):
89
+ raise ValueError('promotion target is not a directory')
90
+ if target.exists():
91
+ backup = pathlib.Path(tempfile.mkdtemp(prefix='.elanous-hqstate-old-', dir=parent))
92
+ backup.rmdir()
93
+ target.rename(backup)
94
+ try:
95
+ stage.rename(target)
96
+ except BaseException:
97
+ if backup is not None:
98
+ backup.rename(target)
99
+ backup = None
100
+ raise
101
+ bytes_count = sum(p.stat().st_size for p in target.rglob('*') if p.is_file())
102
+ if backup is not None:
103
+ shutil.rmtree(backup)
104
+ backup = None
105
+ print('promotion files=' + str(copied) + ' bytes=' + str(bytes_count) + 'B')
106
+ finally:
107
+ if stage.exists():
108
+ shutil.rmtree(stage)
109
+ PY`],
110
+ ['target hq config', `python3 - <<'PY'
111
+ import json, os, pathlib, tempfile
112
+ source = pathlib.Path.home() / '.elanous-hqcfg/config.json'
113
+ target = pathlib.Path.home() / '.elanous-hqstate/config.json'
114
+ with source.open() as f: hq = json.load(f)['hq']
115
+ with target.open() as f: config = json.load(f)
116
+ assert isinstance(hq, dict) and hq.get('hostName') == os.environ['HQ_PROMOTE_HOST'], 'host-specific hq config mismatch'
117
+ config['hq'] = hq
118
+ with tempfile.NamedTemporaryFile(mode='w', dir=target.parent, prefix='config.promote-', delete=False) as f:
119
+ tmp = pathlib.Path(f.name)
120
+ json.dump(config, f)
121
+ f.write(chr(10))
122
+ try:
123
+ os.chmod(tmp, 0o600)
124
+ os.replace(tmp, target)
125
+ finally:
126
+ tmp.unlink(missing_ok=True)
127
+ print('hq.hostName=' + hq['hostName'])
128
+ PY`],
129
+ ['nexus health', `command -v lsof >/dev/null || { echo 'lsof unavailable: cannot identify nexus listener' >&2; exit 1; }
130
+ ${portProbe} || { echo 'port=31416 already occupied or unreadable' >&2; exit 1; }
131
+ ELANOUS_STATE_DIR=${state} ${cli} --config-dir ${state} nexus run --port 31416 --http-host 127.0.0.1 --no-mcp --no-auto-build --no-auto-install --no-auto-restart --no-watch > "$HOME/.elanous-hqstate/nexus-promote.log" 2>&1 & pid=$!
132
+ descendants() {
133
+ for child in $(pgrep -P "$1" 2>/dev/null); do descendants "$child"; echo "$child"; done
134
+ }
135
+ cleanup() {
136
+ tree=$(descendants "$pid")
137
+ for proc in $tree; do kill "$proc" 2>/dev/null || true; done
138
+ kill "$pid" 2>/dev/null || true
139
+ wait "$pid" 2>/dev/null || true
140
+ n=0
141
+ until ${portProbe} >/dev/null 2>&1; do
142
+ if [ "$n" -ge 10 ]; then echo 'port=31416 still occupied after cleanup' >&2; return 1; fi
143
+ n=$((n+1)); sleep 1
144
+ done
145
+ echo 'nexus stopped port=31416 released' >&2
146
+ }
147
+ trap 'cleanup' EXIT
148
+ n=0
149
+ code=000
150
+ owner=''
151
+ owned=0
152
+ while [ "$n" -lt 15 ]; do
153
+ code=$(curl -s --max-time 2 -o /dev/null -w '%{http_code}' http://127.0.0.1:31416/v1/health) || code=000
154
+ listeners=$(lsof -nP -t -iTCP:31416 -sTCP:LISTEN 2> /dev/null) || listeners=''
155
+ owner=$(printf '%s\\n' "$listeners" | head -n 1)
156
+ ancestor=$owner
157
+ owned=0
158
+ while [ -n "$ancestor" ] && [ "$ancestor" != 1 ]; do
159
+ if [ "$ancestor" = "$pid" ]; then owned=1; break; fi
160
+ ancestor=$(ps -o ppid= -p "$ancestor" | tr -d '[:space:]')
161
+ done
162
+ if [ "$code" = 200 ] && [ "$owned" = 1 ] && [ "$listeners" = "$owner" ] && kill -0 "$pid" 2>/dev/null; then
163
+ trap - EXIT
164
+ echo "health=$code port=31416 pid=$pid listener_pid=$owner"; exit 0
165
+ fi
166
+ if ! kill -0 "$pid" 2>/dev/null; then break; fi
167
+ n=$((n+1)); sleep 2
168
+ done
169
+ echo "health=$code port=31416 nexus_pid=$pid listener_pid=$owner owned=$owned" >&2; exit 1`],
170
+ ['steward rescue', `ELANOUS_STATE_DIR=${state} ${cli} --config-dir ${state} steward mode rescue && ELANOUS_STATE_DIR=${state} ${cli} --config-dir ${state} steward mode --json`],
171
+ ];
172
+
173
+ // One --apply per target host: stages run as separate ssh calls, so the lock is a directory that outlives each call.
174
+ const lockDir = '"$HOME/.elanous-hq/promote.lock"';
175
+ export const promoteLockAcquire = `mkdir -p "$HOME/.elanous-hq" && if mkdir ${lockDir} 2>/dev/null; then printf '%s %s\\n' "$PPID" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > ${lockDir}/owner; else echo "promote lock held: $(cat ${lockDir}/owner 2>/dev/null) · remove ${lockDir} only after confirming no promote is running" >&2; exit 1; fi`;
176
+ export const promoteLockRelease = `rm -rf ${lockDir}`;
177
+
178
+ export function defaultPromoteRunner(): PromoteRunner {
179
+ const config = getUserConfig().hq ?? {};
180
+ const arbiter = config.arbiter ?? 'cloud-vm';
181
+ const store = sshLeaseStore(arbiter, arbiter === 'local' ? localShellRunner : defaultSshRunner);
182
+ return {
183
+ lease() {
184
+ const { now, raw } = store.read();
185
+ const record = parseLease(raw);
186
+ return { holder: record?.holder ?? null, generation: record?.generation ?? null,
187
+ expired: !record || now - record.renewedAt > record.ttlSeconds };
188
+ },
189
+ run(host, script) {
190
+ const r = spawnSync('ssh', ['-o', 'BatchMode=yes', host, `bash -c '${script.replaceAll("'", "'\\''")}'`], { encoding: 'utf8', timeout: 120_000 });
191
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? String(r.error ?? '') };
192
+ },
193
+ };
194
+ }
195
+
196
+ export function promoteHq(host: string, apply = false, runner: PromoteRunner = defaultPromoteRunner()): PromoteResult {
197
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(host) || host.includes('..')) throw new Error('hq promote: invalid host');
198
+ const lines: PromoteLine[] = [];
199
+ const acceptedHostNames = hostNamesFor(host);
200
+ const result = (step: string, command: string, status: PromoteLine['status'], measurement: string) => {
201
+ lines.push({ step, status, measurement, command });
202
+ };
203
+ let preflightOk = true;
204
+ for (const [name, command] of checks) {
205
+ let r: ReturnType<PromoteRunner['run']>;
206
+ try { r = runner.run(host, command); } catch (error) { r = { status: null, stdout: '', stderr: String(error) }; }
207
+ const ok = r.status === 0 && (name !== 'installed hq' || acceptedHostNames.some(name => r.stdout.trim() === `host=${name}`));
208
+ if (!ok) preflightOk = false;
209
+ result(`preflight ${name}`, command, ok ? 'done' : 'failed', (r.status === 0 ? r.stdout : r.stderr).trim() || `exit=${r.status}`);
210
+ }
211
+ let lease: ReturnType<PromoteRunner['lease']>;
212
+ try { lease = runner.lease(); } catch (error) {
213
+ result('① lease', 'read arbiter lease', 'failed', String(error));
214
+ return { ok: false, apply, host, lines };
215
+ }
216
+ if (!acceptedHostNames.includes(lease.holder ?? '') || lease.expired) {
217
+ result('① lease', 'read arbiter lease', 'failed', `holder=${lease.holder ?? 'none'} generation=${lease.generation ?? 'none'} expired=${lease.expired}`);
218
+ return { ok: false, apply, host, lines };
219
+ }
220
+ result('① lease', 'read arbiter lease', 'done', `holder=${lease.holder} generation=${lease.generation} expired=false`);
221
+ const targetHostName = lines.find(line => line.step === 'preflight installed hq')?.measurement.trim().replace(/^host=/, '') ?? '';
222
+ if (!acceptedHostNames.includes(targetHostName) || targetHostName !== lease.holder) {
223
+ result('① lease', 'compare arbiter holder to target hq.hostName', 'failed', `holder=${lease.holder} hq.hostName=${targetHostName}`);
224
+ return { ok: false, apply, host, lines };
225
+ }
226
+ if (apply && !preflightOk) return { ok: false, apply, host, lines };
227
+ let pinned: { core: string; big: string; coreManifestSha256: string; bigManifestSha256: string } | null = null;
228
+ let locked = false;
229
+ try {
230
+ for (let i = 0; i < stages.length; i++) {
231
+ const [name, script] = stages[i];
232
+ const command = i === 1 && pinned
233
+ ? `HQ_PROMOTE_CORE=${pinned.core} HQ_PROMOTE_BIG=${pinned.big} HQ_PROMOTE_CORE_MANIFEST_SHA256=${pinned.coreManifestSha256} HQ_PROMOTE_BIG_MANIFEST_SHA256=${pinned.bigManifestSha256} ${script}`
234
+ : i === 2 ? `HQ_PROMOTE_HOST=${targetHostName} ${script}` : script;
235
+ if (apply && i > 0) {
236
+ try {
237
+ const current = runner.lease();
238
+ if (current.holder !== targetHostName || current.generation !== lease.generation || current.expired) {
239
+ result(`${'②③④⑤⑥'[i]} ${name}`, command, 'failed', `lease changed: holder=${current.holder ?? 'none'} generation=${current.generation ?? 'none'} expired=${current.expired}`);
240
+ return { ok: false, apply, host, lines };
241
+ }
242
+ } catch (error) {
243
+ result(`${'②③④⑤⑥'[i]} ${name}`, command, 'failed', `lease read failed: ${String(error)}`);
244
+ return { ok: false, apply, host, lines };
245
+ }
246
+ }
247
+ if (!apply && i > 0) {
248
+ result(`${'②③④⑤⑥'[i]} ${name}`, command, 'ready', `dry-run: would run on ${host}; no writes`);
249
+ continue;
250
+ }
251
+ if (apply && i === 1) {
252
+ // The host lock and the port probe share one call: the lock is held from before the copy until the finally below.
253
+ const lockedProbe = `${promoteLockAcquire} && ${portProbe}`;
254
+ let probe: ReturnType<PromoteRunner['run']>;
255
+ try { probe = runner.run(host, lockedProbe); } catch (error) { probe = { status: null, stdout: '', stderr: String(error) }; }
256
+ locked = !probe.stderr.includes('promote lock held');
257
+ if (!locked) {
258
+ result('③ copy to promotion universe', lockedProbe, 'failed', probe.stderr.trim());
259
+ return { ok: false, apply, host, lines };
260
+ }
261
+ if (probe.status !== 0 || probe.stdout.trim() !== 'port=31416 available') {
262
+ result('③ copy to promotion universe', portProbe, 'failed', `port=31416 occupied or unreadable before copy: ${probe.stderr.trim() || probe.stdout.trim() || `exit=${probe.status}`}`);
263
+ return { ok: false, apply, host, lines };
264
+ }
265
+ }
266
+ let r: ReturnType<PromoteRunner['run']>;
267
+ try { r = runner.run(host, command); } catch (error) { r = { status: null, stdout: '', stderr: String(error) }; }
268
+ let measurement = (r.status === 0 ? r.stdout : r.stderr).trim() || `exit=${r.status}`;
269
+ let ok = r.status === 0;
270
+ if (i === 1 && ok) ok = /promotion files=\d+ bytes=\d+B/.test(r.stdout);
271
+ if (i === 2 && ok) ok = r.stdout.trim() === `hq.hostName=${targetHostName}`;
272
+ if (i === 3 && ok) ok = /health=200 port=31416 pid=\d+ listener_pid=\d+\b/.test(r.stdout);
273
+ if (i === 4 && ok) ok = /"mode"\s*:\s*"rescue"/.test(r.stdout);
274
+ if (i === 0 && ok) {
275
+ try {
276
+ const verification = JSON.parse(r.stdout) as { ok?: boolean; tiers?: Array<{ tier: string; generation: string | null; manifestSha256: string | null; checked: number; entries: number | null }> };
277
+ ok = verification.ok === true && verification.tiers?.length === 2
278
+ && verification.tiers.map(t => t.tier).join(',') === 'core,big'
279
+ && verification.tiers.every(t => t.generation && /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(t.generation)
280
+ && typeof t.manifestSha256 === 'string' && /^[a-f0-9]{64}$/.test(t.manifestSha256)
281
+ && t.entries !== null && t.entries > 0 && t.checked === t.entries) === true;
282
+ if (ok) pinned = { core: verification.tiers![0].generation!, big: verification.tiers![1].generation!,
283
+ coreManifestSha256: verification.tiers![0].manifestSha256!, bigManifestSha256: verification.tiers![1].manifestSha256! };
284
+ } catch { ok = false; }
285
+ if (!ok) measurement = `checksum verification failed · ${measurement}`;
286
+ }
287
+ result(`${'②③④⑤⑥'[i]} ${name}`, command, ok ? 'done' : 'failed', measurement);
288
+ if (!ok) return { ok: false, apply, host, lines };
289
+ }
290
+ return { ok: preflightOk, apply, host, lines };
291
+ } finally {
292
+ if (locked) {
293
+ try { runner.run(host, promoteLockRelease); } catch { /* the lock names its owner for manual release */ }
294
+ }
295
+ }
296
+ }
297
+
298
+ export function formatPromote(result: PromoteResult): string {
299
+ return [`hq promote --to ${result.host} ${result.apply ? '--apply' : '(dry-run)'}`,
300
+ ...result.lines.map(line => `${line.step}: ${line.status} · ${line.measurement}${line.status === 'ready' ? ` · ${line.command}` : ''}`)].join('\n');
301
+ }