specrails-desktop 2.32.0 → 2.33.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 (76) hide show
  1. package/client/dist/assets/{ActivityFeedPage-DvnFkjVG.js → ActivityFeedPage-DaBTALGY.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-CBjcliD7.js → AgentBrowserCapture-SxwVNrUs.js} +1 -1
  3. package/client/dist/assets/{AgentModeCodePane-CokuFVdV.js → AgentModeCodePane-CmLgIoDw.js} +2 -2
  4. package/client/dist/assets/{AgentModeJobsPane-rf_DlWNo.js → AgentModeJobsPane-BvP_T32M.js} +1 -1
  5. package/client/dist/assets/{AgentsPage-B648IzGI.js → AgentsPage-Bz7uDqvI.js} +1 -1
  6. package/client/dist/assets/{AnalyticsPage-0rBoULPX.js → AnalyticsPage-DrNHMTed.js} +1 -1
  7. package/client/dist/assets/{CodePage-ju302Vl7.js → CodePage-DZtAK2Ac.js} +1 -1
  8. package/client/dist/assets/{DesktopAnalyticsPage-gBRVqiw6.js → DesktopAnalyticsPage-DrvNiDb0.js} +1 -1
  9. package/client/dist/assets/{DocsDialog-BzO5dg2B.js → DocsDialog-BEQpGC1o.js} +1 -1
  10. package/client/dist/assets/{DocsPage-Db_uxr1j.js → DocsPage-Btso3Viq.js} +1 -1
  11. package/client/dist/assets/{InteractiveJobComposer-_jmsE_Az.js → InteractiveJobComposer-Ca9-_4VF.js} +1 -1
  12. package/client/dist/assets/{JobDetailModal-DPHh2WOS.js → JobDetailModal-Brk7fL5A.js} +1 -1
  13. package/client/dist/assets/{JobDetailPage-7nwHyHGV.js → JobDetailPage-DiQVcvW7.js} +1 -1
  14. package/client/dist/assets/{JobsPage-C_6YrcmE.js → JobsPage-1SvirYQm.js} +1 -1
  15. package/client/dist/assets/{LoopBuilderPage-DJ1UPDwL.js → LoopBuilderPage-X_YwJ-Ik.js} +1 -1
  16. package/client/dist/assets/{LoopPreviewModal-12cUnljK.js → LoopPreviewModal-BeW-cr7A.js} +1 -1
  17. package/client/dist/assets/{LoopsPage-CHYK0Xjr.js → LoopsPage-Na9vDt4p.js} +1 -1
  18. package/client/dist/assets/{MinimizedChatsContext-Gt6KQySM.js → MinimizedChatsContext-BQQohTp2.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-E59oQmwc.js → PluginsPage-B-NLA93L.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-DQTLQ7ie.js → ProjectSettingsDialog-CQlq1Mf7.js} +1 -1
  21. package/client/dist/assets/{TemplatePreviewModal-nXUcDgfk.js → TemplatePreviewModal-CPz1o3fP.js} +1 -1
  22. package/client/dist/assets/{TicketDetailModal-C-tHW-2s.js → TicketDetailModal-DEoAJOuD.js} +3 -3
  23. package/client/dist/assets/{Trans-B2i5HVcM.js → Trans-CpUoGvNy.js} +1 -1
  24. package/client/dist/assets/agentstudio-BNUth0uj.js +1 -0
  25. package/client/dist/assets/agentstudio-BNdB5hkr.js +1 -0
  26. package/client/dist/assets/agentstudio-C7-bt8FO.js +1 -0
  27. package/client/dist/assets/agentstudio-C8uNchpV.js +1 -0
  28. package/client/dist/assets/agentstudio-CKabyMlg.js +1 -0
  29. package/client/dist/assets/agentstudio-CN2pokQu.js +1 -0
  30. package/client/dist/assets/agentstudio-DR7doNBV.js +1 -0
  31. package/client/dist/assets/agentstudio-y2dVDXeM.js +1 -0
  32. package/client/dist/assets/{dist-js-CkTZKBF9.js → dist-js-1QRck05T.js} +1 -1
  33. package/client/dist/assets/{dist-js-ClShHcGa.js → dist-js-K9AsZWxz.js} +1 -1
  34. package/client/dist/assets/{formatDistanceToNow-BjGwPf6I.js → formatDistanceToNow-2bGOYubE.js} +1 -1
  35. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-CSCbUI4W.js → getTimezoneOffsetInMilliseconds-B_N-0uSl.js} +1 -1
  36. package/client/dist/assets/{i18n-Ch-Db_x2.js → i18n-B2SaZsf2.js} +2 -2
  37. package/client/dist/assets/{index-kCUcnT8G.js → index-Bpzm94S9.js} +17 -17
  38. package/client/dist/assets/index-ChIuonbC.css +2 -0
  39. package/client/dist/assets/setup-CA9J5hck.js +1 -0
  40. package/client/dist/assets/setup-CK8IKkeD.js +1 -0
  41. package/client/dist/assets/setup-Cas_jrnl.js +1 -0
  42. package/client/dist/assets/setup-ClcYC8KP.js +1 -0
  43. package/client/dist/assets/setup-CtfFTXdf.js +1 -0
  44. package/client/dist/assets/setup-D73npsqO.js +1 -0
  45. package/client/dist/assets/setup-DXw1UgZB.js +1 -0
  46. package/client/dist/assets/setup-DsKIBie0.js +1 -0
  47. package/client/dist/assets/{spending-Dq43JYmD.js → spending-B20-uFLQ.js} +1 -1
  48. package/client/dist/assets/{useBrowserCaptureSession-TTO8vHtd.js → useBrowserCaptureSession-DACautTD.js} +1 -1
  49. package/client/dist/assets/{useDesktop-DUvS4fuP.js → useDesktop-Cwyis9DX.js} +1 -1
  50. package/client/dist/index.html +13 -13
  51. package/package.json +1 -1
  52. package/server/dist/ai-invocations.js +35 -0
  53. package/server/dist/job-phase-breakdown.js +132 -0
  54. package/server/dist/loop-executors.js +5 -1
  55. package/server/dist/pr-body.js +13 -0
  56. package/server/dist/project-router-jobs.js +16 -0
  57. package/server/dist/queue-manager.js +4 -0
  58. package/server/dist/rail-pr-decision.js +10 -0
  59. package/server/dist/repo-map.js +293 -0
  60. package/client/dist/assets/agentstudio-8TQqBenW.js +0 -1
  61. package/client/dist/assets/agentstudio-B8Z_mmEX.js +0 -1
  62. package/client/dist/assets/agentstudio-BZR9YmLs.js +0 -1
  63. package/client/dist/assets/agentstudio-Bge9pAWb.js +0 -1
  64. package/client/dist/assets/agentstudio-Bz169Vzt.js +0 -1
  65. package/client/dist/assets/agentstudio-D6L2jpNi.js +0 -1
  66. package/client/dist/assets/agentstudio-K2-Z7CL0.js +0 -1
  67. package/client/dist/assets/agentstudio-Zmgzthv8.js +0 -1
  68. package/client/dist/assets/index-Bz0TeGRs.css +0 -2
  69. package/client/dist/assets/setup-BBiHEO7h.js +0 -1
  70. package/client/dist/assets/setup-BlTWhNIQ.js +0 -1
  71. package/client/dist/assets/setup-Bp2L_oiP.js +0 -1
  72. package/client/dist/assets/setup-CInYXp1-.js +0 -1
  73. package/client/dist/assets/setup-CYH3bP6K.js +0 -1
  74. package/client/dist/assets/setup-DwcQ921P.js +0 -1
  75. package/client/dist/assets/setup-PBLjuDSs.js +0 -1
  76. package/client/dist/assets/setup-bx6FtoBp.js +0 -1
@@ -204,7 +204,7 @@
204
204
  .specrails-splash__mark .pill { opacity: 1; }
205
205
  }
206
206
  </style>
207
- <script type="module" crossorigin src="/assets/index-kCUcnT8G.js"></script>
207
+ <script type="module" crossorigin src="/assets/index-Bpzm94S9.js"></script>
208
208
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
209
209
  <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-zu2_FqZY.js">
210
210
  <link rel="modulepreload" crossorigin href="/assets/react-dom-DiWzBDP9.js">
@@ -215,7 +215,7 @@
215
215
  <link rel="modulepreload" crossorigin href="/assets/addspec-x3mizDxc.js">
216
216
  <link rel="modulepreload" crossorigin href="/assets/agent-Bs9NilfI.js">
217
217
  <link rel="modulepreload" crossorigin href="/assets/agents-ms7TwG16.js">
218
- <link rel="modulepreload" crossorigin href="/assets/agentstudio-Bz169Vzt.js">
218
+ <link rel="modulepreload" crossorigin href="/assets/agentstudio-CN2pokQu.js">
219
219
  <link rel="modulepreload" crossorigin href="/assets/aiedit-iIjz2_Qk.js">
220
220
  <link rel="modulepreload" crossorigin href="/assets/analytics-BHqUazhP.js">
221
221
  <link rel="modulepreload" crossorigin href="/assets/attachments-B53PG733.js">
@@ -234,23 +234,23 @@
234
234
  <link rel="modulepreload" crossorigin href="/assets/mcp-uDkXYu4L.js">
235
235
  <link rel="modulepreload" crossorigin href="/assets/nav-VaB-C4k5.js">
236
236
  <link rel="modulepreload" crossorigin href="/assets/settings-CuVwOpe0.js">
237
- <link rel="modulepreload" crossorigin href="/assets/setup-DwcQ921P.js">
237
+ <link rel="modulepreload" crossorigin href="/assets/setup-D73npsqO.js">
238
238
  <link rel="modulepreload" crossorigin href="/assets/specs-DII0C8gw.js">
239
239
  <link rel="modulepreload" crossorigin href="/assets/terminal-BFRZnMPP.js">
240
240
  <link rel="modulepreload" crossorigin href="/assets/tickets-CqqOHbli.js">
241
- <link rel="modulepreload" crossorigin href="/assets/i18n-Ch-Db_x2.js">
242
- <link rel="modulepreload" crossorigin href="/assets/Trans-B2i5HVcM.js">
241
+ <link rel="modulepreload" crossorigin href="/assets/i18n-B2SaZsf2.js">
242
+ <link rel="modulepreload" crossorigin href="/assets/Trans-CpUoGvNy.js">
243
243
  <link rel="modulepreload" crossorigin href="/assets/dist-bVqfYmby.js">
244
244
  <link rel="modulepreload" crossorigin href="/assets/arrow-left-CpIR4j7G.js">
245
245
  <link rel="modulepreload" crossorigin href="/assets/auth-D_5bQv5W.js">
246
246
  <link rel="modulepreload" crossorigin href="/assets/api-B2M7CtDw.js">
247
- <link rel="modulepreload" crossorigin href="/assets/useBrowserCaptureSession-TTO8vHtd.js">
247
+ <link rel="modulepreload" crossorigin href="/assets/useBrowserCaptureSession-DACautTD.js">
248
248
  <link rel="modulepreload" crossorigin href="/assets/arrow-up-right-9DoMn7fD.js">
249
249
  <link rel="modulepreload" crossorigin href="/assets/sparkles-DrY8hb0X.js">
250
250
  <link rel="modulepreload" crossorigin href="/assets/x-CBMI9ejn.js">
251
251
  <link rel="modulepreload" crossorigin href="/assets/useSharedWebSocket-DaPh7QBL.js">
252
- <link rel="modulepreload" crossorigin href="/assets/useDesktop-DUvS4fuP.js">
253
- <link rel="modulepreload" crossorigin href="/assets/MinimizedChatsContext-Gt6KQySM.js">
252
+ <link rel="modulepreload" crossorigin href="/assets/useDesktop-Cwyis9DX.js">
253
+ <link rel="modulepreload" crossorigin href="/assets/MinimizedChatsContext-BQQohTp2.js">
254
254
  <link rel="modulepreload" crossorigin href="/assets/external-link-BcU5EWZp.js">
255
255
  <link rel="modulepreload" crossorigin href="/assets/chevron-right-CViMxucY.js">
256
256
  <link rel="modulepreload" crossorigin href="/assets/circle-x-CMYsN4ZH.js">
@@ -274,11 +274,11 @@
274
274
  <link rel="modulepreload" crossorigin href="/assets/jira-api-BgFIr8eD.js">
275
275
  <link rel="modulepreload" crossorigin href="/assets/provider-capabilities-BvoExILy.js">
276
276
  <link rel="modulepreload" crossorigin href="/assets/lib-CGdyxIWg.js">
277
- <link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-CSCbUI4W.js">
278
- <link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-BjGwPf6I.js">
279
- <link rel="modulepreload" crossorigin href="/assets/spending-Dq43JYmD.js">
280
- <link rel="modulepreload" crossorigin href="/assets/TicketDetailModal-C-tHW-2s.js">
281
- <link rel="stylesheet" crossorigin href="/assets/index-Bz0TeGRs.css">
277
+ <link rel="modulepreload" crossorigin href="/assets/getTimezoneOffsetInMilliseconds-B_N-0uSl.js">
278
+ <link rel="modulepreload" crossorigin href="/assets/formatDistanceToNow-2bGOYubE.js">
279
+ <link rel="modulepreload" crossorigin href="/assets/spending-B20-uFLQ.js">
280
+ <link rel="modulepreload" crossorigin href="/assets/TicketDetailModal-DEoAJOuD.js">
281
+ <link rel="stylesheet" crossorigin href="/assets/index-ChIuonbC.css">
282
282
  </head>
283
283
  <body>
284
284
  <div id="specrails-splash" aria-hidden="false" role="status" aria-label="Loading Specrails">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.32.0",
3
+ "version": "2.33.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.InvalidSurfaceError = void 0;
4
4
  exports.recordInvocation = recordInvocation;
5
+ exports.sumInvocationCostForRuns = sumInvocationCostForRuns;
5
6
  exports.updateTicketIdForConversation = updateTicketIdForConversation;
6
7
  exports.listInvocationsForConversation = listInvocationsForConversation;
7
8
  exports.getTicketSpendingSummary = getTicketSpendingSummary;
@@ -45,6 +46,40 @@ function recordInvocation(db, input) {
45
46
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
46
47
  `).run(input.id, input.project_id, input.provider, input.surface, input.surface_ref_id ?? null, input.ticket_id ?? null, input.conversation_id ?? null, input.model ?? null, input.status, input.started_at, input.finished_at ?? null, input.duration_ms ?? null, input.duration_api_ms ?? null, input.tokens_in ?? null, input.tokens_out ?? null, input.tokens_cache_read ?? null, input.tokens_cache_create ?? null, input.total_cost_usd ?? null, input.total_cost_usd_estimated ? 1 : 0, input.num_turns ?? null, input.session_id ?? null, input.loop_run_id ?? null);
47
48
  }
49
+ /**
50
+ * Total AI spend attributable to a set of implementation runs, for the PR-body
51
+ * cost footer. A "run" id is either a loop_runs row id (loop AI-step/decider
52
+ * invocations link via `loop_run_id`) or a QueueManager job id (rows with
53
+ * surface='job' and `surface_ref_id` = the id, plus the merge-orchestrator's
54
+ * `<jobId>:merge:<step>` sub-rows). Failed/aborted rows count — they billed
55
+ * real tokens. Returns null when no priced rows match, so callers can omit
56
+ * the footer instead of printing $0.00 for legacy runs.
57
+ */
58
+ function sumInvocationCostForRuns(db, runIds) {
59
+ const ids = [...new Set(runIds.filter((id) => typeof id === 'string' && id.length > 0))];
60
+ if (ids.length === 0)
61
+ return null;
62
+ const placeholders = ids.map(() => '?').join(', ');
63
+ const mergePrefixes = ids.map(() => `surface_ref_id LIKE ? ESCAPE '\\'`).join(' OR ');
64
+ const rows = db.prepare(`SELECT total_cost_usd, total_cost_usd_estimated FROM ai_invocations
65
+ WHERE total_cost_usd IS NOT NULL
66
+ AND (loop_run_id IN (${placeholders})
67
+ OR surface_ref_id IN (${placeholders})
68
+ OR ${mergePrefixes})`).all(...ids, ...ids, ...ids.map((id) => `${escapeLikePattern(id)}:merge:%`));
69
+ if (rows.length === 0)
70
+ return null;
71
+ let totalUsd = 0;
72
+ let estimated = false;
73
+ for (const row of rows) {
74
+ totalUsd += row.total_cost_usd;
75
+ if (row.total_cost_usd_estimated === 1)
76
+ estimated = true;
77
+ }
78
+ return { totalUsd, estimated };
79
+ }
80
+ function escapeLikePattern(value) {
81
+ return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
82
+ }
48
83
  function updateTicketIdForConversation(db, conversationId, ticketId) {
49
84
  const result = db.prepare(`UPDATE ai_invocations SET ticket_id = ? WHERE conversation_id = ? AND ticket_id IS NULL`).run(ticketId, conversationId);
50
85
  return result.changes;
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeJobPhaseBreakdown = computeJobPhaseBreakdown;
4
+ const pricing_1 = require("./pricing");
5
+ function computeJobPhaseBreakdown(events, providerId = 'claude') {
6
+ const phases = new Map();
7
+ /** tool_use id → root phase id, for transitive nested-Task attribution. */
8
+ const rootOf = new Map();
9
+ const orchestrator = emptyTotals();
10
+ const unattributed = emptyTotals();
11
+ let sawUsage = false;
12
+ const totalsFor = (parentId) => {
13
+ if (!parentId)
14
+ return orchestrator;
15
+ const rootId = rootOf.get(parentId);
16
+ if (!rootId)
17
+ return unattributed;
18
+ return phases.get(rootId) ?? unattributed;
19
+ };
20
+ for (const ev of events) {
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(ev.payload);
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ const parentId = typeof parsed.parent_tool_use_id === 'string' ? parsed.parent_tool_use_id : null;
29
+ const message = parsed.message;
30
+ if (parsed.type === 'assistant' && message) {
31
+ // Register Task spawns FIRST so a same-event usage envelope can't
32
+ // attribute to a phase this very event opens.
33
+ for (const block of contentBlocks(message)) {
34
+ if (block.type !== 'tool_use' || block.name !== 'Task' || typeof block.id !== 'string')
35
+ continue;
36
+ const input = (block.input ?? {});
37
+ const rootId = parentId ? (rootOf.get(parentId) ?? null) : null;
38
+ if (rootId) {
39
+ // Nested Task: its events roll up into the ancestor phase.
40
+ rootOf.set(block.id, rootId);
41
+ }
42
+ else {
43
+ rootOf.set(block.id, block.id);
44
+ phases.set(block.id, {
45
+ ...emptyTotals(),
46
+ agent: typeof input.subagent_type === 'string' ? input.subagent_type : 'subagent',
47
+ toolUseId: block.id,
48
+ description: typeof input.description === 'string' ? input.description : null,
49
+ startedAt: ev.timestamp,
50
+ endedAt: null,
51
+ durationMs: null,
52
+ });
53
+ }
54
+ }
55
+ const usage = message.usage;
56
+ if (usage && typeof usage === 'object') {
57
+ sawUsage = true;
58
+ const target = totalsFor(parentId);
59
+ const tokensIn = num(usage.input_tokens);
60
+ const tokensOut = num(usage.output_tokens);
61
+ const cacheRead = num(usage.cache_read_input_tokens);
62
+ const cacheCreate = num(usage.cache_creation_input_tokens);
63
+ target.tokensIn += tokensIn;
64
+ target.tokensOut += tokensOut;
65
+ target.tokensCacheRead += cacheRead;
66
+ target.tokensCacheCreate += cacheCreate;
67
+ target.assistantEvents += 1;
68
+ const cost = (0, pricing_1.estimateCostUsd)(providerId, str(message.model), {
69
+ tokens_in: tokensIn,
70
+ tokens_out: tokensOut,
71
+ tokens_cache_read: cacheRead,
72
+ tokens_cache_create: cacheCreate,
73
+ });
74
+ if (cost !== null) {
75
+ target.estimatedCostUsd = (target.estimatedCostUsd ?? 0) + cost;
76
+ }
77
+ }
78
+ continue;
79
+ }
80
+ if (parsed.type === 'user' && message) {
81
+ // A tool_result closing a root Task block ends that phase.
82
+ for (const block of contentBlocks(message)) {
83
+ if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string')
84
+ continue;
85
+ const phase = phases.get(block.tool_use_id);
86
+ if (phase && phase.endedAt === null) {
87
+ phase.endedAt = ev.timestamp;
88
+ phase.durationMs = spanMs(phase.startedAt, ev.timestamp);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ if (!sawUsage)
94
+ return null;
95
+ const hasUnattributed = unattributed.assistantEvents > 0 || unattributed.tokensIn > 0 || unattributed.tokensOut > 0;
96
+ return {
97
+ phases: [...phases.values()],
98
+ orchestrator,
99
+ unattributed: hasUnattributed ? unattributed : null,
100
+ estimated: true,
101
+ };
102
+ }
103
+ // ─── Internals ────────────────────────────────────────────────────────────────
104
+ function emptyTotals() {
105
+ return {
106
+ tokensIn: 0,
107
+ tokensOut: 0,
108
+ tokensCacheRead: 0,
109
+ tokensCacheCreate: 0,
110
+ assistantEvents: 0,
111
+ estimatedCostUsd: null,
112
+ };
113
+ }
114
+ function contentBlocks(message) {
115
+ const content = message.content;
116
+ return Array.isArray(content) ? content : [];
117
+ }
118
+ function num(value) {
119
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
120
+ }
121
+ function str(value) {
122
+ return typeof value === 'string' ? value : null;
123
+ }
124
+ function spanMs(start, end) {
125
+ if (!start || !end)
126
+ return null;
127
+ const a = Date.parse(start);
128
+ const b = Date.parse(end);
129
+ if (Number.isNaN(a) || Number.isNaN(b) || b < a)
130
+ return null;
131
+ return b - a;
132
+ }
@@ -22,6 +22,7 @@ const spawn_lifecycle_1 = require("./spawn-lifecycle");
22
22
  const result_event_1 = require("./result-event");
23
23
  const loop_decider_1 = require("./loop-decider");
24
24
  const rail_isolation_1 = require("./rail-isolation");
25
+ const repo_map_1 = require("./repo-map");
25
26
  const feature_flags_1 = require("./feature-flags");
26
27
  const runtime_1 = require("./providers/runtime");
27
28
  // Per-step wall-clock caps so a single hung step can't block the engine's
@@ -95,7 +96,10 @@ function runShellCommand(command, cwd, env, timeoutMs, onLine, onSpawn) {
95
96
  */
96
97
  function aiStepEnv(env, repoDir) {
97
98
  const base = repoDir ? { ...env, SPECRAILS_REPO_DIR: repoDir } : env;
98
- return (0, rail_isolation_1.isRailPrDeliveryEnabled)() ? { ...base, SPECRAILS_GIT_AUTO: 'false' } : base;
99
+ // Deterministic repo map (zero-AI) so the pipeline's exploration phase
100
+ // starts oriented instead of spending its first turns on `ls`/`find`.
101
+ const withMap = (0, repo_map_1.injectRepoMapEnv)(base, repoDir);
102
+ return (0, rail_isolation_1.isRailPrDeliveryEnabled)() ? { ...withMap, SPECRAILS_GIT_AUTO: 'false' } : withMap;
99
103
  }
100
104
  /**
101
105
  * Relocated cwd is the workspace; the source repo is reached via the
@@ -79,8 +79,21 @@ function buildCanonicalPrBody(input) {
79
79
  const changesSection = buildChangesSection(input.changes ?? null);
80
80
  if (changesSection)
81
81
  parts.push(changesSection);
82
+ const costLine = buildCostFooter(input.costUsd ?? null);
83
+ if (costLine)
84
+ parts.push(costLine);
82
85
  return parts.join('\n\n');
83
86
  }
87
+ /** `> Specrails cost: $X.XX` — the transparency footer that keeps AI spend
88
+ * visible at the exact place the work is reviewed. `~` marks a total that
89
+ * includes rate-card estimates (providers without native cost, kill-path
90
+ * reconstructions). */
91
+ function buildCostFooter(cost) {
92
+ if (!cost || !Number.isFinite(cost.totalUsd) || cost.totalUsd <= 0)
93
+ return null;
94
+ const prefix = cost.estimated ? '~' : '';
95
+ return `> Specrails cost: ${prefix}$${cost.totalUsd.toFixed(2)}`;
96
+ }
84
97
  function summaryParagraph(input) {
85
98
  const n = input.tickets.length;
86
99
  const refs = input.tickets.map((t) => displayRef(t)).join(', ');
@@ -10,6 +10,7 @@ const db_1 = require("./db");
10
10
  const queue_manager_1 = require("./queue-manager");
11
11
  const feature_flags_1 = require("./feature-flags");
12
12
  const types_1 = require("./types");
13
+ const job_phase_breakdown_1 = require("./job-phase-breakdown");
13
14
  const providers_1 = require("./providers");
14
15
  const hooks_1 = require("./hooks");
15
16
  const explore_smash_1 = require("./explore-smash");
@@ -589,6 +590,21 @@ function registerJobsRoutes(deps) {
589
590
  res.json({ jobs });
590
591
  }
591
592
  });
593
+ // Per-phase cost/usage attribution, computed post-hoc from the persisted
594
+ // raw stream events (claude Task/parent_tool_use_id envelope). Costs are
595
+ // rate-card ESTIMATES and flagged as such; providers whose stream carries
596
+ // no per-event usage (codex/gemini/kimi) return breakdown: null.
597
+ router.get('/:projectId/jobs/:id/phase-breakdown', (req, res) => {
598
+ const { db } = ctx(req);
599
+ const job = db.prepare('SELECT id FROM jobs WHERE id = ?').get(req.params.id);
600
+ if (!job) {
601
+ res.status(404).json({ error: 'Job not found' });
602
+ return;
603
+ }
604
+ const events = db.prepare("SELECT event_type, payload, timestamp FROM events WHERE job_id = ? AND event_type IN ('assistant', 'user') ORDER BY seq ASC").all(req.params.id);
605
+ const breakdown = (0, job_phase_breakdown_1.computeJobPhaseBreakdown)(events);
606
+ res.json({ jobId: req.params.id, breakdown });
607
+ });
592
608
  // Must be registered BEFORE /:projectId/jobs/:id, otherwise Express matches
593
609
  // the parameterized route first with id='compare' and this never runs (the
594
610
  // Job Comparison feature would always 404).
@@ -15,6 +15,7 @@ const win_spawn_1 = require("./util/win-spawn");
15
15
  const types_1 = require("./types");
16
16
  const command_resolver_1 = require("./command-resolver");
17
17
  const rail_isolation_1 = require("./rail-isolation");
18
+ const repo_map_1 = require("./repo-map");
18
19
  const cli_prompt_1 = require("./util/cli-prompt");
19
20
  const stream_display_1 = require("./util/stream-display");
20
21
  const hooks_1 = require("./hooks");
@@ -2368,6 +2369,9 @@ class QueueManager {
2368
2369
  if (profileSnapshotPath) {
2369
2370
  spawnEnv = { ...spawnEnv, SPECRAILS_PROFILE_PATH: profileSnapshotPath };
2370
2371
  }
2372
+ // Deterministic repo map (zero-AI, best-effort) — orients the pipeline's
2373
+ // exploration phase so it doesn't spend its first turns on `ls`/`find`.
2374
+ spawnEnv = (0, repo_map_1.injectRepoMapEnv)(spawnEnv, execution.repoDir);
2371
2375
  // ─── Plugin resolution + snapshot ──────────────────────────────────────
2372
2376
  // Active = installed + verify ok; degraded = installed but verify failed
2373
2377
  // or timed out. Degraded does NOT block spawn — rail proceeds, UI gets
@@ -73,6 +73,7 @@ const rail_pr_delivery_1 = require("./rail-pr-delivery");
73
73
  const rail_worktree_release_1 = require("./rail-worktree-release");
74
74
  const pr_naming_1 = require("./pr-naming");
75
75
  const pr_body_1 = require("./pr-body");
76
+ const ai_invocations_1 = require("./ai-invocations");
76
77
  const jira_db_1 = require("./jira/jira-db");
77
78
  const rail_pr_store_1 = require("./rail-pr-store");
78
79
  const rail_worktrees_store_1 = require("./rail-worktrees-store");
@@ -1292,12 +1293,21 @@ async function runCreatePr(deps, row) {
1292
1293
  catch {
1293
1294
  changes = null;
1294
1295
  }
1296
+ // Cost footer is best-effort: a lookup failure omits the line, never blocks.
1297
+ let costUsd = null;
1298
+ try {
1299
+ costUsd = (0, ai_invocations_1.sumInvocationCostForRuns)(deps.db, snap.runIds);
1300
+ }
1301
+ catch {
1302
+ costUsd = null;
1303
+ }
1295
1304
  const title = (0, pr_naming_1.buildPrTitle)(ticketData, { loopName: row.loop_name });
1296
1305
  const body = (0, pr_body_1.buildCanonicalPrBody)({
1297
1306
  loopName: row.loop_name,
1298
1307
  baseBranch: row.base_branch,
1299
1308
  tickets: ticketData.map((t) => ({ ...t, branch: branchFor(t.ticketId) })),
1300
1309
  changes,
1310
+ costUsd,
1301
1311
  });
1302
1312
  let next;
1303
1313
  // Underlying git/gh failure detail for a degraded or failed delivery —
@@ -0,0 +1,293 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.buildRepoMap = buildRepoMap;
37
+ exports.formatRepoMap = formatRepoMap;
38
+ exports.isRepoMapEnabled = isRepoMapEnabled;
39
+ exports.ensureRepoMapFile = ensureRepoMapFile;
40
+ exports.injectRepoMapEnv = injectRepoMapEnv;
41
+ exports._clearRepoMapCache = _clearRepoMapCache;
42
+ const fs = __importStar(require("fs"));
43
+ const path = __importStar(require("path"));
44
+ const crypto = __importStar(require("crypto"));
45
+ const artifact_registry_1 = require("./artifact-registry");
46
+ const SKIP_DIRS = new Set([
47
+ 'node_modules', 'vendor', 'dist', 'build', 'out', 'coverage', 'target',
48
+ '__pycache__', 'venv', '.venv', 'Pods', 'DerivedData',
49
+ ]);
50
+ /** Manifest filename → ecosystem label. First match wins (iteration order). */
51
+ const MANIFESTS = [
52
+ ['package.json', 'node'],
53
+ ['pyproject.toml', 'python'],
54
+ ['setup.py', 'python'],
55
+ ['requirements.txt', 'python'],
56
+ ['go.mod', 'go'],
57
+ ['Cargo.toml', 'rust'],
58
+ ['pom.xml', 'jvm'],
59
+ ['build.gradle', 'jvm'],
60
+ ['build.gradle.kts', 'jvm'],
61
+ ['Package.swift', 'swift'],
62
+ ['Gemfile', 'ruby'],
63
+ ['composer.json', 'php'],
64
+ ];
65
+ const MAX_SERVICES = 40;
66
+ const README_MAX_LINES = 12;
67
+ const README_MAX_CHARS = 800;
68
+ /** File-count scan caps — the bucket is a rough size label, not a census. */
69
+ const SCAN_MAX_DEPTH = 2;
70
+ const SCAN_MAX_ENTRIES = 500;
71
+ function buildRepoMap(repoDir) {
72
+ const services = [];
73
+ const seen = new Set();
74
+ const addService = (relDir) => {
75
+ if (seen.has(relDir) || services.length >= MAX_SERVICES + 1)
76
+ return;
77
+ const abs = relDir === '' ? repoDir : path.join(repoDir, relDir);
78
+ const eco = detectEcosystem(abs);
79
+ if (!eco)
80
+ return;
81
+ seen.add(relDir);
82
+ services.push({
83
+ dir: relDir,
84
+ name: packageName(abs) ?? (relDir === '' ? path.basename(repoDir) : path.basename(relDir)),
85
+ ecosystem: eco,
86
+ sizeBucket: sizeBucket(abs),
87
+ hasReadme: readmePath(abs) !== null,
88
+ });
89
+ };
90
+ addService('');
91
+ for (const entry of listDirs(repoDir)) {
92
+ addService(entry);
93
+ }
94
+ // npm/yarn workspaces can nest packages one level below the top-level scan.
95
+ for (const rel of resolveNpmWorkspaces(repoDir)) {
96
+ addService(rel);
97
+ }
98
+ const truncated = services.length > MAX_SERVICES;
99
+ return {
100
+ readmeExcerpt: readReadmeExcerpt(repoDir),
101
+ services: services.slice(0, MAX_SERVICES),
102
+ truncated,
103
+ };
104
+ }
105
+ function formatRepoMap(map) {
106
+ const lines = ['# Repository map (deterministic — generated by Specrails, no AI)', ''];
107
+ if (map.readmeExcerpt) {
108
+ lines.push('## README excerpt', '', map.readmeExcerpt, '');
109
+ }
110
+ lines.push('## Packages');
111
+ if (map.services.length === 0) {
112
+ lines.push('- (no recognized package manifests found)');
113
+ }
114
+ for (const s of map.services) {
115
+ const dirLabel = s.dir === '' ? './' : `${s.dir}/`;
116
+ const readme = s.hasReadme ? 'has README' : 'no README';
117
+ lines.push(`- \`${dirLabel}\` — ${s.name} (${s.ecosystem}, ${s.sizeBucket}, ${readme})`);
118
+ }
119
+ if (map.truncated) {
120
+ lines.push(`- … more packages omitted (cap ${MAX_SERVICES})`);
121
+ }
122
+ lines.push('', 'Use this map to orient exploration without spending turns on top-level discovery.', '');
123
+ return lines.join('\n');
124
+ }
125
+ const cache = new Map();
126
+ const CACHE_TTL_MS = 60_000;
127
+ function isRepoMapEnabled() {
128
+ return process.env.SPECRAILS_REPO_MAP !== 'false';
129
+ }
130
+ /**
131
+ * Build (or reuse, 60s TTL) the on-disk map for a repo/worktree. Returns the
132
+ * file path, or null on any failure — the map is an accelerator, never a
133
+ * spawn blocker.
134
+ */
135
+ function ensureRepoMapFile(repoDir) {
136
+ try {
137
+ const real = fs.realpathSync(repoDir);
138
+ const hit = cache.get(real);
139
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS && fs.existsSync(hit.filePath)) {
140
+ return hit.filePath;
141
+ }
142
+ const content = formatRepoMap(buildRepoMap(real));
143
+ const dir = path.join((0, artifact_registry_1.resolveHome)(), '.specrails', 'repo-maps');
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ const filePath = path.join(dir, `${sha1(real)}.md`);
146
+ const tmp = `${filePath}.tmp-${process.pid}`;
147
+ fs.writeFileSync(tmp, content, 'utf8');
148
+ fs.renameSync(tmp, filePath);
149
+ cache.set(real, { filePath, at: Date.now() });
150
+ return filePath;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ /** Add `SPECRAILS_REPO_MAP_PATH` to a spawn env. Never throws, never blocks. */
157
+ function injectRepoMapEnv(env, repoDir) {
158
+ if (!repoDir || !isRepoMapEnabled())
159
+ return env;
160
+ const filePath = ensureRepoMapFile(repoDir);
161
+ return filePath ? { ...env, SPECRAILS_REPO_MAP_PATH: filePath } : env;
162
+ }
163
+ /** Test hook — clears the TTL cache. */
164
+ function _clearRepoMapCache() {
165
+ cache.clear();
166
+ }
167
+ // ─── Internals ────────────────────────────────────────────────────────────────
168
+ function sha1(value) {
169
+ return crypto.createHash('sha1').update(value).digest('hex');
170
+ }
171
+ function listDirs(dir) {
172
+ let entries;
173
+ try {
174
+ entries = fs.readdirSync(dir, { withFileTypes: true });
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ return entries
180
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name))
181
+ .map((e) => e.name)
182
+ .sort();
183
+ }
184
+ function detectEcosystem(dir) {
185
+ for (const [manifest, eco] of MANIFESTS) {
186
+ if (fs.existsSync(path.join(dir, manifest)))
187
+ return eco;
188
+ }
189
+ return null;
190
+ }
191
+ function packageName(dir) {
192
+ try {
193
+ const raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8');
194
+ const parsed = JSON.parse(raw);
195
+ return typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name.trim() : null;
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ }
201
+ function readmePath(dir) {
202
+ for (const name of ['README.md', 'readme.md', 'README.txt', 'README']) {
203
+ const p = path.join(dir, name);
204
+ if (fs.existsSync(p))
205
+ return p;
206
+ }
207
+ return null;
208
+ }
209
+ function readReadmeExcerpt(repoDir) {
210
+ const p = readmePath(repoDir);
211
+ if (!p)
212
+ return null;
213
+ try {
214
+ const lines = fs.readFileSync(p, 'utf8').split('\n').slice(0, README_MAX_LINES);
215
+ const text = lines.join('\n').trim();
216
+ if (!text)
217
+ return null;
218
+ return text.length > README_MAX_CHARS ? `${text.slice(0, README_MAX_CHARS)}…` : text;
219
+ }
220
+ catch {
221
+ return null;
222
+ }
223
+ }
224
+ /** Rough size label from a capped, shallow file count. */
225
+ function sizeBucket(dir) {
226
+ let count = 0;
227
+ const walk = (d, depth) => {
228
+ if (depth > SCAN_MAX_DEPTH || count > SCAN_MAX_ENTRIES)
229
+ return;
230
+ let entries;
231
+ try {
232
+ entries = fs.readdirSync(d, { withFileTypes: true });
233
+ }
234
+ catch {
235
+ return;
236
+ }
237
+ for (const e of entries) {
238
+ if (count > SCAN_MAX_ENTRIES)
239
+ return;
240
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name))
241
+ continue;
242
+ if (e.isDirectory()) {
243
+ walk(path.join(d, e.name), depth + 1);
244
+ }
245
+ else {
246
+ count++;
247
+ }
248
+ }
249
+ };
250
+ walk(dir, 0);
251
+ if (count < 20)
252
+ return 'small';
253
+ if (count < 150)
254
+ return 'medium';
255
+ return 'large';
256
+ }
257
+ /**
258
+ * Resolve npm/yarn `workspaces` from the root package.json. Literal paths and
259
+ * trailing `/*` globs only — deliberately no glob library; anything fancier
260
+ * falls back to the top-level scan.
261
+ */
262
+ function resolveNpmWorkspaces(repoDir) {
263
+ let patterns = [];
264
+ try {
265
+ const raw = fs.readFileSync(path.join(repoDir, 'package.json'), 'utf8');
266
+ const parsed = JSON.parse(raw);
267
+ const ws = parsed.workspaces;
268
+ if (Array.isArray(ws)) {
269
+ patterns = ws.filter((w) => typeof w === 'string');
270
+ }
271
+ else if (ws && typeof ws === 'object' && Array.isArray(ws.packages)) {
272
+ patterns = (ws.packages).filter((w) => typeof w === 'string');
273
+ }
274
+ }
275
+ catch {
276
+ return [];
277
+ }
278
+ const out = [];
279
+ for (const pattern of patterns) {
280
+ const clean = pattern.replace(/^\.\//, '');
281
+ if (clean.endsWith('/*')) {
282
+ const base = clean.slice(0, -2);
283
+ const abs = path.join(repoDir, base);
284
+ for (const child of listDirs(abs)) {
285
+ out.push(path.join(base, child));
286
+ }
287
+ }
288
+ else if (!clean.includes('*')) {
289
+ out.push(clean);
290
+ }
291
+ }
292
+ return out;
293
+ }