@worca/app 1.3.0 → 1.4.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 (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
@@ -0,0 +1,370 @@
1
+ // src/core/ask/model-proposal.mjs
2
+ // The ONE validator behind mcp__worca__propose_model_change, the edit merge its apply replays,
3
+ // and the event/notice text of the model card (docs/models.md "Ask Worca"). Pure: every reader
4
+ // and every dry-run setter is injected (model-deps.mjs binds the real ones), so the MCP child
5
+ // validates for the model's self-correction and the parent turn re-validates authoritatively —
6
+ // the policy-proposal.mjs split. Nothing here writes; applying a card is ui/server.mjs's
7
+ // business, behind the user's click.
8
+ //
9
+ // Why a card: the catalog and the providers are machine-wide settings every run spawns with,
10
+ // and a wrong base URL or key breaks every pipeline that names the model. Credentials never
11
+ // pass through here: a key is a ${VAR} reference or nothing, and the Copilot sign-in and its
12
+ // notice stay on the Providers card.
13
+ import { modelEnvRef, maskModelEnvValue, isLocalBaseUrl, UPSTREAM_PROVIDERS } from '../model-env.mjs';
14
+
15
+ export const MODEL_CHANGE_KINDS = Object.freeze(['add_model', 'edit_model', 'remove_model', 'provider', 'import_copilot', 'import_endpoint']);
16
+ const MAX_IMPORT = 40;
17
+ /** Below this a local model's pipelines thrash auto-compact (docs/models.md Troubleshooting). */
18
+ export const LOCAL_MIN_WINDOW = 65536;
19
+
20
+ // ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY, AWS_SECRET_ACCESS_KEY … — but not *_MAX_OUTPUT_TOKENS.
21
+ const SECRET_ENV_RE = /(^|_)(TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?)(_|$)/i;
22
+ const SECRET_HEADER_RE = /authorization|api[-_]?key|token|secret|cookie|password/i;
23
+
24
+ export const MODEL_ERRORS = Object.freeze({
25
+ kind: `kind must be one of ${MODEL_CHANGE_KINDS.join(', ')}`,
26
+ model: 'model must be an object: {id, label?, efforts?, env?, cost?, upstream?}',
27
+ idRequired: (kind) => `${kind} needs an id (the catalog model id from list_models)`,
28
+ unknownModel: (id) => `unknown model "${id}" — list_models shows the catalog`,
29
+ builtin: (id) => `"${id}" is a built-in model with no catalog entry — propose add_model with the same id to override it`,
30
+ readOnly: (id, where) => `"${id}" comes from ${where} and is read-only here`,
31
+ secretEnv: (k) => `env ${k} looks like a credential — pass it as a \${VAR} reference to a variable set in worca's environment, or leave it out and let the user paste it in Settings › Models`,
32
+ secretKey: (where) => `${where} must be a \${VAR} reference to a variable set in worca's environment (or empty to clear) — never the key itself; the user can paste a literal key in Settings › Providers`,
33
+ secretHeader: (k) => `upstream.headers.${k} looks like a credential — the user sets it in the model editor; use apiKey with a \${VAR} reference instead`,
34
+ provider: `provider must be one of ${UPSTREAM_PROVIDERS.join(', ')}`,
35
+ providerSet: 'provider needs set: {baseUrl?, apiKey?, maxConcurrent?, accountType?}',
36
+ signIn: (k) => `${k} is not settable here — the Copilot sign-in and its notice happen on the Providers card (Settings › Providers), by the user`,
37
+ noChange: 'nothing changes — the settings already say exactly this',
38
+ importIds: `import_copilot needs ids: 1 to ${MAX_IMPORT} Copilot model ids from list_copilot_models`,
39
+ endpointIds: `import_endpoint needs ids: 1 to ${MAX_IMPORT} model ids from list_endpoint_models`,
40
+ editEmpty: 'edit_model needs model with at least one of label, efforts, env, cost, upstream',
41
+ });
42
+
43
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
44
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
45
+ // eslint-disable-next-line no-control-regex
46
+ const BREAKS_RE = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g;
47
+ const clip = (v, n) => String(v ?? '').replace(BREAKS_RE, ' ').slice(0, n);
48
+ const isClear = (v) => v === null || v === '';
49
+
50
+ /** A stored value as the card and the tools show it: a ${VAR} ref reads, a literal is masked. */
51
+ export function maskValue(v) {
52
+ if (typeof v !== 'string' || !v) return v;
53
+ return modelEnvRef(v) ? v : maskModelEnvValue(v);
54
+ }
55
+
56
+ // A value that can carry a credential whatever its key: URL userinfo, or a key / token in a query string.
57
+ const EMBEDDED_SECRET_RE = /:\/\/[^/\s]*@|[?&](?:key|api[-_]?key|token|access_token|sig|secret)=/i;
58
+
59
+ /** One env value as Ask shows it: routing and tuning read (a base URL, a model id, a limit); a credential is masked. */
60
+ export function maskEnvValue(key, v) {
61
+ if (typeof v !== 'string' || !v || modelEnvRef(v)) return v;
62
+ return SECRET_ENV_RE.test(key) || EMBEDDED_SECRET_RE.test(v) ? maskModelEnvValue(v) : v;
63
+ }
64
+
65
+ /** A catalog entry with every credential masked (credential env values, upstream.apiKey). */
66
+ export function maskEntry(m) {
67
+ if (!m) return m;
68
+ return {
69
+ ...m,
70
+ ...(m.env ? { env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(k, v)])) } : {}),
71
+ ...(m.upstream ? { upstream: { ...m.upstream, ...(m.upstream.apiKey ? { apiKey: maskValue(m.upstream.apiKey) } : {}) } } : {}),
72
+ };
73
+ }
74
+
75
+ /** Credentials must arrive as ${VAR}: returns the first error, or null. */
76
+ function secretErrors(model) {
77
+ const errs = [];
78
+ if (isObj(model.env)) {
79
+ for (const [k, v] of Object.entries(model.env)) {
80
+ if (v === null) continue; // a delete marker carries no secret
81
+ if (SECRET_ENV_RE.test(k) && !(typeof v === 'string' && modelEnvRef(v.trim()))) errs.push(MODEL_ERRORS.secretEnv(k));
82
+ }
83
+ }
84
+ if (isObj(model.upstream)) {
85
+ const key = model.upstream.apiKey;
86
+ if (key !== undefined && !isClear(key) && !(typeof key === 'string' && modelEnvRef(key.trim()))) errs.push(MODEL_ERRORS.secretKey('upstream.apiKey'));
87
+ if (isObj(model.upstream.headers)) {
88
+ for (const k of Object.keys(model.upstream.headers)) if (SECRET_HEADER_RE.test(k)) errs.push(MODEL_ERRORS.secretHeader(k));
89
+ }
90
+ }
91
+ return errs;
92
+ }
93
+
94
+ /**
95
+ * The edit patch the setter receives, from the model's patch and the CURRENT entry. `upstream`
96
+ * merges into the current block (a null field removes it; `capabilities` merges one level
97
+ * deeper) so a change of limits never has to restate — or see — the stored key; `upstream: null`
98
+ * drops the block. `env` is updateGlobalModel's own per-key merge. Re-run at apply time against
99
+ * the entry as it is then, so the card never stores a merged block (it could hold a literal key).
100
+ */
101
+ export function mergeEditPatch(current, patch) {
102
+ const out = {};
103
+ for (const k of ['label', 'efforts', 'env', 'cost']) if (patch[k] !== undefined) out[k] = patch[k];
104
+ if (patch.upstream === null) out.upstream = null;
105
+ else if (isObj(patch.upstream)) {
106
+ const next = { ...(current && current.upstream ? current.upstream : {}) };
107
+ for (const [k, v] of Object.entries(patch.upstream)) {
108
+ if (k === 'capabilities' && isObj(v)) {
109
+ const caps = { ...(next.capabilities || {}) };
110
+ for (const [ck, cv] of Object.entries(v)) { if (cv === null) delete caps[ck]; else caps[ck] = cv; }
111
+ if (Object.keys(caps).length) next.capabilities = caps; else delete next.capabilities;
112
+ } else if (isClear(v)) delete next[k];
113
+ else next[k] = v;
114
+ }
115
+ out.upstream = next;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ const connectionOf = (m) => (m.upstream ? `through provider ${m.upstream.provider}` : m.env && m.env.ANTHROPIC_BASE_URL ? 'custom endpoint via env' : 'Anthropic API / CLI default');
121
+ const fmtEfforts = (e) => (Array.isArray(e) && e.length ? e.join(', ') : 'all');
122
+ const fmtCost = (c) => (!c ? null : c.free ? 'free' : c.perMtok ? Object.entries(c.perMtok).map(([k, v]) => `${k} $${v}`).join(' · ') : JSON.stringify(c));
123
+ const fmtCaps = (c) => (!c ? null : Object.entries(c).map(([k, v]) => `${k} ${v}`).join(' · '));
124
+
125
+ /** Display rows for one entry (masked): [{field, value}]. */
126
+ function entryRows(m) {
127
+ const rows = [
128
+ { field: 'Label', value: m.label || m.id },
129
+ { field: 'Connection', value: connectionOf(m) },
130
+ ];
131
+ if (m.upstream) {
132
+ rows.push({ field: 'Upstream model', value: `${m.upstream.model} (${m.upstream.api})` });
133
+ if (m.upstream.baseUrl) rows.push({ field: 'Base URL', value: m.upstream.baseUrl });
134
+ if (m.upstream.apiKey) rows.push({ field: 'API key', value: maskValue(m.upstream.apiKey) });
135
+ if (m.upstream.headers) rows.push({ field: 'Headers', value: Object.keys(m.upstream.headers).join(', ') });
136
+ if (m.upstream.capabilities) rows.push({ field: 'Limits', value: fmtCaps(m.upstream.capabilities) });
137
+ }
138
+ for (const [k, v] of Object.entries(m.env || {})) rows.push({ field: k, value: typeof v === 'string' ? maskEnvValue(k, v) : String(v) });
139
+ rows.push({ field: 'Efforts', value: fmtEfforts(m.efforts) });
140
+ const c = fmtCost(m.cost);
141
+ if (c) rows.push({ field: 'Pricing', value: c });
142
+ return rows;
143
+ }
144
+
145
+ /** before → after rows for the fields that differ. */
146
+ function diffRows(before, after) {
147
+ const b = new Map(entryRows(before).map((r) => [r.field, r.value]));
148
+ const a = new Map(entryRows(after).map((r) => [r.field, r.value]));
149
+ const rows = [];
150
+ for (const f of new Set([...b.keys(), ...a.keys()])) {
151
+ if (b.get(f) !== a.get(f)) rows.push({ field: f, before: b.get(f) ?? null, after: a.get(f) ?? null });
152
+ }
153
+ return rows;
154
+ }
155
+
156
+ /**
157
+ * @param {{
158
+ * listGlobalModels:()=>Array<object>, listPluginModels:()=>Array<object>, policyModels:()=>Array<object>,
159
+ * predefined:Array<{id:string,label:string}>, addModel:(m:object, o:{dryRun:true})=>Promise<object>,
160
+ * updateModel:(id:string, p:object, o:{dryRun:true})=>Promise<object>, updateProvider:(n:string, p:object, o:{dryRun:true})=>Promise<object>,
161
+ * providerConfig:(n:string)=>object, providerReadiness:(u:object)=>{ok:boolean, message?:string},
162
+ * modelRefs:(id:string)=>{steps:Array, nodes:Array, predefinedShadow:boolean}, envHas:(name:string)=>boolean,
163
+ * copilotModels?:()=>Promise<Array<{id:string,name:string,inCatalog:boolean}>>,
164
+ * }} r
165
+ */
166
+ export function createModelChangeValidator(r) {
167
+ const findGlobal = (id) => r.listGlobalModels().find((m) => m.id.toLowerCase() === id.toLowerCase()) || null;
168
+ const where = (id) => {
169
+ const lc = id.toLowerCase();
170
+ const p = r.listPluginModels().find((m) => m.id.toLowerCase() === lc);
171
+ if (p) return `plugin ${p.plugin}`;
172
+ let t = null;
173
+ try { t = r.policyModels().find((m) => m.id.toLowerCase() === lc); } catch { t = null; }
174
+ if (t) return `the team policy${t.home ? ` of ${t.home}` : ''}`;
175
+ return null;
176
+ };
177
+
178
+ /** Warnings about the entry as it will be — never blocking. */
179
+ function entryWarnings(m) {
180
+ const w = [];
181
+ const refs = [];
182
+ for (const v of Object.values(m.env || {})) { const ref = typeof v === 'string' ? modelEnvRef(v) : null; if (ref) refs.push(ref); }
183
+ const keyRef = m.upstream && typeof m.upstream.apiKey === 'string' ? modelEnvRef(m.upstream.apiKey) : null;
184
+ if (keyRef) refs.push(keyRef);
185
+ for (const ref of refs) if (!r.envHas(ref)) w.push(`\${${ref}} is not set in worca's environment — set it and restart Worca before a run uses this model`);
186
+ if (m.upstream) {
187
+ const ready = r.providerReadiness(m.upstream);
188
+ if (!ready.ok && !keyRef) w.push(`${ready.message} — the model shows "needs sign-in" until then`);
189
+ const caps = m.upstream.capabilities || {};
190
+ const base = m.upstream.baseUrl || (m.upstream.provider !== 'copilot' ? r.providerConfig(m.upstream.provider).baseUrl : null);
191
+ if (m.upstream.api === 'openai-chat' && !caps.maxPromptTokens) {
192
+ w.push('no prompt limit (capabilities.maxPromptTokens) — the CLI then assumes a 200k window and compacts too late; set it to what the endpoint serves');
193
+ } else if (base && isLocalBaseUrl(base) && caps.maxPromptTokens < LOCAL_MIN_WINDOW) {
194
+ w.push(`a ${caps.maxPromptTokens}-token window is too small for pipelines — serve at least ${LOCAL_MIN_WINDOW} (llama.cpp -c ${LOCAL_MIN_WINDOW}) and raise the limit to match`);
195
+ }
196
+ }
197
+ return w;
198
+ }
199
+
200
+ async function validateModel(kind, input) {
201
+ const note = clip(str(input.note), 200) || null;
202
+ if (kind === 'add_model') {
203
+ if (!isObj(input.model)) return { ok: false, errors: [MODEL_ERRORS.model] };
204
+ const errs = secretErrors(input.model);
205
+ if (errs.length) return { ok: false, errors: errs };
206
+ const id = str(input.model.id);
207
+ if (id && where(id)) return { ok: false, errors: [MODEL_ERRORS.readOnly(id, where(id))] };
208
+ let entry;
209
+ try { entry = await r.addModel(input.model, { dryRun: true }); } catch (err) { return { ok: false, errors: [err.message] }; }
210
+ const shadows = r.predefined.find((p) => p.id.toLowerCase() === entry.id.toLowerCase());
211
+ return { ok: true, card: {
212
+ type: 'model', kind, target: entry.id, summary: `Add model ${entry.label || entry.id}${shadows ? ` (overrides the built-in ${shadows.label})` : ''}`,
213
+ note, rows: entryRows(maskEntry(entry)).map((x) => ({ field: x.field, before: null, after: x.value })),
214
+ warnings: entryWarnings(entry), change: { model: input.model },
215
+ } };
216
+ }
217
+ const id = str(input.id) || (isObj(input.model) ? str(input.model.id) : '');
218
+ if (!id) return { ok: false, errors: [MODEL_ERRORS.idRequired(kind)] };
219
+ const current = findGlobal(id);
220
+ if (!current) {
221
+ const w = where(id);
222
+ if (w) return { ok: false, errors: [MODEL_ERRORS.readOnly(id, w)] };
223
+ if (r.predefined.some((p) => p.id.toLowerCase() === id.toLowerCase())) return { ok: false, errors: [MODEL_ERRORS.builtin(id)] };
224
+ return { ok: false, errors: [MODEL_ERRORS.unknownModel(id)] };
225
+ }
226
+ if (kind === 'remove_model') {
227
+ const refs = r.modelRefs(current.id);
228
+ const n = (refs.steps || []).length + (refs.nodes || []).length;
229
+ const warnings = [];
230
+ if (n) warnings.push(`${n} workflow ${n === 1 ? 'node uses' : 'nodes use'} this model — ${n === 1 ? 'it falls' : 'they fall'} back to the default model`);
231
+ if (refs.predefinedShadow) warnings.push('this entry overrides a built-in model — removing it restores the built-in');
232
+ if (refs.memoryDefrag) warnings.push('Memory defragment runs use this model (Settings › Memory) — they fall back to the default');
233
+ return { ok: true, card: {
234
+ type: 'model', kind, target: current.id, summary: `Remove model ${current.label || current.id}`, note,
235
+ rows: entryRows(maskEntry(current)).map((x) => ({ field: x.field, before: x.value, after: null })),
236
+ warnings, change: { id: current.id },
237
+ } };
238
+ }
239
+ // edit_model
240
+ const patch = isObj(input.model) ? { ...input.model } : null;
241
+ if (patch) delete patch.id;
242
+ if (!patch || !['label', 'efforts', 'env', 'cost', 'upstream'].some((k) => patch[k] !== undefined)) return { ok: false, errors: [MODEL_ERRORS.editEmpty] };
243
+ const errs = secretErrors(patch);
244
+ if (errs.length) return { ok: false, errors: errs };
245
+ let next;
246
+ try { next = await r.updateModel(current.id, mergeEditPatch(current, patch), { dryRun: true }); } catch (err) { return { ok: false, errors: [err.message] }; }
247
+ const rows = diffRows(maskEntry(current), maskEntry(next));
248
+ if (!rows.length) return { ok: false, errors: [MODEL_ERRORS.noChange] };
249
+ return { ok: true, card: {
250
+ type: 'model', kind, target: current.id, summary: `Edit model ${current.label || current.id}`, note,
251
+ rows, warnings: entryWarnings(next), change: { id: current.id, patch },
252
+ } };
253
+ }
254
+
255
+ async function validateProvider(input) {
256
+ const note = clip(str(input.note), 200) || null;
257
+ const name = str(input.provider);
258
+ if (!UPSTREAM_PROVIDERS.includes(name)) return { ok: false, errors: [MODEL_ERRORS.provider] };
259
+ if (!isObj(input.set) || !Object.keys(input.set).length) return { ok: false, errors: [MODEL_ERRORS.providerSet] };
260
+ const set = { ...input.set };
261
+ for (const k of ['githubToken', 'acknowledgedTerms', 'termsVersion', 'login']) if (k in set) return { ok: false, errors: [MODEL_ERRORS.signIn(k)] };
262
+ if ('apiKey' in set && !isClear(set.apiKey) && !(typeof set.apiKey === 'string' && modelEnvRef(set.apiKey.trim()))) {
263
+ return { ok: false, errors: [MODEL_ERRORS.secretKey(`${name} apiKey`)] };
264
+ }
265
+ const before = r.providerConfig(name);
266
+ let after;
267
+ try { after = await r.updateProvider(name, set, { dryRun: true }); } catch (err) { return { ok: false, errors: [err.message] }; }
268
+ const fields = ['baseUrl', 'apiKey', 'maxConcurrent', 'accountType'];
269
+ const label = { baseUrl: 'Base URL', apiKey: 'API key', maxConcurrent: 'Max concurrent requests', accountType: 'Account type' };
270
+ const shown = (f, v) => (v == null ? null : String(f === 'apiKey' ? maskValue(v) : v));
271
+ const rows = fields
272
+ .map((f) => ({ field: label[f], before: shown(f, before[f]), after: shown(f, after[f]) }))
273
+ .filter((x) => x.before !== x.after);
274
+ if (!rows.length) return { ok: false, errors: [MODEL_ERRORS.noChange] };
275
+ const warnings = [];
276
+ const keyRef = typeof after.apiKey === 'string' ? modelEnvRef(after.apiKey) : null;
277
+ if (keyRef && !r.envHas(keyRef)) warnings.push(`\${${keyRef}} is not set in worca's environment — set it and restart Worca`);
278
+ if (name !== 'copilot' && !after.apiKey && !(name === 'openai' && isLocalBaseUrl(after.baseUrl))) {
279
+ warnings.push(`no API key — models through ${name} show "needs sign-in" until the user sets one`);
280
+ }
281
+ const title = { copilot: 'GitHub Copilot', openai: 'OpenAI-compatible', anthropic: 'Anthropic-compatible' }[name];
282
+ return { ok: true, card: {
283
+ type: 'model', kind: 'provider', target: name, summary: `Change the ${title} provider`, note,
284
+ rows, warnings, change: { provider: name, set },
285
+ } };
286
+ }
287
+
288
+ async function validateImport(input) {
289
+ const note = clip(str(input.note), 200) || null;
290
+ const ids = Array.isArray(input.ids) ? [...new Set(input.ids.map((s) => str(s)).filter(Boolean))] : [];
291
+ if (!ids.length || ids.length > MAX_IMPORT) return { ok: false, errors: [MODEL_ERRORS.importIds] };
292
+ if (typeof r.copilotModels !== 'function') return { ok: false, errors: ['the Copilot model list is unavailable'] };
293
+ let list;
294
+ try { list = await r.copilotModels(); } catch (err) { return { ok: false, errors: [err.message] }; }
295
+ const byId = new Map(list.map((m) => [m.id, m]));
296
+ const unknown = ids.filter((id) => !byId.has(id));
297
+ if (unknown.length) return { ok: false, errors: [`not offered to this Copilot account: ${unknown.join(', ')} — list_copilot_models shows what is`] };
298
+ const rows = ids.map((id) => { const m = byId.get(id); return { field: m.name || id, before: m.inCatalog ? 'in catalog' : null, after: m.inCatalog ? 'capabilities refreshed' : `added as copilot-${id}` }; });
299
+ return { ok: true, card: {
300
+ type: 'model', kind: 'import_copilot', target: 'copilot',
301
+ summary: `Import ${ids.length} Copilot model${ids.length === 1 ? '' : 's'}`, note, rows, warnings: [], change: { ids },
302
+ } };
303
+ }
304
+
305
+ /**
306
+ * import_endpoint: models an OpenAI-compatible server on this machine (or the LAN) serves.
307
+ * The endpoint is asked again here — the card must show the window and tool support the server
308
+ * reports NOW, and a row it will not import (an embedding model, one with no tool calls) is
309
+ * refused rather than silently dropped at apply time.
310
+ */
311
+ async function validateEndpointImport(input) {
312
+ const note = clip(str(input.note), 200) || null;
313
+ const ids = Array.isArray(input.ids) ? [...new Set(input.ids.map((s) => str(s)).filter(Boolean))] : [];
314
+ if (!ids.length || ids.length > MAX_IMPORT) return { ok: false, errors: [MODEL_ERRORS.endpointIds] };
315
+ if (typeof r.endpointModels !== 'function') return { ok: false, errors: ['endpoint discovery is unavailable'] };
316
+ const baseUrl = str(input.baseUrl);
317
+ let out;
318
+ try { out = await r.endpointModels(baseUrl); } catch (err) { return { ok: false, errors: [err.message] }; }
319
+ const byId = new Map((out.models || []).map((m) => [m.id, m]));
320
+ const unknown = ids.filter((id) => !byId.has(id));
321
+ if (unknown.length) return { ok: false, errors: [`${out.serverLabel || 'the endpoint'} at ${out.baseUrl} does not serve: ${unknown.join(', ')} — list_endpoint_models shows what it does`] };
322
+ const blocked = ids.map((id) => byId.get(id)).filter((m) => m.importable === false);
323
+ if (blocked.length) return { ok: false, errors: blocked.map((m) => `${m.id}: ${m.blocked}`) };
324
+ const rows = ids.map((id) => {
325
+ const m = byId.get(id);
326
+ const window = m.servedContext ? `${m.servedContext} tokens` : m.trainedContext ? `window not reported (supports ${m.trainedContext})` : 'window not reported';
327
+ return { field: m.name || id, before: m.inCatalog ? 'in catalog' : null, after: `${m.inCatalog ? 'refreshed' : `added as ${m.catalogId}`} · ${window}` };
328
+ });
329
+ const warnings = [...(Array.isArray(out.warnings) ? out.warnings : [])];
330
+ for (const id of ids) {
331
+ const m = byId.get(id);
332
+ if (!m.servedContext) warnings.push(`${m.id}: the server does not report the window it serves, so the entry gets no prompt limit — set it once you know it, or the CLI assumes 200k`);
333
+ else if (m.servedContext < LOCAL_MIN_WINDOW) warnings.push(`${m.id}: ${m.servedContext} tokens is below the ${LOCAL_MIN_WINDOW} a pipeline needs`);
334
+ }
335
+ return { ok: true, card: {
336
+ type: 'model', kind: 'import_endpoint', target: out.baseUrl,
337
+ summary: `Import ${ids.length} model${ids.length === 1 ? '' : 's'} from ${out.serverLabel || 'the endpoint'}`,
338
+ note, rows, warnings, change: { ids, ...(baseUrl ? { baseUrl } : {}) },
339
+ } };
340
+ }
341
+
342
+ /** @returns {Promise<{ok:true, card:object}|{ok:false, errors:string[]}>} */
343
+ return async function validateModelChange(input) {
344
+ const inp = isObj(input) ? input : {};
345
+ const kind = str(inp.kind);
346
+ if (!MODEL_CHANGE_KINDS.includes(kind)) return { ok: false, errors: [MODEL_ERRORS.kind] };
347
+ if (kind === 'provider') return validateProvider(inp);
348
+ if (kind === 'import_copilot') return validateImport(inp);
349
+ if (kind === 'import_endpoint') return validateEndpointImport(inp);
350
+ return validateModel(kind, inp);
351
+ };
352
+ }
353
+
354
+ const eventText = (s, n) => clip(s, n).replace(/"/g, "'").replace(/\[(\/?)worca context\]/gi, '($1worca context)');
355
+
356
+ /** `[worca event] …` — the synthetic turn's prompt after the user acted on a model card. */
357
+ export function modelEventPrompt({ cardId, state, card = {}, result = null }) {
358
+ const summary = eventText(card.summary, 200);
359
+ if (state === 'declined') return `[worca event] model card ${cardId} declined; "${summary}"`;
360
+ if (state === 'failed') return `[worca event] model card ${cardId} failed: ${eventText(result?.error || 'unknown error', 200)}; "${summary}"`;
361
+ return `[worca event] model card ${cardId} applied; "${summary}"${result?.detail ? `; ${eventText(result.detail, 300)}` : ''}`;
362
+ }
363
+
364
+ /** The user-row notice above the event turn. */
365
+ export function modelNoticeText({ state, card = {}, result = null }) {
366
+ const s = clip(card.summary, 160);
367
+ if (state === 'declined') return `Declined — ${s}`;
368
+ if (state === 'failed') return `Could not apply — ${s}: ${clip(result?.error || 'unknown error', 200)}`;
369
+ return `Applied — ${s}${result?.detail ? ` · ${clip(result.detail, 200)}` : ''}`;
370
+ }
@@ -95,6 +95,18 @@ export function createAskModels({
95
95
  // shows no ⚠cost here. Pre-existing gap, shared with the pipeline dropdown and
96
96
  // /api/config; fixing it means editing composeCatalog and its three other consumers.
97
97
  if (m.costUnreliable === true) entry.costUnreliable = true;
98
+ // Model bridge (model-bridge-design.md §8.5/§8.7): the picker skips a
99
+ // bridged entry whose provider is not usable, and shows why.
100
+ if (m.bridged) {
101
+ entry.bridged = m.bridged;
102
+ if (m.upstreamApi) entry.upstreamApi = m.upstreamApi;
103
+ if (m.needsSignIn) {
104
+ entry.needsSignIn = true;
105
+ entry.signInMessage = m.signInReason === 'terms' ? 'GitHub Copilot notice not acknowledged — Settings › Providers.'
106
+ : m.signInReason === 'no_key' ? `No API key for ${m.bridged} — Settings › Providers.`
107
+ : `Not signed in to ${m.bridged} — Settings › Providers.`;
108
+ }
109
+ }
98
110
  if (custom === 'plugin' && withSecrets) {
99
111
  if (!missing) missing = missingSecretsByIdLc();
100
112
  const keys = missing.get(m.id.toLowerCase());
@@ -0,0 +1,124 @@
1
+ // src/core/ask/policy-deps.mjs
2
+ // The team-policy dependency bundle of the Ask Worca tools (docs/team-policy.md "Ask Worca"): the
3
+ // ONE module that touches the policy core on the tools' behalf (tools.mjs may not import anything).
4
+ // Two callers:
5
+ // • the MCP child (mcp-stdio.mjs): deps.policy.* behind list_projects' `policy`, get_team_policy
6
+ // and propose_policy_change (validate only — the card is the user's to apply);
7
+ // • the parent: turn.mjs re-validates a proposal with validatePolicyChange, and the cards route
8
+ // applies a confirmed card with applyPolicyChange.
9
+ // Domain-level like metrics-deps.mjs: homes, follows, fields and caps — never a worktree path.
10
+ import { listProjects } from '../projects.mjs';
11
+ import { readWorkspace, updateWorkspace } from '../workspaces.mjs';
12
+ import { projectKey } from '../store.mjs';
13
+ import {
14
+ listPolicyScopes, projectPolicyStatus, enableTeamPolicy, publishPolicy, routeWorkspaceMembersPolicy, policyEvents,
15
+ } from '../policy/sync.mjs';
16
+ import { policyForScope, policyPayload } from '../policy/scope.mjs';
17
+ import { createPolicyChangeValidator, normalizeEditOps, applyEditOps } from './policy-proposal.mjs';
18
+
19
+ async function scopePolicy(scope) {
20
+ const out = await policyForScope(scope);
21
+ const payload = out.r && out.r.ok ? policyPayload(out.meta, out.r, { workspaceRun: out.workspaceRun, projectDir: out.projectDir }) : null;
22
+ return { ...out, canPublish: !!payload?.canPublish, payload };
23
+ }
24
+
25
+ async function followersOf(home) {
26
+ const s = await listPolicyScopes();
27
+ const h = (s.homes || []).find((x) => x.slug === home);
28
+ return h ? h.usedBy : [];
29
+ }
30
+
31
+ /** The authoritative validator, over the real readers (the turn's default; the child's too). */
32
+ export const validatePolicyChange = createPolicyChangeValidator({
33
+ listProjects, readWorkspace, projectKeyOf: projectKey,
34
+ projectStatus: (p) => projectPolicyStatus(p, { discover: false }),
35
+ scopePolicy, followersOf,
36
+ });
37
+
38
+ /**
39
+ * Apply a CONFIRMED policy card (ui/server.mjs cards route, after the user's click). Returns a
40
+ * result the card renders and the event turn quotes; throws the core's coded error on failure.
41
+ */
42
+ export async function applyPolicyChange(card, io = {}) {
43
+ const enable = io.enable ?? enableTeamPolicy;
44
+ const publish = io.publish ?? publishPolicy;
45
+ const update = io.updateWorkspace ?? updateWorkspace;
46
+ const route = io.route ?? routeWorkspaceMembersPolicy;
47
+ const resolveScope = io.scopePolicy ?? scopePolicy;
48
+ const projectPath = async (key) => {
49
+ const p = (await (io.listProjects ?? listProjects)()).find((x) => x && x.key === key);
50
+ if (!p) throw Object.assign(new Error(`unknown projectKey "${key}"`), { code: 'NOT_FOUND' });
51
+ return p.path;
52
+ };
53
+ switch (card.kind) {
54
+ case 'enable': {
55
+ const r = await enable(await projectPath(card.projectKey), {
56
+ mode: card.mode, delegateTo: card.delegateTo || null, change: card.change === true, title: card.title || '',
57
+ });
58
+ const action = r && r.action ? String(r.action) : 'enabled';
59
+ const detail = action === 'joined' ? 'the branch already existed — joined it'
60
+ : action === 'created' ? (card.mode === 'follow' ? `now follows ${card.delegateTo}` : 'branch created on origin')
61
+ : action === 'changed' ? `now follows ${card.delegateTo}` : action;
62
+ return { ok: true, action, detail };
63
+ }
64
+ case 'edit': {
65
+ // Re-read the policy NOW and replay the ops on it: a teammate's publish since the proposal
66
+ // is kept field by field, never overwritten by the card's snapshot.
67
+ const scope = card.projectKey ? { kind: 'project', id: card.projectKey } : { kind: 'workspace', id: card.workspaceId };
68
+ const { r, canPublish } = await resolveScope(scope);
69
+ if (!r || !r.ok) throw Object.assign(new Error(r?.detail || 'this scope no longer has a team policy'), { code: 'NOT_ENABLED' });
70
+ if (r.home !== card.home) throw Object.assign(new Error(`the policy home changed from ${card.home} to ${r.home} — ask again`), { code: 'BAD_REQUEST' });
71
+ if (!canPublish) throw Object.assign(new Error(`the policy home ${r.home} is not checked out on this machine`), { code: 'NOT_HOME' });
72
+ // The stored ops were validated at proposal time; re-normalise them against the fresh doc
73
+ // (an unset whose field a teammate already removed simply drops out).
74
+ const replay = {
75
+ set: (card.ops?.set || []).map((s) => ({ key: s.key, value: s.entry.value, kind: s.entry.kind, forWorkspaceRuns: s.block === 'workspaceRuns', ...Object.fromEntries(['onBreach', 'requireReason', 'window'].filter((a) => s.entry[a] !== undefined).map((a) => [a, s.entry[a]])) })),
76
+ unset: (card.ops?.unset || []).filter((u) => r.doc?.[u.block]?.[u.key]).map((u) => ({ key: u.key, forWorkspaceRuns: u.block === 'workspaceRuns' })),
77
+ title: card.ops?.title, notes: card.ops?.notes,
78
+ };
79
+ const n = normalizeEditOps(replay, r.doc);
80
+ if (n.errors) throw Object.assign(new Error(n.errors[0]), { code: 'BAD_REQUEST' });
81
+ const next = applyEditOps(r.doc, n.ops);
82
+ const out = await publish(r.homeDir, next, { message: card.message || null });
83
+ const sha = out && out.sha ? String(out.sha).slice(0, 7) : null;
84
+ const moved = card.baseSha && r.sha && card.baseSha !== r.sha ? ' (on top of a newer version a teammate published)' : '';
85
+ return { ok: true, sha: out?.sha ?? null, unchanged: !!out?.unchanged,
86
+ detail: out?.unchanged ? 'the branch already says this — nothing to commit' : `published ${sha || ''} to ${r.home}${moved}`.replace(/ {2,}/g, ' ') };
87
+ }
88
+ case 'workspace_home': {
89
+ const ws = await update(card.workspaceId, { policyProject: card.homePath ?? null });
90
+ try { policyEvents.emit('changed', { action: 'policy-home' }); } catch { /* a broken listener never fails the apply */ }
91
+ return { ok: true, detail: card.homePath ? `policy home is now ${card.homeProjectName || ws?.policyProject || ''}`.trim() : 'policy home cleared' };
92
+ }
93
+ case 'route_members': {
94
+ const r = await route(card.workspaceId);
95
+ const results = Array.isArray(r?.results) ? r.results : [];
96
+ const n = (k) => results.filter((x) => x.result === k).length;
97
+ return { ok: true, home: r?.home ?? null, results, detail: `${n('routed')} routed · ${n('skipped')} skipped · ${n('failed')} failed` };
98
+ }
99
+ default:
100
+ throw Object.assign(new Error(`unknown policy change kind "${card.kind}"`), { code: 'BAD_REQUEST' });
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @param {{threadId?:string|null}} [o] threadId unused today; keeps the *-deps.mjs signature uniform
106
+ */
107
+ export function defaultPolicyDeps({ threadId = null } = {}) { // eslint-disable-line no-unused-vars
108
+ return {
109
+ policy: {
110
+ /** Every project's and workspace's policy status (the Projects cells / workspace cards read the same). */
111
+ status: () => listPolicyScopes(),
112
+ /**
113
+ * One scope's policy folded against this machine (the Team policy page's payload) plus the
114
+ * machine-level deviations. Throws NOT_FOUND for an unknown scope; no policy → {policy:null}.
115
+ */
116
+ async read(scope) {
117
+ const { meta, r, payload } = await scopePolicy(scope);
118
+ if (!r || !r.ok) return { scope: meta, policy: null, reason: r?.reason || 'not-enabled', code: r?.code || null, detail: r?.detail || null };
119
+ return payload;
120
+ },
121
+ validateChange: validatePolicyChange,
122
+ },
123
+ };
124
+ }