elanous 0.2.7 → 0.2.8

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 (252) 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/4836-3084c58c1725a40b.js +1 -0
  4. package/apps/pwa/out/_next/static/chunks/7032-f4ef698518fba61c.js +1 -0
  5. package/apps/pwa/out/_next/static/chunks/8067-d976b441a104ab74.js +1 -0
  6. package/apps/pwa/out/_next/static/chunks/8519-5632377cce994f86.js +1 -0
  7. package/apps/pwa/out/_next/static/chunks/8894-7317998f6cb97d8c.js +1 -0
  8. package/apps/pwa/out/_next/static/chunks/9740-8f5ff96c522b5f55.js +1 -0
  9. package/apps/pwa/out/_next/static/chunks/app/approvals/page-9aa47b0bc6d570ff.js +1 -0
  10. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-f4d2ecbbae6088d6.js +1 -0
  11. package/apps/pwa/out/_next/static/chunks/app/board/page-2053e49930c82748.js +1 -0
  12. package/apps/pwa/out/_next/static/chunks/app/botlab/{page-624f7a2f89b60c8f.js → page-d4b6df905f185055.js} +1 -1
  13. package/apps/pwa/out/_next/static/chunks/app/bots/{page-5a5fb385a4df0ffe.js → page-a70ba27c33003dd0.js} +1 -1
  14. package/apps/pwa/out/_next/static/chunks/app/chat/page-186fc6952f027acc.js +1 -0
  15. package/apps/pwa/out/_next/static/chunks/app/consult/{page-7457f52683562161.js → page-18166f885d831d3e.js} +1 -1
  16. package/apps/pwa/out/_next/static/chunks/app/control/page-1c9d6cfe68b121c2.js +1 -0
  17. package/apps/pwa/out/_next/static/chunks/app/design-check/page-cbbe03c0da7b081a.js +1 -0
  18. package/apps/pwa/out/_next/static/chunks/app/editor/page-c108ff1fdb665c97.js +1 -0
  19. package/apps/pwa/out/_next/static/chunks/app/exec/{page-b7dcf5d48a854b76.js → page-147a328753d983e2.js} +1 -1
  20. package/apps/pwa/out/_next/static/chunks/app/field/{page-51480757e7d68b92.js → page-799395a9df7d95aa.js} +1 -1
  21. package/apps/pwa/out/_next/static/chunks/app/intake/page-6a8158b71d1fe9d7.js +1 -0
  22. package/apps/pwa/out/_next/static/chunks/app/layout-9567560270857531.js +1 -0
  23. package/apps/pwa/out/_next/static/chunks/app/live/{page-30533117beb53625.js → page-e7ed3d010153d818.js} +1 -1
  24. package/apps/pwa/out/_next/static/chunks/app/market/page-c0c0d19d51342d3e.js +1 -0
  25. package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-07267f1391b96aba.js → page-2e002ad8faa43291.js} +1 -1
  26. package/apps/pwa/out/_next/static/chunks/app/morning/{page-55d50e9b0006a91d.js → page-b672e02e4bec8deb.js} +1 -1
  27. package/apps/pwa/out/_next/static/chunks/app/not-found-1ba4a5d857276334.js +1 -0
  28. package/apps/pwa/out/_next/static/chunks/app/observatory/page-9db2226f38c542fb.js +1 -0
  29. package/apps/pwa/out/_next/static/chunks/app/{page-a7e37af7d0399cb8.js → page-70ee65f5f7a151dc.js} +1 -1
  30. package/apps/pwa/out/_next/static/chunks/app/reflection/page-0bb8e43203efb23b.js +1 -0
  31. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-092e1bcf27d751cc.js +1 -0
  32. package/apps/pwa/out/_next/static/chunks/app/sessions/{page-15e5a15db84c56aa.js → page-eb52580c4b75b905.js} +1 -1
  33. package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-a2e572e4511f2a16.js → page-7464c1987dd76e6d.js} +1 -1
  34. package/apps/pwa/out/_next/static/chunks/app/settings/{page-dd107bf1c7d52a90.js → page-e573437b0a1c728f.js} +1 -1
  35. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-294300625e49e4d3.js +1 -0
  36. package/apps/pwa/out/_next/static/chunks/app/setup/page-085847b088675978.js +1 -0
  37. package/apps/pwa/out/_next/static/chunks/app/share/page-9fe9d38c36999eb4.js +1 -0
  38. package/apps/pwa/out/_next/static/chunks/app/showroom/page-d8ab4d1489223459.js +1 -0
  39. package/apps/pwa/out/_next/static/chunks/app/tasks/page-6141f67f531d8b6a.js +1 -0
  40. package/apps/pwa/out/_next/static/chunks/app/term/page-295a02b1026b8255.js +1 -0
  41. package/apps/pwa/out/_next/static/chunks/app/trace/page-1eb9a0e1e054209d.js +1 -0
  42. package/apps/pwa/out/_next/static/chunks/app/vault/{page-56adbe1a1f1379bd.js → page-82efa177a30f3741.js} +1 -1
  43. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d0be51cec4f62606.js +1 -0
  44. package/apps/pwa/out/_next/static/chunks/app/workflows/page-2e367075ffdbc062.js +1 -0
  45. package/apps/pwa/out/_next/static/chunks/app/workspace/{page-5d422f38c052967e.js → page-647ed2b66c1d49f1.js} +1 -1
  46. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ba058c56e38f2792.js +1 -0
  47. package/apps/pwa/out/_next/static/css/5ef72a618a8994f3.css +1 -0
  48. package/apps/pwa/out/approvals/index.html +1 -1
  49. package/apps/pwa/out/approvals/index.txt +13 -13
  50. package/apps/pwa/out/autopilot/index.html +1 -1
  51. package/apps/pwa/out/autopilot/index.txt +13 -13
  52. package/apps/pwa/out/board/index.html +1 -1
  53. package/apps/pwa/out/board/index.txt +13 -13
  54. package/apps/pwa/out/botlab/index.html +1 -1
  55. package/apps/pwa/out/botlab/index.txt +13 -13
  56. package/apps/pwa/out/bots/index.html +1 -1
  57. package/apps/pwa/out/bots/index.txt +13 -13
  58. package/apps/pwa/out/chat/index.html +1 -1
  59. package/apps/pwa/out/chat/index.txt +13 -13
  60. package/apps/pwa/out/consult/index.html +1 -1
  61. package/apps/pwa/out/consult/index.txt +13 -13
  62. package/apps/pwa/out/control/index.html +1 -1
  63. package/apps/pwa/out/control/index.txt +13 -13
  64. package/apps/pwa/out/design-check/index.html +1 -1
  65. package/apps/pwa/out/design-check/index.txt +13 -13
  66. package/apps/pwa/out/editor/index.html +1 -1
  67. package/apps/pwa/out/editor/index.txt +13 -13
  68. package/apps/pwa/out/exec/index.html +1 -1
  69. package/apps/pwa/out/exec/index.txt +13 -13
  70. package/apps/pwa/out/field/index.html +1 -1
  71. package/apps/pwa/out/field/index.txt +13 -13
  72. package/apps/pwa/out/healthz +1 -1
  73. package/apps/pwa/out/index.html +1 -1
  74. package/apps/pwa/out/index.txt +13 -13
  75. package/apps/pwa/out/intake/index.html +1 -1
  76. package/apps/pwa/out/intake/index.txt +13 -13
  77. package/apps/pwa/out/live/index.html +1 -1
  78. package/apps/pwa/out/live/index.txt +13 -13
  79. package/apps/pwa/out/market/index.html +1 -1
  80. package/apps/pwa/out/market/index.txt +13 -13
  81. package/apps/pwa/out/missions/placeholder/index.html +1 -1
  82. package/apps/pwa/out/missions/placeholder/index.txt +13 -13
  83. package/apps/pwa/out/morning/index.html +1 -1
  84. package/apps/pwa/out/morning/index.txt +13 -13
  85. package/apps/pwa/out/observatory/index.html +1 -1
  86. package/apps/pwa/out/observatory/index.txt +13 -13
  87. package/apps/pwa/out/reflection/index.html +1 -1
  88. package/apps/pwa/out/reflection/index.txt +14 -14
  89. package/apps/pwa/out/scheduler/index.html +1 -1
  90. package/apps/pwa/out/scheduler/index.txt +13 -13
  91. package/apps/pwa/out/sessions/index.html +1 -1
  92. package/apps/pwa/out/sessions/index.txt +13 -13
  93. package/apps/pwa/out/settings/devices/index.html +1 -1
  94. package/apps/pwa/out/settings/devices/index.txt +13 -13
  95. package/apps/pwa/out/settings/index.html +1 -1
  96. package/apps/pwa/out/settings/index.txt +13 -13
  97. package/apps/pwa/out/setup/done/index.html +1 -1
  98. package/apps/pwa/out/setup/done/index.txt +13 -13
  99. package/apps/pwa/out/setup/index.html +1 -1
  100. package/apps/pwa/out/setup/index.txt +13 -13
  101. package/apps/pwa/out/share/index.html +1 -1
  102. package/apps/pwa/out/share/index.txt +13 -13
  103. package/apps/pwa/out/showroom/index.html +1 -1
  104. package/apps/pwa/out/showroom/index.txt +13 -13
  105. package/apps/pwa/out/tasks/index.html +1 -1
  106. package/apps/pwa/out/tasks/index.txt +13 -13
  107. package/apps/pwa/out/term/index.html +1 -1
  108. package/apps/pwa/out/term/index.txt +13 -13
  109. package/apps/pwa/out/trace/index.html +1 -1
  110. package/apps/pwa/out/trace/index.txt +13 -13
  111. package/apps/pwa/out/vault/index.html +1 -1
  112. package/apps/pwa/out/vault/index.txt +13 -13
  113. package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
  114. package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
  115. package/apps/pwa/out/workflows/index.html +1 -1
  116. package/apps/pwa/out/workflows/index.txt +13 -13
  117. package/apps/pwa/out/workspace/index.html +1 -1
  118. package/apps/pwa/out/workspace/index.txt +13 -13
  119. package/apps/pwa/out/worktrees/index.html +1 -1
  120. package/apps/pwa/out/worktrees/index.txt +13 -13
  121. package/graphs/test-diet/recipes.yaml +9 -0
  122. package/graphs/test-diet/test-diet.yaml +22 -0
  123. package/package.json +2 -1
  124. package/persona-presets/_index.yaml +10 -0
  125. package/persona-presets/_schema.json +146 -0
  126. package/persona-presets/dana.yaml +26 -0
  127. package/persona-presets/haru.yaml +27 -0
  128. package/persona-presets/kai.yaml +27 -0
  129. package/persona-presets/leo.yaml +27 -0
  130. package/persona-presets/mira.yaml +30 -0
  131. package/persona-presets/nari.yaml +27 -0
  132. package/persona-presets/sora.yaml +27 -0
  133. package/persona-presets/yuna.yaml +27 -0
  134. package/plugins/geo-check/graphs/run-step.ts +34 -10
  135. package/scripts/field-feed/deliver-node.ts +8 -3
  136. package/scripts/field-feed/lib.ts +2 -0
  137. package/scripts/install.sh +20 -11
  138. package/scripts/persona-presets/lint.ts +79 -0
  139. package/scripts/release-loop/checklist-gate-node.ts +2 -2
  140. package/scripts/release-loop/gate-node.ts +27 -3
  141. package/scripts/release-loop/npm-publish-node.ts +10 -4
  142. package/scripts/release-loop/ops-upgrade-node.ts +11 -3
  143. package/scripts/release-loop/publish-node.ts +23 -1
  144. package/scripts/release-loop/shard-plan.ts +17 -0
  145. package/scripts/storyboard/storyboard.ts +167 -0
  146. package/scripts/test-diet/lib.ts +88 -0
  147. package/scripts/test-diet/node.ts +102 -0
  148. package/src/acp/auth-methods.ts +30 -0
  149. package/src/acp/server.ts +5 -5
  150. package/src/channel-import/detect.ts +191 -0
  151. package/src/channel-import/run.ts +111 -0
  152. package/src/cli/config-test-sync.ts +4 -0
  153. package/src/cli/decisions-cli.ts +13 -3
  154. package/src/cli/doctor-bundle.ts +128 -0
  155. package/src/cli/doctor-cli.ts +36 -1
  156. package/src/cli/doctor-local-llm.ts +95 -0
  157. package/src/cli/import-cli.ts +60 -0
  158. package/src/cli/mcp-login.ts +13 -4
  159. package/src/cli/persona-cli.ts +116 -0
  160. package/src/cli/release-cli.ts +8 -7
  161. package/src/cli/skills-cli.ts +173 -0
  162. package/src/cli/start-cli.ts +13 -4
  163. package/src/codex/setup.ts +15 -13
  164. package/src/dashboard/index.ts +17 -1
  165. package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -2
  166. package/src/dashboard/slash-runtime/help-from-registry.ts +4 -2
  167. package/src/decisions/decision-cards.ts +291 -0
  168. package/src/decisions/decision-ledger.ts +5 -2
  169. package/src/decisions/discord-decision-cards.ts +133 -0
  170. package/src/decisions/telegram-decision-cards.ts +100 -0
  171. package/src/directives/version-at.ts +77 -0
  172. package/src/discord.ts +17 -0
  173. package/src/domains/outbound-alert.ts +44 -1
  174. package/src/domains/repeated-failure.ts +1 -1
  175. package/src/error-report/report.ts +200 -0
  176. package/src/exec-requests/planner.ts +8 -2
  177. package/src/exec-requests/runner.ts +64 -24
  178. package/src/exec-requests/store.ts +2 -0
  179. package/src/field/field-feed-auto.ts +48 -0
  180. package/src/field/field-reel.ts +8 -1
  181. package/src/hooks/error-report.ts +210 -0
  182. package/src/hooks/receiver.ts +41 -1
  183. package/src/import/apply.ts +128 -0
  184. package/src/import/plan.ts +197 -0
  185. package/src/index.ts +82 -7
  186. package/src/input-core/text-entry.ts +13 -5
  187. package/src/intake-plane/seat-answer.ts +96 -0
  188. package/src/intake-plane/seat-doc-route.ts +17 -3
  189. package/src/nexus/api/consult-requests.ts +19 -8
  190. package/src/nexus/api/error-reports.ts +108 -0
  191. package/src/nexus/api/http-server.ts +21 -1
  192. package/src/nexus/api/outbound-report.ts +7 -0
  193. package/src/nexus/api/running-runs-background.ts +68 -0
  194. package/src/nexus/api/running-runs-child.ts +6 -0
  195. package/src/nexus/api/share-captures.ts +91 -0
  196. package/src/nexus/api/skill-problems.ts +56 -0
  197. package/src/nexus/api/terminals.ts +24 -8
  198. package/src/nexus/index.ts +25 -1
  199. package/src/nexus/setup-status.ts +7 -2
  200. package/src/oauth/browser-availability.ts +14 -0
  201. package/src/oauth/codex-quota-policy.ts +16 -0
  202. package/src/onboarding/continue-here.ts +13 -8
  203. package/src/onboarding/web-first.ts +8 -1
  204. package/src/onboarding.ts +66 -12
  205. package/src/persona/presets.ts +105 -0
  206. package/src/release-loop/checklist.ts +28 -5
  207. package/src/self-dev/orchestrate.ts +18 -11
  208. package/src/shell-env-bootstrap.ts +44 -7
  209. package/src/skills/connect-claude-plugin.ts +142 -0
  210. package/src/skills/connect.ts +70 -0
  211. package/src/skills/index.ts +29 -1
  212. package/src/skills/repair.ts +124 -0
  213. package/src/skills/runner.ts +11 -2
  214. package/src/skills/trigger-extract.ts +1 -1
  215. package/src/task-orchestrator/surfaces/pod-command-job.ts +16 -8
  216. package/src/telegram-commands.ts +13 -0
  217. package/src/telegram.ts +34 -0
  218. package/src/user-config.ts +111 -13
  219. package/src/version/packed-revision.json +1 -1
  220. package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +0 -1
  221. package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +0 -1
  222. package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +0 -1
  223. package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +0 -1
  224. package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +0 -1
  225. package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +0 -1
  226. package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +0 -1
  227. package/apps/pwa/out/_next/static/chunks/app/autopilot/page-10ed24f413ed66e9.js +0 -1
  228. package/apps/pwa/out/_next/static/chunks/app/board/page-211810b6c93ab66a.js +0 -1
  229. package/apps/pwa/out/_next/static/chunks/app/chat/page-80497e93a878ddc5.js +0 -1
  230. package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +0 -1
  231. package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +0 -1
  232. package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +0 -1
  233. package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +0 -1
  234. package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +0 -1
  235. package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +0 -1
  236. package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +0 -1
  237. package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +0 -1
  238. package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +0 -1
  239. package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +0 -1
  240. package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +0 -1
  241. package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +0 -1
  242. package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +0 -1
  243. package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +0 -1
  244. package/apps/pwa/out/_next/static/chunks/app/tasks/page-c1c95739c0bff3b8.js +0 -1
  245. package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +0 -1
  246. package/apps/pwa/out/_next/static/chunks/app/trace/page-71d18eec33b9c667.js +0 -1
  247. package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-2c4e59079ee357ac.js +0 -1
  248. package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +0 -1
  249. package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +0 -1
  250. package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +0 -1
  251. /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → lywc8m_HDzH9MKSDQhyEj}/_buildManifest.js +0 -0
  252. /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → lywc8m_HDzH9MKSDQhyEj}/_ssgManifest.js +0 -0
@@ -0,0 +1,88 @@
1
+ // TD2 — a daily look at the test suite, one cost-weighted slice at a time (shadow: it measures and drafts, it
2
+ // never deletes or moves a test). Design: 내부 문서 `TD1-v2-gate-content-review-2026-10-01`.
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export const DEFAULT_COST_SECS = 10;
7
+ export const SLOW_SECS = 60;
8
+ export const HEAVY_MB = 2048;
9
+
10
+ export interface Measurement { file: string; secs: number; rssMb: number | null; rc: number | null; pass: number | null; fail: number | null; reason?: string }
11
+ export type Verdict = 'keep' | 'failing' | 'review';
12
+ export interface Judged extends Measurement { caught90: number; flags: string[]; verdict: Verdict }
13
+ export interface LedgerLine { at: string; range: string; start: number; end: number; next: number; total: number; commit: string; budgetSecs: number; results: Judged[] }
14
+
15
+ /** `file\tsecs\t…` (TD1 whole-gate TSV) → seconds per file. */
16
+ export function costTable(tsv: string): Map<string, number> {
17
+ const costs = new Map<string, number>();
18
+ for (const line of tsv.split('\n').slice(1)) {
19
+ const [file, secs] = line.split('\t');
20
+ const n = Number(secs);
21
+ if (file && Number.isFinite(n) && n > 0) costs.set(file, n);
22
+ }
23
+ return costs;
24
+ }
25
+
26
+ /** Next slice from the cursor until the known cost reaches the budget (at least one file; wraps at the end). */
27
+ export function pickRange(files: readonly string[], start: number, costs: ReadonlyMap<string, number>, budgetSecs: number): { start: number; end: number; next: number; files: string[]; estimatedSecs: number } {
28
+ if (files.length === 0) return { start: 0, end: -1, next: 0, files: [], estimatedSecs: 0 };
29
+ const from = ((start % files.length) + files.length) % files.length;
30
+ const picked: string[] = [];
31
+ let spent = 0;
32
+ let i = from;
33
+ while (picked.length < files.length) {
34
+ const file = files[i]!;
35
+ const cost = costs.get(file) ?? DEFAULT_COST_SECS;
36
+ if (picked.length > 0 && spent + cost > budgetSecs) break;
37
+ picked.push(file);
38
+ spent += cost;
39
+ i = (i + 1) % files.length;
40
+ }
41
+ return { start: from, end: (from + picked.length - 1) % files.length, next: i, files: picked, estimatedSecs: spent };
42
+ }
43
+
44
+ /** Mechanical verdict: failing, or slow/heavy with nothing caught in 90 days → review (a draft card, never an action). */
45
+ export function judge(m: Measurement, caught90: number): Judged {
46
+ const flags: string[] = [];
47
+ if (m.secs >= SLOW_SECS) flags.push('slow');
48
+ if (m.rssMb !== null && m.rssMb >= HEAVY_MB) flags.push('heavy');
49
+ const verdict: Verdict = m.rc !== 0 ? 'failing' : flags.length > 0 && caught90 === 0 ? 'review' : 'keep';
50
+ return { ...m, caught90, flags, verdict };
51
+ }
52
+
53
+ export function ledgerPath(root: string): string { return join(root, 'test-diet', 'ledger.jsonl'); }
54
+
55
+ export function lastLedgerLine(root: string): LedgerLine | null {
56
+ const path = ledgerPath(root);
57
+ if (!existsSync(path)) return null;
58
+ const lines = readFileSync(path, 'utf8').trim().split('\n').filter(Boolean);
59
+ try { return lines.length ? JSON.parse(lines.at(-1)!) as LedgerLine : null; } catch { return null; }
60
+ }
61
+
62
+ export function appendLedger(root: string, line: LedgerLine): void {
63
+ mkdirSync(join(root, 'test-diet'), { recursive: true });
64
+ appendFileSync(ledgerPath(root), `${JSON.stringify(line)}\n`);
65
+ }
66
+
67
+ /** Draft approval card — written as a file for a person to read; nothing is acted on. */
68
+ export function writeCardDraft(root: string, line: LedgerLine): string | null {
69
+ const items = line.results.filter((r) => r.verdict !== 'keep');
70
+ if (items.length === 0) return null;
71
+ const dir = join(root, 'test-diet', 'cards');
72
+ mkdirSync(dir, { recursive: true });
73
+ const path = join(dir, `${line.at.slice(0, 10)}-${line.range.replace(/[^0-9a-z~#-]/gi, '')}.md`);
74
+ const rows = items.map((r) => `| \`${r.file}\` | ${r.verdict} | ${r.secs}s | ${r.rssMb ?? '?'} MB | ${r.caught90} | ${r.flags.join(', ') || '—'} | ${(r.reason ?? '').replace(/\|/g, '/')} |`);
75
+ writeFileSync(path, [
76
+ `# 시험 리뷰 초안 — ${line.range} (${line.at.slice(0, 10)})`,
77
+ '',
78
+ '> 초안이다. 아무것도 지우거나 옮기지 않았다. 사람이 읽고 정한다.',
79
+ '',
80
+ '| 파일 | 판정 | 단독 시간 | 피크 메모리 | 90일 fix 커밋 | 표지 | 실패 사유(첫 줄) |',
81
+ '|---|---|---|---|---|---|---|',
82
+ ...rows,
83
+ '',
84
+ `원장: ${line.results.length}개 중 ${items.length}개 · 측정 판 ${line.commit}`,
85
+ '',
86
+ ].join('\n'));
87
+ return path;
88
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bun
2
+ // TD2 graph nodes: pick → measure → record. Shadow only — measures alone on a remote host and drafts a card.
3
+ // bun scripts/test-diet/node.ts pick|measure|record (inside `elanous graph run graphs/test-diet/test-diet.yaml`)
4
+ import { spawnSync } from 'node:child_process';
5
+ import { readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { debug } from '../../src/debug/log.js';
8
+ import { effectiveInstanceRoot } from '../../src/instance/resolve.js';
9
+ import { appendLedger, costTable, judge, lastLedgerLine, pickRange, writeCardDraft, type Measurement } from './lib.js';
10
+
11
+ type Context = { input: Record<string, unknown>; outputs: Record<string, Record<string, unknown>> };
12
+ function context(): Context {
13
+ const at = process.env.ELANOUS_GRAPH_CONTEXT;
14
+ if (!at) throw new Error('ELANOUS_GRAPH_CONTEXT required');
15
+ const raw = JSON.parse(at.trimStart().startsWith('{') ? at : readFileSync(at, 'utf8')) as Partial<Context>;
16
+ return { input: raw.input ?? {}, outputs: raw.outputs ?? {} };
17
+ }
18
+ function emit(result: Record<string, unknown> & { outcome: 'ok' | 'fail' | 'error'; summary: string }): number {
19
+ console.log(JSON.stringify({ verdict: result.outcome === 'ok' ? 'pass' : 'fail', ...result }));
20
+ return result.outcome === 'ok' ? 0 : result.outcome === 'fail' ? 1 : 2;
21
+ }
22
+ const git = (args: string[]) => spawnSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).stdout ?? '';
23
+
24
+ function pick(ctx: Context): number {
25
+ const budgetSecs = typeof ctx.input.budgetSecs === 'number' && ctx.input.budgetSecs > 0 ? ctx.input.budgetSecs : 1800;
26
+ const files = git(['ls-files', '*.test.ts']).split('\n').filter(Boolean).sort();
27
+ const costs = costTable(readFileSync(join(process.cwd(), 'docs/measurements/td1-whole-gate-mechanical-2026-10-01.tsv'), 'utf8'));
28
+ const last = lastLedgerLine(effectiveInstanceRoot());
29
+ for (const r of last?.results ?? []) costs.set(r.file, r.secs);
30
+ // `start` re-looks a slice on purpose; otherwise continue from the ledger cursor.
31
+ const startAt = typeof ctx.input.start === 'number' && ctx.input.start >= 0 ? ctx.input.start : last?.next ?? 0;
32
+ const range = pickRange(files, startAt, costs, budgetSecs);
33
+ return emit({ outcome: 'ok', summary: `test-diet pick #${range.start}~#${range.end} of ${files.length} (≈${Math.round(range.estimatedSecs)}s)`, ...range, total: files.length, budgetSecs });
34
+ }
35
+
36
+ /** One remote run: shallow clone of the mirror, one install, each file alone with its peak memory. */
37
+ function measure(ctx: Context): number {
38
+ const picked = ctx.outputs.pick?.files;
39
+ if (!Array.isArray(picked) || picked.length === 0) return emit({ outcome: 'fail', summary: 'nothing picked' });
40
+ const host = typeof ctx.input.remote === 'string' ? ctx.input.remote : 'node-b';
41
+ const mirror = typeof ctx.input.mirror === 'string' ? ctx.input.mirror : '~/mirror/elanous-agent.git';
42
+ const script = [
43
+ 'set -u', 'export PATH="$HOME/.bun/bin:$PATH"',
44
+ 'T=$(mktemp -d)', 'trap \'rm -rf "$T"\' EXIT',
45
+ `git clone -q --depth 1 "file://$(eval echo ${mirror})" "$T/repo" || exit 3`,
46
+ // Same installs as the gate: root and apps/pwa (PWA tests import its own dependencies).
47
+ 'cd "$T/repo" && bun install --frozen-lockfile >/dev/null 2>&1 || exit 4',
48
+ '(cd apps/pwa && bun install --frozen-lockfile >/dev/null 2>&1) || echo "WARN pwa-install-failed"',
49
+ 'echo "COMMIT $(git rev-parse HEAD)"',
50
+ 'TO=$(command -v timeout || command -v gtimeout || true)',
51
+ 'while IFS= read -r f; do',
52
+ ' [ -n "$f" ] || continue',
53
+ ' s=$(date +%s)',
54
+ ' if [ -n "$TO" ]; then /usr/bin/time -l "$TO" -k 10 300 bun run scripts/test-deterministic.ts "./$f" > "$T/out" 2> "$T/err"; else /usr/bin/time -l bun run scripts/test-deterministic.ts "./$f" > "$T/out" 2> "$T/err"; fi',
55
+ ' rc=$?; e=$(( $(date +%s) - s ))',
56
+ ' rss=$(awk \'/maximum resident set size/ {printf "%d", $1/1048576}\' "$T/err")',
57
+ // bun test prints its summary on stderr — read both streams.
58
+ ' pass=$(cat "$T/out" "$T/err" | grep -E -o "^ *[0-9]+ pass" | grep -E -o "[0-9]+" | tail -1); fail=$(cat "$T/out" "$T/err" | grep -E -o "^ *[0-9]+ fail" | grep -E -o "[0-9]+" | tail -1)',
59
+ // A failing file names its first error, so an environment gap (e.g. no Java on the host) is not read as a test defect.
60
+ ' why=""; [ "$rc" -ne 0 ] && why=$(grep -v -E "^\\s*$|^bun test|^ *[0-9]+ \\||^error: *$" "$T/err" | grep -m1 -E "Unable to|Cannot find|not found|ENOENT|EACCES|rror" | tr "\\t" " " | cut -c1-160)',
61
+ ' printf "M\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n" "$f" "$e" "${rss:-}" "$rc" "${pass:-}" "${fail:-}" "$why"',
62
+ 'done',
63
+ ].join('\n');
64
+ const run = spawnSync('ssh', ['-o', 'BatchMode=yes', host, 'bash', '-s'], { input: `${script}\n${picked.join('\n')}\n`, encoding: 'utf8', timeout: 4 * 3600_000, maxBuffer: 16 * 1024 * 1024 });
65
+ // The file list follows the script on stdin; `read` in the loop consumes it.
66
+ if (run.status !== 0 && !String(run.stdout).includes('\nM\t')) return emit({ outcome: 'error', summary: `remote measure failed (rc=${run.status}): ${String(run.stderr).trim().slice(0, 200)}` });
67
+ const commit = /^COMMIT (\w+)/m.exec(run.stdout)?.[1] ?? '?';
68
+ const measurements: Measurement[] = String(run.stdout).split('\n').filter((l) => l.startsWith('M\t')).map((l) => {
69
+ const [, file, secs, rss, rc, pass, fail, why] = l.split('\t');
70
+ const num = (v: string | undefined) => (v === undefined || v === '' ? null : Number(v));
71
+ return { file: file!, secs: Number(secs), rssMb: num(rss), rc: num(rc), pass: num(pass), fail: num(fail), ...(why ? { reason: why } : {}) };
72
+ });
73
+ return emit({ outcome: measurements.length ? 'ok' : 'error', summary: `measured ${measurements.length}/${picked.length} on ${host} at ${commit.slice(0, 9)}`, host, commit, measurements });
74
+ }
75
+
76
+ function record(ctx: Context): number {
77
+ const p = ctx.outputs.pick;
78
+ const m = ctx.outputs.measure;
79
+ const measurements = (m?.measurements ?? []) as Measurement[];
80
+ if (!p || measurements.length === 0) return emit({ outcome: 'fail', summary: 'nothing measured' });
81
+ const results = measurements.map((x) => judge(x, Number(git(['log', '--since=90.days', '--format=%s', '--', x.file]).split('\n').filter((s) => /^fix\b/i.test(s)).length)));
82
+ const line = {
83
+ at: new Date().toISOString(), range: `#${p.start}~#${p.end}`, start: Number(p.start), end: Number(p.end), next: Number(p.next), total: Number(p.total),
84
+ commit: String(m?.commit ?? '?'), budgetSecs: Number(p.budgetSecs), results,
85
+ };
86
+ const root = effectiveInstanceRoot();
87
+ appendLedger(root, line);
88
+ const card = writeCardDraft(root, line);
89
+ const counts = { keep: results.filter((r) => r.verdict === 'keep').length, review: results.filter((r) => r.verdict === 'review').length, failing: results.filter((r) => r.verdict === 'failing').length };
90
+ debug.log('test-diet', 'recorded', { range: line.range, ...counts, card: card !== null });
91
+ return emit({ outcome: 'ok', summary: `test-diet ${line.range} · keep ${counts.keep} · review ${counts.review} · failing ${counts.failing}${card ? ' · card draft' : ''}`, range: line.range, ...counts, ...(card ? { card } : {}) });
92
+ }
93
+
94
+ if (import.meta.main) {
95
+ const step = process.argv[2];
96
+ try {
97
+ const ctx = context();
98
+ process.exitCode = step === 'pick' ? pick(ctx) : step === 'measure' ? measure(ctx) : step === 'record' ? record(ctx) : emit({ outcome: 'error', summary: `unknown step: ${step}` });
99
+ } catch (error) {
100
+ process.exitCode = emit({ outcome: 'error', summary: error instanceof Error ? error.message : String(error) });
101
+ }
102
+ }
@@ -0,0 +1,30 @@
1
+ // EN10a — ACP auth methods: a client that runs terminals gets a `terminal` method (it re-launches `elanous --acp-server --login`);
2
+ // an older client gets an agent method carrying Zed's `terminal-auth` meta, so «sign-in needed → run elanous login in a terminal» shows either way.
3
+ import type { AuthMethod, ClientCapabilities } from '@agentclientprotocol/sdk';
4
+
5
+ export const ACP_LOGIN_METHOD_ID = 'elanous-login';
6
+ /** Appended to the configured agent invocation (`elanous --acp-server`) by a terminal-auth client. */
7
+ export const ACP_LOGIN_ARG = '--login';
8
+ /** What the login step runs (the ChatGPT/Codex subscription sign-in). */
9
+ export const ACP_LOGIN_SUBCOMMAND = ['login', 'openai-codex'] as const;
10
+
11
+ export interface AcpLaunch { command: string; args: string[] }
12
+
13
+ /** How this process was started (bun + script, or a compiled binary) so a client can start the same thing. */
14
+ export function currentLaunch(argv: readonly string[] = process.argv, execPath = process.execPath): AcpLaunch {
15
+ const script = argv[1];
16
+ return script && /\.(?:m?js|ts)$/.test(script) ? { command: execPath, args: [script] } : { command: execPath, args: [] };
17
+ }
18
+
19
+ export function acpAuthMethods(client: ClientCapabilities | null | undefined, launch: AcpLaunch = currentLaunch()): AuthMethod[] {
20
+ const name = 'Sign in to elanous';
21
+ if (client?.auth?.terminal === true) {
22
+ return [{ type: 'terminal', id: ACP_LOGIN_METHOD_ID, name, description: 'Opens a terminal to sign in with your ChatGPT (Codex) subscription', args: [ACP_LOGIN_ARG] }];
23
+ }
24
+ return [{
25
+ id: ACP_LOGIN_METHOD_ID,
26
+ name,
27
+ description: `Run \`elanous ${ACP_LOGIN_SUBCOMMAND.join(' ')}\` in a terminal, then try again`,
28
+ _meta: { 'terminal-auth': { command: launch.command, args: [...launch.args, ...ACP_LOGIN_SUBCOMMAND], label: 'elanous login' } },
29
+ }];
30
+ }
package/src/acp/server.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  //
27
27
  // Related: src/acp/client.ts (elanous-as-client path — pre-MT5 work).
28
28
 
29
+ import { ACP_LOGIN_METHOD_ID, acpAuthMethods } from './auth-methods.js';
29
30
  import { AgentSideConnection, ndJsonStream, RequestError } from '@agentclientprotocol/sdk';
30
31
  import type {
31
32
  InitializeRequest, InitializeResponse,
@@ -1415,14 +1416,13 @@ function wireAcpConnection(
1415
1416
  agentCapabilities: opts.agentBrand !== undefined && opts.agentModel !== undefined
1416
1417
  ? buildAgentDeclaration({ brand: opts.agentBrand, model: opts.agentModel })
1417
1418
  : buildAgentDeclaration(),
1418
- authMethods: [],
1419
+ authMethods: acpAuthMethods(req.clientCapabilities),
1419
1420
  };
1420
1421
  },
1421
1422
 
1422
- async authenticate(_req: AuthenticateRequest): Promise<AuthenticateResponse> {
1423
- // No auth flow — we advertise authMethods: [] so the client
1424
- // shouldn't call this, but the Agent interface requires the
1425
- // method to be present.
1423
+ async authenticate(req: AuthenticateRequest): Promise<AuthenticateResponse> {
1424
+ // EN10a — sign-in happens in a terminal (`--acp-server --login`); this only acknowledges the method so the client retries.
1425
+ debug.log('acp.session', 'authenticate', { methodId: req.methodId === ACP_LOGIN_METHOD_ID ? req.methodId : 'other' });
1426
1426
  return {};
1427
1427
  },
1428
1428
 
@@ -0,0 +1,191 @@
1
+ // EN13 — 셋업이 «이미 쓰던» 텔레그램·디스코드 설정을 찾아 온다(대표 10-01).
2
+ // 출처: OpenClaw(~/.openclaw/openclaw.json · tokenFile) · Hermes(~/.hermes/.env) · 이전 엘라누스(~/.monad/config.json).
3
+ // ⛔ 토큰 값은 이 모듈 밖으로 «문자열»로 나가지 않는다 — `BotToken` 이 감싸고, 문자열화·JSON·inspect 는 전부 가린다.
4
+ // 값이 필요한 곳(저장·연결 확인)만 `reveal()` 을 부른다. 미리보기는 «있음 · 끝 4자리 가림»만 낸다.
5
+ import { createHash } from 'node:crypto';
6
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ export type ImportPlatform = 'telegram' | 'discord';
11
+ export type ImportSource = 'openclaw' | 'hermes' | 'elanous-legacy';
12
+
13
+ const MASK = '있음 · 끝 4자리 가림';
14
+
15
+ /** A bot token that never prints itself. */
16
+ export class BotToken {
17
+ readonly #value: string;
18
+ constructor(value: string) { this.#value = value; }
19
+ reveal(): string { return this.#value; }
20
+ /** Same-token check without exposing either value. */
21
+ fingerprint(): string { return createHash('sha256').update(this.#value).digest('hex').slice(0, 8); }
22
+ toString(): string { return MASK; }
23
+ toJSON(): string { return MASK; }
24
+ [Symbol.for('nodejs.util.inspect.custom')](): string { return MASK; }
25
+ }
26
+
27
+ export interface ImportCandidate {
28
+ platform: ImportPlatform;
29
+ source: ImportSource;
30
+ /** `~`-relative file the value came from. */
31
+ file: string;
32
+ token: BotToken;
33
+ allowedUsers: string[];
34
+ /** Allowlist entries we could not use (not a numeric Telegram id, etc.). */
35
+ droppedUsers: number;
36
+ }
37
+
38
+ export const SOURCE_LABEL: Record<ImportSource, string> = {
39
+ openclaw: 'OpenClaw', hermes: 'Hermes', 'elanous-legacy': '이전 엘라누스(~/.monad)',
40
+ };
41
+
42
+ function tilde(path: string, home: string): string { return path.startsWith(home) ? `~${path.slice(home.length)}` : path; }
43
+ function obj(value: unknown): Record<string, unknown> | null {
44
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
45
+ }
46
+ function text(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; }
47
+
48
+ /** JSON with comments and trailing commas (openclaw.json is JSON5-ish). Strings are kept intact. */
49
+ export function parseLooseJson(raw: string): unknown {
50
+ try { return JSON.parse(raw); } catch { /* fall through */ }
51
+ let out = '';
52
+ let inString = false;
53
+ for (let i = 0; i < raw.length; i++) {
54
+ const ch = raw[i]!;
55
+ if (inString) {
56
+ out += ch;
57
+ if (ch === '\\') { out += raw[++i] ?? ''; continue; }
58
+ if (ch === '"') inString = false;
59
+ continue;
60
+ }
61
+ if (ch === '"') { inString = true; out += ch; continue; }
62
+ if (ch === '/' && raw[i + 1] === '/') { while (i < raw.length && raw[i] !== '\n') i++; out += '\n'; continue; }
63
+ if (ch === '/' && raw[i + 1] === '*') { i += 2; while (i < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i++; i++; continue; }
64
+ out += ch;
65
+ }
66
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
67
+ }
68
+
69
+ /** `KEY=value` lines (quotes and `export ` allowed, `#` comments ignored). */
70
+ export function parseDotEnv(raw: string): Record<string, string> {
71
+ const out: Record<string, string> = {};
72
+ for (const line of raw.split(/\r?\n/)) {
73
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
74
+ if (!m) continue;
75
+ let value = m[2]!;
76
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
77
+ else value = value.replace(/\s+#.*$/, '');
78
+ out[m[1]!] = value.trim();
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** Telegram allowlists must be numeric user ids; `telegram:123` / `tg:123` prefixes are accepted. */
84
+ export function normalizeUsers(platform: ImportPlatform, raw: unknown): { users: string[]; dropped: number } {
85
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',') : [];
86
+ const users: string[] = [];
87
+ let dropped = 0;
88
+ for (const entry of list) {
89
+ const value = String(entry).trim().replace(/^(?:telegram|tg|discord|user):/i, '');
90
+ if (!value || value === '*') { if (value === '*') dropped++; continue; }
91
+ const ok = platform === 'telegram' ? /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0 : /^\d{5,}$/.test(value);
92
+ if (ok && !users.includes(value)) users.push(value); else if (!ok) dropped++;
93
+ }
94
+ return { users, dropped };
95
+ }
96
+
97
+ function readRegularFile(path: string): string | null {
98
+ try { return lstatSync(path).isFile() ? readFileSync(path, 'utf8').trim() || null : null; } catch { return null; }
99
+ }
100
+
101
+ function candidate(platform: ImportPlatform, source: ImportSource, file: string, token: string | null, users: unknown): ImportCandidate | null {
102
+ if (!token) return null;
103
+ const { users: allowedUsers, dropped } = normalizeUsers(platform, users);
104
+ return { platform, source, file, token: new BotToken(token), allowedUsers, droppedUsers: dropped };
105
+ }
106
+
107
+ function fromOpenClaw(home: string): ImportCandidate[] {
108
+ const path = join(home, '.openclaw', 'openclaw.json');
109
+ if (!existsSync(path)) return [];
110
+ const cfg = obj(parseLooseJson(readFileSync(path, 'utf8')));
111
+ const channels = obj(cfg?.channels);
112
+ const file = tilde(path, home);
113
+ const out: ImportCandidate[] = [];
114
+ const telegram = obj(channels?.telegram);
115
+ if (telegram) {
116
+ const firstAccount = obj(Object.values(obj(telegram.accounts) ?? {})[0]);
117
+ const token = text(telegram.botToken) ?? (text(telegram.tokenFile) ? readRegularFile(text(telegram.tokenFile)!.replace(/^~(?=\/)/, home)) : null)
118
+ ?? text(firstAccount?.botToken);
119
+ const c = candidate('telegram', 'openclaw', file, token, telegram.allowFrom ?? firstAccount?.allowFrom);
120
+ if (c) out.push(c);
121
+ }
122
+ const discord = obj(channels?.discord);
123
+ if (discord) {
124
+ const firstAccount = obj(Object.values(obj(discord.accounts) ?? {})[0]);
125
+ const c = candidate('discord', 'openclaw', file, text(discord.token) ?? text(firstAccount?.token), discord.allowFrom ?? obj(discord.dm)?.allowFrom);
126
+ if (c) out.push(c);
127
+ }
128
+ return out;
129
+ }
130
+
131
+ function fromHermes(home: string): ImportCandidate[] {
132
+ const path = join(home, '.hermes', '.env');
133
+ if (!existsSync(path)) return [];
134
+ const env = parseDotEnv(readFileSync(path, 'utf8'));
135
+ const file = tilde(path, home);
136
+ return [
137
+ candidate('telegram', 'hermes', file, text(env.TELEGRAM_BOT_TOKEN), env.TELEGRAM_ALLOWED_USERS),
138
+ candidate('discord', 'hermes', file, text(env.DISCORD_BOT_TOKEN), env.DISCORD_ALLOWED_USERS),
139
+ ].filter((c): c is ImportCandidate => c !== null);
140
+ }
141
+
142
+ function fromElanousLegacy(home: string): ImportCandidate[] {
143
+ const path = join(home, '.monad', 'config.json');
144
+ if (!existsSync(path)) return [];
145
+ const cfg = obj(parseLooseJson(readFileSync(path, 'utf8')));
146
+ const file = tilde(path, home);
147
+ return (['telegram', 'discord'] as const)
148
+ .map((platform) => candidate(platform, 'elanous-legacy', file, text(obj(cfg?.[platform])?.botToken), obj(cfg?.[platform])?.allowedUsers))
149
+ .filter((c): c is ImportCandidate => c !== null);
150
+ }
151
+
152
+ export interface DetectResult { candidates: ImportCandidate[]; unreadable: { file: string; reason: string }[] }
153
+
154
+ /** Look in every known place; a broken file is reported by name (never by content) and the rest still count. */
155
+ export function detectChannelImports(home = homedir()): DetectResult {
156
+ const candidates: ImportCandidate[] = [];
157
+ const unreadable: DetectResult['unreadable'] = [];
158
+ const readers: [string, (h: string) => ImportCandidate[]][] = [
159
+ [join(home, '.monad', 'config.json'), fromElanousLegacy],
160
+ [join(home, '.openclaw', 'openclaw.json'), fromOpenClaw],
161
+ [join(home, '.hermes', '.env'), fromHermes],
162
+ ];
163
+ for (const [path, read] of readers) {
164
+ try { candidates.push(...read(home)); }
165
+ catch (error) {
166
+ // JSON parse errors can quote the offending text — keep only the error class.
167
+ unreadable.push({ file: tilde(path, home), reason: error instanceof SyntaxError ? '형식을 읽지 못했습니다' : '읽지 못했습니다' });
168
+ }
169
+ }
170
+ return { candidates, unreadable };
171
+ }
172
+
173
+ /** One per platform: the first source in priority order; the rest are named as «다른 토큰도 있음». */
174
+ export function pickPerPlatform(candidates: readonly ImportCandidate[]): { chosen: ImportCandidate[]; others: ImportCandidate[] } {
175
+ const chosen: ImportCandidate[] = [];
176
+ const others: ImportCandidate[] = [];
177
+ for (const c of candidates) {
178
+ const first = chosen.find((x) => x.platform === c.platform);
179
+ if (!first) chosen.push(c);
180
+ else if (first.token.fingerprint() !== c.token.fingerprint()) others.push(c);
181
+ else for (const user of c.allowedUsers) if (!first.allowedUsers.includes(user)) first.allowedUsers.push(user);
182
+ }
183
+ return { chosen, others };
184
+ }
185
+
186
+ export function previewLine(c: ImportCandidate): string {
187
+ const name = c.platform === 'telegram' ? '텔레그램' : '디스코드';
188
+ const users = c.allowedUsers.length ? `허용 사용자 ${c.allowedUsers.length}명` : '허용 사용자 없음(나중에 정해야 합니다)';
189
+ const dropped = c.droppedUsers ? ` · 쓸 수 없는 항목 ${c.droppedUsers}개 제외` : '';
190
+ return `${name} ← ${SOURCE_LABEL[c.source]} (${c.file}): 토큰 ${MASK} · ${users}${dropped}`;
191
+ }
@@ -0,0 +1,111 @@
1
+ // EN13 — 감지 → 미리보기 → 동의 → 시크릿 저장 → 연결 확인(읽기 1회). 토큰 값은 화면·로그·채널 어디에도 안 나간다.
2
+ import { homedir } from 'node:os';
3
+ import { debug } from '../debug/log.js';
4
+ import { detectChannelImports, pickPerPlatform, previewLine, SOURCE_LABEL, type ImportCandidate, type ImportPlatform } from './detect.js';
5
+
6
+ export interface ChannelImportDeps {
7
+ home?: string;
8
+ out?: { log: (s: string) => void; error: (s: string) => void };
9
+ /** Asks once; returns true for yes. Absent or non-interactive → no. */
10
+ ask?: (question: string) => Promise<boolean>;
11
+ yes?: boolean;
12
+ /** Current token for that platform (to say «already the same»), or null. */
13
+ currentToken?: (platform: ImportPlatform) => string | null | Promise<string | null>;
14
+ store?: (platform: ImportPlatform, token: string, allowedUsers: string[] | undefined) => Promise<void>;
15
+ probe?: (platform: ImportPlatform, token: string) => Promise<{ ok: boolean; botName?: string }>;
16
+ }
17
+
18
+ export interface ChannelImportOutcome {
19
+ exitCode: number;
20
+ imported: { platform: ImportPlatform; source: string; connected: boolean; botName?: string }[];
21
+ }
22
+
23
+ const NAME: Record<ImportPlatform, string> = { telegram: '텔레그램', discord: '디스코드' };
24
+
25
+ async function defaultStore(platform: ImportPlatform, token: string, allowedUsers: string[] | undefined): Promise<void> {
26
+ const { storeChannelBotToken } = await import('../channel-bot-token.js');
27
+ await storeChannelBotToken(platform, token, allowedUsers);
28
+ }
29
+
30
+ async function defaultProbe(platform: ImportPlatform, token: string): Promise<{ ok: boolean; botName?: string }> {
31
+ const { probeChannelBotToken } = await import('../channel-bot-token.js');
32
+ return probeChannelBotToken(platform, token);
33
+ }
34
+
35
+ async function defaultCurrentToken(platform: ImportPlatform): Promise<string | null> {
36
+ try {
37
+ const { resolveChannelBotToken } = await import('../channel-bot-token.js');
38
+ const { getUserConfig } = await import('../user-config.js');
39
+ return resolveChannelBotToken(platform, getUserConfig())?.token ?? null;
40
+ } catch { return null; }
41
+ }
42
+
43
+ export async function runChannelImport(deps: ChannelImportDeps = {}): Promise<ChannelImportOutcome> {
44
+ const out = deps.out ?? console;
45
+ const home = deps.home ?? homedir();
46
+ const { candidates, unreadable } = detectChannelImports(home);
47
+ for (const u of unreadable) out.log(`· ${u.file}: ${u.reason} — 건너뜁니다`);
48
+ const { chosen, others } = pickPerPlatform(candidates);
49
+ debug.log('channel-import', 'detected', {
50
+ sources: candidates.map((c) => `${c.source}:${c.platform}`), chosen: chosen.length, others: others.length, unreadable: unreadable.length,
51
+ });
52
+ if (!chosen.length) {
53
+ out.log('가져올 텔레그램·디스코드 설정을 찾지 못했습니다(OpenClaw · Hermes · 이전 엘라누스). 직접 넣으려면: elanous nexus channel-bot setup telegram');
54
+ return { exitCode: 0, imported: [] };
55
+ }
56
+
57
+ const current = deps.currentToken ?? defaultCurrentToken;
58
+ const todo: ImportCandidate[] = [];
59
+ out.log('이미 쓰던 채널 설정을 찾았습니다:');
60
+ for (const c of chosen) {
61
+ const existing = await current(c.platform);
62
+ if (existing && existing === c.token.reveal()) { out.log(`· ${previewLine(c)} — 이미 같은 토큰을 쓰고 있습니다`); continue; }
63
+ out.log(`· ${previewLine(c)}${existing ? ' — ⚠️ 지금 쓰는 토큰을 바꿉니다' : ''}`);
64
+ todo.push(c);
65
+ }
66
+ for (const c of others) out.log(` (${NAME[c.platform]} 토큰이 ${SOURCE_LABEL[c.source]}에도 있습니다 — 다른 토큰이라 위의 것만 가져옵니다)`);
67
+ if (!todo.length) return { exitCode: 0, imported: [] };
68
+
69
+ const question = `가져올까요? 토큰은 엘라누스 비밀 저장소에만 넣고 화면·로그에는 남기지 않습니다. [Y/n] `;
70
+ const agreed = deps.yes || (deps.ask ? await deps.ask(question) : false);
71
+ if (!agreed) {
72
+ out.log(deps.ask || deps.yes ? '가져오지 않았습니다.' : '가져오려면: elanous nexus channel-bot import --yes');
73
+ debug.log('channel-import', 'declined', { count: todo.length });
74
+ return { exitCode: 0, imported: [] };
75
+ }
76
+
77
+ const store = deps.store ?? defaultStore;
78
+ const probe = deps.probe ?? defaultProbe;
79
+ const imported: ChannelImportOutcome['imported'] = [];
80
+ let failed = 0;
81
+ for (const c of todo) {
82
+ try {
83
+ await store(c.platform, c.token.reveal(), c.allowedUsers.length ? c.allowedUsers : undefined);
84
+ } catch (error) {
85
+ failed++;
86
+ // Store errors are our own (lock, invalid ids) — print the class only, never the message, in case it carries input.
87
+ const reason = error instanceof Error && error.message === 'invalid-allowed-users' ? '허용 사용자 형식이 맞지 않습니다' : '저장하지 못했습니다';
88
+ out.error(`✗ ${NAME[c.platform]}: ${reason}`);
89
+ debug.log('channel-import', 'store-failed', { platform: c.platform, source: c.source }, { level: 'warn' });
90
+ continue;
91
+ }
92
+ const check = await probe(c.platform, c.token.reveal()).catch(() => ({ ok: false } as { ok: boolean; botName?: string }));
93
+ imported.push({ platform: c.platform, source: c.source, connected: check.ok, ...(check.botName ? { botName: check.botName } : {}) });
94
+ debug.log('channel-import', 'stored', { platform: c.platform, source: c.source, connected: check.ok });
95
+ out.log(check.ok
96
+ ? `✓ ${NAME[c.platform]}: 저장했고 연결을 확인했습니다 — @${check.botName}`
97
+ : `△ ${NAME[c.platform]}: 저장했지만 연결 확인에 실패했습니다 — 토큰이 만료됐거나 네트워크 문제일 수 있습니다(다시 넣기: elanous nexus channel-bot setup ${c.platform})`);
98
+ }
99
+ if (imported.length) out.log('봇은 다음 넥서스 시작 때 켜집니다.');
100
+ return { exitCode: failed ? 1 : 0, imported };
101
+ }
102
+
103
+ /** One line for onboarding — null when there is nothing to import. Never reveals a value. */
104
+ export function channelImportHint(home = homedir()): string | null {
105
+ try {
106
+ const { chosen } = pickPerPlatform(detectChannelImports(home).candidates);
107
+ if (!chosen.length) return null;
108
+ const what = chosen.map((c) => `${NAME[c.platform]}(${SOURCE_LABEL[c.source]})`).join(' · ');
109
+ return `쓰던 채널 설정 찾음: ${what} — 가져오려면: elanous nexus channel-bot import`;
110
+ } catch { return null; }
111
+ }
@@ -41,6 +41,9 @@ export const TEST_SYNC_AUX_FILES: readonly string[] = [
41
41
  'llm-fallback.json', // 모델 폴백 체인
42
42
  'auth.json', // oauth 자격 (LLM provider)
43
43
  'identity.json', // self identity — 무해·자기인식 일관
44
+ // POL1 (10-01): the LLM policy (credit use · pace · grants). Without it a tree's test universe resolved «fallback»
45
+ // while production said «credits», and every harness run from a work tree went to grok.
46
+ 'policy/llm.yaml',
44
47
  ];
45
48
 
46
49
  /** 의도적 제외 (복사 금지 — 이유 명시. 목록 변경 시 반드시 사유와 함께). */
@@ -201,6 +204,7 @@ export function syncTestConfig(testDir: string, srcDir: string = prodConfigDir()
201
204
  // 목록 뒤의 `auth.json`(codex 로그인)이 복사되지 않아 격리 우주의 자식이 codex 를 못 썼다.
202
205
  // ⇒ 이미 같은 바이트면 건너뛴다(멱등) — 다르면 종전처럼 복사한다(실패는 그대로 드러난다).
203
206
  if (existsSync(to) && readFileSync(from).equals(readFileSync(to))) { chmodSync(to, 0o600); skippedIdentical.push(f); continue; }
207
+ mkdirSync(dirname(to), { recursive: true });
204
208
  copyFileSync(from, to);
205
209
  chmodSync(to, 0o600);
206
210
  copied.push(f);
@@ -9,6 +9,12 @@ function date(raw?: string): string | undefined {
9
9
  if (!Number.isFinite(parsed.getTime())) throw new Error(`invalid since: ${raw}`);
10
10
  return parsed.toISOString();
11
11
  }
12
+ /** `+6h` → now + 6 hours; otherwise a UTC ISO timestamp (validated by the ledger). */
13
+ function dueAt(raw: string): string {
14
+ const hours = /^\+(\d+(?:\.\d+)?)h$/.exec(raw.trim());
15
+ if (hours) return new Date(Date.now() + Number(hours[1]) * 3600000).toISOString();
16
+ return raw.trim();
17
+ }
12
18
  const kst = (at: string) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Seoul', dateStyle: 'short', timeStyle: 'medium' }).format(new Date(at)) + ' KST';
13
19
  const version = (e: DecisionEntry['version']) => e ? `released=${e.released ?? '-'} · dev=${e.dev ?? '-'}${e.codename ? ` «${e.codename}»` : ''}` : '판 미상';
14
20
  function agent(o?: string): string {
@@ -30,6 +36,7 @@ export function formatDecisionDetail(e: DecisionEntry): string {
30
36
  '선택지 | 결과', ...(e.options.length ? e.options.map(o => `${o.key}: ${o.label} | ${o.consequence}`) : ['원문에 선택지 없음 · 확인 필요']),
31
37
  'skipped' in e.recommendation ? `권고 생략: ${e.recommendation.reason}` : `권고: ${e.recommendation.option} — ${e.recommendation.why}`,
32
38
  `올림: ${e.raisedAt ? kst(e.raisedAt) : '시각 미상'} · ${e.raisedBy.agent} · ${version(e.version)}`,
39
+ ...(e.dueAt ? [`기한: ${kst(e.dueAt)}`] : []),
33
40
  ...(e.importedAt ? [`가져옴: ${kst(e.importedAt)}`] : []),
34
41
  ...(e.status === 'decided' ? [`결정: ${e.decidedAt ? kst(e.decidedAt) : '시각 미상'} · ${e.decidedBy?.kind === 'auto' ? `AUTO ${e.decidedBy.agent} (${e.decidedBy.delegation})` : e.decidedBy?.kind === 'human' ? 'human' : '주체 미상'} · ${e.choice ?? '선택 키 미상'} · ${e.versionAtDecision ? version(e.versionAtDecision) : '결정 당시 판 미상'}`,
35
42
  ...(e.note ? [`메모: ${e.note}`] : [])] : []),
@@ -48,14 +55,17 @@ export function registerDecisionsCommands(program: Command, config: DecisionLedg
48
55
  .requiredOption('--option <key=label:consequence>', '선택지 (두 번 이상)', repeat, [] as string[])
49
56
  .option('--recommend <key>').option('--why <text>').option('--skip-recommend <reason>')
50
57
  .option('--track <track>').option('--agent <agent>').option('--session <id>')
51
- .option('--ref <url>', '참조 (반복)', repeat, [] as string[]).option('--json')
52
- .action((o: { title: string; category: DecisionCategory; s: string; c: string; q?: string; a?: string; option: string[]; recommend?: string; why?: string; skipRecommend?: string; track?: DecisionTrack; agent?: string; session?: string; ref: string[]; json?: boolean }) => fail(() => {
58
+ .option('--ref <url>', '참조 (반복)', repeat, [] as string[])
59
+ .option('--due <when>', '기한 — UTC ISO(2026-10-04T09:00:00Z) 또는 +Nh(지금부터 N시간) · 기한 2시간 전 텔레그램·디스코드로 다시 알린다')
60
+ .option('--json')
61
+ .action((o: { due?: string; title: string; category: DecisionCategory; s: string; c: string; q?: string; a?: string; option: string[]; recommend?: string; why?: string; skipRecommend?: string; track?: DecisionTrack; agent?: string; session?: string; ref: string[]; json?: boolean }) => fail(() => {
53
62
  if (o.skipRecommend !== undefined && (o.recommend !== undefined || o.why !== undefined)) throw new Error('choose recommendation or skip, not both');
54
63
  if (o.skipRecommend === undefined && (!o.recommend || !o.why)) throw new Error('--recommend and --why required, or --skip-recommend <reason>');
55
64
  const options: DecisionOption[] = o.option.map(parseOption);
56
65
  const entry = new DecisionLedger(config).raise({ title: o.title, category: o.category, scqa: { s: o.s, c: o.c, ...(o.q ? { q: o.q } : {}), ...(o.a ? { a: o.a } : {}) }, options,
57
66
  recommendation: o.skipRecommend !== undefined ? { skipped: true, reason: o.skipRecommend } : { option: o.recommend!, why: o.why! },
58
- raisedBy: { agent: agent(o.agent), ...(o.track ? { track: o.track } : {}), ...(o.session ? { session: o.session } : {}) }, ...(o.ref.length ? { refs: o.ref } : {}) });
67
+ raisedBy: { agent: agent(o.agent), ...(o.track ? { track: o.track } : {}), ...(o.session ? { session: o.session } : {}) }, ...(o.ref.length ? { refs: o.ref } : {}),
68
+ ...(o.due ? { dueAt: dueAt(o.due) } : {}) });
59
69
  emit(entry, o.json, `올림: ${formatDecisionRow(entry)}`);
60
70
  }));
61
71
  root.command('list').description('결정 목록 (기본 열린 것)')