@worca/app 1.0.0-rc.1 → 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
@@ -1,11 +1,11 @@
1
1
  // src/core/workflows.mjs
2
2
  // node:sqlite migration: now persisted in the `workflows` table; path helpers vestigial.
3
- // Global workflow-template store + the built-in DEFAULT_WORKFLOW + resolveWorkflow.
3
+ // Global workflow-template store + the built-in GRAPH_DEFAULT_WORKFLOW + resolveGraph.
4
4
  //
5
5
  // Templates are TOPOLOGY + PER-NODE DEFAULTS (steps + feedbacks by node-instance
6
6
  // id; each node may carry an optional `defaults` block — newpipeline-ux-design.md
7
7
  // §4.4). Per-project model/effort/cycle data is the run-config in config.mjs and
8
- // OVERRIDES those defaults; resolveWorkflow merges both.
8
+ // OVERRIDES those defaults; resolveGraph merges both.
9
9
  //
10
10
  // Reads never throw: a missing/corrupt store yields []/null.
11
11
 
@@ -15,7 +15,18 @@ import { join } from 'node:path';
15
15
  import { getDb, prepare, tx } from './db.mjs';
16
16
  import { worcaHome } from './projects.mjs';
17
17
  import { resolveRunConfig, readConfig, EFFORTS } from './config.mjs';
18
+ import { isSubagentModelValue } from './model-env.mjs';
19
+
20
+ /** Enum guard for one resolveGraph layer: a legal value passes, anything else
21
+ * is `undefined` so firstDefined falls through to the next layer. */
22
+ const validSubagentModel = (v) => (isSubagentModelValue(v) ? v : undefined);
18
23
  import { slugify } from './artifacts.mjs';
24
+ import { DEFAULT_AGENTS_DIR, loadAgentRegistry } from './agent-registry.mjs'; // fileURLToPath-based (Windows-safe)
25
+ import { validateGraph, formatIssue, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
26
+ import { classifyLoops } from '../shared/graph/loops.mjs';
27
+ import { GRAPH_DEFAULT_WORKFLOW } from './graph/builtin-workflows.mjs';
28
+ export { GRAPH_DEFAULT_WORKFLOW };
29
+ import { registryPortsFn } from './graph/registry-ports.mjs';
19
30
 
20
31
  /**
21
32
  * Default feedback cycle count when run-config does not override it. Matches the
@@ -33,9 +44,6 @@ function normDomain(raw) {
33
44
  return DOMAIN_RE.test(v) ? v : 'general';
34
45
  }
35
46
 
36
- /** Default location of the agent prompt markdown files (mirrors orchestrator.mjs). */
37
- const DEFAULT_AGENTS_DIR = new URL('../../agents/', import.meta.url).pathname;
38
-
39
47
  /**
40
48
  * Read an agent prompt file and pull its declared tools from YAML frontmatter.
41
49
  * Returns { prompt, tools }. A missing file => { prompt:'', tools:[] } (fails
@@ -46,20 +54,20 @@ const DEFAULT_AGENTS_DIR = new URL('../../agents/', import.meta.url).pathname;
46
54
  * @param {string|null} [agentPath]
47
55
  * @returns {Promise<{prompt:string, tools:string[]}>}
48
56
  */
49
- async function loadAgentFile(agentsDir, agentFile, agentPath = null) {
57
+ export async function loadAgentFile(agentsDir, agentFile, agentPath = null) {
50
58
  if (!agentFile && !agentPath) return { prompt: '', tools: [] };
51
59
  let text = '';
52
60
  try {
53
- // Layered registry: the meta's stamped absolute agentPath (built-in OR user
54
- // layer) wins; the classic agentsDir+agentFile join is the fallback for
55
- // hand-built registries (tests) and a vanished user .md.
61
+ // Layered registry: the meta's stamped absolute agentPath (built-in, user OR
62
+ // plugin layer) wins; the agentsDir+agentFile join serves hand-built
63
+ // registries (tests) that carry no agentPath. A stamped path that cannot be
64
+ // read is an EMPTY prompt, never a fallback into the built-in dir: that
65
+ // fallback let a plugin sidecar naming an absent built-in file (e.g.
66
+ // worca-cc-manual-web-ui-testing.md) run the built-in's prompt and tool
67
+ // grants while its consent card said "none declared" (C-1).
56
68
  text = await readFile(agentPath || join(agentsDir, agentFile), 'utf8');
57
69
  } catch {
58
- if (agentPath && agentFile) {
59
- try { text = await readFile(join(agentsDir, agentFile), 'utf8'); } catch { return { prompt: '', tools: [] }; }
60
- } else {
61
- return { prompt: '', tools: [] };
62
- }
70
+ return { prompt: '', tools: [] };
63
71
  }
64
72
  return { prompt: text, tools: parseFrontmatterTools(text) };
65
73
  }
@@ -77,39 +85,6 @@ function parseFrontmatterTools(text) {
77
85
  .filter(Boolean);
78
86
  }
79
87
 
80
- /**
81
- * The built-in default workflow: the CURRENT pipeline Plan -> Refine -> Implement
82
- * -> Review, with the two feedback loops that reproduce today's _refineLoop and
83
- * _reviewLoop (orchestrator.mjs:331-459):
84
- * - refiner self-loop (s1_0 -> s1_0): re-run the refine step on blocking issues.
85
- * - review -> implement (s3_0 -> s2_0): on blocking review issues, run an
86
- * implementer fix pass (the 'to' step) then re-review.
87
- * Default cycle counts come from run-config resolution (resolveRunConfig falls
88
- * back to DEFAULT_MAX_CYCLES = 3).
89
- * NOT persisted to the user store; always present; readWorkflow('wf_default')
90
- * returns it.
91
- * @type {{id:string,name:string,version:number,steps:Array<Array<{id:string,key:string}>>,feedbacks:Array<{id:string,from:string,to:string}>,createdAt:string,updatedAt:string}}
92
- */
93
- export const DEFAULT_WORKFLOW = Object.freeze({
94
- id: 'wf_default',
95
- name: 'Default',
96
- version: 1,
97
- domain: 'coding', // built-in coding flow
98
- steps: [
99
- [{ id: 's_clarify', key: 'clarify' }],
100
- [{ id: 's0_0', key: 'planner' }],
101
- [{ id: 's1_0', key: 'refiner' }],
102
- [{ id: 's2_0', key: 'implementer' }],
103
- [{ id: 's3_0', key: 'reviewer' }],
104
- ],
105
- feedbacks: [
106
- { id: 'fb_refine', from: 's1_0', to: 's1_0' },
107
- { id: 'fb_review', from: 's3_0', to: 's2_0' },
108
- ],
109
- createdAt: '1970-01-01T00:00:00.000Z',
110
- updatedAt: '1970-01-01T00:00:00.000Z',
111
- });
112
-
113
88
  /**
114
89
  * Sanitize one node's `defaults` block (newpipeline-ux-design.md §4.4). Loud and
115
90
  * lenient, matching the module's house style: a malformed FIELD is dropped with a
@@ -120,7 +95,7 @@ export const DEFAULT_WORKFLOW = Object.freeze({
120
95
  * setStep/setNodeModel.
121
96
  * @param {unknown} raw
122
97
  * @param {string} [nodeId] for the warning message
123
- * @returns {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}|undefined}
98
+ * @returns {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean,subagentModel?:string}|undefined}
124
99
  */
125
100
  export function sanitizeNodeDefaults(raw, nodeId = '?') {
126
101
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
@@ -149,6 +124,14 @@ export function sanitizeNodeDefaults(raw, nodeId = '?') {
149
124
  if (typeof raw[field] === 'boolean') out[field] = raw[field];
150
125
  else warn(field, 'not a boolean');
151
126
  }
127
+ // subagentModel is an ALIAS enum, not a catalog id (the CLI's Task tool refuses
128
+ // anything else), so unlike `model` it is validated structurally right here.
129
+ if (raw.subagentModel !== undefined) {
130
+ if (isSubagentModelValue(raw.subagentModel)) out.subagentModel = raw.subagentModel;
131
+ else if (raw.subagentModel !== '' && raw.subagentModel !== null) {
132
+ warn('subagentModel', `unknown sub-agent model "${raw.subagentModel}"`);
133
+ }
134
+ }
152
135
  return Object.keys(out).length ? out : undefined;
153
136
  }
154
137
 
@@ -180,6 +163,14 @@ export function sanitizeWorkflowSteps(steps) {
180
163
  */
181
164
  export function workflowNodeDefaults(tpl) {
182
165
  const out = {};
166
+ if (Array.isArray(tpl?.nodes)) { // v2: defaults ARE node.config
167
+ for (const node of tpl.nodes) {
168
+ if (node?.kind !== 'agent') continue;
169
+ const clean = sanitizeNodeDefaults(node.config, node.id);
170
+ if (clean) out[node.id] = clean;
171
+ }
172
+ return out;
173
+ }
183
174
  for (const group of Array.isArray(tpl?.steps) ? tpl.steps : []) {
184
175
  for (const node of Array.isArray(group) ? group : []) {
185
176
  if (node && node.id && node.defaults && typeof node.defaults === 'object') out[node.id] = node.defaults;
@@ -194,9 +185,22 @@ export function workflowsDir() {
194
185
  }
195
186
 
196
187
  /** A workflow id is a stem; reject anything that could escape a path-built store
197
- * (path separators, "..", dots, spaces). Valid ids are wf_<slug> / wf_default. */
188
+ * (path separators, "..", dots, spaces). Valid ids are wf_<slug> / wf_default.
189
+ * EXPORTED because it is also the API's gate: PATCH /api/config keys three
190
+ * normalized tables by workflowId, and a workflow id is used as an OBJECT KEY
191
+ * (config.mjs readWorkflowsMap, app.js state.config.workflows[id]) — so every
192
+ * own property name of Object.prototype is refused too. The bare regex accepts
193
+ * "__proto__" (it is only letters and underscores), which is exactly how MAJ-1
194
+ * slipped through. Minted ids are always wf_/wfp_-prefixed, so nothing
195
+ * legitimate can collide with that rule. One rule, one source of truth. */
198
196
  const SAFE_WORKFLOW_ID = /^[A-Za-z0-9_-]+$/;
199
- function isSafeWorkflowId(id) { return typeof id === 'string' && SAFE_WORKFLOW_ID.test(id); }
197
+ /** A workflow id is also an OBJECT KEY (config.mjs readWorkflowsMap, app.js state.config.workflows[id]),
198
+ * so any own property name of Object.prototype ('__proto__', 'constructor', 'hasOwnProperty',
199
+ * 'toString', …) is refused: on a plain object those resolve truthy and the next `.wires[id] =` throws. */
200
+ const isInheritedName = (id) => Object.prototype.hasOwnProperty.call(Object.prototype, id);
201
+ export function isSafeWorkflowId(id) {
202
+ return typeof id === 'string' && SAFE_WORKFLOW_ID.test(id) && !isInheritedName(id);
203
+ }
200
204
 
201
205
  /** Fail-safe JSON.parse to an array; returns [] on any error. */
202
206
  function parseArr(text) {
@@ -204,30 +208,44 @@ function parseArr(text) {
204
208
  try { const v = JSON.parse(text); return Array.isArray(v) ? v : []; } catch { return []; }
205
209
  }
206
210
 
207
- /** Map a workflows row to the template object shape. */
211
+ /** Map a workflows row to the template object shape. Version-aware: `graph`
212
+ * carries {nodes, wires, canvas?} ONLY — id/name/domain/origin stay row columns,
213
+ * so a rename can never drift. */
208
214
  function rowToTpl(r) {
209
- return {
215
+ const base = {
210
216
  id: r.id,
211
217
  name: r.name,
212
218
  version: r.version,
213
219
  domain: r.domain || 'general', // pre-migration NULL → 'general'
214
220
  origin: r.origin || null, // 'plugin:<name>' provenance; NULL = user-created
215
- steps: parseArr(r.steps),
216
- feedbacks: parseArr(r.feedbacks),
217
221
  createdAt: r.created_at,
218
222
  updatedAt: r.updated_at,
223
+ archivedAt: r.archived_at || null,
219
224
  };
225
+ if (r.version === 2) {
226
+ let graph = {};
227
+ try { graph = JSON.parse(r.graph || '{}') || {}; } catch { graph = {}; }
228
+ base.nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
229
+ base.wires = Array.isArray(graph.wires) ? graph.wires : [];
230
+ if (graph.canvas && typeof graph.canvas === 'object') base.canvas = graph.canvas;
231
+ return base;
232
+ }
233
+ base.steps = parseArr(r.steps);
234
+ base.feedbacks = parseArr(r.feedbacks);
235
+ return base;
220
236
  }
221
237
 
238
+ const ROW_COLS = 'id, name, version, domain, steps, feedbacks, graph, archived_at, created_at, updated_at, origin';
239
+
222
240
  /** Read + shallow-validate one stored template row. Unsafe id / missing => null. */
223
- function readRaw(id) {
241
+ function readRaw(id, { includeArchived = false } = {}) {
224
242
  if (!isSafeWorkflowId(id)) return null; // SECURITY: reject path-traversal / unsafe ids
225
243
  getDb();
226
- const r = prepare(
227
- 'SELECT id, name, version, domain, steps, feedbacks, created_at, updated_at, origin FROM workflows WHERE id = ?'
228
- ).get(id);
244
+ const r = prepare(`SELECT ${ROW_COLS} FROM workflows WHERE id = ?`).get(id);
229
245
  if (!r) return null;
246
+ if (!includeArchived && r.archived_at) return null;
230
247
  const tpl = rowToTpl(r);
248
+ if (tpl.version === 2) return Array.isArray(tpl.nodes) ? tpl : null;
231
249
  return Array.isArray(tpl.steps) ? tpl : null; // mirror the legacy steps-array check
232
250
  }
233
251
 
@@ -271,27 +289,145 @@ export async function writeWorkflow(tpl) {
271
289
  }
272
290
 
273
291
  /**
274
- * Read a template by id. Returns the built-in DEFAULT_WORKFLOW for "wf_default";
275
- * otherwise the stored row, or null when absent/corrupt/unsafe-id.
292
+ * Persist a v2 graph template. Saving over an archived id UN-archives it (the
293
+ * user rebuilt it on purpose). v1 columns are written empty so a v1 reader can
294
+ * never mistake a graph row for a step plan.
295
+ *
296
+ * `rejectCollision` (MAJ-5) makes a save whose id was MINTED from the name throw
297
+ * ID_TAKEN instead of upserting over a LIVE row it never asked for. It is opt-in
298
+ * because the importers/seeders that re-write a row by design must keep the
299
+ * upsert; the API's save path is the one caller that turns it on.
300
+ *
301
+ * @param {{id?:string, name:string, domain?:string, origin?:string, nodes:Array, wires:Array, canvas?:object}} tpl
302
+ * @param {{rejectCollision?:boolean}} [opts]
303
+ * @throws {Error} code 'RESERVED_NAME' (the name slugs onto wf_default) or,
304
+ * with rejectCollision, code 'ID_TAKEN' + `.id` (the minted id is in use)
305
+ */
306
+ export async function writeGraphWorkflow(tpl, opts = {}) {
307
+ const now = new Date().toISOString();
308
+ const name = (tpl && typeof tpl.name === 'string' && tpl.name.trim()) || 'Untitled';
309
+ // The ONE reserved id is the built-in default's; a save may never claim it,
310
+ // so it falls back to the slug.
311
+ const asked = tpl && typeof tpl.id === 'string' ? tpl.id.trim() : '';
312
+ const minted = !(asked && isSafeWorkflowId(asked) && asked !== GRAPH_DEFAULT_WORKFLOW.id);
313
+ const id = minted ? `wf_${slugify(name)}` : asked;
314
+ // C-3: the fallback re-mints the reserved id for ANY name slugging to
315
+ // "default" ('Default', ' dEfAuLt ', 'default!!', 'Défault'…). That row is
316
+ // filtered out of listWorkflows(), short-circuited past by readWorkflow() and
317
+ // refused by DELETE — the user's pipeline would vanish behind a 201. Refuse
318
+ // the WRITE instead; only the name is wrong, so the caller can rename.
319
+ if (id === GRAPH_DEFAULT_WORKFLOW.id) {
320
+ throw Object.assign(
321
+ new Error(`the name "${GRAPH_DEFAULT_WORKFLOW.name}" is reserved — choose another name`),
322
+ { code: 'RESERVED_NAME' });
323
+ }
324
+ const domain = normDomain(tpl && tpl.domain);
325
+ const origin = typeof tpl?.origin === 'string' && tpl.origin ? tpl.origin : null;
326
+ const graph = { nodes: Array.isArray(tpl?.nodes) ? tpl.nodes : [], wires: Array.isArray(tpl?.wires) ? tpl.wires : [] };
327
+ if (tpl?.canvas && typeof tpl.canvas === 'object') graph.canvas = tpl.canvas;
328
+
329
+ getDb();
330
+ const existing = prepare('SELECT created_at, archived_at FROM workflows WHERE id = ?').get(id);
331
+ // MAJ-5: the caller asked for NO id, so wf_<slug(name)> is a GUESS — landing it
332
+ // on a row that already exists replaced someone else's pipeline and kept the
333
+ // victim's created_at, leaving no trace. Only LIVE rows collide: an archived
334
+ // row is invisible in the saved list, so a 409 naming it would be a dead end,
335
+ // and re-saving an archived name is the documented way to rebuild it.
336
+ if (opts?.rejectCollision && minted && existing && !existing.archived_at) {
337
+ throw Object.assign(
338
+ new Error(`a pipeline with the id "${id}" already exists — choose another name`),
339
+ { code: 'ID_TAKEN', id });
340
+ }
341
+ const createdAt = (typeof tpl?.createdAt === 'string' && tpl.createdAt) || existing?.created_at || now;
342
+ tx(() => {
343
+ prepare(`
344
+ INSERT INTO workflows (id, name, version, domain, steps, feedbacks, graph, archived_at, created_at, updated_at, origin)
345
+ VALUES (?, ?, 2, ?, '[]', '[]', ?, NULL, ?, ?, ?)
346
+ ON CONFLICT(id) DO UPDATE SET
347
+ name = excluded.name, version = 2, domain = excluded.domain,
348
+ steps = '[]', feedbacks = '[]', graph = excluded.graph, archived_at = NULL,
349
+ updated_at = excluded.updated_at,
350
+ -- COALESCE, never a plain overwrite: the composer's Save on a loaded row
351
+ -- sends {id, name, nodes, wires} and NO origin, so a plain
352
+ -- "origin = excluded.origin" would silently detach a plugin-owned wfp_* row
353
+ -- from removePluginWorkflows' guard. v1's writeWorkflow never touches origin
354
+ -- on conflict either. (SQL comments only: a backtick here would END the JS literal.)
355
+ origin = COALESCE(excluded.origin, workflows.origin)
356
+ `).run(id, name, domain, JSON.stringify(graph), createdAt, now, origin);
357
+ });
358
+ // Re-read `origin`: the UPSERT may have KEPT an existing one this call omitted.
359
+ const stored = prepare('SELECT origin FROM workflows WHERE id = ?').get(id);
360
+ return { id, name, version: 2, domain, origin: stored?.origin ?? null, ...graph, createdAt, updatedAt: now };
361
+ }
362
+
363
+ /**
364
+ * Read a template by id. Returns the built-in GRAPH_DEFAULT_WORKFLOW for "wf_default";
365
+ * otherwise the stored row, or null when absent/corrupt/unsafe-id/archived.
276
366
  * @param {string} id
277
367
  * @returns {Promise<object|null>}
278
368
  */
279
- export async function readWorkflow(id) {
280
- if (id === DEFAULT_WORKFLOW.id) return DEFAULT_WORKFLOW;
281
- return readRaw(id);
369
+ export async function readWorkflow(id, opts = {}) {
370
+ // `wf_default` IS the graph: the v1 default died with the v1 engine.
371
+ if (id === GRAPH_DEFAULT_WORKFLOW.id) return GRAPH_DEFAULT_WORKFLOW;
372
+ return readRaw(id, opts);
282
373
  }
283
374
 
284
375
  /**
285
- * List user templates (NOT DEFAULT_WORKFLOW — callers prepend it), newest first by
286
- * createdAt. Empty store => []. Never throws.
376
+ * List user templates (NOT GRAPH_DEFAULT_WORKFLOW — callers prepend it), newest first by
377
+ * createdAt. Archived rows are hidden unless asked for. Empty store => [].
287
378
  * @returns {Promise<object[]>}
288
379
  */
289
- export async function listWorkflows() {
380
+ export async function listWorkflows({ includeArchived = false } = {}) {
290
381
  getDb();
291
- const rows = prepare(
292
- 'SELECT id, name, version, domain, steps, feedbacks, created_at, updated_at, origin FROM workflows ORDER BY created_at DESC, id'
293
- ).all();
294
- return rows.filter((r) => r.id !== DEFAULT_WORKFLOW.id).map(rowToTpl);
382
+ const where = includeArchived ? '' : 'WHERE archived_at IS NULL';
383
+ const rows = prepare(`SELECT ${ROW_COLS} FROM workflows ${where} ORDER BY created_at DESC, id`).all();
384
+ return rows.filter((r) => r.id !== GRAPH_DEFAULT_WORKFLOW.id).map(rowToTpl);
385
+ }
386
+
387
+ /**
388
+ * The RUN-TIME graph check. A template is validated against the sidecars of the
389
+ * moment it was SAVED; editing an agent's ports afterwards leaves every saved
390
+ * pipeline wired to a port that no longer exists, and nothing re-checked it —
391
+ * the run started and quiesced "done" (lost output) or bound an empty input
392
+ * (lost input, silent). ERRORS only: warnings (V15 unreachable, …) are advisory
393
+ * and must never refuse a run.
394
+ * @param {object} tpl a version-2 template
395
+ * @param {Record<string,object>} [registry] injected registry; the live one otherwise
396
+ * @throws {Error} code 'INVALID_GRAPH', `issues` = the validator's error list
397
+ */
398
+ function assertValidGraph(tpl, registry) {
399
+ const reg = registry && typeof registry === 'object' ? registry : loadAgentRegistry();
400
+ const { ok, errors } = validateGraph(tpl, registryPortsFn(reg));
401
+ if (ok) return;
402
+ throw Object.assign(
403
+ new Error(`workflow "${tpl.id}" no longer matches the agents it uses: `
404
+ + `${errors.map(formatIssue).join('; ')} — open it in the Composer and re-wire it`),
405
+ { code: 'INVALID_GRAPH', issues: errors },
406
+ );
407
+ }
408
+
409
+ /** The ONE gate every run path goes through (POST /api/run, the CLI's
410
+ * --workflow, Ask's proposal validation). Throws with a `code` the callers map
411
+ * to HTTP/exit codes; the ARCHIVED text is user-facing and verbatim.
412
+ * `checkGraph:false` is the READ escape hatch (GET /api/workflows/:id): a
413
+ * stale template must still open in the Composer, or it can never be repaired.
414
+ * @param {string} id
415
+ * @param {{registry?:Record<string,object>, checkGraph?:boolean}} [opts]
416
+ */
417
+ export async function assertRunnableWorkflow(id, { registry, checkGraph = true } = {}) {
418
+ const wanted = typeof id === 'string' && id.trim() ? id.trim() : GRAPH_DEFAULT_WORKFLOW.id;
419
+ const live = await readWorkflow(wanted);
420
+ if (live) {
421
+ // A v1 row is not a graph: engine-select owns its refusal (V1_RUN_RETIRED).
422
+ if (checkGraph && live.version === 2) assertValidGraph(live, registry);
423
+ return live;
424
+ }
425
+ const archived = await readWorkflow(wanted, { includeArchived: true });
426
+ if (archived) {
427
+ throw Object.assign(new Error(`workflow "${wanted}" was archived by the v2 upgrade `
428
+ + '(v1 template, not runnable) — pick a v2 pipeline or rebuild it in the Composer'), { code: 'ARCHIVED' });
429
+ }
430
+ throw Object.assign(new Error(`unknown workflowId "${wanted}"`), { code: 'NOT_FOUND' });
295
431
  }
296
432
 
297
433
  /**
@@ -311,13 +447,30 @@ export async function listWorkflows() {
311
447
  * @throws {Error} unknown/unsafe id, or the built-in default
312
448
  */
313
449
  export async function setWorkflowNodeDefaults(id, map) {
314
- if (id === DEFAULT_WORKFLOW.id) {
450
+ if (id === GRAPH_DEFAULT_WORKFLOW.id) {
315
451
  throw new Error('the built-in Default workflow cannot store defaults — save a copy in Composer first');
316
452
  }
317
453
  const tpl = readRaw(id);
318
454
  if (!tpl) throw new Error(`workflow not found: ${id}`);
319
455
  const patch = map && typeof map === 'object' ? map : {};
320
456
 
457
+ if (tpl.version === 2) {
458
+ const nodes = tpl.nodes.map((node) => {
459
+ if (!Object.prototype.hasOwnProperty.call(patch, node.id)) return node;
460
+ const clean = sanitizeNodeDefaults(patch[node.id], node.id) || {};
461
+ // Only the AGENT_TUNABLES are defaults; awaitAll/arity/planStoreSeed are
462
+ // TOPOLOGY and must survive a defaults patch untouched.
463
+ const kept = Object.fromEntries(Object.entries(node.config || {}).filter(([k]) => !AGENT_TUNABLES.includes(k)));
464
+ return { ...node, config: { ...kept, ...clean } };
465
+ });
466
+ const now = new Date().toISOString();
467
+ tx(() => {
468
+ prepare('UPDATE workflows SET graph = ?, updated_at = ? WHERE id = ?')
469
+ .run(JSON.stringify({ nodes, wires: tpl.wires, ...(tpl.canvas ? { canvas: tpl.canvas } : {}) }), now, id);
470
+ });
471
+ return { ...tpl, nodes, updatedAt: now };
472
+ }
473
+
321
474
  const steps = tpl.steps.map((group) => (Array.isArray(group) ? group.map((node) => {
322
475
  if (!node || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(patch, node.id)) return node;
323
476
  const { defaults, ...rest } = node;
@@ -334,13 +487,13 @@ export async function setWorkflowNodeDefaults(id, map) {
334
487
  }
335
488
 
336
489
  /**
337
- * Delete a saved template by id. Refuses the built-in DEFAULT_WORKFLOW (false) and
490
+ * Delete a saved template by id. Refuses the built-in GRAPH_DEFAULT_WORKFLOW (false) and
338
491
  * unsafe ids (false). Returns false when no row exists; true on removal.
339
492
  * @param {string} id
340
493
  * @returns {Promise<boolean>}
341
494
  */
342
495
  export async function deleteWorkflow(id) {
343
- if (id === DEFAULT_WORKFLOW.id) return false; // built-in default is undeletable
496
+ if (id === GRAPH_DEFAULT_WORKFLOW.id) return false; // built-in default is undeletable
344
497
  if (!isSafeWorkflowId(id)) return false; // SECURITY: reject unsafe ids
345
498
  getDb();
346
499
  let changed = 0;
@@ -370,199 +523,145 @@ export async function deleteWorkflow(id) {
370
523
  * @returns {Promise<object>} ExecutablePlan
371
524
  * @throws {Error} when the workflow id is unknown, or a node resolves the off-pipeline scanner
372
525
  */
373
- export async function resolveWorkflow(projectDir, workflowId, registry, agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
374
- const tpl = await readWorkflow(workflowId);
375
- if (!tpl) throw new Error(`workflow not found: ${workflowId}`);
376
- const reg = registry && typeof registry === 'object' ? registry : {};
377
- const isWorkspace = !!(opts && opts.isWorkspace);
378
- const { nodes: nodeCfg, feedbacks: fbCfg } = await resolveRunConfig(projectDir, workflowId);
379
- // Legacy per-role config (what the Default-workflow UI writes) applies ONLY to
380
- // the default workflow's nodes — this is what makes its per-agent model/effort/
381
- // fanOut actually reach the main runs (saved workflows use nodeCfg only).
382
- const stepsCfg = workflowId === DEFAULT_WORKFLOW.id ? (await readConfig(projectDir)).steps : {};
383
- const firstDefined = (...vals) => vals.find((v) => v !== undefined);
384
- // CONV-4: map each agent key to the UI stepper bucket the live view understands,
385
- // so the dispatcher can emit a real `'phase'` per node (every node gets its own
386
- // stepper cell via the snapshotted manifest; see buildStepperManifest).
387
- const UI_PHASE = {
388
- clarify: 'clarify',
389
- planner: 'plan', refiner: 'refine', decomposer: 'decompose', implementer: 'implement', reviewer: 'review',
390
- manualTestsChecklist: 'manual-checklist', manualWebUiTesting: 'manual-web', planReviewer: 'plan-review',
391
- workspaceReviewer: 'review', // shares the single-project review stepper bucket
392
- };
393
-
394
- const steps = [];
395
- for (const group of tpl.steps) {
396
- const resolvedGroup = [];
397
- for (const node of group) {
398
- // [C5] Workspace substitution: the review node becomes the fan-out synthesizer.
399
- // Applied to the resolved node key (and its nodeId-stable stepper bucket) so the
400
- // dispatcher routes it to runWorkspaceReviewer; single-project keys are untouched.
401
- const key = isWorkspace && node.key === 'reviewer' ? 'workspaceReviewer' : node.key;
402
- // [§6.6] Defensive guard: the off-pipeline scanner is never a workflow node.
403
- // Reject it if hand-authored into a saved workflow so it can't be dispatched.
404
- if (key === 'workspaceScanner') {
405
- throw new Error('workspaceScanner is an off-pipeline producer and cannot be a workflow node');
406
- }
407
- const meta = reg[key] || {};
408
- const { prompt, tools } = await loadAgentFile(agentsDir, meta.agentFile ?? null, meta.agentPath ?? null);
409
- const sel = nodeCfg[node.id] || {};
410
- // Legacy per-role config is keyed by the ORIGINAL UI step key (e.g. `reviewer`),
411
- // so a substituted workspaceReviewer still inherits the user's review model/effort.
412
- const legacy = stepsCfg[node.key] || {};
413
- // Workflow-level per-node defaults sit BELOW both run-config layers and ABOVE
414
- // the registry sidecar (newpipeline-ux-design.md §4.3): a project override
415
- // still wins, but an untouched project inherits the workflow author's tuning.
416
- const wfDef = (node.defaults && typeof node.defaults === 'object') ? node.defaults : {};
417
- resolvedGroup.push({
418
- nodeId: node.id,
419
- key,
420
- uiPhase: UI_PHASE[key] || meta.uiPhase || key, // CONV-4 map > meta.uiPhase (v2) > key
421
- runnerType: meta.runnerType || 'producer',
422
- agentFile: meta.agentFile ?? null,
423
- agentPrompt: prompt,
424
- promptHints: typeof meta.promptHints === 'string' ? meta.promptHints : '',
425
- model: firstDefined(sel.model, legacy.model, wfDef.model), // undefined unless configured (folded later)
426
- // An effort only travels with the model that advertises it, so a project
427
- // override that names its own model must not inherit the workflow default's
428
- // effort — otherwise "Opus/max" silently becomes "Haiku/max".
429
- effort: firstDefined(sel.effort, legacy.effort, (sel.model || legacy.model) ? undefined : wfDef.effort),
430
- fanOut: !!firstDefined(sel.fanOut, legacy.fanOut, wfDef.fanOut, meta.fanOut, false), // node > role > workflow > sidecar > false
431
- // Per-agent user questions (spec 2026-07-11): unsupported is ALWAYS off;
432
- // locked ignores every override; else node > role > workflow > sidecar default.
433
- askQuestions: !meta.asksQuestions
434
- ? false
435
- : (meta.questionsLocked
436
- ? !!meta.questionsDefault
437
- : !!firstDefined(sel.askQuestions, legacy.askQuestions, wfDef.askQuestions, meta.questionsDefault, false)),
438
- tools,
439
- loopSource: !!meta.loopSource,
440
- consumes: meta.consumes || [],
441
- optionalConsumes: meta.optionalConsumes || [],
442
- produces: meta.produces || [],
443
- connectsTo: meta.connectsTo || '*',
444
- });
445
- }
446
- steps.push(resolvedGroup);
447
- }
448
-
449
- const feedbacks = (Array.isArray(tpl.feedbacks) ? tpl.feedbacks : []).map((fb) => ({
450
- id: fb.id,
451
- from: fb.from,
452
- to: fb.to,
453
- maxCycles: Number(fbCfg[fb.id]?.maxCycles) > 0 ? Number(fbCfg[fb.id].maxCycles) : DEFAULT_MAX_CYCLES,
454
- gate: 'hasBlocking',
455
- }));
456
-
457
- return { id: tpl.id, name: tpl.name, steps, feedbacks };
526
+ /** Port SIGNATURE for the workspace-variant check: the fields that change
527
+ * SCHEDULING (ids, types, cardinality, loop/expands, conditional routing).
528
+ * Deliberately excludes `as`, filename and store a variant may render and
529
+ * store differently, it may not fire differently. */
530
+ function portSignature(meta) {
531
+ return JSON.stringify({
532
+ inputs: (meta?.inputs || []).map((p) => ({ id: p.id, type: p.type, required: p.required !== false,
533
+ loop: !!p.loop, expands: !!p.expands })),
534
+ outputs: (meta?.outputs || []).map((p) => ({ id: p.id, type: p.type, when: p.when || 'always' })),
535
+ verdict: Boolean(meta?.verdict),
536
+ });
458
537
  }
459
538
 
539
+ const LAYER_RANK = (origin) => (origin === 'builtin' ? 0 : String(origin || '').startsWith('plugin:') ? 2 : 1);
540
+
460
541
  /**
461
- * Build the UI stepper manifest from a resolved ExecutablePlan + agent registry.
462
- * The manifest is the snapshot the Running/History views render from, so it is
463
- * persisted into state.json (and flows through every 'state' event). It brackets
464
- * the workflow's step-cells with the framework's real Preflight and Done phases.
465
- *
466
- * @param {object} plan resolveWorkflow() output: { id, name, steps, feedbacks }
467
- * @param {Record<string,object>} registry loadAgentRegistry() output
468
- * @returns {{version:1, steps:Array<{kind:string, nodes:object[]}>, feedbacks:Array<{id:string,from:string,to:string,maxCycles:number}>}} node shape includes model, effort
542
+ * Workspace substitutions, derived from META alone (no agent-key literals): every
543
+ * `scope:'workspace-only'` meta that declares `workspaceVariantOf` claims that
544
+ * target. Ties break by layer builtin > user > plugin.
545
+ * @returns {Record<string, object>} target key -> variant meta
469
546
  */
470
- export function buildStepperManifest(plan, registry) {
471
- const reg = registry && typeof registry === 'object' ? registry : {};
472
- const fbs = Array.isArray(plan?.feedbacks) ? plan.feedbacks : [];
473
- const isCycleTarget = (nodeId) => fbs.some((fb) => fb && fb.to === nodeId);
474
-
475
- const agentCells = (Array.isArray(plan?.steps) ? plan.steps : []).map((group) => ({
476
- kind: 'agents',
477
- nodes: group.map((node) => {
478
- const meta = reg[node.key] || {};
479
- return {
480
- id: node.nodeId,
481
- key: node.key,
482
- uiPhase: node.uiPhase || node.key,
483
- label: meta.displayName || node.key,
484
- color: meta.color || '',
485
- sub: meta.description || '',
486
- cycles: isCycleTarget(node.nodeId),
487
- model: node.model || '',
488
- effort: node.effort || '',
489
- };
490
- }),
491
- }));
492
-
493
- return {
494
- version: 1,
495
- steps: [
496
- { kind: 'preflight', nodes: [{ id: 'preflight', label: 'Preflight', sub: 'checks' }] },
497
- ...agentCells,
498
- { kind: 'done', nodes: [{ id: 'done', label: 'Done', sub: 'complete' }] },
499
- ],
500
- // Loop edges for the graph renderer (self-cycle = from===to, cross-loop = from!==to).
501
- // Projected to the UI-facing shape; `gate` is intentionally dropped (UI never reads it).
502
- feedbacks: fbs.map(({ id, from, to, maxCycles }) => ({ id, from, to, maxCycles })),
503
- };
547
+ export function workspaceVariants(registry) {
548
+ const out = {};
549
+ for (const meta of Object.values(registry || {})) {
550
+ if (!meta || meta.scope !== 'workspace-only' || !meta.workspaceVariantOf) continue;
551
+ const prev = out[meta.workspaceVariantOf];
552
+ if (!prev || LAYER_RANK(meta.origin) < LAYER_RANK(prev.origin)) out[meta.workspaceVariantOf] = meta;
553
+ }
554
+ return out;
504
555
  }
505
556
 
506
557
  /**
507
- * Rewrite a UI stepper manifest for a decomposed run: replace the single implementer
508
- * agent cell with one cell PER PHASE, each holding one implementer node PER TASK
509
- * (node id = task.nodeId, label = task title). Feedback edges whose `to` was the
510
- * implementer node are retargeted to the first task node so the review->implement
511
- * loop wire still lands. Pure: returns a NEW manifest; the input is untouched. If no
512
- * implementer cell exists, the manifest is returned unchanged. IDEMPOTENT: when the
513
- * manifest already carries the decomposed task cells (a resumed run re-enters the
514
- * decomposed implement stage and re-applies this rewrite to the persisted, already-
515
- * rewritten manifest), it is returned unchanged instead of duplicating the cells.
516
- * @param {object} manifest buildStepperManifest() output
517
- * @param {Array<{ordinal:number, tasks:Array<{id:string,title?:string,nodeId:string}>}>} phases
518
- * @returns {object} the rewritten manifest
558
+ * Merge a v2 template + the project's run-config + the registry into everything a
559
+ * graph run needs. The template comes back UNMUTATED; effective per-node config
560
+ * lives in `nodes`, per-loop-wire budgets in `wires`. P4's _resolveTopology feeds
561
+ * `nodes`/`wires` to buildGraphManifest as `overlays`.
562
+ * @throws {Error} unknown workflow, a v1 row, an unknown/un-ported/unplaceable agent
519
563
  */
520
- export function rewriteStepperForDecomposition(manifest, phases) {
521
- const steps = Array.isArray(manifest?.steps) ? manifest.steps : [];
522
- const phaseList = Array.isArray(phases) ? phases : [];
523
-
524
- // Idempotency guard: the rewrite emits one node per task with id = task.nodeId
525
- // (stamped `s_impl_p<ordinal>_t<n>` by _persistDecomposition). If any cell already
526
- // holds one of those ids, this decomposition has been applied — return unchanged.
527
- const taskIds = new Set(
528
- phaseList.flatMap((ph) => (Array.isArray(ph.tasks) ? ph.tasks : []))
529
- .map((t) => t.nodeId)
530
- .filter(Boolean),
531
- );
532
- if (steps.some((cell) => (cell.nodes || []).some((n) => taskIds.has(n.id)))) return manifest;
564
+ export async function resolveGraph(projectDir, workflowId, registry, agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
565
+ const stored = await readWorkflow(workflowId);
566
+ if (!stored) throw new Error(`unknown workflowId "${workflowId}"`);
567
+ if (stored.version !== 2) throw new Error('template is not a graph — runs on the v1 engine');
568
+ // The RESOLVED template: a private deep copy (the alias row spreads a deep-frozen
569
+ // constant) whose agent nodes carry the RESOLVED key after workspace substitution.
570
+ const tpl = structuredClone(stored);
571
+ const reg = registry && typeof registry === 'object' ? registry : {};
572
+ const isWorkspace = !!opts.isWorkspace;
573
+ const variants = isWorkspace ? workspaceVariants(reg) : {};
574
+ const { nodes: nodeCfg, wires: wireCfg } = await resolveRunConfig(projectDir, workflowId);
575
+ // The legacy per-role layer is the Default workflow's storage only (saved rows
576
+ // use nodeCfg); it is addressed by agent KEY, never by node id.
577
+ const stepsCfg = workflowId === GRAPH_DEFAULT_WORKFLOW.id ? (await readConfig(projectDir)).steps : {};
578
+ const firstDefined = (...vals) => vals.find((v) => v !== undefined);
533
579
 
534
- const implCellIdx = steps.findIndex(
535
- (cell) => cell.kind === 'agents' && cell.nodes.some((n) => n.key === 'implementer'),
536
- );
537
- if (implCellIdx < 0) return manifest;
538
-
539
- const implNode = steps[implCellIdx].nodes.find((n) => n.key === 'implementer');
540
- const implNodeId = implNode.id;
541
-
542
- const phaseCells = phaseList.map((ph) => ({
543
- kind: 'agents',
544
- label: `Phase ${ph.ordinal}`,
545
- nodes: (Array.isArray(ph.tasks) ? ph.tasks : []).map((t) => ({
546
- id: t.nodeId,
547
- key: 'implementer',
548
- uiPhase: 'implement',
549
- label: t.title || t.id,
550
- color: implNode.color || '',
551
- sub: implNode.sub || '',
552
- cycles: false,
553
- model: implNode.model || '',
554
- effort: implNode.effort || '',
555
- })),
556
- }));
557
-
558
- const firstTaskId = phaseCells[0]?.nodes[0]?.id || implNodeId;
559
- const newSteps = [
560
- ...steps.slice(0, implCellIdx),
561
- ...phaseCells,
562
- ...steps.slice(implCellIdx + 1),
563
- ];
564
- const newFeedbacks = (Array.isArray(manifest.feedbacks) ? manifest.feedbacks : []).map((fb) =>
565
- fb.to === implNodeId ? { ...fb, to: firstTaskId } : { ...fb },
566
- );
567
- return { ...manifest, steps: newSteps, feedbacks: newFeedbacks };
580
+ const nodes = {};
581
+ const agentsByKey = {};
582
+ const agentKeys = new Set();
583
+ for (const node of Array.isArray(tpl.nodes) ? tpl.nodes : []) {
584
+ if (node.kind !== 'agent') {
585
+ nodes[node.id] = { nodeId: node.id, kind: node.kind, key: null, config: { ...(node.config || {}) } };
586
+ continue;
587
+ }
588
+ const authored = node.key;
589
+ const key = variants[authored]?.key || authored;
590
+ if (key !== authored) node.key = key; // the resolved template carries the resolved key
591
+ const meta = reg[key];
592
+ if (!meta) throw new Error(`unknown agent "${key}" — no such key in the registry`);
593
+ if (!Array.isArray(meta.inputs) || !Array.isArray(meta.outputs)) {
594
+ throw new Error(`agent "${key}" has no v2 ports — port its sidecar to metaVersion 2`);
595
+ }
596
+ if (meta.placeable === false) throw new Error(`agent "${key}" declares placeable: false and cannot be a graph node`);
597
+ if (key !== authored && portSignature(meta) !== portSignature(reg[authored] || {})) {
598
+ throw new Error(`workspace variant "${key}" does not match the port signature of "${authored}"`);
599
+ }
600
+ const { prompt, tools } = await loadAgentFile(agentsDir, meta.agentFile ?? null, meta.agentPath ?? null);
601
+ const sel = nodeCfg[node.id] || {};
602
+ // Legacy per-role config is keyed by the AUTHORED key, so a substituted
603
+ // variant still inherits the user's model/effort for that role.
604
+ const legacy = stepsCfg[authored] || {};
605
+ const cfg = node.config && typeof node.config === 'object' ? node.config : {};
606
+ nodes[node.id] = {
607
+ nodeId: node.id,
608
+ kind: 'agent',
609
+ key,
610
+ authoredKey: authored,
611
+ meta,
612
+ runnerType: meta.runnerType || 'producer',
613
+ agentFile: meta.agentFile ?? null,
614
+ agentPrompt: prompt,
615
+ promptHints: typeof meta.promptHints === 'string' ? meta.promptHints : '',
616
+ tools,
617
+ config: { ...cfg },
618
+ model: firstDefined(sel.model, legacy.model, cfg.model),
619
+ // An effort only travels with the model that advertises it: an override
620
+ // naming its own model must not inherit the lower layer's effort.
621
+ effort: firstDefined(sel.effort, legacy.effort, (sel.model || legacy.model) ? undefined : cfg.effort),
622
+ // workspaceFanOut forces fan-out on a workspace run (the generic
623
+ // replacement for the v1 FANOUT_ELIGIBLE key list).
624
+ fanOut: isWorkspace && meta.workspaceFanOut
625
+ ? true
626
+ : !!firstDefined(sel.fanOut, legacy.fanOut, cfg.fanOut, meta.fanOut, false),
627
+ // Sub-agent model policy. Raw tri-state: '' = unset (the RUNTIME resolves
628
+ // that to the auto default — phases.mjs#ctxSubagentModel), a stored value
629
+ // otherwise. Meaningful only WITH fanOut, but resolved unconditionally so
630
+ // the value survives a fan-out toggle without a second write. Each layer
631
+ // is enum-guarded HERE because the TEMPLATE layer has no write-time
632
+ // validation (validateGraph whitelists the key; a plugin import checks
633
+ // nothing): an off-vocabulary value must fall through to the next layer,
634
+ // never freeze verbatim into the run manifest.
635
+ subagentModel: firstDefined(
636
+ validSubagentModel(sel.subagentModel), validSubagentModel(legacy.subagentModel),
637
+ validSubagentModel(cfg.subagentModel), '') || '',
638
+ askQuestions: !meta.asksQuestions
639
+ ? false
640
+ : (meta.questionsLocked
641
+ ? !!meta.questionsDefault
642
+ : !!firstDefined(sel.askQuestions, legacy.askQuestions, cfg.askQuestions, meta.questionsDefault, false)),
643
+ awaitAll: !!cfg.awaitAll,
644
+ };
645
+ agentsByKey[key] = meta;
646
+ agentKeys.add(key);
647
+ }
648
+
649
+ // Two nodes sharing one agent key prefix their run-store outputs (executor dupPrefix).
650
+ const keyCount = new Map();
651
+ for (const nc of Object.values(nodes)) if (nc.kind === 'agent') keyCount.set(nc.key, (keyCount.get(nc.key) || 0) + 1);
652
+ for (const nc of Object.values(nodes)) if (nc.kind === 'agent') nc.duplicateKey = (keyCount.get(nc.key) || 0) > 1;
653
+
654
+ // Budgets ride LOOP wires only: overlay > authored > DEFAULT_MAX_CYCLES.
655
+ // portsFn + loops are computed ONCE here and RETURNED — P4 hands them to the
656
+ // scheduler and the manifest builder instead of re-deriving them.
657
+ const portsFn = registryPortsFn(agentsByKey);
658
+ const loops = classifyLoops(tpl, portsFn);
659
+ const { loopWireIds } = loops;
660
+ const wires = {};
661
+ for (const w of Array.isArray(tpl.wires) ? tpl.wires : []) {
662
+ if (!loopWireIds.has(w.id)) continue;
663
+ const raw = Number(wireCfg[w.id]?.maxCycles ?? w.config?.maxCycles);
664
+ wires[w.id] = { maxCycles: Number.isInteger(raw) && raw >= 1 ? raw : DEFAULT_MAX_CYCLES };
665
+ }
666
+ return { template: tpl, ports: portsFn, loops, nodes, wires, agentsByKey, agentKeys };
568
667
  }