@worca/app 1.2.0-rc.3 → 1.3.0-rc.2
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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3736 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1589 -212
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +447 -29
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1499 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1304 -194
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// The shape DSL (auto-workflow spec §4.1) and the deterministic assembler that
|
|
2
|
+
// turns a shape into a VALID v2 template (§4.2). Pure and browser-safe: the
|
|
3
|
+
// only inputs are the shape, an agent registry slice (key -> meta) and a
|
|
4
|
+
// portsFn. No agent key is special here — every decision reads port META
|
|
5
|
+
// (type / when / loop / required / expands) or `runnerType`.
|
|
6
|
+
import { TEMPLATE_VERSION, DEFAULT_MAX_CYCLES } from './constants.mjs';
|
|
7
|
+
import { portsFnFor, portsOf } from './ports.mjs';
|
|
8
|
+
import { autoLayout } from './layout.mjs';
|
|
9
|
+
import { validateGraph } from './validate.mjs';
|
|
10
|
+
|
|
11
|
+
export class ShapeError extends Error {
|
|
12
|
+
constructor(issues) {
|
|
13
|
+
const list = Array.isArray(issues) && issues.length ? issues : [{ code: 'SHAPE_INVALID', message: 'invalid shape' }];
|
|
14
|
+
super(`invalid workflow shape: ${list.map((i) => i.message).join('; ')}`);
|
|
15
|
+
this.name = 'ShapeError';
|
|
16
|
+
this.code = 'SHAPE_INVALID';
|
|
17
|
+
this.issues = list;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const TASK_KINDS = Object.freeze(['prompt', 'plan-partial', 'plan-complete-detailed', 'plan-complete-small']);
|
|
22
|
+
export const STAGE_TUNABLES = Object.freeze(['model', 'effort', 'fanOut', 'askQuestions']);
|
|
23
|
+
export const SIZES = Object.freeze(['small', 'medium', 'large']);
|
|
24
|
+
export const SHAPE_LIMITS = Object.freeze({ maxStages: 24, maxGroupMembers: 8, maxNameLen: 60, maxReasoningLen: 500, maxCycles: 20, maxSignals: 8, maxSignalLen: 60 });
|
|
25
|
+
|
|
26
|
+
const AGENT_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
27
|
+
const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
|
|
29
|
+
// Model output and plugin-authored text are UNTRUSTED and end up in a terminal
|
|
30
|
+
// (the CLI proposal), a system prompt (agent cards) and a workflow row name:
|
|
31
|
+
// ANSI CSI sequences (ESC [ … m), C0/C1 controls and Unicode format characters
|
|
32
|
+
// (bidi overrides, zero-width joiners, the BOM) are stripped; line breaks and runs
|
|
33
|
+
// of whitespace collapse to one space.
|
|
34
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -\/]*[@-~]/g;
|
|
35
|
+
const CONTROL_RE = /[\p{Cc}\p{Cf}]/gu;
|
|
36
|
+
|
|
37
|
+
/** Printable, single-line, whitespace-collapsed, capped at `max` characters. */
|
|
38
|
+
export function cleanText(v, max) {
|
|
39
|
+
const s = String(v ?? '').replace(ANSI_RE, '').replace(/\s+/g, ' ').replace(CONTROL_RE, '').replace(/\s+/g, ' ').trim();
|
|
40
|
+
return Number.isFinite(max) ? s.slice(0, max) : s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cycles(v, issues, where) {
|
|
44
|
+
if (v === undefined || v === null || v === true) return DEFAULT_MAX_CYCLES;
|
|
45
|
+
const n = isObject(v) ? v.maxCycles : v;
|
|
46
|
+
if (n === undefined || n === null) return DEFAULT_MAX_CYCLES;
|
|
47
|
+
const int = Math.floor(Number(n));
|
|
48
|
+
if (!Number.isFinite(int) || int < 1 || int > SHAPE_LIMITS.maxCycles) {
|
|
49
|
+
issues.push({ code: 'BAD_CYCLES', message: `${where}: maxCycles must be an integer 1..${SHAPE_LIMITS.maxCycles}` });
|
|
50
|
+
return DEFAULT_MAX_CYCLES;
|
|
51
|
+
}
|
|
52
|
+
return int;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The per-stage tunables. A RAW stage carries them at the top level; an
|
|
56
|
+
* already-normalized stage carries them under `tunables` — read both, so
|
|
57
|
+
* normalizeShape(normalizeShape(x)) === normalizeShape(x) (the classifier returns
|
|
58
|
+
* a normalized shape and assembleShape normalizes again). */
|
|
59
|
+
function tunablesOf(raw) {
|
|
60
|
+
const src = { ...(isObject(raw.tunables) ? raw.tunables : {}), ...raw };
|
|
61
|
+
const out = {};
|
|
62
|
+
// Model/effort ids are model-authored too: cleaned + capped (they land in messages).
|
|
63
|
+
if (typeof src.model === 'string' && cleanText(src.model, 80)) out.model = cleanText(src.model, 80);
|
|
64
|
+
if (typeof src.effort === 'string' && cleanText(src.effort, 20)) out.effort = cleanText(src.effort, 20);
|
|
65
|
+
if (typeof src.fanOut === 'boolean') out.fanOut = src.fanOut;
|
|
66
|
+
if (typeof src.askQuestions === 'boolean') out.askQuestions = src.askQuestions;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Canonicalize a raw shape. Throws ShapeError with EVERY issue found (not just
|
|
72
|
+
* the first) so a classifier retry can fix them all in one round. Idempotent.
|
|
73
|
+
* @returns {{name:string, taskKind:string, reasoning:string, size:string, signals:string[], stages:Array, loops:Array}}
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeShape(raw) {
|
|
76
|
+
const issues = [];
|
|
77
|
+
const r = isObject(raw) ? raw : {};
|
|
78
|
+
const name = cleanText(r.name, SHAPE_LIMITS.maxNameLen) || 'Auto workflow';
|
|
79
|
+
const taskKind = TASK_KINDS.includes(r.taskKind) ? r.taskKind : 'prompt';
|
|
80
|
+
const reasoning = cleanText(r.reasoning, SHAPE_LIMITS.maxReasoningLen);
|
|
81
|
+
// size/signals are advisory (they paint chips): never an issue, always a default — a retry is for real errors.
|
|
82
|
+
// Model output is UNTRUSTED (see cleanText): every signal is cleaned per element, deduped and capped.
|
|
83
|
+
const size = SIZES.includes(r.size) ? r.size : 'medium';
|
|
84
|
+
const signals = [];
|
|
85
|
+
for (const v of Array.isArray(r.signals) ? r.signals : []) {
|
|
86
|
+
const t = typeof v === 'string' ? cleanText(v, SHAPE_LIMITS.maxSignalLen) : '';
|
|
87
|
+
if (t && !signals.includes(t)) signals.push(t);
|
|
88
|
+
if (signals.length >= SHAPE_LIMITS.maxSignals) break;
|
|
89
|
+
}
|
|
90
|
+
const rawStages = Array.isArray(r.stages) ? r.stages : [];
|
|
91
|
+
if (!rawStages.length) issues.push({ code: 'NO_STAGES', message: 'stages must be a non-empty array' });
|
|
92
|
+
if (rawStages.length > SHAPE_LIMITS.maxStages) issues.push({ code: 'TOO_MANY_STAGES', message: `at most ${SHAPE_LIMITS.maxStages} stages` });
|
|
93
|
+
|
|
94
|
+
const ids = new Set();
|
|
95
|
+
const idOf = (given, fallback, where) => {
|
|
96
|
+
// Stage ids are model-authored: clean them (they land in ShapeError messages that
|
|
97
|
+
// the terminal and the pause card print) and cap them.
|
|
98
|
+
const cleaned = typeof given === 'string' ? cleanText(given, 40) : '';
|
|
99
|
+
let id = cleaned || fallback;
|
|
100
|
+
if (ids.has(id)) { issues.push({ code: 'DUP_STAGE_ID', message: `${where}: duplicate stage id "${id}"` }); id = `${fallback}_dup`; }
|
|
101
|
+
ids.add(id);
|
|
102
|
+
return id;
|
|
103
|
+
};
|
|
104
|
+
const stageOf = (entry, fallbackId, where) => {
|
|
105
|
+
if (!isObject(entry)) { issues.push({ code: 'BAD_STAGE', message: `${where}: a stage must be an object` }); return null; }
|
|
106
|
+
if (Array.isArray(entry.parallel)) { issues.push({ code: 'NESTED_GROUP', message: `${where}: parallel groups cannot nest` }); return null; }
|
|
107
|
+
const agent = typeof entry.agent === 'string' ? entry.agent.trim() : '';
|
|
108
|
+
if (!AGENT_KEY_RE.test(agent)) { issues.push({ code: 'BAD_AGENT', message: `${where}: "agent" must be an agent key`, stageId: fallbackId }); return null; }
|
|
109
|
+
const id = idOf(entry.id, fallbackId, where);
|
|
110
|
+
const stage = { id, agent, tunables: tunablesOf(entry), selfLoop: null, loop: entry.loop !== false };
|
|
111
|
+
if (entry.selfLoop !== undefined && entry.selfLoop !== null && entry.selfLoop !== false) {
|
|
112
|
+
stage.selfLoop = { maxCycles: cycles(entry.selfLoop, issues, `${where}.selfLoop`) };
|
|
113
|
+
}
|
|
114
|
+
return stage;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const stages = [];
|
|
118
|
+
rawStages.forEach((entry, i) => {
|
|
119
|
+
const n = i + 1;
|
|
120
|
+
if (isObject(entry) && Array.isArray(entry.parallel)) {
|
|
121
|
+
const members = [];
|
|
122
|
+
entry.parallel.forEach((m, j) => {
|
|
123
|
+
const s = stageOf(m, `s${n}${String.fromCharCode(97 + j)}`, `stages[${i}].parallel[${j}]`);
|
|
124
|
+
if (s) members.push(s);
|
|
125
|
+
});
|
|
126
|
+
if (entry.parallel.length > SHAPE_LIMITS.maxGroupMembers) issues.push({ code: 'GROUP_TOO_LARGE', message: `stages[${i}]: at most ${SHAPE_LIMITS.maxGroupMembers} parallel members` });
|
|
127
|
+
if (members.length < 2) issues.push({ code: 'GROUP_TOO_SMALL', message: `stages[${i}]: a parallel group needs at least 2 members` });
|
|
128
|
+
stages.push({ id: idOf(entry.id, `s${n}`, `stages[${i}]`), parallel: members });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const s = stageOf(entry, `s${n}`, `stages[${i}]`);
|
|
132
|
+
if (s) stages.push(s);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Loop endpoints: a stage id, or an agent key that occurs exactly once.
|
|
136
|
+
const flat = stages.flatMap((u) => (u.parallel ? u.parallel : [u]));
|
|
137
|
+
const resolveRef = (ref, where) => {
|
|
138
|
+
const v = typeof ref === 'string' ? cleanText(ref, 60) : '';
|
|
139
|
+
if (!v) { issues.push({ code: 'LOOP_ENDPOINT', message: `${where}: missing stage reference` }); return null; }
|
|
140
|
+
if (flat.some((s) => s.id === v)) return v;
|
|
141
|
+
const byKey = flat.filter((s) => s.agent === v);
|
|
142
|
+
if (byKey.length === 1) return byKey[0].id;
|
|
143
|
+
issues.push({ code: 'LOOP_ENDPOINT', message: byKey.length > 1 ? `${where}: "${v}" names ${byKey.length} stages — use a stage id` : `${where}: unknown stage "${v}"` });
|
|
144
|
+
return null;
|
|
145
|
+
};
|
|
146
|
+
const loops = [];
|
|
147
|
+
(Array.isArray(r.loops) ? r.loops : []).forEach((l, i) => {
|
|
148
|
+
if (!isObject(l)) { issues.push({ code: 'BAD_LOOP', message: `loops[${i}]: must be an object` }); return; }
|
|
149
|
+
const from = resolveRef(l.from, `loops[${i}].from`);
|
|
150
|
+
const to = resolveRef(l.to, `loops[${i}].to`);
|
|
151
|
+
if (from && to) loops.push({ from, to, maxCycles: cycles(l.maxCycles, issues, `loops[${i}]`) });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (issues.length) throw new ShapeError(issues);
|
|
155
|
+
return { name, taskKind, reasoning, size, signals, stages, loops };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── the assembler ─────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
const nonBlocking = (o) => (o?.when || 'always') !== 'blocking';
|
|
161
|
+
const typeOk = (outType, inType) => inType === 'any' || outType === inType;
|
|
162
|
+
const flatStages = (units) => units.flatMap((u) => (u.parallel ? u.parallel : [u]));
|
|
163
|
+
const sameGroup = (units, a, b) => units.some((u) => u.parallel && u.parallel.some((s) => s.id === a) && u.parallel.some((s) => s.id === b));
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Shape -> validated v2 template. Deterministic: same shape + registry in, same
|
|
167
|
+
* template out (ids, wire order, layout). Accepts a raw OR an already-normalized shape.
|
|
168
|
+
* @param {object} rawShape the shape (normalized here — normalizeShape is idempotent)
|
|
169
|
+
* @param {{registry?:object|Map, portsFn?:Function, humanInLoop?:boolean}} [o]
|
|
170
|
+
* @returns {{template:object, warnings:Array, stageToNode:Map<string,string>, tunables:Record<string,object>, shape:object}}
|
|
171
|
+
* @throws {ShapeError}
|
|
172
|
+
*/
|
|
173
|
+
export function assembleShape(rawShape, { registry = {}, portsFn = null, humanInLoop = true } = {}) {
|
|
174
|
+
const shape = normalizeShape(rawShape);
|
|
175
|
+
const index = registry instanceof Map ? Object.fromEntries(registry) : (isObject(registry) ? registry : {});
|
|
176
|
+
const ports = typeof portsFn === 'function' ? portsFn : portsFnFor(index);
|
|
177
|
+
const issues = [];
|
|
178
|
+
const warnings = [];
|
|
179
|
+
const metaOf = (agent) => index[agent];
|
|
180
|
+
|
|
181
|
+
// 1) every agent exists, is ported and placeable (domain is NOT a graph rule — Task 11's cards filter it)
|
|
182
|
+
for (const st of flatStages(shape.stages)) {
|
|
183
|
+
const meta = metaOf(st.agent);
|
|
184
|
+
if (!meta) issues.push({ code: 'UNKNOWN_AGENT', message: `unknown agent "${st.agent}"`, stageId: st.id });
|
|
185
|
+
else if (!Array.isArray(meta.inputs) || !Array.isArray(meta.outputs)) issues.push({ code: 'UNPORTED_AGENT', message: `agent "${st.agent}" has no v2 ports`, stageId: st.id });
|
|
186
|
+
else if (meta.placeable === false) issues.push({ code: 'UNPLACEABLE_AGENT', message: `agent "${st.agent}" cannot be a graph node`, stageId: st.id });
|
|
187
|
+
// The classifier's vocabulary filters scope, but a hand-authored shape (the chat's
|
|
188
|
+
// `shape` mode, P3) reaches the assembler directly — and resolveGraph has no scope check.
|
|
189
|
+
else if (meta.scope === 'workspace-only') issues.push({ code: 'WORKSPACE_ONLY_AGENT', message: `agent "${st.agent}" runs on workspace targets only`, stageId: st.id });
|
|
190
|
+
}
|
|
191
|
+
if (issues.length) throw new ShapeError(issues);
|
|
192
|
+
|
|
193
|
+
// 2) human out of the loop: drop clarifier stages — by META, never by key
|
|
194
|
+
let units = shape.stages;
|
|
195
|
+
let loops = shape.loops;
|
|
196
|
+
if (!humanInLoop) {
|
|
197
|
+
const dropped = new Set();
|
|
198
|
+
const isClarifier = (s) => metaOf(s.agent)?.runnerType === 'clarifier';
|
|
199
|
+
units = units.map((u) => {
|
|
200
|
+
if (u.parallel) {
|
|
201
|
+
const kept = u.parallel.filter((s) => !isClarifier(s));
|
|
202
|
+
u.parallel.filter(isClarifier).forEach((s) => dropped.add(s.id));
|
|
203
|
+
if (kept.length === u.parallel.length) return u;
|
|
204
|
+
if (kept.length === 0) return null;
|
|
205
|
+
return kept.length === 1 ? kept[0] : { ...u, parallel: kept };
|
|
206
|
+
}
|
|
207
|
+
if (isClarifier(u)) { dropped.add(u.id); return null; }
|
|
208
|
+
return u;
|
|
209
|
+
}).filter(Boolean);
|
|
210
|
+
loops = loops.filter((l) => !dropped.has(l.from) && !dropped.has(l.to));
|
|
211
|
+
}
|
|
212
|
+
if (!units.length) throw new ShapeError([{ code: 'EMPTY', message: 'no stages left to run' }]);
|
|
213
|
+
|
|
214
|
+
// 3) nodes. Ids must match NODE_ID_RE (/^n_[a-z0-9]{1,32}$/): lower-cased key, digits on repeats.
|
|
215
|
+
const nodes = [];
|
|
216
|
+
const wires = [];
|
|
217
|
+
const taken = new Set();
|
|
218
|
+
const mintNode = (base) => {
|
|
219
|
+
const stem = `n_${String(base).toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 28) || 'agent'}`;
|
|
220
|
+
let id = stem;
|
|
221
|
+
for (let n = 2; taken.has(id); n += 1) id = `${stem}${n}`;
|
|
222
|
+
taken.add(id);
|
|
223
|
+
return id;
|
|
224
|
+
};
|
|
225
|
+
const addNode = (node) => { nodes.push(node); return node; };
|
|
226
|
+
let wireSeq = 0;
|
|
227
|
+
const addWire = (from, to, config) => {
|
|
228
|
+
const w = { id: `w${++wireSeq}`, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } };
|
|
229
|
+
if (config && Object.keys(config).length) w.config = { ...config };
|
|
230
|
+
wires.push(w);
|
|
231
|
+
return w;
|
|
232
|
+
};
|
|
233
|
+
const inbound = (nodeId) => wires.filter((w) => w.to.node === nodeId);
|
|
234
|
+
|
|
235
|
+
const taskNode = addNode({ id: mintNode('task'), kind: 'task', x: 0, y: 0, config: {} });
|
|
236
|
+
if (shape.taskKind.startsWith('plan-complete')) taskNode.config.planStoreSeed = true; // A2: the task document IS the plan
|
|
237
|
+
|
|
238
|
+
const stageToNode = new Map();
|
|
239
|
+
const tunables = {};
|
|
240
|
+
const nodeOf = new Map(); // stageId -> node
|
|
241
|
+
const resolvedPorts = new Map(); // stageId -> portsOf(...)
|
|
242
|
+
for (const st of flatStages(units)) {
|
|
243
|
+
const node = addNode({ id: mintNode(st.agent), kind: 'agent', key: st.agent, x: 0, y: 0, config: { ...st.tunables } });
|
|
244
|
+
stageToNode.set(st.id, node.id);
|
|
245
|
+
nodeOf.set(st.id, node);
|
|
246
|
+
resolvedPorts.set(st.id, portsOf(ports, node));
|
|
247
|
+
if (Object.keys(st.tunables).length) tunables[node.id] = { ...st.tunables };
|
|
248
|
+
}
|
|
249
|
+
const endNode = addNode({ id: mintNode('end'), kind: 'end', x: 0, y: 0, config: {} });
|
|
250
|
+
|
|
251
|
+
const outsOf = (st) => resolvedPorts.get(st.id).outputs.filter(nonBlocking)
|
|
252
|
+
.map((o) => ({ node: nodeOf.get(st.id).id, port: o.id, type: o.type }));
|
|
253
|
+
const completionOf = (st) => {
|
|
254
|
+
const outs = resolvedPorts.get(st.id).outputs;
|
|
255
|
+
const pick = outs.find((o) => o.when === 'clean' && o.type === 'void')
|
|
256
|
+
|| outs.find((o) => nonBlocking(o) && o.type === 'void')
|
|
257
|
+
|| outs.find((o) => nonBlocking(o) && o.type === 'md')
|
|
258
|
+
|| outs.find(nonBlocking);
|
|
259
|
+
return pick ? { node: nodeOf.get(st.id).id, port: pick.id, type: pick.type } : null;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// A UNIT is a stage or a parallel group (with lazily minted gates).
|
|
263
|
+
const TASK_UNIT = {
|
|
264
|
+
task: true,
|
|
265
|
+
outputs: () => [{ node: taskNode.id, port: 'task', type: 'md' }],
|
|
266
|
+
completion: () => ({ node: taskNode.id, port: 'task', type: 'md' }),
|
|
267
|
+
nodeIds: () => new Set([taskNode.id]),
|
|
268
|
+
};
|
|
269
|
+
const gateSeq = { and: 0, combine: 0, or: 0 };
|
|
270
|
+
const unitView = (u) => {
|
|
271
|
+
if (!u.parallel) {
|
|
272
|
+
return { stages: [u], group: false, outputs: () => outsOf(u), completion: () => completionOf(u), nodeIds: () => new Set([nodeOf.get(u.id).id]) };
|
|
273
|
+
}
|
|
274
|
+
const view = { stages: u.parallel, group: true, andId: null, combineId: null };
|
|
275
|
+
/** Every member's FIRST non-blocking md output — the inputs of the group's md join. */
|
|
276
|
+
view.mds = () => u.parallel.flatMap((s) => outsOf(s).filter((o) => o.type === 'md').slice(0, 1));
|
|
277
|
+
/** The joined md deliverable (rule 2b, D22): ONE Combine over view.mds(), minted on
|
|
278
|
+
* the FIRST bind so an unused join never lands in the graph; a single md producer
|
|
279
|
+
* IS the deliverable; no md producer => null (the successor binds member outputs). */
|
|
280
|
+
view.deliverable = () => {
|
|
281
|
+
const mds = view.mds();
|
|
282
|
+
if (mds.length < 2) return mds[0] || null;
|
|
283
|
+
if (!view.combineId) {
|
|
284
|
+
const c = addNode({ id: mintNode(`combine${++gateSeq.combine}`), kind: 'combine', x: 0, y: 0, config: { arity: mds.length } });
|
|
285
|
+
mds.forEach((o, i) => addWire(o, { node: c.id, port: `in${i + 1}` }));
|
|
286
|
+
view.combineId = c.id;
|
|
287
|
+
}
|
|
288
|
+
return { node: view.combineId, port: 'out', type: 'md' };
|
|
289
|
+
};
|
|
290
|
+
/** Completion: an AND over every member's completion output (minted once the members are wired). */
|
|
291
|
+
view.mintGates = () => {
|
|
292
|
+
const comps = u.parallel.map(completionOf).filter(Boolean);
|
|
293
|
+
const a = addNode({ id: mintNode(`and${++gateSeq.and}`), kind: 'and', x: 0, y: 0, config: { arity: Math.max(2, comps.length) } });
|
|
294
|
+
comps.forEach((o, i) => addWire(o, { node: a.id, port: `in${i + 1}` }));
|
|
295
|
+
view.andId = a.id;
|
|
296
|
+
};
|
|
297
|
+
view.outputs = () => [
|
|
298
|
+
...(view.andId ? [{ node: view.andId, port: 'out', type: 'void' }] : []),
|
|
299
|
+
...u.parallel.flatMap(outsOf),
|
|
300
|
+
];
|
|
301
|
+
view.completion = () => (view.andId ? { node: view.andId, port: 'out', type: 'void' } : null);
|
|
302
|
+
view.nodeIds = () => new Set([...u.parallel.map((s) => nodeOf.get(s.id).id), view.andId, view.combineId].filter(Boolean));
|
|
303
|
+
return view;
|
|
304
|
+
};
|
|
305
|
+
const views = units.map(unitView);
|
|
306
|
+
|
|
307
|
+
// 4) data wiring + sequencing, unit by unit (a group's members see the group's predecessors)
|
|
308
|
+
const predUnit = new Map(); // stageId -> the immediate predecessor unit (for rule 3b)
|
|
309
|
+
const wireStage = (st, preds) => {
|
|
310
|
+
const node = nodeOf.get(st.id);
|
|
311
|
+
predUnit.set(st.id, preds[0] || null);
|
|
312
|
+
for (const p of resolvedPorts.get(st.id).inputs) {
|
|
313
|
+
if (p.loop || p.synthetic || p.id === 'await') continue;
|
|
314
|
+
const required = p.required !== false;
|
|
315
|
+
const candidates = required ? preds : preds.slice(0, 1); // optional inputs bind only from the immediate predecessor
|
|
316
|
+
let pick = null;
|
|
317
|
+
for (const u of candidates) {
|
|
318
|
+
// Rule 2b: a parallel group speaks through its GATES first — its joined md
|
|
319
|
+
// deliverable (minted on this first bind) outranks any single member's
|
|
320
|
+
// output, even one whose port id matches. Other types bind member outputs.
|
|
321
|
+
const joined = u.group && (p.type === 'md' || p.type === 'any') ? u.deliverable() : null;
|
|
322
|
+
const outs = u.outputs().filter((o) => typeOk(o.type, p.type));
|
|
323
|
+
pick = joined || outs.find((o) => o.port === p.id) || outs[0] || null;
|
|
324
|
+
if (pick) break;
|
|
325
|
+
}
|
|
326
|
+
if (pick) addWire(pick, { node: node.id, port: p.id });
|
|
327
|
+
else if (required) issues.push({ code: 'NO_PRODUCER', message: `stage "${st.id}" (${st.agent}): no upstream output of type ${p.type} for its required input "${p.id}"`, stageId: st.id });
|
|
328
|
+
}
|
|
329
|
+
const prev = preds[0];
|
|
330
|
+
if (!prev || prev.task) return;
|
|
331
|
+
if (prev.group) {
|
|
332
|
+
if (!inbound(node.id).some((w) => w.from.node === prev.andId)) addWire({ node: prev.andId, port: 'out' }, { node: node.id, port: 'await' });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const prevIds = prev.nodeIds();
|
|
336
|
+
if (!inbound(node.id).some((w) => prevIds.has(w.from.node))) {
|
|
337
|
+
const c = prev.completion();
|
|
338
|
+
if (c) addWire(c, { node: node.id, port: 'await' });
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
views.forEach((v, i) => {
|
|
342
|
+
const preds = [...views.slice(0, i).reverse(), TASK_UNIT];
|
|
343
|
+
for (const st of v.stages) wireStage(st, preds);
|
|
344
|
+
if (v.group) v.mintGates();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// 5) loops: explicit first, then self-by-default, then the nearest preceding loop input
|
|
348
|
+
const flat = flatStages(units);
|
|
349
|
+
const blockingOut = (st) => resolvedPorts.get(st.id).outputs.find((o) => o.when === 'blocking') || null;
|
|
350
|
+
const loopInputs = (st) => resolvedPorts.get(st.id).inputs.filter((p) => p.loop);
|
|
351
|
+
// Rule 4b: a loop into a MEMBER of a parallel group is legal only when the loop's
|
|
352
|
+
// source binds one of that member's outputs. The group's AND gate re-fires only
|
|
353
|
+
// when EVERY member re-runs, so a verifier whose only tie to the group is
|
|
354
|
+
// `and.out → await` never re-fires after a fix cycle that re-ran one member —
|
|
355
|
+
// the run finishes at quiescence with End unreached (measured on
|
|
356
|
+
// planner → [implementer ∥ manualTestsChecklist] → manualWebUiTesting).
|
|
357
|
+
const groupOf = (stageId) => units.find((u) => u.parallel && u.parallel.some((s) => s.id === stageId)) || null;
|
|
358
|
+
const bindsOutputOf = (srcStage, dstStage) => inbound(nodeOf.get(srcStage.id).id).some((w) => w.from.node === nodeOf.get(dstStage.id).id);
|
|
359
|
+
const loopTargetOk = (fromSt, toSt) => !groupOf(toSt.id) || bindsOutputOf(fromSt, toSt);
|
|
360
|
+
const loopWires = []; // { from:{node,port}, to:{node,port}, maxCycles }
|
|
361
|
+
const handled = new Set();
|
|
362
|
+
for (const st of flat) {
|
|
363
|
+
if (!st.selfLoop) continue;
|
|
364
|
+
const b = blockingOut(st);
|
|
365
|
+
const i = b ? loopInputs(st).find((p) => typeOk(b.type, p.type)) : null;
|
|
366
|
+
if (!b || !i) { issues.push({ code: 'BAD_SELF_LOOP', message: `stage "${st.id}" (${st.agent}) cannot loop on itself`, stageId: st.id }); continue; }
|
|
367
|
+
loopWires.push({ from: { node: nodeOf.get(st.id).id, port: b.id }, to: { node: nodeOf.get(st.id).id, port: i.id }, maxCycles: st.selfLoop.maxCycles });
|
|
368
|
+
handled.add(st.id);
|
|
369
|
+
}
|
|
370
|
+
for (const l of loops) {
|
|
371
|
+
const from = flat.find((s) => s.id === l.from);
|
|
372
|
+
const to = flat.find((s) => s.id === l.to);
|
|
373
|
+
const b = from ? blockingOut(from) : null;
|
|
374
|
+
if (!b) { issues.push({ code: 'LOOP_SOURCE', message: `loop from "${l.from}": that stage has no blocking output` }); continue; }
|
|
375
|
+
const i = to ? loopInputs(to).find((p) => typeOk(b.type, p.type)) : null;
|
|
376
|
+
if (!i) { issues.push({ code: 'LOOP_TARGET', message: `loop to "${l.to}": no loop input of type ${b.type}` }); continue; }
|
|
377
|
+
if (!loopTargetOk(from, to)) {
|
|
378
|
+
issues.push({ code: 'LOOP_INTO_GROUP', message: `loop to "${l.to}": ${to.agent} runs inside a parallel group and "${l.from}" (${from.agent}) reads none of its outputs — the group's AND gate would never re-fire; place ${to.agent} before the group, or loop into a stage outside it`, stageId: to.id });
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
loopWires.push({ from: { node: nodeOf.get(from.id).id, port: b.id }, to: { node: nodeOf.get(to.id).id, port: i.id }, maxCycles: l.maxCycles });
|
|
382
|
+
handled.add(from.id);
|
|
383
|
+
}
|
|
384
|
+
flat.forEach((st, idx) => {
|
|
385
|
+
if (handled.has(st.id) || st.loop === false) return;
|
|
386
|
+
const b = blockingOut(st);
|
|
387
|
+
if (!b) return;
|
|
388
|
+
const own = loopInputs(st).find((p) => typeOk(b.type, p.type));
|
|
389
|
+
if (own) {
|
|
390
|
+
loopWires.push({ from: { node: nodeOf.get(st.id).id, port: b.id }, to: { node: nodeOf.get(st.id).id, port: own.id }, maxCycles: DEFAULT_MAX_CYCLES });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
for (let j = idx - 1; j >= 0; j -= 1) {
|
|
394
|
+
const prev = flat[j];
|
|
395
|
+
if (sameGroup(units, st.id, prev.id) || !loopTargetOk(st, prev)) continue; // never a sibling; never a member the source does not read
|
|
396
|
+
const i = loopInputs(prev).find((p) => typeOk(b.type, p.type));
|
|
397
|
+
if (i) {
|
|
398
|
+
loopWires.push({ from: { node: nodeOf.get(st.id).id, port: b.id }, to: { node: nodeOf.get(prev.id).id, port: i.id }, maxCycles: DEFAULT_MAX_CYCLES });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
warnings.push({ code: 'LOOP_UNWIRED', message: `stage "${st.id}" (${st.agent}): its blocking output "${b.id}" has nowhere to loop back to`, stageId: st.id });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// 5b) rule 3b — awaitAll for whole-unit successors. Loop wires are not in the
|
|
406
|
+
// graph yet, so inbound() holds data + await wires only. A self-loop resolves
|
|
407
|
+
// inside the group's own wave, so only loops from OUTSIDE a group into one of its
|
|
408
|
+
// members veto awaitAll on that group's successor (that member re-runs alone; the
|
|
409
|
+
// AND never re-fires; the successor must stay any-fresh — rule 4b makes sure it
|
|
410
|
+
// reads that member's output).
|
|
411
|
+
const externalLoopTargets = new Set(loopWires.filter((lw) => lw.from.node !== lw.to.node).map((lw) => lw.to.node));
|
|
412
|
+
for (const st of flat) {
|
|
413
|
+
const node = nodeOf.get(st.id);
|
|
414
|
+
const prev = predUnit.get(st.id);
|
|
415
|
+
if (!prev || prev.task) continue;
|
|
416
|
+
const prevIds = prev.nodeIds();
|
|
417
|
+
const all = inbound(node.id);
|
|
418
|
+
const fromPrev = all.filter((w) => prevIds.has(w.from.node));
|
|
419
|
+
if (fromPrev.length < 2 || fromPrev.length !== all.length) continue;
|
|
420
|
+
if (prev.group && [...prevIds].some((id) => externalLoopTargets.has(id))) continue;
|
|
421
|
+
node.config.awaitAll = true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const byTarget = new Map();
|
|
425
|
+
for (const lw of loopWires) {
|
|
426
|
+
const k = `${lw.to.node}.${lw.to.port}`;
|
|
427
|
+
if (!byTarget.has(k)) byTarget.set(k, []);
|
|
428
|
+
byTarget.get(k).push(lw);
|
|
429
|
+
}
|
|
430
|
+
// or.in<k> order = the SOURCE stage's position in the shape (the seeds' convention:
|
|
431
|
+
// reviewer on in1, web-ui review on in2), never the order the loops were declared in.
|
|
432
|
+
const stageIndexOfNode = new Map(flat.map((st, i) => [nodeOf.get(st.id).id, i]));
|
|
433
|
+
for (const list of byTarget.values()) {
|
|
434
|
+
list.sort((a, b) => (stageIndexOfNode.get(a.from.node) ?? 0) - (stageIndexOfNode.get(b.from.node) ?? 0));
|
|
435
|
+
if (list.length === 1) { addWire(list[0].from, list[0].to, { maxCycles: list[0].maxCycles }); continue; }
|
|
436
|
+
const or = addNode({ id: mintNode(`or${++gateSeq.or}`), kind: 'or', x: 0, y: 0, config: { arity: list.length } });
|
|
437
|
+
list.forEach((lw, i) => addWire(lw.from, { node: or.id, port: `in${i + 1}` }, { maxCycles: lw.maxCycles }));
|
|
438
|
+
addWire({ node: or.id, port: 'out' }, list[0].to); // always-sourced: no budget (V13)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 6) End
|
|
442
|
+
const done = views[views.length - 1].completion();
|
|
443
|
+
if (done) addWire(done, { node: endNode.id, port: 'result' });
|
|
444
|
+
else issues.push({ code: 'NO_COMPLETION', message: 'the last stage has no output to end the run on' });
|
|
445
|
+
if (issues.length) throw new ShapeError(issues);
|
|
446
|
+
|
|
447
|
+
// 7) layout + validate
|
|
448
|
+
const template = { id: '', name: shape.name, version: TEMPLATE_VERSION, domain: 'coding', nodes, wires };
|
|
449
|
+
const pos = autoLayout(template, ports);
|
|
450
|
+
for (const n of nodes) if (pos[n.id]) { n.x = pos[n.id].x; n.y = pos[n.id].y; }
|
|
451
|
+
const report = validateGraph(template, ports);
|
|
452
|
+
if (!report.ok) throw new ShapeError(report.errors.map((e) => ({ code: `GRAPH_${e.code}`, message: e.message, nodeId: e.nodeId, wireId: e.wireId })));
|
|
453
|
+
warnings.push(...report.warnings.map((w) => ({ code: `GRAPH_${w.code}`, message: w.message, nodeId: w.nodeId, wireId: w.wireId })));
|
|
454
|
+
return { template, warnings, stageToNode, tunables, shape };
|
|
455
|
+
}
|