wicked-crew 0.7.39 → 0.7.45

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 (380) hide show
  1. package/dist/api/audit.d.ts +3 -0
  2. package/dist/api/audit.d.ts.map +1 -1
  3. package/dist/api/audit.js +2 -0
  4. package/dist/api/audit.js.map +1 -1
  5. package/dist/api/capture.d.ts +149 -0
  6. package/dist/api/capture.d.ts.map +1 -0
  7. package/dist/api/capture.js +330 -0
  8. package/dist/api/capture.js.map +1 -0
  9. package/dist/api/chat-citations.d.ts +174 -0
  10. package/dist/api/chat-citations.d.ts.map +1 -0
  11. package/dist/api/chat-citations.js +528 -0
  12. package/dist/api/chat-citations.js.map +1 -0
  13. package/dist/api/chat-scope.d.ts +37 -1
  14. package/dist/api/chat-scope.d.ts.map +1 -1
  15. package/dist/api/chat-scope.js +131 -7
  16. package/dist/api/chat-scope.js.map +1 -1
  17. package/dist/api/chat-transcripts.d.ts +20 -1
  18. package/dist/api/chat-transcripts.d.ts.map +1 -1
  19. package/dist/api/chat-transcripts.js +29 -0
  20. package/dist/api/chat-transcripts.js.map +1 -1
  21. package/dist/api/delivery-cache.d.ts +2 -2
  22. package/dist/api/delivery-freeze.d.ts +66 -0
  23. package/dist/api/delivery-freeze.d.ts.map +1 -0
  24. package/dist/api/delivery-freeze.js +104 -0
  25. package/dist/api/delivery-freeze.js.map +1 -0
  26. package/dist/api/delivery-index.d.ts +53 -9
  27. package/dist/api/delivery-index.d.ts.map +1 -1
  28. package/dist/api/delivery-index.js +115 -22
  29. package/dist/api/delivery-index.js.map +1 -1
  30. package/dist/api/demo.d.ts +90 -0
  31. package/dist/api/demo.d.ts.map +1 -0
  32. package/dist/api/demo.js +492 -0
  33. package/dist/api/demo.js.map +1 -0
  34. package/dist/api/diagnostics.d.ts +64 -1
  35. package/dist/api/diagnostics.d.ts.map +1 -1
  36. package/dist/api/diagnostics.js +129 -1
  37. package/dist/api/diagnostics.js.map +1 -1
  38. package/dist/api/eval-sample.d.ts +6 -6
  39. package/dist/api/governance-steering.d.ts +2 -2
  40. package/dist/api/mcp.d.ts +282 -0
  41. package/dist/api/mcp.d.ts.map +1 -0
  42. package/dist/api/mcp.js +521 -0
  43. package/dist/api/mcp.js.map +1 -0
  44. package/dist/api/plan-schema.d.ts +56 -0
  45. package/dist/api/plan-schema.d.ts.map +1 -0
  46. package/dist/api/plan-schema.js +26 -0
  47. package/dist/api/plan-schema.js.map +1 -0
  48. package/dist/api/roster-standing.d.ts +15 -2
  49. package/dist/api/roster-standing.d.ts.map +1 -1
  50. package/dist/api/roster-standing.js +55 -3
  51. package/dist/api/roster-standing.js.map +1 -1
  52. package/dist/api/routes.d.ts +647 -59
  53. package/dist/api/routes.d.ts.map +1 -1
  54. package/dist/api/routes.js +906 -146
  55. package/dist/api/routes.js.map +1 -1
  56. package/dist/api/run-files.d.ts.map +1 -1
  57. package/dist/api/run-files.js +31 -2
  58. package/dist/api/run-files.js.map +1 -1
  59. package/dist/api/run-liveness.d.ts +41 -0
  60. package/dist/api/run-liveness.d.ts.map +1 -0
  61. package/dist/api/run-liveness.js +166 -0
  62. package/dist/api/run-liveness.js.map +1 -0
  63. package/dist/api/run-timing-index.d.ts +10 -0
  64. package/dist/api/run-timing-index.d.ts.map +1 -1
  65. package/dist/api/run-timing-index.js +24 -0
  66. package/dist/api/run-timing-index.js.map +1 -1
  67. package/dist/api/seat-health.d.ts.map +1 -1
  68. package/dist/api/seat-health.js +20 -4
  69. package/dist/api/seat-health.js.map +1 -1
  70. package/dist/api/seat-probe.d.ts +141 -0
  71. package/dist/api/seat-probe.d.ts.map +1 -0
  72. package/dist/api/seat-probe.js +257 -0
  73. package/dist/api/seat-probe.js.map +1 -0
  74. package/dist/api/seat-record.d.ts +40 -0
  75. package/dist/api/seat-record.d.ts.map +1 -0
  76. package/dist/api/seat-record.js +218 -0
  77. package/dist/api/seat-record.js.map +1 -0
  78. package/dist/api/seat-signin.d.ts.map +1 -1
  79. package/dist/api/seat-signin.js +3 -1
  80. package/dist/api/seat-signin.js.map +1 -1
  81. package/dist/api/seat-standing.d.ts +24 -4
  82. package/dist/api/seat-standing.d.ts.map +1 -1
  83. package/dist/api/seat-standing.js +50 -3
  84. package/dist/api/seat-standing.js.map +1 -1
  85. package/dist/api/server.d.ts +34 -48
  86. package/dist/api/server.d.ts.map +1 -1
  87. package/dist/api/server.js +316 -116
  88. package/dist/api/server.js.map +1 -1
  89. package/dist/api/skills.d.ts +3 -1
  90. package/dist/api/skills.d.ts.map +1 -1
  91. package/dist/api/skills.js +7 -2
  92. package/dist/api/skills.js.map +1 -1
  93. package/dist/api/stall-watchdog.d.ts +34 -3
  94. package/dist/api/stall-watchdog.d.ts.map +1 -1
  95. package/dist/api/stall-watchdog.js +107 -8
  96. package/dist/api/stall-watchdog.js.map +1 -1
  97. package/dist/api/steering-landing.d.ts +6 -6
  98. package/dist/api/steering-landing.d.ts.map +1 -1
  99. package/dist/api/steering-landing.js +7 -9
  100. package/dist/api/steering-landing.js.map +1 -1
  101. package/dist/api/testing.d.ts +6 -4
  102. package/dist/api/testing.d.ts.map +1 -1
  103. package/dist/api/testing.js +82 -47
  104. package/dist/api/testing.js.map +1 -1
  105. package/dist/campaign/boot-resume.d.ts +34 -0
  106. package/dist/campaign/boot-resume.d.ts.map +1 -0
  107. package/dist/campaign/boot-resume.js +45 -0
  108. package/dist/campaign/boot-resume.js.map +1 -0
  109. package/dist/campaigns/rollup.d.ts +4 -1
  110. package/dist/campaigns/rollup.d.ts.map +1 -1
  111. package/dist/campaigns/rollup.js +6 -1
  112. package/dist/campaigns/rollup.js.map +1 -1
  113. package/dist/campaigns/routes.d.ts +2 -2
  114. package/dist/cli/bearer.d.ts +27 -1
  115. package/dist/cli/bearer.d.ts.map +1 -1
  116. package/dist/cli/bearer.js +62 -2
  117. package/dist/cli/bearer.js.map +1 -1
  118. package/dist/cli/governance.d.ts +11 -0
  119. package/dist/cli/governance.d.ts.map +1 -1
  120. package/dist/cli/governance.js +8 -2
  121. package/dist/cli/governance.js.map +1 -1
  122. package/dist/cli/index.d.ts.map +1 -1
  123. package/dist/cli/index.js +140 -75
  124. package/dist/cli/index.js.map +1 -1
  125. package/dist/cli/mcp.d.ts +11 -0
  126. package/dist/cli/mcp.d.ts.map +1 -1
  127. package/dist/cli/mcp.js +40 -9
  128. package/dist/cli/mcp.js.map +1 -1
  129. package/dist/cli/service.d.ts +78 -0
  130. package/dist/cli/service.d.ts.map +1 -0
  131. package/dist/cli/service.js +334 -0
  132. package/dist/cli/service.js.map +1 -0
  133. package/dist/core/adapter.d.ts +247 -14
  134. package/dist/core/adapter.d.ts.map +1 -1
  135. package/dist/core/adapter.js +638 -161
  136. package/dist/core/adapter.js.map +1 -1
  137. package/dist/core/bridge-reaper.d.ts +4 -1
  138. package/dist/core/bridge-reaper.d.ts.map +1 -1
  139. package/dist/core/bridge-reaper.js +15 -1
  140. package/dist/core/bridge-reaper.js.map +1 -1
  141. package/dist/core/bus-notice.d.ts +27 -0
  142. package/dist/core/bus-notice.d.ts.map +1 -0
  143. package/dist/core/bus-notice.js +24 -0
  144. package/dist/core/bus-notice.js.map +1 -0
  145. package/dist/core/bus.d.ts +99 -0
  146. package/dist/core/bus.d.ts.map +1 -0
  147. package/dist/core/bus.js +176 -0
  148. package/dist/core/bus.js.map +1 -0
  149. package/dist/core/deliver-exclusions.d.ts +71 -0
  150. package/dist/core/deliver-exclusions.d.ts.map +1 -0
  151. package/dist/core/deliver-exclusions.js +110 -0
  152. package/dist/core/deliver-exclusions.js.map +1 -0
  153. package/dist/core/deliver-text.d.ts +61 -14
  154. package/dist/core/deliver-text.d.ts.map +1 -1
  155. package/dist/core/deliver-text.js +178 -30
  156. package/dist/core/deliver-text.js.map +1 -1
  157. package/dist/core/deliver-triage.d.ts +11 -2
  158. package/dist/core/deliver-triage.d.ts.map +1 -1
  159. package/dist/core/deliver-triage.js +19 -1
  160. package/dist/core/deliver-triage.js.map +1 -1
  161. package/dist/core/deliver.d.ts +161 -3
  162. package/dist/core/deliver.d.ts.map +1 -1
  163. package/dist/core/deliver.js +463 -25
  164. package/dist/core/deliver.js.map +1 -1
  165. package/dist/core/deliverable-floor.d.ts.map +1 -1
  166. package/dist/core/deliverable-floor.js +5 -7
  167. package/dist/core/deliverable-floor.js.map +1 -1
  168. package/dist/core/engine-bus.d.ts +40 -0
  169. package/dist/core/engine-bus.d.ts.map +1 -0
  170. package/dist/core/engine-bus.js +27 -0
  171. package/dist/core/engine-bus.js.map +1 -0
  172. package/dist/core/estate-mcp-client.d.ts +87 -5
  173. package/dist/core/estate-mcp-client.d.ts.map +1 -1
  174. package/dist/core/estate-mcp-client.js +90 -11
  175. package/dist/core/estate-mcp-client.js.map +1 -1
  176. package/dist/core/linked-issues.d.ts +46 -0
  177. package/dist/core/linked-issues.d.ts.map +1 -0
  178. package/dist/core/linked-issues.js +123 -0
  179. package/dist/core/linked-issues.js.map +1 -0
  180. package/dist/core/run-identity.d.ts +100 -0
  181. package/dist/core/run-identity.d.ts.map +1 -0
  182. package/dist/core/run-identity.js +178 -0
  183. package/dist/core/run-identity.js.map +1 -0
  184. package/dist/core/shutdown.d.ts +16 -0
  185. package/dist/core/shutdown.d.ts.map +1 -0
  186. package/dist/core/shutdown.js +39 -0
  187. package/dist/core/shutdown.js.map +1 -0
  188. package/dist/core/types.d.ts +15 -1
  189. package/dist/core/types.d.ts.map +1 -1
  190. package/dist/core/types.js +4 -0
  191. package/dist/core/types.js.map +1 -1
  192. package/dist/interactive/bridge-pool.d.ts +67 -3
  193. package/dist/interactive/bridge-pool.d.ts.map +1 -1
  194. package/dist/interactive/bridge-pool.js +129 -13
  195. package/dist/interactive/bridge-pool.js.map +1 -1
  196. package/dist/interactive/bridge-root.d.ts +9 -0
  197. package/dist/interactive/bridge-root.d.ts.map +1 -1
  198. package/dist/interactive/bridge-root.js +11 -4
  199. package/dist/interactive/bridge-root.js.map +1 -1
  200. package/dist/interactive/chat-events.d.ts +14 -9
  201. package/dist/interactive/chat-events.d.ts.map +1 -1
  202. package/dist/interactive/chat-events.js +55 -57
  203. package/dist/interactive/chat-events.js.map +1 -1
  204. package/dist/interactive/council-outcome.d.ts +1 -0
  205. package/dist/interactive/council-outcome.d.ts.map +1 -1
  206. package/dist/interactive/council-outcome.js +33 -0
  207. package/dist/interactive/council-outcome.js.map +1 -1
  208. package/dist/interactive/draft-events.d.ts +36 -17
  209. package/dist/interactive/draft-events.d.ts.map +1 -1
  210. package/dist/interactive/draft-events.js +121 -92
  211. package/dist/interactive/draft-events.js.map +1 -1
  212. package/dist/interactive/draft-skill.d.ts +59 -0
  213. package/dist/interactive/draft-skill.d.ts.map +1 -1
  214. package/dist/interactive/draft-skill.js +233 -0
  215. package/dist/interactive/draft-skill.js.map +1 -1
  216. package/dist/interactive/edit-events.d.ts +1 -14
  217. package/dist/interactive/edit-events.d.ts.map +1 -1
  218. package/dist/interactive/edit-events.js +37 -70
  219. package/dist/interactive/edit-events.js.map +1 -1
  220. package/dist/interactive/proxy-routes.d.ts +2 -2
  221. package/dist/interactive/proxy-routes.d.ts.map +1 -1
  222. package/dist/interactive/proxy-routes.js +4 -13
  223. package/dist/interactive/proxy-routes.js.map +1 -1
  224. package/dist/interactive/ws-relay.d.ts +9 -10
  225. package/dist/interactive/ws-relay.d.ts.map +1 -1
  226. package/dist/interactive/ws-relay.js +23 -50
  227. package/dist/interactive/ws-relay.js.map +1 -1
  228. package/dist/mcp/broker.d.ts +88 -0
  229. package/dist/mcp/broker.d.ts.map +1 -0
  230. package/dist/mcp/broker.js +307 -0
  231. package/dist/mcp/broker.js.map +1 -0
  232. package/dist/mcp/call-records.d.ts +61 -0
  233. package/dist/mcp/call-records.d.ts.map +1 -0
  234. package/dist/mcp/call-records.js +135 -0
  235. package/dist/mcp/call-records.js.map +1 -0
  236. package/dist/mcp/classify.d.ts +45 -0
  237. package/dist/mcp/classify.d.ts.map +1 -0
  238. package/dist/mcp/classify.js +83 -0
  239. package/dist/mcp/classify.js.map +1 -0
  240. package/dist/mcp/discovery.d.ts +24 -0
  241. package/dist/mcp/discovery.d.ts.map +1 -0
  242. package/dist/mcp/discovery.js +78 -0
  243. package/dist/mcp/discovery.js.map +1 -0
  244. package/dist/mcp/invoke.d.ts +36 -0
  245. package/dist/mcp/invoke.d.ts.map +1 -0
  246. package/dist/mcp/invoke.js +80 -0
  247. package/dist/mcp/invoke.js.map +1 -0
  248. package/dist/mcp/policies.d.ts +110 -0
  249. package/dist/mcp/policies.d.ts.map +1 -0
  250. package/dist/mcp/policies.js +400 -0
  251. package/dist/mcp/policies.js.map +1 -0
  252. package/dist/mcp/probe.d.ts +66 -0
  253. package/dist/mcp/probe.d.ts.map +1 -0
  254. package/dist/mcp/probe.js +116 -0
  255. package/dist/mcp/probe.js.map +1 -0
  256. package/dist/mcp/redact.d.ts +34 -0
  257. package/dist/mcp/redact.d.ts.map +1 -0
  258. package/dist/mcp/redact.js +84 -0
  259. package/dist/mcp/redact.js.map +1 -0
  260. package/dist/mcp/registry-store.d.ts +69 -0
  261. package/dist/mcp/registry-store.d.ts.map +1 -0
  262. package/dist/mcp/registry-store.js +82 -0
  263. package/dist/mcp/registry-store.js.map +1 -0
  264. package/dist/mcp/registry.d.ts +147 -0
  265. package/dist/mcp/registry.d.ts.map +1 -0
  266. package/dist/mcp/registry.js +509 -0
  267. package/dist/mcp/registry.js.map +1 -0
  268. package/dist/mcp/rest.d.ts +111 -0
  269. package/dist/mcp/rest.d.ts.map +1 -0
  270. package/dist/mcp/rest.js +606 -0
  271. package/dist/mcp/rest.js.map +1 -0
  272. package/dist/mcp/secrets.d.ts +66 -0
  273. package/dist/mcp/secrets.d.ts.map +1 -0
  274. package/dist/mcp/secrets.js +180 -0
  275. package/dist/mcp/secrets.js.map +1 -0
  276. package/dist/mcp/tool-list.d.ts +25 -0
  277. package/dist/mcp/tool-list.d.ts.map +1 -0
  278. package/dist/mcp/tool-list.js +62 -0
  279. package/dist/mcp/tool-list.js.map +1 -0
  280. package/dist/mcp/upstream-error.d.ts +12 -0
  281. package/dist/mcp/upstream-error.d.ts.map +1 -0
  282. package/dist/mcp/upstream-error.js +14 -0
  283. package/dist/mcp/upstream-error.js.map +1 -0
  284. package/dist/mcp/usage.d.ts +31 -0
  285. package/dist/mcp/usage.d.ts.map +1 -0
  286. package/dist/mcp/usage.js +159 -0
  287. package/dist/mcp/usage.js.map +1 -0
  288. package/dist/presets/routes.d.ts +53 -0
  289. package/dist/presets/routes.d.ts.map +1 -0
  290. package/dist/presets/routes.js +115 -0
  291. package/dist/presets/routes.js.map +1 -0
  292. package/dist/projects/activity.d.ts +6 -18
  293. package/dist/projects/activity.d.ts.map +1 -1
  294. package/dist/projects/activity.js +17 -60
  295. package/dist/projects/activity.js.map +1 -1
  296. package/dist/projects/events.d.ts +6 -5
  297. package/dist/projects/events.d.ts.map +1 -1
  298. package/dist/projects/events.js +33 -58
  299. package/dist/projects/events.js.map +1 -1
  300. package/dist/projects/graph.d.ts +11 -0
  301. package/dist/projects/graph.d.ts.map +1 -1
  302. package/dist/projects/graph.js +12 -0
  303. package/dist/projects/graph.js.map +1 -1
  304. package/dist/projects/routes.d.ts.map +1 -1
  305. package/dist/projects/routes.js +19 -0
  306. package/dist/projects/routes.js.map +1 -1
  307. package/dist/projects/state-home-preflight.d.ts.map +1 -1
  308. package/dist/projects/state-home-preflight.js +7 -0
  309. package/dist/projects/state-home-preflight.js.map +1 -1
  310. package/dist/projects/state-home-registry.d.ts.map +1 -1
  311. package/dist/projects/state-home-registry.js +9 -1
  312. package/dist/projects/state-home-registry.js.map +1 -1
  313. package/dist/qe/acceptance.d.ts +28 -28
  314. package/dist/qe/acceptance.d.ts.map +1 -1
  315. package/dist/qe/acceptance.js +60 -61
  316. package/dist/qe/acceptance.js.map +1 -1
  317. package/dist/qe/test-sets.d.ts +3 -5
  318. package/dist/qe/test-sets.d.ts.map +1 -1
  319. package/dist/qe/test-sets.js +5 -7
  320. package/dist/qe/test-sets.js.map +1 -1
  321. package/dist/skills/installed.d.ts +51 -0
  322. package/dist/skills/installed.d.ts.map +1 -0
  323. package/dist/skills/installed.js +46 -0
  324. package/dist/skills/installed.js.map +1 -0
  325. package/dist/skills/phase-skill-gaps.d.ts +108 -0
  326. package/dist/skills/phase-skill-gaps.d.ts.map +1 -0
  327. package/dist/skills/phase-skill-gaps.js +199 -0
  328. package/dist/skills/phase-skill-gaps.js.map +1 -0
  329. package/dist/skills/runtime.d.ts +1 -1
  330. package/dist/skills/runtime.d.ts.map +1 -1
  331. package/dist/skills/runtime.js +2 -1
  332. package/dist/skills/runtime.js.map +1 -1
  333. package/dist/standing-orders/evaluator.d.ts +113 -0
  334. package/dist/standing-orders/evaluator.d.ts.map +1 -0
  335. package/dist/standing-orders/evaluator.js +225 -0
  336. package/dist/standing-orders/evaluator.js.map +1 -0
  337. package/dist/standing-orders/history.d.ts +58 -0
  338. package/dist/standing-orders/history.d.ts.map +1 -0
  339. package/dist/standing-orders/history.js +117 -0
  340. package/dist/standing-orders/history.js.map +1 -0
  341. package/dist/standing-orders/parse.d.ts +19 -0
  342. package/dist/standing-orders/parse.d.ts.map +1 -0
  343. package/dist/standing-orders/parse.js +75 -0
  344. package/dist/standing-orders/parse.js.map +1 -0
  345. package/dist/standing-orders/routes.d.ts +180 -0
  346. package/dist/standing-orders/routes.d.ts.map +1 -0
  347. package/dist/standing-orders/routes.js +95 -0
  348. package/dist/standing-orders/routes.js.map +1 -0
  349. package/dist/standing-orders/store.d.ts +154 -0
  350. package/dist/standing-orders/store.d.ts.map +1 -0
  351. package/dist/standing-orders/store.js +189 -0
  352. package/dist/standing-orders/store.js.map +1 -0
  353. package/dist/studio/assets/index-CvV5qITf.css +32 -0
  354. package/dist/studio/assets/index-jCOy6Ns7.js +568 -0
  355. package/dist/studio/index.html +3 -3
  356. package/dist/studio/testid-inventory.json +3307 -897
  357. package/dist/team/key.d.ts +5 -0
  358. package/dist/team/key.d.ts.map +1 -0
  359. package/dist/team/key.js +21 -0
  360. package/dist/team/key.js.map +1 -0
  361. package/dist/team/routes.d.ts +196 -0
  362. package/dist/team/routes.d.ts.map +1 -0
  363. package/dist/team/routes.js +274 -0
  364. package/dist/team/routes.js.map +1 -0
  365. package/dist/team/ws-relay.d.ts +39 -0
  366. package/dist/team/ws-relay.d.ts.map +1 -0
  367. package/dist/team/ws-relay.js +63 -0
  368. package/dist/team/ws-relay.js.map +1 -0
  369. package/endpoint-manifest.json +502 -6
  370. package/package.json +6 -6
  371. package/dist/interactive/demo-events.d.ts +0 -367
  372. package/dist/interactive/demo-events.d.ts.map +0 -1
  373. package/dist/interactive/demo-events.js +0 -1293
  374. package/dist/interactive/demo-events.js.map +0 -1
  375. package/dist/qe/gate-events.d.ts +0 -113
  376. package/dist/qe/gate-events.d.ts.map +0 -1
  377. package/dist/qe/gate-events.js +0 -172
  378. package/dist/qe/gate-events.js.map +0 -1
  379. package/dist/studio/assets/index-BRDAN35i.js +0 -548
  380. package/dist/studio/assets/index-Drfcsy8b.css +0 -32
@@ -1,3 +1,5 @@
1
+ import { busSeamsOffWarning, busUnavailableWarning } from '../core/bus-notice.js';
2
+ import { PlanSchema, toLaunchPlan } from './plan-schema.js';
1
3
  import { z } from 'zod';
2
4
  import { listRequirements, getRequirement, patchRequirement } from './requirements.js';
3
5
  import { randomUUID } from 'node:crypto';
@@ -5,12 +7,12 @@ import { readFileSync, existsSync } from 'node:fs';
5
7
  import { promises as fsp } from 'node:fs';
6
8
  import { isAbsolute, join, relative, resolve } from 'node:path';
7
9
  import { fileURLToPath } from 'node:url';
8
- import { CampaignsUnsupportedError, ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, SteeringUnsupportedError, humanGatePhaseIds, settingsFilePath } from '../core/adapter.js';
10
+ import { CampaignsUnsupportedError, ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, GovernanceReplayUnsupportedError, PlanLaunchUnsupportedError, SteeringUnsupportedError, humanGatePhaseIds, settingsFilePath } from '../core/adapter.js';
9
11
  import { codeGraphDb, codeGraphErrorStatus, requirementsGraph } from '../core/repoPaths.js';
10
12
  import { resolveCursorUnit } from '../core/cursor.js';
11
13
  import { detectRefusal } from './gate-cache.js';
12
- import { QeGateCache } from '../qe/gate-events.js';
13
- import { buildAcceptanceView, resolveRunWorkflow } from '../qe/acceptance.js';
14
+ import { acceptanceRequirementOf, buildAcceptanceView } from '../qe/acceptance.js';
15
+ import { runIdentityOf, wireIdentity } from '../core/run-identity.js';
14
16
  import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
15
17
  import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output.js';
16
18
  import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
@@ -20,42 +22,54 @@ import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
20
22
  import { chatSeatAdmission } from './seat-standing.js';
21
23
  import { rosterWithStandingFactory } from './roster-standing.js';
22
24
  import { ChatTurnIndex } from './chat-turns.js';
23
- import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, resolveChatScope, } from './chat-scope.js';
25
+ import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, namedKindRefusal, resolveChatScope, } from './chat-scope.js';
24
26
  import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
25
27
  import { InvalidDiffBaseError, MERGE_BASE_LITERAL, NotARegularFileError, UnresolvableDiffBaseError, branchDiff, isPlainRef, readFileCapped, worktreeDiff, } from './run-files.js';
26
28
  import { DocRunIndex } from '../interactive/doc-run-index.js';
27
29
  import { listInteractiveDocs } from '../interactive/docs-index.js';
28
30
  import { TestSetIndex } from '../qe/test-sets.js';
29
- import { estateExe, resolveProjectGraphBinding } from '../projects/graph.js';
31
+ import { estateExe, parseEstateTotals, resolveProjectGraphBinding } from '../projects/graph.js';
30
32
  import { registerProjectRoutes } from '../projects/routes.js';
33
+ import { registerPresetRoutes } from '../presets/routes.js';
34
+ import { registerTeamRoutes } from '../team/routes.js';
31
35
  import { registerCampaignRoutes } from '../campaigns/routes.js';
32
36
  import { registerGovernanceWikiRoutes } from './governance-wiki.js';
33
37
  import { DEFAULT_STEERING_TYPE, STEERING_TYPE_VALUES, STEERING_TYPES, registerGovernanceSteeringRoutes, } from './governance-steering.js';
34
38
  import { isSteeringAuthorRun, landSteeringProposal } from './steering-landing.js';
39
+ import { ApproveEditSchema, approveEdited, captureLaunchRoots, editInFlight, FileProposalSchema, registerCaptureRoutes } from './capture.js';
35
40
  import { registerTestingRoutes } from './testing.js';
41
+ import { DEMO_PRESET, demoLaunchRoots, registerDemoRoutes } from './demo.js';
36
42
  import { registerSkillsRoutes } from './skills.js';
43
+ import { registerMcpRoutes } from './mcp.js';
44
+ import { McpPolicies } from '../mcp/policies.js';
37
45
  import { disabledSkillsHealth } from '../skills/runtime.js';
46
+ import { phaseSkillFindings, withPhaseSkillGaps } from '../skills/phase-skill-gaps.js';
38
47
  import { STATE_HOME_BLOCKER_CODE, STATE_HOME_REMEDY, isEngineStateHomeRefusal, stateHomeBlockerBody, } from '../projects/state-home-preflight.js';
39
48
  import { BASE_SKILL_POLICIES, BASE_SKILL_REF_SHAPE, baseSkillRemedy } from '../skills/base-skill.js';
40
49
  import { noEligibleSeatBody, parseNoEligibleSeat } from '../core/engine-roster.js';
41
50
  import { ProjectSettingsStore } from '../projects/settings.js';
42
51
  import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
43
- import { composeDeliverText, factsFromRun, framedDeliverText, runUrlFor } from '../core/deliver-text.js';
44
- import { resolvePullRequest as resolvePullRequestViaGh } from '../core/deliver.js';
52
+ import { composeDeliverText, configuredPublicOrigin, extractFollowUps, factsFromRun, FOLLOW_UPS_MAX, framedDeliverText, runUrlFor, } from '../core/deliver-text.js';
53
+ import { deliverRepoFor, deliverTargetView, isGitHubLogin, readDeliverOriginUrl, resolvePullRequest as resolvePullRequestViaGh, } from '../core/deliver.js';
45
54
  import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
46
55
  import { registerInteractiveDocDelete } from '../interactive/doc-delete-routes.js';
47
56
  import { registerInteractiveDocList } from '../interactive/doc-list-routes.js';
48
57
  import { MembershipIndex } from '../projects/membership-index.js';
49
58
  import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
59
+ import { resolveLinkedIssues } from '../core/linked-issues.js';
50
60
  import { AuditLog } from './audit.js';
51
61
  import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
52
62
  import { GovernanceDiagnostics } from './governance-health.js';
53
63
  import { legacyHomeOutboxPath } from '../core/governance-store.js';
64
+ import { UsageError, replayOutboxInto } from '../cli/governance.js';
65
+ import { foldDeadletters } from './governance-health.js';
54
66
  import { RetryIndex } from './retry-index.js';
67
+ import { SEAT_RECORD_DEFAULT_DAYS, SEAT_RECORD_MAX_DAYS, seatRecord } from './seat-record.js';
68
+ import { DeliveryFreeze, frozenRefusal, isDeliverUnit, registerDeliveryFreezeRoutes } from './delivery-freeze.js';
55
69
  import { GroupIndex } from './group-index.js';
56
70
  import { RunTimingIndex, recordRunLaunched } from './run-timing-index.js';
57
71
  import { GuidanceIndex } from './guidance-index.js';
58
- import { DeliveryIndex, gitRunBranchIsEmpty, gitWorktreeIsClean, isDeliverConflictStranded, prUrlFrom, canDeliverResolver, } from './delivery-index.js';
72
+ import { DeliveryIndex, gitRunBranchIsEmpty, gitWorktreeIsClean, isDeliverConflictStranded, deliveryRecordFrom, pushedState, canDeliverResolver, } from './delivery-index.js';
59
73
  import { DeliveryDerivationCache } from './delivery-cache.js';
60
74
  import { gitReprovisionWorktree, runDeliverScript, } from './post-hoc-deliver.js';
61
75
  import { LOCAL_ACTOR } from './auth.js';
@@ -64,6 +78,10 @@ import { LOCAL_ACTOR } from './auth.js';
64
78
  export { API_PREFIX } from './api-prefix.js';
65
79
  import { API_PREFIX } from './api-prefix.js';
66
80
  const V = API_PREFIX;
81
+ /** How long `GET /health` waits on the engine's actor before answering `ping: "busy"` (crew#471). */
82
+ export const HEALTH_PING_WAIT_MS = 1_000;
83
+ /** `HealthResponse.ping` while the engine's actor is occupied: the daemon serves, the engine is busy. */
84
+ export const ENGINE_BUSY_PING = 'busy';
67
85
  // Daemon version reported by /health — read from package.json so it never drifts
68
86
  // from the shipped version across releases. Resolves the package root from the
69
87
  // compiled module location (dist/api/routes.js → ../../package.json) and works the
@@ -83,17 +101,10 @@ const STATUS_ORDER = {
83
101
  function sortActionableFirst(views) {
84
102
  return [...views].sort((a, b) => (STATUS_ORDER[a.session.status] ?? 9) - (STATUS_ORDER[b.session.status] ?? 9));
85
103
  }
86
- /**
87
- * The whole-graph counts from `wicked-estate stats --db` stdout (`nodes=N edges=M files=F …`, the
88
- * first line estate prints — `main.rs`'s `stats` summary). `undefined` when the line does not parse
89
- * (an older/newer estate, an error message): the caller leaves `CodeGraphData.totals` ABSENT.
90
- */
91
- export function parseEstateTotals(stdout) {
92
- const m = /^nodes=(\d+) edges=(\d+) files=(\d+)/m.exec(stdout);
93
- if (m === null)
94
- return undefined;
95
- return { nodes: Number(m[1]), edges: Number(m[2]), files: Number(m[3]) };
96
- }
104
+ // parseEstateTotals lives in projects/graph.ts — shared with chat-scope.ts (crew#642) to avoid
105
+ // a circular import (routes.ts → chat-scope.ts). Re-exported here for backward compat
106
+ // (tests import it from routes.js; see top-of-file import for local use).
107
+ export { parseEstateTotals };
97
108
  function codeGraphDbOr503(repo, reply) {
98
109
  try {
99
110
  return codeGraphDb(repo);
@@ -312,6 +323,12 @@ const RegisterRepoSchema = z
312
323
  // defaults to ~/.wicked/repos/<name>.
313
324
  rootPath: z.string().optional(),
314
325
  gitUrl: z.string().optional(),
326
+ /** crew#496: the project the register form had selected — the onboarding run is filed there
327
+ * (membership `crew.run`), not left unfiled while the repo joins the project. */
328
+ projectId: z.string().min(1).max(128).optional(),
329
+ /** crew#496 / crew#632: the launch surface and caller, recorded on the run's launch entry. */
330
+ channel: z.enum(['studio', 'cli', 'api']).optional(),
331
+ actor: z.string().min(1).max(256).optional(),
315
332
  })
316
333
  .strict()
317
334
  .refine((d) => {
@@ -321,6 +338,14 @@ const RegisterRepoSchema = z
321
338
  // or rootPath alone (register existing local repo) — all valid.
322
339
  return hasRemote || hasLocal;
323
340
  }, { message: 'Provide gitUrl (remote clone) or rootPath (local registration), or both.' });
341
+ /** `POST /repos/:id/onboard`'s optional body (crew#496): where to file the run, and who asked. */
342
+ const OnboardBodySchema = z
343
+ .object({
344
+ projectId: z.string().min(1).max(128).optional(),
345
+ channel: z.enum(['studio', 'cli', 'api']).optional(),
346
+ actor: z.string().min(1).max(256).optional(),
347
+ })
348
+ .strict();
324
349
  /**
325
350
  * The launch body. Every field but `problem` is optional, which is what made stripping dangerous:
326
351
  * omitting one is a legitimate request that gets an engine default, so a MISSPELLED one was
@@ -337,6 +362,7 @@ const RegisterRepoSchema = z
337
362
  // The request-body schemas below are exported so `tests/wire-contract.test.ts` can prove, at
338
363
  // compile time, that every body the published contract (`wicked-crew-api-types`) lets a client
339
364
  // send is a body these schemas accept — the request-direction half of the drift guard (task #84).
365
+ export { PlanSchema };
340
366
  export const LaunchSchema = z.object({
341
367
  problem: z.string().min(1),
342
368
  sessionId: z.string().min(1).optional(),
@@ -345,6 +371,10 @@ export const LaunchSchema = z.object({
345
371
  humanConfirm: z.string().min(1).optional(),
346
372
  repoRef: z.string().min(1).optional(),
347
373
  workflow: z.string().min(1).optional(),
374
+ /** DES-TEAMING-002 T3 — a user-composed plan (`LaunchRunBody.plan`), forwarded to the engine,
375
+ * which publishes `plan.proposed{by:"human"}` and holds it at a `plan_approval` gate when the
376
+ * approval matrix says so. Mutually exclusive with `workflow` (refine below). */
377
+ plan: PlanSchema.optional(),
348
378
  /** DES-PROJECT-001 §2.2 — file the run into a project; membership attaches atomically with
349
379
  * the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
350
380
  projectId: z.string().min(1).optional(),
@@ -395,7 +425,10 @@ export const LaunchSchema = z.object({
395
425
  * launch (e.g. a CI job name or a studio tab id). Persisted on the `run.launched` audit entry
396
426
  * (`detail.actor`) and served on the run DTO; absent = caller omitted it. */
397
427
  actor: z.string().min(1).max(256).optional(),
398
- }).strict().refine((b) => b.deliver !== 'pr' || b.workflow !== undefined, {
428
+ }).strict().refine((b) => b.plan === undefined || b.workflow === undefined, {
429
+ message: 'plan and workflow are mutually exclusive — a launch carries a plan or names a preset, not both',
430
+ path: ['plan'],
431
+ }).refine((b) => b.plan !== undefined || b.deliver !== 'pr' || b.workflow !== undefined, {
399
432
  message: 'deliver: "pr" requires a workflow — a free-text run has no def to append the deliver phase to',
400
433
  path: ['deliver'],
401
434
  }).refine((b) => b.deliverGate === undefined || b.deliver !== 'none', {
@@ -414,19 +447,43 @@ export const LaunchSchema = z.object({
414
447
  message: 'revisesPr needs deliver: "pr" — a launch that declines delivery has no phase to push the revision with',
415
448
  path: ['revisesPr'],
416
449
  });
450
+ /** The escalation arms (wicked-core#469 / #467): approve-shaped, no `amend` / `amendScope`.
451
+ * `extend` | `targeted` | `accept_partial` answer the gate of a repo-checks floor that did not
452
+ * finish (`repo_checks_timeout`) — the engine re-runs the floor on the tree as it stands with 2×
453
+ * bounds, the targeted test set, or the unfinished checks waived; `accept_suggestion` adopts the
454
+ * evaluator's discarded, pinned edit as the creator's amendment. The ENGINE refuses each at any
455
+ * other gate (a 409 naming the gate it answers). */
456
+ export const ESCALATION_ACTIONS = ['extend', 'targeted', 'accept_partial', 'accept_suggestion'];
457
+ const isEscalationAction = (a) => a !== undefined && ESCALATION_ACTIONS.includes(a);
417
458
  /** `POST /runs/:id/gate` (api-types 0.38.0 `GateDecision`; DES-L1 PR-2). Additive arms: `action`
418
459
  * names the arm (`approve` | `request_changes` | `reject`; absent = today's two-arm mapping of
419
460
  * `approve`), `amendScope` says where an approve's `amend` lands (`cursor` = the gated unit, today's
420
461
  * behaviour | `creator` = the first creator phase at/after the cursor — an intake steer reaches the
421
462
  * phase that implements). `request_changes` sends a NOT-PASS review back to the creator with the
422
463
  * findings in context (`amend` = the operator's note). A disagreement between `action` and
423
- * `approve` is a 400 that names both. */
464
+ * `approve` is a 400 that names both. The escalation arms ({@link ESCALATION_ACTIONS}) require
465
+ * `approve: true` and take no `amend` / `amendScope` / `plan`.
466
+ *
467
+ * `amend_intent` (wicked-core#555) approves the gate AND AMENDS THE RUN'S INTENT: `amend` is
468
+ * what the acceptance list now says (required, non-empty), `approve: true`, and no `amendScope`
469
+ * — an intent amendment reaches every unit at or after the cursor, which is what makes it reach
470
+ * the LATER EVALUATOR. It is the only arm that can descope a run mid-flight. */
424
471
  export const GateSchema = z.object({
425
472
  approve: z.boolean(),
426
473
  amend: z.string().optional(),
427
- action: z.enum(['approve', 'request_changes', 'reject']).optional(),
474
+ action: z.enum(['approve', 'request_changes', 'reject', 'edit_plan', 'amend_intent', ...ESCALATION_ACTIONS]).optional(),
428
475
  amendScope: z.enum(['cursor', 'creator']).optional(),
429
- }).strict().refine((b) => b.action === undefined || (b.action === 'approve') === b.approve, { message: '`action` disagrees with `approve`: request_changes and reject require approve: false; approve requires approve: true', path: ['action'] }).refine((b) => b.amendScope === undefined || b.approve, { message: '`amendScope` applies to an approve only (approve: true)', path: ['amendScope'] });
476
+ /** DES-TEAMING-002 T3 — approve a `plan_approval` gate WITH AN EDIT (`GateDecision.plan`). */
477
+ plan: PlanSchema.optional(),
478
+ /** api-types 0.44.0 — the gate this decision answers (its unit `ord`). A mismatch with the run's
479
+ * open gate is a 409 `gate_changed`: the decision was made on a gate that is no longer open. */
480
+ ord: z.number().int().nonnegative().optional(),
481
+ }).strict().refine((b) => b.action === undefined
482
+ || (b.action === 'approve' || b.action === 'edit_plan' || b.action === 'amend_intent' || isEscalationAction(b.action)) === b.approve, { message: '`action` disagrees with `approve`: request_changes and reject require approve: false; approve, edit_plan, amend_intent, extend, targeted, accept_partial and accept_suggestion require approve: true', path: ['action'] }).refine(
483
+ // (wicked-core#555) An intent amendment IS its text: an empty one amends nothing, and the arm's
484
+ // scope is every unit at or after the cursor — which is what makes it reach the evaluator — so
485
+ // `amendScope` cannot narrow it. Both are 400s here rather than a 409 from the engine.
486
+ (b) => b.action !== 'amend_intent' || (b.amend !== undefined && b.amend.trim() !== ''), { message: 'amend_intent needs `amend`: what the run\'s acceptance list now says (approve without an action to proceed unamended)', path: ['amend'] }).refine((b) => b.action !== 'amend_intent' || (b.amendScope === undefined && b.plan === undefined), { message: 'amend_intent takes no amendScope or plan — an intent amendment reaches every unit at or after the cursor, which is what makes it reach the evaluator', path: ['amendScope'] }).refine((b) => !isEscalationAction(b.action) || (b.amend === undefined && b.amendScope === undefined && b.plan === undefined), { message: 'extend, targeted, accept_partial and accept_suggestion take no amend, amendScope or plan — the engine re-runs the floor or adopts the pinned edit as it stands', path: ['action'] }).refine((b) => (b.action === 'edit_plan') === (b.plan !== undefined) || (b.action === undefined && b.plan !== undefined), { message: 'an edited plan answers a plan_approval gate: `plan` needs approve: true with `action` omitted or edit_plan, and edit_plan needs `plan`', path: ['plan'] }).refine((b) => b.plan === undefined || (b.approve && b.amend === undefined && b.amendScope === undefined), { message: '`plan` is an approve WITH an edited plan: approve: true, and no amend / amendScope (the plan is the edit)', path: ['plan'] }).refine((b) => b.amendScope === undefined || b.approve, { message: '`amendScope` applies to an approve only (approve: true)', path: ['amendScope'] });
430
487
  /** `PUT /runs/:id/guidance` (DES-UX-002 §7.2, CREW-UX-7) — the durable pre-gate note body.
431
488
  * The empty string is a legal body: it CLEARS the note. The byte cap is checked in the route
432
489
  * (not zod's char-counting `max`) so the 400 names the actual limit. */
@@ -470,6 +527,14 @@ export const OpenPathSchema = z.object({
470
527
  export const RetireMemorySchema = z.object({
471
528
  scope_prefix: z.string(),
472
529
  }).strict();
530
+ /** `POST /memory/retire-item` body (studio#206): ONE memory, by its `memory_id` from `GET /memory`.
531
+ * `.strict()`: a stray `scope_prefix` is a 400, never read as a wider erase than one row. */
532
+ export const RetireMemoryItemSchema = z.object({
533
+ memory_id: z.string().min(1).max(512),
534
+ }).strict();
535
+ /** The error an estate from before wicked-estate#188 answers to `memory.erase {id}`: it knows only the
536
+ * subtree erase, so it refuses the call (deleting nothing) for want of a `scope_prefix`. */
537
+ const ESTATE_NO_ERASE_BY_ID = /scope_prefix \(non-empty\) required/i;
473
538
  /**
474
539
  * A SKIN-OWNED settings key (crew#323): one lowercase segment under the `studio.` namespace,
475
540
  * e.g. `studio.appearance`, `studio.notifications`. The daemon never interprets these values —
@@ -526,12 +591,89 @@ export const ChatOpenSchema = z.object({
526
591
  /** File the chat into a project (`crew.chat` membership) — and, with no `repoRefs`, scope it to
527
592
  * every registered `crew.repo` member of that project (crew#502). */
528
593
  projectId: z.string().min(1).optional(),
594
+ /** The scope the caller NAMES (studio#323 R4) — omitted ⇒ the legacy inference. Shape rules
595
+ * (which kinds need / refuse `projectId` / `repoRefs`) are the resolver's (`chat-scope.ts`). */
596
+ scopeKind: z.enum(['system', 'everything', 'project', 'repo', 'repos', 'none']).optional(),
529
597
  }).strict();
598
+ /**
599
+ * The `singleSeat` disclosure (crew#641, hole closed in crew#650) — built in ONE place so the 201
600
+ * and the 202 cannot drift.
601
+ *
602
+ * The property this field states is **"this chat has one seat; it cannot disagree with itself"**,
603
+ * and that is true of every one-warm-seat chat. The first cut also required `refused.length > 0`,
604
+ * so the case an operator is most likely to create — a chat deliberately opened with a single seat
605
+ * — returned `ok: true` with no signal at all, which is the exact failure mode #641 was filed
606
+ * about. It also made the 202 go quiet after a restart, when `refusedOf` no longer knows the
607
+ * refusals, even though the chat is just as single-seated as it was a minute earlier.
608
+ *
609
+ * `refused` therefore rides along as EVIDENCE when there is any — it never decides whether the
610
+ * caller is told.
611
+ *
612
+ * THREE states, not two (review of #658). The refusal list has a third possibility that `[]` cannot
613
+ * express: this daemon does not KNOW. `chatScopes.refusedOf` answers `undefined` for a chat this
614
+ * daemon did not open — the engine keeps the warm session across a restart, the in-memory index does
615
+ * not — and the first cut collapsed that to `[]` with `?? []`, which made the 202 tell an operator
616
+ * *"It was opened with a single seat — no other seat was refused"* about a chat that was opened with
617
+ * two seats, one of which was refused. Both halves false, stated confidently. It is the same
618
+ * distinction #651 got right for chat-promotion provenance (`.catch(() => null)`, never `[]`):
619
+ * unreadable and empty are different answers, and only one of them may be asserted.
620
+ *
621
+ * TWO MOMENTS, and only one route witnessed each (third review of #658). `refused` describes the
622
+ * daemon's record — what the OPEN (or the last re-seat) observed; `warmRoster` describes NOW. Any
623
+ * sentence built from both fabricates the instant they diverge, and they diverge routinely: a seat
624
+ * that blows its turn budget is RELEASED by the engine (`chat-turns.ts`), a re-seat clears an entry
625
+ * from the record, a restart drops the record entirely. A chat opened with `['claude','opencode']`
626
+ * — both warm, nothing refused, nothing unknown — whose `opencode` is later released would have been
627
+ * told *"It was opened with a single seat — no other seat was refused"*: false in both halves, with
628
+ * no `undefined` anywhere near it. So `at` names the moment the caller is asking about:
629
+ *
630
+ * • `'open'` — the 201. This route just performed the open, so it is the ONLY one with standing to
631
+ * describe it: refusals known and empty ⇒ it may say the chat was opened with a single seat.
632
+ * • `'turn'` — the 202. It knows the roster NOW and the refusal record; it knows nothing about how
633
+ * the chat came to look like this. Refusals known and empty ⇒ it says NOTHING about opening.
634
+ * It still names refusals that are on record, and still says so when the record is unavailable —
635
+ * both are statements about the record it holds, not about a moment it did not see.
636
+ *
637
+ * `refusalsKnown` carries the known/unknown distinction for a machine reader, because `refused: []`
638
+ * is exactly as ambiguous on the wire as the sentence was in prose.
639
+ */
640
+ function singleSeatDisclosure(warmed,
641
+ /** `undefined` = this daemon has no refusal record for the chat (it did not open it). */
642
+ refused,
643
+ /** Which moment the caller is asking about — see the note above; only `'open'` may describe one. */
644
+ at) {
645
+ const known = refused !== undefined;
646
+ const list = refused ?? [];
647
+ const refusalClause = list.length > 0
648
+ ? `Refused: ${list.map((r) => `${r.cliKey} (${r.reason})`).join('; ')}. `
649
+ : known
650
+ ? at === 'open'
651
+ ? 'It was opened with a single seat — no other seat was refused. '
652
+ : // A turn has no standing to say how the chat was opened: this seat may be the survivor of
653
+ // a two-seat chat whose other seat was released mid-session. Say nothing.
654
+ ''
655
+ : 'This daemon holds no refusal record for this chat — it was opened before a restart — so ' +
656
+ 'whether another seat was refused is UNKNOWN: the empty refused[] means the record is gone, ' +
657
+ 'not that nothing was refused. ';
658
+ return {
659
+ degraded: true,
660
+ warmed,
661
+ refused: list,
662
+ refusalsKnown: known,
663
+ message:
664
+ // "warm" on a turn: the claim is about the roster as it stands, not about how it got there.
665
+ `This chat has one ${at === 'open' ? 'seat' : 'warm seat'} (${warmed}); it cannot disagree with itself. ` +
666
+ refusalClause +
667
+ 'The single-seat root cause is tracked as wicked-core#563; this chat makes it visible.',
668
+ };
669
+ }
670
+ /** N1: why a post-hoc deliver answers 409 for a branch already on a non-GitHub origin. */
671
+ function pushedOnlyRefusal(runId, pushed) {
672
+ return (`run ${runId} is delivered as a pushed branch: ${pushed.branch} is on ${pushed.remote}, and no ` +
673
+ 'pull request can be opened there because that origin is not a GitHub host gh can resolve — ' +
674
+ 'open the pull/merge request on your forge; merge stays human');
675
+ }
530
676
  export function registerRoutes(app, adapter, gateCache, elicitationCache,
531
- // Defaulted so a caller that never arms the bus seam (tests drive this
532
- // function directly) gets the same behavior as an unarmed daemon: an empty
533
- // cache, `busEvent: null`, and the lazy ledger read doing all the work.
534
- qeGateEvents = new QeGateCache(),
535
677
  // Defaulted for the same reason: tests that drive this function directly get
536
678
  // the projects surface with no bus (events skipped) and a fresh index.
537
679
  projects = { bus: null, index: new MembershipIndex(), log: () => undefined },
@@ -545,6 +687,8 @@ security = { audit: AuditLog.noop(), authMode: 'off' },
545
687
  runtime = {}) {
546
688
  const { audit } = security;
547
689
  const seatHealth = runtime.seatHealth ?? new SeatHealthTracker();
690
+ const deliveryFreeze = runtime.deliveryFreeze ?? new DeliveryFreeze();
691
+ registerDeliveryFreezeRoutes(app, { freeze: deliveryFreeze, audit });
548
692
  const openWithOs = runtime.openWithOs ?? openWithSystemDefault;
549
693
  const signedIn = runtime.signedIn ?? signedInHeuristic;
550
694
  const retryIndex = runtime.retryIndex ?? new RetryIndex();
@@ -582,7 +726,7 @@ runtime = {}) {
582
726
  new DeliveryDerivationCache({
583
727
  listViews: () => adapter.sessionsDetail(),
584
728
  probes: vacuityProbes,
585
- isDelivered: (runId) => deliveryIndex.urlFor(runId) !== undefined,
729
+ isDelivered: (runId) => deliveryIndex.isDelivered(runId),
586
730
  canDeliver,
587
731
  });
588
732
  const deliverExec = runtime.deliverExec ?? runDeliverScript;
@@ -601,6 +745,32 @@ runtime = {}) {
601
745
  const repoRootOf = async (repoRef) => (await adapter.listRepos()).find((r) => r.id === repoRef)?.root_path;
602
746
  /** DES-L9: PR number → head branch, through `gh pr view` unless the runtime injected an answerer. */
603
747
  const resolvePullRequest = runtime.resolvePullRequest ?? resolvePullRequestViaGh;
748
+ /** crew#627: the intent's linked issues, read under the daemon's identity at launch. */
749
+ const readLinkedIssues = runtime.resolveLinkedIssues ?? ((p, root, ref) => resolveLinkedIssues(p, root, ref));
750
+ /** crew#550 P-7: the follow-ups the run's evaluator units flagged, read from their captured output.
751
+ * A unit whose output cannot be read contributes nothing (the section then says what WAS read);
752
+ * an adapter without transcripts (a directly-driven route set) reads as "not read" — absent. */
753
+ const evaluatorFollowUps = async (view) => {
754
+ if (typeof adapter.workOutput !== 'function')
755
+ return undefined;
756
+ const evaluators = view.units.filter((u) => u.role === 'evaluator').sort((a, b) => a.ord - b.ord);
757
+ const out = [];
758
+ for (const u of evaluators) {
759
+ let text = null;
760
+ try {
761
+ text = await adapter.workOutput(coreUnitId(view.session.id, u));
762
+ }
763
+ catch {
764
+ text = null;
765
+ }
766
+ if (text === null)
767
+ continue;
768
+ for (const x of extractFollowUps(text))
769
+ if (!out.includes(x))
770
+ out.push(x);
771
+ }
772
+ return out.slice(0, FOLLOW_UPS_MAX);
773
+ };
604
774
  // The run-DTO joins (DES-UX-001 §8.2/§8.3, DES-UX-002 §7.2): `project_id` from the membership
605
775
  // record — `null` = genuinely unfiled, so the field is ALWAYS present on served runs —
606
776
  // `retry_of` from the lineage index and `guidance` from the guidance index, each set only
@@ -648,12 +818,19 @@ runtime = {}) {
648
818
  const url = deliveryIndex.urlFor(view.session.id);
649
819
  if (url !== undefined)
650
820
  return { delivery: 'delivered', deliverUrl: url };
821
+ // N1: a branch pushed to a non-GitHub origin IS the delivery — never 'stranded'.
822
+ const pushed = deliveryIndex.pushedFor(view.session.id);
823
+ if (pushed !== undefined)
824
+ return pushedState(pushed);
651
825
  if (conflictStrand)
652
826
  return { delivery: 'stranded' };
653
827
  return deliveryCache.read(view);
654
828
  };
655
829
  const decorateRun = (view) => {
656
830
  const conflictStrand = normalizeStranded(view);
831
+ // Seam X2: what the run IS — the adapter attaches it from the engine's record; a view from a
832
+ // directly-driven adapter gets it resolved from its record here, so it is always served.
833
+ view.session.run_identity = wireIdentity(runIdentityOf(view));
657
834
  view.session.project_id = projects.index.projectOf(view.session.id) ?? null;
658
835
  // Wave 6 (F-4R2-006): the interactive document this run answered, from the seams' handoff
659
836
  // ledgers — `null` = genuinely not a document run, so the field is ALWAYS present on served runs.
@@ -673,6 +850,10 @@ runtime = {}) {
673
850
  const guidance = guidanceIndex.guidanceFor(view.session.id);
674
851
  if (guidance !== undefined)
675
852
  view.session.guidance = guidance;
853
+ // crew#661: the declared skills this run's phases ran WITHOUT (degrade-and-disclose) — ABSENT when none.
854
+ const skillGaps = runtime.runSkillGaps?.gapsFor(view.session.id);
855
+ if (skillGaps !== undefined)
856
+ view.session.skill_gaps = skillGaps;
676
857
  // Run launch time (home command-center run metrics): unix SECONDS from the `run.launched`
677
858
  // audit entry, ABSENT when the daemon has no launch record for this run (onboarding/campaign
678
859
  // runs launched off POST /runs, pre-field runs) — never fabricated, so a bucketed KPI can
@@ -696,6 +877,17 @@ runtime = {}) {
696
877
  view.session.delivery = state.delivery;
697
878
  if (state.deliverUrl !== undefined)
698
879
  view.session.deliverUrl = state.deliverUrl;
880
+ if (state.deliverBranch !== undefined)
881
+ view.session.deliverBranch = state.deliverBranch;
882
+ if (state.deliverRemote !== undefined)
883
+ view.session.deliverRemote = state.deliverRemote;
884
+ // crew#641/#642 (item 5): chat promotion provenance — ABSENT when not a chat-promoted run.
885
+ const chatSeatCount = runTimingIndex.chatSeatCountFor(view.session.id);
886
+ if (chatSeatCount !== undefined)
887
+ view.session.chat_seat_count = chatSeatCount;
888
+ const chatGrounded = runTimingIndex.chatGroundedFor(view.session.id);
889
+ if (chatGrounded !== undefined)
890
+ view.session.chat_grounded = chatGrounded;
699
891
  return view;
700
892
  };
701
893
  // Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
@@ -707,13 +899,31 @@ runtime = {}) {
707
899
  // still gets the ONE actor shape via this accessor.
708
900
  const actorOf = (req) => req.actor ?? LOCAL_ACTOR;
709
901
  // Liveness — also proves the actor + event pump are up.
902
+ //
903
+ // crew#471: the engine's ping is a round trip through its single-writer actor, and a launch that
904
+ // plans many units holds that actor for minutes. Awaiting it made `/health` go dark exactly when
905
+ // an operator needed to know the daemon was up, and each waiting probe pinned a libuv worker
906
+ // thread the rest of the read path needs. So at most ONE ping is in flight (later probes join it)
907
+ // and `/health` waits for it at most HEALTH_PING_WAIT_MS: past that it answers `ping: "busy"` —
908
+ // the daemon serves, the engine is occupied.
909
+ let pingInFlight = null;
910
+ const enginePing = () => {
911
+ pingInFlight ??= adapter.ping().finally(() => {
912
+ pingInFlight = null;
913
+ });
914
+ let timer;
915
+ const busy = new Promise((resolve) => {
916
+ timer = setTimeout(() => resolve(ENGINE_BUSY_PING), HEALTH_PING_WAIT_MS);
917
+ });
918
+ return Promise.race([pingInFlight, busy]).finally(() => clearTimeout(timer));
919
+ };
710
920
  // `config.manifest` on the routes below (TH-11): the declaration channel the endpoint manifest
711
921
  // reads — type names bind to `wicked-crew-api-types` exports where one exists, structural
712
922
  // spellings where the contract has no name, statusCodes list every code the route answers on
713
923
  // purpose. Declared on the highest-traffic run-lifecycle routes first; the manifest records
714
924
  // null / [] for the rest ("where declared", never invented). See src/api/endpoint-manifest.ts.
715
925
  app.get(`${V}/health`, { config: { manifest: { statusCodes: [200] } } }, async () => {
716
- const ping = await adapter.ping();
926
+ const ping = await enginePing();
717
927
  // F-E2E-030: what this deployment can keep. A composer reads `capabilities.deliverGate`
718
928
  // before it promises "pauses at the deliver gate"; a stub-driven route set without the
719
929
  // probe honestly reports no gate.
@@ -735,9 +945,18 @@ runtime = {}) {
735
945
  // `baseSkill.finding` and `/diagnostics.skills.findings[]` (one surface, no new field;
736
946
  // `HealthWarning.kind` is open); status stays ok because the daemon serves and studio must load
737
947
  // and show it — exactly the state-home blocker's precedent above.
948
+ // crew#661: a drafting seam that armed WITHOUT its declared skill rides `warnings` as well — its
949
+ // runs proceed degraded, so "status ok, no warnings" would be the silent answer the issue names.
738
950
  const warnings = [
739
951
  ...(stateHome === null ? [] : stateHome.findings.map((f) => ({ kind: f.kind, severity: f.severity, message: f.message }))),
740
952
  ...(baseSkill?.finding ? [{ kind: baseSkill.finding.kind, severity: baseSkill.finding.severity, message: baseSkill.finding.message }] : []),
953
+ ...phaseSkillFindings(runtime.phaseSkills?.gaps() ?? []),
954
+ // DES-TEAMING-002 T0: the daemon's bus did not open at boot, so the engine was handed none
955
+ // and runs un-teamed. A daemon-level notice (status stays ok: it serves); the per-run
956
+ // `transport:"none"` record lands with P1.
957
+ ...(adapter.busUnavailable != null ? [busUnavailableWarning(adapter.busUnavailable)] : []),
958
+ // wicked-core#631: the engine has the bus but not the calls crew reaches it through.
959
+ ...(adapter.busSeamsOff != null ? [busSeamsOffWarning(adapter.busSeamsOff)] : []),
741
960
  ];
742
961
  return {
743
962
  status: 'ok',
@@ -790,7 +1009,9 @@ runtime = {}) {
790
1009
  // The skills seam's last outcome (skills keystone): published / fallback / blocked /
791
1010
  // config-error, with the `skills.*` findings the ladder produced — the operator's one
792
1011
  // read-only answer to "why do launches refuse the snapshot".
793
- skills: runtime.skills?.health() ?? disabledSkillsHealth(),
1012
+ // crew#661: plus every subsystem whose phases ARMED without a declared skill (`phaseSkillGaps`,
1013
+ // one `skills.phase-skill` finding each) — the degraded runs' operator-facing record.
1014
+ skills: withPhaseSkillGaps(runtime.skills?.health() ?? disabledSkillsHealth(), runtime.phaseSkills),
794
1015
  // Is the governance evidence LANDING (crew#495): the store, the records on it, the dead
795
1016
  // letters — with a `governance.deadletter` finding the moment the outbox holds one.
796
1017
  governance,
@@ -809,8 +1030,10 @@ runtime = {}) {
809
1030
  authMode: security.authMode,
810
1031
  }));
811
1032
  // The actor audit trail (task #88): who launched/steered/governed what.
812
- // Read-only (observer trust); newest first; `?runId=` / `?action=` / `?limit=`.
813
- app.get(`${V}/audit`, async (req, reply) => {
1033
+ // Read-only (observer trust); newest first; `?runId=` / `?action=` / `?limit=` / `?since=`.
1034
+ // `since` (unix millis, inclusive) is the lower time bound a skin's "while you were away"
1035
+ // handover reads — the trail since the operator's last visit (studio wave 2b).
1036
+ app.get(`${V}/audit`, { config: { manifest: { responseType: 'AuditPage', statusCodes: [200, 400, 500] } } }, async (req, reply) => {
814
1037
  const q = req.query;
815
1038
  const first = (v) => (Array.isArray(v) ? v[0] : v)?.trim() || undefined;
816
1039
  const runId = first(q.runId);
@@ -822,11 +1045,18 @@ runtime = {}) {
822
1045
  if (limitRaw !== undefined && (!Number.isFinite(limit) || limit < 1 || !Number.isInteger(limit))) {
823
1046
  return reply.code(400).send({ error: '`limit` must be a positive integer' });
824
1047
  }
1048
+ // Same strict parse as `limit`, but zero is a valid clock (the epoch).
1049
+ const sinceRaw = first(q.since);
1050
+ const since = sinceRaw !== undefined ? Number(sinceRaw) : undefined;
1051
+ if (sinceRaw !== undefined && (!Number.isInteger(since) || since < 0)) {
1052
+ return reply.code(400).send({ error: '`since` must be a non-negative integer (unix millis)' });
1053
+ }
825
1054
  try {
826
1055
  const entries = await audit.read({
827
1056
  ...(runId !== undefined ? { runId } : {}),
828
1057
  ...(action !== undefined ? { action } : {}),
829
1058
  ...(limit !== undefined ? { limit } : {}),
1059
+ ...(since !== undefined ? { since } : {}),
830
1060
  });
831
1061
  return { entries };
832
1062
  }
@@ -851,6 +1081,33 @@ runtime = {}) {
851
1081
  // disagree about a seat.
852
1082
  const rosterWithStanding = runtime.rosterWithStanding ?? rosterWithStandingFactory({ seatHealth, signedIn });
853
1083
  app.get(`${V}/roster`, async () => ({ roster: rosterWithStanding() }));
1084
+ // Each seat's week (studio's weekly 1:1 per agent; api-types 0.52.0): units, first pass, rework,
1085
+ // stalls, bench and cost, folded from the runs' durable event logs by `seatRecord` — nothing new
1086
+ // is stored. The daemon's remembered stall frames join the engine's, as on `/runs/:id/events`.
1087
+ app.get(`${V}/roster/record`, { config: { manifest: { responseType: 'SeatRecordResponse', statusCodes: [200, 400, 503] } } }, async (req, reply) => {
1088
+ const raw = req.query.days;
1089
+ let days = SEAT_RECORD_DEFAULT_DAYS;
1090
+ if (raw !== undefined) {
1091
+ const one = Array.isArray(raw) ? undefined : raw;
1092
+ const n = one !== undefined && /^[1-9][0-9]?$/.test(one) ? Number(one) : NaN;
1093
+ if (!(n >= 1 && n <= SEAT_RECORD_MAX_DAYS)) {
1094
+ return reply.code(400).send({ error: `Invalid ?days — expected one whole number from 1 to ${SEAT_RECORD_MAX_DAYS}, got ${JSON.stringify(raw)}` });
1095
+ }
1096
+ days = n;
1097
+ }
1098
+ const views = (await adapter.sessionsDetail()).map(decorateRun);
1099
+ const out = await seatRecord(views, async (id) => {
1100
+ const engine = await adapter.runEvents(id);
1101
+ if (engine === null)
1102
+ return null;
1103
+ const daemon = runtime.stallFrames?.(id) ?? [];
1104
+ return daemon.length === 0 ? engine : [...engine, ...daemon];
1105
+ }, { days, now: Date.now() });
1106
+ if (out === null) {
1107
+ return reply.code(503).send({ error: 'Seat records are unavailable: this wicked-core build has no event-log read binding' });
1108
+ }
1109
+ return out;
1110
+ });
854
1111
  // Open a file/folder with the OS default application (crew#273) — the studio Files tab's
855
1112
  // click-to-open. The open MUST happen daemon-side (the SPA cannot spawn a process), which is
856
1113
  // why the path is validated first: absolute, and inside one of the caller-visible roots — the
@@ -1087,33 +1344,61 @@ runtime = {}) {
1087
1344
  });
1088
1345
  // Registered repos → target-repo picker.
1089
1346
  app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
1347
+ /** Filing for an onboarding run: what `POST /runs` does for a launch, for the runs `POST /repos`
1348
+ * and `POST /repos/:id/onboard` start (crew#496). The launch entry (`run.launched`, which dates
1349
+ * the run and names who started it) is always written; with a `projectId` the run joins that
1350
+ * project. The run is LIVE by then, so a filing failure is named in the answer, never a 4xx. */
1351
+ const recordOnboardingLaunch = async (req, runId, repoId, opts) => {
1352
+ recordRunLaunched(audit, runTimingIndex, actorOf(req), runId, {
1353
+ workflow: 'onboarding',
1354
+ repoRef: repoId,
1355
+ ...(opts.projectId !== undefined ? { projectId: opts.projectId } : {}),
1356
+ ...(opts.channel !== undefined ? { channel: opts.channel } : {}),
1357
+ ...(opts.actor !== undefined ? { actor: opts.actor } : {}),
1358
+ });
1359
+ if (opts.projectId === undefined)
1360
+ return undefined;
1361
+ try {
1362
+ const { member } = await adapter.projectMemberAttach(opts.projectId, 'crew.run', runId, { onboarding: true }, actorOf(req).id);
1363
+ projects.index.set(runId, opts.projectId);
1364
+ projects.bus?.emit(MEMBERSHIP_ATTACHED, { project_id: opts.projectId, member: { kind: 'crew.run', ref: runId }, actor: actorOf(req).id }, membershipAttachedKey(opts.projectId, 'crew.run', runId, member.attached_at));
1365
+ return undefined;
1366
+ }
1367
+ catch (err) {
1368
+ req.log.warn({ runId, projectId: opts.projectId }, `onboarding run filing failed: ${message(err)}`);
1369
+ return message(err);
1370
+ }
1371
+ };
1090
1372
  app.post(`${V}/repos`, async (req, reply) => {
1091
1373
  const parsed = RegisterRepoSchema.safeParse(req.body);
1092
1374
  if (!parsed.success) {
1093
1375
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
1094
1376
  }
1095
- const { name, rootPath, gitUrl } = parsed.data;
1377
+ const { name, rootPath, gitUrl, projectId, channel, actor } = parsed.data;
1378
+ let repo;
1379
+ let runId;
1096
1380
  try {
1097
1381
  if (gitUrl) {
1098
1382
  // Remote: clone to rootPath (if provided) or default ~/.wicked/repos/<name>,
1099
1383
  // register, and launch onboarding run.
1100
- const { repoId, runId } = await adapter.cloneAndRegisterRepo(name, gitUrl, rootPath);
1384
+ const cloned = await adapter.cloneAndRegisterRepo(name, gitUrl, rootPath);
1385
+ runId = cloned.runId;
1101
1386
  const repos = await adapter.listRepos();
1102
- const repo = repos.find((r) => r.id === repoId);
1387
+ repo = repos.find((r) => r.id === cloned.repoId);
1103
1388
  if (!repo)
1104
1389
  return reply.code(500).send({ error: 'Repo registered but could not be retrieved' });
1105
- return reply.code(201).send({ repo, onboardRunId: runId });
1106
1390
  }
1107
1391
  else {
1108
1392
  // Local: register then launch onboarding run.
1109
- const repo = await adapter.registerRepo(name, rootPath);
1110
- const runId = await adapter.launchOnboardingRun(repo.id, name);
1111
- return reply.code(201).send({ repo, onboardRunId: runId });
1393
+ repo = await adapter.registerRepo(name, rootPath);
1394
+ runId = await adapter.launchOnboardingRun(repo.id, name);
1112
1395
  }
1113
1396
  }
1114
1397
  catch (err) {
1115
1398
  return reply.code(400).send({ error: message(err) });
1116
1399
  }
1400
+ const projectAttachError = await recordOnboardingLaunch(req, runId, repo.id, { projectId, channel, actor });
1401
+ return reply.code(201).send({ repo, onboardRunId: runId, ...(projectAttachError !== undefined ? { projectAttachError } : {}) });
1117
1402
  });
1118
1403
  // Return the onboarding run id for a repo (so the UI can navigate to it).
1119
1404
  app.get(`${V}/repos/:id/onboard`, async (req, reply) => {
@@ -1128,13 +1413,18 @@ runtime = {}) {
1128
1413
  const repo = repos.find((r) => r.id === id);
1129
1414
  if (!repo)
1130
1415
  return reply.code(404).send({ error: `Repo ${id} not found` });
1416
+ const body = OnboardBodySchema.safeParse(req.body ?? {});
1417
+ if (!body.success)
1418
+ return reply.code(400).send(invalidBody(body.error, 'Invalid request body'));
1419
+ let runId;
1131
1420
  try {
1132
- const runId = await adapter.launchOnboardingRun(repo.id, repo.name);
1133
- return reply.code(201).send({ runId });
1421
+ runId = await adapter.launchOnboardingRun(repo.id, repo.name);
1134
1422
  }
1135
1423
  catch (err) {
1136
1424
  return reply.code(400).send({ error: message(err) });
1137
1425
  }
1426
+ const projectAttachError = await recordOnboardingLaunch(req, runId, repo.id, body.data);
1427
+ return reply.code(201).send({ runId, ...(projectAttachError !== undefined ? { projectAttachError } : {}) });
1138
1428
  });
1139
1429
  // Launch a run (replaces POST /sessions). `clisJson` defaults to the roster;
1140
1430
  // `sessionId` is minted if the client omits it.
@@ -1142,7 +1432,7 @@ runtime = {}) {
1142
1432
  config: {
1143
1433
  manifest: {
1144
1434
  requestType: 'LaunchRunBody',
1145
- responseType: '{ runId: string }',
1435
+ responseType: 'LaunchRunResponse',
1146
1436
  // 404/409: unknown project / unknown campaignId (wicked-studio#27) /
1147
1437
  // archived-or-synthesized project + busy engine + the state-home blocker
1148
1438
  // (`state_home_unregistered`) + a roster with no eligible seat (`no_eligible_seat`,
@@ -1159,6 +1449,13 @@ runtime = {}) {
1159
1449
  return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
1160
1450
  }
1161
1451
  const b = parsed.data;
1452
+ // The demo preset launches only from POST /projects/:id/demo (api/demo.ts), which mints the run's
1453
+ // demo root and brief first: launched here directly it would have neither.
1454
+ if (b.workflow === DEMO_PRESET && (b.sessionId === undefined || !demoLaunchRoots.has(b.sessionId))) {
1455
+ return reply.code(400).send({
1456
+ error: 'the demo preset launches from POST /projects/:id/demo, which makes its demo root and brief; POST /runs does not take it directly',
1457
+ });
1458
+ }
1162
1459
  // wicked-core#411 / crew#497: the state-home blocker, judged BEFORE anything is resolved or
1163
1460
  // committed. While the handed skills snapshot derives a state home with an entry the worker
1164
1461
  // Read fence cannot classify, the engine refuses this launch at intake (and an engine before
@@ -1171,6 +1468,10 @@ runtime = {}) {
1171
1468
  return reply.code(409).send(stateHomeBlockerBody(stateHome));
1172
1469
  }
1173
1470
  }
1471
+ // crew#645: an expired login reads signed out BEFORE routing — wait (bounded) for every seat's
1472
+ // missing or stale login check, so the standing below is the seats' own answer.
1473
+ if (b.clisJson === undefined)
1474
+ await rosterWithStanding.ready?.();
1174
1475
  const input = {
1175
1476
  problem: b.problem,
1176
1477
  sessionId: b.sessionId ?? randomUUID(),
@@ -1179,6 +1480,15 @@ runtime = {}) {
1179
1480
  // (`core/engine-roster.ts`), so a signed-out seat is never convened, never a judge.
1180
1481
  clisJson: b.clisJson ?? JSON.stringify(rosterWithStanding()),
1181
1482
  };
1483
+ // A capture launch (api/capture.ts) reads its materials from a per-run inbox outside every
1484
+ // sandbox: the capture route registered it under the run id it minted.
1485
+ const captureRoot = captureLaunchRoots.get(input.sessionId);
1486
+ if (captureRoot !== undefined)
1487
+ input.extraWriteRoots = [captureRoot];
1488
+ // A demo launch (api/demo.ts) writes every file into the demo root the demo route minted for it.
1489
+ const demoRoot = demoLaunchRoots.get(input.sessionId);
1490
+ if (demoRoot !== undefined)
1491
+ input.extraWriteRoots = [demoRoot];
1182
1492
  if (b.entityMode !== undefined)
1183
1493
  input.entityMode = b.entityMode;
1184
1494
  if (b.humanConfirm !== undefined)
@@ -1192,6 +1502,10 @@ runtime = {}) {
1192
1502
  input.repoRef = b.repoRef;
1193
1503
  if (b.workflow !== undefined)
1194
1504
  input.workflow = b.workflow;
1505
+ // DES-TEAMING-002 T3: the plan is the ENGINE's to propose, score, floor and gate — crew
1506
+ // forwards the command and publishes nothing (§4.0).
1507
+ if (b.plan !== undefined)
1508
+ input.plan = toLaunchPlan(b.plan);
1195
1509
  if (b.projectId !== undefined) {
1196
1510
  input.projectId = b.projectId;
1197
1511
  // A project is a CONTEXT (crew#326): a run filed into one should see the project's whole
@@ -1310,8 +1624,45 @@ runtime = {}) {
1310
1624
  throw err;
1311
1625
  }
1312
1626
  }
1627
+ // crew#627: a workflow run's intent may name its work only by issue ("fix #541"), and the
1628
+ // worker cannot read GitHub. The daemon reads each linked issue now and appends its text to the
1629
+ // problem, inside a marked block the deliver text takes back out. Workflow launches only: the
1630
+ // free-text planner turns sentences into units, so issue text there would become work units.
1631
+ let linkedIssues;
1632
+ if (b.workflow !== undefined) {
1633
+ // Best-effort by contract: reading issues never fails the launch it decorates. A resolver
1634
+ // fault (the repo list unreadable, say) launches the run on its intent alone and is logged.
1635
+ try {
1636
+ const root = b.repoRef !== undefined ? await repoRootOf(b.repoRef) : undefined;
1637
+ const linked = await readLinkedIssues(b.problem, root, b.repoRef);
1638
+ if (linked.block !== null) {
1639
+ input.problem = `${b.problem.trimEnd()}\n\n${linked.block}`;
1640
+ linkedIssues = linked.issues;
1641
+ }
1642
+ }
1643
+ catch (err) {
1644
+ req.log.warn(`linked-issue resolution failed; launching on the intent alone: ${message(err)}`);
1645
+ }
1646
+ }
1313
1647
  try {
1314
1648
  const runId = await adapter.launchRun(input);
1649
+ // crew#641/#642 (item 5): when a run is promoted from a chat, record how many seats
1650
+ // participated and whether the chat was grounded — so an intent authored by a single
1651
+ // ungrounded model is distinguishable from one that survived a disagreement.
1652
+ let chatSeatCount;
1653
+ let chatGrounded;
1654
+ // Both stay ABSENT unless the chat actually resolves: a closed or unknown chatId must read as
1655
+ // "provenance unavailable", not as "promoted from an ungrounded zero-seat chat". A valid chat
1656
+ // always has at least one warm seat (`crew-api-types` ChatOpenResponse), so 0 is not a value
1657
+ // `chat_seat_count` can honestly hold.
1658
+ if (b.chatId !== undefined) {
1659
+ const promotedFrom = await adapter.chatSeats(b.chatId).catch(() => null);
1660
+ const scope = chatScopes.engineOf(b.chatId);
1661
+ if (promotedFrom !== null && promotedFrom.length > 0 && scope !== undefined) {
1662
+ chatSeatCount = promotedFrom.length;
1663
+ chatGrounded = scope.codeGraphDb != null;
1664
+ }
1665
+ }
1315
1666
  // Who launched it — the engine's LaunchOptions carries no actor field
1316
1667
  // (checked, wicked-core-ts 0.6.0), so the crew-side trail is the system
1317
1668
  // of record for run provenance (task #88).
@@ -1344,10 +1695,19 @@ runtime = {}) {
1344
1695
  // crew#619: the chat↔run link is durable so the retention maps can be rehydrated after a
1345
1696
  // daemon restart (rehydration reads `run.launched` entries and keeps non-terminal entries).
1346
1697
  ...(b.chatId !== undefined ? { chatId: b.chatId } : {}),
1698
+ // crew#641/#642 (item 5): chat promotion provenance — seat count and grounding at launch.
1699
+ ...(chatSeatCount !== undefined ? { chatSeatCount } : {}),
1700
+ ...(chatGrounded !== undefined ? { chatGrounded } : {}),
1347
1701
  // crew#632: launch surface and caller identity — human-readable provenance on the audit trail.
1348
1702
  ...(b.channel !== undefined ? { channel: b.channel } : {}),
1349
1703
  ...(b.actor !== undefined ? { actor: b.actor } : {}),
1704
+ // crew#627: which linked issues the daemon read into the problem, and which it could not.
1705
+ ...(linkedIssues !== undefined ? { linkedIssues } : {}),
1350
1706
  });
1707
+ // Stamp the index live so the field is served without waiting for a daemon restart.
1708
+ if (chatSeatCount !== undefined && chatGrounded !== undefined) {
1709
+ runTimingIndex.setChatPromotion(runId, chatSeatCount, chatGrounded);
1710
+ }
1351
1711
  if (b.retryOf !== undefined)
1352
1712
  retryIndex.set(runId, b.retryOf);
1353
1713
  if (revisesPr !== undefined)
@@ -1370,10 +1730,15 @@ runtime = {}) {
1370
1730
  // is always reproducible even if the chat is idle-reclaimed before the run finishes.
1371
1731
  if (b.chatId !== undefined)
1372
1732
  runtime.linkChatRun?.(b.chatId, runId);
1373
- return reply.code(201).send({ runId });
1733
+ return reply.code(201).send({ runId, ...(linkedIssues !== undefined ? { linkedIssues } : {}) });
1374
1734
  }
1375
1735
  catch (err) {
1376
1736
  const msg = message(err);
1737
+ // DES-TEAMING-002 T3: a plan on an engine without the plan approval gate is an "upgrade the
1738
+ // engine" 501 — never a free-text run launched in its place.
1739
+ if (err instanceof PlanLaunchUnsupportedError) {
1740
+ return reply.code(501).send({ error: msg });
1741
+ }
1377
1742
  // The engine's own intake refusal (wicked-core#411 `StateHomeConfigError`) is a configuration
1378
1743
  // error, not a malformed request: the same typed 409 the pre-check above answers, over a fresh
1379
1744
  // survey — the pre-check saw a clean state home (or an addon that could not classify), the
@@ -1536,8 +1901,6 @@ runtime = {}) {
1536
1901
  return reply.code(404).send({ error: 'Run not found' });
1537
1902
  return { run: decorateRun(run) };
1538
1903
  });
1539
- /** The full workflow registry, or `[]` on a directly-driven route set whose fake adapter has none. */
1540
- const listWorkflowsSafe = () => typeof adapter.listWorkflows === 'function' ? adapter.listWorkflows() : [];
1541
1904
  // ── Deliver text (crew#524 / F-3R2-014) — the PR title + body a run's delivery carries ──
1542
1905
  // `gh pr create --fill` gave wicked-studio#249 a mid-word title and an EMPTY body. The deliver
1543
1906
  // script now asks this route for the text composed from the PERSISTED RUN RECORD: the intent,
@@ -1552,12 +1915,12 @@ runtime = {}) {
1552
1915
  const run = views.find((v) => v.session.id === id);
1553
1916
  if (!run)
1554
1917
  return reply.code(404).send({ error: 'Run not found' });
1555
- const origin = boundOrigin(app.server.address());
1556
- // The workflow DEFINITION (a user-registered workflow's view carries the engine instance id).
1557
- const def = resolveRunWorkflow(run, listWorkflowsSafe());
1558
- const text = composeDeliverText(factsFromRun(decorateRun(run), runUrlFor(origin, id), {
1559
- workflowId: def?.id ?? null,
1918
+ const followUps = await evaluatorFollowUps(run);
1919
+ // The run's preset or workflow NAME, as the engine recorded it (seam X2).
1920
+ const text = composeDeliverText(factsFromRun(decorateRun(run), runUrlFor(configuredPublicOrigin(), id), {
1921
+ workflowId: runIdentityOf(run).name,
1560
1922
  revisesPr: retryIndex.revisesPrFor(id) ?? null,
1923
+ ...(followUps !== undefined ? { followUps } : {}),
1561
1924
  }));
1562
1925
  return reply.type('text/plain; charset=utf-8').send(framedDeliverText(text));
1563
1926
  });
@@ -1618,10 +1981,19 @@ runtime = {}) {
1618
1981
  const existing = deliveryIndex.urlFor(id);
1619
1982
  if (existing !== undefined)
1620
1983
  return { prUrl: existing };
1984
+ // N1: a run whose branch is already on a non-GitHub origin has nothing left to lift, and no
1985
+ // pull request can be opened from here — say so instead of pushing the branch again.
1986
+ const pushedAlready = deliveryIndex.pushedFor(id);
1987
+ if (pushedAlready !== undefined) {
1988
+ return reply.code(409).send({ error: pushedOnlyRefusal(id, pushedAlready) });
1989
+ }
1621
1990
  const views = await adapter.sessionsDetail();
1622
1991
  const run = views.find((v) => v.session.id === id);
1623
1992
  if (!run)
1624
1993
  return reply.code(404).send({ error: 'Run not found' });
1994
+ // Freeze deliveries (idea 15): nothing pushes while the switch is on.
1995
+ if (deliveryFreeze.state().frozen)
1996
+ return reply.code(409).send(frozenRefusal(deliveryFreeze.state()));
1625
1997
  // crew#418/#432: a run stranded by a recoverable deliver lift failure reads `failed` from the
1626
1998
  // engine but IS liftable post-hoc — normalize its status to `completed` so this route
1627
1999
  // accepts it, exactly as the run wire and the resume route see it.
@@ -1685,7 +2057,7 @@ runtime = {}) {
1685
2057
  // crew#524: the post-hoc lift composes its PR text from the run record it already
1686
2058
  // holds (the fallback), and names this daemon so the script can re-ask at delivery.
1687
2059
  const origin = boundOrigin(app.server.address());
1688
- const def = resolveRunWorkflow(run, listWorkflowsSafe());
2060
+ const workflowName = runIdentityOf(run).name;
1689
2061
  const revising = retryIndex.revisesPrFor(id);
1690
2062
  let revisesPr = null;
1691
2063
  if (revising !== undefined) {
@@ -1699,14 +2071,20 @@ runtime = {}) {
1699
2071
  }
1700
2072
  revisesPr = { number: revising.number, headRef: again.pr.headRef, url: again.pr.url };
1701
2073
  }
2074
+ const followUps = await evaluatorFollowUps(run);
1702
2075
  result = await deliverExec(workdir, s.problem ?? undefined, {
1703
2076
  runId: id,
1704
2077
  apiOrigin: origin,
1705
- facts: factsFromRun(run, runUrlFor(origin, id), {
1706
- workflowId: def?.id ?? null,
2078
+ facts: factsFromRun(run, runUrlFor(configuredPublicOrigin(), id), {
2079
+ workflowId: workflowName,
1707
2080
  revisesPr: revisesPr === null ? null : { number: revisesPr.number, url: revisesPr.url },
2081
+ ...(followUps !== undefined ? { followUps } : {}),
1708
2082
  }),
1709
2083
  revisesPr,
2084
+ // R1 (Copilot on crew#736): the post-hoc delivery binds `gh pr create --repo` to the
2085
+ // origin's GitHub repository too, read now from the worktree (it shares its repo's
2086
+ // remotes), so the PR opens where origin points rather than where gh's default does.
2087
+ originUrl: await readDeliverOriginUrl(workdir),
1710
2088
  });
1711
2089
  }
1712
2090
  finally {
@@ -1748,7 +2126,19 @@ runtime = {}) {
1748
2126
  error: `deliver failed (exit ${result.status}): ${deliverErrorTail(result.output)}`,
1749
2127
  });
1750
2128
  }
1751
- const url = prUrlFrom(result.output);
2129
+ // The SAME parser the in-run record uses: the script's push-only verdict outranks a URL a
2130
+ // remote hook echoed earlier in the transcript (Copilot on crew#734).
2131
+ const record = deliveryRecordFrom(result.output);
2132
+ const url = record !== null && 'url' in record ? record.url : null;
2133
+ const pushedOnly = record !== null && 'pushed' in record ? record.pushed : null;
2134
+ if (pushedOnly !== null) {
2135
+ // N1: the script pushed the branch and gh could resolve no GitHub repository for the
2136
+ // origin. That IS a delivery — record it, so the run reads `delivery: 'pushed'` — but there
2137
+ // is no PR URL to answer with, so the reply says what happened instead of a 200 `prUrl`.
2138
+ audit.record('run.delivered', actorOf(req), { runId: id, detail: { pushed: pushedOnly, via: 'post-hoc' } });
2139
+ deliveryIndex.setPushed(id, pushedOnly);
2140
+ return reply.code(409).send({ error: pushedOnlyRefusal(id, pushedOnly) });
2141
+ }
1752
2142
  if (url === null) {
1753
2143
  // Exit 0 with no URL should be unreachable (the script re-derives its own success),
1754
2144
  // but a delivery nothing can be pointed at is never recorded (crew#317).
@@ -1826,6 +2216,12 @@ runtime = {}) {
1826
2216
  }
1827
2217
  const b = parsed.data;
1828
2218
  const chatId = b.chatId ?? randomUUID();
2219
+ const requestedRefs = [...(b.repoRef !== undefined ? [b.repoRef] : []), ...(b.repoRefs ?? [])];
2220
+ // A named scope's SHAPE first (studio#323 R4, codex on #664): a body whose kind refuses its own
2221
+ // projectId/repoRefs is a 400 — never masked by the project lookup's 404/409 below.
2222
+ const shape = namedKindRefusal(b.scopeKind, b.projectId, [...new Set(requestedRefs)]);
2223
+ if (shape !== null)
2224
+ return reply.code(400).send({ error: shape.error });
1829
2225
  // Validate the project BEFORE opening seats: a chat has no launch record for the engine to
1830
2226
  // attach against atomically (chats are an in-memory seat pool), so the route validates
1831
2227
  // up-front and attaches right after open — the one non-atomic attach, documented in the ADR
@@ -1866,8 +2262,14 @@ runtime = {}) {
1866
2262
  const resolution = await resolveChatScope({
1867
2263
  chatId,
1868
2264
  ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
1869
- repoRefs: [...(b.repoRef !== undefined ? [b.repoRef] : []), ...(b.repoRefs ?? [])],
1870
- }, { ...chatScopeDeps(adapter), scratchBase: chatScopes.base, log: (m) => req.log.warn(m) });
2265
+ repoRefs: requestedRefs,
2266
+ ...(b.scopeKind !== undefined ? { kind: b.scopeKind } : {}),
2267
+ }, {
2268
+ ...chatScopeDeps(adapter),
2269
+ scratchBase: chatScopes.base,
2270
+ log: (m) => req.log.warn(m),
2271
+ ...(runtime.entityCount !== undefined ? { entityCount: runtime.entityCount } : {}),
2272
+ });
1871
2273
  if (!resolution.ok) {
1872
2274
  chatScopes.release(chatId, token);
1873
2275
  return reply.code(resolution.status).send({
@@ -1895,7 +2297,10 @@ runtime = {}) {
1895
2297
  // here is NAMED on the response (`refused`) and in the thread (`chatSeatRefused`), so a
1896
2298
  // person can see why pi is missing. Explicit `clis` are passed through as asked; the engine
1897
2299
  // refuses per seat with its reason, which is copied into `refused` too.
1898
- const scoped = scope.kind !== 'none';
2300
+ // A chat is SCOPED — held to read-only roots — when it reads repositories by its kind. `none`
2301
+ // and `system` (studio#323 R4: the platform itself, no repository) read none, so they take the
2302
+ // unscoped admission and open on an engine that predates chat scope.
2303
+ const scoped = scope.kind !== 'none' && scope.kind !== 'system';
1899
2304
  const refused = [];
1900
2305
  // The standing roster, read ONCE: the default admission below and the engine-drop
1901
2306
  // attribution after `chatOpen` both consult it (F-A45-011).
@@ -2067,12 +2472,17 @@ runtime = {}) {
2067
2472
  ...(b.projectId !== undefined ? { project_id: b.projectId } : {}),
2068
2473
  });
2069
2474
  }
2475
+ // crew#641: a chat with exactly ONE warm seat cannot disagree with itself — disclosed
2476
+ // prominently so the caller does not have to reason about the refused[] array.
2477
+ const warm = seats.filter((s) => s.ok);
2478
+ const singleSeat = warm.length === 1 ? singleSeatDisclosure(warm[0].cliKey, refused, 'open') : undefined;
2070
2479
  return reply.code(201).send({
2071
2480
  chatId,
2072
2481
  seats,
2073
2482
  scope,
2074
2483
  refused,
2075
2484
  ...(projectAttachError !== undefined ? { projectAttachError } : {}),
2485
+ ...(singleSeat !== undefined ? { singleSeat } : {}),
2076
2486
  });
2077
2487
  }
2078
2488
  catch (err) {
@@ -2123,7 +2533,12 @@ runtime = {}) {
2123
2533
  // warm seats — so the turn can be opened BEFORE the engine call, in the same tick as the
2124
2534
  // predicate below: two racing sends can no longer both pass `inFlight` during the await, and a
2125
2535
  // `chatDelta` that lands before `chatSend` resolves is already stamped with its `turn_id`.
2126
- const audience = parsed.data.targets ?? (await adapter.chatSeats(id));
2536
+ // crew#641 (review): the ROSTER and the AUDIENCE are different things and only one of them can
2537
+ // answer "can this chat disagree with itself". Resolved ONCE here: `audience` is who this turn
2538
+ // goes to (the named targets, else everyone warm), `warmRoster` is who is warm at all. A
2539
+ // targeted send to one seat of a two-seat chat is not a degraded chat.
2540
+ const warmRoster = await adapter.chatSeats(id);
2541
+ const audience = parsed.data.targets ?? warmRoster;
2127
2542
  if (audience.length === 0) {
2128
2543
  return reply.code(409).send({ error: `chat '${id}' has no warm seats — open it first` });
2129
2544
  }
@@ -2141,7 +2556,22 @@ runtime = {}) {
2141
2556
  chatTurns.reconcile(id, turn.turnId, seats);
2142
2557
  chatTranscripts?.appendUser(id, turn.turnId, parsed.data.text, seats);
2143
2558
  }
2144
- return reply.code(202).send({ seats, ...(turn !== null ? { turnId: turn.turnId } : {}) });
2559
+ // crew#641: re-state single-seat degradation on every turn so it is visible in the transcript.
2560
+ // Decided from `warmRoster` — the seats that are WARM — never from `seats`, which is only the
2561
+ // seats this turn reached: a targeted send to one seat of a two-seat chat would otherwise
2562
+ // announce a degradation that does not exist, and a false "you are degraded" sends an operator
2563
+ // after a problem they do not have.
2564
+ // crew#650: `refusedOf` is `undefined` for a chat THIS daemon did not open (a restart drops
2565
+ // the index) — that is "the refusals are unknown", not "the chat gained a seat". The
2566
+ // disclosure is decided by the warm roster alone, and the `undefined` is handed STRAIGHT to
2567
+ // the builder (never `?? []`, review of #658): only it may decide what an unknown record is
2568
+ // allowed to claim.
2569
+ const singleSeat202 = warmRoster.length === 1 ? singleSeatDisclosure(warmRoster[0], chatScopes.refusedOf(id), 'turn') : undefined;
2570
+ return reply.code(202).send({
2571
+ seats,
2572
+ ...(turn !== null ? { turnId: turn.turnId } : {}),
2573
+ ...(singleSeat202 !== undefined ? { singleSeat: singleSeat202 } : {}),
2574
+ });
2145
2575
  }
2146
2576
  catch (err) {
2147
2577
  // Nothing went out: the reservation is retracted so the next send is not refused for it.
@@ -2348,11 +2778,11 @@ runtime = {}) {
2348
2778
  const run = views.find((v) => v.session.id === id);
2349
2779
  if (!run)
2350
2780
  return reply.code(404).send({ error: 'Run not found' });
2351
- // `sessionsDetail()` patches workflow_id back to the definition name for
2352
- // BUILT-INS; runs of user-registered workflows still carry the instance id,
2353
- // so resolve by phase sequence over the full registry. A free-text run
2354
- // resolves to no workflow, which reads as "declares no requirement".
2355
- const workflow = resolveRunWorkflow(run, adapter.listWorkflows());
2781
+ // What the run must prove, from what it CONTAINS (seam X2): a plan or preset run's steps whose
2782
+ // catalog entry re-verifies evidence, a registered workflow's def, nothing for free text — and
2783
+ // an unknown run (or one the daemon cannot read) is declared and denied with the reason.
2784
+ const verifiedCatalog = typeof adapter.verifiedEvidenceCatalog === 'function' ? await adapter.verifiedEvidenceCatalog() : null;
2785
+ const requirement = acceptanceRequirementOf(run, adapter.listWorkflows(), verifiedCatalog);
2356
2786
  let repo = null;
2357
2787
  if (run.session.repo_ref !== null) {
2358
2788
  const repos = await adapter.listRepos();
@@ -2361,8 +2791,7 @@ runtime = {}) {
2361
2791
  return buildAcceptanceView({
2362
2792
  runId: id,
2363
2793
  repo,
2364
- workflow,
2365
- gateEvents: qeGateEvents,
2794
+ requirement,
2366
2795
  ...(qeRunId !== undefined && qeRunId !== '' ? { qeRunId } : {}),
2367
2796
  // AW-14 (arch-R13a + R16): the conformance section reads the same wires the standalone
2368
2797
  // `/governance/claims` and `/runs/:id/events` routes serve, but run-scoped and resolved
@@ -2372,55 +2801,122 @@ runtime = {}) {
2372
2801
  events: (rid) => adapter.runEvents(rid),
2373
2802
  });
2374
2803
  });
2375
- // The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
2376
- app.post(`${V}/runs/:id/gate`, {
2377
- config: {
2378
- manifest: {
2379
- requestType: 'GateDecision',
2380
- responseType: '{ status: SessionStatus; landing?: SteeringLandingResult }',
2381
- // 409 twice over: a run not awaiting a human gate, and an engine refusal at confirm.
2382
- statusCodes: [200, 400, 404, 409],
2383
- },
2384
- },
2385
- }, async (req, reply) => {
2386
- const { id } = req.params;
2387
- const parsed = GateSchema.safeParse(req.body);
2804
+ /**
2805
+ * The run's OPEN gate, resolved the way `GET /runs/:id/gate` serves it: the cache, then the
2806
+ * engine's durable `interaction_requests` row (adopted into the cache), then the event-log replay.
2807
+ * The caller has already checked `awaiting_human`. `null` = the log records no open gate;
2808
+ * `'no-log'` = this build cannot read the log, so nothing can be said.
2809
+ */
2810
+ async function resolveOpenGate(id) {
2811
+ const cached = gateCache.get(id);
2812
+ if (cached)
2813
+ return cached;
2814
+ const durableRows = typeof adapter.interactionRequests === 'function'
2815
+ ? await adapter.interactionRequests(id, 'open')
2816
+ : null;
2817
+ const durableGate = durableRows?.find((r) => r.kind === 'gate');
2818
+ if (durableGate !== undefined) {
2819
+ // This path builds the entry inline (not through `fold`), so it must run the SAME refusal
2820
+ // detection (issue #419) — otherwise a gate served from the durable row after a restart would
2821
+ // silently drop the warning that the live/replay paths carry.
2822
+ const refusal = detectRefusal(durableGate.prompt);
2823
+ const entry = {
2824
+ ord: durableGate.ord ?? 0,
2825
+ prompt: durableGate.prompt,
2826
+ lifecycle: 'open',
2827
+ receivedAt: new Date(durableGate.created_at).toISOString(),
2828
+ ...(refusal !== undefined ? { refusal } : {}),
2829
+ };
2830
+ gateCache.adopt(id, entry);
2831
+ return entry;
2832
+ }
2833
+ const events = await adapter.runEvents(id);
2834
+ if (events === null)
2835
+ return 'no-log';
2836
+ return gateCache.rebuild(id, events) ?? null;
2837
+ }
2838
+ /**
2839
+ * THE gate decision path. `POST /runs/:id/gate` answers with it, and so does a standing order
2840
+ * (behaviour 10) — the same awaiting check, the same ord check and 409 `gate_changed` /
2841
+ * `gate_unknown`, the same `confirmGate`, the same `gate.decided` audit. There is no side path:
2842
+ * an order is just another actor, named on the audit line (`extraDetail.standingOrder`).
2843
+ */
2844
+ async function decideGate(id, decision, actor, extraDetail = {}) {
2845
+ const parsed = GateSchema.safeParse(decision);
2388
2846
  if (!parsed.success) {
2389
- return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
2847
+ return { code: 400, body: invalidBody(parsed.error, 'Invalid request body') };
2390
2848
  }
2391
2849
  const views = await adapter.sessionsDetail();
2392
2850
  const run = views.find((v) => v.session.id === id);
2393
2851
  if (!run)
2394
- return reply.code(404).send({ error: 'Run not found' });
2852
+ return { code: 404, body: { error: 'Run not found' } };
2395
2853
  if (run.session.status !== 'awaiting_human') {
2396
- return reply
2397
- .code(409)
2398
- .send({ error: `Run is not awaiting a human gate (status: ${run.session.status})` });
2854
+ return { code: 409, body: { error: `Run is not awaiting a human gate (status: ${run.session.status})` } };
2855
+ }
2856
+ // api-types 0.44.0: a decision that names its gate answers THAT gate or nothing. When the
2857
+ // daemon cannot tell which gate is open (no cache, no durable row, no log binding, or a log
2858
+ // recording no open gate) it cannot confirm the named one either, so it refuses — the skin
2859
+ // re-reads the gate and the person decides again. A request without `ord` is unchanged.
2860
+ if (parsed.data.ord !== undefined) {
2861
+ const open = await resolveOpenGate(id);
2862
+ if (open === null || open === 'no-log') {
2863
+ return { code: 409, body: {
2864
+ error: `Gate unknown: this decision names the gate before unit ${parsed.data.ord}, but the daemon cannot `
2865
+ + 'tell which gate is open on this run — refresh and decide again.',
2866
+ code: 'gate_unknown',
2867
+ } };
2868
+ }
2869
+ if (open.ord !== parsed.data.ord) {
2870
+ return { code: 409, body: {
2871
+ error: `Gate changed: this decision was made on the gate before unit ${parsed.data.ord}, but the open gate `
2872
+ + `is before unit ${open.ord} — it was answered or replaced. Read the open gate before deciding.`,
2873
+ code: 'gate_changed',
2874
+ openOrd: open.ord,
2875
+ } };
2876
+ }
2877
+ }
2878
+ // Freeze deliveries (idea 15): an APPROVE that would run the run's deliver unit is held while
2879
+ // the switch is on — the gate stays open, so unfreezing lets the same approve through. The gate
2880
+ // is the named one, else the open one, else the unit the run's cursor points at.
2881
+ if (parsed.data.approve && deliveryFreeze.state().frozen) {
2882
+ let gateOrd = parsed.data.ord;
2883
+ if (gateOrd === undefined) {
2884
+ const open = await resolveOpenGate(id);
2885
+ gateOrd = open !== null && open !== 'no-log' ? open.ord : undefined;
2886
+ }
2887
+ const units = run.units ?? [];
2888
+ const gated = gateOrd !== undefined
2889
+ ? units.find((u) => u.ord === gateOrd)
2890
+ : [...units].sort((a, b) => a.ord - b.ord)[run.session.unit_ix];
2891
+ if (gated !== undefined && isDeliverUnit(gated)) {
2892
+ return { code: 409, body: frozenRefusal(deliveryFreeze.state()) };
2893
+ }
2399
2894
  }
2400
2895
  // The steering-author landing (crew#388): decided — and the gate prompt captured — BEFORE
2401
2896
  // the confirm, because a terminal-phase approve prunes the gate cache and moves the run out
2402
- // of `awaiting_human`. The landing itself runs AFTER a successful approve only.
2403
- // (`listWorkflows` is presence-guarded for partial-stub adapters — a registry-less adapter
2404
- // cannot be hosting a steering-author run, and the legacy gate path must not 500 over it.)
2405
- const steeringPropose = parsed.data.approve &&
2406
- typeof adapter.listWorkflows === 'function' &&
2407
- isSteeringAuthorRun(run, adapter.listWorkflows());
2897
+ // of `awaiting_human`. The landing itself runs AFTER a successful approve only. The run is
2898
+ // classified by its recorded name (seam X2), so a partial-stub adapter with no registry cannot
2899
+ // 500 the legacy gate path here.
2900
+ const steeringPropose = parsed.data.approve && isSteeringAuthorRun(run);
2408
2901
  const gatePrompt = steeringPropose ? gateCache.get(id)?.prompt : undefined;
2409
2902
  try {
2410
2903
  // All five positionals, explicit `undefined` for the absent ones (DES-L1 PR-2; the
2411
2904
  // gate-arms tripwire pins the arity). An engine refusal (no creator to send back to,
2412
2905
  // an engine older than the arm) surfaces as the existing 409 below.
2413
- const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend, parsed.data.action, parsed.data.amendScope);
2906
+ const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend, parsed.data.action, parsed.data.amendScope, parsed.data.plan !== undefined ? toLaunchPlan(parsed.data.plan) : undefined);
2414
2907
  // WHO approved/rejected — the gate-decision audit (task #88). The engine
2415
2908
  // records THAT the gate resolved (interaction_requests / gateDecided);
2416
2909
  // only this HTTP layer knows the authenticated principal behind it.
2417
- audit.record('gate.decided', actorOf(req), {
2910
+ audit.record('gate.decided', actor, {
2418
2911
  runId: id,
2419
2912
  detail: {
2420
2913
  approve: parsed.data.approve,
2421
2914
  ...(parsed.data.amend !== undefined ? { amend: parsed.data.amend } : {}),
2422
2915
  ...(parsed.data.action !== undefined ? { action: parsed.data.action } : {}),
2423
2916
  ...(parsed.data.amendScope !== undefined ? { amendScope: parsed.data.amendScope } : {}),
2917
+ ...(parsed.data.plan !== undefined ? { planSteps: parsed.data.plan.steps.length } : {}),
2918
+ ...(parsed.data.ord !== undefined ? { ord: parsed.data.ord } : {}),
2919
+ ...extraDetail,
2424
2920
  status,
2425
2921
  },
2426
2922
  });
@@ -2433,13 +2929,32 @@ runtime = {}) {
2433
2929
  // text (an operator who wants different rules rejects and re-authors). A reject lands
2434
2930
  // nothing — the run cancels and the proposal stays an artifact.
2435
2931
  const landing = steeringPropose
2436
- ? await landSteeringProposal({ adapter, audit, actor: actorOf(req) }, run, gatePrompt)
2932
+ ? await landSteeringProposal({ adapter, audit, actor }, run, gatePrompt)
2437
2933
  : undefined;
2438
- return reply.send({ status, ...(landing !== undefined ? { landing } : {}) });
2934
+ return { code: 200, body: { status, ...(landing !== undefined ? { landing } : {}) } };
2439
2935
  }
2440
2936
  catch (err) {
2441
- return reply.code(409).send({ error: message(err) });
2937
+ if (err instanceof PlanLaunchUnsupportedError) {
2938
+ return { code: 501, body: { error: message(err) } };
2939
+ }
2940
+ return { code: 409, body: { error: message(err) } };
2442
2941
  }
2942
+ }
2943
+ // The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
2944
+ app.post(`${V}/runs/:id/gate`, {
2945
+ config: {
2946
+ manifest: {
2947
+ requestType: 'GateDecision',
2948
+ responseType: '{ status: SessionStatus; landing?: SteeringLandingResult }',
2949
+ // 409 twice over: a run not awaiting a human gate, and an engine refusal at confirm.
2950
+ // 501: an edited plan on an addon without the plan approval gate (DES-TEAMING-002 T3).
2951
+ statusCodes: [200, 400, 404, 409, 501],
2952
+ },
2953
+ },
2954
+ }, async (req, reply) => {
2955
+ const { id } = req.params;
2956
+ const out = await decideGate(id, req.body, actorOf(req));
2957
+ return reply.code(out.code).send(out.body);
2443
2958
  });
2444
2959
  // Cancel a running or paused run (distinct third action, §11.1).
2445
2960
  app.post(`${V}/runs/:id/cancel`, async (req, reply) => {
@@ -2486,9 +3001,11 @@ runtime = {}) {
2486
3001
  ? `run ${id} is cancelled — a terminal run cannot be resumed; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`
2487
3002
  : state.delivery === 'stranded'
2488
3003
  ? `run ${id} is already completed — nothing to resume; its unlifted work is on the wicked/${id} branch: deliver it with POST /runs/${id}/deliver`
2489
- : state.delivery === 'vacuous'
2490
- ? `run ${id} is already completed, but VACUOUSLY — its units produced no work to resume or deliver; relaunch with POST /runs {"retryOf":"${id}"}`
2491
- : `run ${id} is already completed — nothing to resume; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`;
3004
+ : state.delivery === 'pushed'
3005
+ ? `run ${id} is already completed and delivered — its branch ${state.deliverBranch ?? `wicked/${id}`} is on ${state.deliverRemote ?? 'origin'} (no pull request: that origin is not a GitHub host gh can resolve); nothing to resume`
3006
+ : state.delivery === 'vacuous'
3007
+ ? `run ${id} is already completed, but VACUOUSLY — its units produced no work to resume or deliver; relaunch with POST /runs {"retryOf":"${id}"}`
3008
+ : `run ${id} is already completed — nothing to resume; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`;
2492
3009
  return reply.code(409).send({ error: why, recovery });
2493
3010
  }
2494
3011
  try {
@@ -2496,9 +3013,7 @@ runtime = {}) {
2496
3013
  // A gated resume IS a gate approval, so it lands a steering-author proposal exactly like
2497
3014
  // POST /runs/:id/gate would — the "no side door" rule (task #88) holds for the landing
2498
3015
  // write too (crew#388). Prompt captured pre-confirm; see the gate route.
2499
- const steeringPropose = gated &&
2500
- typeof adapter.listWorkflows === 'function' &&
2501
- isSteeringAuthorRun(run, adapter.listWorkflows());
3016
+ const steeringPropose = gated && isSteeringAuthorRun(run);
2502
3017
  const gatePrompt = steeringPropose ? gateCache.get(id)?.prompt : undefined;
2503
3018
  const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
2504
3019
  // A resume of a gated run IS a gate approval — audit it as one, so the
@@ -2580,7 +3095,7 @@ runtime = {}) {
2580
3095
  `this build cannot approve the gate on your behalf (no confirmGate); approve via POST /runs/${id}/gate, then reassign`,
2581
3096
  });
2582
3097
  }
2583
- if (gated && typeof adapter.listWorkflows === 'function' && isSteeringAuthorRun(run, adapter.listWorkflows())) {
3098
+ if (gated && isSteeringAuthorRun(run)) {
2584
3099
  return reply.code(409).send({
2585
3100
  error: `run ${id} is awaiting a steering-author propose gate — approve or reject it via POST /runs/${id}/gate (the approve lands the proposal); reassign is not a review of the rules`,
2586
3101
  });
@@ -2648,34 +3163,12 @@ runtime = {}) {
2648
3163
  return reply.code(404).send({ error: 'No open gate for this run' });
2649
3164
  }
2650
3165
  // DURABLE TRUTH FIRST (DES-PROJECT-001 §5.3): the engine persists the open prompt in
2651
- // `interaction_requests`, written in the same transaction as the `awaiting_human` pause —
2652
- // so a daemon restart reads it back directly instead of replaying the event log. The cache
2653
- // adopts the row (latency layer over durable truth — its comments finally true). The replay
2654
- // below stays as the FALLBACK, not just for engines predating the binding: a run parked
2655
- // BEFORE the engine grew the table is awaiting_human with no row, and answering 404 there
2656
- // would re-open FINDING-051 for exactly the runs mid-upgrade. Row → serve it; no row →
2657
- // fall through and let the log speak.
2658
- const durableRows = typeof adapter.interactionRequests === 'function'
2659
- ? await adapter.interactionRequests(id, 'open')
2660
- : null;
2661
- const durableGate = durableRows?.find((r) => r.kind === 'gate');
2662
- if (durableGate !== undefined) {
2663
- // This path builds the entry inline (not through `fold`), so it must run the SAME refusal
2664
- // detection (issue #419) — otherwise a gate served from the durable row after a restart would
2665
- // silently drop the warning that the live/replay paths carry.
2666
- const refusal = detectRefusal(durableGate.prompt);
2667
- const entry = {
2668
- ord: durableGate.ord ?? 0,
2669
- prompt: durableGate.prompt,
2670
- lifecycle: 'open',
2671
- receivedAt: new Date(durableGate.created_at).toISOString(),
2672
- ...(refusal !== undefined ? { refusal } : {}),
2673
- };
2674
- gateCache.adopt(id, entry);
2675
- return { runId: id, ...entry };
2676
- }
2677
- const events = await adapter.runEvents(id);
2678
- if (events === null) {
3166
+ // `interaction_requests`, written in the same transaction as the `awaiting_human` pause — so a
3167
+ // daemon restart reads it back instead of replaying the log; the replay stays the FALLBACK for
3168
+ // runs parked before the engine grew the table. `resolveOpenGate` is that one resolution, shared
3169
+ // with the POST route's `ord` check.
3170
+ const open = await resolveOpenGate(id);
3171
+ if (open === 'no-log') {
2679
3172
  // Now — and only now — 503 is the honest answer: this run really is holding for a human, and
2680
3173
  // this build cannot say what it is asking. Distinct cause, distinct message; answering "no
2681
3174
  // gate" here would report a capability gap as a fact about the run (the FINDING-050 shape).
@@ -2683,13 +3176,12 @@ runtime = {}) {
2683
3176
  error: 'Gate history is unavailable: this wicked-core build has no event-log read binding',
2684
3177
  });
2685
3178
  }
2686
- const replayed = gateCache.rebuild(id, events);
2687
- if (!replayed) {
3179
+ if (open === null) {
2688
3180
  // Parked, with a history that records no open gate. Pre-log runs land here (their prompt is
2689
3181
  // genuinely lost), as does a gate whose `awaitingHuman` predates the log's retention.
2690
3182
  return reply.code(404).send({ error: 'No open gate for this run' });
2691
3183
  }
2692
- return { runId: id, ...replayed };
3184
+ return { runId: id, ...open };
2693
3185
  });
2694
3186
  // ── Elicitation (DES-002) ────────────────────────────────────────────────────
2695
3187
  //
@@ -2979,6 +3471,74 @@ runtime = {}) {
2979
3471
  const claims = await adapter.listConformanceClaims();
2980
3472
  return { claims };
2981
3473
  });
3474
+ // Replay THIS daemon's governance dead-letter outbox into its own store — the
3475
+ // `wicked-crew governance replay` CLI (crew#495) as a route, so a skin can offer the repair
3476
+ // beside the count it shows (`/diagnostics.governance.deadletters`). `dryRun: true` folds the
3477
+ // outbox and moves nothing, and says up front whether a real replay could run at all
3478
+ // (`blocker`); a real replay drains exactly as the CLI does (archive first, entries that fail
3479
+ // go back onto the live outbox and stay dead letters). One replay at a time per daemon.
3480
+ let deadletterReplayInFlight = false;
3481
+ app.post(`${V}/governance/deadletters/replay`, {
3482
+ config: {
3483
+ manifest: {
3484
+ requestType: 'GovernanceReplayBody',
3485
+ responseType: 'GovernanceReplayOutcome',
3486
+ statusCodes: [200, 400, 409, 500, 501],
3487
+ },
3488
+ },
3489
+ }, async (req, reply) => {
3490
+ const parsed = z.object({ dryRun: z.boolean().optional() }).strict().safeParse(req.body ?? {});
3491
+ if (!parsed.success)
3492
+ return reply.code(400).send({ error: parsed.error.message });
3493
+ const dryRun = parsed.data.dryRun === true;
3494
+ const store = adapter.governanceStore ?? null;
3495
+ if (store === null) {
3496
+ return reply.code(409).send({ error: 'this daemon resolved no governance store — there is nothing to replay into' });
3497
+ }
3498
+ const blocker = store.dbPath === ':memory:'
3499
+ ? 'the governance store is in-memory — name a durable store before replaying'
3500
+ : !CoreAdapter.replayEmitOutboxSupported()
3501
+ ? 'the installed engine cannot replay a dead-letter outbox — upgrade wicked-core-ts'
3502
+ : null;
3503
+ // Checked FIRST, for both modes: a running replay has renamed the live outbox to its archive,
3504
+ // so any read now would report a false zero while those dead letters are still in flight.
3505
+ if (deadletterReplayInFlight)
3506
+ return reply.code(409).send({ error: 'a dead-letter replay is already running' });
3507
+ const outbox = store.outboxPath;
3508
+ if (!existsSync(outbox)) {
3509
+ // No outbox = no dead letters: a zero outcome, never an error.
3510
+ const fold = await foldDeadletters(outbox);
3511
+ return {
3512
+ outbox, store: { path: store.displayPath, source: store.source }, archive: null,
3513
+ read: 0, replayed: 0, alreadyPresent: null, failed: 0, dryRun, note: null,
3514
+ ...(dryRun ? { fold } : {}), blocker,
3515
+ };
3516
+ }
3517
+ if (!dryRun && blocker !== null)
3518
+ return reply.code(blocker.startsWith('the installed engine') ? 501 : 409).send({ error: blocker });
3519
+ if (!dryRun)
3520
+ deadletterReplayInFlight = true;
3521
+ try {
3522
+ const { outcome } = await replayOutboxInto(outbox, store, { dryRun });
3523
+ if (!dryRun) {
3524
+ audit.record('governance.deadletters.replayed', actorOf(req), {
3525
+ detail: { read: outcome.read, replayed: outcome.replayed, failed: outcome.failed },
3526
+ });
3527
+ }
3528
+ return { ...outcome, blocker: dryRun ? blocker : null };
3529
+ }
3530
+ catch (err) {
3531
+ if (err instanceof GovernanceReplayUnsupportedError)
3532
+ return reply.code(501).send({ error: message(err) });
3533
+ if (err instanceof UsageError)
3534
+ return reply.code(409).send({ error: message(err) });
3535
+ return reply.code(500).send({ error: message(err) });
3536
+ }
3537
+ finally {
3538
+ if (!dryRun)
3539
+ deadletterReplayInFlight = false;
3540
+ }
3541
+ });
2982
3542
  // Normalize a `?repo=` query: Fastify parses a repeated `?repo=a&repo=b` into a `string[]` (take
2983
3543
  // the first), and the value is TRIMMED so it's both validated and PASSED trimmed — a `?repo=%20x%20`
2984
3544
  // must not pass the guard then miss the registry lookup on whitespace (Copilot #227).
@@ -3148,15 +3708,41 @@ runtime = {}) {
3148
3708
  }
3149
3709
  });
3150
3710
  // ── Workflow viewer + builder (crew#44) ───────────────────────────────────
3711
+ // (crew#718) Start the engine's verdict pass at registration, so every SYNC reader of
3712
+ // `listWorkflows()` / `getWorkflow()` (the deliver resolver, the acceptance fold, the launch
3713
+ // lookup) sees the judged catalog from the first request rather than after the first catalog GET.
3714
+ // The pass is memoized: the handlers below await the same promise.
3715
+ // Guarded like `listWorkflows` at :1110 — the endpoint-manifest collector registers these routes
3716
+ // against a partial adapter that carries neither.
3717
+ if (typeof adapter.workflowCatalog === 'function') {
3718
+ void adapter.workflowCatalog().catch((err) => {
3719
+ app.log.warn(`wicked-crew: the workflow overlay could not be judged (${message(err)}); the catalog lists what parsed`);
3720
+ });
3721
+ }
3722
+ // (crew#718) The catalog is what the ENGINE will honour, not what parses as `{id, phases[]}`.
3723
+ // A drop-in core refused at boot ("gate evaluates nothing: …") used to be listed here and
3724
+ // offered in studio's selector, and the launch then 400'd `unknown workflow` — the operator
3725
+ // learnt about it only from the failed launch. `workflowCatalog()` asks core's own validator:
3726
+ // the refused def leaves `workflows` (so it cannot be launched) and appears in `unavailable`
3727
+ // with core's verbatim reason (so its author is told why, and what to fix).
3151
3728
  app.get(`${V}/workflows`, async () => {
3152
- const workflows = adapter.listWorkflows();
3153
- return { workflows };
3729
+ const { workflows, unavailable } = await adapter.workflowCatalog();
3730
+ return { workflows, unavailable };
3154
3731
  });
3155
3732
  app.get(`${V}/workflows/:id`, async (req, reply) => {
3156
3733
  const { id } = req.params;
3734
+ // (crew#718) JUDGE FIRST (review of PR #724, MEDIUM). `getWorkflow` only hydrates the overlay
3735
+ // dir; `workflowRefusal` is what runs the engine's verdict pass and drops the defs it refused.
3736
+ // Read the other way round, the FIRST request for a refused drop-in answered 200 with the def
3737
+ // the engine had thrown away — and this route is where the launch looks it up.
3738
+ const refusal = await adapter.workflowRefusal(id);
3739
+ if (refusal !== null) {
3740
+ return reply.code(404).send({ error: `workflow '${id}' was refused by the engine: ${refusal}`, reason: refusal });
3741
+ }
3157
3742
  const workflow = adapter.getWorkflow(id);
3158
- if (!workflow)
3743
+ if (!workflow) {
3159
3744
  return reply.code(404).send({ error: `workflow '${id}' not found` });
3745
+ }
3160
3746
  // humanGates: the phases that will PAUSE for a person even under humanConfirm:none (core#208).
3161
3747
  // Surfaced so an operator can see a workflow's gates BEFORE launching it (FINDING-023).
3162
3748
  return { workflow, humanGates: humanGatePhaseIds(workflow) };
@@ -3478,6 +4064,18 @@ runtime = {}) {
3478
4064
  return reply.code(500).send({ error: message(err) });
3479
4065
  }
3480
4066
  });
4067
+ // R3 (ship-prove-3): what a delivering launch on this repo would push and where — read by the
4068
+ // SAME origin preflight the deliver gate card uses (crew#730), so the launch composer cannot
4069
+ // promise "opens a PR" on an origin that can never carry one. Read-only: one
4070
+ // `git remote get-url --push origin` in the registered checkout; nothing is pushed or probed.
4071
+ app.get(`${V}/repos/:id/deliver-target`, { config: { manifest: { responseType: 'DeliverTargetResponse', statusCodes: [200, 404] } } }, async (req, reply) => {
4072
+ const { id } = req.params;
4073
+ const repo = deliverRepoFor(await adapter.listRepos(), id);
4074
+ if (repo === undefined)
4075
+ return reply.code(404).send({ error: `Repo ${id} not found` });
4076
+ const originUrl = repo.root_path === '' ? null : await readDeliverOriginUrl(repo.root_path);
4077
+ return reply.send({ repo: repo.id, ...deliverTargetView(originUrl) });
4078
+ });
3481
4079
  app.get(`${V}/repos/:id/git-history`, async (req, reply) => {
3482
4080
  const { id } = req.params;
3483
4081
  const repos = await adapter.listRepos();
@@ -3660,6 +4258,22 @@ runtime = {}) {
3660
4258
  .send({ error: 'workerStallMaxEscalations must be an integer between 1 and 10' });
3661
4259
  }
3662
4260
  }
4261
+ // deliverIdentityLogin (crew#549): the login the deliver phase must push as. A typo here
4262
+ // would be an identity the script can never match — every delivery would refuse — so an
4263
+ // invalid value is a 400, never a silently-dropped key. `''` clears it (back to GH_ACCOUNT).
4264
+ // A LOGIN, never a token: the same validator the deliver script's splice uses.
4265
+ if (Object.hasOwn(patch, 'deliverIdentityLogin')) {
4266
+ const login = patch.deliverIdentityLogin;
4267
+ if (typeof login !== 'string' || (login !== '' && !isGitHubLogin(login.trim()))) {
4268
+ return reply.code(400).send({
4269
+ error: 'deliverIdentityLogin must be a GitHub login (letters, digits and single hyphens, '
4270
+ + 'up to 39 characters), or "" to fall back to the GH_ACCOUNT environment variable. '
4271
+ + 'It is a login, never a token — the credential stays in gh\'s keyring or GH_TOKEN.',
4272
+ });
4273
+ }
4274
+ if (typeof login === 'string')
4275
+ patch.deliverIdentityLogin = login.trim();
4276
+ }
3663
4277
  // deliverDefault (crew#393): the repo-scoped launch delivery default. Two values only —
3664
4278
  // this knob decides whether completed code runs open PRs, so a typo must be a 400, never
3665
4279
  // a silently-dropped key that leaves the operator believing they flipped it.
@@ -3731,6 +4345,7 @@ runtime = {}) {
3731
4345
  'workerStallEscalateAction',
3732
4346
  'workerStallMaxEscalations',
3733
4347
  'deliverDefault',
4348
+ 'deliverIdentityLogin',
3734
4349
  'baseSkillRef',
3735
4350
  'baseSkillPolicy',
3736
4351
  ];
@@ -3815,6 +4430,41 @@ runtime = {}) {
3815
4430
  return estateUpstreamError(reply, err);
3816
4431
  }
3817
4432
  });
4433
+ // POST /proposals {content, project?, source?} → proposal.submit → 201 { id } (api-types 0.54.0).
4434
+ // A skin files ONE preference into the same review queue — studio's "takes": the person picks one
4435
+ // of two candidate outputs, and the pick becomes "prefers takes like X", learned only once they
4436
+ // accept it here. The kind (`memory`), tier and `capture: "preference"` marker are the daemon's; a
4437
+ // caller cannot file a policy through this door (the schema is strict). `project` becomes the
4438
+ // `project` facet, so the preference is recalled on that project only.
4439
+ app.post(`${V}/proposals`, {
4440
+ config: {
4441
+ manifest: { requestType: 'FileProposalBody', responseType: 'FileProposalResponse', statusCodes: [201, 400, 502] },
4442
+ },
4443
+ }, async (req, reply) => {
4444
+ const parsed = FileProposalSchema.safeParse(req.body ?? {});
4445
+ if (!parsed.success) {
4446
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid proposal body'));
4447
+ }
4448
+ const { content, project, source } = parsed.data;
4449
+ const payload = { content, tier: 'semantic', capture: 'preference' };
4450
+ if (source !== undefined)
4451
+ payload.source = source;
4452
+ let submitted;
4453
+ try {
4454
+ submitted = (await estateTool('proposal.submit', {
4455
+ kind_type: 'memory',
4456
+ payload,
4457
+ facets: project !== undefined ? { project } : {},
4458
+ }));
4459
+ }
4460
+ catch (err) {
4461
+ return estateUpstreamError(reply, err);
4462
+ }
4463
+ if (typeof submitted?.id !== 'string' || submitted.id === '') {
4464
+ return reply.code(502).send({ error: 'proposal.submit returned no id' });
4465
+ }
4466
+ return reply.code(201).send({ id: submitted.id });
4467
+ });
3818
4468
  // POST /proposals/:id/approve → proposal.approve →
3819
4469
  // { outcome:'promoted', active_id } — a MEMORY proposal, now an active memory (complete);
3820
4470
  // { outcome:'handed_off', payload, landing } — a POLICY proposal, LANDED here as a steering rule.
@@ -3829,7 +4479,16 @@ runtime = {}) {
3829
4479
  // 500, never the silent no-op that crew#388 exists to end — and `payload` is still returned for
3830
4480
  // backward compatibility. The MEMORY path is UNTOUCHED: a `promoted` outcome returns immediately
3831
4481
  // with no extra estate call and no landing.
3832
- app.post(`${V}/proposals/:id/approve`, { config: { manifest: { responseType: 'ApproveProposalResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
4482
+ app.post(`${V}/proposals/:id/approve`, {
4483
+ config: {
4484
+ manifest: {
4485
+ requestType: 'ApproveProposalBody',
4486
+ responseType: 'ApproveProposalResponse',
4487
+ // 404: an accept-with-edit naming no pending proposal; 409: one already in flight for it.
4488
+ statusCodes: [200, 400, 404, 409, 502],
4489
+ },
4490
+ },
4491
+ }, async (req, reply) => {
3833
4492
  // Normalize ONCE and use the trimmed value throughout — an id like `%20pol1%20`
3834
4493
  // decodes to a padded, non-empty string that would otherwise ride upstream as-is
3835
4494
  // and into `proposal:<id>` derivations.
@@ -3837,6 +4496,24 @@ runtime = {}) {
3837
4496
  if (id === '') {
3838
4497
  return reply.code(400).send({ error: '`id` is required' });
3839
4498
  }
4499
+ // An accept WITH an edit (capture review, api-types 0.49.0): the edited copy replaces the
4500
+ // original in the same queue — see `approveEdited`. No body / `{}` is the plain approve below.
4501
+ const edit = ApproveEditSchema.safeParse(req.body ?? {});
4502
+ if (!edit.success) {
4503
+ return reply.code(400).send(invalidBody(edit.error, 'Invalid approve body'));
4504
+ }
4505
+ if (edit.data.content !== undefined || edit.data.reach !== undefined) {
4506
+ try {
4507
+ const done = await approveEdited(estateTool, id, edit.data, (runId) => projects.index.projectOf(runId));
4508
+ return reply.code(done.status).send(done.body);
4509
+ }
4510
+ catch (err) {
4511
+ return estateUpstreamError(reply, err);
4512
+ }
4513
+ }
4514
+ if (editInFlight(id)) {
4515
+ return reply.code(409).send({ error: `proposal ${id} is already being accepted` });
4516
+ }
3840
4517
  let approved;
3841
4518
  try {
3842
4519
  approved = (await estateTool('proposal.approve', { id }));
@@ -3907,11 +4584,16 @@ runtime = {}) {
3907
4584
  return { outcome: 'handed_off', payload: approved.payload, landing };
3908
4585
  });
3909
4586
  // POST /proposals/:id/reject → proposal.reject → { ok: true }.
3910
- app.post(`${V}/proposals/:id/reject`, { config: { manifest: { responseType: 'RejectProposalResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
4587
+ app.post(`${V}/proposals/:id/reject`,
4588
+ // 409: an accept-with-edit of this proposal is in flight (api/capture.ts).
4589
+ { config: { manifest: { responseType: 'RejectProposalResponse', statusCodes: [200, 400, 409, 502] } } }, async (req, reply) => {
3911
4590
  const { id } = req.params;
3912
4591
  if (id.trim() === '') {
3913
4592
  return reply.code(400).send({ error: '`id` is required' });
3914
4593
  }
4594
+ if (editInFlight(id.trim())) {
4595
+ return reply.code(409).send({ error: `proposal ${id.trim()} is being accepted` });
4596
+ }
3915
4597
  try {
3916
4598
  return (await estateTool('proposal.reject', { id }));
3917
4599
  }
@@ -3937,10 +4619,12 @@ runtime = {}) {
3937
4619
  // applied HERE as post-filters over that complete set (see below) — filtering, not retrieval.
3938
4620
  // • list returns { memory_id, scope, content, tier, facets, created_at }; facets ride through to
3939
4621
  // MemoryItem.facets, powering the surface's facet filter.
3940
- // • RETIRE is SUBTREE-scoped, never per-id. estate exposes NO per-memory delete: `memory.erase`
3941
- // hard-deletes EVERY memory whose scope equals or descends from `scope_prefix`, and refuses an
3942
- // empty prefix (a total-wipe guard). So retire takes a `scope_prefix` and reports how many
3943
- // memories the subtree wipe removed — the UI must show the operator the subtree, not one row.
4622
+ // • RETIRE comes in two widths. `POST /memory/retire { scope_prefix }` is SUBTREE-scoped:
4623
+ // `memory.erase {scope_prefix}` hard-deletes EVERY memory whose scope equals or descends from
4624
+ // the prefix, and refuses an empty prefix (a total-wipe guard) — the UI must show the operator
4625
+ // the subtree. `POST /memory/retire-item { memory_id }` (studio#206) removes exactly ONE memory
4626
+ // through `memory.erase {id}` (wicked-estate#188); an older estate refuses that call without
4627
+ // deleting anything, and the route answers 501 naming the upgrade — never a subtree fallback.
3944
4628
  // GET /memory?query=&scope_prefix=&facets=<json>&limit= → memory.list + post-filter → { memories }.
3945
4629
  app.get(`${V}/memory`, { config: { manifest: { responseType: 'ListMemoriesResponse', statusCodes: [200, 400, 502] } } }, async (req, reply) => {
3946
4630
  const q = req.query;
@@ -4043,9 +4727,9 @@ runtime = {}) {
4043
4727
  return estateUpstreamError(reply, err);
4044
4728
  }
4045
4729
  });
4046
- // POST /memory/retire { scope_prefix } → memory.erase → { erased }. SUBTREE-scoped (no per-id
4047
- // delete in estate); an empty scope_prefix is refused at the route (a 400 BEFORE any spawn) — the
4048
- // same total-wipe guard estate enforces with -32602.
4730
+ // POST /memory/retire { scope_prefix } → memory.erase → { erased }. SUBTREE-scoped (the per-item
4731
+ // delete is /memory/retire-item below); an empty scope_prefix is refused at the route (a 400
4732
+ // BEFORE any spawn) — the same total-wipe guard estate enforces with -32602.
4049
4733
  app.post(`${V}/memory/retire`, {
4050
4734
  config: {
4051
4735
  manifest: {
@@ -4062,7 +4746,7 @@ runtime = {}) {
4062
4746
  const scopePrefix = parsed.data.scope_prefix.trim();
4063
4747
  if (scopePrefix === '') {
4064
4748
  return reply.code(400).send({
4065
- error: '`scope_prefix` must not be empty — memory.erase is subtree-scoped (no per-id delete) and refuses a total wipe',
4749
+ error: '`scope_prefix` must not be empty — this retire is subtree-scoped and refuses a total wipe (one memory: POST /memory/retire-item)',
4066
4750
  });
4067
4751
  }
4068
4752
  try {
@@ -4078,8 +4762,64 @@ runtime = {}) {
4078
4762
  return estateUpstreamError(reply, err);
4079
4763
  }
4080
4764
  });
4765
+ // POST /memory/retire-item { memory_id } → memory.erase { id } → { erased: 1 } (studio#206). Removes
4766
+ // exactly ONE memory: the id is the only target sent, so no reading of it can widen to a subtree.
4767
+ // 404 when estate holds no memory with that id (it erased 0). 501 when the estate predates
4768
+ // wicked-estate#188: it refuses an id-only erase for want of `scope_prefix`, deleting nothing.
4769
+ app.post(`${V}/memory/retire-item`, {
4770
+ config: {
4771
+ manifest: {
4772
+ requestType: 'RetireMemoryItemBody',
4773
+ responseType: 'RetireMemoryResponse',
4774
+ statusCodes: [200, 400, 404, 501, 502],
4775
+ },
4776
+ },
4777
+ }, async (req, reply) => {
4778
+ const parsed = RetireMemoryItemSchema.safeParse(req.body);
4779
+ if (!parsed.success) {
4780
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid retire request'));
4781
+ }
4782
+ const memoryId = parsed.data.memory_id.trim();
4783
+ if (memoryId === '')
4784
+ return reply.code(400).send({ error: '`memory_id` must not be empty' });
4785
+ try {
4786
+ const raw = await estateTool('memory.erase', { id: memoryId });
4787
+ const deleted = typeof raw === 'object' && raw !== null ? raw['deleted_count'] : undefined;
4788
+ if (typeof deleted !== 'number') {
4789
+ throw new EstateMcpError('memory.erase returned an unexpected shape');
4790
+ }
4791
+ if (deleted === 0)
4792
+ return reply.code(404).send({ error: `no memory ${memoryId}` });
4793
+ if (deleted !== 1) {
4794
+ // An id names one memory; any other count is estate misbehaving, and it is reported as such.
4795
+ return reply.code(502).send({ error: `memory.erase {id} removed ${deleted} memories, expected 1` });
4796
+ }
4797
+ const body = { erased: 1 };
4798
+ return body;
4799
+ }
4800
+ catch (err) {
4801
+ if (err instanceof EstateMcpError && err.code === -32602 && ESTATE_NO_ERASE_BY_ID.test(err.message)) {
4802
+ return reply.code(501).send({
4803
+ error: 'this wicked-estate cannot erase one memory by id (it needs wicked-estate#188); upgrade wicked-estate-mcp — nothing was deleted',
4804
+ code: 'estate_upgrade_required',
4805
+ });
4806
+ }
4807
+ return estateUpstreamError(reply, err);
4808
+ }
4809
+ });
4081
4810
  // ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
4082
4811
  registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
4812
+ // ── Capture (Studio OS behaviour 8) — notes and photos → a project-filed run whose output is
4813
+ // proposals in the queue above; the launch is POST /runs itself (api/capture.ts).
4814
+ registerCaptureRoutes(app);
4815
+ // The Demo experience (studio#373): the demo preset's launch, view, files and script edit.
4816
+ registerDemoRoutes(app, adapter);
4817
+ // ── Presets (DES-TEAMING-002 §8.4, seam C2) — saved phase selections in the engine's store;
4818
+ // a launch names one via `workflow`, and the ENGINE resolves it.
4819
+ registerPresetRoutes(app, adapter, security);
4820
+ // ── Team (DES-TEAMING-002 T8) — reads and commands over the engine; the live stream is the
4821
+ // teamEvent relay (team/ws-relay.ts). Crew publishes no team fact (§4.0).
4822
+ registerTeamRoutes(app, adapter, security);
4083
4823
  // ── Campaigns (crew#342 + TH-9) — the engine's durable Run-DAG scheduler over REST ──────────
4084
4824
  // Progress streams as campaign* CoreEvents on the existing allowlist-free /ws relay; these
4085
4825
  // routes are launch/resume/cancel + the reads a scoreboard builds from. The GET routes join
@@ -4097,6 +4837,7 @@ runtime = {}) {
4097
4837
  // Wave 6 (F-7R2-014): the registered test sets ride beside the campaigns + groups.
4098
4838
  testSets,
4099
4839
  deliveryUrlFor: (runId) => deliveryIndex.urlFor(runId),
4840
+ deliveryPushedFor: (runId) => deliveryIndex.pushedFor(runId),
4100
4841
  vacuity: vacuityProbes,
4101
4842
  // A non-probe derivation throw in the rollup is a defect — error level, loud in diagnostics.
4102
4843
  logDefect: (m) => app.log.error(m),
@@ -4150,6 +4891,24 @@ runtime = {}) {
4150
4891
  audit,
4151
4892
  actorOf,
4152
4893
  });
4894
+ // ── MCP tools registry (DES-MCP-TOOLS-001 S2) — upstream servers, their tools, their secrets ─
4895
+ // Preview-bound saves, the tool diff on test, and a secret route that never answers the value.
4896
+ // S6: the policy preview and the approvals, over the same registry and the engine's rule store.
4897
+ const mcpPolicies = runtime.mcp !== undefined
4898
+ ? new McpPolicies(runtime.mcp, {
4899
+ listRules: () => adapter.listConformanceRules(),
4900
+ upsertRule: (rule) => adapter.upsertConformanceRule(rule),
4901
+ previewCalls: (json) => adapter.previewMcpCalls(json),
4902
+ })
4903
+ : undefined;
4904
+ registerMcpRoutes(app, {
4905
+ ...(runtime.mcp !== undefined ? { registry: runtime.mcp, toolLister: () => CoreAdapter.mcpToolLister() } : {}),
4906
+ ...(runtime.mcpBroker !== undefined ? { broker: runtime.mcpBroker } : {}),
4907
+ ...(mcpPolicies !== undefined ? { policies: mcpPolicies } : {}),
4908
+ ...(runtime.mcpCalls !== undefined ? { usage: runtime.mcpCalls } : {}),
4909
+ audit,
4910
+ actorOf,
4911
+ });
4153
4912
  // ── The wicked-interactive bridge, reverse-proxied (DES-MERGE-001 §5.3/§7.2) ──
4154
4913
  // Mounted BESIDE the routes above and under the same `${V}` prefix, so it inherits one
4155
4914
  // origin, one auth hook, and one CORS posture — the whole point of slice 1.
@@ -4200,5 +4959,6 @@ runtime = {}) {
4200
4959
  pool: interactiveBridges,
4201
4960
  log: (m) => app.log.warn(m),
4202
4961
  });
4962
+ return { decideGate };
4203
4963
  }
4204
4964
  //# sourceMappingURL=routes.js.map