@synap-core/cli 1.10.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 (461) 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 +297 -176
  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 +3 -14
  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 +9 -2
  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 +198 -25
  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 +307 -24
  50. package/dist/commands/data.js.map +1 -1
  51. package/dist/commands/diagnose.d.ts +66 -4
  52. package/dist/commands/diagnose.js +329 -29
  53. package/dist/commands/diagnose.js.map +1 -1
  54. package/dist/commands/digest.js +2 -1
  55. package/dist/commands/digest.js.map +1 -1
  56. package/dist/commands/doc.d.ts +25 -4
  57. package/dist/commands/doc.js +100 -18
  58. package/dist/commands/doc.js.map +1 -1
  59. package/dist/commands/doctor.d.ts +130 -0
  60. package/dist/commands/doctor.js +814 -0
  61. package/dist/commands/doctor.js.map +1 -0
  62. package/dist/commands/events.js +3 -1
  63. package/dist/commands/events.js.map +1 -1
  64. package/dist/commands/explain.js +5 -5
  65. package/dist/commands/explain.js.map +1 -1
  66. package/dist/commands/graph.js +5 -3
  67. package/dist/commands/graph.js.map +1 -1
  68. package/dist/commands/import.d.ts +368 -13
  69. package/dist/commands/import.js +1096 -55
  70. package/dist/commands/import.js.map +1 -1
  71. package/dist/commands/init.d.ts +20 -9
  72. package/dist/commands/init.js +433 -973
  73. package/dist/commands/init.js.map +1 -1
  74. package/dist/commands/keys.d.ts +1 -0
  75. package/dist/commands/keys.js +15 -2
  76. package/dist/commands/keys.js.map +1 -1
  77. package/dist/commands/knowledge.d.ts +28 -6
  78. package/dist/commands/knowledge.js +358 -73
  79. package/dist/commands/knowledge.js.map +1 -1
  80. package/dist/commands/launch.js +2 -2
  81. package/dist/commands/launch.js.map +1 -1
  82. package/dist/commands/lens.d.ts +37 -1
  83. package/dist/commands/lens.js +193 -72
  84. package/dist/commands/lens.js.map +1 -1
  85. package/dist/commands/market-authoring.d.ts +72 -0
  86. package/dist/commands/market-authoring.js +566 -0
  87. package/dist/commands/market-authoring.js.map +1 -0
  88. package/dist/commands/market-official.d.ts +58 -0
  89. package/dist/commands/market-official.js +84 -0
  90. package/dist/commands/market-official.js.map +1 -0
  91. package/dist/commands/market.d.ts +419 -20
  92. package/dist/commands/market.js +2124 -193
  93. package/dist/commands/market.js.map +1 -1
  94. package/dist/commands/mcp.d.ts +70 -13
  95. package/dist/commands/mcp.js +308 -40
  96. package/dist/commands/mcp.js.map +1 -1
  97. package/dist/commands/open.d.ts +39 -9
  98. package/dist/commands/open.js +80 -25
  99. package/dist/commands/open.js.map +1 -1
  100. package/dist/commands/playbook.d.ts +42 -0
  101. package/dist/commands/playbook.js +162 -0
  102. package/dist/commands/playbook.js.map +1 -0
  103. package/dist/commands/pod.js +1 -1
  104. package/dist/commands/pod.js.map +1 -1
  105. package/dist/commands/pods.d.ts +19 -0
  106. package/dist/commands/pods.js +220 -85
  107. package/dist/commands/pods.js.map +1 -1
  108. package/dist/commands/proposals-actions.d.ts +62 -0
  109. package/dist/commands/proposals-actions.js +146 -8
  110. package/dist/commands/proposals-actions.js.map +1 -1
  111. package/dist/commands/proposals-await.d.ts +68 -0
  112. package/dist/commands/proposals-await.js +248 -0
  113. package/dist/commands/proposals-await.js.map +1 -0
  114. package/dist/commands/proton-setup.d.ts +73 -0
  115. package/dist/commands/proton-setup.js +577 -0
  116. package/dist/commands/proton-setup.js.map +1 -0
  117. package/dist/commands/providers.d.ts +23 -0
  118. package/dist/commands/providers.js +126 -1
  119. package/dist/commands/providers.js.map +1 -1
  120. package/dist/commands/raycast.js +6 -2
  121. package/dist/commands/raycast.js.map +1 -1
  122. package/dist/commands/rule.d.ts +150 -0
  123. package/dist/commands/rule.js +447 -0
  124. package/dist/commands/rule.js.map +1 -0
  125. package/dist/commands/session-wait.d.ts +111 -0
  126. package/dist/commands/session-wait.js +209 -0
  127. package/dist/commands/session-wait.js.map +1 -0
  128. package/dist/commands/sessions.d.ts +52 -0
  129. package/dist/commands/sessions.js +479 -58
  130. package/dist/commands/sessions.js.map +1 -1
  131. package/dist/commands/skill-manage.d.ts +21 -0
  132. package/dist/commands/skill-manage.js +136 -19
  133. package/dist/commands/skill-manage.js.map +1 -1
  134. package/dist/commands/skills.js +19 -1
  135. package/dist/commands/skills.js.map +1 -1
  136. package/dist/commands/status.d.ts +1 -4
  137. package/dist/commands/status.js +5 -34
  138. package/dist/commands/status.js.map +1 -1
  139. package/dist/commands/statusline.backoff.test.d.ts +13 -0
  140. package/dist/commands/statusline.backoff.test.js +100 -0
  141. package/dist/commands/statusline.backoff.test.js.map +1 -0
  142. package/dist/commands/statusline.js +16 -9
  143. package/dist/commands/statusline.js.map +1 -1
  144. package/dist/commands/sync-status.d.ts +12 -0
  145. package/dist/commands/sync-status.js +125 -0
  146. package/dist/commands/sync-status.js.map +1 -0
  147. package/dist/commands/templates-retire.d.ts +34 -0
  148. package/dist/commands/templates-retire.js +192 -0
  149. package/dist/commands/templates-retire.js.map +1 -0
  150. package/dist/commands/tools.js +6 -21
  151. package/dist/commands/tools.js.map +1 -1
  152. package/dist/commands/tracks.d.ts +51 -0
  153. package/dist/commands/tracks.js +291 -0
  154. package/dist/commands/tracks.js.map +1 -0
  155. package/dist/commands/update.d.ts +3 -11
  156. package/dist/commands/update.js +5 -134
  157. package/dist/commands/update.js.map +1 -1
  158. package/dist/commands/upload.d.ts +68 -0
  159. package/dist/commands/upload.js +257 -0
  160. package/dist/commands/upload.js.map +1 -0
  161. package/dist/commands/vendor.d.ts +28 -0
  162. package/dist/commands/vendor.js +110 -0
  163. package/dist/commands/vendor.js.map +1 -0
  164. package/dist/commands/versions.js +6 -0
  165. package/dist/commands/versions.js.map +1 -1
  166. package/dist/commands/view.js +10 -5
  167. package/dist/commands/view.js.map +1 -1
  168. package/dist/commands/whoami.d.ts +23 -3
  169. package/dist/commands/whoami.js +169 -23
  170. package/dist/commands/whoami.js.map +1 -1
  171. package/dist/commands/worker.d.ts +122 -0
  172. package/dist/commands/worker.js +435 -0
  173. package/dist/commands/worker.js.map +1 -0
  174. package/dist/commands/worker.test.d.ts +1 -0
  175. package/dist/commands/worker.test.js +116 -0
  176. package/dist/commands/worker.test.js.map +1 -0
  177. package/dist/commands/workspace-ops.d.ts +35 -0
  178. package/dist/commands/workspace-ops.js +153 -0
  179. package/dist/commands/workspace-ops.js.map +1 -0
  180. package/dist/index.d.ts +6 -6
  181. package/dist/index.js +1009 -171
  182. package/dist/index.js.map +1 -1
  183. package/dist/lib/agent-connections.d.ts +31 -0
  184. package/dist/lib/agent-connections.js +33 -0
  185. package/dist/lib/agent-connections.js.map +1 -0
  186. package/dist/lib/agent-detect.d.ts +27 -0
  187. package/dist/lib/agent-detect.js +71 -0
  188. package/dist/lib/agent-detect.js.map +1 -0
  189. package/dist/lib/agent-door.d.ts +138 -0
  190. package/dist/lib/agent-door.js +426 -0
  191. package/dist/lib/agent-door.js.map +1 -0
  192. package/dist/lib/agents-config.d.ts +2 -0
  193. package/dist/lib/agents-config.js +4 -9
  194. package/dist/lib/agents-config.js.map +1 -1
  195. package/dist/lib/approval-poll.d.ts +31 -2
  196. package/dist/lib/approval-poll.js +20 -7
  197. package/dist/lib/approval-poll.js.map +1 -1
  198. package/dist/lib/atomic-file.d.ts +48 -0
  199. package/dist/lib/atomic-file.js +129 -0
  200. package/dist/lib/atomic-file.js.map +1 -0
  201. package/dist/lib/auth.d.ts +14 -9
  202. package/dist/lib/auth.js +44 -61
  203. package/dist/lib/auth.js.map +1 -1
  204. package/dist/lib/batch-approval.d.ts +85 -0
  205. package/dist/lib/batch-approval.js +170 -0
  206. package/dist/lib/batch-approval.js.map +1 -0
  207. package/dist/lib/capture-lane.d.ts +6 -5
  208. package/dist/lib/capture-lane.js +11 -8
  209. package/dist/lib/capture-lane.js.map +1 -1
  210. package/dist/lib/capture-structure.d.ts +342 -0
  211. package/dist/lib/capture-structure.js +295 -0
  212. package/dist/lib/capture-structure.js.map +1 -0
  213. package/dist/lib/catalog-templates.d.ts +59 -0
  214. package/dist/lib/catalog-templates.js +126 -0
  215. package/dist/lib/catalog-templates.js.map +1 -0
  216. package/dist/lib/cli-build.d.ts +19 -0
  217. package/dist/lib/cli-build.js +37 -0
  218. package/dist/lib/cli-build.js.map +1 -0
  219. package/dist/lib/conversation-headers.d.ts +18 -0
  220. package/dist/lib/conversation-headers.js +32 -0
  221. package/dist/lib/conversation-headers.js.map +1 -0
  222. package/dist/lib/cp-packages.d.ts +216 -4
  223. package/dist/lib/cp-packages.js +414 -11
  224. package/dist/lib/cp-packages.js.map +1 -1
  225. package/dist/lib/credential-class.d.ts +117 -0
  226. package/dist/lib/credential-class.js +116 -0
  227. package/dist/lib/credential-class.js.map +1 -0
  228. package/dist/lib/describe-lens.d.ts +35 -0
  229. package/dist/lib/describe-lens.js +40 -0
  230. package/dist/lib/describe-lens.js.map +1 -1
  231. package/dist/lib/device-connect.d.ts +125 -0
  232. package/dist/lib/device-connect.js +256 -0
  233. package/dist/lib/device-connect.js.map +1 -0
  234. package/dist/lib/directory-lens.d.ts +99 -0
  235. package/dist/lib/directory-lens.js +187 -0
  236. package/dist/lib/directory-lens.js.map +1 -0
  237. package/dist/lib/exporter-coverage.d.ts +71 -0
  238. package/dist/lib/exporter-coverage.js +97 -0
  239. package/dist/lib/exporter-coverage.js.map +1 -0
  240. package/dist/lib/hub-client.d.ts +43 -9
  241. package/dist/lib/hub-client.js +429 -65
  242. package/dist/lib/hub-client.js.map +1 -1
  243. package/dist/lib/id.d.ts +23 -0
  244. package/dist/lib/id.js +35 -0
  245. package/dist/lib/id.js.map +1 -0
  246. package/dist/lib/init-connect.d.ts +41 -0
  247. package/dist/lib/init-connect.js +75 -0
  248. package/dist/lib/init-connect.js.map +1 -0
  249. package/dist/lib/init-exit.d.ts +24 -0
  250. package/dist/lib/init-exit.js +41 -0
  251. package/dist/lib/init-exit.js.map +1 -0
  252. package/dist/lib/init-flow.d.ts +25 -0
  253. package/dist/lib/init-flow.js +40 -0
  254. package/dist/lib/init-flow.js.map +1 -0
  255. package/dist/lib/init-handoff.d.ts +16 -0
  256. package/dist/lib/init-handoff.js +19 -0
  257. package/dist/lib/init-handoff.js.map +1 -0
  258. package/dist/lib/init-output.d.ts +18 -0
  259. package/dist/lib/init-output.js +43 -0
  260. package/dist/lib/init-output.js.map +1 -0
  261. package/dist/lib/init-pod.d.ts +45 -0
  262. package/dist/lib/init-pod.js +36 -0
  263. package/dist/lib/init-pod.js.map +1 -0
  264. package/dist/lib/installed.d.ts +210 -11
  265. package/dist/lib/installed.js +234 -36
  266. package/dist/lib/installed.js.map +1 -1
  267. package/dist/lib/key-approval.d.ts +57 -0
  268. package/dist/lib/key-approval.js +117 -0
  269. package/dist/lib/key-approval.js.map +1 -0
  270. package/dist/lib/key-diagnosis.d.ts +56 -0
  271. package/dist/lib/key-diagnosis.js +227 -0
  272. package/dist/lib/key-diagnosis.js.map +1 -0
  273. package/dist/lib/key-doctor.d.ts +136 -0
  274. package/dist/lib/key-doctor.js +286 -0
  275. package/dist/lib/key-doctor.js.map +1 -0
  276. package/dist/lib/key-locations.d.ts +120 -0
  277. package/dist/lib/key-locations.js +326 -0
  278. package/dist/lib/key-locations.js.map +1 -0
  279. package/dist/lib/key-source.d.ts +66 -0
  280. package/dist/lib/key-source.js +106 -0
  281. package/dist/lib/key-source.js.map +1 -0
  282. package/dist/lib/key-state.d.ts +70 -0
  283. package/dist/lib/key-state.js +115 -0
  284. package/dist/lib/key-state.js.map +1 -0
  285. package/dist/lib/kind-package.d.ts +181 -0
  286. package/dist/lib/kind-package.js +679 -0
  287. package/dist/lib/kind-package.js.map +1 -0
  288. package/dist/lib/knowledge-title.d.ts +24 -0
  289. package/dist/lib/knowledge-title.js +46 -0
  290. package/dist/lib/knowledge-title.js.map +1 -0
  291. package/dist/lib/knowledge-title.test.d.ts +1 -0
  292. package/dist/lib/knowledge-title.test.js +34 -0
  293. package/dist/lib/knowledge-title.test.js.map +1 -0
  294. package/dist/lib/machine-instance.d.ts +64 -0
  295. package/dist/lib/machine-instance.js +142 -0
  296. package/dist/lib/machine-instance.js.map +1 -0
  297. package/dist/lib/mcp-bridge.d.ts +42 -0
  298. package/dist/lib/mcp-bridge.js +147 -0
  299. package/dist/lib/mcp-bridge.js.map +1 -0
  300. package/dist/lib/mcp-bridge.test.d.ts +1 -0
  301. package/dist/lib/mcp-bridge.test.js +90 -0
  302. package/dist/lib/mcp-bridge.test.js.map +1 -0
  303. package/dist/lib/mcp-call.d.ts +30 -0
  304. package/dist/lib/mcp-call.js +107 -0
  305. package/dist/lib/mcp-call.js.map +1 -0
  306. package/dist/lib/mcp-snippets.d.ts +53 -0
  307. package/dist/lib/mcp-snippets.js +82 -0
  308. package/dist/lib/mcp-snippets.js.map +1 -0
  309. package/dist/lib/next-steps.d.ts +8 -0
  310. package/dist/lib/next-steps.js +31 -1
  311. package/dist/lib/next-steps.js.map +1 -1
  312. package/dist/lib/package-wire.d.ts +179 -0
  313. package/dist/lib/package-wire.js +68 -0
  314. package/dist/lib/package-wire.js.map +1 -0
  315. package/dist/lib/pair-link.d.ts +38 -0
  316. package/dist/lib/pair-link.js +68 -0
  317. package/dist/lib/pair-link.js.map +1 -0
  318. package/dist/lib/pod-wide.d.ts +40 -0
  319. package/dist/lib/pod-wide.js +46 -0
  320. package/dist/lib/pod-wide.js.map +1 -0
  321. package/dist/lib/pod.d.ts +119 -115
  322. package/dist/lib/pod.js +206 -412
  323. package/dist/lib/pod.js.map +1 -1
  324. package/dist/lib/project-ref.d.ts +9 -3
  325. package/dist/lib/project-ref.js +10 -4
  326. package/dist/lib/project-ref.js.map +1 -1
  327. package/dist/lib/review-credential.d.ts +119 -0
  328. package/dist/lib/review-credential.js +147 -0
  329. package/dist/lib/review-credential.js.map +1 -0
  330. package/dist/lib/safe-open.d.ts +49 -0
  331. package/dist/lib/safe-open.js +119 -0
  332. package/dist/lib/safe-open.js.map +1 -0
  333. package/dist/lib/session-lens.d.ts +40 -1
  334. package/dist/lib/session-lens.js +54 -4
  335. package/dist/lib/session-lens.js.map +1 -1
  336. package/dist/lib/superwhisper-import.d.ts +8 -0
  337. package/dist/lib/superwhisper-import.js +14 -7
  338. package/dist/lib/superwhisper-import.js.map +1 -1
  339. package/dist/lib/superwhisper-title.test.d.ts +1 -0
  340. package/dist/lib/superwhisper-title.test.js +18 -0
  341. package/dist/lib/superwhisper-title.test.js.map +1 -0
  342. package/dist/lib/targets.d.ts +134 -10
  343. package/dist/lib/targets.js +962 -258
  344. package/dist/lib/targets.js.map +1 -1
  345. package/dist/lib/template-file.d.ts +78 -0
  346. package/dist/lib/template-file.js +140 -0
  347. package/dist/lib/template-file.js.map +1 -0
  348. package/dist/lib/template-lifecycle.d.ts +217 -0
  349. package/dist/lib/template-lifecycle.js +433 -0
  350. package/dist/lib/template-lifecycle.js.map +1 -0
  351. package/dist/lib/template-steps.d.ts +88 -0
  352. package/dist/lib/template-steps.js +118 -0
  353. package/dist/lib/template-steps.js.map +1 -0
  354. package/dist/lib/time-ago.d.ts +2 -0
  355. package/dist/lib/time-ago.js +15 -0
  356. package/dist/lib/time-ago.js.map +1 -0
  357. package/dist/utils/logger.js +5 -2
  358. package/dist/utils/logger.js.map +1 -1
  359. package/package.json +25 -18
  360. package/skills/README.md +12 -10
  361. package/skills/agent-os/SKILL.md +220 -99
  362. package/skills/manifest.json +2 -2
  363. package/skills/onboard/SKILL.md +61 -25
  364. package/skills/synap/README.md +5 -8
  365. package/skills/synap/SKILL.md +1185 -301
  366. package/skills/synap/_order.txt +7 -0
  367. package/skills/synap/_skill.yaml +1 -1
  368. package/skills/synap/automations.md +15 -0
  369. package/skills/synap/capture.md +54 -151
  370. package/skills/synap/cli-operations.md +24 -18
  371. package/skills/synap/common-mistakes.md +1 -0
  372. package/skills/synap/concepts.md +28 -0
  373. package/skills/synap/content-os.md +92 -0
  374. package/skills/synap/creative-loop.md +15 -10
  375. package/skills/synap/crm.md +21 -52
  376. package/skills/synap/digesting-a-channel.md +25 -0
  377. package/skills/synap/document-embeds.md +141 -47
  378. package/skills/synap/escalation-ladder.md +20 -10
  379. package/skills/synap/focus-sessions.md +47 -21
  380. package/skills/synap/from-intent.md +142 -0
  381. package/skills/synap/governance-rules.md +4 -4
  382. package/skills/synap/governance.md +10 -7
  383. package/skills/synap/hand-off.md +33 -0
  384. package/skills/synap/inline-patterns.md +15 -7
  385. package/skills/synap/lenses.md +33 -15
  386. package/skills/synap/linking.md +41 -40
  387. package/skills/synap/mcp-remote-servers.md +64 -0
  388. package/skills/synap/mental-model.md +24 -22
  389. package/skills/synap/multi-entity-capture.md +6 -4
  390. package/skills/synap/operating-mode-rules.md +1 -1
  391. package/skills/synap/processes.md +118 -0
  392. package/skills/synap/quick-reference.md +5 -1
  393. package/skills/synap/reading.md +3 -1
  394. package/skills/synap/reflexes.md +12 -42
  395. package/skills/synap/viewframe-cells.md +38 -5
  396. package/skills/synap/work-flow.md +1 -1
  397. package/skills/synap/work-model.md +96 -0
  398. package/skills/synap/worked-examples.md +21 -1
  399. package/skills/synap/workspace-design.md +14 -10
  400. package/skills/synap/writes.md +165 -35
  401. package/skills/synap/writing-titles.md +63 -0
  402. package/skills/synap-app/SKILL.md +319 -0
  403. package/skills/synap-app/_order.txt +5 -0
  404. package/skills/synap-app/_skill.yaml +20 -0
  405. package/skills/synap-app/creating-the-base-app.md +64 -0
  406. package/skills/synap-app/governance.md +42 -0
  407. package/skills/synap-app/install-commands.md +55 -0
  408. package/skills/synap-app/overview.md +50 -0
  409. package/skills/synap-app/using-the-sdk.md +73 -0
  410. package/skills/synap-design/SKILL.md +474 -0
  411. package/skills/synap-design/_order.txt +4 -0
  412. package/skills/synap-design/_skill.yaml +21 -0
  413. package/skills/synap-design/loop.md +134 -0
  414. package/skills/synap-design/overview.md +44 -0
  415. package/skills/synap-design/principles.md +130 -0
  416. package/skills/synap-design/system.md +133 -0
  417. package/skills/synap-market/SKILL.md +625 -0
  418. package/skills/synap-market/_order.txt +5 -0
  419. package/skills/synap-market/_skill.yaml +24 -0
  420. package/skills/synap-market/author-and-publish.md +212 -0
  421. package/skills/synap-market/discover-and-fetch.md +59 -0
  422. package/skills/synap-market/governance-and-catalog.md +63 -0
  423. package/skills/synap-market/install.md +133 -0
  424. package/skills/synap-market/overview.md +119 -0
  425. package/skills/synap-schema/SKILL.md +172 -10
  426. package/skills/synap-schema/_order.txt +2 -0
  427. package/skills/synap-schema/_skill.yaml +4 -2
  428. package/skills/synap-schema/common-mistakes.md +4 -2
  429. package/skills/synap-schema/discover-profiles.md +1 -1
  430. package/skills/synap-schema/entity-scope.md +15 -3
  431. package/skills/synap-schema/extend-first.md +29 -0
  432. package/skills/synap-schema/extend-vs-create.md +1 -1
  433. package/skills/synap-schema/intent-vocabulary-ssot.md +111 -0
  434. package/skills/synap-schema/property-types.md +1 -1
  435. package/skills/synap-schema/relations.md +1 -1
  436. package/skills/synap-ui/SKILL.md +19 -12
  437. package/skills/synap-ui/_skill.yaml +1 -1
  438. package/skills/synap-ui/ai-companion.md +1 -1
  439. package/skills/synap-ui/arranging-bento.md +2 -0
  440. package/skills/synap-ui/bento-layouts.md +10 -10
  441. package/skills/synap-ui/bento-recipes.md +313 -543
  442. package/skills/synap-ui/four-builds.md +5 -0
  443. package/skills/synap-ui/view-types.md +2 -2
  444. package/skills/synap-ui/widget-catalog.md +22 -278
  445. package/templates/automations/bookmark-enrichment.automation.json +290 -13
  446. package/templates/automations/url-bookmark-capture.automation.json +73 -11
  447. package/templates/skills/authoring-playbooks/SKILL.md +6 -3
  448. package/templates/skills/authoring-playbooks/reference/stages-contract.md +15 -0
  449. package/templates/skills/authoring-playbooks/reference/worked-example.md +3 -0
  450. package/dist/commands/infra.d.ts +0 -21
  451. package/dist/commands/infra.js +0 -262
  452. package/dist/commands/infra.js.map +0 -1
  453. package/dist/lib/browser-auth.d.ts +0 -35
  454. package/dist/lib/browser-auth.js +0 -170
  455. package/dist/lib/browser-auth.js.map +0 -1
  456. package/dist/lib/openclaw.d.ts +0 -32
  457. package/dist/lib/openclaw.js +0 -164
  458. package/dist/lib/openclaw.js.map +0 -1
  459. package/dist/lib/seed.d.ts +0 -13
  460. package/dist/lib/seed.js +0 -135
  461. package/dist/lib/seed.js.map +0 -1
@@ -14,28 +14,31 @@
14
14
  * synap market install <slug> — install
15
15
  * synap market update [slug] [--yes] [--json] — check/apply drift
16
16
  *
17
- * INSTALL SCOPE: the pod's one install door (`/packages/apply`) provisions a
18
- * WORKSPACE from a package definition — it is workspace-centric. So `market
19
- * install` fully installs WORKSPACE-type packages (same door `launch` uses) and,
20
- * for the operational types (capability/skill/…), routes the user to the surface
21
- * that installs them (the browser marketplace, or `synap cap add` for a
22
- * capability already in the pod catalog) rather than silently doing nothing. A
23
- * generic CLI→pod install for standalone non-workspace packages is not wired yet.
17
+ * INSTALL SCOPE: WORKSPACE-type packages install via `/packages/apply` (the
18
+ * same door `launch` uses), which provisions a workspace from the definition.
19
+ * Non-workspace kinds (capability / automation / cell / template) install via
20
+ * the pod's kind-agnostic `market.install` verb on `/capabilities/execute`,
21
+ * which resolves the definition from `cp_catalog_cache`. Types the verb doesn't
22
+ * handle fall back to a route-to-browser message.
24
23
  */
25
24
  import ora from "ora";
26
25
  import chalk from "chalk";
27
- import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, } from "@synap-core/workspace-templates";
26
+ import { assembleCatalog, computeTemplateUpdates, isHashVersion, listPublicTemplates, listWorkspaceTemplates, getWorkspaceTemplate, mergeCatalog, toPackageDefinition, layerTemplateGraph, bundledEdgesOf, groupByDomain, } from "@synap-core/workspace-templates";
28
27
  import { log } from "../utils/logger.js";
29
- import { resolveHubConfig, hubPost, renderHubError } from "../lib/hub-client.js";
28
+ import { resolveHubConfig, hubPost, renderHubError, HubError } from "../lib/hub-client.js";
30
29
  import { fetchProjects } from "../lib/project.js";
31
30
  import { writeGovernance } from "../lib/capture-lane.js";
32
31
  import { renderNextSteps, FLOW } from "../lib/next-steps.js";
33
32
  import { getStoredToken, isTokenLocallyExpired } from "../lib/auth.js";
34
- import { fetchPublicBrowseRows, fetchMyRows, fetchAvailableSlugs, fetchPackageDefinition, } from "../lib/cp-packages.js";
35
- 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";
36
36
  import { isSuite } from "../lib/suite.js";
37
+ import { TEMPLATE_STEP_LABELS, classifyTemplateStep, proposalsDoor, stepFromApplyState, templateStepGlyph, templateStepPhrase, } from "../lib/template-steps.js";
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,157 @@ 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
+ ];
231
+ /**
232
+ * Map a CP package `category` (what {@link typeOf} returns) → the pod
233
+ * `market.install` verb's `kind` vocabulary, or `null` when the verb can't
234
+ * install that type. The CP's live PACKAGE_TYPES and the pod's cache `kind`
235
+ * diverge (see cp-catalog-sync.ts): the CP's `workflow` is the pod's
236
+ * `automation`; the CP's `workspace` is the pod's `template` — but a workspace
237
+ * installs through the dedicated `/packages/apply` create path, so it never
238
+ * routes through here. `automation`/`template` are also accepted verbatim so a
239
+ * row already speaking the pod's cache vocabulary still resolves.
240
+ */
241
+ export function marketInstallKind(type) {
242
+ switch (type) {
243
+ case "capability":
244
+ return "capability";
245
+ case "cell":
246
+ return "cell";
247
+ case "workflow":
248
+ case "automation":
249
+ return "automation";
250
+ case "template":
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";
258
+ default:
259
+ return null;
260
+ }
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
+ }
119
299
  /**
120
300
  * Client-side filter. The CP browse route filters REMOTE rows server-side, but
121
301
  * `assembleCatalog` always adds the BUNDLED (all-`workspace`) templates — so a
@@ -135,27 +315,20 @@ function matchesFilters(entry, filters) {
135
315
  }
136
316
  return true;
137
317
  }
138
- /** Status badges — suite (tag) / private / installed / tier / locked. */
139
- function badges(entry, cat) {
140
- const out = [];
141
- if (isSuite(entry))
142
- out.push(chalk.magenta("suite"));
143
- if (entry.isPrivate)
144
- out.push(chalk.yellow("private"));
145
- if (cat.installed.has(entry.slug))
146
- out.push(chalk.green("installed"));
147
- const tier = cat.tierBySlug.get(entry.slug);
148
- if (tier?.requiredTier)
149
- out.push(chalk.cyan(tier.requiredTier));
150
- else if (tier?.pricingModel && tier.pricingModel !== "free")
151
- out.push(chalk.cyan(tier.pricingModel));
152
- if (cat.lockedSlugs.has(entry.slug))
153
- out.push(chalk.red("locked"));
154
- return out;
155
- }
156
318
  function clip(s, n) {
157
319
  return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "…";
158
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
+ }
159
332
  /**
160
333
  * Print an honest per-dependency seed summary — no more silent "✓ ready" when
161
334
  * a dependency's capabilities/playbooks failed to seed. Prefers the top-level
@@ -163,6 +336,23 @@ function clip(s, n) {
163
336
  * back to re-deriving from the per-dependency `dependencies[]` array on older
164
337
  * pod builds. No-op when there's nothing to report.
165
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
+ }
166
356
  function printSeedOutcomes(dependencies, seedSummary) {
167
357
  if (seedSummary) {
168
358
  if (seedSummary.attempted === 0)
@@ -211,11 +401,61 @@ function hasSeedFailure(dependencies, seedSummary) {
211
401
  * not that the install was genuinely a no-op (the observed `market install
212
402
  * the-arch` → "already up to date" bug).
213
403
  */
214
- function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamped) {
215
- 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)
216
418
  return false;
217
419
  return ws?.outcome === "unchanged" || (ws?.outcome === undefined && seedSummary === undefined);
218
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
+ }
219
459
  /**
220
460
  * Classify one `/packages/apply` response into the honest verdict both
221
461
  * `marketInstall` and `applyOnePackage` print (and both put in `--json`).
@@ -225,8 +465,11 @@ function detectStampContradiction(ws, seedSummary, remoteVersionSent, wasUnstamp
225
465
  * already-versioned slugs, so it always passes `false`).
226
466
  */
227
467
  export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
228
- const seedFailed = hasSeedFailure(dependencies, seedSummary);
229
- 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)) {
230
473
  return {
231
474
  status: "stamp-contradiction",
232
475
  warnings: [
@@ -241,8 +484,13 @@ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
241
484
  if (ws?.outcome === "unchanged")
242
485
  return { status: "unchanged", warnings: [] };
243
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".
244
492
  const warnings = [
245
- `${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}`,
246
494
  ];
247
495
  return { status: seedFailed ? "composed-with-failures" : "composed", warnings };
248
496
  }
@@ -250,6 +498,25 @@ export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
250
498
  return { status: "legacy-unknown", warnings: [] };
251
499
  return { status: "no-workspace", warnings: [] };
252
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
+ }
253
520
  /** Render a `classifyApplyResult` verdict — the ONE honest-text door for both `marketInstall` and `applyOnePackage`. */
254
521
  export function printApplyVerdict(entry, verdict) {
255
522
  switch (verdict.status) {
@@ -257,13 +524,13 @@ export function printApplyVerdict(entry, verdict) {
257
524
  log.success(`Installed ${entry.name}.`);
258
525
  return;
259
526
  case "installed-with-failures":
260
- 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).`);
261
528
  return;
262
529
  case "updated":
263
530
  log.success(`Updated ${entry.name}.`);
264
531
  return;
265
532
  case "updated-with-failures":
266
- 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).`);
267
534
  return;
268
535
  case "unchanged":
269
536
  log.info(`• ${entry.name} is already up to date.`);
@@ -273,7 +540,15 @@ export function printApplyVerdict(entry, verdict) {
273
540
  log.hint(verdict.warnings[0]);
274
541
  return;
275
542
  case "composed-with-failures":
276
- 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.`);
277
552
  log.hint(verdict.warnings[0]);
278
553
  return;
279
554
  case "stamp-contradiction":
@@ -302,8 +577,12 @@ export async function market(opts) {
302
577
  name: e.name,
303
578
  description: e.description,
304
579
  type: typeOf(e),
580
+ domain: e.domain ?? null,
305
581
  tags: e.tags,
306
582
  suite: isSuite(e),
583
+ overlay: isOverlay(e),
584
+ ridesBase: composeBaseOf(e.slug),
585
+ bundles: bundleCountOf(e.slug),
307
586
  isPrivate: e.isPrivate,
308
587
  installed: cat.installed.has(e.slug),
309
588
  requiredTier: tier?.requiredTier ?? null,
@@ -325,42 +604,153 @@ export async function market(opts) {
325
604
  log.dim("Try 'synap market' with no filter, or 'synap login' to see your private packages.");
326
605
  return;
327
606
  }
328
- // Suites headline; then group by type.
329
- const suites = entries.filter((e) => isSuite(e));
330
- const rest = entries.filter((e) => !isSuite(e));
331
- const render = (entry) => {
332
- const b = badges(entry, cat);
333
- console.log(" " +
334
- chalk.cyan(entry.slug.padEnd(24)) +
335
- chalk.bold(entry.name) +
336
- (b.length ? " " + b.join(" ") : ""));
337
- if (entry.description)
338
- console.log(" " + " ".repeat(24) + chalk.dim(clip(entry.description, 66)));
339
- log.blank();
607
+ renderCatalog(entries, cat);
608
+ }
609
+ /**
610
+ * The kind of composition a workspace template participates in, derived from its
611
+ * bundled dependency edges — NOT hardcoded. A `compose` edge = an OVERLAY that
612
+ * rides a base workspace (installing it bare is meaningless); a `require` edge
613
+ * on a suite = a workspace it BUNDLES. Non-bundled (private/CP-only) rows return
614
+ * `null` base and 0 bundles, which the renderer degrades gracefully.
615
+ */
616
+ function composeBaseOf(slug) {
617
+ for (const dep of bundledEdgesOf(slug)) {
618
+ if (dep.relation === "compose")
619
+ return dep.slug;
620
+ }
621
+ return null;
622
+ }
623
+ function bundleCountOf(slug) {
624
+ return bundledEdgesOf(slug).filter((d) => d.relation === "require").length;
625
+ }
626
+ function isOverlay(entry) {
627
+ return typeOf(entry) === "workspace" && !isSuite(entry) && composeBaseOf(entry.slug) !== null;
628
+ }
629
+ /**
630
+ * Section-aware catalog renderer. Three axes carry distinct signal, so each gets
631
+ * its OWN visual channel instead of one flat colored word (the old list):
632
+ * KIND → the SECTION (Suites / Workspaces-by-domain / Overlays / other types)
633
+ * STATE → a leading ✓ glyph (installed)
634
+ * ACCESS → a dim right-hand tag (private / tier / locked)
635
+ * Name reads first (you scan it); the slug is the dim type-token (you type it).
636
+ */
637
+ function renderCatalog(entries, cat) {
638
+ const width = process.stdout.columns ?? 100;
639
+ const NAME_W = 26;
640
+ const SLUG_W = 20;
641
+ // Resolve a base slug to its display name so an overlay names the WORKSPACE it
642
+ // extends, not its raw slug (e.g. "→ Brand Library", not "→ brand-library").
643
+ const nameBySlug = new Map(entries.map((e) => [e.slug, e.name]));
644
+ const accessTag = (entry) => {
645
+ const parts = [];
646
+ if (entry.isPrivate)
647
+ parts.push(chalk.yellow("private"));
648
+ const tier = cat.tierBySlug.get(entry.slug);
649
+ if (tier?.requiredTier)
650
+ parts.push(chalk.cyan(tier.requiredTier));
651
+ else if (tier?.pricingModel && tier.pricingModel !== "free")
652
+ parts.push(chalk.cyan(tier.pricingModel));
653
+ if (cat.lockedSlugs.has(entry.slug))
654
+ parts.push(chalk.red("locked"));
655
+ return parts.length ? " " + parts.join(" ") : "";
340
656
  };
657
+ // One item = one line: glyph · bold name · default-weight slug (the token you
658
+ // TYPE — never dimmed) · dim detail · dim access.
659
+ const row = (entry, detail) => {
660
+ const glyph = cat.installed.has(entry.slug) ? chalk.green("✓") : " ";
661
+ const name = clip(entry.name, NAME_W).padEnd(NAME_W);
662
+ const slug = clip(entry.slug, SLUG_W).padEnd(SLUG_W);
663
+ const access = accessTag(entry);
664
+ const head = ` ${glyph} ${chalk.bold(name)} ${slug}`;
665
+ // Budget the detail to the remaining terminal width (never wrap/overflow).
666
+ const detailText = detail ?? entry.description ?? "";
667
+ const used = 2 + 1 + 1 + NAME_W + 1 + SLUG_W + 1;
668
+ const room = Math.max(12, width - used - stripLen(access) - 1);
669
+ console.log(head + " " + chalk.dim(clip(detailText, room)) + access);
670
+ };
671
+ // An overlay's identity IS "Name → Base Workspace" — the base it extends must
672
+ // read as part of what it IS, not be buried in the description. So it gets its
673
+ // own row shape: a combined, coloured identity column, then slug, then blurb.
674
+ const IDENT_W = 40;
675
+ const overlayRow = (entry) => {
676
+ const glyph = cat.installed.has(entry.slug) ? chalk.green("✓") : " ";
677
+ const baseName = clip(nameBySlug.get(composeBaseOf(entry.slug) ?? "") ?? composeBaseOf(entry.slug) ?? "", IDENT_W - 22 - 3 // leave room for "name → " inside IDENT_W
678
+ );
679
+ const nameText = clip(entry.name, 22);
680
+ const identityVisible = `${nameText} → ${baseName}`;
681
+ const identity = chalk.bold(nameText) + chalk.dim(" → ") + chalk.cyan(baseName);
682
+ const pad = " ".repeat(Math.max(1, IDENT_W - identityVisible.length));
683
+ const slug = clip(entry.slug, SLUG_W).padEnd(SLUG_W);
684
+ const access = accessTag(entry);
685
+ const used = 2 + 1 + 1 + IDENT_W + 1 + SLUG_W + 1;
686
+ const room = Math.max(10, width - used - stripLen(access) - 1);
687
+ console.log(` ${glyph} ${identity}${pad} ${slug} ` +
688
+ chalk.dim(clip(entry.description ?? "", room)) +
689
+ access);
690
+ };
691
+ // ── Suites — the headline: one install = a whole operating core ────────────
692
+ const suites = entries.filter((e) => isSuite(e));
341
693
  if (suites.length > 0) {
342
- log.heading(" Suites — a whole operating core in one install");
694
+ log.heading(" Suites · a whole operating core in one install");
343
695
  log.blank();
344
- suites.forEach(render);
345
- }
346
- // Group the rest by type, in a stable order.
347
- const order = ["workspace", "capability", "skill", "workflow", "view", "cell"];
348
- const byType = new Map();
349
- for (const e of rest) {
350
- const t = typeOf(e);
351
- (byType.get(t) ?? byType.set(t, []).get(t)).push(e);
352
- }
353
- const types = [...byType.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
354
- for (const t of types) {
355
- log.heading(` ${t.charAt(0).toUpperCase() + t.slice(1)}`);
696
+ for (const e of suites) {
697
+ const n = bundleCountOf(e.slug);
698
+ row(e, n > 0 ? `bundles ${n} workspace${n === 1 ? "" : "s"}` : e.description ?? "");
699
+ }
700
+ log.blank();
701
+ }
702
+ // ── Workspaces — standalone installs, grouped by authored DOMAIN ───────────
703
+ const workspaces = entries.filter((e) => typeOf(e) === "workspace" && !isSuite(e) && !isOverlay(e));
704
+ if (workspaces.length > 0) {
705
+ log.heading(" Workspaces");
706
+ for (const group of groupByDomain(workspaces, (e) => e.domain)) {
707
+ log.blank();
708
+ console.log(" " + chalk.underline(group.label));
709
+ for (const e of group.items)
710
+ row(e);
711
+ }
712
+ log.blank();
713
+ }
714
+ // ── Add-ons — each rides a workspace you already have (named in-line) ───────
715
+ const overlays = entries.filter((e) => isOverlay(e));
716
+ if (overlays.length > 0) {
717
+ log.heading(" Add-ons · each extends the workspace named after the →");
718
+ log.blank();
719
+ for (const e of overlays)
720
+ overlayRow(e);
356
721
  log.blank();
357
- byType.get(t).forEach(render);
358
722
  }
359
- log.dim(`${entries.length} package${entries.length === 1 ? "" : "s"} available.`);
723
+ // ── Other package types (capability / skill / view / cell) by type ─────────
724
+ const other = entries.filter((e) => typeOf(e) !== "workspace");
725
+ if (other.length > 0) {
726
+ const order = ["capability", "skill", "workflow", "view", "cell"];
727
+ const byType = new Map();
728
+ for (const e of other) {
729
+ const t = typeOf(e);
730
+ (byType.get(t) ?? byType.set(t, []).get(t)).push(e);
731
+ }
732
+ const types = [...byType.keys()].sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
733
+ for (const t of types) {
734
+ log.heading(` ${kindHeading(t)}`);
735
+ log.blank();
736
+ for (const e of byType.get(t))
737
+ row(e);
738
+ log.blank();
739
+ }
740
+ }
741
+ // Footer: a scannable count line + progressive-disclosure hints.
742
+ const installedN = entries.filter((e) => cat.installed.has(e.slug)).length;
743
+ log.dim(`${installedN} installed · ${entries.length - installedN} available` +
744
+ (suites.length ? ` · ${suites.length} suite${suites.length === 1 ? "" : "s"}` : ""));
360
745
  if (!cat.loggedIn)
361
746
  log.dim("Log in to see your private packages: synap login");
362
747
  renderNextSteps(FLOW.afterMarketList());
363
748
  }
749
+ /** Visible length of a string with chalk color codes stripped — for width math. */
750
+ function stripLen(s) {
751
+ // eslint-disable-next-line no-control-regex
752
+ return s.replace(/\[[0-9;]*m/g, "").length;
753
+ }
364
754
  // ── Optional --project link ──────────────────────────────────────────────────
365
755
  /**
366
756
  * Installs are POD-WIDE. A template provisions WORKSPACES (pod-level) and their
@@ -381,6 +771,67 @@ async function resolveOptionalProject(cfg, explicitId) {
381
771
  }
382
772
  return { projectId: match.id, name: match.name };
383
773
  }
774
+ /** Human render of a preflight report — scannable summary + per-issue reasons. */
775
+ function renderPreflightReport(entry, r) {
776
+ // Hard structural failure — the definition itself won't install.
777
+ if (r.validationErrors.length > 0) {
778
+ log.error(`${entry.name} — ${r.validationErrors.length} structural error${r.validationErrors.length === 1 ? "" : "s"} (won't install):`);
779
+ for (const e of r.validationErrors)
780
+ log.hint(e);
781
+ return;
782
+ }
783
+ const p = r.profiles;
784
+ // Every issue class, not just profile conflicts — the verdict and the printed
785
+ // warnings must agree (`r.ok` excludes advisory deferred/scopeConflicts, so
786
+ // "cleanly" alone could sit above a ⚠ line; count them all here).
787
+ const issueCounts = [];
788
+ if (p.conflicts.length)
789
+ issueCounts.push(`${p.conflicts.length} profile conflict${p.conflicts.length === 1 ? "" : "s"}`);
790
+ if (p.deferred.length)
791
+ issueCounts.push(`${p.deferred.length} duplicate${p.deferred.length === 1 ? "" : "s"}`);
792
+ if (p.scopeConflicts.length)
793
+ issueCounts.push(`${p.scopeConflicts.length} scope note${p.scopeConflicts.length === 1 ? "" : "s"}`);
794
+ if (r.entityLinks.unresolved.length)
795
+ issueCounts.push(`${r.entityLinks.unresolved.length} dropped link${r.entityLinks.unresolved.length === 1 ? "" : "s"}`);
796
+ if (r.views.wouldOrphan.length)
797
+ issueCounts.push(`${r.views.wouldOrphan.length} orphaned view${r.views.wouldOrphan.length === 1 ? "" : "s"}`);
798
+ const blocking = !r.ok; // conflicts / dropped links / orphaned views
799
+ const hasAny = issueCounts.length > 0;
800
+ // Verdict — never says "clean" if any warning will print below.
801
+ if (!hasAny)
802
+ log.success(`Preflight for ${entry.name}: no conflicts — safe to install.`);
803
+ else if (blocking)
804
+ log.warn(`Preflight for ${entry.name}: would install with issues — review before installing.`);
805
+ else
806
+ log.warn(`Preflight for ${entry.name}: installs, with ${issueCounts.length} advisory note${issueCounts.length === 1 ? "" : "s"} — review below.`);
807
+ log.dim(`Profiles: ${p.create.length} to create · ${p.reused.length} reused`);
808
+ if (hasAny)
809
+ log.dim(`Issues: ${issueCounts.join(" · ")}`);
810
+ // Warnings are the PAYLOAD of a dry-run — print them BEFORE the (potentially
811
+ // long) create/reuse list so they never scroll off-screen.
812
+ // The load-bearing signal for an AI author: a declared role/kind that clashes
813
+ // with an existing pod profile is SKIPPED — never created, never mutated.
814
+ for (const c of p.conflicts)
815
+ log.warn(`${c.slug}: declared ${c.declaredKind}, pod has ${c.existingKind} → SKIP (not created)`);
816
+ for (const d of p.deferred)
817
+ log.warn(`${d.slug}: same slug exists but not promotable (${d.reason}) → a duplicate would be created`);
818
+ for (const sc of p.scopeConflicts)
819
+ log.warn(`${sc.slug}: declared scope ${sc.declaredScope}, pod has ${sc.existingScope} → reused as-is (declared scope not applied)`);
820
+ for (const link of r.entityLinks.unresolved)
821
+ log.warn(`link ${link}: an endpoint profile is a conflict → relation dropped`);
822
+ for (const v of r.views.wouldOrphan)
823
+ log.warn(`view "${v}": its scope profile is a conflict → view would have no scope`);
824
+ // One actionable hint for the whole conflict/link/view class — the remedy the
825
+ // per-line messages don't give.
826
+ if (p.conflicts.length || r.entityLinks.unresolved.length || r.views.wouldOrphan.length)
827
+ log.hint("Conflicts are skipped, never merged — rename the template's profile to a new slug, or install as-is to reuse the pod's existing one.");
828
+ for (const slug of p.create)
829
+ log.dim(`+ ${slug} (new profile)`);
830
+ for (const slug of p.reused)
831
+ log.dim(`↺ ${slug} (reuses existing pod profile)`);
832
+ if (!hasAny)
833
+ log.hint("Run without --dry-run to install.");
834
+ }
384
835
  // ── `synap market install <slug>` ───────────────────────────────────────────
385
836
  export async function marketInstall(slug, opts) {
386
837
  const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
@@ -395,15 +846,208 @@ export async function marketInstall(slug, opts) {
395
846
  log.hint("Browse what's available: synap market --list");
396
847
  process.exit(1);
397
848
  }
398
- const type = typeOf(entry);
399
- // Non-workspace types have no CLI→pod install door yet (the pod's
400
- // /packages/apply provisions a WORKSPACE). Be honest and route the user.
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
+ }
884
+ // A CAPABILITY installs through the SAME door as `cap add` — one code path, so
885
+ // the two commands can never diverge. That door resolves the template by key
886
+ // (so opt-in / syncByDefault:false caps like unipile-linkedin work — the
887
+ // params-less market.install verb below cannot) AND prompts for any required
888
+ // credential inline (a key), which a server-side verb can never do. `cap add`
889
+ // is now a thin alias of this. `--pod` is honoured (capabilityAdd resolves the
890
+ // active pod itself); `--json` prompts can't render, so a credentialed cap
891
+ // still prompts — run it interactively.
892
+ if (type === "capability") {
893
+ await capabilityAdd(slug, {});
894
+ return;
895
+ }
896
+ // Other non-workspace packages install through the pod's ONE kind-agnostic
897
+ // door — the `market.install` verb on `/capabilities/execute`. (The WORKSPACE
898
+ // path below uses `/packages/apply`, which provisions a workspace, and is
899
+ // left untouched.) The verb resolves the definition from `cp_catalog_cache`
900
+ // by (kind, slug), tier-gates, and — for the CLI's OPERATOR hub key (no
901
+ // agentUserId) — installs directly; an agent call would file a proposal.
401
902
  if (type !== "workspace") {
402
- log.warn(`"${entry.name}" is a ${type} package — not installable from the CLI yet.`);
403
- if (type === "capability") {
404
- log.hint(`Add a capability from the pod catalog: synap cap add "${entry.name}"`);
903
+ const kind = marketInstallKind(type);
904
+ if (!kind) {
905
+ log.warn(`"${entry.name}" is a ${type} package — the CLI can't install this type yet.`);
906
+ log.hint("Install it from the browser marketplace (Marketplace → find it → Install).");
907
+ return;
908
+ }
909
+ let cfg;
910
+ try {
911
+ cfg = await resolveHubConfig(opts);
912
+ }
913
+ catch (e) {
914
+ renderHubError(e);
915
+ process.exit(1);
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
+ }
935
+ const s = opts.json ? null : ora(`Installing ${entry.name}…`).start();
936
+ let res;
937
+ try {
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));
947
+ }
948
+ catch (e) {
949
+ s?.stop();
950
+ // 404 = this pod predates the seeded `market.install` verb (older deploy).
951
+ if (e instanceof HubError && e.status === 404) {
952
+ log.error(`This pod doesn't expose the market.install verb yet.`);
953
+ log.hint("It needs a newer pod deploy — meanwhile install from the browser marketplace.");
954
+ process.exit(1);
955
+ }
956
+ // The verb throws NOT_FOUND (surfaced as a 500) when the CP kind hasn't
957
+ // synced into `cp_catalog_cache` yet — say so actionably, not opaquely.
958
+ if (e instanceof HubError && /catalog cache/i.test(e.message)) {
959
+ log.error(`"${entry.name}" isn't in the pod's marketplace cache yet.`);
960
+ log.hint("The pod syncs the catalog every ~10 min — retry shortly, or install from the browser marketplace.");
961
+ process.exit(1);
962
+ }
963
+ log.error(`${entry.name} failed`);
964
+ renderHubError(e);
965
+ process.exit(1);
966
+ }
967
+ s?.stop();
968
+ // The execute door wraps the verb's return as { status:"run", result: <MarketInstallOutcome> }.
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.
984
+ const outcome = (res.result ?? res);
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";
996
+ if (opts.json) {
997
+ console.log(JSON.stringify({ slug, kind, outcome: status, result: outcome }, null, 2));
998
+ return;
999
+ }
1000
+ if (status === "proposed") {
1001
+ const proposalId = outcome.proposalId ? String(outcome.proposalId) : undefined;
1002
+ const reviewUrl = outcome.reviewUrl ? String(outcome.reviewUrl) : undefined;
1003
+ log.info(`${entry.name} — proposed (under review, NOT installed yet).`);
1004
+ if (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;
405
1015
  }
406
- log.hint("Install it from the browser marketplace (Marketplace → find it → Install).");
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}).`);
1048
+ return;
1049
+ }
1050
+ log.success(`Installed ${entry.name}.`);
407
1051
  return;
408
1052
  }
409
1053
  // Tier gate: don't silently attempt an ungrantable install.
@@ -434,25 +1078,40 @@ export async function marketInstall(slug, opts) {
434
1078
  // Build the install payload — bundled (offline, byte-identical to the CP
435
1079
  // definition) when available, else the authed CP definition.
436
1080
  const cpToken = getStoredToken()?.token;
437
- let pkg;
438
- if (cat.bundledSlugs.has(slug)) {
439
- pkg = toPackageDefinition(slug);
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);
440
1088
  }
441
- else {
442
- const def = await fetchPackageDefinition(slug, cpToken);
443
- if (!def) {
444
- log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
445
- log.hint(cat.loggedIn ? "" : "It may be private — try: synap login");
1089
+ const pkg = payload.pkg;
1090
+ // Only a catalog-sourced install can predict the stamp the pod will record.
1091
+ const remoteVersion = payload.catalogVersion;
1092
+ // ── --dry-run: write-free preflight of the CREATE path. ───────────────────
1093
+ // Runs the pod's REAL resolver (write-free) so a template author sees
1094
+ // profileKind conflicts and other resolution failures BEFORE installing.
1095
+ // (Previews the create path only; `--onto` reconcile-preview is not modeled.)
1096
+ if (opts.dryRun) {
1097
+ const s = opts.json ? null : ora(`Checking ${entry.name}…`).start();
1098
+ try {
1099
+ const report = (await hubPost("/packages/preflight", pkg, cfg, applyTimeoutMs));
1100
+ s?.stop();
1101
+ if (opts.json) {
1102
+ console.log(JSON.stringify({ slug, ...report }, null, 2));
1103
+ return;
1104
+ }
1105
+ renderPreflightReport(entry, report);
1106
+ return;
1107
+ }
1108
+ catch (e) {
1109
+ s?.stop();
1110
+ log.error(`${entry.name} preflight failed`);
1111
+ renderHubError(e);
446
1112
  process.exit(1);
447
1113
  }
448
- pkg = def;
449
1114
  }
450
- // Stamp the version the catalog resolved for this slug — including the
451
- // authed `/mine` version for private templates — so the pod records the
452
- // installed baseline and later `market update` drift checks work.
453
- const remoteVersion = cat.remoteVersionBySlug.get(slug);
454
- if (remoteVersion)
455
- pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
456
1115
  // Pre-apply state — was this slug already installed with NO version stamp?
457
1116
  // Feeds `classifyApplyResult`'s stamp-contradiction check below.
458
1117
  const preInstalled = installedTemplates.find((t) => t.slug === slug);
@@ -474,18 +1133,27 @@ export async function marketInstall(slug, opts) {
474
1133
  }
475
1134
  log.info(`${entry.name} — proposed (under review, not live yet).`);
476
1135
  if (proposalId)
477
- log.hint(`Approve: synap proposals approve ${proposalId.slice(0, 8)}`);
1136
+ log.hint(`Review and approve: synap open proposal ${proposalId}`);
478
1137
  renderNextSteps(steps);
479
1138
  return;
480
1139
  }
481
1140
  const ws = res.workspace;
482
1141
  const dependencies = res.dependencies;
483
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
+ }
484
1151
  const steps = FLOW.afterMarketInstall({ slug, proposed: false, projectName });
485
1152
  s?.stop();
486
- const verdict = classifyApplyResult(entry, ws, dependencies, seedSummary, {
1153
+ const verdict = classifyApplyResponse(entry, res, {
487
1154
  remoteVersionSent: remoteVersion,
488
1155
  wasUnstamped,
1156
+ stampVerified,
489
1157
  });
490
1158
  if (opts.json) {
491
1159
  console.log(JSON.stringify({
@@ -514,7 +1182,7 @@ export async function marketInstall(slug, opts) {
514
1182
  break;
515
1183
  case "installed-with-failures":
516
1184
  case "updated-with-failures":
517
- 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).`);
518
1186
  break;
519
1187
  case "unchanged":
520
1188
  log.info(`• ${entry.name} is already up to date on ${wsLabel}.`);
@@ -527,6 +1195,9 @@ export async function marketInstall(slug, opts) {
527
1195
  printApplyVerdict(entry, verdict);
528
1196
  }
529
1197
  printSeedOutcomes(dependencies, seedSummary);
1198
+ printLayerOutcomes(ws?.layers);
1199
+ for (const line of deferredPlacementLines(res))
1200
+ log.info(line);
530
1201
  if (ws?.outcome || ws?.status === "composed" || ws?.status === "created")
531
1202
  renderNextSteps(steps);
532
1203
  }
@@ -538,18 +1209,103 @@ export async function marketInstall(slug, opts) {
538
1209
  }
539
1210
  }
540
1211
  /**
541
- * Per-installed-slug drift check. Starts from the shared `computeTemplateUpdates`
542
- * (handles bundled-only / private-only slugs via `bundleVersion`), then
543
- * overrides `latestVersion`/`updateAvailable` with the CP row's OWN version for
544
- * any slug in `remoteVersionBySlug` — the merge-winner bypass this command
545
- * needs (see `MarketCatalog.remoteVersionBySlug`'s doc). A slug the pod never
546
- * 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".
547
1287
  */
548
1288
  export function computeUpdates(installedTemplates, cat) {
549
- const withVersion = installedTemplates.filter((t) => !!t.version);
550
- 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
+ : [];
551
1295
  const baseBySlug = new Map(base.map((b) => [b.slug, b]));
552
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) ──
553
1309
  if (!t.version) {
554
1310
  return {
555
1311
  slug: t.slug,
@@ -557,6 +1313,7 @@ export function computeUpdates(installedTemplates, cat) {
557
1313
  latestVersion: cat.remoteVersionBySlug.get(t.slug),
558
1314
  updateAvailable: false,
559
1315
  noVersionInfo: true,
1316
+ kind: t.kind,
560
1317
  };
561
1318
  }
562
1319
  const b = baseBySlug.get(t.slug);
@@ -567,18 +1324,24 @@ export function computeUpdates(installedTemplates, cat) {
567
1324
  latestVersion: remoteVersion,
568
1325
  updateAvailable: remoteVersion !== t.version,
569
1326
  noVersionInfo: false,
1327
+ kind: t.kind,
570
1328
  };
571
1329
  }
572
- return { ...b, noVersionInfo: false };
1330
+ return { ...b, noVersionInfo: false, kind: t.kind };
573
1331
  });
574
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
+ }
575
1338
  /**
576
1339
  * Apply one package via the SAME door `marketInstall` uses
577
1340
  * (`POST /packages/apply`) — for an already-installed slug this re-runs the
578
1341
  * pod's idempotent-create path, which reconciles when the pod build supports
579
1342
  * it (see the `applyOnePackage` caveat below for what "created" means here).
580
1343
  */
581
- async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS) {
1344
+ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS, extra = {}) {
582
1345
  if (cat.lockedSlugs.has(entry.slug)) {
583
1346
  const tier = cat.tierBySlug.get(entry.slug)?.requiredTier;
584
1347
  if (!opts.json)
@@ -586,27 +1349,19 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
586
1349
  return { slug: entry.slug, outcome: "locked", status: "locked", warnings: [] };
587
1350
  }
588
1351
  const cpToken = getStoredToken()?.token;
589
- let pkg;
590
- if (cat.bundledSlugs.has(entry.slug)) {
591
- pkg = toPackageDefinition(entry.slug);
592
- }
593
- else {
594
- const def = await fetchPackageDefinition(entry.slug, cpToken);
595
- if (!def) {
596
- if (!opts.json) {
597
- log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
598
- if (!cat.loggedIn)
599
- log.hint("It may be private — try: synap login");
600
- }
601
- 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");
602
1359
  }
603
- pkg = def;
1360
+ return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
604
1361
  }
605
- // Same version stamp as `marketInstall` — needed for private templates too,
606
- // since `applyOnePackage` re-runs the apply door on every update.
607
- const remoteVersion = cat.remoteVersionBySlug.get(entry.slug);
608
- if (remoteVersion)
609
- pkg._meta = { ...(pkg._meta ?? {}), version: remoteVersion };
1362
+ const pkg = extra.acceptDataChanges && extra.acceptDataChanges.length > 0
1363
+ ? { ...payload.pkg, acceptDataChanges: extra.acceptDataChanges }
1364
+ : payload.pkg;
610
1365
  const s = opts.json ? null : ora(`Updating ${entry.name}…`).start();
611
1366
  try {
612
1367
  const res = (await hubPost("/packages/apply", pkg, cfg, applyTimeoutMs));
@@ -616,7 +1371,7 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
616
1371
  if (!opts.json) {
617
1372
  log.info(`${entry.name} — update proposed (under review, not live yet).`);
618
1373
  if (proposalId)
619
- log.hint(`Approve: synap proposals approve ${proposalId.slice(0, 8)}`);
1374
+ log.hint(`Review and approve: synap open proposal ${proposalId}`);
620
1375
  }
621
1376
  return { slug: entry.slug, outcome: "proposed", status: "proposed", warnings: [] };
622
1377
  }
@@ -628,13 +1383,16 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
628
1383
  // which by construction means it already carried a recorded version. The
629
1384
  // stamp-contradiction check is therefore a no-op here by design; it's
630
1385
  // `marketInstall`'s job to catch it on a first-time/unstamped install.
631
- const verdict = classifyApplyResult(entry, ws, dependencies, seedSummary, {
632
- remoteVersionSent: remoteVersion,
1386
+ const verdict = classifyApplyResponse(entry, res, {
1387
+ remoteVersionSent: payload.catalogVersion,
633
1388
  wasUnstamped: false,
634
1389
  });
635
1390
  if (!opts.json) {
636
1391
  printApplyVerdict(entry, verdict);
637
1392
  printSeedOutcomes(dependencies, seedSummary);
1393
+ printLayerOutcomes(ws?.layers);
1394
+ for (const line of deferredPlacementLines(res))
1395
+ log.info(line);
638
1396
  }
639
1397
  return {
640
1398
  slug: entry.slug,
@@ -647,10 +1405,33 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
647
1405
  }
648
1406
  catch (e) {
649
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
+ }
650
1427
  const timedOut = e instanceof HubNetworkError && e.code === "TIMEOUT";
1428
+ const refusal = dataChangeRefusalMessage(e);
651
1429
  if (!opts.json) {
652
1430
  log.error(`${entry.name} update failed`);
653
- renderHubError(e);
1431
+ if (refusal)
1432
+ log.warn(refusal);
1433
+ else
1434
+ renderHubError(e);
654
1435
  }
655
1436
  return {
656
1437
  slug: entry.slug,
@@ -661,19 +1442,120 @@ async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIM
661
1442
  };
662
1443
  }
663
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
+ }
664
1510
  export async function marketUpdate(slugsArg, opts) {
665
1511
  const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
666
- 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;
667
1542
  if (installedTemplates.length === 0) {
668
1543
  if (opts.json) {
669
- console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn }, null, 2));
1544
+ console.log(JSON.stringify({ checks: [], loggedIn: cat.loggedIn, degraded, ...(degradedNote ? { degradedNote } : {}) }, null, 2));
670
1545
  return;
671
1546
  }
672
1547
  log.warn("No installed packages found on this pod.");
1548
+ if (degradedNote)
1549
+ log.dim(degradedNote);
673
1550
  log.hint("Install one: synap market install <slug>");
674
1551
  return;
675
1552
  }
676
- 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));
677
1559
  const entryBySlug = new Map(cat.entries.map((e) => [e.slug, e]));
678
1560
  const explicitSlugs = !!slugsArg && slugsArg.length > 0;
679
1561
  let unknownSlugs = [];
@@ -695,7 +1577,26 @@ export async function marketUpdate(slugsArg, opts) {
695
1577
  }
696
1578
  checks = checks.filter((c) => validSlugs.includes(c.slug));
697
1579
  }
698
- 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));
699
1600
  const noVersionInfo = checks.filter((c) => c.noVersionInfo);
700
1601
  // Private templates resolve only via the authed CP path — an update check
701
1602
  // that found NOTHING while logged out is ambiguous (could be genuinely
@@ -705,24 +1606,86 @@ export async function marketUpdate(slugsArg, opts) {
705
1606
  log.dim("Not logged in — private template updates aren't visible. Run: synap login");
706
1607
  }
707
1608
  // --dry-run is a NAMED alias for the "no --yes" preview path — forces the
708
- // preview even when --yes or explicit slugs would otherwise trigger apply.
709
- 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
+ }
710
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
+ }
711
1664
  log.heading(" Installed packages");
712
1665
  log.blank();
713
1666
  for (const c of checks) {
714
- const label = c.noVersionInfo
715
- ? noVersionLabel(entryBySlug.get(c.slug)?.isPrivate ?? false, cat.loggedIn)
716
- : c.updateAvailable
717
- ? chalk.yellow(`update available `) + chalk.dim(c.installedVersion) + " → " + c.latestVersion
718
- : 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);
719
1676
  console.log(" " + chalk.cyan(c.slug.padEnd(28)) + label);
720
1677
  }
721
1678
  log.blank();
722
1679
  if (noVersionInfo.length > 0) {
723
1680
  log.dim(`${noVersionInfo.length} installed without a version stamp — can't check ${noVersionInfo.length === 1 ? "it" : "them"} for updates.`);
724
1681
  }
1682
+ if (degradedNote)
1683
+ log.dim(degradedNote);
725
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]));
726
1689
  if (withUpdates.length === 0) {
727
1690
  // "Nothing to update" is only honest when every installed row was
728
1691
  // actually CHECKED. Rows the pod can't version-check are "couldn't be
@@ -736,6 +1699,7 @@ export async function marketUpdate(slugsArg, opts) {
736
1699
  applied: [],
737
1700
  status: noVersionInfo.length > 0 ? "partial" : "clean",
738
1701
  uncheckable: noVersionInfo.map((c) => c.slug),
1702
+ templateLifecycle: lifecycleJson,
739
1703
  }, null, 2));
740
1704
  }
741
1705
  else if (noVersionInfo.length > 0) {
@@ -744,44 +1708,118 @@ export async function marketUpdate(slugsArg, opts) {
744
1708
  chalk.yellow(`⚠ ${upToDate} up to date · ${noVersionInfo.length} couldn't be checked`));
745
1709
  log.hint("Run 'synap market installed' for why — unstamped install (reinstall to fix) or a private template while logged out (synap login).");
746
1710
  }
1711
+ else if (leaving.length > 0) {
1712
+ log.info("No updates to apply.");
1713
+ }
747
1714
  else {
748
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");
749
1721
  }
750
1722
  return;
751
1723
  }
752
1724
  if (!shouldApply) {
753
1725
  const steps = FLOW.afterMarketUpdateCheck(withUpdates.map((c) => c.slug));
754
1726
  if (opts.json) {
755
- 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));
756
1736
  return;
757
1737
  }
758
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
+ }
759
1742
  renderNextSteps(steps);
760
1743
  return;
761
1744
  }
762
- let cfg;
763
- try {
764
- cfg = await resolveHubConfig(opts);
765
- }
766
- catch (e) {
767
- renderHubError(e);
768
- process.exit(1);
769
- }
1745
+ const acceptList = parseAcceptList(opts.acceptDataChanges);
770
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;
771
1750
  for (const c of withUpdates) {
772
- 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);
773
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).
774
1767
  if (!opts.json)
775
- 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.`);
776
1769
  results.push({ slug: c.slug, outcome: "not-in-catalog", status: "not-in-catalog", warnings: [] });
777
1770
  continue;
778
1771
  }
779
- 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
+ }));
780
1807
  }
1808
+ if (results.some((r) => r.status === "needs-consent"))
1809
+ process.exitCode = 1;
781
1810
  const summary = summarizeApplyResults(results);
782
1811
  const steps = FLOW.afterMarketUpdateApply();
783
1812
  if (opts.json) {
784
- 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));
785
1823
  return;
786
1824
  }
787
1825
  log.blank();
@@ -800,12 +1838,14 @@ export async function marketUpdate(slugsArg, opts) {
800
1838
  * else (locked/proposed/fetch-failed/not-in-catalog/no-workspace/unknown)
801
1839
  * rolls into "failed".
802
1840
  */
803
- function summarizeApplyResults(results) {
1841
+ export function summarizeApplyResults(results) {
804
1842
  let updated = 0;
805
1843
  let unchanged = 0;
806
1844
  let withFailures = 0;
807
1845
  let timedOut = 0;
808
1846
  let failed = 0;
1847
+ let proposed = 0;
1848
+ let needsConsent = 0;
809
1849
  for (const r of results) {
810
1850
  switch (r.status) {
811
1851
  case "installed":
@@ -817,35 +1857,60 @@ function summarizeApplyResults(results) {
817
1857
  case "unchanged":
818
1858
  unchanged++;
819
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;
820
1868
  case "installed-with-failures":
821
1869
  case "updated-with-failures":
822
1870
  case "composed-with-failures":
1871
+ case "not-finished":
823
1872
  case "stamp-contradiction":
824
1873
  withFailures++;
825
1874
  break;
826
1875
  case "timed-out":
827
1876
  timedOut++;
828
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;
829
1883
  default:
830
1884
  failed++;
831
1885
  }
832
1886
  }
833
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).
834
1892
  if (withFailures > 0)
835
- parts.push(`${withFailures} completed with failures`);
1893
+ parts.push(`${withFailures} ${templateStepPhrase("unfinished")}`);
836
1894
  if (failed > 0)
837
1895
  parts.push(`${failed} failed`);
838
1896
  if (timedOut > 0)
839
1897
  parts.push(`${timedOut} timed out`);
840
- const clean = withFailures === 0 && failed === 0 && timedOut === 0;
841
- 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;
842
1905
  return {
843
1906
  updated,
844
1907
  unchanged,
845
1908
  withFailures,
846
1909
  failed,
847
1910
  timedOut,
848
- line: color(`${clean ? "✓" : "⚠"} ${parts.join(" · ")}`),
1911
+ proposed,
1912
+ needsConsent,
1913
+ line: color(`${marker} ${parts.join(" · ")}`),
849
1914
  };
850
1915
  }
851
1916
  // ── `synap market installed [--json] [--outdated]` — pure-read inventory ────
@@ -862,21 +1927,33 @@ function truncateVersion(v) {
862
1927
  function noVersionLabel(isPrivate, loggedIn) {
863
1928
  return isPrivate && !loggedIn
864
1929
  ? chalk.dim("connect to the Control Plane to check/update — run: synap login")
865
- : chalk.dim("version unknown — reinstall to enable update checks");
1930
+ : chalk.dim("version unknown — run `synap market attach <workspace>` to stamp it");
866
1931
  }
867
1932
  /**
868
1933
  * Human status cell for one installed row — distinguishes hash drift (no
869
1934
  * ordering, just "changed") from semver drift ("update available").
870
1935
  */
871
- 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}`);
872
1942
  if (c.noVersionInfo)
873
1943
  return noVersionLabel(isPrivate, loggedIn);
874
- if (!c.updateAvailable)
875
- 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
+ }
876
1953
  const installed = truncateVersion(c.installedVersion);
877
1954
  const latest = c.latestVersion ? truncateVersion(c.latestVersion) : "?";
878
1955
  const hashPair = isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? "");
879
- const label = hashPair ? "content changed" : "update available";
1956
+ const label = hashPair ? "content changed" : templateStepPhrase("update");
880
1957
  return chalk.yellow(`${label} `) + chalk.dim(installed) + " → " + latest;
881
1958
  }
882
1959
  // ── Composition engine — the ONE traversal door ─────────────────────────────
@@ -935,9 +2012,21 @@ async function buildEdgesOf(seeds, cat, cpToken) {
935
2012
  // safe either way since it's the same bundled-only lookup as the default.
936
2013
  return (slug) => cache.get(slug) ?? bundledEdgesOf(slug);
937
2014
  }
938
- /** `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
+ */
939
2026
  function toInstalledEntries(installedTemplates) {
940
- return installedTemplates.map((t) => ({
2027
+ return installedTemplates
2028
+ .filter((t) => t.workspaceId != null)
2029
+ .map((t) => ({
941
2030
  slug: t.slug,
942
2031
  workspaceId: t.workspaceId,
943
2032
  workspaceName: t.workspaceName,
@@ -1009,7 +2098,7 @@ export async function buildCompositionTrees(rows, cat, installedTemplates, works
1009
2098
  relation: edge.relation,
1010
2099
  depKind: edge.depKind,
1011
2100
  installed: !!installedInfo,
1012
- workspaceId: installedInfo?.workspaceId,
2101
+ workspaceId: installedInfo?.workspaceId ?? undefined,
1013
2102
  workspaceName: installedInfo?.workspaceName,
1014
2103
  installedVersion: check?.installedVersion,
1015
2104
  latestVersion: check?.latestVersion ?? null,
@@ -1044,10 +2133,10 @@ function nodeStatusLabel(node) {
1044
2133
  if (node.noVersionInfo)
1045
2134
  return chalk.dim("can't check");
1046
2135
  if (!node.updateAvailable)
1047
- return chalk.green("up to date");
2136
+ return chalk.green(templateStepPhrase("current"));
1048
2137
  const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
1049
2138
  const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
1050
- return chalk.yellow("update available ") + chalk.dim(installed) + " → " + latest;
2139
+ return chalk.yellow(`${templateStepPhrase("update")} `) + chalk.dim(installed) + " → " + latest;
1051
2140
  }
1052
2141
  function printDependencyNode(node, prefix, isLast) {
1053
2142
  const branch = isLast ? "└─ " : "├─ ";
@@ -1077,8 +2166,8 @@ export function printCompositionTree(pkg) {
1077
2166
  (pkg.noVersionInfo
1078
2167
  ? chalk.dim("can't check — reinstall")
1079
2168
  : pkg.updateAvailable
1080
- ? chalk.yellow("update available")
1081
- : chalk.green("up to date")) +
2169
+ ? chalk.yellow(templateStepPhrase("update"))
2170
+ : chalk.green(templateStepPhrase("current"))) +
1082
2171
  suiteTag);
1083
2172
  pkg.children.forEach((child, i) => printDependencyNode(child, "", i === pkg.children.length - 1));
1084
2173
  log.blank();
@@ -1141,10 +2230,10 @@ function layerNodeStatus(node, isPrivate, loggedIn) {
1141
2230
  if (node.noVersionInfo)
1142
2231
  return noVersionLabel(isPrivate, loggedIn);
1143
2232
  if (!node.updateAvailable)
1144
- return chalk.green("up to date");
2233
+ return chalk.green(templateStepPhrase("current"));
1145
2234
  const installed = node.installedVersion ? truncateVersion(node.installedVersion) : "?";
1146
2235
  const latest = node.latestVersion ? truncateVersion(node.latestVersion) : "?";
1147
- return chalk.yellow("update available ") + chalk.dim(installed) + " → " + latest;
2236
+ return chalk.yellow(`${templateStepPhrase("update")} `) + chalk.dim(installed) + " → " + latest;
1148
2237
  }
1149
2238
  /**
1150
2239
  * Render the layered DAG bottom-up: bedrock (the highest layer index, most
@@ -1204,25 +2293,90 @@ function printLayersGraph(graph, cat) {
1204
2293
  * (`market update`'s own drift check), never writes anything. Pairs with
1205
2294
  * `market update` to apply what this surfaces.
1206
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
+ }
1207
2340
  export async function marketInstalled(opts) {
1208
- const [installedTemplates, cat] = await Promise.all([fetchInstalledTemplates(), buildMarketCatalog()]);
1209
- 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) {
1210
2354
  if (opts.json) {
1211
- 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));
1212
2356
  return;
1213
2357
  }
1214
2358
  log.warn("No installed packages found on this pod.");
2359
+ if (degradedNote)
2360
+ log.dim(degradedNote);
1215
2361
  log.hint("Browse what's available: synap market --list");
1216
2362
  return;
1217
2363
  }
1218
2364
  const checks = computeUpdates(installedTemplates, cat);
1219
2365
  // Multiple workspaces can install the same slug (a template composed onto
1220
- // several bases, or the same additive pack in >1 workspace) — one row per
1221
- // DISTINCT slug, with `workspaceCount` carrying the fan-out.
1222
- 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();
1223
2371
  for (const t of installedTemplates) {
1224
- 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");
1225
2377
  }
2378
+ const workspaceCountBySlug = new Map([...placementsBySlug].map(([slug, set]) => [slug, set.size]));
2379
+ const placementLabel = (slug) => fanOutLabel(placementsBySlug.get(slug) ?? new Set());
1226
2380
  const seen = new Set();
1227
2381
  let rows = checks.filter((c) => {
1228
2382
  if (seen.has(c.slug))
@@ -1243,6 +2397,7 @@ export async function marketInstalled(opts) {
1243
2397
  layers: graph.layers.map((bucket) => bucket.map((n) => n.slug)),
1244
2398
  cycles: graph.cycles,
1245
2399
  loggedIn: cat.loggedIn,
2400
+ degraded: inv.degraded,
1246
2401
  nextSteps: steps,
1247
2402
  }, null, 2));
1248
2403
  return;
@@ -1252,6 +2407,8 @@ export async function marketInstalled(opts) {
1252
2407
  return;
1253
2408
  }
1254
2409
  printLayersGraph(graph, cat);
2410
+ if (degradedNote)
2411
+ log.dim(degradedNote);
1255
2412
  if (!cat.loggedIn)
1256
2413
  log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1257
2414
  renderNextSteps(steps);
@@ -1277,6 +2434,7 @@ export async function marketInstalled(opts) {
1277
2434
  };
1278
2435
  }),
1279
2436
  loggedIn: cat.loggedIn,
2437
+ degraded: inv.degraded,
1280
2438
  nextSteps: steps,
1281
2439
  }, null, 2));
1282
2440
  return;
@@ -1299,7 +2457,6 @@ export async function marketInstalled(opts) {
1299
2457
  const entry = entryBySlug.get(c.slug);
1300
2458
  const name = entry?.name ?? c.slug;
1301
2459
  const priv = entry?.isPrivate ? chalk.yellow("private") : "";
1302
- const wsCount = workspaceCountBySlug.get(c.slug) ?? 0;
1303
2460
  console.log(" " +
1304
2461
  chalk.bold(name.padEnd(nameW)) +
1305
2462
  chalk.cyan(c.slug.padEnd(slugW)) +
@@ -1307,10 +2464,12 @@ export async function marketInstalled(opts) {
1307
2464
  " " +
1308
2465
  priv +
1309
2466
  (priv ? " " : "") +
1310
- chalk.dim(`${wsCount} workspace${wsCount === 1 ? "" : "s"}`));
2467
+ chalk.dim(placementLabel(c.slug)));
1311
2468
  }
1312
2469
  log.blank();
1313
2470
  }
2471
+ if (degradedNote)
2472
+ log.dim(degradedNote);
1314
2473
  if (!cat.loggedIn)
1315
2474
  log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1316
2475
  renderNextSteps(steps);
@@ -1323,6 +2482,9 @@ export async function marketInstalled(opts) {
1323
2482
  const entry = entryBySlug.get(c.slug);
1324
2483
  return {
1325
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",
1326
2488
  name: entry?.name ?? c.slug,
1327
2489
  installedVersion: c.installedVersion,
1328
2490
  latestVersion: c.latestVersion ?? null,
@@ -1331,38 +2493,807 @@ export async function marketInstalled(opts) {
1331
2493
  isPrivate: entry?.isPrivate ?? false,
1332
2494
  isHashVersion: isHashVersion(c.installedVersion) || isHashVersion(c.latestVersion ?? ""),
1333
2495
  workspaceCount: workspaceCountBySlug.get(c.slug) ?? 0,
2496
+ placement: placementLabel(c.slug),
1334
2497
  };
1335
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
+ })),
1336
2511
  loggedIn: cat.loggedIn,
2512
+ degraded: inv.degraded,
2513
+ ...(degradedNote ? { degradedNote } : {}),
1337
2514
  nextSteps: steps,
1338
2515
  }, null, 2));
1339
2516
  return;
1340
2517
  }
1341
- if (rows.length === 0) {
2518
+ if (rows.length === 0 && (opts.outdated || unlinked.length === 0)) {
1342
2519
  log.success(opts.outdated ? "Nothing outdated." : "Nothing installed.");
2520
+ if (degradedNote)
2521
+ log.dim(degradedNote);
1343
2522
  return;
1344
2523
  }
1345
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");
1346
2701
  log.blank();
1347
- const nameW = Math.max(4, ...rows.map((c) => (entryBySlug.get(c.slug)?.name ?? c.slug).length)) + 2;
1348
- const slugW = Math.max(4, ...rows.map((c) => c.slug.length)) + 2;
1349
- for (const c of rows) {
1350
- const entry = entryBySlug.get(c.slug);
1351
- const name = entry?.name ?? c.slug;
1352
- const priv = entry?.isPrivate ? chalk.yellow("private") : "";
1353
- 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("—");
1354
2705
  console.log(" " +
1355
- chalk.bold(name.padEnd(nameW)) +
1356
- chalk.cyan(c.slug.padEnd(slugW)) +
1357
- installedStatusLabel(c, entry?.isPrivate ?? false, cat.loggedIn) +
2706
+ chalk.bold(ws.workspaceName.padEnd(nameW)) +
2707
+ chalk.dim(ws.workspaceId) +
1358
2708
  " " +
1359
- priv +
1360
- (priv ? " " : "") +
1361
- chalk.dim(`${wsCount} workspace${wsCount === 1 ? "" : "s"}`));
2709
+ domain +
2710
+ "\n " +
2711
+ attachmentStatusLabel(status));
1362
2712
  }
1363
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
+ }
1364
2719
  if (!cat.loggedIn)
1365
2720
  log.dim("Not logged in — private template updates aren't visible. Run: synap login");
1366
- 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
+ });
1367
3298
  }
1368
3299
  //# sourceMappingURL=market.js.map