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
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
 
3
3
  import { debug } from '../debug/log.js';
4
- import { buildUserConfig, type UserConfig as MainUserConfig } from '../user-config.js';
4
+ import { buildUserConfig, resolveSkillSources, type UserConfig as MainUserConfig } from '../user-config.js';
5
5
  import { decideProviderForConfig } from '../llm.js';
6
6
  import { resolveGrokCredential } from '../grok/credential.js';
7
7
  import { loadTokens } from '../oauth/store.js';
@@ -177,7 +177,12 @@ function checkChannelBot(cfg: NexusUserConfig): SetupItem {
177
177
  }
178
178
 
179
179
  function checkSkillDirs(cfg: MainUserConfig, exists: (path: string) => boolean): SetupItem {
180
- const dirs = Array.isArray(cfg.skills.dirs) ? cfg.skills.dirs.filter(hasText) : [];
180
+ // EN5 — the same list the loader reads (resolveSkillSources): the preset and what `elanous connect` registered.
181
+ // Derived roots (shared ~/.agents/skills, Claude package roots) are optional extras, not «configured» dirs.
182
+ const dirs = resolveSkillSources(cfg)
183
+ .filter((source) => source.enabled && (source.kind === 'preset' || source.kind === 'connected'))
184
+ .map((source) => source.path)
185
+ .filter(hasText);
181
186
  const existingDirs = dirs.filter(exists);
182
187
  const missingDirs = dirs.filter((dir) => !exists(dir));
183
188
  const detail = `${dirs.length} dir${dirs.length === 1 ? '' : 's'} · ${existingDirs.length} exist`;
@@ -0,0 +1,14 @@
1
+ // OB5d — 브라우저를 열 수 없는 세션이면 «왜 못 여는지 ⊕ 대안»을 한 줄로 말한다(대표 10-01 node-c 실측: ssh 에서 아무 말 없이 기기 코드로 갔다).
2
+ // null = 이 세션에서 브라우저 로그인을 먼저 시도해도 된다.
3
+ export function browserUnavailableReason(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string | null {
4
+ const ssh = !!(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
5
+ if (platform === 'darwin' || platform === 'win32') {
6
+ if (!ssh) return null;
7
+ const where = platform === 'darwin' ? '이 Mac' : '이 PC';
8
+ return `원격(ssh) 접속이라 브라우저 로그인을 마칠 수 없습니다 — ${where} 화면의 터미널에서 같은 명령을 실행하면 브라우저가 바로 열립니다(붙여넣기 없이). 지금은 아래 코드로 로그인합니다.`;
9
+ }
10
+ if (env.DISPLAY || env.WAYLAND_DISPLAY) return null;
11
+ return ssh
12
+ ? '원격(ssh) 접속이고 화면이 없어 브라우저를 열 수 없습니다 — 아무 기기의 브라우저에서 아래 주소를 열고 코드를 넣으세요.'
13
+ : '화면(디스플레이)이 없는 세션이라 브라우저를 열 수 없습니다 — 아무 기기의 브라우저에서 아래 주소를 열고 코드를 넣으세요.';
14
+ }
@@ -53,3 +53,19 @@ export function codexPolicyAllowsCredits(policy: CodexQuotaPolicy): boolean {
53
53
  export function codexPolicyAllowsFallback(policy: CodexQuotaPolicy): boolean {
54
54
  return policy !== 'within-quota';
55
55
  }
56
+
57
+ /** POL1 — the one line a harness launch prints about the codex quota policy it will use, and a loud warning when a
58
+ * test universe disagrees with production (10-01: work-tree launches resolved «fallback» and leaked to grok). */
59
+ export function describeLaunchQuotaPolicy(input: {
60
+ current: ResolvedCodexQuotaPolicy;
61
+ universe: { kind: 'prod' | 'test'; root: string };
62
+ production?: ResolvedCodexQuotaPolicy;
63
+ }): { line: string; warning?: string } {
64
+ const { current, universe, production } = input;
65
+ const line = `[pod] codex 한도 정책 = ${current.policy}(${CODEX_QUOTA_POLICY_LABEL[current.policy]}) · 출처 ${current.source} · 우주 ${universe.kind} ${universe.root}`;
66
+ if (universe.kind === 'test' && production && production.policy !== current.policy) {
67
+ return { line, warning: `⚠️ 이 시험 우주의 한도 정책(${current.policy})이 운영(${production.policy})과 다르다 — 운영과 같게 하려면: bun bin/elanous.mjs config sync-test` };
68
+ }
69
+ return { line };
70
+ }
71
+
@@ -5,25 +5,30 @@ export interface ContinueHereOptions {
5
5
  /** Already-issued short-lived phone connection link; never pass its token separately. */
6
6
  phoneLink?: string;
7
7
  telegramEnabled: boolean;
8
+ /** Tailscale signed in. `host` only when MagicDNS gives a name that serves HTTPS; otherwise the command alone. */
9
+ tailscale?: { host?: string };
8
10
  /** Non-interactive output must not include links (or QR payloads). */
9
11
  interactive: boolean;
10
12
  }
11
13
 
12
14
  /** Render post-setup directions without inspecting the daemon, issuing credentials, or performing IO. */
13
15
  export function continueHereLines(opts: ContinueHereOptions): string[] {
14
- const lines = [' Continue here:'];
15
- if (!opts.daemonRunning) lines.push(' Start the daemon: `elanous nexus run`');
16
+ const lines = [' 여기서 이어가세요:'];
17
+ if (!opts.daemonRunning) lines.push(' 데몬 켜기: `elanous nexus run`');
16
18
  if (opts.interactive && opts.daemonRunning && opts.pwaUrl) {
17
- lines.push(` Browser: ${opts.pwaUrl}`);
19
+ lines.push(` 브라우저: ${opts.pwaUrl}`);
18
20
  } else {
19
- lines.push(' Browser: `elanous nexus show`');
21
+ lines.push(' 브라우저: `elanous nexus show`');
20
22
  }
21
23
  if (opts.interactive && opts.daemonRunning && opts.phoneLink) {
22
- lines.push(` Phone: ${opts.phoneLink}`);
23
- lines.push(' ⚠ This link contains a connection token — do not share it or include it in a public screenshot.');
24
+ lines.push(` 폰: ${opts.phoneLink}`);
25
+ lines.push(' ⚠ 이 링크에는 연결 토큰이 들어 있습니다 — 남에게 보내거나 공개 캡처에 넣지 마세요.');
24
26
  } else {
25
- lines.push(' Phone: `elanous phone link --temp --ttl 24h`');
27
+ lines.push(' 폰: `elanous phone link --temp --ttl 24h`');
28
+ }
29
+ if (opts.telegramEnabled) lines.push(' 텔레그램: `elanous telegram`');
30
+ if (opts.tailscale) {
31
+ lines.push(` 어디서든(Tailscale): \`elanous nexus pwa share enable\`${opts.interactive && opts.tailscale.host ? ` → https://${opts.tailscale.host}/app/` : ''}`);
26
32
  }
27
- if (opts.telegramEnabled) lines.push(' Telegram: `elanous telegram`');
28
33
  return lines;
29
34
  }
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import { isHeadlessEnv } from '../acp/codex-auth.js';
3
3
  import { runNexusShow, type NexusShowResult } from '../cli/nexus-show.js';
4
4
  import { debug } from '../debug/log.js';
5
+ import { browserUnavailableReason } from '../oauth/browser-availability.js';
5
6
  import type { UserConfig } from '../user-config.js';
6
7
 
7
8
  export interface WebFirstSetupDeps {
@@ -13,6 +14,8 @@ export interface WebFirstSetupDeps {
13
14
  renderQr?: (link: string) => string | undefined;
14
15
  openBrowser?: (link: string) => boolean;
15
16
  isHeadless?: () => boolean;
17
+ browserEnv?: NodeJS.ProcessEnv;
18
+ browserPlatform?: NodeJS.Platform;
16
19
  print?: (line: string) => void;
17
20
  }
18
21
 
@@ -70,7 +73,11 @@ export async function runWebFirstSetup(deps: WebFirstSetupDeps): Promise<'link-s
70
73
  }
71
74
  print('터미널에서 하려면: `elanous setup --terminal`');
72
75
  let browserOpened = false;
73
- if (!(deps.isHeadless ?? (() => isHeadlessEnv(process.env)))()) {
76
+ const why = browserUnavailableReason(deps.browserEnv ?? process.env, deps.browserPlatform ?? process.platform);
77
+ if (why) {
78
+ print(why);
79
+ debug.log('browser.open', 'skipped', { reason: why.includes('ssh') ? 'ssh' : 'no-display' });
80
+ } else if (!(deps.isHeadless ?? (() => isHeadlessEnv(deps.browserEnv ?? process.env)))()) {
74
81
  try {
75
82
  browserOpened = (deps.openBrowser ?? ((url: string) => {
76
83
  const command = process.platform === 'darwin' ? 'open' : process.platform === 'linux' || process.platform === 'freebsd' || process.platform === 'openbsd' ? 'xdg-open' : undefined;
package/src/onboarding.ts CHANGED
@@ -34,7 +34,7 @@ import { stdin as input, stdout as output } from 'node:process';
34
34
  import {
35
35
  type UserConfig, type LLMProviderName, type SkillSetName,
36
36
  saveUserConfig, markOnboardingComplete, buildUserConfig,
37
- skillSetDir, SKILL_SET_NAMES, userConfigPath, urlRoutingDefaults, devRequestRoutingDefaults,
37
+ skillSetDir, SKILL_SET_NAMES, userConfigPath, urlRoutingDefaults, devRequestRoutingDefaults, defaultSkillDirs, resolveSkillSources,
38
38
  } from './user-config.js';
39
39
  import {
40
40
  GROK_MODEL, OPENAI_MODEL, ANTHROPIC_MODEL, LOCAL_LLM_MODEL, GEMINI_MODEL,
@@ -45,6 +45,7 @@ import { suggestSetupModel } from './model-tier/index.js';
45
45
  import { loginWithCodex, CODEX_DEVICE_LOGIN_URL } from './oauth/codex.js';
46
46
  import { loginWithCodexBrowser } from './oauth/codex-browser-login.js';
47
47
  import { loadTokens } from './oauth/store.js';
48
+ import { browserUnavailableReason } from './oauth/browser-availability.js';
48
49
  import { TelegramBot } from './telegram.js';
49
50
  import { DiscordBot } from './discord.js';
50
51
  import { pickCodexAuthMode, pickCodexModel } from './codex/setup.js';
@@ -61,6 +62,7 @@ import { stepTransition } from './onboarding/transition.js';
61
62
  import { unattendedSetupHint } from './onboarding/entry-hints.js';
62
63
  import { continueHereLines } from './onboarding/continue-here.js';
63
64
  import type { NexusShowResult } from './cli/nexus-show.js';
65
+ import type { TailscaleProbe } from './nexus/onboarding/tailscale-probe.js';
64
66
  import type { IssuedTempToken } from './auth/temp-tokens.js';
65
67
  import {
66
68
  askValidated,
@@ -385,7 +387,11 @@ function assistCodexDeviceLogin(
385
387
  const platform = deps.authPlatform ?? process.platform;
386
388
  const execute = deps.runAuthCommand ?? runAuthCommand;
387
389
  const local = !(deps.isHeadless ?? isHeadlessEnv)(env);
388
- if (local) {
390
+ const unavailable = browserUnavailableReason(env, platform);
391
+ if (unavailable) {
392
+ io.print(` ${unavailable}`);
393
+ debug.log('browser.open', 'skipped', { reason: unavailable.includes('ssh') ? 'ssh' : 'no-display' });
394
+ } else if (local) {
389
395
  try {
390
396
  if (platform === 'darwin') execute('open', [url]);
391
397
  else if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') execute('xdg-open', [url]);
@@ -582,7 +588,12 @@ async function askLLM(
582
588
  if (mode === 'oauth') {
583
589
  const env = grokDeps.codexEnv ?? process.env;
584
590
  let signedIn = false;
585
- if (!(grokDeps.isHeadless ?? isHeadlessEnv)(env)) {
591
+ const why = browserUnavailableReason(env, grokDeps.authPlatform ?? process.platform);
592
+ if (why) {
593
+ io.print(` ${why}`);
594
+ debug.log('browser.open', 'skipped', { reason: why.includes('ssh') ? 'ssh' : 'no-display' });
595
+ }
596
+ if (!why && !(grokDeps.isHeadless ?? isHeadlessEnv)(env)) {
586
597
  // Full-screen IO retains print() messages across redraws. Keep this
587
598
  // waiting hint transient so it cannot reappear on the device-code screen.
588
599
  if (io.showStep) output.write(' 브라우저에서 로그인을 마치세요 — 끝나면 자동으로 이어집니다.\n');
@@ -600,23 +611,23 @@ async function askLLM(
600
611
  } catch { /* device code remains available */ }
601
612
  }
602
613
  }
603
- if (signedIn) io.print(' → using your ChatGPT/Codex login.');
614
+ if (signedIn) io.print(' → ChatGPT/Codex 로그인을 씁니다.');
604
615
  else io.print(' 브라우저 로그인이 안 됐습니다 — 코드로 이어갑니다');
605
616
  }
606
617
  if (!signedIn) {
607
- io.print(' Launching device-code flow…');
618
+ io.print(' 코드로 로그인합니다…');
608
619
  try {
609
620
  await (grokDeps.loginWithCodex ?? loginWithCodex)({
610
621
  onProgress: (p) => {
611
622
  if (p.type === 'user_code') {
612
623
  io.print('');
613
- io.print(` 1) Open in any browser: ${p.loginUrl}`);
614
- io.print(` 2) Enter code: ${p.userCode}`);
624
+ io.print(` 1) 아무 브라우저에서 열기: ${p.loginUrl}`);
625
+ io.print(` 2) 코드 입력: ${p.userCode}`);
615
626
  io.print('');
616
627
  assistCodexDeviceLogin(io, p.loginUrl ?? CODEX_DEVICE_LOGIN_URL, p.userCode ?? '', grokDeps);
617
- io.print(' Waiting for sign-in…');
628
+ io.print(' 로그인을 기다리는 중…');
618
629
  }
619
- if (p.type === 'saved') io.print(' Signed in. Tokens saved.');
630
+ if (p.type === 'saved') io.print(' 로그인했습니다. 저장했습니다.');
620
631
  },
621
632
  });
622
633
  } catch (err: any) {
@@ -1316,6 +1327,7 @@ export interface RunWizardOpts {
1316
1327
  /** Post-save connections; injection keeps wizard tests off the real daemon/token store. */
1317
1328
  continueHereDeps?: {
1318
1329
  showNexus?: () => Promise<Pick<NexusShowResult, 'urls' | 'status' | 'instance'>>;
1330
+ probeTailscale?: () => Promise<TailscaleProbe>;
1319
1331
  issueToken?: () => Pick<IssuedTempToken, 'token'>;
1320
1332
  renderQr?: (link: string) => string | undefined;
1321
1333
  };
@@ -1531,9 +1543,28 @@ export async function runOnboarding(opts: RunWizardOpts = {}): Promise<UserConfi
1531
1543
  io.print('');
1532
1544
  io.print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
1533
1545
  io.print(` ${m.setupComplete}`);
1546
+ // ER1 — first-run consent notice (default on · 대표 10-01): what is sent and how to turn it off.
1547
+ io.print(' 오류가 나면 운영팀에 자동으로 알립니다(오류 내용·버전·무작위 설치 ID — 이름·메일 없이). 끄기: elanous config set errorReports.enabled false');
1534
1548
  io.print(` Provider : ${marked.llm.provider}${marked.llm.model ? ` (${marked.llm.model})` : ''}`);
1535
- io.print(` Skills : ${marked.skills.activeSet} — ${marked.skills.dirs.length} dir(s)`);
1536
- for (const d of marked.skills.dirs) io.print(` ${d}`);
1549
+ // EN5 — show what the loader will actually read (resolveSkillSources), not the raw preset fields.
1550
+ const skillSources = resolveSkillSources(marked).filter((source) => source.enabled);
1551
+ io.print(` Skills : ${marked.skills.activeSet} — ${skillSources.length} dir(s)`);
1552
+ for (const source of skillSources) io.print(` ${source.path}${source.kind === 'preset' ? '' : ` (${source.id})`}`);
1553
+ // EN9 — skills the person already has in another agent: one line, one command (registers in place, no copy).
1554
+ try {
1555
+ const { CONNECT_AGENTS, planConnect } = await import('./skills/connect.js');
1556
+ const active = defaultSkillDirs(marked);
1557
+ for (const agent of CONNECT_AGENTS) {
1558
+ const plan = planConnect(agent, active);
1559
+ if (plan.state === 'new') io.print(` 쓰던 스킬 ${plan.skills.length}개 찾음(${agent}) — 가져오려면: elanous connect ${agent}`);
1560
+ }
1561
+ } catch { /* advisory */ }
1562
+ // EN13 — a bot the person already runs elsewhere: one line, one command (values never shown).
1563
+ try {
1564
+ const { channelImportHint } = await import('./channel-import/run.js');
1565
+ const hint = channelImportHint();
1566
+ if (hint) io.print(` ${hint}`);
1567
+ } catch { /* advisory */ }
1537
1568
  io.print(` Obsidian : ${marked.obsidian.vault}`);
1538
1569
  io.print(` Telegram : ${marked.telegram.enabled ? 'enabled' : 'disabled'}`);
1539
1570
  io.print(` Discord : ${marked.discord.enabled ? 'enabled' : 'disabled'}`);
@@ -1571,7 +1602,30 @@ export async function runOnboarding(opts: RunWizardOpts = {}): Promise<UserConfi
1571
1602
  }
1572
1603
  } catch { /* No token/link on failure; show the manual command instead. */ }
1573
1604
  }
1574
- const directions = continueHereLines({ daemonRunning, pwaUrl, phoneLink, telegramEnabled: marked.telegram.enabled, interactive });
1605
+ let tailscale: { host?: string } | undefined;
1606
+ let installed = false;
1607
+ let alive = false;
1608
+ let probeTimer: ReturnType<typeof setTimeout> | undefined;
1609
+ try {
1610
+ const probe = opts.continueHereDeps?.probeTailscale ?? (async () => {
1611
+ const { probeTailscale } = await import('./nexus/onboarding/tailscale-probe.js');
1612
+ return probeTailscale({ timeoutMs: 2000 });
1613
+ });
1614
+ const status = await Promise.race([
1615
+ probe(),
1616
+ new Promise<undefined>(resolve => { probeTimer = setTimeout(() => resolve(undefined), 2000); }),
1617
+ ]);
1618
+ if (status) {
1619
+ installed = status.installed;
1620
+ alive = status.alive;
1621
+ // A bare hostname has no HTTPS certificate — offer the command without an address.
1622
+ if (installed && alive) tailscale = status.magicDnsHost ? { host: status.magicDnsHost } : {};
1623
+ }
1624
+ } catch { /* Tailscale discovery is advisory. */ }
1625
+ finally { if (probeTimer) clearTimeout(probeTimer); }
1626
+ try { debug.log('onboarding.tailscale', 'probed', { installed, alive, offered: !!tailscale }); }
1627
+ catch { /* Logging must not interrupt setup. */ }
1628
+ const directions = continueHereLines({ daemonRunning, pwaUrl, phoneLink, telegramEnabled: marked.telegram.enabled, interactive, tailscale });
1575
1629
  if (io.nonInteractive) {
1576
1630
  for (const line of directions) process.stdout.write(`${line}\n`);
1577
1631
  } else {
@@ -0,0 +1,105 @@
1
+ // PS1 — 페르소나 프리셋(MK 문면 · persona-presets/<id>.yaml)을 «내 페르소나»로 만든다.
2
+ // 용어: «페르소나»(대표 10-01) — 사용자가 고르고 꾸미는 AI 동료. ⚠️ `SessionSurfaceId`(이름만 Surface 인 캐릭터 축) ·
3
+ // `modelTier.persona`(모델 등급 축)와는 «다른 것»이다 — 여기서 만드는 것은 기존 페르소나 저장소(`resolveStatePersonaDir()`,
4
+ // Discord·PWA 가 이미 읽는 `<id>.yaml`)의 한 파일이다. 프리셋 원본은 고치지 않는다.
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { parse, stringify } from 'yaml';
8
+
9
+ export interface PersonaPreset {
10
+ personaId: string;
11
+ displayName: string;
12
+ names: string[];
13
+ role: string;
14
+ title?: string | null;
15
+ oneLine: string;
16
+ voice: { rule: string; examples: string[] };
17
+ tasks: Array<{ when: string; what: string }>;
18
+ tools: Array<{ name: string; from: string; optional?: boolean }>;
19
+ firstQuestions: string[];
20
+ askBefore?: string[];
21
+ doesNot: string[];
22
+ forWhom: string;
23
+ }
24
+
25
+ export interface PresetCommon { loop_start?: string; always_ask?: string[]; memory?: string; title?: string }
26
+
27
+ /** `persona-presets/` shipped with this package (src/persona → ../../persona-presets). */
28
+ export function presetsDir(): string {
29
+ return join(import.meta.dir, '..', '..', 'persona-presets');
30
+ }
31
+
32
+ export function loadPresetIndex(dir = presetsDir()): { order: string[]; common: PresetCommon } {
33
+ const index = parse(readFileSync(join(dir, '_index.yaml'), 'utf8')) as { presets?: string[]; common?: PresetCommon };
34
+ return { order: index.presets ?? [], common: index.common ?? {} };
35
+ }
36
+
37
+ /** Presets in the index order; files not in the index come last (sorted). */
38
+ export function loadPresets(dir = presetsDir()): PersonaPreset[] {
39
+ if (!existsSync(dir)) return [];
40
+ const { order } = loadPresetIndex(dir);
41
+ const files = readdirSync(dir).filter((f) => f.endsWith('.yaml') && !f.startsWith('_')).map((f) => f.slice(0, -5));
42
+ const ids = [...order.filter((id) => files.includes(id)), ...files.filter((id) => !order.includes(id)).sort()];
43
+ return ids.map((id) => parse(readFileSync(join(dir, `${id}.yaml`), 'utf8')) as PersonaPreset);
44
+ }
45
+
46
+ /** Find by id or by any of its names (case-insensitive). */
47
+ export function findPreset(presets: readonly PersonaPreset[], query: string): PersonaPreset | undefined {
48
+ const q = query.trim().toLowerCase();
49
+ return presets.find((p) => p.personaId === q) ?? presets.find((p) => p.names.some((n) => n.toLowerCase() === q) || p.role === query.trim());
50
+ }
51
+
52
+ /** The persona's working instructions, assembled from the preset (and the shared rules). */
53
+ export function presetSystemPrompt(p: PersonaPreset, common: PresetCommon, name: string): string {
54
+ const lines = [
55
+ `너는 «${name}», ${p.role}다. ${p.oneLine}`,
56
+ '',
57
+ `말투: ${p.voice.rule}`,
58
+ ...p.voice.examples.map((e) => ` 예) ${e}`),
59
+ '',
60
+ '맡은 일:',
61
+ ...p.tasks.map((t) => `- ${t.when}: ${t.what}`),
62
+ '',
63
+ `처음 만나면 묻는다: ${p.firstQuestions.join(' / ')}`,
64
+ `먼저 확인을 받는다: ${[...(p.askBefore ?? []), ...(common.always_ask ?? [])].join(' · ')}`,
65
+ '하지 않는다:',
66
+ ...p.doesNot.map((d) => `- ${d}`),
67
+ ];
68
+ if (common.loop_start) lines.push('', `시작 방식: ${common.loop_start}`);
69
+ return lines.join('\n');
70
+ }
71
+
72
+ /** ASCII id for the profile file: from --name when it is latin, else the preset id; `-2`, `-3`… when taken. */
73
+ export function choosePersonaId(preset: PersonaPreset, name: string | undefined, taken: ReadonlySet<string>): string {
74
+ const fromName = name?.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
75
+ const base = fromName && /^[a-z0-9]/.test(fromName) ? fromName.slice(0, 32) : preset.personaId;
76
+ if (!taken.has(base)) return base;
77
+ for (let i = 2; ; i++) if (!taken.has(`${base}-${i}`)) return `${base}-${i}`;
78
+ }
79
+
80
+ /** The profile YAML written to the persona store (the existing loader reads it; `preset` records where it came from). */
81
+ export function presetToProfileYaml(p: PersonaPreset, common: PresetCommon, opts: { personaId: string; name?: string; title?: string }): string {
82
+ const name = opts.name?.trim() || p.names[0] || p.displayName;
83
+ const title = opts.title ?? p.title ?? undefined;
84
+ const profile = {
85
+ personaId: opts.personaId,
86
+ displayName: opts.name?.trim() ? `${opts.name.trim()} · ${p.role}` : p.displayName,
87
+ description: p.oneLine,
88
+ systemPrompt: presetSystemPrompt(p, common, name),
89
+ preset: { id: p.personaId, role: p.role, ...(title ? { title } : {}), tools: p.tools.map((t) => t.name), firstQuestions: p.firstQuestions },
90
+ };
91
+ return `# 프리셋 «${p.personaId}» 에서 만든 페르소나 — elanous persona add (원본: persona-presets/${p.personaId}.yaml)\n${stringify(profile, { lineWidth: 0 })}`;
92
+ }
93
+
94
+ /** Short block for `persona show` and `persona list`. */
95
+ export function describePreset(p: PersonaPreset): string[] {
96
+ return [
97
+ `${p.displayName} — ${p.role}${p.title ? ` (${p.title})` : ''}`,
98
+ ` ${p.oneLine}`,
99
+ ` 누구에게: ${p.forWhom}`,
100
+ ` 맡는 일: ${p.tasks.map((t) => `${t.when} ${t.what}`).join(' / ')}`,
101
+ ` 쓰는 도구: ${p.tools.map((t) => (t.optional ? `${t.name}(선택 · 따로 설치)` : t.name)).join(', ')}`,
102
+ ` 처음 묻는 것: ${p.firstQuestions.join(' / ')}`,
103
+ ` 하지 않는 것: ${p.doesNot.join(' / ')}`,
104
+ ];
105
+ }
@@ -8,6 +8,8 @@ import { CliUserError } from '../cli/cli-user-error.js';
8
8
 
9
9
  export type ChecklistStatus = 'green' | 'yellow' | 'red' | 'done';
10
10
  export type ChecklistDisposition = 'move' | 'known-issue' | 'block';
11
+ /** `screen` = a five-surface feature cell — its evidence carries a `짝:` line (MANUAL-five-surface-parity §A). */
12
+ export type ChecklistKind = 'screen';
11
13
  export interface ChecklistItem {
12
14
  id: string;
13
15
  title: string;
@@ -15,6 +17,7 @@ export interface ChecklistItem {
15
17
  owner?: string;
16
18
  evidence?: string;
17
19
  disposition?: ChecklistDisposition;
20
+ kind?: ChecklistKind;
18
21
  updatedAt: string;
19
22
  updatedBy: string;
20
23
  }
@@ -110,27 +113,29 @@ function change(data: Checklist, id: string, field: string, from: unknown, to: u
110
113
 
111
114
  function refresh(data: Checklist): void { data.released = releasedVersion(); data.dev = devVersion(); }
112
115
 
113
- export function addItem(v: string, input: { id: string; title: string; owner?: string }): Checklist {
116
+ export function addItem(v: string, input: { id: string; title: string; owner?: string; kind?: ChecklistKind }): Checklist {
114
117
  if (!input.id.trim()) throw new CliUserError('칸 id 가 비었다');
115
118
  if (!input.title.trim()) throw new CliUserError('칸 제목이 비었다');
119
+ if (input.kind !== undefined && input.kind !== 'screen') throw new CliUserError(`잘못된 종류: ${input.kind}`, 'screen');
116
120
  return mutate(v, (data) => {
117
121
  if (data.items.some((item) => item.id === input.id)) throw new CliUserError(`이미 있는 칸: ${input.id}`, 'set <id> 로 고친다');
118
122
  const by = process.env.ELANOUS_TRACK || 'cli';
119
123
  const at = new Date().toISOString();
120
- const item: ChecklistItem = { id: input.id, title: input.title, status: 'yellow', ...(input.owner !== undefined ? { owner: input.owner } : {}), updatedAt: at, updatedBy: by };
124
+ const item: ChecklistItem = { id: input.id, title: input.title, status: 'yellow', ...(input.owner !== undefined ? { owner: input.owner } : {}), ...(input.kind !== undefined ? { kind: input.kind } : {}), updatedAt: at, updatedBy: by };
121
125
  data.items.push(item);
122
126
  change(data, item.id, 'add', null, item, by, at);
123
127
  return true;
124
128
  });
125
129
  }
126
130
 
127
- export function setItem(v: string, id: string, patch: { status?: ChecklistStatus; evidence?: string; owner?: string; disposition?: ChecklistDisposition }, by: string): Checklist {
131
+ export function setItem(v: string, id: string, patch: { status?: ChecklistStatus; evidence?: string; owner?: string; disposition?: ChecklistDisposition; kind?: ChecklistKind }, by: string): Checklist {
128
132
  return mutate(v, (data) => {
129
133
  const item = data.items.find((i) => i.id === id);
130
134
  if (!item) throw new CliUserError(`없는 칸: ${id}`, 'list 로 칸 목록을 본다');
131
135
  if (patch.status !== undefined && !['green', 'yellow', 'red', 'done'].includes(patch.status)) throw new CliUserError(`잘못된 상태: ${patch.status}`);
132
136
  if (patch.disposition !== undefined && !['move', 'known-issue', 'block'].includes(patch.disposition)) throw new CliUserError(`잘못된 처분: ${patch.disposition}`);
133
- const fields = (['evidence', 'owner', 'status', 'disposition'] as const).filter((field) => patch[field] !== undefined && patch[field] !== item[field]);
137
+ if (patch.kind !== undefined && patch.kind !== 'screen') throw new CliUserError(`잘못된 종류: ${patch.kind}`, 'screen');
138
+ const fields = (['evidence', 'owner', 'status', 'disposition', 'kind'] as const).filter((field) => patch[field] !== undefined && patch[field] !== item[field]);
134
139
  if (fields.length === 0) return false;
135
140
  const at = new Date().toISOString();
136
141
  for (const field of fields) {
@@ -180,11 +185,29 @@ export interface ChecklistGate {
180
185
  blocked: string[];
181
186
  moved: string[];
182
187
  knownIssues: Array<{ id: string; title: string; evidence: string }>;
188
+ /** Screen cells whose `짝:` line is missing or has an untracked ⏳ — a warning; it never changes `ok`. */
189
+ parity?: Array<{ id: string; why: string }>;
190
+ }
191
+
192
+ const PARITY_LINE = /짝:\s*PWA\s*(.+?)\s*·\s*데스크톱\s*(.+?)\s*·\s*폴드\s*(.+?)\s*·\s*아이폰\s*(.+?)\s*·\s*아이패드\s*(.+?)\s*$/m;
193
+ const PARITY_SURFACES = ['PWA', '데스크톱', '폴드', '아이폰', '아이패드'] as const;
194
+
195
+ /** Why a screen cell's evidence fails the five-surface rule, or null when it passes. */
196
+ export function parityGap(evidence: string | undefined): string | null {
197
+ if (!evidence || !/짝:/.test(evidence)) return '근거에 짝: 줄이 없다';
198
+ const match = PARITY_LINE.exec(evidence);
199
+ if (!match) return '짝: 줄에 다섯 열(PWA · 데스크톱 · 폴드 · 아이폰 · 아이패드)이 다 없다';
200
+ const untracked = PARITY_SURFACES.filter((_, i) => match[i + 1]!.includes('⏳') && !/\(칸 /.test(match[i + 1]!));
201
+ return untracked.length ? `⏳ 에 (칸 …) 번호가 없다: ${untracked.join(', ')}` : null;
183
202
  }
184
203
 
185
204
  export function checklistGate(v: string): ChecklistGate {
186
- const result: ChecklistGate = { ok: true, red: [], undecided: [], blocked: [], moved: [], knownIssues: [] };
205
+ const result: ChecklistGate = { ok: true, red: [], undecided: [], blocked: [], moved: [], knownIssues: [], parity: [] };
187
206
  for (const item of listChecklist(v).items) {
207
+ if (item.kind === 'screen') {
208
+ const why = parityGap(item.evidence);
209
+ if (why) result.parity!.push({ id: item.id, why });
210
+ }
188
211
  if (item.status === 'red') result.red.push(item.id);
189
212
  if (item.status !== 'yellow') continue;
190
213
  if (item.disposition === 'block') result.blocked.push(item.id);
@@ -987,6 +987,23 @@ async function deleteLabelledJobs(runId: string, selectedTargets: readonly { con
987
987
  return counts.reduce((sum, count) => sum + count, 0);
988
988
  }
989
989
 
990
+ /** `self-dev-runs/<runId>/pid.json` — what `harness stop <runId>` reads (start time guards PID reuse).
991
+ * HS1: written as soon as the run ID exists, not only when the jobs start — pod planning and image sync can
992
+ * take minutes before that, and a stop in that window found «no process» (10-01 · three runs). */
993
+ export function writeRunPidRecord(runId: string, runsDir: string = selfDevRunsDir()): string {
994
+ const pidPath = join(runsDir, runId, 'pid.json');
995
+ mkdirSync(join(runsDir, runId), { recursive: true });
996
+ const temporary = `${pidPath}.${process.pid}.${randomUUID()}.tmp`;
997
+ const fd = openSync(temporary, 'wx', 0o600);
998
+ try {
999
+ try {
1000
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() - Math.floor(process.uptime() * 1000), argv0: process.argv0 }));
1001
+ } finally { closeSync(fd); }
1002
+ renameSync(temporary, pidPath);
1003
+ } catch (error) { unlinkSync(temporary); throw error; }
1004
+ return pidPath;
1005
+ }
1006
+
990
1007
  /**
991
1008
  * Run N self-dev jobs in parallel under the TOX dispatcher and resolve
992
1009
  * once every job reaches a terminal status.
@@ -1419,17 +1436,7 @@ export function orchestrateSelfDev(opts: OrchestrateSelfDevOptions): Promise<Sel
1419
1436
  signals.on('SIGTERM', onTerm);
1420
1437
  signals.on('SIGINT', onInt);
1421
1438
  try {
1422
- if (pidPath) {
1423
- mkdirSync(join(opts.runsDir ?? selfDevRunsDir(), runId!), { recursive: true });
1424
- const temporary = `${pidPath}.${process.pid}.${randomUUID()}.tmp`;
1425
- const fd = openSync(temporary, 'wx', 0o600);
1426
- try {
1427
- try {
1428
- writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() - Math.floor(process.uptime() * 1000), argv0: process.argv0 }));
1429
- } finally { closeSync(fd); }
1430
- renameSync(temporary, pidPath);
1431
- } catch (error) { unlinkSync(temporary); throw error; }
1432
- }
1439
+ if (pidPath) writeRunPidRecord(runId!, opts.runsDir ?? selfDevRunsDir());
1433
1440
  } catch (error) { cleanup(); reject(error); return; }
1434
1441
 
1435
1442
  // Current full result set: resumed-done goals + each running task's
@@ -88,7 +88,7 @@ export function parsePrintenv(raw: string): Record<string, string> {
88
88
  * - HOME, USER, LOGNAME, SHELL, TERM
89
89
  * - LANG / LC_* — locale matters for some prompts (starship, powerlevel)
90
90
  * Everything else is left for the login shell itself to define. */
91
- export function runPrintenvCapture(): Record<string, string> | null {
91
+ function captureSeed(): { shell: string; seedEnv: NodeJS.ProcessEnv } | null {
92
92
  const shell = process.env.SHELL;
93
93
  if (!shell) return null;
94
94
  const base = basename(shell);
@@ -103,6 +103,22 @@ export function runPrintenvCapture(): Record<string, string> | null {
103
103
  for (const [k, v] of Object.entries(process.env)) {
104
104
  if (k.startsWith('LC_') && typeof v === 'string') seedEnv[k] = v;
105
105
  }
106
+ return { shell, seedEnv };
107
+ }
108
+
109
+ /** printenv output → env, or null when it does not look like a real login env. */
110
+ function acceptCapture(stdout: string): Record<string, string> | null {
111
+ if (!stdout) return null;
112
+ const parsed = parsePrintenv(stdout);
113
+ // Sanity: require PATH and at least 5 keys, else something is off.
114
+ if (!parsed.PATH || Object.keys(parsed).length < 5) return null;
115
+ return parsed;
116
+ }
117
+
118
+ export function runPrintenvCapture(): Record<string, string> | null {
119
+ const seed = captureSeed();
120
+ if (!seed) return null;
121
+ const { shell, seedEnv } = seed;
106
122
 
107
123
  let res: ReturnType<typeof spawnSync>;
108
124
  try {
@@ -119,12 +135,33 @@ export function runPrintenvCapture(): Record<string, string> | null {
119
135
  }
120
136
  if (res.status !== 0 && res.status !== null) return null;
121
137
  if (res.signal) return null;
122
- const stdout = typeof res.stdout === 'string' ? res.stdout : '';
123
- if (!stdout) return null;
124
- const parsed = parsePrintenv(stdout);
125
- // Sanity: require PATH and at least 5 keys, else something is off.
126
- if (!parsed.PATH || Object.keys(parsed).length < 5) return null;
127
- return parsed;
138
+ return acceptCapture(typeof res.stdout === 'string' ? res.stdout : '');
139
+ }
140
+
141
+ /** Fill the login-env cache off the event loop (daemon boot). The first web-terminal spawn otherwise ran the
142
+ * `-l -i` printenv with spawnSync and froze the whole daemon for ~3 s (10-02 TERM1). A no-op once captured;
143
+ * a failed warm leaves the cache empty so `getCapturedEnv()` still tries (and falls back) as before. */
144
+ export async function warmCapturedEnv(spawn: typeof Bun.spawn = Bun.spawn): Promise<boolean> {
145
+ if (captured) return cached !== null;
146
+ if (process.env.ELANOUS_SKIP_LOGIN_ENV === '1' || process.env.TERM_PROGRAM === 'monad-agent-nested') return false;
147
+ const seed = captureSeed();
148
+ if (!seed) return false;
149
+ const start = Date.now();
150
+ try {
151
+ const proc = spawn([seed.shell, '-l', '-i', '-c', 'printenv'], { env: seed.seedEnv as Record<string, string>, stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' });
152
+ const timer = setTimeout(() => proc.kill(), TIMEOUT_MS);
153
+ const [out, code] = await Promise.all([new Response(proc.stdout as ReadableStream).text(), proc.exited]);
154
+ clearTimeout(timer);
155
+ const parsed = code === 0 ? acceptCapture(out) : null;
156
+ if (captured) return cached !== null; // a synchronous capture won the race
157
+ if (!parsed) return false;
158
+ cached = parsed;
159
+ captured = true;
160
+ debug.log('shell.envbootstrap.capture', 'warmed', { ok: true, ms: Date.now() - start, keyCount: Object.keys(parsed).length });
161
+ return true;
162
+ } catch {
163
+ return false;
164
+ }
128
165
  }
129
166
 
130
167
  /** Return the captured login env, running the capture on first call.