elanous 0.2.16 → 0.2.17

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 (228) 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/{1307-b46d45b705e8c9ae.js → 1307-aac7332576c4d6fc.js} +1 -1
  4. package/apps/pwa/out/_next/static/chunks/{6877-7c6208705c284124.js → 6877-fe94e76a5841ee9b.js} +1 -1
  5. package/apps/pwa/out/_next/static/chunks/8401-4ce1c42c8d89903f.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/899-f41be85e8a680651.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/9247-269e4464cc4b491c.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/app/approvals/page-3143b07a817d1da9.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-3cb52e5ea8fc2fa5.js → page-17ba086ef82d339b.js} +1 -1
  10. package/apps/pwa/out/_next/static/chunks/app/board/page-7eb661ff23af7da2.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/botlab/page-6b8444914da74ca6.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/bots/{page-482c5dbde699947c.js → page-6e5c3a867557298b.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/ceo/{page-1e962b31d1f11081.js → page-7110f732f45bf944.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/chat/{page-8c2d9526164f5d09.js → page-9a41d65355fc25b9.js} +1 -1
  15. package/apps/pwa/out/_next/static/chunks/app/consult/page-5e77cd1802567c89.js +1 -0
  16. package/apps/pwa/out/_next/static/chunks/app/control/page-41afec0b255ed2bb.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/design-check/{page-c53598cfce6156f2.js → page-4187666132fc98c4.js} +1 -1
  18. package/apps/pwa/out/_next/static/chunks/app/editor/{page-13a63953f53c6a23.js → page-09328d499162bae4.js} +1 -1
  19. package/apps/pwa/out/_next/static/chunks/app/exec/{page-5d3c8429d04f3831.js → page-29ff3a8f84f03b6a.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/field/{page-3193bdc0bbab047a.js → page-80ec7401bc83f511.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/inside/{page-76270efaee638f17.js → page-ab93b240f687d69a.js} +1 -1
  22. package/apps/pwa/out/_next/static/chunks/app/intake/{page-6107ca8ddd5e3bdd.js → page-fc7d86467291ae38.js} +1 -1
  23. package/apps/pwa/out/_next/static/chunks/app/{layout-dce686e582c23937.js → layout-de43a186e5ba5683.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/live/{page-d3d53b146416e422.js → page-8b08cafad27759c9.js} +1 -1
  25. package/apps/pwa/out/_next/static/chunks/app/loops/page-0e2d6985fb42cd2f.js +1 -0
  26. package/apps/pwa/out/_next/static/chunks/app/market/page-13e4108999f58d13.js +1 -0
  27. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-e8296ee284aa1293.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/morning/{page-4167348049166852.js → page-043a9111e74f6b24.js} +1 -1
  29. package/apps/pwa/out/_next/static/chunks/app/{not-found-de52e8fdf9095f7a.js → not-found-23a4601ceb7f452e.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/observatory/page-1ed1324b1dc36754.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-ca3952cd78a83ca2.js → page-10d958605a77b3bd.js} +1 -1
  32. package/apps/pwa/out/_next/static/chunks/app/ops/release/{page-5b042736aa113756.js → page-166745e51ac0c7b0.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/ops/seats/{page-acb749f1e432bcf3.js → page-a04260b8ccc968d4.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/outputs/page-7a1d85e121065cc8.js +1 -0
  35. package/apps/pwa/out/_next/static/chunks/app/page-1f50e3d4e13435f4.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/reflection/{page-1ebfe049c640e171.js → page-12916918988ac97e.js} +1 -1
  37. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-d8a3347a9d0c3f4a.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/app/sessions/page-a9117d2cd3093d36.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-31fa1ce4c4aff9ae.js → page-5a64cb5208c6a8fd.js} +1 -1
  40. package/apps/pwa/out/_next/static/chunks/app/settings/{page-0f5cd57ca928f42b.js → page-1407ec83ea64c1f9.js} +1 -1
  41. package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-abe8e3b74bea1ffc.js → page-1d824510df025d18.js} +1 -1
  42. package/apps/pwa/out/_next/static/chunks/app/setup/page-6e878ebbe1737d00.js +1 -0
  43. package/apps/pwa/out/_next/static/chunks/app/share/{page-d28573d6792b4275.js → page-7f49fe645d5132f7.js} +1 -1
  44. package/apps/pwa/out/_next/static/chunks/app/showroom/{page-59f03fda76c03d22.js → page-81a4e7f8dbfe74cb.js} +1 -1
  45. package/apps/pwa/out/_next/static/chunks/app/tasks/{page-d751de5f8062ff5e.js → page-27d71f131acd9320.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/term/page-3ab59d19913b200e.js +1 -0
  47. package/apps/pwa/out/_next/static/chunks/app/today/page-bf85b2f5bf077925.js +1 -0
  48. package/apps/pwa/out/_next/static/chunks/app/trace/page-424659bacfb8cdd7.js +1 -0
  49. package/apps/pwa/out/_next/static/chunks/app/vault/page-ab32a8f522a60371.js +1 -0
  50. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-d4aef67abf264874.js → page-fc2df261f8a2c7df.js} +1 -1
  51. package/apps/pwa/out/_next/static/chunks/app/workflows/{page-1ea64acadd704ace.js → page-09461366af82ef13.js} +1 -1
  52. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-3544f154c87b43ee.js → page-5d4fef9ccd7d406b.js} +1 -1
  53. package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-30c2073bfc21c004.js → page-ea90a8ac73ce2749.js} +1 -1
  54. package/apps/pwa/out/approvals/index.html +1 -1
  55. package/apps/pwa/out/approvals/index.txt +13 -13
  56. package/apps/pwa/out/autopilot/index.html +1 -1
  57. package/apps/pwa/out/autopilot/index.txt +13 -13
  58. package/apps/pwa/out/board/index.html +1 -1
  59. package/apps/pwa/out/board/index.txt +13 -13
  60. package/apps/pwa/out/botlab/index.html +1 -1
  61. package/apps/pwa/out/botlab/index.txt +13 -13
  62. package/apps/pwa/out/bots/index.html +1 -1
  63. package/apps/pwa/out/bots/index.txt +13 -13
  64. package/apps/pwa/out/ceo/index.html +1 -1
  65. package/apps/pwa/out/ceo/index.txt +13 -13
  66. package/apps/pwa/out/chat/index.html +1 -1
  67. package/apps/pwa/out/chat/index.txt +13 -13
  68. package/apps/pwa/out/consult/index.html +1 -1
  69. package/apps/pwa/out/consult/index.txt +13 -13
  70. package/apps/pwa/out/control/index.html +1 -1
  71. package/apps/pwa/out/control/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/loops/index.html +1 -1
  90. package/apps/pwa/out/loops/index.txt +13 -13
  91. package/apps/pwa/out/market/index.html +1 -1
  92. package/apps/pwa/out/market/index.txt +13 -13
  93. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  94. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  95. package/apps/pwa/out/morning/index.html +1 -1
  96. package/apps/pwa/out/morning/index.txt +13 -13
  97. package/apps/pwa/out/observatory/index.html +1 -1
  98. package/apps/pwa/out/observatory/index.txt +13 -13
  99. package/apps/pwa/out/ops/checklist/index.html +1 -1
  100. package/apps/pwa/out/ops/checklist/index.txt +13 -13
  101. package/apps/pwa/out/ops/release/index.html +1 -1
  102. package/apps/pwa/out/ops/release/index.txt +13 -13
  103. package/apps/pwa/out/ops/seats/index.html +1 -1
  104. package/apps/pwa/out/ops/seats/index.txt +13 -13
  105. package/apps/pwa/out/outputs/index.html +1 -1
  106. package/apps/pwa/out/outputs/index.txt +13 -13
  107. package/apps/pwa/out/reflection/index.html +1 -1
  108. package/apps/pwa/out/reflection/index.txt +14 -14
  109. package/apps/pwa/out/scheduler/index.html +1 -1
  110. package/apps/pwa/out/scheduler/index.txt +13 -13
  111. package/apps/pwa/out/sessions/index.html +1 -1
  112. package/apps/pwa/out/sessions/index.txt +13 -13
  113. package/apps/pwa/out/settings/devices/index.html +1 -1
  114. package/apps/pwa/out/settings/devices/index.txt +13 -13
  115. package/apps/pwa/out/settings/index.html +1 -1
  116. package/apps/pwa/out/settings/index.txt +13 -13
  117. package/apps/pwa/out/setup/done/index.html +1 -1
  118. package/apps/pwa/out/setup/done/index.txt +13 -13
  119. package/apps/pwa/out/setup/index.html +1 -1
  120. package/apps/pwa/out/setup/index.txt +13 -13
  121. package/apps/pwa/out/share/index.html +1 -1
  122. package/apps/pwa/out/share/index.txt +13 -13
  123. package/apps/pwa/out/showroom/index.html +1 -1
  124. package/apps/pwa/out/showroom/index.txt +13 -13
  125. package/apps/pwa/out/tasks/index.html +1 -1
  126. package/apps/pwa/out/tasks/index.txt +13 -13
  127. package/apps/pwa/out/term/index.html +1 -1
  128. package/apps/pwa/out/term/index.txt +13 -13
  129. package/apps/pwa/out/today/index.html +1 -1
  130. package/apps/pwa/out/today/index.txt +13 -13
  131. package/apps/pwa/out/trace/index.html +1 -1
  132. package/apps/pwa/out/trace/index.txt +13 -13
  133. package/apps/pwa/out/vault/index.html +1 -1
  134. package/apps/pwa/out/vault/index.txt +13 -13
  135. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  136. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  137. package/apps/pwa/out/workflows/index.html +1 -1
  138. package/apps/pwa/out/workflows/index.txt +13 -13
  139. package/apps/pwa/out/workspace/index.html +1 -1
  140. package/apps/pwa/out/workspace/index.txt +13 -13
  141. package/apps/pwa/out/worktrees/index.html +1 -1
  142. package/apps/pwa/out/worktrees/index.txt +13 -13
  143. package/graphs/intake/intake-daily.yaml +9 -2
  144. package/graphs/intake/news-sources.yaml +4 -0
  145. package/graphs/intake/recipes.yaml +6 -1
  146. package/package.json +1 -1
  147. package/scripts/__fixtures__/intake-news.xml +7 -0
  148. package/scripts/intake-news.ts +169 -0
  149. package/scripts/release-loop/docs-land-node.ts +69 -10
  150. package/scripts/release-loop/publish-node.ts +2 -2
  151. package/scripts/release-loop/release-docs.ts +134 -0
  152. package/scripts/release-loop/version-node.ts +7 -4
  153. package/scripts/release-story/draft.ts +42 -14
  154. package/src/agent-mission/driver.ts +53 -10
  155. package/src/autopilot/track-agent.ts +5 -3
  156. package/src/cli/autopilot-cli.ts +66 -0
  157. package/src/cli/decisions-cli.ts +4 -1
  158. package/src/cli/intake-cli.ts +1 -1
  159. package/src/cli/log-event-names.ts +1 -0
  160. package/src/cli/pr-cli.ts +12 -1
  161. package/src/cli/provider-cli.ts +14 -0
  162. package/src/cli/self-update-canary.ts +46 -0
  163. package/src/cli/self-update.ts +200 -1
  164. package/src/context-card/index.ts +45 -0
  165. package/src/context-card/task.ts +76 -0
  166. package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -0
  167. package/src/decisions/decision-ledger.ts +10 -1
  168. package/src/harness/harness-cli-command.ts +142 -10
  169. package/src/harness/harness-pod-dispatch.ts +11 -0
  170. package/src/harness/harness-queue.ts +86 -7
  171. package/src/harness/nested-elanous-policy.ts +23 -4
  172. package/src/hq/fence-wrapper.ts +123 -0
  173. package/src/hq/hq.ts +8 -0
  174. package/src/index.ts +57 -8
  175. package/src/intake-plane/digest.ts +39 -4
  176. package/src/nexus/api/grid.ts +61 -0
  177. package/src/nexus/api/http-server.ts +19 -1
  178. package/src/nexus/api/task-cards-api.ts +19 -0
  179. package/src/oauth/codex.ts +82 -2
  180. package/src/release-loop/landing-freeze.ts +18 -10
  181. package/src/seat-loop/seat-loop.ts +95 -31
  182. package/src/self-dev/ask-launch-flow.ts +26 -7
  183. package/src/self-dev/run-store.ts +5 -1
  184. package/src/self-dev/run-supervisor.ts +7 -1
  185. package/src/self-dev/supervisor-verdict-edges.ts +46 -0
  186. package/src/self-implement/frozen-merges.ts +25 -20
  187. package/src/self-implement/goal-author-clarification.ts +81 -0
  188. package/src/self-implement/goal-author-cli.ts +33 -5
  189. package/src/self-implement/goal-author.ts +88 -12
  190. package/src/self-implement/goal-clarification-escalation.ts +37 -1
  191. package/src/self-implement/goal-document-write.ts +28 -0
  192. package/src/self-implement/heal-intake.ts +4 -0
  193. package/src/self-implement/host-regate.ts +3 -1
  194. package/src/self-implement/orchestrator.ts +19 -1
  195. package/src/task-agent/actions.ts +118 -0
  196. package/src/task-agent/judge.ts +96 -0
  197. package/src/task-cards/card-store.ts +4 -1
  198. package/src/task-orchestrator/surfaces/pod-failure-reason.ts +33 -4
  199. package/src/task-orchestrator/surfaces/pod-lease.ts +5 -2
  200. package/src/task-orchestrator/surfaces/pod-pool.ts +81 -13
  201. package/src/task-orchestrator/surfaces/self-implement-pod.ts +151 -29
  202. package/src/telegram-commands.ts +1 -1
  203. package/src/telegram-loops-command.ts +21 -14
  204. package/src/user-config.ts +25 -3
  205. package/src/version/packed-revision.json +1 -1
  206. package/apps/pwa/out/_next/static/chunks/8827-e2f5b09a8d37e83b.js +0 -1
  207. package/apps/pwa/out/_next/static/chunks/899-46946fe8363e1262.js +0 -1
  208. package/apps/pwa/out/_next/static/chunks/9247-29d889b809288e65.js +0 -1
  209. package/apps/pwa/out/_next/static/chunks/app/approvals/page-79c3ed84baaedd03.js +0 -1
  210. package/apps/pwa/out/_next/static/chunks/app/board/page-a424ef80885d1743.js +0 -1
  211. package/apps/pwa/out/_next/static/chunks/app/botlab/page-c1e20d7d338713f1.js +0 -1
  212. package/apps/pwa/out/_next/static/chunks/app/consult/page-ffff6d819fbc6e0f.js +0 -1
  213. package/apps/pwa/out/_next/static/chunks/app/control/page-a0e2a6d1bfb52fc5.js +0 -1
  214. package/apps/pwa/out/_next/static/chunks/app/loops/page-1a61c7ca1ceb3ae7.js +0 -1
  215. package/apps/pwa/out/_next/static/chunks/app/market/page-51f811fa119308b6.js +0 -1
  216. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-dacff46049e1557e.js +0 -1
  217. package/apps/pwa/out/_next/static/chunks/app/observatory/page-36a7d994f4ca0299.js +0 -1
  218. package/apps/pwa/out/_next/static/chunks/app/outputs/page-cd78862707999911.js +0 -1
  219. package/apps/pwa/out/_next/static/chunks/app/page-54b9ef8afe6278fd.js +0 -1
  220. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-57924393e3e4e539.js +0 -1
  221. package/apps/pwa/out/_next/static/chunks/app/sessions/page-9e0ce6c8129fa641.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/app/setup/page-4364ef9f4e7c7f0f.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/app/term/page-fe1759f94003b32b.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/app/today/page-d64c984e855c8c68.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/app/trace/page-86914b0dee8ab7f8.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/vault/page-9afdee66b820c76e.js +0 -1
  227. /package/apps/pwa/out/_next/static/{rv9cierV0jSOvDBm1g0XB → yRA5ur6DcPl0YHSRheH2O}/_buildManifest.js +0 -0
  228. /package/apps/pwa/out/_next/static/{rv9cierV0jSOvDBm1g0XB → yRA5ur6DcPl0YHSRheH2O}/_ssgManifest.js +0 -0
@@ -0,0 +1,123 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { chmodSync, constants, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { createPatch } from 'diff';
6
+
7
+ const quote = (value: string): string => `'${value.replaceAll("'", "'\"'\"'")}'`;
8
+
9
+ /** Only the installed entrypoint runs the fence; never use a checkout as a fallback.
10
+ * Arbiter (cloud-vm) outage retains the current lease and forbids promotion;
11
+ * this wrapper does not change the fence's lease or promotion decisions.
12
+ * cron runs with PATH=/usr/bin:/bin, so bun is baked in as an absolute path (OP 10-06 NO-GO). */
13
+ export function renderHqFenceWrapper({ entry, configDir, bun, logFile }: { entry: string; configDir: string; bun: string; logFile?: string }): string {
14
+ if (!isAbsolute(bun)) throw new Error(`hq-fence: bun must be an absolute path: ${bun}`);
15
+ const log = logFile ?? join(configDir, 'logs', 'hq-fence.log');
16
+ return `#!/bin/sh
17
+ # Arbiter (cloud-vm) outage: retain the lease, do not promote; hq fence owns that decision.
18
+ ENTRY=${quote(entry)}
19
+ CONFIG_DIR=${quote(configDir)}
20
+ # cron has no PATH for bun — the absolute path was resolved when this wrapper was installed.
21
+ BUN=${quote(bun)}
22
+ LOG=${quote(log)}
23
+ # Last resort when no alert path works: one line in a log file, no bun needed.
24
+ log_line() {
25
+ mkdir -p "$(dirname "$LOG")" 2>/dev/null
26
+ printf '%s %s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$LOG" 2>/dev/null
27
+ }
28
+ # The alert goes through the same installed entry; PATH \`elanous\` is only a fallback when the entry itself is missing.
29
+ # Alert bun: the installed one first, then usual absolute install locations (still no PATH lookup).
30
+ alert_bun() {
31
+ for b in "$BUN" "$HOME/.bun/bin/bun" /opt/homebrew/bin/bun /usr/local/bin/bun; do
32
+ if [ -x "$b" ]; then echo "$b"; return 0; fi
33
+ done
34
+ return 1
35
+ }
36
+ alert() {
37
+ if [ -f "$ENTRY" ] && AB=$(alert_bun); then
38
+ if "$AB" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence-wrapper alert "$1"; then return 0; fi
39
+ echo "hq-fence: owner alert failed: $1" >&2
40
+ log_line "owner alert failed: $1"
41
+ return 0
42
+ fi
43
+ if command -v elanous >/dev/null 2>&1 && elanous --config-dir "$CONFIG_DIR" hq fence-wrapper alert "$1"; then return 0; fi
44
+ echo "hq-fence: owner alert failed: $1" >&2
45
+ log_line "owner alert failed: $1"
46
+ }
47
+ if [ ! -x "$BUN" ]; then
48
+ echo "hq-fence: bun missing or not executable: $BUN" >&2
49
+ # Exactly one line on disk; the alert is tried only through an absolute fallback bun (never PATH).
50
+ log_line "bun missing or not executable: $BUN (rc=127)"
51
+ if [ -f "$ENTRY" ] && AB=$(alert_bun); then
52
+ "$AB" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence-wrapper alert "hq-fence: bun missing or not executable: $BUN (rc=127)" || echo "hq-fence: owner alert failed (fallback bun $AB)" >&2
53
+ fi
54
+ exit 127
55
+ fi
56
+ if [ ! -f "$ENTRY" ] || [ ! -r "$ENTRY" ]; then
57
+ echo "hq-fence: installed entrypoint missing: $ENTRY" >&2
58
+ alert "hq-fence: installed entrypoint missing: $ENTRY"
59
+ exit 1
60
+ fi
61
+ cd "$(dirname "$ENTRY")/.." || { alert "hq-fence: installed directory missing"; exit 1; }
62
+ if [ $# -lt 1 ] || [ -z "$1" ]; then
63
+ echo "hq-fence: role required" >&2
64
+ alert "hq-fence: role required"
65
+ exit 2
66
+ fi
67
+ ROLE=$1
68
+ shift
69
+ "$BUN" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence --role "$ROLE" -- /bin/sh -c "$*"
70
+ rc=$?
71
+ if [ "$rc" -ne 0 ]; then alert "hq-fence: fence failed (rc=$rc, role=$ROLE)"; fi
72
+ exit "$rc"
73
+ `;
74
+ }
75
+
76
+ /** Absolute bun for the wrapper: this process when it is bun, else `command -v bun` resolved now (install time). */
77
+ export function resolveHqFenceBun(deps: { isBun?: boolean; execPath?: string; lookup?: () => string } = {}): string {
78
+ const isBun = deps.isBun ?? typeof process.versions.bun === 'string';
79
+ const candidate = isBun
80
+ ? (deps.execPath ?? process.execPath)
81
+ : (deps.lookup ?? (() => spawnSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).stdout ?? ''))().trim();
82
+ if (!candidate || !isAbsolute(candidate)) throw new Error(`hq-fence: cannot resolve an absolute bun path (got ${JSON.stringify(candidate)})`);
83
+ return realpathSync(candidate);
84
+ }
85
+
86
+ export function installedHqFenceEntry(home = homedir()): string {
87
+ return join(home, '.local', 'share', 'elanous', 'current', 'node_modules', 'elanous', 'bin', 'elanous.mjs');
88
+ }
89
+
90
+ /** Literal \`cd <path>\` lines only — a worktree path built from a variable is not detected. */
91
+ export function repositoryWorktreeCdWarning(body: string): string | undefined {
92
+ return body.split('\n').some(line => /^\s*cd\s+(?:['"]?[^\s'";]+\/)?(?:wt-[^\s'";]+|[^\s'";]+\/repo\.worktrees\/[^\s'";]+)(?:['"]?\s*(?:&&|\|\||;|#|$))/.test(line))
93
+ ? 'hq fence-audit: warning — hq-fence wrapper cd targets a repository worktree' : undefined;
94
+ }
95
+
96
+ export function installHqFenceWrapper(configDir: string, body: string, yes: boolean, deps: {
97
+ now?: () => Date;
98
+ } = {}): { path: string; preview: string; backup?: string } {
99
+ const path = join(configDir, 'bin', 'hq-fence');
100
+ let present = false;
101
+ try { present = !!lstatSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
102
+ if (present && !lstatSync(path).isFile()) throw new Error(`hq-fence: refusing non-regular wrapper: ${path}`);
103
+ const old = present ? readFileSync(path, 'utf8') : undefined;
104
+ const preview = `hq-fence proposed: ${path}\n${body}\n${createPatch(path, old ?? '', body, 'existing', 'proposed')}`;
105
+ if (!yes) return { path, preview };
106
+ let backup: string | undefined;
107
+ if (present) {
108
+ const backups = join(configDir, 'backups');
109
+ mkdirSync(backups, { recursive: true });
110
+ const stamp = (deps.now ?? (() => new Date()))().toISOString().replaceAll(':', '-');
111
+ backup = join(backups, `hq-fence.${stamp}`);
112
+ copyFileSync(path, backup, constants.COPYFILE_EXCL);
113
+ }
114
+ mkdirSync(dirname(path), { recursive: true });
115
+ const tempDir = mkdtempSync(join(dirname(path), '.hq-fence-'));
116
+ try {
117
+ const temp = join(tempDir, 'hq-fence');
118
+ writeFileSync(temp, body, { flag: 'wx', mode: 0o755 });
119
+ chmodSync(temp, 0o755);
120
+ renameSync(temp, path);
121
+ } finally { rmSync(tempDir, { recursive: true, force: true }); }
122
+ return { path, preview, ...(backup ? { backup } : {}) };
123
+ }
package/src/hq/hq.ts CHANGED
@@ -205,6 +205,14 @@ function mutate(r: Resolved, decide: (record: LeaseRecord | null, now: number) =
205
205
  return { ok: false, reason: 'lost the race five times', record: null };
206
206
  }
207
207
 
208
+ /** Snapshot for read-only consumers: never advances the seen-generation marker or CASes the arbiter. */
209
+ export function readHqLease(deps: HqDeps = {}) {
210
+ const { store } = resolve(deps);
211
+ const { now, raw } = store.read();
212
+ const record = parseLease(raw);
213
+ return { record, ageSeconds: record ? now - record.renewedAt : null, expired: record ? leaseExpired(record, now) : null };
214
+ }
215
+
208
216
  export type LeaseAction = 'acquire' | 'renew' | 'status' | 'release';
209
217
  export function hqLease(action: LeaseAction, deps: HqDeps = {}, opts: { host?: string; expectedHolder?: string; expectedGeneration?: number } = {}) {
210
218
  const r0 = resolve(deps);
package/src/index.ts CHANGED
@@ -259,7 +259,7 @@ interface RunDevAskFromGoalFileDeps {
259
259
  runSayLaunchFlow: (
260
260
  sayText: string,
261
261
  entrance?: import('./self-dev/entrance-registry.js').EntranceDeclaration,
262
- selection?: { readonly forcePreflight?: boolean; readonly goalType?: import('./self-implement/goal-author.js').GoalType; readonly groundingCwd?: string },
262
+ selection?: { readonly forcePreflight?: boolean; readonly goalType?: import('./self-implement/goal-author.js').GoalType; readonly groundingCwd?: string; readonly authorGrade?: import('./self-implement/goal-author.js').GoalAuthorGradeSelection },
263
263
  ) => Promise<RunDevAskFlowResult>;
264
264
  runAskFileLaunchFlow: (
265
265
  askPath: string,
@@ -294,7 +294,7 @@ export function assembleAskLaunchPolicy(selection: {
294
294
  async function runDefaultSayLaunchFlow(
295
295
  sayText: string,
296
296
  entrance?: import('./self-dev/entrance-registry.js').EntranceDeclaration,
297
- selection: { readonly forcePreflight?: boolean; readonly goalType?: import('./self-implement/goal-author.js').GoalType; readonly groundingCwd?: string } = {},
297
+ selection: { readonly forcePreflight?: boolean; readonly goalType?: import('./self-implement/goal-author.js').GoalType; readonly groundingCwd?: string; readonly authorGrade?: import('./self-implement/goal-author.js').GoalAuthorGradeSelection } = {},
298
298
  ): Promise<RunDevAskFlowResult> {
299
299
  const { readFileSync, writeFileSync } = await import('node:fs');
300
300
  const [flowMod, { runGoalAuthorCli }, { relative: relativeToCwdPath }, { CLI_HARNESS_SAY_ENTRANCE }] = await Promise.all([
@@ -319,6 +319,7 @@ async function runDefaultSayLaunchFlow(
319
319
  ...assembleAskLaunchPolicy(selection),
320
320
  ...(selection.goalType === undefined ? {} : { goalType: selection.goalType }),
321
321
  ...(selection.groundingCwd === undefined ? {} : { groundingCwd: selection.groundingCwd }),
322
+ ...(selection.authorGrade === undefined ? {} : { authorGrade: selection.authorGrade }),
322
323
  }, {
323
324
  print: (line) => console.error(line),
324
325
  log: (event, data, level) => debug.log('dev-pipeline', event, data, { level }),
@@ -328,6 +329,9 @@ async function runDefaultSayLaunchFlow(
328
329
  cwd: () => process.cwd(),
329
330
  now: () => Date.now(),
330
331
  isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
332
+ // POD-SELF-ANSWER: non-TTY (Pod) authoring self-answers clarifications with the author resolver.
333
+ selfResolveClarification: (await import('./self-implement/goal-author-clarification.js')).defaultGoalAuthorSelfResolve,
334
+ writeGoalDocument: (await import('./self-implement/goal-document-write.js')).writeGoalDocumentAtomic,
331
335
  buildPreflightDeps: askIo.buildAskPreflightDeps,
332
336
  priorBlockSamples: () => askIo.priorBlockSamplesFrom(askLogRows),
333
337
  recentAuthoringSamples: () => askIo.recentAuthoringSamplesFrom(askLogRows),
@@ -376,6 +380,9 @@ async function runDefaultAskFileLaunchFlow(
376
380
  cwd: () => process.cwd(),
377
381
  now: () => Date.now(),
378
382
  isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
383
+ // POD-SELF-ANSWER: non-TTY (Pod) authoring self-answers clarifications with the author resolver.
384
+ selfResolveClarification: (await import('./self-implement/goal-author-clarification.js')).defaultGoalAuthorSelfResolve,
385
+ writeGoalDocument: (await import('./self-implement/goal-document-write.js')).writeGoalDocumentAtomic,
379
386
  buildPreflightDeps: askIo.buildAskPreflightDeps,
380
387
  priorBlockSamples: () => askIo.priorBlockSamplesFrom(askLogRows),
381
388
  recentAuthoringSamples: () => askIo.recentAuthoringSamplesFrom(askLogRows),
@@ -818,6 +825,7 @@ async function runDevSayFromWords(
818
825
  authoringEntrance,
819
826
  {
820
827
  ...(opts.forcePreflight === undefined ? {} : { forcePreflight: opts.forcePreflight }),
828
+ ...(opts.authorGrade === undefined ? {} : { authorGrade: { grade: opts.authorGrade, source: opts.authorGradeSource ?? 'flag' } }),
821
829
  ...(grounding.groundingCwd === undefined ? {} : { groundingCwd: grounding.groundingCwd }),
822
830
  },
823
831
  );
@@ -977,12 +985,47 @@ hqCmd.command('seen').description('이 호스트가 본 가장 높은 임대 세
977
985
  process.exitCode = result.seen ? 0 : 3;
978
986
  });
979
987
  registerHqMovePlanCommand(hqCmd);
988
+ const fenceWrapperCmd = hqCmd.command('fence-wrapper').description('설치본 HQ 울타리 크론 래퍼');
989
+ fenceWrapperCmd.command('install').description('래퍼 제안 또는 백업 후 원자적 설치')
990
+ .option('--dry-run', '변경 내용만 출력 (기본)')
991
+ .option('--yes', '기존 파일 백업 후 설치')
992
+ .action(async (opts: { dryRun?: boolean; yes?: boolean }) => {
993
+ try {
994
+ if (opts.dryRun && opts.yes) throw new Error('--dry-run and --yes cannot be used together');
995
+ const { getElanousConfigDir } = await import('./elanous-config-dir.js');
996
+ const { installedHqFenceEntry, installHqFenceWrapper, renderHqFenceWrapper, resolveHqFenceBun } = await import('./hq/fence-wrapper.js');
997
+ const configDir = getElanousConfigDir();
998
+ const bun = resolveHqFenceBun();
999
+ const body = renderHqFenceWrapper({ entry: installedHqFenceEntry(), configDir, bun });
1000
+ const result = installHqFenceWrapper(configDir, body, opts.yes === true);
1001
+ if (opts.yes) console.log(`hq-fence installed: ${result.path} · bun: ${bun}${result.backup ? ` · backup: ${result.backup}` : ''}`);
1002
+ else console.log(`hq-fence bun (absolute · cron has no PATH): ${bun}\n${result.preview}`);
1003
+ } catch (error) { console.error(`hq fence-wrapper install: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; }
1004
+ });
1005
+ fenceWrapperCmd.command('alert <reason>').description('HQ 울타리 실패를 기존 루프 소유자 요청 원장에 적재')
1006
+ .action(async (reason: string) => {
1007
+ try {
1008
+ const { notifyOwners } = await import('./loops/checker.js');
1009
+ const { getElanousConfigDir } = await import('./elanous-config-dir.js');
1010
+ notifyOwners([{ id: `hq-fence:${Date.now()}:${process.pid}`, owner: 'TC', enabled: true, registered: true,
1011
+ state: 'failing', reason }], { root: getElanousConfigDir() });
1012
+ } catch (error) { console.error(`hq fence-wrapper alert: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; }
1013
+ });
980
1014
  hqCmd.command('fence-audit').description('운영 crontab 의 HQ 울타리 밖 쓰기 잡 목록과 감싼 줄 제안(읽기 전용)')
981
1015
  .option('--json', 'JSON 출력')
982
1016
  .action(async (opts: { json?: boolean }) => {
983
1017
  try {
984
1018
  await hqSink();
985
1019
  const rows = (await import('./hq/fence-audit.js')).hqFenceAudit();
1020
+ const { getElanousConfigDir } = await import('./elanous-config-dir.js');
1021
+ const { repositoryWorktreeCdWarning } = await import('./hq/fence-wrapper.js');
1022
+ const wrapper = _joinPath(getElanousConfigDir(), 'bin', 'hq-fence');
1023
+ if (existsSync(wrapper)) {
1024
+ try {
1025
+ const warning = repositoryWorktreeCdWarning(readFileSync(wrapper, 'utf8'));
1026
+ if (warning) console.error(warning);
1027
+ } catch (error) { console.error(`hq fence-audit: wrapper inspection unavailable: ${error instanceof Error ? error.message : String(error)}`); }
1028
+ }
986
1029
  if (opts.json) await writeStdoutJson(`${JSON.stringify(rows)}\n`);
987
1030
  else if (!rows.length) console.log('hq fence-audit: unfenced 0');
988
1031
  else for (const row of rows) console.log(`${row.raw}\n role: ${row.recommendedRole}\n suggested: ${row.suggestedLine}`);
@@ -1255,7 +1298,7 @@ pythonCmd.command('setup').description('elanous venv(~/.local/share/elanous/pyth
1255
1298
 
1256
1299
  program.command('self-update')
1257
1300
  .alias('update')
1258
- .description('설치본은 릴리스로, 체크아웃은 깨끗한 체크아웃으로 갱신하고 승인 시 넥서스 재시작 · `elanous update` 와 같다 · `--auto on` 이면 매일 자동')
1301
+ .description('설치본은 릴리스로, 체크아웃은 깨끗한 체크아웃으로 갱신 · --auto 는 캐너리 자동 판단, --auto on|off|status 는 기존 자동 갱신 서비스 관리')
1259
1302
  .option('--from <checkout>', '설치할 체크아웃 (기본: 설치본은 릴리스, 체크아웃은 현재 체크아웃)')
1260
1303
  .option('--version <version>', '설치본에서 지정한 릴리스 버전 설치 (기본: latest)')
1261
1304
  .option('--restart', '넥서스 재시작 승인')
@@ -1265,9 +1308,13 @@ program.command('self-update')
1265
1308
  .option('--skip-pwa-build', '체크아웃 설치 전 PWA 빌드를 명시적으로 건너뜀')
1266
1309
  .option('--dev', '판 사이 dev 빌드 설치 — versions/<판>-dev.<sha12> · install.json channel=dev · 이전 versionDir 보존 (사람 승인 · 자동 아님)')
1267
1310
  .option('--rollback', 'install.json 의 이전 versionDir 로 current 를 되돌린다 (이전이 없으면 거부)')
1268
- .option('--auto <on|off|status>', '자동 갱신 — macOS launchd · Linux systemd 타이머가 매일 04:17 에 `self-update --restart --alert` (크론이 이미 부르면 켜지 않는다)')
1269
- .action(async (opts: { from?: string; version?: string; restart?: boolean; json?: boolean; keep?: string; alert?: boolean; skipPwaBuild?: boolean; dev?: boolean; rollback?: boolean; auto?: string }) => {
1270
- if (opts.auto !== undefined) {
1311
+ .option('--auto [on|off|status]', '인자 없이: 에지 레일 계획/실행 · on|off|status: 기존 자동 갱신 서비스 관리')
1312
+ .option('--dry-run', '에지 레일 계획만 출력; 설치·되돌림·상태 쓰기 없음')
1313
+ .option('--seat <seat>', '에지 레일 자리 (OP|TC|MK|UX)')
1314
+ .option('--quiet-window', '상주 프로세스 재시작이 가능한 조용한 창이라고 명시')
1315
+ .option('--canary-run-id <id>', '캐너리 성공 런의 고유 ID (독립 검증된 성공 런에서만 입력; 같은 ID 는 중복 적중하지 않음)')
1316
+ .action(async (opts: { from?: string; version?: string; restart?: boolean; json?: boolean; keep?: string; alert?: boolean; skipPwaBuild?: boolean; dev?: boolean; rollback?: boolean; auto?: string | boolean; dryRun?: boolean; seat?: string; quietWindow?: boolean; canaryRunId?: string }) => {
1317
+ if (typeof opts.auto === 'string') {
1271
1318
  if (!['on', 'off', 'status'].includes(opts.auto)) { console.error(`--auto 는 on · off · status 중 하나: ${opts.auto}`); process.exitCode = 2; return; }
1272
1319
  const { runAutoUpdate } = await import('./cli/update-auto.js');
1273
1320
  process.exitCode = (await runAutoUpdate(opts.auto as 'on' | 'off' | 'status')).exitCode;
@@ -1275,9 +1322,9 @@ program.command('self-update')
1275
1322
  }
1276
1323
  const { runUpdateForInstallation } = await import('./cli/self-update.js');
1277
1324
  // ⭐ 한 번 도는 CLI 는 logs.db 싱크를 스스로 붙여야 `debug.log('self-update', …)` 가 저장된다(없으면 조용히 사라진다 · 09-24 실측).
1278
- try { const { registerStandaloneLogSink } = await import('./domains/standalone-log-sink.js'); await registerStandaloneLogSink('self-update'); } catch { /* 관측 실패가 갱신을 막지 않는다 */ }
1325
+ if (!opts.dryRun) try { const { registerStandaloneLogSink } = await import('./domains/standalone-log-sink.js'); await registerStandaloneLogSink('self-update'); } catch { /* 관측 실패가 갱신을 막지 않는다 */ }
1279
1326
  const keep = Number.parseInt(opts.keep ?? '3', 10);
1280
- const result = await runUpdateForInstallation({ ...opts, skipPwaBuild: opts.skipPwaBuild, keep: Number.isFinite(keep) ? keep : 3 }, { cliRoot: REPOSITORY_ROOT });
1327
+ const result = await runUpdateForInstallation({ ...opts, auto: opts.auto === true, quietWindow: opts.quietWindow === true, skipPwaBuild: opts.skipPwaBuild, keep: Number.isFinite(keep) ? keep : 3 }, { cliRoot: REPOSITORY_ROOT });
1281
1328
  process.exitCode = result.exitCode;
1282
1329
  });
1283
1330
 
@@ -7001,6 +7048,8 @@ const selfDevCmd = program
7001
7048
  now: () => Date.now(),
7002
7049
  // ⛔ 「대화형인가」는 «두 쪽»을 본다 — 파이프로 몰면 물어도 답이 안 온다.
7003
7050
  isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
7051
+ selfResolveClarification: (await import('./self-implement/goal-author-clarification.js')).defaultGoalAuthorSelfResolve,
7052
+ writeGoalDocument: (await import('./self-implement/goal-document-write.js')).writeGoalDocumentAtomic,
7004
7053
  buildPreflightDeps: buildAskPreflightDeps,
7005
7054
  priorBlockSamples: () => askIo.priorBlockSamplesFrom(askLogRows),
7006
7055
  recentAuthoringSamples: () => askIo.recentAuthoringSamplesFrom(askLogRows),
@@ -8,7 +8,7 @@ import { intakeOutboxDir, kstDay } from './route.js';
8
8
 
9
9
  export type DigestLensVerdict = '대체 후보' | '보강' | '경쟁 대조';
10
10
  export interface DigestEntry { id: string; sources: string[]; url?: string; note?: string; noteName?: string; oneLiner?: string; axis: string; impact?: { verdict: DigestLensVerdict; why: string; target: string } }
11
- export interface IntakeDigest { day: string; absorbed: DigestEntry[]; goals: { fact: string; url?: string }[]; review?: { fact: string; note?: string }[]; grounding: number; release: number; manual: number; savedSilence?: { days: number; lastNewAt: string } }
11
+ export interface IntakeDigest { day: string; absorbed: DigestEntry[]; goals: { fact: string; url?: string }[]; news?: { title: string; url: string; summary: string[]; implication: string[] }[]; shadowSuggestions?: { fact: string; url?: string; verdict: string }[]; review?: { fact: string; note?: string }[]; grounding: number; release: number; manual: number; savedSilence?: { days: number; lastNewAt: string } }
12
12
 
13
13
  const AXIS_LABEL: Record<string, string> = {
14
14
  video_automation: '영상 자동화', agent_basics: '에이전트 기본', agent_applied: '에이전트 응용', unrelated: '그 밖',
@@ -82,12 +82,24 @@ export function buildIntakeDigest(root: string, day: string, readFile: (p: strin
82
82
  };
83
83
  });
84
84
  const goals = readJsonl(join(out, 'goals', `${day}.jsonl`)).map((g) => ({ fact: String(g.fact ?? ''), ...(g.url ? { url: String(g.url) } : {}) }));
85
+ const lensRows = readJsonl(join(out, 'lens', `${day}.jsonl`));
86
+ const news = lensRows.flatMap((row) =>
87
+ typeof row.title === 'string' && typeof row.url === 'string' && Array.isArray(row.summary) && Array.isArray(row.implication)
88
+ ? [{ title: row.title, url: row.url, summary: row.summary.filter((s): s is string => typeof s === 'string'), implication: row.implication.filter((s): s is string => typeof s === 'string') }]
89
+ : []);
90
+ const shadowSuggestions = lensRows.flatMap((row) =>
91
+ Array.isArray(row.suggestions) ? row.suggestions.flatMap((raw: unknown) => {
92
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [];
93
+ const suggestion = raw as Record<string, unknown>;
94
+ if (suggestion.status !== '제안' || typeof suggestion.fact !== 'string' || !suggestion.fact.trim()) return [];
95
+ return [{ fact: suggestion.fact, ...(typeof row.url === 'string' ? { url: row.url } : {}), verdict: typeof suggestion.verdict === 'string' ? suggestion.verdict : '판단 필요' }];
96
+ }) : []);
85
97
  const review = readJsonl(join(out, 'review', `${day}.jsonl`)).map((r) => ({ fact: String(r.fact ?? ''), ...(r.note ? { note: String(r.note) } : {}) }));
86
98
  const grounding = readJsonl(join(out, 'grounding.jsonl')).filter((g) => typeof g.at === 'string' && kstDay(g.at) === day).length;
87
99
  const lastNewAt = readSavedCursorAt(root);
88
100
  const silentMs = lastNewAt ? now.getTime() - Date.parse(lastNewAt) : NaN;
89
101
  return {
90
- day, absorbed, goals, review, grounding,
102
+ day, absorbed, goals, ...(news.length ? { news } : {}), ...(shadowSuggestions.length ? { shadowSuggestions } : {}), review, grounding,
91
103
  release: readJsonl(join(out, 'release', `${day}.jsonl`)).length,
92
104
  manual: readJsonl(join(out, 'manual', `${day}.jsonl`)).length,
93
105
  ...(lastNewAt && silentMs > SAVED_SILENCE_MS ? { savedSilence: { days: Math.floor(silentMs / 86_400_000), lastNewAt } } : {}),
@@ -98,7 +110,8 @@ export function buildIntakeDigest(root: string, day: string, readFile: (p: strin
98
110
  export function renderDigestMarkdown(d: IntakeDigest): string {
99
111
  const L: string[] = [`## 📰 오늘의 흡수 요약 (${d.absorbed.length})`, ''];
100
112
  if (d.savedSilence) L.push(`> ⚠️ 텔레그램 «저장된 메시지»에서 새 글을 ${d.savedSilence.days}일째 못 받았다(마지막 새 글 수집 ${kstDay(d.savedSilence.lastNewAt)}) — 저장했는데 안 들어왔다면 저장한 곳(본인 «저장된 메시지»인지)을 확인한다.`, '');
101
- if (!d.absorbed.length) { L.push('오늘 흡수한 것이 없다.', ''); return L.join('\n'); }
113
+ if (!d.absorbed.length) L.push('오늘 흡수한 것이 없다.', '');
114
+ if (!d.absorbed.length && !d.news?.length) return L.join('\n');
102
115
  const byAxis = new Map<string, DigestEntry[]>();
103
116
  for (const e of d.absorbed) (byAxis.get(e.axis) ?? byAxis.set(e.axis, []).get(e.axis)!).push(e);
104
117
  for (const [axis, rows] of byAxis) {
@@ -106,11 +119,25 @@ export function renderDigestMarkdown(d: IntakeDigest): string {
106
119
  for (const e of rows) L.push(`- ${e.noteName ? `[[${e.noteName}]]` : e.url ?? e.id} — ${e.oneLiner ?? '(요약 줄 없음)'}`);
107
120
  L.push('');
108
121
  }
122
+ if (d.news?.length) {
123
+ L.push(`### 📰 관심 뉴스 (${d.news.length})`, '');
124
+ for (const article of d.news.slice(0, 10)) {
125
+ L.push(`- [${article.title}](${article.url})`);
126
+ for (const line of article.summary) L.push(` - ${line}`);
127
+ for (const line of article.implication) L.push(` - 엘라누스 함의: ${line}`);
128
+ }
129
+ L.push('');
130
+ }
109
131
  if (d.goals.length) {
110
132
  L.push(`### 🔴 엘라누스에 없는 것 — 골 후보 (${d.goals.length})`, '');
111
133
  for (const g of d.goals.slice(0, 10)) L.push(`- ${g.fact}${g.url ? ` · [원본](${g.url})` : ''}`);
112
134
  L.push('');
113
135
  }
136
+ if (d.shadowSuggestions?.length) {
137
+ L.push(`### 📰 뉴스 칸 제안 — 그림자, 판 미등록 (${d.shadowSuggestions.length})`, '');
138
+ for (const s of d.shadowSuggestions.slice(0, 10)) L.push(`- [${s.verdict}] ${s.fact}${s.url ? ` · [원본](${s.url})` : ''}`);
139
+ L.push('');
140
+ }
114
141
  const undecided = d.absorbed.filter((e) => !e.impact).length;
115
142
  if (undecided) L.push(`렌즈 판정 못 함 ${undecided} — 원장 \`elanous intake items\``, '');
116
143
  L.push(`> 매뉴얼 후보 ${d.manual} · 릴리스 노트 맥락 ${d.release} · 그라운딩 후보 ${d.grounding} — 흡수 원장 \`elanous intake items\` · 큐 \`<인스턴스>/intake/outbox/\``, '');
@@ -122,7 +149,15 @@ export function renderDigestTelegram(d: IntakeDigest, _opts: { vaultRoot?: strin
122
149
  // «Touching» is decided by the lens verdict alone; a missing note summary only changes how S reads (ACP must-fix).
123
150
  const touching = d.absorbed.filter((e) => e.impact);
124
151
  const L = [`흡수 ${d.absorbed.length} → 우리에게 닿는 것 ${touching.length}`];
125
- if (!touching.length) return L[0];
152
+ // 관심 뉴스는 최대 셋 — 기사마다 S(요약 첫 줄) · A(엘라누스 함의 첫 줄) · 링크를 한 덩어리로(NEWS-INTAKE).
153
+ for (const article of (d.news ?? []).slice(0, 3)) {
154
+ L.push(`📰 ${article.title}`);
155
+ if (article.summary[0]) L.push(`S: ${article.summary[0]}`);
156
+ if (article.implication[0]) L.push(`A: ${article.implication[0]}`);
157
+ L.push(article.url);
158
+ }
159
+ if (d.shadowSuggestions?.length) L.push(`뉴스 칸 제안 ${d.shadowSuggestions.length}건 (그림자·판 미등록): ${d.shadowSuggestions.slice(0, 3).map((s) => `[${s.verdict}] ${s.fact}${s.url ? ` ${s.url}` : ''}`).join(' · ')}`);
160
+ if (!touching.length) return L.join('\n');
126
161
  // At most three items are shown, each as its own S·C·A·link block so context and source stay paired;
127
162
  // a later item sharing (verdict, target) says «위와 같은 행동» instead of repeating the A sentence (ACP must-fix).
128
163
  // S must be the note's own summary: items without one still count as touching but are not shown (ACP must-fix).
@@ -0,0 +1,61 @@
1
+ import { getUserConfig } from '../../user-config.js';
2
+ import { readHqLease, type HqDeps } from '../../hq/hq.js';
3
+ import { measurePoolLease, type PoolLeaseMeasure } from '../../task-orchestrator/surfaces/pod-lease.js';
4
+ import { parsePodPool, resolvePodPoolSpec, type PodPoolMember, type PoolKubectl } from '../../task-orchestrator/surfaces/pod-pool.js';
5
+
6
+ export interface GridData {
7
+ /** null means the arbiter could not be read, not that no lease exists. */
8
+ hq: { record: ReturnType<typeof readHqLease>['record']; ageSeconds: number | null; expired: boolean | null; reason: string | null };
9
+ /** Configured member order; capacity comes from measurePoolLease when available. occupied is running + pending, or null on measurement failure. */
10
+ members: Array<{ context: string; capacity: number; running: number | null; pending: number | null; occupied: number | null; reason: string | null }>;
11
+ poolReason: string | null;
12
+ }
13
+
14
+ export interface GridDeps {
15
+ hq?: HqDeps;
16
+ readHq?: () => ReturnType<typeof readHqLease>;
17
+ poolSpec?: () => string | null;
18
+ /** Optional read-only kubectl seam; never use a probe that creates Pods. */
19
+ kubectl?: PoolKubectl;
20
+ measure?: (members: readonly PodPoolMember[]) => PoolLeaseMeasure;
21
+ }
22
+
23
+ /** Independent, fail-soft read snapshots. No lease status/heartbeat, DNS probe Pod, host reservation or writes. */
24
+ export function buildGridData(deps: GridDeps = {}): GridData {
25
+ let hq: GridData['hq'];
26
+ try {
27
+ hq = { ...(deps.readHq ?? (() => readHqLease(deps.hq)))(), reason: null };
28
+ } catch (error) {
29
+ hq = { record: null, ageSeconds: null, expired: null, reason: String(error) };
30
+ }
31
+
32
+ let members: GridData['members'] = [];
33
+ let poolReason: string | null = null;
34
+ try {
35
+ const spec = (deps.poolSpec ?? (() => resolvePodPoolSpec(undefined, process.env, () => {
36
+ const config = getUserConfig();
37
+ return config.harness?.podPool ?? config.pod?.pool;
38
+ })))();
39
+ if (spec) {
40
+ const configured = parsePodPool(spec);
41
+ try {
42
+ const measured = (deps.measure ?? ((pool) => measurePoolLease(pool, { skipDnsProbe: true, ...(deps.kubectl ? { kubectl: deps.kubectl } : {}) })))(configured);
43
+ const byContext = new Map(measured.members.map((m) => [m.context, m]));
44
+ members = configured.map((member) => {
45
+ const row = byContext.get(member.context);
46
+ const running = row?.running ?? null;
47
+ const pending = row?.pending ?? null;
48
+ return { context: member.context, capacity: row?.capacity ?? member.capacity, running, pending,
49
+ occupied: running === null || pending === null ? null : running + pending,
50
+ reason: row?.reason ?? (row ? null : '측정 불가') };
51
+ });
52
+ } catch (error) {
53
+ poolReason = String(error);
54
+ members = configured.map(({ context, capacity }) => ({ context, capacity, running: null, pending: null, occupied: null, reason: poolReason }));
55
+ }
56
+ } else poolReason = 'pool not configured';
57
+ } catch (error) {
58
+ poolReason = String(error);
59
+ }
60
+ return { hq, members, poolReason };
61
+ }
@@ -22,6 +22,7 @@ import { spawnSync } from 'node:child_process';
22
22
  import { isAbsolute } from 'node:path';
23
23
  import { createProject, listProjects } from '../../project/project-store.js';
24
24
  import { handleOutputsGet, type OutputsDeps } from './outputs-route.js';
25
+ import { buildGridData, type GridDeps } from './grid.js';
25
26
  import { DECISIONS_PATH, handleDecisions, type DecisionsRouteDeps } from './decisions-route.js';
26
27
  import { CARD_FOLLOWUP_PATH, createCardFollowupJobs, type CardFollowupJobs } from './card-followup-route.js';
27
28
  import { SKILL_EXEC_PATH, createSkillExecJobs, type SkillExecJobs } from './skill-exec-route.js';
@@ -341,7 +342,7 @@ import {
341
342
  handleTasksList,
342
343
  handleTaskDetail,
343
344
  } from './tasks-scheduler.js';
344
- import { handleTaskCardsGet, handleTaskCardsWishPost } from './task-cards-api.js';
345
+ import { handleTaskCardsGet, handleTaskCardsWishPost, handleTaskCardsClosePost } from './task-cards-api.js';
345
346
  import { handleTaskCreatePost, handleTaskApprovePost } from './tasks-create.js';
346
347
  import { matchIngestAuthorization } from './ingest-token.js';
347
348
  import {
@@ -478,6 +479,8 @@ export interface NexusHttpServerOpts {
478
479
  /** Optional installer state root for isolated API consumers and tests. */
479
480
  pluginStateRoot?: string;
480
481
  outputs?: OutputsDeps;
482
+ /** Read-only grid measurement seam; omitted in production for live HQ and Pod pool reads. */
483
+ grid?: GridDeps;
481
484
  decisions?: Pick<DecisionsRouteDeps, 'ledger'>;
482
485
  cardFollowup?: CardFollowupJobs;
483
486
  skillExec?: SkillExecJobs;
@@ -1385,6 +1388,12 @@ export async function routeRequest(
1385
1388
  });
1386
1389
  }
1387
1390
 
1391
+ // routeRequest is the live GET-only caller of buildGridData; keep it behind the owner auth gate.
1392
+ if (pathname === '/v1/grid') {
1393
+ if (!opts.metaApi || !checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
1394
+ if (method !== 'GET') return jsonResponse({ error: 'method-not-allowed', method }, 405);
1395
+ return jsonResponse(buildGridData(opts.grid));
1396
+ }
1388
1397
  if (method === 'GET' && pathname === '/v1/outputs') {
1389
1398
  if (!opts.metaApi || !checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
1390
1399
  return handleOutputsGet(req, opts.outputs);
@@ -1457,6 +1466,15 @@ export async function routeRequest(
1457
1466
  const mutated = await handleGraphsMutation(pathname, req);
1458
1467
  if (mutated) return mutated;
1459
1468
  }
1469
+ const cardClose = /^\/v1\/task-cards\/([^/]+)\/close$/.exec(pathname);
1470
+ if (method === 'POST' && cardClose) {
1471
+ if (!opts.metaApi || !checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
1472
+ if (bearerCredential(req, opts.metaApi) !== 'bearer-match') return jsonResponse({ error: 'forbidden' }, 403);
1473
+ let id: string;
1474
+ try { id = decodeURIComponent(cardClose[1]!); }
1475
+ catch { return jsonResponse({ error: 'not_found' }, 404); }
1476
+ return handleTaskCardsClosePost(req, id);
1477
+ }
1460
1478
  if (method === 'POST' && pathname === '/v1/task-cards/wish') {
1461
1479
  if (!opts.metaApi || !checkAuth(req, opts.metaApi)) return jsonResponse({ error: 'unauthorized' }, 401);
1462
1480
  return handleTaskCardsWishPost(req);
@@ -25,6 +25,25 @@ export async function handleTaskCardsWishPost(req: Request, root?: string): Prom
25
25
  }
26
26
  }
27
27
 
28
+ export async function handleTaskCardsClosePost(req: Request, id: string, root?: string): Promise<Response> {
29
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return jsonResponse({ error: 'not_found' }, 404);
30
+ let body: unknown;
31
+ try { body = await req.json(); }
32
+ catch { return jsonResponse({ error: 'bad_request' }, 400); }
33
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return jsonResponse({ error: 'bad_request' }, 400);
34
+ const { reason } = body as Record<string, unknown>;
35
+ if (typeof reason !== 'string' || !reason.trim() || reason.trim().includes('\n') || reason.trim().includes('\r')) {
36
+ return jsonResponse({ error: 'bad_request' }, 400);
37
+ }
38
+ const store = new CardStore(root);
39
+ try {
40
+ if (!store.getCard(id)) return jsonResponse({ error: 'not_found' }, 404);
41
+ return jsonResponse({ card: store.closeCard(id, reason) });
42
+ } finally {
43
+ store.close();
44
+ }
45
+ }
46
+
28
47
  export interface WishPlacement { cellId: string; cellTitle: string; version: string; status: 'green' | 'yellow' | 'red' | 'done' }
29
48
 
30
49
  export function isSplitCell(cell: unknown): cell is { id: string } {
@@ -24,9 +24,11 @@
24
24
  // the official CLI stays in sync.
25
25
 
26
26
  import { execFileSync } from 'node:child_process';
27
+ import { chmodSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
27
28
  import { join } from 'node:path';
28
- import { resolveCodexAccount, effectiveCodexHome, type CodexAccountResolution } from './codex-account.js';
29
- import { resolveCodexAccountForRun } from './codex-account-store.js';
29
+ import { DEFAULT_CODEX_ACCOUNT, codexStoreKey, isValidAccountName, resolveCodexAccount, effectiveCodexHome, type CodexAccountResolution } from './codex-account.js';
30
+ import { importCodexAccountFromHome, resolveCodexAccountForRun } from './codex-account-store.js';
31
+ import { decodeJWTPayload } from './jwt.js';
30
32
  import { authStorePath } from './store.js';
31
33
  import { platform, release, arch } from 'node:os';
32
34
  import {
@@ -533,3 +535,81 @@ export async function getCodexAccessToken(
533
535
  const state = await loadFreshCodexAuthState(opts);
534
536
  return state ? state.tokens : null;
535
537
  }
538
+
539
+ // ── Named-account host refresh (POD-TOKEN-PREREFRESH · 10-06) ─────────
540
+ //
541
+ // 🩸 10-06 05:3x team · 09:47 third — Pod launches refused a codex account whose access token had
542
+ // under 3 h left, and a human refreshed it by hand (refreshCodexTokens → atomic 0600 write → account
543
+ // import). This is that path as one function: backup → refresh → atomic write → canonical store via
544
+ // the same import path. ⛔ Token values never leave this function — the result carries hours only.
545
+
546
+ export type CodexAccountRefreshResult =
547
+ | { ok: true; storeKey: string; codexHome: string; beforeH: number | null; afterH: number | null; backupPath: string }
548
+ | { ok: false; kind: 'bad-name' | 'default-refused' | 'unknown-account' | 'unreadable' | 'no-tokens' | 'refresh-failed' | 'write-failed' | 'import-failed'; message: string };
549
+
550
+ /** Hours left on a JWT access token (one decimal) — null when the token carries no readable `exp`. */
551
+ export function codexAccessHoursLeft(accessToken: unknown, now: number = Date.now()): number | null {
552
+ if (typeof accessToken !== 'string') return null;
553
+ const exp = decodeJWTPayload(accessToken)?.exp;
554
+ return typeof exp === 'number' ? Math.round(((exp * 1000 - now) / 3_600_000) * 10) / 10 : null;
555
+ }
556
+
557
+ export async function refreshCodexAccountHome(
558
+ name: string,
559
+ opts: {
560
+ /** ⛔ Test isolation seam — default is the canonical store. */
561
+ storePath?: string;
562
+ /** ⛔ Test seam — tests inject a fake refresher; never the network. */
563
+ refresh?: (refreshToken: string) => Promise<TokenResponse>;
564
+ now?: () => number;
565
+ } = {},
566
+ ): Promise<CodexAccountRefreshResult> {
567
+ // ⛔ The CEO's default account (~/.codex) is shared with the official CLI — refreshing it here
568
+ // rotates the refresh token out from under that CLI. Named accounts only.
569
+ if (name === DEFAULT_CODEX_ACCOUNT) {
570
+ return { ok: false, kind: 'default-refused', message: `'${DEFAULT_CODEX_ACCOUNT}'(~/.codex) 는 대표 기본 계정이다 — 여기서 갱신하지 않는다` };
571
+ }
572
+ if (!isValidAccountName(name)) return { ok: false, kind: 'bad-name', message: `계정 이름이 부적격이다: ${name}` };
573
+ const storePath = opts.storePath ?? authStorePath();
574
+ const now = opts.now ?? Date.now;
575
+ const storeKey = codexStoreKey(name);
576
+ const codexHome = loadTokens(storeKey, storePath)?.codexHome;
577
+ if (!codexHome) return { ok: false, kind: 'unknown-account', message: `${storeKey} 계정이 없거나 codexHome 을 모른다 — 먼저 account import` };
578
+ const authPath = join(codexHome, 'auth.json');
579
+ let raw: string;
580
+ let parsed: { tokens?: Record<string, unknown> } & Record<string, unknown>;
581
+ try { raw = readFileSync(authPath, 'utf8'); parsed = JSON.parse(raw); }
582
+ catch (error) { return { ok: false, kind: 'unreadable', message: `${authPath} 를 못 읽었다 (${error instanceof Error ? error.name : 'error'})` }; }
583
+ const refreshToken = parsed.tokens?.refresh_token;
584
+ if (typeof refreshToken !== 'string' || !refreshToken) return { ok: false, kind: 'no-tokens', message: '그 홈에 refresh 토큰이 없다 — 그 홈으로 codex login' };
585
+ const beforeH = codexAccessHoursLeft(parsed.tokens?.access_token, now());
586
+ // Backup first — the server rotates the refresh token, so a failed write after refresh must leave something to recover from.
587
+ const backupPath = `${authPath}.bak-${new Date(now()).toISOString().replace(/[:.]/g, '-')}`;
588
+ try { writeFileSync(backupPath, raw, { mode: 0o600, flag: 'wx' }); }
589
+ catch (error) { return { ok: false, kind: 'write-failed', message: `백업을 못 썼다 (${error instanceof Error ? error.name : 'error'})` }; }
590
+ let fresh: TokenResponse;
591
+ try { fresh = await (opts.refresh ?? ((token) => refreshCodexTokens(token)))(refreshToken); }
592
+ catch (error) {
593
+ // refreshCodexTokens' message is `codex refresh failed: status=N` — no token. Anything else: a fixed line.
594
+ const message = error instanceof Error && /^codex refresh failed: status=\d+$/.test(error.message) ? error.message : 'codex refresh failed';
595
+ return { ok: false, kind: 'refresh-failed', message };
596
+ }
597
+ const next = {
598
+ ...parsed,
599
+ tokens: { ...parsed.tokens, access_token: fresh.access_token, refresh_token: fresh.refresh_token, ...(fresh.id_token ? { id_token: fresh.id_token } : {}) },
600
+ last_refresh: new Date(now()).toISOString(),
601
+ };
602
+ const tmp = `${authPath}.tmp-${process.pid}-${now()}`;
603
+ try {
604
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
605
+ chmodSync(tmp, 0o600);
606
+ renameSync(tmp, authPath);
607
+ } catch (error) {
608
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
609
+ return { ok: false, kind: 'write-failed', message: `${authPath} 원자적 쓰기 실패 (${error instanceof Error ? error.name : 'error'}) — 백업: ${backupPath}` };
610
+ }
611
+ // ⭐ Canonical store through the same path as `account import` — not a second writer.
612
+ const imported = await importCodexAccountFromHome(name, codexHome, storePath);
613
+ if (!imported.ok) return { ok: false, kind: 'import-failed', message: imported.message };
614
+ return { ok: true, storeKey, codexHome, beforeH, afterH: codexAccessHoursLeft(fresh.access_token, now()), backupPath };
615
+ }