@worca/app 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,568 @@
1
+ // src/core/workflows.mjs
2
+ // node:sqlite migration: now persisted in the `workflows` table; path helpers vestigial.
3
+ // Global workflow-template store + the built-in DEFAULT_WORKFLOW + resolveWorkflow.
4
+ //
5
+ // Templates are TOPOLOGY + PER-NODE DEFAULTS (steps + feedbacks by node-instance
6
+ // id; each node may carry an optional `defaults` block — newpipeline-ux-design.md
7
+ // §4.4). Per-project model/effort/cycle data is the run-config in config.mjs and
8
+ // OVERRIDES those defaults; resolveWorkflow merges both.
9
+ //
10
+ // Reads never throw: a missing/corrupt store yields []/null.
11
+
12
+ import { readFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+
15
+ import { getDb, prepare, tx } from './db.mjs';
16
+ import { worcaHome } from './projects.mjs';
17
+ import { resolveRunConfig, readConfig, EFFORTS } from './config.mjs';
18
+ import { slugify } from './artifacts.mjs';
19
+
20
+ /**
21
+ * Default feedback cycle count when run-config does not override it. Matches the
22
+ * Composer's per-loop input default (app.js), so an unset loop runs 3 cycles.
23
+ */
24
+ const DEFAULT_MAX_CYCLES = 3;
25
+
26
+ /** Local domain guard (mirrors agent-registry DOMAIN_RE). workflows.mjs deliberately
27
+ * does not import the registry, so a one-line constant is cheaper than a new coupling.
28
+ * Unlike the registry's normalizeDomain, this .trim()s — store input may carry
29
+ * whitespace from a prompt. Absent/malformed → the VISIBLE 'general' default. */
30
+ const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
31
+ function normDomain(raw) {
32
+ const v = typeof raw === 'string' ? raw.trim() : '';
33
+ return DOMAIN_RE.test(v) ? v : 'general';
34
+ }
35
+
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
+ /**
40
+ * Read an agent prompt file and pull its declared tools from YAML frontmatter.
41
+ * Returns { prompt, tools }. A missing file => { prompt:'', tools:[] } (fails
42
+ * safe; the orchestrator already tolerates an empty agent body). The frontmatter
43
+ * `tools:` line is a comma-separated list (matches agents/*.md convention).
44
+ * @param {string} agentsDir
45
+ * @param {string|null} agentFile
46
+ * @param {string|null} [agentPath]
47
+ * @returns {Promise<{prompt:string, tools:string[]}>}
48
+ */
49
+ async function loadAgentFile(agentsDir, agentFile, agentPath = null) {
50
+ if (!agentFile && !agentPath) return { prompt: '', tools: [] };
51
+ let text = '';
52
+ 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.
56
+ text = await readFile(agentPath || join(agentsDir, agentFile), 'utf8');
57
+ } 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
+ }
63
+ }
64
+ return { prompt: text, tools: parseFrontmatterTools(text) };
65
+ }
66
+
67
+ /** Extract a comma-separated `tools:` list from leading --- YAML frontmatter. */
68
+ function parseFrontmatterTools(text) {
69
+ const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
70
+ if (!m) return [];
71
+ const line = m[1].split(/\r?\n/).find((l) => /^tools\s*:/.test(l));
72
+ if (!line) return [];
73
+ return line
74
+ .replace(/^tools\s*:/, '')
75
+ .split(',')
76
+ .map((s) => s.trim())
77
+ .filter(Boolean);
78
+ }
79
+
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
+ /**
114
+ * Sanitize one node's `defaults` block (newpipeline-ux-design.md §4.4). Loud and
115
+ * lenient, matching the module's house style: a malformed FIELD is dropped with a
116
+ * console.warn naming it, the rest of the block survives. An empty/absent block
117
+ * (or one left empty after dropping) yields undefined so callers omit the key.
118
+ * Structural only — that `model` names a catalog entry is validated at the API
119
+ * boundary (where the effective per-project catalog is reachable), exactly like
120
+ * setStep/setNodeModel.
121
+ * @param {unknown} raw
122
+ * @param {string} [nodeId] for the warning message
123
+ * @returns {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}|undefined}
124
+ */
125
+ export function sanitizeNodeDefaults(raw, nodeId = '?') {
126
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
127
+ const out = {};
128
+ const warn = (field, why) =>
129
+ console.warn(`workflow node "${nodeId}": dropping defaults.${field} (${why})`);
130
+
131
+ if (raw.model !== undefined) {
132
+ const model = typeof raw.model === 'string' ? raw.model.trim() : '';
133
+ if (model) out.model = model;
134
+ else if (raw.model !== '' && raw.model !== null) warn('model', 'not a non-empty string');
135
+ }
136
+ if (raw.effort !== undefined) {
137
+ const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
138
+ if (EFFORTS.includes(effort)) out.effort = effort;
139
+ else if (effort) warn('effort', `unknown effort "${effort}"`);
140
+ }
141
+ // An effort without a model is meaningless (it is filtered by the model's
142
+ // advertised effort list), so it never survives on its own.
143
+ if (out.effort && !out.model) {
144
+ warn('effort', 'no model to interpret it');
145
+ delete out.effort;
146
+ }
147
+ for (const field of ['fanOut', 'askQuestions']) {
148
+ if (raw[field] === undefined) continue;
149
+ if (typeof raw[field] === 'boolean') out[field] = raw[field];
150
+ else warn(field, 'not a boolean');
151
+ }
152
+ return Object.keys(out).length ? out : undefined;
153
+ }
154
+
155
+ /**
156
+ * Normalize a steps matrix for persistence: pass every node through untouched
157
+ * EXCEPT its `defaults` block, which is sanitized (and dropped when empty).
158
+ * Unknown node fields are preserved verbatim — plugin-shipped templates may carry
159
+ * their own, and this function must not be the thing that silently eats them.
160
+ * Pure: returns new arrays/objects, never mutates the input.
161
+ * @param {unknown} steps
162
+ * @returns {Array<Array<object>>}
163
+ */
164
+ export function sanitizeWorkflowSteps(steps) {
165
+ if (!Array.isArray(steps)) return [];
166
+ return steps.map((group) => (Array.isArray(group) ? group.map((node) => {
167
+ if (!node || typeof node !== 'object') return node;
168
+ const { defaults, ...rest } = node;
169
+ const clean = sanitizeNodeDefaults(defaults, node.id);
170
+ return clean ? { ...rest, defaults: clean } : rest;
171
+ }) : group));
172
+ }
173
+
174
+ /**
175
+ * Flatten a template's per-node defaults to { [nodeId]: defaults }. Nodes without
176
+ * a defaults block are absent from the map. Used by the UI/API to answer "what is
177
+ * this workflow's default for node X" without re-walking steps.
178
+ * @param {object|null} tpl
179
+ * @returns {Record<string,object>}
180
+ */
181
+ export function workflowNodeDefaults(tpl) {
182
+ const out = {};
183
+ for (const group of Array.isArray(tpl?.steps) ? tpl.steps : []) {
184
+ for (const node of Array.isArray(group) ? group : []) {
185
+ if (node && node.id && node.defaults && typeof node.defaults === 'object') out[node.id] = node.defaults;
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /** Absolute path to ~/.worca-cc/workflows (honors WORCA_HOME via projects.mjs). */
192
+ export function workflowsDir() {
193
+ return join(worcaHome(), 'workflows');
194
+ }
195
+
196
+ /** 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. */
198
+ const SAFE_WORKFLOW_ID = /^[A-Za-z0-9_-]+$/;
199
+ function isSafeWorkflowId(id) { return typeof id === 'string' && SAFE_WORKFLOW_ID.test(id); }
200
+
201
+ /** Fail-safe JSON.parse to an array; returns [] on any error. */
202
+ function parseArr(text) {
203
+ if (typeof text !== 'string' || !text) return [];
204
+ try { const v = JSON.parse(text); return Array.isArray(v) ? v : []; } catch { return []; }
205
+ }
206
+
207
+ /** Map a workflows row to the template object shape. */
208
+ function rowToTpl(r) {
209
+ return {
210
+ id: r.id,
211
+ name: r.name,
212
+ version: r.version,
213
+ domain: r.domain || 'general', // pre-migration NULL → 'general'
214
+ origin: r.origin || null, // 'plugin:<name>' provenance; NULL = user-created
215
+ steps: parseArr(r.steps),
216
+ feedbacks: parseArr(r.feedbacks),
217
+ createdAt: r.created_at,
218
+ updatedAt: r.updated_at,
219
+ };
220
+ }
221
+
222
+ /** Read + shallow-validate one stored template row. Unsafe id / missing => null. */
223
+ function readRaw(id) {
224
+ if (!isSafeWorkflowId(id)) return null; // SECURITY: reject path-traversal / unsafe ids
225
+ 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);
229
+ if (!r) return null;
230
+ const tpl = rowToTpl(r);
231
+ return Array.isArray(tpl.steps) ? tpl : null; // mirror the legacy steps-array check
232
+ }
233
+
234
+ /**
235
+ * Persist a template. Stamps a wf_<slug> id (from the name) when missing, version 1,
236
+ * createdAt (preserved across re-saves), and a fresh updatedAt. steps/feedbacks are
237
+ * stored as JSON. Returns the stored object. Never mutates the input.
238
+ * @param {object} tpl { id?, name, steps, feedbacks, createdAt? }
239
+ * @returns {Promise<object>}
240
+ */
241
+ export async function writeWorkflow(tpl) {
242
+ const now = new Date().toISOString();
243
+ const name = (tpl && typeof tpl.name === 'string' && tpl.name.trim()) || 'Untitled';
244
+ const id = (tpl && typeof tpl.id === 'string' && tpl.id.trim()) || `wf_${slugify(name)}`;
245
+ const steps = sanitizeWorkflowSteps(tpl?.steps);
246
+ const feedbacks = Array.isArray(tpl?.feedbacks) ? tpl.feedbacks : [];
247
+ const domain = normDomain(tpl && tpl.domain);
248
+
249
+ getDb();
250
+ // Preserve the original createdAt if this id already exists (re-save).
251
+ const existing = isSafeWorkflowId(id)
252
+ ? prepare('SELECT created_at FROM workflows WHERE id = ?').get(id)
253
+ : null;
254
+ const createdAt =
255
+ (tpl && typeof tpl.createdAt === 'string' && tpl.createdAt) ||
256
+ (existing && existing.created_at) ||
257
+ now;
258
+
259
+ const stored = { id, name, version: 1, domain, steps, feedbacks, createdAt, updatedAt: now };
260
+ tx(() => {
261
+ prepare(`
262
+ INSERT INTO workflows (id, name, version, domain, steps, feedbacks, created_at, updated_at)
263
+ VALUES (?, ?, 1, ?, ?, ?, ?, ?)
264
+ ON CONFLICT(id) DO UPDATE SET
265
+ name = excluded.name, version = 1, domain = excluded.domain,
266
+ steps = excluded.steps, feedbacks = excluded.feedbacks,
267
+ updated_at = excluded.updated_at
268
+ `).run(id, name, domain, JSON.stringify(steps), JSON.stringify(feedbacks), createdAt, now);
269
+ });
270
+ return stored;
271
+ }
272
+
273
+ /**
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.
276
+ * @param {string} id
277
+ * @returns {Promise<object|null>}
278
+ */
279
+ export async function readWorkflow(id) {
280
+ if (id === DEFAULT_WORKFLOW.id) return DEFAULT_WORKFLOW;
281
+ return readRaw(id);
282
+ }
283
+
284
+ /**
285
+ * List user templates (NOT DEFAULT_WORKFLOW — callers prepend it), newest first by
286
+ * createdAt. Empty store => []. Never throws.
287
+ * @returns {Promise<object[]>}
288
+ */
289
+ export async function listWorkflows() {
290
+ 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);
295
+ }
296
+
297
+ /**
298
+ * Replace the per-node `defaults` of a SAVED template (newpipeline-ux-design.md
299
+ * §4.4). `map` is { [nodeId]: defaults|null }: a sanitized block sets that node's
300
+ * defaults, null/empty clears them, and a node absent from the map keeps what it
301
+ * has. Node ids unknown to the template are ignored (a stale UI must not
302
+ * resurrect deleted nodes). Topology is untouched.
303
+ *
304
+ * Refuses wf_default: the built-in is frozen and never persisted, so it has no row
305
+ * to carry defaults — its sensible defaults come from the agent registry instead
306
+ * (design D6). Duplicating it in Composer yields a saved workflow that can.
307
+ *
308
+ * @param {string} id
309
+ * @param {Record<string,object|null>} map
310
+ * @returns {Promise<object>} the updated template
311
+ * @throws {Error} unknown/unsafe id, or the built-in default
312
+ */
313
+ export async function setWorkflowNodeDefaults(id, map) {
314
+ if (id === DEFAULT_WORKFLOW.id) {
315
+ throw new Error('the built-in Default workflow cannot store defaults — save a copy in Composer first');
316
+ }
317
+ const tpl = readRaw(id);
318
+ if (!tpl) throw new Error(`workflow not found: ${id}`);
319
+ const patch = map && typeof map === 'object' ? map : {};
320
+
321
+ const steps = tpl.steps.map((group) => (Array.isArray(group) ? group.map((node) => {
322
+ if (!node || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(patch, node.id)) return node;
323
+ const { defaults, ...rest } = node;
324
+ const clean = sanitizeNodeDefaults(patch[node.id], node.id);
325
+ return clean ? { ...rest, defaults: clean } : rest;
326
+ }) : group));
327
+
328
+ const now = new Date().toISOString();
329
+ tx(() => {
330
+ prepare('UPDATE workflows SET steps = ?, updated_at = ? WHERE id = ?')
331
+ .run(JSON.stringify(steps), now, id);
332
+ });
333
+ return { ...tpl, steps, updatedAt: now };
334
+ }
335
+
336
+ /**
337
+ * Delete a saved template by id. Refuses the built-in DEFAULT_WORKFLOW (false) and
338
+ * unsafe ids (false). Returns false when no row exists; true on removal.
339
+ * @param {string} id
340
+ * @returns {Promise<boolean>}
341
+ */
342
+ export async function deleteWorkflow(id) {
343
+ if (id === DEFAULT_WORKFLOW.id) return false; // built-in default is undeletable
344
+ if (!isSafeWorkflowId(id)) return false; // SECURITY: reject unsafe ids
345
+ getDb();
346
+ let changed = 0;
347
+ tx(() => {
348
+ changed = prepare('DELETE FROM workflows WHERE id = ?').run(id).changes;
349
+ });
350
+ return changed > 0;
351
+ }
352
+ /**
353
+ * Merge a workflow template + the project's run-config + the agent registry into
354
+ * an ExecutablePlan the dispatcher runs:
355
+ * { id, name, steps:[[Node]], feedbacks:[{id,from,to,maxCycles,gate}] }
356
+ * Node = { nodeId, key, uiPhase, runnerType, agentFile, agentPrompt, model, effort, tools, loopSource }
357
+ * model/effort come from run-config (undefined when unset; the orchestrator folds
358
+ * in the global fallback at dispatch). maxCycles defaults to DEFAULT_MAX_CYCLES.
359
+ * [v2/C5] When `opts.isWorkspace` is set, the review node is substituted at resolve
360
+ * time: any `reviewer` node key becomes `workspaceReviewer` (the fan-out synthesizer
361
+ * that diffs each member's checkpoint and folds one merged verdict). This is the ONE
362
+ * topology change a workspace run makes here; the orchestrator then forces fanOut on
363
+ * the eligible nodes (which now includes `workspaceReviewer`). Absent `isWorkspace`,
364
+ * the resolved plan is BYTE-IDENTICAL to today's single-project path.
365
+ * @param {string} projectDir
366
+ * @param {string} workflowId
367
+ * @param {Record<string,object>} registry loadAgentRegistry() output
368
+ * @param {string} [agentsDir] override for tests; defaults to ../../agents
369
+ * @param {{ isWorkspace?: boolean }} [opts] workspace-mode resolve options
370
+ * @returns {Promise<object>} ExecutablePlan
371
+ * @throws {Error} when the workflow id is unknown, or a node resolves the off-pipeline scanner
372
+ */
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 };
458
+ }
459
+
460
+ /**
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
469
+ */
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
+ };
504
+ }
505
+
506
+ /**
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
519
+ */
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;
533
+
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 };
568
+ }