@synap-core/cli 1.11.0 → 1.12.2

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 (452) hide show
  1. package/README.md +23 -198
  2. package/dist/build-info.json +6 -0
  3. package/dist/commands/agent-run.js +3 -2
  4. package/dist/commands/agent-run.js.map +1 -1
  5. package/dist/commands/agents.d.ts +33 -17
  6. package/dist/commands/agents.js +203 -237
  7. package/dist/commands/agents.js.map +1 -1
  8. package/dist/commands/app.d.ts +143 -0
  9. package/dist/commands/app.js +523 -0
  10. package/dist/commands/app.js.map +1 -0
  11. package/dist/commands/automate.d.ts +21 -0
  12. package/dist/commands/automate.js +42 -0
  13. package/dist/commands/automate.js.map +1 -0
  14. package/dist/commands/automation-schema.test.d.ts +16 -0
  15. package/dist/commands/automation-schema.test.js +77 -0
  16. package/dist/commands/automation-schema.test.js.map +1 -0
  17. package/dist/commands/automation.d.ts +12 -0
  18. package/dist/commands/automation.js +80 -35
  19. package/dist/commands/automation.js.map +1 -1
  20. package/dist/commands/bridge-setup.d.ts +14 -1
  21. package/dist/commands/bridge-setup.js +392 -128
  22. package/dist/commands/bridge-setup.js.map +1 -1
  23. package/dist/commands/cap-connections.d.ts +4 -4
  24. package/dist/commands/cap-connections.js +14 -14
  25. package/dist/commands/cap-connections.js.map +1 -1
  26. package/dist/commands/capability.d.ts +2 -0
  27. package/dist/commands/capability.js +218 -168
  28. package/dist/commands/capability.js.map +1 -1
  29. package/dist/commands/catalog.d.ts +275 -0
  30. package/dist/commands/catalog.js +481 -0
  31. package/dist/commands/catalog.js.map +1 -0
  32. package/dist/commands/cell.d.ts +155 -8
  33. package/dist/commands/cell.js +416 -40
  34. package/dist/commands/cell.js.map +1 -1
  35. package/dist/commands/centrality.js +1 -13
  36. package/dist/commands/centrality.js.map +1 -1
  37. package/dist/commands/connect.d.ts +38 -13
  38. package/dist/commands/connect.js +68 -167
  39. package/dist/commands/connect.js.map +1 -1
  40. package/dist/commands/connections.d.ts +3 -2
  41. package/dist/commands/connections.js +95 -25
  42. package/dist/commands/connections.js.map +1 -1
  43. package/dist/commands/context.js +7 -1
  44. package/dist/commands/context.js.map +1 -1
  45. package/dist/commands/data-extra.d.ts +34 -0
  46. package/dist/commands/data-extra.js +193 -23
  47. package/dist/commands/data-extra.js.map +1 -1
  48. package/dist/commands/data.d.ts +120 -2
  49. package/dist/commands/data.js +293 -20
  50. package/dist/commands/data.js.map +1 -1
  51. package/dist/commands/diagnose.d.ts +66 -4
  52. package/dist/commands/diagnose.js +285 -28
  53. package/dist/commands/diagnose.js.map +1 -1
  54. package/dist/commands/doc.d.ts +11 -4
  55. package/dist/commands/doc.js +39 -14
  56. package/dist/commands/doc.js.map +1 -1
  57. package/dist/commands/doctor.d.ts +112 -3
  58. package/dist/commands/doctor.js +657 -32
  59. package/dist/commands/doctor.js.map +1 -1
  60. package/dist/commands/explain.js +5 -5
  61. package/dist/commands/explain.js.map +1 -1
  62. package/dist/commands/import.d.ts +368 -13
  63. package/dist/commands/import.js +1064 -54
  64. package/dist/commands/import.js.map +1 -1
  65. package/dist/commands/init.d.ts +20 -9
  66. package/dist/commands/init.js +433 -973
  67. package/dist/commands/init.js.map +1 -1
  68. package/dist/commands/keys.d.ts +1 -0
  69. package/dist/commands/keys.js +15 -2
  70. package/dist/commands/keys.js.map +1 -1
  71. package/dist/commands/knowledge.d.ts +22 -6
  72. package/dist/commands/knowledge.js +117 -55
  73. package/dist/commands/knowledge.js.map +1 -1
  74. package/dist/commands/lens.d.ts +37 -1
  75. package/dist/commands/lens.js +192 -71
  76. package/dist/commands/lens.js.map +1 -1
  77. package/dist/commands/market-authoring.d.ts +29 -2
  78. package/dist/commands/market-authoring.js +348 -7
  79. package/dist/commands/market-authoring.js.map +1 -1
  80. package/dist/commands/market-official.d.ts +58 -0
  81. package/dist/commands/market-official.js +84 -0
  82. package/dist/commands/market-official.js.map +1 -0
  83. package/dist/commands/market.d.ts +400 -16
  84. package/dist/commands/market.js +1802 -146
  85. package/dist/commands/market.js.map +1 -1
  86. package/dist/commands/mcp.d.ts +60 -25
  87. package/dist/commands/mcp.js +251 -64
  88. package/dist/commands/mcp.js.map +1 -1
  89. package/dist/commands/open.d.ts +39 -9
  90. package/dist/commands/open.js +80 -25
  91. package/dist/commands/open.js.map +1 -1
  92. package/dist/commands/playbook.d.ts +42 -0
  93. package/dist/commands/playbook.js +162 -0
  94. package/dist/commands/playbook.js.map +1 -0
  95. package/dist/commands/pod.js +1 -1
  96. package/dist/commands/pod.js.map +1 -1
  97. package/dist/commands/pods.d.ts +5 -0
  98. package/dist/commands/pods.js +97 -116
  99. package/dist/commands/pods.js.map +1 -1
  100. package/dist/commands/proposals-actions.d.ts +62 -0
  101. package/dist/commands/proposals-actions.js +143 -8
  102. package/dist/commands/proposals-actions.js.map +1 -1
  103. package/dist/commands/proposals-await.d.ts +68 -0
  104. package/dist/commands/proposals-await.js +248 -0
  105. package/dist/commands/proposals-await.js.map +1 -0
  106. package/dist/commands/proton-setup.d.ts +73 -0
  107. package/dist/commands/proton-setup.js +577 -0
  108. package/dist/commands/proton-setup.js.map +1 -0
  109. package/dist/commands/providers.d.ts +23 -0
  110. package/dist/commands/providers.js +126 -1
  111. package/dist/commands/providers.js.map +1 -1
  112. package/dist/commands/raycast.js +6 -2
  113. package/dist/commands/raycast.js.map +1 -1
  114. package/dist/commands/rule.d.ts +150 -0
  115. package/dist/commands/rule.js +447 -0
  116. package/dist/commands/rule.js.map +1 -0
  117. package/dist/commands/session-wait.d.ts +111 -0
  118. package/dist/commands/session-wait.js +209 -0
  119. package/dist/commands/session-wait.js.map +1 -0
  120. package/dist/commands/sessions.d.ts +52 -0
  121. package/dist/commands/sessions.js +478 -58
  122. package/dist/commands/sessions.js.map +1 -1
  123. package/dist/commands/skill-manage.d.ts +21 -0
  124. package/dist/commands/skill-manage.js +136 -19
  125. package/dist/commands/skill-manage.js.map +1 -1
  126. package/dist/commands/skills.js +16 -0
  127. package/dist/commands/skills.js.map +1 -1
  128. package/dist/commands/status.d.ts +1 -4
  129. package/dist/commands/status.js +5 -34
  130. package/dist/commands/status.js.map +1 -1
  131. package/dist/commands/statusline.backoff.test.d.ts +13 -0
  132. package/dist/commands/statusline.backoff.test.js +100 -0
  133. package/dist/commands/statusline.backoff.test.js.map +1 -0
  134. package/dist/commands/statusline.js +16 -9
  135. package/dist/commands/statusline.js.map +1 -1
  136. package/dist/commands/sync-status.d.ts +12 -0
  137. package/dist/commands/sync-status.js +125 -0
  138. package/dist/commands/sync-status.js.map +1 -0
  139. package/dist/commands/templates-retire.d.ts +34 -0
  140. package/dist/commands/templates-retire.js +192 -0
  141. package/dist/commands/templates-retire.js.map +1 -0
  142. package/dist/commands/tools.js +6 -21
  143. package/dist/commands/tools.js.map +1 -1
  144. package/dist/commands/tracks.d.ts +51 -0
  145. package/dist/commands/tracks.js +291 -0
  146. package/dist/commands/tracks.js.map +1 -0
  147. package/dist/commands/update.d.ts +3 -11
  148. package/dist/commands/update.js +5 -134
  149. package/dist/commands/update.js.map +1 -1
  150. package/dist/commands/upload.d.ts +41 -3
  151. package/dist/commands/upload.js +120 -18
  152. package/dist/commands/upload.js.map +1 -1
  153. package/dist/commands/vendor.d.ts +28 -0
  154. package/dist/commands/vendor.js +110 -0
  155. package/dist/commands/vendor.js.map +1 -0
  156. package/dist/commands/versions.js +6 -0
  157. package/dist/commands/versions.js.map +1 -1
  158. package/dist/commands/view.js +10 -5
  159. package/dist/commands/view.js.map +1 -1
  160. package/dist/commands/whoami.d.ts +23 -3
  161. package/dist/commands/whoami.js +169 -23
  162. package/dist/commands/whoami.js.map +1 -1
  163. package/dist/commands/worker.d.ts +122 -0
  164. package/dist/commands/worker.js +435 -0
  165. package/dist/commands/worker.js.map +1 -0
  166. package/dist/commands/worker.test.d.ts +1 -0
  167. package/dist/commands/worker.test.js +116 -0
  168. package/dist/commands/worker.test.js.map +1 -0
  169. package/dist/commands/workspace-ops.d.ts +35 -0
  170. package/dist/commands/workspace-ops.js +153 -0
  171. package/dist/commands/workspace-ops.js.map +1 -0
  172. package/dist/index.d.ts +6 -6
  173. package/dist/index.js +900 -180
  174. package/dist/index.js.map +1 -1
  175. package/dist/lib/agent-connections.d.ts +31 -0
  176. package/dist/lib/agent-connections.js +33 -0
  177. package/dist/lib/agent-connections.js.map +1 -0
  178. package/dist/lib/agent-detect.d.ts +27 -0
  179. package/dist/lib/agent-detect.js +71 -0
  180. package/dist/lib/agent-detect.js.map +1 -0
  181. package/dist/lib/agent-door.d.ts +138 -0
  182. package/dist/lib/agent-door.js +426 -0
  183. package/dist/lib/agent-door.js.map +1 -0
  184. package/dist/lib/agents-config.d.ts +2 -0
  185. package/dist/lib/agents-config.js +4 -9
  186. package/dist/lib/agents-config.js.map +1 -1
  187. package/dist/lib/approval-poll.d.ts +31 -2
  188. package/dist/lib/approval-poll.js +20 -7
  189. package/dist/lib/approval-poll.js.map +1 -1
  190. package/dist/lib/atomic-file.d.ts +48 -0
  191. package/dist/lib/atomic-file.js +129 -0
  192. package/dist/lib/atomic-file.js.map +1 -0
  193. package/dist/lib/auth.d.ts +14 -9
  194. package/dist/lib/auth.js +44 -61
  195. package/dist/lib/auth.js.map +1 -1
  196. package/dist/lib/batch-approval.d.ts +85 -0
  197. package/dist/lib/batch-approval.js +170 -0
  198. package/dist/lib/batch-approval.js.map +1 -0
  199. package/dist/lib/capture-lane.d.ts +6 -5
  200. package/dist/lib/capture-lane.js +6 -5
  201. package/dist/lib/capture-lane.js.map +1 -1
  202. package/dist/lib/capture-structure.d.ts +230 -11
  203. package/dist/lib/capture-structure.js +251 -13
  204. package/dist/lib/capture-structure.js.map +1 -1
  205. package/dist/lib/catalog-templates.d.ts +59 -0
  206. package/dist/lib/catalog-templates.js +126 -0
  207. package/dist/lib/catalog-templates.js.map +1 -0
  208. package/dist/lib/cli-build.d.ts +19 -0
  209. package/dist/lib/cli-build.js +37 -0
  210. package/dist/lib/cli-build.js.map +1 -0
  211. package/dist/lib/conversation-headers.d.ts +18 -0
  212. package/dist/lib/conversation-headers.js +32 -0
  213. package/dist/lib/conversation-headers.js.map +1 -0
  214. package/dist/lib/cp-packages.d.ts +167 -11
  215. package/dist/lib/cp-packages.js +253 -21
  216. package/dist/lib/cp-packages.js.map +1 -1
  217. package/dist/lib/credential-class.d.ts +117 -0
  218. package/dist/lib/credential-class.js +116 -0
  219. package/dist/lib/credential-class.js.map +1 -0
  220. package/dist/lib/describe-lens.d.ts +35 -0
  221. package/dist/lib/describe-lens.js +40 -0
  222. package/dist/lib/describe-lens.js.map +1 -1
  223. package/dist/lib/device-connect.d.ts +125 -0
  224. package/dist/lib/device-connect.js +256 -0
  225. package/dist/lib/device-connect.js.map +1 -0
  226. package/dist/lib/directory-lens.d.ts +99 -0
  227. package/dist/lib/directory-lens.js +187 -0
  228. package/dist/lib/directory-lens.js.map +1 -0
  229. package/dist/lib/exporter-coverage.d.ts +71 -0
  230. package/dist/lib/exporter-coverage.js +97 -0
  231. package/dist/lib/exporter-coverage.js.map +1 -0
  232. package/dist/lib/hub-client.d.ts +29 -8
  233. package/dist/lib/hub-client.js +328 -55
  234. package/dist/lib/hub-client.js.map +1 -1
  235. package/dist/lib/init-connect.d.ts +41 -0
  236. package/dist/lib/init-connect.js +75 -0
  237. package/dist/lib/init-connect.js.map +1 -0
  238. package/dist/lib/init-exit.d.ts +24 -0
  239. package/dist/lib/init-exit.js +41 -0
  240. package/dist/lib/init-exit.js.map +1 -0
  241. package/dist/lib/init-flow.d.ts +25 -0
  242. package/dist/lib/init-flow.js +40 -0
  243. package/dist/lib/init-flow.js.map +1 -0
  244. package/dist/lib/init-handoff.d.ts +16 -0
  245. package/dist/lib/init-handoff.js +19 -0
  246. package/dist/lib/init-handoff.js.map +1 -0
  247. package/dist/lib/init-output.d.ts +18 -0
  248. package/dist/lib/init-output.js +43 -0
  249. package/dist/lib/init-output.js.map +1 -0
  250. package/dist/lib/init-pod.d.ts +45 -0
  251. package/dist/lib/init-pod.js +36 -0
  252. package/dist/lib/init-pod.js.map +1 -0
  253. package/dist/lib/installed.d.ts +210 -11
  254. package/dist/lib/installed.js +234 -36
  255. package/dist/lib/installed.js.map +1 -1
  256. package/dist/lib/key-approval.d.ts +57 -0
  257. package/dist/lib/key-approval.js +117 -0
  258. package/dist/lib/key-approval.js.map +1 -0
  259. package/dist/lib/key-diagnosis.d.ts +56 -0
  260. package/dist/lib/key-diagnosis.js +227 -0
  261. package/dist/lib/key-diagnosis.js.map +1 -0
  262. package/dist/lib/key-doctor.d.ts +136 -0
  263. package/dist/lib/key-doctor.js +286 -0
  264. package/dist/lib/key-doctor.js.map +1 -0
  265. package/dist/lib/key-locations.d.ts +120 -0
  266. package/dist/lib/key-locations.js +326 -0
  267. package/dist/lib/key-locations.js.map +1 -0
  268. package/dist/lib/key-source.d.ts +66 -0
  269. package/dist/lib/key-source.js +106 -0
  270. package/dist/lib/key-source.js.map +1 -0
  271. package/dist/lib/key-state.d.ts +70 -0
  272. package/dist/lib/key-state.js +115 -0
  273. package/dist/lib/key-state.js.map +1 -0
  274. package/dist/lib/kind-package.d.ts +181 -0
  275. package/dist/lib/kind-package.js +679 -0
  276. package/dist/lib/kind-package.js.map +1 -0
  277. package/dist/lib/knowledge-title.d.ts +24 -0
  278. package/dist/lib/knowledge-title.js +46 -0
  279. package/dist/lib/knowledge-title.js.map +1 -0
  280. package/dist/lib/knowledge-title.test.d.ts +1 -0
  281. package/dist/lib/knowledge-title.test.js +34 -0
  282. package/dist/lib/knowledge-title.test.js.map +1 -0
  283. package/dist/lib/machine-instance.d.ts +64 -0
  284. package/dist/lib/machine-instance.js +142 -0
  285. package/dist/lib/machine-instance.js.map +1 -0
  286. package/dist/lib/mcp-bridge.d.ts +42 -0
  287. package/dist/lib/mcp-bridge.js +147 -0
  288. package/dist/lib/mcp-bridge.js.map +1 -0
  289. package/dist/lib/mcp-bridge.test.d.ts +1 -0
  290. package/dist/lib/mcp-bridge.test.js +90 -0
  291. package/dist/lib/mcp-bridge.test.js.map +1 -0
  292. package/dist/lib/mcp-call.d.ts +30 -0
  293. package/dist/lib/mcp-call.js +107 -0
  294. package/dist/lib/mcp-call.js.map +1 -0
  295. package/dist/lib/mcp-snippets.d.ts +53 -0
  296. package/dist/lib/mcp-snippets.js +82 -0
  297. package/dist/lib/mcp-snippets.js.map +1 -0
  298. package/dist/lib/next-steps.d.ts +8 -0
  299. package/dist/lib/next-steps.js +31 -1
  300. package/dist/lib/next-steps.js.map +1 -1
  301. package/dist/lib/package-wire.d.ts +179 -0
  302. package/dist/lib/package-wire.js +68 -0
  303. package/dist/lib/package-wire.js.map +1 -0
  304. package/dist/lib/pair-link.d.ts +38 -0
  305. package/dist/lib/pair-link.js +68 -0
  306. package/dist/lib/pair-link.js.map +1 -0
  307. package/dist/lib/pod-wide.d.ts +40 -0
  308. package/dist/lib/pod-wide.js +46 -0
  309. package/dist/lib/pod-wide.js.map +1 -0
  310. package/dist/lib/pod.d.ts +66 -112
  311. package/dist/lib/pod.js +134 -412
  312. package/dist/lib/pod.js.map +1 -1
  313. package/dist/lib/project-ref.d.ts +9 -3
  314. package/dist/lib/project-ref.js +10 -4
  315. package/dist/lib/project-ref.js.map +1 -1
  316. package/dist/lib/review-credential.d.ts +119 -0
  317. package/dist/lib/review-credential.js +147 -0
  318. package/dist/lib/review-credential.js.map +1 -0
  319. package/dist/lib/safe-open.d.ts +49 -0
  320. package/dist/lib/safe-open.js +119 -0
  321. package/dist/lib/safe-open.js.map +1 -0
  322. package/dist/lib/session-lens.d.ts +40 -1
  323. package/dist/lib/session-lens.js +54 -4
  324. package/dist/lib/session-lens.js.map +1 -1
  325. package/dist/lib/superwhisper-import.d.ts +8 -0
  326. package/dist/lib/superwhisper-import.js +14 -7
  327. package/dist/lib/superwhisper-import.js.map +1 -1
  328. package/dist/lib/superwhisper-title.test.d.ts +1 -0
  329. package/dist/lib/superwhisper-title.test.js +18 -0
  330. package/dist/lib/superwhisper-title.test.js.map +1 -0
  331. package/dist/lib/targets.d.ts +134 -10
  332. package/dist/lib/targets.js +962 -258
  333. package/dist/lib/targets.js.map +1 -1
  334. package/dist/lib/template-file.d.ts +4 -0
  335. package/dist/lib/template-file.js.map +1 -1
  336. package/dist/lib/template-lifecycle.d.ts +217 -0
  337. package/dist/lib/template-lifecycle.js +433 -0
  338. package/dist/lib/template-lifecycle.js.map +1 -0
  339. package/dist/lib/template-steps.d.ts +88 -0
  340. package/dist/lib/template-steps.js +118 -0
  341. package/dist/lib/template-steps.js.map +1 -0
  342. package/dist/lib/time-ago.d.ts +2 -0
  343. package/dist/lib/time-ago.js +15 -0
  344. package/dist/lib/time-ago.js.map +1 -0
  345. package/dist/utils/logger.js +5 -2
  346. package/dist/utils/logger.js.map +1 -1
  347. package/package.json +23 -17
  348. package/skills/README.md +12 -10
  349. package/skills/agent-os/SKILL.md +214 -99
  350. package/skills/manifest.json +1 -1
  351. package/skills/onboard/SKILL.md +55 -26
  352. package/skills/synap/README.md +5 -8
  353. package/skills/synap/SKILL.md +1086 -283
  354. package/skills/synap/_order.txt +7 -0
  355. package/skills/synap/_skill.yaml +1 -1
  356. package/skills/synap/automations.md +15 -0
  357. package/skills/synap/capture.md +5 -0
  358. package/skills/synap/cli-operations.md +24 -18
  359. package/skills/synap/concepts.md +28 -0
  360. package/skills/synap/content-os.md +10 -1
  361. package/skills/synap/creative-loop.md +15 -10
  362. package/skills/synap/crm.md +21 -52
  363. package/skills/synap/document-embeds.md +141 -47
  364. package/skills/synap/escalation-ladder.md +20 -10
  365. package/skills/synap/focus-sessions.md +47 -21
  366. package/skills/synap/from-intent.md +142 -0
  367. package/skills/synap/governance-rules.md +4 -4
  368. package/skills/synap/governance.md +10 -7
  369. package/skills/synap/hand-off.md +33 -0
  370. package/skills/synap/inline-patterns.md +15 -7
  371. package/skills/synap/lenses.md +33 -15
  372. package/skills/synap/linking.md +41 -40
  373. package/skills/synap/mcp-remote-servers.md +64 -0
  374. package/skills/synap/mental-model.md +17 -15
  375. package/skills/synap/operating-mode-rules.md +1 -1
  376. package/skills/synap/processes.md +118 -0
  377. package/skills/synap/quick-reference.md +5 -1
  378. package/skills/synap/reading.md +3 -1
  379. package/skills/synap/reflexes.md +12 -42
  380. package/skills/synap/viewframe-cells.md +38 -5
  381. package/skills/synap/work-flow.md +1 -1
  382. package/skills/synap/work-model.md +96 -0
  383. package/skills/synap/worked-examples.md +4 -5
  384. package/skills/synap/workspace-design.md +14 -10
  385. package/skills/synap/writes.md +97 -24
  386. package/skills/synap/writing-titles.md +63 -0
  387. package/skills/synap-app/SKILL.md +2 -2
  388. package/skills/synap-app/_skill.yaml +1 -1
  389. package/skills/synap-app/install-commands.md +1 -1
  390. package/skills/synap-design/SKILL.md +474 -0
  391. package/skills/synap-design/_order.txt +4 -0
  392. package/skills/synap-design/_skill.yaml +21 -0
  393. package/skills/synap-design/loop.md +134 -0
  394. package/skills/synap-design/overview.md +44 -0
  395. package/skills/synap-design/principles.md +130 -0
  396. package/skills/synap-design/system.md +133 -0
  397. package/skills/synap-market/SKILL.md +306 -16
  398. package/skills/synap-market/_skill.yaml +1 -1
  399. package/skills/synap-market/author-and-publish.md +145 -8
  400. package/skills/synap-market/governance-and-catalog.md +7 -0
  401. package/skills/synap-market/install.md +79 -1
  402. package/skills/synap-market/overview.md +74 -6
  403. package/skills/synap-schema/SKILL.md +172 -10
  404. package/skills/synap-schema/_order.txt +2 -0
  405. package/skills/synap-schema/_skill.yaml +4 -2
  406. package/skills/synap-schema/common-mistakes.md +4 -2
  407. package/skills/synap-schema/discover-profiles.md +1 -1
  408. package/skills/synap-schema/entity-scope.md +15 -3
  409. package/skills/synap-schema/extend-first.md +29 -0
  410. package/skills/synap-schema/extend-vs-create.md +1 -1
  411. package/skills/synap-schema/intent-vocabulary-ssot.md +111 -0
  412. package/skills/synap-schema/property-types.md +1 -1
  413. package/skills/synap-schema/relations.md +1 -1
  414. package/skills/synap-ui/SKILL.md +19 -12
  415. package/skills/synap-ui/_skill.yaml +1 -1
  416. package/skills/synap-ui/ai-companion.md +1 -1
  417. package/skills/synap-ui/arranging-bento.md +2 -0
  418. package/skills/synap-ui/bento-layouts.md +10 -10
  419. package/skills/synap-ui/bento-recipes.md +313 -543
  420. package/skills/synap-ui/four-builds.md +5 -0
  421. package/skills/synap-ui/view-types.md +2 -2
  422. package/skills/synap-ui/widget-catalog.md +22 -278
  423. package/templates/automations/bookmark-enrichment.automation.json +290 -13
  424. package/templates/automations/url-bookmark-capture.automation.json +73 -11
  425. package/templates/skills/authoring-playbooks/SKILL.md +6 -3
  426. package/templates/skills/authoring-playbooks/reference/stages-contract.md +15 -0
  427. package/templates/skills/authoring-playbooks/reference/worked-example.md +3 -0
  428. package/dist/commands/infra.d.ts +0 -21
  429. package/dist/commands/infra.js +0 -262
  430. package/dist/commands/infra.js.map +0 -1
  431. package/dist/commands/template.d.ts +0 -42
  432. package/dist/commands/template.js +0 -191
  433. package/dist/commands/template.js.map +0 -1
  434. package/dist/lib/browser-auth.d.ts +0 -35
  435. package/dist/lib/browser-auth.js +0 -170
  436. package/dist/lib/browser-auth.js.map +0 -1
  437. package/dist/lib/openclaw.d.ts +0 -32
  438. package/dist/lib/openclaw.js +0 -164
  439. package/dist/lib/openclaw.js.map +0 -1
  440. package/dist/lib/seed.d.ts +0 -13
  441. package/dist/lib/seed.js +0 -135
  442. package/dist/lib/seed.js.map +0 -1
  443. package/node_modules/@synap/hub-rest-client/dist/index.cjs +0 -1261
  444. package/node_modules/@synap/hub-rest-client/dist/index.d.cts +0 -1647
  445. package/node_modules/@synap/hub-rest-client/dist/index.d.ts +0 -1647
  446. package/node_modules/@synap/hub-rest-client/dist/index.js +0 -1231
  447. package/node_modules/@synap/hub-rest-client/package.json +0 -45
  448. package/node_modules/@synap/hub-rest-client/src/client.ts +0 -1631
  449. package/node_modules/@synap/hub-rest-client/src/errors.ts +0 -30
  450. package/node_modules/@synap/hub-rest-client/src/index.ts +0 -153
  451. package/node_modules/@synap/hub-rest-client/src/setup.ts +0 -133
  452. package/node_modules/@synap/hub-rest-client/src/types.ts +0 -1299
@@ -23,19 +23,22 @@
23
23
  */
24
24
  import ora from "ora";
25
25
  import chalk from "chalk";
26
- import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, groupByDomain, } from "@synap-core/workspace-templates";
26
+ import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, getWorkspaceTemplate, mergeCatalog, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, groupByDomain, } from "@synap-core/workspace-templates";
27
27
  import { log } from "../utils/logger.js";
28
28
  import { resolveHubConfig, hubPost, renderHubError, HubError } from "../lib/hub-client.js";
29
29
  import { fetchProjects } from "../lib/project.js";
30
30
  import { writeGovernance } from "../lib/capture-lane.js";
31
31
  import { renderNextSteps, FLOW } from "../lib/next-steps.js";
32
32
  import { getStoredToken, isTokenLocallyExpired } from "../lib/auth.js";
33
- import { fetchPublicBrowseRows, fetchMyRows, fetchAvailableSlugs, fetchPackageDefinition, } from "../lib/cp-packages.js";
34
- import { fetchInstalledSlugs, fetchInstalledTemplates } from "../lib/installed.js";
33
+ import prompts from "prompts";
34
+ import { fetchPublicBrowseRows, fetchMyRows, fetchAvailableSlugs, fetchRetiredWorkspaceSlugs, fetchPackageDefinition, fetchPackageRecord, hydrateMineCategories, } from "../lib/cp-packages.js";
35
+ import { fetchInstalledSlugs, fetchInstalledTemplates, fetchInstalledInventory, fetchInstalledInventoryStrict, installedRowsToTemplates, fetchWorkspaceAttachments, fetchWorkspaceAttachmentsStrict, verifyStampLanded, INSTALLED_KINDS } from "../lib/installed.js";
35
36
  import { isSuite } from "../lib/suite.js";
37
+ import { TEMPLATE_STEP_LABELS, classifyTemplateStep, proposalsDoor, stepFromApplyState, templateStepGlyph, templateStepPhrase, } from "../lib/template-steps.js";
36
38
  import { capabilityAdd } from "./capability.js";
37
39
  import { bundledTemplatesVersion } from "../lib/bundle-version.js";
38
40
  import { HubNetworkError } from "../lib/hub-client.js";
41
+ import { parseDataChanges, dataChangesFromRefusal, consentNeedsPerson, dataChangeRefusalMessage, printDataChanges, parseAcceptList, decideConsent, consentRerunCommand, consentRefusalLines, lifecycleNotice, overlayNotice, overlayNeedsAttention, } from "../lib/template-lifecycle.js";
39
42
  // Same longer budget launch uses — an apply writes profiles/views/entities.
40
43
  const APPLY_TIMEOUT_MS = 120_000;
41
44
  /** `--timeout <seconds>` → ms, falling back to `APPLY_TIMEOUT_MS` when unset/invalid. */
@@ -43,6 +46,12 @@ function parseApplyTimeoutMs(raw) {
43
46
  const secs = raw ? parseInt(raw, 10) : NaN;
44
47
  return Number.isFinite(secs) && secs > 0 ? secs * 1000 : APPLY_TIMEOUT_MS;
45
48
  }
49
+ /** Drop workspace entries whose slug the CP retired. `null` (unknown) keeps every entry. */
50
+ export function dropRetiredWorkspaces(entries, retiredSlugs) {
51
+ if (!retiredSlugs || retiredSlugs.size === 0)
52
+ return entries;
53
+ return entries.filter((e) => !(typeOf(e) === "workspace" && retiredSlugs.has(e.slug)));
54
+ }
46
55
  /**
47
56
  * Assemble the market catalog: bundled workspace templates ∪ public CP browse
48
57
  * (all types) ∪ the caller's own packages (when logged in), through the shared
@@ -54,7 +63,7 @@ export async function buildMarketCatalog(filters) {
54
63
  const creds = getStoredToken();
55
64
  const loggedIn = !!creds && !isTokenLocallyExpired(creds);
56
65
  let reachedCp = true;
57
- const [publicRows, mineRows, availableSlugs, installed] = await Promise.all([
66
+ const [publicRows, rawMineRows, availableSlugs, installed, retiredSlugs] = await Promise.all([
58
67
  fetchPublicBrowseRows(filters).catch(() => {
59
68
  reachedCp = false;
60
69
  return [];
@@ -66,14 +75,36 @@ export async function buildMarketCatalog(filters) {
66
75
  ? fetchAvailableSlugs(creds.token, filters).catch(() => null)
67
76
  : Promise.resolve(null),
68
77
  fetchInstalledSlugs(),
78
+ fetchRetiredWorkspaceSlugs(),
69
79
  ]);
80
+ // The `/mine` LIST PROJECTION omits `category` (CP `routes/packages.ts`), so
81
+ // the caller's own rows — the ONLY way a PRIVATE package can reach this
82
+ // catalog — arrive type-less. Untreated, `typeOf`'s display default calls
83
+ // every one of them a workspace: that is why a published cell package was
84
+ // invisible to `--list --type cell` and installed as an empty "New
85
+ // Workspace". Hydrate from the public rows we already have, else the authed
86
+ // single-package door. Best-effort: a row that can't be resolved keeps an
87
+ // ABSENT category (the install refusal is the floor under it).
88
+ const publicCategoryBySlug = new Map();
89
+ for (const r of publicRows)
90
+ publicCategoryBySlug.set(r.slug, r.category);
91
+ const mineRows = loggedIn && creds && rawMineRows.length > 0
92
+ ? await hydrateMineCategories(rawMineRows, creds.token, publicCategoryBySlug).catch(() => rawMineRows)
93
+ : rawMineRows;
70
94
  const merged = assembleCatalog({
71
95
  // Logged in → include PRIVATE bundled templates (e.g. the `the-arch` suite),
72
96
  // matching the "private templates unlocked" promise. Logged out → public
73
97
  // bundle only. (mergeCatalog derives `isPrivate` from each template's
74
98
  // `meta.isPublic === false`, so private bundled entries stay badged.)
75
99
  bundled: loggedIn ? listWorkspaceTemplates() : listPublicTemplates(),
76
- remoteRows: [...publicRows, ...mineRows],
100
+ // `/mine` FIRST: `mergeCatalog` keeps the first remote row for a slug, so
101
+ // this makes the caller's OWN row win a collision with a stranger's public
102
+ // one. That matches the CP's own `GET /:slug` precedence (own row beats a
103
+ // public row) and `remoteVersionBySlug` below, and it is the only order
104
+ // under which the private flag and the hydrated category survive a slug a
105
+ // public package also claims. (The bundled-template squatting defense sits
106
+ // above both and is unaffected.)
107
+ remoteRows: [...mineRows, ...publicRows],
77
108
  bundleVersion: bundledTemplatesVersion(),
78
109
  remoteStatus: reachedCp ? "ok" : "unreachable",
79
110
  });
@@ -102,7 +133,9 @@ export async function buildMarketCatalog(filters) {
102
133
  if (r.version)
103
134
  remoteVersionBySlug.set(r.slug, r.version);
104
135
  return {
105
- entries: merged.entries,
136
+ // A template the CP retired is never offered, listed as installable or
137
+ // suggested — even when the bundled copy this CLI ships still carries it.
138
+ entries: dropRetiredWorkspaces(merged.entries, retiredSlugs),
106
139
  tierBySlug,
107
140
  lockedSlugs,
108
141
  installed,
@@ -112,10 +145,89 @@ export async function buildMarketCatalog(filters) {
112
145
  remoteVersionBySlug,
113
146
  };
114
147
  }
115
- /** The package type of an entry — bundled templates are always `workspace`. */
148
+ /**
149
+ * The human plural for a package kind, in the CLI's own listing.
150
+ *
151
+ * This was `t.charAt(0).toUpperCase() + t.slice(1)`, which `.claude/rules/
152
+ * vocabulary.md` forbids by name — and it showed: the listing printed "Cell"
153
+ * (the registry says **Card**) and "Workflow" (the registry says Automation).
154
+ *
155
+ * The real SSOT is `@synap-core/types/vocabulary`, but this package cannot
156
+ * resolve it — the CLI links only `@synap-core/workspace-templates`, and adding
157
+ * a cross-repo dependency is an install-topology change, not a labelling fix.
158
+ * So the table is local AND pinned: `test/kind-heading-vocabulary-parity.test.ts`
159
+ * source-scans the registry and fails if these words drift from it. That is the
160
+ * same shape as `market-install-kind-parity.test.ts`, which keeps
161
+ * `marketInstallKind` honest against the pod's zod enum across repos.
162
+ */
163
+ const KIND_HEADINGS = {
164
+ capability: "Tools",
165
+ skill: "Tools",
166
+ // The CP publish vocabulary calls it `workflow`; the pod's install ledger
167
+ // (`GET /installed`) calls the same thing `automation`. Both tokens reach
168
+ // `kindHeading()` — `market --list` speaks CP, `market installed` speaks pod —
169
+ // so both are mapped, to the ONE plural the registry says.
170
+ workflow: "Rules",
171
+ automation: "Rules",
172
+ view: "Views",
173
+ cell: "Cards",
174
+ workspace: "Spaces",
175
+ };
176
+ /** Max width of the NAME column in `market installed` — see `displayName`. */
177
+ const NAME_COL_MAX = 44;
178
+ function kindHeading(kind) {
179
+ return KIND_HEADINGS[kind] ?? humanizeKindToken(kind);
180
+ }
181
+ /** Last resort for a kind the CP added and this CLI has not learned yet. */
182
+ function humanizeKindToken(kind) {
183
+ const words = kind.replace(/[-_]+/g, " ").trim();
184
+ return words ? words[0].toUpperCase() + words.slice(1) : kind;
185
+ }
186
+ /**
187
+ * The package type of an entry — bundled templates are always `workspace`.
188
+ *
189
+ * ⚠️ The `?? "workspace"` default is safe for DISPLAY and FILTERING (a bundled
190
+ * template genuinely has no category). It is NOT safe for INSTALL: a package
191
+ * whose category is merely UNKNOWN would install as a workspace. That is not
192
+ * hypothetical — on 2026-09-05 `synap market install probe-card-pack`, a
193
+ * freshly published CELL package, created an empty "New Workspace" and reported
194
+ * "✓ Installed". Install paths must use {@link declaredCategoryOf} and refuse
195
+ * an unknown, never fall through to this default.
196
+ */
116
197
  function typeOf(entry) {
117
198
  return entry.category ?? "workspace";
118
199
  }
200
+ /**
201
+ * The category the catalog ACTUALLY declared, or null when it said nothing.
202
+ *
203
+ * The distinction `typeOf` erases: "this is a workspace" and "I don't know what
204
+ * this is" are different facts, and only one of them may be acted on. Third
205
+ * instance in one day of a defaulted discriminator turning "I don't know" into
206
+ * a confident claim (`status ?? "installed"`, an absent `runResult.success`
207
+ * read as failure, and this).
208
+ */
209
+ function declaredCategoryOf(entry) {
210
+ return entry.category ?? null;
211
+ }
212
+ /**
213
+ * The `kind` values the pod's `market.install` verb provisions.
214
+ *
215
+ * SSOT: the `kind` zod enum of `marketInstallParams` in
216
+ * `synap-backend/packages/api/src/services/capabilities/builtin-verbs.ts`
217
+ * (each arm implemented in `marketplace-install.ts`). This list is a
218
+ * hand-maintained copy — `test/market-install-kind.test.ts` pins it against a
219
+ * copy of that enum so a widening on the pod cannot silently leave the CLI
220
+ * refusing a kind the verb accepts (which is exactly how `view` and `skill`
221
+ * became uninstallable from the CLI).
222
+ */
223
+ export const MARKET_INSTALL_KINDS = [
224
+ "capability",
225
+ "automation",
226
+ "template",
227
+ "cell",
228
+ "skill",
229
+ "view",
230
+ ];
119
231
  /**
120
232
  * Map a CP package `category` (what {@link typeOf} returns) → the pod
121
233
  * `market.install` verb's `kind` vocabulary, or `null` when the verb can't
@@ -137,10 +249,53 @@ export function marketInstallKind(type) {
137
249
  return "automation";
138
250
  case "template":
139
251
  return "template";
252
+ // The verb has always accepted these — the CLI returning null here is what
253
+ // made the published `view` packages uninstallable from the terminal.
254
+ case "skill":
255
+ return "skill";
256
+ case "view":
257
+ return "view";
140
258
  default:
141
259
  return null;
142
260
  }
143
261
  }
262
+ /**
263
+ * Kinds whose `market.install` arm HARD-REQUIRES an acting workspace and 400s
264
+ * without one (`marketplace-install.ts`): an automation is added TO a workspace,
265
+ * and a view package needs a workspace to place its views in. Every other kind
266
+ * (capability / cell / skill) lands pod-wide — that branch is deliberate, so
267
+ * they must NOT be added here.
268
+ */
269
+ export function marketInstallNeedsWorkspace(kind) {
270
+ return kind === "automation" || kind === "view";
271
+ }
272
+ /**
273
+ * Classify the PER-ITEM results of a kind:automation (workflow) install.
274
+ *
275
+ * The backend `market.install` automation case installs each automation under
276
+ * its own try/catch (per-item isolation), so the verb can report the overall
277
+ * install as `{status:"installed"}` while individual items are
278
+ * `{status:"error"}`. The CLI must inspect these before printing success — a
279
+ * bare "✓ Installed" is a lie when every item errored (the false-success bug).
280
+ *
281
+ * Returns null for any outcome that carries NO per-item array (cell, skill,
282
+ * workspace, …) so the caller falls through to the unchanged success line.
283
+ * Reads `automations[]` (the automation kind's field) or a generic `results[]`.
284
+ */
285
+ export function summarizeItemInstall(installed) {
286
+ const raw = Array.isArray(installed.automations)
287
+ ? installed.automations
288
+ : Array.isArray(installed.results)
289
+ ? installed.results
290
+ : undefined;
291
+ if (!raw)
292
+ return null;
293
+ const items = raw;
294
+ const created = items.filter((i) => i.status === "created").length;
295
+ const reused = items.filter((i) => i.status === "reused").length;
296
+ const errored = items.filter((i) => i.status === "error");
297
+ return { created, reused, errored, okCount: created + reused };
298
+ }
144
299
  /**
145
300
  * Client-side filter. The CP browse route filters REMOTE rows server-side, but
146
301
  * `assembleCatalog` always adds the BUNDLED (all-`workspace`) templates — so a
@@ -163,6 +318,17 @@ function matchesFilters(entry, filters) {
163
318
  function clip(s, n) {
164
319
  return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "…";
165
320
  }
321
+ /**
322
+ * True when the pod reported a FAILED install layer.
323
+ *
324
+ * Reads `status` explicitly rather than inferring failure from the absence of a
325
+ * success marker: an unrecognised status is not a failure, and an absent
326
+ * `layers` array is not a success. Both are simply "not a reported failure",
327
+ * which is what the pre-existing `seedFailed` path already covers.
328
+ */
329
+ export function hasLayerFailure(layers) {
330
+ return (layers ?? []).some((l) => l?.status === "failed");
331
+ }
166
332
  /**
167
333
  * Print an honest per-dependency seed summary — no more silent "✓ ready" when
168
334
  * a dependency's capabilities/playbooks failed to seed. Prefers the top-level
@@ -170,6 +336,23 @@ function clip(s, n) {
170
336
  * back to re-deriving from the per-dependency `dependencies[]` array on older
171
337
  * pod builds. No-op when there's nothing to report.
172
338
  */
339
+ /**
340
+ * Name the install layers the pod reported as FAILED.
341
+ *
342
+ * The verdict line already says "some capabilities failed"; without
343
+ * this the user is told something failed and never which thing — the same
344
+ * complaint that made `install-layers.ts` fall back from `name` to `key`.
345
+ * Silent when the pod sent no layers (an older pod) or none failed.
346
+ */
347
+ export function printLayerOutcomes(layers) {
348
+ const failed = (layers ?? []).filter((l) => l?.status === "failed");
349
+ if (failed.length === 0)
350
+ return;
351
+ for (const l of failed) {
352
+ log.error(`${l.layer ?? "(unnamed layer)"} — ${l.message ?? "no detail reported"}`);
353
+ }
354
+ log.hint("The workspace itself was created. Re-run the install to retry the failed layer(s); nothing above is retried automatically.");
355
+ }
173
356
  function printSeedOutcomes(dependencies, seedSummary) {
174
357
  if (seedSummary) {
175
358
  if (seedSummary.attempted === 0)
@@ -218,11 +401,61 @@ function hasSeedFailure(dependencies, seedSummary) {
218
401
  * not that the install was genuinely a no-op (the observed `market install
219
402
  * the-arch` → "already up to date" bug).
220
403
  */
221
- function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamped) {
222
- if (!remoteVersionSent || !wasUnstamped)
404
+ function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamped, stampVerified) {
405
+ if (!remoteVersionSent)
406
+ return false;
407
+ // GROUND TRUTH wins: a post-apply re-read of the workspace's stored stamp.
408
+ // `true` = it landed (never warn), `false` = reached the pod and it's still
409
+ // missing (warn — old pod, no stamp-on-reconcile). Only fall back to the
410
+ // proxy below when the re-read wasn't attempted/possible (`undefined`/`null`),
411
+ // because the proxy false-positives once the pod DOES stamp on an unchanged
412
+ // (content-identical) reconcile — `outcome` still comes back `"unchanged"`.
413
+ if (stampVerified === true)
414
+ return false;
415
+ if (stampVerified === false)
416
+ return true;
417
+ if (!wasUnstamped)
223
418
  return false;
224
419
  return ws?.outcome === "unchanged" || (ws?.outcome === undefined && seedSummary === undefined);
225
420
  }
421
+ /**
422
+ * How far the pod says layer 2 got, read from the `/packages/apply` response
423
+ * (synap-backend `hub-protocol/rest/packages.ts`): a thrown layer 2 arrives as
424
+ * `layer2: {status: "failed"}`, an unconverged one as the `templateApply`
425
+ * record, and either way the pod's sentence as `templateApplyWarning`.
426
+ * `null` = the pod reported nothing unfinished (converged, or an older pod).
427
+ */
428
+ export function podApplyState(res) {
429
+ const layer2 = res.layer2;
430
+ if (layer2?.status === "failed")
431
+ return "not-finished";
432
+ const record = res.templateApply;
433
+ if (record?.state === "awaiting-approval")
434
+ return "awaiting-approval";
435
+ if (record || heldVersionLine(res))
436
+ return "not-finished";
437
+ return null;
438
+ }
439
+ /**
440
+ * The verdict of one `/packages/apply` response — the ONE reader both
441
+ * `marketInstall` and `applyOnePackage` call. Layer 2's outcome comes from the
442
+ * pod's own state (`podApplyState`): an apply the pod says is unfinished is
443
+ * never "updated", and never a stamp contradiction (the pod held the version
444
+ * on purpose — redeploying fixes nothing).
445
+ */
446
+ export function classifyApplyResponse(entry, res, ctx) {
447
+ const state = podApplyState(res);
448
+ if (state) {
449
+ const recorded = res.templateApply?.proposals;
450
+ const ids = Array.isArray(recorded) ? recorded.filter((p) => typeof p === "string") : [];
451
+ const next = heldVersionLine(res) ??
452
+ (state === "awaiting-approval"
453
+ ? `Approve its proposals (${proposalsDoor(ids) ?? "synap proposals"}), then run synap templates update ${entry.slug}.`
454
+ : `Run synap templates update ${entry.slug} to finish it.`);
455
+ return { status: state, warnings: [next] };
456
+ }
457
+ return classifyApplyResult(entry, res.workspace, res.dependencies, res.seedSummary, ctx);
458
+ }
226
459
  /**
227
460
  * Classify one `/packages/apply` response into the honest verdict both
228
461
  * `marketInstall` and `applyOnePackage` print (and both put in `--json`).
@@ -232,8 +465,11 @@ function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamp
232
465
  * already-versioned slugs, so it always passes `false`).
233
466
  */
234
467
  export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
235
- const seedFailed = hasSeedFailure(dependencies, seedSummary);
236
- if (detectStampContradiction(ws, seedSummary, ctx.remoteVersionSent, ctx.wasUnstamped)) {
468
+ // A post-workspace LAYER failure counts the same as a seed failure: the
469
+ // workspace exists, but part of what the package promised did not land.
470
+ const seedFailed = hasSeedFailure(dependencies, seedSummary) || hasLayerFailure(ws?.layers);
471
+ if (!ctx.stampHeld &&
472
+ detectStampContradiction(ws, seedSummary, ctx.remoteVersionSent, ctx.wasUnstamped, ctx.stampVerified)) {
237
473
  return {
238
474
  status: "stamp-contradiction",
239
475
  warnings: [
@@ -248,8 +484,13 @@ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
248
484
  if (ws?.outcome === "unchanged")
249
485
  return { status: "unchanged", warnings: [] };
250
486
  if (ws?.status === "composed") {
487
+ // Honest: a compose-overlay has no standalone workspace — it merges INTO
488
+ // its base. But it IS updatable: `market update <slug>` re-sends the
489
+ // package, the pod re-resolves the base and re-layers the latest content
490
+ // (idempotent, additive). So this agrees with `market update` listing the
491
+ // slug as a normal updatable row — don't tell the user it "isn't updatable".
251
492
  const warnings = [
252
- `${entry.name} is a compose-overlay — it rides its base workspace and isn't independently updatable; update the base workspace to update this too.`,
493
+ `${entry.name} is a compose-overlay — it layers onto its base workspace rather than creating its own. Pull its latest anytime with: synap market update ${entry.slug}`,
253
494
  ];
254
495
  return { status: seedFailed ? "composed-with-failures" : "composed", warnings };
255
496
  }
@@ -257,6 +498,25 @@ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
257
498
  return { status: "legacy-unknown", warnings: [] };
258
499
  return { status: "no-workspace", warnings: [] };
259
500
  }
501
+ /** One plain line per deferred placement; `[]` when none (or an older pod). */
502
+ export function deferredPlacementLines(res) {
503
+ const deferred = res.actionPlacements?.deferred;
504
+ if (!Array.isArray(deferred))
505
+ return [];
506
+ return deferred
507
+ .filter((d) => d && typeof d.proposalId === "string")
508
+ .map((d) => `The "${d.label ?? d.ref}" button appears once you approve proposal ${d.proposalId} (synap open proposal ${d.proposalId}), then run synap market update again.`);
509
+ }
510
+ /**
511
+ * Why the pod did NOT record the template version — the pod's ONE sentence
512
+ * (`templateApplyWarning` on the `/packages/apply` response, synap-backend
513
+ * `templateApplyWarning`): the same words `synap templates` keeps showing under
514
+ * the space. Printed verbatim; `null` when the version was recorded (or an
515
+ * older pod that does not say).
516
+ */
517
+ export function heldVersionLine(res) {
518
+ return typeof res.templateApplyWarning === "string" ? res.templateApplyWarning : null;
519
+ }
260
520
  /** Render a `classifyApplyResult` verdict — the ONE honest-text door for both `marketInstall` and `applyOnePackage`. */
261
521
  export function printApplyVerdict(entry, verdict) {
262
522
  switch (verdict.status) {
@@ -264,13 +524,13 @@ export function printApplyVerdict(entry, verdict) {
264
524
  log.success(`Installed ${entry.name}.`);
265
525
  return;
266
526
  case "installed-with-failures":
267
- log.warn(`Installed ${entry.name} — completed with capability failures (see below).`);
527
+ log.warn(`Installed ${entry.name}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
268
528
  return;
269
529
  case "updated":
270
530
  log.success(`Updated ${entry.name}.`);
271
531
  return;
272
532
  case "updated-with-failures":
273
- log.warn(`Updated ${entry.name} — completed with capability failures (see below).`);
533
+ log.warn(`Updated ${entry.name}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
274
534
  return;
275
535
  case "unchanged":
276
536
  log.info(`• ${entry.name} is already up to date.`);
@@ -280,7 +540,15 @@ export function printApplyVerdict(entry, verdict) {
280
540
  log.hint(verdict.warnings[0]);
281
541
  return;
282
542
  case "composed-with-failures":
283
- log.warn(`${entry.name} — layered onto its base workspace, with capability failures (see below).`);
543
+ log.warn(`${entry.name} — layered onto its base workspace, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
544
+ log.hint(verdict.warnings[0]);
545
+ return;
546
+ case "not-finished":
547
+ log.warn(`${entry.name} is in place, but ${templateStepPhrase("unfinished")}.`);
548
+ log.hint(verdict.warnings[0]);
549
+ return;
550
+ case "awaiting-approval":
551
+ log.info(`${entry.name} is in place; the rest waits on your approval.`);
284
552
  log.hint(verdict.warnings[0]);
285
553
  return;
286
554
  case "stamp-contradiction":
@@ -463,7 +731,7 @@ function renderCatalog(entries, cat) {
463
731
  }
464
732
  const types = [...byType.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
465
733
  for (const t of types) {
466
- log.heading(` ${t.charAt(0).toUpperCase() + t.slice(1)}`);
734
+ log.heading(` ${kindHeading(t)}`);
467
735
  log.blank();
468
736
  for (const e of byType.get(t))
469
737
  row(e);
@@ -578,7 +846,41 @@ export async function marketInstall(slug, opts) {
578
846
  log.hint("Browse what's available: synap market --list");
579
847
  process.exit(1);
580
848
  }
581
- const type = typeOf(entry);
849
+ // Install must know what it is installing. `typeOf`'s display default would
850
+ // silently provision a WORKSPACE for anything the catalog could not classify
851
+ // — which is exactly how a published cell package became "New Workspace".
852
+ // Last chance before refusing: the catalog's `/mine` rows carry no category,
853
+ // and hydration there is bounded/best-effort. For THIS one slug we can ask
854
+ // the authed single-package door directly — the only door that can see a
855
+ // private package the caller owns. Still no defaulting: an unresolved
856
+ // category falls through to the refusal below.
857
+ let declared = declaredCategoryOf(entry);
858
+ if (!declared) {
859
+ const token = getStoredToken()?.token;
860
+ if (token) {
861
+ const rec = await fetchPackageRecord(slug, token);
862
+ declared = rec?.category ?? null;
863
+ }
864
+ }
865
+ if (!declared) {
866
+ log.error(`Can't install "${slug}": the catalog didn't say what kind of package it is.`);
867
+ log.hint("This usually means the package is PRIVATE and the public catalog can't see it. Run `synap login`, then retry — nothing was installed.");
868
+ process.exit(1);
869
+ }
870
+ const type = declared;
871
+ // `--dry-run` promises "writes nothing". Only the WORKSPACE path below can
872
+ // keep that promise: it has a write-free preflight (`/packages/preflight`).
873
+ // The `market.install` verb has no `dryRun` parameter (see
874
+ // `marketInstallParams` in the pod's `builtin-verbs.ts`), and `cap add`
875
+ // installs immediately — so for every other kind this flag used to be
876
+ // silently ignored and the package was INSTALLED FOR REAL. A safety flag that
877
+ // performs the action it promises to skip is worse than no flag, so refuse
878
+ // and say why rather than quietly doing the write.
879
+ if (opts.dryRun && type !== "workspace") {
880
+ log.error(`--dry-run isn't available for ${type} packages.`);
881
+ log.hint(`Only workspace packages have a write-free preflight. Nothing was installed. To install for real: synap market install ${slug}`);
882
+ process.exit(1);
883
+ }
582
884
  // A CAPABILITY installs through the SAME door as `cap add` — one code path, so
583
885
  // the two commands can never diverge. That door resolves the template by key
584
886
  // (so opt-in / syncByDefault:false caps like unipile-linkedin work — the
@@ -612,13 +914,36 @@ export async function marketInstall(slug, opts) {
612
914
  renderHubError(e);
613
915
  process.exit(1);
614
916
  }
917
+ // An `automation`/`workflow` install is added TO a workspace, and a `view`
918
+ // package needs a workspace to place its views in — the backend
919
+ // `market.install` verb hard-requires `workspaceId` for both
920
+ // (marketplace-install.ts) and 400s without it. Resolve it from the
921
+ // explicit `--workspace` flag, else the active lens (`synap use` / env),
922
+ // and fail with an actionable message rather than passing null downstream.
923
+ // Every OTHER kind (cell/capability/skill) lands pod-wide — leave
924
+ // workspaceId undefined so that deliberate pod-wide branch is preserved.
925
+ let workspaceId;
926
+ if (marketInstallNeedsWorkspace(kind)) {
927
+ workspaceId = opts.workspace ?? cfg.workspaceId;
928
+ if (!workspaceId) {
929
+ log.error(`Installing "${entry.name}" (a ${type}) needs an acting workspace to add it to.`);
930
+ log.hint("Pass one: synap market install " + slug + " --workspace <workspaceId>");
931
+ log.hint("Or set a default lens: synap use <workspace> (see: synap orient)");
932
+ process.exit(1);
933
+ }
934
+ }
615
935
  const s = opts.json ? null : ora(`Installing ${entry.name}…`).start();
616
936
  let res;
617
937
  try {
618
- // Pod-wide install: no workspaceId (a capability/cell/template lands on
619
- // the pod). `market.install` is a WRITE builtin — an operator run
620
- // owner-bypasses the gate and runs it in-process.
621
- res = (await hubPost("/capabilities/execute", { verbId: "market.install", parameters: { slug, kind } }, cfg, applyTimeoutMs));
938
+ // Pod-wide install by default: no workspaceId (a capability/cell lands on
939
+ // the pod). An automation/workflow carries the resolved `workspaceId`
940
+ // (required — see above). `market.install` is a WRITE builtin — an
941
+ // operator run owner-bypasses the gate and runs it in-process.
942
+ res = (await hubPost("/capabilities/execute", {
943
+ verbId: "market.install",
944
+ parameters: { slug, kind },
945
+ ...(workspaceId ? { workspaceId } : {}),
946
+ }, cfg, applyTimeoutMs));
622
947
  }
623
948
  catch (e) {
624
949
  s?.stop();
@@ -641,18 +966,85 @@ export async function marketInstall(slug, opts) {
641
966
  }
642
967
  s?.stop();
643
968
  // The execute door wraps the verb's return as { status:"run", result: <MarketInstallOutcome> }.
644
- // The outcome is { status:"installed", result } (operator) or { status:"proposed", proposalId, reviewUrl }.
969
+ //
970
+ // ⚠️ DO NOT default this to "installed". A governed install comes back as a
971
+ // PROPOSAL, and the envelope's discriminator is `kind` — not `status`
972
+ // (`execute-capability.ts`: `{ kind:"proposed", proposalId, reviewUrl,
973
+ // ackState:"proposed" }`). Some hops flatten that to `{ proposed:true, … }`.
974
+ // Either way `outcome.status` is undefined, so `?? "installed"` printed a
975
+ // green "✓ Installed" for a write that had NOT happened — found by
976
+ // dogfooding `synap market install task-views-pack` on a live pod
977
+ // 2026-09-05, which returned `outcome:"installed"` alongside
978
+ // `proposed:true`. This is the same defaulted-discriminator class as the
979
+ // `changeType ?? "update"` bug that once titled every capability run
980
+ // "Update Capability".
981
+ //
982
+ // So: read every honest marker, and when none is present say so rather than
983
+ // assuming success.
645
984
  const outcome = (res.result ?? res);
646
- const status = String(outcome.status ?? "installed");
985
+ const isProposed = outcome.kind === "proposed" ||
986
+ outcome.status === "proposed" ||
987
+ outcome.ackState === "proposed" ||
988
+ outcome.proposed === true;
989
+ const status = isProposed
990
+ ? "proposed"
991
+ : outcome.status
992
+ ? String(outcome.status)
993
+ : outcome.ackState === "applied" || outcome.result !== undefined
994
+ ? "installed"
995
+ : "unknown";
647
996
  if (opts.json) {
648
997
  console.log(JSON.stringify({ slug, kind, outcome: status, result: outcome }, null, 2));
649
998
  return;
650
999
  }
651
1000
  if (status === "proposed") {
652
1001
  const proposalId = outcome.proposalId ? String(outcome.proposalId) : undefined;
653
- log.info(`${entry.name} — proposed (under review, not live yet).`);
1002
+ const reviewUrl = outcome.reviewUrl ? String(outcome.reviewUrl) : undefined;
1003
+ log.info(`${entry.name} — proposed (under review, NOT installed yet).`);
654
1004
  if (proposalId)
655
- log.hint(`Approve: synap proposals approve ${proposalId}`);
1005
+ log.hint(`Review and approve: synap open proposal ${proposalId}`);
1006
+ if (reviewUrl)
1007
+ log.hint(`Review: ${reviewUrl}`);
1008
+ return;
1009
+ }
1010
+ if (status === "unknown") {
1011
+ // The pod answered with a shape carrying no honest marker at all. Saying
1012
+ // "installed" here is exactly the lie this block exists to prevent.
1013
+ log.warn(`${entry.name} — the pod returned an unrecognised result; nothing is assumed installed. Check \`synap market installed\` before retrying.`);
1014
+ return;
1015
+ }
1016
+ // A kind:automation (workflow) install returns PER-ITEM results with
1017
+ // per-item isolation — each automation can be {status:"error"} while the
1018
+ // verb overall still reports {status:"installed"} (marketplace-install.ts
1019
+ // automation case). A bare "✓ Installed" here would lie when every item
1020
+ // errored (0 automations created). Inspect the per-item array and report
1021
+ // the real outcome: N created · N reused · N error, surfacing each error's
1022
+ // message, and exit non-zero if NOTHING was created. Other kinds carry no
1023
+ // such array — `summarizeItemInstall` returns null and we fall through to
1024
+ // the unchanged success line.
1025
+ const installed = (outcome.result ?? outcome);
1026
+ const summary = summarizeItemInstall(installed);
1027
+ if (summary) {
1028
+ const { created, reused, errored, okCount } = summary;
1029
+ const counts = `${created} created · ${reused} reused · ${errored.length} error`;
1030
+ if (errored.length > 0) {
1031
+ // Never print a bare success when any item failed.
1032
+ if (okCount === 0) {
1033
+ log.error(`${entry.name} — install failed (${counts}).`);
1034
+ }
1035
+ else {
1036
+ log.warn(`${entry.name} — installed partially (${counts}).`);
1037
+ }
1038
+ for (const e of errored) {
1039
+ log.error(`${e.name ?? "(unnamed)"} — ${e.message ?? "unknown error"}`);
1040
+ }
1041
+ // Total failure (nothing created or reused) exits non-zero so scripts
1042
+ // and users see the real outcome; a partial install stays exit 0.
1043
+ if (okCount === 0)
1044
+ process.exit(1);
1045
+ return;
1046
+ }
1047
+ log.success(`Installed ${entry.name} (${counts}).`);
656
1048
  return;
657
1049
  }
658
1050
  log.success(`Installed ${entry.name}.`);
@@ -686,25 +1078,17 @@ export async function marketInstall(slug, opts) {
686
1078
  // Build the install payload — bundled (offline, byte-identical to the CP
687
1079
  // definition) when available, else the authed CP definition.
688
1080
  const cpToken = getStoredToken()?.token;
689
- let pkg;
690
- if (cat.bundledSlugs.has(slug)) {
691
- pkg = toPackageDefinition(slug);
692
- }
693
- else {
694
- const def = await fetchPackageDefinition(slug, cpToken);
695
- if (!def) {
696
- log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
697
- log.hint(cat.loggedIn ? "" : "It may be private — try: synap login");
698
- process.exit(1);
699
- }
700
- pkg = def;
1081
+ // The definition + an HONEST version label (see `resolveInstallPayload`):
1082
+ // the catalog version only rides on the catalog's own definition.
1083
+ const payload = await resolveInstallPayload(slug, cat, cpToken);
1084
+ if (!payload) {
1085
+ log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
1086
+ log.hint(cat.loggedIn ? "" : "It may be private — try: synap login");
1087
+ process.exit(1);
701
1088
  }
702
- // Stamp the version the catalog resolved for this slug — including the
703
- // authed `/mine` version for private templates — so the pod records the
704
- // installed baseline and later `market update` drift checks work.
705
- const remoteVersion = cat.remoteVersionBySlug.get(slug);
706
- if (remoteVersion)
707
- pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
1089
+ const pkg = payload.pkg;
1090
+ // Only a catalog-sourced install can predict the stamp the pod will record.
1091
+ const remoteVersion = payload.catalogVersion;
708
1092
  // ── --dry-run: write-free preflight of the CREATE path. ───────────────────
709
1093
  // Runs the pod's REAL resolver (write-free) so a template author sees
710
1094
  // profileKind conflicts and other resolution failures BEFORE installing.
@@ -749,18 +1133,27 @@ export async function marketInstall(slug, opts) {
749
1133
  }
750
1134
  log.info(`${entry.name} — proposed (under review, not live yet).`);
751
1135
  if (proposalId)
752
- log.hint(`Approve: synap proposals approve ${proposalId}`);
1136
+ log.hint(`Review and approve: synap open proposal ${proposalId}`);
753
1137
  renderNextSteps(steps);
754
1138
  return;
755
1139
  }
756
1140
  const ws = res.workspace;
757
1141
  const dependencies = res.dependencies;
758
1142
  const seedSummary = res.seedSummary;
1143
+ // GROUND TRUTH: re-read the workspace's actual stored stamp instead of
1144
+ // guessing "did it stamp?" from the apply-response `outcome` (which reads
1145
+ // "unchanged" on a content-identical reconcile even when the stamp DID
1146
+ // land). Only worth a call when we sent a version and know the workspace.
1147
+ let stampVerified;
1148
+ if (remoteVersion && ws?.workspaceId) {
1149
+ stampVerified = await verifyStampLanded(ws.workspaceId, remoteVersion, cfg);
1150
+ }
759
1151
  const steps = FLOW.afterMarketInstall({ slug, proposed: false, projectName });
760
1152
  s?.stop();
761
- const verdict = classifyApplyResult(entry, ws, dependencies, seedSummary, {
1153
+ const verdict = classifyApplyResponse(entry, res, {
762
1154
  remoteVersionSent: remoteVersion,
763
1155
  wasUnstamped,
1156
+ stampVerified,
764
1157
  });
765
1158
  if (opts.json) {
766
1159
  console.log(JSON.stringify({
@@ -789,7 +1182,7 @@ export async function marketInstall(slug, opts) {
789
1182
  break;
790
1183
  case "installed-with-failures":
791
1184
  case "updated-with-failures":
792
- log.warn(`Reconciled ${entry.name} onto ${wsLabel} — completed with capability failures (see below).`);
1185
+ log.warn(`Reconciled ${entry.name} onto ${wsLabel}, but ${templateStepPhrase("unfinished")}: some capabilities failed (see below).`);
793
1186
  break;
794
1187
  case "unchanged":
795
1188
  log.info(`• ${entry.name} is already up to date on ${wsLabel}.`);
@@ -802,6 +1195,9 @@ export async function marketInstall(slug, opts) {
802
1195
  printApplyVerdict(entry, verdict);
803
1196
  }
804
1197
  printSeedOutcomes(dependencies, seedSummary);
1198
+ printLayerOutcomes(ws?.layers);
1199
+ for (const line of deferredPlacementLines(res))
1200
+ log.info(line);
805
1201
  if (ws?.outcome || ws?.status === "composed" || ws?.status === "created")
806
1202
  renderNextSteps(steps);
807
1203
  }
@@ -813,18 +1209,103 @@ export async function marketInstall(slug, opts) {
813
1209
  }
814
1210
  }
815
1211
  /**
816
- * Per-installed-slug drift check. Starts from the shared `computeTemplateUpdates`
817
- * (handles bundled-only / private-only slugs via `bundleVersion`), then
818
- * overrides `latestVersion`/`updateAvailable` with the CP row's OWN version for
819
- * any slug in `remoteVersionBySlug` — the merge-winner bypass this command
820
- * needs (see `MarketCatalog.remoteVersionBySlug`'s doc). A slug the pod never
821
- * version-stamped is reported `noVersionInfo: true`, never silently "outdated".
1212
+ * Pick the install definition through the SAME winner rule the catalog uses
1213
+ * (`mergeCatalog`: an official CP row wins over this CLI's frozen bundle only
1214
+ * when its `definition.sourcePackage.version` is strictly newer), and label it
1215
+ * honestly: a catalog definition carries the catalog version; a bundled one
1216
+ * carries the bundle's own package version (`bundle@x.y.z`), never the
1217
+ * catalog's. The pod re-derives the stamp from what it applied either way.
1218
+ *
1219
+ * Returns `null` only for a non-bundled slug whose definition could not be
1220
+ * fetched (the caller reports it). A failed fetch for a BUNDLED slug falls back
1221
+ * to the bundle — the documented offline path — labelled as the bundle.
1222
+ */
1223
+ export async function resolveInstallPayload(slug, cat, cpToken) {
1224
+ const catalogVersion = cat.remoteVersionBySlug.get(slug);
1225
+ if (!cat.bundledSlugs.has(slug)) {
1226
+ const def = await fetchPackageDefinition(slug, cpToken);
1227
+ if (!def)
1228
+ return null;
1229
+ return { pkg: stampVersion(def, slug, catalogVersion), from: "catalog", catalogVersion };
1230
+ }
1231
+ if (catalogVersion && cat.reachedCp) {
1232
+ const def = await fetchPackageDefinition(slug, cpToken).catch(() => null);
1233
+ const tpl = getWorkspaceTemplate(slug);
1234
+ if (def && tpl) {
1235
+ const winner = mergeCatalog({
1236
+ bundled: [tpl],
1237
+ remote: [{ slug, name: slug, definition: def }],
1238
+ bundleVersion: bundledTemplatesVersion(),
1239
+ }).entries.find((e) => e.slug === slug);
1240
+ if (winner?.source === "remote") {
1241
+ return { pkg: stampVersion(def, slug, catalogVersion), from: "catalog", catalogVersion };
1242
+ }
1243
+ }
1244
+ }
1245
+ const bundleVersion = bundledTemplatesVersion();
1246
+ const pkg = toPackageDefinition(slug);
1247
+ return { pkg: stampVersion(pkg, slug, bundleVersion ? `bundle@${bundleVersion}` : undefined), from: "bundle" };
1248
+ }
1249
+ /**
1250
+ * Label the payload with the slug it was resolved for and the version it came
1251
+ * from. The SLUG is load-bearing: the pod keys "which existing workspace is
1252
+ * this?" on `body._meta.slug`. The CP now stamps `_meta.slug` on every stored
1253
+ * and served definition (`synap-control-plane-api` `seeds/template-identity.ts`),
1254
+ * and the bundle's `toPackageDefinition` carries it too; setting it here keeps
1255
+ * it true against a CP that predates the stamp. Without it a governed apply's
1256
+ * approval matched nothing and CREATED a second "Content OS" (2026-10-06)
1257
+ * instead of updating.
1258
+ */
1259
+ function stampVersion(pkg, slug, version) {
1260
+ const meta = pkg._meta ?? {};
1261
+ return { ...pkg, _meta: { ...meta, slug, ...(version ? { version } : {}) } };
1262
+ }
1263
+ export function updateDoorFor(kind) {
1264
+ switch (kind ?? "workspace") {
1265
+ case "workspace":
1266
+ return "packages-apply";
1267
+ case "view":
1268
+ case "skill":
1269
+ case "automation":
1270
+ return "configs-reconcile";
1271
+ default:
1272
+ return null;
1273
+ }
1274
+ }
1275
+ /**
1276
+ * Per-installed-slug drift check. SERVER-FIRST: when the pod runs the
1277
+ * server-side TemplateHealth code (Hub `/workspaces` returns `drifted`/
1278
+ * `latestVersion`), that verdict wins — the CLI stops re-deriving drift, so it
1279
+ * can't disagree with the pod (or the browser, which reads the same authority).
1280
+ *
1281
+ * FALLBACK (older pod, `drifted === undefined`): the legacy client derivation —
1282
+ * the shared `computeTemplateUpdates` (handles bundled-only / private-only slugs
1283
+ * via `bundleVersion`), then override `latestVersion`/`updateAvailable` with the
1284
+ * CP row's OWN version for any slug in `remoteVersionBySlug` (the merge-winner
1285
+ * bypass). A slug the pod never version-stamped is `noVersionInfo: true`, never
1286
+ * silently "outdated".
822
1287
  */
823
1288
  export function computeUpdates(installedTemplates, cat) {
824
- const withVersion = installedTemplates.filter((t) => !!t.version);
825
- const base = computeTemplateUpdates(withVersion.map((t) => ({ slug: t.slug, version: t.version })), cat.entries, bundledTemplatesVersion());
1289
+ // Only the fallback path needs the client-derived base — compute it for the
1290
+ // slugs the server didn't answer for, so a fully-modern pod does no extra work.
1291
+ const needsFallback = installedTemplates.filter((t) => t.drifted === undefined && !!t.version);
1292
+ const base = needsFallback.length
1293
+ ? computeTemplateUpdates(needsFallback.map((t) => ({ slug: t.slug, version: t.version })), cat.entries, bundledTemplatesVersion())
1294
+ : [];
826
1295
  const baseBySlug = new Map(base.map((b) => [b.slug, b]));
827
1296
  return installedTemplates.map((t) => {
1297
+ // ── Server-side TemplateHealth wins ──
1298
+ if (t.drifted !== undefined) {
1299
+ return {
1300
+ slug: t.slug,
1301
+ installedVersion: t.version ?? "",
1302
+ latestVersion: t.latestVersion ?? undefined,
1303
+ updateAvailable: t.drifted,
1304
+ noVersionInfo: !t.version,
1305
+ kind: t.kind,
1306
+ };
1307
+ }
1308
+ // ── Client fallback (older pod) ──
828
1309
  if (!t.version) {
829
1310
  return {
830
1311
  slug: t.slug,
@@ -832,6 +1313,7 @@ export function computeUpdates(installedTemplates, cat) {
832
1313
  latestVersion: cat.remoteVersionBySlug.get(t.slug),
833
1314
  updateAvailable: false,
834
1315
  noVersionInfo: true,
1316
+ kind: t.kind,
835
1317
  };
836
1318
  }
837
1319
  const b = baseBySlug.get(t.slug);
@@ -842,18 +1324,24 @@ export function computeUpdates(installedTemplates, cat) {
842
1324
  latestVersion: remoteVersion,
843
1325
  updateAvailable: remoteVersion !== t.version,
844
1326
  noVersionInfo: false,
1327
+ kind: t.kind,
845
1328
  };
846
1329
  }
847
- return { ...b, noVersionInfo: false };
1330
+ return { ...b, noVersionInfo: false, kind: t.kind };
848
1331
  });
849
1332
  }
1333
+ /** First check per slug, order preserved — an update is applied per PACKAGE, not per installed object. */
1334
+ export function dedupeBySlug(checks) {
1335
+ const seen = new Set();
1336
+ return checks.filter((c) => (seen.has(c.slug) ? false : (seen.add(c.slug), true)));
1337
+ }
850
1338
  /**
851
1339
  * Apply one package via the SAME door `marketInstall` uses
852
1340
  * (`POST /packages/apply`) — for an already-installed slug this re-runs the
853
1341
  * pod's idempotent-create path, which reconciles when the pod build supports
854
1342
  * it (see the `applyOnePackage` caveat below for what "created" means here).
855
1343
  */
856
- async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS) {
1344
+ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS, extra = {}) {
857
1345
  if (cat.lockedSlugs.has(entry.slug)) {
858
1346
  const tier = cat.tierBySlug.get(entry.slug)?.requiredTier;
859
1347
  if (!opts.json)
@@ -861,27 +1349,19 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
861
1349
  return { slug: entry.slug, outcome: "locked", status: "locked", warnings: [] };
862
1350
  }
863
1351
  const cpToken = getStoredToken()?.token;
864
- let pkg;
865
- if (cat.bundledSlugs.has(entry.slug)) {
866
- pkg = toPackageDefinition(entry.slug);
867
- }
868
- else {
869
- const def = await fetchPackageDefinition(entry.slug, cpToken);
870
- if (!def) {
871
- if (!opts.json) {
872
- log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
873
- if (!cat.loggedIn)
874
- log.hint("It may be private — try: synap login");
875
- }
876
- return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
1352
+ // Same honest payload as `marketInstall` (see `resolveInstallPayload`).
1353
+ const payload = extra.payload !== undefined ? extra.payload : await resolveInstallPayload(entry.slug, cat, cpToken);
1354
+ if (!payload) {
1355
+ if (!opts.json) {
1356
+ log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
1357
+ if (!cat.loggedIn)
1358
+ log.hint("It may be private — try: synap login");
877
1359
  }
878
- pkg = def;
1360
+ return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
879
1361
  }
880
- // Same version stamp as `marketInstall` — needed for private templates too,
881
- // since `applyOnePackage` re-runs the apply door on every update.
882
- const remoteVersion = cat.remoteVersionBySlug.get(entry.slug);
883
- if (remoteVersion)
884
- pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
1362
+ const pkg = extra.acceptDataChanges && extra.acceptDataChanges.length > 0
1363
+ ? { ...payload.pkg, acceptDataChanges: extra.acceptDataChanges }
1364
+ : payload.pkg;
885
1365
  const s = opts.json ? null : ora(`Updating ${entry.name}…`).start();
886
1366
  try {
887
1367
  const res = (await hubPost("/packages/apply", pkg, cfg, applyTimeoutMs));
@@ -891,7 +1371,7 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
891
1371
  if (!opts.json) {
892
1372
  log.info(`${entry.name} — update proposed (under review, not live yet).`);
893
1373
  if (proposalId)
894
- log.hint(`Approve: synap proposals approve ${proposalId}`);
1374
+ log.hint(`Review and approve: synap open proposal ${proposalId}`);
895
1375
  }
896
1376
  return { slug: entry.slug, outcome: "proposed", status: "proposed", warnings: [] };
897
1377
  }
@@ -903,13 +1383,16 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
903
1383
  // which by construction means it already carried a recorded version. The
904
1384
  // stamp-contradiction check is therefore a no-op here by design; it's
905
1385
  // `marketInstall`'s job to catch it on a first-time/unstamped install.
906
- const verdict = classifyApplyResult(entry, ws, dependencies, seedSummary, {
907
- remoteVersionSent: remoteVersion,
1386
+ const verdict = classifyApplyResponse(entry, res, {
1387
+ remoteVersionSent: payload.catalogVersion,
908
1388
  wasUnstamped: false,
909
1389
  });
910
1390
  if (!opts.json) {
911
1391
  printApplyVerdict(entry, verdict);
912
1392
  printSeedOutcomes(dependencies, seedSummary);
1393
+ printLayerOutcomes(ws?.layers);
1394
+ for (const line of deferredPlacementLines(res))
1395
+ log.info(line);
913
1396
  }
914
1397
  return {
915
1398
  slug: entry.slug,
@@ -922,10 +1405,33 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
922
1405
  }
923
1406
  catch (e) {
924
1407
  s?.stop();
1408
+ // The pod refused: data changes are owed that the person has not agreed to.
1409
+ // Nothing was written. Show the list and the exact command that agrees.
1410
+ const owed = dataChangesFromRefusal(e);
1411
+ if (owed) {
1412
+ if (!opts.json) {
1413
+ const said = consentRefusalLines(entry.name, entry.slug, owed, consentNeedsPerson(e));
1414
+ log.warn(said.warn);
1415
+ printDataChanges(entry.slug, owed);
1416
+ for (const hint of said.hints)
1417
+ log.hint(hint);
1418
+ }
1419
+ return {
1420
+ slug: entry.slug,
1421
+ outcome: "needs-consent",
1422
+ status: "needs-consent",
1423
+ warnings: [],
1424
+ pendingDataChanges: owed,
1425
+ };
1426
+ }
925
1427
  const timedOut = e instanceof HubNetworkError && e.code === "TIMEOUT";
1428
+ const refusal = dataChangeRefusalMessage(e);
926
1429
  if (!opts.json) {
927
1430
  log.error(`${entry.name} update failed`);
928
- renderHubError(e);
1431
+ if (refusal)
1432
+ log.warn(refusal);
1433
+ else
1434
+ renderHubError(e);
929
1435
  }
930
1436
  return {
931
1437
  slug: entry.slug,
@@ -936,19 +1442,120 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
936
1442
  };
937
1443
  }
938
1444
  }
1445
+ /**
1446
+ * Ask the pod's write-free preview door (`POST /packages/preflight`, the same
1447
+ * body `/packages/apply` takes) which data changes an update would run.
1448
+ */
1449
+ async function previewDataChanges(entry, cat, cfg) {
1450
+ const payload = await resolveInstallPayload(entry.slug, cat, getStoredToken()?.token).catch(() => null);
1451
+ if (!payload)
1452
+ return { payload: null, error: "its definition couldn't be fetched" };
1453
+ try {
1454
+ const res = await hubPost("/packages/preflight", payload.pkg, cfg);
1455
+ return { payload, changes: parseDataChanges(res) };
1456
+ }
1457
+ catch (e) {
1458
+ const status = e instanceof HubError ? `HTTP ${e.status}` : e instanceof Error ? e.message : String(e);
1459
+ return { payload, error: `the pod's preview failed: ${status}` };
1460
+ }
1461
+ }
1462
+ /** One slug's outcome out of the shared pod-wide config reconcile. */
1463
+ async function reconcileOutcome(c, report, opts) {
1464
+ let r;
1465
+ try {
1466
+ r = await report;
1467
+ }
1468
+ catch (e) {
1469
+ if (!opts.json) {
1470
+ log.error(`${c.slug} update failed`);
1471
+ renderHubError(e);
1472
+ }
1473
+ return { slug: c.slug, outcome: "failed", status: "failed", warnings: [], error: e instanceof Error ? e.message : String(e) };
1474
+ }
1475
+ // A report this CLI cannot read is a FAILED read — never "up to date".
1476
+ const lists = [r?.updated, r?.ownerOwnedSkipped, r?.skipped, r?.conflicts];
1477
+ if (!lists.some(Array.isArray) || lists.some((l) => l !== undefined && !Array.isArray(l))) {
1478
+ const error = "the pod's reconcile answered with a report this CLI can't read — not assuming it updated";
1479
+ if (!opts.json)
1480
+ log.error(`${c.slug} — ${error}`);
1481
+ return { slug: c.slug, outcome: "failed", status: "failed", warnings: [], error };
1482
+ }
1483
+ const mine = (rows) => (rows ?? []).filter((e) => e.packageSlug === c.slug);
1484
+ const conflicts = mine(r.conflicts);
1485
+ const owned = mine(r.ownerOwnedSkipped);
1486
+ const updated = mine(r.updated);
1487
+ const warnings = owned.map((e) => `${e.name}: kept your edits — ${e.reason}`);
1488
+ if (conflicts.length > 0) {
1489
+ const error = conflicts.map((e) => `${e.name}: ${e.reason}`).join("; ");
1490
+ if (!opts.json)
1491
+ log.error(`${c.slug} update failed — ${error}`);
1492
+ return { slug: c.slug, outcome: "failed", status: updated.length > 0 ? "updated-with-failures" : "failed", warnings, error };
1493
+ }
1494
+ if (!opts.json)
1495
+ for (const w of warnings)
1496
+ log.warn(`${c.slug} — ${w}`);
1497
+ if (updated.length > 0 || owned.length > 0) {
1498
+ // Every field it could advance did; a field you edited stays yours (warned above).
1499
+ const status = updated.length > 0 ? "updated" : "unchanged";
1500
+ if (!opts.json)
1501
+ log.success(`${c.slug} — ${status}`);
1502
+ return { slug: c.slug, outcome: status, status, warnings };
1503
+ }
1504
+ // Skipped (cache miss, unresolved definition…) or not reported at all: NOT updated — never "current".
1505
+ const error = mine(r.skipped).map((e) => `${e.name}: ${e.reason}`).join("; ") || "the pod's reconcile did not report this package";
1506
+ if (!opts.json)
1507
+ log.warn(`${c.slug} was not updated — ${error}`);
1508
+ return { slug: c.slug, outcome: "not-updated", status: "not-updated", warnings, error };
1509
+ }
939
1510
  export async function marketUpdate(slugsArg, opts) {
940
1511
  const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
941
- const [installedTemplates, cat] = await Promise.all([fetchInstalledTemplates(), buildMarketCatalog()]);
1512
+ // STRICT fetch: distinguish "pod unreachable" (transient — don't claim the
1513
+ // pod is empty, that reads as data loss) from "genuinely nothing installed".
1514
+ const cat = await buildMarketCatalog();
1515
+ let installedTemplates;
1516
+ // Whether the pod answered from the ONE read door (`GET /installed`, all six
1517
+ // kinds) or is old enough that only workspace packages were visible. An old
1518
+ // pod is not an empty pod — say which, rather than letting a narrower list
1519
+ // read as the whole truth.
1520
+ let degraded = false;
1521
+ try {
1522
+ const inv = await fetchInstalledInventoryStrict();
1523
+ installedTemplates = installedRowsToTemplates(inv.rows);
1524
+ degraded = inv.degraded;
1525
+ }
1526
+ catch (e) {
1527
+ // Unreachable ≠ success — a script checking `$?` (or `&&`-chaining) must see
1528
+ // failure, not exit 0, even though the JSON payload also carries the reason.
1529
+ process.exitCode = 1;
1530
+ if (opts.json) {
1531
+ console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn, error: "pod-unreachable" }, null, 2));
1532
+ return;
1533
+ }
1534
+ log.error("Couldn't reach your pod to read what's installed — not saying it's empty.");
1535
+ renderHubError(e);
1536
+ log.hint("Check the pod is up / you're on the right --pod, then retry.");
1537
+ return;
1538
+ }
1539
+ const degradedNote = degraded
1540
+ ? "This pod does not serve GET /api/hub/installed — only workspace packages were checked. Capability / view / skill / automation / card installs were NOT (upgrade the pod to include them)."
1541
+ : undefined;
942
1542
  if (installedTemplates.length === 0) {
943
1543
  if (opts.json) {
944
- console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn }, null, 2));
1544
+ console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn, degraded, ...(degradedNote ? { degradedNote } : {}) }, null, 2));
945
1545
  return;
946
1546
  }
947
1547
  log.warn("No installed packages found on this pod.");
1548
+ if (degradedNote)
1549
+ log.dim(degradedNote);
948
1550
  log.hint("Install one: synap market install <slug>");
949
1551
  return;
950
1552
  }
951
- let checks = computeUpdates(installedTemplates, cat);
1553
+ // ONE ROW PER SLUG. `GET /installed` reports one row per installed OBJECT —
1554
+ // a views pack that installed four views is four rows — and an update is
1555
+ // applied per PACKAGE, not per object. Without this the preview listed
1556
+ // `task-views-pack` four times and the "N up to date" tally counted it four
1557
+ // times. `market installed` has always deduped the same way.
1558
+ let checks = dedupeBySlug(computeUpdates(installedTemplates, cat));
952
1559
  const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
953
1560
  const explicitSlugs = !!slugsArg && slugsArg.length > 0;
954
1561
  let unknownSlugs = [];
@@ -970,7 +1577,26 @@ export async function marketUpdate(slugsArg, opts) {
970
1577
  }
971
1578
  checks = checks.filter((c) => validSlugs.includes(c.slug));
972
1579
  }
973
- const withUpdates = checks.filter((c) => c.updateAvailable);
1580
+ // ── Template lifecycle (retired / unlisted / unknown) ──
1581
+ // Read from the pod's template health (Hub `/workspaces`). A FAILED read is
1582
+ // said out loud and never folded into "every template is current".
1583
+ const lifecycleBySlug = new Map();
1584
+ let lifecycleReadFailed = false;
1585
+ try {
1586
+ for (const a of await fetchWorkspaceAttachmentsStrict(await resolveHubConfig(opts))) {
1587
+ if (a.packageSlug && a.lifecycle && !lifecycleBySlug.has(a.packageSlug))
1588
+ lifecycleBySlug.set(a.packageSlug, a.lifecycle);
1589
+ }
1590
+ }
1591
+ catch {
1592
+ lifecycleReadFailed = true;
1593
+ }
1594
+ const leaving = checks
1595
+ .map((c) => ({ slug: c.slug, info: lifecycleBySlug.get(c.slug) }))
1596
+ .filter((r) => !!r.info && r.info.lifecycle !== "current");
1597
+ const retiredSlugs = new Set(leaving.filter((r) => r.info.lifecycle === "retired").map((r) => r.slug));
1598
+ // A retired template is never re-applied: its data moves to the replacement instead.
1599
+ const withUpdates = checks.filter((c) => c.updateAvailable && !retiredSlugs.has(c.slug));
974
1600
  const noVersionInfo = checks.filter((c) => c.noVersionInfo);
975
1601
  // Private templates resolve only via the authed CP path — an update check
976
1602
  // that found NOTHING while logged out is ambiguous (could be genuinely
@@ -980,24 +1606,86 @@ export async function marketUpdate(slugsArg, opts) {
980
1606
  log.dim("Not logged in — private template updates aren't visible. Run: synap login");
981
1607
  }
982
1608
  // --dry-run is a NAMED alias for the "no --yes" preview path — forces the
983
- // preview even when --yes or explicit slugs would otherwise trigger apply.
984
- const shouldApply = !opts.dryRun && (explicitSlugs || opts.yes);
1609
+ // preview even when --yes / `all` or explicit slugs would otherwise trigger apply.
1610
+ const shouldApply = !opts.dryRun && (explicitSlugs || opts.yes || opts.all);
1611
+ // ── Data changes each update would run (contract C) ──
1612
+ // Asked of the pod's write-free preview door BEFORE anything is shown, so the
1613
+ // person reads what will happen to their data before the template diff.
1614
+ let cfg;
1615
+ const previews = new Map();
1616
+ if (withUpdates.length > 0) {
1617
+ try {
1618
+ cfg = await resolveHubConfig(opts);
1619
+ }
1620
+ catch (e) {
1621
+ renderHubError(e);
1622
+ process.exit(1);
1623
+ }
1624
+ for (const c of withUpdates) {
1625
+ // The data-change preview is the workspace door's (`/packages/preflight`).
1626
+ if (updateDoorFor(c.kind) !== "packages-apply")
1627
+ continue;
1628
+ const entry = entryBySlug.get(c.slug);
1629
+ if (entry)
1630
+ previews.set(c.slug, await previewDataChanges(entry, cat, cfg));
1631
+ }
1632
+ }
985
1633
  if (!opts.json) {
1634
+ const withChanges = withUpdates.filter((c) => (previews.get(c.slug)?.changes?.length ?? 0) > 0);
1635
+ if (withChanges.length > 0) {
1636
+ log.heading(" Data changes");
1637
+ log.blank();
1638
+ for (const c of withChanges)
1639
+ printDataChanges(c.slug, previews.get(c.slug).changes);
1640
+ log.blank();
1641
+ }
1642
+ const unread = withUpdates.filter((c) => previews.get(c.slug)?.error);
1643
+ for (const c of unread) {
1644
+ log.warn(`Couldn't check ${c.slug} for data changes (${previews.get(c.slug).error}). The pod still refuses an update whose data changes you have not agreed to.`);
1645
+ }
1646
+ if (leaving.length > 0) {
1647
+ log.heading(" Catalog status");
1648
+ log.blank();
1649
+ for (const r of leaving) {
1650
+ const n = lifecycleNotice(r.slug, r.info);
1651
+ const tone = r.info.lifecycle === "unknown" ? chalk.dim : chalk.yellow;
1652
+ console.log(" " + tone(`⚠ ${n.label}`));
1653
+ // The pod's warning already carries the reason; print it only on the fallback wording.
1654
+ if (r.info.retiredReason && !r.info.warning)
1655
+ console.log(" " + chalk.dim(r.info.retiredReason));
1656
+ if (n.action)
1657
+ console.log(" " + chalk.dim(`Move its data: ${n.action}`));
1658
+ }
1659
+ log.blank();
1660
+ }
1661
+ if (lifecycleReadFailed) {
1662
+ log.dim("Couldn't read which templates are retired from the pod — retired templates are not flagged above.");
1663
+ }
986
1664
  log.heading(" Installed packages");
987
1665
  log.blank();
988
1666
  for (const c of checks) {
989
- const label = c.noVersionInfo
990
- ? noVersionLabel(entryBySlug.get(c.slug)?.isPrivate ?? false, cat.loggedIn)
991
- : c.updateAvailable
992
- ? chalk.yellow(`update available `) + chalk.dim(c.installedVersion) + " → " + c.latestVersion
993
- : chalk.green("up to date");
1667
+ // ONE label door. This was a hand-rolled second copy that diverged from
1668
+ // `installedStatusLabel` in two ways that both LIED: it had no cache-cold
1669
+ // branch (so a pod with no catalog version to compare against printed a
1670
+ // green "up to date" nothing had verified) and no hash-version branch (so
1671
+ // a content change read as "update available").
1672
+ // A retired template is not "behind": its next step is the move, said above.
1673
+ const label = retiredSlugs.has(c.slug)
1674
+ ? chalk.yellow("retired") + chalk.dim(` · synap templates retire ${c.slug}`)
1675
+ : installedStatusLabel(c, entryBySlug.get(c.slug)?.isPrivate ?? false, cat.loggedIn);
994
1676
  console.log(" " + chalk.cyan(c.slug.padEnd(28)) + label);
995
1677
  }
996
1678
  log.blank();
997
1679
  if (noVersionInfo.length > 0) {
998
1680
  log.dim(`${noVersionInfo.length} installed without a version stamp — can't check ${noVersionInfo.length === 1 ? "it" : "them"} for updates.`);
999
1681
  }
1682
+ if (degradedNote)
1683
+ log.dim(degradedNote);
1000
1684
  }
1685
+ // What the JSON carries for the new facts: per slug, the pod's data changes
1686
+ // (`null` = this pod sent none: an older pod) and the template lifecycle.
1687
+ const dataChangesJson = Object.fromEntries([...previews].map(([slug, pv]) => [slug, pv.error ? { error: pv.error } : pv.changes ?? null]));
1688
+ const lifecycleJson = Object.fromEntries(leaving.map((r) => [r.slug, r.info]));
1001
1689
  if (withUpdates.length === 0) {
1002
1690
  // "Nothing to update" is only honest when every installed row was
1003
1691
  // actually CHECKED. Rows the pod can't version-check are "couldn't be
@@ -1011,6 +1699,7 @@ export async function marketUpdate(slugsArg, opts) {
1011
1699
  applied: [],
1012
1700
  status: noVersionInfo.length > 0 ? "partial" : "clean",
1013
1701
  uncheckable: noVersionInfo.map((c) => c.slug),
1702
+ templateLifecycle: lifecycleJson,
1014
1703
  }, null, 2));
1015
1704
  }
1016
1705
  else if (noVersionInfo.length > 0) {
@@ -1019,44 +1708,118 @@ export async function marketUpdate(slugsArg, opts) {
1019
1708
  chalk.yellow(`⚠ ${upToDate} up to date · ${noVersionInfo.length} couldn't be checked`));
1020
1709
  log.hint("Run 'synap market installed' for why — unstamped install (reinstall to fix) or a private template while logged out (synap login).");
1021
1710
  }
1711
+ else if (leaving.length > 0) {
1712
+ log.info("No updates to apply.");
1713
+ }
1022
1714
  else {
1023
1715
  log.success("Nothing to update.");
1716
+ // `market update` only checks TEMPLATE-ATTACHED workspaces — a hand-built
1717
+ // one (no packageSlug) can't be "behind" a template, so it's silently
1718
+ // absent here. Point at the fuller home so "nothing to update" never reads
1719
+ // as "nothing to do" when an unattached workspace is waiting to be linked.
1720
+ log.hint("See every workspace's template status (incl. unattached): synap templates");
1024
1721
  }
1025
1722
  return;
1026
1723
  }
1027
1724
  if (!shouldApply) {
1028
1725
  const steps = FLOW.afterMarketUpdateCheck(withUpdates.map((c) => c.slug));
1029
1726
  if (opts.json) {
1030
- console.log(JSON.stringify({ checks, loggedIn: cat.loggedIn, unknownSlugs, applied: [], nextSteps: steps }, null, 2));
1727
+ console.log(JSON.stringify({
1728
+ checks,
1729
+ loggedIn: cat.loggedIn,
1730
+ unknownSlugs,
1731
+ applied: [],
1732
+ nextSteps: steps,
1733
+ dataChanges: dataChangesJson,
1734
+ templateLifecycle: lifecycleJson,
1735
+ }, null, 2));
1031
1736
  return;
1032
1737
  }
1033
1738
  log.info(`${withUpdates.length} package${withUpdates.length === 1 ? "" : "s"} have updates.`);
1739
+ if ([...previews.values()].some((pv) => (pv.changes?.length ?? 0) > 0)) {
1740
+ log.hint("Updates with data changes ask before they run. To agree up front: --accept-data-changes <opKey,…> (or --yes for all listed)");
1741
+ }
1034
1742
  renderNextSteps(steps);
1035
1743
  return;
1036
1744
  }
1037
- let cfg;
1038
- try {
1039
- cfg = await resolveHubConfig(opts);
1040
- }
1041
- catch (e) {
1042
- renderHubError(e);
1043
- process.exit(1);
1044
- }
1745
+ const acceptList = parseAcceptList(opts.acceptDataChanges);
1045
1746
  const results = [];
1747
+ // One pod-wide reconcile serves every view/skill/automation update — run it
1748
+ // at most once, lazily, and read each slug's outcome from its report.
1749
+ let configsReport;
1046
1750
  for (const c of withUpdates) {
1047
- const entry = cat.entries.find((e) => e.slug === c.slug);
1751
+ const door = updateDoorFor(c.kind);
1752
+ if (door === null) {
1753
+ if (!opts.json)
1754
+ log.warn(`${c.slug} is a ${c.kind} — the pod has no per-package update for this kind, so it was not updated. Nothing was written.`);
1755
+ results.push({ slug: c.slug, outcome: "no-updater", status: "no-updater", warnings: [] });
1756
+ continue;
1757
+ }
1758
+ if (door === "configs-reconcile") {
1759
+ configsReport ??= hubPost("/configs/reconcile", {}, cfg, applyTimeoutMs);
1760
+ results.push(await reconcileOutcome(c, configsReport, { json: opts.json }));
1761
+ continue;
1762
+ }
1763
+ const entry = entryBySlug.get(c.slug);
1048
1764
  if (!entry) {
1765
+ // Absent from the catalog THIS CLI read — not proof it left the catalog
1766
+ // (that is the pod's `unlisted`, shown above, and only after a complete sync).
1049
1767
  if (!opts.json)
1050
- log.warn(`"${c.slug}" is no longer in the catalog — skipping.`);
1768
+ log.warn(`Couldn't find "${c.slug}" in the catalog read just now — skipping.`);
1051
1769
  results.push({ slug: c.slug, outcome: "not-in-catalog", status: "not-in-catalog", warnings: [] });
1052
1770
  continue;
1053
1771
  }
1054
- results.push(await applyOnePackage(entry, cat, cfg, { json: opts.json }, applyTimeoutMs));
1772
+ const pv = previews.get(c.slug);
1773
+ const pending = pv?.changes ?? [];
1774
+ let accepted = [];
1775
+ if (pending.length > 0) {
1776
+ const decision = decideConsent(pending, { yes: opts.yes, acceptList });
1777
+ accepted = decision.accepted;
1778
+ let missing = decision.missing;
1779
+ if (decision.refusedOperatorSteps.length > 0 && !opts.json) {
1780
+ log.warn(`Not agreed here: ${decision.refusedOperatorSteps.join(", ")} retire${decision.refusedOperatorSteps.length === 1 ? "s" : ""} old data, so an operator runs ${decision.refusedOperatorSteps.length === 1 ? "it" : "them"} (see Operator steps above).`);
1781
+ }
1782
+ if (missing.length > 0 && !opts.json && process.stdin.isTTY) {
1783
+ const answer = await prompts({
1784
+ type: "confirm",
1785
+ name: "ok",
1786
+ message: `Run ${missing.length === 1 ? "this data change" : `these ${missing.length} data changes`} with the ${c.slug} update? (${missing.map((d) => d.opKey).join(", ")})`,
1787
+ initial: false,
1788
+ });
1789
+ if (answer.ok === true) {
1790
+ accepted = [...accepted, ...missing.map((d) => d.opKey)];
1791
+ missing = [];
1792
+ }
1793
+ }
1794
+ if (missing.length > 0) {
1795
+ if (!opts.json) {
1796
+ log.warn(`${entry.name} not updated: you have not agreed to its data changes. Nothing was written.`);
1797
+ log.hint(`To agree and update: ${consentRerunCommand(c.slug, missing)}`);
1798
+ }
1799
+ results.push({ slug: c.slug, outcome: "needs-consent", status: "needs-consent", warnings: [], pendingDataChanges: missing });
1800
+ continue;
1801
+ }
1802
+ }
1803
+ results.push(await applyOnePackage(entry, cat, cfg, { json: opts.json }, applyTimeoutMs, {
1804
+ payload: pv?.payload,
1805
+ acceptDataChanges: accepted,
1806
+ }));
1055
1807
  }
1808
+ if (results.some((r) => r.status === "needs-consent"))
1809
+ process.exitCode = 1;
1056
1810
  const summary = summarizeApplyResults(results);
1057
1811
  const steps = FLOW.afterMarketUpdateApply();
1058
1812
  if (opts.json) {
1059
- console.log(JSON.stringify({ checks, loggedIn: cat.loggedIn, unknownSlugs, applied: results, summary, nextSteps: steps }, null, 2));
1813
+ console.log(JSON.stringify({
1814
+ checks,
1815
+ loggedIn: cat.loggedIn,
1816
+ unknownSlugs,
1817
+ applied: results,
1818
+ summary,
1819
+ nextSteps: steps,
1820
+ dataChanges: dataChangesJson,
1821
+ templateLifecycle: lifecycleJson,
1822
+ }, null, 2));
1060
1823
  return;
1061
1824
  }
1062
1825
  log.blank();
@@ -1075,12 +1838,14 @@ export async function marketUpdate(slugsArg, opts) {
1075
1838
  * else (locked/proposed/fetch-failed/not-in-catalog/no-workspace/unknown)
1076
1839
  * rolls into "failed".
1077
1840
  */
1078
- function summarizeApplyResults(results) {
1841
+ export function summarizeApplyResults(results) {
1079
1842
  let updated = 0;
1080
1843
  let unchanged = 0;
1081
1844
  let withFailures = 0;
1082
1845
  let timedOut = 0;
1083
1846
  let failed = 0;
1847
+ let proposed = 0;
1848
+ let needsConsent = 0;
1084
1849
  for (const r of results) {
1085
1850
  switch (r.status) {
1086
1851
  case "installed":
@@ -1092,35 +1857,60 @@ function summarizeApplyResults(results) {
1092
1857
  case "unchanged":
1093
1858
  unchanged++;
1094
1859
  break;
1860
+ // A governed write queued for review — SUCCESS, not a failure. Counting
1861
+ // this as `failed` (the old `default` fall-through) was dishonest: the
1862
+ // whole point of governance is "proposed ≠ error". It just isn't LIVE yet.
1863
+ case "proposed":
1864
+ // Layer 2 filed proposals and nothing failed: same bucket, same reason.
1865
+ case "awaiting-approval":
1866
+ proposed++;
1867
+ break;
1095
1868
  case "installed-with-failures":
1096
1869
  case "updated-with-failures":
1097
1870
  case "composed-with-failures":
1871
+ case "not-finished":
1098
1872
  case "stamp-contradiction":
1099
1873
  withFailures++;
1100
1874
  break;
1101
1875
  case "timed-out":
1102
1876
  timedOut++;
1103
1877
  break;
1878
+ // Held back on purpose: data changes the person has not agreed to.
1879
+ // Not a failure of the pod, not a success either.
1880
+ case "needs-consent":
1881
+ needsConsent++;
1882
+ break;
1104
1883
  default:
1105
1884
  failed++;
1106
1885
  }
1107
1886
  }
1108
1887
  const parts = [`${updated} updated`, `${unchanged} already current`];
1888
+ if (proposed > 0)
1889
+ parts.push(`${proposed} proposed (awaiting review)`);
1890
+ // The vocabulary's `unfinished` ("Not finished"): one state, one name on
1891
+ // every surface — never "completed with failures" (review2 UX §1 #2).
1109
1892
  if (withFailures > 0)
1110
- parts.push(`${withFailures} completed with failures`);
1893
+ parts.push(`${withFailures} ${templateStepPhrase("unfinished")}`);
1111
1894
  if (failed > 0)
1112
1895
  parts.push(`${failed} failed`);
1113
1896
  if (timedOut > 0)
1114
1897
  parts.push(`${timedOut} timed out`);
1115
- const clean = withFailures === 0 && failed === 0 && timedOut === 0;
1116
- const color = clean ? chalk.green : chalk.yellow;
1898
+ if (needsConsent > 0)
1899
+ parts.push(`${needsConsent} waiting for your agreement to data changes`);
1900
+ const clean = withFailures === 0 && failed === 0 && timedOut === 0 && needsConsent === 0;
1901
+ // Proposed isn't a failure (never ⚠) but isn't live yet (not a bare ✓) —
1902
+ // a distinct "→ awaiting review" marker keeps the summary honest.
1903
+ const marker = !clean ? "⚠" : proposed > 0 ? "→" : "✓";
1904
+ const color = !clean ? chalk.yellow : proposed > 0 ? chalk.cyan : chalk.green;
1117
1905
  return {
1118
1906
  updated,
1119
1907
  unchanged,
1120
1908
  withFailures,
1121
1909
  failed,
1122
1910
  timedOut,
1123
- line: color(`${clean ? "✓" : "⚠"} ${parts.join(" · ")}`),
1911
+ proposed,
1912
+ needsConsent,
1913
+ line: color(`${marker} ${parts.join(" · ")}`),
1124
1914
  };
1125
1915
  }
1126
1916
  // ── `synap market installed [--json] [--outdated]` — pure-read inventory ────
@@ -1137,21 +1927,33 @@ function truncateVersion(v) {
1137
1927
  function noVersionLabel(isPrivate, loggedIn) {
1138
1928
  return isPrivate && !loggedIn
1139
1929
  ? chalk.dim("connect to the Control Plane to check/update — run: synap login")
1140
- : chalk.dim("version unknown — reinstall to enable update checks");
1930
+ : chalk.dim("version unknown — run `synap market attach <workspace>` to stamp it");
1141
1931
  }
1142
1932
  /**
1143
1933
  * Human status cell for one installed row — distinguishes hash drift (no
1144
1934
  * ordering, just "changed") from semver drift ("update available").
1145
1935
  */
1146
- function installedStatusLabel(c, isPrivate, loggedIn) {
1936
+ export function installedStatusLabel(c, isPrivate, loggedIn) {
1937
+ // No version recorded BUT the pod says it drifted: the last install did not
1938
+ // finish (it waits on an approval, or part of it failed) — the pod holds the
1939
+ // version until it does. Not "version unknown": the fix is to finish it.
1940
+ if (c.noVersionInfo && c.updateAvailable)
1941
+ return chalk.yellow(templateStepPhrase("unfinished")) + chalk.dim(` — run synap templates update ${c.slug}`);
1147
1942
  if (c.noVersionInfo)
1148
1943
  return noVersionLabel(isPrivate, loggedIn);
1149
- if (!c.updateAvailable)
1150
- return chalk.green("up to date");
1944
+ if (!c.updateAvailable) {
1945
+ // Cache-cold honesty: stamped + no drift reported, but the catalog has no
1946
+ // version to compare against (a freshly-deployed pod before its first sync,
1947
+ // or a private/removed slug). Don't assert a green "up to date" we never
1948
+ // actually verified — say we couldn't check (ArgoCD's Unknown ≠ Synced).
1949
+ if (c.latestVersion == null && c.installedVersion)
1950
+ return chalk.dim("can't check — catalog version unknown");
1951
+ return chalk.green(templateStepPhrase("current"));
1952
+ }
1151
1953
  const installed = truncateVersion(c.installedVersion);
1152
1954
  const latest = c.latestVersion ? truncateVersion(c.latestVersion) : "?";
1153
1955
  const hashPair = isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? "");
1154
- const label = hashPair ? "content changed" : "update available";
1956
+ const label = hashPair ? "content changed" : templateStepPhrase("update");
1155
1957
  return chalk.yellow(`${label} `) + chalk.dim(installed) + " → " + latest;
1156
1958
  }
1157
1959
  // ── Composition engine — the ONE traversal door ─────────────────────────────
@@ -1210,9 +2012,21 @@ async function buildEdgesOf(seeds, cat, cpToken) {
1210
2012
  // safe either way since it's the same bundled-only lookup as the default.
1211
2013
  return (slug) => cache.get(slug) ?? bundledEdgesOf(slug);
1212
2014
  }
1213
- /** `CompositionInstalledEntry[]` from the Hub's installed-templates rows — the shape both `--tree` and `--layers` feed the engine. */
2015
+ /**
2016
+ * `CompositionInstalledEntry[]` from the Hub's installed-templates rows — the
2017
+ * shape both `--tree` and `--layers` feed the engine.
2018
+ *
2019
+ * POD-GLOBAL rows (`workspaceId: null` — a capability/cell/skill installed with
2020
+ * no owning workspace) are dropped: `CompositionInstalledEntry` is
2021
+ * workspace-keyed by construction, and the composition views answer "which
2022
+ * workspace does this template land in", which a pod-wide row has no answer to.
2023
+ * They are still listed by the flat `market installed` table, which is where
2024
+ * they belong.
2025
+ */
1214
2026
  function toInstalledEntries(installedTemplates) {
1215
- return installedTemplates.map((t) => ({
2027
+ return installedTemplates
2028
+ .filter((t) => t.workspaceId != null)
2029
+ .map((t) => ({
1216
2030
  slug: t.slug,
1217
2031
  workspaceId: t.workspaceId,
1218
2032
  workspaceName: t.workspaceName,
@@ -1284,7 +2098,7 @@ export async function buildCompositionTrees(rows, cat, installedTemplates, works
1284
2098
  relation: edge.relation,
1285
2099
  depKind: edge.depKind,
1286
2100
  installed: !!installedInfo,
1287
- workspaceId: installedInfo?.workspaceId,
2101
+ workspaceId: installedInfo?.workspaceId ?? undefined,
1288
2102
  workspaceName: installedInfo?.workspaceName,
1289
2103
  installedVersion: check?.installedVersion,
1290
2104
  latestVersion: check?.latestVersion ?? null,
@@ -1319,10 +2133,10 @@ function nodeStatusLabel(node) {
1319
2133
  if (node.noVersionInfo)
1320
2134
  return chalk.dim("can't check");
1321
2135
  if (!node.updateAvailable)
1322
- return chalk.green("up to date");
2136
+ return chalk.green(templateStepPhrase("current"));
1323
2137
  const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
1324
2138
  const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
1325
- return chalk.yellow("update available ") + chalk.dim(installed) + " → " + latest;
2139
+ return chalk.yellow(`${templateStepPhrase("update")} `) + chalk.dim(installed) + " → " + latest;
1326
2140
  }
1327
2141
  function printDependencyNode(node, prefix, isLast) {
1328
2142
  const branch = isLast ? "└─ " : "├─ ";
@@ -1352,8 +2166,8 @@ export function printCompositionTree(pkg) {
1352
2166
  (pkg.noVersionInfo
1353
2167
  ? chalk.dim("can't check — reinstall")
1354
2168
  : pkg.updateAvailable
1355
- ? chalk.yellow("update available")
1356
- : chalk.green("up to date")) +
2169
+ ? chalk.yellow(templateStepPhrase("update"))
2170
+ : chalk.green(templateStepPhrase("current"))) +
1357
2171
  suiteTag);
1358
2172
  pkg.children.forEach((child, i) => printDependencyNode(child, "", i === pkg.children.length - 1));
1359
2173
  log.blank();
@@ -1416,10 +2230,10 @@ function layerNodeStatus(node, isPrivate, loggedIn) {
1416
2230
  if (node.noVersionInfo)
1417
2231
  return noVersionLabel(isPrivate, loggedIn);
1418
2232
  if (!node.updateAvailable)
1419
- return chalk.green("up to date");
2233
+ return chalk.green(templateStepPhrase("current"));
1420
2234
  const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
1421
2235
  const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
1422
- return chalk.yellow("update available ") + chalk.dim(installed) + " → " + latest;
2236
+ return chalk.yellow(`${templateStepPhrase("update")} `) + chalk.dim(installed) + " → " + latest;
1423
2237
  }
1424
2238
  /**
1425
2239
  * Render the layered DAG bottom-up: bedrock (the highest layer index, most
@@ -1479,25 +2293,90 @@ function printLayersGraph(graph, cat) {
1479
2293
  * (`market update`'s own drift check), never writes anything. Pairs with
1480
2294
  * `market update` to apply what this surfaces.
1481
2295
  */
2296
+ /** Fan-out bucket for a POD-GLOBAL row — a row with no owning workspace. */
2297
+ const POD_WIDE = "\u0000pod-wide";
2298
+ /**
2299
+ * "3 workspaces" / "pod-wide" / "2 workspaces · pod-wide" — where a slug landed.
2300
+ *
2301
+ * Counts DISTINCT placements, not rows. Since `GET /installed` reports one row
2302
+ * per installed OBJECT (four `task-views-pack` views across two workspaces is
2303
+ * four rows), tallying rows would have printed "4 workspaces" for two. The
2304
+ * workspace-kind rows this replaced are one-per-workspace either way, so their
2305
+ * output is unchanged.
2306
+ */
2307
+ export function fanOutLabel(placements) {
2308
+ const podWide = placements.has(POD_WIDE);
2309
+ const wsCount = placements.size - (podWide ? 1 : 0);
2310
+ const parts = [];
2311
+ if (wsCount > 0)
2312
+ parts.push(`${wsCount} workspace${wsCount === 1 ? "" : "s"}`);
2313
+ if (podWide)
2314
+ parts.push("pod-wide");
2315
+ return parts.join(" · ");
2316
+ }
2317
+ /** Dedupe the not-package-linked rows by `kind + templateKey` (falling back to the row name). */
2318
+ export function collectUnlinked(rows) {
2319
+ const byKey = new Map();
2320
+ for (const r of rows) {
2321
+ if (r.packageSlug)
2322
+ continue;
2323
+ const key = `${r.kind}::${r.templateKey ?? r.name}`;
2324
+ const existing = byKey.get(key);
2325
+ const placement = r.workspaceId ?? POD_WIDE;
2326
+ if (existing) {
2327
+ existing.placements.add(placement);
2328
+ continue;
2329
+ }
2330
+ byKey.set(key, {
2331
+ kind: r.kind,
2332
+ templateKey: r.templateKey,
2333
+ name: r.templateKey ?? r.name,
2334
+ placements: new Set([placement]),
2335
+ ...(r.note ? { note: r.note } : {}),
2336
+ });
2337
+ }
2338
+ return [...byKey.values()];
2339
+ }
1482
2340
  export async function marketInstalled(opts) {
1483
- const [installedTemplates, cat] = await Promise.all([fetchInstalledTemplates(), buildMarketCatalog()]);
1484
- if (installedTemplates.length === 0) {
2341
+ // THE ONE READ DOOR. `GET /api/hub/installed` unions all four install
2342
+ // ledgers; before this, `market installed` asked `GET /workspaces` and so
2343
+ // reported workspace packages ONLY — a pod-wide capability, cell, skill or
2344
+ // view install was invisible here, to `market update`, and to every drift
2345
+ // marker. On a pod too old to serve it, `fetchInstalledInventory` degrades to
2346
+ // exactly the old workspace-only behaviour and says so (`degraded`).
2347
+ const [inv, cat] = await Promise.all([fetchInstalledInventory(), buildMarketCatalog()]);
2348
+ const installedTemplates = installedRowsToTemplates(inv.rows);
2349
+ const unlinked = collectUnlinked(inv.rows);
2350
+ const degradedNote = inv.degraded
2351
+ ? "This pod does not serve GET /api/hub/installed — showing workspace packages only. Capability / view / skill / automation / card installs are NOT listed (upgrade the pod to see them)."
2352
+ : undefined;
2353
+ if (installedTemplates.length === 0 && unlinked.length === 0) {
1485
2354
  if (opts.json) {
1486
- console.log(JSON.stringify({ rows: [], loggedIn: cat.loggedIn }, null, 2));
2355
+ console.log(JSON.stringify({ rows: [], unlinked: [], loggedIn: cat.loggedIn, degraded: inv.degraded, degradedNote }, null, 2));
1487
2356
  return;
1488
2357
  }
1489
2358
  log.warn("No installed packages found on this pod.");
2359
+ if (degradedNote)
2360
+ log.dim(degradedNote);
1490
2361
  log.hint("Browse what's available: synap market --list");
1491
2362
  return;
1492
2363
  }
1493
2364
  const checks = computeUpdates(installedTemplates, cat);
1494
2365
  // Multiple workspaces can install the same slug (a template composed onto
1495
- // several bases, or the same additive pack in >1 workspace) — one row per
1496
- // DISTINCT slug, with `workspaceCount` carrying the fan-out.
1497
- const workspaceCountBySlug = new Map();
2366
+ // several bases, the same additive pack in >1 workspace, a views pack whose
2367
+ // views span workspaces) — one row per DISTINCT slug, with the fan-out
2368
+ // carried as the set of PLACEMENTS it landed in.
2369
+ const placementsBySlug = new Map();
2370
+ const kindBySlug = new Map();
1498
2371
  for (const t of installedTemplates) {
1499
- workspaceCountBySlug.set(t.slug, (workspaceCountBySlug.get(t.slug) ?? 0) + 1);
2372
+ const set = placementsBySlug.get(t.slug) ?? new Set();
2373
+ set.add(t.workspaceId ?? POD_WIDE);
2374
+ placementsBySlug.set(t.slug, set);
2375
+ if (!kindBySlug.has(t.slug))
2376
+ kindBySlug.set(t.slug, t.kind ?? "workspace");
1500
2377
  }
2378
+ const workspaceCountBySlug = new Map([...placementsBySlug].map(([slug, set]) => [slug, set.size]));
2379
+ const placementLabel = (slug) => fanOutLabel(placementsBySlug.get(slug) ?? new Set());
1501
2380
  const seen = new Set();
1502
2381
  let rows = checks.filter((c) => {
1503
2382
  if (seen.has(c.slug))
@@ -1518,6 +2397,7 @@ export async function marketInstalled(opts) {
1518
2397
  layers: graph.layers.map((bucket) => bucket.map((n) => n.slug)),
1519
2398
  cycles: graph.cycles,
1520
2399
  loggedIn: cat.loggedIn,
2400
+ degraded: inv.degraded,
1521
2401
  nextSteps: steps,
1522
2402
  }, null, 2));
1523
2403
  return;
@@ -1527,6 +2407,8 @@ export async function marketInstalled(opts) {
1527
2407
  return;
1528
2408
  }
1529
2409
  printLayersGraph(graph, cat);
2410
+ if (degradedNote)
2411
+ log.dim(degradedNote);
1530
2412
  if (!cat.loggedIn)
1531
2413
  log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1532
2414
  renderNextSteps(steps);
@@ -1552,6 +2434,7 @@ export async function marketInstalled(opts) {
1552
2434
  };
1553
2435
  }),
1554
2436
  loggedIn: cat.loggedIn,
2437
+ degraded: inv.degraded,
1555
2438
  nextSteps: steps,
1556
2439
  }, null, 2));
1557
2440
  return;
@@ -1574,7 +2457,6 @@ export async function marketInstalled(opts) {
1574
2457
  const entry = entryBySlug.get(c.slug);
1575
2458
  const name = entry?.name ?? c.slug;
1576
2459
  const priv = entry?.isPrivate ? chalk.yellow("private") : "";
1577
- const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
1578
2460
  console.log(" " +
1579
2461
  chalk.bold(name.padEnd(nameW)) +
1580
2462
  chalk.cyan(c.slug.padEnd(slugW)) +
@@ -1582,10 +2464,12 @@ export async function marketInstalled(opts) {
1582
2464
  " " +
1583
2465
  priv +
1584
2466
  (priv ? " " : "") +
1585
- chalk.dim(`${wsCount} workspace${wsCount === 1 ? "" : "s"}`));
2467
+ chalk.dim(placementLabel(c.slug)));
1586
2468
  }
1587
2469
  log.blank();
1588
2470
  }
2471
+ if (degradedNote)
2472
+ log.dim(degradedNote);
1589
2473
  if (!cat.loggedIn)
1590
2474
  log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1591
2475
  renderNextSteps(steps);
@@ -1598,6 +2482,9 @@ export async function marketInstalled(opts) {
1598
2482
  const entry = entryBySlug.get(c.slug);
1599
2483
  return {
1600
2484
  slug: c.slug,
2485
+ // Which install ledger this row came from. Absent kinds mean the
2486
+ // pod is older than `GET /installed` — see `degraded`.
2487
+ kind: kindBySlug.get(c.slug) ?? "workspace",
1601
2488
  name: entry?.name ?? c.slug,
1602
2489
  installedVersion: c.installedVersion,
1603
2490
  latestVersion: c.latestVersion ?? null,
@@ -1606,38 +2493,807 @@ export async function marketInstalled(opts) {
1606
2493
  isPrivate: entry?.isPrivate ?? false,
1607
2494
  isHashVersion: isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? ""),
1608
2495
  workspaceCount: workspaceCountBySlug.get(c.slug) ?? 0,
2496
+ placement: placementLabel(c.slug),
1609
2497
  };
1610
2498
  }),
2499
+ // Installed but not package-source-linked: no slug, so no update
2500
+ // path. `drift` is NOT COMPUTED for these — never reported as clean.
2501
+ unlinked: opts.outdated
2502
+ ? []
2503
+ : unlinked.map((u) => ({
2504
+ kind: u.kind,
2505
+ templateKey: u.templateKey,
2506
+ name: u.name,
2507
+ driftComputed: false,
2508
+ placement: fanOutLabel(u.placements),
2509
+ ...(u.note ? { note: u.note } : {}),
2510
+ })),
1611
2511
  loggedIn: cat.loggedIn,
2512
+ degraded: inv.degraded,
2513
+ ...(degradedNote ? { degradedNote } : {}),
1612
2514
  nextSteps: steps,
1613
2515
  }, null, 2));
1614
2516
  return;
1615
2517
  }
1616
- if (rows.length === 0) {
2518
+ if (rows.length === 0 && (opts.outdated || unlinked.length === 0)) {
1617
2519
  log.success(opts.outdated ? "Nothing outdated." : "Nothing installed.");
2520
+ if (degradedNote)
2521
+ log.dim(degradedNote);
1618
2522
  return;
1619
2523
  }
1620
2524
  log.heading(" Installed packages");
2525
+ // Widths are computed across ALL rows (and the unlinked block below) so the
2526
+ // per-kind groups stay in one column grid rather than each re-aligning.
2527
+ //
2528
+ // CAPPED: some CP catalog entries carry a sentence where a name belongs (the
2529
+ // view packs' `name` is their whole description), which uncapped pushed the
2530
+ // status column ~90 columns right and wrapped every line. The cap truncates
2531
+ // the label; it never truncates the SLUG, which is the identifier a user
2532
+ // retypes into `market update`.
2533
+ const displayName = (raw) => raw.length > NAME_COL_MAX ? raw.slice(0, NAME_COL_MAX - 1) + "…" : raw;
2534
+ const labelLengths = [
2535
+ ...rows.map((c) => displayName(entryBySlug.get(c.slug)?.name ?? c.slug).length),
2536
+ ...unlinked.map((u) => displayName(u.name).length),
2537
+ ];
2538
+ const nameW = Math.max(4, ...labelLengths) + 2;
2539
+ const slugW = Math.max(4, ...rows.map((c) => c.slug.length), ...unlinked.map((u) => u.name.length)) + 2;
2540
+ // Grouped by the kind its ledger reports, in the canonical kind order, with
2541
+ // headings from the pinned `KIND_HEADINGS` vocabulary (Card, never "cell";
2542
+ // Automations, never "Workflow").
2543
+ for (const kind of INSTALLED_KINDS) {
2544
+ const group = rows.filter((c) => (kindBySlug.get(c.slug) ?? "workspace") === kind);
2545
+ if (group.length === 0)
2546
+ continue;
2547
+ log.heading(` ${kindHeading(kind)}`);
2548
+ for (const c of group) {
2549
+ const entry = entryBySlug.get(c.slug);
2550
+ const name = displayName(entry?.name ?? c.slug);
2551
+ const priv = entry?.isPrivate ? chalk.yellow("private") : "";
2552
+ console.log(" " +
2553
+ chalk.bold(name.padEnd(nameW)) +
2554
+ chalk.cyan(c.slug.padEnd(slugW)) +
2555
+ installedStatusLabel(c, entry?.isPrivate ?? false, cat.loggedIn) +
2556
+ " " +
2557
+ priv +
2558
+ (priv ? " " : "") +
2559
+ chalk.dim(placementLabel(c.slug)));
2560
+ }
2561
+ log.blank();
2562
+ }
2563
+ // Installed, but with no package slug to update THROUGH — listed so they are
2564
+ // not invisible, kept out of the table above so nothing offers `market update
2565
+ // <templateKey>`, which the catalog would miss.
2566
+ if (!opts.outdated && unlinked.length > 0) {
2567
+ log.heading(" Installed, not from a package");
2568
+ log.dim(" source-linked to a Control-Plane template, not a catalog slug — `market update` can't act on these");
2569
+ for (const kind of INSTALLED_KINDS) {
2570
+ const group = unlinked.filter((u) => u.kind === kind);
2571
+ if (group.length === 0)
2572
+ continue;
2573
+ log.blank();
2574
+ log.heading(` ${kindHeading(kind)}`);
2575
+ for (const u of group) {
2576
+ console.log(" " +
2577
+ chalk.bold(displayName(u.name).padEnd(nameW)) +
2578
+ // No slug column value: there IS no package slug for these rows,
2579
+ // and printing the template key twice (it is already the label)
2580
+ // invites pasting it into `market update`, which would miss.
2581
+ chalk.dim("—".padEnd(slugW)) +
2582
+ chalk.dim("drift not computed") +
2583
+ " " +
2584
+ chalk.dim(fanOutLabel(u.placements)) +
2585
+ // The pod's own explanation of WHY drift is unknown for this row —
2586
+ // "not computed" without the reason is where a user gives up and
2587
+ // assumes it means clean.
2588
+ (u.note ? chalk.dim(` ${u.note}`) : ""));
2589
+ }
2590
+ }
2591
+ log.blank();
2592
+ }
2593
+ if (degradedNote)
2594
+ log.dim(degradedNote);
2595
+ if (!cat.loggedIn)
2596
+ log.dim("Not logged in — private template updates aren't visible. Run: synap login");
2597
+ renderNextSteps(FLOW.afterMarketInstalledCheck(rows.filter((c) => c.updateAvailable).map((c) => c.slug)));
2598
+ }
2599
+ /** Classify one workspace's attachment, reusing `computeUpdates`' drift verdict for the stamped case. */
2600
+ function classifyAttachment(ws, check) {
2601
+ if (!ws.packageSlug)
2602
+ return { kind: "unattached" };
2603
+ if (!ws.packageVersion && ws.drifted === true)
2604
+ return { kind: "unfinished", slug: ws.packageSlug };
2605
+ if (!ws.packageVersion || check?.noVersionInfo)
2606
+ return { kind: "no-version", slug: ws.packageSlug };
2607
+ return {
2608
+ kind: "attached",
2609
+ slug: ws.packageSlug,
2610
+ version: ws.packageVersion,
2611
+ updateAvailable: check?.updateAvailable ?? false,
2612
+ latestVersion: check?.latestVersion,
2613
+ };
2614
+ }
2615
+ /** Human status cell for one workspace row — the same vocabulary as `market installed`, keyed on attachment. */
2616
+ export function attachmentStatusLabel(s) {
2617
+ switch (s.kind) {
2618
+ case "unattached":
2619
+ return chalk.yellow("⚠ not attached to a template");
2620
+ case "unfinished":
2621
+ return chalk.yellow("⚠ ") + chalk.cyan(s.slug) + chalk.yellow(` · ${templateStepPhrase("unfinished")}`);
2622
+ case "no-version":
2623
+ return (chalk.yellow("⚠ attached to ") +
2624
+ chalk.cyan(s.slug) +
2625
+ chalk.yellow(" · no version stamp — reattach to enable updates"));
2626
+ case "attached": {
2627
+ if (!s.updateAvailable) {
2628
+ // Cache-cold: stamped but the catalog has no version to compare against
2629
+ // → show the stamp without a green "up to date" claim we never verified.
2630
+ if (s.latestVersion == null)
2631
+ return chalk.cyan(s.slug) + chalk.dim(` @ ${truncateVersion(s.version)} · can't check`);
2632
+ return chalk.green("✓ ") + chalk.cyan(s.slug) + chalk.dim(` @ ${truncateVersion(s.version)}`);
2633
+ }
2634
+ const latest = s.latestVersion ? truncateVersion(s.latestVersion) : "?";
2635
+ return (chalk.cyan(s.slug) +
2636
+ chalk.dim(` @ ${truncateVersion(s.version)} → ${latest} `) +
2637
+ chalk.yellow(templateStepPhrase("update")));
2638
+ }
2639
+ }
2640
+ }
2641
+ /**
2642
+ * Pure-read discovery surface — every workspace on the pod with its template-
2643
+ * attachment status. Sources the linkage from the SAME Hub `GET /workspaces`
2644
+ * call `market installed` uses (`fetchWorkspaceAttachments`), and reuses
2645
+ * `computeUpdates` for the stamped-drift verdict. Never writes.
2646
+ */
2647
+ export async function marketWorkspaces(opts) {
2648
+ const [attachments, cat] = await Promise.all([fetchWorkspaceAttachments(), buildMarketCatalog()]);
2649
+ if (attachments.length === 0) {
2650
+ if (opts.json) {
2651
+ console.log(JSON.stringify({ workspaces: [], loggedIn: cat.loggedIn }, null, 2));
2652
+ return;
2653
+ }
2654
+ log.warn("No workspaces found on this pod.");
2655
+ log.hint("Stand one up: synap launch · or install a template: synap market --list");
2656
+ return;
2657
+ }
2658
+ // Reuse the exact drift check `market installed`/`update` use. Only the
2659
+ // STAMPED workspaces need it; `computeUpdates` preserves input order, so the
2660
+ // result is index-aligned with `stamped` for a clean zip-back by workspace.
2661
+ const stamped = attachments.filter((a) => a.packageSlug);
2662
+ const stampedChecks = computeUpdates(stamped.map((a) => ({
2663
+ slug: a.packageSlug,
2664
+ version: a.packageVersion ?? undefined,
2665
+ workspaceId: a.workspaceId,
2666
+ workspaceName: a.workspaceName,
2667
+ latestVersion: a.latestVersion,
2668
+ drifted: a.drifted,
2669
+ })), cat);
2670
+ const checkByWorkspace = new Map();
2671
+ stamped.forEach((a, i) => checkByWorkspace.set(a.workspaceId, stampedChecks[i]));
2672
+ let rows = attachments.map((ws) => ({
2673
+ ws,
2674
+ status: classifyAttachment(ws, checkByWorkspace.get(ws.workspaceId)),
2675
+ }));
2676
+ if (opts.unattached) {
2677
+ rows = rows.filter((r) => r.status.kind !== "attached" && r.status.kind !== "unfinished");
2678
+ }
2679
+ if (opts.json) {
2680
+ console.log(JSON.stringify({
2681
+ workspaces: rows.map((r) => ({
2682
+ id: r.ws.workspaceId,
2683
+ name: r.ws.workspaceName,
2684
+ domain: r.ws.domain,
2685
+ packageSlug: r.ws.packageSlug,
2686
+ packageVersion: r.ws.packageVersion,
2687
+ attachment: r.status.kind,
2688
+ ...(r.status.kind === "attached"
2689
+ ? { updateAvailable: r.status.updateAvailable, latestVersion: r.status.latestVersion ?? null }
2690
+ : {}),
2691
+ })),
2692
+ loggedIn: cat.loggedIn,
2693
+ }, null, 2));
2694
+ return;
2695
+ }
2696
+ if (rows.length === 0) {
2697
+ log.success("Every workspace is attached to a template.");
2698
+ return;
2699
+ }
2700
+ log.heading(opts.unattached ? " Workspaces needing attention" : " Your workspaces");
1621
2701
  log.blank();
1622
- const nameW = Math.max(4, ...rows.map((c) => (entryBySlug.get(c.slug)?.name ?? c.slug).length)) + 2;
1623
- const slugW = Math.max(4, ...rows.map((c) => c.slug.length)) + 2;
1624
- for (const c of rows) {
1625
- const entry = entryBySlug.get(c.slug);
1626
- const name = entry?.name ?? c.slug;
1627
- const priv = entry?.isPrivate ? chalk.yellow("private") : "";
1628
- const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
2702
+ const nameW = Math.max(4, ...rows.map((r) => r.ws.workspaceName.length)) + 2;
2703
+ for (const { ws, status } of rows) {
2704
+ const domain = ws.domain ? chalk.dim(String(ws.domain)) : chalk.dim("—");
1629
2705
  console.log(" " +
1630
- chalk.bold(name.padEnd(nameW)) +
1631
- chalk.cyan(c.slug.padEnd(slugW)) +
1632
- installedStatusLabel(c, entry?.isPrivate ?? false, cat.loggedIn) +
2706
+ chalk.bold(ws.workspaceName.padEnd(nameW)) +
2707
+ chalk.dim(ws.workspaceId) +
1633
2708
  " " +
1634
- priv +
1635
- (priv ? " " : "") +
1636
- chalk.dim(`${wsCount} workspace${wsCount === 1 ? "" : "s"}`));
2709
+ domain +
2710
+ "\n " +
2711
+ attachmentStatusLabel(status));
1637
2712
  }
1638
2713
  log.blank();
2714
+ const needsAttach = rows.filter((r) => r.status.kind !== "attached" && r.status.kind !== "unfinished");
2715
+ if (needsAttach.length > 0) {
2716
+ log.hint(`Attach a template to a workspace: synap market attach <workspaceId> [--slug <slug>]`);
2717
+ log.dim("This is how you relink a workspace that was installed before the marketplace existed.");
2718
+ }
1639
2719
  if (!cat.loggedIn)
1640
2720
  log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1641
- renderNextSteps(FLOW.afterMarketInstalledCheck(rows.filter((c) => c.updateAvailable).map((c) => c.slug)));
2721
+ }
2722
+ // ── `synap market attach <workspaceId>` — ergonomic reattach/relink ──────────
2723
+ //
2724
+ // A friendly wrapper over `market install <slug> --onto <workspaceId>`: it
2725
+ // resolves WHICH template to reconcile onto the workspace (from `--slug`, the
2726
+ // workspace's existing `packageSlug`, or an unambiguous domain match), then
2727
+ // hands off to `marketInstall` so the reconcile + version-stamp path is shared,
2728
+ // never duplicated. `market install --onto` reconciles ADDITIVELY and stamps —
2729
+ // this command just makes it discoverable per-workspace.
2730
+ export async function marketAttach(workspaceId, opts) {
2731
+ let cfg;
2732
+ try {
2733
+ cfg = await resolveHubConfig(opts);
2734
+ }
2735
+ catch (e) {
2736
+ renderHubError(e);
2737
+ process.exit(1);
2738
+ }
2739
+ const attachments = await fetchWorkspaceAttachments(cfg);
2740
+ const ws = attachments.find((a) => a.workspaceId === workspaceId);
2741
+ if (!ws) {
2742
+ log.error(`No workspace "${workspaceId}" found on this pod.`);
2743
+ log.hint("List your workspaces: synap market workspaces");
2744
+ process.exit(1);
2745
+ }
2746
+ // Resolve the slug to reconcile. Priority: explicit --slug > the workspace's
2747
+ // own recorded packageSlug (the reattach case) > the SHARED `suggestTemplateFor`
2748
+ // inference (same algorithm `synap templates attach` uses — domain/name match
2749
+ // with the generic-domain guard) so the two attach doors never disagree on the
2750
+ // same workspace. This non-interactive door only auto-uses a STRONG (exact
2751
+ // domain) match; a weak/none guess is never silently written — it asks for --slug.
2752
+ let slug = opts.slug;
2753
+ let inferredFrom;
2754
+ if (!slug && ws.packageSlug) {
2755
+ slug = ws.packageSlug;
2756
+ inferredFrom = "the workspace's existing attachment";
2757
+ }
2758
+ if (!slug) {
2759
+ const cat = await buildMarketCatalog();
2760
+ const suggestion = suggestTemplateFor(ws, cat.entries);
2761
+ if (suggestion && suggestion.confidence === "strong") {
2762
+ slug = suggestion.slug;
2763
+ inferredFrom = `its domain "${ws.domain}"`;
2764
+ }
2765
+ else {
2766
+ log.error(`Couldn't confidently infer which template to attach to "${ws.workspaceName}".`);
2767
+ if (suggestion)
2768
+ log.hint(`Best guess: ${chalk.cyan(suggestion.slug)} (${suggestion.confidence}) — confirm it with --slug ${suggestion.slug}`);
2769
+ const templates = cat.entries.filter((e) => typeOf(e) === "workspace").slice(0, 12);
2770
+ if (templates.length > 0) {
2771
+ log.hint("Available workspace templates:");
2772
+ for (const e of templates) {
2773
+ console.log(" " + chalk.cyan(e.slug.padEnd(28)) + chalk.dim(e.name));
2774
+ }
2775
+ }
2776
+ log.hint(`Re-run with the one you want: synap market attach ${workspaceId} --slug <slug>`);
2777
+ log.hint(`Or use the name-friendly door: synap templates attach "${ws.workspaceName}"`);
2778
+ process.exit(1);
2779
+ }
2780
+ }
2781
+ if (!opts.json && inferredFrom) {
2782
+ log.info(`Attaching ${chalk.cyan(slug)} — inferred from ${inferredFrom}.`);
2783
+ }
2784
+ // Hand off to the SAME reconcile+stamp door `market install --onto` uses. It
2785
+ // prints the honest "Reconciled <slug> onto <workspace>" verdict, surfaces a
2786
+ // governed proposal's review link when the write is queued, and stamps the
2787
+ // version — none of which we re-implement here.
2788
+ await marketInstall(slug, {
2789
+ onto: workspaceId,
2790
+ project: opts.project,
2791
+ timeout: opts.timeout,
2792
+ json: opts.json,
2793
+ podUrl: opts.podUrl,
2794
+ apiKey: opts.apiKey,
2795
+ });
2796
+ }
2797
+ /** Best template guess for an unattached workspace: exact domain match wins; else the name containing a template slug. Null when nothing fits — we never invent a link. */
2798
+ export function suggestTemplateFor(ws, entries) {
2799
+ const templates = entries.filter((e) => typeOf(e) === "workspace");
2800
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
2801
+ const wsDomain = ws.domain ? norm(String(ws.domain)) : "";
2802
+ const wsName = norm(ws.workspaceName);
2803
+ // Generic/default domains carry NO subject signal — `personal` is the default
2804
+ // `workspaceType` for any hand-built or system workspace (Pod Admin, "New
2805
+ // Workspace"). Matching those to a same-named base template is a false positive
2806
+ // on every unconfigured workspace, so we never suggest from them.
2807
+ // `personal` is the CONFIRMED default `workspaceType` (backend schema default);
2808
+ // `general`/`foundation` are precautionary — not observed as real subtypes, but
2809
+ // if a base template ever used one, matching it would be the same false positive.
2810
+ const GENERIC_DOMAINS = new Set(["personal", "general", "foundation"]);
2811
+ // STRONG: a SPECIFIC domain IS a template slug (or that template's domain).
2812
+ if (wsDomain && !GENERIC_DOMAINS.has(wsDomain)) {
2813
+ for (const e of templates) {
2814
+ if (norm(e.slug) === wsDomain || norm(e.domain ?? "") === wsDomain) {
2815
+ return { slug: e.slug, name: e.name, confidence: "strong" };
2816
+ }
2817
+ }
2818
+ }
2819
+ // WEAK: the workspace name contains a template's slug token (≥3 chars to avoid
2820
+ // noise), skipping the generic base templates a name would falsely brush.
2821
+ for (const e of templates) {
2822
+ const s = norm(e.slug);
2823
+ if (GENERIC_DOMAINS.has(s))
2824
+ continue;
2825
+ if (s.length >= 3 && wsName.includes(s)) {
2826
+ return { slug: e.slug, name: e.name, confidence: "weak" };
2827
+ }
2828
+ }
2829
+ return null;
2830
+ }
2831
+ /** Resolve a user-typed workspace reference — exact id, else exact (case-insensitive) name, else a UNIQUE substring name match. No silent pick when >1 fits. */
2832
+ export function resolveWorkspaceQuery(query, attachments) {
2833
+ const byId = attachments.find((a) => a.workspaceId === query);
2834
+ if (byId)
2835
+ return { kind: "found", ws: byId };
2836
+ const q = query.toLowerCase().trim();
2837
+ const exact = attachments.filter((a) => a.workspaceName.toLowerCase() === q);
2838
+ if (exact.length === 1)
2839
+ return { kind: "found", ws: exact[0] };
2840
+ if (exact.length > 1)
2841
+ return { kind: "ambiguous", matches: exact };
2842
+ const partial = attachments.filter((a) => a.workspaceName.toLowerCase().includes(q));
2843
+ if (partial.length === 1)
2844
+ return { kind: "found", ws: partial[0] };
2845
+ if (partial.length > 1)
2846
+ return { kind: "ambiguous", matches: partial };
2847
+ return { kind: "none" };
2848
+ }
2849
+ /** Sort one workspace into the action-ranked home: drifted / unstamped / (unattached-with-suggestion) → attention; current → ok; unattached-no-match → unlinked. */
2850
+ export function classifyTemplateRow(ws, check, entries) {
2851
+ const status = classifyAttachment(ws, check);
2852
+ let suggestion = null;
2853
+ let group = "ok";
2854
+ let action = null;
2855
+ switch (status.kind) {
2856
+ case "attached":
2857
+ if (status.updateAvailable) {
2858
+ group = "attention";
2859
+ action = `synap templates update ${status.slug}`;
2860
+ }
2861
+ break;
2862
+ case "unfinished":
2863
+ group = "attention";
2864
+ action = `synap templates update ${status.slug}`;
2865
+ break;
2866
+ case "no-version":
2867
+ group = "attention";
2868
+ action = `synap templates attach "${ws.workspaceName}"`;
2869
+ break;
2870
+ case "unattached":
2871
+ suggestion = suggestTemplateFor(ws, entries);
2872
+ if (suggestion) {
2873
+ group = "attention";
2874
+ action = `synap templates attach "${ws.workspaceName}"`;
2875
+ }
2876
+ else {
2877
+ group = "unlinked";
2878
+ }
2879
+ break;
2880
+ }
2881
+ // Lifecycle outranks drift: updating a retired template is the wrong move,
2882
+ // moving its data into the replacement is the right one. `unknown` is only
2883
+ // a note — an incomplete catalog read never claims the template is gone.
2884
+ const notice = ws.packageSlug && ws.lifecycle ? lifecycleNotice(ws.packageSlug, ws.lifecycle) : null;
2885
+ if (notice && ws.lifecycle.lifecycle !== "unknown") {
2886
+ group = "attention";
2887
+ action = notice.action;
2888
+ }
2889
+ // An overlay retired with its replacement absent from the space needs the
2890
+ // person too; it never takes the row's action (an overlay is not a space).
2891
+ if ((ws.overlays ?? []).some(overlayNeedsAttention))
2892
+ group = "attention";
2893
+ const overlayNotices = (ws.overlays ?? []).map((o) => ({
2894
+ slug: o.slug,
2895
+ label: overlayNotice(o)?.label ?? null,
2896
+ lifecycle: o.info.lifecycle,
2897
+ }));
2898
+ // Why the last update did not finish — the POD's sentence, printed as is.
2899
+ // Waiting on the person's approval (live, by the pod): the update cannot
2900
+ // finish before it, so the next step is opening the proposal.
2901
+ const waiting = ws.awaitingApproval ?? [];
2902
+ const dataChanges = ws.pendingDataChanges ?? null;
2903
+ const updateProposalId = ws.updateProposal?.id ?? null;
2904
+ // The space's ONE step, by the shared rule and precedence (unfinished >
2905
+ // needs-consent > agent-asked > awaiting-approval > update): the mark and
2906
+ // the door the desktop and the catalog draw for the same row.
2907
+ const step = classifyTemplateStep(ws);
2908
+ // A held update's sentence ("…waits for you to agree…") is what the data
2909
+ // changes block says, door and all: print it once. Any unfinished state's
2910
+ // sentence wins over it at the pod, so it is kept then.
2911
+ const applyNotice = dataChanges && !stepFromApplyState(ws.state, waiting) ? null : ws.applyWarning ?? null;
2912
+ if (applyNotice || (step && step !== "update"))
2913
+ group = "attention";
2914
+ // The door of the leading step. Finishing and agreeing to data changes are
2915
+ // both `templates update` (its prompt lists the changes with their keys; the
2916
+ // health row carries none). An agent's ask: approving it beats a second
2917
+ // update beside it. Proposals the last apply waits on: the one proposal, or
2918
+ // the review queue for several — never no door. `update` keeps the action
2919
+ // above (lifecycle outranks drift).
2920
+ if (ws.packageSlug && (step === "unfinished" || step === "needs-consent")) {
2921
+ action = `synap templates update ${ws.packageSlug}`;
2922
+ }
2923
+ else if (step === "agent-asked" && updateProposalId) {
2924
+ action = `synap open proposal ${updateProposalId}`;
2925
+ }
2926
+ else if (step === "awaiting-approval" || (!ws.state && waiting.length > 0)) {
2927
+ // (An older pod sends no `state`: its live pending list is the signal.)
2928
+ action = proposalsDoor(waiting);
2929
+ }
2930
+ if (!ws.state && waiting.length > 0)
2931
+ group = "attention";
2932
+ return {
2933
+ ws,
2934
+ status,
2935
+ suggestion,
2936
+ group,
2937
+ action,
2938
+ lifecycleNotice: notice,
2939
+ overlayNotices,
2940
+ applyNotice,
2941
+ step,
2942
+ dataChanges,
2943
+ updateProposalId,
2944
+ };
2945
+ }
2946
+ function templateRowToJson(r) {
2947
+ return {
2948
+ workspaceId: r.ws.workspaceId,
2949
+ name: r.ws.workspaceName,
2950
+ domain: r.ws.domain,
2951
+ status: r.status.kind,
2952
+ slug: r.ws.packageSlug,
2953
+ version: r.ws.packageVersion,
2954
+ latestVersion: r.status.kind === "attached" ? r.status.latestVersion ?? null : null,
2955
+ updateAvailable: r.status.kind === "attached" ? r.status.updateAvailable : false,
2956
+ suggestedSlug: r.suggestion?.slug ?? null,
2957
+ action: r.action,
2958
+ awaitingApproval: r.ws.awaitingApproval ?? [],
2959
+ applyWarning: r.ws.applyWarning ?? null,
2960
+ state: r.ws.state ?? null,
2961
+ step: r.step,
2962
+ pendingDataChanges: r.dataChanges,
2963
+ updateProposal: r.updateProposalId ? { id: r.updateProposalId } : null,
2964
+ stepWarnings: r.ws.stepWarnings ?? [],
2965
+ lifecycle: r.ws.lifecycle?.lifecycle ?? null,
2966
+ replacedBy: r.ws.lifecycle?.replacedBy ?? null,
2967
+ retiredReason: r.ws.lifecycle?.retiredReason ?? null,
2968
+ ...(r.ws.overlays
2969
+ ? {
2970
+ overlays: r.ws.overlays.map((o) => ({
2971
+ slug: o.slug,
2972
+ lifecycle: o.info.lifecycle,
2973
+ replacedBy: o.info.replacedBy,
2974
+ retiredReason: o.info.retiredReason,
2975
+ warning: o.info.warning,
2976
+ replacementHere: o.replacementHere,
2977
+ })),
2978
+ }
2979
+ : {}),
2980
+ };
2981
+ }
2982
+ /** "2 data changes to existing records" — the count the pod sends, or that it could not tell. */
2983
+ function dataChangesCountPhrase(d) {
2984
+ if (d.unreadable && d.count === 0)
2985
+ return "this update changes existing data";
2986
+ return `${d.count} ${d.count === 1 ? "data change" : "data changes"} to existing records`;
2987
+ }
2988
+ export function renderTemplateRow(r) {
2989
+ const raw = r.ws.workspaceName;
2990
+ const name = raw.length > 34 ? raw.slice(0, 33) + "…" : raw;
2991
+ let label = attachmentStatusLabel(r.status);
2992
+ if (r.status.kind === "unattached" && r.suggestion) {
2993
+ label =
2994
+ chalk.yellow("⚠ not attached") +
2995
+ chalk.dim(" · looks like → ") +
2996
+ chalk.cyan(r.suggestion.slug);
2997
+ }
2998
+ // The pod's apply state outranks the stamp-based cell: a stamped space whose
2999
+ // last update failed is not "update available", and one waiting on live
3000
+ // proposals is waiting, not unfinished.
3001
+ if (r.step && r.step !== "update" && r.ws.packageSlug) {
3002
+ const glyph = templateStepGlyph(r.step);
3003
+ const tone = r.step === "awaiting-approval" || r.step === "agent-asked" ? chalk.cyan : chalk.yellow;
3004
+ label = tone(`${glyph} `) + chalk.cyan(r.ws.packageSlug) + tone(` · ${templateStepPhrase(r.step)}`);
3005
+ }
3006
+ const indent = " " + " ".repeat(36);
3007
+ console.log(" " + chalk.bold(name.padEnd(36)) + label);
3008
+ if (r.lifecycleNotice) {
3009
+ const tone = r.ws.lifecycle?.lifecycle === "unknown" ? chalk.dim : chalk.yellow;
3010
+ console.log(" " + " ".repeat(36) + tone(`⚠ ${r.lifecycleNotice.label}`));
3011
+ if (r.ws.lifecycle?.retiredReason && !r.ws.lifecycle.warning)
3012
+ console.log(" " + " ".repeat(36) + chalk.dim(r.ws.lifecycle.retiredReason));
3013
+ }
3014
+ if (r.applyNotice) {
3015
+ // ⏳ only while the space waits on someone's decision (live pending
3016
+ // proposals — an older pod sends no `state`); anything else is the
3017
+ // reader's to act on.
3018
+ const waiting = r.step === "awaiting-approval" || (r.ws.awaitingApproval ?? []).length > 0;
3019
+ console.log(indent + (waiting ? chalk.cyan(`⏳ ${r.applyNotice}`) : chalk.yellow(`⚠ ${r.applyNotice}`)));
3020
+ }
3021
+ if (r.dataChanges) {
3022
+ // Leading, the mark above already says "needs your OK"; behind another
3023
+ // step (a failed apply that also holds data changes) the block names itself.
3024
+ const what = dataChangesCountPhrase(r.dataChanges);
3025
+ console.log(indent +
3026
+ chalk.yellow(r.step === "needs-consent" ? `${what}:` : `⚠ ${TEMPLATE_STEP_LABELS["needs-consent"]}: ${what}`));
3027
+ for (const sentence of r.dataChanges.sentences)
3028
+ console.log(indent + " • " + sentence);
3029
+ if (r.dataChanges.needsPodAdmin)
3030
+ console.log(indent + " " + chalk.dim("Only a pod owner or admin can agree to them."));
3031
+ }
3032
+ for (const w of r.ws.stepWarnings ?? [])
3033
+ console.log(indent + chalk.dim(w));
3034
+ // An agent's ask behind another step keeps its own door on its own line;
3035
+ // leading, the mark says it and the action line below is its door.
3036
+ const agentDoor = r.updateProposalId && r.step !== "agent-asked" ? `synap open proposal ${r.updateProposalId}` : null;
3037
+ if (agentDoor)
3038
+ console.log(indent + chalk.cyan(`→ ${TEMPLATE_STEP_LABELS["agent-asked"]}`) + chalk.dim(` · ${agentDoor}`));
3039
+ if (r.action && r.action !== agentDoor)
3040
+ console.log(indent + chalk.dim(r.action));
3041
+ for (const o of r.overlayNotices) {
3042
+ console.log(" " + " ".repeat(36) + chalk.dim(`↳ overlay ${o.slug}`));
3043
+ if (o.label) {
3044
+ const tone = o.lifecycle === "unknown" ? chalk.dim : chalk.yellow;
3045
+ console.log(" " + " ".repeat(38) + tone(`⚠ ${o.label}`));
3046
+ }
3047
+ }
3048
+ }
3049
+ /**
3050
+ * `synap templates` — grouped, action-ranked view of every workspace's template
3051
+ * link. Default surfaces "Needs attention" first (drifted + unattached-with-
3052
+ * suggestion + unstamped), then up-to-date, then unlinked. `--needs-attention`
3053
+ * shows only the first group; `--json` for scripts. Pure read.
3054
+ */
3055
+ export async function marketTemplates(opts) {
3056
+ let cfg;
3057
+ try {
3058
+ cfg = await resolveHubConfig(opts);
3059
+ }
3060
+ catch (e) {
3061
+ renderHubError(e);
3062
+ process.exit(1);
3063
+ }
3064
+ const cat = await buildMarketCatalog();
3065
+ // STRICT: this is the consolidated home — "pod unreachable" must never render
3066
+ // as "No workspaces found · stand one up", the exact false-empty lie the
3067
+ // strict fetchers exist to prevent.
3068
+ let attachments;
3069
+ try {
3070
+ attachments = await fetchWorkspaceAttachmentsStrict(cfg);
3071
+ }
3072
+ catch (e) {
3073
+ process.exitCode = 1;
3074
+ if (opts.json) {
3075
+ console.log(JSON.stringify({ needsAttention: [], upToDate: [], unlinked: [], loggedIn: cat.loggedIn, error: "pod-unreachable" }, null, 2));
3076
+ return;
3077
+ }
3078
+ log.error("Couldn't reach your pod to read its workspaces — not saying it's empty.");
3079
+ renderHubError(e);
3080
+ log.hint("Check the pod is up / you're on the right --pod, then retry.");
3081
+ return;
3082
+ }
3083
+ if (attachments.length === 0) {
3084
+ if (opts.json) {
3085
+ console.log(JSON.stringify({ needsAttention: [], upToDate: [], unlinked: [], loggedIn: cat.loggedIn }, null, 2));
3086
+ return;
3087
+ }
3088
+ log.warn("No workspaces found on this pod.");
3089
+ log.hint("Stand one up: synap launch · browse templates: synap market --list");
3090
+ return;
3091
+ }
3092
+ // Drift for the stamped rows via the server-first `computeUpdates`.
3093
+ const stamped = attachments.filter((a) => a.packageSlug);
3094
+ const checks = computeUpdates(stamped.map((a) => ({
3095
+ slug: a.packageSlug,
3096
+ version: a.packageVersion ?? undefined,
3097
+ workspaceId: a.workspaceId,
3098
+ workspaceName: a.workspaceName,
3099
+ latestVersion: a.latestVersion,
3100
+ drifted: a.drifted,
3101
+ })), cat);
3102
+ const checkByWs = new Map();
3103
+ stamped.forEach((a, i) => checkByWs.set(a.workspaceId, checks[i]));
3104
+ const rows = attachments.map((ws) => classifyTemplateRow(ws, checkByWs.get(ws.workspaceId), cat.entries));
3105
+ const attention = rows.filter((r) => r.group === "attention");
3106
+ const ok = rows.filter((r) => r.group === "ok");
3107
+ const unlinked = rows.filter((r) => r.group === "unlinked");
3108
+ if (opts.json) {
3109
+ console.log(JSON.stringify({
3110
+ needsAttention: attention.map(templateRowToJson),
3111
+ upToDate: ok.map(templateRowToJson),
3112
+ unlinked: unlinked.map(templateRowToJson),
3113
+ loggedIn: cat.loggedIn,
3114
+ }, null, 2));
3115
+ return;
3116
+ }
3117
+ // Band — the one-line summary, most-actionable count first.
3118
+ log.blank();
3119
+ const bandParts = [];
3120
+ if (attention.length)
3121
+ bandParts.push(chalk.yellow(`${attention.length} need attention`));
3122
+ bandParts.push(chalk.green(`${ok.length} ${templateStepPhrase("current")}`));
3123
+ if (unlinked.length)
3124
+ bandParts.push(chalk.dim(`${unlinked.length} without a template`));
3125
+ console.log(" " + bandParts.join(chalk.dim(" · ")));
3126
+ log.blank();
3127
+ if (attention.length) {
3128
+ log.heading(" Needs attention");
3129
+ for (const r of attention)
3130
+ renderTemplateRow(r);
3131
+ log.blank();
3132
+ }
3133
+ else {
3134
+ log.success(" Everything attached is up to date.");
3135
+ log.blank();
3136
+ }
3137
+ if (!opts.needsAttention) {
3138
+ if (ok.length) {
3139
+ console.log(chalk.dim(` ${TEMPLATE_STEP_LABELS.current}`));
3140
+ for (const r of ok)
3141
+ renderTemplateRow(r);
3142
+ log.blank();
3143
+ }
3144
+ if (unlinked.length) {
3145
+ console.log(chalk.dim(" No template (attach one if it should have it)"));
3146
+ for (const r of unlinked)
3147
+ renderTemplateRow(r);
3148
+ log.blank();
3149
+ }
3150
+ }
3151
+ // Footer — the one-glance "what can I do from here". Only surfaces verbs that
3152
+ // actually apply to the current state, so it's never a menu of dead options.
3153
+ const driftedCount = attention.filter((r) => (r.status.kind === "attached" && r.status.updateAvailable) ||
3154
+ r.status.kind === "unfinished").length;
3155
+ const attachableCount = attention.filter((r) => r.status.kind !== "attached" && r.status.kind !== "unfinished").length;
3156
+ if (driftedCount > 0 || attachableCount > 0) {
3157
+ log.blank();
3158
+ if (driftedCount > 0)
3159
+ log.hint(`Update all ${driftedCount} drifted: synap templates update all`);
3160
+ if (attachableCount > 0)
3161
+ log.hint(`Connect a workspace: synap templates attach "<name>"`);
3162
+ }
3163
+ if (!cat.loggedIn)
3164
+ log.dim("Not logged in — private template updates aren't visible. Run: synap login");
3165
+ }
3166
+ /**
3167
+ * `synap templates attach <workspace>` — the ONE natural connect command. Takes a
3168
+ * workspace NAME (or id), suggests the template, confirms once. `--slug` forces a
3169
+ * specific template; `--yes` accepts a strong suggestion non-interactively.
3170
+ * Hands the actual reconcile+stamp to the same door `market install --onto` uses.
3171
+ */
3172
+ export async function templatesAttach(query, opts) {
3173
+ let cfg;
3174
+ try {
3175
+ cfg = await resolveHubConfig(opts);
3176
+ }
3177
+ catch (e) {
3178
+ renderHubError(e);
3179
+ process.exit(1);
3180
+ }
3181
+ const attachments = await fetchWorkspaceAttachments(cfg);
3182
+ const res = resolveWorkspaceQuery(query, attachments);
3183
+ if (res.kind === "none") {
3184
+ if (opts.json) {
3185
+ console.log(JSON.stringify({ error: "workspace-not-found", query }, null, 2));
3186
+ process.exitCode = 1;
3187
+ return;
3188
+ }
3189
+ log.error(`No workspace matching "${query}" on this pod.`);
3190
+ log.hint("See them all: synap templates");
3191
+ process.exit(1);
3192
+ }
3193
+ let ws;
3194
+ if (res.kind === "ambiguous") {
3195
+ const nonInteractive = opts.json || opts.yes || !process.stdout.isTTY;
3196
+ if (nonInteractive) {
3197
+ if (opts.json) {
3198
+ console.log(JSON.stringify({ error: "ambiguous-workspace", query, matches: res.matches.map((m) => ({ workspaceId: m.workspaceId, name: m.workspaceName })) }, null, 2));
3199
+ process.exitCode = 1;
3200
+ return;
3201
+ }
3202
+ log.error(`"${query}" matches ${res.matches.length} workspaces — be more specific:`);
3203
+ for (const m of res.matches) {
3204
+ console.log(" " + chalk.bold(m.workspaceName) + chalk.dim(` ${m.workspaceId}`));
3205
+ }
3206
+ process.exit(1);
3207
+ }
3208
+ const answer = await prompts({
3209
+ type: "select",
3210
+ name: "workspaceId",
3211
+ message: `"${query}" matches several — which one?`,
3212
+ choices: res.matches.map((m) => ({ title: m.workspaceName, value: m.workspaceId })),
3213
+ });
3214
+ if (!answer.workspaceId) {
3215
+ log.warn("Cancelled.");
3216
+ return;
3217
+ }
3218
+ ws = res.matches.find((m) => m.workspaceId === answer.workspaceId);
3219
+ }
3220
+ else {
3221
+ ws = res.ws;
3222
+ }
3223
+ const cat = await buildMarketCatalog();
3224
+ let slug = opts.slug;
3225
+ let note;
3226
+ if (!slug && ws.packageSlug) {
3227
+ slug = ws.packageSlug;
3228
+ note = "its existing attachment";
3229
+ }
3230
+ if (!slug) {
3231
+ const suggestion = suggestTemplateFor(ws, cat.entries);
3232
+ if (suggestion) {
3233
+ if (opts.yes) {
3234
+ // Script path: --yes accepts the suggestion (any confidence) — the ONLY
3235
+ // way a suggestion attaches without an interactive confirm.
3236
+ slug = suggestion.slug;
3237
+ note = `it looks like a ${suggestion.slug} workspace`;
3238
+ }
3239
+ else if (process.stdout.isTTY && !opts.json) {
3240
+ // Interactive: NEVER silent-attach — always confirm (a strong match just
3241
+ // defaults the prompt to yes). Attaching reconciles a template onto a
3242
+ // real workspace; that write must be consented to, not inferred.
3243
+ const answer = await prompts({
3244
+ type: "confirm",
3245
+ name: "ok",
3246
+ message: `"${ws.workspaceName}" looks like the ${chalk.cyan(suggestion.slug)} template (${suggestion.confidence} match). Attach it?`,
3247
+ initial: suggestion.confidence === "strong",
3248
+ });
3249
+ if (!answer.ok) {
3250
+ log.warn(`Cancelled. Pick another: synap templates attach "${ws.workspaceName}" --slug <slug>`);
3251
+ return;
3252
+ }
3253
+ slug = suggestion.slug;
3254
+ }
3255
+ else {
3256
+ // Non-interactive without --yes: don't write on a guess. Surface the
3257
+ // suggestion + how to accept it, and fail (so a script doesn't proceed
3258
+ // thinking it attached).
3259
+ if (opts.json) {
3260
+ console.log(JSON.stringify({ error: "suggestion-needs-confirmation", workspaceId: ws.workspaceId, suggestedSlug: suggestion.slug, confidence: suggestion.confidence, hint: "re-run with --yes to accept, or --slug <slug>" }, null, 2));
3261
+ }
3262
+ else {
3263
+ log.warn(`"${ws.workspaceName}" looks like the ${chalk.cyan(suggestion.slug)} template (${suggestion.confidence}). Not attaching without confirmation.`);
3264
+ log.hint(`Accept it: synap templates attach "${ws.workspaceName}" --yes · or pick: --slug <slug>`);
3265
+ }
3266
+ process.exitCode = 1;
3267
+ return;
3268
+ }
3269
+ }
3270
+ }
3271
+ if (!slug) {
3272
+ if (opts.json) {
3273
+ console.log(JSON.stringify({ error: "no-template-match", workspaceId: ws.workspaceId, name: ws.workspaceName }, null, 2));
3274
+ process.exitCode = 1;
3275
+ return;
3276
+ }
3277
+ log.error(`Couldn't tell which template fits "${ws.workspaceName}".`);
3278
+ const templates = cat.entries.filter((e) => typeOf(e) === "workspace").slice(0, 15);
3279
+ if (templates.length > 0) {
3280
+ log.hint("Available templates:");
3281
+ for (const e of templates) {
3282
+ console.log(" " + chalk.cyan(e.slug.padEnd(28)) + chalk.dim(e.name));
3283
+ }
3284
+ }
3285
+ log.hint(`Re-run with: synap templates attach "${ws.workspaceName}" --slug <slug>`);
3286
+ process.exit(1);
3287
+ }
3288
+ if (!opts.json && note)
3289
+ log.info(`Attaching ${chalk.cyan(slug)} — ${note}.`);
3290
+ await marketInstall(slug, {
3291
+ onto: ws.workspaceId,
3292
+ project: opts.project,
3293
+ timeout: opts.timeout,
3294
+ json: opts.json,
3295
+ podUrl: opts.podUrl,
3296
+ apiKey: opts.apiKey,
3297
+ });
1642
3298
  }
1643
3299
  //# sourceMappingURL=market.js.map