@worca/app 1.2.0 → 1.3.0-rc.2

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 (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3715 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1359 -214
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +423 -25
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1450 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1282 -193
@@ -0,0 +1,192 @@
1
+ // ui/public/memory-view.mjs
2
+ // Pure DOM renderers for the Memory view (agent-memory-design.md §10). Every function takes the
3
+ // target `document` via opts (defaults to the browser global) and returns DETACHED elements — no
4
+ // fetch, no listeners outside the returned tree (guardrails-view.mjs posture). app.js owns the
5
+ // endpoint calls and the mounting; node:test drives these via jsdom. Interactive elements carry a
6
+ // routing class (mem-new, mem-save, mem-cancel, mem-delete, mem-restore, mem-defrag) plus
7
+ // data-name / data-id, so the controller wires ONE delegated listener per host. The namespace is
8
+ // `mem-*`: `mv-*` belongs to Settings → Models (models-view.mjs). Every string that came from disk
9
+ // (names, hooks, bodies, reasons) is a textContent / value — never innerHTML.
10
+
11
+ function h(doc, tag, cls, text) {
12
+ const n = doc.createElement(tag);
13
+ if (cls) n.className = cls;
14
+ if (text != null) n.textContent = text;
15
+ return n;
16
+ }
17
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
18
+
19
+ /** The ONE name rule, shared by the editor's pre-check and the server's 400 text, so a user never
20
+ * sees two spellings of the same rule. The server (isValidMemoryName) stays the authority — this
21
+ * check is instant feedback, not a complete gate (it knows nothing about Win32 reserved stems). */
22
+ export const MEMORY_NAME_HELP = 'letters, digits, ".", "_" and "-" only, no extension, no leading or trailing dot';
23
+
24
+ /** The hash for a scope (and optionally one file): global lives under Settings, a project under its project page's Memory tab. */
25
+ export function memoryRoute(scopeKey, name = '') {
26
+ const base = scopeKey === 'global' ? 'settings/memory' : `${scopeKey}/memory`;
27
+ return name ? `${base}/${encodeURIComponent(name)}` : base;
28
+ }
29
+
30
+ const BADGES = { fresh: ['No memory yet', ''], ok: ['Healthy', 'green'], due: ['Defragment due', 'amber'], overdue: ['Defragment overdue', 'red'] };
31
+ export function healthBadge(level) {
32
+ const [text, cls] = BADGES[level] || BADGES.fresh;
33
+ return { text, cls };
34
+ }
35
+
36
+ /** `YYYY-MM-DD HH:MM` in LOCAL time (like fmtDate everywhere else in the app), or '' for anything
37
+ * unparsable — these dates are display-only. */
38
+ export function formatWhen(iso) {
39
+ const t = Date.parse(iso || '');
40
+ if (!Number.isFinite(t)) return '';
41
+ const d = new Date(t);
42
+ const p = (n) => String(n).padStart(2, '0');
43
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
44
+ }
45
+
46
+ const NO_HOST_HINT = 'Register a project on the Projects page to host the global defragment run.';
47
+
48
+ /** The defragment glyph: three rows of blocks, the lower ones fragmented. Pure SVG elements (no
49
+ * text node), so the button's textContent stays its label alone. */
50
+ function defragIcon(doc) {
51
+ const NS = 'http://www.w3.org/2000/svg';
52
+ const svg = doc.createElementNS(NS, 'svg');
53
+ svg.setAttribute('width', '13'); svg.setAttribute('height', '13'); svg.setAttribute('viewBox', '0 0 24 24');
54
+ svg.setAttribute('fill', 'currentColor'); svg.setAttribute('aria-hidden', 'true');
55
+ for (const [x, y, w] of [[3, 4, 18], [3, 10, 8], [13, 10, 8], [3, 16, 5], [10, 16, 11]]) {
56
+ const r = doc.createElementNS(NS, 'rect');
57
+ r.setAttribute('x', String(x)); r.setAttribute('y', String(y)); r.setAttribute('width', String(w)); r.setAttribute('height', '4'); r.setAttribute('rx', '1');
58
+ svg.appendChild(r);
59
+ }
60
+ return svg;
61
+ }
62
+
63
+ /**
64
+ * The health card: badge + reasons + counters + the ONE Defragment control (spec §10). `host` is
65
+ * `{ key, name } | null` — the project that would host a GLOBAL defragment run (a project scope
66
+ * hosts its own, so `host` is ignored there); with no registered project the button is disabled
67
+ * and the hint says so, in visible text as well as the title. While a run is live the button stays
68
+ * ENABLED, reads "Defragmenting… open the run" and carries `data-run-id`: the controller routes to
69
+ * `#running/<id>`.
70
+ */
71
+ export function renderHealthCard(report, { doc = globalThis.document, host = null } = {}) {
72
+ const health = report?.health || {};
73
+ const isGlobal = String(report?.scope || 'global') === 'global';
74
+ const card = h(doc, 'div', 'card mem-health');
75
+ const head = h(doc, 'div', 'mem-health-head');
76
+ const { text, cls } = healthBadge(health.level);
77
+ head.appendChild(h(doc, 'span', `badge${cls ? ` ${cls}` : ''}`, text));
78
+ head.appendChild(h(doc, 'span', 'mem-counters',
79
+ `${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)}` : '')));
81
+ // The ONE control sits at the head's right edge (margin-left:auto), styled like every other
82
+ // secondary pill (btn-ghost) with the defragment glyph before its label.
83
+ const runId = report?.defragRunId ? String(report.defragRunId) : '';
84
+ const btn = h(doc, 'button', 'btn btn-ghost btn-mini mem-defrag');
85
+ btn.type = 'button';
86
+ btn.appendChild(defragIcon(doc));
87
+ btn.appendChild(doc.createTextNode(runId ? 'Defragmenting… open the run' : 'Defragment'));
88
+ if (runId) btn.dataset.runId = runId;
89
+ else if (isGlobal && !host) { btn.disabled = true; btn.title = NO_HOST_HINT; }
90
+ head.appendChild(btn);
91
+ card.appendChild(head);
92
+ if (Array.isArray(health.reasons) && health.reasons.length) {
93
+ const ul = h(doc, 'ul', 'mem-reasons');
94
+ for (const r of health.reasons) ul.appendChild(h(doc, 'li', '', String(r)));
95
+ card.appendChild(ul);
96
+ }
97
+ if (isGlobal && !runId) {
98
+ 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));
100
+ }
101
+ 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
+ return card;
103
+ }
104
+
105
+ /** The file list: a head with the New button, one row per file. The ROW is the control (click,
106
+ * Enter, Space) — no nested button, so `role="button"` holds no interactive descendant. */
107
+ export function renderFileList(files, { doc = globalThis.document, selected = '' } = {}) {
108
+ const wrap = h(doc, 'div', 'mem-list');
109
+ const head = h(doc, 'div', 'mem-list-head');
110
+ head.appendChild(h(doc, 'span', 'mem-list-title', 'Files'));
111
+ const add = h(doc, 'button', 'btn btn-ghost btn-mini mem-new', 'New file');
112
+ add.type = 'button';
113
+ head.appendChild(add);
114
+ wrap.appendChild(head);
115
+ const list = Array.isArray(files) ? files : [];
116
+ if (!list.length) { wrap.appendChild(h(doc, 'div', 'hist-empty', 'No memory files yet — agents and Ask Worca add them, or click New file.')); return wrap; }
117
+ for (const f of list) {
118
+ const row = h(doc, 'div', `mem-row${f.name === selected ? ' on' : ''}`);
119
+ row.dataset.name = f.name;
120
+ row.setAttribute('role', 'button'); row.tabIndex = 0;
121
+ const main = h(doc, 'div', 'mem-row-main');
122
+ main.appendChild(h(doc, 'div', 'mem-row-name mono', `${f.name}.md`));
123
+ main.appendChild(h(doc, 'div', 'mem-row-hook', f.description || '(no description)'));
124
+ const meta = [formatWhen(f.updated), f.source || ''].filter(Boolean).join(' · ') + (f.hasFrontmatter === false ? ' · no frontmatter' : '');
125
+ main.appendChild(h(doc, 'div', 'mem-row-meta', meta));
126
+ row.appendChild(main);
127
+ wrap.appendChild(row);
128
+ }
129
+ return wrap;
130
+ }
131
+
132
+ /** The editor: the whole file (frontmatter included) as text. A NEW file has an editable name and
133
+ * no Delete; `locked` (a live defragment run on this scope) disables Save and Delete — Cancel
134
+ * always closes. */
135
+ export function renderEditor(file, { doc = globalThis.document, isNew = false, msg = '', msgErr = false, locked = false } = {}) {
136
+ const wrap = h(doc, 'div', 'mem-editor');
137
+ const nameField = h(doc, 'div', 'field field-compact');
138
+ nameField.appendChild(h(doc, 'label', '', 'Name'));
139
+ const name = h(doc, 'input', 'input mono mem-name');
140
+ name.type = 'text'; name.spellcheck = false; name.value = file?.name || '';
141
+ name.readOnly = !isNew;
142
+ name.placeholder = 'topic-name';
143
+ nameField.appendChild(name);
144
+ if (isNew) nameField.appendChild(h(doc, 'small', 'hint mem-name-help', MEMORY_NAME_HELP));
145
+ wrap.appendChild(nameField);
146
+ const textField = h(doc, 'div', 'field');
147
+ textField.appendChild(h(doc, 'label', '', 'Markdown (frontmatter + body)'));
148
+ const ta = h(doc, 'textarea', 'textarea mono mem-text');
149
+ ta.rows = 16; ta.spellcheck = false;
150
+ ta.value = file?.text || (isNew ? '---\nname: \ndescription: \n---\n' : '');
151
+ textField.appendChild(ta);
152
+ textField.appendChild(h(doc, 'small', 'hint', 'Keep name, description (one line: when the file matters — shown here and to Ask Worca) and optional paths (the file loads only when a matching file is read) in the frontmatter; worca stamps source and updated.'));
153
+ wrap.appendChild(textField);
154
+ const text = msg || (locked ? 'A defragment run is live on this scope — Save and Delete resume when it finishes.' : '');
155
+ const status = h(doc, 'div', `hint mem-msg${msgErr ? ' err' : ''}`, text);
156
+ status.setAttribute('role', 'status');
157
+ wrap.appendChild(status);
158
+ const actions = h(doc, 'div', 'actions mem-actions');
159
+ if (!isNew) {
160
+ const d = h(doc, 'button', 'btn btn-danger btn-mini mem-delete', 'Delete');
161
+ d.type = 'button'; d.disabled = !!locked;
162
+ actions.appendChild(d);
163
+ }
164
+ const cancel = h(doc, 'button', 'btn btn-ghost btn-mini mem-cancel', 'Cancel'); cancel.type = 'button';
165
+ const save = h(doc, 'button', 'btn btn-primary btn-mini mem-save', 'Save'); save.type = 'button'; save.disabled = !!locked;
166
+ actions.append(cancel, save);
167
+ wrap.appendChild(actions);
168
+ return wrap;
169
+ }
170
+
171
+ export function collectEditor(rootEl) {
172
+ return { name: String(rootEl.querySelector('.mem-name')?.value || '').trim(), text: String(rootEl.querySelector('.mem-text')?.value ?? '') };
173
+ }
174
+
175
+ /** The snapshot ring, newest first, one Restore per row (disabled while a defragment run is live). */
176
+ export function renderMemoryHistory(snapshots, { doc = globalThis.document, locked = false } = {}) {
177
+ const wrap = h(doc, 'div', 'mem-history');
178
+ wrap.appendChild(h(doc, 'div', 'mem-list-title', 'History'));
179
+ const list = Array.isArray(snapshots) ? [...snapshots].reverse() : [];
180
+ if (!list.length) { wrap.appendChild(h(doc, 'div', 'hist-empty', 'No snapshots yet — every write takes one.')); return wrap; }
181
+ for (const s of list) {
182
+ const row = h(doc, 'div', 'mem-snap');
183
+ row.dataset.id = s.id;
184
+ row.appendChild(h(doc, 'span', 'mem-snap-id mono', s.id));
185
+ row.appendChild(h(doc, 'span', 'mem-snap-count', plural((s.files || []).length, 'file')));
186
+ const btn = h(doc, 'button', 'btn btn-ghost btn-mini mem-restore', 'Restore');
187
+ btn.type = 'button'; btn.dataset.id = s.id; btn.disabled = !!locked;
188
+ row.appendChild(btn);
189
+ wrap.appendChild(row);
190
+ }
191
+ return wrap;
192
+ }
@@ -0,0 +1,201 @@
1
+ // ui/public/node-tunables.mjs — PURE tunables resolution (no DOM, no fetch),
2
+ // shared by the New Pipeline agents accordion (app.js) and the Ask Worca run card
3
+ // (ask-panel.mjs). The two panels must never drift: this is the one definition.
4
+ // Moved verbatim out of app.js (2026-09-06); app.js keeps thin wrappers that add
5
+ // its own ports source (panelPortsFn, which can fall back to the Composer index).
6
+ import { classifyLoops } from '../../src/shared/graph/loops.mjs';
7
+ import { portsFnFor } from '../../src/shared/graph/ports.mjs';
8
+
9
+ // Flatten workflow.steps[][] into an ordered list of node rows, joining each
10
+ // node's role `key` to its registry metadata (label/color) and resolving every
11
+ // setting through the four layers (newpipeline-ux-design.md §4.3):
12
+ // 1. the per-project override — run-config nodes[nodeId], or, for the built-in
13
+ // Default workflow, the legacy per-role opts.legacySteps[key];
14
+ // 2. the workflow's own node.defaults;
15
+ // 3. the agent-registry sidecar (fanOut / questionsDefault);
16
+ // 4. nothing configured — the CLI default.
17
+ // Order = outer (sequential) then inner (parallel) — exactly the dispatch order.
18
+ //
19
+ // model/effort/fanOut/askQuestions on the returned row are the EFFECTIVE values
20
+ // (what the run will use). `def` carries the same four resolved WITHOUT layer 1,
21
+ // so the renderer can mark deviation and the writer can prune a redundant save
22
+ // back to "inherit". `override` is layer 1 verbatim.
23
+ export function buildNodeConfigRows(workflow, registry, runConfig, opts = {}) {
24
+ if (workflow && workflow.version === 2) return buildGraphNodeRows(workflow, registry, runConfig, opts);
25
+ const steps = Array.isArray(workflow && workflow.steps) ? workflow.steps : [];
26
+ const reg = registry || {};
27
+ const nodes = (runConfig && runConfig.nodes) || {};
28
+ const legacySteps = opts.legacySteps || null; // wf_default only: per-ROLE storage
29
+ const rows = [];
30
+ steps.forEach((group, stepIndex) => {
31
+ const members = Array.isArray(group) ? group : [];
32
+ members.forEach((node) => {
33
+ if (!node || !node.id) return;
34
+ const meta = reg[node.key] || null;
35
+ // The Default workflow's overrides live under the role key; a saved
36
+ // workflow's under the node-instance id. Both can exist for wf_default
37
+ // (a node write wins, mirroring resolveWorkflow's firstDefined order).
38
+ const role = legacySteps ? node.key : null;
39
+ const saved = { ...(role ? legacySteps[role] : null), ...nodes[node.id] };
40
+ const wfDef = (node.defaults && typeof node.defaults === 'object') ? node.defaults : {};
41
+ const metaFan = meta && typeof meta.fanOut === 'boolean' ? meta.fanOut : false;
42
+ const metaAsks = !!(meta && meta.asksQuestions);
43
+ const metaLocked = !!(meta && meta.questionsLocked);
44
+ const metaQDefault = !!(meta && meta.questionsDefault);
45
+
46
+ const t = resolveNodeTunables(saved, wfDef, { fanOut: metaFan, questionsDefault: metaQDefault });
47
+
48
+ rows.push({
49
+ nodeId: node.id,
50
+ key: node.key,
51
+ role, // non-null => persist via the legacy per-role path (saveStep)
52
+ label: (meta && meta.displayName) || node.key || node.id,
53
+ color: (meta && meta.color) || '',
54
+ description: (meta && meta.description) || '',
55
+ stepIndex,
56
+ parallel: members.length > 1,
57
+ model: t.model,
58
+ effort: t.effort,
59
+ fanOut: t.fanOut,
60
+ subagentModel: t.subagentModel,
61
+ // null => the agent has no questions capability (no checkbox rendered).
62
+ askQuestions: !metaAsks ? null : (metaLocked ? metaQDefault : t.askQuestions),
63
+ questionsLocked: metaAsks && metaLocked,
64
+ def: t.def,
65
+ override: t.override,
66
+ // A locked questions toggle is never the user's doing, so it never counts
67
+ // as a modification (it cannot be reset either).
68
+ modified: modifiedFieldsOf(t, t.def,
69
+ { asksQuestions: metaAsks, questionsLocked: metaLocked }).length > 0,
70
+ });
71
+ });
72
+ });
73
+ return rows;
74
+ }
75
+
76
+ // ONE resolution rule for a node's five tunables, shared verbatim by the v1
77
+ // (buildNodeConfigRows) and v2 (buildGraphNodeRows) row builders — the two
78
+ // panels must never drift (the v2 path is the one every live workflow uses).
79
+ // `saved` = the per-project override entry (role + node merged), `wfDef` = the
80
+ // workflow's own defaults block (node.defaults in v1, node.config in v2),
81
+ // `caps` = the registry meta's capability booleans.
82
+ export function resolveNodeTunables(saved, wfDef, caps = {}) {
83
+ const override = {};
84
+ if (typeof saved.model === 'string' && saved.model) override.model = saved.model;
85
+ if (typeof saved.effort === 'string' && saved.effort) override.effort = saved.effort;
86
+ if (typeof saved.fanOut === 'boolean') override.fanOut = saved.fanOut;
87
+ if (typeof saved.askQuestions === 'boolean') override.askQuestions = saved.askQuestions;
88
+ if (typeof saved.subagentModel === 'string' && saved.subagentModel) override.subagentModel = saved.subagentModel;
89
+
90
+ // Layers 2-4 alone: what this row falls back to once its override is gone.
91
+ const def = {
92
+ model: typeof wfDef.model === 'string' ? wfDef.model : '',
93
+ effort: typeof wfDef.model === 'string' && typeof wfDef.effort === 'string' ? wfDef.effort : '',
94
+ fanOut: typeof wfDef.fanOut === 'boolean' ? wfDef.fanOut : !!caps.fanOut,
95
+ askQuestions: typeof wfDef.askQuestions === 'boolean' ? wfDef.askQuestions : !!caps.questionsDefault,
96
+ // No sidecar layer: an agent manifest declares whether a node CAN fan out,
97
+ // never what its children run on. '' = unset (the run resolves auto).
98
+ subagentModel: typeof wfDef.subagentModel === 'string' ? wfDef.subagentModel : '',
99
+ };
100
+
101
+ // An effort is only meaningful for the model that advertises it, so an
102
+ // override naming its own model does not inherit the default's effort.
103
+ const model = override.model !== undefined ? override.model : def.model;
104
+ const effort = override.effort !== undefined
105
+ ? override.effort
106
+ : (override.model !== undefined ? '' : def.effort);
107
+ const fanOut = override.fanOut !== undefined ? override.fanOut : def.fanOut;
108
+ const askQuestions = override.askQuestions !== undefined ? override.askQuestions : def.askQuestions;
109
+ const subagentModel = override.subagentModel !== undefined ? override.subagentModel : def.subagentModel;
110
+ return { override, def, model, effort, fanOut, askQuestions, subagentModel };
111
+ }
112
+
113
+ // Which of the five settings deviate from the row's resolved default. Pure; the
114
+ // single definition of "modified" for both the row dot and the header count.
115
+ export function modifiedFieldsOf(effective, def, caps = {}) {
116
+ const out = [];
117
+ if ((effective.model || '') !== (def.model || '')) out.push('model');
118
+ if ((effective.effort || '') !== (def.effort || '')) out.push('effort');
119
+ if (!!effective.fanOut !== !!def.fanOut) out.push('fanOut');
120
+ if ((effective.subagentModel || '') !== (def.subagentModel || '')) out.push('subagentModel');
121
+ if (caps.asksQuestions && !caps.questionsLocked && !!effective.askQuestions !== !!def.askQuestions) {
122
+ out.push('askQuestions');
123
+ }
124
+ return out;
125
+ }
126
+
127
+ // Prune a row's would-be selection against its resolved default (§4.5): a value
128
+ // equal to the default is stored as "inherit" instead — '' clears a model/effort,
129
+ // null clears a boolean toggle (config.mjs#inheritOr). Returns the patch to send.
130
+ // `next` carries only the fields the caller is changing; the rest ride along at
131
+ // their current effective value so the setters' replace semantics cannot wipe them.
132
+ export function pruneNodeSelection(row, next = {}) {
133
+ const eff = {
134
+ model: next.model !== undefined ? next.model : row.model,
135
+ effort: next.effort !== undefined ? next.effort : row.effort,
136
+ fanOut: next.fanOut !== undefined ? next.fanOut : row.fanOut,
137
+ askQuestions: next.askQuestions !== undefined ? next.askQuestions : row.askQuestions,
138
+ subagentModel: next.subagentModel !== undefined ? next.subagentModel : row.subagentModel,
139
+ };
140
+ // model+effort prune as a PAIR: an effort is only interpretable against the
141
+ // model that advertises it, so storing one without the other is rejected by
142
+ // the setters ("select a model before choosing an effort").
143
+ const inheritPair = (eff.model || '') === (row.def.model || '')
144
+ && (eff.effort || '') === (row.def.effort || '');
145
+ return {
146
+ model: inheritPair ? '' : (eff.model || ''),
147
+ effort: inheritPair || !eff.model ? '' : eff.effort,
148
+ fanOut: !!eff.fanOut === !!row.def.fanOut ? null : !!eff.fanOut,
149
+ askQuestions: row.askQuestions === null || row.questionsLocked
150
+ ? undefined // no capability / locked: never persist a value for it
151
+ : (!!eff.askQuestions === !!row.def.askQuestions ? null : !!eff.askQuestions),
152
+ // '' IS the clear for a string tunable (config.mjs#inheritOrSubagentModel), so
153
+ // a value equal to the default prunes to inherit exactly like model/effort.
154
+ subagentModel: (eff.subagentModel || '') === (row.def.subagentModel || '') ? '' : (eff.subagentModel || ''),
155
+ };
156
+ }
157
+
158
+ // v2: agent nodes only, in condensation-topo launch order (loop wires excluded
159
+ // from the ranking, exactly as the scheduler orders launches). The four config
160
+ // layers are the same as v1: run-config nodes[nodeId] -> template node.config ->
161
+ // sidecar -> hard default.
162
+ export function buildGraphNodeRows(tpl, registry, runConfig, opts = {}) {
163
+ const reg = registry || {};
164
+ const nodes = (runConfig && runConfig.nodes) || {};
165
+ // wf_default only: the legacy per-ROLE storage, layered under the per-node one
166
+ // exactly as resolveGraph does (sel -> legacy -> node config).
167
+ const legacySteps = opts.legacySteps || null;
168
+ // app.js hands its own panelPortsFn (falls back to the Composer's index for
169
+ // agents the palette omits); the card and the tests use the registry alone.
170
+ const portsFn = typeof opts.portsFn === 'function' ? opts.portsFn : portsFnFor(reg);
171
+ const order = classifyLoops(tpl, portsFn).launchOrder;
172
+ const byId = new Map(tpl.nodes.map((n) => [n.id, n]));
173
+ const rank = new Map(order.map((id, i) => [id, i]));
174
+ const agentNodes = order.map((id) => byId.get(id)).filter((n) => n && n.kind === 'agent');
175
+ const rows = [];
176
+ for (const node of agentNodes) {
177
+ const meta = reg[node.key] || null;
178
+ 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 : {};
181
+ const metaFan = meta && typeof meta.fanOut === 'boolean' ? meta.fanOut : false;
182
+ const metaAsks = !!(meta && meta.asksQuestions);
183
+ const metaLocked = !!(meta && meta.questionsLocked);
184
+ const metaQDefault = !!(meta && meta.questionsDefault);
185
+ const t = resolveNodeTunables(saved, wfDef, { fanOut: metaFan, questionsDefault: metaQDefault });
186
+ rows.push({
187
+ nodeId: node.id, key: node.key, role, // non-null => persist via saveStep (wf_default)
188
+ label: (meta && meta.displayName) || node.key || node.id,
189
+ color: (meta && meta.color) || '', description: (meta && meta.description) || '',
190
+ stepIndex: rank.get(node.id) || 0,
191
+ parallel: false,
192
+ model: t.model, effort: t.effort, fanOut: t.fanOut, subagentModel: t.subagentModel,
193
+ askQuestions: !metaAsks ? null : (metaLocked ? metaQDefault : t.askQuestions),
194
+ questionsLocked: metaAsks && metaLocked,
195
+ def: t.def, override: t.override,
196
+ modified: modifiedFieldsOf(t, t.def,
197
+ { asksQuestions: metaAsks, questionsLocked: metaLocked }).length > 0,
198
+ });
199
+ }
200
+ return rows;
201
+ }
@@ -0,0 +1,75 @@
1
+ // ui/public/report-run.mjs
2
+ // Pure renderers for the "Report this run" preview modal. No fetch, no listeners,
3
+ // no document lookups: every function takes { doc } and returns DETACHED nodes, the
4
+ // way plugins-view.mjs / guardrails-view.mjs do. app.js owns the modal, the network
5
+ // call, the clipboard and the download.
6
+ //
7
+ // The reason and opt-in vocabulary comes from src/shared/report-reasons.mjs — the
8
+ // SAME module the core builder imports, so a label and a payload cannot drift. Two
9
+ // `..` because this file sits two levels below the repo root; the browser clamps the
10
+ // URL at '/' and the server serves src/shared there. Absolute specifiers are
11
+ // FORBIDDEN — they break the Node ESM resolver the UI tests use; app.js already uses
12
+ // this exact prefix.
13
+ import { REPORT_REASONS, OPT_IN_CLASSES } from '../../src/shared/report-reasons.mjs';
14
+
15
+ /** <option> elements for the reason <select>, in menu order. */
16
+ export function renderReasonOptions({ doc = globalThis.document } = {}) {
17
+ return REPORT_REASONS.map((reason) => {
18
+ const opt = doc.createElement('option');
19
+ opt.value = reason.id;
20
+ opt.textContent = reason.label;
21
+ return opt;
22
+ });
23
+ }
24
+
25
+ /**
26
+ * The three opt-in checkboxes, as .confirm-checkbox rows (the #export-modal shape).
27
+ * Each input carries data-optin="<key>" so app.js can delegate one change listener
28
+ * over the wrapper.
29
+ *
30
+ * The sub-label class is .report-optin-hint, NOT .hint: ui-settings-about.test.mjs
31
+ * asserts every `.hint` in the settings view is empty-texted, and keeping a
32
+ * non-empty .hint out of the codebase's shared vocabulary avoids ever tripping that
33
+ * view-wide rule if this markup is later reused inside Settings.
34
+ */
35
+ export function renderOptIns({ doc = globalThis.document, include = {} } = {}) {
36
+ const wrap = doc.createElement('div');
37
+ wrap.className = 'report-optins';
38
+ for (const cls of OPT_IN_CLASSES) {
39
+ const label = doc.createElement('label');
40
+ label.className = 'confirm-checkbox';
41
+
42
+ const input = doc.createElement('input');
43
+ input.type = 'checkbox';
44
+ input.dataset.optin = cls.key;
45
+ input.checked = include[cls.key] === true;
46
+
47
+ const span = doc.createElement('span');
48
+ span.textContent = cls.label;
49
+ const hint = doc.createElement('span');
50
+ hint.className = 'report-optin-hint';
51
+ hint.textContent = cls.hint;
52
+ span.appendChild(doc.createElement('br'));
53
+ span.appendChild(hint);
54
+
55
+ label.appendChild(input);
56
+ label.appendChild(span);
57
+ wrap.appendChild(label);
58
+ }
59
+ return wrap;
60
+ }
61
+
62
+ /**
63
+ * The preview text. This is the contract of the whole feature: what the reporter
64
+ * reads here is byte-identical to what Copy JSON puts on the clipboard and what
65
+ * Download JSON writes.
66
+ */
67
+ export function previewText(payload) {
68
+ if (!payload) return 'Building the report…';
69
+ return JSON.stringify(payload, null, 2);
70
+ }
71
+
72
+ /** Blob parts for Download JSON — pretty JSON with a trailing newline. */
73
+ export function reportBlobParts(payload) {
74
+ return [`${JSON.stringify(payload, null, 2)}\n`];
75
+ }
@@ -119,3 +119,28 @@ export function workflowPickerLabel(wf, enabledPluginNames = []) {
119
119
  const disabled = Array.isArray(enabledPluginNames) && !enabledPluginNames.includes(plugin);
120
120
  return `${wf.name} [plugin: ${plugin}${disabled ? ' — disabled' : ''}]`;
121
121
  }
122
+
123
+ /**
124
+ * Rows for the History Overview "Memory changes" block (agent-memory-design.md §6).
125
+ * Pure: `memory` is results.json.memory / the detail's `memory` ({ changes }).
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.
128
+ * Each chip also carries the ref it was built from — `scope` (mount-relative:
129
+ * `global` | `project` | `projects/<key>`) and `name` — so History can link to
130
+ * the file in its Memory view (B6).
131
+ */
132
+ export function memoryChangesRows(memory) {
133
+ const changes = Array.isArray(memory?.changes) ? memory.changes : [];
134
+ const file = (r) => `${r.scope}/${r.name}.md`;
135
+ const rows = [];
136
+ for (const c of changes) {
137
+ const chips = [
138
+ ...(c.added || []).map((r) => ({ kind: 'add', text: `+ ${file(r)}`, scope: r.scope, name: r.name })),
139
+ ...(c.modified || []).map((r) => ({ kind: 'mod', text: `~ ${file(r)}`, scope: r.scope, name: r.name })),
140
+ ...(c.deleted || []).map((r) => ({ kind: 'del', text: `− ${file(r)}`, scope: r.scope, name: r.name })),
141
+ ...(c.rejected || []).map((r) => ({ kind: 'rej', text: `✕ ${file(r)}`, title: String(r.reason || ''), scope: r.scope, name: r.name })),
142
+ ];
143
+ if (chips.length) rows.push({ node: c.agentKey || c.nodeId || 'run', chips });
144
+ }
145
+ return rows;
146
+ }
@@ -23,6 +23,20 @@ export function debounce(fn, ms, timers = {}) {
23
23
  };
24
24
  }
25
25
 
26
+ // A task body is markdown (sources.mjs writes it under `# title` into the prompt).
27
+ // `renderMarkdown(text)` — injected by app.js, the page's sanitized pipeline — returns
28
+ // { kind: 'md', frag } or { kind: 'plain' }; without it, or before it is ready, the
29
+ // body is a verbatim <pre>.
30
+ function taskBody(doc, text, renderMarkdown) {
31
+ const out = text && typeof renderMarkdown === 'function' ? renderMarkdown(text) : null;
32
+ if (out && out.kind === 'md' && out.frag) {
33
+ const box = h(doc, 'div', 'sp-prev-body artifact-markdown');
34
+ box.appendChild(out.frag);
35
+ return box;
36
+ }
37
+ return h(doc, 'pre', 'sp-prev-body', text);
38
+ }
39
+
26
40
  // Values of every non-task-browser input in the pane, keyed by input key —
27
41
  // these travel as `inputs` to connector ops and into body.source at submit.
28
42
  function collectInputs(pane) {
@@ -100,7 +114,7 @@ function taskRow(doc, t, now) {
100
114
  // dropdown populated ONCE on first focus via call(optionsFrom) (promise kept on
101
115
  // ._load for deterministic tests); task-browser -> debounced (300ms) search +
102
116
  // result list + preview (call getTask on pick) + hidden selected taskId.
103
- export function renderSourcePane(source, { call, doc = globalThis.document, timers, now } = {}) {
117
+ export function renderSourcePane(source, { call, doc = globalThis.document, timers, now, renderMarkdown } = {}) {
104
118
  const clock = typeof now === 'function' ? now : () => Date.now();
105
119
  const pane = h(doc, 'div', 'sp-pane');
106
120
  pane.dataset.plugin = source.plugin;
@@ -207,7 +221,7 @@ export function renderSourcePane(source, { call, doc = globalThis.document, time
207
221
  const task = await call('getTask', { id: row.dataset.taskId });
208
222
  preview.replaceChildren(
209
223
  h(doc, 'b', 'sp-prev-title', (task && task.title) || row.dataset.taskId),
210
- h(doc, 'pre', 'sp-prev-body', (task && task.body) || ''),
224
+ taskBody(doc, (task && task.body) || '', renderMarkdown),
211
225
  );
212
226
  })().catch((e) => { preview.textContent = `preview failed: ${e.message}`; });
213
227
  });
@@ -373,7 +373,7 @@ const PW = CW - L - R, PH = CH - T - B;
373
373
  * integer=true (runs chart) keeps ticks on whole numbers — a sub-1 or ×2.5
374
374
  * step on tiny count maxima would render duplicate rounded labels ("0, 1, 1"
375
375
  * for a 1-run day) or off-grid labels ("3" at the 2.5 line). */
376
- function niceScale(maxVal, integer = false) {
376
+ export function niceScale(maxVal, integer = false) {
377
377
  const m = maxVal > 0 ? maxVal : 1;
378
378
  const raw = m / 3; // aim for ~3-4 ticks
379
379
  const pow = 10 ** Math.floor(Math.log10(raw));
@@ -384,7 +384,7 @@ function niceScale(maxVal, integer = false) {
384
384
  }
385
385
 
386
386
  /** Column path with a rounded TOP only (square baseline). r clamps to h/2. */
387
- function roundedTopBar(x, y, w, hgt, r = 4) {
387
+ export function roundedTopBar(x, y, w, hgt, r = 4) {
388
388
  const rr = Math.max(0, Math.min(r, hgt / 2, w / 2));
389
389
  return `M${x},${y + hgt} L${x},${y + rr} Q${x},${y} ${x + rr},${y} L${x + w - rr},${y} ` +
390
390
  `Q${x + w},${y} ${x + w},${y + rr} L${x + w},${y + hgt} Z`;