@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.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,206 @@
1
+ // src/core/agent-gen.mjs
2
+ // The agent-creation wizard's builder engine. An AgentGen is an EventEmitter the
3
+ // server wires onto the WS bus exactly like wireScan wires a WorkspaceScan:
4
+ // agentgen-progress { genId, phase, message } (many)
5
+ // agentgen-done { genId, draft: { meta, markdown } } (terminal)
6
+ // agentgen-error { genId, message } (terminal)
7
+ // run() NEVER throws. The draft is NOT saved — saving is the wizard's explicit
8
+ // POST /api/agents. Mode A (no userMarkdown): one runClaude writes BOTH the .md
9
+ // body and the meta JSON draft. Mode B (userMarkdown given): the body is the
10
+ // user's verbatim; the LLM writes ONLY the meta JSON, inferred from the body +
11
+ // the neighbors' produces/consumes. Files are read back as authoritative
12
+ // (phases.mjs runWorkspaceScan pattern) then normalized via normalizeMeta.
13
+
14
+ import { EventEmitter } from 'node:events';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { join } from 'node:path';
17
+ import { mkdir, rm, readFile, writeFile } from 'node:fs/promises';
18
+ import { worcaHome } from './projects.mjs';
19
+ import { runClaude } from './claude-runner.mjs';
20
+ import { resolveModelEnv } from './config.mjs';
21
+ import { normalizeMeta } from './agent-registry.mjs';
22
+
23
+ const SYSTEM_PROMPT =
24
+ 'You are an expert at writing agent system prompts and machine-readable agent metadata ' +
25
+ 'for worca-cc, a deterministic multi-agent pipeline. Write files exactly where asked. ' +
26
+ 'Metadata must be a single valid JSON object.';
27
+
28
+ export function createAgentGen(opts = {}) { return new AgentGen(opts); }
29
+
30
+ class AgentGen extends EventEmitter {
31
+ constructor(opts = {}) {
32
+ super();
33
+ this.name = (typeof opts.name === 'string' && opts.name.trim()) || 'Custom Agent';
34
+ this.purpose = String(opts.purpose || '');
35
+ this.details = String(opts.details || '');
36
+ this.expectedBefore = Array.isArray(opts.expectedBefore) ? opts.expectedBefore : [];
37
+ this.expectedAfter = Array.isArray(opts.expectedAfter) ? opts.expectedAfter : [];
38
+ this.userMarkdown = typeof opts.userMarkdown === 'string' && opts.userMarkdown.trim() ? opts.userMarkdown : '';
39
+ this.channels = Array.isArray(opts.channels) ? opts.channels : [];
40
+ this.claude = opts.claude || {};
41
+ this.genId = `agen_${randomUUID()}`;
42
+ this.scratchDir = join(worcaHome(), 'tmp', 'agent-gen', this.genId.slice(5, 13));
43
+ this.mdPath = join(this.scratchDir, 'agent.md');
44
+ this.metaPath = join(this.scratchDir, 'agent.meta.json');
45
+ this.abort = new AbortController();
46
+ this.phase = 'draft';
47
+ this.message = 'preparing…';
48
+ this.status = 'created';
49
+ this._terminal = false;
50
+ }
51
+
52
+ getState() {
53
+ return { genId: this.genId, phase: this.phase, message: this.message, status: this.status };
54
+ }
55
+
56
+ stop() {
57
+ if (this.status === 'done' || this.status === 'stopped' || this.status === 'error') return;
58
+ this.status = 'stopped';
59
+ try { this.abort.abort(); } catch { /* ignore */ }
60
+ }
61
+
62
+ async run() {
63
+ try {
64
+ this.status = 'running';
65
+ this._checkAbort();
66
+ await mkdir(this.scratchDir, { recursive: true });
67
+ const metaOnly = !!this.userMarkdown;
68
+ this._setPhase('draft', metaOnly
69
+ ? `inferring metadata for "${this.name}" from your markdown…`
70
+ : `drafting agent + metadata for "${this.name}"…`);
71
+ if (metaOnly) await writeFile(this.mdPath, this.userMarkdown, 'utf8'); // the LLM reads it
72
+ await runClaude({
73
+ cwd: this.scratchDir,
74
+ systemPrompt: SYSTEM_PROMPT,
75
+ prompt: metaOnly ? this._metaPrompt() : this._fullPrompt(),
76
+ allowedTools: ['Read', 'Write'],
77
+ permissionMode: this.claude.permissionMode || 'acceptEdits',
78
+ model: this.claude.model,
79
+ modelEnv: resolveModelEnv(this.claude.model), // catalog routing env (design §4.8)
80
+ bin: this.claude.bin,
81
+ mock: this.claude.mock,
82
+ signal: this.abort.signal,
83
+ onEvent: (e) => this._onAgentEvent(e),
84
+ });
85
+ this._checkAbort();
86
+ this._setPhase('finalize', 'validating the draft…');
87
+ // Authoritative read-back (runWorkspaceScan pattern, phases.mjs:803-809).
88
+ const markdown = metaOnly ? this.userMarkdown : await readFile(this.mdPath, 'utf8');
89
+ const rawMeta = JSON.parse(await readFile(this.metaPath, 'utf8'));
90
+ if (!Number.isFinite(Number(rawMeta?.order))) rawMeta.order = 99;
91
+ const meta = normalizeMeta(rawMeta);
92
+ if (!meta) throw new Error('the generator produced unusable metadata');
93
+ if (!String(markdown || '').trim()) throw new Error('the generator produced an empty agent body');
94
+ this.status = 'done';
95
+ const draft = { meta, markdown };
96
+ this._emitTerminal('agentgen-done', { draft });
97
+ return { status: 'done', draft };
98
+ } catch (err) {
99
+ if (isAbort(err) || this.status === 'stopped') {
100
+ this.status = 'stopped';
101
+ this._emitTerminal('agentgen-error', { message: 'stopped' });
102
+ return { status: 'stopped' };
103
+ }
104
+ this.status = 'error';
105
+ const message = (err && err.message) || String(err);
106
+ this._emitTerminal('agentgen-error', { message });
107
+ return { status: 'error', message };
108
+ } finally {
109
+ await rm(this.scratchDir, { recursive: true, force: true }).catch(() => {});
110
+ }
111
+ }
112
+
113
+ _neighborBlock() {
114
+ const j = (list) => JSON.stringify(list.map((m) => ({
115
+ key: m.key, displayName: m.displayName, produces: m.produces || [],
116
+ consumes: m.consumes || [], optionalConsumes: m.optionalConsumes || [],
117
+ })), null, 2);
118
+ return (
119
+ `## Pipeline neighbors\n\n` +
120
+ `Agents expected to run BEFORE this one (their produces are this agent's likely consumes):\n${j(this.expectedBefore)}\n\n` +
121
+ `Agents expected to run AFTER this one (their consumes are this agent's likely produces):\n${j(this.expectedAfter)}\n\n` +
122
+ `## Channel vocabulary\n\nconsumes/optionalConsumes/produces MUST use ONLY these ids: ` +
123
+ `${this.channels.join(', ') || '(see neighbors)'}\n\n`
124
+ );
125
+ }
126
+
127
+ _metaSchemaBlock() {
128
+ return (
129
+ `Write the metadata JSON to: ${this.metaPath}\n` +
130
+ 'EXACT shape (one JSON object): { "key": "<lowerCamel>", "displayName", "description", ' +
131
+ '"color": "green|peach|red|blue|violet|amber", "runnerType": "producer|verifier", ' +
132
+ '"loopSource": bool, "fanOut": bool, "asksQuestions": bool, "questionsLocked": bool, ' +
133
+ '"questionsDefault": bool, "consumes": [..], "optionalConsumes": [..], ' +
134
+ '"produces": [..], "connectsTo": "*"|["key",..], "order": number }\n' +
135
+ '"description" is the palette blurb: 1-2 plain sentences, max 160 chars total and the ' +
136
+ 'FIRST sentence max 75 chars (the palette card clamps at 1-2 short lines). It is shown under ' +
137
+ 'the agent name in the composer palette — say what the agent does and what it reads/produces.\n' +
138
+ 'Questions flags: asksQuestions=true if the agent may need a user decision mid-task ' +
139
+ '(the orchestrator pauses it and resumes it with the answers). questionsLocked=true ONLY if ' +
140
+ "asking the user is the agent's whole purpose (the user then cannot toggle it in the " +
141
+ 'pipeline menu). questionsDefault=true only for locked-on agents; every other agent ' +
142
+ 'starts OFF and the user opts in per pipeline.\n\n'
143
+ );
144
+ }
145
+
146
+ _fullPrompt() {
147
+ return (
148
+ `# Task: Build a worca-cc agent — ${this.name}\n\n` +
149
+ `## Purpose\n${this.purpose}\n\n## Detailed description\n${this.details}\n\n` +
150
+ this._neighborBlock() +
151
+ '## What to write\n\n' +
152
+ `1. The agent's system-prompt markdown (role, inputs, outputs, method, output contract) to: ${this.mdPath}\n` +
153
+ `2. ${this._metaSchemaBlock()}` +
154
+ 'Announce progress with lines starting `DRAFTING `.\n\n' +
155
+ `MOCK_ROLE: agent-gen\nMOCK_OUT: ${this.mdPath}\nMOCK_JSON: ${this.metaPath}\nMOCK_BASE: ${this.name}\n`
156
+ );
157
+ }
158
+
159
+ _metaPrompt() {
160
+ return (
161
+ `# Task: Infer worca-cc agent metadata — ${this.name}\n\n` +
162
+ `The user wrote the agent system prompt themselves. Read it at: ${this.mdPath}\n` +
163
+ 'Do NOT modify that file. Derive the metadata from its content and the neighbors below.\n\n' +
164
+ this._neighborBlock() +
165
+ `## What to write\n\n${this._metaSchemaBlock()}` +
166
+ 'Announce progress with lines starting `DRAFTING `.\n\n' +
167
+ `MOCK_ROLE: agent-gen\nMOCK_JSON: ${this.metaPath}\nMOCK_BASE: ${this.name}\n`
168
+ );
169
+ }
170
+
171
+ _onAgentEvent(e) {
172
+ const text = typeof e?.text === 'string' ? e.text : '';
173
+ const m = text.match(/DRAFTING\s+(.{0,80})/i);
174
+ if (m) this._progress(`drafting ${m[1].trim()}…`);
175
+ }
176
+
177
+ _setPhase(phase, message) { this.phase = phase; this._progress(message); }
178
+
179
+ _progress(message) {
180
+ if (this._terminal) return;
181
+ if (message) this.message = message;
182
+ this.emit('agentgen-progress', { genId: this.genId, phase: this.phase, message: this.message });
183
+ }
184
+
185
+ _emitTerminal(type, payload) {
186
+ if (this._terminal) return;
187
+ this._terminal = true;
188
+ this.emit(type, { genId: this.genId, ...payload });
189
+ }
190
+
191
+ _checkAbort() {
192
+ if (this.abort.signal.aborted || this.status === 'stopped') {
193
+ const err = new Error('stopped');
194
+ err.name = 'AbortError';
195
+ throw err;
196
+ }
197
+ }
198
+ }
199
+
200
+ function isAbort(err) {
201
+ // NAME only, matching orchestrator.isAbort (kept local: importing it would
202
+ // create an import cycle). _checkAbort stamps name='AbortError'; a message
203
+ // sniff also matched real failures mentioning "aborted"/"stopped" and would
204
+ // end agent generation silently.
205
+ return !!err && err.name === 'AbortError';
206
+ }
@@ -0,0 +1,417 @@
1
+ // src/core/agent-registry.mjs
2
+ // Data-driven agent registry. Scans agents/*.meta.json into an in-memory map
3
+ // keyed by agent key, sorted by `.order`. This replaces what used to be hardcoded
4
+ // across AGENT_FILES (orchestrator.mjs) and AGENT_STEPS (config.mjs): adding an
5
+ // agent is now "drop agents/<key>.md + agents/<key>.meta.json", no core edit.
6
+ //
7
+ // Read synchronously so it can back a synchronous AGENT_STEPS constant in
8
+ // config.mjs. Tolerant: a malformed sidecar, or one missing `key`/`order`, is
9
+ // skipped rather than throwing (mirrors the tolerant readers elsewhere).
10
+
11
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { CHANNEL_IDS as CHANNEL_ID_LIST } from './channels.mjs'; // single source (m2)
14
+ import { worcaHome } from './projects.mjs'; // user agent layer root (read fresh per call)
15
+ import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs'; // plugin layer roots (Task 2)
16
+
17
+ /** Default location of the agent metadata sidecars, relative to this module. */
18
+ const DEFAULT_AGENTS_DIR = new URL('../../agents/', import.meta.url).pathname;
19
+
20
+ const COLORS = new Set(['green', 'peach', 'red', 'blue', 'violet', 'amber']);
21
+ const RUNNER_TYPES = new Set(['producer', 'verifier', 'clarifier']);
22
+ const CHANNEL_IDS = new Set(CHANNEL_ID_LIST);
23
+
24
+ /** Organizational-only domain tag (coding, marketing, financing, …): lowercase
25
+ * kebab, ≤32 chars. 'shared' is a recognized sentinel that passes this regex and
26
+ * is stored verbatim; the palette injects it into every section. */
27
+ const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
28
+
29
+ /** Coerce a raw domain to a valid tag; absent/malformed fails safe to the VISIBLE
30
+ * 'general' default. Does NOT trim (meta-file input is authored). */
31
+ function normalizeDomain(raw) {
32
+ return typeof raw === 'string' && DOMAIN_RE.test(raw) ? raw : 'general';
33
+ }
34
+
35
+ /**
36
+ * Built-in channel/governance spec per agent key. Used when a sidecar omits the
37
+ * fields, so the six shipped agents behave byte-identically to today's _nodeIo
38
+ * switch and every saved pipeline stays connectsTo-legal.
39
+ */
40
+ const DEFAULT_SPEC = {
41
+ clarify: { consumes: ['userPrompt'], produces: ['clarify'], connectsTo: ['planner'] },
42
+ planner: { consumes: ['userPrompt', 'clarify', 'review'], optionalConsumes: ['clarify', 'review'], produces: ['plan'], connectsTo: ['refiner', 'implementer', 'planReviewer', 'decomposer'] },
43
+ refiner: { consumes: ['plan'], produces: ['plan', 'review'], connectsTo: ['implementer', 'refiner', 'decomposer'] },
44
+ decomposer: { consumes: ['plan'], produces: ['decomposition'], connectsTo: ['implementer'] },
45
+ implementer: { consumes: ['plan', 'review'], optionalConsumes: ['review'], produces: ['code'], connectsTo: ['reviewer', 'manualTestsChecklist'] },
46
+ reviewer: { consumes: ['plan', 'code'], produces: ['review'], connectsTo: ['implementer', 'manualTestsChecklist'] },
47
+ manualTestsChecklist: { consumes: ['plan', 'code'], produces: ['checklist'], connectsTo: ['manualWebUiTesting'] },
48
+ manualWebUiTesting: { consumes: ['checklist', 'code'], produces: ['review'], connectsTo: ['implementer'] },
49
+ planReviewer: { consumes: ['plan'], produces: ['review'], connectsTo: ['planner', 'implementer', 'decomposer'] },
50
+ // Workspace agents (scope:'workspace-only', §6.2). The scanner is off-pipeline
51
+ // (connectsTo:[] -> non-composable); the reviewer slots into the code->review->
52
+ // implementer loop exactly like `reviewer`.
53
+ workspaceScanner: { consumes: ['userPrompt'], produces: ['workspace'], connectsTo: [] },
54
+ workspaceReviewer: { consumes: ['plan', 'code'], produces: ['review'], connectsTo: ['implementer'] },
55
+ };
56
+
57
+ /** Channel ids: built-ins or any well-formed CUSTOM id (open vocabulary, m1-v2).
58
+ * Only a malformed id is warned on and dropped — a typo of a built-in becomes a
59
+ * custom channel. Consumed ids are surfaced by the validator's reachability
60
+ * warning; a typo'd pre-seeded id in `produces` has no warning net — the
61
+ * artifact simply lands on the typo'd channel. */
62
+ const CUSTOM_CHANNEL_ID_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
63
+ function channelList(raw, key, field) {
64
+ if (!Array.isArray(raw)) return undefined;
65
+ const out = [];
66
+ for (const s of raw) {
67
+ const id = String(s || '').trim();
68
+ if (!id) continue;
69
+ if (CHANNEL_IDS.has(id) || CUSTOM_CHANNEL_ID_RE.test(id)) out.push(id);
70
+ else console.warn(`[agent-registry] ${key}.${field}: malformed channel id "${id}" ignored`);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** Normalize connectsTo: '*' | string[] of agent keys. Anything else => fallback.
76
+ * A raw value of '*' is treated as "unset" so DEFAULT_SPEC can override it. */
77
+ function normalizeConnectsTo(raw, fallback) {
78
+ if (Array.isArray(raw)) {
79
+ const out = raw.map((s) => String(s || '').trim()).filter(Boolean);
80
+ return out.length ? out : (fallback ?? '*');
81
+ }
82
+ // raw === '*' or anything else: use the fallback (spec array or '*')
83
+ return fallback ?? '*';
84
+ }
85
+
86
+ /**
87
+ * Legacy short labels for the original four roles, so the derived AGENT_STEPS is
88
+ * byte-identical to the hardcoded one the UI/orchestrator have always used. New
89
+ * agents fall back to their `displayName`.
90
+ */
91
+ const LEGACY_LABELS = {
92
+ planner: 'Plan',
93
+ refiner: 'Refine',
94
+ implementer: 'Implement',
95
+ reviewer: 'Review',
96
+ };
97
+
98
+ const CHANNEL_DEF_KINDS = new Set(['md', 'json']);
99
+
100
+ /** Normalize a sidecar's channelDefs: well-formed custom ids only, kind md|json
101
+ * (default md), filename a plain basename (default <id>.<ext>); built-in channel
102
+ * ids cannot be redefined. */
103
+ function normalizeChannelDefs(raw, key) {
104
+ if (!Array.isArray(raw)) return [];
105
+ const out = [];
106
+ const seen = new Set();
107
+ for (const d of raw) {
108
+ if (!d || typeof d !== 'object') continue;
109
+ const id = typeof d.id === 'string' ? d.id.trim() : '';
110
+ if (!CUSTOM_CHANNEL_ID_RE.test(id)) {
111
+ if (id) console.warn(`[agent-registry] ${key}.channelDefs: bad channel id "${id}" ignored`);
112
+ continue;
113
+ }
114
+ if (CHANNEL_IDS.has(id)) {
115
+ console.warn(`[agent-registry] ${key}.channelDefs: "${id}" is a built-in channel and cannot be redefined`);
116
+ continue;
117
+ }
118
+ if (seen.has(id)) continue;
119
+ seen.add(id);
120
+ const kind = CHANNEL_DEF_KINDS.has(d.kind) ? d.kind : 'md';
121
+ const fnRaw = typeof d.filename === 'string' ? d.filename.trim() : '';
122
+ // basename only: a def must never escape the pipeline dir
123
+ const pathSafe = fnRaw && !/[\\/]/.test(fnRaw) && !fnRaw.includes('..');
124
+ if (fnRaw && !pathSafe) {
125
+ console.warn(`[agent-registry] ${key}.channelDefs: filename "${fnRaw}" is not a plain basename; using "${id}.${kind}"`);
126
+ }
127
+ const filename = pathSafe ? fnRaw : `${id}.${kind}`;
128
+ out.push({ id, kind, filename });
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Registry-level channel definition collection: merge every agent's channelDefs
135
+ * into { [channelId]: {id, kind, filename} }. Registry order (sorted by .order)
136
+ * makes "first definition wins" deterministic; conflicts warn.
137
+ * @param {Record<string, object>} registry
138
+ */
139
+ export function collectChannelDefs(registry) {
140
+ const defs = {};
141
+ for (const m of Object.values(registry || {})) {
142
+ for (const d of m.channelDefs || []) {
143
+ if (Object.hasOwn(defs, d.id)) {
144
+ if (defs[d.id].kind !== d.kind || defs[d.id].filename !== d.filename) {
145
+ console.warn(`[agent-registry] channel "${d.id}" redefined by "${m.key}"; first definition wins`);
146
+ }
147
+ continue;
148
+ }
149
+ defs[d.id] = { ...d };
150
+ }
151
+ }
152
+ return defs;
153
+ }
154
+
155
+ /**
156
+ * Ordered unique domain list for UI section headers. Registry is already sorted
157
+ * by .order (loadAgentRegistry sorts at line 260), so first-seen order is stable.
158
+ * 'general' is pinned LAST (fail-safe bucket renders last); 'shared' is EXCLUDED —
159
+ * it is injected into every section, never a header of its own. 'general' is always
160
+ * present so the fail-safe bucket is reachable.
161
+ * @param {Record<string, object>} registry
162
+ */
163
+ export function collectDomains(registry) {
164
+ const seen = [];
165
+ for (const meta of Object.values(registry || {})) {
166
+ const d = meta && meta.domain;
167
+ if (!d || d === 'shared' || d === 'general' || seen.includes(d)) continue;
168
+ seen.push(d);
169
+ }
170
+ seen.push('general'); // always present, always last
171
+ return seen;
172
+ }
173
+
174
+ /** Agent keys become filename stems (review basenames, config keys); keep them
175
+ * identifier-shaped so a key can never escape a directory. */
176
+ const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
177
+
178
+ /** Coerce one parsed sidecar into a normalized AgentMeta, or null if unusable. */
179
+ export function normalizeMeta(raw) {
180
+ if (!raw || typeof raw !== 'object') return null;
181
+ const key = typeof raw.key === 'string' ? raw.key.trim() : '';
182
+ if (!key) return null;
183
+ if (!AGENT_KEY_RE.test(key)) {
184
+ console.warn(`[agent-registry] sidecar key "${key}" is not a valid agent key; skipped`);
185
+ return null;
186
+ }
187
+ const order = Number(raw.order);
188
+ if (!Number.isFinite(order)) return null;
189
+ const color = COLORS.has(raw.color) ? raw.color : 'amber';
190
+ const runnerType = RUNNER_TYPES.has(raw.runnerType) ? raw.runnerType : 'producer';
191
+ // §6.6 scope coercion (fail-safe, mirrors color): anything but the explicit
192
+ // 'workspace-only' marker is a normal 'project'-scope agent, so a typo fails
193
+ // safe to a VISIBLE project agent (surfaced by the palette test) rather than a
194
+ // silently-hidden one.
195
+ const scope = raw.scope === 'workspace-only' ? 'workspace-only' : 'project';
196
+ // Per-agent user questions (spec 2026-07-11): capability + lock + default.
197
+ // Coherence is forced HERE (single source of truth): an agent that cannot ask
198
+ // can be neither locked nor default-on, so UI/agent-gen never validate this.
199
+ const asksQuestions = !!raw.asksQuestions;
200
+ const spec = DEFAULT_SPEC[key] || {};
201
+ const rtFallbackConsumes = runnerType === 'verifier' ? ['code'] : ['userPrompt'];
202
+ const consumes = channelList(raw.consumes, key, 'consumes') || spec.consumes || rtFallbackConsumes;
203
+ const produces = channelList(raw.produces, key, 'produces') || spec.produces || (runnerType === 'verifier' ? ['review'] : []);
204
+ const optionalConsumes = channelList(raw.optionalConsumes, key, 'optionalConsumes') || spec.optionalConsumes || [];
205
+ const connectsTo = normalizeConnectsTo(raw.connectsTo, spec.connectsTo || '*');
206
+ return {
207
+ key,
208
+ displayName: typeof raw.displayName === 'string' && raw.displayName.trim()
209
+ ? raw.displayName.trim()
210
+ : key,
211
+ description: typeof raw.description === 'string' ? raw.description : '',
212
+ color,
213
+ icon: typeof raw.icon === 'string' ? raw.icon : '',
214
+ agentFile: typeof raw.agentFile === 'string' && raw.agentFile.trim() ? raw.agentFile.trim() : null,
215
+ runnerType,
216
+ scope,
217
+ domain: normalizeDomain(raw.domain), // always set; fail-safe VISIBLE default 'general'
218
+ loopSource: !!raw.loopSource,
219
+ fanOut: !!raw.fanOut,
220
+ asksQuestions,
221
+ questionsLocked: asksQuestions && !!raw.questionsLocked,
222
+ questionsDefault: asksQuestions && !!raw.questionsDefault,
223
+ consumes,
224
+ optionalConsumes,
225
+ produces,
226
+ connectsTo,
227
+ order,
228
+ // ── schema v2 (all optional; absent => safe defaults; origin/agentPath are
229
+ // stamped by scanLayer as COMPUTED fields, never read from the sidecar) ──
230
+ uiPhase: typeof raw.uiPhase === 'string' && raw.uiPhase.trim() ? raw.uiPhase.trim() : null,
231
+ promptHints: typeof raw.promptHints === 'string' ? raw.promptHints : '',
232
+ version: typeof raw.version === 'string' || typeof raw.version === 'number' ? String(raw.version) : '1',
233
+ channelDefs: normalizeChannelDefs(raw.channelDefs, key),
234
+ requiresSkills: Array.isArray(raw.requiresSkills)
235
+ ? raw.requiresSkills.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
236
+ : [],
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Directory of USER agents: <worcaHome()>/agents (~/.worca-cc/agents). Resolved
242
+ * fresh on every call (mirrors worcaHome's read-fresh contract). Returns null
243
+ * when the home cannot be resolved (e.g. under the node:test runner with no
244
+ * WORCA_HOME — projects.mjs throws there to protect the real store), so module
245
+ * import and registry loads never throw.
246
+ */
247
+ export function userAgentsDir() {
248
+ try { return join(worcaHome(), 'agents'); } catch { return null; }
249
+ }
250
+
251
+ /**
252
+ * Third registry layer (spec §9.1): every ENABLED installed plugin's
253
+ * current/agents dir, in lexicographic plugin-name order — the deterministic
254
+ * collision winner among plugins. An entry is skipped when disabled
255
+ * (enabled === false in the lock) or broken (existsSync follows the current/
256
+ * symlink, so a missing or dangling symlink — and a version dir without
257
+ * agents/ — drops out). Wrapped in try/catch like userAgentsDir(): with no
258
+ * resolvable worca-cc home (bare node:test runner) or an unreadable lock this
259
+ * returns [] and registry loads never throw.
260
+ * @returns {Array<{plugin: string, dir: string}>}
261
+ */
262
+ export function pluginAgentLayers() {
263
+ try {
264
+ const lock = readPluginsLock();
265
+ return Object.keys(lock)
266
+ .sort()
267
+ .filter((name) => lock[name] && lock[name].enabled !== false)
268
+ .map((name) => ({ plugin: name, dir: join(pluginCurrentDir(name), 'agents') }))
269
+ .filter(({ dir }) => existsSync(dir));
270
+ } catch {
271
+ return []; // no home / unreadable lock => no plugin layer (fails safe)
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Fallback palette blurb: the agent .md's YAML frontmatter `description:` line,
277
+ * stored VERBATIM (clarify 2026-08-09: the UI clamps, the bubble wants the full
278
+ * text — never truncate here). Single-line values only (plain or quoted);
279
+ * folded/multi-line scalars are out of scope by design (spec 2026-08-09; every
280
+ * shipped .md uses a single-line scalar) and degrade to '' — the block-scalar
281
+ * indicator is detected, never stored. Any read/parse failure returns '' so
282
+ * scanLayer never throws because of the fallback.
283
+ */
284
+ function frontmatterDescription(mdPath) {
285
+ let text;
286
+ try { text = readFileSync(mdPath, 'utf8'); } catch { return ''; }
287
+ const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
288
+ if (!fm) return '';
289
+ const line = fm[1].match(/^description:[ \t]*(.+)$/m);
290
+ if (!line) return '';
291
+ let v = line[1].trim();
292
+ // Folded/literal block scalars ('>', '>-', '|', '|+', …): the captured value
293
+ // is just the indicator, not the text — degrade to '' rather than store junk.
294
+ if (/^[>|][+-]?$/.test(v)) return '';
295
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
296
+ v = v.slice(1, -1).trim();
297
+ }
298
+ return v;
299
+ }
300
+
301
+ /** Scan one layer dir for *.meta.json; stamps the COMPUTED origin/agentPath/
302
+ * descriptionDerived fields (none of which normalizeMeta returns, so none can
303
+ * be persisted back into a sidecar). */
304
+ function scanLayer(dir, origin) {
305
+ let files;
306
+ try {
307
+ files = readdirSync(dir);
308
+ } catch {
309
+ return []; // missing layer dir => empty layer (fails safe)
310
+ }
311
+ const metas = [];
312
+ for (const f of files) {
313
+ if (!f.endsWith('.meta.json')) continue;
314
+ let parsed;
315
+ try {
316
+ parsed = JSON.parse(readFileSync(join(dir, f), 'utf8'));
317
+ } catch {
318
+ continue; // skip unreadable / malformed sidecars
319
+ }
320
+ const meta = normalizeMeta(parsed);
321
+ if (!meta) continue;
322
+ meta.origin = origin; // computed, never stored
323
+ meta.agentPath = meta.agentFile ? join(dir, meta.agentFile) : null; // layer-correct abs path
324
+ // Description fallback (spec 2026-08-09): empty sidecar description →
325
+ // the .md frontmatter description. Only costs a file read when empty.
326
+ // descriptionDerived marks the RESOLVED description as computed too: unlike
327
+ // origin/agentPath, `description` has a slot in normalizeMeta, so without
328
+ // this flag every write path would bake the fallback into the sidecar and
329
+ // the blurb would stop tracking the .md (and could never be cleared).
330
+ if (!meta.description && meta.agentPath) {
331
+ meta.description = frontmatterDescription(meta.agentPath);
332
+ if (meta.description) meta.descriptionDerived = true; // computed, never stored
333
+ }
334
+ metas.push(meta);
335
+ }
336
+ return metas;
337
+ }
338
+
339
+ /**
340
+ * Scan the built-in layer (`agentsDir`) AND the user layer (~/.worca-cc/agents) and
341
+ * build the merged registry. Built-ins are IMMUTABLE: a user sidecar whose key
342
+ * collides with a built-in is skipped with a warning. Re-scans both layers on
343
+ * every call (no module-level cache), so the registry is always reloadable.
344
+ * @param {string} [agentsDir] built-in layer (repo agents/)
345
+ * @param {{userAgentsDir?: string|null}} [opts] user layer override; null disables
346
+ * @returns {Record<string, object>} agent key -> AgentMeta, sorted by `.order`
347
+ */
348
+ export function loadAgentRegistry(agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
349
+ const builtins = scanLayer(agentsDir, 'builtin');
350
+ const builtinKeys = new Set(builtins.map((m) => m.key));
351
+ const userDir = opts.userAgentsDir === undefined ? userAgentsDir() : opts.userAgentsDir;
352
+ const users = [];
353
+ if (userDir) {
354
+ for (const m of scanLayer(userDir, 'user')) {
355
+ if (builtinKeys.has(m.key)) {
356
+ console.warn(
357
+ `[agent-registry] user agent "${m.key}" shadows a built-in and was skipped (built-ins are immutable)`,
358
+ );
359
+ continue;
360
+ }
361
+ users.push(m);
362
+ }
363
+ }
364
+ // Plugin layer (spec §9.1): builtin > user > plugin; among plugins the
365
+ // lexicographic name order of pluginAgentLayers() decides. Same skip-on-
366
+ // collision + warning contract as the user layer above. scanLayer stamps the
367
+ // COMPUTED origin ('plugin:<name>') and agentPath (through current/, so a
368
+ // version swap retargets every path atomically). opts.includePlugins=false is
369
+ // the escape hatch for callers that must not see plugins (default true).
370
+ // Zero plugins installed => pluginAgentLayers() === [] => byte-identical merge.
371
+ const plugins = [];
372
+ if (opts.includePlugins !== false) {
373
+ const taken = new Set([...builtinKeys, ...users.map((m) => m.key)]);
374
+ for (const { plugin, dir } of pluginAgentLayers()) {
375
+ for (const m of scanLayer(dir, `plugin:${plugin}`)) {
376
+ if (taken.has(m.key)) {
377
+ console.warn(
378
+ `[agent-registry] plugin agent "${m.key}" (plugin "${plugin}") collides with an existing agent and was skipped`,
379
+ );
380
+ continue;
381
+ }
382
+ taken.add(m.key);
383
+ plugins.push(m);
384
+ }
385
+ }
386
+ }
387
+ const metas = [...builtins, ...users, ...plugins].sort((a, b) => a.order - b.order); // stable sort
388
+ const registry = {};
389
+ for (const m of metas) registry[m.key] = m;
390
+ return registry;
391
+ }
392
+
393
+ /**
394
+ * Derive the legacy `[{key,label}]` step list from a registry (replacement source
395
+ * for the hardcoded AGENT_STEPS). The original four roles keep their short legacy
396
+ * labels; any additional agent uses its `displayName`.
397
+ *
398
+ * §6.6/C9: `scope:'workspace-only'` agents are EXCLUDED — they are not part of the
399
+ * single-project UI stepper / per-step config keyspace that AGENT_STEPS drives, so
400
+ * this returns the 9 built-in project-scope steps plus any user-layer project
401
+ * agents (without the exclusion the two workspace sidecars would add 2 more).
402
+ * @param {Record<string, object>} registry
403
+ * @returns {Array<{key:string,label:string,fanOut:boolean,asksQuestions:boolean,questionsLocked:boolean,questionsDefault:boolean}>}
404
+ */
405
+ export function registryToSteps(registry) {
406
+ return Object.values(registry || {})
407
+ .filter((m) => m.scope !== 'workspace-only')
408
+ .sort((a, b) => a.order - b.order)
409
+ .map((m) => ({
410
+ key: m.key,
411
+ label: LEGACY_LABELS[m.key] || m.displayName,
412
+ fanOut: !!m.fanOut,
413
+ asksQuestions: !!m.asksQuestions,
414
+ questionsLocked: !!m.questionsLocked,
415
+ questionsDefault: !!m.questionsDefault,
416
+ }));
417
+ }