@worca/app 0.0.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.
- package/README.md +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ui/public/log-filter.mjs
|
|
2
|
+
|
|
3
|
+
// Pure, DOM-free log-filtering rules shared by the run-card live log and the
|
|
4
|
+
// History "Live logs" panel. Extracted so the narrowing semantics are
|
|
5
|
+
// unit-testable without booting app.js (mirrors log-line.mjs).
|
|
6
|
+
//
|
|
7
|
+
// A filter is { source, level, step, cycle, search } where '' / undefined means
|
|
8
|
+
// "all". Every axis composes with AND.
|
|
9
|
+
// - level: exact match; a record with no level counts as 'info' (the same
|
|
10
|
+
// default logLineClass applies when rendering).
|
|
11
|
+
// - source: matches the role itself AND its fanned-out sub-agents — a child
|
|
12
|
+
// line's source is "role ▸ label", so filtering by "planner" keeps
|
|
13
|
+
// "planner ▸ research auth" but not "plannerX".
|
|
14
|
+
// - step: matches String(rec.stepIndex). Lines with no step attribution
|
|
15
|
+
// (preflight, orchestrator, ui notices) only show when no step is chosen.
|
|
16
|
+
// - cycle: matches String(rec.cycle) — the feedback-loop rewind counter, which is
|
|
17
|
+
// orthogonal to step (a re-run keeps its stepIndex and bumps its cycle). Same
|
|
18
|
+
// attribution rule as step: cycle-less lines only show when no cycle is chosen.
|
|
19
|
+
// - search: case-insensitive substring of rec.text. Text only — the source column
|
|
20
|
+
// is already a dropdown, so matching it here would make that filter ambiguous.
|
|
21
|
+
|
|
22
|
+
const SUB_SEP = ' ▸ ';
|
|
23
|
+
|
|
24
|
+
/** Compile a filter into a predicate. Per-axis normalization (notably the
|
|
25
|
+
* search term's toLowerCase) happens ONCE here instead of once per record —
|
|
26
|
+
* a repaint runs the predicate over up to MAX_LOG_LINES records per
|
|
27
|
+
* keystroke tick. Same semantics as logLineVisible by construction. */
|
|
28
|
+
export function compileLogFilter(filter) {
|
|
29
|
+
if (!filter) return () => true;
|
|
30
|
+
const level = filter.level || '';
|
|
31
|
+
const source = filter.source || '';
|
|
32
|
+
const hasStep = filter.step !== undefined && filter.step !== '';
|
|
33
|
+
const step = hasStep ? String(filter.step) : '';
|
|
34
|
+
const hasCycle = filter.cycle !== undefined && filter.cycle !== '';
|
|
35
|
+
const cycle = hasCycle ? String(filter.cycle) : '';
|
|
36
|
+
const term = filter.search ? String(filter.search).toLowerCase() : '';
|
|
37
|
+
return (rec) => {
|
|
38
|
+
if (level && (rec.level || 'info') !== level) return false;
|
|
39
|
+
if (source) {
|
|
40
|
+
const src = rec.source || '';
|
|
41
|
+
if (src !== source && !src.startsWith(source + SUB_SEP)) return false;
|
|
42
|
+
}
|
|
43
|
+
if (hasStep && (rec.stepIndex == null || String(rec.stepIndex) !== step)) return false;
|
|
44
|
+
if (hasCycle && (rec.cycle == null || String(rec.cycle) !== cycle)) return false;
|
|
45
|
+
if (term && !String(rec.text || '').toLowerCase().includes(term)) return false;
|
|
46
|
+
return true;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function logLineVisible(rec, filter) {
|
|
51
|
+
return compileLogFilter(filter)(rec);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Distinct facet values the filter dropdowns offer, from the lines seen so far:
|
|
55
|
+
// sources collapsed to their parent role (sub-agents fold into the role they
|
|
56
|
+
// belong to), levels defaulted to 'info', steps = the stepIndex values present,
|
|
57
|
+
// cycles = the cycle values present. All sorted (steps/cycles numerically) for
|
|
58
|
+
// stable dropdowns. `search` is free text and has no facet.
|
|
59
|
+
export function logFacets(lines) {
|
|
60
|
+
const sources = new Set();
|
|
61
|
+
const levels = new Set();
|
|
62
|
+
const steps = new Set();
|
|
63
|
+
const cycles = new Set();
|
|
64
|
+
for (const rec of lines || []) {
|
|
65
|
+
if (!rec) continue;
|
|
66
|
+
const src = rec.source || '';
|
|
67
|
+
if (src) {
|
|
68
|
+
const sep = src.indexOf(SUB_SEP);
|
|
69
|
+
sources.add(sep === -1 ? src : src.slice(0, sep));
|
|
70
|
+
}
|
|
71
|
+
levels.add(rec.level || 'info');
|
|
72
|
+
if (rec.stepIndex != null) steps.add(rec.stepIndex);
|
|
73
|
+
if (rec.cycle != null) cycles.add(rec.cycle);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
sources: [...sources].sort(),
|
|
77
|
+
levels: [...levels].sort(),
|
|
78
|
+
steps: [...steps].sort((a, b) => a - b),
|
|
79
|
+
cycles: [...cycles].sort((a, b) => a - b),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ui/public/log-line.mjs
|
|
2
|
+
|
|
3
|
+
// Pure, DOM-free: the className for one log line, given its level and whether it
|
|
4
|
+
// belongs to a fanned-out sub-agent. Extracted so the sub-agent styling decision
|
|
5
|
+
// is unit-testable without booting app.js (mirrors composer-core.mjs).
|
|
6
|
+
export function logLineClass(level, sub) {
|
|
7
|
+
return 'log-line lvl-' + (level || 'info') + (sub ? ' sub-agent' : '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Local wall-clock hh:mm:ss for a log record's ts (ISO string or epoch ms).
|
|
11
|
+
* Shared by the rendered line and the clipboard serializer so a copied log
|
|
12
|
+
* reads exactly like the pane it came from. */
|
|
13
|
+
export function logLineTime(ts) {
|
|
14
|
+
const d = ts ? new Date(ts) : new Date();
|
|
15
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
16
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
17
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
18
|
+
return `${hh}:${mm}:${ss}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One log record as plain text, in the same order the DOM line renders it:
|
|
22
|
+
* `<hh:mm:ss> [source] text`. The pane spaces its ts/src/msg spans with a flex
|
|
23
|
+
* `gap` rather than whitespace, so a native selection-copy would run them
|
|
24
|
+
* together — the copy affordance serializes from the model through here
|
|
25
|
+
* instead of reading the DOM. */
|
|
26
|
+
export function logLineText(rec) {
|
|
27
|
+
if (!rec) return '';
|
|
28
|
+
const src = rec.source ? `[${rec.source}] ` : '';
|
|
29
|
+
return `${logLineTime(rec.ts)} ${src}${rec.text == null ? '' : String(rec.text)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A whole (already filtered) sequence of records as newline-joined text, with
|
|
33
|
+
* the SAME "Cycle N" rules the pane draws (one cycleSeparatorBefore walk) —
|
|
34
|
+
* without them a copied re-run repeats its steps with no visible boundary.
|
|
35
|
+
* Rendered as `── Cycle N ──`: the pane's rule lines are CSS, plain text
|
|
36
|
+
* needs the dashes. */
|
|
37
|
+
export function serializeLog(recs) {
|
|
38
|
+
const out = [];
|
|
39
|
+
let prevCycle = null;
|
|
40
|
+
for (const rec of recs || []) {
|
|
41
|
+
if (!rec) continue;
|
|
42
|
+
const sep = cycleSeparatorBefore(prevCycle, rec);
|
|
43
|
+
if (sep) out.push(`── ${sep} ──`);
|
|
44
|
+
if (rec.cycle != null) prevCycle = rec.cycle;
|
|
45
|
+
out.push(logLineText(rec));
|
|
46
|
+
}
|
|
47
|
+
return out.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One persisted NDJSON record projected to the UI log-record shape. EVERY
|
|
51
|
+
* consumer of the persisted log goes through here — the live-card resume seed
|
|
52
|
+
* and the History replay must agree on which fields survive, or an axis works
|
|
53
|
+
* in one pane while silently dead in the other (`cycle` was dropped by one of
|
|
54
|
+
* three hand-rolled copies of this projection). `cycle` drives the cycle
|
|
55
|
+
* picker AND the "── Cycle N ──" separators; `stream` is stderr provenance.
|
|
56
|
+
* Absent attribution stays ABSENT (the filters test `!= null`). */
|
|
57
|
+
export function projectLogRecord(rec) {
|
|
58
|
+
return {
|
|
59
|
+
source: rec.source, level: rec.level, text: rec.text, ts: rec.ts, sub: !!rec.sub,
|
|
60
|
+
...(rec.stepIndex != null ? { stepIndex: rec.stepIndex } : {}),
|
|
61
|
+
...(rec.cycle != null ? { cycle: rec.cycle } : {}),
|
|
62
|
+
...(rec.stream ? { stream: rec.stream } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The separator label to draw BEFORE `rec`, or null for none.
|
|
68
|
+
*
|
|
69
|
+
* `cycle` is the feedback-loop rewind counter: when a reviewer returns blocking
|
|
70
|
+
* issues the pipeline rewinds and re-runs the same steps with cycle+1, so a
|
|
71
|
+
* re-run is otherwise indistinguishable from its first pass. A rule drawn at the
|
|
72
|
+
* boundary makes that legible without the reader having to filter for it.
|
|
73
|
+
*
|
|
74
|
+
* `prevCycle` is the cycle of the last RENDERED record that HAD one — the
|
|
75
|
+
* caller carries it past cycle-less notices (artifact events, git/orchestrator
|
|
76
|
+
* lines), which land exactly at rewind boundaries and must not mask them. It
|
|
77
|
+
* must come from rendered records, not the model, so a filter that hides an
|
|
78
|
+
* entire cycle cannot orphan a separator. null (no cycled record rendered yet)
|
|
79
|
+
* yields null: no leading "Cycle 1" header.
|
|
80
|
+
*/
|
|
81
|
+
export function cycleSeparatorBefore(prevCycle, rec) {
|
|
82
|
+
if (!rec || rec.cycle == null) return null;
|
|
83
|
+
if (prevCycle == null) return null;
|
|
84
|
+
if (String(prevCycle) === String(rec.cycle)) return null;
|
|
85
|
+
return `Cycle ${rec.cycle}`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// ui/public/models-view.mjs
|
|
2
|
+
// Pure DOM renderers for the Models view (configurable-models-design.md §4.10).
|
|
3
|
+
// Mirrors guardrails-view.mjs: every function takes `doc` via opts (defaults to
|
|
4
|
+
// the browser global) and returns DETACHED elements — no fetch, no listeners
|
|
5
|
+
// outside the returned tree. app.js owns endpoint calls and mounting.
|
|
6
|
+
// Interactive elements carry data-id + a routing class (mv-edit, mv-delete,
|
|
7
|
+
// mv-promote, mv-save, mv-cancel, mv-env-add, mv-env-rm) so app.js wires ONE
|
|
8
|
+
// delegated listener on the list container.
|
|
9
|
+
|
|
10
|
+
function h(doc, tag, cls, text) {
|
|
11
|
+
const n = doc.createElement(tag);
|
|
12
|
+
if (cls) n.className = cls;
|
|
13
|
+
if (text != null) n.textContent = text;
|
|
14
|
+
return n;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** "medium · high" or "all efforts" (the full set carries no signal). */
|
|
18
|
+
export function effortsSummary(efforts, allEfforts) {
|
|
19
|
+
const list = Array.isArray(efforts) ? efforts : [];
|
|
20
|
+
if (!list.length || (allEfforts && list.length === allEfforts.length)) return 'all efforts';
|
|
21
|
+
return list.join(' · ');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "2 env vars · routes via base URL" | "1 env var" | '' (none). */
|
|
25
|
+
export function envSummary(env) {
|
|
26
|
+
const keys = Object.keys(env || {});
|
|
27
|
+
if (!keys.length) return '';
|
|
28
|
+
const n = `${keys.length} env var${keys.length === 1 ? '' : 's'}`;
|
|
29
|
+
return keys.includes('ANTHROPIC_BASE_URL') ? `${n} · routes via base URL` : n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Models view body: global entries (editable), the selected project's
|
|
34
|
+
* legacy custom models (promotable), and the built-in catalog (read-only).
|
|
35
|
+
* `globals` come MASKED from GET /api/models. `predefinedShadowedIds` marks
|
|
36
|
+
* built-ins currently overridden by a global entry.
|
|
37
|
+
*/
|
|
38
|
+
export function renderModelsList({ globals = [], legacy = [], plugins = [], predefined = [], efforts = [], projectName = '' } = {}, { doc = globalThis.document } = {}) {
|
|
39
|
+
const root = h(doc, 'div', 'mv-list');
|
|
40
|
+
const predefLc = new Set(predefined.map((m) => m.id.toLowerCase()));
|
|
41
|
+
const pluginLc = new Set(plugins.map((m) => m.id.toLowerCase()));
|
|
42
|
+
|
|
43
|
+
const section = (title, hint) => {
|
|
44
|
+
const s = h(doc, 'div', 'mv-section');
|
|
45
|
+
s.appendChild(h(doc, 'h3', 'mv-section-title', title));
|
|
46
|
+
if (hint) s.appendChild(h(doc, 'small', 'hint', hint));
|
|
47
|
+
return s;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ── Your models (global) ──
|
|
51
|
+
const yours = section('Your models', 'Defined once, available in every project. An entry with a built-in id overrides that built-in.');
|
|
52
|
+
if (!globals.length) {
|
|
53
|
+
yours.appendChild(h(doc, 'div', 'hist-empty', 'No global models yet — Add model to define one.'));
|
|
54
|
+
}
|
|
55
|
+
for (const m of globals) {
|
|
56
|
+
const card = h(doc, 'section', 'card mv-card');
|
|
57
|
+
card.dataset.id = m.id;
|
|
58
|
+
const body = h(doc, 'div', 'mv-body');
|
|
59
|
+
const head = h(doc, 'div', 'mv-head');
|
|
60
|
+
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
61
|
+
if (predefLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides built-in'));
|
|
62
|
+
else if (pluginLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides plugin'));
|
|
63
|
+
if (m.costUnreliable) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
|
|
64
|
+
body.appendChild(head);
|
|
65
|
+
const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env)].filter(Boolean);
|
|
66
|
+
body.appendChild(h(doc, 'small', 'mv-summary hint', bits.join(' — ')));
|
|
67
|
+
card.appendChild(body);
|
|
68
|
+
const del = h(doc, 'button', 'btn-ghost mv-delete', 'Delete');
|
|
69
|
+
del.type = 'button'; del.dataset.id = m.id;
|
|
70
|
+
card.appendChild(del);
|
|
71
|
+
const edit = h(doc, 'button', 'btn-ghost mv-edit', 'Edit');
|
|
72
|
+
edit.type = 'button'; edit.dataset.id = m.id;
|
|
73
|
+
card.appendChild(edit);
|
|
74
|
+
yours.appendChild(card);
|
|
75
|
+
}
|
|
76
|
+
root.appendChild(yours);
|
|
77
|
+
|
|
78
|
+
// ── Legacy per-project models (promotable) ──
|
|
79
|
+
if (legacy.length) {
|
|
80
|
+
const leg = section(
|
|
81
|
+
`Project models${projectName ? ` — ${projectName}` : ''} (legacy)`,
|
|
82
|
+
'Defined only for this project. Promote to make them global; the id keeps working everywhere it is referenced.'
|
|
83
|
+
);
|
|
84
|
+
for (const m of legacy) {
|
|
85
|
+
const card = h(doc, 'section', 'card mv-card mv-legacy');
|
|
86
|
+
card.dataset.id = m.id;
|
|
87
|
+
const body = h(doc, 'div', 'mv-body');
|
|
88
|
+
const head = h(doc, 'div', 'mv-head');
|
|
89
|
+
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
90
|
+
head.appendChild(h(doc, 'span', 'badge waiting mv-origin', 'project (legacy)'));
|
|
91
|
+
body.appendChild(head);
|
|
92
|
+
body.appendChild(h(doc, 'small', 'mv-summary hint', m.id));
|
|
93
|
+
card.appendChild(body);
|
|
94
|
+
const promote = h(doc, 'button', 'btn-ghost mv-promote', 'Promote to global');
|
|
95
|
+
promote.type = 'button'; promote.dataset.id = m.id;
|
|
96
|
+
card.appendChild(promote);
|
|
97
|
+
leg.appendChild(card);
|
|
98
|
+
}
|
|
99
|
+
root.appendChild(leg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── From plugins (read-only; design §9.6) ──
|
|
103
|
+
const globalLc = new Set(globals.map((m) => m.id.toLowerCase()));
|
|
104
|
+
if (plugins.length) {
|
|
105
|
+
const plug = section('From plugins',
|
|
106
|
+
'Installed by plugins — read-only and updated with the plugin. "Edit a copy" clones one into Your models, which then overrides it.');
|
|
107
|
+
for (const m of plugins) {
|
|
108
|
+
const card = h(doc, 'section', 'card mv-card mv-plugin');
|
|
109
|
+
card.dataset.id = m.id;
|
|
110
|
+
card.dataset.plugin = m.plugin;
|
|
111
|
+
const body = h(doc, 'div', 'mv-body');
|
|
112
|
+
const head = h(doc, 'div', 'mv-head');
|
|
113
|
+
head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
|
|
114
|
+
head.appendChild(h(doc, 'span', 'badge waiting mv-origin', `plugin: ${m.plugin}`));
|
|
115
|
+
if (globalLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden by your copy'));
|
|
116
|
+
if (m.costUnreliable) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
|
|
117
|
+
body.appendChild(head);
|
|
118
|
+
const bits = [m.id, effortsSummary(m.efforts, efforts), envSummary(m.env)].filter(Boolean);
|
|
119
|
+
body.appendChild(h(doc, 'small', 'mv-summary hint', bits.join(' — ')));
|
|
120
|
+
for (const s of m.secrets || []) {
|
|
121
|
+
body.appendChild(h(doc, 'small', `mv-secret hint${s.set ? '' : ' err'}`,
|
|
122
|
+
s.set ? `secret ${s.key}: set` : `secret ${s.key}: NOT SET — configure it in the plugin's settings`));
|
|
123
|
+
}
|
|
124
|
+
card.appendChild(body);
|
|
125
|
+
const copy = h(doc, 'button', 'btn-ghost mv-copy', 'Edit a copy');
|
|
126
|
+
copy.type = 'button'; copy.dataset.id = m.id; copy.dataset.plugin = m.plugin;
|
|
127
|
+
card.appendChild(copy);
|
|
128
|
+
plug.appendChild(card);
|
|
129
|
+
}
|
|
130
|
+
root.appendChild(plug);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Built-ins (read-only) ──
|
|
134
|
+
const builtins = section('Built-in models', 'Shipped with worca. Add a model with the same id to override its label, efforts, or routing.');
|
|
135
|
+
for (const m of predefined) {
|
|
136
|
+
const row = h(doc, 'div', 'mv-builtin');
|
|
137
|
+
row.dataset.id = m.id;
|
|
138
|
+
row.appendChild(h(doc, 'b', 'mv-name', m.label));
|
|
139
|
+
if (globalLc.has(m.id.toLowerCase()) || pluginLc.has(m.id.toLowerCase())) {
|
|
140
|
+
row.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden'));
|
|
141
|
+
}
|
|
142
|
+
row.appendChild(h(doc, 'small', 'mv-summary hint', `${m.id} — ${effortsSummary(m.efforts, efforts)}`));
|
|
143
|
+
builtins.appendChild(row);
|
|
144
|
+
}
|
|
145
|
+
root.appendChild(builtins);
|
|
146
|
+
return root;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One env key/value editor row. Existing values arrive MASKED; leaving a
|
|
150
|
+
* masked value untouched means "keep" (the server drops masked echoes).
|
|
151
|
+
* STORED rows (a key was persisted) also get a copy button that fetches the
|
|
152
|
+
* RAW value — the user owns it on disk anyway (design note: same trust
|
|
153
|
+
* boundary as ~/.worca-cc/settings.json, made deliberate by the click). */
|
|
154
|
+
function envRow(doc, key = '', value = '') {
|
|
155
|
+
const row = h(doc, 'div', 'mv-env-row');
|
|
156
|
+
const k = h(doc, 'input', 'input mv-env-key');
|
|
157
|
+
k.type = 'text'; k.placeholder = 'ANTHROPIC_BASE_URL'; k.value = key;
|
|
158
|
+
const v = h(doc, 'input', 'input mv-env-val');
|
|
159
|
+
v.type = 'text'; v.placeholder = 'value, or ${VAR} to read your shell env'; v.value = value;
|
|
160
|
+
if (value) v.dataset.original = value; // masked echo detection on collect
|
|
161
|
+
row.appendChild(k); row.appendChild(v);
|
|
162
|
+
if (key) {
|
|
163
|
+
// The STORED key, frozen at render time — the copy must reveal what is on
|
|
164
|
+
// disk even after the user edits the key input.
|
|
165
|
+
row.dataset.key = key;
|
|
166
|
+
const cp = h(doc, 'button', 'btn-ghost mv-env-copy', '⧉');
|
|
167
|
+
cp.type = 'button';
|
|
168
|
+
cp.title = 'Copy the real (unmasked) value';
|
|
169
|
+
row.appendChild(cp);
|
|
170
|
+
}
|
|
171
|
+
const rm = h(doc, 'button', 'btn-ghost mv-env-rm', '✕');
|
|
172
|
+
rm.type = 'button';
|
|
173
|
+
row.appendChild(rm);
|
|
174
|
+
return row;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The add/edit form. `model` is a MASKED global entry, or null for create.
|
|
179
|
+
* Returns detached DOM; app.js wires mv-save / mv-cancel / mv-env-add /
|
|
180
|
+
* mv-env-rm and calls collectModelEditor on save.
|
|
181
|
+
*/
|
|
182
|
+
export function renderModelEditor(model, efforts, { doc = globalThis.document } = {}) {
|
|
183
|
+
const editing = !!model;
|
|
184
|
+
const root = h(doc, 'section', 'card mv-editor');
|
|
185
|
+
root.dataset.mode = editing ? 'edit' : 'create';
|
|
186
|
+
if (editing) {
|
|
187
|
+
root.dataset.id = model.id;
|
|
188
|
+
// The stored env keys, so collect can send null (= delete) for rows the
|
|
189
|
+
// user removed — the write-only PATCH can't infer deletions otherwise.
|
|
190
|
+
root.dataset.envKeys = Object.keys(model.env || {}).join('\n');
|
|
191
|
+
}
|
|
192
|
+
root.appendChild(h(doc, 'h3', 'mv-editor-title', editing ? `Edit ${model.label || model.id}` : 'Add model'));
|
|
193
|
+
|
|
194
|
+
const grid = h(doc, 'div', 'mv-editor-grid');
|
|
195
|
+
const field = (labelText, input, hint) => {
|
|
196
|
+
const wrap = h(doc, 'label', 'mv-field');
|
|
197
|
+
wrap.appendChild(h(doc, 'span', 'mv-field-label', labelText));
|
|
198
|
+
wrap.appendChild(input);
|
|
199
|
+
if (hint) wrap.appendChild(h(doc, 'small', 'hint', hint));
|
|
200
|
+
return wrap;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const idInput = h(doc, 'input', 'input mv-id');
|
|
204
|
+
idInput.type = 'text';
|
|
205
|
+
idInput.placeholder = 'claude-opus-4-8, glm-4.7, a fine-tune id…';
|
|
206
|
+
idInput.value = editing ? model.id : '';
|
|
207
|
+
idInput.disabled = editing; // the id IS the reference — delete + re-add to rename
|
|
208
|
+
grid.appendChild(field('Model id (passed to claude --model)', idInput,
|
|
209
|
+
editing ? '' : 'Use a built-in id to override that built-in.'));
|
|
210
|
+
|
|
211
|
+
const labelInput = h(doc, 'input', 'input mv-label');
|
|
212
|
+
labelInput.type = 'text';
|
|
213
|
+
labelInput.placeholder = 'Display name (defaults to the id)';
|
|
214
|
+
labelInput.value = editing ? (model.label === model.id ? '' : model.label) : '';
|
|
215
|
+
grid.appendChild(field('Label', labelInput));
|
|
216
|
+
|
|
217
|
+
const effWrap = h(doc, 'div', 'mv-efforts');
|
|
218
|
+
const selected = new Set(editing && Array.isArray(model.efforts) ? model.efforts : efforts);
|
|
219
|
+
for (const e of efforts) {
|
|
220
|
+
const lab = h(doc, 'label', 'mv-effort');
|
|
221
|
+
const cb = h(doc, 'input', 'mv-effort-cb');
|
|
222
|
+
cb.type = 'checkbox'; cb.value = e; cb.checked = selected.has(e);
|
|
223
|
+
lab.appendChild(cb);
|
|
224
|
+
lab.appendChild(h(doc, 'span', null, e));
|
|
225
|
+
effWrap.appendChild(lab);
|
|
226
|
+
}
|
|
227
|
+
grid.appendChild(field('Supported efforts', effWrap, 'All checked = every effort (the default).'));
|
|
228
|
+
|
|
229
|
+
const envWrap = h(doc, 'div', 'mv-env');
|
|
230
|
+
const rows = editing && model.env ? Object.entries(model.env) : [];
|
|
231
|
+
for (const [k, v] of rows) envWrap.appendChild(envRow(doc, k, v));
|
|
232
|
+
const add = h(doc, 'button', 'btn-ghost mv-env-add', '+ env var');
|
|
233
|
+
add.type = 'button';
|
|
234
|
+
grid.appendChild(field('Routing env (merged into the claude spawn for this model)', envWrap,
|
|
235
|
+
'e.g. ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. Stored values show masked; leave masked to keep. WORCA_* and process keys are reserved.'));
|
|
236
|
+
const envBtns = h(doc, 'div', 'mv-env-btns');
|
|
237
|
+
envBtns.appendChild(add);
|
|
238
|
+
if (rows.length) {
|
|
239
|
+
// Reveal toggle: swaps the masked inputs to the REAL stored values (the
|
|
240
|
+
// user owns them on disk in ~/.worca-cc/settings.json). app.js wires it.
|
|
241
|
+
const reveal = h(doc, 'button', 'btn-ghost mv-env-reveal', 'Show values');
|
|
242
|
+
reveal.type = 'button';
|
|
243
|
+
reveal.title = 'Reveal the real stored values';
|
|
244
|
+
envBtns.appendChild(reveal);
|
|
245
|
+
}
|
|
246
|
+
grid.appendChild(envBtns);
|
|
247
|
+
|
|
248
|
+
root.appendChild(grid);
|
|
249
|
+
const msg = h(doc, 'p', 'form-msg mv-editor-msg');
|
|
250
|
+
msg.setAttribute('aria-live', 'polite');
|
|
251
|
+
root.appendChild(msg);
|
|
252
|
+
|
|
253
|
+
const btns = h(doc, 'div', 'mv-editor-btns');
|
|
254
|
+
const save = h(doc, 'button', 'btn-go mv-save', editing ? 'Save' : 'Add model');
|
|
255
|
+
save.type = 'button';
|
|
256
|
+
const cancel = h(doc, 'button', 'btn-ghost mv-cancel', 'Cancel');
|
|
257
|
+
cancel.type = 'button';
|
|
258
|
+
btns.appendChild(save); btns.appendChild(cancel);
|
|
259
|
+
root.appendChild(btns);
|
|
260
|
+
return root;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** app.js exposes envRow creation to the delegated mv-env-add handler. */
|
|
264
|
+
export function makeEnvRow({ doc = globalThis.document } = {}) {
|
|
265
|
+
return envRow(doc);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Collect the editor into a request body. For EDIT mode the env is a write-only
|
|
270
|
+
* PATCH: unchanged masked values are echoed (the server treats them as "keep"),
|
|
271
|
+
* removed keys are sent as null. Returns { id, body } — id is null in create
|
|
272
|
+
* mode (POST carries it in the body instead).
|
|
273
|
+
*/
|
|
274
|
+
export function collectModelEditor(rootEl) {
|
|
275
|
+
const editing = rootEl.dataset.mode === 'edit';
|
|
276
|
+
const id = editing ? rootEl.dataset.id : (rootEl.querySelector('.mv-id')?.value || '').trim();
|
|
277
|
+
const label = (rootEl.querySelector('.mv-label')?.value || '').trim();
|
|
278
|
+
const efforts = [...rootEl.querySelectorAll('.mv-effort-cb')].filter((c) => c.checked).map((c) => c.value);
|
|
279
|
+
const allCount = rootEl.querySelectorAll('.mv-effort-cb').length;
|
|
280
|
+
|
|
281
|
+
const env = {};
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
for (const row of rootEl.querySelectorAll('.mv-env-row')) {
|
|
284
|
+
const k = (row.querySelector('.mv-env-key')?.value || '').trim();
|
|
285
|
+
const v = row.querySelector('.mv-env-val')?.value ?? '';
|
|
286
|
+
if (!k) continue;
|
|
287
|
+
seen.add(k);
|
|
288
|
+
env[k] = v;
|
|
289
|
+
}
|
|
290
|
+
if (editing) {
|
|
291
|
+
// Keys the stored entry had but the editor no longer shows -> delete (null).
|
|
292
|
+
for (const k of (rootEl.dataset.envKeys || '').split('\n').filter(Boolean)) {
|
|
293
|
+
if (!seen.has(k)) env[k] = null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const body = {
|
|
298
|
+
...(editing ? {} : { id }),
|
|
299
|
+
label,
|
|
300
|
+
// All boxes checked = the full set = store the default (empty).
|
|
301
|
+
efforts: efforts.length === allCount ? [] : efforts,
|
|
302
|
+
env,
|
|
303
|
+
};
|
|
304
|
+
return { id: editing ? id : null, body };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Share-as-plugin export wizard (design §9.5) ─────────────────────────────
|
|
308
|
+
|
|
309
|
+
// Credential-looking env keys default to "Require at install". Word-ish
|
|
310
|
+
// boundaries keep limits like MAX_OUTPUT_TOKENS or MAX_THINKING_TOKENS (a
|
|
311
|
+
// count, not a credential) out of the net.
|
|
312
|
+
const SECRETISH_RE = /auth|secret|password|credential|api_?key|(?:^|_)key(?:_|$)|(?:^|_)token(?:_|$)/i;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The export wizard: pick global models, set the per-env-key policy, name the
|
|
316
|
+
* plugin, choose a destination folder. `globals` are MASKED entries from
|
|
317
|
+
* GET /api/models (only KEYS matter here — values are read server-side at
|
|
318
|
+
* export). Detached DOM; app.js wires mvx-export / mvx-cancel.
|
|
319
|
+
*/
|
|
320
|
+
export function renderExportWizard(globals, { doc = globalThis.document } = {}) {
|
|
321
|
+
const root = h(doc, 'section', 'card mv-editor mvx');
|
|
322
|
+
root.appendChild(h(doc, 'h3', 'mv-editor-title', 'Share models as a plugin'));
|
|
323
|
+
|
|
324
|
+
const step = (title, hint) => {
|
|
325
|
+
const s = h(doc, 'div', 'mvx-step');
|
|
326
|
+
s.appendChild(h(doc, 'h4', 'mvx-step-title', title));
|
|
327
|
+
if (hint) s.appendChild(h(doc, 'small', 'hint', hint));
|
|
328
|
+
root.appendChild(s);
|
|
329
|
+
return s;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const pick = step('1 — Models to include', 'Only your global models can be shared.');
|
|
333
|
+
for (const m of globals) {
|
|
334
|
+
const lab = h(doc, 'label', 'mvx-model');
|
|
335
|
+
const cb = h(doc, 'input', 'mvx-model-cb');
|
|
336
|
+
cb.type = 'checkbox'; cb.value = m.id;
|
|
337
|
+
cb.dataset.envKeys = Object.keys(m.env || {}).join('\n');
|
|
338
|
+
lab.appendChild(cb);
|
|
339
|
+
lab.appendChild(h(doc, 'span', null, `${m.label || m.id} `));
|
|
340
|
+
lab.appendChild(h(doc, 'small', 'hint', m.id));
|
|
341
|
+
pick.appendChild(lab);
|
|
342
|
+
}
|
|
343
|
+
if (!globals.length) pick.appendChild(h(doc, 'div', 'hist-empty', 'No global models to share yet.'));
|
|
344
|
+
|
|
345
|
+
const envKeys = [...new Set(globals.flatMap((m) => Object.keys(m.env || {})))];
|
|
346
|
+
const pol = step('2 — Env var policy',
|
|
347
|
+
'Include value commits the stored value to the plugin (it will live in a git repo). ' +
|
|
348
|
+
'Require at install strips it — teammates are prompted once and the value stays on their machine.');
|
|
349
|
+
for (const k of envKeys) {
|
|
350
|
+
const row = h(doc, 'div', 'mvx-env-row');
|
|
351
|
+
row.dataset.key = k;
|
|
352
|
+
row.appendChild(h(doc, 'span', 'mono mvx-env-key', k));
|
|
353
|
+
const sel = h(doc, 'select', 'select mvx-mode');
|
|
354
|
+
for (const [v, label] of [['include', 'Include value'], ['secret', 'Require at install'], ['omit', 'Omit']]) {
|
|
355
|
+
const opt = h(doc, 'option', null, label);
|
|
356
|
+
opt.value = v;
|
|
357
|
+
sel.appendChild(opt);
|
|
358
|
+
}
|
|
359
|
+
sel.value = SECRETISH_RE.test(k) ? 'secret' : 'include';
|
|
360
|
+
row.appendChild(sel);
|
|
361
|
+
if (SECRETISH_RE.test(k)) row.appendChild(h(doc, 'small', 'hint mvx-warn', 'looks like a credential'));
|
|
362
|
+
pol.appendChild(row);
|
|
363
|
+
}
|
|
364
|
+
if (!envKeys.length) pol.appendChild(h(doc, 'small', 'hint', 'No env vars on your models — nothing to decide.'));
|
|
365
|
+
|
|
366
|
+
const meta = step('3 — Plugin metadata + destination');
|
|
367
|
+
const field = (labelText, cls, placeholder, hint) => {
|
|
368
|
+
const wrap = h(doc, 'label', 'mv-field');
|
|
369
|
+
wrap.appendChild(h(doc, 'span', 'mv-field-label', labelText));
|
|
370
|
+
const input = h(doc, 'input', `input ${cls}`);
|
|
371
|
+
input.type = 'text'; input.placeholder = placeholder;
|
|
372
|
+
wrap.appendChild(input);
|
|
373
|
+
if (hint) wrap.appendChild(h(doc, 'small', 'hint', hint));
|
|
374
|
+
meta.appendChild(wrap);
|
|
375
|
+
return input;
|
|
376
|
+
};
|
|
377
|
+
field('Plugin name', 'mvx-name', 'discretestack-models', 'kebab-case; becomes the folder + install name');
|
|
378
|
+
field('Description', 'mvx-desc', 'Team routing for …');
|
|
379
|
+
field('Version', 'mvx-version', '0.1.0');
|
|
380
|
+
field('Destination folder', 'mvx-dest', '~/dev/discretestack-models',
|
|
381
|
+
'Must be new or empty. The scaffold goes here — git init + push it to share.');
|
|
382
|
+
|
|
383
|
+
const msg = h(doc, 'p', 'form-msg mvx-msg');
|
|
384
|
+
msg.setAttribute('aria-live', 'polite');
|
|
385
|
+
root.appendChild(msg);
|
|
386
|
+
const btns = h(doc, 'div', 'mv-editor-btns');
|
|
387
|
+
const go = h(doc, 'button', 'btn-go mvx-export', 'Export scaffold');
|
|
388
|
+
go.type = 'button';
|
|
389
|
+
const cancel = h(doc, 'button', 'btn-ghost mvx-cancel', 'Cancel');
|
|
390
|
+
cancel.type = 'button';
|
|
391
|
+
btns.appendChild(go); btns.appendChild(cancel);
|
|
392
|
+
root.appendChild(btns);
|
|
393
|
+
return root;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Collect the wizard into the POST /api/models/export-plugin body. */
|
|
397
|
+
export function collectExportWizard(rootEl) {
|
|
398
|
+
const modes = {};
|
|
399
|
+
for (const row of rootEl.querySelectorAll('.mvx-env-row')) {
|
|
400
|
+
modes[row.dataset.key] = row.querySelector('.mvx-mode')?.value || 'include';
|
|
401
|
+
}
|
|
402
|
+
const models = [...rootEl.querySelectorAll('.mvx-model-cb')]
|
|
403
|
+
.filter((cb) => cb.checked)
|
|
404
|
+
.map((cb) => ({
|
|
405
|
+
id: cb.value,
|
|
406
|
+
env: Object.fromEntries((cb.dataset.envKeys || '').split('\n').filter(Boolean)
|
|
407
|
+
.map((k) => [k, modes[k] || 'include'])),
|
|
408
|
+
}));
|
|
409
|
+
const val = (cls) => (rootEl.querySelector(`.${cls}`)?.value || '').trim();
|
|
410
|
+
return {
|
|
411
|
+
name: val('mvx-name'),
|
|
412
|
+
description: val('mvx-desc'),
|
|
413
|
+
...(val('mvx-version') ? { version: val('mvx-version') } : {}),
|
|
414
|
+
dest: val('mvx-dest'),
|
|
415
|
+
models,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Human summary of what deleting `id` clears, for the confirmation prompt. */
|
|
420
|
+
export function deleteRefsSummary(id, refs) {
|
|
421
|
+
if (refs && refs.predefinedShadow) {
|
|
422
|
+
return `Remove the override of built-in "${id}"? The built-in entry comes back; nothing else changes.`;
|
|
423
|
+
}
|
|
424
|
+
const nodes = refs && Array.isArray(refs.nodes) ? refs.nodes.length : 0;
|
|
425
|
+
const steps = refs && Array.isArray(refs.steps) ? refs.steps.length : 0;
|
|
426
|
+
const projects = new Set([
|
|
427
|
+
...((refs && refs.nodes) || []).map((n) => n.projectKey),
|
|
428
|
+
...((refs && refs.steps) || []).map((s) => s.projectKey),
|
|
429
|
+
]).size;
|
|
430
|
+
if (!nodes && !steps) return `Delete model "${id}"? No pipeline configuration references it.`;
|
|
431
|
+
return `Delete model "${id}"? This also clears ${nodes} node selection${nodes === 1 ? '' : 's'} and ` +
|
|
432
|
+
`${steps} role selection${steps === 1 ? '' : 's'} across ${projects} project${projects === 1 ? '' : 's'}.`;
|
|
433
|
+
}
|