@worca/app 1.0.0 → 1.1.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 +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
package/src/core/agent-gen.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// POST /api/agents. Mode A (no userMarkdown): one runClaude writes BOTH the .md
|
|
9
9
|
// body and the meta JSON draft. Mode B (userMarkdown given): the body is the
|
|
10
10
|
// user's verbatim; the LLM writes ONLY the meta JSON, inferred from the body +
|
|
11
|
-
// the neighbors'
|
|
11
|
+
// the neighbors' typed input/output PORTS. Files are read back as authoritative
|
|
12
12
|
// (phases.mjs runWorkspaceScan pattern) then normalized via normalizeMeta.
|
|
13
13
|
|
|
14
14
|
import { EventEmitter } from 'node:events';
|
|
@@ -19,10 +19,11 @@ import { worcaHome } from './projects.mjs';
|
|
|
19
19
|
import { runClaude } from './claude-runner.mjs';
|
|
20
20
|
import { resolveModelEnv } from './config.mjs';
|
|
21
21
|
import { normalizeMeta } from './agent-registry.mjs';
|
|
22
|
+
import { validateMetaV2 } from '../shared/graph/agent-meta.mjs';
|
|
22
23
|
|
|
23
24
|
const SYSTEM_PROMPT =
|
|
24
25
|
'You are an expert at writing agent system prompts and machine-readable agent metadata ' +
|
|
25
|
-
'for worca
|
|
26
|
+
'for worca, a deterministic multi-agent pipeline. Write files exactly where asked. ' +
|
|
26
27
|
'Metadata must be a single valid JSON object.';
|
|
27
28
|
|
|
28
29
|
export function createAgentGen(opts = {}) { return new AgentGen(opts); }
|
|
@@ -36,7 +37,6 @@ class AgentGen extends EventEmitter {
|
|
|
36
37
|
this.expectedBefore = Array.isArray(opts.expectedBefore) ? opts.expectedBefore : [];
|
|
37
38
|
this.expectedAfter = Array.isArray(opts.expectedAfter) ? opts.expectedAfter : [];
|
|
38
39
|
this.userMarkdown = typeof opts.userMarkdown === 'string' && opts.userMarkdown.trim() ? opts.userMarkdown : '';
|
|
39
|
-
this.channels = Array.isArray(opts.channels) ? opts.channels : [];
|
|
40
40
|
this.claude = opts.claude || {};
|
|
41
41
|
this.genId = `agen_${randomUUID()}`;
|
|
42
42
|
this.scratchDir = join(worcaHome(), 'tmp', 'agent-gen', this.genId.slice(5, 13));
|
|
@@ -69,25 +69,18 @@ class AgentGen extends EventEmitter {
|
|
|
69
69
|
? `inferring metadata for "${this.name}" from your markdown…`
|
|
70
70
|
: `drafting agent + metadata for "${this.name}"…`);
|
|
71
71
|
if (metaOnly) await writeFile(this.mdPath, this.userMarkdown, 'utf8'); // the LLM reads it
|
|
72
|
-
await
|
|
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
|
-
});
|
|
72
|
+
await this._runClaude(metaOnly);
|
|
85
73
|
this._checkAbort();
|
|
86
74
|
this._setPhase('finalize', 'validating the draft…');
|
|
87
|
-
// Authoritative read-back (runWorkspaceScan pattern, phases.mjs
|
|
75
|
+
// Authoritative read-back (runWorkspaceScan pattern, phases.mjs).
|
|
88
76
|
const markdown = metaOnly ? this.userMarkdown : await readFile(this.mdPath, 'utf8');
|
|
89
77
|
const rawMeta = JSON.parse(await readFile(this.metaPath, 'utf8'));
|
|
90
78
|
if (!Number.isFinite(Number(rawMeta?.order))) rawMeta.order = 99;
|
|
79
|
+
// The SAME gate the store applies on save: a draft that breaks a port rule
|
|
80
|
+
// must fail here, naming every rule, instead of 400-ing after the user has
|
|
81
|
+
// reviewed it on Step 3.
|
|
82
|
+
const { errors } = validateMetaV2(rawMeta);
|
|
83
|
+
if (errors.length) throw new Error(`the generator produced invalid metadata: ${errors.join('; ')}`);
|
|
91
84
|
const meta = normalizeMeta(rawMeta);
|
|
92
85
|
if (!meta) throw new Error('the generator produced unusable metadata');
|
|
93
86
|
if (!String(markdown || '').trim()) throw new Error('the generator produced an empty agent body');
|
|
@@ -112,29 +105,72 @@ class AgentGen extends EventEmitter {
|
|
|
112
105
|
|
|
113
106
|
_neighborBlock() {
|
|
114
107
|
const j = (list) => JSON.stringify(list.map((m) => ({
|
|
115
|
-
key: m.key,
|
|
116
|
-
|
|
108
|
+
key: m.key,
|
|
109
|
+
displayName: m.displayName,
|
|
110
|
+
inputs: (m.inputs || []).map((p) => ({ id: p.id, type: p.type })),
|
|
111
|
+
outputs: (m.outputs || []).map((p) => ({ id: p.id, type: p.type, when: p.when || 'always' })),
|
|
117
112
|
})), null, 2);
|
|
118
113
|
return (
|
|
119
114
|
`## Pipeline neighbors\n\n` +
|
|
120
|
-
`Agents expected to run BEFORE this one (their
|
|
121
|
-
`Agents expected to run AFTER this one (their
|
|
122
|
-
|
|
123
|
-
|
|
115
|
+
`Agents expected to run BEFORE this one (their OUTPUT ports are what this agent's inputs get wired to):\n${j(this.expectedBefore)}\n\n` +
|
|
116
|
+
`Agents expected to run AFTER this one (their INPUT ports are what this agent's outputs feed):\n${j(this.expectedAfter)}\n\n` +
|
|
117
|
+
'Wires are drawn in the composer and only require matching port TYPES, so port ids are yours ' +
|
|
118
|
+
'to choose: declare the ports this agent actually needs, and reuse a neighbor\'s id only when ' +
|
|
119
|
+
'it genuinely names the same payload.\n\n'
|
|
124
120
|
);
|
|
125
121
|
}
|
|
126
122
|
|
|
123
|
+
/** The single claude call (seam: tests replace it to pin the read-back gate). */
|
|
124
|
+
async _runClaude(metaOnly) {
|
|
125
|
+
await runClaude({
|
|
126
|
+
cwd: this.scratchDir,
|
|
127
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
128
|
+
prompt: metaOnly ? this._metaPrompt() : this._fullPrompt(),
|
|
129
|
+
allowedTools: ['Read', 'Write'],
|
|
130
|
+
permissionMode: this.claude.permissionMode || 'acceptEdits',
|
|
131
|
+
model: this.claude.model,
|
|
132
|
+
modelEnv: resolveModelEnv(this.claude.model), // catalog routing env (design §4.8)
|
|
133
|
+
bin: this.claude.bin,
|
|
134
|
+
mock: this.claude.mock,
|
|
135
|
+
signal: this.abort.signal,
|
|
136
|
+
onEvent: (e) => this._onAgentEvent(e),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
127
140
|
_metaSchemaBlock() {
|
|
128
141
|
return (
|
|
129
142
|
`Write the metadata JSON to: ${this.metaPath}\n` +
|
|
130
|
-
'
|
|
131
|
-
'"
|
|
132
|
-
'"
|
|
133
|
-
'
|
|
134
|
-
'"
|
|
143
|
+
'One JSON object, sidecar meta v2 — typed PORTS, no channel vocabulary. ' +
|
|
144
|
+
'REQUIRED: { "metaVersion": 2, "key": "<lowerCamel>", "displayName", "description", ' +
|
|
145
|
+
'"runnerType": "producer"|"verifier"|"clarifier", "inputs": [..], "outputs": [..] } — ' +
|
|
146
|
+
'at least one output port, at most 8 ports per side.\n' +
|
|
147
|
+
'An INPUT port: { "id", "type": "md"|"json"|"void", "label", "required" (default true — a ' +
|
|
148
|
+
'required input is a barrier the agent waits on), "loop" (true = loop receiver; forces ' +
|
|
149
|
+
'required:false; a fresh token re-fires the agent), "expands" (json only — run once per ' +
|
|
150
|
+
'element of the array it carries), "as": "file"|"answers"|"fix-review"|"worktree" (how the ' +
|
|
151
|
+
'payload renders into the prompt; default "file" on non-void inputs; "answers" needs a json ' +
|
|
152
|
+
'port, "fix-review" an md port, and "worktree" is the only renderer a void input takes), ' +
|
|
153
|
+
'"directive" (markdown appended to the task prompt only when this port fires; ' +
|
|
154
|
+
'{path} is substituted) }.\n' +
|
|
155
|
+
'An OUTPUT port: { "id", "type", "when": "always"|"blocking"|"clean" (default always; ' +
|
|
156
|
+
'anything else requires "verdict"), "filename" (plain basename, required on md/json, may use ' +
|
|
157
|
+
'{cycle} {vsuffix} {base}), "store": "run"|"project" (default run), "artifactKind" (defaults ' +
|
|
158
|
+
'to the id) }. A void port carries no payload — no filename, no store.\n' +
|
|
159
|
+
'Port ids are lowerCamel (letters and digits only, first char lowercase), <=32 chars, unique ' +
|
|
160
|
+
'per side. The id "await" is RESERVED — the ' +
|
|
161
|
+
'engine synthesizes an await gate on every agent node; never declare it.\n' +
|
|
162
|
+
'Runner obligations: "verifier" MUST declare "verdict": { "filename": "<basename>" }; ' +
|
|
163
|
+
'"clarifier" MUST declare at least one json output; "producer" just writes its outputs.\n' +
|
|
164
|
+
'Optional agent fields: "color" (green|peach|red|blue|violet|amber), "icon" (inline SVG ' +
|
|
165
|
+
'path), "sideEffect": "code", "scope": "project"|"workspace-only", "domain", "order", ' +
|
|
166
|
+
'"fanOut", "asksQuestions"/"questionsLocked"/"questionsDefault", "requiresSkills": [..], ' +
|
|
167
|
+
'"promptHints", "wantsRequest", "workspaceFanOut", "workspaceStrategy": ' +
|
|
168
|
+
'"explore"|"task"|"review", "workspaceVariantOf": "<agentKey>" (requires scope ' +
|
|
169
|
+
'"workspace-only"), "placeable": false, "mockRole" (omit unless mimicking a built-in ' +
|
|
170
|
+
'writer; unknown values dropped).\n' +
|
|
135
171
|
'"description" is the palette blurb: 1-2 plain sentences, max 160 chars total and the ' +
|
|
136
172
|
'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/
|
|
173
|
+
'the agent name in the composer palette — say what the agent does and what it reads/writes.\n' +
|
|
138
174
|
'Questions flags: asksQuestions=true if the agent may need a user decision mid-task ' +
|
|
139
175
|
'(the orchestrator pauses it and resumes it with the answers). questionsLocked=true ONLY if ' +
|
|
140
176
|
"asking the user is the agent's whole purpose (the user then cannot toggle it in the " +
|
|
@@ -145,11 +181,13 @@ class AgentGen extends EventEmitter {
|
|
|
145
181
|
|
|
146
182
|
_fullPrompt() {
|
|
147
183
|
return (
|
|
148
|
-
`# Task: Build a worca
|
|
184
|
+
`# Task: Build a worca agent — ${this.name}\n\n` +
|
|
149
185
|
`## Purpose\n${this.purpose}\n\n## Detailed description\n${this.details}\n\n` +
|
|
150
186
|
this._neighborBlock() +
|
|
151
187
|
'## What to write\n\n' +
|
|
152
188
|
`1. The agent's system-prompt markdown (role, inputs, outputs, method, output contract) to: ${this.mdPath}\n` +
|
|
189
|
+
' Document every port under a `## Ports` heading (one bullet per port id, what it carries); ' +
|
|
190
|
+
'never hardcode filenames — the engine binds every port to an absolute path in the task prompt.\n' +
|
|
153
191
|
`2. ${this._metaSchemaBlock()}` +
|
|
154
192
|
'Announce progress with lines starting `DRAFTING `.\n\n' +
|
|
155
193
|
`MOCK_ROLE: agent-gen\nMOCK_OUT: ${this.mdPath}\nMOCK_JSON: ${this.metaPath}\nMOCK_BASE: ${this.name}\n`
|
|
@@ -158,7 +196,7 @@ class AgentGen extends EventEmitter {
|
|
|
158
196
|
|
|
159
197
|
_metaPrompt() {
|
|
160
198
|
return (
|
|
161
|
-
`# Task: Infer worca
|
|
199
|
+
`# Task: Infer worca agent metadata — ${this.name}\n\n` +
|
|
162
200
|
`The user wrote the agent system prompt themselves. Read it at: ${this.mdPath}\n` +
|
|
163
201
|
'Do NOT modify that file. Derive the metadata from its content and the neighbors below.\n\n' +
|
|
164
202
|
this._neighborBlock() +
|
|
@@ -5,21 +5,33 @@
|
|
|
5
5
|
// agent is now "drop agents/<key>.md + agents/<key>.meta.json", no core edit.
|
|
6
6
|
//
|
|
7
7
|
// Read synchronously so it can back a synchronous AGENT_STEPS constant in
|
|
8
|
-
// config.mjs. Tolerant: a malformed sidecar, or one missing `key
|
|
9
|
-
//
|
|
8
|
+
// config.mjs. Tolerant: a malformed sidecar, or one missing `key`, is skipped
|
|
9
|
+
// rather than throwing (mirrors the tolerant readers elsewhere); an ABSENT
|
|
10
|
+
// `order` is backfilled to DEFAULT_ORDER, matching normalizeAgentMeta.
|
|
10
11
|
|
|
11
12
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import {
|
|
13
|
+
import { join, resolve, isAbsolute, sep } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
14
15
|
import { worcaHome } from './projects.mjs'; // user agent layer root (read fresh per call)
|
|
15
16
|
import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs'; // plugin layer roots (Task 2)
|
|
17
|
+
import { declaredApi, NOT_META_V2 } from './plugin-manifest.mjs'; // plugin API declared by a layer's manifest
|
|
18
|
+
import { normalizeAgentMeta, DEFAULT_ORDER } from '../shared/graph/agent-meta.mjs'; // meta v2 (one source: registry + store + UI)
|
|
19
|
+
import { MOCK_WRITER_ROLES } from './claude-runner.mjs'; // mockRole vocabulary (no cycle: claude-runner imports no registry)
|
|
16
20
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Default location of the agent metadata sidecars, relative to this module.
|
|
23
|
+
* Single source for every module that needs the built-in agents dir
|
|
24
|
+
* (workflows.mjs, orchestrator.mjs). MUST go through fileURLToPath: `new URL(...)
|
|
25
|
+
* .pathname` is a URL path, not a filesystem path — on Windows it yields
|
|
26
|
+
* `/C:/…/agents/` (ENOENT) and on every platform it leaves spaces as `%20`, so
|
|
27
|
+
* the built-in layer silently scanned as EMPTY: /api/agents returned nothing,
|
|
28
|
+
* saved workflows painted "Could not load this workflow", and setStep rejected
|
|
29
|
+
* every model change with `unknown step` (the New Pipeline picker reverted).
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_AGENTS_DIR = fileURLToPath(new URL('../../agents/', import.meta.url));
|
|
19
32
|
|
|
20
33
|
const COLORS = new Set(['green', 'peach', 'red', 'blue', 'violet', 'amber']);
|
|
21
34
|
const RUNNER_TYPES = new Set(['producer', 'verifier', 'clarifier']);
|
|
22
|
-
const CHANNEL_IDS = new Set(CHANNEL_ID_LIST);
|
|
23
35
|
|
|
24
36
|
/** Organizational-only domain tag (coding, marketing, financing, …): lowercase
|
|
25
37
|
* kebab, ≤32 chars. 'shared' is a recognized sentinel that passes this regex and
|
|
@@ -32,57 +44,6 @@ function normalizeDomain(raw) {
|
|
|
32
44
|
return typeof raw === 'string' && DOMAIN_RE.test(raw) ? raw : 'general';
|
|
33
45
|
}
|
|
34
46
|
|
|
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
47
|
/**
|
|
87
48
|
* Legacy short labels for the original four roles, so the derived AGENT_STEPS is
|
|
88
49
|
* byte-identical to the hardcoded one the UI/orchestrator have always used. New
|
|
@@ -95,63 +56,6 @@ const LEGACY_LABELS = {
|
|
|
95
56
|
reviewer: 'Review',
|
|
96
57
|
};
|
|
97
58
|
|
|
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
59
|
/**
|
|
156
60
|
* Ordered unique domain list for UI section headers. Registry is already sorted
|
|
157
61
|
* by .order (loadAgentRegistry sorts at line 260), so first-seen order is stable.
|
|
@@ -175,17 +79,29 @@ export function collectDomains(registry) {
|
|
|
175
79
|
* identifier-shaped so a key can never escape a directory. */
|
|
176
80
|
const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
177
81
|
|
|
178
|
-
/** Coerce one parsed sidecar into a normalized AgentMeta, or null if unusable.
|
|
179
|
-
|
|
82
|
+
/** Coerce one parsed sidecar into a normalized AgentMeta, or null if unusable.
|
|
83
|
+
* `warn` is INJECTABLE (defaults to console.warn) so scanLayer can capture the
|
|
84
|
+
* reason a sidecar was dropped and hand it to a diagnostics sink — the reason
|
|
85
|
+
* is authored here and must never be re-derived by a second reader. */
|
|
86
|
+
export function normalizeMeta(raw, { warn = console.warn } = {}) {
|
|
180
87
|
if (!raw || typeof raw !== 'object') return null;
|
|
181
88
|
const key = typeof raw.key === 'string' ? raw.key.trim() : '';
|
|
182
89
|
if (!key) return null;
|
|
183
90
|
if (!AGENT_KEY_RE.test(key)) {
|
|
184
|
-
|
|
91
|
+
warn(`[agent-registry] sidecar key "${key}" is not a valid agent key; skipped`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
// `order` is OPTIONAL: agent-meta.mjs's normalizer backfills DEFAULT_ORDER and
|
|
95
|
+
// validateMetaV2 reports zero errors for a sidecar that omits it, so dropping
|
|
96
|
+
// it here made the plugin validator certify an agent this loader then silently
|
|
97
|
+
// discarded — every node referencing it failed V4 with `unknown agent "<key>"`.
|
|
98
|
+
// The two normalizers must agree. A PRESENT but non-numeric order is still a
|
|
99
|
+
// skip (normalizeAgentMeta errors on it), but a loud one, like the key branch.
|
|
100
|
+
const order = raw.order === undefined ? DEFAULT_ORDER : Number(raw.order);
|
|
101
|
+
if (!Number.isFinite(order)) {
|
|
102
|
+
warn(`[agent-registry] sidecar "${key}" has a non-numeric order ${JSON.stringify(raw.order)}; skipped`);
|
|
185
103
|
return null;
|
|
186
104
|
}
|
|
187
|
-
const order = Number(raw.order);
|
|
188
|
-
if (!Number.isFinite(order)) return null;
|
|
189
105
|
const color = COLORS.has(raw.color) ? raw.color : 'amber';
|
|
190
106
|
const runnerType = RUNNER_TYPES.has(raw.runnerType) ? raw.runnerType : 'producer';
|
|
191
107
|
// §6.6 scope coercion (fail-safe, mirrors color): anything but the explicit
|
|
@@ -197,13 +113,7 @@ export function normalizeMeta(raw) {
|
|
|
197
113
|
// Coherence is forced HERE (single source of truth): an agent that cannot ask
|
|
198
114
|
// can be neither locked nor default-on, so UI/agent-gen never validate this.
|
|
199
115
|
const asksQuestions = !!raw.asksQuestions;
|
|
200
|
-
const
|
|
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 {
|
|
116
|
+
const base = {
|
|
207
117
|
key,
|
|
208
118
|
displayName: typeof raw.displayName === 'string' && raw.displayName.trim()
|
|
209
119
|
? raw.displayName.trim()
|
|
@@ -215,26 +125,46 @@ export function normalizeMeta(raw) {
|
|
|
215
125
|
runnerType,
|
|
216
126
|
scope,
|
|
217
127
|
domain: normalizeDomain(raw.domain), // always set; fail-safe VISIBLE default 'general'
|
|
218
|
-
loopSource: !!raw.loopSource,
|
|
219
128
|
fanOut: !!raw.fanOut,
|
|
220
129
|
asksQuestions,
|
|
221
130
|
questionsLocked: asksQuestions && !!raw.questionsLocked,
|
|
222
131
|
questionsDefault: asksQuestions && !!raw.questionsDefault,
|
|
223
|
-
consumes,
|
|
224
|
-
optionalConsumes,
|
|
225
|
-
produces,
|
|
226
|
-
connectsTo,
|
|
227
132
|
order,
|
|
228
133
|
// ── schema v2 (all optional; absent => safe defaults; origin/agentPath are
|
|
229
134
|
// 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
135
|
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
136
|
requiresSkills: Array.isArray(raw.requiresSkills)
|
|
235
137
|
? raw.requiresSkills.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
|
|
236
138
|
: [],
|
|
237
139
|
};
|
|
140
|
+
// ── meta v2 merge (dual shape, P2a..P8) ────────────────────────────────────
|
|
141
|
+
// A v2 sidecar KEEPS every v1 field and GAINS typed ports + capabilities, so
|
|
142
|
+
// both engines read the same file during coexistence. normalizeMeta returns a
|
|
143
|
+
// FIXED key set and agent-store round-trips {...existing, ...raw} through it,
|
|
144
|
+
// so a v2 sidecar that only "passed unknown keys through" would lose its ports
|
|
145
|
+
// on the next save. Invalid v2 => warn and SKIP THE WHOLE SIDECAR: half-loading
|
|
146
|
+
// an agent whose ports are wrong is worse than not loading it.
|
|
147
|
+
if (raw.metaVersion !== 2) return base;
|
|
148
|
+
const { meta, errors } = normalizeAgentMeta(raw, {
|
|
149
|
+
mockWriterRoles: MOCK_WRITER_ROLES,
|
|
150
|
+
warn: (msg) => warn(msg),
|
|
151
|
+
});
|
|
152
|
+
if (errors.length) {
|
|
153
|
+
warn(`[agent-registry] sidecar "${key}" declares metaVersion 2 but is invalid; skipped: ${errors.join('; ')}`);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const merged = {
|
|
157
|
+
...base,
|
|
158
|
+
metaVersion: 2,
|
|
159
|
+
inputs: meta.inputs,
|
|
160
|
+
outputs: meta.outputs,
|
|
161
|
+
portSummary: meta.portSummary,
|
|
162
|
+
};
|
|
163
|
+
for (const field of ['verdict', 'sideEffect', 'mockRole', 'wantsRequest', 'workspaceFanOut',
|
|
164
|
+
'workspaceStrategy', 'workspaceVariantOf', 'placeable']) {
|
|
165
|
+
if (field in meta) merged[field] = meta[field];
|
|
166
|
+
}
|
|
167
|
+
return merged;
|
|
238
168
|
}
|
|
239
169
|
|
|
240
170
|
/**
|
|
@@ -257,7 +187,7 @@ export function userAgentsDir() {
|
|
|
257
187
|
* agents/ — drops out). Wrapped in try/catch like userAgentsDir(): with no
|
|
258
188
|
* resolvable worca-cc home (bare node:test runner) or an unreadable lock this
|
|
259
189
|
* returns [] and registry loads never throw.
|
|
260
|
-
* @returns {Array<{plugin: string, dir: string}>}
|
|
190
|
+
* @returns {Array<{plugin: string, dir: string, builtFor: number|null}>}
|
|
261
191
|
*/
|
|
262
192
|
export function pluginAgentLayers() {
|
|
263
193
|
try {
|
|
@@ -265,7 +195,18 @@ export function pluginAgentLayers() {
|
|
|
265
195
|
return Object.keys(lock)
|
|
266
196
|
.sort()
|
|
267
197
|
.filter((name) => lock[name] && lock[name].enabled !== false)
|
|
268
|
-
.map((name) =>
|
|
198
|
+
.map((name) => {
|
|
199
|
+
const dir = pluginCurrentDir(name);
|
|
200
|
+
let builtFor = null;
|
|
201
|
+
try {
|
|
202
|
+
const raw = JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8'));
|
|
203
|
+
// `|| null`: declaredApi('') is 0 (an unconstrained range accepts
|
|
204
|
+
// everything), and "built for plugin API 0" is not English. apiMismatch
|
|
205
|
+
// guards the same case the same way.
|
|
206
|
+
builtFor = declaredApi(raw?.engines?.['worca-cc-api'] ?? '') || null;
|
|
207
|
+
} catch { builtFor = null; } // unreadable manifest: the message degrades, the skip does not
|
|
208
|
+
return { plugin: name, dir: join(dir, 'agents'), builtFor };
|
|
209
|
+
})
|
|
269
210
|
.filter(({ dir }) => existsSync(dir));
|
|
270
211
|
} catch {
|
|
271
212
|
return []; // no home / unreadable lock => no plugin layer (fails safe)
|
|
@@ -301,7 +242,12 @@ function frontmatterDescription(mdPath) {
|
|
|
301
242
|
/** Scan one layer dir for *.meta.json; stamps the COMPUTED origin/agentPath/
|
|
302
243
|
* descriptionDerived fields (none of which normalizeMeta returns, so none can
|
|
303
244
|
* be persisted back into a sidecar). */
|
|
304
|
-
function scanLayer(dir, origin) {
|
|
245
|
+
function scanLayer(dir, origin, { requireMetaV2 = false, builtFor = null, onDrop = null } = {}) {
|
|
246
|
+
// Every skip below is a CONTRIBUTION THE USER CANNOT SEE unless someone
|
|
247
|
+
// reports it: console.warn reaches a server log, not the Plugins card, the
|
|
248
|
+
// install receipt or the doctor. onDrop is that reporting channel — optional,
|
|
249
|
+
// so the hot registry path pays nothing when nobody is listening.
|
|
250
|
+
const drop = (file, reason) => { if (onDrop) onDrop({ origin, file, reason }); };
|
|
305
251
|
let files;
|
|
306
252
|
try {
|
|
307
253
|
files = readdirSync(dir);
|
|
@@ -315,11 +261,38 @@ function scanLayer(dir, origin) {
|
|
|
315
261
|
try {
|
|
316
262
|
parsed = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
317
263
|
} catch {
|
|
264
|
+
drop(f, 'unreadable JSON');
|
|
318
265
|
continue; // skip unreadable / malformed sidecars
|
|
319
266
|
}
|
|
320
|
-
|
|
321
|
-
|
|
267
|
+
// API 3 (plugin layers only): a sidecar that is not meta v2 has no typed
|
|
268
|
+
// ports, so it can be neither placed on a canvas nor resolved by the graph
|
|
269
|
+
// engine. Ignore it with a line that names the fix — reusing the SAME
|
|
270
|
+
// clause validate-time prints, so the two can never drift. Builtin/user
|
|
271
|
+
// layers keep the v1 path until the engine cut-over.
|
|
272
|
+
if (requireMetaV2 && Number(parsed?.metaVersion) !== 2) {
|
|
273
|
+
const builtForText = builtFor == null ? 'an older plugin API' : `plugin API ${builtFor}`;
|
|
274
|
+
console.warn(`[agent-registry] ${origin}/${f}: built for ${builtForText} — ${NOT_META_V2} — ignored`);
|
|
275
|
+
drop(f, `built for ${builtForText} — ${NOT_META_V2}`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// Capture normalizeMeta's own reason rather than re-deriving one: the LAST
|
|
279
|
+
// warning it emits is the fatal one (non-fatal coercion warnings precede it).
|
|
280
|
+
let why = '';
|
|
281
|
+
const meta = normalizeMeta(parsed, {
|
|
282
|
+
warn: (m) => { why = String(m).replace(/^\[agent-registry\] /, ''); console.warn(m); },
|
|
283
|
+
});
|
|
284
|
+
if (!meta) { drop(f, why || 'invalid sidecar'); continue; }
|
|
322
285
|
meta.origin = origin; // computed, never stored
|
|
286
|
+
// agentFile is a PATH read as the agent's system prompt AND for its
|
|
287
|
+
// `tools:` frontmatter, so the loader refuses to stamp an agentPath outside
|
|
288
|
+
// the layer it is scanning. Belt-and-braces behind validatePluginDir, which
|
|
289
|
+
// never sees a live-edited linked dir or a hand-written user sidecar.
|
|
290
|
+
if (meta.agentFile
|
|
291
|
+
&& (isAbsolute(meta.agentFile) || !resolve(dir, meta.agentFile).startsWith(resolve(dir) + sep))) {
|
|
292
|
+
console.warn(`[agent-registry] ${origin}/${f}: agentFile "${meta.agentFile}" resolves outside the agents dir — ignored`);
|
|
293
|
+
drop(f, `agentFile "${meta.agentFile}" resolves outside the agents dir`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
323
296
|
meta.agentPath = meta.agentFile ? join(dir, meta.agentFile) : null; // layer-correct abs path
|
|
324
297
|
// Description fallback (spec 2026-08-09): empty sidecar description →
|
|
325
298
|
// the .md frontmatter description. Only costs a file read when empty.
|
|
@@ -346,16 +319,22 @@ function scanLayer(dir, origin) {
|
|
|
346
319
|
* @returns {Record<string, object>} agent key -> AgentMeta, sorted by `.order`
|
|
347
320
|
*/
|
|
348
321
|
export function loadAgentRegistry(agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
|
|
349
|
-
|
|
322
|
+
// opts.onDrop({origin, file, reason}) — every sidecar this load IGNORED, so a
|
|
323
|
+
// caller (plugin-store's card/receipt/doctor) can show what did not load.
|
|
324
|
+
const onDrop = typeof opts.onDrop === 'function' ? opts.onDrop : null;
|
|
325
|
+
const builtins = scanLayer(agentsDir, 'builtin', { onDrop });
|
|
350
326
|
const builtinKeys = new Set(builtins.map((m) => m.key));
|
|
351
327
|
const userDir = opts.userAgentsDir === undefined ? userAgentsDir() : opts.userAgentsDir;
|
|
352
328
|
const users = [];
|
|
353
329
|
if (userDir) {
|
|
354
|
-
for (const m of scanLayer(userDir, 'user')) {
|
|
330
|
+
for (const m of scanLayer(userDir, 'user', { onDrop })) {
|
|
355
331
|
if (builtinKeys.has(m.key)) {
|
|
356
332
|
console.warn(
|
|
357
333
|
`[agent-registry] user agent "${m.key}" shadows a built-in and was skipped (built-ins are immutable)`,
|
|
358
334
|
);
|
|
335
|
+
// The sidecar filename is `<key>.meta.json` on every write path (the
|
|
336
|
+
// agent store writes it, and validatePluginDir requires key === stem).
|
|
337
|
+
if (onDrop) onDrop({ origin: 'user', file: `${m.key}.meta.json`, reason: 'shadows a built-in agent' });
|
|
359
338
|
continue;
|
|
360
339
|
}
|
|
361
340
|
users.push(m);
|
|
@@ -371,12 +350,13 @@ export function loadAgentRegistry(agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
|
|
|
371
350
|
const plugins = [];
|
|
372
351
|
if (opts.includePlugins !== false) {
|
|
373
352
|
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}
|
|
353
|
+
for (const { plugin, dir, builtFor } of pluginAgentLayers()) {
|
|
354
|
+
for (const m of scanLayer(dir, `plugin:${plugin}`, { requireMetaV2: true, builtFor, onDrop })) {
|
|
376
355
|
if (taken.has(m.key)) {
|
|
377
356
|
console.warn(
|
|
378
357
|
`[agent-registry] plugin agent "${m.key}" (plugin "${plugin}") collides with an existing agent and was skipped`,
|
|
379
358
|
);
|
|
359
|
+
if (onDrop) onDrop({ origin: `plugin:${plugin}`, file: `${m.key}.meta.json`, reason: 'collides with an existing agent' });
|
|
380
360
|
continue;
|
|
381
361
|
}
|
|
382
362
|
taken.add(m.key);
|