@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
@@ -0,0 +1,491 @@
1
+ // src/shared/graph/validate.mjs
2
+ // THE authority on v2 graph legality (base spec §2, V1-V21; V22 is RETIRED —
3
+ // subsumed by the restored V7 — and its number stays reserved so V-rule
4
+ // references remain stable). Server save (422), plugin import, the composer's
5
+ // live report, the run-time check and the seed drift guard all come through
6
+ // here, so it is pure, never throws on a malformed template, and every lookup
7
+ // guards: a dangling endpoint or an unknown key is an issue to COLLECT.
8
+ import { KINDS, NODE_ID_RE, LIMITS } from './constants.mjs';
9
+ import { portsOf, findPort, resolveOrOutType } from './ports.mjs';
10
+ import { classifyLoops } from './loops.mjs';
11
+
12
+ const ARITY_SET = new Set(['and', 'or', 'combine']);
13
+ const IN_PORT_RE = /^in\d+$/;
14
+ const WHENS = new Set(['always', 'blocking', 'clean']);
15
+ const AWAIT_PORT_ID = 'await';
16
+
17
+ /** Known `config` keys PER KIND (V17). Without the per-kind split `planStoreSeed`
18
+ * would warn on the task node and an agent-only `awaitAll` would pass silently
19
+ * on a flow card. Unknown keys are PRESERVED and ignored, never stripped. */
20
+ const KNOWN_CONFIG = {
21
+ agent: new Set(['model', 'effort', 'fanOut', 'askQuestions', 'awaitAll', 'subagentModel']),
22
+ task: new Set(['planStoreSeed']),
23
+ and: new Set(['arity']),
24
+ or: new Set(['arity']),
25
+ combine: new Set(['arity']),
26
+ end: new Set(),
27
+ };
28
+ const KNOWN_WIRE_CONFIG = new Set(['maxCycles']);
29
+
30
+ /** The per-node AGENT tunables (the defaults/overrides vocabulary) — the single
31
+ * source for every writer that splits tunables from topology keys
32
+ * (workflows.mjs#setWorkflowNodeDefaults, ui/server.mjs template save). A
33
+ * strict subset of KNOWN_CONFIG.agent: awaitAll is TOPOLOGY, not a tunable. */
34
+ export const AGENT_TUNABLES = ['model', 'effort', 'fanOut', 'askQuestions', 'subagentModel'];
35
+
36
+ const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
37
+
38
+ /**
39
+ * @param {object} tpl v2 template
40
+ * @param {(node:object) => object|undefined} portsFn MUST synthesize the await port
41
+ * @param {{limits?:{maxNodes:number, maxWires:number}}} [opts]
42
+ * @returns {{ok:boolean, errors:Array, warnings:Array}} Issue = {code, message, nodeId?, wireId?, portId?}
43
+ */
44
+ export function validateGraph(tpl, portsFn, opts = {}) {
45
+ const limits = { ...LIMITS, ...(isObject(opts?.limits) ? opts.limits : {}) };
46
+ // The ceilings are enforced HERE, before anything walks the graph. buildContext
47
+ // resolves every node's ports and runs classifyLoops -> condensationTopo, which
48
+ // is O(n^2): on the raw uncapped input a multi-megabyte body blocked the single
49
+ // event loop for tens of seconds and then answered with an N+3-entry error
50
+ // array — more bytes out than in. Returning the V1 issue ALONE keeps both the
51
+ // work and the 422 body O(1). Rule V1 still owns the message (one source), so
52
+ // the in-limit path is byte-for-byte what it always was.
53
+ const over = limitIssue(tpl, limits);
54
+ if (over) return { ok: false, errors: [over], warnings: [] };
55
+ const ctx = buildContext(tpl, portsFn, limits);
56
+ const errors = [];
57
+ const warnings = [];
58
+ for (const rule of RULES) {
59
+ const sink = rule.level === 'W' ? warnings : errors;
60
+ rule.check(ctx, (message, extra = {}) => sink.push({ code: rule.code, message, ...extra }));
61
+ }
62
+ return { ok: errors.length === 0, errors, warnings };
63
+ }
64
+
65
+ /** One line per issue, for logs and CLI output. */
66
+ export function formatIssue(issue) {
67
+ const where = issue?.wireId ? ` (wire ${issue.wireId})` : issue?.nodeId ? ` (node ${issue.nodeId})` : '';
68
+ return `${issue?.code || '?'}: ${issue?.message || ''}${where}`;
69
+ }
70
+
71
+ /** The ONE structural-ceiling check, shared by validateGraph's pre-pass and rule
72
+ * V1. Counts only — it must never touch a node's contents, or it would be the
73
+ * very O(n) walk the pre-pass exists to avoid. Returns the V1 issue, or null
74
+ * when the template is inside both ceilings. At most ONE issue by design: the
75
+ * caller's job is to say "too big", not to enumerate. */
76
+ function limitIssue(template, limits) {
77
+ const n = Array.isArray(template?.nodes) ? template.nodes.length : 0;
78
+ const w = Array.isArray(template?.wires) ? template.wires.length : 0;
79
+ if (n > limits.maxNodes) return { code: 'V1', message: `template has ${n} nodes — the limit is ${limits.maxNodes}` };
80
+ if (w > limits.maxWires) return { code: 'V1', message: `template has ${w} wires — the limit is ${limits.maxWires}` };
81
+ return null;
82
+ }
83
+
84
+ /** Resolve every node's ports ONCE and derive the views the rules read. */
85
+ function buildContext(template, portsFn, limits) {
86
+ const nodes = (Array.isArray(template?.nodes) ? template.nodes : []).filter(isObject);
87
+ const wires = (Array.isArray(template?.wires) ? template.wires : []).filter(isObject);
88
+ const nodeById = new Map();
89
+ // STRING ids only. Index a node under the key `undefined` and a wire with a
90
+ // missing `from`/`to` passes every `nodeById.has(w?.from?.node)` guard, so the
91
+ // next non-optional dereference throws instead of collecting V2/V5.
92
+ for (const n of nodes) if (typeof n.id === 'string' && !nodeById.has(n.id)) nodeById.set(n.id, n);
93
+
94
+ const resolved = new Map();
95
+ for (const n of nodes) if (!resolved.has(n.id)) resolved.set(n.id, portsOf(portsFn, n));
96
+
97
+ const portsFor = (id) => resolved.get(id) || { known: false, ported: false, inputs: [], outputs: [], meta: null };
98
+ // metaOf is null for BOTH V4 cases (unknown key, un-ported sidecar) so no other
99
+ // rule piles onto a node V4 already named.
100
+ const metaOf = (id) => (portsFor(id).ported ? portsFor(id).meta : null);
101
+ const inputsOf = (id) => portsFor(id).inputs;
102
+ const outputsOf = (id) => portsFor(id).outputs;
103
+ const inputPort = (id, port) => findPort(portsFor(id), port, 'in');
104
+ const outputPort = (id, port) => findPort(portsFor(id), port, 'out');
105
+
106
+ const liveWires = wires.filter((w) => nodeById.has(w?.from?.node) && nodeById.has(w?.to?.node)
107
+ && outputPort(w.from.node, w.from.port) && inputPort(w.to.node, w.to.port));
108
+ const inboundByInput = new Map();
109
+ for (const w of liveWires) {
110
+ const key = `${w.to.node}.${w.to.port}`;
111
+ if (!inboundByInput.has(key)) inboundByInput.set(key, w);
112
+ }
113
+
114
+ const { loopWireIds, loopInputs, sccOf } = classifyLoops(template, portsFn);
115
+ const components = [];
116
+ for (const [id, i] of sccOf) { (components[i] = components[i] || []).push(id); }
117
+ const selfWired = new Set(wires
118
+ .filter((w) => nodeById.has(w?.from?.node) && w.from.node === w?.to?.node).map((w) => w.from.node));
119
+ // A "nontrivial SCC" INCLUDES self-wired singletons — otherwise a self-loop
120
+ // escapes V10 and its maxCycles escapes V13.
121
+ const cycles = components.filter(Boolean).map((c) => [...c].sort())
122
+ .filter((c) => c.length > 1 || selfWired.has(c[0]));
123
+
124
+ return {
125
+ template, portsFn, limits, nodes, wires, nodeById, metaOf, inputsOf, outputsOf,
126
+ inputPort, outputPort, liveWires, loopWireIds, cycles,
127
+ portsFor,
128
+ isWired: (id, port) => inboundByInput.has(`${id}.${port}`),
129
+ inboundOf: (id, port) => inboundByInput.get(`${id}.${port}`) || null,
130
+ isLoopInput: (id, port) => loopInputs.has(`${id}.${port}`),
131
+ };
132
+ }
133
+
134
+ /** The payload type a wire carries: the declared output type, except on an or
135
+ * card, where it resolves from the or's own inbound wires (chained ors walk
136
+ * through, seen-set guarded). */
137
+ function sourceTypeOf(ctx, sourceNode, outPort) {
138
+ if (outPort?.type && outPort.type !== 'any') return outPort.type;
139
+ if (sourceNode?.kind === 'or') return resolveOrOutType(ctx.template, ctx.portsFn, sourceNode.id);
140
+ return outPort?.type ?? null;
141
+ }
142
+
143
+ /** The rule table — run in numeric order, so `errors` reads outside-in (shape,
144
+ * nodes, wires, semantics). `add(message, extra)` stamps the rule's own code
145
+ * and routes to errors/warnings by `level`. */
146
+ export const RULES = [
147
+ { code: 'V1', level: 'E', check({ template, limits }, add) {
148
+ if (!isObject(template)) { add('template must be an object'); return; }
149
+ if (template.version !== 2) add(`template version must be 2 (got ${JSON.stringify(template.version)})`);
150
+ if (!Array.isArray(template.nodes) || template.nodes.length === 0) add('template must declare a non-empty nodes array');
151
+ if (!Array.isArray(template.wires)) add('template must declare a wires array');
152
+ // A non-object entry is REJECTED here, not silently filtered by buildContext:
153
+ // a filtered `null` node validates clean, is written, and then crashes every
154
+ // consumer that does not filter (Ask's shapeWorkflow, the layout, the
155
+ // thumbnail). V1 owns the shape, so this is the one place it can be named.
156
+ (Array.isArray(template.nodes) ? template.nodes : []).forEach((entry, i) => {
157
+ if (!isObject(entry)) add(`nodes[${i}] must be an object (got ${JSON.stringify(entry)})`);
158
+ });
159
+ (Array.isArray(template.wires) ? template.wires : []).forEach((entry, i) => {
160
+ if (!isObject(entry)) add(`wires[${i}] must be an object (got ${JSON.stringify(entry)})`);
161
+ });
162
+ // Unreachable through validateGraph (its pre-pass short-circuits an overflow
163
+ // before any context exists), but the rule keeps OWNING the ceiling so a
164
+ // caller walking RULES directly still gets it, and so the message has one home.
165
+ const over = limitIssue(template, limits);
166
+ if (over) add(over.message);
167
+ } },
168
+
169
+ { code: 'V2', level: 'E', check({ nodes }, add) {
170
+ const seen = new Set();
171
+ for (const n of nodes) {
172
+ const id = n.id;
173
+ if (typeof id !== 'string' || !NODE_ID_RE.test(id)) {
174
+ add(`node id ${JSON.stringify(id)} must match ${NODE_ID_RE}`, { nodeId: id });
175
+ } else if (seen.has(id)) add(`duplicate node id '${id}'`, { nodeId: id });
176
+ else seen.add(id);
177
+ if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) {
178
+ add(`node '${id}' must have finite x/y coordinates`, { nodeId: id });
179
+ }
180
+ }
181
+ } },
182
+
183
+ { code: 'V3', level: 'E', check({ nodes }, add) {
184
+ for (const n of nodes) {
185
+ if (!KINDS.includes(n.kind)) { // KINDS is a frozen ARRAY (P1 constants)
186
+ add(`node '${n.id}' has unknown kind ${JSON.stringify(n.kind)} — expected one of ${[...KINDS].join(', ')}`, { nodeId: n.id });
187
+ } else if (n.kind === 'agent' && !n.key) add(`agent node '${n.id}' must declare a key`, { nodeId: n.id });
188
+ else if (n.kind !== 'agent' && n.key !== undefined) {
189
+ add(`node '${n.id}' of kind '${n.kind}' must not declare a key`, { nodeId: n.id });
190
+ }
191
+ }
192
+ } },
193
+
194
+ { code: 'V4', level: 'E', check({ nodes, portsFor }, add) {
195
+ for (const n of nodes) {
196
+ if (n.kind !== 'agent' || !n.key) continue; // a missing key is V3's
197
+ const p = portsFor(n.id);
198
+ if (!p.known) add(`unknown agent "${n.key}" — no such key in the registry`, { nodeId: n.id });
199
+ else if (!p.ported) add(`agent "${n.key}" has no v2 ports — port its sidecar to metaVersion 2`, { nodeId: n.id });
200
+ else if (p.meta?.placeable === false) {
201
+ add(`agent "${n.key}" declares placeable: false and cannot be a graph node`, { nodeId: n.id });
202
+ }
203
+ }
204
+ } },
205
+
206
+ { code: 'V5', level: 'E', check({ wires, nodeById, metaOf, inputPort, outputPort }, add) {
207
+ for (const w of wires) {
208
+ const fromId = w?.from?.node;
209
+ const toId = w?.to?.node;
210
+ if (!nodeById.has(fromId)) add(`wire '${w.id}' starts at unknown node '${fromId}'`, { wireId: w.id });
211
+ else if (metaOf(fromId) && !outputPort(fromId, w?.from?.port)) {
212
+ add(`wire '${w.id}': '${fromId}.${w?.from?.port}' is not a declared output`, { wireId: w.id });
213
+ }
214
+ if (!nodeById.has(toId)) add(`wire '${w.id}' ends at unknown node '${toId}'`, { wireId: w.id });
215
+ else if (metaOf(toId) && !inputPort(toId, w?.to?.port)) {
216
+ add(`wire '${w.id}': '${toId}.${w?.to?.port}' is not a declared input`, { wireId: w.id });
217
+ }
218
+ }
219
+ } },
220
+
221
+ { code: 'V6', level: 'E', check({ wires }, add) {
222
+ const seenIds = new Set();
223
+ const seenPairs = new Set();
224
+ for (const w of wires) {
225
+ if (seenIds.has(w.id)) add(`duplicate wire id '${w.id}'`, { wireId: w.id });
226
+ else seenIds.add(w.id);
227
+ const pair = `${w?.from?.node}.${w?.from?.port} -> ${w?.to?.node}.${w?.to?.port}`;
228
+ if (seenPairs.has(pair)) add(`duplicate wire ${pair}`, { wireId: w.id });
229
+ else seenPairs.add(pair);
230
+ }
231
+ } },
232
+
233
+ // V7 counts over ALL wires with no per-kind carve-out and no dependency on meta
234
+ // resolution: stacking two wires on an input is wrong even when the key is unknown.
235
+ { code: 'V7', level: 'E', check({ wires }, add) {
236
+ const byInput = new Map();
237
+ for (const w of wires) {
238
+ const key = `${w?.to?.node}.${w?.to?.port}`;
239
+ if (!byInput.has(key)) byInput.set(key, []);
240
+ byInput.get(key).push(w);
241
+ }
242
+ for (const [key, inbound] of byInput) {
243
+ for (const w of inbound.slice(1)) {
244
+ add(`input '${key}' already has an inbound wire — every input accepts at most one (fan in through an or card)`,
245
+ { nodeId: w?.to?.node, portId: w?.to?.port, wireId: w.id });
246
+ }
247
+ }
248
+ } },
249
+
250
+ { code: 'V8', level: 'E', check(ctx, add) {
251
+ const { liveWires, nodeById, inputPort, outputPort } = ctx;
252
+ for (const w of liveWires) {
253
+ const inPort = inputPort(w.to.node, w.to.port);
254
+ if (inPort.type === 'any') continue;
255
+ const sourceType = sourceTypeOf(ctx, nodeById.get(w.from.node), outputPort(w.from.node, w.from.port));
256
+ if (sourceType === null) continue; // unresolvable or card — V12 owns it
257
+ if (sourceType !== inPort.type) {
258
+ add(`wire '${w.id}' type mismatch: ${sourceType} -> ${inPort.type} `
259
+ + `(${w.from.node}.${w.from.port} -> ${w.to.node}.${w.to.port})`, { wireId: w.id });
260
+ }
261
+ }
262
+ } },
263
+
264
+ { code: 'V9', level: 'E', check({ nodes, metaOf, inputsOf, isWired, isLoopInput }, add) {
265
+ for (const n of nodes) {
266
+ // AGENT inputs only. P1 marks the gate `inK` ports and End's `result`
267
+ // `required: true`, and V12/V21 already own them with a card-specific
268
+ // sentence — without this guard one unwired `n_and.in2` reports TWICE.
269
+ if (n.kind !== 'agent' || !metaOf(n.id)) continue; // V3/V4 own an unresolved node
270
+ for (const inp of inputsOf(n.id)) {
271
+ if (!inp?.required || inp.loop || isLoopInput(n.id, inp.id)) continue;
272
+ if (!isWired(n.id, inp.id)) add(`required input '${n.id}.${inp.id}' is unwired`, { nodeId: n.id, portId: inp.id });
273
+ }
274
+ }
275
+ } },
276
+
277
+ { code: 'V10', level: 'E', check({ cycles, liveWires, loopWireIds }, add) {
278
+ for (const scc of cycles) {
279
+ const members = new Set(scc);
280
+ const inner = liveWires.filter((w) => members.has(w.from.node) && members.has(w.to.node));
281
+ if (inner.some((w) => loopWireIds.has(w.id))) continue;
282
+ add(`cycle without a blocking-source edge: ${scc.join(', ')} (wires ${inner.map((w) => w.id).join(', ') || 'none'})`);
283
+ }
284
+ } },
285
+
286
+ { code: 'V11', level: 'E', check({ cycles, metaOf, inputsOf, inboundOf, isLoopInput }, add) {
287
+ for (const scc of cycles) {
288
+ const members = new Set(scc);
289
+ const canStart = scc.some((id) => {
290
+ if (!metaOf(id)) return true; // V4 owns it; do not pile on
291
+ return inputsOf(id).every((inp) => {
292
+ if (!inp?.required || inp.loop || isLoopInput(id, inp.id)) return true;
293
+ const w = inboundOf(id, inp.id);
294
+ return Boolean(w) && !members.has(w.from.node);
295
+ });
296
+ });
297
+ if (!canStart) {
298
+ add(`deadlock: no node in cycle ${scc.join(', ')} can start — every member's required inputs come from inside the cycle`);
299
+ }
300
+ }
301
+ } },
302
+
303
+ // Arity is checked only when EXPLICITLY present: flowPorts defaults it to 2, so
304
+ // a card authored without one is legal. Combine's "all md" and AND's "any type"
305
+ // ride the declared port types and are enforced per wire by V8; what needs a
306
+ // rule of its own is the or, whose inbound source types must ALL resolve equal.
307
+ { code: 'V12', level: 'E', check(ctx, add) {
308
+ const { nodes, metaOf, inputsOf, isWired, liveWires, nodeById, outputPort, limits } = ctx;
309
+ for (const n of nodes) {
310
+ if (!ARITY_SET.has(n.kind)) continue; // local Set, not a P1 array
311
+ const arity = n.config?.arity;
312
+ // BOTH bounds: gatePorts clamps to MAX_PORTS_PER_SIDE, so `arity: 99`
313
+ // would validate, store, ship `arity: 99` in the manifest and only ever
314
+ // render in1..in8 — an unwirable card with no error.
315
+ if (arity !== undefined
316
+ && (!Number.isInteger(arity) || arity < limits.minArity || arity > limits.maxArity)) {
317
+ add(`${n.kind} node '${n.id}' needs an integer arity between ${limits.minArity} and ${limits.maxArity}`
318
+ + ` (got ${JSON.stringify(arity)})`, { nodeId: n.id });
319
+ }
320
+ if (!metaOf(n.id)) continue;
321
+ for (const inp of inputsOf(n.id)) {
322
+ if (!isWired(n.id, inp.id)) {
323
+ add(`${n.kind} node '${n.id}' has unwired input '${inp.id}' — every inK must be wired`,
324
+ { nodeId: n.id, portId: inp.id });
325
+ }
326
+ }
327
+ if (n.kind !== 'or') continue;
328
+ const typed = [];
329
+ for (const w of liveWires) {
330
+ if (w.to.node !== n.id) continue;
331
+ const type = sourceTypeOf(ctx, nodeById.get(w.from.node), outputPort(w.from.node, w.from.port));
332
+ if (type !== null) typed.push({ wireId: w.id, type });
333
+ }
334
+ const distinct = [...new Set(typed.map((t) => t.type))];
335
+ if (distinct.length < 2) continue;
336
+ add(`or node '${n.id}' has heterogeneous inbound types (${distinct.join(', ')}) — `
337
+ + 'every wire into an or must carry the same payload type',
338
+ { nodeId: n.id, wireIds: typed.map((t) => t.wireId) });
339
+ }
340
+ } },
341
+
342
+ // maxCycles belongs on LOOP WIRES only: an always-sourced in-SCC wire such as
343
+ // `or.out -> fix` is budget-less by construction (the budgets sit on the
344
+ // blocking wires INTO the or).
345
+ { code: 'V13', level: 'E', check({ nodes, wires, metaOf, outputsOf, loopWireIds }, add) {
346
+ for (const n of nodes) {
347
+ const meta = metaOf(n.id);
348
+ if (!meta) continue;
349
+ for (const out of outputsOf(n.id)) {
350
+ const when = out?.when || 'always';
351
+ if (!WHENS.has(when)) {
352
+ add(`output '${n.id}.${out.id}' declares unknown when ${JSON.stringify(out.when)}`, { nodeId: n.id, portId: out.id });
353
+ } else if (when !== 'always' && !meta.verdict) {
354
+ add(`output '${n.id}.${out.id}' is when:'${when}' but the node produces no verdict`, { nodeId: n.id, portId: out.id });
355
+ }
356
+ }
357
+ }
358
+ for (const w of wires) {
359
+ const budget = w?.config?.maxCycles;
360
+ if (budget === undefined) continue;
361
+ if (!Number.isInteger(budget) || budget < 1) {
362
+ add(`wire '${w.id}' maxCycles must be an integer >= 1 (got ${JSON.stringify(budget)})`, { wireId: w.id });
363
+ }
364
+ if (!loopWireIds.has(w.id)) add(`wire '${w.id}' carries maxCycles but is not a loop wire`, { wireId: w.id });
365
+ }
366
+ } },
367
+
368
+ { code: 'V14', level: 'E', check({ nodes, metaOf, inputsOf }, add) {
369
+ for (const n of nodes) {
370
+ if (!metaOf(n.id)) continue;
371
+ for (const inp of inputsOf(n.id)) {
372
+ if (inp?.expands && inp.type !== 'json') {
373
+ add(`input '${n.id}.${inp.id}' declares expands but is '${inp.type}' — expands inputs must be json`,
374
+ { nodeId: n.id, portId: inp.id });
375
+ }
376
+ }
377
+ }
378
+ } },
379
+
380
+ // The ENTRY SET is the nodes whose RESOLVED ports declare zero inputs, EXCLUDING
381
+ // nodes whose meta did not resolve — an unknown-key node otherwise looks like an
382
+ // entry and suppresses the whole cascade.
383
+ { code: 'V15', level: 'W', check({ nodes, metaOf, inputsOf, liveWires }, add) {
384
+ const out = new Map();
385
+ for (const w of liveWires) {
386
+ if (!out.has(w.from.node)) out.set(w.from.node, []);
387
+ out.get(w.from.node).push(w.to.node);
388
+ }
389
+ const reached = new Set();
390
+ const queue = nodes.filter((n) => metaOf(n.id) && inputsOf(n.id).length === 0).map((n) => n.id);
391
+ for (const id of queue) reached.add(id);
392
+ while (queue.length) {
393
+ for (const next of out.get(queue.shift()) || []) {
394
+ if (reached.has(next)) continue;
395
+ reached.add(next);
396
+ queue.push(next);
397
+ }
398
+ }
399
+ for (const n of nodes) if (!reached.has(n.id)) add(`node '${n.id}' is unreachable from any entry`, { nodeId: n.id });
400
+ } },
401
+
402
+ { code: 'V16', level: 'W', check({ nodes, metaOf, inputsOf, isWired, isLoopInput }, add) {
403
+ for (const n of nodes) {
404
+ if (n.kind !== 'agent' || !n.config?.awaitAll || !metaOf(n.id)) continue;
405
+ const wired = inputsOf(n.id).filter((inp) => !isLoopInput(n.id, inp.id) && isWired(n.id, inp.id));
406
+ if (wired.length < 2) {
407
+ add(`node '${n.id}' sets awaitAll but has ${wired.length} wired non-loop input(s) — the barrier is a no-op`,
408
+ { nodeId: n.id });
409
+ }
410
+ }
411
+ } },
412
+
413
+ { code: 'V17', level: 'W', check({ nodes, wires }, add) {
414
+ for (const n of nodes) {
415
+ const known = KNOWN_CONFIG[n.kind];
416
+ if (!known) continue; // unknown kind — V3's error
417
+ for (const key of Object.keys(n.config || {})) {
418
+ if (known.has(key)) continue;
419
+ add(`node '${n.id}' has unknown config key '${key}' for kind '${n.kind}' — preserved and ignored`, { nodeId: n.id });
420
+ }
421
+ }
422
+ for (const w of wires) {
423
+ for (const key of Object.keys(w.config || {})) {
424
+ if (KNOWN_WIRE_CONFIG.has(key)) continue;
425
+ add(`wire '${w.id}' has unknown config key '${key}' — preserved and ignored`, { wireId: w.id });
426
+ }
427
+ }
428
+ } },
429
+
430
+ // Exemptions: (a) task-sourced, (b) void, (c) the synthesized await (any-typed,
431
+ // so (b) misses it), (d) loop inputs — (d) is LOAD-BEARING: `or.out -> fix` is an
432
+ // always source, so without it every double-loop seed would warn permanently.
433
+ { code: 'V18', level: 'W', check({ nodes, nodeById, metaOf, inputsOf, inboundOf, outputPort, isLoopInput }, add) {
434
+ for (const n of nodes) {
435
+ if (n.kind !== 'agent' || n.config?.awaitAll || !metaOf(n.id)) continue;
436
+ let paired = 0;
437
+ for (const inp of inputsOf(n.id)) {
438
+ if (inp.id === AWAIT_PORT_ID || inp.synthetic) continue; // (c)
439
+ if (inp.type === 'void') continue; // (b)
440
+ if (inp.loop || isLoopInput(n.id, inp.id)) continue; // (d)
441
+ const w = inboundOf(n.id, inp.id);
442
+ if (!w) continue;
443
+ if (nodeById.get(w.from.node)?.kind === 'task') continue; // (a)
444
+ if ((outputPort(w.from.node, w.from.port)?.when || 'always') !== 'always') continue;
445
+ paired += 1;
446
+ }
447
+ if (paired >= 2) {
448
+ add(`agent node '${n.id}' has ${paired} always-sourced payload inputs without awaitAll — `
449
+ + 'it may double-fire on re-runs (enable Await-all or insert an AND card)', { nodeId: n.id });
450
+ }
451
+ }
452
+ } },
453
+
454
+ // An or `inK` is THE canonical loop-valve terminal (the double-loop seeds land
455
+ // their blocking review wires there by design); AND/End/await are explicit
456
+ // flow-control sinks. Combine inputs are payload-bearing and still warn.
457
+ { code: 'V19', level: 'W', check({ liveWires, nodeById, outputPort, isLoopInput }, add) {
458
+ for (const w of liveWires) {
459
+ if (outputPort(w.from.node, w.from.port)?.when !== 'blocking') continue;
460
+ if (isLoopInput(w.to.node, w.to.port)) continue;
461
+ const target = nodeById.get(w.to.node);
462
+ if ((target.kind === 'and' || target.kind === 'or') && IN_PORT_RE.test(w.to.port)) continue;
463
+ if (target.kind === 'end' && w.to.port === 'result') continue;
464
+ if (target.kind === 'agent' && w.to.port === AWAIT_PORT_ID) continue;
465
+ add(`blocking output '${w.from.node}.${w.from.port}' is wired into '${w.to.node}.${w.to.port}', `
466
+ + 'which is not a loop input', { wireId: w.id });
467
+ }
468
+ } },
469
+
470
+ { code: 'V20', level: 'E', check({ nodes, metaOf, inputsOf, liveWires }, add) {
471
+ const taskNodes = nodes.filter((n) => n.kind === 'task');
472
+ if (taskNodes.length !== 1) add(`a template must declare exactly one task node (found ${taskNodes.length})`);
473
+ for (const t of taskNodes) {
474
+ if (!metaOf(t.id)) continue;
475
+ if (inputsOf(t.id).length) add(`task node '${t.id}' must declare zero inputs`, { nodeId: t.id });
476
+ if (!liveWires.some((w) => w.from.node === t.id && w.from.port === 'task')) {
477
+ add(`task node '${t.id}' output 'task' must have at least one wire`, { nodeId: t.id });
478
+ }
479
+ }
480
+ } },
481
+
482
+ { code: 'V21', level: 'E', check({ nodes, metaOf, outputsOf, isWired }, add) {
483
+ const endNodes = nodes.filter((n) => n.kind === 'end');
484
+ if (endNodes.length !== 1) add(`a template must declare exactly one end node (found ${endNodes.length})`);
485
+ for (const e of endNodes) {
486
+ if (!metaOf(e.id)) continue;
487
+ if (outputsOf(e.id).length) add(`end node '${e.id}' must declare zero outputs`, { nodeId: e.id });
488
+ if (!isWired(e.id, 'result')) add(`end node '${e.id}' input 'result' must be wired`, { nodeId: e.id });
489
+ }
490
+ } },
491
+ ];
@@ -0,0 +1,41 @@
1
+ // src/shared/graph/verdict.mjs
2
+ // The verdict vocabulary: severities, what blocks, and the two readers the
3
+ // engine's conditional outputs (`when: blocking|clean`) and the UI both need.
4
+ // Moved verbatim out of src/core/protocol.mjs, which re-exports them: protocol
5
+ // imports node:fs/promises, so it can never be loaded in a browser, and the
6
+ // composer/run monitor need exactly these five symbols.
7
+
8
+ /**
9
+ * Severity ranking used throughout the pipeline. Order is significant:
10
+ * earlier entries are more severe. "critical" and "major" are *blocking*.
11
+ */
12
+ export const SEVERITIES = ['critical', 'major', 'minor', 'suggestion'];
13
+
14
+ export const BLOCKING = new Set(['critical', 'major']);
15
+
16
+ /** Normalize an arbitrary value to one of SEVERITIES (default "minor"). */
17
+ export function normalizeSeverity(value) {
18
+ if (typeof value !== 'string') return 'minor';
19
+ const v = value.trim().toLowerCase();
20
+ return SEVERITIES.includes(v) ? v : 'minor';
21
+ }
22
+
23
+ /**
24
+ * True if a review contains any critical or major issue.
25
+ * @param {{issues: Array}} review
26
+ * @returns {boolean}
27
+ */
28
+ export function hasBlocking(review) {
29
+ if (!review || !Array.isArray(review.issues)) return false;
30
+ return review.issues.some((i) => BLOCKING.has(normalizeSeverity(i?.severity)));
31
+ }
32
+
33
+ /**
34
+ * The subset of issues that are critical or major.
35
+ * @param {{issues: Array}} review
36
+ * @returns {Array}
37
+ */
38
+ export function blockingIssues(review) {
39
+ if (!review || !Array.isArray(review.issues)) return [];
40
+ return review.issues.filter((i) => BLOCKING.has(normalizeSeverity(i?.severity)));
41
+ }