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,173 @@
1
+ // SK2 — `elanous skills repair`: 색인이 못 읽은 스킬을 보여 주고, 확인([y/N])한 것만 그 자리에서 고친다.
2
+ import type { Command } from 'commander';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { getSkillIndex, resetSkillIndex, skillIndexProblems } from '../skills/index.js';
5
+ import { applySkillRepair, planSkillRepair, skillRepairNotice } from '../skills/repair.js';
6
+ import { CONNECT_AGENTS, connectPrompt, connectSkillRoot, planConnect, withConnectedSource, withoutSources, type ConnectAgent } from '../skills/connect.js';
7
+ import { debug } from '../debug/log.js';
8
+ import { resolve } from 'node:path';
9
+ import { claudePluginPrompt, isClaudePluginDir, mcpEnableHint, planClaudePlugin, readClaudePlugin } from '../skills/connect-claude-plugin.js';
10
+
11
+ async function ask(question: string, defaultYes = false): Promise<boolean> {
12
+ if (!process.stdin.isTTY) return false;
13
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
14
+ try {
15
+ const answer = (await rl.question(question)).trim();
16
+ return answer === '' ? defaultYes : /^(y|yes|예|네)$/i.test(answer);
17
+ }
18
+ finally { rl.close(); }
19
+ }
20
+
21
+ /** EN9 — `elanous connect <claude|codex>`: find the skills that agent already uses and register their folder (no copy). */
22
+ export function registerConnectCommand(program: Command): void {
23
+ program.command('connect <agent>')
24
+ .description(`Use the skills you already have in another agent (${CONNECT_AGENTS.join(' | ')}) or a Claude Code plugin folder — registers in place, never copies; plugin MCP servers are added switched off`)
25
+ .option('--yes', 'Register without asking')
26
+ .option('--json', 'Print the plan as JSON and change nothing')
27
+ .option('--undo', 'Stop using that folder (the files stay where they are)')
28
+ .action(async (agentArg: string, opts: { yes?: boolean; json?: boolean; undo?: boolean }) => {
29
+ const agent = agentArg.toLowerCase() as ConnectAgent;
30
+ if (!CONNECT_AGENTS.includes(agent) && isClaudePluginDir(resolve(agentArg))) {
31
+ await connectClaudePlugin(resolve(agentArg), opts);
32
+ return;
33
+ }
34
+ if (!CONNECT_AGENTS.includes(agent)) {
35
+ console.error(`지원하는 에이전트: ${CONNECT_AGENTS.join(', ')} · 또는 Claude 플러그인 폴더(.claude-plugin/plugin.json) (받은 값: ${agentArg})`);
36
+ process.exitCode = 2;
37
+ return;
38
+ }
39
+ const { defaultSkillDirs, getUserConfig, saveUserConfig } = await import('../user-config.js');
40
+ const cfg = getUserConfig();
41
+ if (opts.undo) {
42
+ const root = connectSkillRoot(agent);
43
+ const sources = withoutSources(cfg.skills.sources, [root]);
44
+ if (sources.length === (cfg.skills.sources ?? []).length) { console.log(`연결된 적이 없습니다(${root}).`); return; }
45
+ saveUserConfig({ ...cfg, skills: { ...cfg.skills, sources } });
46
+ resetSkillIndex();
47
+ debug.log('skills.connect', 'undone', { agent });
48
+ console.log(`연결을 끊었습니다. 파일은 그대로 있습니다(${root}).`);
49
+ return;
50
+ }
51
+ const plan = planConnect(agent, defaultSkillDirs(cfg));
52
+ debug.log('skills.connect', 'plan', { agent, state: plan.state, count: 'skills' in plan ? plan.skills.length : 0 });
53
+ if (opts.json) { console.log(JSON.stringify(plan, null, 2)); return; }
54
+ const prompt = connectPrompt(plan);
55
+ if (plan.state !== 'new') { console.log(prompt); return; }
56
+ if (!opts.yes) {
57
+ if (!process.stdin.isTTY) { console.log(`${prompt.split('\n')[0]}\n가져오려면: elanous connect ${agent} --yes`); return; }
58
+ if (!(await ask(prompt, true))) { console.log('가져오지 않았습니다. 나중에: elanous connect ' + agent); return; }
59
+ } else {
60
+ console.log(prompt.split('\n')[0]);
61
+ }
62
+ const next = { ...cfg, skills: { ...cfg.skills, sources: withConnectedSource(cfg.skills.sources, plan.root, agent) } };
63
+ saveUserConfig(next);
64
+ resetSkillIndex();
65
+ const index = getSkillIndex(defaultSkillDirs(next));
66
+ const problems = skillIndexProblems().filter((p) => p.dir === plan.root);
67
+ const usable = plan.skills.length - problems.length;
68
+ debug.log('skills.connect', 'registered', { agent, found: plan.skills.length, problems: problems.length, indexed: index.length });
69
+ console.log(`연결했습니다 — ${usable}개를 바로 쓸 수 있습니다. 끊으려면: elanous connect ${agent} --undo`);
70
+ const notice = skillRepairNotice(problems);
71
+ if (notice) console.log(notice);
72
+ });
73
+ }
74
+
75
+ export function registerSkillsCommands(program: Command): void {
76
+ const skills = program.command('skills').description('Check and repair installed skills');
77
+ skills.command('sources')
78
+ .description('List every skill folder elanous reads, in order (preset · shared · connected · package)')
79
+ .option('--json', 'JSON output')
80
+ .action(async (opts: { json?: boolean }) => {
81
+ const { resolveSkillSources } = await import('../user-config.js');
82
+ const { existsSync } = await import('node:fs');
83
+ const sources = resolveSkillSources().map((source) => ({ ...source, exists: existsSync(source.path) }));
84
+ debug.log('skills.sources', 'listed', { count: sources.length, kinds: sources.map((source) => source.kind) });
85
+ if (opts.json) { console.log(JSON.stringify(sources, null, 2)); return; }
86
+ const width = Math.max(10, ...sources.map((source) => source.id.length));
87
+ for (const source of sources) {
88
+ console.log(`${source.enabled ? '●' : '○'} ${source.id.padEnd(width)} ${source.kind.padEnd(9)} ${source.path}${source.exists ? '' : ' (없음)'}`);
89
+ }
90
+ console.log('묶음 스킬(패키지 skills/)은 늘 마지막에 읽습니다. 다른 에이전트 스킬 더하기: elanous connect claude|codex|<플러그인 폴더>');
91
+ });
92
+ skills.command('repair')
93
+ .description('List skills that could not be read and repair the ones that can be fixed (asks first)')
94
+ .option('--yes', 'Repair every fixable skill without asking')
95
+ .action(async (opts: { yes?: boolean }) => {
96
+ getSkillIndex();
97
+ const problems = skillIndexProblems();
98
+ if (!problems.length) { console.log('모든 스킬을 읽었습니다. 고칠 것이 없습니다.'); return; }
99
+ console.log(`스킬 ${problems.length}개를 읽지 못했습니다.`);
100
+ // ER1 — operations hears about it too (consented · scrubbed).
101
+ try {
102
+ const { reportError } = await import('../error-report/report.js');
103
+ for (const p of problems.slice(0, 3)) await reportError({ code: 'skill-index', message: `${p.name}: ${p.code ?? ''} ${p.error}`.trim(), surface: 'cli' });
104
+ } catch { /* reporting is advisory */ }
105
+ let fixed = 0;
106
+ for (const problem of problems) {
107
+ const plan = planSkillRepair(problem);
108
+ console.log(`\n· ${problem.name} — ${problem.error.split('\n')[0]}`);
109
+ if (plan.kind === 'permission' || plan.kind === 'manual') { console.log(` ${plan.hint}`); continue; }
110
+ const what = plan.kind === 'fixable-permission' ? '읽기 권한을 켭니다' : `${plan.keys.join(', ')} 를 한 줄로 고칩니다(원본은 .bak 으로 남김)`;
111
+ if (!opts.yes && !(await ask(` ${what}. 고칠까요? [y/N] `))) { console.log(' 건너뜀'); continue; }
112
+ const result = applySkillRepair(plan);
113
+ if (result.ok) { fixed += 1; console.log(` 고쳤습니다 — ${result.path}`); }
114
+ else console.log(` 고치지 못했습니다(${result.reason}) — 원래대로 되돌렸습니다.`);
115
+ }
116
+ console.log(`\n${fixed}개를 고쳤습니다.`);
117
+ if (fixed < problems.length) process.exitCode = 1;
118
+ });
119
+ }
120
+
121
+ /** EN8 — a Claude Code plugin folder: skills join `skills.sources[]` (kind connected) in place, `.mcp.json` servers join `mcp.servers[]` switched off. */
122
+ async function connectClaudePlugin(dir: string, opts: { yes?: boolean; json?: boolean; undo?: boolean }): Promise<void> {
123
+ const { defaultSkillDirs, getUserConfig, saveUserConfig } = await import('../user-config.js');
124
+ const cfg = getUserConfig();
125
+ let plugin;
126
+ try { plugin = readClaudePlugin(dir); }
127
+ catch (error) {
128
+ console.error(`플러그인 정보를 읽지 못했습니다(.claude-plugin/plugin.json): ${error instanceof Error ? error.message : String(error)}`);
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ const rawMcp = (cfg.raw.mcp && typeof cfg.raw.mcp === 'object' && !Array.isArray(cfg.raw.mcp) ? cfg.raw.mcp : {}) as Record<string, unknown>;
133
+ const rawServers = (Array.isArray(rawMcp.servers) ? rawMcp.servers : []) as Array<Record<string, unknown>>;
134
+ const writeServers = (servers: Array<Record<string, unknown>>) => ({ ...cfg.raw, mcp: { ...rawMcp, servers } });
135
+ if (opts.undo) {
136
+ const ids = new Set(plugin.mcp.map((server) => server.id));
137
+ const sources = withoutSources(cfg.skills.sources, plugin.skillRoots);
138
+ const servers = rawServers.filter((row) => !ids.has(row.id as string));
139
+ if (sources.length === (cfg.skills.sources ?? []).length && servers.length === rawServers.length) { console.log(`연결된 적이 없습니다(${plugin.name}).`); return; }
140
+ saveUserConfig({ ...cfg, raw: writeServers(servers), skills: { ...cfg.skills, sources } });
141
+ resetSkillIndex();
142
+ debug.log('skills.connect', 'undone', { plugin: plugin.name });
143
+ console.log(`«${plugin.name}» 연결을 끊었습니다. 파일은 그대로 있습니다(${plugin.root}).`);
144
+ return;
145
+ }
146
+ const plan = planClaudePlugin(plugin, defaultSkillDirs(cfg), rawServers.map((row) => row.id as string));
147
+ debug.log('skills.connect', 'plan', { plugin: plugin.name, state: plan.state, skills: plugin.skills.length, mcp: plugin.mcp.length, skipped: plugin.skipped.length });
148
+ if (opts.json) { console.log(JSON.stringify(plan, null, 2)); return; }
149
+ const prompt = claudePluginPrompt(plan);
150
+ if (plan.state !== 'new') { console.log(prompt); return; }
151
+ const firstLines = prompt.slice(0, prompt.lastIndexOf('\n'));
152
+ if (!opts.yes) {
153
+ if (!process.stdin.isTTY) { console.log(`${firstLines}\n가져오려면: elanous connect ${plugin.root} --yes`); return; }
154
+ if (!(await ask(prompt, true))) { console.log(`가져오지 않았습니다. 나중에: elanous connect ${plugin.root}`); return; }
155
+ } else {
156
+ console.log(firstLines);
157
+ }
158
+ const sources = plan.newSkillRoots.reduce((list, root, i) => withConnectedSource(list, root, `plugin:${plugin.name}${i ? `:${i + 1}` : ''}`), cfg.skills.sources ?? []);
159
+ const servers = [...rawServers, ...plan.newMcp];
160
+ saveUserConfig({ ...cfg, raw: writeServers(servers), skills: { ...cfg.skills, sources } });
161
+ resetSkillIndex();
162
+ const next = { ...cfg, skills: { ...cfg.skills, sources } };
163
+ getSkillIndex(defaultSkillDirs(next));
164
+ const problems = skillIndexProblems().filter((p) => plugin.skillRoots.includes(p.dir));
165
+ debug.log('skills.connect', 'registered', { plugin: plugin.name, skills: plugin.skills.length, problems: problems.length, mcp: plan.newMcp.length });
166
+ console.log(`연결했습니다 — 스킬 ${plugin.skills.length - problems.length}개를 바로 쓸 수 있습니다. 끊으려면: elanous connect ${plugin.root} --undo`);
167
+ const notice = skillRepairNotice(problems);
168
+ if (notice) console.log(notice);
169
+ if (plan.newMcp.length) {
170
+ console.log(`MCP 서버 ${plan.newMcp.length}개를 꺼진 채로 등록했습니다. 믿을 수 있는 것만 켜 주세요(켠 뒤 데몬 재시작):`);
171
+ for (const line of mcpEnableHint(plan.newMcp, servers)) console.log(line);
172
+ }
173
+ }
@@ -5,6 +5,8 @@ import type { DetectedProvider } from '../llm/provider-detect.js';
5
5
  import { resolveUsableLlm, type UsableLlm } from '../llm/usable-llm.js';
6
6
  import { resolveDaemonEndpoint, type DaemonEndpoint, type ResolveDaemonEndpointOpts } from '../nexus/daemon-endpoint.js';
7
7
  import { runBgLaunch } from './bg-launch.js';
8
+ import { browserUnavailableReason } from '../oauth/browser-availability.js';
9
+ import { debug } from '../debug/log.js';
8
10
 
9
11
  export interface StartOptions {
10
12
  gui?: boolean;
@@ -63,6 +65,8 @@ export interface StartDeps {
63
65
  openTui?: (json: boolean) => Promise<number>;
64
66
  sleep?: (ms: number) => Promise<void>;
65
67
  output?: (line: string) => void;
68
+ browserEnv?: NodeJS.ProcessEnv;
69
+ browserPlatform?: NodeJS.Platform;
66
70
  /** Test seam — daemon address. `null` means this universe has no daemon (do not guess a port).
67
71
  * Called again after a launch so the opened address is the daemon that was just started. */
68
72
  resolveEndpoint?: (opts?: ResolveDaemonEndpointOpts) => DaemonEndpoint | null;
@@ -193,10 +197,15 @@ export async function runStart(options: StartOptions = {}, deps: StartDeps = {})
193
197
  if (!openUrl) {
194
198
  record('open-gui', 'failed', 'Daemon address unknown');
195
199
  } else {
196
- try {
197
- if (await (deps.openGui ?? defaultOpenGui)(openUrl)) surface = 'gui';
198
- } catch { /* A browser is optional; retain the URL for manual opening. */ }
199
- record('open-gui', surface === 'gui' ? 'done' : 'failed', surface === 'gui' ? `Opened ${openUrl}` : `Open manually: ${openUrl}`);
200
+ const why = browserUnavailableReason(deps.browserEnv ?? process.env, deps.browserPlatform ?? process.platform);
201
+ if (why) {
202
+ debug.log('browser.open', 'skipped', { reason: why.includes('ssh') ? 'ssh' : 'no-display' });
203
+ } else {
204
+ try {
205
+ if (await (deps.openGui ?? defaultOpenGui)(openUrl)) surface = 'gui';
206
+ } catch { /* A browser is optional; retain the URL for manual opening. */ }
207
+ }
208
+ record('open-gui', surface === 'gui' ? 'done' : why ? 'skipped' : 'failed', surface === 'gui' ? `Opened ${openUrl}` : `Open manually: ${openUrl}${why ? ` — ${why}` : ''}`);
200
209
  }
201
210
  record('open-tui', 'skipped', 'GUI selected');
202
211
  } else {
@@ -82,12 +82,14 @@ export async function pickCodexAuthMode(io: WizardIO, deps: CodexAuthModeDeps =
82
82
  if (existing || codexCli) {
83
83
  if (existing) {
84
84
  const exp = existing.tokens.expiresAt;
85
- const when = exp != null
86
- ? (Date.now() > exp ? 'EXPIRED' : `${Math.round((exp - Date.now()) / 60000)}min left`)
87
- : 'no expiry tracked';
88
- io.print(` OAuth tokens already on file (${when}).`);
85
+ const left = exp != null ? exp - Date.now() : null;
86
+ const when = left == null ? '만료 시각 모름'
87
+ : left <= 0 ? '만료됨'
88
+ : left >= 86_400_000 ? `약 ${Math.round(left / 86_400_000)}일 남음`
89
+ : left >= 3_600_000 ? `약 ${Math.round(left / 3_600_000)}시간 남음` : `약 ${Math.max(1, Math.round(left / 60_000))}분 남음`;
90
+ io.print(` 이미 로그인돼 있습니다(${when}).`);
89
91
  } else {
90
- io.print(' Found your ChatGPT/Codex login from the codex CLI (~/.codex/auth.json).');
92
+ io.print(' Codex CLI 에 있는 ChatGPT/Codex 로그인을 찾았습니다(~/.codex/auth.json).');
91
93
  }
92
94
  // Sprint 11 — chooseFrom Yes/No so fullScreenIO renders the arrow
93
95
  // picker (with 'y'/'n' + 한글 자모 quick-pick from PR #986 + #988)
@@ -96,10 +98,10 @@ export async function pickCodexAuthMode(io: WizardIO, deps: CodexAuthModeDeps =
96
98
  // rotation on an unnecessary refresh).
97
99
  const keep = await chooseFrom<boolean>(
98
100
  io,
99
- 'Keep using existing OAuth tokens?',
101
+ '지금 로그인을 그대로 쓸까요?',
100
102
  [
101
- { key: 'y', label: 'Yes — keep them', value: true },
102
- { key: 'n', label: 'No — re-run device-code flow', value: false },
103
+ { key: 'y', label: '예 — 그대로 씁니다', value: true },
104
+ { key: 'n', label: '아니오 — 다시 로그인합니다', value: false },
103
105
  ],
104
106
  { defaultIndex: 0 },
105
107
  undefined,
@@ -265,18 +267,18 @@ async function runCodexSetupImpl(
265
267
  let apiKey: string | undefined = initial.llm.apiKey;
266
268
 
267
269
  if (authMode === 'oauth') {
268
- io.print(' Launching device-code flow…');
270
+ io.print(' 코드로 로그인합니다…');
269
271
  try {
270
272
  await loginWithCodex({
271
273
  onProgress: (p) => {
272
274
  if (p.type === 'user_code') {
273
275
  io.print('');
274
- io.print(` 1) Open in any browser: ${p.loginUrl}`);
275
- io.print(` 2) Enter code: ${p.userCode}`);
276
+ io.print(` 1) 아무 브라우저에서 열기: ${p.loginUrl}`);
277
+ io.print(` 2) 코드 입력: ${p.userCode}`);
276
278
  io.print('');
277
- io.print(' Waiting for sign-in…');
279
+ io.print(' 로그인을 기다리는 중…');
278
280
  }
279
- if (p.type === 'saved') io.print(' Signed in. Tokens saved.');
281
+ if (p.type === 'saved') io.print(' 로그인했습니다. 저장했습니다.');
280
282
  },
281
283
  fetchImpl: opts.fetchImpl,
282
284
  sleepImpl: opts.sleepImpl,
@@ -725,7 +725,8 @@ import { SELF_IMPLEMENT_TOOL_NAMES } from '../boot/daemon-tools/self-implement-n
725
725
  import { FOLD_LIMITS, type FoldMode } from '../log-entry.js';
726
726
  import { renderToolCallEvent, renderToolResultVariants } from '../chat/tool-render/index.js';
727
727
  import { FoldStack } from '../fold-stack.js';
728
- import { getSkillIndex, reloadSkillIndex, applySkillFilter } from '../skills/index.js';
728
+ import { getSkillIndex, reloadSkillIndex, applySkillFilter, skillIndexProblems } from '../skills/index.js';
729
+ import { skillRepairNotice } from '../skills/repair.js';
729
730
  import type { DetectResult } from '../skills/router.js';
730
731
  import { detectUrlRoute } from '../skills/url-router.js';
731
732
  import {
@@ -7190,6 +7191,21 @@ Mode- and sync-specific instructions are injected per-turn when relevant — do
7190
7191
  // and after each user-initiated skill cursor change.
7191
7192
  const skillView: SkillViewState = createSkillViewState(LOCAL_SKILLS_DIR);
7192
7193
  try { refreshSkills(skillView); refreshSkillFiles(skillView); } catch { /* missing root fine */ }
7194
+ // SK2 — a skill the index could not read is a one-line notice with the repair command, never a crash.
7195
+ try {
7196
+ getSkillIndex();
7197
+ const problems = skillIndexProblems();
7198
+ const notice = skillRepairNotice(problems);
7199
+ if (notice) chatLines.push(C.info(notice));
7200
+ // ER1 — tell operations too (consented · scrubbed · async); a kept unsent report is retried once a day.
7201
+ void import('../error-report/report.js').then(async ({ reportError, retryPendingErrorReport }) => {
7202
+ await retryPendingErrorReport();
7203
+ for (const p of problems.slice(0, 3)) {
7204
+ const outcome = await reportError({ code: 'skill-index', message: `${p.name}: ${p.code ?? ''} ${p.error}`.trim(), surface: 'tui' });
7205
+ if (outcome.sent && outcome.reportId) { chatLines.push(C.muted(` 운영팀에 알렸습니다 (보고 번호 ${outcome.reportId})`)); }
7206
+ }
7207
+ }).catch(() => { /* reporting never breaks the dashboard */ });
7208
+ } catch { /* the notice is advisory */ }
7193
7209
 
7194
7210
  // Scratch visibility (Phase S5). When closed, the workingDir
7195
7211
  // layouts drop the scratch cell so the remaining panes expand
@@ -1370,8 +1370,8 @@ export function buildDashboardSlashRegistry(): SlashCommandRegistry<DashboardSla
1370
1370
  ctx.attachmentRowMap.clear();
1371
1371
  ctx.clearLogSearch();
1372
1372
  ctx.clearLogFilter();
1373
- ctx.pushDebugLine(ctx.muted('Status cleared'));
1374
- ctx.chatLines.push(ctx.muted('Status cleared'));
1373
+ ctx.pushDebugLine(ctx.muted('Chat log cleared'));
1374
+ ctx.chatLines.push(ctx.muted('Chat log cleared'));
1375
1375
  ctx.setChatScrollOffset(-1);
1376
1376
  });
1377
1377
 
@@ -3,7 +3,9 @@ import { visibleWidth } from '../../tui.js';
3
3
 
4
4
  const ESSENTIAL_KEYS = [
5
5
  ['Enter', 'Send message'],
6
- ['Esc', 'Clear input / close help modal'],
6
+ // U1 · 2026-10-02 — stopping and queueing while an answer streams were missing from help (same line count:
7
+ // the modal has a row budget and falls back to the chat log when it is exceeded).
8
+ ['Esc', 'Stop the answer (Esc again = rewind) · clear input / close help'],
7
9
  ['Ctrl+U', 'Clear text before cursor'],
8
10
  ['Ctrl+W', 'Delete previous word'],
9
11
  ['Ctrl+← / →', 'Move by word'],
@@ -11,7 +13,7 @@ const ESSENTIAL_KEYS = [
11
13
  ['Home / End', 'Move to start / end of line'],
12
14
  ['Ctrl+A / E', 'Move to start / end of line'],
13
15
  ['PgUp / PgDn', 'Scroll chat log'],
14
- ['Ctrl+↑', 'Recall sent line while streaming'],
16
+ ['Ctrl+↑', 'Recall a queued line (Enter while answering queues it)'],
15
17
  ] as const;
16
18
 
17
19
  function fitLine(text: string, width: number): string {
@@ -0,0 +1,291 @@
1
+ // DEC-TG(대표 10-02 07:1x) — «밖에 있어도 주요 결정을 텔레그램·디스코드로 다 끝낸다».
2
+ // `elanous decisions raise` 로 올라온 결정 → 대표 DM 카드(SCQA 요약 ⊕ 선택지 버튼 ⊕ 메모 달기) → 누르면 CLI `decide` 와 같은
3
+ // 원장 메서드로 기록 → 올린 자리에 회신 → 카드는 «결정됨: A · 시각»으로 바뀐다.
4
+ // ⛔ 보안: 버튼은 대표 id 만 받는다(그 밖은 무시 ⊕ 관측). 되돌릴 수 없는 범주(결제·외부 게시·보안·비가역)는 «되돌릴 수 없음» ⊕ 두 번 확인.
5
+ // 플랫폼(텔레그램·디스코드)은 `CardTransport` 하나만 구현한다 — 판단은 전부 여기 있다.
6
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { debug } from '../debug/log.js';
9
+ import { DecisionLedger, type DecisionEntry } from './decision-ledger.js';
10
+
11
+ export type CardPlatform = 'telegram' | 'discord';
12
+ export interface CardButton { label: string; data: string }
13
+ export interface CardView { text: string; buttons: CardButton[][] }
14
+ export interface CardRef { chat: string; message: string }
15
+
16
+ /** What a platform must provide. Every method is best-effort; failures are observed, never thrown to the poller. */
17
+ export interface CardTransport {
18
+ platform: CardPlatform;
19
+ /** Owner DM destinations (Telegram chat id = user id for a DM; Discord DM channel id). */
20
+ ownerChats(): Promise<string[]>;
21
+ send(chat: string, view: CardView): Promise<CardRef | null>;
22
+ edit(ref: CardRef, view: CardView): Promise<void>;
23
+ /** Plain text (reminders, list replies). */
24
+ notify(chat: string, text: string): Promise<void>;
25
+ }
26
+
27
+ const IRREVERSIBLE = new Set(['money', 'publish', 'security', 'irreversible', 'secret']);
28
+ export const DECISION_DATA_PREFIX = 'dec:';
29
+ const REMIND_BEFORE_MS = 2 * 3600_000;
30
+
31
+ export function isIrreversible(entry: Pick<DecisionEntry, 'category'>): boolean {
32
+ return IRREVERSIBLE.has(entry.category);
33
+ }
34
+
35
+ const kst = (at: string) => new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(at));
36
+
37
+ /** The card body: title, SCQA in four short lines, options with consequences, recommendation, deadline. */
38
+ export function renderCardText(e: DecisionEntry, extra: { note?: string; confirm?: string } = {}): string {
39
+ const lines = [
40
+ `🗳 결정 요청 ${e.id}${isIrreversible(e) ? ' · ⚠️ 되돌릴 수 없음' : ''}`,
41
+ `«${e.title}» · 올린 자리: ${e.raisedBy.agent}${e.raisedBy.track ? `(${e.raisedBy.track})` : ''}`,
42
+ '',
43
+ `상황: ${e.scqa.s}`,
44
+ `문제: ${e.scqa.c}`,
45
+ ...(e.scqa.q ? [`질문: ${e.scqa.q}`] : []),
46
+ ...(e.scqa.a ? [`제안: ${e.scqa.a}`] : []),
47
+ '',
48
+ ...e.options.map((o) => `${o.key.toUpperCase()}) ${o.label} — ${o.consequence}`),
49
+ 'skipped' in e.recommendation ? `권고 없음: ${e.recommendation.reason}` : `권고: ${e.recommendation.option.toUpperCase()} — ${e.recommendation.why}`,
50
+ ...(e.dueAt ? [`기한: ${kst(e.dueAt)}`] : []),
51
+ ...(extra.note ? ['', `📝 메모: ${extra.note}`] : []),
52
+ ];
53
+ if (extra.confirm) {
54
+ const option = e.options.find((o) => o.key === extra.confirm);
55
+ lines.push('', `⚠️ ${extra.confirm.toUpperCase()}) ${option?.label ?? ''} — 되돌릴 수 없습니다. 정말 이것으로 정할까요?`);
56
+ }
57
+ return lines.join('\n');
58
+ }
59
+
60
+ export function renderCard(e: DecisionEntry, extra: { note?: string; confirm?: string } = {}): CardView {
61
+ const text = renderCardText(e, extra);
62
+ if (e.status === 'decided') {
63
+ const option = e.options.find((o) => o.key === e.choice);
64
+ return { text: `${text}\n\n✅ 결정됨: ${e.choice?.toUpperCase() ?? '?'}) ${option?.label ?? ''} · ${e.decidedAt ? kst(e.decidedAt) : ''}${e.note ? `\n📝 ${e.note}` : ''}`, buttons: [] };
65
+ }
66
+ if (e.status === 'withdrawn') return { text: `${text}\n\n↩️ 철회됨${e.withdrawReason ? `: ${e.withdrawReason}` : ''}`, buttons: [] };
67
+ if (extra.confirm) {
68
+ return { text, buttons: [[
69
+ { label: `✅ 예, ${extra.confirm.toUpperCase()} 로 정한다`, data: `${DECISION_DATA_PREFIX}${e.id}:${extra.confirm}:ok` },
70
+ { label: '취소', data: `${DECISION_DATA_PREFIX}${e.id}:-` },
71
+ ]] };
72
+ }
73
+ const choices = e.options.map((o) => ({ label: Array.from(`${o.key.toUpperCase()}) ${o.label}`).slice(0, 60).join(''), data: `${DECISION_DATA_PREFIX}${e.id}:${o.key}` }));
74
+ const rows: CardButton[][] = [];
75
+ for (let i = 0; i < choices.length; i += 2) rows.push(choices.slice(i, i + 2));
76
+ rows.push([{ label: '📝 메모 달기', data: `${DECISION_DATA_PREFIX}${e.id}:memo` }]);
77
+ return { text, buttons: rows };
78
+ }
79
+
80
+ export type ParsedTap = { id: string; action: 'choose'; key: string; confirmed: boolean } | { id: string; action: 'memo' } | { id: string; action: 'cancel' };
81
+
82
+ export function parseTap(data: string): ParsedTap | null {
83
+ const m = /^dec:(D-\d{8}-\d+):([a-z]|memo|-)(:ok)?$/.exec(data);
84
+ if (!m) return null;
85
+ if (m[2] === 'memo') return { id: m[1]!, action: 'memo' };
86
+ if (m[2] === '-') return { id: m[1]!, action: 'cancel' };
87
+ return { id: m[1]!, action: 'choose', key: m[2]!, confirmed: Boolean(m[3]) };
88
+ }
89
+
90
+ interface CardState {
91
+ /** First start of this platform's service — decisions raised before it are not pushed (no burst of old items at
92
+ * deploy); they stay reachable through `/decisions`. */
93
+ since?: string;
94
+ cards: Record<string, { refs: CardRef[]; closed?: boolean; remindedAt?: string; note?: string }>;
95
+ }
96
+
97
+ export interface DecisionCardServiceOptions {
98
+ transport: CardTransport;
99
+ /** Owner user ids for this platform — taps from anyone else are refused and observed. */
100
+ ownerIds: readonly string[];
101
+ ledger?: DecisionLedger;
102
+ /** Where this platform keeps which card it sent (one file per platform — the two bots may run in different processes). */
103
+ statePath?: string;
104
+ now?: () => Date;
105
+ /** Tell the seat that raised it. Default: none (observed only). */
106
+ replyToRaiser?: (entry: DecisionEntry, via: CardPlatform) => Promise<void>;
107
+ }
108
+
109
+ export type TapOutcome =
110
+ | { kind: 'refused' } | { kind: 'ignored' } | { kind: 'unknown' }
111
+ | { kind: 'confirm'; view: CardView; toast: string }
112
+ | { kind: 'decided'; view: CardView; toast: string }
113
+ | { kind: 'cancelled'; view: CardView; toast: string }
114
+ | { kind: 'memo-requested'; toast: string }
115
+ | { kind: 'closed'; view: CardView; toast: string };
116
+
117
+ /** One per platform bot. `tick()` sends new cards, closes decided ones, sends reminders; `tap()` handles a button. */
118
+ export class DecisionCardService {
119
+ private readonly ledger: DecisionLedger;
120
+ private readonly statePath: string;
121
+ private readonly now: () => Date;
122
+ constructor(private readonly opts: DecisionCardServiceOptions) {
123
+ this.ledger = opts.ledger ?? new DecisionLedger();
124
+ this.statePath = opts.statePath ?? join(this.ledger.path, '..', `cards-${opts.transport.platform}.json`);
125
+ this.now = opts.now ?? (() => new Date());
126
+ }
127
+
128
+ private load(): CardState {
129
+ try { return JSON.parse(readFileSync(this.statePath, 'utf8')) as CardState; } catch { return { cards: {} }; }
130
+ }
131
+ private save(state: CardState): void {
132
+ mkdirSync(join(this.statePath, '..'), { recursive: true });
133
+ const tmp = `${this.statePath}.${process.pid}.tmp`;
134
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
135
+ renameSync(tmp, this.statePath);
136
+ }
137
+
138
+ isOwner(userId: string): boolean { return this.opts.ownerIds.includes(userId); }
139
+
140
+ /** Send cards for new open decisions · close cards decided elsewhere · remind two hours before a deadline. */
141
+ async tick(): Promise<{ sent: number; closed: number; reminded: number }> {
142
+ const platform = this.opts.transport.platform;
143
+ const state = this.load();
144
+ let sent = 0; let closed = 0; let reminded = 0;
145
+ let all: DecisionEntry[];
146
+ try { all = this.ledger.list({ status: 'all' }); } catch (error) {
147
+ debug.log('decisions.telegram', 'ledger-unreadable', { platform, reason: error instanceof Error ? error.message.slice(0, 80) : 'unknown' }, { level: 'warn' });
148
+ return { sent, closed, reminded };
149
+ }
150
+ let dirty = false;
151
+ if (!state.since) { state.since = this.now().toISOString(); dirty = true; debug.log('decisions.telegram', 'baseline', { platform, since: state.since }); }
152
+ const chats = await this.opts.transport.ownerChats().catch(() => [] as string[]);
153
+ for (const e of all) {
154
+ const card = state.cards[e.id];
155
+ if (e.status === 'open' && !card && e.options.length >= 2 && e.raisedAt !== undefined && e.raisedAt >= state.since) {
156
+ const refs: CardRef[] = [];
157
+ for (const chat of chats) {
158
+ const ref = await this.opts.transport.send(chat, renderCard(e)).catch(() => null);
159
+ if (ref) refs.push(ref);
160
+ }
161
+ if (refs.length) { state.cards[e.id] = { refs }; sent++; }
162
+ debug.log('decisions.telegram', 'card-sent', { platform, id: e.id, category: e.category, chats: chats.length, delivered: refs.length });
163
+ continue;
164
+ }
165
+ if (card && !card.closed && e.status !== 'open') {
166
+ for (const ref of card.refs) await this.opts.transport.edit(ref, renderCard(e)).catch(() => undefined);
167
+ card.closed = true; closed++;
168
+ debug.log('decisions.telegram', 'card-closed', { platform, id: e.id, status: e.status });
169
+ continue;
170
+ }
171
+ if (e.status === 'open' && card && e.dueAt && !card.remindedAt && this.now().getTime() >= Date.parse(e.dueAt) - REMIND_BEFORE_MS) {
172
+ for (const chat of chats) await this.opts.transport.notify(chat, `⏰ 기한 2시간 전 — ${e.id} «${e.title}» (기한 ${kst(e.dueAt)}) · 카드에서 정하거나 /decisions`).catch(() => undefined);
173
+ card.remindedAt = this.now().toISOString(); reminded++;
174
+ debug.log('decisions.telegram', 'reminded', { platform, id: e.id });
175
+ }
176
+ }
177
+ if (dirty || sent || closed || reminded) this.save(state);
178
+ return { sent, closed, reminded };
179
+ }
180
+
181
+ /** A button tap. Never throws. */
182
+ async tap(userId: string, data: string): Promise<TapOutcome> {
183
+ const platform = this.opts.transport.platform;
184
+ const parsed = parseTap(data);
185
+ if (!parsed) return { kind: 'ignored' };
186
+ if (!this.isOwner(userId)) {
187
+ debug.log('decisions.telegram', 'tap-refused', { platform, id: parsed.id }, { level: 'warn' });
188
+ return { kind: 'refused' };
189
+ }
190
+ let entry: DecisionEntry;
191
+ try { entry = this.ledger.show(parsed.id); } catch { return { kind: 'unknown' }; }
192
+ const state = this.load();
193
+ const card = state.cards[entry.id];
194
+ if (entry.status !== 'open') return { kind: 'closed', view: renderCard(entry), toast: '이미 정해진 결정입니다' };
195
+ if (parsed.action === 'memo') {
196
+ debug.log('decisions.telegram', 'memo-requested', { platform, id: entry.id });
197
+ return { kind: 'memo-requested', toast: '메모를 보내 주세요 — 다음 메시지가 메모가 됩니다' };
198
+ }
199
+ if (parsed.action === 'cancel') return { kind: 'cancelled', view: renderCard(entry, card?.note ? { note: card.note } : {}), toast: '취소했습니다' };
200
+ if (!entry.options.some((o) => o.key === parsed.key)) return { kind: 'unknown' };
201
+ if (isIrreversible(entry) && !parsed.confirmed) {
202
+ debug.log('decisions.telegram', 'confirm-asked', { platform, id: entry.id, key: parsed.key });
203
+ return { kind: 'confirm', view: renderCard(entry, { confirm: parsed.key, ...(card?.note ? { note: card.note } : {}) }), toast: '되돌릴 수 없는 결정 — 한 번 더 확인해 주세요' };
204
+ }
205
+ let decided: DecisionEntry;
206
+ try {
207
+ // Same ledger method the CLI `decide` uses — one record path.
208
+ decided = this.ledger.decide(entry.id, parsed.key, { kind: 'human' }, card?.note);
209
+ } catch (error) {
210
+ debug.log('decisions.telegram', 'decide-failed', { platform, id: entry.id, reason: error instanceof Error ? error.message.slice(0, 80) : 'unknown' }, { level: 'warn' });
211
+ try { const now = this.ledger.show(entry.id); if (now.status !== 'open') return { kind: 'closed', view: renderCard(now), toast: '이미 정해진 결정입니다' }; } catch { /* fall through */ }
212
+ return { kind: 'unknown' };
213
+ }
214
+ const view = renderCard(decided);
215
+ if (card) {
216
+ for (const ref of card.refs) await this.opts.transport.edit(ref, view).catch(() => undefined);
217
+ card.closed = true;
218
+ this.save(state);
219
+ }
220
+ debug.log('decisions.telegram', 'decided', { platform, id: decided.id, choice: decided.choice, category: decided.category, noted: Boolean(decided.note) });
221
+ await this.opts.replyToRaiser?.(decided, platform).catch((error: unknown) => {
222
+ debug.log('decisions.telegram', 'reply-failed', { platform, id: decided.id, reason: error instanceof Error ? error.message.slice(0, 80) : 'unknown' }, { level: 'warn' });
223
+ });
224
+ return { kind: 'decided', view, toast: `결정했습니다: ${decided.choice?.toUpperCase()}` };
225
+ }
226
+
227
+ /** Store a memo typed after «메모 달기»; the card shows it and the eventual decision carries it. */
228
+ async setNote(userId: string, id: string, note: string): Promise<CardView | null> {
229
+ if (!this.isOwner(userId)) return null;
230
+ let entry: DecisionEntry;
231
+ try { entry = this.ledger.show(id); } catch { return null; }
232
+ const clean = note.replace(/\s+/g, ' ').trim().slice(0, 300);
233
+ if (!clean || entry.status !== 'open') return null;
234
+ const state = this.load();
235
+ const card = state.cards[id] ?? { refs: [] };
236
+ card.note = clean;
237
+ state.cards[id] = card;
238
+ this.save(state);
239
+ const view = renderCard(entry, { note: clean });
240
+ for (const ref of card.refs) await this.opts.transport.edit(ref, view).catch(() => undefined);
241
+ debug.log('decisions.telegram', 'memo-set', { platform: this.opts.transport.platform, id, chars: clean.length });
242
+ return view;
243
+ }
244
+
245
+ /** `/decisions` — open decisions as one list; each card is sent again so the buttons are at hand. */
246
+ async listOpen(chat: string): Promise<string> {
247
+ let open: DecisionEntry[];
248
+ try { open = this.ledger.list({ status: 'open' }).filter((e) => e.options.length >= 2); } catch { return '결정 원장을 읽지 못했습니다.'; }
249
+ if (!open.length) return '열린 결정이 없습니다.';
250
+ const state = this.load();
251
+ for (const e of open) {
252
+ const ref = await this.opts.transport.send(chat, renderCard(e, state.cards[e.id]?.note ? { note: state.cards[e.id]!.note } : {})).catch(() => null);
253
+ if (ref) { const card = state.cards[e.id] ?? { refs: [] }; card.refs.push(ref); state.cards[e.id] = card; }
254
+ }
255
+ this.save(state);
256
+ debug.log('decisions.telegram', 'listed', { platform: this.opts.transport.platform, open: open.length });
257
+ return `열린 결정 ${open.length}건 — ${open.map((e) => `${e.id}${e.dueAt ? `(기한 ${kst(e.dueAt)})` : ''}`).join(' · ')}`;
258
+ }
259
+ }
260
+
261
+ /** Reply line for the seat that raised it. Posted to the coordination PR when `decisions.replyGhPr` = "owner/repo#N". */
262
+ export function raiserReplyText(e: DecisionEntry, via: CardPlatform): string {
263
+ const option = e.options.find((o) => o.key === e.choice);
264
+ const at = e.decidedAt ? new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Seoul', dateStyle: 'short', timeStyle: 'short' }).format(new Date(e.decidedAt)) : '';
265
+ return `**[대표]** ${at} KST → ${e.raisedBy.agent} · 결정 ${e.id} «${e.title}» = ${e.choice?.toUpperCase()}) ${option?.label ?? ''}${e.note ? ` · 메모: ${e.note}` : ''} (${via === 'telegram' ? '텔레그램' : '디스코드'})`;
266
+ }
267
+
268
+ export function ghPrReplier(target: string, run: (args: string[], stdin: string) => Promise<number> = runGh): (e: DecisionEntry, via: CardPlatform) => Promise<void> {
269
+ const m = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(target.trim());
270
+ return async (e, via) => {
271
+ if (!m) throw new Error('decisions.replyGhPr must be owner/repo#N');
272
+ const code = await run(['pr', 'comment', m[2]!, '--repo', m[1]!, '--body-file', '-'], raiserReplyText(e, via));
273
+ debug.log('decisions.telegram', 'replied', { id: e.id, via, target: 'gh-pr', ok: code === 0 });
274
+ if (code !== 0) throw new Error(`gh exit ${code}`);
275
+ };
276
+ }
277
+
278
+ /** Post as the automation App (not the machine's personal gh login — that would make 대표 a participant and mail them),
279
+ * with proxy variables removed (they break gh on the ops host). TC review #22660. */
280
+ async function runGh(args: string[], stdin: string): Promise<number> {
281
+ const env: Record<string, string> = {};
282
+ for (const [k, v] of Object.entries(process.env)) if (v !== undefined && !/^(https?|all|no)_proxy$/i.test(k)) env[k] = v;
283
+ try {
284
+ const { githubAutomationToken } = await import('../auth/github-app-token.js');
285
+ const token = githubAutomationToken();
286
+ if (token) env.GH_TOKEN = token;
287
+ debug.log('decisions.telegram', 'reply-identity', { app: Boolean(token) });
288
+ } catch { /* falls back to the machine login */ }
289
+ const proc = Bun.spawn(['gh', ...args], { stdin: new Blob([stdin]), stdout: 'ignore', stderr: 'ignore', env });
290
+ return proc.exited;
291
+ }