@productbrain/cli 0.1.0-beta.95 → 0.1.0-beta.958

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 (419) hide show
  1. package/dist/__tests__/audit.test.js +5 -0
  2. package/dist/__tests__/audit.test.js.map +1 -1
  3. package/dist/__tests__/authority-domains.test.js +3 -0
  4. package/dist/__tests__/authority-domains.test.js.map +1 -1
  5. package/dist/__tests__/canonicalRefs.vocab.test.d.ts +2 -0
  6. package/dist/__tests__/canonicalRefs.vocab.test.d.ts.map +1 -0
  7. package/dist/__tests__/canonicalRefs.vocab.test.js +251 -0
  8. package/dist/__tests__/canonicalRefs.vocab.test.js.map +1 -0
  9. package/dist/__tests__/config.test.js +272 -2
  10. package/dist/__tests__/config.test.js.map +1 -1
  11. package/dist/__tests__/constants.test.js +6 -1
  12. package/dist/__tests__/constants.test.js.map +1 -1
  13. package/dist/__tests__/envelope-contract.test.js +29 -3
  14. package/dist/__tests__/envelope-contract.test.js.map +1 -1
  15. package/dist/__tests__/errors.test.js +1 -0
  16. package/dist/__tests__/errors.test.js.map +1 -1
  17. package/dist/__tests__/handshake-augment.test.d.ts +2 -0
  18. package/dist/__tests__/handshake-augment.test.d.ts.map +1 -0
  19. package/dist/__tests__/handshake-augment.test.js +423 -0
  20. package/dist/__tests__/handshake-augment.test.js.map +1 -0
  21. package/dist/__tests__/handshake-dormancy.test.d.ts +2 -0
  22. package/dist/__tests__/handshake-dormancy.test.d.ts.map +1 -0
  23. package/dist/__tests__/handshake-dormancy.test.js +207 -0
  24. package/dist/__tests__/handshake-dormancy.test.js.map +1 -0
  25. package/dist/__tests__/handshake-formatter.test.d.ts +2 -0
  26. package/dist/__tests__/handshake-formatter.test.d.ts.map +1 -0
  27. package/dist/__tests__/handshake-formatter.test.js +67 -0
  28. package/dist/__tests__/handshake-formatter.test.js.map +1 -0
  29. package/dist/__tests__/handshake-preview.test.js +566 -4
  30. package/dist/__tests__/handshake-preview.test.js.map +1 -1
  31. package/dist/__tests__/handshake.e2e.test.d.ts +2 -0
  32. package/dist/__tests__/handshake.e2e.test.d.ts.map +1 -0
  33. package/dist/__tests__/handshake.e2e.test.js +1252 -0
  34. package/dist/__tests__/handshake.e2e.test.js.map +1 -0
  35. package/dist/__tests__/handshake.test.js +611 -2
  36. package/dist/__tests__/handshake.test.js.map +1 -1
  37. package/dist/__tests__/manifest.test.js +118 -1
  38. package/dist/__tests__/manifest.test.js.map +1 -1
  39. package/dist/__tests__/onboarding-path-b.test.js +4 -4
  40. package/dist/__tests__/onboarding-path-b.test.js.map +1 -1
  41. package/dist/__tests__/orient.test.js +184 -7
  42. package/dist/__tests__/orient.test.js.map +1 -1
  43. package/dist/__tests__/perimeter.test.d.ts +2 -0
  44. package/dist/__tests__/perimeter.test.d.ts.map +1 -0
  45. package/dist/__tests__/perimeter.test.js +165 -0
  46. package/dist/__tests__/perimeter.test.js.map +1 -0
  47. package/dist/__tests__/personal-layer.test.d.ts +1 -2
  48. package/dist/__tests__/personal-layer.test.d.ts.map +1 -1
  49. package/dist/__tests__/personal-layer.test.js +12 -48
  50. package/dist/__tests__/personal-layer.test.js.map +1 -1
  51. package/dist/__tests__/profiles.test.js +122 -7
  52. package/dist/__tests__/profiles.test.js.map +1 -1
  53. package/dist/__tests__/promote.test.js +71 -2
  54. package/dist/__tests__/promote.test.js.map +1 -1
  55. package/dist/__tests__/session-state-machine.test.js +45 -1
  56. package/dist/__tests__/session-state-machine.test.js.map +1 -1
  57. package/dist/__tests__/session-switch.test.d.ts +2 -0
  58. package/dist/__tests__/session-switch.test.d.ts.map +1 -0
  59. package/dist/__tests__/session-switch.test.js +129 -0
  60. package/dist/__tests__/session-switch.test.js.map +1 -0
  61. package/dist/__tests__/setup-ingest.test.js +16 -0
  62. package/dist/__tests__/setup-ingest.test.js.map +1 -1
  63. package/dist/__tests__/skill-vocabulary.test.d.ts +21 -0
  64. package/dist/__tests__/skill-vocabulary.test.d.ts.map +1 -0
  65. package/dist/__tests__/skill-vocabulary.test.js +187 -0
  66. package/dist/__tests__/skill-vocabulary.test.js.map +1 -0
  67. package/dist/__tests__/update-check.test.d.ts +2 -0
  68. package/dist/__tests__/update-check.test.d.ts.map +1 -0
  69. package/dist/__tests__/update-check.test.js +56 -0
  70. package/dist/__tests__/update-check.test.js.map +1 -0
  71. package/dist/__tests__/upgrade-runner.test.d.ts +2 -0
  72. package/dist/__tests__/upgrade-runner.test.d.ts.map +1 -0
  73. package/dist/__tests__/upgrade-runner.test.js +42 -0
  74. package/dist/__tests__/upgrade-runner.test.js.map +1 -0
  75. package/dist/__tests__/vocabulary-leak.test.d.ts +39 -0
  76. package/dist/__tests__/vocabulary-leak.test.d.ts.map +1 -0
  77. package/dist/__tests__/vocabulary-leak.test.js +534 -0
  78. package/dist/__tests__/vocabulary-leak.test.js.map +1 -0
  79. package/dist/__tests__/workspace.test.js +32 -12
  80. package/dist/__tests__/workspace.test.js.map +1 -1
  81. package/dist/commands/__tests__/connect-handoff.test.d.ts +11 -0
  82. package/dist/commands/__tests__/connect-handoff.test.d.ts.map +1 -0
  83. package/dist/commands/__tests__/connect-handoff.test.js +111 -0
  84. package/dist/commands/__tests__/connect-handoff.test.js.map +1 -0
  85. package/dist/commands/__tests__/setup-detect-surfaces.test.d.ts +15 -0
  86. package/dist/commands/__tests__/setup-detect-surfaces.test.d.ts.map +1 -0
  87. package/dist/commands/__tests__/setup-detect-surfaces.test.js +149 -0
  88. package/dist/commands/__tests__/setup-detect-surfaces.test.js.map +1 -0
  89. package/dist/commands/__tests__/setup-state.test.d.ts +2 -0
  90. package/dist/commands/__tests__/setup-state.test.d.ts.map +1 -0
  91. package/dist/commands/__tests__/setup-state.test.js +194 -0
  92. package/dist/commands/__tests__/setup-state.test.js.map +1 -0
  93. package/dist/commands/admin/seed.d.ts +46 -2
  94. package/dist/commands/admin/seed.d.ts.map +1 -1
  95. package/dist/commands/admin/seed.js +475 -33
  96. package/dist/commands/admin/seed.js.map +1 -1
  97. package/dist/commands/admin/seed.test.d.ts +5 -0
  98. package/dist/commands/admin/seed.test.d.ts.map +1 -1
  99. package/dist/commands/admin/seed.test.js +67 -2
  100. package/dist/commands/admin/seed.test.js.map +1 -1
  101. package/dist/commands/admin/seedRegistryEntries.generated.d.ts +14 -0
  102. package/dist/commands/admin/seedRegistryEntries.generated.d.ts.map +1 -0
  103. package/dist/commands/admin/seedRegistryEntries.generated.js +117 -0
  104. package/dist/commands/admin/seedRegistryEntries.generated.js.map +1 -0
  105. package/dist/commands/admin/seedRegistryEntries.test.d.ts +11 -0
  106. package/dist/commands/admin/seedRegistryEntries.test.d.ts.map +1 -0
  107. package/dist/commands/admin/seedRegistryEntries.test.js +67 -0
  108. package/dist/commands/admin/seedRegistryEntries.test.js.map +1 -0
  109. package/dist/commands/audit.d.ts.map +1 -1
  110. package/dist/commands/audit.js +30 -3
  111. package/dist/commands/audit.js.map +1 -1
  112. package/dist/commands/authority-domains.d.ts +25 -1
  113. package/dist/commands/authority-domains.d.ts.map +1 -1
  114. package/dist/commands/authority-domains.js +51 -4
  115. package/dist/commands/authority-domains.js.map +1 -1
  116. package/dist/commands/capture.d.ts.map +1 -1
  117. package/dist/commands/capture.js +3 -2
  118. package/dist/commands/capture.js.map +1 -1
  119. package/dist/commands/codex-prep.d.ts +1 -0
  120. package/dist/commands/codex-prep.d.ts.map +1 -1
  121. package/dist/commands/codex-prep.js +10 -7
  122. package/dist/commands/codex-prep.js.map +1 -1
  123. package/dist/commands/connect-config.test.d.ts +2 -0
  124. package/dist/commands/connect-config.test.d.ts.map +1 -0
  125. package/dist/commands/connect-config.test.js +44 -0
  126. package/dist/commands/connect-config.test.js.map +1 -0
  127. package/dist/commands/connect-context.d.ts +45 -0
  128. package/dist/commands/connect-context.d.ts.map +1 -0
  129. package/dist/commands/connect-context.js +64 -0
  130. package/dist/commands/connect-context.js.map +1 -0
  131. package/dist/commands/connect-context.test.d.ts +2 -0
  132. package/dist/commands/connect-context.test.d.ts.map +1 -0
  133. package/dist/commands/connect-context.test.js +110 -0
  134. package/dist/commands/connect-context.test.js.map +1 -0
  135. package/dist/commands/connect-handoff.d.ts +51 -0
  136. package/dist/commands/connect-handoff.d.ts.map +1 -0
  137. package/dist/commands/connect-handoff.js +70 -0
  138. package/dist/commands/connect-handoff.js.map +1 -0
  139. package/dist/commands/connect-integration.test.js +29 -12
  140. package/dist/commands/connect-integration.test.js.map +1 -1
  141. package/dist/commands/connect-screens.d.ts +6 -4
  142. package/dist/commands/connect-screens.d.ts.map +1 -1
  143. package/dist/commands/connect-screens.js +30 -19
  144. package/dist/commands/connect-screens.js.map +1 -1
  145. package/dist/commands/connect.d.ts +21 -6
  146. package/dist/commands/connect.d.ts.map +1 -1
  147. package/dist/commands/connect.js +65 -58
  148. package/dist/commands/connect.js.map +1 -1
  149. package/dist/commands/connect.test.js +17 -1
  150. package/dist/commands/connect.test.js.map +1 -1
  151. package/dist/commands/doctor.d.ts.map +1 -1
  152. package/dist/commands/doctor.js +68 -3
  153. package/dist/commands/doctor.js.map +1 -1
  154. package/dist/commands/doctor.test.js +131 -0
  155. package/dist/commands/doctor.test.js.map +1 -1
  156. package/dist/commands/handshake.d.ts +194 -2
  157. package/dist/commands/handshake.d.ts.map +1 -1
  158. package/dist/commands/handshake.js +1563 -54
  159. package/dist/commands/handshake.js.map +1 -1
  160. package/dist/commands/method.d.ts.map +1 -1
  161. package/dist/commands/method.js +3 -0
  162. package/dist/commands/method.js.map +1 -1
  163. package/dist/commands/orient.d.ts +52 -2
  164. package/dist/commands/orient.d.ts.map +1 -1
  165. package/dist/commands/orient.js +106 -4
  166. package/dist/commands/orient.js.map +1 -1
  167. package/dist/commands/profile.d.ts +1 -14
  168. package/dist/commands/profile.d.ts.map +1 -1
  169. package/dist/commands/profile.js +89 -72
  170. package/dist/commands/profile.js.map +1 -1
  171. package/dist/commands/promote.d.ts.map +1 -1
  172. package/dist/commands/promote.js +25 -2
  173. package/dist/commands/promote.js.map +1 -1
  174. package/dist/commands/relate.d.ts.map +1 -1
  175. package/dist/commands/relate.js +13 -0
  176. package/dist/commands/relate.js.map +1 -1
  177. package/dist/commands/session.d.ts.map +1 -1
  178. package/dist/commands/session.js +51 -14
  179. package/dist/commands/session.js.map +1 -1
  180. package/dist/commands/setup-audit.d.ts +59 -0
  181. package/dist/commands/setup-audit.d.ts.map +1 -0
  182. package/dist/commands/setup-audit.js +250 -0
  183. package/dist/commands/setup-audit.js.map +1 -0
  184. package/dist/commands/setup-detect-surfaces.d.ts +38 -0
  185. package/dist/commands/setup-detect-surfaces.d.ts.map +1 -0
  186. package/dist/commands/setup-detect-surfaces.js +76 -0
  187. package/dist/commands/setup-detect-surfaces.js.map +1 -0
  188. package/dist/commands/setup-ingest.d.ts.map +1 -1
  189. package/dist/commands/setup-ingest.js +4 -2
  190. package/dist/commands/setup-ingest.js.map +1 -1
  191. package/dist/commands/setup-state.d.ts +42 -0
  192. package/dist/commands/setup-state.d.ts.map +1 -0
  193. package/dist/commands/setup-state.js +93 -0
  194. package/dist/commands/setup-state.js.map +1 -0
  195. package/dist/commands/setup.d.ts +17 -9
  196. package/dist/commands/setup.d.ts.map +1 -1
  197. package/dist/commands/setup.js +52 -131
  198. package/dist/commands/setup.js.map +1 -1
  199. package/dist/commands/upgrade.d.ts +5 -0
  200. package/dist/commands/upgrade.d.ts.map +1 -0
  201. package/dist/commands/upgrade.js +89 -0
  202. package/dist/commands/upgrade.js.map +1 -0
  203. package/dist/commands/whoami.d.ts +12 -0
  204. package/dist/commands/whoami.d.ts.map +1 -0
  205. package/dist/commands/whoami.js +70 -0
  206. package/dist/commands/whoami.js.map +1 -0
  207. package/dist/commands/whoami.test.d.ts +2 -0
  208. package/dist/commands/whoami.test.d.ts.map +1 -0
  209. package/dist/commands/whoami.test.js +50 -0
  210. package/dist/commands/whoami.test.js.map +1 -0
  211. package/dist/commands/workspace.d.ts +23 -2
  212. package/dist/commands/workspace.d.ts.map +1 -1
  213. package/dist/commands/workspace.js +2 -2
  214. package/dist/commands/workspace.js.map +1 -1
  215. package/dist/formatters/__tests__/orient-provenance.test.d.ts +7 -0
  216. package/dist/formatters/__tests__/orient-provenance.test.d.ts.map +1 -0
  217. package/dist/formatters/__tests__/orient-provenance.test.js +227 -0
  218. package/dist/formatters/__tests__/orient-provenance.test.js.map +1 -0
  219. package/dist/formatters/audit.d.ts +6 -0
  220. package/dist/formatters/audit.d.ts.map +1 -1
  221. package/dist/formatters/audit.js.map +1 -1
  222. package/dist/formatters/entry.d.ts +6 -0
  223. package/dist/formatters/entry.d.ts.map +1 -1
  224. package/dist/formatters/entry.js +30 -5
  225. package/dist/formatters/entry.js.map +1 -1
  226. package/dist/formatters/handshake.d.ts +19 -3
  227. package/dist/formatters/handshake.d.ts.map +1 -1
  228. package/dist/formatters/handshake.js +48 -13
  229. package/dist/formatters/handshake.js.map +1 -1
  230. package/dist/formatters/orient.d.ts +68 -4
  231. package/dist/formatters/orient.d.ts.map +1 -1
  232. package/dist/formatters/orient.js +97 -17
  233. package/dist/formatters/orient.js.map +1 -1
  234. package/dist/formatters/session.js +1 -1
  235. package/dist/formatters/session.js.map +1 -1
  236. package/dist/generators/adapters.js +2 -2
  237. package/dist/generators/boundary-manifest.d.ts +29 -0
  238. package/dist/generators/boundary-manifest.d.ts.map +1 -0
  239. package/dist/generators/boundary-manifest.js +183 -0
  240. package/dist/generators/boundary-manifest.js.map +1 -0
  241. package/dist/generators/boundary-manifest.test.d.ts +2 -0
  242. package/dist/generators/boundary-manifest.test.d.ts.map +1 -0
  243. package/dist/generators/boundary-manifest.test.js +91 -0
  244. package/dist/generators/boundary-manifest.test.js.map +1 -0
  245. package/dist/generators/context-md.js +6 -6
  246. package/dist/generators/context-md.js.map +1 -1
  247. package/dist/generators/manifest.d.ts +78 -0
  248. package/dist/generators/manifest.d.ts.map +1 -1
  249. package/dist/generators/manifest.js +125 -14
  250. package/dist/generators/manifest.js.map +1 -1
  251. package/dist/generators/portable-knowledge.d.ts +6 -12
  252. package/dist/generators/portable-knowledge.d.ts.map +1 -1
  253. package/dist/generators/portable-knowledge.js +2 -19
  254. package/dist/generators/portable-knowledge.js.map +1 -1
  255. package/dist/generators/region-projections.d.ts +18 -0
  256. package/dist/generators/region-projections.d.ts.map +1 -0
  257. package/dist/generators/region-projections.js +49 -0
  258. package/dist/generators/region-projections.js.map +1 -0
  259. package/dist/generators/region-projections.test.d.ts +2 -0
  260. package/dist/generators/region-projections.test.d.ts.map +1 -0
  261. package/dist/generators/region-projections.test.js +63 -0
  262. package/dist/generators/region-projections.test.js.map +1 -0
  263. package/dist/generators/region.d.ts +24 -0
  264. package/dist/generators/region.d.ts.map +1 -0
  265. package/dist/generators/region.js +87 -0
  266. package/dist/generators/region.js.map +1 -0
  267. package/dist/generators/region.test.d.ts +2 -0
  268. package/dist/generators/region.test.d.ts.map +1 -0
  269. package/dist/generators/region.test.js +126 -0
  270. package/dist/generators/region.test.js.map +1 -0
  271. package/dist/generators/surface-profiles.d.ts +1 -2
  272. package/dist/generators/surface-profiles.d.ts.map +1 -1
  273. package/dist/generators/surface-profiles.js.map +1 -1
  274. package/dist/index.js +142 -27
  275. package/dist/index.js.map +1 -1
  276. package/dist/lib/activation.d.ts.map +1 -1
  277. package/dist/lib/activation.js +3 -3
  278. package/dist/lib/activation.js.map +1 -1
  279. package/dist/lib/activation.test.js +3 -3
  280. package/dist/lib/activation.test.js.map +1 -1
  281. package/dist/lib/canonicalRefs.d.ts +98 -0
  282. package/dist/lib/canonicalRefs.d.ts.map +1 -1
  283. package/dist/lib/canonicalRefs.js +67 -0
  284. package/dist/lib/canonicalRefs.js.map +1 -1
  285. package/dist/lib/client.d.ts.map +1 -1
  286. package/dist/lib/client.js +14 -4
  287. package/dist/lib/client.js.map +1 -1
  288. package/dist/lib/config.d.ts +70 -4
  289. package/dist/lib/config.d.ts.map +1 -1
  290. package/dist/lib/config.js +151 -11
  291. package/dist/lib/config.js.map +1 -1
  292. package/dist/lib/connectKeyLabel.d.ts +9 -0
  293. package/dist/lib/connectKeyLabel.d.ts.map +1 -0
  294. package/dist/lib/connectKeyLabel.js +12 -0
  295. package/dist/lib/connectKeyLabel.js.map +1 -0
  296. package/dist/lib/constants.d.ts +2 -0
  297. package/dist/lib/constants.d.ts.map +1 -1
  298. package/dist/lib/constants.js +2 -0
  299. package/dist/lib/constants.js.map +1 -1
  300. package/dist/lib/errors.d.ts +3 -0
  301. package/dist/lib/errors.d.ts.map +1 -1
  302. package/dist/lib/errors.js +3 -0
  303. package/dist/lib/errors.js.map +1 -1
  304. package/dist/lib/normalizeMaterializedFilename.d.ts +28 -0
  305. package/dist/lib/normalizeMaterializedFilename.d.ts.map +1 -0
  306. package/dist/lib/normalizeMaterializedFilename.js +56 -0
  307. package/dist/lib/normalizeMaterializedFilename.js.map +1 -0
  308. package/dist/lib/normalizeMaterializedFilename.test.d.ts +16 -0
  309. package/dist/lib/normalizeMaterializedFilename.test.d.ts.map +1 -0
  310. package/dist/lib/normalizeMaterializedFilename.test.js +90 -0
  311. package/dist/lib/normalizeMaterializedFilename.test.js.map +1 -0
  312. package/dist/lib/onboarding-path-b.d.ts.map +1 -1
  313. package/dist/lib/onboarding-path-b.js +0 -1
  314. package/dist/lib/onboarding-path-b.js.map +1 -1
  315. package/dist/lib/onboarding-shared.d.ts +0 -1
  316. package/dist/lib/onboarding-shared.d.ts.map +1 -1
  317. package/dist/lib/onboarding-shared.js +1 -17
  318. package/dist/lib/onboarding-shared.js.map +1 -1
  319. package/dist/lib/profiles.d.ts +3 -1
  320. package/dist/lib/profiles.d.ts.map +1 -1
  321. package/dist/lib/profiles.js +9 -6
  322. package/dist/lib/profiles.js.map +1 -1
  323. package/dist/lib/session.d.ts +10 -0
  324. package/dist/lib/session.d.ts.map +1 -1
  325. package/dist/lib/session.js +14 -0
  326. package/dist/lib/session.js.map +1 -1
  327. package/dist/lib/update-check.d.ts +20 -0
  328. package/dist/lib/update-check.d.ts.map +1 -1
  329. package/dist/lib/update-check.js +122 -21
  330. package/dist/lib/update-check.js.map +1 -1
  331. package/dist/lib/upgrade-runner.d.ts +21 -0
  332. package/dist/lib/upgrade-runner.d.ts.map +1 -0
  333. package/dist/lib/upgrade-runner.js +109 -0
  334. package/dist/lib/upgrade-runner.js.map +1 -0
  335. package/dist/lib/workspaceVocabCache.d.ts +60 -0
  336. package/dist/lib/workspaceVocabCache.d.ts.map +1 -0
  337. package/dist/lib/workspaceVocabCache.js +98 -0
  338. package/dist/lib/workspaceVocabCache.js.map +1 -0
  339. package/dist/setup/__tests__/coach-traces.test.d.ts +2 -0
  340. package/dist/setup/__tests__/coach-traces.test.d.ts.map +1 -0
  341. package/dist/setup/__tests__/coach-traces.test.js +189 -0
  342. package/dist/setup/__tests__/coach-traces.test.js.map +1 -0
  343. package/dist/setup/__tests__/setup-commands.test.d.ts +2 -0
  344. package/dist/setup/__tests__/setup-commands.test.d.ts.map +1 -0
  345. package/dist/setup/__tests__/setup-commands.test.js +177 -0
  346. package/dist/setup/__tests__/setup-commands.test.js.map +1 -0
  347. package/dist/setup/__tests__/state-machine.test.d.ts +2 -0
  348. package/dist/setup/__tests__/state-machine.test.d.ts.map +1 -0
  349. package/dist/setup/__tests__/state-machine.test.js +341 -0
  350. package/dist/setup/__tests__/state-machine.test.js.map +1 -0
  351. package/dist/setup/detect-surfaces.d.ts +21 -0
  352. package/dist/setup/detect-surfaces.d.ts.map +1 -0
  353. package/dist/setup/detect-surfaces.js +39 -0
  354. package/dist/setup/detect-surfaces.js.map +1 -0
  355. package/dist/setup/manifest-writer.d.ts +17 -0
  356. package/dist/setup/manifest-writer.d.ts.map +1 -0
  357. package/dist/setup/manifest-writer.js +153 -0
  358. package/dist/setup/manifest-writer.js.map +1 -0
  359. package/dist/setup/perimeter.d.ts +72 -0
  360. package/dist/setup/perimeter.d.ts.map +1 -0
  361. package/dist/setup/perimeter.js +128 -0
  362. package/dist/setup/perimeter.js.map +1 -0
  363. package/dist/setup/state-machine.d.ts +67 -0
  364. package/dist/setup/state-machine.d.ts.map +1 -0
  365. package/dist/setup/state-machine.js +124 -0
  366. package/dist/setup/state-machine.js.map +1 -0
  367. package/dist/surfaces/__tests__/adapter.test.d.ts +2 -0
  368. package/dist/surfaces/__tests__/adapter.test.d.ts.map +1 -0
  369. package/dist/surfaces/__tests__/adapter.test.js +90 -0
  370. package/dist/surfaces/__tests__/adapter.test.js.map +1 -0
  371. package/dist/surfaces/__tests__/pb-setup-passthrough.test.d.ts +2 -0
  372. package/dist/surfaces/__tests__/pb-setup-passthrough.test.d.ts.map +1 -0
  373. package/dist/surfaces/__tests__/pb-setup-passthrough.test.js +132 -0
  374. package/dist/surfaces/__tests__/pb-setup-passthrough.test.js.map +1 -0
  375. package/dist/surfaces/__tests__/telemetry.test.d.ts +2 -0
  376. package/dist/surfaces/__tests__/telemetry.test.d.ts.map +1 -0
  377. package/dist/surfaces/__tests__/telemetry.test.js +55 -0
  378. package/dist/surfaces/__tests__/telemetry.test.js.map +1 -0
  379. package/dist/surfaces/adapter.d.ts +70 -0
  380. package/dist/surfaces/adapter.d.ts.map +1 -0
  381. package/dist/surfaces/adapter.js +2 -0
  382. package/dist/surfaces/adapter.js.map +1 -0
  383. package/dist/surfaces/adapters/claude.d.ts +3 -0
  384. package/dist/surfaces/adapters/claude.d.ts.map +1 -0
  385. package/dist/surfaces/adapters/claude.js +67 -0
  386. package/dist/surfaces/adapters/claude.js.map +1 -0
  387. package/dist/surfaces/adapters/codex.d.ts +3 -0
  388. package/dist/surfaces/adapters/codex.d.ts.map +1 -0
  389. package/dist/surfaces/adapters/codex.js +61 -0
  390. package/dist/surfaces/adapters/codex.js.map +1 -0
  391. package/dist/surfaces/adapters/copilot.d.ts +3 -0
  392. package/dist/surfaces/adapters/copilot.d.ts.map +1 -0
  393. package/dist/surfaces/adapters/copilot.js +59 -0
  394. package/dist/surfaces/adapters/copilot.js.map +1 -0
  395. package/dist/surfaces/adapters/cursor.d.ts +3 -0
  396. package/dist/surfaces/adapters/cursor.d.ts.map +1 -0
  397. package/dist/surfaces/adapters/cursor.js +78 -0
  398. package/dist/surfaces/adapters/cursor.js.map +1 -0
  399. package/dist/surfaces/registry.d.ts +58 -2
  400. package/dist/surfaces/registry.d.ts.map +1 -1
  401. package/dist/surfaces/registry.js +82 -7
  402. package/dist/surfaces/registry.js.map +1 -1
  403. package/dist/surfaces/telemetry.d.ts +17 -0
  404. package/dist/surfaces/telemetry.d.ts.map +1 -0
  405. package/dist/surfaces/telemetry.js +31 -0
  406. package/dist/surfaces/telemetry.js.map +1 -0
  407. package/package.json +3 -1
  408. package/dist/__tests__/setup.test.d.ts +0 -2
  409. package/dist/__tests__/setup.test.d.ts.map +0 -1
  410. package/dist/__tests__/setup.test.js +0 -141
  411. package/dist/__tests__/setup.test.js.map +0 -1
  412. package/dist/generators/__tests__/surface-profiles.test.d.ts +0 -2
  413. package/dist/generators/__tests__/surface-profiles.test.d.ts.map +0 -1
  414. package/dist/generators/__tests__/surface-profiles.test.js +0 -89
  415. package/dist/generators/__tests__/surface-profiles.test.js.map +0 -1
  416. package/dist/lib/onboarding-phases.d.ts +0 -9
  417. package/dist/lib/onboarding-phases.d.ts.map +0 -1
  418. package/dist/lib/onboarding-phases.js +0 -120
  419. package/dist/lib/onboarding-phases.js.map +0 -1
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * pb handshake — generate context files for AI developer tools.
3
- * The fourth delivery surface: context export (GLO-63, DEC-161).
3
+ * Context export wiring (read-only filesystem bridge; GLO-63, DEC-161) — not a product surface.
4
4
  */
5
- import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync } from 'fs';
6
- import { join, dirname, resolve } from 'path';
5
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, copyFileSync, appendFileSync, unlinkSync, statSync, renameSync, rmSync, realpathSync } from 'fs';
6
+ import { join, dirname, resolve, basename, relative, sep, isAbsolute } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { createHash } from 'crypto';
10
10
  import { getConfigOrGuide } from '../lib/config.js';
11
- import { select as promptSelect } from '../lib/prompts.js';
11
+ import { select as promptSelect, confirm as promptConfirm } from '../lib/prompts.js';
12
12
  import { composeHooksFromIntents, getHookStatusForSurface } from '../lib/hook-intents.js';
13
13
  import { kernelCall, kernelCallWithSession } from '../lib/client.js';
14
14
  import { readSession } from '../lib/session.js';
@@ -21,10 +21,231 @@ import { generateChainRules } from '../generators/chain-rules.js';
21
21
  import { saveHandshakeState, loadPreviousState, diffHandshakeState, formatDiff, buildCurrentState, } from '../generators/handshake-diff.js';
22
22
  import { resolveSurfaceProfile } from '../generators/surface-profiles.js';
23
23
  import { formatHandshakeReport } from '../formatters/handshake.js';
24
- import { readManifest, filterByAdoptionState } from '../generators/manifest.js';
24
+ import { classifyAdapterFile, detectEol, spliceAppend, spliceReplace } from '../generators/region.js';
25
+ import { REGION_PROJECTIONS } from '../generators/region-projections.js';
26
+ import { readManifest, readManifestStatus, filterByAdoptionState } from '../generators/manifest.js';
27
+ import { generateBoundaryManifest, getBoundaryEnforcementMode } from '../generators/boundary-manifest.js';
25
28
  import { loadMethodRegistry } from '../lib/method-registry.js';
26
29
  import { CLIError, ErrorCode } from '../lib/errors.js';
27
30
  import { trackEvent } from '../lib/telemetry.js';
31
+ import { replaceVocabTokens } from '../lib/canonicalRefs.js';
32
+ // WP-436 S3: vocab projector — resolves {{vocab:...}} tokens before writing to disk.
33
+ import { getOrFetchVocabCtx } from '../lib/workspaceVocabCache.js';
34
+ import { normalizeMaterializedFilename } from '../lib/normalizeMaterializedFilename.js';
35
+ import { assertSetupWritePath } from '../setup/perimeter.js';
36
+ // WP-421 S3: SurfaceAdapter reverse-map for tampered prompts (DEC-952, doneWhen #34).
37
+ import { canonicalPathForAnySurface, SURFACE_GOVERN_NO_SURFACES, SURFACE_REGISTRY, validateSurfacesForMode, } from '../surfaces/registry.js';
38
+ import { getReverseMapFallbackMessage, reportReverseMapMissing } from '../surfaces/telemetry.js';
39
+ const MAX_HANDSHAKE_WAIT_MS = 10_000; // 10 seconds
40
+ const POLL_INTERVAL_MS = 500; // 500 ms per poll
41
+ const MAX_POLLS = MAX_HANDSHAKE_WAIT_MS / POLL_INTERVAL_MS; // 20
42
+ // ── WP-379 S4: Dormant marker ─────────────────────────────────────────────────
43
+ /**
44
+ * DORMANT_MARKER — appended to previously-projected asset files when the asset's
45
+ * gate deactivates (e.g. workspace readiness exceeds the max threshold).
46
+ *
47
+ * Contract:
48
+ * - The file is NOT deleted. It persists on disk so that history is preserved
49
+ * and the agent surface remains inspectable.
50
+ * - The marker is appended at the end of the file, idempotent — if it already
51
+ * exists, no second append occurs.
52
+ * - The marker does NOT trigger a drift TEN. Dormant files are intentionally
53
+ * deactivated, not accidentally forked.
54
+ * - The marker is never included in active file writes — only dormant writes.
55
+ *
56
+ * Used by: writeDormantMarker() (write) and hasDormantMarker() (idempotency check).
57
+ * Exported for use in tests.
58
+ *
59
+ * Chain: WP-379 S4.
60
+ */
61
+ export const DORMANT_MARKER = '<!-- pb-status: dormant -->';
62
+ /**
63
+ * hasDormantMarker — check whether a file on disk already has the dormant marker.
64
+ * Used for idempotency: if the marker is already present, skip the append.
65
+ */
66
+ function hasDormantMarker(content) {
67
+ return content.includes(DORMANT_MARKER);
68
+ }
69
+ /**
70
+ * writeDormantMarker — append the dormant marker to a previously-projected file.
71
+ *
72
+ * Idempotent: if DORMANT_MARKER is already present, no-op.
73
+ * Only operates on files that have the auto-gen MARKER — we never touch
74
+ * manually-authored files.
75
+ *
76
+ * @param filePath Absolute path to the file.
77
+ * @returns 'written' | 'already-dormant' | 'skipped' (no auto-gen marker)
78
+ */
79
+ export function writeDormantMarkerToFile(filePath) {
80
+ if (!existsSync(filePath))
81
+ return 'skipped';
82
+ const content = readFileSync(filePath, 'utf8');
83
+ // Only mark files that were originally projected by pb handshake.
84
+ // Files without the auto-gen MARKER are manually authored — leave them alone.
85
+ if (!content.includes(MARKER))
86
+ return 'skipped';
87
+ if (hasDormantMarker(content))
88
+ return 'already-dormant';
89
+ // Append the marker on its own line. No trailing newline assumption —
90
+ // appendFileSync adds to whatever is already there.
91
+ appendFileSync(filePath, `\n${DORMANT_MARKER}\n`);
92
+ return 'written';
93
+ }
94
+ /**
95
+ * renameSurfaceForDormancy — rename a projected surface file to `<path>.dormant`
96
+ * so external scanners (which glob *.md / *.mdc) stop loading it. WP-426 E4.
97
+ * The HTML-comment marker is invisible to scanners; the extension change removes
98
+ * the file from their glob set. Only touches files carrying the auto-gen MARKER.
99
+ * Runs on an INDEPENDENT existence check (not gated on marker-write result) so
100
+ * legacy WP-379 marker-only files still rename. Idempotent.
101
+ * Codex P1: when a .dormant already exists, only replace it if its body matches the
102
+ * live file. A divergent .dormant (user edited it) is preserved and reported as 'drift'
103
+ * rather than force-deleted — no silent data loss.
104
+ * @returns 'renamed' | 'replaced' | 'already-dormant' | 'skipped' | 'drift'
105
+ */
106
+ export function renameSurfaceForDormancy(filePath) {
107
+ const dormantPath = `${filePath}.dormant`;
108
+ if (!existsSync(filePath))
109
+ return existsSync(dormantPath) ? 'already-dormant' : 'skipped';
110
+ if (!readFileSync(filePath, 'utf8').includes(MARKER))
111
+ return 'skipped';
112
+ const replacing = existsSync(dormantPath);
113
+ if (replacing) {
114
+ // Codex P1: the existing .dormant may carry user edits (e.g. the user reactivated to
115
+ // the live path but kept an edited dormant sibling). Compare normalized bodies
116
+ // (DORMANT_MARKER + volatile auto-gen timestamp stripped from both); if they diverge,
117
+ // preserve the .dormant and signal drift instead of force-replacing it.
118
+ const strip = (s) => normalizeHandshakeContentForComparison(s.split(DORMANT_MARKER).join('')).trimEnd();
119
+ if (strip(readFileSync(filePath, 'utf8')) !== strip(readFileSync(dormantPath, 'utf8'))) {
120
+ return 'drift';
121
+ }
122
+ rmSync(dormantPath, { force: true });
123
+ }
124
+ renameSync(filePath, dormantPath);
125
+ return replacing ? 'replaced' : 'renamed';
126
+ }
127
+ /**
128
+ * restoreSurfaceFromDormant — clean the orphan `<path>.dormant` sibling after a
129
+ * raised (observe→project) asset has been re-projected fresh from the DB body by
130
+ * the normal write loop. WP-426 E4.
131
+ * Normalizes the volatile auto-gen timestamp (via normalizeHandshakeContentForComparison)
132
+ * before comparing, else the lowering-time timestamp would always force a false
133
+ * 'orphan-drift'. Identical body → remove. Differs (user edited) → preserve + drift.
134
+ * @returns 'restored' | 'orphan-drift' | 'skipped'
135
+ */
136
+ export function restoreSurfaceFromDormant(filePath) {
137
+ const dormantPath = `${filePath}.dormant`;
138
+ if (!existsSync(dormantPath))
139
+ return 'skipped';
140
+ // WP-426 E4: no fresh live projection this run (surface filtered / user-owned / not written) →
141
+ // nothing to reconcile; absence of fresh is NOT evidence of a user edit. Leave the .dormant.
142
+ if (!existsSync(filePath))
143
+ return 'skipped';
144
+ const fresh = readFileSync(filePath, 'utf8');
145
+ // Codex P2: only reconcile against a PB-managed projection. A live file WITHOUT the auto-gen
146
+ // MARKER is a user-owned file (shouldWriteAdapter would have skipped reprojecting it),
147
+ // so it was NOT raised this run — comparing the .dormant against it would wrongly delete it or
148
+ // re-fire "edited while dormant" drift. Leave the .dormant untouched.
149
+ if (!fresh.includes(MARKER))
150
+ return 'skipped';
151
+ const dormant = readFileSync(dormantPath, 'utf8').split(DORMANT_MARKER).join('');
152
+ const norm = (s) => normalizeHandshakeContentForComparison(s).trimEnd();
153
+ if (norm(dormant) === norm(fresh)) {
154
+ rmSync(dormantPath, { force: true });
155
+ return 'restored';
156
+ }
157
+ return 'orphan-drift';
158
+ }
159
+ /**
160
+ * deriveDormantFilePaths — compute the set of on-disk file paths that would have
161
+ * been projected for a given dormant asset (by name and assetKind).
162
+ *
163
+ * Assets are projected to one or more surfaces (cursor/claude/codex) depending
164
+ * on shouldEmitToTarget. Since we don't re-run shouldEmitToTarget here, we
165
+ * speculatively probe all known surface paths and let writeDormantMarkerToFile
166
+ * decide whether each exists and has the auto-gen MARKER.
167
+ *
168
+ * @param asset The dormant asset from the server.
169
+ * @param cwd Current working directory (project root).
170
+ * @returns Each candidate path paired with its owning surface, so callers can
171
+ * filter by the run's allowedTargets (--surfaces) and the perimeter.
172
+ */
173
+ function deriveDormantFilePaths(asset, cwd) {
174
+ // Defense-in-depth: even though `name` originates from platform-seeded DB
175
+ // entries (not user input), validate it against a strict charset before
176
+ // interpolating into a filesystem path. Reject anything that could traverse
177
+ // out of the expected directories. WP-379 S4 review finding.
178
+ if (!/^[A-Za-z0-9 ._-]+$/.test(asset.name)) {
179
+ return [];
180
+ }
181
+ const out = [];
182
+ const { name, assetKind } = asset;
183
+ if (assetKind === 'skill') {
184
+ out.push({ path: join(cwd, '.cursor', 'skills', name, 'SKILL.md'), surface: 'cursor' });
185
+ out.push({ path: join(cwd, '.codex', 'skills', `${name}.md`), surface: 'codex' });
186
+ }
187
+ else if (assetKind === 'rule' || assetKind === 'hook') {
188
+ out.push({ path: join(cwd, '.cursor', 'rules', `${name}.mdc`), surface: 'cursor' });
189
+ out.push({ path: join(cwd, '.claude', 'rules', `${name}.md`), surface: 'claude' });
190
+ }
191
+ return out;
192
+ }
193
+ /**
194
+ * Single-shot health probe — calls `workspace.health` and inspects
195
+ * `starterSetupSeeded`. Does NOT poll internally; polling is the caller's
196
+ * responsibility (connect-context.ts).
197
+ *
198
+ * Returns:
199
+ * - `seeds-ready` — health query succeeded AND starterSetupSeeded is true
200
+ * - `seeds-pending` — health query succeeded but starterSetupSeeded is false
201
+ * - `probe-failed` — health query threw (network, auth, etc.)
202
+ */
203
+ export async function probeStarterSetupSeeded() {
204
+ try {
205
+ const health = await kernelCall('workspace.health', {});
206
+ if (health.starterSetupSeeded) {
207
+ return { status: 'seeds-ready' };
208
+ }
209
+ const starterGaps = (health.gaps ?? []).filter((g) => g.kind === 'starter-setup-missing' || g.kind === 'platform-domains-missing');
210
+ return {
211
+ status: 'seeds-pending',
212
+ gaps: starterGaps.length > 0 ? starterGaps : [
213
+ {
214
+ kind: 'starter-setup-missing',
215
+ severity: 'warn',
216
+ message: 'Starter setup seeds are still running.',
217
+ },
218
+ ],
219
+ };
220
+ }
221
+ catch (err) {
222
+ return {
223
+ status: 'probe-failed',
224
+ error: err instanceof Error ? err.message : String(err),
225
+ };
226
+ }
227
+ }
228
+ /**
229
+ * Poll `probeStarterSetupSeeded` up to MAX_POLLS times (10s at 500ms intervals).
230
+ * Returns the final probe result — caller decides how to render the outcome.
231
+ *
232
+ * Exported so connect-context.ts can use it without re-implementing the loop.
233
+ */
234
+ export async function pollUntilSeedsReady() {
235
+ for (let poll = 0; poll < MAX_POLLS; poll++) {
236
+ const result = await probeStarterSetupSeeded();
237
+ if (result.status === 'seeds-ready')
238
+ return result;
239
+ if (result.status === 'probe-failed')
240
+ return result; // don't retry on auth/network errors
241
+ // seeds-pending — wait before next poll
242
+ if (poll < MAX_POLLS - 1) {
243
+ await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS));
244
+ }
245
+ }
246
+ // Final probe after exhausting waits — return whatever state we have
247
+ return probeStarterSetupSeeded();
248
+ }
28
249
  const LEVELS = {
29
250
  guide: {
30
251
  label: 'Guide me',
@@ -269,6 +490,256 @@ function shouldWriteAdapter(filePath, force) {
269
490
  const content = readFileSync(filePath, 'utf8');
270
491
  return content.includes(MARKER);
271
492
  }
493
+ function normalizeSurfaceName(surface) {
494
+ const stripped = surface.startsWith('.') ? surface.slice(1) : surface;
495
+ const normalized = stripped === 'github' ? 'copilot' : stripped;
496
+ return normalized in SURFACE_REGISTRY ? normalized : null;
497
+ }
498
+ function surfacePerimeterRoots(surface) {
499
+ if (surface === 'codex')
500
+ return ['.codex', SURFACE_REGISTRY.codex.hookFilePath];
501
+ if (surface === 'copilot')
502
+ return ['.github', SURFACE_REGISTRY.copilot.hookFilePath];
503
+ if (surface === 'claude')
504
+ return ['.claude', 'CLAUDE.md'];
505
+ return [`.${surface}`];
506
+ }
507
+ function modeRank(mode) {
508
+ return { off: 0, observe: 1, project: 2, govern: 3 }[mode];
509
+ }
510
+ function normalizeSetupAuthoringBody(body) {
511
+ return body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd();
512
+ }
513
+ function setupAuthoringAssetHash(asset) {
514
+ const canonical = {
515
+ entryId: asset.entryId,
516
+ name: asset.name,
517
+ description: asset.description ?? '',
518
+ assetKind: asset.assetKind,
519
+ triggers: asset.triggers ?? [],
520
+ semanticRefs: asset.semanticRefs ?? [],
521
+ body: normalizeSetupAuthoringBody(asset.body),
522
+ };
523
+ return `sha256:${createHash('sha256').update(JSON.stringify(canonical), 'utf8').digest('hex')}`;
524
+ }
525
+ function parseSetupAuthoringFrontmatter(raw) {
526
+ const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
527
+ if (!fmMatch) {
528
+ const h1 = raw.match(/^# (.+)$/m);
529
+ return { name: h1?.[1] ?? '', description: '', body: raw, triggers: [], semanticRefs: [] };
530
+ }
531
+ const fields = new Map();
532
+ const arrayFields = new Map();
533
+ const lines = fmMatch[1].split('\n');
534
+ let currentArrayKey = null;
535
+ let currentArrayValues = [];
536
+ for (const line of lines) {
537
+ const arrayItemMatch = line.match(/^\s+-\s+(.+)$/);
538
+ const keyValueMatch = line.match(/^(\w+):\s*(.*)$/);
539
+ if (arrayItemMatch && currentArrayKey) {
540
+ currentArrayValues.push(arrayItemMatch[1].trim().replace(/^['"]|['"]$/g, ''));
541
+ }
542
+ else if (keyValueMatch) {
543
+ if (currentArrayKey) {
544
+ arrayFields.set(currentArrayKey, currentArrayValues);
545
+ currentArrayKey = null;
546
+ currentArrayValues = [];
547
+ }
548
+ const [, key, value] = keyValueMatch;
549
+ if (value.trim() === '') {
550
+ currentArrayKey = key;
551
+ }
552
+ else {
553
+ fields.set(key, value.trim().replace(/^['"]|['"]$/g, ''));
554
+ }
555
+ }
556
+ }
557
+ if (currentArrayKey)
558
+ arrayFields.set(currentArrayKey, currentArrayValues);
559
+ const body = fmMatch[2];
560
+ return {
561
+ frontmatterId: fields.get('id'),
562
+ name: fields.get('name') ?? body.match(/^# (.+)$/m)?.[1] ?? '',
563
+ description: fields.get('description') ?? '',
564
+ body,
565
+ triggers: arrayFields.get('triggers') ?? [],
566
+ semanticRefs: arrayFields.get('semanticRefs') ?? [],
567
+ };
568
+ }
569
+ function deriveSetupAuthoringEntryId(filename, kind) {
570
+ const base = basename(filename, '.md');
571
+ const snakeCase = base.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
572
+ return `SETUP-${kind.toUpperCase()}-${snakeCase}`;
573
+ }
574
+ function scanSetupAuthoringFiles(productbrainDir) {
575
+ const dirs = [
576
+ { dir: 'skills', kind: 'skill' },
577
+ { dir: 'rules', kind: 'rule' },
578
+ { dir: 'hooks', kind: 'hook' },
579
+ ];
580
+ const items = [];
581
+ for (const { dir, kind } of dirs) {
582
+ const absDir = join(productbrainDir, dir);
583
+ if (!existsSync(absDir))
584
+ continue;
585
+ for (const file of readdirSync(absDir).filter((f) => f.endsWith('.md'))) {
586
+ const filePath = join(absDir, file);
587
+ const parsed = parseSetupAuthoringFrontmatter(readFileSync(filePath, 'utf8'));
588
+ const fallbackName = basename(file, '.md');
589
+ items.push({
590
+ filePath,
591
+ derivedEntryId: deriveSetupAuthoringEntryId(file, kind),
592
+ frontmatterId: parsed.frontmatterId,
593
+ name: parsed.name || fallbackName,
594
+ description: parsed.description,
595
+ body: parsed.body,
596
+ assetKind: kind,
597
+ triggers: parsed.triggers,
598
+ semanticRefs: parsed.semanticRefs,
599
+ });
600
+ }
601
+ }
602
+ return items.sort((a, b) => a.filePath.localeCompare(b.filePath));
603
+ }
604
+ function setupAuthoringDirForKind(kind) {
605
+ if (kind === 'skill')
606
+ return 'skills';
607
+ if (kind === 'rule')
608
+ return 'rules';
609
+ if (kind === 'hook')
610
+ return 'hooks';
611
+ return null;
612
+ }
613
+ function setupAuthoringFilename(name, entryId) {
614
+ const base = (name || entryId)
615
+ .replace(/[\/\\:*?"<>|]/g, '-')
616
+ .replace(/\s+/g, ' ')
617
+ .trim();
618
+ return `${base || entryId}.md`;
619
+ }
620
+ function setupAuthoringPath(cwd, asset) {
621
+ const dir = setupAuthoringDirForKind(asset.assetKind);
622
+ if (!dir)
623
+ return null;
624
+ // WP-426 E3: recorded authoring source path wins so reprojection lands where the
625
+ // user authored — no duplicate (TEN-1920). Stored relative to .productbrain/.
626
+ if (asset.authoringPath && asset.authoringPath.trim()) {
627
+ // Codex P1/P2: authoringPath comes from the DB and is untrusted at projection
628
+ // time. It is probed via existsSync/readFileSync (and later writeFileSync) in the
629
+ // writeback loop BEFORE any assertSetupWritePath guard runs. Containment alone is
630
+ // not enough — a bad DB row could still point at a directory (e.g. "skills") or an
631
+ // internal PB file (".authoring-sync.json", "manifest.yaml"), which the loop would
632
+ // mis-handle as markdown (throw on a directory read, or overwrite PB state).
633
+ // Constrain to a kind-appropriate markdown file (<dir>/**/*.md); otherwise fall
634
+ // back to the safe name-derived path.
635
+ const pbDir = join(cwd, '.productbrain');
636
+ const candidate = resolve(pbDir, asset.authoringPath);
637
+ const within = relative(pbDir, candidate);
638
+ const withinPosix = within.split(sep).join('/');
639
+ if (!within.startsWith('..') &&
640
+ !isAbsolute(within) &&
641
+ withinPosix.startsWith(`${dir}/`) &&
642
+ withinPosix.toLowerCase().endsWith('.md')) {
643
+ return candidate;
644
+ }
645
+ }
646
+ return join(cwd, '.productbrain', dir, setupAuthoringFilename(asset.name, asset.entryId));
647
+ }
648
+ function renderSetupAuthoringFile(asset) {
649
+ const lines = [
650
+ '---',
651
+ `id: ${asset.entryId}`,
652
+ `name: ${JSON.stringify(asset.name)}`,
653
+ `description: ${JSON.stringify(asset.description ?? '')}`,
654
+ `assetKind: ${asset.assetKind}`,
655
+ ];
656
+ const pushArray = (key, values) => {
657
+ if (!values || values.length === 0)
658
+ return;
659
+ lines.push(`${key}:`);
660
+ for (const value of values)
661
+ lines.push(` - ${JSON.stringify(value)}`);
662
+ };
663
+ pushArray('triggers', asset.triggers);
664
+ pushArray('semanticRefs', asset.semanticRefs);
665
+ lines.push('---', normalizeSetupAuthoringBody(asset.body), '');
666
+ return lines.join('\n');
667
+ }
668
+ function loadAuthoringSyncState(productbrainDir) {
669
+ const statePath = join(productbrainDir, '.authoring-sync.json');
670
+ if (!existsSync(statePath))
671
+ return { version: 1, assets: {} };
672
+ try {
673
+ const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
674
+ if (parsed.version !== 1 || !parsed.assets || typeof parsed.assets !== 'object') {
675
+ return { version: 1, assets: {} };
676
+ }
677
+ // Codex P2: the dormant registries are consumed via .includes() in the dormant loop
678
+ // BEFORE its per-file try/catch, so a malformed .authoring-sync.json (a field set to
679
+ // an object/number instead of an array) would throw a TypeError and abort the whole
680
+ // handshake. Coerce to a string[] (mirrors the assets validation above) to fail open.
681
+ const toStringArray = (v) => Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
682
+ return {
683
+ version: 1,
684
+ assets: parsed.assets,
685
+ dormantRenamed: toStringArray(parsed.dormantRenamed),
686
+ dormantReactivated: toStringArray(parsed.dormantReactivated),
687
+ };
688
+ }
689
+ catch {
690
+ return { version: 1, assets: {} };
691
+ }
692
+ }
693
+ function saveAuthoringSyncState(productbrainDir, state) {
694
+ const statePath = join(productbrainDir, '.authoring-sync.json');
695
+ assertSetupWritePath(statePath, { surfaces: [] });
696
+ mkdirSync(productbrainDir, { recursive: true });
697
+ writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n');
698
+ }
699
+ const DRIFT_HASH_TRAILER_REGEX = /^<!--\s*pb-hash:\s*sha256:([0-9a-f]+)\s*-->\s*$/m;
700
+ const DRIFT_HASH_TRAILER_STRIP = /^<!--\s*pb-hash:.*-->\s*$/gm;
701
+ const DRIFT_TIMESTAMP_STRIP = /^<!--\s*pb-generated-at:.*-->\s*$/gm;
702
+ /**
703
+ * Classify a single projection-target file into one of the three drift buckets.
704
+ *
705
+ * Returns `null` when `filePath` does not exist (first-run / unprojected) — the
706
+ * write loop treats that as "would-write" and the file is not part of any bucket.
707
+ *
708
+ * @param filePath Absolute path to the projection file on disk.
709
+ * @returns { bucket, expectedHash, actualHash } when the file exists.
710
+ * `expectedHash`/`actualHash` are populated only for the
711
+ * tampered bucket so the headless refusal payload can include
712
+ * them verbatim. For clean / user-owned, both are `''`.
713
+ */
714
+ export function classifyDriftBucket(filePath) {
715
+ if (!existsSync(filePath))
716
+ return null;
717
+ const content = readFileSync(filePath, 'utf8');
718
+ // No auto-gen MARKER → user-owned. Never touch.
719
+ if (!content.includes(MARKER)) {
720
+ return { bucket: 'user-owned', expectedHash: '', actualHash: '' };
721
+ }
722
+ // Marker present but no hash trailer → legacy / pre-S0c projection: treat as
723
+ // clean (the hash trailer was added in WP-345 S0c). The user-owned-vs-clean
724
+ // semantic falls back to existing shouldWriteAdapter behavior.
725
+ const trailerMatch = content.match(DRIFT_HASH_TRAILER_REGEX);
726
+ if (!trailerMatch) {
727
+ return { bucket: 'pb-managed-clean', expectedHash: '', actualHash: '' };
728
+ }
729
+ const expectedHash = `sha256:${trailerMatch[1]}`;
730
+ // Recompute the actual hash from the body (strip trailer + timestamp, LF, trim).
731
+ const normalized = content
732
+ .replace(DRIFT_HASH_TRAILER_STRIP, '')
733
+ .replace(DRIFT_TIMESTAMP_STRIP, '')
734
+ .replace(/\r\n/g, '\n')
735
+ .replace(/\r/g, '\n')
736
+ .trimEnd();
737
+ const actualHash = `sha256:${createHash('sha256').update(normalized, 'utf8').digest('hex')}`;
738
+ if (actualHash === expectedHash) {
739
+ return { bucket: 'pb-managed-clean', expectedHash, actualHash };
740
+ }
741
+ return { bucket: 'pb-managed-tampered', expectedHash, actualHash };
742
+ }
272
743
  function deduplicateEntries(entries) {
273
744
  const seen = new Set();
274
745
  const result = [];
@@ -281,10 +752,168 @@ function deduplicateEntries(entries) {
281
752
  }
282
753
  return result;
283
754
  }
755
+ /**
756
+ * resolveProjectionCollision — WP-379 S5b
757
+ *
758
+ * Marker-scoped orphan unlink: enumerates target dirs (.cursor/rules/,
759
+ * .claude/rules/, .claude/skills/, .codex/skills/); for each file that has
760
+ * the auto-gen MARKER whose lowercase-normalized filename does NOT match any
761
+ * current active-asset materializedFilename, the file is unlinked.
762
+ *
763
+ * User-owned files (no MARKER) are never touched, regardless of name.
764
+ *
765
+ * Linux case-collision disambiguation:
766
+ * 1. Exact match (lowercase name == any canonical name): survives.
767
+ * 2. Case-variant with MARKER (marker file, no exact canonical match): unlinked.
768
+ * 3. Ambiguous (zero exact, multiple case-variants with MARKER):
769
+ * newest mtime wins; all others are unlinked; a collision TEN is
770
+ * appended to the session capture queue (not fired inline).
771
+ *
772
+ * Returns a list of unlink results so the caller can log/report them.
773
+ *
774
+ * @param cwd Project root (absolute path).
775
+ * @param assetNames The current set of canonical asset names from the server
776
+ * (e.g. ["Setup-ProductBrain", "chain-rules"]).
777
+ * @param log Progress log function.
778
+ * @param logErr Error log function.
779
+ */
780
+ export function resolveProjectionCollision(cwd, assetNames, log, logErr) {
781
+ // Target directories by extension suffix.
782
+ const TARGET_DIRS_BY_EXT = [
783
+ { dir: join(cwd, '.cursor', 'rules'), ext: '.mdc' },
784
+ { dir: join(cwd, '.claude', 'rules'), ext: '.md' },
785
+ { dir: join(cwd, '.claude', 'skills'), ext: '.md' },
786
+ { dir: join(cwd, '.codex', 'skills'), ext: '.md' },
787
+ ];
788
+ // Build a set of normalized canonical names (without extension) for fast lookup.
789
+ // We normalize all asset names to detect case-variant collisions.
790
+ // For each asset name we derive the normalized basename (the part before the ext).
791
+ // canonicalNormalizedNames: Set<normalized-stem> (lowercase + slug).
792
+ const canonicalNormalizedStems = new Set(assetNames.map((n) => normalizeMaterializedFilename(n)));
793
+ const results = [];
794
+ const collisionTens = [];
795
+ for (const { dir, ext } of TARGET_DIRS_BY_EXT) {
796
+ if (!existsSync(dir))
797
+ continue;
798
+ let files;
799
+ try {
800
+ files = readdirSync(dir);
801
+ }
802
+ catch {
803
+ continue; // unreadable dir — skip
804
+ }
805
+ // Group files by their normalized stem.
806
+ // normalizedStem → [ { filename, fullPath } ]
807
+ const groups = new Map();
808
+ for (const filename of files) {
809
+ if (!filename.endsWith(ext))
810
+ continue;
811
+ const fullPath = join(dir, filename);
812
+ // Only operate on files that have the auto-gen MARKER.
813
+ let content;
814
+ try {
815
+ content = readFileSync(fullPath, 'utf8');
816
+ }
817
+ catch {
818
+ continue; // unreadable file — skip
819
+ }
820
+ if (!content.includes(MARKER))
821
+ continue; // user-owned — never touch
822
+ const stem = basename(filename, ext);
823
+ const normalizedStem = normalizeMaterializedFilename(stem);
824
+ const group = groups.get(normalizedStem) ?? [];
825
+ group.push({ filename, fullPath });
826
+ groups.set(normalizedStem, group);
827
+ }
828
+ // Evaluate each normalized stem group.
829
+ for (const [normalizedStem, members] of groups) {
830
+ const isKnownCanonical = canonicalNormalizedStems.has(normalizedStem);
831
+ if (!isKnownCanonical) {
832
+ // All members of this group are orphans (no canonical asset with this stem).
833
+ // Unlink them all — they're stale projections of an asset no longer in the server.
834
+ for (const { filename, fullPath } of members) {
835
+ try {
836
+ unlinkSync(fullPath);
837
+ log(`Orphan unlinked: ${fullPath}`);
838
+ results.push({ action: 'unlinked', filePath: fullPath, reason: 'orphan-no-canonical-match' });
839
+ }
840
+ catch (err) {
841
+ logErr(`Warning: could not unlink orphan ${fullPath} — ${err instanceof Error ? err.message : String(err)}`);
842
+ results.push({ action: 'kept', filePath: fullPath, reason: 'unlink-failed' });
843
+ }
844
+ }
845
+ continue;
846
+ }
847
+ // The stem IS known canonical. Check for case-collision.
848
+ if (members.length === 1) {
849
+ // Single file — no collision.
850
+ results.push({ action: 'kept', filePath: members[0].fullPath, reason: 'canonical-exact' });
851
+ continue;
852
+ }
853
+ // Multiple files with the same normalized stem → case-collision.
854
+ // Rule: exact match (filename stem === normalized stem, i.e. already lowercase) wins.
855
+ const exactMatches = members.filter(({ filename }) => {
856
+ const stem = basename(filename, ext);
857
+ return stem === normalizedStem; // lowercase-equal means already normalized
858
+ });
859
+ if (exactMatches.length === 1) {
860
+ // Rule 1: exactly one exact match → keep it, unlink all case-variants.
861
+ const keeper = exactMatches[0];
862
+ results.push({ action: 'kept', filePath: keeper.fullPath, reason: 'case-exact-match-wins' });
863
+ for (const member of members) {
864
+ if (member.fullPath === keeper.fullPath)
865
+ continue;
866
+ try {
867
+ unlinkSync(member.fullPath);
868
+ log(`Case-variant unlinked: ${member.fullPath} (kept: ${keeper.filename})`);
869
+ results.push({ action: 'unlinked', filePath: member.fullPath, reason: 'case-variant-unlinked' });
870
+ }
871
+ catch (err) {
872
+ logErr(`Warning: could not unlink case-variant ${member.fullPath} — ${err instanceof Error ? err.message : String(err)}`);
873
+ results.push({ action: 'kept', filePath: member.fullPath, reason: 'unlink-failed' });
874
+ }
875
+ }
876
+ continue;
877
+ }
878
+ // Rule 3: ambiguous — zero exact matches (or multiple exact matches, which
879
+ // can't happen on a case-sensitive FS). Newest mtime wins.
880
+ // Sort by mtime descending: highest mtime = newest = winner.
881
+ const withStats = members.map(({ filename, fullPath }) => {
882
+ try {
883
+ const { mtimeMs } = statSync(fullPath);
884
+ return { filename, fullPath, mtimeMs };
885
+ }
886
+ catch {
887
+ return { filename, fullPath, mtimeMs: 0 };
888
+ }
889
+ });
890
+ withStats.sort((a, b) => b.mtimeMs - a.mtimeMs);
891
+ const winner = withStats[0];
892
+ log(`Case-collision ambiguous for ${normalizedStem}${ext}: newest mtime wins (${winner.filename})`);
893
+ results.push({ action: 'collision-ten', filePath: winner.fullPath, reason: 'ambiguous-newest-mtime-wins' });
894
+ const tenMsg = `Handshake case-collision: ambiguous filename for stem "${normalizedStem}${ext}" ` +
895
+ `(${members.map((m) => m.filename).join(', ')}). ` +
896
+ `Kept newest: ${winner.filename}. Consider renaming to ${normalizedStem}${ext}.`;
897
+ collisionTens.push(tenMsg);
898
+ for (const member of withStats.slice(1)) {
899
+ try {
900
+ unlinkSync(member.fullPath);
901
+ log(`Case-variant (ambiguous) unlinked: ${member.fullPath}`);
902
+ results.push({ action: 'unlinked', filePath: member.fullPath, reason: 'ambiguous-case-variant-unlinked' });
903
+ }
904
+ catch (err) {
905
+ logErr(`Warning: could not unlink ambiguous case-variant ${member.fullPath} — ${err instanceof Error ? err.message : String(err)}`);
906
+ results.push({ action: 'kept', filePath: member.fullPath, reason: 'unlink-failed' });
907
+ }
908
+ }
909
+ }
910
+ }
911
+ return { results, collisionTens };
912
+ }
284
913
  export async function runHandshake(options = {}) {
285
- const config = await getConfigOrGuide(() => runHandshake(options));
914
+ const config = await getConfigOrGuide(async () => { await runHandshake(options); });
286
915
  if (!config)
287
- return;
916
+ return undefined;
288
917
  const cwd = process.cwd();
289
918
  const force = options.force ?? false;
290
919
  const dryRun = options.dryRun ?? false;
@@ -323,7 +952,7 @@ export async function runHandshake(options = {}) {
323
952
  try {
324
953
  const workspaceReadinessPromise = kernelCall('chain.workspaceReadiness', {}).catch(() => null);
325
954
  const [orientResult, readinessRaw] = await Promise.all([
326
- kernelCall('chain.getOrientView', {}).catch((err) => {
955
+ kernelCall('chain.getOrientView', { tier: 'standard' }).catch((err) => {
327
956
  logErr(`Warning: could not fetch workspace context — ${err instanceof Error ? err.message : err}`);
328
957
  logErr('Continuing with limited context (Chain search + portable knowledge only).');
329
958
  return null;
@@ -364,25 +993,208 @@ export async function runHandshake(options = {}) {
364
993
  // Primary: query setup.listAssetsForUser from DB (workspace SSOT).
365
994
  // Fallback: read from .productbrain/ filesystem (legacy — used when DB is empty or unavailable).
366
995
  const pbDir = join(cwd, '.productbrain');
996
+ const manifestStatus = readManifestStatus(pbDir);
997
+ const manifest = manifestStatus.manifest;
998
+ const surfaceValidation = validateSurfacesForMode(manifestStatus.mode, manifestStatus.surfaces);
999
+ if (applyMode && surfaceValidation.error === SURFACE_GOVERN_NO_SURFACES) {
1000
+ throw new CLIError('materialize: govern requires at least one registered manifest surface.', {
1001
+ code: ErrorCode.VALIDATION_FAILED,
1002
+ category: 'validation',
1003
+ guidance: 'Add surfaces such as `.cursor` or `.claude` to .productbrain/manifest.yaml.',
1004
+ });
1005
+ }
1006
+ for (const surface of surfaceValidation.unregisteredSurfaces) {
1007
+ logErr(`Warning: manifest surface "${surface}" is not registered; skipping it.`);
1008
+ }
1009
+ const manifestTargets = new Set(surfaceValidation.registeredSurfaces);
1010
+ const cliTargets = new Set();
1011
+ const ignoredCliSurfaces = [];
1012
+ for (const surface of options.surfaces ?? []) {
1013
+ const normalized = normalizeSurfaceName(surface);
1014
+ if (normalized && manifestTargets.has(normalized)) {
1015
+ cliTargets.add(normalized);
1016
+ }
1017
+ else {
1018
+ ignoredCliSurfaces.push(surface);
1019
+ }
1020
+ }
1021
+ if (ignoredCliSurfaces.length > 0) {
1022
+ logErr(`Warning: --surfaces ignored outside manifest.surfaces: ${ignoredCliSurfaces.join(', ')}`);
1023
+ }
1024
+ const allowedTargets = options.surfaces && options.surfaces.length > 0
1025
+ ? cliTargets
1026
+ : manifestTargets;
1027
+ const perimeterManifest = {
1028
+ surfaces: [...manifestTargets].flatMap(surfacePerimeterRoots),
1029
+ };
1030
+ const authorityCanWrite = manifestStatus.mode === 'project' || manifestStatus.mode === 'govern';
1031
+ const authorityPreviewOnly = applyMode && !authorityCanWrite;
1032
+ const writeMode = applyMode && authorityCanWrite;
367
1033
  let dbSkills = [];
368
1034
  let dbRules = [];
369
1035
  let usedDbSource = false;
370
1036
  let dbAssetRows = [];
1037
+ // WP-379 S4: dormant assets (gate-failed) — their on-disk files get the dormant marker.
1038
+ let dormantDbAssetRows = [];
1039
+ // WP-428 S2 (Finding #5): tracks entryIds whose body fetch from storage failed.
1040
+ // Hoisted here (same scope as dbAssetRows) so both the skills/rules projection loop
1041
+ // AND the authoring-file projection loop can skip failed assets.
1042
+ const bodyFetchFailedEntryIds = new Set();
371
1043
  const dbProjectionHashes = new Map();
1044
+ const syncDriftTensToFire = [];
1045
+ const deferredAuthoringBaselineEntryIds = new Set();
1046
+ if (writeMode) {
1047
+ const authoringItems = scanSetupAuthoringFiles(pbDir);
1048
+ if (authoringItems.length > 0) {
1049
+ // WP-428 S2 (Critical #1): ingestSetupAssetsBatch is an internalMutation — it cannot
1050
+ // call ctx.storage.store(). We now call ingestSetupAssetWithBody (action) per-asset so
1051
+ // each asset gets bodyStorageId written. This loses intra-batch collision detection;
1052
+ // canonical-id collision check is now client-side before dispatch.
1053
+ //
1054
+ // Client-side collision detection (replaces server-side DEC-954 check in the batch mutation):
1055
+ const idToPaths = new Map();
1056
+ for (const item of authoringItems) {
1057
+ const canonicalId = item.frontmatterId?.trim() || item.derivedEntryId;
1058
+ const paths = idToPaths.get(canonicalId) ?? [];
1059
+ paths.push(item.filePath);
1060
+ idToPaths.set(canonicalId, paths);
1061
+ }
1062
+ const conflictingPaths = new Set();
1063
+ for (const [, paths] of idToPaths) {
1064
+ if (paths.length > 1) {
1065
+ for (const p of paths)
1066
+ conflictingPaths.add(p);
1067
+ }
1068
+ }
1069
+ if (conflictingPaths.size > 0) {
1070
+ logErr(`Setup authoring import partial: ${conflictingPaths.size} file(s) refused for duplicate setup id. ` +
1071
+ [...conflictingPaths].join(', '));
1072
+ }
1073
+ const itemsToIngest = authoringItems.filter((item) => !conflictingPaths.has(item.filePath));
1074
+ if (itemsToIngest.length > 0) {
1075
+ try {
1076
+ // Parallel uploads — concurrency limit 10 to avoid overwhelming the gateway.
1077
+ const CONCURRENCY = 10;
1078
+ const ingestResults = [];
1079
+ for (let i = 0; i < itemsToIngest.length; i += CONCURRENCY) {
1080
+ const batch = itemsToIngest.slice(i, i + CONCURRENCY);
1081
+ const settled = await Promise.allSettled(batch.map(async (item) => {
1082
+ const result = await kernelCall('setup.ingestSetupAssetWithBody', {
1083
+ entryId: item.derivedEntryId,
1084
+ frontmatterId: item.frontmatterId,
1085
+ name: item.name,
1086
+ description: item.description,
1087
+ body: item.body,
1088
+ assetKind: item.assetKind,
1089
+ triggers: item.triggers,
1090
+ semanticRefs: item.semanticRefs,
1091
+ authoringPath: relative(pbDir, item.filePath).split(sep).join('/'),
1092
+ });
1093
+ return { filePath: item.filePath, ...result };
1094
+ }));
1095
+ for (const r of settled) {
1096
+ if (r.status === 'fulfilled') {
1097
+ ingestResults.push(r.value);
1098
+ if (r.value.conflict === 'repo-wins') {
1099
+ syncDriftTensToFire.push(`Repo authoring file won setup sync conflict for ${r.value.entryId} at ${r.value.filePath}.`);
1100
+ logErr(`Warning: repo authoring file won sync conflict for ${r.value.entryId}; DB edit was overwritten.`);
1101
+ }
1102
+ }
1103
+ else {
1104
+ trackEvent('setup.authoring_import.item_failed', { error: r.reason instanceof Error ? r.reason.message : String(r.reason) });
1105
+ logErr(`Warning: setup authoring import failed for one asset — ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`);
1106
+ }
1107
+ }
1108
+ }
1109
+ if (ingestResults.length > 0) {
1110
+ log(`Setup authoring import: ${ingestResults.length} file(s) checked (with bodyStorageId).`);
1111
+ }
1112
+ }
1113
+ catch (err) {
1114
+ trackEvent('setup.authoring_import.failed', { error: err instanceof Error ? err.message : String(err) });
1115
+ logErr(`Warning: setup authoring import failed — ${err instanceof Error ? err.message : String(err)}`);
1116
+ }
1117
+ }
1118
+ }
1119
+ }
372
1120
  try {
373
- const dbAssets = await kernelCall('setup.listAssetsForUser', {}).catch(() => null);
374
- if (dbAssets && dbAssets.length > 0) {
1121
+ // WP-379 S4: listAssetsForUser now returns { activeAssets, dormantAssets }.
1122
+ // Wire format changed from DbAsset[] to { activeAssets: DbAsset[], dormantAssets: DbAsset[] }.
1123
+ // Fall back to empty arrays if the server returns the old flat-array shape (graceful degradation).
1124
+ const rawResponse = await kernelCall('setup.listAssetsForUser', {}).catch(() => null);
1125
+ let dbAssets = [];
1126
+ if (rawResponse !== null) {
1127
+ if (Array.isArray(rawResponse)) {
1128
+ // Pre-S4 server — treat entire response as active assets with no dormant list.
1129
+ dbAssets = rawResponse;
1130
+ dormantDbAssetRows = [];
1131
+ }
1132
+ else {
1133
+ dbAssets = rawResponse.activeAssets ?? [];
1134
+ dormantDbAssetRows = rawResponse.dormantAssets ?? [];
1135
+ }
1136
+ }
1137
+ if (dbAssets.length > 0) {
375
1138
  dbAssetRows = dbAssets;
1139
+ // WP-428 S2: body is no longer inline — fetch from storage per-asset when bodyStorageId is set.
1140
+ // Projectable assets: non-disabled skill/rule/hook entries. Fetch bodies in parallel (bounded).
1141
+ const projectableAssets = dbAssets.filter((a) => !a.disabledByOwner && (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
1142
+ const assetsNeedingBodyFetch = projectableAssets.filter((a) => a.bodyStorageId);
1143
+ const bodyFetchMap = new Map(); // entryId → body
1144
+ // WP-428 S2 (Finding #5/#12): track fetch-failed assets so we can skip their projection.
1145
+ // Failed entryIds are excluded from dbSkills/dbRules — no empty body written, no lastProjectedHash update.
1146
+ // bodyFetchFailedEntryIds is declared at outer scope (also used by authoring-file projection loop).
1147
+ if (assetsNeedingBodyFetch.length > 0) {
1148
+ log(`Fetching ${assetsNeedingBodyFetch.length} asset body(s) from storage...`);
1149
+ const bodyFetchResults = await Promise.allSettled(assetsNeedingBodyFetch.map(async (asset) => {
1150
+ const result = await kernelCall('setup.fetchAssetBody', { bodyStorageId: asset.bodyStorageId });
1151
+ return { entryId: asset.entryId, name: asset.name, body: result.body };
1152
+ }));
1153
+ for (let i = 0; i < bodyFetchResults.length; i++) {
1154
+ const result = bodyFetchResults[i];
1155
+ if (result.status === 'fulfilled') {
1156
+ bodyFetchMap.set(result.value.entryId, result.value.body);
1157
+ }
1158
+ else {
1159
+ // WP-428 S2 (Finding #12): include entryId and name in the warning (Finding #5: skip projection).
1160
+ const asset = assetsNeedingBodyFetch[i];
1161
+ logErr(`Warning: failed to fetch body for ${asset.entryId} (${asset.name}) — ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
1162
+ bodyFetchFailedEntryIds.add(asset.entryId);
1163
+ }
1164
+ }
1165
+ if (bodyFetchFailedEntryIds.size > 0) {
1166
+ // WP-439 S4: strict-by-default. Any body-fetch failure is a hard
1167
+ // failure unless --lenient is passed. The strict path surfaces an
1168
+ // actionable pointer at the audit/repair command operators can run
1169
+ // to diagnose and fix orphaned setup-asset rows.
1170
+ const failedList = [...bodyFetchFailedEntryIds].join(', ');
1171
+ if (options.lenient) {
1172
+ logErr(`Warning: ${bodyFetchFailedEntryIds.size} asset(s) skipped due to body fetch failure (--lenient): ${failedList}`);
1173
+ }
1174
+ else {
1175
+ throw new CLIError(`${bodyFetchFailedEntryIds.size} asset(s) failed body fetch: ${failedList}. ` +
1176
+ `Run \`pb setup-audit\` to diagnose, then \`pb setup-audit --repair\` to reseed. ` +
1177
+ `Pass --lenient to suppress this and continue with affected entries skipped (legacy behaviour).`, { code: ErrorCode.INTERNAL, category: 'internal' });
1178
+ }
1179
+ }
1180
+ }
376
1181
  // Map DB assets to CanonicalSkill/CanonicalRule shapes
377
1182
  for (const asset of dbAssets) {
378
1183
  if (asset.disabledByOwner)
379
1184
  continue;
1185
+ // WP-428 S2 (Finding #5): skip projection for assets whose body fetch failed.
1186
+ // Do NOT write empty body to disk and do NOT update lastProjectedHash.
1187
+ if (bodyFetchFailedEntryIds.has(asset.entryId))
1188
+ continue;
1189
+ // WP-428 S2: resolve body — prefer storage-fetched body, fall back to inline body (pre-S2 servers).
1190
+ // Fallback: pre-S2 servers (no bodyStorageId) return inline body. Once all servers are S2+, this can be deleted.
1191
+ const resolvedBody = bodyFetchMap.get(asset.entryId) ?? asset.body ?? '';
380
1192
  if (asset.assetKind === 'skill') {
381
1193
  dbSkills.push({
382
1194
  name: asset.name,
383
1195
  description: asset.description,
384
1196
  triggers: asset.triggers ?? [],
385
- body: asset.body,
1197
+ body: resolvedBody,
386
1198
  sourcePath: `db:${asset.entryId}`,
387
1199
  });
388
1200
  }
@@ -391,13 +1203,16 @@ export async function runHandshake(options = {}) {
391
1203
  name: asset.name,
392
1204
  description: asset.description,
393
1205
  autoApply: false,
394
- body: asset.body,
1206
+ body: resolvedBody,
395
1207
  sourcePath: `db:${asset.entryId}`,
396
1208
  });
397
1209
  }
398
1210
  }
399
1211
  usedDbSource = true;
400
1212
  log(`Setup assets: ${dbSkills.length} skills, ${dbRules.length} rules/hooks from DB (WP-345 DB-first path)`);
1213
+ if (dormantDbAssetRows.length > 0) {
1214
+ log(`Setup assets: ${dormantDbAssetRows.length} dormant (gate-filtered) asset(s) will be marked on disk`);
1215
+ }
401
1216
  }
402
1217
  }
403
1218
  catch {
@@ -412,14 +1227,15 @@ export async function runHandshake(options = {}) {
412
1227
  // For each asset with semanticRefs[], resolve them via the Convex resolver and
413
1228
  // replace {{ref:key}} placeholders in the body. Runs in apply mode only (not preview).
414
1229
  // NG11: PostHog events fire from CLI side only (never inside Convex mutations).
415
- if (usedDbSource && applyMode) {
1230
+ if (usedDbSource && writeMode) {
416
1231
  const projectableDbAssets = dbAssetRows.filter((a) => !a.disabledByOwner && (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
417
1232
  const assetsWithRefs = projectableDbAssets.filter((a) => a.semanticRefs && a.semanticRefs.length > 0);
418
1233
  if (assetsWithRefs.length > 0) {
419
1234
  log(`Resolving semantic refs for ${assetsWithRefs.length} asset(s)...`);
420
1235
  // Collect unique ref keys across all assets
421
1236
  const allRefKeys = [...new Set(assetsWithRefs.flatMap((a) => a.semanticRefs))];
422
- // Resolve all refs in a single batch call
1237
+ // Resolve all refs in a single batch call. Shape: SetupRefResolution[]
1238
+ // (DEC-767 / WP-354 Build-Order #6 — kind + status discriminator).
423
1239
  let resolvedRefs = [];
424
1240
  try {
425
1241
  resolvedRefs = await kernelCall('setup.resolveSemanticRefs', { semanticRefs: allRefKeys });
@@ -428,21 +1244,26 @@ export async function runHandshake(options = {}) {
428
1244
  trackEvent('setup.refs.resolve_failed', { error: err instanceof Error ? err.message : String(err) });
429
1245
  logErr(`Warning: could not resolve semantic refs — ${err instanceof Error ? err.message : String(err)}`);
430
1246
  }
431
- // Build resolved map: canonicalKey → display name
1247
+ // Build resolved map: canonicalKey → display name. Only required refs
1248
+ // count as unresolved warnings; seed/unknown refs are not gates.
432
1249
  const resolvedMap = new Map();
433
1250
  let unresolvedCount = 0;
434
1251
  for (const result of resolvedRefs) {
435
- if (result.resolved) {
436
- resolvedMap.set(result.ref, result.resolved.name);
437
- trackEvent('skill.ref.resolved', { ref: result.ref });
1252
+ if (result.status === 'resolved' && result.localEntryId) {
1253
+ resolvedMap.set(result.ref, result.localEntryId);
1254
+ trackEvent('skill.ref.resolved', { ref: result.ref, kind: result.kind });
438
1255
  }
439
- else {
1256
+ else if (result.status === 'unsupported-future') {
1257
+ // seed: refs are explicitly future scope per WP-354 Build-Order #6 — not a warning.
1258
+ trackEvent('skill.ref.future', { ref: result.ref, kind: result.kind });
1259
+ }
1260
+ else if (result.required) {
440
1261
  unresolvedCount++;
441
- trackEvent('skill.ref.unresolved', { ref: result.ref });
1262
+ trackEvent('skill.ref.unresolved', { ref: result.ref, kind: result.kind, status: result.status });
442
1263
  }
443
1264
  }
444
1265
  if (unresolvedCount > 0) {
445
- logErr(`Warning: ${unresolvedCount} semantic ref(s) could not be resolved.`);
1266
+ logErr(`Warning: ${unresolvedCount} required semantic ref(s) could not be resolved.`);
446
1267
  }
447
1268
  // Projection must preserve ref tokens as portable machine-readable refs.
448
1269
  // resolvedMap is only used for validation/telemetry here; generated setup
@@ -554,9 +1375,6 @@ export async function runHandshake(options = {}) {
554
1375
  }
555
1376
  allSkills.push(...personalSkills);
556
1377
  }
557
- // 5c. Apply manifest-based adoption filter (WP-310 E1)
558
- // readManifest returns null when manifest.yaml is absent → filterByAdoptionState is a no-op.
559
- const manifest = readManifest(pbDir);
560
1378
  // 5d. Load method registry (WP-310 E4) — only when manifest is present.
561
1379
  let registrySource;
562
1380
  let registryStale;
@@ -623,13 +1441,13 @@ export async function runHandshake(options = {}) {
623
1441
  const agentsWorkspaceContext = workspaceProfile
624
1442
  ? {
625
1443
  stage: workspaceProfile.stage,
626
- focus: orientView?.strategicContext?.currentBet ?? undefined,
1444
+ focus: orientView?.strategicContext?.currentWorkPackage ?? undefined,
627
1445
  governanceMode: workspaceProfile.governanceMode,
628
1446
  totalEntries: workspaceProfile.totalEntries,
629
1447
  }
630
1448
  : undefined;
631
- // Collect codex-targeted skills for AGENTS.md skill directory
632
- // Exclude persist: 'local' rules committed adapter files must never include local-only rules.
1449
+ // Collect codex-targeted skills for AGENTS.md skill directory.
1450
+ // persist: 'local' entries are included; all projections are now local-only.
633
1451
  const agentsCodexSkills = canonicalSkills
634
1452
  .filter((s) => shouldEmitToTarget(s, 'codex'))
635
1453
  .map((s) => ({
@@ -668,21 +1486,141 @@ export async function runHandshake(options = {}) {
668
1486
  const claudeContent = generateClaudeMd(timestamp);
669
1487
  const cursorContent = generateCursorMdc(timestamp);
670
1488
  const copilotContent = generateCopilotMd(timestamp, copilotOptions);
1489
+ const boundaryEnforcementMode = getBoundaryEnforcementMode(manifest);
1490
+ const boundaryManifestContent = boundaryEnforcementMode === 'advisory'
1491
+ ? null
1492
+ : generateBoundaryManifest(pbDir);
671
1493
  // 7. Write files
672
1494
  const filesWritten = [];
673
1495
  const filesSkipped = [];
674
1496
  const previewPlan = [];
675
- // Surface filtering: skip adapter writes for targets not in the allowed set
676
- const allowedTargets = options.surfaces && options.surfaces.length > 0
677
- ? new Set(options.surfaces)
678
- : null; // null = write all
1497
+ if (writeMode && usedDbSource) {
1498
+ const authoringSyncState = loadAuthoringSyncState(pbDir);
1499
+ let authoringSyncStateChanged = false;
1500
+ const personalAssets = dbAssetRows.filter((asset) => asset.scope === 'personal' &&
1501
+ !asset.disabledByOwner &&
1502
+ (asset.assetKind === 'skill' || asset.assetKind === 'rule' || asset.assetKind === 'hook'));
1503
+ for (const asset of personalAssets) {
1504
+ // WP-428 S2 (Finding #5): skip projection for assets whose body fetch failed.
1505
+ // Do NOT write the authoring file with empty body, do NOT update lastProjectedHash.
1506
+ if (bodyFetchFailedEntryIds.has(asset.entryId))
1507
+ continue;
1508
+ const authoringPath = setupAuthoringPath(cwd, asset);
1509
+ if (!authoringPath)
1510
+ continue;
1511
+ // Review: use relative() for correct cross-platform path computation (string-replace
1512
+ // mis-fires when cwd uses a non-'/' separator), matching the dormant passes below.
1513
+ const relativeAuthoringPath = relative(cwd, authoringPath);
1514
+ const bodyHash = setupAuthoringAssetHash(asset);
1515
+ const authoringExists = existsSync(authoringPath);
1516
+ const tracked = authoringSyncState.assets[asset.entryId];
1517
+ const trackedHere = tracked?.path === relativeAuthoringPath;
1518
+ const trackedMatchesServer = Boolean(trackedHere && asset.lastProjectedHash && tracked?.hash === asset.lastProjectedHash);
1519
+ if (!authoringExists && asset.lastProjectedHash && trackedHere && trackedMatchesServer) {
1520
+ try {
1521
+ const dormantResult = await kernelCall('setup.markPersonalSetupAssetDormantFromSync', { entryId: asset.entryId, expectedLastProjectedHash: tracked.hash });
1522
+ if (dormantResult.action === 'conflict') {
1523
+ syncDriftTensToFire.push(`Repo authoring deletion skipped for ${asset.entryId}; server baseline changed during sync, so writeback was deferred until the next DB read.`);
1524
+ logErr(`Warning: authoring deletion for ${asset.entryId} was not applied because the DB baseline changed during sync.`);
1525
+ deferredAuthoringBaselineEntryIds.add(asset.entryId);
1526
+ continue;
1527
+ }
1528
+ else {
1529
+ syncDriftTensToFire.push(`Repo authoring file was deleted for ${asset.entryId}; caller-owned personal setup asset marked dormant.`);
1530
+ log(`Setup authoring deletion: ${asset.entryId} marked dormant.`);
1531
+ delete authoringSyncState.assets[asset.entryId];
1532
+ authoringSyncStateChanged = true;
1533
+ continue;
1534
+ }
1535
+ }
1536
+ catch (err) {
1537
+ logErr(`Warning: could not mark ${asset.entryId} dormant after authoring deletion — ${err instanceof Error ? err.message : String(err)}`);
1538
+ continue;
1539
+ }
1540
+ }
1541
+ else if (!authoringExists && asset.lastProjectedHash && trackedHere) {
1542
+ syncDriftTensToFire.push(`Repo authoring deletion skipped for ${asset.entryId}; local sync baseline is stale, so DB writeback wins.`);
1543
+ logErr(`Warning: authoring deletion for ${asset.entryId} was not applied because the DB baseline changed.`);
1544
+ }
1545
+ const nextAuthoringContent = renderSetupAuthoringFile(asset);
1546
+ if (authoringExists) {
1547
+ const parsed = parseSetupAuthoringFrontmatter(readFileSync(authoringPath, 'utf8'));
1548
+ const fileHash = setupAuthoringAssetHash({
1549
+ entryId: parsed.frontmatterId?.trim() || asset.entryId,
1550
+ name: parsed.name || asset.name,
1551
+ description: parsed.description,
1552
+ body: parsed.body,
1553
+ assetKind: asset.assetKind,
1554
+ triggers: parsed.triggers,
1555
+ semanticRefs: parsed.semanticRefs,
1556
+ });
1557
+ if (fileHash === bodyHash) {
1558
+ if (asset.lastProjectedHash !== bodyHash) {
1559
+ await kernelCall('setup.updateLastProjectedHash', {
1560
+ entryId: asset.entryId,
1561
+ hash: bodyHash,
1562
+ }).catch(() => null);
1563
+ }
1564
+ authoringSyncState.assets[asset.entryId] = { path: relativeAuthoringPath, hash: bodyHash };
1565
+ authoringSyncStateChanged = true;
1566
+ continue;
1567
+ }
1568
+ if (!asset.lastProjectedHash) {
1569
+ syncDriftTensToFire.push(`Repo authoring file differs from DB for ${asset.entryId} at ${authoringPath} with no shared baseline; DB writeback skipped.`);
1570
+ logErr(`Warning: setup authoring drift for ${asset.entryId}; DB writeback skipped because no shared baseline exists.`);
1571
+ continue;
1572
+ }
1573
+ if (fileHash !== asset.lastProjectedHash &&
1574
+ bodyHash !== asset.lastProjectedHash) {
1575
+ syncDriftTensToFire.push(`Repo authoring file won setup sync conflict for ${asset.entryId} at ${authoringPath}.`);
1576
+ logErr(`Warning: repo authoring file won sync conflict for ${asset.entryId}; DB writeback skipped.`);
1577
+ continue;
1578
+ }
1579
+ if (fileHash !== asset.lastProjectedHash) {
1580
+ syncDriftTensToFire.push(`Repo authoring file differs from DB baseline for ${asset.entryId} at ${authoringPath}; DB writeback skipped.`);
1581
+ logErr(`Warning: setup authoring drift for ${asset.entryId}; DB writeback skipped.`);
1582
+ continue;
1583
+ }
1584
+ }
1585
+ try {
1586
+ assertSetupWritePath(authoringPath, perimeterManifest);
1587
+ mkdirSync(dirname(authoringPath), { recursive: true });
1588
+ writeFileSync(authoringPath, nextAuthoringContent);
1589
+ filesWritten.push(relativeAuthoringPath);
1590
+ await kernelCall('setup.updateLastProjectedHash', {
1591
+ entryId: asset.entryId,
1592
+ hash: bodyHash,
1593
+ }).catch(() => null);
1594
+ authoringSyncState.assets[asset.entryId] = { path: relativeAuthoringPath, hash: bodyHash };
1595
+ authoringSyncStateChanged = true;
1596
+ }
1597
+ catch (err) {
1598
+ logErr(`Warning: could not write setup authoring file for ${asset.entryId} — ${err instanceof Error ? err.message : String(err)}`);
1599
+ }
1600
+ }
1601
+ if (authoringSyncStateChanged) {
1602
+ try {
1603
+ saveAuthoringSyncState(pbDir, authoringSyncState);
1604
+ }
1605
+ catch (err) {
1606
+ logErr(`Warning: could not persist setup authoring sync state — ${err instanceof Error ? err.message : String(err)}`);
1607
+ }
1608
+ }
1609
+ }
679
1610
  const writes = [
680
1611
  ...(contextContent ? [{ path: join(cwd, '.productbrain', 'context.md'), relative: '.productbrain/context.md', content: contextContent, dirs: join(cwd, '.productbrain'), isAdapter: false }] : []),
681
1612
  { path: join(cwd, '.productbrain', 'briefing.md'), relative: '.productbrain/briefing.md', content: briefingContent, isAdapter: false },
682
- { path: join(cwd, 'AGENTS.md'), relative: 'AGENTS.md', content: agentsContent, isAdapter: true, target: 'codex' },
683
- { path: join(cwd, 'CLAUDE.md'), relative: 'CLAUDE.md', content: claudeContent, isAdapter: true, target: 'claude' },
1613
+ { path: join(cwd, 'AGENTS.md'), relative: 'AGENTS.md', content: agentsContent, isAdapter: true, target: 'codex', augmentTarget: true },
1614
+ { path: join(cwd, 'CLAUDE.md'), relative: 'CLAUDE.md', content: claudeContent, isAdapter: true, target: 'claude', augmentTarget: true },
684
1615
  { path: join(cwd, '.cursor', 'rules', 'chain.mdc'), relative: '.cursor/rules/chain.mdc', content: cursorContent, dirs: join(cwd, '.cursor', 'rules'), isAdapter: true, target: 'cursor' },
685
1616
  { path: join(cwd, '.github', 'copilot-instructions.md'), relative: '.github/copilot-instructions.md', content: copilotContent, dirs: join(cwd, '.github'), isAdapter: true, target: 'copilot' },
1617
+ ...(boundaryManifestContent ? [{
1618
+ path: join(cwd, '.productbrain', 'generated', 'boundaries.json'),
1619
+ relative: '.productbrain/generated/boundaries.json',
1620
+ content: boundaryManifestContent,
1621
+ dirs: join(cwd, '.productbrain', 'generated'),
1622
+ isAdapter: false,
1623
+ }] : []),
686
1624
  ];
687
1625
  // Add Cursor skill copies (filtered by target)
688
1626
  const cursorProfile = resolveSurfaceProfile('cursor');
@@ -770,33 +1708,166 @@ export async function runHandshake(options = {}) {
770
1708
  target: 'claude',
771
1709
  });
772
1710
  }
773
- const forkedPaths = [];
1711
+ // 7a. WP-379 S5b: Resolve projection collisions before writing.
1712
+ // In apply mode, enumerate target dirs and unlink any auto-generated files
1713
+ // whose normalized name no longer matches any active asset from the server.
1714
+ // This prevents case-variant orphans from accumulating across handshakes.
1715
+ // Runs only when we have a DB asset list (usedDbSource) — without a DB source,
1716
+ // we can't determine which files are canonical vs. orphan.
1717
+ const collisionTensToFire = [];
1718
+ if (writeMode && usedDbSource) {
1719
+ const activeAssetNames = dbAssetRows
1720
+ .filter((a) => !a.disabledByOwner)
1721
+ .map((a) => a.name);
1722
+ const { collisionTens } = resolveProjectionCollision(cwd, activeAssetNames, log, logErr);
1723
+ collisionTensToFire.push(...collisionTens);
1724
+ }
1725
+ // ── WP-436 S3: Vocab projector — fetch workspace vocab context once per handshake ──
1726
+ // Source-side (.productbrain/skills/*.md + rules/*.md) stays tokenized.
1727
+ // The projector resolves {{vocab:...}} tokens before writing adapter output to disk
1728
+ // (.cursor/rules/, .claude/rules/, CLAUDE.md, AGENTS.md, .github/copilot-instructions.md).
1729
+ // Fail-open: if vocab fetch fails, skip resolution and write raw token (no breakage).
1730
+ const handshakeVocabCtx = await getOrFetchVocabCtx(config.apiKey, async () => {
1731
+ try {
1732
+ const vocab = await kernelCall('chain.getVocabulary', {});
1733
+ if (vocab?.collectionLabels || vocab?.collectionDefaults) {
1734
+ return {
1735
+ ...(vocab.collectionLabels ? { collectionLabels: vocab.collectionLabels } : {}),
1736
+ ...(vocab.collectionDefaults ? { collectionDefaults: vocab.collectionDefaults } : {}),
1737
+ };
1738
+ }
1739
+ return null;
1740
+ }
1741
+ catch {
1742
+ return null; // fail-open
1743
+ }
1744
+ });
1745
+ const userOwnedSkipped = [];
774
1746
  const projectedHashUpdates = new Map();
1747
+ const cleanBucketPaths = [];
1748
+ const tamperedBucket = [];
775
1749
  const recordProjectedHash = (entryId) => {
776
1750
  if (!applyMode || !entryId)
777
1751
  return;
1752
+ if (deferredAuthoringBaselineEntryIds.has(entryId))
1753
+ return;
778
1754
  const projection = dbProjectionHashes.get(entryId);
779
1755
  if (projection)
780
1756
  projectedHashUpdates.set(entryId, projection.hash);
781
1757
  };
1758
+ // TEN-2155: region augmentation context + symlink dedupe.
1759
+ // codexActive gates the AGENTS.md skills-index pointer (region-projections.ts).
1760
+ const regionCtx = { codexActive: allowedTargets.has('codex') };
1761
+ const malformedRegionPaths = [];
1762
+ // If two augment targets resolve to the same inode (e.g. CLAUDE.md symlinked to AGENTS.md),
1763
+ // augment only the first-seen (AGENTS.md precedes CLAUDE.md in `writes`) to avoid double-injection.
1764
+ const seenAugmentRealpaths = new Set();
1765
+ const augmentTargetSkip = new Set();
1766
+ for (const w of writes) {
1767
+ if (!w.augmentTarget || !w.target || !allowedTargets.has(w.target) || !existsSync(w.path))
1768
+ continue;
1769
+ let real;
1770
+ try {
1771
+ real = realpathSync(w.path);
1772
+ }
1773
+ catch {
1774
+ real = w.path;
1775
+ }
1776
+ if (seenAugmentRealpaths.has(real))
1777
+ augmentTargetSkip.add(w.path);
1778
+ else
1779
+ seenAugmentRealpaths.add(real);
1780
+ }
782
1781
  for (const w of writes) {
783
1782
  // Surface filtering: skip adapter writes for targets not in the allowed set
784
- if (allowedTargets && w.target && !allowedTargets.has(w.target)) {
1783
+ if (w.target && !allowedTargets.has(w.target)) {
785
1784
  filesSkipped.push({ path: w.relative, reason: `filtered (surface: ${w.target})` });
786
1785
  if (preview)
787
1786
  previewPlan.push({ path: w.relative, status: 'filtered' });
788
1787
  continue;
789
1788
  }
1789
+ // ── TEN-2155: augment user-owned CLAUDE.md / AGENTS.md with a marked PB region ──
1790
+ // Runs ahead of the legacy MARKER-keyed gates. Only existing augmentable/region-present
1791
+ // files are spliced here; absent + legacy-pb-managed fall through to the legacy path.
1792
+ if (w.augmentTarget) {
1793
+ const projection = REGION_PROJECTIONS[w.target];
1794
+ if (projection && existsSync(w.path)) {
1795
+ if (augmentTargetSkip.has(w.path)) {
1796
+ filesSkipped.push({ path: w.relative, reason: 'symlinked to another augment target — augmented once' });
1797
+ continue;
1798
+ }
1799
+ const disk = readFileSync(w.path, 'utf8');
1800
+ const cls = classifyAdapterFile(disk);
1801
+ if (cls === 'augmentable' || cls === 'region-present') {
1802
+ const eol = detectEol(disk);
1803
+ const region = replaceVocabTokens(projection.build(regionCtx, eol), handshakeVocabCtx);
1804
+ // Defense-in-depth: the composed region (post vocab-token resolution) must itself be a
1805
+ // single well-formed region. v1 content has no vocab tokens so this is a no-op, but it
1806
+ // guards the documented future override/vocab seam from injecting a stray sentinel/MARKER.
1807
+ if (classifyAdapterFile(region) !== 'region-present') {
1808
+ filesSkipped.push({ path: w.relative, reason: 'internal: composed PB region malformed — skipped to protect your file' });
1809
+ if (preview)
1810
+ previewPlan.push({ path: w.relative, status: 'needs-attention' });
1811
+ continue;
1812
+ }
1813
+ const candidate = cls === 'augmentable' ? spliceAppend(disk, region, eol) : spliceReplace(disk, region);
1814
+ if (candidate === disk) {
1815
+ filesSkipped.push({ path: w.relative, reason: 'unchanged' });
1816
+ if (preview)
1817
+ previewPlan.push({ path: w.relative, status: 'unchanged' });
1818
+ continue;
1819
+ }
1820
+ if (preview || dryRun) {
1821
+ filesWritten.push(w.relative + (dryRun ? ' (dry run)' : ''));
1822
+ if (preview)
1823
+ previewPlan.push({ path: w.relative, status: 'would-augment' });
1824
+ continue;
1825
+ }
1826
+ if (authorityPreviewOnly) {
1827
+ // apply + materialize observe/off: honor authority — do NOT write; mirror the legacy
1828
+ // preview-only skip so the report does not falsely list it as written.
1829
+ filesSkipped.push({ path: w.relative, reason: `preview-only (materialize: ${manifestStatus.mode})` });
1830
+ continue;
1831
+ }
1832
+ assertSetupWritePath(w.path, perimeterManifest);
1833
+ writeFileSync(w.path, candidate);
1834
+ filesWritten.push(w.relative);
1835
+ continue;
1836
+ }
1837
+ if (cls === 'malformed') {
1838
+ filesSkipped.push({ path: w.relative, reason: 'malformed PB region — left untouched; fix the pb:region sentinels' });
1839
+ if (preview)
1840
+ previewPlan.push({ path: w.relative, status: 'needs-attention' });
1841
+ malformedRegionPaths.push(w.relative);
1842
+ continue;
1843
+ }
1844
+ // cls 'pb-managed' → fall through to the legacy whole-file re-projection path below.
1845
+ // cls 'opt-out' (file carries the pb:no-augment sentinel — e.g. this repo's committed
1846
+ // constitution) → fall through too, landing on the legacy user-owned skip: left untouched,
1847
+ // never spliced. This is how a file declines augmentation without losing its user-owned status.
1848
+ }
1849
+ // absent file → fall through to legacy whole-file CREATE.
1850
+ }
790
1851
  if (w.isAdapter && !shouldWriteAdapter(w.path, force)) {
791
- filesSkipped.push({ path: w.relative, reason: 'exists without auto-generated marker (use --force to overwrite)' });
1852
+ // User-owned: a file at an adapter path without our auto-gen MARKER is the
1853
+ // user's own file. Leave it untouched — never overwrite, never treat as drift
1854
+ // or log a TEN (TEN-2150). Relayed to the connect screen via userOwnedSkipped.
1855
+ filesSkipped.push({ path: w.relative, reason: 'user-owned — left untouched (pb won\'t overwrite your file)' });
792
1856
  if (preview) {
793
- previewPlan.push({ path: w.relative, status: 'forked' });
1857
+ previewPlan.push({ path: w.relative, status: 'user-owned' });
794
1858
  }
795
1859
  else {
796
- forkedPaths.push(w.relative);
1860
+ userOwnedSkipped.push(w.relative);
797
1861
  }
798
1862
  continue;
799
1863
  }
1864
+ if (authorityPreviewOnly) {
1865
+ filesSkipped.push({
1866
+ path: w.relative,
1867
+ reason: `preview-only (materialize: ${manifestStatus.mode})`,
1868
+ });
1869
+ continue;
1870
+ }
800
1871
  if (preview || dryRun) {
801
1872
  // In preview/dry-run mode: check content to distinguish new/update/unchanged
802
1873
  if (existsSync(w.path)) {
@@ -821,11 +1892,44 @@ export async function runHandshake(options = {}) {
821
1892
  }
822
1893
  continue;
823
1894
  }
1895
+ // ── WP-421 S3: defer tampered adapter writes (doneWhen #17) ──────────────
1896
+ // For adapter projections that already exist on disk, classify into
1897
+ // pb-managed-clean / pb-managed-tampered. Tampered = MARKER present +
1898
+ // hash trailer mismatches body. Defer (do NOT overwrite); post-loop
1899
+ // resolution handles them. --force bypasses the tampered defer (legacy
1900
+ // semantic: explicit user opt-in to overwrite).
1901
+ if (w.isAdapter && !force) {
1902
+ const drift = classifyDriftBucket(w.path);
1903
+ if (drift && drift.bucket === 'pb-managed-tampered') {
1904
+ tamperedBucket.push({
1905
+ path: w.path,
1906
+ relative: w.relative,
1907
+ content: w.content,
1908
+ expectedHash: drift.expectedHash,
1909
+ actualHash: drift.actualHash,
1910
+ dirs: w.dirs,
1911
+ dbAssetEntryId: w.dbAssetEntryId,
1912
+ });
1913
+ // Do NOT write — defer until prompt/refusal resolves the file.
1914
+ continue;
1915
+ }
1916
+ if (drift && drift.bucket === 'pb-managed-clean') {
1917
+ cleanBucketPaths.push(w.relative);
1918
+ }
1919
+ }
1920
+ assertSetupWritePath(w.path, perimeterManifest);
824
1921
  if (w.dirs)
825
1922
  mkdirSync(w.dirs, { recursive: true });
1923
+ // WP-436 S3: resolve {{vocab:...}} tokens before writing projected adapter files.
1924
+ // Source-side (.productbrain/skills/*.md, rules/*.md) stays tokenized.
1925
+ // Only adapter projections (cursor/rules, claude/rules, CLAUDE.md, AGENTS.md, etc.) get resolved.
1926
+ // Fail-open: if vocabCtx is undefined, replaceVocabTokens falls back to canonicalKey literals.
1927
+ const resolvedContent = w.isAdapter
1928
+ ? replaceVocabTokens(w.content, handshakeVocabCtx)
1929
+ : w.content;
826
1930
  if (existsSync(w.path)) {
827
1931
  const current = readFileSync(w.path, 'utf8');
828
- const nextNormalized = normalizeHandshakeContentForComparison(w.content);
1932
+ const nextNormalized = normalizeHandshakeContentForComparison(resolvedContent);
829
1933
  const currentNormalized = normalizeHandshakeContentForComparison(current);
830
1934
  if (nextNormalized === currentNormalized) {
831
1935
  filesSkipped.push({ path: w.relative, reason: 'unchanged' });
@@ -833,7 +1937,7 @@ export async function runHandshake(options = {}) {
833
1937
  continue;
834
1938
  }
835
1939
  }
836
- writeFileSync(w.path, w.content);
1940
+ writeFileSync(w.path, resolvedContent);
837
1941
  filesWritten.push(w.relative);
838
1942
  recordProjectedHash(w.dbAssetEntryId);
839
1943
  }
@@ -850,26 +1954,420 @@ export async function runHandshake(options = {}) {
850
1954
  }
851
1955
  });
852
1956
  }
853
- // 8. Drift logging if apply mode encountered forked adapters and a session is active, log a draft TEN
854
- if (forkedPaths.length > 0) {
1957
+ // Ordering note: this refusal runs AFTER the projected-hash flush so that files legitimately
1958
+ // written THIS run still record their hashes; malformed files were never written (no hash to record).
1959
+ // TEN-2155: in non-interactive apply, a malformed PB region is a refusal, not a silent skip.
1960
+ // Gated on applyMode (NOT writeMode): a malformed region is a user-file INTEGRITY fault, so the
1961
+ // refusal fires on any headless `--apply` regardless of materialize authority (STD-263 invariant vi —
1962
+ // "headless → non-zero refusal", unqualified). writeMode would suppress it under observe/off, where
1963
+ // the malformed file still gets enumerated but the corruption signal would be silently dropped.
1964
+ if (applyMode && malformedRegionPaths.length > 0 && (options.noPrompt || !process.stdout.isTTY)) {
1965
+ throw new CLIError(`Malformed PB region in: ${malformedRegionPaths.join(', ')}`, {
1966
+ code: ErrorCode.VALIDATION_FAILED,
1967
+ category: 'validation',
1968
+ guidance: 'Fix the `<!-- pb:region:start -->` / `<!-- pb:region:end -->` sentinels (one balanced pair), then re-run `pb handshake`.',
1969
+ });
1970
+ }
1971
+ // ── WP-421 S3: tampered-bucket resolution (doneWhen #17) ────────────────────
1972
+ // Apply mode only. Tampered files were DEFERRED in the write loop above;
1973
+ // here we either prompt the user (interactive TTY) or refuse (headless).
1974
+ //
1975
+ // Headless = `--no-prompt` flag OR `process.stdout.isTTY === false`. When
1976
+ // headless: enumerate each tampered file to stderr, write a setup_receipt
1977
+ // row with kind='transition' (DEC-962) capturing `refusedTamperedFiles[]`,
1978
+ // then exit non-zero. NEVER auto-resolve. (#17 + exclusions: edits to
1979
+ // projection dirs are detected, NOT silently overwritten.)
1980
+ //
1981
+ // Interactive: for each tampered file, present adopt-or-revert. Adopt =
1982
+ // create a personal-scoped setup_asset draft from the tampered content
1983
+ // (see helper below). Revert = re-project canonical content over the
1984
+ // tampered file (write w.content to w.path).
1985
+ const adoptedTamperedPaths = [];
1986
+ const revertedTamperedPaths = [];
1987
+ if (writeMode && tamperedBucket.length > 0) {
1988
+ const headless = options.noPrompt === true || !process.stdout.isTTY;
1989
+ if (headless) {
1990
+ // ── Headless refusal path (doneWhen #17) ────────────────────────────────
1991
+ logErr('');
1992
+ logErr(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
1993
+ logErr('Headless mode (--no-prompt or no TTY) cannot resolve adopt-or-revert — refusing.');
1994
+ logErr('');
1995
+ const refusedTamperedFiles = tamperedBucket.map((t) => ({
1996
+ path: t.relative,
1997
+ expectedHash: t.expectedHash,
1998
+ actualHash: t.actualHash,
1999
+ }));
2000
+ for (const refused of refusedTamperedFiles) {
2001
+ // Per #17: stderr enumerates each tampered file as
2002
+ // {path, expectedHash, actualHash, bucket}.
2003
+ logErr(` ${JSON.stringify({ ...refused, bucket: 'pb-managed-tampered' })}`);
2004
+ }
2005
+ logErr('');
2006
+ logErr('Re-run interactively to resolve, or use --force to overwrite (data loss).');
2007
+ // Write the kind='transition' setup_receipt row. Fail-open on the
2008
+ // network/auth side: if the row cannot be written, we still exit non-zero
2009
+ // (the audit trail is best-effort; refusal is mandatory).
2010
+ try {
2011
+ const manifestStatus = readManifestStatus(pbDir);
2012
+ await kernelCall('setup.recordTamperRefusal', {
2013
+ mode: manifestStatus.mode,
2014
+ refusedTamperedFiles,
2015
+ });
2016
+ trackEvent('setup.transition.refused', {
2017
+ fileCount: refusedTamperedFiles.length,
2018
+ mode: manifestStatus.mode,
2019
+ });
2020
+ }
2021
+ catch (err) {
2022
+ trackEvent('setup.transition.refused.write_failed', {
2023
+ error: err instanceof Error ? err.message : String(err),
2024
+ });
2025
+ logErr(`Warning: could not record transition receipt — ${err instanceof Error ? err.message : String(err)}`);
2026
+ }
2027
+ // Surface the report counts before exit (visibility for CI logs).
2028
+ if (!quiet) {
2029
+ process.stdout.write('\n');
2030
+ process.stdout.write(formatHandshakeReport({
2031
+ filesWritten,
2032
+ filesSkipped,
2033
+ matchedEntries,
2034
+ searchQueries: uniqueQueries,
2035
+ repo,
2036
+ codexWarnings: codexWarnings.length > 0 ? codexWarnings : undefined,
2037
+ chainRulesStats: chainRulesStats ?? undefined,
2038
+ chainGaps: chainGaps.length > 0 ? chainGaps : undefined,
2039
+ adoptedCount: adoptedRulesCount,
2040
+ rejectedCount: rejectedRulesCount,
2041
+ personalRuleCount: personalRules.length > 0 ? personalRules.length : undefined,
2042
+ personalSkillCount: personalSkills.length > 0 ? personalSkills.length : undefined,
2043
+ registrySource,
2044
+ registryStale,
2045
+ userOwnedSkipped: userOwnedSkipped.length > 0 ? userOwnedSkipped : undefined,
2046
+ managedCleanCount: cleanBucketPaths.length || undefined,
2047
+ tamperedFiles: refusedTamperedFiles,
2048
+ }) + '\n');
2049
+ }
2050
+ // Exit non-zero per #17.
2051
+ process.exit(1);
2052
+ }
2053
+ // ── Interactive path: prompt adopt-or-revert per file ──────────────────────
2054
+ log('');
2055
+ log(`pb handshake: ${tamperedBucket.length} PB-managed projection file(s) were edited downstream of the auto-gen marker.`);
2056
+ log('You can ADOPT (capture your edits as a personal-scoped draft) or REVERT (overwrite with canonical content).');
2057
+ // Batch yes-to-all / no-to-all when the user has many tampered files.
2058
+ // Threshold: 5 (arbitrary; mirrors the typical handshake projection set).
2059
+ let batchChoice = null;
2060
+ if (tamperedBucket.length >= 5) {
2061
+ const useBatch = await promptConfirm({
2062
+ message: `Apply the same choice to all ${tamperedBucket.length} tampered files?`,
2063
+ initialValue: false,
2064
+ });
2065
+ if (useBatch) {
2066
+ const choice = await promptSelect({
2067
+ message: 'Apply to all:',
2068
+ options: [
2069
+ { value: 'adopt', label: 'Adopt all — capture each as a personal-scoped draft' },
2070
+ { value: 'revert', label: 'Revert all — overwrite with canonical content' },
2071
+ ],
2072
+ });
2073
+ batchChoice = choice === 'adopt' ? 'adopt-all' : 'revert-all';
2074
+ }
2075
+ }
2076
+ for (const tamper of tamperedBucket) {
2077
+ // Reverse-map the projection path back to the canonical authoring path.
2078
+ const reverse = canonicalPathForAnySurface(tamper.relative);
2079
+ const canonicalHint = reverse
2080
+ ? `Canonical authoring path: ${reverse.canonicalPath}`
2081
+ : (() => {
2082
+ // Telemetry + fallback message per surfaces/telemetry.ts.
2083
+ reportReverseMapMissing({ surface: 'unknown', projectionPath: tamper.relative }, logErr);
2084
+ return `Canonical authoring path: ${getReverseMapFallbackMessage()}`;
2085
+ })();
2086
+ log('');
2087
+ log(`Tampered: ${tamper.relative}`);
2088
+ log(` expected: ${tamper.expectedHash}`);
2089
+ log(` actual: ${tamper.actualHash}`);
2090
+ log(` ${canonicalHint}`);
2091
+ let action;
2092
+ if (batchChoice === 'adopt-all')
2093
+ action = 'adopt';
2094
+ else if (batchChoice === 'revert-all')
2095
+ action = 'revert';
2096
+ else {
2097
+ action = await promptSelect({
2098
+ message: 'Adopt or revert?',
2099
+ options: [
2100
+ { value: 'adopt', label: 'Adopt — capture this as a personal-scoped setup_asset draft' },
2101
+ { value: 'revert', label: 'Revert — overwrite with canonical content' },
2102
+ ],
2103
+ });
2104
+ }
2105
+ if (action === 'revert') {
2106
+ // Re-project canonical content over the tampered file.
2107
+ // WP-436 S3: resolve vocab tokens before writing (all tampered files are adapters).
2108
+ if (tamper.dirs)
2109
+ mkdirSync(tamper.dirs, { recursive: true });
2110
+ assertSetupWritePath(tamper.path, perimeterManifest);
2111
+ writeFileSync(tamper.path, replaceVocabTokens(tamper.content, handshakeVocabCtx));
2112
+ revertedTamperedPaths.push(tamper.relative);
2113
+ recordProjectedHash(tamper.dbAssetEntryId);
2114
+ trackEvent('setup.tampered.reverted', { path: tamper.relative });
2115
+ }
2116
+ else {
2117
+ // Adopt: capture the tampered content as a personal-scoped draft.
2118
+ // Per DEC-953 sync rules: personal scope = push (no fork required).
2119
+ // The mutation is best-effort — if the adopt write fails, the file is
2120
+ // kept on disk untouched and a warning is logged. Adopt does NOT
2121
+ // revert; it preserves the user's edits AND records them as a draft.
2122
+ const draftName = basename(tamper.relative).replace(/\.(md|mdc)$/, '') + ' (adopted)';
2123
+ try {
2124
+ const tamperedContent = readFileSync(tamper.path, 'utf8');
2125
+ const session = readSession();
2126
+ const caller = session ? kernelCallWithSession : kernelCall;
2127
+ await caller('setup.ingestSetupAsset', {
2128
+ entryId: `SETUP-ADOPTED-${Date.now()}-${draftName.replace(/\s+/g, '-')}`,
2129
+ name: draftName,
2130
+ description: `Adopted from tampered projection at ${tamper.relative} (WP-421 S3).`,
2131
+ body: tamperedContent,
2132
+ assetKind: tamper.relative.includes('/skills/') ? 'skill' : 'rule',
2133
+ triggers: [],
2134
+ semanticRefs: [],
2135
+ });
2136
+ adoptedTamperedPaths.push(tamper.relative);
2137
+ trackEvent('setup.tampered.adopted', { path: tamper.relative });
2138
+ }
2139
+ catch (err) {
2140
+ logErr(`Warning: could not adopt ${tamper.relative} as draft — ${err instanceof Error ? err.message : String(err)}`);
2141
+ trackEvent('setup.tampered.adopt_failed', {
2142
+ path: tamper.relative,
2143
+ error: err instanceof Error ? err.message : String(err),
2144
+ });
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+ // 8a. Dormant marker + .dormant rename (WP-379 S4 + WP-426 E4) — apply mode only.
2150
+ const dormantMarkedPaths = [];
2151
+ if (writeMode && dormantDbAssetRows.length > 0) {
2152
+ const dormantState = loadAuthoringSyncState(pbDir);
2153
+ let dormantStateChanged = false;
2154
+ for (const dormantAsset of dormantDbAssetRows) {
2155
+ for (const { path: filePath, surface } of deriveDormantFilePaths(dormantAsset, cwd)) {
2156
+ // Codex P2: deriveDormantFilePaths emits every known surface path. Honor the run's
2157
+ // target set (allowedTargets = the --surfaces selection, else all manifest surfaces)
2158
+ // so a `--surfaces cursor` run never renames .claude/.codex to .dormant, and surfaces
2159
+ // outside the manifest are skipped silently (no false "could not dormant-mark" warning).
2160
+ if (!allowedTargets.has(surface))
2161
+ continue;
2162
+ // FIX 4: use relative() instead of string-replace for correct cross-platform behaviour.
2163
+ const rel = relative(cwd, filePath);
2164
+ const alreadyReactivated = dormantState.dormantReactivated?.includes(rel) ?? false;
2165
+ const previouslyRenamed = dormantState.dormantRenamed?.includes(rel) ?? false;
2166
+ try {
2167
+ assertSetupWritePath(filePath, perimeterManifest);
2168
+ // WP-426 E4: BUG 1 fix — hands-off set.
2169
+ //
2170
+ // Step 1: permanently hands-off — user already reactivated this path on a
2171
+ // prior run. Skip silently every time until they re-lower or raise in PB.
2172
+ // (Task 7 raise-cleanup must later also prune dormantReactivated for raised
2173
+ // assets — see the `dormantReactivated` comment on AuthoringSyncState.)
2174
+ if (alreadyReactivated) {
2175
+ continue; // leave file untouched; no TEN (already fired on first detection)
2176
+ }
2177
+ // Step 2: FIRST detection of manual reactivation — we previously renamed
2178
+ // this to .dormant but the user renamed it back to a live file. Push the
2179
+ // drift TEN exactly once, add to the permanent hands-off set, remove from
2180
+ // dormantRenamed, leave file untouched.
2181
+ if (previouslyRenamed && existsSync(filePath) && !existsSync(`${filePath}.dormant`)) {
2182
+ syncDriftTensToFire.push(`Dormant asset ${dormantAsset.entryId} was manually reactivated at ${rel}; left untouched. Re-lower or raise it in PB to resync.`);
2183
+ logErr(`Warning: ${rel} was manually un-dormanted; leaving it in place (drift).`);
2184
+ dormantState.dormantReactivated = [...(dormantState.dormantReactivated ?? []), rel];
2185
+ dormantState.dormantRenamed = (dormantState.dormantRenamed ?? []).filter((p) => p !== rel);
2186
+ dormantStateChanged = true;
2187
+ continue;
2188
+ }
2189
+ // Step 3: normal lowering — write dormant marker + rename to .dormant.
2190
+ // WP-426 E4 spec: every rename TARGET must also pass the perimeter guard.
2191
+ // Check the post-rename .dormant path BEFORE any FS mutation, so a guard
2192
+ // failure can't leave a half-dormant file (marker appended but not renamed).
2193
+ assertSetupWritePath(`${filePath}.dormant`, perimeterManifest);
2194
+ const markerResult = writeDormantMarkerToFile(filePath);
2195
+ if (markerResult === 'written')
2196
+ log(`Dormant marker written: ${filePath}`);
2197
+ const renameResult = renameSurfaceForDormancy(filePath);
2198
+ if (renameResult === 'renamed' || renameResult === 'replaced') {
2199
+ // dormantMarkedPaths holds post-rename .dormant paths (not the original surface paths).
2200
+ dormantMarkedPaths.push(`${filePath}.dormant`);
2201
+ if (!dormantState.dormantRenamed?.includes(rel)) {
2202
+ dormantState.dormantRenamed = [...(dormantState.dormantRenamed ?? []), rel];
2203
+ dormantStateChanged = true;
2204
+ }
2205
+ log(`Dormant rename: ${filePath} → ${filePath}.dormant`);
2206
+ }
2207
+ else if (renameResult === 'drift') {
2208
+ // Codex P1: an edited .dormant already exists alongside the live file. Preserve
2209
+ // both and flag, rather than overwriting the user's edited dormant copy.
2210
+ syncDriftTensToFire.push(`Edited dormant copy ${rel}.dormant diverges from the live surface for ${dormantAsset.entryId}; left both in place. Resolve manually.`);
2211
+ logErr(`Warning: ${rel}.dormant diverges from the live file; not replacing (possible manual edit).`);
2212
+ }
2213
+ }
2214
+ catch (err) {
2215
+ logErr(`Warning: could not dormant-mark ${filePath} — ${err instanceof Error ? err.message : String(err)}`);
2216
+ }
2217
+ }
2218
+ }
2219
+ if (dormantMarkedPaths.length > 0) {
2220
+ log('Run `pb setup observe --purge` to remove these instead of dormant-renaming.');
2221
+ }
2222
+ if (dormantStateChanged) {
2223
+ try {
2224
+ saveAuthoringSyncState(pbDir, dormantState);
2225
+ }
2226
+ catch (err) {
2227
+ logErr(`Warning: could not persist dormancy state — ${err instanceof Error ? err.message : String(err)}`);
2228
+ }
2229
+ }
2230
+ }
2231
+ // 8b. Raise-cleanup (WP-426 E4): for assets active this run, drop any orphan
2232
+ // <surface>.dormant left by a prior lowering (active surface was re-projected
2233
+ // fresh above). User-edited dormant copies are preserved + flagged. Fail-open.
2234
+ if (writeMode) {
2235
+ const raiseState = loadAuthoringSyncState(pbDir);
2236
+ let raiseStateChanged = false;
2237
+ const dormantIds = new Set(dormantDbAssetRows.map((a) => a.entryId));
2238
+ const activeRows = dbAssetRows.filter((a) => !dormantIds.has(a.entryId) &&
2239
+ (a.assetKind === 'skill' || a.assetKind === 'rule' || a.assetKind === 'hook'));
2240
+ for (const asset of activeRows) {
2241
+ // Codex P2: if this asset's body fetch failed, the write loop did NOT reproject its
2242
+ // surfaces this run (same skip the authoring loop applies), so there's no fresh surface
2243
+ // to reconcile against — skip raise-cleanup to avoid acting on stale content.
2244
+ if (bodyFetchFailedEntryIds.has(asset.entryId))
2245
+ continue;
2246
+ for (const { path: filePath, surface } of deriveDormantFilePaths(asset, cwd)) {
2247
+ // Codex P2: honor the run's target set (parity with the dormant pass) — a
2248
+ // `--surfaces cursor` run must not touch .claude/.codex .dormant files, and surfaces
2249
+ // outside the manifest are skipped silently (no false "raise-cleanup failed" warning).
2250
+ if (!allowedTargets.has(surface))
2251
+ continue;
2252
+ const rel = relative(cwd, filePath);
2253
+ try {
2254
+ assertSetupWritePath(filePath, perimeterManifest); // WP-426 E4: perimeter before any FS mutation (parity with the dormant pass)
2255
+ // E4 spec parity (review): restoreSurfaceFromDormant deletes the .dormant
2256
+ // sibling, so guard that delete TARGET too — exactly as the lowering pass
2257
+ // guards both filePath and ${filePath}.dormant.
2258
+ assertSetupWritePath(`${filePath}.dormant`, perimeterManifest);
2259
+ const r = restoreSurfaceFromDormant(filePath);
2260
+ if (r === 'restored') {
2261
+ log(`Raised: removed superseded ${filePath}.dormant`);
2262
+ }
2263
+ else if (r === 'orphan-drift') {
2264
+ syncDriftTensToFire.push(`Dormant copy ${rel}.dormant was edited while dormant for ${asset.entryId}; preserved on raise. Resolve manually.`);
2265
+ logErr(`Warning: ${rel}.dormant differs from the freshly raised projection; left in place.`);
2266
+ }
2267
+ // Prune the registry only when no .dormant sibling remains for this surface:
2268
+ // • 'restored' removed it, or
2269
+ // • it was already gone (manual .dormant→live reactivation; 'skipped', no .dormant).
2270
+ // Carry-over obligation (Phase 3) + Codex P1: the manual-reactivation case must
2271
+ // leave the hands-off set so a future lowering can re-evaluate the surface.
2272
+ // Codex P2: but if a .dormant STILL exists (surface-filtered 'skipped' with no
2273
+ // fresh live file, or a preserved 'orphan-drift'), KEEP the registry evidence —
2274
+ // otherwise a later manual .dormant→live reactivation would not be recognized as
2275
+ // previouslyRenamed and would be silently re-dormanted on the next lowering.
2276
+ if (!existsSync(`${filePath}.dormant`)) {
2277
+ if (raiseState.dormantRenamed?.includes(rel)) {
2278
+ raiseState.dormantRenamed = raiseState.dormantRenamed.filter((p) => p !== rel);
2279
+ raiseStateChanged = true;
2280
+ }
2281
+ if (raiseState.dormantReactivated?.includes(rel)) {
2282
+ raiseState.dormantReactivated = raiseState.dormantReactivated.filter((p) => p !== rel);
2283
+ raiseStateChanged = true;
2284
+ }
2285
+ }
2286
+ }
2287
+ catch (err) {
2288
+ logErr(`Warning: raise-cleanup failed for ${filePath} — ${err instanceof Error ? err.message : String(err)}`);
2289
+ }
2290
+ }
2291
+ }
2292
+ if (raiseStateChanged) {
2293
+ try {
2294
+ saveAuthoringSyncState(pbDir, raiseState);
2295
+ }
2296
+ catch { /* fail-open */ }
2297
+ }
2298
+ }
2299
+ // 8. User-owned files left untouched are NOT drift (TEN-2150).
2300
+ // A marker-less file at an adapter path is the user's own file: handshake never
2301
+ // wrote it, so there is nothing to "sync" and no draft TEN is logged. These files
2302
+ // are surfaced benignly under "Skipped:" and relayed to the connect screen via
2303
+ // report.userOwnedSkipped. (The legitimate "tampered" and authoring-sync drift
2304
+ // signals below are unaffected.)
2305
+ if (syncDriftTensToFire.length > 0) {
855
2306
  const session = readSession();
856
2307
  if (session) {
857
- const names = forkedPaths.join(', ');
858
- kernelCallWithSession('chain.createEntry', {
859
- collectionSlug: 'tensions',
860
- name: `TEN: handshake drift — ${forkedPaths.length} adapter(s) forked, sync blocked`,
861
- status: 'draft',
862
- data: {
863
- description: `pb handshake --apply encountered forked adapters that blocked sync. Files: ${names}. Use --force to overwrite or resolve drift manually.`,
864
- },
865
- sessionId: session.sessionId,
866
- createdBy: `agent:${session.sessionId}`,
867
- }).catch(() => { });
2308
+ for (const driftDescription of syncDriftTensToFire) {
2309
+ kernelCallWithSession('chain.createEntry', {
2310
+ collectionSlug: 'tensions',
2311
+ name: 'TEN: setup authoring sync drift — repo wins',
2312
+ status: 'draft',
2313
+ data: {
2314
+ kind: 'drift',
2315
+ description: driftDescription,
2316
+ },
2317
+ sessionId: session.sessionId,
2318
+ createdBy: `agent:${session.sessionId}`,
2319
+ }).catch(() => { });
2320
+ }
2321
+ }
2322
+ }
2323
+ // 8. Case-collision TENs (WP-379 S5b).
2324
+ // These are distinct from drift TENs: they record ambiguous filename collisions
2325
+ // where the "newest mtime wins" heuristic was applied. They always fire on a
2326
+ // detected collision (collision is a data quality issue, not a drift issue).
2327
+ if (collisionTensToFire.length > 0) {
2328
+ const session = readSession();
2329
+ if (session) {
2330
+ for (const tenDescription of collisionTensToFire) {
2331
+ kernelCallWithSession('chain.createEntry', {
2332
+ collectionSlug: 'tensions',
2333
+ name: `TEN: handshake case-collision — ambiguous filename resolved by mtime`,
2334
+ // Collision audit TENs intentionally stay draft — they need explicit human review,
2335
+ // not auto-commit, even in Open mode (mirrors smart-capture.ts recordCommitFailure).
2336
+ status: 'draft',
2337
+ data: { description: tenDescription },
2338
+ sessionId: session.sessionId,
2339
+ createdBy: `agent:${session.sessionId}`,
2340
+ }).catch(() => { });
2341
+ }
868
2342
  }
869
2343
  }
870
2344
  // 8b. Setup receipt — record which assets were materialized (apply mode only)
871
2345
  // Fail-open: receipt write is advisory, never blocks the handshake.
872
2346
  if (applyMode) {
2347
+ const session = readSession();
2348
+ const caller = session ? kernelCallWithSession : kernelCall;
2349
+ try {
2350
+ const currentState = await caller('setup.getCurrentSetupState', {});
2351
+ const fromMode = currentState?.effectiveMode ?? 'observe';
2352
+ if (fromMode !== manifestStatus.mode) {
2353
+ await caller('setup.recordTransition', {
2354
+ fromMode,
2355
+ toMode: manifestStatus.mode,
2356
+ parseStatus: manifestStatus.parseStatus,
2357
+ surfaces: manifestStatus.surfaces,
2358
+ lock: manifestStatus.lock,
2359
+ });
2360
+ if (modeRank(manifestStatus.mode) < modeRank(fromMode)) {
2361
+ trackEvent('setup.transition.lowered', { fromMode, toMode: manifestStatus.mode });
2362
+ }
2363
+ }
2364
+ }
2365
+ catch (err) {
2366
+ trackEvent('setup.transition.write_failed', { error: err instanceof Error ? err.message : String(err) });
2367
+ logErr(`Warning: could not record setup transition — ${err instanceof Error ? err.message : String(err)}`);
2368
+ }
2369
+ }
2370
+ if (writeMode) {
873
2371
  const session = readSession();
874
2372
  const caller = session ? kernelCallWithSession : kernelCall;
875
2373
  try {
@@ -901,11 +2399,22 @@ export async function runHandshake(options = {}) {
901
2399
  registryStale,
902
2400
  preview: preview ? true : undefined,
903
2401
  previewPlan: preview && previewPlan.length > 0 ? previewPlan : undefined,
904
- driftConflicts: forkedPaths.length > 0 ? forkedPaths : undefined,
2402
+ userOwnedSkipped: userOwnedSkipped.length > 0 ? userOwnedSkipped : undefined,
2403
+ // WP-421 S3: three-bucket drift report (doneWhen #17). PB-managed-clean is
2404
+ // the count of files whose marker + hash matched. Tampered files were
2405
+ // resolved (adopted/reverted) above and are reported separately.
2406
+ managedCleanCount: cleanBucketPaths.length > 0 ? cleanBucketPaths.length : undefined,
2407
+ adoptedTamperedPaths: adoptedTamperedPaths.length > 0 ? adoptedTamperedPaths : undefined,
2408
+ revertedTamperedPaths: revertedTamperedPaths.length > 0 ? revertedTamperedPaths : undefined,
905
2409
  };
906
2410
  if (!quiet) {
907
2411
  process.stdout.write('\n');
908
2412
  process.stdout.write(formatHandshakeReport(report) + '\n');
909
2413
  }
2414
+ // Return the report so non-UI callers (e.g. prepareConnectContext) can surface
2415
+ // skipped/user-owned files without re-deriving the skip logic (TEN-2107).
2416
+ return report;
910
2417
  }
2418
+ // WP-426 E3/E4: exported test-only surface (not part of the public CLI API).
2419
+ export const __test = { setupAuthoringPath, renameSurfaceForDormancy, restoreSurfaceFromDormant, loadAuthoringSyncState, MARKER };
911
2420
  //# sourceMappingURL=handshake.js.map