@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.
- package/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- 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
|
+
}
|