@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.
Files changed (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -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' produces/consumes. Files are read back as authoritative
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-cc, a deterministic multi-agent pipeline. Write files exactly where asked. ' +
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 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
- });
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:803-809).
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, displayName: m.displayName, produces: m.produces || [],
116
- consumes: m.consumes || [], optionalConsumes: m.optionalConsumes || [],
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 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`
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
- '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' +
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/produces.\n' +
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-cc agent — ${this.name}\n\n` +
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-cc agent metadata — ${this.name}\n\n` +
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`/`order`, is
9
- // skipped rather than throwing (mirrors the tolerant readers elsewhere).
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 { CHANNEL_IDS as CHANNEL_ID_LIST } from './channels.mjs'; // single source (m2)
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
- /** Default location of the agent metadata sidecars, relative to this module. */
18
- const DEFAULT_AGENTS_DIR = new URL('../../agents/', import.meta.url).pathname;
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
- export function normalizeMeta(raw) {
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
- console.warn(`[agent-registry] sidecar key "${key}" is not a valid agent key; skipped`);
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 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 {
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) => ({ plugin: name, dir: join(pluginCurrentDir(name), 'agents') }))
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
- const meta = normalizeMeta(parsed);
321
- if (!meta) continue;
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
- const builtins = scanLayer(agentsDir, 'builtin');
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);