specrails-desktop 2.34.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/client/dist/assets/ActivityFeedPage-LCBJ5WiD.js +1 -0
  2. package/client/dist/assets/AgentBrowserCapture-B-xQpyZr.js +1 -0
  3. package/client/dist/assets/AgentModeAnalyticsPane-BI1Vcf3T.js +2 -0
  4. package/client/dist/assets/AgentModeCodePane-BTRwCl9h.js +2 -0
  5. package/client/dist/assets/AgentModeJobsPane-VzeBJLqP.js +1 -0
  6. package/client/dist/assets/AgentsPage-CJj1K24O.js +87 -0
  7. package/client/dist/assets/AnalyticsPage-CQpV5_fX.js +1 -0
  8. package/client/dist/assets/CodePage-9-tFh0jB.js +2 -0
  9. package/client/dist/assets/DesktopAnalyticsPage-BcZ0_O_C.js +1 -0
  10. package/client/dist/assets/DocsDialog-BPAzDICW.js +11 -0
  11. package/client/dist/assets/DocsPage-Dqo4Hn6G.js +11 -0
  12. package/client/dist/assets/IntegrationsPage-Bhy58lnQ.js +1 -0
  13. package/client/dist/assets/InteractiveJobComposer-D36fZWai.js +19 -0
  14. package/client/dist/assets/JobDetailModal-BxsktbkQ.js +1 -0
  15. package/client/dist/assets/JobDetailPage-WWyCy6Sp.js +1 -0
  16. package/client/dist/assets/JobsPage-CEbM7uxU.js +1 -0
  17. package/client/dist/assets/{LoopBuilderPage-WeiofeL6.js → LoopBuilderPage-CYsSN8io.js} +2 -2
  18. package/client/dist/assets/LoopPreviewModal-BJhgX5wY.js +1 -0
  19. package/client/dist/assets/LoopsPage-DB4OqMot.js +1 -0
  20. package/client/dist/assets/MinimizedChatsContext-CcjBHMHd.js +1 -0
  21. package/client/dist/assets/PluginsPage-CR8K4nM_.js +2 -0
  22. package/client/dist/assets/{ProjectSettingsDialog-BkCXBQWk.js → ProjectSettingsDialog-C45-qhF4.js} +1 -1
  23. package/client/dist/assets/ReviewPacketPage-Cz_QQeC3.js +1 -0
  24. package/client/dist/assets/TemplatePreviewModal-wFBd2vQP.js +1 -0
  25. package/client/dist/assets/TicketDetailModal-BvZoyDAL.js +14 -0
  26. package/client/dist/assets/{Trans-CTqw6rr9.js → Trans-DXZ-BLXb.js} +1 -1
  27. package/client/dist/assets/agent-BQgKv_2F.js +1 -0
  28. package/client/dist/assets/agent-C1NRDJqL.js +1 -0
  29. package/client/dist/assets/agent-CLNleiy6.js +1 -0
  30. package/client/dist/assets/agent-CmceArkQ.js +1 -0
  31. package/client/dist/assets/agent-D3UxQ3SR.js +1 -0
  32. package/client/dist/assets/agent-DB1JeE64.js +1 -0
  33. package/client/dist/assets/agent-DXkHcQ-D.js +1 -0
  34. package/client/dist/assets/agent-DjJJJ2M8.js +1 -0
  35. package/client/dist/assets/ban-E4EaZe3a.js +1 -0
  36. package/client/dist/assets/bot-DzuuuC5R.js +1 -0
  37. package/client/dist/assets/{browser-DxP-alBK.js → browser-BgqHDMXP.js} +1 -1
  38. package/client/dist/assets/{browser-CyR1Kp3m.js → browser-CsKJtrpv.js} +1 -1
  39. package/client/dist/assets/browser-Cu58EpZU.js +1 -0
  40. package/client/dist/assets/{browser-rRCwi7Y7.js → browser-CvXOJm-j.js} +1 -1
  41. package/client/dist/assets/{browser-CvgDySd9.js → browser-DOrFh1c7.js} +1 -1
  42. package/client/dist/assets/{browser-DwZljcRh.js → browser-DQv_Jo7x.js} +1 -1
  43. package/client/dist/assets/{browser-C25DkQiO.js → browser-LPgxvJu2.js} +1 -1
  44. package/client/dist/assets/{browser-CiJ7-L32.js → browser-eL-3MIEI.js} +1 -1
  45. package/client/dist/assets/button-CJqFnUOL.js +1 -0
  46. package/client/dist/assets/{chunk-KS7C4IRE-Cx5sqopX.js → chunk-KS7C4IRE-DEKu087k.js} +1 -1
  47. package/client/dist/assets/{commands-DyUZQ1FI.js → commands-6tYQih5m.js} +1 -1
  48. package/client/dist/assets/{commands-B9RZO4qS.js → commands-BK2QinXN.js} +1 -1
  49. package/client/dist/assets/{commands-Bbxu8kLU.js → commands-C2Nca76h.js} +1 -1
  50. package/client/dist/assets/{commands-CDU3qv68.js → commands-CKdLihNX.js} +1 -1
  51. package/client/dist/assets/{commands-DTQBF_q5.js → commands-CYIBTAlc.js} +1 -1
  52. package/client/dist/assets/{commands-hOtKuOXc.js → commands-CjUtYJIM.js} +1 -1
  53. package/client/dist/assets/{commands-BEwbAjtD.js → commands-Cq_u_8y9.js} +1 -1
  54. package/client/dist/assets/{commands-CzezgtQj.js → commands-D_ZHYQug.js} +1 -1
  55. package/client/dist/assets/core-B3mKBIh5.js +1 -0
  56. package/client/dist/assets/dashboard-B9GhC5ku.js +1 -0
  57. package/client/dist/assets/dashboard-C8WOEiPu.js +1 -0
  58. package/client/dist/assets/dashboard-CBnv6zOI.js +1 -0
  59. package/client/dist/assets/dashboard-CaP0fU87.js +1 -0
  60. package/client/dist/assets/dashboard-D4ZZ4x84.js +1 -0
  61. package/client/dist/assets/dashboard-DmuTQBX8.js +1 -0
  62. package/client/dist/assets/dashboard-DynLYj3d.js +1 -0
  63. package/client/dist/assets/dashboard-zj4gGFJ1.js +1 -0
  64. package/client/dist/assets/dialog-CdWCb02j.js +45 -0
  65. package/client/dist/assets/dist-js-CWeUdf5Z.js +1 -0
  66. package/client/dist/assets/dist-js-Rlh4FLH5.js +1 -0
  67. package/client/dist/assets/event-Bt7f3wXe.js +1 -0
  68. package/client/dist/assets/file-text-BcLCfcWZ.js +1 -0
  69. package/client/dist/assets/{formatDistanceToNow-BOuXgebD.js → formatDistanceToNow-C0ImhVKp.js} +1 -1
  70. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-EjGOpn4b.js → getTimezoneOffsetInMilliseconds-owhDdTXs.js} +1 -1
  71. package/client/dist/assets/git-refresh-wTW0rO0Z.js +1 -0
  72. package/client/dist/assets/i18n-DwgvwTnx.js +2 -0
  73. package/client/dist/assets/index-DhjjFyU5.js +66 -0
  74. package/client/dist/assets/index-xBFsOEiJ.css +2 -0
  75. package/client/dist/assets/jira-api-CcZMcEN5.js +1 -0
  76. package/client/dist/assets/loops-B7YpGizO.js +1 -0
  77. package/client/dist/assets/{loops-CMMQOw2P.js → loops-BomD2J-h.js} +1 -1
  78. package/client/dist/assets/{loops-B2_97D26.js → loops-BrQHSQw3.js} +1 -1
  79. package/client/dist/assets/{loops-BwxCndZk.js → loops-BvBwvTPA.js} +1 -1
  80. package/client/dist/assets/{loops-BISOkuCu.js → loops-CODk6Slz.js} +1 -1
  81. package/client/dist/assets/{loops-CD19kEzq.js → loops-DC6k-Ym-.js} +1 -1
  82. package/client/dist/assets/{loops-aEb4RxN2.js → loops-DSEg8-uY.js} +1 -1
  83. package/client/dist/assets/{loops-Bo6w_peZ.js → loops-DhCoXD_v.js} +1 -1
  84. package/client/dist/assets/narration-4Z2VUhZw.js +1 -0
  85. package/client/dist/assets/narration-BGuiEDj3.js +1 -0
  86. package/client/dist/assets/narration-BuSoqRmv.js +1 -0
  87. package/client/dist/assets/narration-CB9t29fy.js +1 -0
  88. package/client/dist/assets/narration-CU3x8FHD.js +1 -0
  89. package/client/dist/assets/narration-CWeXhfP5.js +1 -0
  90. package/client/dist/assets/narration-DRaDnhT5.js +1 -0
  91. package/client/dist/assets/narration-DtEbQgCw.js +1 -0
  92. package/client/dist/assets/packet-B1IxaL5f.js +1 -0
  93. package/client/dist/assets/packet-Bh5kkbiB.js +1 -0
  94. package/client/dist/assets/packet-By8fqYxW.js +1 -0
  95. package/client/dist/assets/packet-CZdvA44E.js +1 -0
  96. package/client/dist/assets/packet-CrjHRQCP.js +1 -0
  97. package/client/dist/assets/packet-Dwi2pTtx.js +1 -0
  98. package/client/dist/assets/packet-_-ZIa0IR.js +1 -0
  99. package/client/dist/assets/packet-n0W6E1Hn.js +1 -0
  100. package/client/dist/assets/save-B6wGWwBR.js +1 -0
  101. package/client/dist/assets/settings-Bd0x4TBa.js +1 -0
  102. package/client/dist/assets/settings-C1-7cXzC.js +1 -0
  103. package/client/dist/assets/settings-CAFaT38b.js +1 -0
  104. package/client/dist/assets/settings-Ct3v1nW2.js +1 -0
  105. package/client/dist/assets/settings-DHh7_IF5.js +1 -0
  106. package/client/dist/assets/settings-DQXAmdcv.js +1 -0
  107. package/client/dist/assets/settings-guzlhpup.js +1 -0
  108. package/client/dist/assets/settings-sZfTPMEH.js +1 -0
  109. package/client/dist/assets/{spending-BpYPsevQ.js → spending-BQX3P7Cq.js} +1 -1
  110. package/client/dist/assets/useDesktop-BzX769yH.js +1 -0
  111. package/client/dist/index.html +29 -31
  112. package/docs/internals/embedded-browser-native-webview-evaluation.md +161 -0
  113. package/docs/internals/review-packet.md +212 -0
  114. package/package.json +1 -1
  115. package/server/dist/accept-ladder.js +61 -0
  116. package/server/dist/agent-chat-manager.js +22 -1
  117. package/server/dist/agent-defaults.js +359 -0
  118. package/server/dist/agent-operator-prompt.js +13 -5
  119. package/server/dist/browser-playwright.js +39 -2
  120. package/server/dist/db.js +38 -0
  121. package/server/dist/delivery-evidence.js +242 -0
  122. package/server/dist/desktop-router.js +23 -0
  123. package/server/dist/feature-flags.js +23 -0
  124. package/server/dist/loop-command-catalog.js +33 -0
  125. package/server/dist/loop-executors.js +12 -2
  126. package/server/dist/loop-factory.js +16 -1
  127. package/server/dist/loops-router.js +2 -0
  128. package/server/dist/mcp/tools/rails.js +16 -0
  129. package/server/dist/project-registry.js +33 -1
  130. package/server/dist/project-router-loop-runs.js +27 -2
  131. package/server/dist/providers/claude-adapter.js +60 -3
  132. package/server/dist/queue-manager.js +47 -4
  133. package/server/dist/rail-isolated-launch.js +119 -5
  134. package/server/dist/rail-pr-store.js +40 -3
  135. package/server/dist/rail-worktree-release.js +17 -1
  136. package/server/dist/rails-router.js +124 -3
  137. package/server/dist/review-packet.js +327 -0
  138. package/server/dist/revision-seed.js +73 -0
  139. package/server/dist/run-duration-stats.js +71 -0
  140. package/server/dist/stuck-run-detector.js +153 -0
  141. package/server/dist/telemetry-receiver.js +22 -0
  142. package/server/dist/worktree-node-modules.js +138 -16
  143. package/client/dist/assets/ActivityFeedPage-DIDxNsDj.js +0 -1
  144. package/client/dist/assets/AgentBrowserCapture-7pJ2G5VY.js +0 -1
  145. package/client/dist/assets/AgentModeAnalyticsPane-BSSyTnTM.js +0 -2
  146. package/client/dist/assets/AgentModeCodePane-DzW6gvDw.js +0 -2
  147. package/client/dist/assets/AgentModeJobsPane-9wJff15f.js +0 -1
  148. package/client/dist/assets/AgentsPage-CIA67psb.js +0 -87
  149. package/client/dist/assets/AnalyticsPage-C0LwYELz.js +0 -1
  150. package/client/dist/assets/CodePage-RtKvHmAO.js +0 -2
  151. package/client/dist/assets/DesktopAnalyticsPage-BU8Y_Ts1.js +0 -1
  152. package/client/dist/assets/DocsDialog-DIpoaGlQ.js +0 -11
  153. package/client/dist/assets/DocsPage-ME47KZpK.js +0 -11
  154. package/client/dist/assets/IntegrationsPage-Bs00_tl4.js +0 -1
  155. package/client/dist/assets/InteractiveJobComposer-BGjIe9R6.js +0 -19
  156. package/client/dist/assets/JobDetailModal-1UZkqH4I.js +0 -1
  157. package/client/dist/assets/JobDetailPage-CN65sAf1.js +0 -1
  158. package/client/dist/assets/JobsPage-f-4dqys7.js +0 -1
  159. package/client/dist/assets/LoopPreviewModal-DDTnTyH0.js +0 -1
  160. package/client/dist/assets/LoopsPage-BVNchIex.js +0 -1
  161. package/client/dist/assets/MinimizedChatsContext-CHsu3NCX.js +0 -1
  162. package/client/dist/assets/PluginsPage-CdFzGEfQ.js +0 -2
  163. package/client/dist/assets/TemplatePreviewModal-RDi0khFG.js +0 -1
  164. package/client/dist/assets/TicketDetailModal-MhJzrqbi.js +0 -14
  165. package/client/dist/assets/agent-Bd-q3Wqp.js +0 -1
  166. package/client/dist/assets/agent-BlvdW4Hv.js +0 -1
  167. package/client/dist/assets/agent-C-KQhxeM.js +0 -1
  168. package/client/dist/assets/agent-Ca15w5eu.js +0 -1
  169. package/client/dist/assets/agent-D6fKaoMd.js +0 -1
  170. package/client/dist/assets/agent-HFMYMTbB.js +0 -1
  171. package/client/dist/assets/agent-QIEbkBjg.js +0 -1
  172. package/client/dist/assets/agent-mTCsAbCl.js +0 -1
  173. package/client/dist/assets/api-B2M7CtDw.js +0 -1
  174. package/client/dist/assets/arrow-left-CpIR4j7G.js +0 -1
  175. package/client/dist/assets/arrow-up-right-9DoMn7fD.js +0 -1
  176. package/client/dist/assets/browser-CXUcnTMI.js +0 -1
  177. package/client/dist/assets/button-BUIIBQPT.js +0 -1
  178. package/client/dist/assets/chevron-right-CViMxucY.js +0 -1
  179. package/client/dist/assets/dashboard-BHRqsGCV.js +0 -1
  180. package/client/dist/assets/dashboard-Bfr90vOl.js +0 -1
  181. package/client/dist/assets/dashboard-CTsM8x3m.js +0 -1
  182. package/client/dist/assets/dashboard-CqC6wX0h.js +0 -1
  183. package/client/dist/assets/dashboard-CuZtAszc.js +0 -1
  184. package/client/dist/assets/dashboard-DAcqIc-n.js +0 -1
  185. package/client/dist/assets/dashboard-DekiV-86.js +0 -1
  186. package/client/dist/assets/dashboard-DoDczc5S.js +0 -1
  187. package/client/dist/assets/dialog-CzIekCw0.js +0 -45
  188. package/client/dist/assets/dist-Cez5CRkX.js +0 -1
  189. package/client/dist/assets/dist-js-BmsFWRly.js +0 -1
  190. package/client/dist/assets/dist-js-J5W_N5ng.js +0 -1
  191. package/client/dist/assets/external-link-BcU5EWZp.js +0 -1
  192. package/client/dist/assets/file-text-CFT-xXKN.js +0 -1
  193. package/client/dist/assets/i18n-nJQvD5zJ.js +0 -2
  194. package/client/dist/assets/index-C_sdsSZt.js +0 -63
  195. package/client/dist/assets/index-DCN5QbDm.css +0 -2
  196. package/client/dist/assets/jira-api-Bhixwn-d.js +0 -1
  197. package/client/dist/assets/loops-CnxqL8IL.js +0 -1
  198. package/client/dist/assets/save-28sIlCdr.js +0 -1
  199. package/client/dist/assets/settings-BveOJoaI.js +0 -1
  200. package/client/dist/assets/settings-Cj1C6F5k.js +0 -1
  201. package/client/dist/assets/settings-CuVwOpe0.js +0 -1
  202. package/client/dist/assets/settings-D5X4DfRc.js +0 -1
  203. package/client/dist/assets/settings-DXwxe-BW.js +0 -1
  204. package/client/dist/assets/settings-Di81Ba_V.js +0 -1
  205. package/client/dist/assets/settings-jbLjCYC_.js +0 -1
  206. package/client/dist/assets/settings-wIZNPFnn.js +0 -1
  207. package/client/dist/assets/useBrowserCaptureSession-BBXBN0Na.js +0 -1
  208. package/client/dist/assets/useDesktop-B6Du2N83.js +0 -1
  209. package/client/dist/assets/useSharedWebSocket-DK96ctb7.js +0 -1
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prDeliveryRevisionAllowed = prDeliveryRevisionAllowed;
3
4
  exports.createRailsRouter = createRailsRouter;
4
5
  const express_1 = require("express");
5
6
  const rails_store_1 = require("./rails-store");
@@ -11,6 +12,8 @@ const file_story_1 = require("./file-story");
11
12
  const loops_store_1 = require("./loops-store");
12
13
  const loop_runs_store_1 = require("./loop-runs-store");
13
14
  const providers_1 = require("./providers");
15
+ const runtime_1 = require("./providers/runtime");
16
+ const agent_defaults_1 = require("./agent-defaults");
14
17
  const spec_models_1 = require("./spec-models");
15
18
  const workspace_resolution_1 = require("./workspace-resolution");
16
19
  const loop_factory_1 = require("./loop-factory");
@@ -20,6 +23,8 @@ const loop_graph_1 = require("./loop-graph");
20
23
  const rail_isolation_1 = require("./rail-isolation");
21
24
  const rail_pr_store_1 = require("./rail-pr-store");
22
25
  const loop_effect_1 = require("./loop-effect");
26
+ const review_packet_1 = require("./review-packet");
27
+ const accept_ladder_1 = require("./accept-ladder");
23
28
  const rail_pr_decision_1 = require("./rail-pr-decision");
24
29
  const active_pr_continuation_1 = require("./active-pr-continuation");
25
30
  const rail_isolated_launch_1 = require("./rail-isolated-launch");
@@ -41,6 +46,22 @@ function prDeliveryContinuesTickets(delivery, ticketIds) {
41
46
  const requested = new Set(ticketIds);
42
47
  return requested.size > 0 && requested.size === covered.size && [...requested].every((id) => covered.has(id));
43
48
  }
49
+ /**
50
+ * Whether a launch may revise an undecided delivery. Deliberately strict:
51
+ * the named id must BE the rail's active generation, that generation must still
52
+ * be awaiting a decision (a terminal one is history, not revisable), and the
53
+ * rail must still carry exactly the delivery's ticket set — a subset would
54
+ * revise work the user never asked about while leaving the rest undecided.
55
+ */
56
+ function prDeliveryRevisionAllowed(delivery, revisionOfDeliveryId, ticketIds) {
57
+ if (delivery.id !== revisionOfDeliveryId)
58
+ return false;
59
+ if ((0, rail_pr_store_1.isTerminalPrDecision)(delivery.decision))
60
+ return false;
61
+ const covered = new Set(delivery.ticketIds);
62
+ const requested = new Set(ticketIds);
63
+ return requested.size > 0 && requested.size === covered.size && [...requested].every((id) => covered.has(id));
64
+ }
44
65
  function prDeliveryCheckoutBlock(delivery) {
45
66
  if (delivery.deliveryOutcome === 'blocked') {
46
67
  return 'delivery is blocked and has no safely checkoutable result';
@@ -381,7 +402,20 @@ function createRailsRouter() {
381
402
  return;
382
403
  }
383
404
  let { mode = 'implement' } = req.body ?? {};
384
- const { profileName, aiEngine, model, loopId: rawLoopId, reasoning_effort, originConversationId, originSurface, targetPrNumber } = req.body ?? {};
405
+ const { profileName, aiEngine, model, loopId: rawLoopId, reasoning_effort, originConversationId, originSurface, targetPrNumber, revisionOfDeliveryId, revisionNote } = req.body ?? {};
406
+ // Revision launch (nontech-review-experience Wave 3): the user asked for a
407
+ // change to a delivery that is already awaiting their decision. Shape is
408
+ // validated here; the narrow guard exemption is enforced below.
409
+ if (revisionOfDeliveryId !== undefined && revisionOfDeliveryId !== null) {
410
+ if (typeof revisionOfDeliveryId !== 'string' || !/^[A-Za-z0-9-]{1,64}$/.test(revisionOfDeliveryId)) {
411
+ res.status(400).json({ error: 'invalid_revision_target', detail: 'revisionOfDeliveryId must be a delivery id' });
412
+ return;
413
+ }
414
+ if (typeof revisionNote !== 'string' || revisionNote.trim().length === 0) {
415
+ res.status(400).json({ error: 'revision_note_required', detail: 'revisionNote must describe the change to make' });
416
+ return;
417
+ }
418
+ }
385
419
  // Explicit target PR (deliver-rail-into-existing-pr): the user names an
386
420
  // existing open PR as the delivery destination. Shape-validated here;
387
421
  // resolved and verified inside the isolated launch, fail-closed.
@@ -414,6 +448,18 @@ function createRailsRouter() {
414
448
  res.status(404).json({ error: 'Factory loop not found' });
415
449
  return;
416
450
  }
451
+ // The revision loop is meaningless without a revision to apply: its prompt
452
+ // consumes {{const:REVISION_REQUEST}}, and an unresolved constant renders
453
+ // as an EMPTY string (loop-constants), so naming it directly would spawn a
454
+ // run whose central instruction is silently blank. It is reachable by id
455
+ // (fork/preview resolve it), so the launch door has to refuse it.
456
+ if (loopId === loop_factory_1.FACTORY_REVISION_LOOP_ID && !revisionOfDeliveryId) {
457
+ res.status(400).json({
458
+ error: 'revision_loop_requires_revision',
459
+ detail: 'factory:revision only runs as a revision — pass revisionOfDeliveryId and revisionNote instead of naming the loop',
460
+ });
461
+ return;
462
+ }
417
463
  mode = fmode;
418
464
  }
419
465
  if (!VALID_MODES.has(mode)) {
@@ -429,6 +475,17 @@ function createRailsRouter() {
429
475
  if ((0, feature_flags_1.isLoopsEnabled)() && (typeof loopId !== 'string' || !loopId) && mode !== 'loop') {
430
476
  loopId = (0, loop_factory_1.factoryLoopForMode)(mode)?.id;
431
477
  }
478
+ // A revision must run the Architect-LESS revision loop, whatever the rail's
479
+ // stored mode is. Without this every one-sentence tweak would re-run the
480
+ // full implement pipeline — the exact cost the feature exists to avoid.
481
+ // An explicit loopId is respected (a caller naming a loop means it).
482
+ if (revisionOfDeliveryId && (0, feature_flags_1.isLoopsEnabled)() && (typeof loopId !== 'string' || !loopId || (0, loop_factory_1.isFactoryLoopId)(loopId))) {
483
+ const revisionLoop = (0, loop_factory_1.getFactoryLoop)(loop_factory_1.FACTORY_REVISION_LOOP_ID);
484
+ if (revisionLoop) {
485
+ loopId = revisionLoop.id;
486
+ mode = revisionLoop.mode;
487
+ }
488
+ }
432
489
  if (mode === 'loop' && !(0, feature_flags_1.isLoopsEnabled)()) {
433
490
  res.status(403).json({ error: 'Loops are disabled on this server' });
434
491
  return;
@@ -599,7 +656,14 @@ function createRailsRouter() {
599
656
  res.status(400).json({ error: `model is not valid for provider "${loopProvider}"`, allowed: (0, spec_models_1.getModelsForProvider)(loopProvider) });
600
657
  return;
601
658
  }
602
- const loopModel = typeof model === 'string' && model ? model : loopAdapter.defaultModel();
659
+ // Global Specrails Agents defaults (Settings ▸ Specrails Agents): fill
660
+ // model/effort ONLY when the launch body carries no explicit choice —
661
+ // dashboard factory launches send neither. Read at launch time, so a
662
+ // settings change applies to the next launch without restart.
663
+ const globalAgentDefaults = (0, agent_defaults_1.resolveAgentDefaults)(c.desktopDb, loopProvider);
664
+ const loopModel = typeof model === 'string' && model
665
+ ? model
666
+ : (globalAgentDefaults?.pipelineModel ?? loopAdapter.defaultModel());
603
667
  let effort;
604
668
  if (reasoning_effort !== undefined && reasoning_effort !== null) {
605
669
  const allowed = (0, providers_1.reasoningEffortsForModel)(loopAdapter, loopModel);
@@ -613,6 +677,10 @@ function createRailsRouter() {
613
677
  }
614
678
  effort = reasoning_effort;
615
679
  }
680
+ else if (globalAgentDefaults?.pipelineEffort
681
+ && (0, runtime_1.isReasoningEffortValidForModel)(loopAdapter, loopModel, globalAgentDefaults.pipelineEffort)) {
682
+ effort = globalAgentDefaults.pipelineEffort;
683
+ }
616
684
  // The loop's command(s) declare ticket scope + required capabilities.
617
685
  const promptsText = loopGraph.nodes
618
686
  .filter((n) => n.type === 'ai-step')
@@ -634,6 +702,7 @@ function createRailsRouter() {
634
702
  // cards) is diagnosable from the UI, not just the server log.
635
703
  let isolationUnavailableDetail;
636
704
  let continuablePrDelivery = null;
705
+ let revisionRequest = null;
637
706
  // Read-only vs mutating is DERIVED from the loop's nodes (see loop-effect),
638
707
  // not a user flag — a content-read-only loop (no ai-step/shell) never writes,
639
708
  // so it is not isolated; anything that can write is.
@@ -644,10 +713,32 @@ function createRailsRouter() {
644
713
  if ((0, rail_isolation_1.isRailPrDeliveryEnabled)()) {
645
714
  const pending = (0, rail_pr_store_1.getActivePrDeliveryByRail)(c.db, railIndex);
646
715
  const pendingSnapshot = pending ? (0, rail_pr_store_1.toPrDeliverySnapshot)(pending) : null;
647
- if (pendingSnapshot && !prDeliveryContinuesTickets(pendingSnapshot, rail.ticketIds)) {
716
+ // A revision is the ONE launch allowed to proceed against an
717
+ // undecided delivery, and only on the exact terms below: the caller
718
+ // names that delivery AND covers its full ticket set. Anything else
719
+ // would silently append work to branches the user has not judged.
720
+ const revisionsEnabled = (0, feature_flags_1.areDeliveryRevisionsEnabled)();
721
+ const revisionOfPending = Boolean(revisionsEnabled && pendingSnapshot && revisionOfDeliveryId
722
+ && prDeliveryRevisionAllowed(pendingSnapshot, revisionOfDeliveryId, rail.ticketIds));
723
+ if (revisionsEnabled && pendingSnapshot && revisionOfDeliveryId && !revisionOfPending) {
724
+ res.status(409).json({
725
+ error: 'invalid_revision_target',
726
+ prDeliveryId: pendingSnapshot.id,
727
+ detail: 'a revision must target the rail\'s active delivery and cover all of its specs',
728
+ });
729
+ return;
730
+ }
731
+ if (pendingSnapshot && !revisionOfPending && !prDeliveryContinuesTickets(pendingSnapshot, rail.ticketIds)) {
648
732
  res.status(409).json({ error: 'pr_decision_pending', prDeliveryId: pendingSnapshot.id });
649
733
  return;
650
734
  }
735
+ if (revisionOfPending && pendingSnapshot) {
736
+ revisionRequest = {
737
+ ofDeliveryId: pendingSnapshot.id,
738
+ decision: pendingSnapshot.decision,
739
+ note: revisionNote,
740
+ };
741
+ }
651
742
  // Explicit target vs an undecided continuable delivery: the slot's
652
743
  // active generation owns its PR. A DIFFERENT explicit target would
653
744
  // append to the undecided branches → 409; the SAME PR is redundant
@@ -720,6 +811,9 @@ function createRailsRouter() {
720
811
  ...(typeof targetPrNumber === 'number' && !continuablePrDelivery
721
812
  ? { explicitPrTarget: { prNumber: targetPrNumber } }
722
813
  : {}),
814
+ // Revision of an undecided delivery: the guard above proved the
815
+ // exemption, so pass the contract through for supersession.
816
+ ...(revisionRequest ? { revision: revisionRequest } : {}),
723
817
  });
724
818
  res.status(202).json({ loopRunIds: ids, railIndex, mode, isolated: true });
725
819
  return;
@@ -999,6 +1093,33 @@ function createRailsRouter() {
999
1093
  }
1000
1094
  res.json({ ok: true, jobIds: targetJobIds, loopRunIds: targetLoopRunIds, canceled: canceledCount });
1001
1095
  });
1096
+ // GET /rails/pr-deliveries/:id/packet — the plain-language review packet for a
1097
+ // settled delivery: composed server-side from durable rows only (no model
1098
+ // calls, no live-store reads) plus the pre-resolved Accept ladder, so the
1099
+ // client never has to ask the user to choose between git actions. Read-only.
1100
+ router.get('/pr-deliveries/:prDeliveryId/packet', async (req, res) => {
1101
+ if (!(0, feature_flags_1.isReviewPacketEnabled)()) {
1102
+ res.status(404).json({ error: 'Not Found' });
1103
+ return;
1104
+ }
1105
+ const c = ctx(req);
1106
+ const row = (0, rail_pr_store_1.getPrDelivery)(c.db, req.params.prDeliveryId);
1107
+ if (!row) {
1108
+ res.status(404).json({ error: 'Delivery not found' });
1109
+ return;
1110
+ }
1111
+ try {
1112
+ const packet = (0, review_packet_1.composeReviewPacket)({ db: c.db, row });
1113
+ // Capability probes are read-only and offline; a failure degrades to the
1114
+ // confirm-gated local path rather than blocking the packet.
1115
+ const acceptCapability = await (0, accept_ladder_1.resolveAcceptCapability)({ repoDir: c.project.path, exec: pr_publisher_1.defaultExec });
1116
+ res.json({ packet, acceptCapability, snapshot: (0, rail_pr_store_1.toPrDeliverySnapshot)(row) });
1117
+ }
1118
+ catch (err) {
1119
+ console.error('[rails-router] packet composition failed:', err);
1120
+ res.status(500).json({ error: 'packet composition failed', detail: err.message });
1121
+ }
1122
+ });
1002
1123
  // POST /rails/pr-decision — the ONE decision action (safe-pr-review-flow) both
1003
1124
  // surfaces (dashboard rail row + agent-chat card) call on a rail_pr_deliveries
1004
1125
  // row: create-pr | publish | discard/dismiss | poll/reopen | merge-local |
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DRIFT_REVISION_COUNT = exports.DRIFT_COST_SHARE = exports.MAX_VERSION_CHAIN = exports.HUMAN_REVIEW_BAND = void 0;
4
+ exports.selectVariant = selectVariant;
5
+ exports.computeDriftNudges = computeDriftNudges;
6
+ exports.composeReviewPacket = composeReviewPacket;
7
+ exports.packetHasUnsourcedNumericClaim = packetHasUnsourcedNumericClaim;
8
+ const delivery_evidence_1 = require("./delivery-evidence");
9
+ const rail_pr_store_1 = require("./rail-pr-store");
10
+ const pr_body_1 = require("./pr-body");
11
+ const ai_invocations_1 = require("./ai-invocations");
12
+ const TEST_FILE_RE = /(\.test\.|\.spec\.|(^|\/)__tests__\/)/;
13
+ function churnForRuns(db, runIds) {
14
+ const ids = [...new Set(runIds.filter((id) => typeof id === 'string' && id.length > 0))];
15
+ if (ids.length === 0)
16
+ return [];
17
+ const placeholders = ids.map(() => '?').join(', ');
18
+ try {
19
+ return db.prepare(`
20
+ SELECT job_id, file_path, added_lines, removed_lines
21
+ FROM file_story_contributions
22
+ WHERE job_id IN (${placeholders})
23
+ `).all(...ids);
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ }
29
+ /**
30
+ * Variant selection reads ONLY durable outcomes. `no-changes` must win over
31
+ * `success` (a run that changed nothing has nothing to show), and a mixed batch
32
+ * is `partial` rather than a success that quietly hides a failed ticket.
33
+ */
34
+ function selectVariant(row, units) {
35
+ if (row.decision === 'no_changes' || row.delivery_outcome === 'no_changes' || row.status_code === 'no_changes') {
36
+ return 'no-changes';
37
+ }
38
+ if (row.implementation_outcome === 'failed' || row.decision === 'implementation_failed')
39
+ return 'failed';
40
+ const succeeded = units.filter((unit) => unit.implementationOutcome === 'succeeded' || (unit.implementationOutcome == null && unit.succeeded)).length;
41
+ const failed = units.filter((unit) => unit.implementationOutcome === 'failed' || (unit.implementationOutcome == null && !unit.succeeded)).length;
42
+ if (row.implementation_outcome === 'partially_succeeded' || row.delivery_outcome === 'partial' || (succeeded > 0 && failed > 0)) {
43
+ return 'partial';
44
+ }
45
+ return 'success';
46
+ }
47
+ function headlineFor(variant, succeeded, total) {
48
+ switch (variant) {
49
+ case 'no-changes': return 'headline.noChanges';
50
+ case 'failed': return succeeded > 0 ? 'headline.failedPartial' : 'headline.failed';
51
+ case 'partial': return 'headline.partial';
52
+ default: return total > 1 ? 'headline.successMany' : 'headline.success';
53
+ }
54
+ }
55
+ /** Reviewer band documented by sr-reviewer's schema: 50-69 ⇒ recommend a human. */
56
+ exports.HUMAN_REVIEW_BAND = { min: 50, max: 69 };
57
+ function buildProof(units, evidence, churn, confidence) {
58
+ const proof = [];
59
+ // ── Tier 1: what Specrails measured itself ────────────────────────────────
60
+ const testFiles = [...new Set(churn.filter((row) => TEST_FILE_RE.test(row.file_path)).map((row) => row.file_path))];
61
+ const totalFiles = new Set(churn.map((row) => row.file_path)).size;
62
+ const added = churn.reduce((sum, row) => sum + (row.added_lines || 0), 0);
63
+ const removed = churn.reduce((sum, row) => sum + (row.removed_lines || 0), 0);
64
+ if (totalFiles > 0) {
65
+ proof.push({
66
+ tier: 'app-verified',
67
+ code: 'proof.filesChanged',
68
+ values: { files: totalFiles, added, removed },
69
+ });
70
+ }
71
+ proof.push(testFiles.length > 0
72
+ ? { tier: 'app-verified', code: 'proof.testFilesChanged', values: { count: testFiles.length } }
73
+ // The honest negative: silence here would read as "tests fine".
74
+ : { tier: 'app-verified', code: 'proof.noTestFilesChanged' });
75
+ const verifiedBranches = units.filter((unit) => unit.finalSha && unit.finalSha !== unit.initialSha).length;
76
+ if (verifiedBranches > 0) {
77
+ proof.push({ tier: 'app-verified', code: 'proof.commitsRecorded', values: { branches: verifiedBranches } });
78
+ }
79
+ // ── Tier 2: what the agent itself reported ────────────────────────────────
80
+ const sentinels = (evidence ?? []).filter((unit) => unit.sentinel !== 'absent');
81
+ if (sentinels.length === 0) {
82
+ proof.push({ tier: 'ai-reported', code: 'proof.noVerificationReported' });
83
+ }
84
+ else {
85
+ const failed = sentinels.filter((unit) => unit.sentinel === 'fail');
86
+ if (failed.length > 0) {
87
+ // Failure leads: a packet must never bury a reported failure under a pass.
88
+ proof.push({
89
+ tier: 'ai-reported',
90
+ code: 'proof.verificationFailed',
91
+ values: { count: failed.length },
92
+ ...(failed[0].sentinelDetail ? { rawExcerpt: failed[0].sentinelDetail } : {}),
93
+ });
94
+ }
95
+ const passed = sentinels.filter((unit) => unit.sentinel === 'pass');
96
+ if (passed.length > 0) {
97
+ proof.push({ tier: 'ai-reported', code: 'proof.verificationPassed', values: { count: passed.length } });
98
+ }
99
+ const tail = sentinels.map((unit) => unit.verifyTail).find((value) => Boolean(value));
100
+ if (tail)
101
+ proof.push({ tier: 'ai-reported', code: 'proof.verifyOutput', rawExcerpt: tail });
102
+ }
103
+ // ── Tier 3: the reviewer grading its own pipeline ─────────────────────────
104
+ if (confidence) {
105
+ if (confidence.overall !== null) {
106
+ proof.push({
107
+ tier: 'reviewer-score',
108
+ code: 'proof.reviewerScore',
109
+ values: { overall: confidence.overall },
110
+ });
111
+ }
112
+ for (const [aspect, score] of Object.entries(confidence.aspects)) {
113
+ proof.push({ tier: 'reviewer-score', code: 'proof.reviewerAspect', values: { aspect, score } });
114
+ }
115
+ }
116
+ else {
117
+ proof.push({ tier: 'reviewer-score', code: 'proof.reviewerScoreUnavailable' });
118
+ }
119
+ return proof;
120
+ }
121
+ /**
122
+ * "What to watch out for" is capped and derived from REAL signals only —
123
+ * reviewer flags, the human-review band, and a code-changed-without-tests
124
+ * observation. Confabulated caution would train the user to ignore the section.
125
+ */
126
+ function buildWatchOut(confidence, churn, variant) {
127
+ const out = [];
128
+ if (confidence?.overall !== null && confidence?.overall !== undefined) {
129
+ if (confidence.overall >= exports.HUMAN_REVIEW_BAND.min && confidence.overall <= exports.HUMAN_REVIEW_BAND.max) {
130
+ out.push({ tier: 'reviewer-score', code: 'watch.humanReviewRecommended', values: { overall: confidence.overall } });
131
+ }
132
+ else if (confidence.overall < exports.HUMAN_REVIEW_BAND.min) {
133
+ out.push({ tier: 'reviewer-score', code: 'watch.lowConfidence', values: { overall: confidence.overall } });
134
+ }
135
+ }
136
+ for (const flag of (confidence?.flags ?? []).slice(0, 3)) {
137
+ out.push({ tier: 'reviewer-score', code: 'watch.reviewerFlag', rawExcerpt: flag });
138
+ }
139
+ const codeFiles = churn.filter((row) => !TEST_FILE_RE.test(row.file_path));
140
+ const testFiles = churn.filter((row) => TEST_FILE_RE.test(row.file_path));
141
+ if (out.length < 3 && variant !== 'no-changes' && codeFiles.length > 0 && testFiles.length === 0) {
142
+ out.push({ tier: 'app-verified', code: 'watch.noTestsForChangedCode', values: { files: codeFiles.length } });
143
+ }
144
+ return out.slice(0, 3);
145
+ }
146
+ /**
147
+ * Walk the supersession chain back to the original build. Bounded so a corrupted
148
+ * lineage cannot spin, and tolerant of a missing ancestor (an old row may have
149
+ * been pruned) — the chain simply starts where the data does.
150
+ */
151
+ exports.MAX_VERSION_CHAIN = 25;
152
+ function versionChain(db, row) {
153
+ const chain = [row];
154
+ const seen = new Set([row.id]);
155
+ let cursor = row;
156
+ while (chain.length < exports.MAX_VERSION_CHAIN && cursor.revision_of) {
157
+ const previous = (0, rail_pr_store_1.getPrDelivery)(db, cursor.revision_of);
158
+ if (!previous || seen.has(previous.id))
159
+ break;
160
+ seen.add(previous.id);
161
+ chain.push(previous);
162
+ cursor = previous;
163
+ }
164
+ return chain.reverse(); // oldest first: v1 … vN
165
+ }
166
+ /** Fraction of the original build's cost above which revisions are worth flagging. */
167
+ exports.DRIFT_COST_SHARE = 0.5;
168
+ /** Revision count backstop — advisory, never a block. */
169
+ exports.DRIFT_REVISION_COUNT = 3;
170
+ /**
171
+ * Reshape-the-spec nudges. Every trigger is a real measurement (cumulative cost
172
+ * vs the original build, churn landing outside the original file set, revision
173
+ * depth) so the banner can show its own numbers instead of asserting a vibe.
174
+ * A hard count alone was rejected: it treats a 40-cent typo fix and a nine-dollar
175
+ * architectural thrash identically.
176
+ */
177
+ function computeDriftNudges(input) {
178
+ const nudges = [];
179
+ const [original, ...revisions] = input.versions;
180
+ if (revisions.length === 0)
181
+ return nudges;
182
+ const originalCost = original?.costUsd ?? null;
183
+ const revisionCost = revisions.reduce((sum, version) => sum + (version.costUsd ?? 0), 0);
184
+ if (originalCost !== null && originalCost > 0 && revisionCost > originalCost * exports.DRIFT_COST_SHARE) {
185
+ nudges.push({
186
+ code: 'drift.costShare',
187
+ values: {
188
+ revisions: revisions.length,
189
+ revisionCost: revisionCost.toFixed(2),
190
+ originalCost: originalCost.toFixed(2),
191
+ share: Math.round((revisionCost / originalCost) * 100),
192
+ },
193
+ });
194
+ }
195
+ if (input.originalFileSet.size > 0 && input.currentFiles.size > 0) {
196
+ const outside = [...input.currentFiles].filter((file) => !input.originalFileSet.has(file));
197
+ if (outside.length * 2 > input.currentFiles.size) {
198
+ nudges.push({ code: 'drift.outOfScopeChurn', values: { files: outside.length, total: input.currentFiles.size } });
199
+ }
200
+ }
201
+ if (revisions.length >= exports.DRIFT_REVISION_COUNT) {
202
+ nudges.push({ code: 'drift.revisionCount', values: { revisions: revisions.length } });
203
+ }
204
+ return nudges;
205
+ }
206
+ function composeReviewPacket({ db, row }) {
207
+ const ticketIds = safeParse(row.ticket_ids, []);
208
+ const units = safeParse(row.branches, []);
209
+ const runIds = safeParse(row.run_ids, []);
210
+ const snapshot = (0, rail_pr_store_1.readSpecSnapshot)(row.spec_snapshot);
211
+ const evidence = (0, delivery_evidence_1.readSettleEvidence)(row.settle_evidence);
212
+ const churn = churnForRuns(db, runIds);
213
+ const variant = selectVariant(row, units);
214
+ const confidence = (evidence?.units ?? [])
215
+ .map((unit) => unit.confidence)
216
+ .find((score) => score !== null) ?? null;
217
+ const sections = ticketIds.map((ticketId) => {
218
+ const snap = snapshot?.find((entry) => entry.ticketId === ticketId) ?? null;
219
+ const narrative = (0, pr_body_1.extractSpecNarrative)(snap?.description ?? null);
220
+ const unitRows = units.filter((unit) => unit.ticketId === ticketId);
221
+ const unitRunIds = [...new Set(unitRows.map((unit) => unit.runId).filter((id) => Boolean(id)))];
222
+ // A run that covered several tickets (a batch collapsed into one checkout)
223
+ // cannot have its files split per ticket: provenance keys them to the run's
224
+ // primary ticket. Report null rather than repeat delivery totals per section.
225
+ const sharedRun = unitRunIds.some((runId) => new Set(units.filter((unit) => unit.runId === runId).map((unit) => unit.ticketId)).size > 1);
226
+ const unitChurn = !sharedRun && unitRunIds.length > 0
227
+ ? churn.filter((row) => row.job_id != null && unitRunIds.includes(row.job_id))
228
+ : null;
229
+ return {
230
+ ticketId,
231
+ title: snap?.title ?? null,
232
+ problem: narrative.problem,
233
+ solution: narrative.solution,
234
+ labels: snap?.labels ?? [],
235
+ implementationOutcome: unitRows[0]?.implementationOutcome ?? null,
236
+ deliveryOutcome: unitRows[0]?.deliveryOutcome ?? null,
237
+ changed: unitRows[0]?.changed ?? null,
238
+ churn: unitChurn === null ? null : {
239
+ filesTouched: new Set(unitChurn.map((c) => c.file_path)).size,
240
+ addedLines: unitChurn.reduce((sum, c) => sum + (c.added_lines || 0), 0),
241
+ removedLines: unitChurn.reduce((sum, c) => sum + (c.removed_lines || 0), 0),
242
+ testFilesTouched: [...new Set(unitChurn.filter((c) => TEST_FILE_RE.test(c.file_path)).map((c) => c.file_path))],
243
+ },
244
+ runIds: unitRunIds,
245
+ };
246
+ });
247
+ const succeededCount = units.length > 0
248
+ ? units.filter((unit) => unit.implementationOutcome === 'succeeded' || (unit.implementationOutcome == null && unit.succeeded)).length
249
+ : row.implementation_outcome === 'failed' ? 0 : ticketIds.length;
250
+ const failedCount = units.length > 0
251
+ ? units.filter((unit) => unit.implementationOutcome === 'failed' || (unit.implementationOutcome == null && !unit.succeeded)).length
252
+ : row.implementation_outcome === 'failed' ? ticketIds.length : 0;
253
+ const costSum = (0, ai_invocations_1.sumInvocationCostForRuns)(db, runIds);
254
+ const chain = versionChain(db, row);
255
+ const versions = chain.map((entry, index) => {
256
+ const entryRunIds = safeParse(entry.run_ids, []);
257
+ const entryCost = (0, ai_invocations_1.sumInvocationCostForRuns)(db, entryRunIds);
258
+ return {
259
+ prDeliveryId: entry.id,
260
+ version: index + 1,
261
+ revisionNote: entry.revision_note,
262
+ decision: entry.decision,
263
+ costUsd: entryCost?.totalUsd ?? null,
264
+ costEstimated: entryCost?.estimated ?? false,
265
+ current: entry.id === row.id,
266
+ };
267
+ });
268
+ const chainCosts = versions.map((version) => version.costUsd).filter((value) => value !== null);
269
+ // Churn comparison uses the ORIGINAL generation's files as the scope baseline:
270
+ // a revision touching mostly new files is the honest signal that the spec no
271
+ // longer describes the work.
272
+ const originalFiles = chain.length > 1
273
+ ? new Set(churnForRuns(db, safeParse(chain[0].run_ids, [])).map((entry) => entry.file_path))
274
+ : new Set();
275
+ const driftNudges = computeDriftNudges({
276
+ versions,
277
+ originalFileSet: originalFiles,
278
+ currentFiles: new Set(churn.map((entry) => entry.file_path)),
279
+ });
280
+ return {
281
+ schemaVersion: 1,
282
+ prDeliveryId: row.id,
283
+ railIndex: row.rail_index,
284
+ variant,
285
+ decision: row.decision,
286
+ statusCode: row.status_code,
287
+ headlineCode: headlineFor(variant, succeededCount, ticketIds.length),
288
+ ticketIds,
289
+ baseBranch: row.base_branch,
290
+ loopName: row.loop_name,
291
+ prUrl: row.pr_url,
292
+ prNumber: row.pr_number,
293
+ succeededCount,
294
+ failedCount,
295
+ totalCount: units.length > 0 ? new Set(units.map((u) => u.ticketId)).size : ticketIds.length,
296
+ sections,
297
+ proof: buildProof(units, evidence?.units ?? null, churn, confidence),
298
+ watchOut: buildWatchOut(confidence, churn, variant),
299
+ confidence,
300
+ cost: { totalUsd: costSum?.totalUsd ?? null, estimated: costSum?.estimated ?? false },
301
+ evidenceUnavailable: evidence === null || evidence.harvest === 'failed',
302
+ runIds,
303
+ supersedesDeliveryId: row.supersedes_delivery_id,
304
+ revisionNote: row.revision_note,
305
+ versions,
306
+ chainCostUsd: chainCosts.length > 0 ? chainCosts.reduce((sum, value) => sum + value, 0) : null,
307
+ chainCostEstimated: versions.some((version) => version.costEstimated),
308
+ driftNudges,
309
+ };
310
+ }
311
+ /** Assert-by-construction guard used in tests and by the route: no packet field
312
+ * may carry a numeric verification claim without a structured source. */
313
+ function packetHasUnsourcedNumericClaim(packet) {
314
+ return packet.proof.some((item) => item.tier === 'ai-reported'
315
+ && Object.keys(item.values ?? {}).some((key) => key === 'tests' || key === 'passed' || key === 'failedTests'));
316
+ }
317
+ function safeParse(raw, fallback) {
318
+ if (!raw)
319
+ return fallback;
320
+ try {
321
+ const parsed = JSON.parse(raw);
322
+ return parsed ?? fallback;
323
+ }
324
+ catch {
325
+ return fallback;
326
+ }
327
+ }
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SEED_EVIDENCE_CAP = exports.SEED_DIFF_CAP = exports.SEED_SPEC_BODY_CAP = void 0;
4
+ exports.buildRevisionSeed = buildRevisionSeed;
5
+ /** Bounds keep the seed a briefing, not a transcript. */
6
+ exports.SEED_SPEC_BODY_CAP = 4000;
7
+ exports.SEED_DIFF_CAP = 4000;
8
+ exports.SEED_EVIDENCE_CAP = 1200;
9
+ function clamp(value, cap) {
10
+ const trimmed = value.trim();
11
+ return trimmed.length <= cap ? trimmed : `${trimmed.slice(0, cap)}\n…(truncated)`;
12
+ }
13
+ /** One-line digest of what the previous run's verification actually reported. */
14
+ function evidenceLine(evidence) {
15
+ if (!evidence || evidence.units.length === 0)
16
+ return 'No verification evidence was captured for the previous run.';
17
+ const failed = evidence.units.filter((unit) => unit.sentinel === 'fail');
18
+ const passed = evidence.units.filter((unit) => unit.sentinel === 'pass');
19
+ const parts = [];
20
+ if (failed.length > 0) {
21
+ // Failure first: a revision briefing must not bury it behind a pass.
22
+ const detail = failed.map((unit) => unit.sentinelDetail).find(Boolean);
23
+ parts.push(`The previous run REPORTED FAILED verification${detail ? ` (${clamp(detail, 200)})` : ''}.`);
24
+ }
25
+ if (passed.length > 0)
26
+ parts.push('The previous run reported its verification passed.');
27
+ if (parts.length === 0)
28
+ parts.push('The previous run did not report a verification verdict.');
29
+ const score = evidence.units.map((unit) => unit.confidence?.overall).find((value) => typeof value === 'number');
30
+ if (typeof score === 'number')
31
+ parts.push(`Its reviewer scored the work ${score}/100.`);
32
+ return parts.join(' ');
33
+ }
34
+ /**
35
+ * The briefing a revision run is seeded with. Deterministic (same input ⇒ same
36
+ * text) so it is cache-friendly and testable, and honest: it never claims the
37
+ * previous run verified anything the evidence does not say.
38
+ */
39
+ function buildRevisionSeed(input) {
40
+ const specs = (input.specSnapshot ?? []).length > 0
41
+ ? (input.specSnapshot ?? []).map((entry) => [
42
+ `### #${entry.ticketId} ${entry.title ?? '(untitled)'}`,
43
+ entry.description ? clamp(entry.description, exports.SEED_SPEC_BODY_CAP) : '(no description recorded)',
44
+ ].join('\n')).join('\n\n')
45
+ : input.ticketIds.map((id) => `### #${id}\n(spec text was not captured at launch)`).join('\n\n');
46
+ return [
47
+ '## Revision briefing',
48
+ '',
49
+ `This is revision ${input.revisionNumber} of work that has ALREADY been delivered for the spec(s) below.`,
50
+ 'The code exists on the branch listed here. Do NOT start over, re-plan, or re-implement it.',
51
+ '',
52
+ '## What the user asked to change',
53
+ '',
54
+ clamp(input.note, exports.SEED_SPEC_BODY_CAP),
55
+ '',
56
+ '## The original request (frozen at launch)',
57
+ '',
58
+ specs,
59
+ '',
60
+ '## Where the existing work lives',
61
+ '',
62
+ input.branches.length > 0
63
+ ? `Branch(es): ${input.branches.join(', ')} (based on ${input.baseBranch}).`
64
+ : `Base branch: ${input.baseBranch}.`,
65
+ ...(input.branchDiffSummary
66
+ ? ['', 'Already changed on that branch:', '', clamp(input.branchDiffSummary, exports.SEED_DIFF_CAP)]
67
+ : []),
68
+ '',
69
+ '## What the previous run reported',
70
+ '',
71
+ clamp(evidenceLine(input.evidence), exports.SEED_EVIDENCE_CAP),
72
+ ].join('\n');
73
+ }