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,142 @@
1
+ // EN8 — `elanous connect <플러그인 폴더>`: Claude Code 플러그인(`.claude-plugin/plugin.json`)을 «그대로» 쓴다.
2
+ // 스킬 폴더는 EN9 와 같은 칸(`skills.sources[]` · kind connected · id `plugin:<이름>`)에 등록하고(복사 없음), `.mcp.json` 의 서버는 «꺼진 채»(`enabled:false`)
3
+ // `mcp.servers[]` 에 넣는다 — 켜기는 사람이 확인한 뒤에 한다. 환경변수가 필요한 서버는 등록하지 않고 이유를 말한다.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { findSkillFolders } from './connect.js';
7
+
8
+ export const CLAUDE_PLUGIN_MANIFEST = join('.claude-plugin', 'plugin.json');
9
+
10
+ export function isClaudePluginDir(dir: string): boolean {
11
+ return existsSync(join(dir, CLAUDE_PLUGIN_MANIFEST));
12
+ }
13
+
14
+ /** What we write into `mcp.servers[]` (raw config shape — parsed by parseMcpServerSpec). */
15
+ export type PluginMcpServer =
16
+ | { id: string; command: string[]; enabled: false }
17
+ | { id: string; transport: 'http'; url: string; enabled: false };
18
+
19
+ export interface ClaudePlugin {
20
+ root: string;
21
+ name: string;
22
+ version: string;
23
+ /** Skill folders that hold at least one SKILL.md folder. */
24
+ skillRoots: string[];
25
+ skills: string[];
26
+ mcp: PluginMcpServer[];
27
+ skipped: { name: string; reason: string }[];
28
+ }
29
+
30
+ function readJson(path: string): unknown {
31
+ return JSON.parse(readFileSync(path, 'utf8'));
32
+ }
33
+
34
+ function asObject(value: unknown): Record<string, unknown> | null {
35
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
36
+ }
37
+
38
+ function serverId(plugin: string, name: string): string {
39
+ return `${plugin}-${name}`.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
40
+ }
41
+
42
+ function expandRoot(value: string, root: string): string {
43
+ return value.split('${CLAUDE_PLUGIN_ROOT}').join(root);
44
+ }
45
+
46
+ /** One `.mcp.json` entry → a server spec, or the reason it is not registered. */
47
+ export function convertMcpEntry(plugin: string, name: string, raw: unknown, root: string): PluginMcpServer | { skipped: string } {
48
+ const entry = asObject(raw);
49
+ if (!entry) return { skipped: '형식을 읽지 못했습니다' };
50
+ const env = asObject(entry.env);
51
+ if (env && Object.keys(env).length) return { skipped: `환경변수가 필요합니다(${Object.keys(env).join(', ')}) — 직접 등록해 주세요` };
52
+ const id = serverId(plugin, name);
53
+ const type = typeof entry.type === 'string' ? entry.type : undefined;
54
+ if (typeof entry.url === 'string' && entry.url) {
55
+ if (type && type !== 'http') return { skipped: `${type} 방식은 아직 지원하지 않습니다` };
56
+ if (entry.headers && Object.keys(asObject(entry.headers) ?? {}).length) return { skipped: '헤더(인증)가 필요합니다 — 직접 등록해 주세요' };
57
+ return { id, transport: 'http', url: entry.url, enabled: false };
58
+ }
59
+ if (typeof entry.command === 'string' && entry.command) {
60
+ const args = Array.isArray(entry.args) ? entry.args.filter((a): a is string => typeof a === 'string') : [];
61
+ return { id, command: [expandRoot(entry.command, root), ...args.map((a) => expandRoot(a, root))], enabled: false };
62
+ }
63
+ return { skipped: '실행 명령이나 주소가 없습니다' };
64
+ }
65
+
66
+ function mcpSource(root: string, manifest: Record<string, unknown>): Record<string, unknown> | null {
67
+ const declared = manifest.mcpServers;
68
+ let raw: unknown = null;
69
+ if (typeof declared === 'string') {
70
+ const path = resolve(root, declared);
71
+ if (path.startsWith(`${root}/`) && existsSync(path)) raw = readJson(path);
72
+ } else if (asObject(declared)) {
73
+ raw = declared;
74
+ } else if (existsSync(join(root, '.mcp.json'))) {
75
+ raw = readJson(join(root, '.mcp.json'));
76
+ }
77
+ const object = asObject(raw);
78
+ if (!object) return null;
79
+ return asObject(object.mcpServers) ?? object;
80
+ }
81
+
82
+ function skillRootsOf(root: string, manifest: Record<string, unknown>): string[] {
83
+ const declared = typeof manifest.skills === 'string' ? [manifest.skills]
84
+ : Array.isArray(manifest.skills) ? manifest.skills.filter((s): s is string => typeof s === 'string') : [];
85
+ const candidates = [join(root, 'skills'), ...declared.map((rel) => resolve(root, rel))]
86
+ .filter((path) => path === root || path.startsWith(`${root}/`));
87
+ return [...new Set(candidates)].filter((path) => findSkillFolders(path).length > 0);
88
+ }
89
+
90
+ /** Read a Claude Code plugin folder. Throws only when the manifest is not JSON. */
91
+ export function readClaudePlugin(dir: string): ClaudePlugin {
92
+ const root = resolve(dir);
93
+ const manifest = asObject(readJson(join(root, CLAUDE_PLUGIN_MANIFEST))) ?? {};
94
+ const name = typeof manifest.name === 'string' && manifest.name.trim() ? manifest.name.trim() : root.split('/').pop() ?? 'plugin';
95
+ const version = typeof manifest.version === 'string' ? manifest.version : '0.0.0';
96
+ const skillRoots = skillRootsOf(root, manifest);
97
+ const skills = skillRoots.flatMap(findSkillFolders).sort();
98
+ const mcp: PluginMcpServer[] = [];
99
+ const skipped: ClaudePlugin['skipped'] = [];
100
+ for (const [server, raw] of Object.entries(mcpSource(root, manifest) ?? {})) {
101
+ const converted = convertMcpEntry(name, server, raw, root);
102
+ if ('skipped' in converted) skipped.push({ name: server, reason: converted.skipped });
103
+ else mcp.push(converted);
104
+ }
105
+ return { root, name, version, skillRoots, skills, mcp, skipped };
106
+ }
107
+
108
+ export interface ClaudePluginPlan {
109
+ plugin: ClaudePlugin;
110
+ newSkillRoots: string[];
111
+ newMcp: PluginMcpServer[];
112
+ state: 'new' | 'already' | 'empty';
113
+ }
114
+
115
+ export function planClaudePlugin(plugin: ClaudePlugin, activeDirs: readonly string[], mcpIds: readonly string[]): ClaudePluginPlan {
116
+ const newSkillRoots = plugin.skillRoots.filter((dir) => !activeDirs.includes(dir));
117
+ const newMcp = plugin.mcp.filter((server) => !mcpIds.includes(server.id));
118
+ const state = !plugin.skills.length && !plugin.mcp.length ? 'empty' : newSkillRoots.length || newMcp.length ? 'new' : 'already';
119
+ return { plugin, newSkillRoots, newMcp, state };
120
+ }
121
+
122
+ export function claudePluginPrompt(plan: ClaudePluginPlan): string {
123
+ const { plugin } = plan;
124
+ const head = `Claude 플러그인 «${plugin.name}» ${plugin.version}`;
125
+ const skipped = plugin.skipped.map((s) => `\n · MCP ${s.name}: ${s.reason}`).join('');
126
+ if (plan.state === 'empty') return `${head}에서 쓸 스킬이나 MCP 서버를 찾지 못했습니다.${skipped}`;
127
+ if (plan.state === 'already') return `${head}은 이미 연결돼 있습니다 — 스킬 ${plugin.skills.length}개 · MCP ${plugin.mcp.length}개.${skipped}`;
128
+ const parts = [
129
+ plugin.skills.length ? `스킬 ${plugin.skills.length}개(그 자리를 그대로 씁니다)` : '',
130
+ plan.newMcp.length ? `MCP 서버 ${plan.newMcp.length}개(꺼진 채로 등록 — 켜기는 따로 확인합니다)` : '',
131
+ ].filter(Boolean).join(' · ');
132
+ return `${head}: ${parts}${skipped}\n가져올까요? [Y/n] `;
133
+ }
134
+
135
+ /** How to turn one registered server on — shown after connecting, never done for the person. */
136
+ export function mcpEnableHint(servers: readonly PluginMcpServer[], all: readonly { id?: unknown }[]): string[] {
137
+ return servers.map((server) => {
138
+ const index = all.findIndex((row) => row.id === server.id);
139
+ const what = 'url' in server ? server.url : server.command.join(' ');
140
+ return ` · ${server.id} — ${what}\n 켜려면: elanous config set mcp.servers.${index}.enabled true`;
141
+ });
142
+ }
@@ -0,0 +1,70 @@
1
+ // EN9 — `elanous connect <claude|codex>`: 쓰던 에이전트의 스킬을 찾아 «복사가 아니라 등록»한다(대표 «기존 플러그인을 그대로 쓸 수 있다»).
2
+ // 등록 = `skills.sources[]`(EN5 한 모델)에 kind `connected` 로 원본 경로를 더한다 · 원본은 읽기만 하고 고치지 않는다 · 읽지 못한 스킬은 SK2(`skills repair`)로 안내한다.
3
+ import { existsSync, readdirSync, statSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { skillSourceId, type SkillSource } from '../user-config.js';
7
+
8
+ export type ConnectAgent = 'claude' | 'codex';
9
+ export const CONNECT_AGENTS: readonly ConnectAgent[] = ['claude', 'codex'];
10
+
11
+ const AGENT_LABEL: Record<ConnectAgent, string> = { claude: 'Claude Code', codex: 'Codex' };
12
+
13
+ export function connectSkillRoot(agent: ConnectAgent, home = homedir()): string {
14
+ return agent === 'claude' ? join(home, '.claude', 'skills') : join(home, '.codex', 'skills');
15
+ }
16
+
17
+ /** Folders that hold a SKILL.md (one level, like the skill index). Hidden folders such as `.system` are skipped. */
18
+ export function findSkillFolders(root: string): string[] {
19
+ try {
20
+ return readdirSync(root)
21
+ .filter((name) => !name.startsWith('.'))
22
+ .filter((name) => {
23
+ try { return statSync(join(root, name)).isDirectory() && existsSync(join(root, name, 'SKILL.md')); }
24
+ catch { return false; }
25
+ })
26
+ .sort();
27
+ } catch { return []; }
28
+ }
29
+
30
+ export type ConnectPlan =
31
+ | { agent: ConnectAgent; root: string; state: 'missing' }
32
+ | { agent: ConnectAgent; root: string; state: 'empty' }
33
+ | { agent: ConnectAgent; root: string; state: 'already'; skills: string[] }
34
+ | { agent: ConnectAgent; root: string; state: 'new'; skills: string[] };
35
+
36
+ /** `activeDirs` = the skill directories elanous reads today (defaultSkillDirs). */
37
+ export function planConnect(agent: ConnectAgent, activeDirs: readonly string[], home = homedir()): ConnectPlan {
38
+ const root = connectSkillRoot(agent, home);
39
+ if (!existsSync(root)) return { agent, root, state: 'missing' };
40
+ const skills = findSkillFolders(root);
41
+ if (!skills.length) return { agent, root, state: 'empty' };
42
+ return activeDirs.includes(root) ? { agent, root, state: 'already', skills } : { agent, root, state: 'new', skills };
43
+ }
44
+
45
+ function sample(skills: readonly string[]): string {
46
+ const head = skills.slice(0, 5).join(', ');
47
+ return skills.length > 5 ? `${head} 외 ${skills.length - 5}개` : head;
48
+ }
49
+
50
+ /** The first screen — one question, or one line saying why there is nothing to ask. */
51
+ export function connectPrompt(plan: ConnectPlan): string {
52
+ const who = AGENT_LABEL[plan.agent];
53
+ switch (plan.state) {
54
+ case 'missing': return `${who} 스킬 폴더가 없습니다(${plan.root}). 가져올 것이 없습니다.`;
55
+ case 'empty': return `${who} 스킬 폴더에 스킬이 없습니다(${plan.root}).`;
56
+ case 'already': return `${who}에서 쓰던 스킬 ${plan.skills.length}개는 이미 연결돼 있습니다 — ${sample(plan.skills)}`;
57
+ case 'new': return `${who}에서 쓰던 스킬 ${plan.skills.length}개를 찾았습니다 — ${sample(plan.skills)}\n복사하지 않고 그 자리(${plan.root})를 그대로 씁니다. 가져올까요? [Y/n] `;
58
+ }
59
+ }
60
+
61
+ /** Returns the new connected-source list (unchanged when the path is already there). */
62
+ export function withConnectedSource(sources: readonly SkillSource[] | undefined, path: string, id = skillSourceId(path)): SkillSource[] {
63
+ const list = [...(sources ?? [])];
64
+ if (!list.some((source) => source.path === path)) list.push({ id, path, kind: 'connected', enabled: true });
65
+ return list;
66
+ }
67
+
68
+ export function withoutSources(sources: readonly SkillSource[] | undefined, paths: readonly string[]): SkillSource[] {
69
+ return (sources ?? []).filter((source) => !paths.includes(source.path));
70
+ }
@@ -93,6 +93,17 @@ export function nameTriggers(name: string): string[] {
93
93
  return out;
94
94
  }
95
95
 
96
+ /** A skill the index could not read — surfaced to the person (startup notice · repair offer), never swallowed. */
97
+ export interface SkillIndexProblem { name: string; dir: string; code: string | null; error: string }
98
+ const skillProblems = new Map<string, SkillIndexProblem>();
99
+ function recordSkillIndexProblem(problem: SkillIndexProblem): void {
100
+ skillProblems.set(`${problem.dir}\u0000${problem.name}`, problem);
101
+ }
102
+ /** Skills skipped by the most recent index builds (deduped by dir + name). */
103
+ export function skillIndexProblems(): SkillIndexProblem[] {
104
+ return [...skillProblems.values()];
105
+ }
106
+
96
107
  function makeEntry(name: string, rootDir: string): SkillIndexEntry | null {
97
108
  let m: ReturnType<typeof parseSkillMd>;
98
109
  try {
@@ -458,9 +469,26 @@ export function buildSkillIndex(
458
469
  const out: SkillIndexEntry[] = [];
459
470
  const seenNames = new Set<string>();
460
471
  for (const dir of dirs) {
472
+ // Problems reflect the latest build of each dir — a repaired skill must drop off the list (SK2).
473
+ for (const key of [...skillProblems.keys()]) if (key.startsWith(`${dir}\u0000`)) skillProblems.delete(key);
461
474
  const names = listSkillNames(dir);
462
475
  for (const name of names) {
463
- const entry = makeEntry(name, dir);
476
+ // One malformed third-party skill must not take down the index (and with it the first screen).
477
+ let entry: SkillIndexEntry | null;
478
+ try {
479
+ entry = makeEntry(name, dir);
480
+ } catch (err) {
481
+ // 대표 10-01: a broken skill must never crash the product — and must never vanish silently either (G9 P5a).
482
+ // It is skipped, recorded in skillIndexProblems() (a startup notice offers to repair it), and logged.
483
+ const problem: SkillIndexProblem = {
484
+ name, dir,
485
+ code: (err as NodeJS.ErrnoException)?.code ?? null,
486
+ error: err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200),
487
+ };
488
+ recordSkillIndexProblem(problem);
489
+ debug.log('skills.index', 'skill-skipped', { ...problem }, { level: 'warn' });
490
+ continue;
491
+ }
464
492
  if (!entry) continue;
465
493
  if (seenNames.has(entry.name)) continue; // first-wins dedup
466
494
  seenNames.add(entry.name);
@@ -0,0 +1,124 @@
1
+ // SK2 — 남의 SKILL.md 를 색인이 못 읽으면(skillIndexProblems) 시스템 에러 대신 «안내 ⊕ 확인 뒤 수리».
2
+ // 대표 10-01: «시스템 에러가 아니라 별도 시스템 메시지를 받고 스킬을 수리» · «PR 이 아니라 접근한 서피스에서 간단히».
3
+ // 고칠 수 있는 것만 고친다: 한 줄이어야 하는 프론트매터 값이 YAML 목록·인라인 배열이면 한 줄로 편다.
4
+ // 권한 오류는 원인·명령만 안내한다. 고치기 전 원본은 옆에 `.bak` 으로 남기고, 다시 읽어 안 풀리면 되돌린다.
5
+ import { chmodSync, copyFileSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { debug } from '../debug/log.js';
8
+ import { buildSkillIndex, skillIndexProblems, type SkillIndexProblem } from './index.js';
9
+
10
+ /** Frontmatter keys the index reads as one-line text. */
11
+ const SCALAR_KEYS = ['name', 'description', 'version', 'when_to_use', 'when-to-use', 'argument-hint', 'model', 'category'];
12
+
13
+ export type SkillRepairPlan =
14
+ | { kind: 'fixable'; problem: SkillIndexProblem; path: string; keys: string[] }
15
+ | { kind: 'fixable-permission'; problem: SkillIndexProblem; path: string }
16
+ | { kind: 'permission'; problem: SkillIndexProblem; path: string; hint: string }
17
+ | { kind: 'manual'; problem: SkillIndexProblem; path: string; hint: string };
18
+
19
+ export function skillFilePath(problem: SkillIndexProblem): string {
20
+ return join(problem.dir, problem.name, 'SKILL.md');
21
+ }
22
+
23
+ function quote(value: string): string {
24
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
25
+ }
26
+
27
+ function stripQuotes(value: string): string {
28
+ const v = value.trim();
29
+ return (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")) ? v.slice(1, -1) : v;
30
+ }
31
+
32
+ /** Rewrite list-valued scalar keys as one quoted line; returns null when nothing needs changing. */
33
+ export function flattenScalarLists(markdown: string): { text: string; keys: string[] } | null {
34
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
35
+ if (!fm) return null;
36
+ const lines = fm[1]!.split(/\r?\n/);
37
+ const out: string[] = [];
38
+ const keys: string[] = [];
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i]!;
41
+ const m = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
42
+ if (!m || !SCALAR_KEYS.includes(m[1]!)) { out.push(line); continue; }
43
+ const key = m[1]!;
44
+ const value = m[2]!.trim();
45
+ if (/^\[.*\]$/.test(value)) {
46
+ const items = value.slice(1, -1).split(',').map(stripQuotes).filter(Boolean);
47
+ out.push(`${key}: ${quote(items.join(' · '))}`); keys.push(key);
48
+ continue;
49
+ }
50
+ if (value === '') {
51
+ const items: string[] = [];
52
+ let j = i + 1;
53
+ while (j < lines.length && /^\s+-\s+/.test(lines[j]!)) { items.push(stripQuotes(lines[j]!.replace(/^\s+-\s+/, ''))); j++; }
54
+ if (items.length) { out.push(`${key}: ${quote(items.join(' · '))}`); keys.push(key); i = j - 1; continue; }
55
+ }
56
+ out.push(line);
57
+ }
58
+ if (!keys.length) return null;
59
+ const text = markdown.slice(0, fm.index) + `---\n${out.join('\n')}\n---` + markdown.slice(fm.index + fm[0].length);
60
+ return { text, keys };
61
+ }
62
+
63
+ export function planSkillRepair(problem: SkillIndexProblem, read: (path: string) => string = (p) => readFileSync(p, 'utf8')): SkillRepairPlan {
64
+ const path = skillFilePath(problem);
65
+ if (problem.code === 'EACCES' || problem.code === 'EPERM') {
66
+ // Our own file: we may add the read bit after the person says yes. Someone else's: name the command only.
67
+ try {
68
+ if (typeof process.getuid === 'function' && statSync(path).uid === process.getuid()) return { kind: 'fixable-permission', problem, path };
69
+ } catch { /* fall through to the hint */ }
70
+ return { kind: 'permission', problem, path, hint: `읽기 권한이 없습니다 — 터미널에서: sudo chmod a+r "${path}"` };
71
+ }
72
+ try {
73
+ const fixed = flattenScalarLists(read(path));
74
+ if (fixed) return { kind: 'fixable', problem, path, keys: fixed.keys };
75
+ } catch (error) {
76
+ const code = (error as NodeJS.ErrnoException).code;
77
+ if (code === 'EACCES' || code === 'EPERM') return { kind: 'permission', problem, path, hint: `읽기 권한이 없습니다 — 터미널에서: chmod u+r "${path}"` };
78
+ }
79
+ return { kind: 'manual', problem, path, hint: `자동으로 고칠 수 없는 형식입니다 — 파일 맨 위 «---» 사이를 확인해 주세요: ${path}` };
80
+ }
81
+
82
+ export type SkillRepairResult = { ok: true; path: string; keys: string[]; backup: string } | { ok: false; path: string; reason: string };
83
+
84
+ function stillUnreadable(problem: SkillIndexProblem): boolean {
85
+ buildSkillIndex([problem.dir]);
86
+ return skillIndexProblems().some((p) => p.dir === problem.dir && p.name === problem.name);
87
+ }
88
+
89
+ /** Apply a fixable plan (after the person said yes): read bit, or back up → rewrite → re-index → restore on failure. */
90
+ export function applySkillRepair(plan: Extract<SkillRepairPlan, { kind: 'fixable' | 'fixable-permission' }>): SkillRepairResult {
91
+ const { path, problem } = plan;
92
+ if (plan.kind === 'fixable-permission') {
93
+ const before = statSync(path).mode & 0o777;
94
+ chmodSync(path, before | 0o400);
95
+ if (stillUnreadable(problem)) {
96
+ chmodSync(path, before);
97
+ debug.log('skills.repair', 'reverted', { name: problem.name, kind: 'permission' });
98
+ return { ok: false, path, reason: 'still-unreadable' };
99
+ }
100
+ debug.log('skills.repair', 'repaired', { name: problem.name, kind: 'permission' });
101
+ return { ok: true, path, keys: ['permission'], backup: '' };
102
+ }
103
+ const original = readFileSync(path, 'utf8');
104
+ const fixed = flattenScalarLists(original);
105
+ if (!fixed) return { ok: false, path, reason: 'nothing-to-fix' };
106
+ const backup = `${path}.bak`;
107
+ copyFileSync(path, existsSync(backup) ? `${path}.${Date.now()}.bak` : backup);
108
+ const temp = `${path}.tmp-${process.pid}`;
109
+ writeFileSync(temp, fixed.text);
110
+ renameSync(temp, path);
111
+ if (stillUnreadable(problem)) {
112
+ writeFileSync(path, original);
113
+ debug.log('skills.repair', 'reverted', { name: problem.name, keys: fixed.keys });
114
+ return { ok: false, path, reason: 'still-unreadable' };
115
+ }
116
+ debug.log('skills.repair', 'repaired', { name: problem.name, keys: fixed.keys });
117
+ return { ok: true, path, keys: fixed.keys, backup };
118
+ }
119
+
120
+ /** One line for a surface (TUI · REPL · PWA) — null when every skill was read. */
121
+ export function skillRepairNotice(problems: readonly SkillIndexProblem[]): string | null {
122
+ if (!problems.length) return null;
123
+ return `스킬 ${problems.length}개를 읽지 못했습니다 — 고치려면: elanous skills repair (확인한 뒤에만 고칩니다)`;
124
+ }
@@ -330,6 +330,15 @@ function parseTier(raw: unknown): SkillTier | undefined {
330
330
 
331
331
  const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
332
332
 
333
+ /** A frontmatter value as one line of text. Third-party SKILL.md files (~/.codex/skills, ~/.claude/skills …)
334
+ * sometimes write `description:` as a YAML list; the parser then returns an array, and a 0.2.6 beta tester's
335
+ * first screen crashed on `description.trim is not a function`. Lists are joined; anything else non-string is dropped. */
336
+ export function frontmatterText(value: unknown): string {
337
+ if (typeof value === 'string') return value;
338
+ if (Array.isArray(value)) return value.filter((x): x is string => typeof x === 'string').join(' ').trim();
339
+ return '';
340
+ }
341
+
333
342
  function parseFrontmatter(md: string): { fm: Record<string, any>; body: string } {
334
343
  const match = md.match(FRONTMATTER_RE);
335
344
  if (!match) return { fm: {}, body: md };
@@ -544,8 +553,8 @@ export function parseSkillMd(skillName: string, baseDir: string = LOCAL_SKILLS_D
544
553
  const cost = rawCost === 'light' || rawCost === 'heavy' ? rawCost : undefined;
545
554
 
546
555
  return {
547
- name: fm['name'] || skillName,
548
- description: fm['description'] || '',
556
+ name: frontmatterText(fm['name']) || skillName,
557
+ description: frontmatterText(fm['description']),
549
558
  model: fm['model'] || undefined,
550
559
  allowedTools,
551
560
  deniedTools,
@@ -56,7 +56,7 @@ const BACKWARD_MARKER = /(?:등(?:의)?\s*)?(?:언급\s*)?시\s*(?:이?\s*스킬
56
56
  * keywords. Empty array is a perfectly normal result (description
57
57
  * without any recognized marker). */
58
58
  export function extractTriggers(description: string): string[] {
59
- if (!description || !description.trim()) return [];
59
+ if (typeof description !== 'string' || !description.trim()) return [];
60
60
 
61
61
  const segments = extractTriggerSegments(description);
62
62
  const items: string[] = [];
@@ -5,6 +5,7 @@
5
5
  // codex·elanous 자격 파일은 이 Job 에 없다. 격리 관문(호스트 넥서스에 닿으면 종료)은 기존 Job 과 같다.
6
6
  // 명령 인자는 셸 문자열로 이어붙이지 않고 `"$@"` 로 따로 인용한다.
7
7
 
8
+ import { randomBytes } from 'node:crypto';
8
9
  import { spawnSync } from 'node:child_process';
9
10
  import { isAbsolute } from 'node:path';
10
11
  import { getUserConfig } from '../../user-config.js';
@@ -66,6 +67,8 @@ if [ -d "$HOME/outbox" ]; then
66
67
  fi`;
67
68
 
68
69
  export interface PodCommandJobInput {
70
+ /** POD1 — this launch's own label; cleanup selects by it, never by name alone. */
71
+ launch?: string;
69
72
  name: string;
70
73
  namespace: string;
71
74
  image: string;
@@ -122,13 +125,13 @@ export function podCommandJobManifest(o: PodCommandJobInput): Record<string, unk
122
125
  return {
123
126
  apiVersion: 'batch/v1',
124
127
  kind: 'Job',
125
- metadata: { name: o.name, namespace: o.namespace, labels: { 'elanous.substrate': 'pod', 'elanous.job': o.name, 'elanous.kind': 'command' } },
128
+ metadata: { name: o.name, namespace: o.namespace, labels: { 'elanous.substrate': 'pod', 'elanous.job': o.name, 'elanous.kind': 'command', ...(o.launch ? { 'elanous.launch': o.launch } : {}) } },
126
129
  spec: {
127
130
  backoffLimit: 0,
128
131
  ttlSecondsAfterFinished: 7200,
129
132
  activeDeadlineSeconds: o.deadlineSeconds,
130
133
  template: {
131
- metadata: { labels: { 'elanous.job': o.name, 'elanous.kind': 'command' } },
134
+ metadata: { labels: { 'elanous.job': o.name, 'elanous.kind': 'command', ...(o.launch ? { 'elanous.launch': o.launch } : {}) } },
132
135
  spec: {
133
136
  restartPolicy: 'Never',
134
137
  securityContext: { runAsUser: 1000, fsGroup: 1000 },
@@ -166,6 +169,7 @@ export function commandJobSecret(o: {
166
169
  skillEnvText: Readonly<Record<string, string>>;
167
170
  grokAuth?: string;
168
171
  ghToken?: string;
172
+ launch?: string;
169
173
  }): Record<string, unknown> | null {
170
174
  const stringData: Record<string, string> = {};
171
175
  if (o.grokAuth !== undefined) stringData['grok-auth.json'] = o.grokAuth;
@@ -177,7 +181,7 @@ export function commandJobSecret(o: {
177
181
  if (Object.keys(stringData).length === 0) return null;
178
182
  return {
179
183
  apiVersion: 'v1', kind: 'Secret', type: 'Opaque',
180
- metadata: { name: `${o.name}-creds`, namespace: o.namespace, labels: { 'elanous.job': o.name } },
184
+ metadata: { name: `${o.name}-creds`, namespace: o.namespace, labels: { 'elanous.job': o.name, ...(o.launch ? { 'elanous.launch': o.launch } : {}) } },
181
185
  stringData,
182
186
  };
183
187
  }
@@ -265,7 +269,11 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
265
269
  const image = options.image ?? 'elanous-harness:local';
266
270
  const repoUrl = options.repoUrl ?? 'https://github.com/ElanvitalAI/elanous';
267
271
  const deadlineSeconds = options.deadlineSeconds ?? POD_COMMAND_DEADLINE_SECONDS;
268
- const name = options.name ?? podJobName(`cmd-${Date.now().toString(36)}`);
272
+ // POD1 (10-01): two launches in the same moment got the same `si-cmd-<time>` and the failing one's cleanup
273
+ // deleted the other's credentials. The name now has a random tail, and cleanup selects this launch's label.
274
+ const launch = randomBytes(6).toString('hex');
275
+ const name = options.name ?? podJobName(`cmd-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`);
276
+ const own = `elanous.job=${name},elanous.launch=${launch}`;
269
277
  const env = options.env ?? process.env;
270
278
  const hostMirror = (options.hostMirror ?? env.ELANOUS_POD_HOST_MIRROR
271
279
  ?? (options.configHostMirror ?? (() => getUserConfig().pod?.hostMirror))())?.trim() || undefined;
@@ -293,7 +301,7 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
293
301
  const currentContext = member || options.kubectl ? null : baseKubectl(['config', 'current-context']);
294
302
  const context = member?.context ?? (currentContext?.status === 0 ? currentContext.stdout.trim() : '');
295
303
  const kubectl: Kubectl = (args, stdin) => baseKubectl(context ? ['--context', context, ...args] : [...args], stdin);
296
- const cleanupSecret = () => { kubectl(['-n', namespace, 'delete', 'secret', `${name}-creds`, '--ignore-not-found']); };
304
+ const cleanupSecret = () => { kubectl(['-n', namespace, 'delete', 'secret', '-l', own, '--ignore-not-found']); };
297
305
  const artifactsDir = `${options.artifactsRoot ?? `${effectiveInstanceRoot()}/pod-artifacts`}/${name}`;
298
306
  try {
299
307
  if (!context && !options.kubectl) throw new Error('pod command: kubectl context 를 확인할 수 없다');
@@ -328,19 +336,19 @@ export async function runPodCommand(options: RunPodCommandOptions): Promise<PodC
328
336
  if (synced?.imageRef) member = { ...member, imageRef: synced.imageRef };
329
337
  }
330
338
  const ghToken = options.clone ? (options.ghToken ?? defaultGhToken)() : undefined;
331
- const secret = commandJobSecret({ name, namespace, skills, skillEnvText, ...(grokAuth !== undefined ? { grokAuth } : {}), ...(ghToken !== undefined ? { ghToken } : {}) });
339
+ const secret = commandJobSecret({ name, namespace, launch, skills, skillEnvText, ...(grokAuth !== undefined ? { grokAuth } : {}), ...(ghToken !== undefined ? { ghToken } : {}) });
332
340
  if (secret) {
333
341
  const applied = kubectl(['apply', '-f', '-'], JSON.stringify(secret));
334
342
  if (applied.status !== 0) throw new Error(applied.stderr.trim() || 'pod command: Secret 적용 실패');
335
343
  }
336
344
  const manifest = podCommandJobManifest({
337
- name, namespace, image: member?.imageRef ?? image,
345
+ name, namespace, launch, image: member?.imageRef ?? image,
338
346
  ...(member?.imageRef ? { imagePullPolicy: 'IfNotPresent' as const } : {}),
339
347
  repoUrl, command, skills, ...(llm ? { llm } : {}), ...(options.clone ? { clone: true } : {}),
340
348
  ...(options.source ? { source: options.source } : {}), ...(hostMirror ? { hostMirror } : {}), ...(bunCache ? { bunCache } : {}), ...(options.memoryLimit ? { memoryLimit: options.memoryLimit } : {}), deadlineSeconds,
341
349
  ...(options.runId ? { runId: options.runId } : {}),
342
350
  });
343
- kubectl(['-n', namespace, 'delete', 'job', name, '--ignore-not-found']);
351
+ kubectl(['-n', namespace, 'delete', 'job', '-l', own, '--ignore-not-found']);
344
352
  const applied = kubectl(['apply', '-f', '-'], JSON.stringify(manifest));
345
353
  if (applied.status !== 0) {
346
354
  cleanupSecret();
@@ -762,6 +762,19 @@ export function defaultTelegramCommands(): TgSlashCommand[] {
762
762
  });
763
763
  },
764
764
  },
765
+ {
766
+ name: 'decisions',
767
+ description: '열린 결정 목록 — 카드를 다시 보내 여기서 바로 정할 수 있습니다',
768
+ handler: async (_args, ctx) => {
769
+ const { telegramDecisionService } = await import('./decisions/telegram-decision-cards.js');
770
+ const service = telegramDecisionService();
771
+ if (!service) return '결정 카드가 이 봇에 연결되어 있지 않습니다.';
772
+ if (!service.isOwner(String(ctx.userId))) return '결정은 소유자만 볼 수 있습니다.';
773
+ // A group would show every member the cards (and route memos from them) — private chat only (TC review #22660).
774
+ if (ctx.chatId !== ctx.userId) return '결정은 개인 대화에서만 볼 수 있습니다.';
775
+ return service.listOpen(String(ctx.chatId));
776
+ },
777
+ },
765
778
  {
766
779
  name: 'work',
767
780
  description: '글을 알맞은 곳(흡수·태스크·하니스)에 넣고 결과를 여기로 답합니다 — /work [absorb|tasks|graph] <글>',
package/src/telegram.ts CHANGED
@@ -919,6 +919,24 @@ export class TelegramBot {
919
919
  return undefined;
920
920
  }
921
921
 
922
+ /** DEC-TG — replace a message's text AND its inline keyboard (empty rows remove the buttons). */
923
+ async editMessageWithKeyboard(
924
+ chatId: number,
925
+ messageId: number,
926
+ text: string,
927
+ buttons: Array<Array<{ text: string; data: string }>>,
928
+ ): Promise<void> {
929
+ await this.throttledCall<unknown>(chatId, 'editMessageText', {
930
+ chat_id: chatId,
931
+ message_id: messageId,
932
+ text: text.length > this.maxChars ? `${text.slice(0, this.maxChars - 2)} …` : text,
933
+ reply_markup: { inline_keyboard: buttons.map((row) => row.map((b) => ({ text: b.text, callback_data: b.data.slice(0, 64) }))) },
934
+ });
935
+ }
936
+
937
+ /** True while the long-poll loop runs — only a polling bot should push decision cards (nexus may hold a send-only bot). */
938
+ isPolling(): boolean { return this.running; }
939
+
922
940
  /** T2-P6 — acknowledge a callback_query so Telegram clears the
923
941
  * spinner next to the button the user tapped. Pass a `text` to
924
942
  * flash a tiny toast on the user's device (≤200 chars). Returns
@@ -1156,6 +1174,8 @@ export class TelegramBot {
1156
1174
  if (u.callback_query) {
1157
1175
  const cq = u.callback_query;
1158
1176
  if (!this.isOwnerAllowed(cq.from.id)) {
1177
+ // DEC-TG — a decision-card tap from someone else is the rehearsal's «refused» case; name it there too.
1178
+ if ((cq.data ?? '').startsWith('dec:')) debug.log('decisions.telegram', 'tap-refused', { platform: 'telegram', gate: 'owner-allowlist' }, { level: 'warn' });
1159
1179
  debug.log('telegram.owner-gate', 'refused', {
1160
1180
  kind: 'callback', reason: this.allowedUsers.size === 0 ? 'empty-allowlist' : 'not-allowed', userId: cq.from.id,
1161
1181
  });
@@ -1425,6 +1445,9 @@ export class TelegramBot {
1425
1445
  ...reply, mimeType: 'video/mp4', caption: `현장 영상 · ${event} · ${result.seconds}초`,
1426
1446
  });
1427
1447
  if (!sent) throw new Error('sendDocument failed');
1448
+ if (result.feed?.started) {
1449
+ await this.sendMessage(ctx.chatId, `인스타 피드 초안을 만드는 중 · ${event} — 1분쯤 뒤 앱 «게시 대기»에서 고치고 «최종 게시»를 누르면 됩니다(누르기 전엔 아무 데도 안 올라갑니다).`, reply);
1450
+ }
1428
1451
  } else {
1429
1452
  await this.sendMessage(ctx.chatId, `현장 영상 실패 · ${event} · ${result.error ?? 'render failed'}`, reply);
1430
1453
  }
@@ -2840,6 +2863,17 @@ export function botFromConfig(opts: BotFromConfigOpts): TelegramBot {
2840
2863
  // poll loop requests `callback_query` from the first getUpdates —
2841
2864
  // in-turn approval taps then arrive without a mid-turn delay.
2842
2865
  botRef.ensureSurfaceHitl();
2866
+ // DEC-TG(대표 10-02) — decision cards on the MAIN bot only (other channel pollers carry other tokens). The ticker
2867
+ // itself sends only while this bot polls, so a send-only nexus bot never duplicates the standalone poller's cards.
2868
+ if (opts.userConfig.raw?.decisions === undefined || (opts.userConfig.raw.decisions as { cards?: unknown }).cards !== false) {
2869
+ const mainToken = resolveChannelBotToken('telegram', opts.userConfig)?.token;
2870
+ if (mainToken && botToken === mainToken) {
2871
+ const bot = botRef;
2872
+ void import('./decisions/telegram-decision-cards.js')
2873
+ .then(({ attachTelegramDecisionCards }) => { attachTelegramDecisionCards(bot, opts.userConfig); })
2874
+ .catch((err: unknown) => log(`[telegram] decision cards not attached: ${err instanceof Error ? err.message : String(err)}`));
2875
+ }
2876
+ }
2843
2877
  return botRef;
2844
2878
  }
2845
2879