@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
@@ -5,6 +5,12 @@
5
5
  // listener, routing on `data-field`. Capability rows are gated by META
6
6
  // BOOLEANS: a new agent's sidecar drives its panel with no UI change.
7
7
  import { resolveOrOutType } from '../../../src/shared/graph/ports.mjs';
8
+ import { DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS, wirableParams, hasParamsPort } from '../../../src/shared/graph/script-meta.mjs';
9
+ // The DOM primitives and the two script forms live in ../script-forms.mjs so the
10
+ // composer, the Scripts page's Overview tab and the Test tab share ONE copy (C3).
11
+ import {
12
+ h, field, select, toggle, number, renderParamsForm, renderPortEditor,
13
+ } from '../script-forms.mjs';
8
14
 
9
15
  const ARITY_KINDS = new Set(['and', 'or', 'combine']);
10
16
  const FLOW_TITLES = { task: 'Task', end: 'End', and: 'AND', or: 'OR', combine: 'Combine' };
@@ -16,51 +22,12 @@ const FLOW_BLURB = {
16
22
  combine: 'Joins its md inputs into one document, in port order.',
17
23
  };
18
24
 
19
- const h = (doc, tag, cls, text) => { const n = doc.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
20
- const field = (doc, cls, label) => { const w = h(doc, 'div', `ins-f ${cls}`); w.appendChild(h(doc, 'label', 'ins-label', label)); return w; };
21
-
22
- function select(doc, cls, name, label, items, value, { disabled = false, title = '' } = {}) {
23
- const wrap = field(doc, cls, label);
24
- if (title) wrap.title = title;
25
- const sel = h(doc, 'select', 'ins-select');
26
- sel.dataset.field = name;
27
- sel.disabled = Boolean(disabled);
28
- for (const opt of items) {
29
- const o = doc.createElement('option');
30
- o.value = opt.value; o.textContent = opt.text;
31
- if (opt.value === (value == null ? '' : String(value))) o.selected = true;
32
- sel.appendChild(o);
33
- }
34
- const shell = h(doc, 'span', 'ins-select-wrap'); // the product's .select-wrap idea: the chevron is a token-coloured ::after on a wrapper
35
- shell.appendChild(sel);
36
- wrap.appendChild(shell);
37
- return wrap;
38
- }
39
- function toggle(doc, cls, name, label, hint, { checked = false, disabled = false, title = '' } = {}) {
40
- const row = h(doc, 'div', `ins-tog ${cls}`);
41
- if (title) row.title = title;
42
- const box = doc.createElement('input');
43
- box.type = 'checkbox'; box.dataset.field = name; box.checked = Boolean(checked); box.disabled = Boolean(disabled);
44
- const body = h(doc, 'span', 'ins-tog-b');
45
- body.appendChild(h(doc, 'span', 'ins-tog-t', label));
46
- if (hint) body.appendChild(h(doc, 'small', 'ins-tog-h', hint));
47
- row.append(box, body);
48
- return row;
49
- }
50
25
  function head(doc, title, sub) {
51
26
  const w = h(doc, 'div', 'ins-head');
52
27
  w.appendChild(h(doc, 'div', 'ins-name', title));
53
28
  if (sub) w.appendChild(h(doc, 'div', 'ins-sub', sub));
54
29
  return w;
55
30
  }
56
- function number(doc, cls, name, label, value, min) {
57
- const wrap = field(doc, cls, label);
58
- const input = doc.createElement('input');
59
- input.type = 'number'; input.className = 'ins-number'; input.dataset.field = name;
60
- input.min = String(min); input.step = '1'; input.value = String(value);
61
- wrap.appendChild(input);
62
- return wrap;
63
- }
64
31
  /** Read-only listing of a node's resolved ports. */
65
32
  function portList(doc, ports) {
66
33
  const wrap = h(doc, 'div', 'ins-ports');
@@ -73,7 +40,7 @@ function portList(doc, ports) {
73
40
  item.appendChild(h(doc, 'i', p.synthetic ? 'gdot' : `dot ${p.type}`));
74
41
  item.appendChild(h(doc, 'span', 'pn', p.id));
75
42
  const bits = [p.type];
76
- if (p.synthetic) bits.push('engine');
43
+ if (p.synthetic || p.engine) bits.push('engine');
77
44
  else if (dir === 'in') bits.push(p.loop ? 'loop' : (p.required === false ? 'optional' : 'required'));
78
45
  else if (p.when && p.when !== 'always') bits.push(`on ${p.when}`);
79
46
  if (p.expands) bits.push('fan-out');
@@ -86,10 +53,9 @@ function portList(doc, ports) {
86
53
  zone('Outputs', ports.outputs, 'out');
87
54
  return wrap;
88
55
  }
89
-
90
- export function renderNodeInspector(node, { template, portsFn, meta = null, models = [], efforts = [], subagentModels = [], doc = globalThis.document } = {}) {
56
+ export function renderNodeInspector(node, { template, portsFn, meta = null, models = [], efforts = [], subagentModels = [], editorFor = null, doc = globalThis.document } = {}) {
91
57
  const ports = portsFn(node) || { inputs: [], outputs: [] };
92
- const root = h(doc, 'div', `ins-panel ins-${node.kind === 'agent' ? 'agent' : `flow ins-${node.kind}`}`);
58
+ const root = h(doc, 'div', `ins-panel ins-${node.kind === 'agent' ? 'agent' : node.kind === 'script' ? 'script' : `flow ins-${node.kind}`}`);
93
59
  root.dataset.nodeId = node.id;
94
60
  const body = h(doc, 'div', 'ins-body-in');
95
61
 
@@ -103,8 +69,8 @@ export function renderNodeInspector(node, { template, portsFn, meta = null, mode
103
69
  body.appendChild(select(doc, 'ins-effort', 'effort', 'Effort',
104
70
  [{ value: '', text: 'default' }, ...efforts.map((e) => ({ value: e, text: e }))], node.config.effort));
105
71
  if (meta && meta.fanOut) {
106
- body.appendChild(toggle(doc, 'ins-fanout', 'fanOut', 'Research fan-out', 'parallel research sub-agents',
107
- { checked: node.config.fanOut === true }));
72
+ body.appendChild(lv(toggle(doc, 'ins-fanout', 'fanOut', 'Research fan-out', 'parallel research sub-agents',
73
+ { checked: node.config.fanOut === true }), 'expert', node.config.fanOut === true));
108
74
  // What this node's sub-agents run on. Gated by the SAME meta flag as the
109
75
  // toggle above: an agent that cannot fan out has no children to place.
110
76
  // '' = unset — the run resolves the auto default (a per-spawn choice
@@ -117,7 +83,7 @@ export function renderNodeInspector(node, { template, portsFn, meta = null, mode
117
83
  // the locked option carries the stored value — and the full control
118
84
  // returns when the node moves back to a plain model.
119
85
  const chosen = node.config.model ? models.find((m) => m && m.id === node.config.model) : null;
120
- body.appendChild(chosen && chosen.routed
86
+ body.appendChild(lv(chosen && chosen.routed
121
87
  ? select(doc, 'ins-subagent', 'subagentModel', 'Sub-agent model',
122
88
  [{ value: node.config.subagentModel || '', text: 'same endpoint (locked)' }],
123
89
  node.config.subagentModel || '',
@@ -125,19 +91,44 @@ export function renderNodeInspector(node, { template, portsFn, meta = null, mode
125
91
  : select(doc, 'ins-subagent', 'subagentModel', 'Sub-agent model',
126
92
  [{ value: '', text: 'default (agent picks)' },
127
93
  ...subagentModels.map((m) => ({ value: m, text: m === 'auto' ? 'agent picks' : m }))],
128
- node.config.subagentModel));
94
+ node.config.subagentModel), 'expert', !!node.config.subagentModel));
129
95
  }
130
96
  if (meta && meta.asksQuestions) {
131
97
  const locked = Boolean(meta.questionsLocked);
132
98
  const saved = node.config.askQuestions;
133
- body.appendChild(toggle(doc, 'ins-questions', 'askQuestions', 'Ask questions', 'pauses the run for input', {
99
+ body.appendChild(lv(toggle(doc, 'ins-questions', 'askQuestions', 'Ask questions', 'pauses the run for input', {
134
100
  checked: locked ? Boolean(meta.questionsDefault) : (typeof saved === 'boolean' ? saved : Boolean(meta.questionsDefault)),
135
101
  disabled: locked,
136
102
  title: locked ? (meta.questionsDefault ? 'Always on for this agent' : 'Always off for this agent') : '',
137
- }));
103
+ }), 'expert', !locked && typeof saved === 'boolean'));
104
+ }
105
+ body.appendChild(lv(toggle(doc, 'ins-awaitall', 'awaitAll', 'Await all inputs', 'gate until every wire fires',
106
+ { checked: node.config.awaitAll === true }), 'expert', node.config.awaitAll === true));
107
+ } else if (node.kind === 'script') {
108
+ root.appendChild(head(doc, (meta && meta.displayName) || node.key || node.id, `${node.key} · ${node.id}`));
109
+ const chips = h(doc, 'div', 'ins-chiprow');
110
+ chips.append(h(doc, 'span', 'badge', (meta && meta.origin) || 'builtin'), h(doc, 'span', 'chip rt', (meta && meta.runtime) || 'script'));
111
+ body.appendChild(chips);
112
+ body.appendChild(renderParamsForm(meta, node.config, { doc, editorFor }));
113
+ const ms = Number.isInteger(node.config.timeoutMs) ? node.config.timeoutMs : ((meta && meta.timeoutMs) || DEFAULT_TIMEOUT_MS);
114
+ // Interface mode (docs/ui-levels.md): what the card RUNS is never hidden; per-node tuning and ports are expert.
115
+ const timeout = number(doc, 'ins-timeout', 'timeoutMs', 'Timeout (s)', Math.round(ms / 1000), 1);
116
+ timeout.querySelector('input').max = String(MAX_TIMEOUT_MS / 1000);
117
+ body.appendChild(lv(timeout, 'expert', Number.isInteger(node.config.timeoutMs)));
118
+ body.appendChild(lv(toggle(doc, 'ins-awaitall', 'awaitAll', 'Await all inputs', 'gate until every wire fires',
119
+ { checked: node.config.awaitAll === true }), 'expert', node.config.awaitAll === true));
120
+ // Offered only where ticking it takes effect: `meta` here is the registry entry, so the forced-on probe is honest.
121
+ // A ticked box always renders, so an opt-in V22 refuses (the script changed under the card) can still be un-ticked.
122
+ if (node.config.paramsPort === true || hasParamsPort(meta, { ...node.config, paramsPort: true })) {
123
+ body.appendChild(lv(toggle(doc, 'ins-paramsport', 'paramsPort', 'Params from a wire', `json sets: ${wirableParams(meta).map((p) => p.id).join(', ') || 'nothing'}`,
124
+ { checked: node.config.paramsPort === true }), 'expert', node.config.paramsPort === true));
138
125
  }
139
- body.appendChild(toggle(doc, 'ins-awaitall', 'awaitAll', 'Await all inputs', 'gate until every wire fires',
140
- { checked: node.config.awaitAll === true }));
126
+ body.appendChild(lv(h(doc, 'div', 'ins-sep'), 'expert'));
127
+ body.appendChild(lv(meta && meta.ports === 'config'
128
+ ? renderPortEditor(node.config.ports, { doc, hasVerdict: Boolean(meta && meta.verdict) })
129
+ : portList(doc, ports), 'expert'));
130
+ root.appendChild(body);
131
+ return root;
141
132
  } else {
142
133
  root.appendChild(head(doc, FLOW_TITLES[node.kind] || node.kind, node.id));
143
134
  body.appendChild(h(doc, 'p', 'ins-blurb', FLOW_BLURB[node.kind] || ''));
@@ -152,28 +143,40 @@ export function renderNodeInspector(node, { template, portsFn, meta = null, mode
152
143
  body.appendChild(h(doc, 'div', 'ins-resolved', resolved ? `forwards: ${resolved}` : 'unresolved'));
153
144
  }
154
145
  if (node.kind === 'task') {
155
- body.appendChild(toggle(doc, 'ins-seed', 'planStoreSeed', 'Seed the plan store', 'treat an attached plan as the run’s plan',
156
- { checked: node.config.planStoreSeed === true }));
146
+ body.appendChild(lv(toggle(doc, 'ins-seed', 'planStoreSeed', 'Seed the plan store', 'treat an attached plan as the run’s plan',
147
+ { checked: node.config.planStoreSeed === true }), 'expert', node.config.planStoreSeed === true));
157
148
  }
158
149
  if (node.kind === 'end') body.appendChild(h(doc, 'div', 'ins-result', ''));
159
150
  }
160
- body.appendChild(h(doc, 'div', 'ins-sep'));
161
- body.appendChild(portList(doc, ports));
151
+ body.appendChild(lv(h(doc, 'div', 'ins-sep'), 'expert'));
152
+ body.appendChild(lv(portList(doc, ports), 'expert'));
162
153
  root.appendChild(body);
163
154
  return root;
164
155
  }
165
156
 
166
157
  /** Loop wires carry the per-wire cycle budget; a plain wire must NOT expose one
167
158
  * (maxCycles on a non-loop wire is V13's error). */
159
+ /** Interface mode (docs/ui-levels.md): model and effort are the advanced inspector; everything else
160
+ * here is expert. `keep` = the control holds a non-default value, so it stays on screen. */
161
+ function lv(el, min, keep = false) {
162
+ el.dataset.minLevel = min;
163
+ if (keep) el.dataset.levelKeep = '1';
164
+ return el;
165
+ }
166
+
168
167
  export function renderWireInspector(wire, { loop = false, doc = globalThis.document } = {}) {
169
168
  const root = h(doc, 'div', `ins-panel ins-wire${loop ? ' ins-loop' : ''}`);
170
169
  root.dataset.wireId = wire.id;
171
170
  root.appendChild(head(doc, loop ? 'Loop wire' : 'Wire', `${wire.from.node}.${wire.from.port} → ${wire.to.node}.${wire.to.port}`));
172
171
  const body = h(doc, 'div', 'ins-body-in');
173
172
  if (loop) {
174
- body.appendChild(number(doc, 'ins-maxcycles', 'maxCycles', 'Max cycles',
175
- wire.config && Number.isInteger(wire.config.maxCycles) ? wire.config.maxCycles : 3, 1));
176
- body.appendChild(h(doc, 'small', 'ins-hint', 'How many times this loop may re-deliver before the gate asks.'));
173
+ const budgeted = !!(wire.config && Number.isInteger(wire.config.maxCycles));
174
+ body.appendChild(lv(number(doc, 'ins-maxcycles', 'maxCycles', 'Max cycles',
175
+ budgeted ? wire.config.maxCycles : 3, 1), 'expert', budgeted));
176
+ body.appendChild(lv(h(doc, 'small', 'ins-hint', 'How many times this loop may re-deliver before the gate asks.'), 'expert', budgeted));
177
+ const plain = h(doc, 'p', 'ins-blurb ins-loop-plain', 'A loop: work flows back here until it passes, up to a cycle limit. Expert mode sets the limit.');
178
+ plain.dataset.maxLevel = 'advanced';
179
+ body.appendChild(plain);
177
180
  } else {
178
181
  body.appendChild(h(doc, 'p', 'ins-blurb', 'Plain data wire. Delete it to rewire the target input.'));
179
182
  }
@@ -1,9 +1,11 @@
1
1
  // ui/public/graph/palette.mjs
2
- // The rail's Agents tab: one DISCLOSURE per domain (plus the PINNED Flow group),
2
+ // The rail's Agents tab: one DISCLOSURE per domain (plus the Scripts group and the PINNED Flow group),
3
3
  // a filter, and agent pills stacked one per row. Pure DOM + one delegated
4
4
  // controller; it never touches the template — it calls back into the composer,
5
5
  // which owns every mutation.
6
6
  export const FLOW_GROUP = 'flow';
7
+ /** The Scripts group (D4): its own disclosure between the domain groups and the pinned Flow group. */
8
+ export const SCRIPTS_GROUP = 'scripts';
7
9
  /** Flow pills advertise their ports the way agent pills do (pill line 2). */
8
10
  export const FLOW_PORT_LINE = {
9
11
  task: 'source · out task',
@@ -34,7 +36,7 @@ export function portLineOf(entry) {
34
36
  * then `general`, then the pinned Flow group. `shared` agents are folded into
35
37
  * every domain group; `placeable:false` agents are dropped everywhere (that is
36
38
  * how workspaceScanner never reaches a canvas). Empty groups are omitted. */
37
- export function paletteEntries(agents, { placedKinds = [] } = {}) {
39
+ export function paletteEntries(agents, { scripts = [], placedKinds = [] } = {}) {
38
40
  const list = (Array.isArray(agents) ? agents : []).filter((a) => a && a.placeable !== false)
39
41
  .map((a) => ({ ...a, order: typeof a.order === 'number' ? a.order : 99, domain: a.domain || 'general' }));
40
42
  const shared = list.filter((a) => a.domain === 'shared');
@@ -45,6 +47,13 @@ export function paletteEntries(agents, { placedKinds = [] } = {}) {
45
47
  const groups = domains
46
48
  .map((domain) => ({ domain, flow: false, agents: [...shared, ...list.filter((a) => a.domain === domain)].sort(byOrder) }))
47
49
  .filter((g) => g.agents.length);
50
+ // Scripts: one group, ordered by `order`, placeable only; a config-ported
51
+ // script advertises "in/out (per card)" because its ports live on the card.
52
+ const scriptList = (Array.isArray(scripts) ? scripts : []).filter((s) => s && s.placeable !== false)
53
+ .map((s) => ({ ...s, kind: 'script', order: typeof s.order === 'number' ? s.order : 99 }))
54
+ .sort(byOrder)
55
+ .map((s) => ({ ...s, chip: s.runtime || '', portLine: s.ports === 'config' ? 'in/out (per card)' : portLineOf(s) }));
56
+ if (scriptList.length) groups.push({ domain: SCRIPTS_GROUP, scripts: true, flow: false, agents: scriptList });
48
57
  const placed = new Set(placedKinds);
49
58
  groups.push({
50
59
  domain: FLOW_GROUP, flow: true,
@@ -63,17 +72,24 @@ function h(doc, tag, cls, text) {
63
72
  function pill(doc, entry) {
64
73
  const btn = h(doc, 'button', 'ap');
65
74
  btn.type = 'button';
66
- if (entry.key) btn.dataset.key = entry.key; else btn.dataset.kind = entry.kind;
75
+ const script = entry.kind === 'script';
76
+ if (entry.key) { btn.dataset.key = entry.key; if (script) { btn.dataset.kind = 'script'; btn.dataset.rt = entry.chip || ''; } }
77
+ else btn.dataset.kind = entry.kind;
67
78
  btn.disabled = Boolean(entry.disabled);
68
79
  if (entry.disabled) btn.classList.add('dim');
69
80
  const dot = h(doc, 'span', 'd');
70
- dot.dataset.color = entry.kind ? 'flow' : (entry.color || 'blue');
81
+ dot.dataset.color = entry.kind && !script ? 'flow' : (entry.color || 'blue');
71
82
  const body = h(doc, 'span', 'b');
72
- body.append(h(doc, 'span', 'n', entry.displayName || entry.key || entry.kind),
83
+ const nrow = h(doc, 'span', 'nrow');
84
+ nrow.appendChild(h(doc, 'span', 'n', entry.displayName || entry.key || entry.kind));
85
+ if (script && entry.chip) nrow.appendChild(h(doc, 'span', 'chip rt', entry.chip));
86
+ body.append(nrow,
73
87
  h(doc, 'span', 'p pt', entry.portLine != null ? entry.portLine : portLineOf(entry)));
74
88
  btn.append(dot, body);
75
89
  if (entry.disabled) btn.appendChild(h(doc, 'span', 'chip', '1 placed'));
76
90
  if (entry.description) btn.title = entry.description;
91
+ // Logic nodes are expert building blocks (docs/ui-levels.md); Task and End stay with everyone.
92
+ if (entry.kind && !SINGLETON_KINDS.has(entry.kind)) btn.dataset.minLevel = 'expert';
77
93
  return btn;
78
94
  }
79
95
 
@@ -92,19 +108,21 @@ function chevron(doc) {
92
108
  return svg;
93
109
  }
94
110
 
95
- export function renderPalette(host, { agents = [], placedKinds = [], collapsed = new Set(), query = '', doc = globalThis.document } = {}) {
111
+ export function renderPalette(host, { agents = [], scripts = [], placedKinds = [], collapsed = new Set(), query = '', doc = globalThis.document } = {}) {
96
112
  if (!host) return;
97
- const groups = paletteEntries(agents, { placedKinds });
113
+ const groups = paletteEntries(agents, { scripts, placedKinds });
98
114
  const frag = doc.createDocumentFragment();
99
115
  for (const g of groups) {
100
116
  const sec = h(doc, 'section', `pal-group${g.flow ? ' pal-pinned' : ''}`);
101
117
  sec.dataset.domain = g.domain;
118
+ // Placing a card that runs a command is authoring: expert (docs/ui-levels.md).
119
+ if (g.scripts) sec.dataset.minLevel = 'expert';
102
120
  // The group HEAD is the control: there is no separate chip row, so a domain
103
121
  // can only be reached — and only be folded away — through its own header.
104
122
  const head = h(doc, 'button', 'pal-grp');
105
123
  head.type = 'button';
106
124
  head.dataset.domain = g.domain;
107
- head.append(chevron(doc), h(doc, 'span', 'lab', g.flow ? 'Flow' : g.domain), h(doc, 'span', 'chip', String(g.agents.length)));
125
+ head.append(chevron(doc), h(doc, 'span', 'lab', g.flow ? 'Flow' : g.scripts ? 'Scripts' : g.domain), h(doc, 'span', 'chip', String(g.agents.length)));
108
126
  if (g.flow) head.appendChild(h(doc, 'span', 'chip pinned-tag', 'pinned'));
109
127
  const pills = h(doc, 'div', 'pills');
110
128
  for (const a of g.agents) pills.appendChild(pill(doc, a));
@@ -129,7 +147,7 @@ export function applyFilter(host, query, collapsed = new Set()) {
129
147
  for (const sec of host.querySelectorAll('.pal-group')) {
130
148
  let any = false;
131
149
  for (const btn of sec.querySelectorAll('.ap')) {
132
- const hay = `${btn.querySelector('.n').textContent} ${btn.dataset.key || btn.dataset.kind || ''} ${btn.querySelector('.p').textContent}`.toLowerCase();
150
+ const hay = `${btn.querySelector('.n').textContent} ${btn.dataset.key || btn.dataset.kind || ''} ${btn.dataset.rt || ''} ${btn.querySelector('.p').textContent}`.toLowerCase();
133
151
  const show = !q || hay.includes(q);
134
152
  btn.hidden = !show;
135
153
  if (show) any = true;
@@ -7,8 +7,9 @@
7
7
  // labels read the manifest's port metadata (`loop: true`), statuses read the
8
8
  // execution ledger (state.steps[], one row per execution, key === executionId),
9
9
  // colours read the manifest node. History renders with the registry absent.
10
+ import { levelAtLeast } from '../ui-level.mjs';
10
11
  import { manifestPortsFn, manifestTemplate } from '../../../src/shared/graph/manifest.mjs';
11
- import { BOOKEND_EXECUTION_IDS, DEFAULT_MAX_CYCLES } from '../../../src/shared/graph/constants.mjs';
12
+ import { BOOKEND_EXECUTION_IDS, DEFAULT_MAX_CYCLES, KEYED_KINDS } from '../../../src/shared/graph/constants.mjs';
12
13
  import { fanLines } from '../../../src/shared/graph/geometry.mjs';
13
14
 
14
15
  /** The run-level warning a run that drained without binding End carries. */
@@ -69,7 +70,9 @@ export function manifestWires(stepper) {
69
70
  export function manifestAgents(stepper) {
70
71
  const out = {};
71
72
  for (const n of manifestNodes(stepper)) {
72
- if (n.kind === 'agent' && n.key && !out[n.key]) out[n.key] = { displayName: n.label || n.key, color: n.color || '', icon: n.icon || '' };
73
+ if (KEYED_KINDS.includes(n.kind) && n.key && !out[n.key]) {
74
+ out[n.key] = { displayName: n.label || n.key, color: n.color || '', icon: n.icon || '', ...(n.kind === 'script' ? { runtime: n.runtime || '' } : {}) };
75
+ }
73
76
  }
74
77
  return out;
75
78
  }
@@ -153,7 +156,7 @@ export function statusOf(node, rows, ctx) {
153
156
  * @param {object} st { stepper, status, steps, active, endReached, result, warnings, wireDeliveries, tokens, gate }
154
157
  * @param {{live?:boolean, now?:number, subsOf?:(nodeId:string)=>Array}} opts
155
158
  */
156
- export function decorFromState(st, { live = true, now = Date.now(), subsOf = null } = {}) {
159
+ export function decorFromState(st, { live = true, now = Date.now(), subsOf = null, lastLines = null } = {}) {
157
160
  const state = st || {};
158
161
  const stepper = state.stepper || null;
159
162
  const nodes = manifestNodes(stepper);
@@ -186,9 +189,9 @@ export function decorFromState(st, { live = true, now = Date.now(), subsOf = nul
186
189
  colors[node.id] = node.color || '';
187
190
  }
188
191
 
189
- // Progress = done AGENT nodes / AGENT nodes (D15: a number, never a bar).
190
- const agents = nodes.filter((n) => n.kind === 'agent');
191
- const progress = { done: agents.filter((n) => status[n.id] === 'done').length, total: agents.length };
192
+ // Progress = done KEYED nodes / keyed nodes (D15: a number, never a bar). A script is a keyed card.
193
+ const keyed = nodes.filter((n) => KEYED_KINDS.includes(n.kind));
194
+ const progress = { done: keyed.filter((n) => status[n.id] === 'done').length, total: keyed.length };
192
195
 
193
196
  // Active nodes, most recently started FIRST (the compact row and the pill name
194
197
  // the newest one; two or more collapse to "N agents running").
@@ -226,7 +229,7 @@ export function decorFromState(st, { live = true, now = Date.now(), subsOf = nul
226
229
  executions: rows.length, loopDeliveries,
227
230
  nodeIds: nodes.map((n) => n.id), wireIds: wires.map((w) => w.id), expanded: null,
228
231
  };
229
- decorateExecutions(decor, { stepper, nodes, wires, grouped, rows, activeList, rowFor, stepByExec, state, now, live, subsOf });
232
+ decorateExecutions(decor, { stepper, nodes, wires, grouped, rows, activeList, rowFor, stepByExec, state, now, live, subsOf, lastLines, status });
230
233
  return decor;
231
234
  }
232
235
 
@@ -289,26 +292,29 @@ export function execBandLayout(label, right) {
289
292
  }
290
293
 
291
294
  function decorateExecutions(decor, ctx) {
292
- const { nodes, wires, grouped, activeList, rowFor, state, now, live, subsOf } = ctx;
295
+ const { nodes, wires, grouped, activeList, rowFor, state, now, live, subsOf, lastLines, status } = ctx;
293
296
 
294
297
  for (const node of nodes) {
295
298
  const list = grouped.get(node.id) || [];
296
299
  // A FLOW node (task/and/or/combine/end) executes instantly and for free: its
297
300
  // rows carry no duration and no cost pill, and its card no header totals.
298
- const flow = node.kind !== 'agent';
301
+ const flow = !KEYED_KINDS.includes(node.kind);
302
+ // A SCRIPT runs a child process: real duration, no cost pill — an exit code where an agent shows cost (S5).
303
+ const script = node.kind === 'script';
299
304
  const rows = list.map((row) => {
300
305
  // A flow node executes instantly and for free: the NUMBERS are zeroed with
301
306
  // the pill texts, so `durMs`/`costUsd` never contradict `dur`/`cost` (a
302
307
  // consumer that sums the numbers must not pick up a flow row's real ms).
303
308
  const durMs = flow ? 0 : rowMs(row, now, live);
304
- const costUsd = flow ? 0 : round2(row.costUsd);
309
+ const costUsd = flow || script ? 0 : round2(row.costUsd);
305
310
  return {
306
311
  executionId: row.executionId, nodeId: node.id,
307
312
  kind: row.kind === 'task' ? 'task' : 'cycle',
308
313
  ordinal: Number(row.ordinal ?? row.cycle) || 1,
309
314
  label: rowLabel(node, row), led: ledOf(row.status),
310
315
  dur: !flow && row.activeMs != null ? fmtDur(durMs) : '',
311
- cost: flow ? '' : fmtUsd(costUsd),
316
+ cost: flow || script ? '' : fmtUsd(costUsd),
317
+ exit: script && row.exitCode != null ? `exit ${row.exitCode}` : '',
312
318
  durMs, costUsd, flow,
313
319
  };
314
320
  });
@@ -321,11 +327,17 @@ function decorateExecutions(decor, ctx) {
321
327
  if (rows.length || fan) {
322
328
  decor.footers[node.id] = { rows, summary: stripText(rows), leds: rows.map((r) => r.led), fan };
323
329
  }
330
+ // S4: the last captured line of a RUNNING script, when the page has one.
331
+ const liveLine = script && live && status[node.id] === 'active' && lastLines && typeof lastLines.get === 'function' ? lastLines.get(node.id) : null;
332
+ if (liveLine) {
333
+ if (!decor.footers[node.id]) decor.footers[node.id] = { rows: [], summary: '', leds: [], fan: null };
334
+ decor.footers[node.id].live = String(liveLine);
335
+ }
324
336
  if (rows.length && !flow) {
325
337
  const durMs = rows.reduce((a, r) => a + r.durMs, 0);
326
338
  const costUsd = sumUsd(rows);
327
339
  decor.totals[node.id] = {
328
- durMs, dur: fmtDur(durMs), costUsd, cost: fmtUsd(costUsd),
340
+ durMs, dur: fmtDur(durMs), costUsd, cost: script ? '' : fmtUsd(costUsd),
329
341
  hasStep: rows.some((r) => r.dur !== ''),
330
342
  };
331
343
  }
@@ -376,16 +388,20 @@ function decorateExecutions(decor, ctx) {
376
388
  */
377
389
  export function applyDecor(view, decor) {
378
390
  if (!view || !decor) return;
379
- const expanded = decor.expanded || null;
391
+ // Interface mode (docs/ui-levels.md): status colours, the gate pip, the End result and live wires
392
+ // are for everyone; per-node totals, the fan and execution strips and loop-count badges are
393
+ // expert detail. Gated HERE, not in CSS — a footer band sets the card's height.
394
+ const full = decor.detail !== undefined ? !!decor.detail : levelAtLeast('expert');
395
+ const expanded = full ? (decor.expanded || null) : null;
380
396
  for (const nodeId of decor.nodeIds || []) {
381
397
  view.setStatus(nodeId, decor.status[nodeId] || 'pending');
382
398
  view.setNodeChrome(nodeId, {
383
399
  color: decor.colors[nodeId] || '',
384
400
  gate: decor.gate && decor.gate.nodeId === nodeId
385
401
  ? { wireId: decor.gate.wireId, title: 'waiting on a loop gate — open the question panel' } : null,
386
- totals: decor.totals[nodeId] || null,
402
+ totals: full ? (decor.totals[nodeId] || null) : null,
387
403
  });
388
- const foot = decor.footers[nodeId] || null;
404
+ const foot = full ? (decor.footers[nodeId] || null) : null;
389
405
  const bands = [];
390
406
  if (foot && foot.fan) {
391
407
  bands.push({ kind: 'fan', leds: foot.fan.leds, count: foot.fan.count, lines: fanLines(foot.fan.leds.length) });
@@ -394,17 +410,18 @@ export function applyDecor(view, decor) {
394
410
  bands.push({ kind: 'strip', leds: foot.leds.slice(0, STRIP_LED_CAP), summary: foot.summary, expanded: expanded === nodeId });
395
411
  if (expanded === nodeId) {
396
412
  for (const r of foot.rows) {
397
- const right = [r.dur, r.cost].filter(Boolean).join(' · ');
413
+ const right = [r.dur, r.cost || r.exit].filter(Boolean).join(' · ');
398
414
  bands.push({ kind: 'exec', executionId: r.executionId, led: r.led, label: r.label,
399
415
  right, ...execBandLayout(r.label, right) });
400
416
  }
401
417
  }
402
418
  }
419
+ if (foot && foot.live) bands.push({ kind: 'live', text: foot.live });
403
420
  if (decor.endResult && decor.endResult.nodeId === nodeId) {
404
421
  bands.push({ kind: 'result', text: decor.endResult.text, path: decor.endResult.path });
405
422
  }
406
423
  view.setFooter(nodeId, bands);
407
424
  }
408
- for (const wireId of decor.wireIds || []) view.setWireBadge(wireId, decor.loopBadges[wireId] || null);
425
+ for (const wireId of decor.wireIds || []) view.setWireBadge(wireId, full ? (decor.loopBadges[wireId] || null) : null);
409
426
  view.setWireLive(decor.liveWireIds || []);
410
427
  }
@@ -134,6 +134,7 @@ export function mountRunGraph(hostEl, opts = {}) {
134
134
  }
135
135
  function buildNav() {
136
136
  nav = doc.createElement('div');
137
+ nav.dataset.minLevel = 'advanced'; // zoom cluster: interface mode (docs/ui-levels.md)
137
138
  nav.className = 'gv-nav';
138
139
  for (const [key, label, glyph] of NAV_BTNS) {
139
140
  const b = doc.createElement('button');
@@ -214,6 +215,7 @@ export function mountRunGraph(hostEl, opts = {}) {
214
215
  if (!isStatic) {
215
216
  const hint = doc.createElement('div');
216
217
  hint.className = 'rg-hint';
218
+ hint.dataset.minLevel = 'advanced';
217
219
  hint.textContent = HINT_TEXT;
218
220
  wrap.appendChild(hint);
219
221
  buildNav();
@@ -258,5 +260,9 @@ export function mountRunGraph(hostEl, opts = {}) {
258
260
  bound = false;
259
261
  }
260
262
 
261
- return { update, fit, destroy, get view() { return view; } };
263
+ /** Re-run the decor pass with the bag already held (an interface-mode change alters which
264
+ * footer bands show, hence the card heights, hence the fit). */
265
+ function repaint() { paint(); refit(); }
266
+
267
+ return { update, fit, repaint, destroy, get view() { return view; } };
262
268
  }
@@ -22,6 +22,9 @@ export function renderSaveDialog({ name = '', domain = '', domains = [], title =
22
22
  form.appendChild(nameWrap);
23
23
 
24
24
  const domWrap = h(doc, 'div', 'sd-field');
25
+ // Grouping by domain is expert housekeeping (docs/ui-levels.md); a non-default one stays visible.
26
+ domWrap.dataset.minLevel = 'expert';
27
+ if (domain && domain !== 'general') domWrap.dataset.levelKeep = '1';
25
28
  domWrap.appendChild(h(doc, 'label', null, 'Domain'));
26
29
  const domInput = doc.createElement('input');
27
30
  domInput.type = 'text'; domInput.className = 'sd-domain'; domInput.value = domain;
@@ -26,6 +26,7 @@ import { portsOf, resolveOrOutType } from '../../../src/shared/graph/ports.mjs';
26
26
  import { classifyLoops } from '../../../src/shared/graph/loops.mjs';
27
27
  import { thumbnailSvg } from '../../../src/shared/graph/thumbnail.mjs';
28
28
  import { sanitizeIcon } from '../../../src/shared/graph/manifest.mjs';
29
+ import { KEYED_KINDS } from '../../../src/shared/graph/constants.mjs';
29
30
 
30
31
  const SVG_NS = 'http://www.w3.org/2000/svg';
31
32
 
@@ -77,6 +78,10 @@ export function safeAgentIcon(meta) {
77
78
  return sanitizeIcon(raw) || USER_AGENT_ICON;
78
79
  }
79
80
 
81
+ // The ƒ glyph lives with the icon set (one source for the page's tile and this canvas).
82
+ import { SCRIPT_GLYPH } from '../../../src/shared/graph/script-icons.mjs';
83
+ export { SCRIPT_GLYPH };
84
+
80
85
  const dotClass = (t) => `dot ${t === 'md' || t === 'json' || t === 'void' || t === 'any' ? t : 'md'}`;
81
86
  const whenCaption = (w) => (w === 'blocking' ? 'on blocking' : w === 'clean' ? 'on clean' : '');
82
87
 
@@ -199,17 +204,19 @@ export function createGraphView(host, {
199
204
  }
200
205
 
201
206
  function headerOf(node) {
202
- if (node.kind === 'agent') {
203
- const meta = agents[node.key] || null;
207
+ if (KEYED_KINDS.includes(node.kind)) {
208
+ const meta = agents[node.key] || null; // the MERGED key -> meta index (agents + scripts)
209
+ const script = node.kind === 'script';
204
210
  return {
205
- cls: `h-${(meta && meta.color) || 'blue'}`,
211
+ cls: `h-${(meta && meta.color) || (script ? 'amber' : 'blue')}`,
206
212
  title: (meta && meta.displayName) || node.key || node.id,
207
- icon: safeAgentIcon(meta),
213
+ icon: safeAgentIcon(meta) || (script ? SCRIPT_GLYPH : ''),
208
214
  viewBox: AGENT_VIEWBOX,
215
+ chip: script ? ((meta && meta.runtime) || 'script') : '',
209
216
  };
210
217
  }
211
218
  const flow = FLOW_META[node.kind] || { title: node.kind, icon: '' };
212
- return { cls: 'h-flow', title: flow.title, icon: flow.icon, viewBox: FLOW_VIEWBOX };
219
+ return { cls: 'h-flow', title: flow.title, icon: flow.icon, viewBox: FLOW_VIEWBOX, chip: '' };
213
220
  }
214
221
 
215
222
  function portRow(port, dir, resolvedType) {
@@ -310,6 +317,12 @@ export function createGraphView(host, {
310
317
  row.append(h('i', 'led'), h('span', 'xl', band.label || ''), h('span', 'xr', band.right || ''));
311
318
  return row;
312
319
  }
320
+ if (band.kind === 'live') {
321
+ const l = h('div', 'xlive mono');
322
+ l.textContent = band.text || '';
323
+ l.title = band.text || '';
324
+ return l;
325
+ }
313
326
  const res = h('div', 'xresult'); // kind: 'result'
314
327
  if (!band.path) { res.textContent = band.text || ''; return res; }
315
328
  const a = h('a', null, band.text || '');
@@ -363,6 +376,7 @@ export function createGraphView(host, {
363
376
  if (sum && sum.textContent !== t) sum.textContent = t;
364
377
  return true;
365
378
  }
379
+ if (band.kind === 'live') { if (el.textContent !== band.text) { el.textContent = band.text || ''; el.title = band.text || ''; } return true; }
366
380
  if (band.kind === 'exec') {
367
381
  const cls = execRowClass(band);
368
382
  if (el.className !== cls) el.className = cls;
@@ -427,7 +441,7 @@ export function createGraphView(host, {
427
441
  el.style.height = `${box.h}px`;
428
442
  const head = el.querySelector(':scope > .nhead');
429
443
  const hd = headerOf(node);
430
- const sig = `${hd.cls}|${hd.title}|${hd.icon}`;
444
+ const sig = `${hd.cls}|${hd.title}|${hd.icon}|${hd.chip}`;
431
445
  if (head.dataset.sig !== sig) {
432
446
  head.dataset.sig = sig;
433
447
  head.className = `nhead ${hd.cls}`;
@@ -438,7 +452,9 @@ export function createGraphView(host, {
438
452
  icon.innerHTML = hd.icon;
439
453
  const tt = h('span', 'tt', hd.title);
440
454
  tt.title = hd.title; // A35: an ellipsised name keeps its tooltip
441
- head.replaceChildren(icon, tt);
455
+ const kids = [icon, tt];
456
+ if (hd.chip) kids.push(h('span', 'chip rt', hd.chip));
457
+ head.replaceChildren(...kids);
442
458
  }
443
459
  paintBand(el, node);
444
460
  paintBody(el, node, p, orType, awaitWired);
@@ -23,9 +23,20 @@ export function guardrailSummary(s) {
23
23
  function originBadge(doc, origin) {
24
24
  if (origin === 'builtin') return h(doc, 'span', 'badge waiting grv-origin', 'built-in');
25
25
  if (typeof origin === 'string' && origin.startsWith('plugin:')) return h(doc, 'span', 'badge violet grv-origin', origin);
26
+ // Team policy sets (gp:<id>): blue, like every policy origin; the title names the home.
27
+ if (typeof origin === 'string' && origin.startsWith('policy:')) {
28
+ const b = h(doc, 'span', 'badge blue grv-origin', 'policy');
29
+ b.title = `Shipped by the team policy on ${origin.slice(7)} — edit it on the Team policy page`;
30
+ return b;
31
+ }
26
32
  return h(doc, 'span', 'badge green grv-origin', 'user');
27
33
  }
28
34
 
35
+ /** Built-ins and team-policy sets are read-only here: View, Save as new set, never Delete. */
36
+ export function isReadOnlyGuardrailSet(s) {
37
+ return !!s && (s.origin === 'builtin' || (typeof s.origin === 'string' && s.origin.startsWith('policy:')));
38
+ }
39
+
29
40
  // renderGuardrailList(sets) -> <div.grv-list> of cards; built-ins get View + no Delete.
30
41
  export function renderGuardrailList(sets, { doc = globalThis.document } = {}) {
31
42
  const root = h(doc, 'div', 'grv-list');
@@ -39,8 +50,9 @@ export function renderGuardrailList(sets, { doc = globalThis.document } = {}) {
39
50
  body.appendChild(head);
40
51
  body.appendChild(h(doc, 'small', 'grv-summary hint', guardrailSummary(s.settings)));
41
52
  card.appendChild(body);
42
- if (s.origin !== 'builtin') {
53
+ if (!isReadOnlyGuardrailSet(s)) {
43
54
  const del = h(doc, 'button', 'btn-ghost grv-delete', 'Delete');
55
+ del.dataset.minLevel = 'expert'; // authoring sets is expert (docs/ui-levels.md)
44
56
  del.type = 'button';
45
57
  del.dataset.id = s.id;
46
58
  card.appendChild(del);
@@ -52,7 +64,7 @@ export function renderGuardrailList(sets, { doc = globalThis.document } = {}) {
52
64
  const open = h(doc, 'button', 'btn-ghost grv-edit grv-details', 'Details');
53
65
  open.type = 'button';
54
66
  open.dataset.id = s.id;
55
- open.title = s.origin === 'builtin' ? 'View' : 'Edit';
67
+ open.title = isReadOnlyGuardrailSet(s) ? 'View' : 'Edit';
56
68
  card.appendChild(open);
57
69
  root.appendChild(card);
58
70
  }
@@ -172,7 +184,7 @@ export function renderGuardrailEditor(set, { doc = globalThis.document, mode = '
172
184
  }
173
185
  if (readOnly) {
174
186
  head.appendChild(h(doc, 'b', 'grv-name', set.name));
175
- head.appendChild(h(doc, 'span', 'badge waiting grv-origin', 'built-in'));
187
+ head.appendChild(originBadge(doc, set.origin || 'builtin'));
176
188
  } else {
177
189
  const name = h(doc, 'input', 'input grv-name-input');
178
190
  name.type = 'text';