@worca/app 1.3.0-rc.2 → 1.4.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 (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -14,7 +14,8 @@ import { join, resolve, isAbsolute, sep } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { worcaHome } from './projects.mjs'; // user agent layer root (read fresh per call)
16
16
  import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs'; // plugin layer roots (Task 2)
17
- import { declaredApi, NOT_META_V2 } from './plugin-manifest.mjs'; // plugin API declared by a layer's manifest
17
+ import { declaredApi, negotiatedApi, NOT_META_V2, ASK_NEEDS_API_4 } from './plugin-manifest.mjs'; // plugin API declared/negotiated by a layer's manifest
18
+ import { WORCA_ASK_FORMS_API } from './plugin-api.mjs';
18
19
  import { normalizeAgentMeta, DEFAULT_ORDER } from '../shared/graph/agent-meta.mjs'; // meta v2 (one source: registry + store + UI)
19
20
  import { MOCK_WRITER_ROLES } from './claude-runner.mjs'; // mockRole vocabulary (no cycle: claude-runner imports no registry)
20
21
  import { readFrontmatterSync } from './frontmatter.mjs';
@@ -84,7 +85,7 @@ const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
84
85
  * `warn` is INJECTABLE (defaults to console.warn) so scanLayer can capture the
85
86
  * reason a sidecar was dropped and hand it to a diagnostics sink — the reason
86
87
  * is authored here and must never be re-derived by a second reader. */
87
- export function normalizeMeta(raw, { warn = console.warn } = {}) {
88
+ export function normalizeMeta(raw, { warn = console.warn, onDropForm = null } = {}) {
88
89
  if (!raw || typeof raw !== 'object') return null;
89
90
  const key = typeof raw.key === 'string' ? raw.key.trim() : '';
90
91
  if (!key) return null;
@@ -149,6 +150,7 @@ export function normalizeMeta(raw, { warn = console.warn } = {}) {
149
150
  const { meta, errors } = normalizeAgentMeta(raw, {
150
151
  mockWriterRoles: MOCK_WRITER_ROLES,
151
152
  warn: (msg) => warn(msg),
153
+ onDropForm, // §3: a dropped FORM must not look like a dropped SIDECAR
152
154
  });
153
155
  if (errors.length) {
154
156
  warn(`[agent-registry] sidecar "${key}" declares metaVersion 2 but is invalid; skipped: ${errors.join('; ')}`);
@@ -162,7 +164,7 @@ export function normalizeMeta(raw, { warn = console.warn } = {}) {
162
164
  portSummary: meta.portSummary,
163
165
  };
164
166
  for (const field of ['verdict', 'sideEffect', 'mockRole', 'wantsRequest', 'workspaceFanOut',
165
- 'workspaceStrategy', 'workspaceVariantOf', 'placeable']) {
167
+ 'workspaceStrategy', 'workspaceVariantOf', 'placeable', 'ask', 'humanEffort']) {
166
168
  if (field in meta) merged[field] = meta[field];
167
169
  }
168
170
  return merged;
@@ -181,16 +183,18 @@ export function userAgentsDir() {
181
183
 
182
184
  /**
183
185
  * Third registry layer (spec §9.1): every ENABLED installed plugin's
184
- * current/agents dir, in lexicographic plugin-name order — the deterministic
185
- * collision winner among plugins. An entry is skipped when disabled
186
+ * current/<subdir> dir, in lexicographic plugin-name order — the deterministic
187
+ * collision winner among plugins. Shared by the agent registry (`agents`) and the
188
+ * script registry (`scripts`). An entry is skipped when disabled
186
189
  * (enabled === false in the lock) or broken (existsSync follows the current/
187
190
  * symlink, so a missing or dangling symlink — and a version dir without
188
- * agents/ — drops out). Wrapped in try/catch like userAgentsDir(): with no
191
+ * <subdir>/ — drops out). Wrapped in try/catch like userAgentsDir(): with no
189
192
  * resolvable worca-cc home (bare node:test runner) or an unreadable lock this
190
193
  * returns [] and registry loads never throw.
191
- * @returns {Array<{plugin: string, dir: string, builtFor: number|null}>}
194
+ * @param {'agents'|'scripts'} subdir
195
+ * @returns {Array<{plugin: string, dir: string, builtFor: number|null, api: number|null}>}
192
196
  */
193
- export function pluginAgentLayers() {
197
+ export function pluginLayers(subdir) {
194
198
  try {
195
199
  const lock = readPluginsLock();
196
200
  return Object.keys(lock)
@@ -199,14 +203,22 @@ export function pluginAgentLayers() {
199
203
  .map((name) => {
200
204
  const dir = pluginCurrentDir(name);
201
205
  let builtFor = null;
206
+ let api = null;
202
207
  try {
203
208
  const raw = JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8'));
209
+ const range = raw?.engines?.['worca-cc-api'] ?? '';
204
210
  // `|| null`: declaredApi('') is 0 (an unconstrained range accepts
205
211
  // everything), and "built for plugin API 0" is not English. apiMismatch
206
212
  // guards the same case the same way.
207
- builtFor = declaredApi(raw?.engines?.['worca-cc-api'] ?? '') || null;
208
- } catch { builtFor = null; } // unreadable manifest: the message degrades, the skip does not
209
- return { plugin: name, dir: join(dir, 'agents'), builtFor };
213
+ builtFor = declaredApi(range) || null;
214
+ // builtFor is the LOWEST integer the range accepts (what the plugin was
215
+ // written against); `api` is the HIGHEST host API it admits — what the
216
+ // plugin actually NEGOTIATES, and therefore what decides which host
217
+ // features it gets. `null` when the manifest is missing or unreadable:
218
+ // every feature gate below must fail CLOSED on it.
219
+ api = negotiatedApi(range);
220
+ } catch { builtFor = null; api = null; } // unreadable manifest: the message degrades, the skip does not
221
+ return { plugin: name, dir: join(dir, subdir), builtFor, api };
210
222
  })
211
223
  .filter(({ dir }) => existsSync(dir));
212
224
  } catch {
@@ -214,14 +226,23 @@ export function pluginAgentLayers() {
214
226
  }
215
227
  }
216
228
 
217
- /** Scan one layer dir for *.meta.json; stamps the COMPUTED origin/agentPath/
218
- * descriptionDerived fields (none of which normalizeMeta returns, so none can
219
- * be persisted back into a sidecar). */
220
- function scanLayer(dir, origin, { requireMetaV2 = false, builtFor = null, onDrop = null } = {}) {
229
+ export function pluginAgentLayers() { return pluginLayers('agents'); }
230
+
231
+ /**
232
+ * Scan one layer dir for `*.meta.json` and normalize each through
233
+ * `normalize(parsed, { warn })` (null = skip). Stamps the COMPUTED `origin`.
234
+ * ONE reader for "a directory of sidecars", two normalizers (agents, scripts).
235
+ * `tag` prefixes the warnings (`[agent-registry]` / `[script-registry]`).
236
+ * @returns {Array<{meta: object, file: string}>}
237
+ */
238
+ export function scanMetaLayer(dir, origin, { normalize, tag, requireMetaV2 = false, builtFor = null, onDrop = null }) {
221
239
  // Every skip below is a CONTRIBUTION THE USER CANNOT SEE unless someone
222
240
  // reports it: console.warn reaches a server log, not the Plugins card, the
223
241
  // install receipt or the doctor. onDrop is that reporting channel — optional,
224
- // so the hot registry path pays nothing when nobody is listening.
242
+ // so the hot registry path pays nothing when nobody is listening. A drop means
243
+ // "this file, or a declared PART of it, was ignored": scanLayer's API-4 gate
244
+ // reports a stripped `ask` block through the same channel, and the reason
245
+ // string is what tells the two apart.
225
246
  const drop = (file, reason) => { if (onDrop) onDrop({ origin, file, reason }); };
226
247
  let files;
227
248
  try {
@@ -229,7 +250,7 @@ function scanLayer(dir, origin, { requireMetaV2 = false, builtFor = null, onDrop
229
250
  } catch {
230
251
  return []; // missing layer dir => empty layer (fails safe)
231
252
  }
232
- const metas = [];
253
+ const out = [];
233
254
  for (const f of files) {
234
255
  if (!f.endsWith('.meta.json')) continue;
235
256
  let parsed;
@@ -246,18 +267,35 @@ function scanLayer(dir, origin, { requireMetaV2 = false, builtFor = null, onDrop
246
267
  // layers keep the v1 path until the engine cut-over.
247
268
  if (requireMetaV2 && Number(parsed?.metaVersion) !== 2) {
248
269
  const builtForText = builtFor == null ? 'an older plugin API' : `plugin API ${builtFor}`;
249
- console.warn(`[agent-registry] ${origin}/${f}: built for ${builtForText} — ${NOT_META_V2} — ignored`);
270
+ console.warn(`[${tag}] ${origin}/${f}: built for ${builtForText} — ${NOT_META_V2} — ignored`);
250
271
  drop(f, `built for ${builtForText} — ${NOT_META_V2}`);
251
272
  continue;
252
273
  }
253
- // Capture normalizeMeta's own reason rather than re-deriving one: the LAST
274
+ // Capture the normalizer's own reason rather than re-deriving one: the LAST
254
275
  // warning it emits is the fatal one (non-fatal coercion warnings precede it).
255
276
  let why = '';
256
- const meta = normalizeMeta(parsed, {
257
- warn: (m) => { why = String(m).replace(/^\[agent-registry\] /, ''); console.warn(m); },
277
+ const prefix = `[${tag}] `;
278
+ const meta = normalize(parsed, {
279
+ warn: (m) => { why = String(m).startsWith(prefix) ? String(m).slice(prefix.length) : String(m); console.warn(m); },
280
+ // A form that fails gate 1 drops the FORM, not the sidecar, so it reports
281
+ // straight to the diagnostics sink instead of through `why` (which names
282
+ // the reason an agent vanished). The script normalizer ignores the opt.
283
+ onDropForm: ({ message }) => drop(f, message),
258
284
  });
259
285
  if (!meta) { drop(f, why || 'invalid sidecar'); continue; }
260
286
  meta.origin = origin; // computed, never stored
287
+ out.push({ meta, file: f });
288
+ }
289
+ return out;
290
+ }
291
+
292
+ /** Scan one agent layer dir for *.meta.json; stamps the COMPUTED origin/agentPath/
293
+ * descriptionDerived fields (none of which normalizeMeta returns, so none can
294
+ * be persisted back into a sidecar). */
295
+ function scanLayer(dir, origin, { requireMetaV2 = false, builtFor = null, onDrop = null, askApi = null } = {}) {
296
+ const drop = (file, reason) => { if (onDrop) onDrop({ origin, file, reason }); };
297
+ const metas = [];
298
+ for (const { meta, file: f } of scanMetaLayer(dir, origin, { normalize: normalizeMeta, tag: 'agent-registry', requireMetaV2, builtFor, onDrop })) {
261
299
  // agentFile is a PATH read as the agent's system prompt AND for its
262
300
  // `tools:` frontmatter, so the loader refuses to stamp an agentPath outside
263
301
  // the layer it is scanning. Belt-and-braces behind validatePluginDir, which
@@ -268,6 +306,20 @@ function scanLayer(dir, origin, { requireMetaV2 = false, builtFor = null, onDrop
268
306
  drop(f, `agentFile "${meta.agentFile}" resolves outside the agents dir`);
269
307
  continue;
270
308
  }
309
+
310
+ // API 4 (plugin layers only — `requireMetaV2` is the same plugin-layer
311
+ // marker the meta v2 gate above rides): an agent's `ask` block is honoured
312
+ // only when the plugin NEGOTIATES plugin API 4. Below that it is stripped
313
+ // HERE, at the single choke point, so the prompt block, the ask-time gate,
314
+ // the Agents view and History all see an agent that simply has no forms and
315
+ // the agent falls back to generic questions on its own (ask-forms spec §10).
316
+ // Number(null) is 0, so an unknowable API fails CLOSED. The sidecar itself
317
+ // is NOT dropped: its ports are fine and its agent stays usable.
318
+ if (requireMetaV2 && meta.ask && !(Number(askApi) >= WORCA_ASK_FORMS_API)) {
319
+ console.warn(`[agent-registry] ${origin}/${f}: ${ASK_NEEDS_API_4}`);
320
+ drop(f, ASK_NEEDS_API_4);
321
+ delete meta.ask;
322
+ }
271
323
  meta.agentPath = meta.agentFile ? join(dir, meta.agentFile) : null; // layer-correct abs path
272
324
  // The agent .md's frontmatter (name/description/tools/model), read from the
273
325
  // file HEAD only (frontmatter.mjs) — COMPUTED like origin/agentPath, never
@@ -328,8 +380,8 @@ export function loadAgentRegistry(agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
328
380
  const plugins = [];
329
381
  if (opts.includePlugins !== false) {
330
382
  const taken = new Set([...builtinKeys, ...users.map((m) => m.key)]);
331
- for (const { plugin, dir, builtFor } of pluginAgentLayers()) {
332
- for (const m of scanLayer(dir, `plugin:${plugin}`, { requireMetaV2: true, builtFor, onDrop })) {
383
+ for (const { plugin, dir, builtFor, api } of pluginAgentLayers()) {
384
+ for (const m of scanLayer(dir, `plugin:${plugin}`, { requireMetaV2: true, builtFor, askApi: api, onDrop })) {
333
385
  if (taken.has(m.key)) {
334
386
  console.warn(
335
387
  `[agent-registry] plugin agent "${m.key}" (plugin "${plugin}") collides with an existing agent and was skipped`,
@@ -11,6 +11,9 @@ import { listWorkflows } from './workflows.mjs';
11
11
  import { validateMetaV2 } from '../shared/graph/agent-meta.mjs';
12
12
  import { AWAIT_PORT } from '../shared/graph/constants.mjs'; // the synthesized gate port is wirable
13
13
 
14
+ import { normalizeAskBlock, validateFormDef } from '../shared/forms/form-def.mjs';
15
+ import { ASK_LIMITS } from '../shared/forms/catalog.mjs';
16
+
14
17
  export { userAgentsDir }; // single source: the Phase 1 layer resolver
15
18
 
16
19
  /** A key is a bare alphanumeric stem — can never contain "/" or "..". */
@@ -24,8 +27,48 @@ function err(message, code) { return Object.assign(new Error(message), { code })
24
27
  * `scope:'workspace-only'` can never be undone from the editor. Everything else
25
28
  * (including the v1 wiring the registry still derives, which P8 owns) MERGES. */
26
29
  const V2_CLEARABLE = ['verdict', 'sideEffect', 'mockRole', 'wantsRequest', 'workspaceFanOut',
27
- 'workspaceStrategy', 'workspaceVariantOf', 'placeable', 'scope', 'domain', 'icon',
28
- 'promptHints', 'requiresSkills'];
30
+ 'workspaceStrategy', 'workspaceVariantOf', 'placeable', 'scope', 'domain', 'icon', 'ask',
31
+ 'promptHints', 'requiresSkills', 'humanEffort'];
32
+
33
+ /**
34
+ * GATE 1 for a sidecar's ask forms (ask-forms spec §5, §11). The SAME checks the
35
+ * registry runs at load and `worca plugin validate` runs on a plugin dir — one
36
+ * validator, three callers. Returns [] when the sidecar declares no forms.
37
+ * Every failed rule is named: the editor marks fields by `path`, so a single
38
+ * "invalid form" sentence would be unusable there.
39
+ * @param {object} raw a raw sidecar (pre-normalizeMeta)
40
+ * @returns {Array<{path: string, code: string, message: string}>}
41
+ */
42
+ function askFormIssues(raw) {
43
+ if (!raw || raw.ask === undefined || raw.ask === null) return [];
44
+ // normalizeAskBlock is used ONLY for the whole-block refusals it alone knows
45
+ // (id '*': not `{ forms }`, or over the 64 KB cap). Its per-form `reason` is a
46
+ // collapsed one-liner of the first three errors — right for a registry log
47
+ // line, wrong for an editor that marks fields BY PATH — so each form goes
48
+ // through validateFormDef directly and keeps P1's paths verbatim
49
+ // (`layout#<n>`, a depth-first 1-based ordinal).
50
+ const blockDrops = normalizeAskBlock(raw.ask).dropped.filter((d) => d.id === '*');
51
+ if (blockDrops.length) return blockDrops.map((d) => ({ path: 'ask', code: 'dialect', message: d.reason }));
52
+ const issues = [];
53
+ const forms = raw.ask.forms && typeof raw.ask.forms === 'object' ? raw.ask.forms : {};
54
+ const ids = Object.keys(forms);
55
+ if (ids.length > ASK_LIMITS.formsPerAgent) {
56
+ issues.push({ path: 'ask.forms', code: 'too-many', message: `at most ${ASK_LIMITS.formsPerAgent} forms per agent` });
57
+ }
58
+ for (const id of ids) {
59
+ for (const e of validateFormDef(forms[id], { id }).errors) {
60
+ issues.push({ path: `ask.forms."${id}"${e.path ? ` ${e.path}` : ''}`, code: e.code, message: e.message });
61
+ }
62
+ }
63
+ return issues;
64
+ }
65
+
66
+ /** The ONE throw shape both write paths use for a failed gate 1: a joined message
67
+ * for CLI/log surfaces, plus the structured list the route turns into a 422 body. */
68
+ function askFormError(issues) {
69
+ return Object.assign(new Error(issues.map((i) => `${i.path}: ${i.message}`).join('; ')),
70
+ { code: 'ASK_FORM', errors: issues });
71
+ }
29
72
 
30
73
  /** The writable user layer dir. userAgentsDir() returns null only when the home
31
74
  * cannot be resolved (no WORCA_HOME under node:test) — surface that as a 400. */
@@ -76,6 +119,10 @@ export async function createAgent({ meta: rawMeta, markdown } = {}) {
76
119
  // wrote — or rejected with "invalid agent metadata". Every failed rule is named.
77
120
  const issues = validateMetaV2(raw).errors;
78
121
  if (issues.length) throw err(issues.join('; '), 'BAD_REQUEST');
122
+ // Gate 1 runs AFTER the meta gate and BEFORE normalizeMeta: normalizeMeta is
123
+ // lossy by design, so a form must be judged as the author wrote it.
124
+ const formIssues = askFormIssues(raw);
125
+ if (formIssues.length) throw askFormError(formIssues);
79
126
  const meta = normalizeMeta(raw);
80
127
  if (!meta) throw err('invalid agent metadata', 'BAD_REQUEST');
81
128
  const existing = loadAgentRegistry()[key];
@@ -241,6 +288,8 @@ export async function updateAgent(key, { meta: rawMeta, markdown } = {}) {
241
288
  // The same gate the create path applies: every failed rule named, nothing written.
242
289
  const updIssues = validateMetaV2(raw).errors;
243
290
  if (updIssues.length) throw err(updIssues.join('; '), 'BAD_REQUEST');
291
+ const updFormIssues = askFormIssues(raw);
292
+ if (updFormIssues.length) throw askFormError(updFormIssues);
244
293
  const meta = normalizeMeta(raw);
245
294
  if (!meta) throw err('invalid agent metadata', 'BAD_REQUEST');
246
295
  const dir = requireUserDir();
@@ -19,6 +19,7 @@ import { branchExists, diffShortstat, hasGh, findPrForBranch } from './git-info.
19
19
  import { getDb, tx } from './db.mjs';
20
20
  import { RUN_LOG_FILE } from './run-log.mjs';
21
21
  import { readRunLedger } from './metrics/ledger.mjs';
22
+ import { readPolicyState } from './policy/state.mjs';
22
23
  import { memoryTotals } from './memory-sync.mjs';
23
24
 
24
25
  // ── DB row <-> state object mapping (Phase 3) ──────────────────────────────────
@@ -249,6 +250,36 @@ export async function writeStepQuestions(pipelineId, stepKey, round, { agentKey,
249
250
  }
250
251
  }
251
252
 
253
+ /**
254
+ * Spec §9: a form ask persists into the SAME schemaless JSON TEXT columns as the
255
+ * legacy Q&A — the `questions` half holds the full resolved ask, the `answers`
256
+ * half `{kind:'form', form, version, values}`. This merges the two into the one
257
+ * object History renders, or null for a legacy row. The legacy arrays are read
258
+ * separately and stay empty for a form row, so no existing reader changes.
259
+ * @param {object|null} qWrap parsed `questions` column
260
+ * @param {object|null} aWrap parsed `answers` column
261
+ */
262
+ function formAskOf(qWrap, aWrap) {
263
+ if (!qWrap || qWrap.kind !== 'form') return null;
264
+ const values = aWrap && aWrap.kind === 'form' && aWrap.values && typeof aWrap.values === 'object'
265
+ ? aWrap.values
266
+ : null;
267
+ return { ...qWrap, values };
268
+ }
269
+
270
+ /** The two form fields a reader row carries — `{}` for a legacy row, so no legacy
271
+ * payload gains a key (test/step-questions-db.test.mjs pins the exact row shape). */
272
+ function formFieldsOf(qWrap, aWrap) {
273
+ const ask = formAskOf(qWrap, aWrap);
274
+ return ask ? { ask, formAnswer: formAnswerOf(aWrap) } : {};
275
+ }
276
+
277
+ /** The `{form, version, values}` payload the agent was resumed with, or null. */
278
+ function formAnswerOf(aWrap) {
279
+ if (!aWrap || aWrap.kind !== 'form') return null;
280
+ return { form: aWrap.form, version: aWrap.version, values: aWrap.values && typeof aWrap.values === 'object' ? aWrap.values : {} };
281
+ }
282
+
252
283
  /**
253
284
  * All ask-then-resume rounds of a pipeline, unwrapped to plain arrays, in
254
285
  * chronological insert order (rowid — lexicographic step_key would mis-order
@@ -270,6 +301,10 @@ export function readStepQuestions(pipelineId) {
270
301
  agentKey: r.agent_key || '',
271
302
  questions: Array.isArray(qWrap?.questions) ? qWrap.questions : [],
272
303
  answers: Array.isArray(aWrap?.answers) ? aWrap.answers : [],
304
+ // Ask forms (spec §9): `ask` + `formAnswer` on a FORM row only. A legacy row
305
+ // gains no key at all, so its wire shape (History, get_run_progress) stays
306
+ // byte-identical; consumers test `row.ask`, never `'ask' in row`.
307
+ ...formFieldsOf(qWrap, aWrap),
273
308
  };
274
309
  });
275
310
  }
@@ -348,6 +383,7 @@ export function readPipelineExtras(pipelineId) {
348
383
  const clarify = {
349
384
  questions: Array.isArray(qWrap?.questions) ? qWrap.questions : [],
350
385
  answers: Array.isArray(aWrap?.answers) ? aWrap.answers : [],
386
+ ...formFieldsOf(qWrap, aWrap), // spec §9: `ask` + `formAnswer` on a form row only
351
387
  };
352
388
  const reviews = getDb().prepare(
353
389
  'SELECT kind, cycle, verdict FROM reviews WHERE pipeline_id = ? ORDER BY kind, cycle'
@@ -1128,15 +1164,16 @@ export async function writeState(pipelineDir, stateObj) {
1128
1164
  INSERT INTO pipelines (id, project_key, workspace_key, target, title, base_name,
1129
1165
  date_prefix, status, phase, cycle, started_at, updated_at, total_cost_usd,
1130
1166
  total_active_ms, prompt, branch, workspace_meta, stepper, tools, resume_point,
1131
- source_type, source_ref, guardrails_id, outcome)
1167
+ source_type, source_ref, guardrails_id, outcome, human_hours)
1132
1168
  VALUES (@id,@project_key,@workspace_key,@target,@title,@base_name,@date_prefix,
1133
1169
  @status,@phase,@cycle,@started_at,@updated_at,@total_cost_usd,@total_active_ms,
1134
1170
  @prompt,@branch,@workspace_meta,@stepper,@tools,@resume_point,
1135
- @source_type,@source_ref,@guardrails_id,@outcome)
1171
+ @source_type,@source_ref,@guardrails_id,@outcome,@human_hours)
1136
1172
  ON CONFLICT(id) DO UPDATE SET
1137
1173
  status=excluded.status, phase=excluded.phase, cycle=excluded.cycle,
1138
1174
  updated_at=excluded.updated_at, total_cost_usd=excluded.total_cost_usd,
1139
- total_active_ms=excluded.total_active_ms, branch=excluded.branch,
1175
+ total_active_ms=excluded.total_active_ms, human_hours=excluded.human_hours,
1176
+ branch=excluded.branch,
1140
1177
  workspace_meta=excluded.workspace_meta, stepper=excluded.stepper,
1141
1178
  tools=excluded.tools,
1142
1179
  resume_point=excluded.resume_point,
@@ -1150,16 +1187,22 @@ export async function writeState(pipelineDir, stateObj) {
1150
1187
  INSERT INTO pipeline_steps (pipeline_id, key, node_id, phase, step_index, cycle,
1151
1188
  status, started_at, updated_at, active_ms, running_since, cost_usd, session_id,
1152
1189
  skills, graphify_count,
1153
- execution_id, exec_kind, agent_key, ended_at, exec_trigger, exec_result, exec_meta)
1154
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
1190
+ execution_id, exec_kind, agent_key, ended_at, exec_trigger, exec_result, exec_meta,
1191
+ human_hours, human_signals)
1192
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
1155
1193
  `);
1156
1194
  for (const st of Array.isArray(obj.steps) ? obj.steps : []) {
1157
1195
  // v2 rows: execution_id === key. v1 rows leave every exec_* column NULL, so
1158
1196
  // the readers below reproduce today's exact shape for a v1 pipeline.
1159
- const meta = (st.taskId != null || st.parentExecutionId != null || st.title != null || st.phaseOrdinal != null)
1197
+ const hasMeta = st.taskId != null || st.parentExecutionId != null || st.title != null || st.phaseOrdinal != null
1198
+ || st.nodeKey != null || st.runtime != null || st.exitCode != null || st.bridgeCalls != null;
1199
+ const meta = hasMeta
1160
1200
  ? s({ taskId: st.taskId ?? null, parentExecutionId: st.parentExecutionId ?? null,
1161
1201
  title: st.title ?? null, phaseOrdinal: st.phaseOrdinal ?? null,
1162
- taskIndex: st.taskIndex ?? null, taskTotal: st.taskTotal ?? null })
1202
+ taskIndex: st.taskIndex ?? null, taskTotal: st.taskTotal ?? null,
1203
+ nodeKey: st.nodeKey ?? null, runtime: st.runtime ?? null, exitCode: st.exitCode ?? null,
1204
+ // Model bridge (§8.6): requests the node initiated through the bridge.
1205
+ ...(st.bridgeCalls != null ? { bridgeCalls: st.bridgeCalls, bridgeContinued: st.bridgeContinued ?? 0 } : {}) })
1163
1206
  : null;
1164
1207
  ins.run(
1165
1208
  id, st.key, st.nodeId ?? null, st.phase ?? null,
@@ -1178,6 +1221,8 @@ export async function writeState(pipelineDir, stateObj) {
1178
1221
  st.trigger === undefined ? null : s(st.trigger),
1179
1222
  st.result === undefined ? null : s(st.result),
1180
1223
  meta,
1224
+ Number.isFinite(st.humanHours) ? st.humanHours : null,
1225
+ st.humanSignals && typeof st.humanSignals === 'object' ? s(st.humanSignals) : null,
1181
1226
  );
1182
1227
  }
1183
1228
  });
@@ -1416,10 +1461,13 @@ export function reconcileStaleRunning({
1416
1461
  .map((r) => r.id);
1417
1462
  if (candidates.length === 0) return { reconciled: 0, ids: [] };
1418
1463
 
1419
- // Status-guarded UPDATE. Also NULLs owner columns so reclassified rows are clean.
1464
+ // Status-guarded UPDATE. Also NULLs the owner columns so reclassified rows are clean.
1465
+ // heartbeat_at STAYS: it is the last time the dead process was seen alive, and resume()
1466
+ // measures the interrupted run's parked time (and closes its step clocks) from it. Nothing
1467
+ // reads it on a non-running row otherwise; claimPipelineOwnership re-stamps it on resume.
1420
1468
  return tx(() => {
1421
1469
  const upd = getDb().prepare(
1422
- `UPDATE pipelines SET status = ?, owner_pid = NULL, owner_host = NULL, heartbeat_at = NULL
1470
+ `UPDATE pipelines SET status = ?, owner_pid = NULL, owner_host = NULL
1423
1471
  WHERE id = ? AND status IN (${placeholders})`);
1424
1472
  const flipped = [];
1425
1473
  for (const id of candidates) {
@@ -1494,6 +1542,7 @@ function toPipelineRow(o) {
1494
1542
  updated_at: o.updatedAt ?? null,
1495
1543
  total_cost_usd: Number.isFinite(o.totalCostUsd) ? o.totalCostUsd : 0,
1496
1544
  total_active_ms: Number.isFinite(o.totalActiveMs) ? o.totalActiveMs : 0,
1545
+ human_hours: Number.isFinite(o.humanHours) ? o.humanHours : 0,
1497
1546
  prompt: o.prompt ?? null,
1498
1547
  branch: s(o.branch),
1499
1548
  workspace_meta: workspaceMeta,
@@ -1841,6 +1890,8 @@ function stepRowToStep(r) {
1841
1890
  if (r.execution_id != null && r.cycle != null) step.ordinal = r.cycle; // `ordinal` is the v2 name; `cycle` is its alias
1842
1891
  if (r.exec_trigger != null) step.trigger = j(r.exec_trigger, { wireIds: [], freshPorts: [] });
1843
1892
  if (r.exec_result != null) step.result = j(r.exec_result, null);
1893
+ if (r.human_hours != null) step.humanHours = r.human_hours;
1894
+ if (r.human_signals != null) step.humanSignals = j(r.human_signals, null);
1844
1895
  const em = r.exec_meta != null ? j(r.exec_meta, null) : null;
1845
1896
  if (em) {
1846
1897
  if (em.taskId != null) step.taskId = em.taskId;
@@ -1849,6 +1900,10 @@ function stepRowToStep(r) {
1849
1900
  if (em.phaseOrdinal != null) step.phaseOrdinal = em.phaseOrdinal;
1850
1901
  if (em.taskIndex != null) step.taskIndex = em.taskIndex;
1851
1902
  if (em.taskTotal != null) step.taskTotal = em.taskTotal;
1903
+ if (em.nodeKey != null) step.nodeKey = em.nodeKey;
1904
+ if (em.runtime != null) step.runtime = em.runtime;
1905
+ if (em.exitCode != null) step.exitCode = em.exitCode;
1906
+ if (em.bridgeCalls != null) { step.bridgeCalls = em.bridgeCalls; step.bridgeContinued = em.bridgeContinued ?? 0; }
1852
1907
  }
1853
1908
  return step;
1854
1909
  }
@@ -1875,6 +1930,7 @@ function rowToState(row) {
1875
1930
  updatedAt: row.updated_at ?? null,
1876
1931
  totalCostUsd: row.total_cost_usd ?? 0,
1877
1932
  totalActiveMs: row.total_active_ms ?? 0,
1933
+ humanHours: Number(row.human_hours || 0),
1878
1934
  prompt: row.prompt ?? null,
1879
1935
  baseName: row.base_name ?? null,
1880
1936
  datePrefix: row.date_prefix ?? null,
@@ -1882,13 +1938,17 @@ function rowToState(row) {
1882
1938
  stepper: j(row.stepper, null),
1883
1939
  tools: j(row.tools, null),
1884
1940
  guardrailsId: row.guardrails_id ?? null,
1941
+ // v31 provenance: set when a schedule started this run (NULL = started by hand).
1942
+ scheduledFor: row.scheduled_for ?? null,
1943
+ scheduleId: row.schedule_id ?? null,
1885
1944
  // A retired v1 resume point was NULLed by the v2 upgrade: the run stays in
1886
1945
  // History with an honest status, but it can never be resumed again.
1887
1946
  resumable: row.resume_point != null,
1888
1947
  steps: getDb().prepare(`
1889
1948
  SELECT key, node_id, phase, step_index, cycle, status, started_at, updated_at,
1890
1949
  active_ms, running_since, cost_usd, session_id, skills, graphify_count,
1891
- execution_id, exec_kind, agent_key, ended_at, exec_trigger, exec_result, exec_meta
1950
+ execution_id, exec_kind, agent_key, ended_at, exec_trigger, exec_result, exec_meta,
1951
+ human_hours, human_signals
1892
1952
  FROM pipeline_steps WHERE pipeline_id = ? ORDER BY rowid
1893
1953
  `).all(row.id).map(stepRowToStep),
1894
1954
  subAgents: listSubAgents(row.id),
@@ -2161,6 +2221,9 @@ export async function readPipelineByKey(key, id) {
2161
2221
  results,
2162
2222
  overview,
2163
2223
  teamMetrics: readRunLedger(row.id),
2224
+ // Team policy (design §10 / board 10): the home the run's policy came from and what the
2225
+ // developer did about it — the History meta line's "policy · 1 override" segment.
2226
+ policy: readPolicyState(row.id),
2164
2227
  memory: await readMemoryLedger(dir),
2165
2228
  ...readPipelineExtras(row.id),
2166
2229
  };