@worca/app 1.0.0 → 1.2.0-rc.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 (143) hide show
  1. package/README.md +30 -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 +386 -56
  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 +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -8,6 +8,8 @@ import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { loadAgentRegistry, normalizeMeta, userAgentsDir } from './agent-registry.mjs';
10
10
  import { listWorkflows } from './workflows.mjs';
11
+ import { validateMetaV2 } from '../shared/graph/agent-meta.mjs';
12
+ import { AWAIT_PORT } from '../shared/graph/constants.mjs'; // the synthesized gate port is wirable
11
13
 
12
14
  export { userAgentsDir }; // single source: the Phase 1 layer resolver
13
15
 
@@ -16,6 +18,15 @@ export const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
16
18
 
17
19
  function err(message, code) { return Object.assign(new Error(message), { code }); }
18
20
 
21
+ /** The v2 capabilities whose ABSENCE is their off state in a sidecar. A complete
22
+ * v2 PUT is a REPLACE of this surface — otherwise `{...existing, ...raw}` turns
23
+ * every one of them into a one-way switch and `placeable:false` /
24
+ * `scope:'workspace-only'` can never be undone from the editor. Everything else
25
+ * (including the v1 wiring the registry still derives, which P8 owns) MERGES. */
26
+ const V2_CLEARABLE = ['verdict', 'sideEffect', 'mockRole', 'wantsRequest', 'workspaceFanOut',
27
+ 'workspaceStrategy', 'workspaceVariantOf', 'placeable', 'scope', 'domain', 'icon',
28
+ 'promptHints', 'requiresSkills'];
29
+
19
30
  /** The writable user layer dir. userAgentsDir() returns null only when the home
20
31
  * cannot be resolved (no WORCA_HOME under node:test) — surface that as a 400. */
21
32
  function requireUserDir() {
@@ -59,6 +70,12 @@ export async function createAgent({ meta: rawMeta, markdown } = {}) {
59
70
  raw.key = key;
60
71
  raw.agentFile = `${key}.md`; // store-owned sibling file
61
72
  if (!Number.isFinite(Number(raw.order))) raw.order = 99; // sort after built-ins by default
73
+ // The v2 gate runs BEFORE normalizeMeta: normalizeMeta is lossy by design
74
+ // (fixed key set, silent coercions, and it returns null rather than a reason),
75
+ // so a broken sidecar would otherwise be "fixed" into something the user never
76
+ // wrote — or rejected with "invalid agent metadata". Every failed rule is named.
77
+ const issues = validateMetaV2(raw).errors;
78
+ if (issues.length) throw err(issues.join('; '), 'BAD_REQUEST');
62
79
  const meta = normalizeMeta(raw);
63
80
  if (!meta) throw err('invalid agent metadata', 'BAD_REQUEST');
64
81
  const existing = loadAgentRegistry()[key];
@@ -73,6 +90,126 @@ export async function createAgent({ meta: rawMeta, markdown } = {}) {
73
90
  return { meta: { ...meta, origin: 'user' }, markdown };
74
91
  }
75
92
 
93
+ /** Per-port fields the workspace-variant signature deliberately EXCLUDES
94
+ * (workflows.mjs portSignature): a variant may render and store differently, it
95
+ * may not fire differently. These survive a propagation; everything else on the
96
+ * port comes from the base. */
97
+ const VARIANT_OWN_PORT_FIELDS = ['label', 'description', 'as', 'directive', 'filename', 'store', 'artifactKind'];
98
+
99
+ /** One side of a variant's ports, rebuilt from the base's: the base decides the
100
+ * set, the order and every scheduling field; the variant keeps its own rendering.
101
+ * @param {object[]} basePorts @param {object[]} variantPorts */
102
+ function mergeVariantPorts(basePorts, variantPorts) {
103
+ const mine = new Map((Array.isArray(variantPorts) ? variantPorts : []).map((p) => [p.id, p]));
104
+ return (Array.isArray(basePorts) ? basePorts : []).map((bp) => {
105
+ const own = mine.get(bp.id);
106
+ const out = { ...bp };
107
+ if (!own) return out;
108
+ // `as` is TYPE-COUPLED (agent-meta.mjs AS_REQUIRES_TYPE: worktree=>void,
109
+ // answers=>json, fix-review=>md; a bare 'file' demands a NON-void port and
110
+ // readInputs materializes it on every non-void input). The TYPE is the
111
+ // base's — the signature owns it — so the variant's stored `as` only
112
+ // survives a type it was written for; on a type change the base's own `as`
113
+ // stands. Without this, a base input that BECOMES void (the shipped
114
+ // reviewer/workspaceReviewer `done` worktree port) leaves the variant
115
+ // carrying `as:'file'` on a void port and the WHOLE merge is refused.
116
+ const keep = own.type === bp.type
117
+ ? VARIANT_OWN_PORT_FIELDS : VARIANT_OWN_PORT_FIELDS.filter((f) => f !== 'as');
118
+ for (const f of keep) if (own[f] !== undefined) out[f] = own[f];
119
+ // A void port carries none of these, whatever the variant used to say.
120
+ if (out.type === 'void') { delete out.filename; delete out.store; delete out.artifactKind; }
121
+ return out;
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Re-point every USER workspace variant of `key` at the base's new port signature.
127
+ * A variant is BY DEFINITION the same ports with a different workspace strategy, so
128
+ * a base port edit that is not propagated makes resolveGraph refuse every workspace
129
+ * run ("workspace variant X does not match the port signature of Y") at a moment
130
+ * disconnected from the edit. Non-user variants (builtin/plugin) cannot be written
131
+ * — they are reported instead. Never throws: a variant that will not validate
132
+ * after the merge, or whose sidecar cannot be written, is left untouched and
133
+ * reported — the base's own save has already succeeded and must stand.
134
+ * @param {string} key the edited base key
135
+ * @param {object} baseMeta the base's NEW normalized meta
136
+ * @param {string} dir the writable user layer
137
+ * @returns {Promise<{updated:string[], warnings:string[]}>}
138
+ */
139
+ async function propagateToVariants(key, baseMeta, dir) {
140
+ const updated = [];
141
+ const warnings = [];
142
+ for (const variant of Object.values(loadAgentRegistry())) {
143
+ if (!variant || variant.workspaceVariantOf !== key) continue;
144
+ if (variant.origin !== 'user') {
145
+ warnings.push(`workspace variant "${variant.key}" (${variant.origin}) still declares the old ports `
146
+ + '— workspace runs using it will be refused until it is updated');
147
+ continue;
148
+ }
149
+ const raw = {
150
+ ...variant,
151
+ inputs: mergeVariantPorts(baseMeta.inputs, variant.inputs),
152
+ outputs: mergeVariantPorts(baseMeta.outputs, variant.outputs),
153
+ };
154
+ // Boolean(verdict) IS part of the signature; its filename is not. Copying the
155
+ // base's verdict into a variant that had none also copies the base's filename
156
+ // TEMPLATE, so the variant writes its verdict there — correct for the
157
+ // `{base}`-tokenised names every shipped verifier uses; a hardcoded one would
158
+ // need re-pointing by hand.
159
+ if (baseMeta.verdict && !variant.verdict) raw.verdict = { ...baseMeta.verdict };
160
+ if (!baseMeta.verdict) delete raw.verdict;
161
+ if (variant.descriptionDerived) raw.description = ''; // never persist a derived blurb (agent-store.mjs:114)
162
+ delete raw.origin; delete raw.agentPath; delete raw.descriptionDerived;
163
+ const issues = validateMetaV2(raw).errors;
164
+ const merged = issues.length ? null : normalizeMeta(raw);
165
+ if (!merged) {
166
+ warnings.push(`workspace variant "${variant.key}" could not adopt the new ports `
167
+ + `(${issues.join('; ') || 'invalid agent metadata'}) — re-point it by hand`);
168
+ continue;
169
+ }
170
+ try {
171
+ await writeFile(join(dir, `${variant.key}.meta.json`), JSON.stringify(merged, null, 2) + '\n', 'utf8');
172
+ } catch (e) {
173
+ // Only the fs CODE: the message carries the absolute home path, which no
174
+ // banner may surface. `updatedVariants` lists what was actually written.
175
+ warnings.push(`workspace variant "${variant.key}" could not adopt the new ports `
176
+ + `(write failed: ${e?.code || 'unknown error'}) — re-point it by hand`);
177
+ continue;
178
+ }
179
+ updated.push(variant.key);
180
+ }
181
+ return { updated: updated.sort(), warnings };
182
+ }
183
+
184
+ /**
185
+ * Saved-template wires that the NEW port set of `key` no longer satisfies:
186
+ * `["<workflow name> (<nodeId>.<portId>)", …]`. A port rename/removal is NOT
187
+ * refused here — no saved template can reference the new port before it exists,
188
+ * so a 409 would make renaming impossible. The RUN refuses instead
189
+ * (assertRunnableWorkflow -> INVALID_GRAPH); this list is the editor's heads-up.
190
+ * `await` is the engine-synthesized gate input and is always wirable.
191
+ * @param {object[]} workflows every saved template (archived included)
192
+ * @param {string} key the edited agent key
193
+ * @param {object} meta the NEW normalized meta
194
+ * @returns {string[]}
195
+ */
196
+ function stalePortRefs(workflows, key, meta) {
197
+ const outs = new Set((meta.outputs || []).map((p) => p.id));
198
+ const ins = new Set([...(meta.inputs || []).map((p) => p.id), AWAIT_PORT.id]);
199
+ const hits = [];
200
+ for (const wf of workflows) {
201
+ const mine = new Set((wf.nodes || [])
202
+ .filter((n) => n && n.kind === 'agent' && n.key === key).map((n) => n.id));
203
+ if (!mine.size) continue;
204
+ const label = wf.name || wf.id;
205
+ for (const w of wf.wires || []) {
206
+ if (mine.has(w?.from?.node) && !outs.has(w.from.port)) hits.push(`${label} (${w.from.node}.${w.from.port})`);
207
+ if (mine.has(w?.to?.node) && !ins.has(w.to.port)) hits.push(`${label} (${w.to.node}.${w.to.port})`);
208
+ }
209
+ }
210
+ return hits;
211
+ }
212
+
76
213
  /** Update a USER agent (meta and/or markdown). Built-ins -> BUILTIN (409). */
77
214
  export async function updateAgent(key, { meta: rawMeta, markdown } = {}) {
78
215
  if (!AGENT_KEY_RE.test(String(key || ''))) throw err(`agent not found: ${key}`, 'NOT_FOUND');
@@ -96,10 +233,14 @@ export async function updateAgent(key, { meta: rawMeta, markdown } = {}) {
96
233
  // field) freezes the fallback into the sidecar and it stops tracking the .md.
97
234
  const base = { ...existing };
98
235
  if (base.descriptionDerived) base.description = '';
236
+ if (Number(rawMeta?.metaVersion) === 2) for (const k of V2_CLEARABLE) delete base[k];
99
237
  const raw = { ...base, ...(rawMeta && typeof rawMeta === 'object' ? rawMeta : {}) };
100
238
  raw.key = key; // key immutable on update
101
239
  raw.agentFile = `${key}.md`;
102
240
  if (!Number.isFinite(Number(raw.order))) raw.order = existing.order;
241
+ // The same gate the create path applies: every failed rule named, nothing written.
242
+ const updIssues = validateMetaV2(raw).errors;
243
+ if (updIssues.length) throw err(updIssues.join('; '), 'BAD_REQUEST');
103
244
  const meta = normalizeMeta(raw);
104
245
  if (!meta) throw err('invalid agent metadata', 'BAD_REQUEST');
105
246
  const dir = requireUserDir();
@@ -111,7 +252,12 @@ export async function updateAgent(key, { meta: rawMeta, markdown } = {}) {
111
252
  const body = typeof markdown === 'string'
112
253
  ? markdown
113
254
  : await readFile(join(dir, `${key}.md`), 'utf8').catch(() => '');
114
- return { meta: { ...meta, origin: 'user' }, markdown: body };
255
+ // Always present, possibly empty: the Agents view reads both unconditionally.
256
+ const stale = stalePortRefs(await listWorkflows({ includeArchived: true }), key, meta);
257
+ const warnings = stale.length ? [`saved pipelines reference a removed port: ${stale.join(', ')}`] : [];
258
+ const variants = await propagateToVariants(key, meta, dir);
259
+ warnings.push(...variants.warnings);
260
+ return { meta: { ...meta, origin: 'user' }, markdown: body, warnings, updatedVariants: variants.updated };
115
261
  }
116
262
 
117
263
  /** Delete a USER agent; REFERENCED (409) while a saved workflow uses the key. */
@@ -130,11 +276,25 @@ export async function deleteAgent(key) {
130
276
  );
131
277
  }
132
278
  if (!existing) throw err(`agent not found: ${key}`, 'NOT_FOUND');
133
- const refs = (await listWorkflows())
134
- .filter((wf) => (wf.steps || []).some((col) => (col || []).some((n) => n && n.key === key)))
279
+ const refs = (await listWorkflows({ includeArchived: true }))
280
+ // v1 rows are still live until the engine cut-over; v2 rows carry a key only
281
+ // on kind:'agent' nodes (a task/end/and/or/combine card never does).
282
+ .filter((wf) => (wf.steps || []).some((col) => (col || []).some((n) => n && n.key === key))
283
+ || (wf.nodes || []).some((n) => n && n.kind === 'agent' && n.key === key))
135
284
  .map((wf) => wf.name || wf.id);
136
285
  if (refs.length) {
137
- throw err(`agent "${key}" is used by saved workflow(s): ${refs.join(', ')} — delete or edit those first`, 'REFERENCED');
286
+ throw err(`agent "${key}" is used by saved workflow(s): ${refs.join(', ')} `
287
+ + '— delete or edit those first (archived rows count)', 'REFERENCED');
288
+ }
289
+ // A workspace variant substitutes for its target agent by KEY: deleting the
290
+ // target would leave the variant pointing at nothing, and the substitution
291
+ // would silently stop happening on workspace runs.
292
+ const variants = Object.values(loadAgentRegistry())
293
+ .filter((m) => m && m.workspaceVariantOf === key)
294
+ .map((m) => m.key);
295
+ if (variants.length) {
296
+ throw err(`agent "${key}" is the workspace variant target of: ${variants.join(', ')} `
297
+ + '— delete or re-point those first', 'REFERENCED');
138
298
  }
139
299
  const dir = requireUserDir();
140
300
  await rm(join(dir, `${key}.meta.json`), { force: true });
@@ -322,7 +322,8 @@ export function readPipelineExtras(pipelineId) {
322
322
  * @param {string} pipelineId
323
323
  * @param {{id:string, label?:string, nodeId?:string, stepIndex?:number, cycle?:number,
324
324
  * stepKey?:string, status?:string, startedAt?:string, finishedAt?:string,
325
- * durationMs?:number, tokens?:number, costUsd?:number, subagentType?:string}} rec
325
+ * durationMs?:number, tokens?:number, costUsd?:number, subagentType?:string,
326
+ * runModel?:string}} rec
326
327
  */
327
328
  export function upsertSubAgent(pipelineId, rec) {
328
329
  if (!pipelineId || !rec || !rec.id) return;
@@ -330,9 +331,11 @@ export function upsertSubAgent(pipelineId, rec) {
330
331
  tx(() => {
331
332
  getDb().prepare(`
332
333
  INSERT INTO sub_agents (pipeline_id, id, step_key, node_id, step_index, cycle,
333
- label, status, started_at, finished_at, duration_ms, tokens, cost_usd, ui_phase, skills, subagent_type, graphify_count)
334
+ label, status, started_at, finished_at, duration_ms, tokens, cost_usd, ui_phase, skills, subagent_type, graphify_count,
335
+ run_model)
334
336
  VALUES (@pipeline_id,@id,@step_key,@node_id,@step_index,@cycle,@label,@status,
335
- @started_at,@finished_at,@duration_ms,@tokens,@cost_usd,@ui_phase,@skills,@subagent_type,@graphify_count)
337
+ @started_at,@finished_at,@duration_ms,@tokens,@cost_usd,@ui_phase,@skills,@subagent_type,@graphify_count,
338
+ @run_model)
336
339
  ON CONFLICT(pipeline_id, id) DO UPDATE SET
337
340
  status = excluded.status,
338
341
  step_key = COALESCE(excluded.step_key, step_key),
@@ -348,7 +351,8 @@ export function upsertSubAgent(pipelineId, rec) {
348
351
  ui_phase = COALESCE(excluded.ui_phase, ui_phase),
349
352
  skills = COALESCE(excluded.skills, skills),
350
353
  subagent_type = COALESCE(excluded.subagent_type, subagent_type),
351
- graphify_count = COALESCE(excluded.graphify_count, graphify_count)
354
+ graphify_count = COALESCE(excluded.graphify_count, graphify_count),
355
+ run_model = COALESCE(excluded.run_model, run_model)
352
356
  `).run({
353
357
  pipeline_id: pipelineId,
354
358
  id: rec.id,
@@ -367,6 +371,10 @@ export function upsertSubAgent(pipelineId, rec) {
367
371
  skills: s(rec.skills), // s() = JSON.stringify or null; growing supersets overwrite via COALESCE
368
372
  subagent_type: rec.subagentType ?? null, // scalar TEXT: bound directly (no s() JSON wrap)
369
373
  graphify_count: Number.isFinite(rec.graphifyCount) ? rec.graphifyCount : null, // scalar INTEGER
374
+ // The model this child actually ran on: the alias its Task call asked for,
375
+ // else the parent node's model (what it inherits). Written at spawn and
376
+ // COALESCE-guarded, so a later finish/telemetry update never nulls it.
377
+ run_model: rec.runModel ?? null,
370
378
  });
371
379
  });
372
380
  } catch { /* best-effort: live state.subAgents is the reconcile source of truth; a swallowed write is caught by tests, not a crashed run. */ }
@@ -382,13 +390,14 @@ export function upsertSubAgent(pipelineId, rec) {
382
390
  * @returns {Array<{id:string, label:string|null, nodeId:string|null, stepIndex:number|null,
383
391
  * cycle:number|null, stepKey:string|null, status:string, startedAt:string|null,
384
392
  * finishedAt:string|null, durationMs:number|null, tokens:number|null, costUsd:number|null,
385
- * subagentType:string|null}>}
393
+ * subagentType:string|null, runModel:string|null}>}
386
394
  */
387
395
  export function listSubAgents(pipelineId) {
388
396
  if (!pipelineId) return [];
389
397
  return getDb().prepare(`
390
398
  SELECT id, label, node_id, step_index, cycle, step_key, status,
391
- started_at, finished_at, duration_ms, tokens, cost_usd, ui_phase, skills, subagent_type, graphify_count
399
+ started_at, finished_at, duration_ms, tokens, cost_usd, ui_phase, skills, subagent_type, graphify_count,
400
+ run_model
392
401
  FROM sub_agents WHERE pipeline_id = ? ORDER BY started_at, id
393
402
  `).all(pipelineId).map((r) => ({
394
403
  id: r.id,
@@ -407,6 +416,7 @@ export function listSubAgents(pipelineId) {
407
416
  skills: j(r.skills, []), // NULL -> [] so the UI always has an array (no pills)
408
417
  subagentType: r.subagent_type ?? null, // scalar TEXT: mapped directly (no j() parse)
409
418
  graphifyCount: r.graphify_count ?? null, // scalar INTEGER: NULL -> null (no badge)
419
+ runModel: r.run_model ?? null, // scalar TEXT: NULL -> null (no pill on pre-v25 rows)
410
420
  }));
411
421
  }
412
422
 
@@ -728,6 +738,42 @@ function resolveAgainst(base, p) {
728
738
  return isAbsolute(p) ? p : resolve(base, p);
729
739
  }
730
740
 
741
+ /**
742
+ * The ONE typed error every prompt-file reader throws. Carried by `code` so the
743
+ * CLI can fail() on it and ui/server.mjs can answer 400 instead of letting it
744
+ * surface as an anonymous mid-run error event.
745
+ * @param {string} absPath the resolved path the caller named
746
+ * @param {unknown} cause
747
+ */
748
+ export function promptFileError(absPath, cause) {
749
+ const err = new Error(`cannot read prompt file ${absPath}: ${cause?.message || cause}`);
750
+ err.code = 'PROMPT_FILE_UNREADABLE';
751
+ err.promptFile = absPath;
752
+ err.cause = cause;
753
+ return err;
754
+ }
755
+
756
+ /**
757
+ * Read a NAMED prompt file, resolved against `projectDir` exactly as createPipeline
758
+ * does. Never degrades: a file the caller named but we cannot read is an ERROR, not
759
+ * an empty prompt. The old bare `catch { promptText = '' }` ran a whole pipeline on
760
+ * an empty task and exited 0 — in real mode spending tokens and cutting a worktree
761
+ * + feature branch for nothing. The empty-string fallback is only meaningful when
762
+ * NO file was named.
763
+ * @param {string} projectDir
764
+ * @param {string} promptFile absolute, or relative to projectDir
765
+ * @returns {Promise<string>}
766
+ * @throws {Error & {code:'PROMPT_FILE_UNREADABLE'}}
767
+ */
768
+ export async function readPromptFile(projectDir, promptFile) {
769
+ const abs = resolveAgainst(projectDir, promptFile);
770
+ try {
771
+ return await readFile(abs, 'utf8');
772
+ } catch (cause) {
773
+ throw promptFileError(abs, cause);
774
+ }
775
+ }
776
+
731
777
  /**
732
778
  * Create a new pipeline directory and seed it with the prompt, extras and an audit
733
779
  * header (pipeline.md). The structured run state is INSERTed as a pipelines row
@@ -785,12 +831,14 @@ export async function createPipeline(projectDir, opts = {}) {
785
831
  // verbatim copy below is unchanged).
786
832
  let promptText = typeof prompt === 'string' ? prompt : '';
787
833
  if (!promptText && typeof precomputedPromptText === 'string') promptText = precomputedPromptText;
788
- if (!promptText && promptFile) {
789
- try {
790
- promptText = await readFile(resolveAgainst(projectDir, promptFile), 'utf8');
791
- } catch {
792
- promptText = '';
793
- }
834
+ // A NAMED prompt file must be readable, and it is checked BEFORE anything is
835
+ // created (no dir, no row). It is checked even when an inline prompt or a
836
+ // precomputed body already won the text, because the file is still copied
837
+ // verbatim into prompt.md below — the old bare catch there silently substituted
838
+ // the inline text for the file the caller named.
839
+ if (promptFile) {
840
+ const fileText = await readPromptFile(projectDir, promptFile);
841
+ if (!promptText) promptText = fileText;
794
842
  }
795
843
 
796
844
  const resolvedTitle =
@@ -810,8 +858,11 @@ export async function createPipeline(projectDir, opts = {}) {
810
858
  if (promptFile) {
811
859
  try {
812
860
  await copyFile(resolveAgainst(projectDir, promptFile), promptDest);
813
- } catch {
814
- await writeFile(promptDest, promptText, 'utf8');
861
+ } catch (cause) {
862
+ // The read above already proved this path readable, so a failure here is a
863
+ // TOCTOU (the file vanished) or a destination problem — either way the run
864
+ // must not proceed on a substituted prompt.
865
+ throw promptFileError(resolveAgainst(projectDir, promptFile), cause);
815
866
  }
816
867
  } else {
817
868
  await writeFile(promptDest, promptText, 'utf8');
@@ -992,11 +1043,11 @@ export async function writeState(pipelineDir, stateObj) {
992
1043
  INSERT INTO pipelines (id, project_key, workspace_key, target, title, base_name,
993
1044
  date_prefix, status, phase, cycle, started_at, updated_at, total_cost_usd,
994
1045
  total_active_ms, prompt, branch, workspace_meta, stepper, tools, resume_point,
995
- source_type, source_ref, guardrails_id)
1046
+ source_type, source_ref, guardrails_id, outcome)
996
1047
  VALUES (@id,@project_key,@workspace_key,@target,@title,@base_name,@date_prefix,
997
1048
  @status,@phase,@cycle,@started_at,@updated_at,@total_cost_usd,@total_active_ms,
998
1049
  @prompt,@branch,@workspace_meta,@stepper,@tools,@resume_point,
999
- @source_type,@source_ref,@guardrails_id)
1050
+ @source_type,@source_ref,@guardrails_id,@outcome)
1000
1051
  ON CONFLICT(id) DO UPDATE SET
1001
1052
  status=excluded.status, phase=excluded.phase, cycle=excluded.cycle,
1002
1053
  updated_at=excluded.updated_at, total_cost_usd=excluded.total_cost_usd,
@@ -1004,6 +1055,7 @@ export async function writeState(pipelineDir, stateObj) {
1004
1055
  workspace_meta=excluded.workspace_meta, stepper=excluded.stepper,
1005
1056
  tools=excluded.tools,
1006
1057
  resume_point=excluded.resume_point,
1058
+ outcome=excluded.outcome,
1007
1059
  base_name=COALESCE(excluded.base_name, base_name),
1008
1060
  date_prefix=COALESCE(excluded.date_prefix, date_prefix)
1009
1061
  `).run(toPipelineRow(obj));
@@ -1011,10 +1063,19 @@ export async function writeState(pipelineDir, stateObj) {
1011
1063
  getDb().prepare('DELETE FROM pipeline_steps WHERE pipeline_id = ?').run(id);
1012
1064
  const ins = getDb().prepare(`
1013
1065
  INSERT INTO pipeline_steps (pipeline_id, key, node_id, phase, step_index, cycle,
1014
- status, started_at, updated_at, active_ms, running_since, cost_usd, session_id, skills, graphify_count)
1015
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
1066
+ status, started_at, updated_at, active_ms, running_since, cost_usd, session_id,
1067
+ skills, graphify_count,
1068
+ execution_id, exec_kind, agent_key, ended_at, exec_trigger, exec_result, exec_meta)
1069
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
1016
1070
  `);
1017
1071
  for (const st of Array.isArray(obj.steps) ? obj.steps : []) {
1072
+ // v2 rows: execution_id === key. v1 rows leave every exec_* column NULL, so
1073
+ // the readers below reproduce today's exact shape for a v1 pipeline.
1074
+ const meta = (st.taskId != null || st.parentExecutionId != null || st.title != null || st.phaseOrdinal != null)
1075
+ ? s({ taskId: st.taskId ?? null, parentExecutionId: st.parentExecutionId ?? null,
1076
+ title: st.title ?? null, phaseOrdinal: st.phaseOrdinal ?? null,
1077
+ taskIndex: st.taskIndex ?? null, taskTotal: st.taskTotal ?? null })
1078
+ : null;
1018
1079
  ins.run(
1019
1080
  id, st.key, st.nodeId ?? null, st.phase ?? null,
1020
1081
  st.stepIndex ?? null, st.cycle ?? null, st.status ?? null,
@@ -1025,6 +1086,13 @@ export async function writeState(pipelineDir, stateObj) {
1025
1086
  st.sessionId ?? null,
1026
1087
  s(st.skills),
1027
1088
  Number.isFinite(st.graphifyCount) ? st.graphifyCount : null,
1089
+ st.executionId ?? null,
1090
+ st.kind ?? null,
1091
+ st.agentKey ?? null,
1092
+ st.endedAt ?? null,
1093
+ st.trigger === undefined ? null : s(st.trigger),
1094
+ st.result === undefined ? null : s(st.result),
1095
+ meta,
1028
1096
  );
1029
1097
  }
1030
1098
  });
@@ -1334,11 +1402,23 @@ function toPipelineRow(o) {
1334
1402
  source_type: o.sourceType ?? 'prompt',
1335
1403
  source_ref: s(o.sourceMeta),
1336
1404
  guardrails_id: o.guardrailsId ?? null,
1405
+ // §5.9 outcome: the derived run-level v2 facts, so a rehydrated state matches
1406
+ // a live one. NULL for a v1 run (nothing to say), so v1 rows are unchanged.
1407
+ outcome: (o.engine === 2 || o.endReached !== undefined)
1408
+ ? s({
1409
+ endReached: !!o.endReached,
1410
+ result: o.result ?? null,
1411
+ warnings: Array.isArray(o.warnings) ? o.warnings : [],
1412
+ wireDeliveries: o.wireDeliveries ?? {},
1413
+ tokens: o.tokens ?? {},
1414
+ })
1415
+ : null,
1337
1416
  };
1338
1417
  }
1339
1418
 
1340
1419
  /**
1341
- * The pipeline's {cost, active} totals for the history list, read from the DB row.
1420
+ * The pipeline's {cost, active} totals for the history list (and the Ask Worca
1421
+ * `get_run` tool, ask-worca-design.md §6.4), read from the DB row.
1342
1422
  * Normal runs carry NOT-NULL 0-defaulted totals, so when a total is > 0 it is used
1343
1423
  * verbatim and NO extra query runs. Only when a total is 0 do we fall back to the
1344
1424
  * per-step SUM/COUNT (the DB-native equivalent of the old pipelineTotalCost/
@@ -1349,7 +1429,7 @@ function toPipelineRow(o) {
1349
1429
  * @param {object} row a pipelines DB row (total_cost_usd / total_active_ms)
1350
1430
  * @returns {{cost:number|null, active:number|null}}
1351
1431
  */
1352
- function totalsFor(row) {
1432
+ export function totalsFor(row) {
1353
1433
  const agg = getDb().prepare(`
1354
1434
  SELECT COUNT(cost_usd) cc, SUM(cost_usd) sc, COUNT(active_ms) ca, SUM(active_ms) sa
1355
1435
  FROM pipeline_steps WHERE pipeline_id = ?
@@ -1439,6 +1519,7 @@ async function rowToHistoryEntry(row, repoDir = null, opts = {}) {
1439
1519
  sourceBranch: source,
1440
1520
  guardrailsId: row.guardrails_id ?? null,
1441
1521
  pauseReason: row.pause_reason ?? null,
1522
+ pauseDetail: row.pause_detail ?? null,
1442
1523
  retainedWork: retainedWorkFor(row),
1443
1524
  survived,
1444
1525
  added,
@@ -1494,7 +1575,8 @@ export async function listPipelines(projectDir, opts = {}, workspaceKey) {
1494
1575
  const rows = getDb().prepare(`
1495
1576
  SELECT id, project_key, target, title, status, started_at, updated_at, total_cost_usd, total_active_ms,
1496
1577
  branch, workspace_meta, guardrails_id,
1497
- json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
1578
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
1579
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
1498
1580
  FROM pipelines
1499
1581
  WHERE ${workspaceKey ? 'workspace_key = ?' : 'project_key = ?'} AND archived_at IS NULL
1500
1582
  ORDER BY started_at DESC
@@ -1522,7 +1604,8 @@ export async function listAllPipelines(opts = {}, { batchSize = 16 } = {}) {
1522
1604
  const rows = getDb().prepare(`
1523
1605
  SELECT id, project_key, workspace_key, target, title, status, started_at, updated_at,
1524
1606
  total_cost_usd, total_active_ms, branch, workspace_meta, guardrails_id,
1525
- json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason
1607
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseReason') AS pause_reason,
1608
+ json_extract(CASE WHEN json_valid(resume_point) THEN resume_point END, '$.pauseDetail') AS pause_detail
1526
1609
  FROM pipelines
1527
1610
  WHERE archived_at IS NULL
1528
1611
  ORDER BY COALESCE(updated_at, started_at) DESC, project_key, id
@@ -1646,6 +1729,22 @@ function stepRowToStep(r) {
1646
1729
  };
1647
1730
  if (r.node_id != null) step.nodeId = r.node_id;
1648
1731
  if (r.step_index != null) step.stepIndex = r.step_index;
1732
+ if (r.execution_id != null) step.executionId = r.execution_id;
1733
+ if (r.exec_kind != null) step.kind = r.exec_kind;
1734
+ if (r.agent_key != null) step.agentKey = r.agent_key;
1735
+ if (r.ended_at != null) step.endedAt = r.ended_at;
1736
+ if (r.execution_id != null && r.cycle != null) step.ordinal = r.cycle; // `ordinal` is the v2 name; `cycle` is its alias
1737
+ if (r.exec_trigger != null) step.trigger = j(r.exec_trigger, { wireIds: [], freshPorts: [] });
1738
+ if (r.exec_result != null) step.result = j(r.exec_result, null);
1739
+ const em = r.exec_meta != null ? j(r.exec_meta, null) : null;
1740
+ if (em) {
1741
+ if (em.taskId != null) step.taskId = em.taskId;
1742
+ if (em.parentExecutionId != null) step.parentExecutionId = em.parentExecutionId;
1743
+ if (em.title != null) step.title = em.title;
1744
+ if (em.phaseOrdinal != null) step.phaseOrdinal = em.phaseOrdinal;
1745
+ if (em.taskIndex != null) step.taskIndex = em.taskIndex;
1746
+ if (em.taskTotal != null) step.taskTotal = em.taskTotal;
1747
+ }
1649
1748
  return step;
1650
1749
  }
1651
1750
 
@@ -1678,13 +1777,33 @@ function rowToState(row) {
1678
1777
  stepper: j(row.stepper, null),
1679
1778
  tools: j(row.tools, null),
1680
1779
  guardrailsId: row.guardrails_id ?? null,
1780
+ // A retired v1 resume point was NULLed by the v2 upgrade: the run stays in
1781
+ // History with an honest status, but it can never be resumed again.
1782
+ resumable: row.resume_point != null,
1681
1783
  steps: getDb().prepare(`
1682
1784
  SELECT key, node_id, phase, step_index, cycle, status, started_at, updated_at,
1683
- active_ms, running_since, cost_usd, session_id, skills, graphify_count
1785
+ active_ms, running_since, cost_usd, session_id, skills, graphify_count,
1786
+ execution_id, exec_kind, agent_key, ended_at, exec_trigger, exec_result, exec_meta
1684
1787
  FROM pipeline_steps WHERE pipeline_id = ? ORDER BY rowid
1685
1788
  `).all(row.id).map(stepRowToStep),
1686
1789
  subAgents: listSubAgents(row.id),
1687
1790
  };
1791
+ // The pause cause rides resume_point (no column): expose it on the DETAIL payload
1792
+ // too, so a deep-linked History detail no longer waits for the LIST row.
1793
+ const rp = j(row.resume_point, null);
1794
+ state.pauseReason = typeof rp?.pauseReason === 'string' ? rp.pauseReason : null;
1795
+ state.pauseDetail = typeof rp?.pauseDetail === 'string' ? rp.pauseDetail : null;
1796
+ const outcome = j(row.outcome, null);
1797
+ if (outcome) {
1798
+ state.engine = 2;
1799
+ state.endReached = !!outcome.endReached;
1800
+ state.result = outcome.result ?? null;
1801
+ state.warnings = Array.isArray(outcome.warnings) ? outcome.warnings : [];
1802
+ state.wireDeliveries = outcome.wireDeliveries ?? {};
1803
+ state.tokens = outcome.tokens ?? {};
1804
+ state.active = []; // nothing is in flight in a rehydrated snapshot
1805
+ state.gate = null;
1806
+ }
1688
1807
  const meta = readStoreMeta(row.project_key);
1689
1808
  state.projectDir = meta?.path ?? null;
1690
1809
  // Workspace superset: spread workspace_meta back onto the top level + target.
@@ -1752,6 +1871,24 @@ export function lookupPipelineRow(key, id) {
1752
1871
  return row || null;
1753
1872
  }
1754
1873
 
1874
+ /**
1875
+ * Ask Worca (ask-worca-design.md §6.4 get_run): one pipelines row by short id
1876
+ * across EVERY store key, archived included. `pipelines.id` is the PRIMARY KEY, so
1877
+ * there is at most one row; the dir-name form (`…-<8hex>`) is accepted like
1878
+ * lookupPipelineRow does.
1879
+ * @param {string} id
1880
+ * @returns {object|null}
1881
+ */
1882
+ export function findPipelineRowById(id) {
1883
+ const raw = String(id ?? '').trim();
1884
+ if (!raw) return null;
1885
+ let row = getDb().prepare('SELECT * FROM pipelines WHERE id = ?').get(raw.toLowerCase());
1886
+ if (row) return row;
1887
+ const m = DIR_ID_RE.exec(raw);
1888
+ if (m) row = getDb().prepare('SELECT * FROM pipelines WHERE id = ?').get(m[1].toLowerCase());
1889
+ return row || null;
1890
+ }
1891
+
1755
1892
  /**
1756
1893
  * The DB-backed lookups `sweepRunRoots` (worktree.mjs) needs, in ONE place shared by
1757
1894
  * both callers — `ui/server.mjs`'s boot sweep and the `worca doctor` subcommand.
@@ -1980,6 +2117,45 @@ export async function findRunDir(pipelinesDir, id) {
1980
2117
  return null;
1981
2118
  }
1982
2119
 
2120
+ /**
2121
+ * Read one INDEXED artifact of a pipelines ROW. `rel` never reaches the
2122
+ * filesystem: it only SELECTS among the rows the artifacts table already holds —
2123
+ * the exact rel_path first, else the LONGEST rel_path that is a path SUFFIX of it
2124
+ * (`…/<rel_path>`; the engine records absolute paths and the client sends
2125
+ * `result.path` verbatim). Basenames CAN collide across dirs, so a bare basename
2126
+ * never matches a nested row. The path that is read is ALWAYS the stored one,
2127
+ * run dir first, store root second (plan/review markdown is store-root-relative);
2128
+ * a stored path with a `..` segment is refused outright. Null when the row, the
2129
+ * index row or the file is missing.
2130
+ */
2131
+ export async function resolveIndexedArtifactForRow(row, rel) {
2132
+ const norm = (p) => String(p || '').replace(/\\/g, '/');
2133
+ const want = norm(rel);
2134
+ if (!row || !want) return null;
2135
+ const arts = (await listArtifacts(row.id))
2136
+ .filter((a) => a && typeof a.relPath === 'string' && a.relPath && !a.relPath.split('/').includes('..'));
2137
+ // Exact first, then the LONGEST suffix — with rows `plan.md` and `a/plan.md`
2138
+ // a request for `/x/a/plan.md` ends with BOTH `/plan.md` and `/a/plan.md`, and a
2139
+ // first-match `find` would serve whichever row the table happens to list first.
2140
+ const hit = arts.find((a) => a.relPath === want)
2141
+ || arts.filter((a) => want.endsWith(`/${a.relPath}`))
2142
+ .sort((x, y) => y.relPath.length - x.relPath.length)[0];
2143
+ if (!hit) return null;
2144
+ const isWs = row.target === 'workspace' || !!row.workspace_key;
2145
+ const storeRoot = isWs ? workspaceStorePath(row.workspace_key) : projectStorePath(row.project_key);
2146
+ const runDir = await runDirForRow(row);
2147
+ for (const base of [runDir, storeRoot]) {
2148
+ try { return { rel: hit.relPath, text: await readFile(join(base, hit.relPath), 'utf8') }; } catch { /* try the next base */ }
2149
+ }
2150
+ return null;
2151
+ }
2152
+
2153
+ /** The keyed form (`/api/history/:key/:id`, workspace twin) over lookupPipelineRow. */
2154
+ export async function resolveIndexedArtifact(key, id, rel) {
2155
+ const row = lookupPipelineRow(key, id);
2156
+ return row ? resolveIndexedArtifactForRow(row, rel) : null;
2157
+ }
2158
+
1983
2159
  /** Read a pipeline-local artifact file as text, or null if absent. */
1984
2160
  export async function readRunArtifactText(key, id, relPath) {
1985
2161
  const row = lookupPipelineRow(key, id);