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
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.CONFIDENCE_RAW_CAP = exports.VERIFY_TAIL_CAP = void 0;
7
+ exports.parseVerificationSentinel = parseVerificationSentinel;
8
+ exports.extractVerifyStepText = extractVerifyStepText;
9
+ exports.parseConfidenceScore = parseConfidenceScore;
10
+ exports.harvestDeliveryEvidence = harvestDeliveryEvidence;
11
+ exports.readSettleEvidence = readSettleEvidence;
12
+ /**
13
+ * Deterministic settle-time evidence harvest for the review packet
14
+ * (nontech-review-experience Wave 1). Reads three sources with zero model
15
+ * calls: the `VERIFICATION: PASS/FAIL` sentinel from the run's persisted
16
+ * stream (the rail-merge-orchestrator regex precedent), a bounded tail of the
17
+ * verify step's output, and the reviewer's `confidence-score.json` written
18
+ * into the worktree's `openspec/changes/<name>/` (core ≥4.12 — previously read
19
+ * by nothing). Harvest NEVER throws and never blocks settle: every missing or
20
+ * malformed source degrades to an absent value on the evidence record, so the
21
+ * packet can always state honestly what it does and does not know.
22
+ */
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const path_1 = __importDefault(require("path"));
25
+ /** Bounded tail of the verify step's raw output persisted per unit. */
26
+ exports.VERIFY_TAIL_CAP = 4096;
27
+ /** Raw confidence-score.json payloads are capped before persistence. */
28
+ exports.CONFIDENCE_RAW_CAP = 8192;
29
+ /** Last sentinel wins: the verify prompt itself quotes the format, so earlier
30
+ * mentions in assistant reasoning must not shadow the final verdict line. */
31
+ const SENTINEL_RE = /VERIFICATION:\s*(PASS|FAIL)\b[ \t]*(?:[—:-][ \t]*)?([^\n]*)/gi;
32
+ function parseVerificationSentinel(text) {
33
+ let verdict = 'absent';
34
+ let detail = null;
35
+ for (const match of text.matchAll(SENTINEL_RE)) {
36
+ verdict = match[1].toUpperCase() === 'PASS' ? 'pass' : 'fail';
37
+ const tail = (match[2] ?? '').trim();
38
+ detail = verdict === 'fail' && tail ? tail.slice(0, 512) : null;
39
+ }
40
+ return { verdict, detail };
41
+ }
42
+ function parseLoopStepPayload(payload) {
43
+ try {
44
+ const parsed = JSON.parse(payload);
45
+ return {
46
+ index: typeof parsed.index === 'number' ? parsed.index : null,
47
+ nodeId: typeof parsed.nodeId === 'string' ? parsed.nodeId : null,
48
+ };
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** Extract human-readable text from one persisted event row. Assistant rows
55
+ * hold the raw provider JSONL line; log rows hold plain text. Everything else
56
+ * contributes nothing (structured/loop events are boundaries, not output). */
57
+ function eventText(event) {
58
+ if (event.event_type === 'log')
59
+ return event.payload;
60
+ if (event.event_type !== 'assistant')
61
+ return '';
62
+ try {
63
+ const frame = JSON.parse(event.payload);
64
+ const blocks = frame.message?.content;
65
+ if (!Array.isArray(blocks))
66
+ return '';
67
+ return blocks
68
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
69
+ .map((block) => block.text)
70
+ .join('\n');
71
+ }
72
+ catch {
73
+ return '';
74
+ }
75
+ }
76
+ /**
77
+ * Text of the LAST `verify` step in the run (loop_step nodeId === 'verify'
78
+ * through its loop_step_end / next step / stream end). Runs without a verify
79
+ * step boundary (non-loop jobs, legacy runs) fall back to the whole stream's
80
+ * text — still raw output, just unscoped.
81
+ */
82
+ function extractVerifyStepText(events) {
83
+ let start = -1;
84
+ let end = events.length;
85
+ let stepIndex = null;
86
+ for (let i = 0; i < events.length; i++) {
87
+ const event = events[i];
88
+ if (event.event_type === 'loop_step') {
89
+ const step = parseLoopStepPayload(event.payload);
90
+ if (step?.nodeId === 'verify') {
91
+ start = i;
92
+ stepIndex = step.index;
93
+ end = events.length;
94
+ }
95
+ else if (start >= 0 && i > start) {
96
+ // A later non-verify step closes the previous verify range unless a
97
+ // later verify step reopens it (loop iterations: last verify wins).
98
+ if (end === events.length)
99
+ end = i;
100
+ }
101
+ }
102
+ else if (start >= 0 && end === events.length && event.event_type === 'loop_step_end' &&
103
+ stepIndex !== null && parseLoopStepPayload(event.payload)?.index === stepIndex) {
104
+ end = i;
105
+ }
106
+ }
107
+ const range = start >= 0 ? events.slice(start, end) : events;
108
+ const text = range.map(eventText).filter(Boolean).join('\n');
109
+ return { text, scoped: start >= 0 };
110
+ }
111
+ function toNumberRecord(value) {
112
+ if (!value || typeof value !== 'object' || Array.isArray(value))
113
+ return {};
114
+ const out = {};
115
+ for (const [key, raw] of Object.entries(value)) {
116
+ if (typeof raw === 'number' && Number.isFinite(raw))
117
+ out[key] = raw;
118
+ else if (raw && typeof raw === 'object' && typeof raw.score === 'number') {
119
+ out[key] = raw.score;
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+ /** Tolerant parse of a confidence-score.json payload. Returns null only when
125
+ * the payload is not a JSON object at all. */
126
+ function parseConfidenceScore(rawJson, changeName) {
127
+ let parsed;
128
+ try {
129
+ parsed = JSON.parse(rawJson);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
135
+ return null;
136
+ const obj = parsed;
137
+ return {
138
+ changeName,
139
+ overall: typeof obj.overall === 'number' && Number.isFinite(obj.overall) ? obj.overall : null,
140
+ aspects: toNumberRecord(obj.aspects ?? obj.scores),
141
+ flags: Array.isArray(obj.flags) ? obj.flags.filter((f) => typeof f === 'string').slice(0, 20) : [],
142
+ raw: rawJson.length > exports.CONFIDENCE_RAW_CAP ? null : parsed,
143
+ };
144
+ }
145
+ /** Newest confidence-score.json under `<worktree>/openspec/changes/<name>/`.
146
+ * A worktree hosts at most a handful of change dirs; when several carry a
147
+ * score (unusual), the lexicographically last dir wins deterministically. */
148
+ function readConfidenceFromWorktree(worktreePath, io) {
149
+ const changesDir = path_1.default.join(worktreePath, 'openspec', 'changes');
150
+ let dirs;
151
+ try {
152
+ dirs = io.listDir(changesDir);
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ let found = null;
158
+ for (const dir of [...dirs].sort()) {
159
+ const scorePath = path_1.default.join(changesDir, dir, 'confidence-score.json');
160
+ try {
161
+ if (!io.fileExists(scorePath))
162
+ continue;
163
+ const raw = io.readFile(scorePath);
164
+ const parsed = parseConfidenceScore(raw, dir);
165
+ if (parsed)
166
+ found = parsed;
167
+ }
168
+ catch {
169
+ /* unreadable file = absent, not fatal */
170
+ }
171
+ }
172
+ return found;
173
+ }
174
+ /**
175
+ * Harvest evidence for every unit of a settling delivery. Never throws; the
176
+ * per-unit try/catch turns internal errors into `partial`/`failed` harvest
177
+ * status while absence of a source stays `ok` (absence is honest data).
178
+ */
179
+ function harvestDeliveryEvidence(io, units) {
180
+ const readFile = io.readFile ?? ((p) => fs_1.default.readFileSync(p, 'utf8'));
181
+ const listDir = io.listDir ?? ((p) => fs_1.default.readdirSync(p));
182
+ const fileExists = io.fileExists ?? ((p) => fs_1.default.existsSync(p));
183
+ const fsIo = { readFile, listDir, fileExists };
184
+ let errored = 0;
185
+ const harvested = units.map((unit) => {
186
+ const evidence = {
187
+ ticketId: unit.ticketId,
188
+ runId: unit.runId,
189
+ sentinel: 'absent',
190
+ sentinelDetail: null,
191
+ verifyTail: null,
192
+ confidence: null,
193
+ };
194
+ let unitErrored = false;
195
+ try {
196
+ if (unit.runId) {
197
+ const events = io.readEvents(unit.runId);
198
+ const { text } = extractVerifyStepText(events);
199
+ if (text) {
200
+ const sentinel = parseVerificationSentinel(text);
201
+ evidence.sentinel = sentinel.verdict;
202
+ evidence.sentinelDetail = sentinel.detail;
203
+ evidence.verifyTail = text.slice(-exports.VERIFY_TAIL_CAP);
204
+ }
205
+ }
206
+ }
207
+ catch {
208
+ unitErrored = true;
209
+ }
210
+ try {
211
+ if (unit.worktreePath) {
212
+ evidence.confidence = readConfidenceFromWorktree(unit.worktreePath, fsIo);
213
+ }
214
+ }
215
+ catch {
216
+ unitErrored = true;
217
+ }
218
+ if (unitErrored)
219
+ errored++;
220
+ return evidence;
221
+ });
222
+ return {
223
+ schemaVersion: 1,
224
+ harvest: units.length > 0 && errored === units.length ? 'failed' : errored > 0 ? 'partial' : 'ok',
225
+ harvestedAt: (io.now?.() ?? new Date()).toISOString(),
226
+ units: harvested,
227
+ };
228
+ }
229
+ /** Parse a persisted settle_evidence column value (null-tolerant). */
230
+ function readSettleEvidence(raw) {
231
+ if (!raw)
232
+ return null;
233
+ try {
234
+ const parsed = JSON.parse(raw);
235
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.units))
236
+ return null;
237
+ return parsed;
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
@@ -17,6 +17,7 @@ const specrails_tech_client_1 = require("./specrails-tech-client");
17
17
  const core_compat_1 = require("./core-compat");
18
18
  const providers_1 = require("./providers");
19
19
  const provider_detection_1 = require("./provider-detection");
20
+ const agent_defaults_1 = require("./agent-defaults");
20
21
  const workspace_manager_1 = require("./workspace-manager");
21
22
  const workspace_resolution_1 = require("./workspace-resolution");
22
23
  const desktop_analytics_1 = require("./desktop-analytics");
@@ -1130,6 +1131,28 @@ function createDesktopRouter(registry, broadcast) {
1130
1131
  const monthlyBudgetUsd = Number.isFinite(parsed) && parsed >= 0 ? parsed : 5.0;
1131
1132
  res.json({ language, monthlyBudgetUsd });
1132
1133
  });
1134
+ // ─── Specrails Agents defaults (global per-provider agent model/effort) ───
1135
+ // App-level layer read AT SPAWN TIME by QueueManager / rails-router / loop
1136
+ // executors — a change applies to the next run with zero restart.
1137
+ router.get('/agent-defaults', (_req, res) => {
1138
+ res.json({
1139
+ settings: (0, agent_defaults_1.readAgentDefaultsSettings)(registry.desktopDb),
1140
+ catalog: (0, agent_defaults_1.buildAgentDefaultsCatalog)(),
1141
+ });
1142
+ });
1143
+ router.patch('/agent-defaults', (req, res) => {
1144
+ try {
1145
+ const settings = (0, agent_defaults_1.applyAgentDefaultsPatch)(registry.desktopDb, req.body);
1146
+ res.json({ settings, catalog: (0, agent_defaults_1.buildAgentDefaultsCatalog)() });
1147
+ }
1148
+ catch (err) {
1149
+ if (err instanceof agent_defaults_1.AgentDefaultsValidationError) {
1150
+ res.status(400).json({ error: err.code, message: err.message });
1151
+ return;
1152
+ }
1153
+ res.status(500).json({ error: 'agent_defaults_failed', message: err.message });
1154
+ }
1155
+ });
1133
1156
  // ─── specrails-core update channel (app-global) ─────────────────────────────
1134
1157
  // Detect + apply a specrails-core framework update independently of the desktop
1135
1158
  // app update. See server/core-update-manager.ts. A single manager instance
@@ -5,6 +5,8 @@ exports.isBrowserCaptureEnabled = isBrowserCaptureEnabled;
5
5
  exports.isJiraEnabled = isJiraEnabled;
6
6
  exports.isInteractiveJobsEnabled = isInteractiveJobsEnabled;
7
7
  exports.isLoopsEnabled = isLoopsEnabled;
8
+ exports.isReviewPacketEnabled = isReviewPacketEnabled;
9
+ exports.areDeliveryRevisionsEnabled = areDeliveryRevisionsEnabled;
8
10
  function isCodeExplorerEnabled() {
9
11
  return process.env.SPECRAILS_CODE_EXPLORER !== 'false';
10
12
  }
@@ -54,3 +56,24 @@ function isInteractiveJobsEnabled() {
54
56
  function isLoopsEnabled() {
55
57
  return process.env.SPECRAILS_LOOPS_SECTION !== 'false';
56
58
  }
59
+ /**
60
+ * Review packet (nontech-review-experience): the plain-language what-changed +
61
+ * proof surface a non-technical user decides on, replacing raw git vocabulary
62
+ * at the review moment. Server-side default ON; set
63
+ * SPECRAILS_REVIEW_PACKET="false" to 404 the packet route (emergency
64
+ * rollback — the existing decision strip keeps working untouched). The client
65
+ * gates separately on VITE_FEATURE_REVIEW_PACKET.
66
+ */
67
+ function isReviewPacketEnabled() {
68
+ return process.env.SPECRAILS_REVIEW_PACKET !== 'false';
69
+ }
70
+ /**
71
+ * Delivery revisions (nontech-review-experience Wave 3): the ability to launch a
72
+ * change against a delivery that is still awaiting the user's decision. Default
73
+ * ON; set SPECRAILS_DELIVERY_REVISIONS="false" to restore the byte-identical
74
+ * legacy guard — every launch against an undecided delivery 409s again and the
75
+ * packet's "Ask for changes" cannot be satisfied.
76
+ */
77
+ function areDeliveryRevisionsEnabled() {
78
+ return process.env.SPECRAILS_DELIVERY_REVISIONS !== 'false';
79
+ }
@@ -78,6 +78,39 @@ exports.LOOP_COMMANDS = [
78
78
  requiredCapability: 'freestyle',
79
79
  ticketScope: 'per-ticket',
80
80
  },
81
+ {
82
+ name: 'revise',
83
+ label: 'revise',
84
+ description: 'Revision step (nontech-review-experience): apply the ONE change the user asked for on top of work already delivered, then have the reviewer re-grade it. No re-planning, no re-implementing.',
85
+ ticketScope: 'all',
86
+ // Deliberately NOT the implement pipeline: the plan and the code already
87
+ // exist, so an Architect pass would re-derive both and pay the full cost of
88
+ // a first run for a one-sentence tweak. The reviewer pass is orchestrated
89
+ // from HERE rather than added to core, because the sr-* agents are already
90
+ // present in the run's worktree via the overlay — that keeps the loop a
91
+ // pure desktop concern with zero specrails-core coupling, and it is what
92
+ // keeps the review packet's reviewer tier populated for revisions.
93
+ template: [
94
+ 'A previous run already delivered work for this spec, and the user has now asked for ONE specific change to it. Apply exactly that change.',
95
+ '',
96
+ 'What the user asked to change:',
97
+ '{{const:REVISION_REQUEST}}',
98
+ '',
99
+ 'If the section above is EMPTY, no change was actually requested: do not',
100
+ 'guess, do not re-implement anything, and do not touch the branch. Report',
101
+ 'that no revision instruction was provided and stop.',
102
+ '',
103
+ 'Rules:',
104
+ '- Start from the work that is ALREADY on this branch. Read it first (git diff against the base branch) so you extend it instead of redoing it.',
105
+ '- Make the SMALLEST change that satisfies the request. Do not refactor, re-plan, or re-implement anything the user did not ask about.',
106
+ '- If the request is ambiguous, choose the most conservative reading and say which one you chose.',
107
+ '- If the request cannot be done without breaking something the spec requires, stop and report that instead of forcing it.',
108
+ '',
109
+ 'Then re-grade the result: run the `sr-reviewer` agent over the resulting diff so its review report and `confidence-score.json` reflect THIS revision, not the previous run.',
110
+ '',
111
+ '{{const:GUARDRAILS}}',
112
+ ].join('\n'),
113
+ },
81
114
  {
82
115
  name: 'fix',
83
116
  label: 'fix',
@@ -121,6 +121,16 @@ function aiStepExtraArgs(adapter, cwd, repoDir) {
121
121
  }
122
122
  function createLoopExecutors(opts = {}) {
123
123
  const resolveEnv = () => typeof opts.env === 'function' ? opts.env() : opts.env ?? process.env;
124
+ // Deciders deliberately excluded: they run no pipeline agents.
125
+ const withProfileEnv = (env, provider) => {
126
+ try {
127
+ const profilePath = opts.profilePathFor?.(provider) ?? null;
128
+ return profilePath ? { ...env, SPECRAILS_PROFILE_PATH: profilePath } : env;
129
+ }
130
+ catch {
131
+ return env;
132
+ }
133
+ };
124
134
  return {
125
135
  // Cheap working-tree fingerprint for the engine's non-convergence guard:
126
136
  // HEAD sha + the porcelain dirty set, hashed. Two identical fingerprints
@@ -150,7 +160,7 @@ function createLoopExecutors(opts = {}) {
150
160
  // for the pipeline's I/O exactly like QueueManager: SPECRAILS_REPO_DIR +
151
161
  // claude `--add-dir <repoDir>` (see the shared helpers above). Best-effort
152
162
  // agent self-heal on Windows.
153
- const stepEnv = aiStepEnv(resolveEnv(), repoDir);
163
+ const stepEnv = withProfileEnv(aiStepEnv(resolveEnv(), repoDir), provider);
154
164
  const extraArgs = aiStepExtraArgs(adapter, cwd, repoDir);
155
165
  const effectivePrompt = (0, runtime_1.formatProviderCommand)(adapter, prompt, cwd, sessionId ?? undefined);
156
166
  const buildOpts = {
@@ -294,7 +304,7 @@ function createLoopExecutors(opts = {}) {
294
304
  const adapter = (0, providers_1.getAdapter)(provider);
295
305
  if (!adapter.capabilities.persistentStdin)
296
306
  return null;
297
- const stepEnv = aiStepEnv(resolveEnv(), repoDir);
307
+ const stepEnv = withProfileEnv(aiStepEnv(resolveEnv(), repoDir), provider);
298
308
  const extraArgs = aiStepExtraArgs(adapter, cwd, repoDir);
299
309
  if (repoDir) {
300
310
  try {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FACTORY_LOOPS = void 0;
3
+ exports.FACTORY_LOOPS = exports.FACTORY_REVISION_LOOP_ID = void 0;
4
4
  exports.getFactoryLoop = getFactoryLoop;
5
5
  exports.isFactoryLoopId = isFactoryLoopId;
6
6
  exports.factoryLoopMode = factoryLoopMode;
@@ -23,6 +23,8 @@ const SDD_QUICK_OPENSPEC_FACTORY = {
23
23
  mode: 'loop',
24
24
  graph: (0, loop_templates_1.opsxLifecycleGraph)(),
25
25
  };
26
+ /** The Architect-less revision loop every "ask for a change" launch runs. */
27
+ exports.FACTORY_REVISION_LOOP_ID = 'factory:revision';
26
28
  exports.FACTORY_LOOPS = [
27
29
  {
28
30
  id: 'factory:implement',
@@ -49,6 +51,19 @@ exports.FACTORY_LOOPS = [
49
51
  requiredCapability: 'freestyle',
50
52
  graph: (0, loop_templates_1.fixLoopGraph)(['{{cmd:freestyle}}'], GREEN_GOAL, FACTORY_MAX_ITERATIONS, FACTORY_LOOP_TIMEOUT_MIN, FACTORY_AI_STEP_TIMEOUT_MIN),
51
53
  },
54
+ {
55
+ id: exports.FACTORY_REVISION_LOOP_ID,
56
+ name: 'Revision',
57
+ // Listed so the platform's behaviour is discoverable, but `launchable:false`:
58
+ // the app runs this itself when a user asks for a change to a delivery. Its
59
+ // prompt consumes `{{const:REVISION_REQUEST}}`, and an unresolved constant
60
+ // renders as an EMPTY string, so a hand-launched run would have a blank
61
+ // central instruction. The launch route refuses it; the prompt guards too.
62
+ launchable: false,
63
+ description: "Apply the ONE change the user asked for on top of work already delivered, re-grade it with the reviewer, then verify + refine until green. No re-planning. Runs automatically when you ask for changes on a review — it is not started by hand.",
64
+ mode: 'loop',
65
+ graph: (0, loop_templates_1.fixLoopGraph)(['{{cmd:revise}}'], GREEN_GOAL, FACTORY_MAX_ITERATIONS, FACTORY_LOOP_TIMEOUT_MIN, FACTORY_AI_STEP_TIMEOUT_MIN),
66
+ },
52
67
  SDD_QUICK_OPENSPEC_FACTORY,
53
68
  ];
54
69
  const FACTORY_ALIASES = new Map([
@@ -65,6 +65,8 @@ function registerLoopsRoutes(router, deps) {
65
65
  description: f.description,
66
66
  mode: f.mode,
67
67
  requiredCapability: f.requiredCapability ?? null,
68
+ // Absent means launchable (every pre-existing factory loop is).
69
+ launchable: f.launchable !== false,
68
70
  graph: f.graph,
69
71
  })),
70
72
  });
@@ -122,6 +122,14 @@ function railsTools() {
122
122
  .min(1)
123
123
  .optional()
124
124
  .describe('Deliver INTO an existing open PR instead of creating a new one (launch). When the user names an existing PR ("extend PR #151"), pass its number here — the rail then works on that PR\'s head branch and settle pushes to it; NEVER launch without it in that case (a plain launch would create a duplicate PR). The PR must be open and same-repo (fork PRs are rejected with target_pr_fork).'),
125
+ revisionOfDeliveryId: zod_1.z
126
+ .string()
127
+ .optional()
128
+ .describe('Revise a delivery that is ALREADY awaiting the user\'s decision (launch). This is the only way to launch against an undecided delivery: pass its prDeliveryId (from rails.prDeliveries) together with revisionNote. Use it whenever the user asks for a change to work you already delivered — never publish/discard/merge first. The rail must still carry exactly that delivery\'s specs.'),
129
+ revisionNote: zod_1.z
130
+ .string()
131
+ .optional()
132
+ .describe('What to change, in the user\'s own words (required with revisionOfDeliveryId). It is injected into the revision run and shown on the updated review packet as "what you asked to change".'),
125
133
  },
126
134
  async handler(ctx, args) {
127
135
  const base = `${(0, types_1.projectPath)(ctx, args.projectId)}/rails`;
@@ -210,6 +218,14 @@ function railsTools() {
210
218
  body.reasoning_effort = args.reasoning_effort;
211
219
  if (args.targetPrNumber !== undefined)
212
220
  body.targetPrNumber = args.targetPrNumber;
221
+ // Revision of a delivery already awaiting the user's decision: the
222
+ // ONE launch allowed against an undecided delivery. The route
223
+ // re-validates the exemption (must be the rail's active delivery and
224
+ // cover its full spec set), so a wrong id fails closed there.
225
+ if (args.revisionOfDeliveryId !== undefined)
226
+ body.revisionOfDeliveryId = args.revisionOfDeliveryId;
227
+ if (args.revisionNote !== undefined)
228
+ body.revisionNote = args.revisionNote;
213
229
  // Origin link (safe-pr-review-flow): a launch driven by the in-app
214
230
  // agent carries its conversation id (from the loopback header, via
215
231
  // ctx) so the PR decision card is posted back into that conversation.
@@ -11,6 +11,7 @@ const fs_1 = __importDefault(require("fs"));
11
11
  const os_1 = __importDefault(require("os"));
12
12
  const db_1 = require("./db");
13
13
  const queue_manager_1 = require("./queue-manager");
14
+ const agent_defaults_1 = require("./agent-defaults");
14
15
  const chat_manager_1 = require("./chat-manager");
15
16
  const setup_manager_1 = require("./setup-manager");
16
17
  const proposal_manager_1 = require("./proposal-manager");
@@ -35,6 +36,7 @@ const project_env_1 = require("./project-env");
35
36
  const workspace_manager_1 = require("./workspace-manager");
36
37
  const ticket_store_1 = require("./ticket-store");
37
38
  const jira_sync_manager_1 = require("./jira/jira-sync-manager");
39
+ const stuck_run_detector_1 = require("./stuck-run-detector");
38
40
  const loop_run_manager_1 = require("./loop-run-manager");
39
41
  const loop_executors_1 = require("./loop-executors");
40
42
  const rail_isolated_launch_1 = require("./rail-isolated-launch");
@@ -291,6 +293,10 @@ class ProjectRegistry {
291
293
  ctx.jiraSyncManager.stop();
292
294
  }
293
295
  catch { /* ignore */ }
296
+ try {
297
+ ctx.stuckRunDetector.stop();
298
+ }
299
+ catch { /* ignore */ }
294
300
  // Kill any terminal sessions belonging to this project
295
301
  try {
296
302
  (0, terminal_manager_1.getTerminalManager)().killAllForProject(id);
@@ -437,6 +443,10 @@ class ProjectRegistry {
437
443
  ctx.jiraSyncManager.stop();
438
444
  }
439
445
  catch { /* ignore */ }
446
+ try {
447
+ ctx.stuckRunDetector.stop();
448
+ }
449
+ catch { /* ignore */ }
440
450
  // Release chokidar watchers + abort in-flight generations so a restart
441
451
  // does not leak handles/children — mirror removeProject()'s per-project teardown.
442
452
  try {
@@ -509,6 +519,10 @@ class ProjectRegistry {
509
519
  projectId: project.id,
510
520
  projectSlug: project.slug,
511
521
  desktopPort: this._desktopPort,
522
+ // Global Specrails Agents defaults — resolved at spawn time from
523
+ // desktop.sqlite so a Settings change applies to the next job without
524
+ // restarting (mirrors the pipelineTelemetryEnabled read-at-spawn rule).
525
+ agentDefaults: (provider) => (0, agent_defaults_1.resolveAgentDefaults)(this._desktopDb, provider),
512
526
  onJobAdmission: (projectDb, job) => {
513
527
  const ticketIds = (0, ticket_store_1.extractTicketIdsFromCommand)(job.command);
514
528
  (0, rails_store_1.claimTicketOutcomeOwners)(projectDb, ticketIds, job.id);
@@ -899,6 +913,22 @@ class ProjectRegistry {
899
913
  // See resolveLoopBaseEnv.
900
914
  const loopRunManager = new loop_run_manager_1.LoopRunManager(db, boundBroadcast, (0, loop_executors_1.createLoopExecutors)({
901
915
  env: () => (0, workspace_resolution_1.resolveLoopBaseEnv)({ slug: project.slug, path: project.path }, undefined, (0, project_env_1.applyWorktreeEnvPassthrough)(db, process.env)),
916
+ // Global Specrails Agents defaults, loop path: per-agent model overrides
917
+ // ride SPECRAILS_PROFILE_PATH on each ai-step spawn. Resolved per step
918
+ // (no restart); inert (null) unless the global layer carries per-agent
919
+ // models for the provider — byte-identical legacy behaviour otherwise.
920
+ profilePathFor: (0, agent_defaults_1.createLoopProfilePathResolver)({
921
+ desktopDb: this._desktopDb,
922
+ profileRoot: () => (0, workspace_resolution_1.resolveProjectExecution)({ slug: project.slug, path: project.path }).cwd,
923
+ // Lazy require: several suites mock './queue-manager' with only the
924
+ // class export — a top-level named import would throw on module eval.
925
+ // The closure only runs at loop ai-step spawn time, never in tests
926
+ // that stub the queue.
927
+ supportsProfiles: (root) => {
928
+ const { projectSupportsProfiles } = require('./queue-manager');
929
+ return projectSupportsProfiles(root);
930
+ },
931
+ }),
902
932
  }));
903
933
  const getTicketSpec = (ticketId) => {
904
934
  try {
@@ -1081,7 +1111,9 @@ class ProjectRegistry {
1081
1111
  console.error(`[loops] terminal effects failed for ${runId}:`, err);
1082
1112
  }
1083
1113
  };
1084
- const ctx = { project, db, queueManager, chatManager, setupManager, proposalManager, agentRefineManager, fileSummaryManager, specLauncherManager, ticketWatcher, browserCaptureManager, jiraSyncManager, broadcast: boundBroadcast, railJobs, loopRunManager, railLoopRuns, onLoopRunFinished, getTicketSpec, desktopDb: this._desktopDb };
1114
+ const stuckRunDetector = new stuck_run_detector_1.StuckRunDetector(project.id, { db }, boundBroadcast);
1115
+ stuckRunDetector.start();
1116
+ const ctx = { project, db, queueManager, chatManager, setupManager, proposalManager, agentRefineManager, fileSummaryManager, specLauncherManager, ticketWatcher, browserCaptureManager, jiraSyncManager, stuckRunDetector, broadcast: boundBroadcast, railJobs, loopRunManager, railLoopRuns, onLoopRunFinished, getTicketSpec, desktopDb: this._desktopDb };
1085
1117
  this._contexts.set(project.id, ctx);
1086
1118
  const loopRecoveryOk = this._recoverOrphanLoopRuns(project, db, railLoopRuns, onLoopRunFinished, orphanLoopRuns);
1087
1119
  if (loopRecoveryOk) {
@@ -4,8 +4,11 @@ exports.registerLoopRunRoutes = registerLoopRunRoutes;
4
4
  const feature_flags_1 = require("./feature-flags");
5
5
  const loops_store_1 = require("./loops-store");
6
6
  const loop_runs_store_1 = require("./loop-runs-store");
7
+ const run_duration_stats_1 = require("./run-duration-stats");
7
8
  const loop_constants_1 = require("./loop-constants");
8
9
  const providers_1 = require("./providers");
10
+ const runtime_1 = require("./providers/runtime");
11
+ const agent_defaults_1 = require("./agent-defaults");
9
12
  const provider_selection_1 = require("./provider-selection");
10
13
  const spec_models_1 = require("./spec-models");
11
14
  const workspace_resolution_1 = require("./workspace-resolution");
@@ -40,6 +43,22 @@ function registerLoopRunRoutes(deps) {
40
43
  },
41
44
  });
42
45
  });
46
+ // Measured duration band for "runs like this have taken X–Y". Returns
47
+ // `range: null` below the sample floor — the caller MUST render nothing
48
+ // rather than a guess (honest-metrics contract; see run-duration-stats).
49
+ router.get('/:projectId/run-duration-range', (req, res) => {
50
+ const c = ctx(req);
51
+ const loopId = typeof req.query.loopId === 'string' ? req.query.loopId : null;
52
+ const command = typeof req.query.command === 'string' ? req.query.command : null;
53
+ if (!loopId && !command) {
54
+ res.status(400).json({ error: 'loopId or command is required' });
55
+ return;
56
+ }
57
+ const range = loopId
58
+ ? (0, run_duration_stats_1.getLoopDurationRange)(c.db, c.project.id, loopId)
59
+ : (0, run_duration_stats_1.getJobCommandDurationRange)(c.db, (0, run_duration_stats_1.jobCommandShape)(command));
60
+ res.json({ range, minSamples: run_duration_stats_1.MIN_DURATION_SAMPLES });
61
+ });
43
62
  router.post('/:projectId/loop-runs', (req, res) => {
44
63
  if (!(0, feature_flags_1.isLoopsEnabled)()) {
45
64
  res.status(404).json({ error: 'Not Found' });
@@ -89,7 +108,9 @@ function registerLoopRunRoutes(deps) {
89
108
  return;
90
109
  }
91
110
  // Optional explicit model — validated against the chosen provider's catalog
92
- // (mirrors Add Spec). Omitted ⇒ the provider's default.
111
+ // (mirrors Add Spec). Omitted ⇒ the global Specrails Agents default when
112
+ // set (read at launch time — no restart), else the provider's default.
113
+ const globalAgentDefaults = (0, agent_defaults_1.resolveAgentDefaults)(c.desktopDb, provider);
93
114
  let model;
94
115
  if (requestedModel !== undefined && requestedModel !== null) {
95
116
  if (!(0, spec_models_1.isValidModelForProvider)(requestedModel, provider)) {
@@ -99,7 +120,7 @@ function registerLoopRunRoutes(deps) {
99
120
  model = requestedModel;
100
121
  }
101
122
  else {
102
- model = adapter.defaultModel();
123
+ model = globalAgentDefaults?.pipelineModel ?? adapter.defaultModel();
103
124
  }
104
125
  let effort;
105
126
  if (reasoning_effort !== undefined && reasoning_effort !== null) {
@@ -114,6 +135,10 @@ function registerLoopRunRoutes(deps) {
114
135
  }
115
136
  effort = reasoning_effort;
116
137
  }
138
+ else if (globalAgentDefaults?.pipelineEffort
139
+ && (0, runtime_1.isReasoningEffortValidForModel)(adapter, model, globalAgentDefaults.pipelineEffort)) {
140
+ effort = globalAgentDefaults.pipelineEffort;
141
+ }
117
142
  const exec = (0, workspace_resolution_1.resolveProjectExecution)({ slug: c.project.slug, path: c.project.path });
118
143
  const runId = (0, ids_1.newId)();
119
144
  c.loopRunManager