@worca/app 1.3.0 → 1.4.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 (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -16,9 +16,15 @@ import { getDb, prepare, tx } from './db.mjs';
16
16
  import { projectKey } from './store.mjs';
17
17
  import { AUTO_WORKFLOW_ID } from './graph/builtin-workflows.mjs';
18
18
  import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
19
- import { EFFORTS, prepareModelEnv, withTierModelEnv, isSubagentModelValue, subagentModelIssue } from './model-env.mjs';
20
- import { listGlobalModels, addGlobalModel, removeGlobalModel, hideBuiltinModels } from './settings.mjs';
19
+ import { EFFORTS, prepareModelEnv, withTierModelEnv, isSubagentModelValue, subagentModelIssue, BRIDGE_ROUTING_KEYS, bridgeExcludedTools } from './model-env.mjs';
20
+ import { findBridgedEntry, providerReadiness } from './bridge/registry.mjs';
21
+ import { bridgeBaseUrl, bridgeSecret } from './bridge/server.mjs';
22
+ import { listGlobalModels, addGlobalModel, removeGlobalModel, hideBuiltinModels, readSettings, memoryDefragModel, setMemoryDefragModel } from './settings.mjs';
23
+ /** Whether the developer stored the hide-built-ins flag (a team default applies only when not). */
24
+ const readSettingsHideStored = () => { const s = readSettings(); return typeof s.hideBuiltinModels === 'boolean' || s.hideBuiltinModelsChosen === true; };
21
25
  import { listPluginModels, allPluginModels, flattenPluginModelEnv } from './plugin-models.mjs';
26
+ // Team policy defaults (team-policy design §6, §8): read from the discovery CACHE only (a leaf module).
27
+ import { policyCatalogModels, teamDefault } from './policy/cache.mjs';
22
28
 
23
29
  /**
24
30
  * Recompute the agent step list FRESH from the layered registry (repo agents/ +
@@ -61,12 +67,14 @@ export { EFFORTS };
61
67
  * available for this subscription"). Fable 5.1 needs no `[1m]` suffix: its context
62
68
  * window is 1M by default (verified to resolve via `claude --model`, CLI 2.1.257).
63
69
  * It replaced Fable 5 (`claude-fable-5`) on 2026-09-01; db.mjs V26 moves every
64
- * stored pin on the retired id to the successor, so nothing keeps it here. Opus 5
65
- * (`claude-opus-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
66
- * carry no `[1m]` twin.
70
+ * stored pin on the retired id to the successor, so nothing keeps it here. Opus 5.5
71
+ * (`claude-opus-5-5`) and Sonnet 5 (`claude-sonnet-5`) are likewise 1M-only and
72
+ * carry no `[1m]` twin. Opus 5.5 replaced Opus 5 (`claude-opus-5`) on 2026-09-22
73
+ * (verified to resolve via `claude --model`, CLI 2.1.280); db.mjs V35 moves the
74
+ * stored pins the same way.
67
75
  */
68
76
  export const PREDEFINED_MODELS = [
69
- { id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
77
+ { id: 'claude-opus-5-5', label: 'Opus 5.5', efforts: ['medium', 'high', 'xhigh', 'max'] },
70
78
  { id: 'claude-fable-5-1', label: 'Fable 5.1 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
71
79
  { id: 'claude-opus-4-8', label: 'Opus 4.8', efforts: ['medium', 'high', 'xhigh', 'max'] },
72
80
  { id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
@@ -134,7 +142,7 @@ function parseJson(text, fallback) {
134
142
  * @param {string} key
135
143
  * @returns {{steps:string,custom_models:string,active_workflow_id:(string|null),extra:string}|null}
136
144
  */
137
- function readConfigRow(key) {
145
+ export function readConfigRow(key) {
138
146
  getDb();
139
147
  return prepare(
140
148
  'SELECT steps, custom_models, active_workflow_id, extra, human_in_loop FROM project_config WHERE project_key = ?'
@@ -175,7 +183,7 @@ export async function readConfig(projectDir) {
175
183
  * env objects already in scope here, NEVER by calling modelHasBaseUrlRouting per
176
184
  * entry (that re-reads settings + the plugins lock from disk on every row).
177
185
  */
178
- function composeCatalog(projectCustom = []) {
186
+ function composeCatalog(projectCustom = [], { projectDir = null } = {}) {
179
187
  const globals = listGlobalModels();
180
188
  const globalByIdLc = new Map(globals.map((m) => [m.id.toLowerCase(), m]));
181
189
  const plugins = listPluginModels();
@@ -189,17 +197,40 @@ function composeCatalog(projectCustom = []) {
189
197
  // read by every picker. The entry stays in the catalog — hiding an id must
190
198
  // never stop it resolving, or a stored run / plugin reference that names it
191
199
  // would break — so pickers skip `hidden`, validators ignore it.
192
- const hidden = hideBuiltinModels() ? { hidden: true } : {};
200
+ // A team-policy default applies only while the developer has not stored the flag themselves.
201
+ const teamHide = projectDir ? teamDefault(projectDir, 'models.hideBuiltins') : undefined;
202
+ const hideStored = readSettingsHideStored();
203
+ const hidden = (hideStored ? hideBuiltinModels() : (teamHide === true || hideBuiltinModels())) ? { hidden: true } : {};
204
+ // Bridged entries (model-bridge-design.md §8.5): `bridged` names the provider
205
+ // (false otherwise), `upstreamApi` the wire protocol, `needsSignIn` whether
206
+ // the provider is usable right now (pickers skip such entries unless they are
207
+ // the current selection), `capabilities` what the editor pinned. `routed` is
208
+ // true too: the CLI IS pointed at a custom endpoint (worca's own bridge).
209
+ // provider + the entry's own key/base URL -> readiness (both can decide it)
210
+ const readiness = new Map();
211
+ const bridgeShape = (m) => {
212
+ if (!m.upstream) return {};
213
+ const p = m.upstream.provider;
214
+ const key = `${p}\n${m.upstream.apiKey || ''}\n${m.upstream.baseUrl || ''}`;
215
+ if (!readiness.has(key)) readiness.set(key, providerReadiness(m.upstream));
216
+ const r = readiness.get(key);
217
+ return {
218
+ bridged: p, upstreamApi: m.upstream.api, upstreamModel: m.upstream.model,
219
+ needsSignIn: !r.ok, ...(r.ok ? {} : { signInReason: r.reason }),
220
+ ...(m.upstream.capabilities ? { capabilities: { ...m.upstream.capabilities } } : {}),
221
+ };
222
+ };
223
+ const routedOrBridged = (m) => routedOf(m.env) || !!m.upstream;
193
224
  const pluginShape = (id, m, lc) => ({
194
225
  id, label: m.label, efforts: [...m.efforts], custom: 'plugin', plugin: m.plugin,
195
- hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc),
226
+ hasEnv: !!m.env, routed: routedOrBridged(m), ...unreliable(lc), ...bridgeShape(m),
196
227
  });
197
228
  for (const m of PREDEFINED_MODELS) {
198
229
  const lc = m.id.toLowerCase();
199
230
  const shadow = globalByIdLc.get(lc);
200
231
  const pshadow = pluginByIdLc.get(lc);
201
232
  out.push(shadow
202
- ? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, routed: routedOf(shadow.env), ...unreliable(lc) }
233
+ ? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, routed: routedOrBridged(shadow), ...unreliable(lc), ...bridgeShape(shadow) }
203
234
  : pshadow
204
235
  ? pluginShape(m.id, pshadow, lc)
205
236
  : { ...m, custom: false, hasEnv: false, routed: false, ...hidden });
@@ -209,7 +240,7 @@ function composeCatalog(projectCustom = []) {
209
240
  const lc = m.id.toLowerCase();
210
241
  if (seen.has(lc)) continue; // predefined shadow, already emitted
211
242
  seen.add(lc);
212
- out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, routed: routedOf(m.env), ...unreliable(lc) });
243
+ out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, routed: routedOrBridged(m), ...unreliable(lc), ...bridgeShape(m) });
213
244
  }
214
245
  for (const m of plugins) {
215
246
  const lc = m.id.toLowerCase();
@@ -217,6 +248,15 @@ function composeCatalog(projectCustom = []) {
217
248
  seen.add(lc);
218
249
  out.push(pluginShape(m.id, m, lc));
219
250
  }
251
+ // Team-policy catalog entries (team-policy design §8): after global and plugin, before the
252
+ // legacy per-project ones. Read-only rows with a policy badge; `home` names the policy.
253
+ for (const m of policyCatalogModels()) {
254
+ const lc = m.id.toLowerCase();
255
+ if (seen.has(lc)) continue;
256
+ seen.add(lc);
257
+ out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'policy', policy: m.home,
258
+ hasEnv: !!m.env, routed: routedOrBridged(m), ...unreliable(lc), ...bridgeShape(m) });
259
+ }
220
260
  for (const m of projectCustom) {
221
261
  if (seen.has(m.id.toLowerCase())) continue; // predefined/global/plugin wins
222
262
  seen.add(m.id.toLowerCase());
@@ -241,9 +281,29 @@ export function modelHasBaseUrlRouting(modelId) {
241
281
  if (!id) return false;
242
282
  const lc = id.toLowerCase();
243
283
  const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
244
- if (entry) return !!(entry.env && 'ANTHROPIC_BASE_URL' in entry.env);
284
+ if (entry) return !!entry.upstream || !!(entry.env && 'ANTHROPIC_BASE_URL' in entry.env);
245
285
  const pm = listPluginModels().find((m) => m.id.toLowerCase() === lc);
246
- return !!(pm && pm.env && 'ANTHROPIC_BASE_URL' in pm.env);
286
+ if (pm) return !!pm.upstream || !!(pm.env && 'ANTHROPIC_BASE_URL' in pm.env);
287
+ return !!findBridgedEntry(id); // a team-policy entry with an upstream
288
+ }
289
+
290
+ /**
291
+ * The bridge facts for a model id (model-bridge-design.md §4.2/§8.5), or null
292
+ * when it is not a bridged entry: `{id, provider, api, upstreamModel,
293
+ * excludeTools, ready, reason?, message?}`. `excludeTools` are the CLI built-ins
294
+ * the runner must withhold (web tools have no chat/completions equivalent);
295
+ * `ready` is the provider's sign-in state — a spawn fails fast on it instead
296
+ * of with an opaque 401 mid-run. Synchronous; never throws.
297
+ */
298
+ export function bridgedModelInfo(modelId) {
299
+ const e = findBridgedEntry(modelId);
300
+ if (!e) return null;
301
+ const r = providerReadiness(e.upstream);
302
+ return {
303
+ id: e.id, provider: e.upstream.provider, api: e.upstream.api, upstreamModel: e.upstream.model,
304
+ excludeTools: bridgeExcludedTools(e.upstream),
305
+ ready: r.ok, ...(r.ok ? {} : { reason: r.reason, message: r.message }),
306
+ };
247
307
  }
248
308
 
249
309
  /** Lowercased ids currently flagged cost-unreliable. Never throws ({} on any
@@ -423,7 +483,8 @@ export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined
423
483
 
424
484
  // ── display-only list prices ──────────────────────────────────────────────────
425
485
  // USD per MILLION tokens for the built-in ids, from Anthropic's published
426
- // pricing (platform.claude.com/docs/en/pricing — snapshot 2026-06-24). DISPLAY
486
+ // pricing (platform.claude.com/docs/en/pricing — snapshot 2026-06-24; Opus 5.5
487
+ // added 2026-09-22). DISPLAY
427
488
  // APPROXIMATION ONLY: it feeds the chat footer's live "≈" estimate while a turn
428
489
  // streams (ask/events.mjs `estimatedCostUsd`). The CLI's result.total_cost_usd,
429
490
  // re-priced by resolveModelCost, stays the ONLY figure any message row, thread
@@ -431,12 +492,12 @@ export function resolveModelCost(modelId, cliCostUsd, usage, costCfg = undefined
431
492
  // Ids missing here get no estimate (null), which is the pre-existing behaviour;
432
493
  // `[1m]` twins and dated ids resolve to their base row (the long-context premium
433
494
  // is not modelled). cacheWrite = 1.25× input (5-minute TTL), cacheWrite1h = 2×
434
- // input, cacheRead = 0.1× input except Fable 5.1 (0.025×). Refresh by hand when
495
+ // input, cacheRead = 0.1× input except Fable 5.1 (0.025×) and Opus 5.5 (0.05×). Refresh by hand when
435
496
  // Anthropic moves a price. PREDEFINED_MODELS itself stays untouched — its entry
436
497
  // shape is pinned (test/config-models-global.test.mjs:205).
437
498
  export const PREDEFINED_LIST_PRICES = Object.freeze({
438
499
  'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 12.5, cacheWrite1h: 20 },
439
- 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
500
+ 'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5, cacheWrite1h: 8 },
440
501
  'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
441
502
  'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
442
503
  'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheWrite1h: 10 },
@@ -469,9 +530,9 @@ export function liveCostRates(modelId) {
469
530
  * raw id); global entries advertise their configured subset.
470
531
  */
471
532
  export async function listModels(projectDir) {
472
- if (!projectDir) return composeCatalog([]); // project-less: predefined ⊕ global only
533
+ if (!projectDir) return composeCatalog([]); // project-less: predefined ⊕ global ⊕ plugin ⊕ policy
473
534
  const { customModels } = readRaw(projectDir);
474
- return composeCatalog(customModels);
535
+ return composeCatalog(customModels, { projectDir });
475
536
  }
476
537
 
477
538
  /**
@@ -486,7 +547,7 @@ export async function listModels(projectDir) {
486
547
  * @param {string} modelId
487
548
  * @returns {Record<string,string>|undefined}
488
549
  */
489
- export function resolveModelEnv(modelId) {
550
+ export function resolveModelEnv(modelId, { tag } = {}) {
490
551
  const id = typeof modelId === 'string' ? modelId.trim() : '';
491
552
  if (!id) return undefined;
492
553
  const lc = id.toLowerCase();
@@ -494,6 +555,56 @@ export function resolveModelEnv(modelId) {
494
555
  let who;
495
556
  let canonicalId = id;
496
557
  const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
558
+ // A BRIDGED entry (model-bridge-design.md §4.2): the CLI talks to worca's
559
+ // own loopback bridge, which forwards to the entry's upstream. The routing
560
+ // keys are synthesized here — never user-editable beside `upstream` — and
561
+ // the entry's remaining env (a CLAUDE_CODE_* knob, say) still merges. Only
562
+ // the user's global layer and a plugin layer may be bridged; policy entries
563
+ // carry `upstream` too but ride the same lookup (registry.findBridgedEntry).
564
+ const bridged = findBridgedEntry(id);
565
+ if (bridged) {
566
+ // Fail fast (§8.5): a provider that is not signed in / not acknowledged
567
+ // would otherwise surface as an opaque 401 mid-run. This is the ONE case
568
+ // in which this resolver throws; every dispatch site already routes a
569
+ // runClaude failure to its error path, and the Test button's hint keys on
570
+ // `bridgeReason`. The error class is `auth` so recovery policy treats it
571
+ // like any credential failure (never retried blindly).
572
+ const ready = providerReadiness(bridged.upstream);
573
+ if (!ready.ok) {
574
+ const err = new Error(ready.message);
575
+ err.errorClass = 'auth';
576
+ err.bridgeReason = ready.reason;
577
+ err.bridgeProvider = bridged.upstream.provider;
578
+ throw err;
579
+ }
580
+ const base = bridged.source === 'global' && entry ? entry : null;
581
+ const { env: extra, dropped } = prepareModelEnv(base && base.env ? base.env : {});
582
+ for (const k of dropped) {
583
+ console.warn(`[worca] model ${JSON.stringify(bridged.id)}: dropping env key ${JSON.stringify(k)} (reserved or unresolvable \${VAR} ref)`);
584
+ }
585
+ for (const k of BRIDGE_ROUTING_KEYS) delete extra[k];
586
+ const env = {
587
+ ...extra,
588
+ ANTHROPIC_BASE_URL: bridgeBaseUrl(bridged.id, { tag }),
589
+ ANTHROPIC_AUTH_TOKEN: bridgeSecret(),
590
+ ANTHROPIC_MODEL: bridged.id,
591
+ };
592
+ // The CLI turns tool search off for a non-Anthropic base URL and then sends
593
+ // every MCP tool schema in full — hundreds of KB with a few user MCP
594
+ // servers, far past a translated model's prompt limit (a local 32k model
595
+ // fails its first call). Its ToolSearch is client-side (schemas come back
596
+ // as tool_result text), so it works through the translation layer; the
597
+ // entry's own env may still turn it off.
598
+ if (bridged.upstream.api === 'openai-chat' && !('ENABLE_TOOL_SEARCH' in env)) env.ENABLE_TOOL_SEARCH = 'true';
599
+ // A bridged id is never a model name the CLI knows, so it assumes a 200k
600
+ // window and compacts only once the upstream rejects a request — on a 32k
601
+ // local model that means turns whose reply is cut to a few hundred tokens
602
+ // long before any overflow. The pinned limits are the real window.
603
+ const caps = bridged.upstream.capabilities || {};
604
+ if (caps.maxPromptTokens && !('CLAUDE_CODE_MAX_CONTEXT_TOKENS' in env)) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(caps.maxPromptTokens);
605
+ if (caps.maxOutputTokens && !('CLAUDE_CODE_MAX_OUTPUT_TOKENS' in env)) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(caps.maxOutputTokens);
606
+ return withTierModelEnv(env, bridged.id);
607
+ }
497
608
  if (entry && entry.env) {
498
609
  rawEnv = entry.env;
499
610
  who = JSON.stringify(entry.id);
@@ -508,6 +619,15 @@ export function resolveModelEnv(modelId) {
508
619
  rawEnv = env;
509
620
  who = `${JSON.stringify(pm.id)} (plugin "${pm.plugin}")`;
510
621
  canonicalId = pm.id;
622
+ } else if (!pm) {
623
+ // A team-policy catalog entry, reached only when neither the user nor a plugin defines the
624
+ // id. Its env carries literals and ${VAR} refs only: the editor and the reader refuse secrets.
625
+ const tm = policyCatalogModels().find((m) => m.id.toLowerCase() === lc);
626
+ if (tm && tm.env) {
627
+ rawEnv = tm.env;
628
+ who = `${JSON.stringify(tm.id)} (team policy ${tm.home})`;
629
+ canonicalId = tm.id;
630
+ }
511
631
  }
512
632
  }
513
633
  if (!rawEnv) return undefined;
@@ -535,7 +655,8 @@ export function catalogHasModel(modelId) {
535
655
  const lc = id.toLowerCase();
536
656
  return PREDEFINED_MODELS.some((m) => m.id.toLowerCase() === lc)
537
657
  || listGlobalModels().some((m) => m.id.toLowerCase() === lc)
538
- || listPluginModels().some((m) => m.id.toLowerCase() === lc);
658
+ || listPluginModels().some((m) => m.id.toLowerCase() === lc)
659
+ || policyCatalogModels().some((m) => m.id.toLowerCase() === lc);
539
660
  }
540
661
 
541
662
  /**
@@ -546,9 +667,12 @@ export function catalogHasModel(modelId) {
546
667
  */
547
668
  export async function resolveStepModels(projectDir, fallbackModel) {
548
669
  const cfg = readRaw(projectDir);
670
+ // Team policy `models.steps` (a default): starts a role the project has NOT configured.
671
+ const team = teamDefault(projectDir, 'models.steps') || {};
549
672
  const out = {};
550
673
  for (const { key } of agentSteps()) {
551
- const sel = cfg.steps[key] || {};
674
+ const own = cfg.steps[key] || {};
675
+ const sel = own.model || own.effort ? own : (team[key] || {});
552
676
  out[key] = { model: sel.model || fallbackModel || undefined, effort: sel.effort || undefined };
553
677
  }
554
678
  return out;
@@ -841,16 +965,22 @@ export async function readRunConfig(projectDir) {
841
965
  // Forward any OTHER unknown keys verbatim too (future-proof, matches "preserve unknown").
842
966
  // prRemotes is the ship-it dialog's own preference (readPrRemotePrefs), not run config.
843
967
  for (const [k, v] of Object.entries(extra)) {
844
- if (k !== 'webUiTesting' && k !== PR_REMOTES_KEY && k !== TEAM_METRICS_KEY && !(k in out)) out[k] = v;
968
+ if (k !== 'webUiTesting' && k !== PR_REMOTES_KEY && k !== TEAM_METRICS_KEY && k !== TEAM_POLICY_KEY && k !== 'humanInLoopSet' && !(k in out)) out[k] = v;
845
969
  }
846
970
  const active = row && typeof row.active_workflow_id === 'string' ? row.active_workflow_id.trim() : '';
847
- // Spec §6.1 / D16: a project with no remembered New-pipeline choice starts on Auto.
848
- out.activeWorkflowId = active || AUTO_WORKFLOW_ID;
971
+ // Spec §6.1 / D16: a project with no remembered New-pipeline choice starts on Auto — unless a
972
+ // team policy names a default workflow (team-policy design §8), which starts it there instead.
973
+ const teamWf = active ? undefined : teamDefault(projectDir, 'workflows.default');
974
+ out.activeWorkflowId = active || (typeof teamWf === 'string' && teamWf ? teamWf : AUTO_WORKFLOW_ID);
975
+ if (!active && teamWf) out.activeWorkflowSource = 'team-policy';
849
976
  // Auto workflow (spec §6.1): the human-in-the-loop switch. ON is the default
850
977
  // and is NOT echoed — the key appears only when the project turned it off, so
851
978
  // every consumer reads `config.humanInLoop ?? true` and the config shape of a
852
- // project that never touched it stays otherwise byte-identical.
979
+ // project that never touched it stays otherwise byte-identical. A team default applies only
980
+ // while the project has never set its own switch (setHumanInLoop stamps extra.humanInLoopSet).
853
981
  if (row && row.human_in_loop === 0) out.humanInLoop = false;
982
+ else if (!extra.humanInLoopSet && teamDefault(projectDir, 'run.humanInLoop') === false) out.humanInLoop = false;
983
+ delete out.humanInLoopSet;
854
984
  return out;
855
985
  }
856
986
 
@@ -1110,6 +1240,40 @@ export function writeTeamMetricsPrefs(key, patch) {
1110
1240
  return next;
1111
1241
  }
1112
1242
 
1243
+ // ── Team-policy preferences (project_config.extra.teamPolicy) ──────────────
1244
+ // The discovery cache for the worca-policy branch (team-policy design §9): the last
1245
+ // verdict, the document read from origin, the delegate marker, and the per-window
1246
+ // total-cap acknowledgements. Same KEY-taking contract as the team-metrics pair.
1247
+ export const TEAM_POLICY_KEY = 'teamPolicy';
1248
+
1249
+ /** @returns {object|null} the cached team-policy state for a project key */
1250
+ export function readTeamPolicyPrefs(key) {
1251
+ assertProjectKey(key);
1252
+ const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
1253
+ const extra = row ? parseJson(row.extra, {}) : {};
1254
+ const v = extra[TEAM_POLICY_KEY];
1255
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
1256
+ }
1257
+
1258
+ /** Shallow-merge `patch` into extra.teamPolicy; returns the merged object. */
1259
+ export function writeTeamPolicyPrefs(key, patch) {
1260
+ assertProjectKey(key);
1261
+ let next = null;
1262
+ tx(() => {
1263
+ const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
1264
+ const extra = row ? parseJson(row.extra, {}) : {};
1265
+ const cur = extra[TEAM_POLICY_KEY] && typeof extra[TEAM_POLICY_KEY] === 'object' ? extra[TEAM_POLICY_KEY] : {};
1266
+ next = { ...cur, ...patch };
1267
+ extra[TEAM_POLICY_KEY] = next;
1268
+ prepare(`
1269
+ INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
1270
+ VALUES (?, '{}', '[]', NULL, ?)
1271
+ ON CONFLICT(project_key) DO UPDATE SET extra = excluded.extra
1272
+ `).run(key, JSON.stringify(extra));
1273
+ });
1274
+ return next;
1275
+ }
1276
+
1113
1277
  /**
1114
1278
  * Set the project's human-in-the-loop switch for Auto runs (spec D15/D20).
1115
1279
  * @param {string} projectDir
@@ -1124,6 +1288,13 @@ export async function setHumanInLoop(projectDir, value) {
1124
1288
  VALUES (?, '{}', '[]', NULL, '{}', ?)
1125
1289
  ON CONFLICT(project_key) DO UPDATE SET human_in_loop = excluded.human_in_loop
1126
1290
  `).run(key, v);
1291
+ // The project now has its own switch: a team-policy default no longer applies (design §6).
1292
+ const row = prepare('SELECT extra FROM project_config WHERE project_key = ?').get(key);
1293
+ const extra = row ? parseJson(row.extra, {}) : {};
1294
+ if (!extra.humanInLoopSet) {
1295
+ extra.humanInLoopSet = true;
1296
+ prepare('UPDATE project_config SET extra = ? WHERE project_key = ?').run(JSON.stringify(extra), key);
1297
+ }
1127
1298
  });
1128
1299
  }
1129
1300
 
@@ -1165,18 +1336,25 @@ function allProjectConfigRows() {
1165
1336
  /**
1166
1337
  * Preview what removing a global catalog entry would clear, for the UI's
1167
1338
  * confirmation dialog. `predefinedShadow: true` means the removal only reverts
1168
- * an override and clears nothing. Synchronous; never throws.
1339
+ * an override and clears nothing. `memoryDefrag: true` (present only then) —
1340
+ * Settings › Memory's defragment model is this id. Synchronous; never throws.
1169
1341
  * @param {string} id
1170
1342
  * @returns {{predefinedShadow: boolean,
1171
1343
  * steps: Array<{projectKey:string, step:string}>,
1172
- * nodes: Array<{projectKey:string, workflowId:string, nodeId:string}>}}
1344
+ * nodes: Array<{projectKey:string, workflowId:string, nodeId:string}>,
1345
+ * memoryDefrag?: true}}
1173
1346
  */
1174
1347
  export function globalModelRefs(id) {
1175
1348
  const lc = (typeof id === 'string' ? id : '').trim().toLowerCase();
1176
1349
  if (PREDEFINED_MODELS.some((m) => m.id.toLowerCase() === lc)) {
1177
1350
  return { predefinedShadow: true, steps: [], nodes: [] };
1178
1351
  }
1179
- return { predefinedShadow: false, ...refsForModelId(lc, allProjectConfigRows()) };
1352
+ const defrag = memoryDefragModel().model;
1353
+ return {
1354
+ predefinedShadow: false,
1355
+ ...refsForModelId(lc, allProjectConfigRows()),
1356
+ ...(defrag && defrag.toLowerCase() === lc ? { memoryDefrag: true } : {}),
1357
+ };
1180
1358
  }
1181
1359
 
1182
1360
  /** Cross-project step/node refs to one lowercased model id, minus projects
@@ -1235,8 +1413,10 @@ export function referencedPluginModels(pluginName) {
1235
1413
  * above). Ref purge and settings removal are not one transaction — a purge
1236
1414
  * that lands without the removal (or vice versa on a crash) is harmless, since
1237
1415
  * refs can be re-set and purging is idempotent.
1416
+ * Settings › Memory's defragment model is a ref too: it is cleared with the entry
1417
+ * (its effort with it) and the result says so with `clearedMemoryDefrag: true`.
1238
1418
  * @param {string} id
1239
- * @returns {Promise<{clearedSteps:number, clearedNodes:number, predefinedShadow:boolean}>}
1419
+ * @returns {Promise<{clearedSteps:number, clearedNodes:number, predefinedShadow:boolean, clearedMemoryDefrag?:true}>}
1240
1420
  * @throws {Error} on an unknown id (from removeGlobalModel)
1241
1421
  */
1242
1422
  export async function removeGlobalModelAndRefs(id) {
@@ -1289,6 +1469,7 @@ export async function removeGlobalModelAndRefs(id) {
1289
1469
  }
1290
1470
  });
1291
1471
  }
1472
+ if (refs.memoryDefrag) await setMemoryDefragModel(null); // idempotent, like the purge above
1292
1473
  await removeGlobalModel(id); // throws on unknown id — AFTER the idempotent purge
1293
- return { clearedSteps, clearedNodes, predefinedShadow: refs.predefinedShadow };
1474
+ return { clearedSteps, clearedNodes, predefinedShadow: refs.predefinedShadow, ...(refs.memoryDefrag ? { clearedMemoryDefrag: true } : {}) };
1294
1475
  }
@@ -87,12 +87,13 @@ export function allTimeTotals() {
87
87
  const row = prepare(`
88
88
  SELECT
89
89
  COALESCE(SUM(CASE WHEN p.total_cost_usd > 0 THEN p.total_cost_usd ELSE COALESCE(s.sc, 0) END), 0) AS spend,
90
- COALESCE(SUM(CASE WHEN p.total_active_ms > 0 THEN p.total_active_ms ELSE COALESCE(s.sa, 0) END), 0) AS active
90
+ COALESCE(SUM(CASE WHEN p.total_active_ms > 0 THEN p.total_active_ms ELSE COALESCE(s.sa, 0) END), 0) AS active,
91
+ COALESCE(SUM(p.human_hours), 0) AS humanHours
91
92
  FROM pipelines p
92
93
  LEFT JOIN (SELECT pipeline_id, SUM(cost_usd) sc, SUM(active_ms) sa
93
94
  FROM pipeline_steps GROUP BY pipeline_id) s ON s.pipeline_id = p.id
94
95
  `).get();
95
- return { spendUsd: roundUsd(row?.spend || 0), activeMs: Number(row?.active || 0) };
96
+ return { spendUsd: roundUsd(row?.spend || 0), activeMs: Number(row?.active || 0), humanHours: Math.round(Number(row?.humanHours || 0) * 100) / 100 };
96
97
  }
97
98
 
98
99
  /** True when this pipeline ignores the per-pipeline cap (persistent, F7). */