@synap-core/cli 1.11.0 → 1.13.0

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 (533) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -198
  3. package/dist/build-info.json +6 -0
  4. package/dist/commands/agent-run.js +3 -2
  5. package/dist/commands/agent-run.js.map +1 -1
  6. package/dist/commands/agents.d.ts +32 -17
  7. package/dist/commands/agents.js +203 -237
  8. package/dist/commands/agents.js.map +1 -1
  9. package/dist/commands/app.d.ts +143 -0
  10. package/dist/commands/app.js +524 -0
  11. package/dist/commands/app.js.map +1 -0
  12. package/dist/commands/automate.d.ts +21 -0
  13. package/dist/commands/automate.js +42 -0
  14. package/dist/commands/automate.js.map +1 -0
  15. package/dist/commands/automation-schema.test.d.ts +16 -0
  16. package/dist/commands/automation-schema.test.js +77 -0
  17. package/dist/commands/automation-schema.test.js.map +1 -0
  18. package/dist/commands/automation.d.ts +12 -0
  19. package/dist/commands/automation.js +116 -66
  20. package/dist/commands/automation.js.map +1 -1
  21. package/dist/commands/box.d.ts +42 -0
  22. package/dist/commands/box.js +89 -0
  23. package/dist/commands/box.js.map +1 -0
  24. package/dist/commands/bridge-setup.d.ts +14 -1
  25. package/dist/commands/bridge-setup.js +395 -129
  26. package/dist/commands/bridge-setup.js.map +1 -1
  27. package/dist/commands/cap-connections.d.ts +4 -4
  28. package/dist/commands/cap-connections.js +14 -14
  29. package/dist/commands/cap-connections.js.map +1 -1
  30. package/dist/commands/capability.d.ts +3 -1
  31. package/dist/commands/capability.js +249 -176
  32. package/dist/commands/capability.js.map +1 -1
  33. package/dist/commands/catalog.d.ts +275 -0
  34. package/dist/commands/catalog.js +481 -0
  35. package/dist/commands/catalog.js.map +1 -0
  36. package/dist/commands/cell.d.ts +155 -8
  37. package/dist/commands/cell.js +416 -40
  38. package/dist/commands/cell.js.map +1 -1
  39. package/dist/commands/centrality.js +1 -13
  40. package/dist/commands/centrality.js.map +1 -1
  41. package/dist/commands/close-questions.d.ts +27 -0
  42. package/dist/commands/close-questions.js +49 -0
  43. package/dist/commands/close-questions.js.map +1 -0
  44. package/dist/commands/close-questions.test.d.ts +1 -0
  45. package/dist/commands/close-questions.test.js +52 -0
  46. package/dist/commands/close-questions.test.js.map +1 -0
  47. package/dist/commands/connect.d.ts +36 -13
  48. package/dist/commands/connect.js +68 -167
  49. package/dist/commands/connect.js.map +1 -1
  50. package/dist/commands/connections.d.ts +3 -2
  51. package/dist/commands/connections.js +95 -25
  52. package/dist/commands/connections.js.map +1 -1
  53. package/dist/commands/context.js +79 -44
  54. package/dist/commands/context.js.map +1 -1
  55. package/dist/commands/data-extra.d.ts +34 -0
  56. package/dist/commands/data-extra.js +203 -25
  57. package/dist/commands/data-extra.js.map +1 -1
  58. package/dist/commands/data.d.ts +120 -2
  59. package/dist/commands/data.js +295 -21
  60. package/dist/commands/data.js.map +1 -1
  61. package/dist/commands/diagnose.d.ts +66 -4
  62. package/dist/commands/diagnose.js +285 -28
  63. package/dist/commands/diagnose.js.map +1 -1
  64. package/dist/commands/doc.d.ts +11 -4
  65. package/dist/commands/doc.js +39 -14
  66. package/dist/commands/doc.js.map +1 -1
  67. package/dist/commands/doctor.d.ts +133 -3
  68. package/dist/commands/doctor.js +761 -41
  69. package/dist/commands/doctor.js.map +1 -1
  70. package/dist/commands/explain.js +5 -5
  71. package/dist/commands/explain.js.map +1 -1
  72. package/dist/commands/hooks.d.ts +19 -0
  73. package/dist/commands/hooks.js +70 -0
  74. package/dist/commands/hooks.js.map +1 -0
  75. package/dist/commands/import.d.ts +393 -13
  76. package/dist/commands/import.js +1150 -54
  77. package/dist/commands/import.js.map +1 -1
  78. package/dist/commands/init.d.ts +34 -9
  79. package/dist/commands/init.js +481 -981
  80. package/dist/commands/init.js.map +1 -1
  81. package/dist/commands/keys.d.ts +1 -0
  82. package/dist/commands/keys.js +15 -2
  83. package/dist/commands/keys.js.map +1 -1
  84. package/dist/commands/knowledge.d.ts +22 -6
  85. package/dist/commands/knowledge.js +121 -57
  86. package/dist/commands/knowledge.js.map +1 -1
  87. package/dist/commands/lens.d.ts +37 -1
  88. package/dist/commands/lens.js +194 -72
  89. package/dist/commands/lens.js.map +1 -1
  90. package/dist/commands/market-authoring.d.ts +29 -2
  91. package/dist/commands/market-authoring.js +348 -7
  92. package/dist/commands/market-authoring.js.map +1 -1
  93. package/dist/commands/market-official.d.ts +58 -0
  94. package/dist/commands/market-official.js +84 -0
  95. package/dist/commands/market-official.js.map +1 -0
  96. package/dist/commands/market.d.ts +400 -16
  97. package/dist/commands/market.js +1802 -146
  98. package/dist/commands/market.js.map +1 -1
  99. package/dist/commands/mcp.d.ts +60 -25
  100. package/dist/commands/mcp.js +251 -64
  101. package/dist/commands/mcp.js.map +1 -1
  102. package/dist/commands/observe.category.test.d.ts +1 -0
  103. package/dist/commands/observe.category.test.js +59 -0
  104. package/dist/commands/observe.category.test.js.map +1 -0
  105. package/dist/commands/observe.d.ts +3 -0
  106. package/dist/commands/observe.js +7 -5
  107. package/dist/commands/observe.js.map +1 -1
  108. package/dist/commands/observe.title.test.d.ts +1 -0
  109. package/dist/commands/observe.title.test.js +37 -0
  110. package/dist/commands/observe.title.test.js.map +1 -0
  111. package/dist/commands/open.d.ts +39 -9
  112. package/dist/commands/open.js +80 -25
  113. package/dist/commands/open.js.map +1 -1
  114. package/dist/commands/playbook.d.ts +42 -0
  115. package/dist/commands/playbook.js +171 -0
  116. package/dist/commands/playbook.js.map +1 -0
  117. package/dist/commands/pod.js +1 -1
  118. package/dist/commands/pod.js.map +1 -1
  119. package/dist/commands/pods.d.ts +11 -1
  120. package/dist/commands/pods.js +129 -131
  121. package/dist/commands/pods.js.map +1 -1
  122. package/dist/commands/proposals-actions.d.ts +62 -0
  123. package/dist/commands/proposals-actions.js +143 -8
  124. package/dist/commands/proposals-actions.js.map +1 -1
  125. package/dist/commands/proposals-await.d.ts +68 -0
  126. package/dist/commands/proposals-await.js +248 -0
  127. package/dist/commands/proposals-await.js.map +1 -0
  128. package/dist/commands/proton-setup.d.ts +73 -0
  129. package/dist/commands/proton-setup.js +579 -0
  130. package/dist/commands/proton-setup.js.map +1 -0
  131. package/dist/commands/providers.d.ts +23 -0
  132. package/dist/commands/providers.js +126 -1
  133. package/dist/commands/providers.js.map +1 -1
  134. package/dist/commands/raycast.js +6 -2
  135. package/dist/commands/raycast.js.map +1 -1
  136. package/dist/commands/rule.d.ts +150 -0
  137. package/dist/commands/rule.js +447 -0
  138. package/dist/commands/rule.js.map +1 -0
  139. package/dist/commands/session-feed-wait.d.ts +48 -0
  140. package/dist/commands/session-feed-wait.js +261 -0
  141. package/dist/commands/session-feed-wait.js.map +1 -0
  142. package/dist/commands/session-post.d.ts +40 -0
  143. package/dist/commands/session-post.js +96 -0
  144. package/dist/commands/session-post.js.map +1 -0
  145. package/dist/commands/session-wait.d.ts +116 -0
  146. package/dist/commands/session-wait.js +224 -0
  147. package/dist/commands/session-wait.js.map +1 -0
  148. package/dist/commands/sessions.d.ts +54 -0
  149. package/dist/commands/sessions.js +501 -58
  150. package/dist/commands/sessions.js.map +1 -1
  151. package/dist/commands/skill-manage.d.ts +21 -0
  152. package/dist/commands/skill-manage.js +136 -19
  153. package/dist/commands/skill-manage.js.map +1 -1
  154. package/dist/commands/skills-commands.d.ts +2 -0
  155. package/dist/commands/skills-commands.js +9 -1
  156. package/dist/commands/skills-commands.js.map +1 -1
  157. package/dist/commands/skills-commands.test.d.ts +1 -0
  158. package/dist/commands/skills-commands.test.js +57 -0
  159. package/dist/commands/skills-commands.test.js.map +1 -0
  160. package/dist/commands/skills.js +16 -0
  161. package/dist/commands/skills.js.map +1 -1
  162. package/dist/commands/status.d.ts +1 -4
  163. package/dist/commands/status.js +6 -35
  164. package/dist/commands/status.js.map +1 -1
  165. package/dist/commands/statusline.backoff.test.d.ts +13 -0
  166. package/dist/commands/statusline.backoff.test.js +100 -0
  167. package/dist/commands/statusline.backoff.test.js.map +1 -0
  168. package/dist/commands/statusline.js +16 -9
  169. package/dist/commands/statusline.js.map +1 -1
  170. package/dist/commands/sync-status.d.ts +12 -0
  171. package/dist/commands/sync-status.js +125 -0
  172. package/dist/commands/sync-status.js.map +1 -0
  173. package/dist/commands/templates-retire.d.ts +34 -0
  174. package/dist/commands/templates-retire.js +192 -0
  175. package/dist/commands/templates-retire.js.map +1 -0
  176. package/dist/commands/tools.js +6 -21
  177. package/dist/commands/tools.js.map +1 -1
  178. package/dist/commands/tracks.d.ts +112 -0
  179. package/dist/commands/tracks.js +543 -0
  180. package/dist/commands/tracks.js.map +1 -0
  181. package/dist/commands/update.d.ts +3 -11
  182. package/dist/commands/update.js +5 -134
  183. package/dist/commands/update.js.map +1 -1
  184. package/dist/commands/upload.d.ts +41 -3
  185. package/dist/commands/upload.js +120 -18
  186. package/dist/commands/upload.js.map +1 -1
  187. package/dist/commands/vendor.d.ts +28 -0
  188. package/dist/commands/vendor.js +110 -0
  189. package/dist/commands/vendor.js.map +1 -0
  190. package/dist/commands/versions.js +6 -0
  191. package/dist/commands/versions.js.map +1 -1
  192. package/dist/commands/view.js +10 -5
  193. package/dist/commands/view.js.map +1 -1
  194. package/dist/commands/whoami.d.ts +30 -3
  195. package/dist/commands/whoami.js +186 -23
  196. package/dist/commands/whoami.js.map +1 -1
  197. package/dist/commands/worker-accounts.d.ts +149 -0
  198. package/dist/commands/worker-accounts.js +339 -0
  199. package/dist/commands/worker-accounts.js.map +1 -0
  200. package/dist/commands/worker-accounts.test.d.ts +1 -0
  201. package/dist/commands/worker-accounts.test.js +224 -0
  202. package/dist/commands/worker-accounts.test.js.map +1 -0
  203. package/dist/commands/worker.d.ts +331 -0
  204. package/dist/commands/worker.js +1067 -0
  205. package/dist/commands/worker.js.map +1 -0
  206. package/dist/commands/worker.test.d.ts +1 -0
  207. package/dist/commands/worker.test.js +427 -0
  208. package/dist/commands/worker.test.js.map +1 -0
  209. package/dist/commands/workspace-ops.d.ts +35 -0
  210. package/dist/commands/workspace-ops.js +153 -0
  211. package/dist/commands/workspace-ops.js.map +1 -0
  212. package/dist/index.d.ts +6 -6
  213. package/dist/index.js +1043 -186
  214. package/dist/index.js.map +1 -1
  215. package/dist/lib/agent-connections.d.ts +31 -0
  216. package/dist/lib/agent-connections.js +33 -0
  217. package/dist/lib/agent-connections.js.map +1 -0
  218. package/dist/lib/agent-detect.d.ts +27 -0
  219. package/dist/lib/agent-detect.js +71 -0
  220. package/dist/lib/agent-detect.js.map +1 -0
  221. package/dist/lib/agent-door.d.ts +136 -0
  222. package/dist/lib/agent-door.js +429 -0
  223. package/dist/lib/agent-door.js.map +1 -0
  224. package/dist/lib/agents-config.d.ts +2 -0
  225. package/dist/lib/agents-config.js +4 -9
  226. package/dist/lib/agents-config.js.map +1 -1
  227. package/dist/lib/approval-poll.d.ts +31 -2
  228. package/dist/lib/approval-poll.js +20 -7
  229. package/dist/lib/approval-poll.js.map +1 -1
  230. package/dist/lib/atomic-file.d.ts +48 -0
  231. package/dist/lib/atomic-file.js +129 -0
  232. package/dist/lib/atomic-file.js.map +1 -0
  233. package/dist/lib/auth.d.ts +17 -11
  234. package/dist/lib/auth.js +47 -64
  235. package/dist/lib/auth.js.map +1 -1
  236. package/dist/lib/batch-approval.d.ts +85 -0
  237. package/dist/lib/batch-approval.js +170 -0
  238. package/dist/lib/batch-approval.js.map +1 -0
  239. package/dist/lib/box/egress.d.ts +82 -0
  240. package/dist/lib/box/egress.js +264 -0
  241. package/dist/lib/box/egress.js.map +1 -0
  242. package/dist/lib/box/evidence.d.ts +86 -0
  243. package/dist/lib/box/evidence.js +121 -0
  244. package/dist/lib/box/evidence.js.map +1 -0
  245. package/dist/lib/box/job-server.d.ts +72 -0
  246. package/dist/lib/box/job-server.js +246 -0
  247. package/dist/lib/box/job-server.js.map +1 -0
  248. package/dist/lib/box/provisioner.d.ts +83 -0
  249. package/dist/lib/box/provisioner.js +312 -0
  250. package/dist/lib/box/provisioner.js.map +1 -0
  251. package/dist/lib/box/proxmox.d.ts +112 -0
  252. package/dist/lib/box/proxmox.js +211 -0
  253. package/dist/lib/box/proxmox.js.map +1 -0
  254. package/dist/lib/box/wire.d.ts +98 -0
  255. package/dist/lib/box/wire.js +70 -0
  256. package/dist/lib/box/wire.js.map +1 -0
  257. package/dist/lib/capture-lane.d.ts +6 -5
  258. package/dist/lib/capture-lane.js +6 -5
  259. package/dist/lib/capture-lane.js.map +1 -1
  260. package/dist/lib/capture-structure.d.ts +230 -11
  261. package/dist/lib/capture-structure.js +251 -13
  262. package/dist/lib/capture-structure.js.map +1 -1
  263. package/dist/lib/catalog-templates.d.ts +59 -0
  264. package/dist/lib/catalog-templates.js +126 -0
  265. package/dist/lib/catalog-templates.js.map +1 -0
  266. package/dist/lib/cli-build.d.ts +19 -0
  267. package/dist/lib/cli-build.js +37 -0
  268. package/dist/lib/cli-build.js.map +1 -0
  269. package/dist/lib/client-header.test.d.ts +1 -0
  270. package/dist/lib/client-header.test.js +85 -0
  271. package/dist/lib/client-header.test.js.map +1 -0
  272. package/dist/lib/conversation-headers.d.ts +33 -0
  273. package/dist/lib/conversation-headers.js +69 -0
  274. package/dist/lib/conversation-headers.js.map +1 -0
  275. package/dist/lib/cp-packages.d.ts +167 -11
  276. package/dist/lib/cp-packages.js +253 -21
  277. package/dist/lib/cp-packages.js.map +1 -1
  278. package/dist/lib/credential-class.d.ts +144 -0
  279. package/dist/lib/credential-class.js +138 -0
  280. package/dist/lib/credential-class.js.map +1 -0
  281. package/dist/lib/describe-lens.d.ts +35 -0
  282. package/dist/lib/describe-lens.js +40 -0
  283. package/dist/lib/describe-lens.js.map +1 -1
  284. package/dist/lib/device-connect.d.ts +125 -0
  285. package/dist/lib/device-connect.js +256 -0
  286. package/dist/lib/device-connect.js.map +1 -0
  287. package/dist/lib/directory-lens.d.ts +99 -0
  288. package/dist/lib/directory-lens.js +187 -0
  289. package/dist/lib/directory-lens.js.map +1 -0
  290. package/dist/lib/exporter-coverage.d.ts +71 -0
  291. package/dist/lib/exporter-coverage.js +97 -0
  292. package/dist/lib/exporter-coverage.js.map +1 -0
  293. package/dist/lib/goal-words.d.ts +38 -0
  294. package/dist/lib/goal-words.js +112 -0
  295. package/dist/lib/goal-words.js.map +1 -0
  296. package/dist/lib/hub-client.d.ts +57 -15
  297. package/dist/lib/hub-client.js +396 -82
  298. package/dist/lib/hub-client.js.map +1 -1
  299. package/dist/lib/init-connect.d.ts +41 -0
  300. package/dist/lib/init-connect.js +75 -0
  301. package/dist/lib/init-connect.js.map +1 -0
  302. package/dist/lib/init-exit.d.ts +24 -0
  303. package/dist/lib/init-exit.js +41 -0
  304. package/dist/lib/init-exit.js.map +1 -0
  305. package/dist/lib/init-flow.d.ts +25 -0
  306. package/dist/lib/init-flow.js +40 -0
  307. package/dist/lib/init-flow.js.map +1 -0
  308. package/dist/lib/init-handoff.d.ts +24 -0
  309. package/dist/lib/init-handoff.js +26 -0
  310. package/dist/lib/init-handoff.js.map +1 -0
  311. package/dist/lib/init-output.d.ts +18 -0
  312. package/dist/lib/init-output.js +43 -0
  313. package/dist/lib/init-output.js.map +1 -0
  314. package/dist/lib/init-pod.d.ts +45 -0
  315. package/dist/lib/init-pod.js +36 -0
  316. package/dist/lib/init-pod.js.map +1 -0
  317. package/dist/lib/installed.d.ts +210 -11
  318. package/dist/lib/installed.js +234 -36
  319. package/dist/lib/installed.js.map +1 -1
  320. package/dist/lib/key-approval.d.ts +57 -0
  321. package/dist/lib/key-approval.js +117 -0
  322. package/dist/lib/key-approval.js.map +1 -0
  323. package/dist/lib/key-diagnosis.d.ts +56 -0
  324. package/dist/lib/key-diagnosis.js +227 -0
  325. package/dist/lib/key-diagnosis.js.map +1 -0
  326. package/dist/lib/key-doctor.d.ts +136 -0
  327. package/dist/lib/key-doctor.js +286 -0
  328. package/dist/lib/key-doctor.js.map +1 -0
  329. package/dist/lib/key-locations.d.ts +120 -0
  330. package/dist/lib/key-locations.js +326 -0
  331. package/dist/lib/key-locations.js.map +1 -0
  332. package/dist/lib/key-source.d.ts +68 -0
  333. package/dist/lib/key-source.js +109 -0
  334. package/dist/lib/key-source.js.map +1 -0
  335. package/dist/lib/key-state.d.ts +70 -0
  336. package/dist/lib/key-state.js +115 -0
  337. package/dist/lib/key-state.js.map +1 -0
  338. package/dist/lib/kind-package.d.ts +181 -0
  339. package/dist/lib/kind-package.js +679 -0
  340. package/dist/lib/kind-package.js.map +1 -0
  341. package/dist/lib/knowledge-title.d.ts +25 -0
  342. package/dist/lib/knowledge-title.js +47 -0
  343. package/dist/lib/knowledge-title.js.map +1 -0
  344. package/dist/lib/knowledge-title.test.d.ts +1 -0
  345. package/dist/lib/knowledge-title.test.js +34 -0
  346. package/dist/lib/knowledge-title.test.js.map +1 -0
  347. package/dist/lib/machine-instance.d.ts +64 -0
  348. package/dist/lib/machine-instance.js +142 -0
  349. package/dist/lib/machine-instance.js.map +1 -0
  350. package/dist/lib/mcp-bridge.d.ts +49 -0
  351. package/dist/lib/mcp-bridge.js +180 -0
  352. package/dist/lib/mcp-bridge.js.map +1 -0
  353. package/dist/lib/mcp-bridge.test.d.ts +1 -0
  354. package/dist/lib/mcp-bridge.test.js +118 -0
  355. package/dist/lib/mcp-bridge.test.js.map +1 -0
  356. package/dist/lib/mcp-call.d.ts +30 -0
  357. package/dist/lib/mcp-call.js +107 -0
  358. package/dist/lib/mcp-call.js.map +1 -0
  359. package/dist/lib/mcp-snippets.d.ts +53 -0
  360. package/dist/lib/mcp-snippets.js +82 -0
  361. package/dist/lib/mcp-snippets.js.map +1 -0
  362. package/dist/lib/next-steps.d.ts +8 -0
  363. package/dist/lib/next-steps.js +31 -1
  364. package/dist/lib/next-steps.js.map +1 -1
  365. package/dist/lib/openclaw.js +3 -2
  366. package/dist/lib/openclaw.js.map +1 -1
  367. package/dist/lib/package-wire.d.ts +179 -0
  368. package/dist/lib/package-wire.js +68 -0
  369. package/dist/lib/package-wire.js.map +1 -0
  370. package/dist/lib/pair-link.d.ts +38 -0
  371. package/dist/lib/pair-link.js +68 -0
  372. package/dist/lib/pair-link.js.map +1 -0
  373. package/dist/lib/pod-moved.d.ts +70 -0
  374. package/dist/lib/pod-moved.js +146 -0
  375. package/dist/lib/pod-moved.js.map +1 -0
  376. package/dist/lib/pod-reach.d.ts +39 -0
  377. package/dist/lib/pod-reach.js +117 -0
  378. package/dist/lib/pod-reach.js.map +1 -0
  379. package/dist/lib/pod-reach.test.d.ts +1 -0
  380. package/dist/lib/pod-reach.test.js +67 -0
  381. package/dist/lib/pod-reach.test.js.map +1 -0
  382. package/dist/lib/pod-wide.d.ts +40 -0
  383. package/dist/lib/pod-wide.js +46 -0
  384. package/dist/lib/pod-wide.js.map +1 -0
  385. package/dist/lib/pod.d.ts +74 -112
  386. package/dist/lib/pod.js +146 -415
  387. package/dist/lib/pod.js.map +1 -1
  388. package/dist/lib/project-ref.d.ts +9 -3
  389. package/dist/lib/project-ref.js +10 -4
  390. package/dist/lib/project-ref.js.map +1 -1
  391. package/dist/lib/register-claude-code-mcp.test.d.ts +1 -0
  392. package/dist/lib/register-claude-code-mcp.test.js +78 -0
  393. package/dist/lib/register-claude-code-mcp.test.js.map +1 -0
  394. package/dist/lib/review-credential.d.ts +119 -0
  395. package/dist/lib/review-credential.js +149 -0
  396. package/dist/lib/review-credential.js.map +1 -0
  397. package/dist/lib/safe-open.d.ts +49 -0
  398. package/dist/lib/safe-open.js +119 -0
  399. package/dist/lib/safe-open.js.map +1 -0
  400. package/dist/lib/session-feed.d.ts +185 -0
  401. package/dist/lib/session-feed.js +257 -0
  402. package/dist/lib/session-feed.js.map +1 -0
  403. package/dist/lib/session-lens.d.ts +40 -1
  404. package/dist/lib/session-lens.js +54 -4
  405. package/dist/lib/session-lens.js.map +1 -1
  406. package/dist/lib/superwhisper-import.d.ts +8 -0
  407. package/dist/lib/superwhisper-import.js +14 -7
  408. package/dist/lib/superwhisper-import.js.map +1 -1
  409. package/dist/lib/superwhisper-title.test.d.ts +1 -0
  410. package/dist/lib/superwhisper-title.test.js +18 -0
  411. package/dist/lib/superwhisper-title.test.js.map +1 -0
  412. package/dist/lib/targets.d.ts +152 -10
  413. package/dist/lib/targets.js +974 -325
  414. package/dist/lib/targets.js.map +1 -1
  415. package/dist/lib/template-file.d.ts +4 -0
  416. package/dist/lib/template-file.js.map +1 -1
  417. package/dist/lib/template-lifecycle.d.ts +217 -0
  418. package/dist/lib/template-lifecycle.js +433 -0
  419. package/dist/lib/template-lifecycle.js.map +1 -0
  420. package/dist/lib/template-steps.d.ts +88 -0
  421. package/dist/lib/template-steps.js +118 -0
  422. package/dist/lib/template-steps.js.map +1 -0
  423. package/dist/lib/time-ago.d.ts +2 -0
  424. package/dist/lib/time-ago.js +15 -0
  425. package/dist/lib/time-ago.js.map +1 -0
  426. package/dist/lib/wake-hook-settings.d.ts +57 -0
  427. package/dist/lib/wake-hook-settings.js +153 -0
  428. package/dist/lib/wake-hook-settings.js.map +1 -0
  429. package/dist/lib/wake-state.d.ts +65 -0
  430. package/dist/lib/wake-state.js +122 -0
  431. package/dist/lib/wake-state.js.map +1 -0
  432. package/dist/utils/logger.js +5 -2
  433. package/dist/utils/logger.js.map +1 -1
  434. package/package.json +26 -17
  435. package/skills/README.md +13 -11
  436. package/skills/agent-os/SKILL.md +214 -99
  437. package/skills/manifest.json +1 -1
  438. package/skills/onboard/SKILL.md +55 -26
  439. package/skills/synap/README.md +5 -8
  440. package/skills/synap/SKILL.md +1149 -289
  441. package/skills/synap/_order.txt +7 -0
  442. package/skills/synap/_skill.yaml +1 -1
  443. package/skills/synap/automations.md +15 -0
  444. package/skills/synap/capture.md +5 -0
  445. package/skills/synap/cli-operations.md +24 -18
  446. package/skills/synap/code-skills.md +70 -0
  447. package/skills/synap/concepts.md +36 -0
  448. package/skills/synap/content-os.md +10 -1
  449. package/skills/synap/creative-loop.md +15 -10
  450. package/skills/synap/crm.md +21 -52
  451. package/skills/synap/document-embeds.md +143 -47
  452. package/skills/synap/escalation-ladder.md +20 -10
  453. package/skills/synap/focus-sessions.md +56 -21
  454. package/skills/synap/from-intent.md +158 -0
  455. package/skills/synap/governance-rules.md +5 -5
  456. package/skills/synap/governance.md +10 -7
  457. package/skills/synap/hand-off.md +33 -0
  458. package/skills/synap/inline-patterns.md +15 -7
  459. package/skills/synap/lenses.md +34 -16
  460. package/skills/synap/linking.md +42 -41
  461. package/skills/synap/mcp-remote-servers.md +64 -0
  462. package/skills/synap/mental-model.md +17 -15
  463. package/skills/synap/operating-mode-rules.md +2 -2
  464. package/skills/synap/processes.md +118 -0
  465. package/skills/synap/quick-reference.md +7 -2
  466. package/skills/synap/reading.md +3 -1
  467. package/skills/synap/reflexes.md +12 -42
  468. package/skills/synap/viewframe-cells.md +38 -5
  469. package/skills/synap/work-flow.md +1 -1
  470. package/skills/synap/work-model.md +110 -0
  471. package/skills/synap/worked-examples.md +4 -5
  472. package/skills/synap/workspace-design.md +15 -11
  473. package/skills/synap/writes.md +105 -25
  474. package/skills/synap/writing-titles.md +63 -0
  475. package/skills/synap-app/SKILL.md +149 -67
  476. package/skills/synap-app/_skill.yaml +1 -1
  477. package/skills/synap-app/creating-the-base-app.md +12 -7
  478. package/skills/synap-app/governance.md +27 -16
  479. package/skills/synap-app/install-commands.md +18 -9
  480. package/skills/synap-app/overview.md +16 -9
  481. package/skills/synap-app/using-the-sdk.md +75 -25
  482. package/skills/synap-design/SKILL.md +474 -0
  483. package/skills/synap-design/_order.txt +4 -0
  484. package/skills/synap-design/_skill.yaml +21 -0
  485. package/skills/synap-design/loop.md +134 -0
  486. package/skills/synap-design/overview.md +44 -0
  487. package/skills/synap-design/principles.md +130 -0
  488. package/skills/synap-design/system.md +133 -0
  489. package/skills/synap-market/SKILL.md +306 -16
  490. package/skills/synap-market/_skill.yaml +1 -1
  491. package/skills/synap-market/author-and-publish.md +145 -8
  492. package/skills/synap-market/governance-and-catalog.md +7 -0
  493. package/skills/synap-market/install.md +79 -1
  494. package/skills/synap-market/overview.md +74 -6
  495. package/skills/synap-schema/SKILL.md +172 -10
  496. package/skills/synap-schema/_order.txt +2 -0
  497. package/skills/synap-schema/_skill.yaml +4 -2
  498. package/skills/synap-schema/common-mistakes.md +4 -2
  499. package/skills/synap-schema/discover-profiles.md +1 -1
  500. package/skills/synap-schema/entity-scope.md +15 -3
  501. package/skills/synap-schema/extend-first.md +29 -0
  502. package/skills/synap-schema/extend-vs-create.md +1 -1
  503. package/skills/synap-schema/intent-vocabulary-ssot.md +111 -0
  504. package/skills/synap-schema/property-types.md +1 -1
  505. package/skills/synap-schema/relations.md +1 -1
  506. package/skills/synap-ui/SKILL.md +19 -12
  507. package/skills/synap-ui/_skill.yaml +1 -1
  508. package/skills/synap-ui/ai-companion.md +1 -1
  509. package/skills/synap-ui/arranging-bento.md +2 -0
  510. package/skills/synap-ui/bento-layouts.md +10 -10
  511. package/skills/synap-ui/bento-recipes.md +313 -543
  512. package/skills/synap-ui/four-builds.md +5 -0
  513. package/skills/synap-ui/view-types.md +2 -2
  514. package/skills/synap-ui/widget-catalog.md +22 -278
  515. package/templates/automations/bookmark-enrichment.automation.json +290 -13
  516. package/templates/automations/url-bookmark-capture.automation.json +73 -11
  517. package/templates/box/runner.py +206 -0
  518. package/templates/skills/authoring-playbooks/SKILL.md +6 -3
  519. package/templates/skills/authoring-playbooks/reference/stages-contract.md +15 -0
  520. package/templates/skills/authoring-playbooks/reference/worked-example.md +3 -0
  521. package/dist/commands/infra.d.ts +0 -21
  522. package/dist/commands/infra.js +0 -262
  523. package/dist/commands/infra.js.map +0 -1
  524. package/node_modules/@synap/hub-rest-client/dist/index.cjs +0 -1261
  525. package/node_modules/@synap/hub-rest-client/dist/index.d.cts +0 -1647
  526. package/node_modules/@synap/hub-rest-client/dist/index.d.ts +0 -1647
  527. package/node_modules/@synap/hub-rest-client/dist/index.js +0 -1231
  528. package/node_modules/@synap/hub-rest-client/package.json +0 -45
  529. package/node_modules/@synap/hub-rest-client/src/client.ts +0 -1631
  530. package/node_modules/@synap/hub-rest-client/src/errors.ts +0 -30
  531. package/node_modules/@synap/hub-rest-client/src/index.ts +0 -153
  532. package/node_modules/@synap/hub-rest-client/src/setup.ts +0 -133
  533. package/node_modules/@synap/hub-rest-client/src/types.ts +0 -1299
@@ -1,34 +1,59 @@
1
1
  /**
2
- * synap import — "capture, but for files and URLs".
2
+ * synap import — "capture, but for files and URLs" (personal OS intake).
3
3
  *
4
- * Routes EVERY input (local file, folder, or http(s) URL) through the SAME AI
5
- * capture centerpiece as free-text `capture`:
6
- * POST /capture/structure (AI entity extraction + workspace/project routing)
7
- * POST /capture/execute (materialize the chosen proposals + relations)
4
+ * Routing (INTAKE-CONSOLIDATION-PLAN Wave 1):
5
+ * - Superwhisper store-first → lib/superwhisper-import (unchanged)
6
+ * - Single file OR single URL → POST /capture/structure + /capture/execute
7
+ * - Any .csv (even ONE) → its own `source: csv` batch on the table
8
+ * door below (a CSV is a table, never prose)
9
+ * - 2 text-like files
10
+ * (md / markdown / txt / csv) → ONE batch via SYNCHRONOUS REST import:
11
+ * POST /import/analyze → human review (or --yes / --dry-run)
12
+ * POST /import/apply → materialize the EXACT previewed ops
13
+ * - ≥CORPUS_THRESHOLD (3) such
14
+ * files → ONE BACKGROUND job (the sync door times out):
15
+ * POST /import/enqueue-corpus → { jobId }, returns immediately
16
+ * GET /import/corpus-job/{id} → poll to "completed"
17
+ * On completion the result is ONE pending governed `import.graph`
18
+ * proposal — NOT stored entities. `--dry-run` never takes this door
19
+ * (enqueueing is itself a write).
20
+ * - Mixed binary/URL + text → capture path for URLs/binaries;
21
+ * batch text via /import/* when N≥2
8
22
  *
9
- * One item per file/url. This inherits AI entity extraction, the relation graph,
10
- * workspace routing AND project routing for free — no separate /import/* path.
23
+ * Directory walks cap at DIR_FILE_CAP (sync analyze max); truncate is warned,
24
+ * never silent. After expansion, batch-eligible text is also capped at
25
+ * GLOBAL_BATCH_CAP (same ceiling) across all inputs — not only per-directory.
26
+ * Workspace/project from --workspace / --project (or cfg);
27
+ * session from --session (active session id).
11
28
  *
12
- * Inputs:
13
- * - http(s) URL → sent as `url`
14
- * - directory → recursively globbed (skips node_modules/.git/dotfiles)
15
- * - file → text-like → utf8, everything else → base64
29
+ * Optional --home-map rewrites batch item paths so the backend path heuristic
30
+ * (workspace **name** as a path segment) pins multi-home destinations. Example:
31
+ * --home-map "Projects=Builder,Posts=Content OS"
32
+ * rewrites `5. Projects/foo.md` → `Builder/5. Projects/foo.md`. No product
33
+ * defaults — user-supplied pairs only. It applies to the corpus door too: the
34
+ * background job forwards `items` verbatim into the SAME
35
+ * `ImportOrchestrator.analyzeLarge` the sync door reaches, so the path
36
+ * heuristic is identical on both.
16
37
  *
17
- * Routing: --workspace / --project override the AI's suggestion; otherwise the
18
- * AI-proposed targetWorkspaceId / targetProjectId from /capture/structure win.
38
+ * No Company OS coupling — pure personal intake doors.
19
39
  */
20
40
  import chalk from "chalk";
21
41
  import { readFileSync, statSync, readdirSync, existsSync } from "node:fs";
22
- import { extname, basename, join } from "node:path";
42
+ import { extname, basename, join, relative } from "node:path";
23
43
  import * as readline from "node:readline/promises";
24
44
  import { stdin as input, stdout as output } from "node:process";
25
45
  import { log } from "../utils/logger.js";
26
- import { resolveHubConfig, resolveUserId, hubGet, hubPost, readActiveSessionId, renderHubError, } from "../lib/hub-client.js";
46
+ import { resolveHubConfig, resolveUserId, hubGet, hubPost, resolveActiveSessionId, renderHubError, } from "../lib/hub-client.js";
27
47
  import { unwrapList } from "../lib/unwrapList.js";
48
+ import { pollForApproval } from "../lib/approval-poll.js";
49
+ import { FLOW, renderNextSteps } from "../lib/next-steps.js";
50
+ import { fetchEffectiveIdentity, isAgentReviewRejection, proposalWebLink, } from "../lib/credential-class.js";
28
51
  import { formatLaneLine, resolveWorkspaceName } from "../lib/capture-lane.js";
29
- import { isDegraded, degradedMessage, readCaptureExecute, } from "../lib/capture-structure.js";
52
+ import { isDegraded, degradedMessage, readCaptureExecute, formatWorkspaceRouting, formatPendingWorkspaceSwitch, headlessPlacementFields, projectRoutingHints, workspaceRoutingHints, } from "../lib/capture-structure.js";
30
53
  // ─── input classification ─────────────────────────────────────────────────────
31
54
  const TEXT_LIKE = new Set([".md", ".markdown", ".txt", ".csv", ".json", ".html", ".htm"]);
55
+ /** Extensions eligible for the multi-file REST /import/analyze batch path. */
56
+ const BATCH_TEXT_EXTS = new Set([".md", ".markdown", ".txt", ".csv"]);
32
57
  const MIME = {
33
58
  ".md": "text/markdown",
34
59
  ".markdown": "text/markdown",
@@ -55,26 +80,72 @@ const MIME = {
55
80
  };
56
81
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
57
82
  const URL_RE = /^https?:\/\//i;
58
- /** Cap on files pulled from a single directory — log + truncate, never silently drop. */
59
- const DIR_FILE_CAP = 200;
83
+ /**
84
+ * Cap on files pulled from a single directory — log + truncate, never silently drop.
85
+ * Aligned with sync /import/analyze max items (2000).
86
+ */
87
+ const DIR_FILE_CAP = 2000;
88
+ /**
89
+ * Global cap on batch-eligible text items across ALL inputs (not only one dir).
90
+ * Same ceiling as sync /import/analyze max items.
91
+ */
92
+ const GLOBAL_BATCH_CAP = 2000;
93
+ /** Generous timeout: deep vault structure can take minutes on large corpora. */
94
+ const IMPORT_ANALYZE_TIMEOUT_MS = 300_000;
95
+ const IMPORT_APPLY_TIMEOUT_MS = 120_000;
96
+ /**
97
+ * At or above this many batch-eligible text files, route to the BACKGROUND
98
+ * corpus door (`POST /import/enqueue-corpus`) instead of the synchronous
99
+ * `/import/analyze`.
100
+ *
101
+ * Why 3, measured rather than guessed: 3 markdown files complete in ~2m01s on
102
+ * the background worker, and the SAME 3 files fail 0/5 through /import/analyze,
103
+ * each dying at 45–48s against the pod's 45s request timeout. So 3 is the
104
+ * smallest N at which the synchronous door is observed to be unusable.
105
+ *
106
+ * Below it (1–2 files) the synchronous path stays: it is proven to work at that
107
+ * size and it is strictly more interactive — it renders the operation list, the
108
+ * multi-home placement and the quality report, then applies on a y/N confirm.
109
+ * The corpus door can offer none of that; it returns a job handle and files ONE
110
+ * governed proposal for later review. Routing everything through it would trade
111
+ * a working interactive preview for a background job on inputs that never
112
+ * needed one.
113
+ */
114
+ export const CORPUS_THRESHOLD = 3;
115
+ /** Gap between corpus-job polls. 12 req/min — well under the hub RPM budget. */
116
+ const CORPUS_POLL_INTERVAL_MS = 5_000;
117
+ /** Heartbeat cadence for the human progress line (poll ticks are quieter). */
118
+ const CORPUS_PROGRESS_EVERY_MS = 30_000;
119
+ /** Observed worker throughput: 3 files → ~2m01s ≈ 40s/file. Rounded up. */
120
+ const CORPUS_MS_PER_ITEM = 45_000;
121
+ /** Never wait less than this, even for a 3-file corpus. */
122
+ const CORPUS_MIN_WAIT_MS = 10 * 60_000;
123
+ /**
124
+ * Never block a terminal longer than this. Hitting it is NOT a failure — the
125
+ * job keeps running server-side and the CLI prints how to re-poll.
126
+ */
127
+ const CORPUS_MAX_WAIT_MS = 2 * 60 * 60_000;
60
128
  function mimeFor(path) {
61
129
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
62
130
  }
63
131
  /** Read a single file into an ImportItem (utf8 for text-like, base64 otherwise). */
64
- function fileToItem(path) {
132
+ function fileToItem(path, displayPath) {
65
133
  const ext = extname(path).toLowerCase();
66
134
  const filename = basename(path);
67
135
  const mimeType = mimeFor(path);
136
+ const rel = displayPath ?? path;
68
137
  if (TEXT_LIKE.has(ext)) {
69
138
  return {
70
139
  kind: "file",
71
- label: path,
140
+ label: rel,
141
+ path: rel,
72
142
  file: { content: readFileSync(path, "utf8"), mimeType, filename, encoding: "utf8" },
73
143
  };
74
144
  }
75
145
  return {
76
146
  kind: "file",
77
- label: path,
147
+ label: rel,
148
+ path: rel,
78
149
  file: { content: readFileSync(path).toString("base64"), mimeType, filename, encoding: "base64" },
79
150
  };
80
151
  }
@@ -138,7 +209,9 @@ function expandInputs(inputs) {
138
209
  truncated = true;
139
210
  for (const f of files) {
140
211
  try {
141
- items.push(fileToItem(f));
212
+ // Preserve vault-relative paths so wikilink/relation resolution works.
213
+ const rel = relative(raw, f) || basename(f);
214
+ items.push(fileToItem(f, rel));
142
215
  }
143
216
  catch (e) {
144
217
  failures.push({ label: f, error: e.message });
@@ -156,8 +229,151 @@ function expandInputs(inputs) {
156
229
  }
157
230
  return { items, failures, truncated };
158
231
  }
159
- // Structure/execute wire shapes + the degraded/execute interpreters are shared
160
- // with `synap capture` in lib/capture-structure.ts (ONE mirror, no drift).
232
+ /** True when this item can join a multi-file /import/analyze batch. */
233
+ function isBatchTextItem(item) {
234
+ if (item.kind !== "file")
235
+ return false;
236
+ if (item.file.encoding !== "utf8")
237
+ return false;
238
+ const ext = extname(item.path).toLowerCase() || extname(item.file.filename).toLowerCase();
239
+ return BATCH_TEXT_EXTS.has(ext);
240
+ }
241
+ /**
242
+ * Source heuristic for the batch door:
243
+ * - any .csv → "csv"
244
+ * - mostly .md/.markdown → "markdown"
245
+ * - default → "markdown"
246
+ */
247
+ function sourceForBatchItems(items) {
248
+ if (items.some((i) => i.path.toLowerCase().endsWith(".csv")))
249
+ return "csv";
250
+ const mdCount = items.filter((i) => /\.(md|markdown)$/i.test(i.path)).length;
251
+ if (mdCount * 2 >= items.length)
252
+ return "markdown";
253
+ return "markdown";
254
+ }
255
+ /**
256
+ * Which batch-eligible files go through the table/batch door, and in which
257
+ * batches:
258
+ * - every CSV, even a lone one, in ONE `source: csv` batch. A CSV is a TABLE:
259
+ * the batch door builds a column→kind plan for it, while the single-file
260
+ * capture door reads it as free text (a 1,500-row contacts export came back
261
+ * as one degraded note filed in the wrong space). Keeping CSVs apart also
262
+ * stops a mixed md + csv batch from parsing the markdown as CSV.
263
+ * - the other text files in ONE batch when there are ≥2; a lone one stays on
264
+ * the capture door (unchanged).
265
+ */
266
+ export function groupBatchFiles(files) {
267
+ const isCsv = (f) => f.path.toLowerCase().endsWith(".csv");
268
+ const csv = files.filter(isCsv);
269
+ const text = files.filter((f) => !isCsv(f));
270
+ const batches = [];
271
+ if (csv.length > 0)
272
+ batches.push(csv);
273
+ if (text.length >= 2)
274
+ batches.push(text);
275
+ return { batches, single: text.length >= 2 ? [] : text };
276
+ }
277
+ function toBatchTextItem(item) {
278
+ return {
279
+ path: item.path,
280
+ content: item.file.content,
281
+ label: item.label,
282
+ };
283
+ }
284
+ /**
285
+ * Parse `--home-map "Projects=Builder,Posts=Content OS"` into pairs.
286
+ * Values may be workspace names or UUIDs (resolved later).
287
+ */
288
+ export function parseHomeMap(raw) {
289
+ const pairs = [];
290
+ // Split on commas that separate pairs. Values may contain spaces but not commas.
291
+ for (const part of raw.split(",")) {
292
+ const trimmed = part.trim();
293
+ if (!trimmed)
294
+ continue;
295
+ const eq = trimmed.indexOf("=");
296
+ if (eq <= 0 || eq === trimmed.length - 1) {
297
+ throw new Error(`Invalid --home-map entry '${trimmed}'. Expected pathSubstring=workspaceNameOrId ` +
298
+ `(e.g. Projects=Builder,Posts=Content OS).`);
299
+ }
300
+ const pathSubstring = trimmed.slice(0, eq).trim();
301
+ const workspace = trimmed.slice(eq + 1).trim();
302
+ if (!pathSubstring || !workspace) {
303
+ throw new Error(`Invalid --home-map entry '${trimmed}'. Both pathSubstring and workspace are required.`);
304
+ }
305
+ pairs.push({ pathSubstring, workspace });
306
+ }
307
+ if (pairs.length === 0) {
308
+ throw new Error(`Empty --home-map. Expected pathSubstring=workspaceNameOrId pairs ` +
309
+ `(e.g. Projects=Builder,Posts=Content OS).`);
310
+ }
311
+ return pairs;
312
+ }
313
+ /**
314
+ * Resolve home-map values to workspace **names** (id → name when UUID given).
315
+ * Backend path heuristic matches segment equality against workspace names.
316
+ */
317
+ async function resolveHomeMap(pairs, cfg) {
318
+ const res = (await hubGet("/workspaces", {}, cfg));
319
+ const list = unwrapList(res, ["workspaces"]);
320
+ const names = list.map((w) => String(w.name ?? w.id)).join(", ");
321
+ return pairs.map(({ pathSubstring, workspace }) => {
322
+ if (UUID_RE.test(workspace)) {
323
+ const match = list.find((w) => String(w.id) === workspace);
324
+ if (!match) {
325
+ throw new Error(`Home-map workspace id '${workspace}' not found.${names ? ` Available: ${names}` : ""}`);
326
+ }
327
+ const workspaceName = String(match.name ?? "").trim();
328
+ if (!workspaceName) {
329
+ throw new Error(`Home-map workspace '${workspace}' has no name — cannot rewrite paths.`);
330
+ }
331
+ return { pathSubstring, workspaceName };
332
+ }
333
+ const match = list.find((w) => String(w.name ?? "").toLowerCase() === workspace.toLowerCase());
334
+ if (!match) {
335
+ throw new Error(`Home-map workspace '${workspace}' not found.${names ? ` Available: ${names}` : ""}`);
336
+ }
337
+ return { pathSubstring, workspaceName: String(match.name) };
338
+ });
339
+ }
340
+ /**
341
+ * If `path` contains any map pathSubstring (case-insensitive), prefix with the
342
+ * resolved workspace name so deep-structure segment match pins that home.
343
+ * First matching pair wins. Skips rewrite when the path already starts with the
344
+ * workspace name segment.
345
+ */
346
+ export function applyHomeMapToPath(path, map) {
347
+ if (map.length === 0 || !path)
348
+ return path;
349
+ const lower = path.toLowerCase();
350
+ for (const { pathSubstring, workspaceName } of map) {
351
+ if (!pathSubstring)
352
+ continue;
353
+ if (!lower.includes(pathSubstring.toLowerCase()))
354
+ continue;
355
+ const prefix = workspaceName + "/";
356
+ // Already rewritten or vault already uses workspace name as root.
357
+ if (path === workspaceName ||
358
+ path.startsWith(prefix) ||
359
+ path.toLowerCase().startsWith(prefix.toLowerCase())) {
360
+ return path;
361
+ }
362
+ return `${workspaceName}/${path}`;
363
+ }
364
+ return path;
365
+ }
366
+ /** Apply home-map path rewrite to batch text items (labels stay original). */
367
+ function applyHomeMapToBatchItems(items, map) {
368
+ if (map.length === 0)
369
+ return items;
370
+ return items.map((i) => {
371
+ const next = applyHomeMapToPath(i.path, map);
372
+ if (next === i.path)
373
+ return i;
374
+ return { ...i, path: next };
375
+ });
376
+ }
161
377
  // ─── workspace / project name → id resolution ─────────────────────────────────
162
378
  /** Resolve a --workspace value (id or name) to a workspace id. Throws if unknown. */
163
379
  async function resolveWorkspaceArg(value, cfg) {
@@ -221,17 +437,176 @@ function pct(c) {
221
437
  }
222
438
  /** Print the AI's proposed workspace/project routing for this item. */
223
439
  function renderRouting(result, wsOverride, projOverride) {
224
- const ws = wsOverride ?? result.targetWorkspaceId ?? null;
440
+ // The pod's NAME for its pick, with its confidence once (never a raw id
441
+ // when the name is known). An override is the user's own id, shown as given.
442
+ const ws = wsOverride ? `${wsOverride} (override)` : formatWorkspaceRouting(result);
225
443
  const proj = projOverride ?? result.targetProjectId ?? null;
226
- const wsSrc = wsOverride ? " (override)" : pct(result.targetWorkspaceConfidence);
227
444
  const projSrc = projOverride ? " (override)" : pct(result.targetProjectConfidence);
228
- log.dim(` → workspace: ${ws ?? "pod-wide / unclear"}${wsSrc}`);
445
+ log.dim(` → workspace: ${ws ?? "pod-wide / unclear"}`);
229
446
  if (result.targetWorkspaceReason && !wsOverride)
230
447
  log.dim(` ${result.targetWorkspaceReason.slice(0, 90)}`);
231
448
  log.dim(` → project: ${proj ?? "none / unclear"}${projSrc}`);
232
449
  if (result.targetProjectReason && !projOverride)
233
450
  log.dim(` ${result.targetProjectReason.slice(0, 90)}`);
234
451
  }
452
+ function renderBatchAnalyze(result, fileCount, source) {
453
+ const ops = result.operations ?? [];
454
+ log.heading(`▸ Batch import (${fileCount} file${fileCount !== 1 ? "s" : ""}, source=${source})`);
455
+ if (result.summary)
456
+ log.info(` ${result.summary}`);
457
+ log.dim(` ${ops.length} operation${ops.length !== 1 ? "s" : ""}` +
458
+ (result.mode ? ` · mode ${result.mode}` : "") +
459
+ (result.proposalId ? ` · proposal ${result.proposalId}` : "") +
460
+ (result.sessionId ? ` · session ${result.sessionId}` : ""));
461
+ if (result.stats && typeof result.stats === "object") {
462
+ const s = result.stats;
463
+ const bits = [];
464
+ if (s.entityCount != null)
465
+ bits.push(`${s.entityCount} entities`);
466
+ if (s.relationCount != null)
467
+ bits.push(`${s.relationCount} relations`);
468
+ if (s.itemCount != null)
469
+ bits.push(`${s.itemCount} items`);
470
+ if (s.typeCount != null)
471
+ bits.push(`${s.typeCount} types`);
472
+ if (s.itemsProcessed != null)
473
+ bits.push(`${s.itemsProcessed} processed`);
474
+ if (bits.length > 0)
475
+ log.dim(` stats: ${bits.join(" · ")}`);
476
+ }
477
+ // Multi-home: show where create_entity ops will land (per-op targetWorkspaceId).
478
+ const homes = result.homes;
479
+ if (homes && typeof homes === "object") {
480
+ const parts = [];
481
+ for (const [wid, n] of Object.entries(homes.byWorkspace ?? {})) {
482
+ parts.push(`${wid.slice(0, 8)}…×${n}`);
483
+ }
484
+ if (homes.podWide && homes.podWide > 0)
485
+ parts.push(`pod-wide×${homes.podWide}`);
486
+ if (parts.length > 0) {
487
+ log.info(` homes${homes.multiHome ? " (multi)" : ""}: ${parts.join(" · ")}`);
488
+ }
489
+ const proj = homes.byProject ?? {};
490
+ const projParts = Object.entries(proj).map(([pid, n]) => `${pid.slice(0, 8)}…×${n}`);
491
+ if (projParts.length > 0)
492
+ log.dim(` projects: ${projParts.join(" · ")}`);
493
+ }
494
+ else if (result.workspaceId) {
495
+ log.dim(` → workspace: ${result.workspaceId}`);
496
+ }
497
+ // Quality report — refuse / improve / apply loop
498
+ const q = result.quality;
499
+ if (q && typeof q.score === "number") {
500
+ log.info(` ${q.summary ?? `quality ${q.score}/100`}`);
501
+ const findings = q.findings ?? [];
502
+ for (const f of findings.slice(0, 8)) {
503
+ const tag = (f.severity ?? "info").toUpperCase();
504
+ log.dim(` [${tag}] ${f.message ?? f.id}`);
505
+ }
506
+ const ups = q.nextUpgrades ?? [];
507
+ if (ups.length > 0) {
508
+ log.info(" next upgrades:");
509
+ for (const u of ups.slice(0, 5))
510
+ log.dim(` → ${u}`);
511
+ }
512
+ if (result.proposalId) {
513
+ log.dim(` proposal ${result.proposalId} stores quality+ops — refuse in UI, re-import with upgrades, apply when score looks good`);
514
+ }
515
+ }
516
+ }
517
+ /**
518
+ * Build the outcome for an item the IS structurer could not structure.
519
+ *
520
+ * Carries `degradedReason` so `--json` reports the same cause the human path
521
+ * already prints via `degradedMessage()`. Without it a scripted import saw
522
+ * `{"status":"degraded"}` with no way to tell an auth error from an empty
523
+ * result — the reason existed on the wire and was dropped at this one seam.
524
+ */
525
+ export function degradedOutcome(label, res, workspaceId, projectId) {
526
+ return {
527
+ label,
528
+ status: "degraded",
529
+ workspaceId,
530
+ projectId,
531
+ ...(res.degradedReason ? { degradedReason: res.degradedReason } : {}),
532
+ };
533
+ }
534
+ /**
535
+ * The exit code for a finished import run.
536
+ *
537
+ * A degraded item produced ZERO entities — the IS structurer was down and the
538
+ * CLI deliberately created nothing. Exiting 0 on that told a scripted backfill
539
+ * "success" while most of its input was silently lost, so `degraded` is a
540
+ * non-zero outcome exactly like `failed`.
541
+ *
542
+ * Everything else stays 0, including a clean `--dry-run` (nothing was meant to
543
+ * be written), a `skipped` item (the user declined) and an `empty` one (the
544
+ * structurer ran fine and honestly found nothing to create).
545
+ *
546
+ * Scoped to `import` on purpose: this is `import`'s own final line, not a
547
+ * shared helper, so no other command's exit semantics change.
548
+ */
549
+ export function importExitCode(outcomes) {
550
+ return outcomes.some((o) => o.status === "failed" || o.status === "degraded") ? 1 : 0;
551
+ }
552
+ /**
553
+ * The `keepRaw` half of a `/capture/execute` body — `{}` unless `--keep-raw`
554
+ * was passed for a FILE item.
555
+ *
556
+ * Three things it must get right, all of which the pod silently tolerates:
557
+ * • `content` MUST be base64. `fileToItem` reads text-like files as utf8, and
558
+ * the pod does `Buffer.from(content, "base64")` unconditionally — sending
559
+ * utf8 there stores a mangled blob with no error.
560
+ * • `extractedText` is echoed from the structure response so the stored
561
+ * document lands with a real v1 body. Skipping it leaves
562
+ * `document_versions.content` empty, which blinds the embedding worker, the
563
+ * retrieval body join and Typesense enrichment.
564
+ * • A URL item has no bytes to keep, so `keepRaw` is simply not sent — the pod
565
+ * would accept `keepRaw: true` with no file and do nothing.
566
+ */
567
+ export function buildKeepRawPayload(opts, item, structureRes) {
568
+ if (!opts.keepRaw || item.kind !== "file")
569
+ return {};
570
+ const content = item.file.encoding === "base64"
571
+ ? item.file.content
572
+ : Buffer.from(item.file.content, "utf8").toString("base64");
573
+ const extractedText = structureRes.extraction?.text;
574
+ return {
575
+ keepRaw: true,
576
+ file: {
577
+ content,
578
+ mimeType: item.file.mimeType,
579
+ filename: item.file.filename,
580
+ ...(extractedText ? { extractedText } : {}),
581
+ ...(structureRes.extraction?.textTruncated
582
+ ? { extractedTextTruncated: true }
583
+ : {}),
584
+ },
585
+ };
586
+ }
587
+ /**
588
+ * One line describing what ACTUALLY happened to a kept original. The pod
589
+ * reports four dispositions and they are not interchangeable: a
590
+ * governance-parked attach is not a save, and a policy denial is not a storage
591
+ * failure. Returns null when nothing was kept (so nothing is claimed).
592
+ */
593
+ export function keepRawLine(res) {
594
+ const sf = res.sourceFile;
595
+ if (!sf)
596
+ return null;
597
+ switch (sf.status) {
598
+ case "stored":
599
+ return "original kept and linked to the entity";
600
+ case "proposed":
601
+ return `original awaiting review${sf.reviewUrl ? ` — ${sf.reviewUrl}` : ""}`;
602
+ case "denied":
603
+ return `original NOT kept — policy declined it${sf.reason ? `: ${sf.reason}` : ""}`;
604
+ case "failed":
605
+ return "original NOT kept — the pod could not store it";
606
+ default:
607
+ return null;
608
+ }
609
+ }
235
610
  async function processItem(item, opts, cfg, userId, wsOverride, projOverride, sessionId, rl) {
236
611
  // 1. Structure — workspace hint = override ?? active (the AI may still re-route).
237
612
  const structureBody = {
@@ -247,6 +622,8 @@ async function processItem(item, opts, cfg, userId, wsOverride, projOverride, se
247
622
  // give structure a generous timeout (capture's free-text path is fast).
248
623
  const structureRes = (await hubPost("/capture/structure", structureBody, cfg, 120_000));
249
624
  const wsTarget = wsOverride ?? structureRes.targetWorkspaceId ?? cfg.workspaceId ?? null;
625
+ // What the receipt reports for the project: the user's pin, else the AI's
626
+ // suggestion. Only the PIN is sent as `projectId` (see the execute body).
250
627
  const projTarget = projOverride ?? structureRes.targetProjectId ?? null;
251
628
  const proposals = structureRes.proposals ?? [];
252
629
  // Degraded: the IS structurer is down. Create nothing (same as `synap
@@ -257,7 +634,7 @@ async function processItem(item, opts, cfg, userId, wsOverride, projOverride, se
257
634
  log.heading(`▸ ${item.label}`);
258
635
  log.warn(` ${degradedMessage(structureRes)}`);
259
636
  }
260
- return { label: item.label, status: "degraded", workspaceId: wsTarget, projectId: projTarget };
637
+ return degradedOutcome(item.label, structureRes, wsTarget, projTarget);
261
638
  }
262
639
  if (!opts.json) {
263
640
  log.heading(`▸ ${item.label}`);
@@ -294,9 +671,12 @@ async function processItem(item, opts, cfg, userId, wsOverride, projOverride, se
294
671
  // forward the ambient workspace + the AI's routing signal and let the
295
672
  // backend decide (auto = AI target wins over ambient when confidence is
296
673
  // high enough AND the user is a member).
297
- // NOTE: the hub REST /capture/execute door currently forwards only
298
- // workspaceId (not projectId) to the tRPC caller; projectId is sent for
299
- // forward-compatibility but is not yet applied server-side via this door.
674
+ // The AI's advice goes through the ONE structure → execute mapper
675
+ // (`captureExecuteRoutingHints`, mirrored in capture-structure.ts) as
676
+ // ADVISORY hints. Project: only an explicit --project is a `projectId`
677
+ // pin; the AI's project pick is the advisory `aiProjectId` — promoting it
678
+ // to `projectId` linked it outright (widening cross-workspace access)
679
+ // before the user ever confirmed it.
300
680
  const executeBody = {
301
681
  userId,
302
682
  ...(wsOverride
@@ -304,14 +684,20 @@ async function processItem(item, opts, cfg, userId, wsOverride, projOverride, se
304
684
  : {
305
685
  ...(cfg.workspaceId ? { workspaceId: cfg.workspaceId } : {}),
306
686
  workspaceRouting: "auto",
307
- aiWorkspaceId: structureRes.targetWorkspaceId,
308
- aiWorkspaceConfidence: structureRes.targetWorkspaceConfidence,
309
- aiWorkspaceReason: structureRes.targetWorkspaceReason,
687
+ // Headless door: the structure step's PLACEMENT decides what is
688
+ // pinned (the ONE derivation, shared with the pod's headless doors).
689
+ // An AI suggestion stays a proposal (`workspaceChoice: "ignored"`).
690
+ ...headlessPlacementFields(structureRes),
691
+ ...workspaceRoutingHints(structureRes),
310
692
  }),
311
- ...(projTarget ? { projectId: projTarget } : {}),
693
+ ...(projOverride ? { projectId: projOverride } : projectRoutingHints(structureRes)),
312
694
  ...(sessionId ? { sessionId } : {}),
313
695
  entities: proposals,
314
696
  relations: structureRes.relations ?? [],
697
+ // --keep-raw: preserve the ORIGINAL artifact, not just what the AI read out
698
+ // of it. Without this the imported bytes are discarded, so the created
699
+ // entity has no `documentId` and no source document at all.
700
+ ...buildKeepRawPayload(opts, item, structureRes),
315
701
  };
316
702
  const executeRes = (await hubPost("/capture/execute", executeBody, cfg, 60_000));
317
703
  // Report what the pod ACTUALLY returned. A workspace policy can queue the
@@ -331,9 +717,10 @@ async function processItem(item, opts, cfg, userId, wsOverride, projOverride, se
331
717
  }
332
718
  const entityCount = outcome.entitiesCreated;
333
719
  const relCount = outcome.relationsCreated;
334
- // Final resting workspace: the backend may have moved it (auto routing);
335
- // otherwise fall back to the pre-execute guess.
336
- const finalWorkspaceId = outcome.movedToWorkspace ?? wsTarget;
720
+ // Final resting workspace: the pre-execute target. The ladder NEVER moves a
721
+ // capture on an AI guess (rung 5 proposes), so there is nothing to override
722
+ // it with — a pending suggestion is reported separately, below.
723
+ const finalWorkspaceId = wsTarget;
337
724
  if (!opts.json) {
338
725
  const report = {
339
726
  lane: "work",
@@ -345,11 +732,21 @@ async function processItem(item, opts, cfg, userId, wsOverride, projOverride, se
345
732
  if (outcome.entitiesLinked) {
346
733
  log.dim(` ${outcome.entitiesLinked} linked to existing entit${outcome.entitiesLinked !== 1 ? "ies" : "y"}`);
347
734
  }
348
- console.log(" " + formatLaneLine(report) + (projTarget ? chalk.dim(` [project ${projTarget.slice(0, 8)}]`) : ""));
349
- if (outcome.movedToWorkspace) {
350
- log.dim(` → filed into workspace ${outcome.movedToWorkspace}`);
351
- }
735
+ // Only a --project pin is filed; the AI's pick is a suggestion to confirm.
736
+ const projTag = projOverride
737
+ ? ` [project ${projOverride.slice(0, 8)}]`
738
+ : projTarget
739
+ ? ` [project ${projTarget.slice(0, 8)} suggested]`
740
+ : "";
741
+ console.log(" " + formatLaneLine(report) + (projTag ? chalk.dim(projTag) : ""));
742
+ const pending = formatPendingWorkspaceSwitch(outcome);
743
+ if (pending)
744
+ log.dim(` ${pending}`);
745
+ const kept = keepRawLine(executeRes);
746
+ if (kept)
747
+ log.dim(` ${kept}`);
352
748
  }
749
+ const keptStatus = executeRes.sourceFile?.status;
353
750
  return {
354
751
  label: item.label,
355
752
  status: "stored",
@@ -357,8 +754,605 @@ async function processItem(item, opts, cfg, userId, wsOverride, projOverride, se
357
754
  relations: relCount,
358
755
  workspaceId: finalWorkspaceId,
359
756
  projectId: projTarget,
757
+ // --json parity with the human line above: a scripted import must be able
758
+ // to tell a kept original from a denied or failed one.
759
+ ...(keptStatus ? { sourceFile: keptStatus } : {}),
760
+ };
761
+ }
762
+ /**
763
+ * Judge a completed job's `output`.
764
+ *
765
+ * The ONE place absence is turned into `known: false`. A pod that predates the
766
+ * result-carrying worker returns no output; so does a job whose handler reported
767
+ * nothing. Neither is evidence that every file landed, so neither may collapse
768
+ * into `filesFailed: 0`.
769
+ */
770
+ export function readCorpusFileOutcome(output) {
771
+ if (!output || typeof output.filesFailed !== "number")
772
+ return { known: false };
773
+ return {
774
+ known: true,
775
+ filesProcessed: typeof output.filesProcessed === "number" ? output.filesProcessed : null,
776
+ filesFailed: output.filesFailed,
777
+ };
778
+ }
779
+ /**
780
+ * The outcome status a COMPLETED corpus job earns.
781
+ *
782
+ * The ONE decision point: files were dropped → `degraded`, which `importExitCode`
783
+ * already treats as non-zero (no second exit-code path). An unknown outcome stays
784
+ * `proposed` — the proposal genuinely exists — but the caller must print
785
+ * CORPUS_OUTCOME_UNAVAILABLE so the unknown is never read as "0 failed".
786
+ */
787
+ export function corpusCompletionOutcome(output) {
788
+ const outcome = readCorpusFileOutcome(output);
789
+ if (outcome.known && outcome.filesFailed > 0) {
790
+ return {
791
+ status: "degraded",
792
+ degradedReason: `corpus_files_failed:${outcome.filesFailed}`,
793
+ };
794
+ }
795
+ return { status: "proposed" };
796
+ }
797
+ /** The message shown when the pod cannot tell us what happened per file. */
798
+ export const CORPUS_OUTCOME_UNAVAILABLE = "file-level outcome unavailable from this pod — it did not report how many files were structured or dropped. " +
799
+ "This is NOT a confirmation that every file landed: open the import.graph proposal and read quality.counts.";
800
+ /**
801
+ * Human lines for a completed corpus run's file-level outcome.
802
+ *
803
+ * Exported so the run summary, the `--job-status` re-poll and the tests all read
804
+ * the SAME judgement — a second copy is how "0 failed" survived on one path.
805
+ */
806
+ export function corpusOutcomeLines(output, itemCount) {
807
+ const outcome = readCorpusFileOutcome(output);
808
+ if (!outcome.known)
809
+ return [CORPUS_OUTCOME_UNAVAILABLE];
810
+ const processed = outcome.filesProcessed ?? "?";
811
+ const total = typeof itemCount === "number" ? ` of ${itemCount}` : "";
812
+ const lines = [
813
+ `files: ${processed}${total} structured · ${outcome.filesFailed} failed`,
814
+ ];
815
+ if (outcome.filesFailed > 0) {
816
+ lines.push(`${outcome.filesFailed} file(s) produced NOTHING and are absent from the proposal — re-import them alone once the cause below is addressed.`);
817
+ }
818
+ for (const f of (output?.findings ?? []).slice(0, 8)) {
819
+ if (!f?.message)
820
+ continue;
821
+ lines.push(`[${(f.severity ?? "info").toUpperCase()}] ${f.message}`);
822
+ }
823
+ return lines;
824
+ }
825
+ /** pg-boss states that mean "this job is over and it did not succeed". */
826
+ const CORPUS_DEAD_STATES = new Set(["failed", "cancelled", "expired"]);
827
+ /**
828
+ * Which batch door to use. The ONE place the threshold is applied — the human
829
+ * message, the --json payload and the actual POST all read this.
830
+ *
831
+ * `--dry-run` always stays on `/import/analyze`: the corpus door has no preview
832
+ * mode (`previewOnly` is an /import/analyze field), and enqueueing is itself a
833
+ * durable write. A dry run that queued a background job would be a dry run that
834
+ * writes — the exact thing the flag promises not to do.
835
+ */
836
+ export function chooseBatchDoor(args) {
837
+ if (args.dryRun)
838
+ return "analyze";
839
+ return args.fileCount >= CORPUS_THRESHOLD ? "enqueue-corpus" : "analyze";
840
+ }
841
+ /**
842
+ * How long to wait for a corpus job before handing the terminal back.
843
+ *
844
+ * Scaled from measured throughput (~45s/file) so a small corpus is not waited on
845
+ * for an hour and a 257-file backfill is not abandoned after five minutes, then
846
+ * clamped: never under 10 minutes, never over 2 hours. The ceiling is safe
847
+ * precisely because expiry is not failure — see corpusTimeoutLines().
848
+ */
849
+ export function corpusPollTimeoutMs(itemCount) {
850
+ const scaled = itemCount * CORPUS_MS_PER_ITEM;
851
+ return Math.min(Math.max(scaled, CORPUS_MIN_WAIT_MS), CORPUS_MAX_WAIT_MS);
852
+ }
853
+ function humanDuration(ms) {
854
+ const totalMin = Math.round(ms / 60_000);
855
+ if (totalMin < 60)
856
+ return `${Math.max(totalMin, 1)}m`;
857
+ const h = Math.floor(totalMin / 60);
858
+ const m = totalMin % 60;
859
+ return m ? `${h}h${m}m` : `${h}h`;
860
+ }
861
+ /**
862
+ * What we print when the wait window elapses.
863
+ *
864
+ * The job is STILL RUNNING — pg-boss holds it, the worker keeps chunking, and
865
+ * the governed `import.graph` proposal will still be written. Saying "failed"
866
+ * (or exiting non-zero) here would send a user to re-run a 257-file import that
867
+ * is already half-done. So: state plainly that nothing was lost, and give the
868
+ * exact command to re-poll.
869
+ */
870
+ export function corpusTimeoutLines(jobId, waitedMs) {
871
+ return [
872
+ `Still running after ${humanDuration(waitedMs)} — this is not a failure.`,
873
+ `The job is queued on the pod and keeps going after this command exits; nothing was lost and nothing was imported twice.`,
874
+ `Re-check it with: synap import --job-status ${jobId}`,
875
+ `Or watch the review inbox — the finished import lands there as ONE import.graph proposal: synap proposals list`,
876
+ ];
877
+ }
878
+ /** Poll a corpus job to completion. Returns the last observed status. */
879
+ async function pollCorpusJob(jobId, cfg, userId, itemCount, quiet) {
880
+ const timeoutMs = corpusPollTimeoutMs(itemCount);
881
+ const url = new URL(`${cfg.podUrl}/api/hub/import/corpus-job/${jobId}`);
882
+ url.searchParams.set("userId", userId);
883
+ let lastState = "created";
884
+ let lastProgressAt = 0;
885
+ const startedAt = Date.now();
886
+ try {
887
+ // REUSE the shared approval poll — same create→poll shape as CP login and
888
+ // agent-key approval. No second loop, no second backoff policy.
889
+ const status = await pollForApproval({
890
+ url: url.toString(),
891
+ headers: { Authorization: `Bearer ${cfg.apiKey}` },
892
+ intervalMs: CORPUS_POLL_INTERVAL_MS,
893
+ timeoutMs,
894
+ isApproved: (d) => d?.state === "completed",
895
+ isRejected: (d) => {
896
+ const state = String(d?.state ?? "");
897
+ // Record the terminal state here too, not only in onTick: the rejected
898
+ // path throws, and the catch below reports `lastState`. Reading it only
899
+ // from onTick would make a failed job report the state BEFORE it failed.
900
+ if (state)
901
+ lastState = state;
902
+ return CORPUS_DEAD_STATES.has(state);
903
+ },
904
+ onApproved: (d) => d,
905
+ rejectedError: "corpus import job did not complete",
906
+ timeoutError: "__corpus_timeout__",
907
+ onTick: ({ data, elapsedMs }) => {
908
+ const state = data?.state;
909
+ if (state)
910
+ lastState = state;
911
+ if (quiet)
912
+ return;
913
+ if (elapsedMs - lastProgressAt < CORPUS_PROGRESS_EVERY_MS)
914
+ return;
915
+ lastProgressAt = elapsedMs;
916
+ log.dim(` … ${lastState} · ${humanDuration(elapsedMs)} elapsed (waiting up to ${humanDuration(timeoutMs)})`);
917
+ },
918
+ });
919
+ return { status, timedOut: false, waitedMs: Date.now() - startedAt };
920
+ }
921
+ catch (e) {
922
+ const waitedMs = Date.now() - startedAt;
923
+ if (e.message === "__corpus_timeout__") {
924
+ return { status: { jobId, state: lastState }, timedOut: true, waitedMs };
925
+ }
926
+ // Rejected: the job reached a terminal non-success state. Report the state
927
+ // we actually saw rather than a generic "failed".
928
+ return { status: { jobId, state: lastState }, timedOut: false, waitedMs };
929
+ }
930
+ }
931
+ /**
932
+ * Enqueue a text corpus on the background door and wait for the worker.
933
+ *
934
+ * The door is ADDITIVE onto the same `ImportOrchestrator.analyzeLarge` that
935
+ * `/import/analyze` reaches — chunking, cross-chunk dedup and the single
936
+ * governed `import.graph` proposal all live server-side. This function only
937
+ * posts, polls, and reports.
938
+ */
939
+ async function processCorpusImport(mappedItems, source, opts, cfg, userId, workspaceId, projOverride, sessionId) {
940
+ const label = `corpus:${source} (${mappedItems.length} files)`;
941
+ const itemCount = mappedItems.length;
942
+ // The enqueue payload the pod builds is {userId, workspaceId, source, items}
943
+ // (hub-protocol/rest/capture.ts) — projectId and sessionId are accepted by the
944
+ // schema and then DROPPED before the job. Warn rather than let a passed flag
945
+ // vanish: on this path they genuinely do not apply.
946
+ if (!opts.json && (projOverride || sessionId)) {
947
+ const dropped = [projOverride ? "--project" : null, sessionId ? "--session" : null]
948
+ .filter(Boolean)
949
+ .join(" and ");
950
+ log.warn(` ${dropped} ${projOverride && sessionId ? "do" : "does"} not apply to the background corpus import — ` +
951
+ `the pod's enqueue payload carries only userId, workspaceId, source and items. ` +
952
+ `Set the project/session on the resulting proposal when you review it.`);
953
+ }
954
+ if (!opts.json) {
955
+ log.info(`Enqueuing ${itemCount} text file${itemCount !== 1 ? "s" : ""} on the background corpus door ` +
956
+ `(source=${source}) — the synchronous /import/analyze door times out at this size.`);
957
+ }
958
+ const enqueueRes = (await hubPost("/import/enqueue-corpus", {
959
+ userId,
960
+ source,
961
+ items: mappedItems.map((i) => ({ path: i.path, content: i.content })),
962
+ ...(workspaceId ? { workspaceId } : {}),
963
+ }, cfg, 60_000));
964
+ const jobId = enqueueRes.jobId ?? null;
965
+ const finalWs = enqueueRes.workspaceId ?? workspaceId ?? null;
966
+ const corpusBase = { jobId, itemCount, waitedMs: 0 };
967
+ // pg-boss can accept a send and still return no id (dedup/throttle). We were
968
+ // told "queued", so nothing failed — but we cannot poll a job we cannot name.
969
+ if (!jobId) {
970
+ if (!opts.json) {
971
+ log.warn(" The pod accepted the corpus but returned no job id — it is running, but this command cannot follow it.");
972
+ log.dim(" Watch for the import.graph proposal with: synap proposals list");
973
+ }
974
+ return {
975
+ label,
976
+ status: "queued",
977
+ workspaceId: finalWs,
978
+ projectId: null,
979
+ batch: {
980
+ source,
981
+ fileCount: itemCount,
982
+ proposalId: null,
983
+ sessionId: null,
984
+ corpus: { ...corpusBase, state: "unknown", timedOut: false },
985
+ },
986
+ };
987
+ }
988
+ if (!opts.json) {
989
+ log.dim(` job ${jobId} · polling every ${CORPUS_POLL_INTERVAL_MS / 1000}s`);
990
+ }
991
+ const { status, timedOut, waitedMs } = await pollCorpusJob(jobId, cfg, userId, itemCount, Boolean(opts.json));
992
+ const state = status?.state ?? "unknown";
993
+ const corpus = { jobId, itemCount, state, timedOut, waitedMs };
994
+ const batchMeta = {
995
+ source,
996
+ fileCount: itemCount,
997
+ proposalId: null,
998
+ sessionId: null,
999
+ corpus,
1000
+ };
1001
+ if (timedOut) {
1002
+ if (!opts.json) {
1003
+ log.blank();
1004
+ for (const line of corpusTimeoutLines(jobId, waitedMs))
1005
+ log.info(` ${line}`);
1006
+ }
1007
+ return { label, status: "queued", workspaceId: finalWs, projectId: null, batch: batchMeta };
1008
+ }
1009
+ if (state !== "completed") {
1010
+ if (!opts.json) {
1011
+ log.error(` corpus job ${jobId} ended in state "${state}" — nothing was imported.`);
1012
+ log.hint("Check the pod's job logs, then re-run the import.");
1013
+ }
1014
+ return {
1015
+ label,
1016
+ status: "failed",
1017
+ error: `corpus job ended in state "${state}"`,
1018
+ workspaceId: finalWs,
1019
+ projectId: null,
1020
+ batch: batchMeta,
1021
+ };
1022
+ }
1023
+ // Completed. The result is a PENDING governed proposal — not stored entities.
1024
+ // Say exactly that; "created N entities" here would be a lie.
1025
+ //
1026
+ // And "completed" is NOT "every file landed": the pod structures each file
1027
+ // separately and records `filesFailed` on the proposal. Read that number here
1028
+ // or the run reports success over an unknown amount of loss.
1029
+ const output = status?.output ?? null;
1030
+ const fileOutcome = readCorpusFileOutcome(output);
1031
+ const lostFiles = fileOutcome.known && fileOutcome.filesFailed > 0;
1032
+ const corpusFull = {
1033
+ ...corpus,
1034
+ outcomeKnown: fileOutcome.known,
1035
+ filesProcessed: fileOutcome.known ? fileOutcome.filesProcessed : null,
1036
+ filesFailed: fileOutcome.known ? fileOutcome.filesFailed : null,
1037
+ ...(output?.findings ? { findings: output.findings } : {}),
1038
+ proposalId: output?.proposalId ?? null,
1039
+ };
1040
+ const fullBatchMeta = {
1041
+ ...batchMeta,
1042
+ proposalId: output?.proposalId ?? null,
1043
+ corpus: corpusFull,
1044
+ };
1045
+ if (!opts.json) {
1046
+ const headline = ` Corpus import finished in ${humanDuration(waitedMs)}${lostFiles ? " with FILES MISSING" : ""} — ONE import.graph proposal is waiting for review.`;
1047
+ if (lostFiles)
1048
+ log.warn(headline);
1049
+ else
1050
+ log.success(headline);
1051
+ for (const line of corpusOutcomeLines(output, itemCount)) {
1052
+ if (fileOutcome.known && !lostFiles)
1053
+ log.dim(` ${line}`);
1054
+ else
1055
+ log.warn(` ${line}`);
1056
+ }
1057
+ log.dim(" Nothing is in the graph yet: approving the proposal is what materializes it.");
1058
+ renderNextSteps(FLOW.afterCorpusImport());
1059
+ }
1060
+ // Files dropped → `degraded`, the existing non-zero outcome (importExitCode);
1061
+ // otherwise `proposed`, the same meaning the capture path already carries.
1062
+ // The judgement lives in corpusCompletionOutcome so the tests exercise the
1063
+ // real decision rather than a copy of it.
1064
+ const completion = corpusCompletionOutcome(output);
1065
+ return {
1066
+ label,
1067
+ status: completion.status,
1068
+ ...(completion.degradedReason
1069
+ ? { degradedReason: completion.degradedReason }
1070
+ : {}),
1071
+ entities: 0,
1072
+ relations: 0,
1073
+ workspaceId: finalWs,
1074
+ projectId: null,
1075
+ batch: fullBatchMeta,
360
1076
  };
361
1077
  }
1078
+ // ─── multi-file batch via /import/analyze + /import/apply ─────────────────────
1079
+ async function processBatchImport(batchItems, opts, cfg, userId, wsOverride, projOverride, sessionId, rl, homeMap = []) {
1080
+ // Path rewrite for multi-home: backend matches workspace **names** in path
1081
+ // segments. --home-map prefixes matched paths with the resolved name.
1082
+ const mappedItems = applyHomeMapToBatchItems(batchItems, homeMap);
1083
+ const homeMapRewrites = homeMap.length > 0
1084
+ ? mappedItems.filter((m, i) => m.path !== batchItems[i]?.path).length
1085
+ : 0;
1086
+ const source = sourceForBatchItems(mappedItems);
1087
+ const label = `batch:${source} (${mappedItems.length} files)`;
1088
+ // Only an explicit --workspace pins an import. The saved default space is
1089
+ // not a choice the person made for THIS import: sent as a pin it filed a
1090
+ // 2,827-entity, all pod-wide contacts import under "Brand" (live 0728053b,
1091
+ // 2026-10-22). Without a pin the pod places the graph by its kinds and
1092
+ // roles (one space when they all point at it, else pod-wide).
1093
+ const workspaceId = wsOverride;
1094
+ // Above CORPUS_THRESHOLD the synchronous door cannot finish inside the pod's
1095
+ // request timeout — hand the SAME mapped items (home-map already applied, caps
1096
+ // already enforced, source already resolved) to the background door instead.
1097
+ if (chooseBatchDoor({ fileCount: mappedItems.length, dryRun: opts.dryRun }) === "enqueue-corpus") {
1098
+ if (!opts.json && homeMapRewrites > 0) {
1099
+ log.dim(` home-map: rewrote ${homeMapRewrites} path${homeMapRewrites !== 1 ? "s" : ""} ` +
1100
+ `(workspace name as first segment for multi-home placement)`);
1101
+ }
1102
+ return processCorpusImport(mappedItems, source, opts, cfg, userId, workspaceId, projOverride, sessionId);
1103
+ }
1104
+ const analyzeBody = {
1105
+ userId,
1106
+ source,
1107
+ items: mappedItems.map((i) => ({ path: i.path, content: i.content })),
1108
+ aiStructure: true,
1109
+ // --dry-run must not file a durable import.graph proposal (inbox spam).
1110
+ // Interactive / --yes still create a real proposal for HITL apply.
1111
+ ...(opts.dryRun ? { previewOnly: true } : {}),
1112
+ ...(workspaceId ? { workspaceId } : {}),
1113
+ ...(projOverride ? { projectId: projOverride } : {}),
1114
+ ...(sessionId ? { sessionId } : {}),
1115
+ };
1116
+ if (!opts.json) {
1117
+ log.info(`Analyzing ${mappedItems.length} text file${mappedItems.length !== 1 ? "s" : ""} via /import/analyze (source=${source})…`);
1118
+ if (homeMapRewrites > 0) {
1119
+ log.dim(` home-map: rewrote ${homeMapRewrites} path${homeMapRewrites !== 1 ? "s" : ""} ` +
1120
+ `(workspace name as first segment for multi-home placement)`);
1121
+ }
1122
+ }
1123
+ const analyzeRes = (await hubPost("/import/analyze", analyzeBody, cfg, IMPORT_ANALYZE_TIMEOUT_MS));
1124
+ const ops = analyzeRes.operations ?? [];
1125
+ const batchMeta = {
1126
+ source,
1127
+ fileCount: mappedItems.length,
1128
+ proposalId: analyzeRes.proposalId ?? null,
1129
+ sessionId: analyzeRes.sessionId ?? sessionId ?? null,
1130
+ analyze: analyzeRes,
1131
+ };
1132
+ if (!opts.json) {
1133
+ renderBatchAnalyze(analyzeRes, mappedItems.length, source);
1134
+ }
1135
+ if (ops.length === 0) {
1136
+ return {
1137
+ label,
1138
+ status: "empty",
1139
+ workspaceId: analyzeRes.workspaceId ?? workspaceId ?? null,
1140
+ projectId: projOverride ?? null,
1141
+ batch: batchMeta,
1142
+ };
1143
+ }
1144
+ // Dry-run (or --json without --yes): analyze only — no apply.
1145
+ if (opts.dryRun) {
1146
+ return {
1147
+ label,
1148
+ status: "dry-run",
1149
+ entities: ops.filter((o) => o.op === "create_entity").length || ops.length,
1150
+ relations: ops.filter((o) => o.op === "create_relation").length,
1151
+ workspaceId: analyzeRes.workspaceId ?? workspaceId ?? null,
1152
+ projectId: projOverride ?? null,
1153
+ batch: batchMeta,
1154
+ };
1155
+ }
1156
+ // Human apply default: confirm y/N unless --yes.
1157
+ // --json without --yes skips apply (same as per-item capture path).
1158
+ let confirmed = Boolean(opts.yes);
1159
+ if (!confirmed && !opts.json && rl) {
1160
+ const answer = await rl.question(chalk.bold(` Apply this import (${ops.length} operation${ops.length !== 1 ? "s" : ""})? [y/N] `));
1161
+ confirmed = answer.trim().toLowerCase() === "y";
1162
+ }
1163
+ if (!confirmed) {
1164
+ if (!opts.json)
1165
+ log.dim(" Skipped.");
1166
+ return {
1167
+ label,
1168
+ status: "skipped",
1169
+ workspaceId: analyzeRes.workspaceId ?? workspaceId ?? null,
1170
+ projectId: projOverride ?? null,
1171
+ batch: batchMeta,
1172
+ };
1173
+ }
1174
+ // Applying a NAMED import proposal is approving it, and approval is the
1175
+ // human step: the pod refuses an agent credential (HTTP 403, correct
1176
+ // governance). So an agent's import ends at the proposal — reported as
1177
+ // `proposed` with the review link, never as `failed`. `/users/me` `isAgent`
1178
+ // is the pod's own answer; when it is unknown the apply runs and a 403
1179
+ // agent refusal is read the same way.
1180
+ const filedProposalId = analyzeRes.proposalId ?? null;
1181
+ if (filedProposalId && (await callerIsAgent(cfg))) {
1182
+ return proposedForReview(label, filedProposalId, cfg, opts, {
1183
+ workspaceId: analyzeRes.workspaceId ?? workspaceId ?? null,
1184
+ projectId: projOverride ?? null,
1185
+ batch: batchMeta,
1186
+ });
1187
+ }
1188
+ const applySessionId = analyzeRes.sessionId ?? sessionId;
1189
+ // Prefer analyze-resolved placement so apply lands where the proposal was filed.
1190
+ const applyWorkspaceId = analyzeRes.workspaceId ?? workspaceId ?? undefined;
1191
+ const applyBody = {
1192
+ userId,
1193
+ source,
1194
+ operations: ops,
1195
+ ...(applyWorkspaceId ? { workspaceId: applyWorkspaceId } : {}),
1196
+ ...(projOverride ? { projectId: projOverride } : {}),
1197
+ ...(applySessionId ? { sessionId: applySessionId } : {}),
1198
+ // Client-stable idempotency namespace (U1): analyze proposalId.
1199
+ ...(analyzeRes.proposalId
1200
+ ? {
1201
+ idempotencyKey: analyzeRes.proposalId,
1202
+ proposalId: analyzeRes.proposalId,
1203
+ }
1204
+ : {}),
1205
+ };
1206
+ let applyRes;
1207
+ try {
1208
+ applyRes = (await hubPost("/import/apply", applyBody, cfg, IMPORT_APPLY_TIMEOUT_MS));
1209
+ }
1210
+ catch (e) {
1211
+ if (filedProposalId && isAgentReviewRejection(e)) {
1212
+ return proposedForReview(label, filedProposalId, cfg, opts, {
1213
+ workspaceId: analyzeRes.workspaceId ?? workspaceId ?? null,
1214
+ projectId: projOverride ?? null,
1215
+ batch: batchMeta,
1216
+ });
1217
+ }
1218
+ throw e;
1219
+ }
1220
+ const created = applyRes.created ?? 0;
1221
+ const linked = applyRes.linked ?? 0;
1222
+ const finalWs = applyRes.workspaceId ?? analyzeRes.workspaceId ?? workspaceId ?? null;
1223
+ if (!opts.json) {
1224
+ log.success(` ${created} entit${created !== 1 ? "ies" : "y"} created` +
1225
+ (linked ? `, ${linked} linked` : ""));
1226
+ if (finalWs) {
1227
+ const report = {
1228
+ lane: "work",
1229
+ workspaceId: finalWs,
1230
+ workspaceName: await resolveWorkspaceName(finalWs, cfg),
1231
+ governance: "auto",
1232
+ };
1233
+ console.log(" " +
1234
+ formatLaneLine(report) +
1235
+ (projOverride ? chalk.dim(` [project ${projOverride.slice(0, 8)}]`) : ""));
1236
+ }
1237
+ if (applySessionId)
1238
+ log.dim(` session: ${applySessionId}`);
1239
+ }
1240
+ return {
1241
+ label,
1242
+ status: "stored",
1243
+ entities: created,
1244
+ relations: linked,
1245
+ workspaceId: finalWs,
1246
+ projectId: projOverride ?? null,
1247
+ batch: { ...batchMeta, apply: applyRes },
1248
+ };
1249
+ }
1250
+ /**
1251
+ * Is the calling credential an agent? The pod's own `/users/me` `isAgent`.
1252
+ * Unknown (older pod, network) is `false`: the apply then runs and the pod's
1253
+ * 403 agent refusal is translated after the fact — never a guess that blocks
1254
+ * a human.
1255
+ */
1256
+ async function callerIsAgent(cfg) {
1257
+ try {
1258
+ return (await fetchEffectiveIdentity(cfg)).isAgent === true;
1259
+ }
1260
+ catch {
1261
+ return false;
1262
+ }
1263
+ }
1264
+ /** The import was filed for review and not applied: say so, with the door. */
1265
+ function proposedForReview(label, proposalId, cfg, opts, rest) {
1266
+ const reviewUrl = proposalWebLink(cfg.podUrl, proposalId);
1267
+ if (!opts.json) {
1268
+ log.info(" Proposed — waiting for your approval. Nothing is stored until a person approves it.");
1269
+ log.dim(` proposal: ${proposalId}`);
1270
+ log.dim(` review: ${reviewUrl}`);
1271
+ log.dim(` or run: synap open proposal ${proposalId}`);
1272
+ }
1273
+ return {
1274
+ label,
1275
+ status: "proposed",
1276
+ entities: 0,
1277
+ relations: 0,
1278
+ reviewUrl,
1279
+ ...rest,
1280
+ };
1281
+ }
1282
+ // ─── `synap import --job-status <jobId>` ──────────────────────────────────────
1283
+ /**
1284
+ * Re-poll a corpus job started by an earlier `synap import`.
1285
+ *
1286
+ * This is the command `corpusTimeoutLines()` tells the user to run, so it has to
1287
+ * exist: a timeout message naming a command that does not resolve is worse than
1288
+ * no message. One GET, no waiting — it reports the current state and exits.
1289
+ */
1290
+ export async function importJobStatus(jobId, opts) {
1291
+ try {
1292
+ if (!UUID_RE.test(jobId)) {
1293
+ console.error(chalk.red(`'${jobId}' is not a job id — pass the UUID printed when the import was enqueued.`));
1294
+ process.exit(1);
1295
+ }
1296
+ const cfg = await resolveHubConfig(opts);
1297
+ const userId = await resolveUserId(cfg);
1298
+ const res = (await hubGet(`/import/corpus-job/${jobId}`, { userId }, cfg));
1299
+ const state = res.state ?? "unknown";
1300
+ const output = res.output ?? null;
1301
+ const fileOutcome = readCorpusFileOutcome(output);
1302
+ const lostFiles = fileOutcome.known && fileOutcome.filesFailed > 0;
1303
+ if (opts.json) {
1304
+ console.log(JSON.stringify({
1305
+ jobId,
1306
+ state,
1307
+ createdOn: res.createdOn ?? null,
1308
+ completedOn: res.completedOn ?? null,
1309
+ // Same UNKNOWN-vs-zero distinction the run path makes.
1310
+ outcomeKnown: state === "completed" ? fileOutcome.known : false,
1311
+ filesProcessed: fileOutcome.known ? fileOutcome.filesProcessed : null,
1312
+ filesFailed: fileOutcome.known ? fileOutcome.filesFailed : null,
1313
+ ...(output?.findings ? { findings: output.findings } : {}),
1314
+ proposalId: output?.proposalId ?? null,
1315
+ }, null, 2));
1316
+ if (state === "completed" && lostFiles)
1317
+ process.exit(1);
1318
+ return;
1319
+ }
1320
+ log.heading(`Corpus import job ${jobId}`);
1321
+ log.info(` state: ${state}`);
1322
+ if (res.createdOn)
1323
+ log.dim(` created: ${res.createdOn}`);
1324
+ if (res.completedOn)
1325
+ log.dim(` completed: ${res.completedOn}`);
1326
+ if (state === "completed") {
1327
+ if (lostFiles) {
1328
+ log.warn(" Finished with FILES MISSING — ONE import.graph proposal is waiting for review.");
1329
+ }
1330
+ else {
1331
+ log.success(" Finished — ONE import.graph proposal is waiting for review.");
1332
+ }
1333
+ for (const line of corpusOutcomeLines(output)) {
1334
+ if (fileOutcome.known && !lostFiles)
1335
+ log.dim(` ${line}`);
1336
+ else
1337
+ log.warn(` ${line}`);
1338
+ }
1339
+ renderNextSteps(FLOW.afterCorpusImport());
1340
+ // A run that lost files is not a success on the re-poll path either.
1341
+ if (lostFiles)
1342
+ process.exit(1);
1343
+ return;
1344
+ }
1345
+ if (CORPUS_DEAD_STATES.has(state)) {
1346
+ log.error(` The job ended in state "${state}" — nothing was imported.`);
1347
+ process.exit(1);
1348
+ }
1349
+ log.info(" Still running — nothing was lost. Re-run this command later.");
1350
+ }
1351
+ catch (e) {
1352
+ renderHubError(e);
1353
+ process.exit(1);
1354
+ }
1355
+ }
362
1356
  // ─── command entrypoint ─────────────────────────────────────────────────────────
363
1357
  export async function importData(inputs, opts) {
364
1358
  try {
@@ -394,10 +1388,17 @@ export async function importData(inputs, opts) {
394
1388
  // Resolve routing overrides (id or name) up front so a bad value fails fast.
395
1389
  const wsOverride = opts.workspace ? await resolveWorkspaceArg(opts.workspace, cfg) : undefined;
396
1390
  const projOverride = opts.project ? await resolveProjectArg(opts.project, cfg) : undefined;
397
- const sessionId = opts.session ? readActiveSessionId() : undefined;
398
- const { items, failures, truncated } = expandInputs(inputs);
399
- if (truncated && !opts.json) {
400
- log.warn(`A directory exceeded ${DIR_FILE_CAP} files — only the first ${DIR_FILE_CAP} were taken. Narrow the path or split the import.`);
1391
+ const sessionId = opts.session ? resolveActiveSessionId(cfg.podUrl) : undefined;
1392
+ // Optional multi-home path map (no product defaults — user pairs only).
1393
+ let homeMap = [];
1394
+ if (opts.homeMap) {
1395
+ homeMap = await resolveHomeMap(parseHomeMap(opts.homeMap), cfg);
1396
+ }
1397
+ const { items, failures, truncated: dirTruncated } = expandInputs(inputs);
1398
+ let truncated = dirTruncated;
1399
+ if (dirTruncated && !opts.json) {
1400
+ log.warn(`A directory exceeded ${DIR_FILE_CAP} files — only the first ${DIR_FILE_CAP} were taken ` +
1401
+ `(sync /import/analyze max). Narrow the path, split the import, or use a smaller folder.`);
401
1402
  }
402
1403
  if (items.length === 0) {
403
1404
  if (opts.json) {
@@ -410,15 +1411,71 @@ export async function importData(inputs, opts) {
410
1411
  }
411
1412
  process.exit(failures.length > 0 ? 1 : 0);
412
1413
  }
1414
+ // Partition: N≥2 batch-eligible text → /import/*; rest → per-item capture.
1415
+ // Single text file stays on capture structure/execute (same as a lone URL).
1416
+ const batchFileItems = [];
1417
+ const captureItems = [];
1418
+ // `--keep-raw` is a PER-FILE disposition and the batch door
1419
+ // (/import/analyze → /import/apply) has no `keepRaw` half at all. Routing a
1420
+ // kept-raw text file through it would accept the flag and silently discard
1421
+ // every original — an inert flag rendered as a live one. Send those items
1422
+ // down the per-item capture path instead, which is where `keepRaw` exists.
1423
+ for (const item of items) {
1424
+ if (isBatchTextItem(item) && !opts.keepRaw) {
1425
+ batchFileItems.push(item);
1426
+ }
1427
+ else {
1428
+ captureItems.push(item);
1429
+ }
1430
+ }
1431
+ if (opts.keepRaw && !opts.json && captureItems.length > 1) {
1432
+ log.dim(`--keep-raw: importing ${captureItems.length} items one by one (the batch door cannot keep originals).`);
1433
+ }
1434
+ // Global batch cap: multi-dir / multi-file inputs can exceed the sync max
1435
+ // even when no single directory hit DIR_FILE_CAP.
1436
+ if (batchFileItems.length > GLOBAL_BATCH_CAP) {
1437
+ truncated = true;
1438
+ if (!opts.json) {
1439
+ log.warn(`Batch has ${batchFileItems.length} text files — truncating to ${GLOBAL_BATCH_CAP} ` +
1440
+ `(sync /import/analyze max). Split the import or narrow the paths.`);
1441
+ }
1442
+ batchFileItems.length = GLOBAL_BATCH_CAP;
1443
+ }
1444
+ const grouped = groupBatchFiles(batchFileItems);
1445
+ const batchGroups = grouped.batches.map((group) => group.map(toBatchTextItem));
1446
+ captureItems.push(...grouped.single);
413
1447
  if (!opts.json) {
414
- log.info(`Importing ${items.length} item${items.length !== 1 ? "s" : ""}${opts.dryRun ? " (dry run)" : ""}…`);
1448
+ const parts = [];
1449
+ for (const group of batchGroups) {
1450
+ parts.push(`${group.length} ${sourceForBatchItems(group) === "csv" ? "CSV" : "text"} file${group.length !== 1 ? "s" : ""} (batch import)`);
1451
+ }
1452
+ if (captureItems.length > 0) {
1453
+ parts.push(`${captureItems.length} item${captureItems.length !== 1 ? "s" : ""} (capture pipeline)`);
1454
+ }
1455
+ log.info(`Importing ${parts.join(" + ") || `${items.length} item${items.length !== 1 ? "s" : ""}`}` +
1456
+ `${opts.dryRun ? " (dry run)" : ""}…`);
415
1457
  }
416
1458
  const outcomes = [];
417
1459
  // One shared readline for all interactive confirmations (human, non-json).
418
1460
  const interactive = !opts.json && !opts.dryRun && !opts.yes;
419
1461
  const rl = interactive ? readline.createInterface({ input, output }) : undefined;
420
1462
  try {
421
- for (const item of items) {
1463
+ for (const batchCandidates of batchGroups) {
1464
+ try {
1465
+ outcomes.push(await processBatchImport(batchCandidates, opts, cfg, userId, wsOverride, projOverride, sessionId, rl, homeMap));
1466
+ }
1467
+ catch (e) {
1468
+ const error = e.message;
1469
+ if (!opts.json)
1470
+ log.error(` batch import: ${error}`);
1471
+ outcomes.push({
1472
+ label: `batch (${batchCandidates.length} files)`,
1473
+ status: "failed",
1474
+ error,
1475
+ });
1476
+ }
1477
+ }
1478
+ for (const item of captureItems) {
422
1479
  try {
423
1480
  outcomes.push(await processItem(item, opts, cfg, userId, wsOverride, projOverride, sessionId, rl));
424
1481
  }
@@ -438,7 +1495,19 @@ export async function importData(inputs, opts) {
438
1495
  for (const f of failures)
439
1496
  outcomes.push({ label: f.label, status: "failed", error: f.error });
440
1497
  if (opts.json) {
441
- console.log(JSON.stringify({ imported: outcomes, truncated }, null, 2));
1498
+ // Surface analyze/apply payloads for the batch path (agents need ops + receipt).
1499
+ const batchOutcome = outcomes.find((o) => o.batch);
1500
+ console.log(JSON.stringify({
1501
+ imported: outcomes,
1502
+ truncated,
1503
+ ...(batchOutcome?.batch?.analyze ? { analyze: batchOutcome.batch.analyze } : {}),
1504
+ ...(batchOutcome?.batch?.apply ? { apply: batchOutcome.batch.apply } : {}),
1505
+ // Background corpus path: a script needs the jobId + final state to
1506
+ // decide whether to re-poll, and nextSteps to chain the review.
1507
+ ...(batchOutcome?.batch?.corpus
1508
+ ? { corpus: batchOutcome.batch.corpus, nextSteps: FLOW.afterCorpusImport() }
1509
+ : {}),
1510
+ }, null, 2));
442
1511
  }
443
1512
  else {
444
1513
  const stored = outcomes.filter((o) => o.status === "stored");
@@ -448,20 +1517,47 @@ export async function importData(inputs, opts) {
448
1517
  log.heading("Import summary");
449
1518
  const proposed = outcomes.filter((o) => o.status === "proposed").length;
450
1519
  const degraded = outcomes.filter((o) => o.status === "degraded").length;
451
- log.info(`${outcomes.length} item${outcomes.length !== 1 ? "s" : ""} · ` +
1520
+ const queued = outcomes.filter((o) => o.status === "queued").length;
1521
+ // Background corpus path: ONE outcome represents ONE JOB covering N files.
1522
+ // So this line's counters are denominated in JOBS, and the file-level truth
1523
+ // is a separate line below.
1524
+ const corpusMeta = outcomes.find((o) => o.batch?.corpus)?.batch?.corpus;
1525
+ const corpusLost = (corpusMeta?.filesFailed ?? 0) > 0;
1526
+ const noun = corpusMeta ? "job" : "item";
1527
+ log.info(`${outcomes.length} ${noun}${outcomes.length !== 1 ? "s" : ""} · ` +
452
1528
  `${stored.length} stored (${totalEntities} entit${totalEntities !== 1 ? "ies" : "y"}) · ` +
453
1529
  (proposed ? `${proposed} proposed · ` : "") +
1530
+ (queued ? `${queued} still running · ` : "") +
454
1531
  `${outcomes.filter((o) => o.status === "dry-run").length} dry-run · ` +
455
1532
  `${outcomes.filter((o) => o.status === "skipped").length} skipped · ` +
456
1533
  `${outcomes.filter((o) => o.status === "empty").length} empty · ` +
457
1534
  (degraded ? `${degraded} degraded · ` : "") +
458
- `${failed.length} failed`);
1535
+ // A corpus run that LOST files expresses that loss one line below, in
1536
+ // files. Printing "0 failed" here too — same word, different
1537
+ // denominator, three lines apart — restates the exact sentence this
1538
+ // whole wave existed to delete. Job-level failure is already carried
1539
+ // by `degraded`, so the counter is dropped rather than qualified.
1540
+ (corpusLost ? "" : `${failed.length} failed`));
459
1541
  for (const f of failed)
460
1542
  log.dim(` ✗ ${f.label}: ${f.error ?? "unknown error"}`);
1543
+ if (corpusMeta && !corpusMeta.timedOut) {
1544
+ if (corpusMeta.outcomeKnown) {
1545
+ const line = `files: ${corpusMeta.filesProcessed ?? "?"} of ${corpusMeta.itemCount} structured · ${corpusMeta.filesFailed ?? 0} failed`;
1546
+ if (corpusLost)
1547
+ log.warn(` ${line}`);
1548
+ else
1549
+ log.info(` ${line}`);
1550
+ }
1551
+ else if (corpusMeta.state === "completed") {
1552
+ log.warn(` ${CORPUS_OUTCOME_UNAVAILABLE}`);
1553
+ }
1554
+ }
461
1555
  }
462
- const anyFailed = outcomes.some((o) => o.status === "failed");
463
- if (anyFailed)
464
- process.exit(1);
1556
+ // Only hard-exit on a non-zero code: a plain `return` lets Node flush the
1557
+ // (possibly large) --json payload it just wrote to stdout.
1558
+ const code = importExitCode(outcomes);
1559
+ if (code !== 0)
1560
+ process.exit(code);
465
1561
  }
466
1562
  catch (e) {
467
1563
  renderHubError(e);