@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,157 @@
1
+ // src/shared/graph/geometry.mjs
2
+ // THE geometry: card sizing, port anchors and model-driven card/port hit tests.
3
+ // Wire SHAPE lives in route.mjs (the orthogonal router), which imports
4
+ // WIRE_HIT_TOL from here. Framework-free and DOM-free so the whole render path
5
+ // derives from the
6
+ // model's x/y — zero getBoundingClientRect on the pointer path, and every claim
7
+ // is unit-testable without jsdom. style.css consumes these numbers ONLY through
8
+ // the --gv-* custom properties injectGeometry writes, so the CSS box model can
9
+ // never drift from nodeSize.
10
+ export const NODE_W = 220;
11
+ export const HEAD_H = 34;
12
+ export const ROW_H = 24;
13
+ export const SEP_H = 9;
14
+ export const PAD_T = 8.5;
15
+ export const PAD_B = 8;
16
+ export const BORDER = 1.5;
17
+ export const DOT = 10;
18
+ export const FOOT_H = 26;
19
+ export const EXEC_ROW_H = 22;
20
+ /** Sub-agent fan squares per wrapped line. The squares are 7px + 3px gap, so a
21
+ * full line is 16·10 − 3 = 157px (--gv-fan-w), leaving the ×N tail its column. */
22
+ export const FAN_PER_ROW = 16;
23
+ export const FAN_ROW_W = FAN_PER_ROW * 10 - 3;
24
+ export const SNAP = 11;
25
+ export const PORT_HIT_R = 14;
26
+ export const WIRE_HIT_TOL = 6;
27
+ export const ZOOM_MIN = 0.4;
28
+ export const ZOOM_MAX = 1.6;
29
+ export const ZOOM_K = 0.002;
30
+ /** First row centre from the top of the card: 1.5 + 34 + 8.5 + 12. */
31
+ export const ROW0 = BORDER + HEAD_H + PAD_T + ROW_H / 2;
32
+
33
+ /** Every CSS-visible number, as the custom properties style.css reads. */
34
+ export const GEOMETRY_CSS_VARS = Object.freeze({
35
+ '--gv-node-w': `${NODE_W}px`, '--gv-head-h': `${HEAD_H}px`, '--gv-row-h': `${ROW_H}px`,
36
+ '--gv-sep-h': `${SEP_H}px`, '--gv-pad-t': `${PAD_T}px`, '--gv-pad-b': `${PAD_B}px`,
37
+ '--gv-border': `${BORDER}px`, '--gv-dot': `${DOT}px`, '--gv-foot-h': `${FOOT_H}px`,
38
+ '--gv-exec-row-h': `${EXEC_ROW_H}px`, '--gv-fan-w': `${FAN_ROW_W}px`,
39
+ });
40
+
41
+ /** Write the variables onto a host element at mount. Guarded: jsdom hosts and a
42
+ * missing element are both fine (the caller is a renderer, not a validator). */
43
+ export function injectGeometry(el) {
44
+ if (!el || !el.style || typeof el.style.setProperty !== 'function') return;
45
+ for (const [name, value] of Object.entries(GEOMETRY_CSS_VARS)) el.style.setProperty(name, value);
46
+ }
47
+
48
+ const CAPTION_SET = new Set(['task', 'end', 'or']);
49
+ const metaInputs = (ports) => (Array.isArray(ports?.inputs) ? ports.inputs : []).filter((p) => !p?.synthetic);
50
+ const hasAwaitRow = (ports) => (Array.isArray(ports?.inputs) ? ports.inputs : []).some((p) => p?.synthetic);
51
+ const outs = (ports) => (Array.isArray(ports?.outputs) ? ports.outputs : []);
52
+
53
+ /** Zones top to bottom: inputs -> outputs -> await gate (agents) -> caption
54
+ * (task/end/or). A zone is emitted only when NON-EMPTY and a separator sits
55
+ * only BETWEEN emitted zones — that is what reproduces the closed forms and
56
+ * degrades sanely on a 0-input card. */
57
+ function zones(node, ports) {
58
+ const z = [];
59
+ const ins = metaInputs(ports).length;
60
+ if (ins) z.push({ kind: 'in', n: ins });
61
+ if (outs(ports).length) z.push({ kind: 'out', n: outs(ports).length });
62
+ if (hasAwaitRow(ports)) z.push({ kind: 'await', n: 1 });
63
+ if (CAPTION_SET.has(node?.kind)) z.push({ kind: 'cap', n: 1 });
64
+ return z;
65
+ }
66
+
67
+ /** y offset of a zone's FIRST row centre, or null when the zone is not emitted. */
68
+ function zoneTop(node, ports, kind) {
69
+ let y = ROW0;
70
+ for (const z of zones(node, ports)) {
71
+ if (z.kind === kind) return y;
72
+ y += z.n * ROW_H + SEP_H;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** Wrapped lines a fan of `n` squares occupies (the leds are pre-capped upstream). */
78
+ export function fanLines(n) {
79
+ return Math.max(1, Math.ceil((Number(n) || 0) / FAN_PER_ROW));
80
+ }
81
+
82
+ /**
83
+ * @param {{kind:string}} node
84
+ * @param {{inputs:Array, outputs:Array}} ports RESOLVED ports (await included for agents)
85
+ * @param {{footerRows?:number}} [opts] footer LINES: 0 none · 1 collapsed executions
86
+ * strip · more for extra lines (a wrapped fan line, a stacked exec row's extra
87
+ * lines). The first line is FOOT_H tall, every further one EXEC_ROW_H.
88
+ */
89
+ export function nodeSize(node, ports, { footerRows = 0 } = {}) {
90
+ const zs = zones(node, ports);
91
+ const rows = zs.reduce((s, z) => s + z.n, 0);
92
+ const seps = Math.max(0, zs.length - 1);
93
+ const footer = footerRows ? FOOT_H + (footerRows - 1) * EXEC_ROW_H : 0;
94
+ return { w: NODE_W, h: 2 * BORDER + HEAD_H + PAD_T + rows * ROW_H + seps * SEP_H + PAD_B + footer };
95
+ }
96
+
97
+ /** Inputs and the await gate anchor on the LEFT edge, outputs on the RIGHT.
98
+ * The footer is the bottom-most box, so no anchor depends on it. */
99
+ export function portAnchor(node, ports, portId, dir) {
100
+ if (dir === 'in' && portId === 'await' && hasAwaitRow(ports)) {
101
+ return { x: node.x, y: node.y + zoneTop(node, ports, 'await') };
102
+ }
103
+ if (dir === 'in') {
104
+ const i = metaInputs(ports).findIndex((p) => p?.id === portId);
105
+ const top = zoneTop(node, ports, 'in');
106
+ return i < 0 || top === null ? null : { x: node.x, y: node.y + top + ROW_H * i };
107
+ }
108
+ const j = outs(ports).findIndex((p) => p?.id === portId);
109
+ const top = zoneTop(node, ports, 'out');
110
+ return j < 0 || top === null ? null : { x: node.x + NODE_W, y: node.y + top + ROW_H * j };
111
+ }
112
+
113
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
114
+
115
+ /** Snap to the 11px half-grid (the 22px dot grid's half step). DRAG only —
116
+ * loaded templates render at their authored positions, unsnapped. */
117
+ export function snap(value, grid = SNAP) {
118
+ return Math.round(value / grid) * grid;
119
+ }
120
+
121
+ export function hitNode(node, size, pt) {
122
+ return pt.x >= node.x && pt.x <= node.x + size.w && pt.y >= node.y && pt.y <= node.y + size.h;
123
+ }
124
+
125
+ export function hitPort(anchor, pt, r = PORT_HIT_R) {
126
+ return Math.hypot(pt.x - anchor.x, pt.y - anchor.y) <= r;
127
+ }
128
+
129
+ /** The union of the card boxes, optionally padded. `footerRowsOf(node)` lets the
130
+ * run monitor fit an expanded executions footer. null when there is nothing. */
131
+ export function graphBounds(tpl, portsFn, { pad = 0, footerRowsOf } = {}) {
132
+ // OBJECTS only: `filter(Boolean)` kept a truthy non-object (`7`), sized it as a
133
+ // card at the origin and stretched the bounds of every fit built from it.
134
+ const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : [])
135
+ .filter((n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n));
136
+ if (!nodes.length) return null;
137
+ let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
138
+ for (const node of nodes) {
139
+ const ports = (typeof portsFn === 'function' ? portsFn(node) : null) || { inputs: [], outputs: [] };
140
+ const size = nodeSize(node, ports, { footerRows: footerRowsOf ? footerRowsOf(node) : 0 });
141
+ const x = Number(node.x) || 0;
142
+ const y = Number(node.y) || 0;
143
+ minX = Math.min(minX, x); minY = Math.min(minY, y);
144
+ maxX = Math.max(maxX, x + size.w); maxY = Math.max(maxY, y + size.h);
145
+ }
146
+ return { x: minX - pad, y: minY - pad, w: maxX - minX + 2 * pad, h: maxY - minY + 2 * pad };
147
+ }
148
+
149
+ /** Fit `bounds` into a viewport: `screen = world·z + t`. zoomMax defaults to 1 —
150
+ * auto-fit NEVER magnifies past 1x (spec §7.6). */
151
+ export function fitBounds(bounds, viewport, { zoomMin = ZOOM_MIN, zoomMax = 1 } = {}) {
152
+ const width = Number(viewport?.width) || 0;
153
+ const height = Number(viewport?.height) || 0;
154
+ if (!bounds || !(bounds.w > 0) || !(bounds.h > 0) || width <= 0 || height <= 0) return { z: 1, tx: 0, ty: 0 };
155
+ const z = clamp(Math.min(width / bounds.w, height / bounds.h), zoomMin, zoomMax);
156
+ return { z, tx: (width - bounds.w * z) / 2 - bounds.x * z, ty: (height - bounds.h * z) / 2 - bounds.y * z };
157
+ }
@@ -0,0 +1,134 @@
1
+ // src/shared/graph/layout.mjs
2
+ // Auto-layout for the composer's header button: longest-path ranks with LOOP
3
+ // WIRES EXCLUDED, columns at x = 60 + rank*320, barycenter ordering inside each
4
+ // column, y stacked so no two cards touch and snapped to the 11px grid.
5
+ // Deterministic by construction — same template in, same positions out, and
6
+ // re-running over an already laid-out template reproduces it exactly.
7
+ import { classifyLoops } from './loops.mjs';
8
+ import { nodeSize, snap } from './geometry.mjs';
9
+
10
+ export const RANK_X0 = 60;
11
+ export const RANK_DX = 320; // 100px column gap: two 24px clearance bands + laned verticals
12
+ export const RANK_Y0 = 60;
13
+ export const ROW_GAP = 64; // > 2×ROUTE_CLEARANCE so a wire corridor survives between stacked cards
14
+ const SWEEPS = 2;
15
+
16
+ /** A layoutable node: an object with a string id. `filter(Boolean)` let a truthy
17
+ * non-object through and indexed an id-less node under `undefined`, so a wire
18
+ * with a missing endpoint resolved through it and threw. */
19
+ const isNode = (n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n) && typeof n.id === 'string';
20
+
21
+ /** @param {object} tpl @param {{loopWireIds:Set<string>}} loops classifyLoops() output */
22
+ export function rankNodes(tpl, loops) {
23
+ const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
24
+ const ids = new Set(nodes.map((n) => n.id));
25
+ const edges = nonLoopEdges(tpl, loops, ids);
26
+
27
+ const rank = {};
28
+ const indegree = {};
29
+ for (const id of ids) { rank[id] = 0; indegree[id] = 0; }
30
+ const out = new Map();
31
+ for (const e of edges) {
32
+ if (!out.has(e.from)) out.set(e.from, []);
33
+ out.get(e.from).push(e.to);
34
+ indegree[e.to] += 1;
35
+ }
36
+ // Kahn with a SORTED frontier: ties break by node id, so the walk order — and
37
+ // therefore the result — never depends on declaration order.
38
+ const ready = [...ids].filter((id) => indegree[id] === 0).sort();
39
+ const settled = new Set();
40
+ while (ready.length) {
41
+ const id = ready.shift();
42
+ settled.add(id);
43
+ for (const next of out.get(id) || []) {
44
+ rank[next] = Math.max(rank[next], rank[id] + 1);
45
+ indegree[next] -= 1;
46
+ if (indegree[next] === 0) insertSorted(ready, next);
47
+ }
48
+ }
49
+ // A residual cycle survives only when no loop wire cuts it (V10 blocks SAVING
50
+ // such a graph, but the editor still has to draw it). Rank the leftovers from
51
+ // whatever settled feeds them, in id order — bounded, never a hang.
52
+ for (const id of [...ids].filter((n) => !settled.has(n)).sort()) {
53
+ rank[id] = edges.filter((e) => e.to === id && settled.has(e.from))
54
+ .reduce((best, e) => Math.max(best, rank[e.from] + 1), 0);
55
+ settled.add(id);
56
+ }
57
+ return rank;
58
+ }
59
+
60
+ /** @returns {{[nodeId:string]: {x:number, y:number}}} — the caller applies them. */
61
+ export function autoLayout(tpl, portsFn, { x0 = RANK_X0, dx = RANK_DX, y0 = RANK_Y0, gap = ROW_GAP } = {}) {
62
+ const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
63
+ const loops = classifyLoops(tpl, portsFn);
64
+ const rank = rankNodes(tpl, loops);
65
+ const ids = new Set(nodes.map((n) => n.id));
66
+ const edges = nonLoopEdges(tpl, loops, ids);
67
+
68
+ const columns = new Map();
69
+ for (const n of nodes) {
70
+ if (!columns.has(rank[n.id])) columns.set(rank[n.id], []);
71
+ columns.get(rank[n.id]).push(n.id);
72
+ }
73
+ const ranksAscending = [...columns.keys()].sort((a, b) => a - b);
74
+
75
+ // Barycenter: sweep left to right, ordering each column by the mean row index
76
+ // of its predecessors. A node with no ranked predecessor keeps its index, so
77
+ // the pass is stable.
78
+ const preds = new Map();
79
+ for (const e of edges) {
80
+ if (!preds.has(e.to)) preds.set(e.to, []);
81
+ preds.get(e.to).push(e.from);
82
+ }
83
+ for (let sweep = 0; sweep < SWEEPS; sweep += 1) {
84
+ for (const r of ranksAscending) {
85
+ const rowOf = new Map();
86
+ for (const prevRank of ranksAscending) {
87
+ if (prevRank >= r) break;
88
+ columns.get(prevRank).forEach((id, i) => rowOf.set(id, i));
89
+ }
90
+ const keyed = columns.get(r).map((id, i) => ({ id, i, bary: barycenter(preds.get(id), rowOf, i) }));
91
+ keyed.sort((a, b) => a.bary - b.bary || a.i - b.i);
92
+ columns.set(r, keyed.map((k) => k.id));
93
+ }
94
+ }
95
+
96
+ const byId = new Map(nodes.map((n) => [n.id, n]));
97
+ const positions = {};
98
+ for (const r of ranksAscending) {
99
+ let cursor = y0;
100
+ for (const id of columns.get(r)) {
101
+ const node = byId.get(id);
102
+ const ports = (typeof portsFn === 'function' ? portsFn(node) : null) || { inputs: [], outputs: [] };
103
+ const { h } = nodeSize(node, ports);
104
+ const y = snap(cursor);
105
+ positions[id] = { x: x0 + r * dx, y };
106
+ cursor = y + h + gap; // stack from the SNAPPED row: idempotent
107
+ }
108
+ }
109
+ return positions;
110
+ }
111
+
112
+ function nonLoopEdges(tpl, loops, ids) {
113
+ const loopWireIds = loops?.loopWireIds instanceof Set ? loops.loopWireIds : new Set();
114
+ return (Array.isArray(tpl?.wires) ? tpl.wires : [])
115
+ .filter((w) => ids.has(w?.from?.node) && ids.has(w?.to?.node)
116
+ && w.from.node !== w.to.node && !loopWireIds.has(w.id))
117
+ .map((w) => ({ from: w.from.node, to: w.to.node }));
118
+ }
119
+
120
+ function barycenter(predecessors, rowOf, fallback) {
121
+ const rows = (predecessors || []).map((id) => rowOf.get(id)).filter((row) => row !== undefined);
122
+ if (!rows.length) return fallback;
123
+ return rows.reduce((sum, row) => sum + row, 0) / rows.length;
124
+ }
125
+
126
+ function insertSorted(list, id) {
127
+ let lo = 0;
128
+ let hi = list.length;
129
+ while (lo < hi) {
130
+ const mid = (lo + hi) >> 1;
131
+ if (list[mid] < id) lo = mid + 1; else hi = mid;
132
+ }
133
+ list.splice(lo, 0, id);
134
+ }
@@ -0,0 +1,130 @@
1
+ // src/shared/graph/loops.mjs
2
+ // Loop classification and launch order. Two orthogonal concepts (base spec §2):
3
+ // loop WIRE (budget + amber styling) = both endpoints in ONE nontrivial SCC
4
+ // (or a self-wire) AND the source output is `when:'blocking'`.
5
+ // loop INPUT (firing semantics) = an input declared `loop:true` in meta;
6
+ // wiring-independent, so an unwired one is still in the set.
7
+ // Pure and crash-free: dangling endpoints and unknown agent keys are the
8
+ // validator's errors (V5/V4), so they are filtered, never thrown on.
9
+ import { portsOf, findPort } from './ports.mjs';
10
+
11
+ /**
12
+ * Iterative Tarjan (an explicit stack — recursion depth is not a property worth
13
+ * depending on). Roots are visited in sorted id order and every component is
14
+ * returned sorted, so the result is reproducible run to run.
15
+ * @param {string[]} ids
16
+ * @param {Array<{from:string, to:string}>} edges
17
+ * @returns {string[][]}
18
+ */
19
+ export function tarjanSccs(ids, edges) {
20
+ const adj = new Map((Array.isArray(ids) ? ids : []).map((id) => [id, []]));
21
+ for (const e of Array.isArray(edges) ? edges : []) {
22
+ if (adj.has(e?.from) && adj.has(e?.to)) adj.get(e.from).push(e.to);
23
+ }
24
+ for (const tos of adj.values()) tos.sort();
25
+ const index = new Map(); const low = new Map(); const onStack = new Set();
26
+ const stack = []; const sccs = []; let counter = 0;
27
+ for (const root of [...adj.keys()].sort()) {
28
+ if (index.has(root)) continue;
29
+ index.set(root, counter); low.set(root, counter); counter += 1;
30
+ stack.push(root); onStack.add(root);
31
+ const work = [{ id: root, edges: adj.get(root) || [], at: 0 }];
32
+ while (work.length) {
33
+ const frame = work[work.length - 1];
34
+ if (frame.at < frame.edges.length) {
35
+ const next = frame.edges[frame.at];
36
+ frame.at += 1;
37
+ if (!index.has(next)) {
38
+ index.set(next, counter); low.set(next, counter); counter += 1;
39
+ stack.push(next); onStack.add(next);
40
+ work.push({ id: next, edges: adj.get(next) || [], at: 0 });
41
+ } else if (onStack.has(next)) {
42
+ low.set(frame.id, Math.min(low.get(frame.id), index.get(next)));
43
+ }
44
+ continue;
45
+ }
46
+ work.pop();
47
+ if (work.length) {
48
+ const parent = work[work.length - 1].id;
49
+ low.set(parent, Math.min(low.get(parent), low.get(frame.id)));
50
+ }
51
+ if (low.get(frame.id) === index.get(frame.id)) {
52
+ const scc = [];
53
+ for (;;) { const id = stack.pop(); onStack.delete(id); scc.push(id); if (id === frame.id) break; }
54
+ sccs.push(scc.sort());
55
+ }
56
+ }
57
+ }
58
+ return sccs;
59
+ }
60
+
61
+ /**
62
+ * @param {object} tpl v2 template { nodes, wires }
63
+ * @param {(node:object) => object|undefined} portsFn
64
+ * @returns {{loopWireIds:Set<string>, loopInputs:Set<string>, sccOf:Map<string,number>, launchOrder:string[]}}
65
+ * `loopInputs` is graph-global (`'<nodeId>.<port>'`).
66
+ */
67
+ export function classifyLoops(tpl, portsFn) {
68
+ const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : [])
69
+ .filter((n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n));
70
+ const byId = new Map();
71
+ // STRING ids only — an id-less node indexed under `undefined` would make the
72
+ // wire filter below accept a wire with a missing `from`/`to`, and the map on
73
+ // the next line would throw on it (V2/V5 are the validator's to report).
74
+ for (const n of nodes) if (typeof n.id === 'string' && !byId.has(n.id)) byId.set(n.id, n);
75
+ const wires = (Array.isArray(tpl?.wires) ? tpl.wires : [])
76
+ .filter((w) => byId.has(w?.from?.node) && byId.has(w?.to?.node));
77
+
78
+ const ids = [...byId.keys()];
79
+ const sccs = tarjanSccs(ids, wires.map((w) => ({ from: w.from.node, to: w.to.node })));
80
+ const sccOf = new Map();
81
+ sccs.forEach((scc, i) => scc.forEach((id) => sccOf.set(id, i)));
82
+ const nontrivial = new Set(sccs.map((scc, i) => (scc.length > 1 ? i : -1)).filter((i) => i >= 0));
83
+ const selfWired = new Set(wires.filter((w) => w.from.node === w.to.node).map((w) => w.from.node));
84
+
85
+ const loopWireIds = new Set();
86
+ for (const w of wires) {
87
+ const a = sccOf.get(w.from.node);
88
+ if (a === undefined || a !== sccOf.get(w.to.node)) continue;
89
+ if (!nontrivial.has(a) && !selfWired.has(w.from.node)) continue;
90
+ const out = findPort(portsOf(portsFn, byId.get(w.from.node)), w.from.port, 'out');
91
+ if (out && out.when === 'blocking') loopWireIds.add(w.id);
92
+ }
93
+
94
+ const loopInputs = new Set();
95
+ for (const n of nodes) {
96
+ for (const inp of portsOf(portsFn, n).inputs) {
97
+ if (inp?.loop) loopInputs.add(`${n.id}.${inp.id}`);
98
+ }
99
+ }
100
+
101
+ return { loopWireIds, loopInputs, sccOf, launchOrder: condensationTopo(sccs, wires) };
102
+ }
103
+
104
+ /** Kahn over the condensation: ties break by the component's minimum node id
105
+ * (members are sorted), which is what makes the launch order reproducible.
106
+ * Parallel wires collapse to one condensation edge so in-degrees stay balanced. */
107
+ function condensationTopo(sccs, wires) {
108
+ const sccOf = new Map();
109
+ sccs.forEach((scc, i) => scc.forEach((id) => sccOf.set(id, i)));
110
+ const succ = sccs.map(() => new Set());
111
+ const indegree = sccs.map(() => 0);
112
+ for (const w of wires) {
113
+ const a = sccOf.get(w.from.node);
114
+ const b = sccOf.get(w.to.node);
115
+ if (a === undefined || b === undefined || a === b || succ[a].has(b)) continue;
116
+ succ[a].add(b);
117
+ indegree[b] += 1;
118
+ }
119
+ const remaining = new Set(sccs.map((_, i) => i));
120
+ const order = [];
121
+ while (remaining.size) {
122
+ let pick = -1;
123
+ for (const i of remaining) if (indegree[i] === 0 && (pick < 0 || sccs[i][0] < sccs[pick][0])) pick = i;
124
+ if (pick < 0) break; // acyclic by construction — defensive only
125
+ remaining.delete(pick);
126
+ order.push(...sccs[pick]);
127
+ for (const b of succ[pick]) indegree[b] -= 1;
128
+ }
129
+ return order;
130
+ }
@@ -0,0 +1,257 @@
1
+ // src/shared/graph/manifest.mjs
2
+ // The run-start snapshot persisted as pipelines.stepper. SELF-SUFFICIENT by
3
+ // design: History renders it when the registry is gone or edited, so nothing
4
+ // here may be re-resolved later. Built once per run (and by resume()), NEVER
5
+ // rewritten mid-run — fan-out lives in the execution ledger, not the manifest.
6
+ //
7
+ // It also carries DERIVED `steps` cells and `feedbacks` in the shape the v1
8
+ // buildStepperManifest used to produce. P1's handoff said P8 would delete them;
9
+ // it does NOT, and deliberately: they have LIVE v2 readers (findManifestNode,
10
+ // cycleAwareLabel, nodeLabelLookup, manifestStepsForWires, loopCounts), so they
11
+ // are a real part of the v2 manifest now, not a shim. What made them safe is
12
+ // manifestFor() returning an EMPTY manifest instead of the v1 default seven.
13
+ // UI_PHASE survives for the same reason: `:116` stamps `uiPhase` on every v2
14
+ // agent cell, and the sub_agents.ui_phase attribution column still needs it. The
15
+ // workflows.mjs copy is gone (the v1 topology helpers went with the v1 engine),
16
+ // so THIS is the only copy — shared code may not import workflows.mjs.
17
+ import { TEMPLATE_VERSION, AWAIT_PORT, DEFAULT_MAX_CYCLES, FLOW_LABEL } from './constants.mjs';
18
+ import { portsFnFor, portsOf, resolveOrOutType } from './ports.mjs';
19
+ import { classifyLoops } from './loops.mjs';
20
+ import { rankNodes } from './layout.mjs';
21
+
22
+ /** Agent key -> the UI stepper bucket. The only copy: the v1 original left
23
+ * workflows.mjs with the topology helpers. */
24
+ export const UI_PHASE = Object.freeze({
25
+ clarify: 'clarify',
26
+ planner: 'plan', refiner: 'refine', decomposer: 'decompose', implementer: 'implement', reviewer: 'review',
27
+ manualTestsChecklist: 'manual-checklist', manualWebUiTesting: 'manual-web', planReviewer: 'plan-review',
28
+ workspaceReviewer: 'review',
29
+ });
30
+
31
+ const ICON_MAX = 2048;
32
+ /** The icon is an ALLOWLIST, not a denylist: a sidecar can be user- or
33
+ * plugin-authored (exactly what v1's UI refuses to inline — `safeAgentIcon`),
34
+ * the markup rides the manifest into innerHTML, and the manifest is persisted,
35
+ * so it outlives any renderer-side fix. A denylist of `on…=` handlers misses
36
+ * `/`, `"` and `'` as attribute separators and every entity encoding; these
37
+ * three tables cover the whole shipped icon vocabulary instead. */
38
+ const ICON_TAGS = new Set(['path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', 'g']);
39
+ const ICON_ATTRS = new Set(['d', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2',
40
+ 'width', 'height', 'points', 'transform', 'opacity', 'fill', 'fill-rule', 'fill-opacity',
41
+ 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray',
42
+ 'stroke-dashoffset', 'stroke-miterlimit', 'vector-effect', 'clip-rule']);
43
+ // Both alternations are UNAMBIGUOUS at every position (the bare class excludes
44
+ // the quotes), so neither can backtrack quadratically on a malformed icon.
45
+ const ICON_TAG_RE = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g;
46
+ const ICON_ATTR_RE = /([a-zA-Z][a-zA-Z0-9:-]*)\s*=\s*("[^"]*"|'[^']*')/g;
47
+
48
+ /** Inline SVG markup rides the manifest into innerHTML, so anything outside the
49
+ * allowlist is dropped WHOLE (never truncated — a half tag is worse).
50
+ * EXPORTED (C-2): the composer's canvas reaches the SAME untrusted field through
51
+ * GET /api/agents rather than through a manifest, and it had only an
52
+ * `origin === 'user'` denylist — so a plugin sidecar's icon went to innerHTML
53
+ * verbatim. One allowlist, both paths. Idempotent: a string that passes is
54
+ * returned unchanged, so sanitizing an already-sanitized icon is a no-op. */
55
+ export function sanitizeIcon(raw) {
56
+ const s = typeof raw === 'string' ? raw.trim() : '';
57
+ if (!s || s.length > ICON_MAX) return '';
58
+ let cursor = 0;
59
+ ICON_TAG_RE.lastIndex = 0;
60
+ for (let m; (m = ICON_TAG_RE.exec(s));) {
61
+ if (s.slice(cursor, m.index).trim()) return ''; // a text node between tags
62
+ cursor = m.index + m[0].length;
63
+ if (!ICON_TAGS.has(m[2].toLowerCase())) return '';
64
+ if (m[1]) { if (m[3].trim()) return ''; continue; } // a closing tag carries nothing
65
+ if (!iconAttrsOk(m[3])) return '';
66
+ }
67
+ return cursor === s.length ? s : ''; // trailing text or an unclosed tag
68
+ }
69
+
70
+ /** Every attribute quoted, named in ICON_ATTRS, and free of `<`/`&` — an
71
+ * entity-encoded `javascript:` decodes only AFTER the filter would have run. */
72
+ function iconAttrsOk(rawAttrs) {
73
+ const attrs = rawAttrs.replace(/\/\s*$/, ''); // the self-closing slash
74
+ let cursor = 0;
75
+ ICON_ATTR_RE.lastIndex = 0;
76
+ for (let m; (m = ICON_ATTR_RE.exec(attrs));) {
77
+ if (attrs.slice(cursor, m.index).trim()) return false; // a bare or unquoted attribute
78
+ cursor = m.index + m[0].length;
79
+ if (!ICON_ATTRS.has(m[1].toLowerCase())) return false;
80
+ if (/[<&]/.test(m[2])) return false;
81
+ }
82
+ return !attrs.slice(cursor).trim();
83
+ }
84
+
85
+ const isNode = (n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n) && typeof n.id === 'string';
86
+ const isWire = (w) => Boolean(w) && typeof w === 'object' && !Array.isArray(w)
87
+ && typeof w?.from?.node === 'string' && typeof w?.to?.node === 'string';
88
+
89
+ /**
90
+ * @param {object} tpl resolved v2 template
91
+ * @param {Record<string,object>} agentsByKey merged registry metas
92
+ * @param {{overlays?:{nodes?:object, wires?:object}}} [opts] effective per-node config + per-wire budgets
93
+ */
94
+ export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
95
+ const overlays = opts.overlays || {};
96
+ const nodeOverlays = overlays.nodes || {};
97
+ const wireOverlays = overlays.wires || {};
98
+ const portsFn = portsFnFor(agentsByKey);
99
+ // Objects with real endpoints only. `filter(Boolean)` kept a truthy non-object
100
+ // node and an endpoint-less wire, and `w.from.node` threw one map later — the
101
+ // manifest is built from an ALREADY validated template, but it is also built
102
+ // by resume() from a persisted one, so it degrades instead of crashing.
103
+ const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
104
+ const wires = (Array.isArray(tpl?.wires) ? tpl.wires : []).filter(isWire);
105
+ const loops = classifyLoops(tpl, portsFn);
106
+ const ranks = rankNodes(tpl, loops);
107
+ const launchIndex = new Map(loops.launchOrder.map((id, i) => [id, i]));
108
+ const isWired = new Set(wires.map((w) => `${w?.to?.node}.${w?.to?.port}`));
109
+
110
+ const manifestNodes = nodes.map((node) => {
111
+ const resolved = portsOf(portsFn, node);
112
+ const meta = node.kind === 'agent' ? (agentsByKey?.[node.key] || null) : null;
113
+ const over = nodeOverlays[node.id] || {};
114
+ const cfg = node.config || {};
115
+ const outType = (port) => (node.kind === 'or' && (!port.type || port.type === 'any')
116
+ ? (resolveOrOutType(tpl, portsFn, node.id) || 'any') : port.type);
117
+ const cell = {
118
+ id: node.id,
119
+ kind: node.kind,
120
+ key: node.kind === 'agent' ? node.key : null,
121
+ x: Number(node.x) || 0,
122
+ y: Number(node.y) || 0,
123
+ label: node.kind === 'agent' ? (meta?.displayName || node.key) : (FLOW_LABEL[node.kind] || node.kind),
124
+ // The v1 stepper bucket. UI_PHASE knows the 11 builtins; a custom agent
125
+ // buckets under its own key (the sidecar's `uiPhase` died with the v1
126
+ // vocabulary). The whole field goes with the phase shim in Task 16.
127
+ uiPhase: node.kind === 'agent' ? (UI_PHASE[node.key] || node.key) : node.kind,
128
+ // The AUTHORED config, verbatim and complete (unknown keys included —
129
+ // V17's "preserved and ignored" promise). The manifest is the ONLY
130
+ // persisted copy of the topology: P4 rebuilds the template from it on a
131
+ // resume and never re-reads the workflow row, so anything dropped here
132
+ // (`planStoreSeed`, a future tunable) is lost for the rest of the run.
133
+ config: { ...(node.config || {}) },
134
+ ports: {
135
+ inputs: resolved.inputs.filter((p) => !p.synthetic).map((p) => ({
136
+ id: p.id, type: p.type, required: p.required !== false, loop: !!p.loop, expands: !!p.expands })),
137
+ outputs: resolved.outputs.map((p) => ({ id: p.id, type: outType(p), when: p.when || 'always' })),
138
+ await: resolved.inputs.some((p) => p.synthetic),
139
+ },
140
+ };
141
+ if (node.kind === 'agent') {
142
+ cell.color = meta?.color || '';
143
+ cell.icon = sanitizeIcon(meta?.icon);
144
+ cell.model = over.model ?? cfg.model ?? '';
145
+ cell.effort = over.effort ?? cfg.effort ?? '';
146
+ cell.askQuestions = !!(over.askQuestions ?? cfg.askQuestions ?? meta?.questionsDefault ?? false);
147
+ cell.awaitAll = !!(over.awaitAll ?? cfg.awaitAll ?? false);
148
+ cell.fanOut = !!(over.fanOut ?? cfg.fanOut ?? meta?.fanOut ?? false);
149
+ // Sub-agent model policy ('' = inherit). Recorded like model/effort so a
150
+ // RESUMED run rebuilds the same child-model behavior from the manifest
151
+ // alone — the workflow row is never re-read after the run starts.
152
+ cell.subagentModel = over.subagentModel ?? cfg.subagentModel ?? '';
153
+ }
154
+ if (node.kind === 'and' || node.kind === 'or' || node.kind === 'combine') {
155
+ cell.arity = Number.isInteger(cfg.arity) ? cfg.arity : 2;
156
+ }
157
+ return cell;
158
+ });
159
+
160
+ const manifestWires = wires.map((w) => {
161
+ const loop = loops.loopWireIds.has(w.id);
162
+ const cell = { id: w.id, from: { node: w.from.node, port: w.from.port },
163
+ to: { node: w.to.node, port: w.to.port }, loop };
164
+ // maxCycles rides LOOP wires only: overlay > authored > default.
165
+ if (loop) cell.maxCycles = coerceCycles(wireOverlays[w.id]?.maxCycles ?? w.config?.maxCycles);
166
+ return cell;
167
+ });
168
+
169
+ // ── the v1 shim (P4-P7; deleted in P8) ─────────────────────────────────────
170
+ const byRank = new Map();
171
+ for (const node of manifestNodes) {
172
+ const r = ranks[node.id] ?? 0;
173
+ if (!byRank.has(r)) byRank.set(r, []);
174
+ byRank.get(r).push(node);
175
+ }
176
+ const agentCells = [...byRank.keys()].sort((a, b) => a - b).map((r) => ({
177
+ kind: 'agents',
178
+ nodes: byRank.get(r)
179
+ .sort((a, b) => (launchIndex.get(a.id) ?? 0) - (launchIndex.get(b.id) ?? 0))
180
+ .map((n) => ({
181
+ id: n.id,
182
+ key: n.key,
183
+ uiPhase: n.uiPhase,
184
+ label: n.label,
185
+ color: n.color || '',
186
+ sub: (n.key && agentsByKey?.[n.key]?.description) || '',
187
+ cycles: n.ports.inputs.some((p) => p.loop && isWired.has(`${n.id}.${p.id}`)),
188
+ model: n.model || '',
189
+ effort: n.effort || '',
190
+ })),
191
+ }));
192
+
193
+ return {
194
+ version: 2,
195
+ template: { id: tpl?.id ?? '', name: tpl?.name ?? '' },
196
+ graph: { nodes: manifestNodes, wires: manifestWires },
197
+ bookends: { preflight: true, done: true },
198
+ steps: [
199
+ { kind: 'preflight', nodes: [{ id: 'preflight', label: 'Preflight', sub: 'checks' }] },
200
+ ...agentCells,
201
+ { kind: 'done', nodes: [{ id: 'done', label: 'Done', sub: 'complete' }] },
202
+ ],
203
+ feedbacks: manifestWires.filter((w) => w.loop)
204
+ .map((w) => ({ id: w.id, from: w.from.node, to: w.to.node, maxCycles: w.maxCycles })),
205
+ };
206
+ }
207
+
208
+ function coerceCycles(value) {
209
+ const n = Math.floor(Number(value));
210
+ return Number.isFinite(n) && n >= 1 ? n : DEFAULT_MAX_CYCLES;
211
+ }
212
+
213
+ /** A portsFn over a MANIFEST — the run monitor never touches the live registry.
214
+ * The await port is re-synthesized from the boolean so geometry, validation and
215
+ * hit-testing behave exactly as they do in the composer. */
216
+ export function manifestPortsFn(manifest) {
217
+ const byId = new Map((manifest?.graph?.nodes || []).map((n) => [n.id, n]));
218
+ return (node) => {
219
+ const cell = byId.get(node?.id);
220
+ if (!cell) return undefined;
221
+ return {
222
+ known: true,
223
+ ported: true,
224
+ inputs: cell.ports.await ? [...cell.ports.inputs, AWAIT_PORT] : [...cell.ports.inputs],
225
+ outputs: [...cell.ports.outputs],
226
+ displayName: cell.label,
227
+ color: cell.color,
228
+ icon: cell.icon,
229
+ // A verdict-bearing node is one with a conditional output — enough for V13
230
+ // and firedOutputs; the filename itself never leaves the engine.
231
+ verdict: cell.ports.outputs.some((p) => p.when && p.when !== 'always') ? { filename: '' } : undefined,
232
+ };
233
+ };
234
+ }
235
+
236
+ /** The renderable template inside a manifest (id/kind/x/y/config + wires). */
237
+ export function manifestTemplate(manifest) {
238
+ return {
239
+ id: manifest?.template?.id ?? '',
240
+ name: manifest?.template?.name ?? '',
241
+ version: TEMPLATE_VERSION,
242
+ domain: '',
243
+ nodes: (manifest?.graph?.nodes || []).map((n) => {
244
+ // `config` is restored VERBATIM from the cell — no per-key rebuild. The
245
+ // old `{arity, awaitAll}` reconstruction silently dropped everything else
246
+ // (`planStoreSeed` on the Task card of `wf_provided-plan`, for one).
247
+ const node = { id: n.id, kind: n.kind, x: n.x, y: n.y, config: { ...(n.config || {}) } };
248
+ if (n.kind === 'agent') node.key = n.key;
249
+ return node;
250
+ }),
251
+ wires: (manifest?.graph?.wires || []).map((w) => {
252
+ const wire = { id: w.id, from: { ...w.from }, to: { ...w.to } };
253
+ if (w.loop && w.maxCycles !== undefined) wire.config = { maxCycles: w.maxCycles };
254
+ return wire;
255
+ }),
256
+ };
257
+ }