@worca/app 1.0.0 → 1.2.0-rc.1

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 (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -15,7 +15,7 @@
15
15
  import { getDb, prepare, tx } from './db.mjs';
16
16
  import { projectKey } from './store.mjs';
17
17
  import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
18
- import { EFFORTS, prepareModelEnv } from './model-env.mjs';
18
+ import { EFFORTS, prepareModelEnv, isSubagentModelValue, subagentModelIssue } from './model-env.mjs';
19
19
  import { listGlobalModels, addGlobalModel, removeGlobalModel } from './settings.mjs';
20
20
  import { listPluginModels, allPluginModels, flattenPluginModelEnv } from './plugin-models.mjs';
21
21
 
@@ -57,19 +57,23 @@ export { EFFORTS };
57
57
  * The `[1m]` suffix selects the 1M-token long-context variant. Opus 4.6–4.8 and
58
58
  * Sonnet 4.6 1M ids were verified to resolve via `claude --model`; Haiku 4.5 1M
59
59
  * is intentionally omitted — the CLI rejects it ("long context beta is not yet
60
- * available for this subscription"). Fable 5 needs no `[1m]` suffix: its context
61
- * window is 1M by default (verified to resolve via `claude --model`). Opus 5
62
- * (`claude-opus-5`) is likewise 1M-only and carries no `[1m]` twin.
60
+ * available for this subscription"). Fable 5.1 needs no `[1m]` suffix: its context
61
+ * window is 1M by default (verified to resolve via `claude --model`, CLI 2.1.257).
62
+ * It replaced Fable 5 (`claude-fable-5`) on 2026-09-01; db.mjs V26 moves every
63
+ * stored pin on the retired id to the successor, so nothing keeps it here. Opus 5
64
+ * (`claude-opus-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
65
+ * carry no `[1m]` twin.
63
66
  */
64
67
  export const PREDEFINED_MODELS = [
65
68
  { id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
66
- { id: 'claude-fable-5', label: 'Fable 5 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
69
+ { id: 'claude-fable-5-1', label: 'Fable 5.1 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
67
70
  { id: 'claude-opus-4-8', label: 'Opus 4.8', efforts: ['medium', 'high', 'xhigh', 'max'] },
68
71
  { id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
69
72
  { id: 'claude-opus-4-7', label: 'Opus 4.7', efforts: ['medium', 'high', 'xhigh', 'max'] },
70
73
  { id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
71
74
  { id: 'claude-opus-4-6', label: 'Opus 4.6', efforts: ['medium', 'high', 'max'] },
72
75
  { id: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
76
+ { id: 'claude-sonnet-5', label: 'Sonnet 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
73
77
  { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', efforts: ['medium', 'high', 'max'] },
74
78
  { id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
75
79
  { id: 'claude-haiku-4-5', label: 'Haiku 4.5', efforts: ['medium', 'high'] },
@@ -84,24 +88,17 @@ function defaultConfig() {
84
88
  return { steps: {}, customModels: [] };
85
89
  }
86
90
 
87
- /** Keep only known step keys carrying a non-empty model/effort and/or a fanOut/askQuestions boolean. */
91
+ /** Keep only known step keys whose entry survives cleanNodeSel ONE coercion
92
+ * rule for both scopes (a per-step entry IS a node selection; a second
93
+ * line-for-line copy here is how the two silently diverge). An unknown
94
+ * subagentModel is dropped, never stored. */
88
95
  function sanitizeSteps(steps) {
89
96
  const out = {};
90
97
  const keys = stepKeys();
91
98
  for (const [k, v] of Object.entries(steps || {})) {
92
99
  if (!keys.has(k) || !v || typeof v !== 'object') continue;
93
- const model = typeof v.model === 'string' ? v.model.trim() : '';
94
- const effort = typeof v.effort === 'string' ? v.effort.trim() : '';
95
- const fanOut = typeof v.fanOut === 'boolean' ? v.fanOut : undefined;
96
- const askQuestions = typeof v.askQuestions === 'boolean' ? v.askQuestions : undefined;
97
- if (model || effort || fanOut !== undefined || askQuestions !== undefined) {
98
- out[k] = {
99
- ...(model && { model }),
100
- ...(effort && { effort }),
101
- ...(fanOut !== undefined && { fanOut }),
102
- ...(askQuestions !== undefined && { askQuestions }),
103
- };
104
- }
100
+ const sel = cleanNodeSel(v); // hoisted declaration (defined below)
101
+ if (sel) out[k] = sel;
105
102
  }
106
103
  return out;
107
104
  }
@@ -172,7 +169,10 @@ export async function readConfig(projectDir) {
172
169
  * `custom` is false | 'global' | 'plugin' | 'project' (strings truthy, so
173
170
  * existing `m.custom` checks keep working); plugin entries also carry
174
171
  * `plugin: '<name>'`; `hasEnv` advertises routing env WITHOUT the values
175
- * (this shape feeds UI dropdowns).
172
+ * (this shape feeds UI dropdowns). `routed` advertises an ANTHROPIC_BASE_URL
173
+ * override the same way (key presence, values never leaked) — computed from the
174
+ * env objects already in scope here, NEVER by calling modelHasBaseUrlRouting per
175
+ * entry (that re-reads settings + the plugins lock from disk on every row).
176
176
  */
177
177
  function composeCatalog(projectCustom = []) {
178
178
  const globals = listGlobalModels();
@@ -183,26 +183,27 @@ function composeCatalog(projectCustom = []) {
183
183
  const out = [];
184
184
  const seen = new Set();
185
185
  const unreliable = (lc) => (flagged.has(lc) ? { costUnreliable: true } : {});
186
+ const routedOf = (env) => !!(env && 'ANTHROPIC_BASE_URL' in env);
186
187
  const pluginShape = (id, m, lc) => ({
187
188
  id, label: m.label, efforts: [...m.efforts], custom: 'plugin', plugin: m.plugin,
188
- hasEnv: !!m.env, ...unreliable(lc),
189
+ hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc),
189
190
  });
190
191
  for (const m of PREDEFINED_MODELS) {
191
192
  const lc = m.id.toLowerCase();
192
193
  const shadow = globalByIdLc.get(lc);
193
194
  const pshadow = pluginByIdLc.get(lc);
194
195
  out.push(shadow
195
- ? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, ...unreliable(lc) }
196
+ ? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, routed: routedOf(shadow.env), ...unreliable(lc) }
196
197
  : pshadow
197
198
  ? pluginShape(m.id, pshadow, lc)
198
- : { ...m, custom: false, hasEnv: false });
199
+ : { ...m, custom: false, hasEnv: false, routed: false });
199
200
  seen.add(lc);
200
201
  }
201
202
  for (const m of globals) {
202
203
  const lc = m.id.toLowerCase();
203
204
  if (seen.has(lc)) continue; // predefined shadow, already emitted
204
205
  seen.add(lc);
205
- out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, ...unreliable(lc) });
206
+ out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc) });
206
207
  }
207
208
  for (const m of plugins) {
208
209
  const lc = m.id.toLowerCase();
@@ -213,7 +214,7 @@ function composeCatalog(projectCustom = []) {
213
214
  for (const m of projectCustom) {
214
215
  if (seen.has(m.id.toLowerCase())) continue; // predefined/global/plugin wins
215
216
  seen.add(m.id.toLowerCase());
216
- out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false });
217
+ out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false, routed: false });
217
218
  }
218
219
  return out;
219
220
  }
@@ -258,11 +259,24 @@ export function costUnreliableModelIds() {
258
259
  * @param {string} modelId
259
260
  * @param {number|null} costUsd the reported cost (null when absent)
260
261
  * @param {object} [usage]
262
+ * @param {object|null} [costCfg] this model's already-looked-up cost override
263
+ * (modelCostConfig). Pass it when the caller has one in hand — every lookup is
264
+ * a fresh settings.json read (settings.mjs is deliberately uncached), and the
265
+ * result path needs the same answer for resolveModelCost. `undefined` = look
266
+ * it up here; `null` = "checked, there is none".
261
267
  * @returns {'flagged'|'cleared'|null} what changed — 'flagged' asks the caller
262
268
  * to surface its one-per-run warning; null = no observation recorded
263
269
  */
264
- export function observeModelCost(modelId, costUsd, usage) {
270
+ export function observeModelCost(modelId, costUsd, usage, costCfg = undefined) {
265
271
  if (!modelHasBaseUrlRouting(modelId)) return null;
272
+ // An explicit per-model cost override GOVERNS this model's spend (resolveModelCost
273
+ // below) — the CLI's own figure is never trusted for it, so the "unreliable"
274
+ // badge is meaningless. Never flag it, and lift any flag left from before the
275
+ // override existed. Derived state: a DB hiccup here must never fail the run.
276
+ if (costCfg !== undefined ? costCfg : modelCostConfig(modelId)) {
277
+ try { prepare('DELETE FROM model_cost_flags WHERE model_id = ?').run(String(modelId).trim()); } catch { /* derived */ }
278
+ return null;
279
+ }
266
280
  const u = usage && typeof usage === 'object' ? usage : {};
267
281
  const tokens = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']
268
282
  .reduce((n, k) => n + (Number(u[k]) || 0), 0);
@@ -284,6 +298,164 @@ export function observeModelCost(modelId, costUsd, usage) {
284
298
  return null; // no cost AND no tokens (e.g. an errored run) — no signal either way
285
299
  }
286
300
 
301
+ // ── per-model cost override (opt-in) ──────────────────────────────────────────
302
+ // The Claude CLI computes total_cost_usd from its OWN per-model price table keyed
303
+ // on the model NAME — so an on-prem/proxied endpoint (even one that returns no
304
+ // cost) still gets a fabricated dollar figure, which observeModelCost cannot
305
+ // distinguish from a real one once it is positive. A user who KNOWS a model's
306
+ // real price (or that it is free) can pin it in the GLOBAL catalog; that override
307
+ // then wins over whatever the CLI reports. Inspired by worca 0.x's cost_alias /
308
+ // worca.pricing.models mechanism. Opt-in: with no override the CLI value stands.
309
+ //
310
+ // It governs EVERY surface that books spend, because they share one windowed
311
+ // budget (cost-budget.mjs combinedWindowedSpendUsd): the orchestrator's result
312
+ // intake and sub-agent telemetry (orchestrator.mjs), an Ask Worca turn (the
313
+ // `resolveCost` hook ask/turn.mjs injects into the reducer), and the overview
314
+ // agent's telemetry row. Re-pricing only some of them would leave the phantom
315
+ // spend this exists to remove still inflating the budget from the others.
316
+
317
+ /** The explicit cost override governing a model, or null. Shape: {free:true} |
318
+ * {perMtok:{input?,output?,cacheRead?,cacheWrite?,cacheWrite1h?}} (USD per
319
+ * million tokens). Resolved with the SAME precedence as the rest of a model's
320
+ * configuration (§9.3, mirroring modelHasBaseUrlRouting): the user's GLOBAL
321
+ * entry wins outright, else the winning PLUGIN entry's manifest price — a
322
+ * plugin shipping a model on its own endpoint is exactly the case that needs
323
+ * one. Note a global entry shadows the plugin's price even when it pins none:
324
+ * taking over a model id means owning its pricing too, so the two layers can
325
+ * never half-merge. Built-ins carry none. Never throws. */
326
+ export function modelCostConfig(modelId) {
327
+ const id = typeof modelId === 'string' ? modelId.trim() : '';
328
+ if (!id) return null;
329
+ const lc = id.toLowerCase();
330
+ const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
331
+ if (entry) return entry.cost ?? null;
332
+ const pm = listPluginModels().find((m) => m.id.toLowerCase() === lc);
333
+ return pm?.cost ?? null;
334
+ }
335
+
336
+ /** The four token classes read off a usage object, accepting BOTH spellings in
337
+ * play here: the RAW Claude result usage (`input_tokens`, …) that the pipeline
338
+ * path carries, and Ask Worca's normalized persisted shape (`input`, `output`,
339
+ * `cacheRead`, `cacheCreation` — ask/events.mjs normalizeUsage). Same tokens,
340
+ * two names; a missing field counts as 0. Only the raw shape ever carries the
341
+ * ephemeral cache-creation breakdown. */
342
+ function usageTokens(u) {
343
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
344
+ const pick = (snake, camel) => (u[snake] != null ? num(u[snake]) : num(u[camel]));
345
+ const cc = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
346
+ return {
347
+ input: pick('input_tokens', 'input'),
348
+ output: pick('output_tokens', 'output'),
349
+ cacheRead: pick('cache_read_input_tokens', 'cacheRead'),
350
+ cacheWrite: pick('cache_creation_input_tokens', 'cacheCreation'),
351
+ eph1h: cc ? num(cc.ephemeral_1h_input_tokens) : 0,
352
+ eph5m: cc ? num(cc.ephemeral_5m_input_tokens) : 0,
353
+ };
354
+ }
355
+
356
+ /** True when `usage` is an object that actually reports token counts — i.e. it
357
+ * can be priced. A result event that carried NO usage at all is unpriceable and
358
+ * must not be silently booked at $0 (resolveModelCost returns NaN for it); an
359
+ * object whose counts are genuinely all zero IS priceable, at $0. */
360
+ export function isPriceableUsage(usage) {
361
+ if (!usage || typeof usage !== 'object') return false;
362
+ return ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens',
363
+ 'cache_creation', 'input', 'output', 'cacheRead', 'cacheCreation'].some((k) => usage[k] != null);
364
+ }
365
+
366
+ /** Estimate USD from a `usage` object (either spelling, see usageTokens) and a
367
+ * per-million-token rate table (mirrors worca 0.x estimate_cost). Absent rates/
368
+ * fields count as 0. When the CLI breaks cache-creation into ephemeral 1h/5m
369
+ * buckets they are priced separately (1h falls back to the cacheWrite rate);
370
+ * otherwise the flat cache_creation_input_tokens total is priced at cacheWrite. */
371
+ export function estimateCost(usage, perMtok) {
372
+ if (!perMtok || typeof perMtok !== 'object') return 0;
373
+ const u = usage && typeof usage === 'object' ? usage : {};
374
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
375
+ const rate = (k) => num(perMtok[k]);
376
+ const t = usageTokens(u);
377
+ const cacheWriteCost = (t.eph1h || t.eph5m)
378
+ ? (t.eph5m * rate('cacheWrite')
379
+ + t.eph1h * (perMtok.cacheWrite1h != null ? rate('cacheWrite1h') : rate('cacheWrite'))) / 1e6
380
+ : t.cacheWrite * rate('cacheWrite') / 1e6;
381
+ return (
382
+ t.input * rate('input')
383
+ + t.output * rate('output')
384
+ + t.cacheRead * rate('cacheRead')
385
+ ) / 1e6 + cacheWriteCost;
386
+ }
387
+
388
+ /**
389
+ * The AUTHORITATIVE cost for a dispatched model: the CLI's own figure unless the
390
+ * model carries an explicit override, which then wins. {free} → $0; {perMtok} →
391
+ * recomputed from `usage`. This is what stops a CLI that prices an on-prem model
392
+ * by name from inflating the ledger. With no override, `cliCostUsd` is returned
393
+ * verbatim (finite or not — the caller already gates on Number.isFinite).
394
+ *
395
+ * ONLY call this on a genuinely cost-bearing event. A {free} model answers 0 for
396
+ * ANY input, so feeding it a non-result stream frame (whose `cliCostUsd` is NaN)
397
+ * would turn "nothing to record" into a real $0 the caller then books.
398
+ *
399
+ * A {perMtok} model is priced from tokens ALONE, so a result that carried no
400
+ * usage object at all is UNPRICEABLE: NaN comes back rather than a silent $0, so
401
+ * the caller's existing "no cost estimate" branch reports it instead of the
402
+ * operator quietly under-billing a model they explicitly asked to be priced.
403
+ * @param {string} modelId the dispatched model id
404
+ * @param {number} cliCostUsd the cost the CLI reported (may be NaN)
405
+ * @param {object} [usage] the result event's usage object (either spelling)
406
+ * @param {object|null} [costCfg] this model's already-looked-up cost override;
407
+ * `undefined` = look it up here (see observeModelCost's note on sharing it)
408
+ * @returns {number}
409
+ */
410
+ export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined) {
411
+ const cost = costCfg !== undefined ? costCfg : modelCostConfig(modelId);
412
+ if (!cost) return cliCostUsd;
413
+ if (cost.free) return 0;
414
+ if (cost.perMtok) return isPriceableUsage(usage) ? estimateCost(usage, cost.perMtok) : NaN;
415
+ return cliCostUsd;
416
+ }
417
+
418
+ // ── display-only list prices ──────────────────────────────────────────────────
419
+ // USD per MILLION tokens for the built-in ids, from Anthropic's published
420
+ // pricing (platform.claude.com/docs/en/pricing — snapshot 2026-06-24). DISPLAY
421
+ // APPROXIMATION ONLY: it feeds the chat footer's live "≈" estimate while a turn
422
+ // streams (ask/events.mjs `estimatedCostUsd`). The CLI's result.total_cost_usd,
423
+ // re-priced by resolveModelCost, stays the ONLY figure any message row, thread
424
+ // total, ledger or budget ever books — nothing here is read by those paths.
425
+ // Ids missing here get no estimate (null), which is the pre-existing behaviour;
426
+ // `[1m]` twins and dated ids resolve to their base row (the long-context premium
427
+ // is not modelled). cacheWrite = 1.25× input (5-minute TTL), cacheWrite1h = 2×
428
+ // input, cacheRead = 0.1× input except Fable 5.1 (0.025×). Refresh by hand when
429
+ // Anthropic moves a price. PREDEFINED_MODELS itself stays untouched — its entry
430
+ // shape is pinned (test/config-models-global.test.mjs:205).
431
+ export const PREDEFINED_LIST_PRICES = Object.freeze({
432
+ 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite1h: 20 },
433
+ 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
434
+ 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
435
+ 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
436
+ 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
437
+ 'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheWrite1h: 4 },
438
+ 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheWrite1h: 6 },
439
+ 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheWrite1h: 2 },
440
+ });
441
+
442
+ const FREE_RATES = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0 });
443
+
444
+ /** The per-Mtok rates a DISPLAY estimate may price `modelId` with: the operator's
445
+ * modelCostConfig override when one exists ({free} → all-zero rates, so a free
446
+ * model estimates $0 instead of a list price), else the built-in list price,
447
+ * else null (no estimate). Never throws. */
448
+ export function liveCostRates(modelId) {
449
+ const id = typeof modelId === 'string' ? modelId.trim() : '';
450
+ if (!id) return null;
451
+ let cfg = null;
452
+ try { cfg = modelCostConfig(id); } catch { cfg = null; }
453
+ if (cfg && cfg.free === true) return FREE_RATES;
454
+ if (cfg && cfg.perMtok && typeof cfg.perMtok === 'object') return cfg.perMtok;
455
+ const base = id.toLowerCase().replace(/\[1m\]$/, '').replace(/-\d{8}$/, '');
456
+ return PREDEFINED_LIST_PRICES[base] ?? null;
457
+ }
458
+
287
459
  /**
288
460
  * All selectable models for a project = the effective catalog (predefined ⊕
289
461
  * global ⊕ this project's legacy custom models). Legacy custom models
@@ -388,6 +560,27 @@ function inheritOr(next, prev) {
388
560
  return typeof prev === 'boolean' ? prev : undefined;
389
561
  }
390
562
 
563
+ /**
564
+ * Tri-state resolution for the STRING tunable (subagentModel), shared by setStep
565
+ * and setNodeModel. It is preserve-on-absent rather than replace-like model/effort
566
+ * on purpose: the field arrived after the write APIs shipped, so an older client
567
+ * (or any caller that only means to change the model) POSTs without it and must
568
+ * not silently wipe a configured sub-agent policy.
569
+ * a valid value -> that value
570
+ * '' or null -> undefined = cleared (an explicit "inherit again")
571
+ * absent -> the previous value
572
+ * An unknown non-empty string is treated as absent: the enum is validated at the
573
+ * API boundary, and a typo must not clear a working setting.
574
+ * @param {unknown} next
575
+ * @param {unknown} prev
576
+ * @returns {string|undefined}
577
+ */
578
+ function inheritOrSubagentModel(next, prev) {
579
+ if (isSubagentModelValue(next)) return next;
580
+ if (next === null || next === '') return undefined;
581
+ return isSubagentModelValue(prev) ? prev : undefined;
582
+ }
583
+
391
584
  /**
392
585
  * Set (or clear) the model + effort for one agent step. An empty model => inherit
393
586
  * the global/CLI default; an empty effort => model default. Effort must be supported
@@ -411,6 +604,11 @@ export async function setStep(projectDir, step, selection = {}) {
411
604
  }
412
605
  }
413
606
 
607
+ {
608
+ const issue = subagentModelIssue(selection.subagentModel);
609
+ if (issue) throw new Error(issue);
610
+ }
611
+
414
612
  const key = projectKey(projectDir);
415
613
  const cfg = readRaw(projectDir);
416
614
  const prev = cfg.steps[step] || {};
@@ -422,12 +620,16 @@ export async function setStep(projectDir, step, selection = {}) {
422
620
  // askQuestions mirrors fanOut: preserved when omitted (only the toggle sends
423
621
  // it), set when a boolean (spec 2026-07-11 §4), cleared on null.
424
622
  const askQuestions = inheritOr(selection.askQuestions, prev.askQuestions);
623
+ // subagentModel: the sub-agent model policy for a fan-out node. Preserved when
624
+ // omitted (see inheritOrSubagentModel), cleared on '' / null.
625
+ const subagentModel = inheritOrSubagentModel(selection.subagentModel, prev.subagentModel);
425
626
 
426
627
  const steps = { ...cfg.steps };
427
- if (!model && !effort && fanOut === undefined && askQuestions === undefined) delete steps[step];
628
+ if (!model && !effort && !subagentModel && fanOut === undefined && askQuestions === undefined) delete steps[step];
428
629
  else steps[step] = {
429
630
  ...(model && { model }),
430
631
  ...(effort && { effort }),
632
+ ...(subagentModel && { subagentModel }),
431
633
  ...(fanOut !== undefined && { fanOut }),
432
634
  ...(askQuestions !== undefined && { askQuestions }),
433
635
  };
@@ -523,16 +725,19 @@ export async function promoteCustomModel(projectDir, id) {
523
725
  // nested shape from those rows. activeWorkflowId is project_config.active_workflow_id;
524
726
  // unknown top-level keys (e.g. webUiTesting) round-trip via project_config.extra.
525
727
 
526
- /** Coerce a per-node selection to a clean {model?,effort?,fanOut?,askQuestions?} or null (all empty). */
728
+ /** Coerce a per-node selection to a clean {model?,effort?,subagentModel?,fanOut?,askQuestions?}
729
+ * or null (all empty). */
527
730
  function cleanNodeSel(selection) {
528
731
  const model = typeof selection?.model === 'string' ? selection.model.trim() : '';
529
732
  const effort = typeof selection?.effort === 'string' ? selection.effort.trim() : '';
733
+ const subagentModel = isSubagentModelValue(selection?.subagentModel) ? selection.subagentModel : '';
530
734
  const fanOut = typeof selection?.fanOut === 'boolean' ? selection.fanOut : undefined;
531
735
  const askQuestions = typeof selection?.askQuestions === 'boolean' ? selection.askQuestions : undefined;
532
- if (!model && !effort && fanOut === undefined && askQuestions === undefined) return null;
736
+ if (!model && !effort && !subagentModel && fanOut === undefined && askQuestions === undefined) return null;
533
737
  return {
534
738
  ...(model && { model }),
535
739
  ...(effort && { effort }),
740
+ ...(subagentModel && { subagentModel }),
536
741
  ...(fanOut !== undefined && { fanOut }),
537
742
  ...(askQuestions !== undefined && { askQuestions }),
538
743
  };
@@ -547,17 +752,23 @@ function cleanNodeSel(selection) {
547
752
  */
548
753
  function readWorkflowsMap(key) {
549
754
  getDb();
550
- const workflows = {};
755
+ // NULL-prototype accumulator, deliberately: a stored workflow_id of
756
+ // '__proto__' would resolve truthy to Object.prototype on a plain `{}` and the
757
+ // next `.wires[id] =` would throw FOREVER for that project (MAJ-1). With no
758
+ // prototype, `workflows['__proto__'] = ...` is an ordinary own property, so a
759
+ // poisoned row degrades to a visible junk entry instead of a permanent 500.
760
+ const workflows = Object.create(null);
551
761
  const ensure = (wf) => {
552
- if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {} };
762
+ if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {}, wires: {} };
553
763
  return workflows[wf];
554
764
  };
555
765
  for (const r of prepare(
556
- 'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ?'
766
+ 'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions, subagent_model FROM config_workflow_nodes WHERE project_key = ?'
557
767
  ).all(key)) {
558
768
  const sel = {};
559
769
  if (r.model) sel.model = r.model;
560
770
  if (r.effort) sel.effort = r.effort;
771
+ if (isSubagentModelValue(r.subagent_model)) sel.subagentModel = r.subagent_model;
561
772
  if (r.fan_out !== null && r.fan_out !== undefined) sel.fanOut = !!r.fan_out;
562
773
  if (r.ask_questions !== null && r.ask_questions !== undefined) sel.askQuestions = !!r.ask_questions;
563
774
  // Only attach a node entry that carries something (matches cleanNodeSel output).
@@ -568,7 +779,17 @@ function readWorkflowsMap(key) {
568
779
  ).all(key)) {
569
780
  ensure(r.workflow_id).feedbacks[r.fb_id] = { maxCycles: r.max_cycles };
570
781
  }
571
- return workflows;
782
+ // v23: per-loop-wire budgets (the graph twin of config_workflow_feedbacks).
783
+ for (const r of prepare(
784
+ 'SELECT workflow_id, wire_id, max_cycles FROM config_workflow_wires WHERE project_key = ?'
785
+ ).all(key)) {
786
+ ensure(r.workflow_id).wires[r.wire_id] = { maxCycles: r.max_cycles };
787
+ }
788
+ // Hand callers an ORDINARY object: spread copies with CreateDataProperty, so a
789
+ // '__proto__' key stays an own enumerable property (a plain assignment would
790
+ // have hit the setter and silently vanished) while the public shape — what
791
+ // JSON.stringify and every deepEqual in the suite see — is unchanged.
792
+ return { ...workflows };
572
793
  }
573
794
 
574
795
  /**
@@ -598,8 +819,8 @@ export async function readRunConfig(projectDir) {
598
819
  }
599
820
 
600
821
  /**
601
- * Set (or clear) the model+effort+fanOut+askQuestions for one node instance of a
602
- * workflow. A cleaned selection of null (all blank) deletes the row. fanOut and
822
+ * Set (or clear) the model+effort+subagentModel+fanOut+askQuestions for one node
823
+ * instance of a workflow. A cleaned selection of null (all blank) deletes the row. fanOut and
603
824
  * askQuestions are preserved when the caller omits them (read from the existing
604
825
  * row), set when a boolean, and cleared on an explicit null. Writes only the config_workflow_nodes table
605
826
  * (legacy view + extra untouched). Model/effort validate against the effective
@@ -609,7 +830,7 @@ export async function readRunConfig(projectDir) {
609
830
  * @param {string} projectDir
610
831
  * @param {string} workflowId
611
832
  * @param {string} nodeId
612
- * @param {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}} selection
833
+ * @param {{model?:string,effort?:string,subagentModel?:string,fanOut?:boolean,askQuestions?:boolean}} selection
613
834
  * @returns {Promise<void>}
614
835
  */
615
836
  export async function setNodeModel(projectDir, workflowId, nodeId, selection = {}) {
@@ -626,16 +847,22 @@ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {
626
847
  }
627
848
  }
628
849
 
850
+ {
851
+ const issue = subagentModelIssue(selection.subagentModel);
852
+ if (issue) throw new Error(issue);
853
+ }
854
+
629
855
  const key = projectKey(projectDir);
630
856
  getDb();
631
857
  const prev = prepare(
632
- 'SELECT fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
858
+ 'SELECT fan_out, ask_questions, subagent_model FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
633
859
  ).get(key, workflowId, nodeId);
634
860
  const prevFanOut = prev && prev.fan_out !== null && prev.fan_out !== undefined ? !!prev.fan_out : undefined;
635
861
  const fanOut = inheritOr(selection.fanOut, prevFanOut);
636
862
  const prevAsk = prev && prev.ask_questions !== null && prev.ask_questions !== undefined ? !!prev.ask_questions : undefined;
637
863
  const askQuestions = inheritOr(selection.askQuestions, prevAsk);
638
- const sel = cleanNodeSel({ model: selection.model, effort: selection.effort, fanOut, askQuestions });
864
+ const subagentModel = inheritOrSubagentModel(selection.subagentModel, prev && prev.subagent_model);
865
+ const sel = cleanNodeSel({ model: selection.model, effort: selection.effort, subagentModel, fanOut, askQuestions });
639
866
 
640
867
  tx(() => {
641
868
  if (!sel) {
@@ -645,17 +872,19 @@ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {
645
872
  return;
646
873
  }
647
874
  prepare(`
648
- INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions)
649
- VALUES (?, ?, ?, ?, ?, ?, ?)
875
+ INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions, subagent_model)
876
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
650
877
  ON CONFLICT(project_key, workflow_id, node_id)
651
878
  DO UPDATE SET model = excluded.model, effort = excluded.effort,
652
- fan_out = excluded.fan_out, ask_questions = excluded.ask_questions
879
+ fan_out = excluded.fan_out, ask_questions = excluded.ask_questions,
880
+ subagent_model = excluded.subagent_model
653
881
  `).run(
654
882
  key, workflowId, nodeId,
655
883
  sel.model ?? null,
656
884
  sel.effort ?? null,
657
885
  sel.fanOut === undefined ? null : (sel.fanOut ? 1 : 0),
658
886
  sel.askQuestions === undefined ? null : (sel.askQuestions ? 1 : 0),
887
+ sel.subagentModel ?? null,
659
888
  );
660
889
  });
661
890
  }
@@ -681,6 +910,23 @@ export async function setFeedbackCycles(projectDir, workflowId, fbId, maxCycles)
681
910
  });
682
911
  }
683
912
 
913
+ /**
914
+ * Set the cycle budget for ONE loop wire of a v2 workflow. Coerced to an integer
915
+ * >= 1 (a loop runs at least once), exactly like setFeedbackCycles — this never
916
+ * throws, so a stale UI value cannot 500 a save. Writes only config_workflow_wires.
917
+ */
918
+ export async function setWireCycles(projectDir, workflowId, wireId, maxCycles) {
919
+ const n = Math.max(1, Math.floor(Number(maxCycles) || 0) || 1);
920
+ const key = projectKey(projectDir);
921
+ tx(() => {
922
+ prepare(`
923
+ INSERT INTO config_workflow_wires (project_key, workflow_id, wire_id, max_cycles)
924
+ VALUES (?, ?, ?, ?)
925
+ ON CONFLICT(project_key, workflow_id, wire_id) DO UPDATE SET max_cycles = excluded.max_cycles
926
+ `).run(key, workflowId, wireId, n);
927
+ });
928
+ }
929
+
684
930
  /**
685
931
  * Drop every per-project override for one workflow — the New-Pipeline accordion's
686
932
  * "Reset to defaults" (newpipeline-ux-design.md §4.5). Deletes the workflow's
@@ -706,6 +952,7 @@ export async function resetWorkflowConfig(projectDir, workflowId) {
706
952
  tx(() => {
707
953
  prepare('DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ?').run(key, id);
708
954
  prepare('DELETE FROM config_workflow_feedbacks WHERE project_key = ? AND workflow_id = ?').run(key, id);
955
+ prepare('DELETE FROM config_workflow_wires WHERE project_key = ? AND workflow_id = ?').run(key, id);
709
956
  if (clearLegacy) {
710
957
  prepare(`
711
958
  INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
@@ -746,6 +993,7 @@ export async function resolveRunConfig(projectDir, workflowId) {
746
993
  const wf = readWorkflowsMap(projectKey(projectDir))[workflowId] || {};
747
994
  return {
748
995
  nodes: wf.nodes && typeof wf.nodes === 'object' ? wf.nodes : {},
996
+ wires: wf.wires && typeof wf.wires === 'object' ? wf.wires : {},
749
997
  feedbacks: wf.feedbacks && typeof wf.feedbacks === 'object' ? wf.feedbacks : {},
750
998
  };
751
999
  }
@@ -864,16 +1112,34 @@ export async function removeGlobalModelAndRefs(id) {
864
1112
  if (!stepKeysByProject.has(r.projectKey)) continue;
865
1113
  const filtered = {};
866
1114
  for (const [k, v] of Object.entries(r.steps)) {
867
- if (v?.model && v.model.toLowerCase() === lc) { clearedSteps += 1; continue; }
1115
+ if (v?.model && v.model.toLowerCase() === lc) {
1116
+ // Clear ONLY the dangling ref (and the effort that travels with its
1117
+ // model); fanOut/askQuestions/subagentModel are not the removed
1118
+ // model's business and must survive — mirroring cleanNodeSel's
1119
+ // emptiness rule, the entry itself goes only when nothing is left.
1120
+ clearedSteps += 1;
1121
+ const { model: _m, effort: _e, ...rest } = v;
1122
+ if (Object.keys(rest).length) filtered[k] = rest;
1123
+ continue;
1124
+ }
868
1125
  filtered[k] = v;
869
1126
  }
870
1127
  prepare('UPDATE project_config SET steps = ? WHERE project_key = ?')
871
1128
  .run(JSON.stringify(filtered), r.projectKey);
872
1129
  }
873
1130
  for (const n of refs.nodes) {
874
- clearedNodes += prepare(
875
- 'DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
876
- ).run(n.projectKey, n.workflowId, n.nodeId).changes;
1131
+ // Same rule for node rows: NULL the model+effort, keep the other
1132
+ // tunables, and drop the row only once every column is NULL (matching
1133
+ // readWorkflowsMap, which would not surface an all-NULL row anyway).
1134
+ clearedNodes += prepare(`
1135
+ UPDATE config_workflow_nodes SET model = NULL, effort = NULL
1136
+ WHERE project_key = ? AND workflow_id = ? AND node_id = ?
1137
+ `).run(n.projectKey, n.workflowId, n.nodeId).changes;
1138
+ prepare(`
1139
+ DELETE FROM config_workflow_nodes
1140
+ WHERE project_key = ? AND workflow_id = ? AND node_id = ?
1141
+ AND fan_out IS NULL AND ask_questions IS NULL AND subagent_model IS NULL
1142
+ `).run(n.projectKey, n.workflowId, n.nodeId);
877
1143
  }
878
1144
  });
879
1145
  }
@@ -54,6 +54,33 @@ export function windowedSpendUsd(windowStartMs) {
54
54
  return roundUsd(row?.s || 0);
55
55
  }
56
56
 
57
+ /** Append one Ask Worca cost event (ask-cost-statistics-design.md §7.1). Same
58
+ * no-op gate as recordCostDelta: turns that ended before a `result` frame
59
+ * (amountUsd null, §6.2.8 of the ask spec) and $0 mock turns leave no row.
60
+ * messageId is the v20 backfill's idempotency key (db.mjs NOT EXISTS on
61
+ * l.message_id) — every live caller must pass it. */
62
+ export function recordAskCostDelta({ threadId, messageId = null, amountUsd,
63
+ tokens = null, model = null, tsMs = Date.now() }) {
64
+ if (!threadId || !Number.isFinite(amountUsd) || amountUsd <= 0) return;
65
+ prepare(`INSERT INTO ask_cost_ledger (thread_id, message_id, amount_usd, tokens, model, ts)
66
+ VALUES (?, ?, ?, ?, ?, ?)`).run(threadId, messageId, amountUsd, tokens, model, tsMs);
67
+ }
68
+
69
+ /** Windowed Ask Worca spend; toMs null = open-ended (budget windows), else ts < toMs. */
70
+ export function askWindowedSpendUsd(fromMs, toMs = null) {
71
+ const row = toMs == null
72
+ ? prepare('SELECT SUM(amount_usd) AS s FROM ask_cost_ledger WHERE ts >= ?').get(fromMs)
73
+ : prepare('SELECT SUM(amount_usd) AS s FROM ask_cost_ledger WHERE ts >= ? AND ts < ?').get(fromMs, toMs);
74
+ return roundUsd(row?.s || 0);
75
+ }
76
+
77
+ /** Pipeline + Ask Worca spend since windowStartMs — THE enforcement figure
78
+ * (count-everywhere, ask-cost-statistics-design.md D3). windowedSpendUsd /
79
+ * allTimeTotals stay pipeline-only for the Statistics split. */
80
+ export function totalWindowSpendUsd(windowStartMs) {
81
+ return roundUsd(windowedSpendUsd(windowStartMs) + askWindowedSpendUsd(windowStartMs));
82
+ }
83
+
57
84
  /** All-time spend + active time over ALL pipelines (archived included),
58
85
  * falling back to per-step sums when the row total is 0. */
59
86
  export function allTimeTotals() {
@@ -86,7 +113,7 @@ export function budgetStatus(now = new Date()) {
86
113
  const totalLimitUsd = totalCostLimitUsd();
87
114
  const windowStartMs = costWindowStart(now, resetPeriod).getTime();
88
115
  const windowEndMs = costWindowEnd(now, resetPeriod).getTime();
89
- const windowSpendUsd = windowedSpendUsd(windowStartMs);
116
+ const windowSpendUsd = totalWindowSpendUsd(windowStartMs);
90
117
  const blocked = totalLimitUsd != null && windowSpendUsd >= totalLimitUsd;
91
118
  return {
92
119
  pipelineLimitUsd,
@@ -96,7 +123,7 @@ export function budgetStatus(now = new Date()) {
96
123
  windowEndMs,
97
124
  msUntilReset: windowEndMs - now.getTime(),
98
125
  windowSpendUsd,
99
- allTimeSpendUsd: allTimeTotals().spendUsd,
126
+ allTimeSpendUsd: roundUsd(allTimeTotals().spendUsd + askWindowedSpendUsd(0)),
100
127
  remainingUsd: totalLimitUsd == null ? null : Math.max(0, roundUsd(totalLimitUsd - windowSpendUsd)),
101
128
  blocked,
102
129
  };