@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
@@ -27,7 +27,7 @@ export function memoryRoute(scopeKey, name = '') {
27
27
  return name ? `${base}/${encodeURIComponent(name)}` : base;
28
28
  }
29
29
 
30
- const BADGES = { fresh: ['No memory yet', ''], ok: ['Healthy', 'green'], due: ['Defragment due', 'amber'], overdue: ['Defragment overdue', 'red'] };
30
+ const BADGES = { fresh: ['No memory yet', ''], ok: ['Healthy', 'green'], due: ['Defragment due', 'amber'], overdue: ['Defragment overdue', 'red'], failing: ['Writes failing', 'red'] };
31
31
  export function healthBadge(level) {
32
32
  const [text, cls] = BADGES[level] || BADGES.fresh;
33
33
  return { text, cls };
@@ -45,6 +45,17 @@ export function formatWhen(iso) {
45
45
 
46
46
  const NO_HOST_HINT = 'Register a project on the Projects page to host the global defragment run.';
47
47
 
48
+ /** Settings › Memory's defragment model as the host hint names it — ` with Opus 5.5 · high` — from
49
+ * the report's `defragModel` ({ model, effort, label, stale } | null). '' when unset, so the unset
50
+ * sentence stays byte-identical. A model that left the catalog says so without naming a fallback:
51
+ * the run then uses the node layers — the host project's own pick or the template's model — and the
52
+ * global report cannot know which (memory-defrag-model.mjs). */
53
+ export function defragModelPhrase(dm) {
54
+ if (!dm || typeof dm.model !== 'string' || !dm.model) return '';
55
+ if (dm.stale) return ` without the Settings › Memory model (${dm.model} is no longer in the catalog)`;
56
+ return ` with ${dm.label || dm.model}${dm.effort ? ` · ${dm.effort}` : ''}`;
57
+ }
58
+
48
59
  /** The defragment glyph: three rows of blocks, the lower ones fragmented. Pure SVG elements (no
49
60
  * text node), so the button's textContent stays its label alone. */
50
61
  function defragIcon(doc) {
@@ -66,18 +77,24 @@ function defragIcon(doc) {
66
77
  * hosts its own, so `host` is ignored there); with no registered project the button is disabled
67
78
  * and the hint says so, in visible text as well as the title. While a run is live the button stays
68
79
  * ENABLED, reads "Defragmenting… open the run" and carries `data-run-id`: the controller routes to
69
- * `#running/<id>`.
80
+ * `#running/<id>`. The global host hint also names Settings › Memory's defragment model when one is
81
+ * set (`report.defragModel`, see defragModelPhrase).
70
82
  */
71
83
  export function renderHealthCard(report, { doc = globalThis.document, host = null } = {}) {
72
84
  const health = report?.health || {};
73
85
  const isGlobal = String(report?.scope || 'global') === 'global';
74
86
  const card = h(doc, 'div', 'card mem-health');
87
+ // Interface mode (docs/ui-levels.md): byte counters and Defragment are expert upkeep — until the
88
+ // store needs it. An overdue or failing store is a problem the owner must see at any mode.
89
+ card.dataset.minLevel = 'expert';
90
+ if (health.level === 'overdue' || health.level === 'failing') card.dataset.levelKeep = '1';
75
91
  const head = h(doc, 'div', 'mem-health-head');
76
92
  const { text, cls } = healthBadge(health.level);
77
93
  head.appendChild(h(doc, 'span', `badge${cls ? ` ${cls}` : ''}`, text));
78
94
  head.appendChild(h(doc, 'span', 'mem-counters',
79
95
  `${plural(health.files || 0, 'file')} · ${health.bytes || 0} bytes · ${health.alwaysOnBytes || 0} bytes always loaded · ${plural(health.writesSinceDefrag || 0, 'write')} since the last defragment` +
80
- (health.lastDefragAt ? ` · last defragmented ${formatWhen(health.lastDefragAt)}` : '')));
96
+ (health.lastDefragAt ? ` · last defragmented ${formatWhen(health.lastDefragAt)}` : '') +
97
+ (health.failedWrites ? ` · ${plural(health.failedWrites, 'failed write')}` : '')));
81
98
  // The ONE control sits at the head's right edge (margin-left:auto), styled like every other
82
99
  // secondary pill (btn-ghost) with the defragment glyph before its label.
83
100
  const runId = report?.defragRunId ? String(report.defragRunId) : '';
@@ -96,7 +113,7 @@ export function renderHealthCard(report, { doc = globalThis.document, host = nul
96
113
  }
97
114
  if (isGlobal && !runId) {
98
115
  card.appendChild(h(doc, 'small', 'hint mem-host-hint',
99
- host ? `Runs on ${host.name || host.key} — pick another project on the New pipeline page.` : NO_HOST_HINT));
116
+ host ? `Runs on ${host.name || host.key}${defragModelPhrase(report?.defragModel)} — pick another project on the New pipeline page.` : NO_HOST_HINT));
100
117
  }
101
118
  card.appendChild(h(doc, 'small', 'hint', 'A defragment run merges duplicate topics, splits overgrown files, drops stale rules and tightens hooks. It is an ordinary pipeline run; the previous files stay in History.'));
102
119
  return card;
@@ -175,6 +192,7 @@ export function collectEditor(rootEl) {
175
192
  /** The snapshot ring, newest first, one Restore per row (disabled while a defragment run is live). */
176
193
  export function renderMemoryHistory(snapshots, { doc = globalThis.document, locked = false } = {}) {
177
194
  const wrap = h(doc, 'div', 'mem-history');
195
+ wrap.dataset.minLevel = 'expert'; // snapshot restore: expert (docs/ui-levels.md)
178
196
  wrap.appendChild(h(doc, 'div', 'mem-list-title', 'History'));
179
197
  const list = Array.isArray(snapshots) ? [...snapshots].reverse() : [];
180
198
  if (!list.length) { wrap.appendChild(h(doc, 'div', 'hist-empty', 'No snapshots yet — every write takes one.')); return wrap; }
@@ -7,6 +7,8 @@
7
7
  // mv-promote, mv-save, mv-cancel, mv-env-add, mv-env-rm) so app.js wires ONE
8
8
  // delegated listener on the list container.
9
9
 
10
+ import { bridgedBadge, needsSignInPill, degradationLine, renderConnectionSection, collectConnection, applyConnectionMode } from './bridge-view.mjs';
11
+
10
12
  function h(doc, tag, cls, text) {
11
13
  const n = doc.createElement(tag);
12
14
  if (cls) n.className = cls;
@@ -77,15 +79,54 @@ export function suggestDuplicateId(id, takenIds = []) {
77
79
  * `globals` come MASKED from GET /api/models. `predefinedShadowedIds` marks
78
80
  * built-ins currently overridden by a global entry.
79
81
  */
80
- export function renderModelsList({ globals = [], legacy = [], plugins = [], predefined = [], efforts = [], hideBuiltin = false, projectName = '' } = {}, { doc = globalThis.document } = {}) {
82
+ /**
83
+ * The catalog. `query` / `filter` narrow it and `collapsed` folds a group away: with the built-ins,
84
+ * a plugin's models and a team policy's all listed at once the page ran to several screens, and the
85
+ * entry you came for was never the one on top.
86
+ * @param {{query?:string, filter?:string, collapsed?:object, highlight?:string[]}} [o]
87
+ */
88
+ export function renderModelsList({ globals = [], legacy = [], plugins = [], policy = [], predefined = [], efforts = [], hideBuiltin = false, projectName = '', query = '', filter = 'all', collapsed = {}, highlight = [] } = {}, { doc = globalThis.document } = {}) {
81
89
  const root = h(doc, 'div', 'mv-list');
82
90
  const predefLc = new Set(predefined.map((m) => m.id.toLowerCase()));
83
91
  const pluginLc = new Set(plugins.map((m) => m.id.toLowerCase()));
92
+ const q = String(query || '').trim().toLowerCase();
93
+ const hi = new Set((highlight || []).map((x) => String(x).toLowerCase()));
94
+ const searching = !!q || filter !== 'all';
95
+
96
+ /** Does this entry survive the search box and the chip? */
97
+ const keep = (m, source) => {
98
+ if (filter === 'imported' && !hi.has(String(m.id).toLowerCase())) return false;
99
+ if (filter === 'needs-setup' && !m.needsSignIn) return false;
100
+ if (!['all', 'imported', 'needs-setup'].includes(filter) && filter !== source) return false;
101
+ if (!q) return true;
102
+ return [m.id, m.label, m.plugin, m.home, m.upstream && m.upstream.model].filter(Boolean).some((v) => String(v).toLowerCase().includes(q));
103
+ };
84
104
 
85
- const section = (title, hint) => {
105
+ // Each group can fold: the header is the control, and its count is the answer to "is my model
106
+ // in there?" without opening it.
107
+ const sections = [];
108
+ const section = (title, hint, key = '') => {
86
109
  const s = h(doc, 'div', 'mv-section');
87
- s.appendChild(h(doc, 'h3', 'mv-section-title', title));
88
- if (hint) s.appendChild(h(doc, 'small', 'hint', hint));
110
+ s.dataset.section = key;
111
+ const head = h(doc, 'div', 'mv-section-head');
112
+ const btn = h(doc, 'button', 'mv-sec-toggle');
113
+ btn.type = 'button';
114
+ btn.dataset.section = key;
115
+ btn.appendChild(h(doc, 'span', 'mv-sec-caret', '▾'));
116
+ btn.appendChild(h(doc, 'h3', 'mv-section-title', title));
117
+ btn.appendChild(h(doc, 'span', 'mv-sec-count', ''));
118
+ head.appendChild(btn);
119
+ s.appendChild(head);
120
+ if (hint) s.appendChild(h(doc, 'small', 'hint mv-sec-hint', hint));
121
+ const body = h(doc, 'div', 'mv-sec-body');
122
+ s.appendChild(body);
123
+ // Searching opens every group that still has a hit — a match hidden inside a fold is a bug.
124
+ const folded = !searching && !!collapsed[key];
125
+ s.classList.toggle('is-folded', folded);
126
+ btn.setAttribute('aria-expanded', folded ? 'false' : 'true');
127
+ sections.push({ el: s, body, btn, key });
128
+ // The rows go into the body; every caller appends to the section, so proxy it.
129
+ s.appendChild = (node) => body.appendChild(node);
89
130
  return s;
90
131
  };
91
132
 
@@ -100,6 +141,26 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
100
141
  return b;
101
142
  };
102
143
 
144
+ // ── Toolbar: search + the chip that says which layer you are looking at ──
145
+ const bar = h(doc, 'div', 'mv-toolbar');
146
+ const search = h(doc, 'input', 'input mv-search');
147
+ search.type = 'search';
148
+ search.placeholder = 'Search models by id, label or upstream…';
149
+ search.value = query || '';
150
+ search.setAttribute('aria-label', 'Search models');
151
+ bar.appendChild(search);
152
+ const chips = h(doc, 'div', 'mv-filters');
153
+ const CHIPS = [['all', 'All'], ['global', 'Yours'], ['builtin', 'Built-in'], ['plugin', 'Plugin'], ['policy', 'Team'], ['needs-setup', 'Needs setup']];
154
+ if (highlight.length) CHIPS.push(['imported', 'Just imported']);
155
+ for (const [id, label] of CHIPS) {
156
+ const c = h(doc, 'button', `mv-filter${filter === id ? ' on' : ''}`, label);
157
+ c.type = 'button'; c.dataset.filter = id;
158
+ c.setAttribute('aria-pressed', filter === id ? 'true' : 'false');
159
+ chips.appendChild(c);
160
+ }
161
+ bar.appendChild(chips);
162
+ root.appendChild(bar);
163
+
103
164
  // ── Hide built-in models (#422) — one checkbox, top of the pane ──
104
165
  const hideRow = h(doc, 'div', 'mv-hide-builtin-row');
105
166
  const hideLabel = h(doc, 'label', 'check-row');
@@ -114,11 +175,11 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
114
175
  root.appendChild(hideRow);
115
176
 
116
177
  // ── Your models (global) ──
117
- const yours = section('Your models', 'Defined once, available in every project. An entry with a built-in id overrides that built-in.');
178
+ const yours = section('Your models', 'Defined once, available in every project. An entry with a built-in id overrides that built-in.', 'global');
118
179
  if (!globals.length) {
119
180
  yours.appendChild(h(doc, 'div', 'hist-empty', 'No global models yet — Add model to define one.'));
120
181
  }
121
- for (const m of globals) {
182
+ for (const m of globals.filter((x) => keep(x, 'global'))) {
122
183
  const card = h(doc, 'section', 'card mv-card');
123
184
  card.dataset.id = m.id;
124
185
  const body = h(doc, 'div', 'mv-body');
@@ -128,14 +189,22 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
128
189
  else if (pluginLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides plugin'));
129
190
  const rb = routedBadge(m);
130
191
  if (rb) head.appendChild(rb);
192
+ // Model bridge (model-bridge-design.md §8.5): the provider badge, and the
193
+ // blocking "needs sign-in" pill when that provider is not usable yet.
194
+ const bb = bridgedBadge(m, { doc });
195
+ if (bb) head.appendChild(bb);
196
+ const ns = needsSignInPill(m, { doc });
197
+ if (ns) head.appendChild(ns);
131
198
  // The §4.6 "unreliable" badge is meaningless once an override GOVERNS this
132
199
  // model's spend — and the backend only lifts the stored flag on the model's
133
200
  // next result event, so suppress it here the moment pricing is pinned.
134
201
  if (m.costUnreliable && !m.cost) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
135
202
  if (m.cost) head.appendChild(h(doc, 'span', 'badge violet mv-cost-pinned', m.cost.free ? 'free' : 'priced'));
136
203
  body.appendChild(head);
137
- const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env), costSummary(m.cost)].filter(Boolean);
204
+ const bits = [m.id, effortsSummary(m.efforts, efforts), m.upstream ? `→ ${m.upstream.model}` : '', envSummary(m.env), costSummary(m.cost)].filter(Boolean);
138
205
  body.appendChild(h(doc, 'small', 'mv-summary hint', bits.join(' — ')));
206
+ const deg = degradationLine(m);
207
+ if (deg) body.appendChild(h(doc, 'small', 'mv-degradation hint', deg));
139
208
  body.appendChild(h(doc, 'small', 'mv-test-result hint')); // app.js paints the Test outcome here
140
209
  card.appendChild(body);
141
210
  const del = h(doc, 'button', 'btn-ghost mv-delete', 'Delete');
@@ -185,8 +254,8 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
185
254
  const globalLc = new Set(globals.map((m) => m.id.toLowerCase()));
186
255
  if (plugins.length) {
187
256
  const plug = section('From plugins',
188
- 'Installed by plugins — read-only and updated with the plugin. "Edit a copy" clones one into Your models, which then overrides it.');
189
- for (const m of plugins) {
257
+ 'Installed by plugins — read-only and updated with the plugin. "Edit a copy" clones one into Your models, which then overrides it.', 'plugin');
258
+ for (const m of plugins.filter((x) => keep(x, 'plugin'))) {
190
259
  const card = h(doc, 'section', 'card mv-card mv-plugin');
191
260
  card.dataset.id = m.id;
192
261
  card.dataset.plugin = m.plugin;
@@ -197,13 +266,19 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
197
266
  if (globalLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden by your copy'));
198
267
  const prb = routedBadge(m);
199
268
  if (prb) head.appendChild(prb);
269
+ const pbb = bridgedBadge(m, { doc });
270
+ if (pbb) head.appendChild(pbb);
271
+ const pns = needsSignInPill(m, { doc });
272
+ if (pns) head.appendChild(pns);
200
273
  // Same rule as a global card: a manifest-pinned price governs the spend,
201
274
  // so the §4.6 "unreliable" flag says nothing about it.
202
275
  if (m.costUnreliable && !m.cost) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
203
276
  if (m.cost) head.appendChild(h(doc, 'span', 'badge violet mv-cost-pinned', m.cost.free ? 'free' : 'priced'));
204
277
  body.appendChild(head);
205
- const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env), costSummary(m.cost)].filter(Boolean);
278
+ const bits = [m.id, effortsSummary(m.efforts, efforts), m.upstream ? `→ ${m.upstream.model}` : '', envSummary(m.env), costSummary(m.cost)].filter(Boolean);
206
279
  body.appendChild(h(doc, 'small', 'mv-summary hint', bits.join(' — ')));
280
+ const pdeg = degradationLine(m);
281
+ if (pdeg) body.appendChild(h(doc, 'small', 'mv-degradation hint', pdeg));
207
282
  for (const s of m.secrets || []) {
208
283
  body.appendChild(h(doc, 'small', `mv-secret hint${s.set ? '' : ' err'}`,
209
284
  s.set ? `secret ${s.key}: set` : `secret ${s.key}: NOT SET — configure it in the plugin's settings`));
@@ -226,12 +301,41 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
226
301
  root.appendChild(plug);
227
302
  }
228
303
 
304
+ // ── From team policy (read-only; team-policy design §8) ──
305
+ if (policy.length) {
306
+ const pol = section('From team policy',
307
+ 'Shipped by a team policy — read-only and updated when the policy changes. Add a model with the same id to Your models to override one on this machine.', 'policy');
308
+ for (const m of policy.filter((x) => keep(x, 'policy'))) {
309
+ const card = h(doc, 'section', 'card mv-card mv-policy');
310
+ card.dataset.id = m.id;
311
+ const body = h(doc, 'div', 'mv-body');
312
+ const head = h(doc, 'div', 'mv-head');
313
+ head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
314
+ const badge = h(doc, 'span', 'badge blue mv-origin', 'policy');
315
+ badge.title = `Team policy on ${m.home}`;
316
+ head.appendChild(badge);
317
+ if (globalLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden by your copy'));
318
+ const rb = routedBadge(m);
319
+ if (rb) head.appendChild(rb);
320
+ body.appendChild(head);
321
+ const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env), m.home ? `policy ${m.home}` : ''].filter(Boolean);
322
+ body.appendChild(h(doc, 'small', 'mv-summary hint', bits.join(' — ')));
323
+ body.appendChild(h(doc, 'small', 'mv-test-result hint'));
324
+ card.appendChild(body);
325
+ const tst = h(doc, 'button', 'btn-ghost mv-test', 'Test');
326
+ tst.type = 'button'; tst.dataset.id = m.id;
327
+ card.appendChild(tst);
328
+ pol.appendChild(card);
329
+ }
330
+ root.appendChild(pol);
331
+ }
332
+
229
333
  // ── Built-ins (read-only) ──
230
334
  const builtins = section('Built-in models', hideBuiltin
231
335
  ? `Hidden from every picker (${predefined.length} built-in${predefined.length === 1 ? '' : 's'}) — untick the box above to show them.`
232
- : 'Shipped with worca. Add a model with the same id to override its label, efforts, or routing.');
336
+ : 'Shipped with worca. Add a model with the same id to override its label, efforts, or routing.', 'builtin');
233
337
  builtins.classList.add(hideBuiltin ? 'mv-builtins-hidden' : 'mv-builtins-shown');
234
- for (const m of hideBuiltin ? [] : predefined) {
338
+ for (const m of (hideBuiltin ? [] : predefined).filter((x) => keep(x, 'builtin'))) {
235
339
  const row = h(doc, 'div', 'mv-builtin');
236
340
  row.dataset.id = m.id;
237
341
  row.appendChild(h(doc, 'b', 'mv-name', m.label));
@@ -242,6 +346,18 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
242
346
  builtins.appendChild(row);
243
347
  }
244
348
  root.appendChild(builtins);
349
+
350
+ // Counts on every header, and a group with nothing left drops out while a search is on.
351
+ let shown = 0;
352
+ for (const sec of sections) {
353
+ const n = sec.body.querySelectorAll('.mv-card, .mv-builtin').length;
354
+ shown += n;
355
+ sec.btn.querySelector('.mv-sec-count').textContent = String(n);
356
+ if (searching && !n) sec.el.classList.add('hidden');
357
+ }
358
+ if (searching && !shown) {
359
+ root.appendChild(h(doc, 'div', 'hist-empty mv-no-hits', q ? `No model matches “${query}”.` : 'No model in this group.'));
360
+ }
245
361
  return root;
246
362
  }
247
363
 
@@ -278,7 +394,7 @@ function envRow(doc, key = '', value = '') {
278
394
  * Returns detached DOM; app.js wires mv-save / mv-cancel / mv-env-add /
279
395
  * mv-env-rm and calls collectModelEditor on save.
280
396
  */
281
- export function renderModelEditor(model, efforts, { doc = globalThis.document } = {}) {
397
+ export function renderModelEditor(model, efforts, { doc = globalThis.document, providers = null, copilotModels = [] } = {}) {
282
398
  const editing = !!model;
283
399
  const root = h(doc, 'section', 'card mv-editor');
284
400
  root.dataset.mode = editing ? 'edit' : 'create';
@@ -313,6 +429,11 @@ export function renderModelEditor(model, efforts, { doc = globalThis.document }
313
429
  labelInput.value = editing ? (model.label === model.id ? '' : model.label) : '';
314
430
  grid.appendChild(field('Label', labelInput));
315
431
 
432
+ // ── Connection (model-bridge-design.md §8.3): direct / env / provider ──
433
+ // Rendered first among the routing controls: it decides whether the env
434
+ // rows below carry the routing or Worca's own bridge does.
435
+ grid.appendChild(field('Connection', renderConnectionSection(model, { doc, providers, copilotModels })));
436
+
316
437
  const effWrap = h(doc, 'div', 'mv-efforts');
317
438
  const selected = new Set(editing && Array.isArray(model.efforts) ? model.efforts : efforts);
318
439
  for (const e of efforts) {
@@ -323,7 +444,9 @@ export function renderModelEditor(model, efforts, { doc = globalThis.document }
323
444
  lab.appendChild(h(doc, 'span', null, e));
324
445
  effWrap.appendChild(lab);
325
446
  }
326
- grid.appendChild(field('Supported efforts', effWrap, 'All checked = every effort (the default).'));
447
+ const effField = field('Supported efforts', effWrap, 'All checked = every effort (the default).');
448
+ effField.querySelector('.hint').classList.add('mv-efforts-hint'); // applyConnectionMode rewrites it
449
+ grid.appendChild(effField);
327
450
 
328
451
  const envWrap = h(doc, 'div', 'mv-env');
329
452
  const rows = editing && model.env ? Object.entries(model.env) : [];
@@ -331,7 +454,7 @@ export function renderModelEditor(model, efforts, { doc = globalThis.document }
331
454
  const add = h(doc, 'button', 'btn-ghost mv-env-add', '+ env var');
332
455
  add.type = 'button';
333
456
  grid.appendChild(field('Routing env (merged into the claude spawn for this model)', envWrap,
334
- 'e.g. ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. ANTHROPIC_MODEL sets the wire id sent to --model (the id above stays worca’s handle). Stored values show masked; leave masked to keep. WORCA_* and process keys are reserved.'));
457
+ 'e.g. ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. ANTHROPIC_MODEL sets the wire id sent to --model (the id above stays worca’s handle). Stored values show masked; leave masked to keep. WORCA_* and process keys are reserved. Through a provider, the bridge owns ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY / ANTHROPIC_MODEL — setting them here is refused.'));
335
458
  const envBtns = h(doc, 'div', 'mv-env-btns');
336
459
  envBtns.appendChild(add);
337
460
  if (rows.length) {
@@ -386,6 +509,7 @@ export function renderModelEditor(model, efforts, { doc = globalThis.document }
386
509
 
387
510
  root.appendChild(grid);
388
511
  setModelCost(root, editing ? model.cost : null); // grid is attached now — the block is reachable from root
512
+ applyConnectionModeIn(root); // efforts hint + collapse follow the Connection (now reachable)
389
513
  const msg = h(doc, 'p', 'form-msg mv-editor-msg');
390
514
  msg.setAttribute('aria-live', 'polite');
391
515
  root.appendChild(msg);
@@ -486,6 +610,10 @@ export function collectModelEditor(rootEl) {
486
610
  cost = { perMtok }; // empty -> the server rejects it by name, surfaced in the form
487
611
  }
488
612
 
613
+ // Connection (model-bridge-design.md §8.3): the object to store, or null to
614
+ // clear — like 'cli' for pricing, the form shows the truth.
615
+ const { upstream } = collectConnection(rootEl);
616
+
489
617
  const body = {
490
618
  ...(editing ? {} : { id }),
491
619
  label,
@@ -493,10 +621,20 @@ export function collectModelEditor(rootEl) {
493
621
  efforts: efforts.length === allCount ? [] : efforts,
494
622
  env,
495
623
  cost,
624
+ // Create mode has nothing to clear, so a null upstream is simply omitted
625
+ // and the POST body stays byte-identical for a non-bridged entry.
626
+ ...(upstream === undefined || (!editing && upstream === null) ? {} : { upstream }),
496
627
  };
497
628
  return { id: editing ? id : null, body };
498
629
  }
499
630
 
631
+ /** applyConnectionMode over an editor root (re-exported here so app.js's one
632
+ * delegated `change` handler for the editor needs a single import). */
633
+ export function applyConnectionModeIn(rootEl) {
634
+ const conn = rootEl && rootEl.querySelector && rootEl.querySelector('.mv-conn');
635
+ if (conn) applyConnectionMode(conn);
636
+ }
637
+
500
638
  // ── Share-as-plugin export wizard (design §9.5) ─────────────────────────────
501
639
 
502
640
  // Credential-looking env keys default to "Require at install". Word-ish
@@ -620,7 +758,14 @@ export function deleteRefsSummary(id, refs) {
620
758
  ...((refs && refs.nodes) || []).map((n) => n.projectKey),
621
759
  ...((refs && refs.steps) || []).map((s) => s.projectKey),
622
760
  ]).size;
623
- if (!nodes && !steps) return `Delete model "${id}"? No pipeline configuration references it.`;
761
+ // Settings › Memory's defragment model is the one GLOBAL ref (globalModelRefs `memoryDefrag`).
762
+ const defrag = !!(refs && refs.memoryDefrag);
763
+ if (!nodes && !steps) {
764
+ return defrag
765
+ ? `Delete model "${id}"? Memory defragment runs use it (Settings › Memory) — that setting is cleared and they fall back to the default.`
766
+ : `Delete model "${id}"? No pipeline configuration references it.`;
767
+ }
624
768
  return `Delete model "${id}"? This also clears ${nodes} node selection${nodes === 1 ? '' : 's'} and ` +
625
- `${steps} role selection${steps === 1 ? '' : 's'} across ${projects} project${projects === 1 ? '' : 's'}.`;
769
+ `${steps} role selection${steps === 1 ? '' : 's'} across ${projects} project${projects === 1 ? '' : 's'}` +
770
+ (defrag ? ', and the Memory defragment model (Settings › Memory).' : '.');
626
771
  }
@@ -142,9 +142,13 @@ export function pruneNodeSelection(row, next = {}) {
142
142
  // the setters ("select a model before choosing an effort").
143
143
  const inheritPair = (eff.model || '') === (row.def.model || '')
144
144
  && (eff.effort || '') === (row.def.effort || '');
145
+ // A PINNED row (Settings › Memory owns the pair) never edits model/effort, and the setters
146
+ // REPLACE both on every save: re-send the project's own stored pick untouched, so saving
147
+ // another tunable cannot erase it — it applies again the moment the setting is cleared.
148
+ const kept = row.pinned && row.storedPair ? row.storedPair : null;
145
149
  return {
146
- model: inheritPair ? '' : (eff.model || ''),
147
- effort: inheritPair || !eff.model ? '' : eff.effort,
150
+ model: kept ? kept.model : (inheritPair ? '' : (eff.model || '')),
151
+ effort: kept ? kept.effort : (inheritPair || !eff.model ? '' : eff.effort),
148
152
  fanOut: !!eff.fanOut === !!row.def.fanOut ? null : !!eff.fanOut,
149
153
  askQuestions: row.askQuestions === null || row.questionsLocked
150
154
  ? undefined // no capability / locked: never persist a value for it
@@ -168,6 +172,25 @@ export function buildGraphNodeRows(tpl, registry, runConfig, opts = {}) {
168
172
  // app.js hands its own panelPortsFn (falls back to the Composer's index for
169
173
  // agents the palette omits); the card and the tests use the registry alone.
170
174
  const portsFn = typeof opts.portsFn === 'function' ? opts.portsFn : portsFnFor(reg);
175
+ // Settings › Memory (the built-in Memory defragment workflow only): GET /api/workflows/:id
176
+ // stamps `pinnedAgentModel` when a valid pair is stored, and every run of the workflow then
177
+ // uses it for every agent node whatever the project picked (resolveGraph `agentPair`). So the
178
+ // rows SHOW the pair as the default and carry `pinned`; both renderers lock model and effort —
179
+ // an editable model the run ignores would be a control that lies.
180
+ const pin = tpl && tpl.pinnedAgentModel && typeof tpl.pinnedAgentModel.model === 'string' && tpl.pinnedAgentModel.model
181
+ ? tpl.pinnedAgentModel : null;
182
+ const withoutPair = ({ model: _m, effort: _e, ...rest }) => rest;
183
+ // A pinned row re-sends the project's hidden pick on every save (pruneNodeSelection), and the
184
+ // setter refuses a model that left the catalog or an effort it no longer offers — so heal the
185
+ // pick against the catalog the caller holds (`opts.models`), dropping exactly what an unpinned
186
+ // row's selects would drop. No catalog (not loaded yet) → the pick as stored.
187
+ const catalog = Array.isArray(opts.models) && opts.models.length ? opts.models : null;
188
+ const healPair = (model, effort) => {
189
+ if (!model || !catalog) return { model: model || '', effort: (model && effort) || '' };
190
+ const hit = catalog.find((m) => m && m.id === model);
191
+ if (!hit) return { model: '', effort: '' };
192
+ return { model, effort: effort && Array.isArray(hit.efforts) && hit.efforts.includes(effort) ? effort : '' };
193
+ };
171
194
  const order = classifyLoops(tpl, portsFn).launchOrder;
172
195
  const byId = new Map(tpl.nodes.map((n) => [n.id, n]));
173
196
  const rank = new Map(order.map((id, i) => [id, i]));
@@ -176,8 +199,12 @@ export function buildGraphNodeRows(tpl, registry, runConfig, opts = {}) {
176
199
  for (const node of agentNodes) {
177
200
  const meta = reg[node.key] || null;
178
201
  const role = legacySteps ? node.key : null;
179
- const saved = { ...(role ? legacySteps[role] : null), ...nodes[node.id] };
180
- const wfDef = (node.config && typeof node.config === 'object') ? node.config : {};
202
+ const stored = { ...(role ? legacySteps[role] : null), ...nodes[node.id] };
203
+ const authored = (node.config && typeof node.config === 'object') ? node.config : {};
204
+ const saved = pin ? withoutPair(stored) : stored;
205
+ const wfDef = pin
206
+ ? { ...withoutPair(authored), model: pin.model, ...(typeof pin.effort === 'string' && pin.effort ? { effort: pin.effort } : {}) }
207
+ : authored;
181
208
  const metaFan = meta && typeof meta.fanOut === 'boolean' ? meta.fanOut : false;
182
209
  const metaAsks = !!(meta && meta.asksQuestions);
183
210
  const metaLocked = !!(meta && meta.questionsLocked);
@@ -195,6 +222,8 @@ export function buildGraphNodeRows(tpl, registry, runConfig, opts = {}) {
195
222
  def: t.def, override: t.override,
196
223
  modified: modifiedFieldsOf(t, t.def,
197
224
  { asksQuestions: metaAsks, questionsLocked: metaLocked }).length > 0,
225
+ // `storedPair`: the project's own pick the pin hides — pruneNodeSelection re-sends it.
226
+ ...(pin ? { pinned: 'settings', storedPair: healPair(stored.model, stored.effort) } : {}),
198
227
  });
199
228
  }
200
229
  return rows;
@@ -34,7 +34,7 @@ function iconBtn(doc, cls, icon, label) {
34
34
  function contribSummary(c) {
35
35
  const n = (v) => (Array.isArray(v) ? v.length : (Number.isFinite(v) ? v : 0));
36
36
  const parts = [
37
- [n(c && c.agents), 'agent'], [n(c && c.taskSources), 'source'],
37
+ [n(c && c.agents), 'agent'], [n(c && c.scripts), 'script'], [n(c && c.taskSources), 'source'],
38
38
  [n(c && c.chatChannels), 'chat channel'], [n(c && c.models), 'model'],
39
39
  [n(c && c.skills), 'skill'], [n(c && c.workflows), 'workflow'],
40
40
  ].filter(([k]) => k > 0).map(([k, w]) => `${k} ${w}${k > 1 ? 's' : ''}`);
@@ -59,6 +59,8 @@ export function renderPluginList(plugins, { doc = globalThis.document, channelSt
59
59
  if (p.linked) head.appendChild(h(doc, 'span', 'badge waiting pl-linked', 'linked'));
60
60
  if (p.apiMismatch) head.appendChild(h(doc, 'span', 'badge amber pl-api-mismatch', 'needs update'));
61
61
  else if (p.broken) head.appendChild(h(doc, 'span', 'badge red pl-broken', 'broken'));
62
+ // A shipped python script on a host without python: a chip, never a block.
63
+ if (p.pythonMissing) head.appendChild(h(doc, 'span', 'badge amber pl-python-missing', 'python not found'));
62
64
  // Enabling a plugin acts on a live system the moment it flips, so it reads
63
65
  // as a switch. `.switch` MUST be the input's immediate next sibling — the
64
66
  // `.sw-input:checked + .switch` rule is what paints the on state.
@@ -100,6 +102,7 @@ export function renderPluginList(plugins, { doc = globalThis.document, channelSt
100
102
  const b = h(doc, 'button', `btn-ghost ${cls}`, label);
101
103
  b.type = 'button';
102
104
  b.dataset.name = p.name;
105
+ if (cls === 'pl-doctor') b.dataset.minLevel = 'expert'; // diagnostics (docs/ui-levels.md)
103
106
  actions.appendChild(b);
104
107
  }
105
108
  card.appendChild(actions);
@@ -126,6 +129,7 @@ export function channelBadge(doc, c) {
126
129
  // listener. Empty/nullish input -> childless container (app.js skips mounting).
127
130
  export function renderOrphanList(orphans, { doc = globalThis.document } = {}) {
128
131
  const root = h(doc, 'div', 'pl-orphans');
132
+ root.dataset.minLevel = 'expert'; // purge of leftover data (docs/ui-levels.md)
129
133
  if (!orphans || !orphans.length) return root;
130
134
  root.appendChild(h(doc, 'h3', 'pl-orphans-title', 'Leftover data'));
131
135
  for (const o of orphans) {
@@ -159,6 +163,24 @@ export function renderInstallConsent(entry, inventory, { doc = globalThis.docume
159
163
  for (const a of inv.agents || []) {
160
164
  agents.appendChild(h(doc, 'div', 'pl-consent-row mono',
161
165
  `${a.key} — tools: ${(a.tools || []).join(', ') || 'none declared'}`));
166
+ // Ask forms (spec §10): how many question forms this agent can put on
167
+ // screen, and which file types they may display from the run folder. An
168
+ // older snapshot has neither key, so both are read defensively.
169
+ const forms = Array.isArray(a.forms) ? a.forms : [];
170
+ if (forms.length) {
171
+ const types = Array.isArray(a.fileTypes) ? a.fileTypes : [];
172
+ agents.appendChild(h(doc, 'div', 'pl-consent-row pl-consent-forms',
173
+ `${forms.length} form${forms.length === 1 ? '' : 's'}: ${forms.join(', ')}`
174
+ + (types.length ? ` · may display ${types.join(', ')} from the run folder` : '')));
175
+ }
176
+ }
177
+ if ((inv.scripts || []).length) {
178
+ const scripts = section(`Scripts (${inv.scripts.length})`);
179
+ for (const s of inv.scripts) {
180
+ scripts.appendChild(h(doc, 'div', 'pl-consent-row mono',
181
+ `${s.key} — ${s.runtime}${s.command ? ` · ${s.command}` : s.file ? ` · ${s.file}` : ''}`
182
+ + `${s.cases ? ` · ${s.cases} case${s.cases === 1 ? '' : 's'}` : ''}`));
183
+ }
162
184
  }
163
185
  const sources = section(`Task sources (${(inv.taskSources || []).length})`);
164
186
  for (const s of inv.taskSources || []) {
@@ -124,14 +124,14 @@ export function workflowPickerLabel(wf, enabledPluginNames = []) {
124
124
  * Rows for the History Overview "Memory changes" block (agent-memory-design.md §6).
125
125
  * Pure: `memory` is results.json.memory / the detail's `memory` ({ changes }).
126
126
  * One row per change entry that carries at least one file; chips in the order
127
- * added, modified, deleted, rejected; a rejected chip's `title` is the reason.
127
+ * added, modified, deleted, rejected, failed; a rejected or failed chip's `title` is the reason.
128
128
  * Each chip also carries the ref it was built from — `scope` (mount-relative:
129
129
  * `global` | `project` | `projects/<key>`) and `name` — so History can link to
130
130
  * the file in its Memory view (B6).
131
131
  */
132
132
  export function memoryChangesRows(memory) {
133
133
  const changes = Array.isArray(memory?.changes) ? memory.changes : [];
134
- const file = (r) => `${r.scope}/${r.name}.md`;
134
+ const file = (r) => `${r.scope ? `${r.scope}/` : ''}${r.name}.md`;
135
135
  const rows = [];
136
136
  for (const c of changes) {
137
137
  const chips = [
@@ -139,6 +139,8 @@ export function memoryChangesRows(memory) {
139
139
  ...(c.modified || []).map((r) => ({ kind: 'mod', text: `~ ${file(r)}`, scope: r.scope, name: r.name })),
140
140
  ...(c.deleted || []).map((r) => ({ kind: 'del', text: `− ${file(r)}`, scope: r.scope, name: r.name })),
141
141
  ...(c.rejected || []).map((r) => ({ kind: 'rej', text: `✕ ${file(r)}`, title: String(r.reason || ''), scope: r.scope, name: r.name })),
142
+ // A write that never reached the mount (a refused tool call): nothing in the store to open.
143
+ ...(c.failed || []).map((r) => ({ kind: 'fail', text: `⊘ ${file(r)}`, title: String(r.reason || ''), scope: r.scope, name: r.name })),
142
144
  ];
143
145
  if (chips.length) rows.push({ node: c.agentKey || c.nodeId || 'run', chips });
144
146
  }