@worca/app 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -33,6 +33,7 @@ import {
33
33
  } from './worktree.mjs';
34
34
  import { runWorkspaceScan } from './phases.mjs';
35
35
  import { fanoutCap, mapWithCap } from './fanout.mjs';
36
+ import { modelHasBaseUrlRouting } from './config.mjs';
36
37
 
37
38
  // The scanning agent's name in the prompt-role/registry sense (drives the .md body
38
39
  // it loads; the MOCK_ROLE marker differs — workspace-scan — and is set inside
@@ -298,6 +299,9 @@ class WorkspaceScan extends EventEmitter {
298
299
  // ctxFanOut(ctx) -> ctx.fanOut (no node) -> grants Task/Agent so the agent
299
300
  // can dispatch investigators (scan-fanout).
300
301
  fanOut: true,
302
+ // No node on this ctx: ctxEndpointRouted reads the ctx-level flag, so the
303
+ // scanner's fan-out block degrades the same way a routed node's does.
304
+ endpointRouted: modelHasBaseUrlRouting(this.claude.model),
301
305
  claudeOpts: {
302
306
  permissionMode: this.claude.permissionMode || 'acceptEdits',
303
307
  model: this.claude.model,
@@ -49,12 +49,17 @@ const BRANCH_STOPWORDS = new Set([
49
49
  // 30 s on large repos — give them a longer leash before the SIGKILL deadline.
50
50
  const SLOW_GIT_TIMEOUT_MS = 120_000;
51
51
 
52
- /** Run git and resolve to { ok, stdout, stderr, code }. Never throws. */
53
- function git(cwd, args, { signal, timeout = 30_000 } = {}) {
52
+ /** Run git and resolve to { ok, stdout, stderr, code }. Never throws.
53
+ * `env` is MERGED OVER process.env (never a replacement): the git binary and the
54
+ * credential helper need PATH/HOME. Only the Ask Worca callers pass it. */
55
+ function git(cwd, args, { signal, timeout = 30_000, env, maxBytes = 0 } = {}) {
54
56
  return new Promise((res) => {
55
57
  let child;
56
58
  try {
57
- child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], signal });
59
+ child = spawn('git', args, {
60
+ cwd, stdio: ['ignore', 'pipe', 'pipe'], signal,
61
+ ...(env ? { env: { ...process.env, ...env } } : {}),
62
+ });
58
63
  } catch (err) {
59
64
  res({ ok: false, stdout: '', stderr: err.message, code: -1 });
60
65
  return;
@@ -73,7 +78,24 @@ function git(cwd, args, { signal, timeout = 30_000 } = {}) {
73
78
  done({ ok: false, stdout, stderr: stderr ? `git timed out: ${stderr}` : 'git timed out', code: -1 });
74
79
  }, timeout)
75
80
  : null;
76
- child.stdout?.on('data', (b) => (stdout += b.toString()));
81
+ // `maxBytes` caps the CAPTURE (review of PR #376): the Ask `git` tool re-runs a
82
+ // command per `offset` page, so `log -p --all` on a big repo accumulated the
83
+ // whole history in memory every page. Past the cap the child is killed and
84
+ // the result is `ok` with `truncated: true` — the caller says so and pages
85
+ // within what it got.
86
+ let captured = 0;
87
+ let truncated = false;
88
+ child.stdout?.on('data', (b) => {
89
+ if (truncated) return;
90
+ captured += b.length;
91
+ stdout += b.toString();
92
+ if (maxBytes > 0 && captured > maxBytes) {
93
+ truncated = true;
94
+ stdout = Buffer.from(stdout, 'utf8').subarray(0, maxBytes).toString('utf8');
95
+ try { child.kill('SIGKILL'); } catch { /* gone */ }
96
+ done({ ok: true, stdout, stderr, code: 0, truncated: true });
97
+ }
98
+ });
77
99
  child.stderr?.on('data', (b) => (stderr += b.toString()));
78
100
  child.on('error', (err) => done({ ok: false, stdout, stderr: stderr || err.message, code: -1 }));
79
101
  child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code: code ?? -1 }));
@@ -185,7 +207,9 @@ export async function worktreePathForBranch(projectDir, branch) {
185
207
  if (!r.ok) return null;
186
208
  let curPath = null;
187
209
  for (const line of r.stdout.split(/\r?\n/)) {
188
- if (line.startsWith('worktree ')) curPath = line.slice('worktree '.length).trim();
210
+ // git prints '/'-separated paths even on Windows; resolve() gives callers the
211
+ // native form they compare against (createWorktree's own worktreeDir).
212
+ if (line.startsWith('worktree ')) curPath = resolve(line.slice('worktree '.length).trim());
189
213
  else if (line.startsWith('branch ')) {
190
214
  const ref = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
191
215
  if (ref === branch) return curPath;
@@ -302,8 +326,16 @@ export async function removeWorktree({ projectDir, worktreeDir, branch, force =
302
326
  : ['worktree', 'remove', worktreeDir];
303
327
  const r = await git(projectDir, args, { timeout: SLOW_GIT_TIMEOUT_MS });
304
328
  steps.push({ step: 'worktree-remove', ok: r.ok, stderr: r.stderr.trim() });
305
- if (force) {
306
- const fsRes = await rm(worktreeDir, { recursive: true, force: true })
329
+ // fs backstop whenever the checkout survives the git remove — not only on
330
+ // force. Git for Windows routinely leaves the directory behind (a read-only
331
+ // packed object, or a file a scanner/just-exited git still holds → EBUSY),
332
+ // which left detached run roots and worktrees on disk. Retry to ride out the
333
+ // transient lock; a no-op on POSIX where git already emptied the dir.
334
+ // ...but only when git REPORTED success and still left the dir behind (the
335
+ // Windows leftover). A non-force refusal on a dirty worktree (r.ok === false)
336
+ // must keep the checkout — deleting it would discard uncommitted agent work.
337
+ if (force || (r.ok && existsSync(worktreeDir))) {
338
+ const fsRes = await rm(worktreeDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
307
339
  .then(() => null)
308
340
  .catch((e) => e.message);
309
341
  if (fsRes) steps.push({ step: 'rm-dir', ok: false, stderr: fsRes });
@@ -318,6 +350,65 @@ export async function removeWorktree({ projectDir, worktreeDir, branch, force =
318
350
  return { ok: steps.every((s) => s.ok), steps };
319
351
  }
320
352
 
353
+ /**
354
+ * Create a DETACHED worktree at `worktreeDir` checking out `ref` — the Ask
355
+ * Worca inspection checkout (ask-worca-worktrees-design.md §3). Detached by
356
+ * construction: no branch is created, locked or deleted, so the pipeline
357
+ * branch-in-use check (M2) never sees these. The caller (ask/worktrees.mjs)
358
+ * owns path construction and containment; this validates only the ref (M1
359
+ * doctrine: reject option-injection before git parses argv) and throws on a
360
+ * fatal git failure with the same AbortError stamp createWorktree uses.
361
+ */
362
+ export async function createDetachedWorktree({ projectDir, worktreeDir, ref, signal } = {}) {
363
+ if (!projectDir) throw new Error('projectDir required');
364
+ if (!worktreeDir) throw new Error('worktreeDir required');
365
+ if (!(await isValidSourceRef(projectDir, ref))) {
366
+ throw new Error(`ref is not a valid commit-ish: ${JSON.stringify(ref ?? null)}`);
367
+ }
368
+ await git(projectDir, ['worktree', 'prune']);
369
+ const r = await git(projectDir, ['worktree', 'add', '--detach', '--', worktreeDir, ref],
370
+ { signal, timeout: SLOW_GIT_TIMEOUT_MS });
371
+ if (!r.ok) {
372
+ const err = new Error(`git worktree add --detach failed: ${r.stderr.trim() || `exit ${r.code}`}`);
373
+ if (signal?.aborted) err.name = 'AbortError';
374
+ throw err;
375
+ }
376
+ const head = await git(worktreeDir, ['rev-parse', 'HEAD']);
377
+ return { worktreeDir, commit: head.ok ? head.stdout.trim() : null };
378
+ }
379
+
380
+ /** HEAD commit sha of any checkout (worktree or repo), or null. */
381
+ export async function worktreeHead(dir) {
382
+ const r = await git(dir, ['rev-parse', 'HEAD']);
383
+ return r.ok ? r.stdout.trim() : null;
384
+ }
385
+
386
+ /**
387
+ * Env for every Ask Worca git spawn (spec §8). Pager OFF and — load-bearing —
388
+ * NEVER prompt: an https/ssh fetch with no cached credential would otherwise
389
+ * block on a terminal/askpass prompt until the SLOW_GIT_TIMEOUT_MS SIGKILL,
390
+ * holding the MCP call open for two minutes. GIT_ASKPASS/SSH_ASKPASS='' stop the
391
+ * GUI askpass fallback too. NOTE we deliberately do NOT set GIT_CONFIG_GLOBAL:
392
+ * the keychain credential helper for https fetch lives in the user's global
393
+ * config (E3), so nuking it would break authenticated fetch; the external-diff
394
+ * exec vector is closed per-invocation in the tool handler with
395
+ * `-c diff.external=` + `--no-ext-diff` instead.
396
+ */
397
+ export const ASK_GIT_ENV = Object.freeze({
398
+ GIT_PAGER: 'cat', PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '',
399
+ });
400
+
401
+ /**
402
+ * Raw capture runner for the Ask Worca `git` MCP tool. The ONLY gate between a
403
+ * model-authored argv and this spawn is ask/git-allowlist.mjs — callers pass
404
+ * exclusively its validated output. Spawn semantics identical to every other
405
+ * helper here ({ok, stdout, stderr, code}, never throws), plus the ASK_GIT_ENV
406
+ * hardening and the caller's abort signal.
407
+ */
408
+ export function runGitCapture(cwd, args, { signal, timeoutMs = SLOW_GIT_TIMEOUT_MS, maxBytes = 0 } = {}) {
409
+ return git(cwd, args, { signal, timeout: timeoutMs, env: ASK_GIT_ENV, maxBytes });
410
+ }
411
+
321
412
  /**
322
413
  * Stage every remaining change and render a binary-capable patch against HEAD
323
414
  * DIRECTLY INTO outFile (no in-memory patch string — agent-created artifacts can
@@ -0,0 +1,278 @@
1
+ // src/shared/graph/agent-meta.mjs
2
+ // Agent metadata v2: ONE normalizer + validator for the registry loader (skip +
3
+ // warn), the agent store (hard 400), the Agents-view port editor (live hints)
4
+ // and agent-gen's read-back check. Pure — shared code cannot import
5
+ // claude-runner.mjs, so the mock-role vocabulary is INJECTED.
6
+ import { PORT_TYPES, MAX_PORTS_PER_SIDE, PORT_ID_RE } from './constants.mjs';
7
+
8
+ const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
9
+ // PORT_ID_RE is P1's, NOT a local copy: spec §3 puts it in constants.mjs
10
+ // precisely so the store's 400, P1's seed guard and the composer's editor hint
11
+ // can never disagree. It is the STRICT lowerCamel form `/^[a-z][A-Za-z0-9]{0,31}$/`
12
+ // — no `_`, no `-` (see the Q&A; `in_1` and `in-1` are INVALID port ids).
13
+ /** The engine synthesizes a type-agnostic `await` gate on every agent node, so
14
+ * the id is RESERVED on BOTH sides and no sidecar may declare it. */
15
+ const RESERVED_PORT_ID = 'await';
16
+ const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
17
+ const COLORS = new Set(['green', 'peach', 'red', 'blue', 'violet', 'amber']);
18
+ const RUNNER_TYPES = new Set(['producer', 'verifier', 'clarifier']);
19
+ const INPUT_AS = new Set(['file', 'answers', 'fix-review', 'worktree']);
20
+ /** The port type each non-default `as` renderer requires. `file` is the default
21
+ * and is materialized on NON-VOID inputs only, which makes `worktree` the only
22
+ * `as` a void input takes. */
23
+ const AS_REQUIRES_TYPE = { answers: 'json', 'fix-review': 'md', worktree: 'void' };
24
+ const OUTPUT_WHEN = new Set(['always', 'blocking', 'clean']);
25
+ const OUTPUT_STORES = new Set(['run', 'project']);
26
+ const WORKSPACE_STRATEGIES = new Set(['explore', 'task', 'review']);
27
+ const FILENAME_TOKENS = new Set(['cycle', 'vsuffix', 'base']);
28
+ /** Sort key a sidecar that omits `order` gets. Exported so the registry loader
29
+ * (agent-registry.mjs normalizeMeta) backfills the SAME value this normalizer
30
+ * does — a sidecar validateMetaV2 certifies must never vanish from the registry. */
31
+ export const DEFAULT_ORDER = 999;
32
+ const TYPES = [...PORT_TYPES].filter((t) => t !== 'any'); // `any` is engine-only, never declarable
33
+
34
+ /** `{key: meta}` from a registry LIST (the /api/agents payload shape). */
35
+ export function indexByKey(list) {
36
+ const out = {};
37
+ for (const m of Array.isArray(list) ? list : []) {
38
+ if (m && typeof m === 'object' && typeof m.key === 'string' && m.key) out[m.key] = m;
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** The palette one-liner and the generic system-prompt fallback: the NON-VOID
44
+ * ids per side; an all-void side falls back to its declared ids so the sentence
45
+ * never degenerates to "produces .". */
46
+ export function derivePortSummary(meta) {
47
+ const ids = (ports) => {
48
+ const list = Array.isArray(ports) ? ports.filter(Boolean) : [];
49
+ const nonVoid = list.filter((p) => p.type !== 'void');
50
+ return (nonVoid.length ? nonVoid : list).map((p) => p.id);
51
+ };
52
+ const reads = ids(meta?.inputs);
53
+ const writes = ids(meta?.outputs);
54
+ if (!writes.length) return reads.length ? `Reads ${reads.join(', ')}.` : '';
55
+ if (!reads.length) return `Produces ${writes.join(', ')}.`;
56
+ return `Reads ${reads.join(', ')}; produces ${writes.join(', ')}.`;
57
+ }
58
+
59
+ /** Pure validation for the store's 400 path. Silent by design — the load path warns. */
60
+ export function validateMetaV2(raw, opts = {}) {
61
+ return { errors: normalizeAgentMeta(raw, { ...opts, warn: () => {} }).errors };
62
+ }
63
+
64
+ /**
65
+ * @param {object} raw parsed sidecar
66
+ * @param {{mockWriterRoles?:Set<string>, warn?:(msg:string)=>void}} [opts]
67
+ * @returns {{meta:object|null, errors:string[]}} meta is meaningful only when errors is empty
68
+ */
69
+ export function normalizeAgentMeta(raw, opts = {}) {
70
+ const errors = [];
71
+ const err = (msg) => errors.push(msg);
72
+ const warn = typeof opts.warn === 'function' ? opts.warn : () => {};
73
+ if (!raw || typeof raw !== 'object') return { errors: ['meta must be an object'], meta: null };
74
+
75
+ const key = typeof raw.key === 'string' ? raw.key.trim() : '';
76
+ if (!key) err('key is required');
77
+ else if (!AGENT_KEY_RE.test(key)) err(`key "${key}" is not a valid agent key`);
78
+ if (raw.metaVersion !== 2) err('sidecar requires metaVersion 2');
79
+ if (!RUNNER_TYPES.has(raw.runnerType)) err(`runnerType must be one of ${[...RUNNER_TYPES].join(', ')}`);
80
+ const runnerType = RUNNER_TYPES.has(raw.runnerType) ? raw.runnerType : 'producer';
81
+
82
+ const order = raw.order === undefined ? DEFAULT_ORDER : Number(raw.order);
83
+ if (!Number.isFinite(order)) err('order must be a number');
84
+
85
+ // agentFile is a PATH: the registry joins it onto the layer's agents dir and
86
+ // reads it as the agent's system prompt AND for its `tools:` frontmatter, so
87
+ // it takes the SAME basename rule as verdict.filename / outputs[].filename.
88
+ // A non-<key>.md basename stays legal — every built-in uses worca-cc-<role>.md.
89
+ const agentFile = typeof raw.agentFile === 'string' && raw.agentFile.trim() ? raw.agentFile.trim() : null;
90
+ if (agentFile && (/[\\/]/.test(agentFile) || agentFile.includes('..'))) {
91
+ err(`agentFile "${agentFile}" must be a plain basename`);
92
+ }
93
+
94
+ const verdict = readVerdict(raw.verdict, err);
95
+ if (runnerType === 'verifier' && !verdict) err('runnerType "verifier" requires verdict: { filename }');
96
+
97
+ const inputs = readInputs(raw.inputs, err, warn);
98
+ const outputs = readOutputs(raw.outputs, !!verdict, err);
99
+ if (runnerType === 'clarifier' && !outputs.some((p) => p.type === 'json')) {
100
+ err('runnerType "clarifier" requires at least one json output port');
101
+ }
102
+ // Two outputs may share one filename template only with an identical type (the
103
+ // refiner's clean/blocking plan arms); allocation then yields ONE path.
104
+ const typeByTemplate = new Map();
105
+ for (const p of outputs) {
106
+ if (!p.filename) continue;
107
+ const prev = typeByTemplate.get(p.filename);
108
+ if (prev === undefined) typeByTemplate.set(p.filename, p.type);
109
+ else if (prev !== p.type) err(`outputs: filename template "${p.filename}" is shared by ports of different types`);
110
+ }
111
+
112
+ // Scope coercion mirrors color: anything but the explicit 'workspace-only'
113
+ // marker is a normal project agent, so a typo fails safe to a VISIBLE agent.
114
+ const scope = raw.scope === 'workspace-only' ? 'workspace-only' : 'project';
115
+ if (raw.sideEffect !== undefined && raw.sideEffect !== 'code') err('sideEffect must be "code" when present');
116
+ if (raw.workspaceStrategy !== undefined && !WORKSPACE_STRATEGIES.has(raw.workspaceStrategy)) {
117
+ err(`workspaceStrategy must be one of ${[...WORKSPACE_STRATEGIES].join(', ')}`);
118
+ }
119
+ let workspaceVariantOf = null;
120
+ if (raw.workspaceVariantOf !== undefined) {
121
+ const target = typeof raw.workspaceVariantOf === 'string' ? raw.workspaceVariantOf.trim() : '';
122
+ if (!AGENT_KEY_RE.test(target)) err('workspaceVariantOf must be an agent key');
123
+ else if (target === key) err('workspaceVariantOf must not reference the agent itself');
124
+ else if (scope !== 'workspace-only') err('workspaceVariantOf requires scope "workspace-only"');
125
+ else workspaceVariantOf = target;
126
+ }
127
+ // An unknown mockRole is a WARNING, never a 400: the field is dropped and the
128
+ // generic mock-role fallback chain applies.
129
+ let mockRole = null;
130
+ if (raw.mockRole !== undefined) {
131
+ const role = typeof raw.mockRole === 'string' ? raw.mockRole.trim() : '';
132
+ const vocab = opts.mockWriterRoles instanceof Set ? opts.mockWriterRoles : null;
133
+ if (!vocab || vocab.has(role)) mockRole = role || null;
134
+ else warn(`[agent-registry] ${key || '<unkeyed>'}.mockRole: unknown mock role "${role}"; ignored (the generic mock chain applies)`);
135
+ }
136
+
137
+ const asksQuestions = !!raw.asksQuestions;
138
+ const meta = {
139
+ metaVersion: 2,
140
+ key,
141
+ displayName: typeof raw.displayName === 'string' && raw.displayName.trim() ? raw.displayName.trim() : key,
142
+ description: typeof raw.description === 'string' ? raw.description : '',
143
+ color: COLORS.has(raw.color) ? raw.color : 'amber',
144
+ icon: typeof raw.icon === 'string' ? raw.icon : '',
145
+ agentFile,
146
+ runnerType,
147
+ scope,
148
+ domain: typeof raw.domain === 'string' && DOMAIN_RE.test(raw.domain) ? raw.domain : 'general',
149
+ fanOut: !!raw.fanOut,
150
+ asksQuestions,
151
+ questionsLocked: asksQuestions && !!raw.questionsLocked,
152
+ questionsDefault: asksQuestions && !!raw.questionsDefault,
153
+ order: Number.isFinite(order) ? order : DEFAULT_ORDER,
154
+ promptHints: typeof raw.promptHints === 'string' ? raw.promptHints : '',
155
+ requiresSkills: Array.isArray(raw.requiresSkills)
156
+ ? raw.requiresSkills.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
157
+ : [],
158
+ inputs,
159
+ outputs,
160
+ portSummary: '',
161
+ };
162
+ meta.portSummary = derivePortSummary(meta);
163
+ // Capability defaults are applied at READ time, never written into the entry:
164
+ // an absent field means "the default", so a v2 entry stays diffable against
165
+ // the sidecar that produced it.
166
+ if (verdict) meta.verdict = verdict;
167
+ if (raw.sideEffect === 'code') meta.sideEffect = 'code';
168
+ if (mockRole) meta.mockRole = mockRole;
169
+ if (raw.wantsRequest) meta.wantsRequest = true;
170
+ if (raw.workspaceFanOut) meta.workspaceFanOut = true;
171
+ if (WORKSPACE_STRATEGIES.has(raw.workspaceStrategy)) meta.workspaceStrategy = raw.workspaceStrategy;
172
+ if (workspaceVariantOf) meta.workspaceVariantOf = workspaceVariantOf;
173
+ if (raw.placeable !== undefined && !raw.placeable) meta.placeable = false;
174
+ return { errors, meta };
175
+ }
176
+
177
+ function readVerdict(raw, err) {
178
+ if (raw === undefined) return null;
179
+ const filename = raw && typeof raw === 'object' && typeof raw.filename === 'string' ? raw.filename.trim() : '';
180
+ if (!filename) { err('verdict must be an object with a filename'); return null; }
181
+ if (/[\\/]/.test(filename) || filename.includes('..')) {
182
+ err(`verdict filename "${filename}" must be a plain basename`);
183
+ return null;
184
+ }
185
+ return { filename };
186
+ }
187
+
188
+ function readPortHead(raw, side, seen, err) {
189
+ if (!raw || typeof raw !== 'object') { err(`${side}: each port must be an object`); return null; }
190
+ const id = typeof raw.id === 'string' ? raw.id.trim() : '';
191
+ if (id === RESERVED_PORT_ID) {
192
+ err(`${side}: port id "${RESERVED_PORT_ID}" is reserved — the engine synthesizes the await gate port on every agent node`);
193
+ return null;
194
+ }
195
+ if (!PORT_ID_RE.test(id)) { err(`${side}: bad port id "${id}"`); return null; }
196
+ if (seen.has(id)) { err(`${side}: duplicate port id "${id}"`); return null; }
197
+ seen.add(id);
198
+ if (!TYPES.includes(raw.type)) { err(`${side}.${id}: type must be one of ${TYPES.join(', ')}`); return null; }
199
+ const port = { id, type: raw.type };
200
+ if (typeof raw.label === 'string' && raw.label.trim()) port.label = raw.label.trim();
201
+ if (typeof raw.description === 'string' && raw.description.trim()) port.description = raw.description.trim();
202
+ if (raw.type === 'void' && (raw.filename !== undefined || raw.store !== undefined)) {
203
+ err(`${side}.${id}: void ports carry no filename or store`);
204
+ }
205
+ return port;
206
+ }
207
+
208
+ function readInputs(raw, err, warn) {
209
+ if (!Array.isArray(raw)) { err('inputs must be an array'); return []; }
210
+ if (raw.length > MAX_PORTS_PER_SIDE) err(`inputs: at most ${MAX_PORTS_PER_SIDE} ports per side (got ${raw.length})`);
211
+ const seen = new Set();
212
+ const out = [];
213
+ for (const p of raw) {
214
+ const port = readPortHead(p, 'inputs', seen, err);
215
+ if (!port) continue;
216
+ // A loop receiver is excused from the first-execution barrier, so `loop` and
217
+ // `required` can never both hold. Coerced rather than rejected — but it is
218
+ // authoring intent quietly overruled, hence the warning.
219
+ const loop = !!p.loop;
220
+ let required = p.required === undefined ? true : !!p.required;
221
+ if (loop && required) {
222
+ warn(`[agent-registry] inputs.${port.id}: loop:true forces required:false (a loop receiver is never a barrier)`);
223
+ required = false;
224
+ }
225
+ port.required = required;
226
+ if (loop) port.loop = true;
227
+ if (p.expands) {
228
+ if (port.type !== 'json') err(`inputs.${port.id}: expands is only legal on json inputs`);
229
+ else port.expands = true;
230
+ }
231
+ if (p.as !== undefined) {
232
+ const need = Object.hasOwn(AS_REQUIRES_TYPE, p.as) ? AS_REQUIRES_TYPE[p.as] : null;
233
+ if (!INPUT_AS.has(p.as)) err(`inputs.${port.id}: as must be one of ${[...INPUT_AS].join(', ')}`);
234
+ else if (need ? port.type !== need : port.type === 'void') {
235
+ err(`inputs.${port.id}: as "${p.as}" requires a ${need || 'non-void'} port (got ${port.type})`);
236
+ } else port.as = p.as;
237
+ } else if (port.type !== 'void') port.as = 'file';
238
+ if (typeof p.directive === 'string' && p.directive.trim()) port.directive = p.directive;
239
+ out.push(port);
240
+ }
241
+ return out;
242
+ }
243
+
244
+ function readOutputs(raw, hasVerdict, err) {
245
+ if (!Array.isArray(raw)) { err('outputs must be an array'); return []; }
246
+ if (raw.length === 0) err('at least one output port is required');
247
+ if (raw.length > MAX_PORTS_PER_SIDE) err(`outputs: at most ${MAX_PORTS_PER_SIDE} ports per side (got ${raw.length})`);
248
+ const seen = new Set();
249
+ const out = [];
250
+ for (const p of raw) {
251
+ const port = readPortHead(p, 'outputs', seen, err);
252
+ if (!port) continue;
253
+ const when = p.when === undefined ? 'always' : p.when;
254
+ if (!OUTPUT_WHEN.has(when)) err(`outputs.${port.id}: when must be one of ${[...OUTPUT_WHEN].join(', ')}`);
255
+ else {
256
+ if (when !== 'always' && !hasVerdict) {
257
+ err(`outputs.${port.id}: when "${when}" requires the agent to declare verdict: { filename }`);
258
+ }
259
+ port.when = when;
260
+ }
261
+ if (port.type !== 'void') {
262
+ const filename = typeof p.filename === 'string' ? p.filename.trim() : '';
263
+ const tokens = [...filename.matchAll(/\{([^}]*)\}/g)].map((m) => m[1]).filter((t) => !FILENAME_TOKENS.has(t));
264
+ if (!filename) err(`outputs.${port.id}: ${port.type} outputs require a filename template`);
265
+ else if (/[\\/]/.test(filename) || filename.includes('..')) {
266
+ err(`outputs.${port.id}: filename "${filename}" must be a plain basename`);
267
+ } else if (tokens.length) {
268
+ err(`outputs.${port.id}: filename "${filename}" uses unknown token(s) ${tokens.map((t) => `{${t}}`).join(', ')}`);
269
+ } else port.filename = filename;
270
+ const store = p.store === undefined ? 'run' : p.store;
271
+ if (!OUTPUT_STORES.has(store)) err(`outputs.${port.id}: store must be one of ${[...OUTPUT_STORES].join(', ')}`);
272
+ else port.store = store;
273
+ port.artifactKind = typeof p.artifactKind === 'string' && p.artifactKind.trim() ? p.artifactKind.trim() : port.id;
274
+ }
275
+ out.push(port);
276
+ }
277
+ return out;
278
+ }
@@ -0,0 +1,105 @@
1
+ // src/shared/graph/constants.mjs
2
+ // The frozen vocabulary of the v2 template model: node kinds, port types, the
3
+ // synthesized await gate, the flow cards' port tables, id shapes and limits.
4
+ // Pure data + one pure function — imported unchanged by the engine, the
5
+ // server's 422 path, the tests and the browser (served at /src/shared).
6
+
7
+ /** Templates this model understands. v1 rows carry `version: 1`. */
8
+ export const TEMPLATE_VERSION = 2;
9
+
10
+ /** Every node kind a template may carry (palette order). */
11
+ export const KINDS = Object.freeze(['agent', 'task', 'end', 'and', 'or', 'combine']);
12
+
13
+ /** The flow cards: every kind that is not a spawned agent. They are pure engine
14
+ * executions — instant, $0, no spawn. */
15
+ export const FLOW_KINDS = Object.freeze(['task', 'end', 'and', 'or', 'combine']);
16
+
17
+ /** The flow cards' DISPLAY names — one label per FLOW_KIND. The single copy:
18
+ * the run manifest (`manifest.mjs` cell labels), the run monitor and the
19
+ * New-pipeline loop caption all read this table, so a flow card can never be
20
+ * called 'OR' on one surface and 'n_or' on another. */
21
+ export const FLOW_LABEL = Object.freeze({ task: 'Task', end: 'End', and: 'AND', or: 'OR', combine: 'Combine' });
22
+
23
+ /** Port payload types. 'any' is engine-internal: it lives only on AND inputs,
24
+ * OR ports before resolution, End's `result` and the synthesized `await` gate —
25
+ * never declarable in agent meta. */
26
+ export const PORT_TYPES = Object.freeze(['md', 'json', 'void', 'any']);
27
+
28
+ /** Max ports per side of one card (agent meta inputs/outputs, gate arity). */
29
+ export const MAX_PORTS_PER_SIDE = 8;
30
+
31
+ /** Per-wire loop budget when neither the overlay nor `wire.config` sets one. */
32
+ export const DEFAULT_MAX_CYCLES = 3;
33
+
34
+ /** The universal gate input every agent card gets: synthesized by portsFn and
35
+ * appended LAST to the agent's declared inputs. Never stored in a template and
36
+ * never declared in meta ('await' is a reserved port id on both sides). It
37
+ * accepts a wire from ANY output type and its payload is discarded — pure
38
+ * sequencing: no file, no renderer, no directive, no mode effect. */
39
+ export const AWAIT_PORT = Object.freeze({ id: 'await', type: 'any', required: false, synthetic: true });
40
+
41
+ /** Task card — the graph's single source: zero inputs, one always-firing md
42
+ * output carrying the rendered task md. */
43
+ export const TASK_PORTS = Object.freeze({
44
+ inputs: Object.freeze([]),
45
+ outputs: Object.freeze([Object.freeze({ id: 'task', type: 'md', when: 'always' })]),
46
+ });
47
+
48
+ /** End card — the graph's single sink: one single-wire input, zero outputs. The
49
+ * token arriving on its wire completes the run. */
50
+ export const END_PORTS = Object.freeze({
51
+ inputs: Object.freeze([Object.freeze({ id: 'result', type: 'any', required: true, loop: false, expands: false })]),
52
+ outputs: Object.freeze([]),
53
+ });
54
+
55
+ /**
56
+ * Ports of a gate card at a given arity. AND is the pure synchronizer (`any`
57
+ * ins, ONE static `void` out, fires when all ins are fresh, payloads discarded);
58
+ * OR is the payload-forwarding valve (fires on any fresh in, re-emits that
59
+ * payload — its in/out types RESOLVE FROM WIRING, so `any` here is the
60
+ * pre-resolution placeholder); Combine is the md AND-join.
61
+ * @param {'and'|'or'|'combine'} kind
62
+ * @param {number} arity clamped to [2, MAX_PORTS_PER_SIDE]
63
+ * @returns {{inputs: Array<object>, outputs: Array<object>}}
64
+ */
65
+ export function gatePorts(kind, arity) {
66
+ const n = Math.min(MAX_PORTS_PER_SIDE, Math.max(2, Math.floor(Number(arity)) || 2));
67
+ const inType = kind === 'combine' ? 'md' : 'any';
68
+ const inputs = [];
69
+ for (let i = 1; i <= n; i += 1) {
70
+ inputs.push(Object.freeze({ id: `in${i}`, type: inType, required: true, loop: false, expands: false }));
71
+ }
72
+ const outType = kind === 'and' ? 'void' : kind === 'combine' ? 'md' : 'any';
73
+ return Object.freeze({
74
+ inputs: Object.freeze(inputs),
75
+ outputs: Object.freeze([Object.freeze({ id: 'out', type: outType, when: 'always' })]),
76
+ });
77
+ }
78
+
79
+ /** Minted node ids are `n_` + 8 base36; the seed graphs use readable `n_<word>`
80
+ * ids. Both shapes match. */
81
+ export const NODE_ID_RE = /^n_[a-z0-9]{1,32}$/;
82
+
83
+ /** Minted wire ids are `w_` + 8 base36; the seed graphs use `w1`..`w17`. Both
84
+ * shapes match — the underscore is optional. */
85
+ export const WIRE_ID_RE = /^w_?[a-z0-9]{1,32}$/;
86
+
87
+ /** Port ids are lowerCamel, at most 32 chars (`task`, `revise`, `in1`, `await`). */
88
+ export const PORT_ID_RE = /^[a-z][A-Za-z0-9]{0,31}$/;
89
+
90
+ /** The two ledger rows every run writes for its own bookends (P8 makes them
91
+ * `exec` rows keyed exactly so). Shared by the run monitor and the CLI so
92
+ * neither counts them as executions or progress. */
93
+ export const BOOKEND_EXECUTION_IDS = Object.freeze(['x:preflight:1', 'x:done:1']);
94
+
95
+ /** Structural ceilings the validator enforces (override per call with
96
+ * `validateGraph(tpl, portsFn, { limits })`). */
97
+ export const LIMITS = Object.freeze({
98
+ maxNodes: 80,
99
+ maxWires: 200,
100
+ maxPortsPerSide: MAX_PORTS_PER_SIDE,
101
+ minArity: 2,
102
+ maxArity: MAX_PORTS_PER_SIDE,
103
+ maxCycles: 20,
104
+ maxNameLen: 80,
105
+ });