@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,249 @@
|
|
|
1
|
+
// src/shared/graph/flow-layout.mjs
|
|
2
|
+
// The FLOW layout + its channel router: composer cards at a fixed scale in rows
|
|
3
|
+
// of `perRow`, in dispatch order, with orthogonal wires routed only through the
|
|
4
|
+
// card-free vertical channels (left pad, the column gaps, right pad) and the
|
|
5
|
+
// horizontal gutters between rows. Pure and DOM-free like layout.mjs; the view
|
|
6
|
+
// applies the positions and paints the routes. No agent key is read (D23).
|
|
7
|
+
// A port of the 2026-09-05 mockup's layoutFlow()/routeAll() (docs/superpowers/mockups).
|
|
8
|
+
import { NODE_W, nodeSize, portAnchor } from './geometry.mjs';
|
|
9
|
+
import { rankNodes } from './layout.mjs';
|
|
10
|
+
import { classifyLoops } from './loops.mjs';
|
|
11
|
+
import { portsOf } from './ports.mjs';
|
|
12
|
+
|
|
13
|
+
export const FLOW_SCALE = 0.65;
|
|
14
|
+
export const FLOW_PAD = 20; // host padding on X, NOT scaled
|
|
15
|
+
export const FLOW_PAD_Y = 28; // host padding above the first row and under whatever is painted last
|
|
16
|
+
export const FLOW_GAP = 40; // column gap at 1× (× scale)
|
|
17
|
+
export const FLOW_ROW_GAP = 44; // row gap at 1× (× scale)
|
|
18
|
+
export const FLOW_MIN_H = 120;
|
|
19
|
+
export const FLOW_DEFAULT_WIDTH = 702; // the chat sheet's inner width; used when a host has no layout width yet
|
|
20
|
+
export const FLOW_LANE = 6; // lane pitch inside gaps and gutters
|
|
21
|
+
export const FLOW_PAD_LANE = 5; // lane pitch inside the pads
|
|
22
|
+
export const FLOW_GUT_ROOM = 12; // lane room in the BOTTOM gutter (3 lanes at the nominal pitch, tighter after)
|
|
23
|
+
export const FLOW_BADGE_H = 18; // the wire badge's painted box (10px/14px line + scaled padding + borders)
|
|
24
|
+
export const FLOW_RADIUS = 6; // wire corner radius
|
|
25
|
+
|
|
26
|
+
const isNode = (n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n) && typeof n.id === 'string';
|
|
27
|
+
const portsAt = (portsFn, node) => portsOf(portsFn, node); // never null: {known, ported, inputs, outputs, meta}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Dispatch order: Task cards → agents and the gates ranked among them → gates fed
|
|
31
|
+
* ONLY by loop wires (the valves) → End. Inside the middle class the key is the
|
|
32
|
+
* graph rank FIRST (so a combine/and gate stays inside its parallel group), then the
|
|
33
|
+
* host-given index (a proposal's `order[]`, rank-consistent by construction), then id.
|
|
34
|
+
*/
|
|
35
|
+
export function flowOrder(tpl, portsFn, { agentOrder = null } = {}) {
|
|
36
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
|
|
37
|
+
const loops = classifyLoops(tpl, portsFn);
|
|
38
|
+
const rank = rankNodes(tpl, loops); // plain object {[id]: number}
|
|
39
|
+
const nonLoopIn = new Map();
|
|
40
|
+
for (const w of Array.isArray(tpl?.wires) ? tpl.wires : []) {
|
|
41
|
+
if (!w?.to?.node || loops.loopWireIds.has(w.id)) continue;
|
|
42
|
+
nonLoopIn.set(w.to.node, (nonLoopIn.get(w.to.node) || 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
const given = new Map((Array.isArray(agentOrder) ? agentOrder : []).map((id, i) => [id, i]));
|
|
45
|
+
const cls = (n) => (n.kind === 'task' ? 0 : n.kind === 'end' ? 3 : (n.kind !== 'agent' && !nonLoopIn.get(n.id) ? 2 : 1));
|
|
46
|
+
const key = (n) => [cls(n), rank[n.id] ?? 0, given.has(n.id) ? given.get(n.id) : Number.MAX_SAFE_INTEGER, n.id];
|
|
47
|
+
return nodes.map((n) => ({ n, k: key(n) })).sort((a, b) => {
|
|
48
|
+
for (let i = 0; i < 4; i += 1) if (a.k[i] !== b.k[i]) return a.k[i] < b.k[i] ? -1 : 1;
|
|
49
|
+
return 0;
|
|
50
|
+
}).map((x) => x.n.id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** perRow for a host width: max(1, ⌊(W − 2·pad + gap) / (cardW + gap)⌋). */
|
|
54
|
+
export function flowPerRow(width, { scale = FLOW_SCALE, pad = FLOW_PAD, gap = FLOW_GAP } = {}) {
|
|
55
|
+
const cw = NODE_W * scale;
|
|
56
|
+
const g = gap * scale;
|
|
57
|
+
return Math.max(1, Math.floor(((Number(width) || 0) - 2 * pad + g) / (cw + g)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How many lanes can the LAST row's gutter hold at most? Only a same-row backwards (non-`direct`) wire
|
|
61
|
+
* routes there — routeFlow's `g` is `S.r` for a same-row pair, and no wire reaches a gutter below the row
|
|
62
|
+
* it starts in — and the router gives one interval per output trunk, so the distinct trunk count is an
|
|
63
|
+
* upper bound on its lane count (allocLanes only ever reuses lanes). */
|
|
64
|
+
function bottomGutterTrunks(tpl, pos, lastR) {
|
|
65
|
+
if (lastR < 0) return 0;
|
|
66
|
+
const keys = new Set();
|
|
67
|
+
for (const w of Array.isArray(tpl?.wires) ? tpl.wires : []) {
|
|
68
|
+
const S = pos[w?.from?.node]; const T = pos[w?.to?.node];
|
|
69
|
+
if (!S || !T || S.r !== lastR || T.r !== lastR || T.c === S.c + 1) continue;
|
|
70
|
+
keys.add(`${w.from.node}.${w.from.port || ''}`);
|
|
71
|
+
}
|
|
72
|
+
return keys.size;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @returns {{positions:{[id]:{x,y}}, pos:{[id]:{r,c,h,x,y}}, order:string[], rows:{top,h,ids}[],
|
|
77
|
+
* perRow:number, height:number, cardW:number, gap:number, rowGap:number, pad:number,
|
|
78
|
+
* scale:number, width:number, band:boolean}}
|
|
79
|
+
*/
|
|
80
|
+
export function flowLayout(tpl, portsFn, {
|
|
81
|
+
width = FLOW_DEFAULT_WIDTH, scale = FLOW_SCALE, band = true, order = null, agentOrder = null,
|
|
82
|
+
pad = FLOW_PAD, padY = FLOW_PAD_Y, gap = FLOW_GAP, rowGap = FLOW_ROW_GAP, minHeight = FLOW_MIN_H,
|
|
83
|
+
} = {}) {
|
|
84
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
|
|
85
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
86
|
+
const ids = (Array.isArray(order) ? order.filter((id) => byId.has(id)) : flowOrder(tpl, portsFn, { agentOrder }));
|
|
87
|
+
for (const n of nodes) if (!ids.includes(n.id)) ids.push(n.id); // a host order may be partial
|
|
88
|
+
const cw = NODE_W * scale;
|
|
89
|
+
const g = gap * scale;
|
|
90
|
+
const rg = rowGap * scale;
|
|
91
|
+
const per = flowPerRow(width, { scale, pad, gap });
|
|
92
|
+
const rows = [];
|
|
93
|
+
const pos = {};
|
|
94
|
+
ids.forEach((id, i) => {
|
|
95
|
+
const node = byId.get(id);
|
|
96
|
+
const r = Math.floor(i / per);
|
|
97
|
+
const c = i % per;
|
|
98
|
+
if (!rows[r]) rows[r] = { top: 0, h: 0, ids: [] };
|
|
99
|
+
const { h } = nodeSize(node, portsAt(portsFn, node), { band, scale });
|
|
100
|
+
rows[r].h = Math.max(rows[r].h, h); // row height = its tallest card; cards top-align
|
|
101
|
+
rows[r].ids.push(id);
|
|
102
|
+
pos[id] = { r, c, h, x: 0, y: 0 };
|
|
103
|
+
});
|
|
104
|
+
let y = padY;
|
|
105
|
+
for (const row of rows) { row.top = y; y = y + row.h + rg; } // left-to-right, the order the tests reproduce
|
|
106
|
+
// A backwards wire inside the LAST row is routed through the bottom gutter and carries its `N×` badge
|
|
107
|
+
// there (routeFlow's `g === rows.length - 1` case), so the height must bill that band too — billing the
|
|
108
|
+
// cards alone left half a badge sitting on the host's border (it read as clipped).
|
|
109
|
+
const trunks = bottomGutterTrunks(tpl, pos, rows.length - 1);
|
|
110
|
+
const bottomBand = trunks ? END_OFF + Math.min(FLOW_GUT_ROOM, (trunks - 1) * FLOW_LANE) + FLOW_BADGE_H / 2 : 0;
|
|
111
|
+
// Height from the LAST row's own numbers (never `y - rg + padY`: 0.65-scaled sums do not round-trip through
|
|
112
|
+
// a subtraction — 464.775 vs 464.77500000000003 — and the placement test compares with `===`).
|
|
113
|
+
const last = rows[rows.length - 1];
|
|
114
|
+
const height = Math.max(minHeight, last ? last.top + last.h + bottomBand + padY : 2 * padY);
|
|
115
|
+
const positions = {};
|
|
116
|
+
for (const id of ids) {
|
|
117
|
+
const p = pos[id];
|
|
118
|
+
p.x = pad + p.c * (cw + g);
|
|
119
|
+
p.y = rows[p.r].top;
|
|
120
|
+
positions[id] = { x: p.x, y: p.y };
|
|
121
|
+
}
|
|
122
|
+
return { positions, pos, order: ids, rows, perRow: per, height, cardW: cw, gap: g, rowGap: rg, pad, padY, bottomBand, scale, width: Number(width) || 0, band };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The anchor list the router consumes, from a laid-out template: one entry per wire
|
|
126
|
+
* whose both ends resolve. `a`/`b` come from portAnchor with the SAME band/scale; it is
|
|
127
|
+
* the mockup's `S.x + cw` / `S.y + portY(node, dir, port)·s` with the band term. The synthetic
|
|
128
|
+
* await row is addressed as port id `await` (the mockup called it `gate`). */
|
|
129
|
+
export function flowAnchors(tpl, portsFn, lay) {
|
|
130
|
+
const byId = new Map((tpl?.nodes || []).filter(isNode).map((n) => [n.id, { ...n, ...lay.positions[n.id] }]));
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const w of Array.isArray(tpl?.wires) ? tpl.wires : []) {
|
|
133
|
+
const S = byId.get(w?.from?.node); const T = byId.get(w?.to?.node);
|
|
134
|
+
if (!S || !T) continue;
|
|
135
|
+
const a = portAnchor(S, portsAt(portsFn, S), w.from.port, 'out', { band: lay.band, scale: lay.scale });
|
|
136
|
+
const b = portAnchor(T, portsAt(portsFn, T), w.to.port, 'in', { band: lay.band, scale: lay.scale });
|
|
137
|
+
if (a && b) out.push({ id: w.id, a, b, from: w.from.node, to: w.to.node, fromPort: w.from.port });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const CLEAR = 4; // min clearance between two intervals sharing a lane (mockup alloc(items, 4))
|
|
143
|
+
const RUN_PAD = 8; // a gutter run extends 8px past both channel centres
|
|
144
|
+
const PAD_OFF = 8; // first pad lane sits 8px outside the card edge
|
|
145
|
+
const END_OFF = 8; // the bottom gutter (below the last row) starts 8px under it
|
|
146
|
+
const EDGE = 2; // a lane never comes closer than this to a card edge or the host edge (A30)
|
|
147
|
+
|
|
148
|
+
/** The lane pitch that keeps `n` lanes inside `room` px: the nominal pitch while it fits, tighter after (A30). */
|
|
149
|
+
const pitchFor = (n, room, nominal) => (n <= 1 ? nominal : Math.min(nominal, Math.max(0, room) / (n - 1)));
|
|
150
|
+
|
|
151
|
+
/** Greedy interval colouring, sorted by start — ties keep insertion order exactly like the mockup's alloc()
|
|
152
|
+
* (a stable sort, NO length tiebreak: a trunk and a sibling often start at the same port y). Sets `it.lane`, returns the lane count. */
|
|
153
|
+
function allocLanes(items, clear = CLEAR) {
|
|
154
|
+
const ends = [];
|
|
155
|
+
for (const it of [...items].sort((p, q) => p.a - q.a)) {
|
|
156
|
+
let k = ends.findIndex((e) => e + clear < it.a);
|
|
157
|
+
if (k < 0) { k = ends.length; ends.push(it.b); } else ends[k] = it.b;
|
|
158
|
+
it.lane = k;
|
|
159
|
+
}
|
|
160
|
+
return ends.length;
|
|
161
|
+
}
|
|
162
|
+
/** Wires leaving the same output share ONE interval (a trunk that splits). The merge normalises `a`/`b` first —
|
|
163
|
+
* a DELIBERATE fix over the mockup's putIn, which merged the raw pair and under-extended a trunk whose new leg
|
|
164
|
+
* runs upward (ys > yg on a loop wire); do not "restore" the mockup's version. */
|
|
165
|
+
function putIn(map, ch, a, b, key) {
|
|
166
|
+
if (!map.has(ch)) map.set(ch, []);
|
|
167
|
+
const list = map.get(ch);
|
|
168
|
+
const lo = Math.min(a, b); const hi = Math.max(a, b);
|
|
169
|
+
let it = key ? list.find((x) => x.key === key) : null;
|
|
170
|
+
if (it) { it.a = Math.min(it.a, lo); it.b = Math.max(it.b, hi); return it; }
|
|
171
|
+
it = { a: lo, b: hi, key: key || null, lane: 0, x: 0 };
|
|
172
|
+
list.push(it);
|
|
173
|
+
return it;
|
|
174
|
+
}
|
|
175
|
+
/** Drop duplicate points and merge collinear runs so routePathD/routeMid see clean legs. */
|
|
176
|
+
export function simplifyOrtho(pts) {
|
|
177
|
+
const p = [];
|
|
178
|
+
for (const q of pts) { const l = p[p.length - 1]; if (!l || Math.abs(l.x - q.x) > 0.01 || Math.abs(l.y - q.y) > 0.01) p.push({ x: q.x, y: q.y }); }
|
|
179
|
+
for (let i = 1; i < p.length - 1;) {
|
|
180
|
+
const a = p[i - 1]; const b = p[i]; const c = p[i + 1];
|
|
181
|
+
const sameX = Math.abs(a.x - b.x) < 0.01 && Math.abs(b.x - c.x) < 0.01;
|
|
182
|
+
const sameY = Math.abs(a.y - b.y) < 0.01 && Math.abs(b.y - c.y) < 0.01;
|
|
183
|
+
if (sameX || sameY) p.splice(i, 1); else i += 1;
|
|
184
|
+
}
|
|
185
|
+
return p;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Route every wire through channels and gutters. `wires` = flowAnchors() output; `lay` = flowLayout() output.
|
|
190
|
+
* Channel `ch` (0 … perRow) is the card-free strip LEFT of column `ch` (0 = left pad, perRow = right pad);
|
|
191
|
+
* gutter `g` is the strip BELOW row `g` (the last one is the bottom pad). A wire leaves its out-dot into the
|
|
192
|
+
* channel right of its card, runs vertically to a gutter lane (below its row for forward/same-row wires,
|
|
193
|
+
* above it for loops going up), horizontally to the channel left of its target, vertically to the port and
|
|
194
|
+
* into the in-dot. Adjacent same-row cards skip the gutter (one jog).
|
|
195
|
+
* @returns {{raw:Map<string,{x,y}[]>, routes:Map<string,{x,y}[]>, badges:Map<string,{x,y}>}}
|
|
196
|
+
* `raw` and `routes` are the SAME map — the view reads `routesBag.raw` (as `prev`) and `routesBag.routes`.
|
|
197
|
+
*/
|
|
198
|
+
export function routeFlow(wires, lay) {
|
|
199
|
+
const { pos, rows, perRow: per, cardW: cw, gap, pad, rowGap } = lay;
|
|
200
|
+
const rightEdge = pad + per * cw + (per - 1) * gap;
|
|
201
|
+
const W = Math.max(Number(lay.width) || 0, rightEdge + pad); // the host's width (never narrower than the rows)
|
|
202
|
+
const chanC = (ch) => (ch === 0 ? pad - 10 : ch === per ? rightEdge + 10 : pad + ch * (cw + gap) - gap / 2);
|
|
203
|
+
// Lanes keep their nominal pitch while they fit and tighten when they do not (A30): `room` is the strip's
|
|
204
|
+
// free width minus an EDGE margin, so a lane never lands inside a card or outside the host. At the mockup's
|
|
205
|
+
// width every pitch below equals the nominal one (3 left-pad lanes at 12/7/2, 2 right-pad lanes, ≤ 4 gutter lanes).
|
|
206
|
+
const chanX = (ch, lane, n) => {
|
|
207
|
+
if (ch === 0) return pad - PAD_OFF - pitchFor(n, pad - PAD_OFF - EDGE, FLOW_PAD_LANE) * lane;
|
|
208
|
+
if (ch === per) return rightEdge + PAD_OFF + pitchFor(n, W - rightEdge - PAD_OFF - EDGE, FLOW_PAD_LANE) * lane;
|
|
209
|
+
return chanC(ch) + (lane - (n - 1) / 2) * pitchFor(n, gap - 2 * EDGE, FLOW_LANE);
|
|
210
|
+
};
|
|
211
|
+
const gutY = (g, lane, n) => {
|
|
212
|
+
const row = rows[g]; const bottom = row.top + row.h;
|
|
213
|
+
if (g === rows.length - 1) return bottom + END_OFF + pitchFor(n, FLOW_GUT_ROOM, FLOW_LANE) * lane;
|
|
214
|
+
return bottom + rowGap / 2 + (lane - (n - 1) / 2) * pitchFor(n, rowGap - 2 * EDGE, FLOW_LANE);
|
|
215
|
+
};
|
|
216
|
+
const R = [];
|
|
217
|
+
for (const w of wires) {
|
|
218
|
+
const S = pos[w.from]; const T = pos[w.to];
|
|
219
|
+
if (!S || !T || !w.a || !w.b) continue;
|
|
220
|
+
const direct = S.r === T.r && T.c === S.c + 1;
|
|
221
|
+
const g = direct ? -1 : (T.r > S.r ? S.r : (T.r < S.r ? S.r - 1 : S.r));
|
|
222
|
+
R.push({ w, xs: w.a.x, ys: w.a.y, xt: w.b.x, yt: w.b.y, direct, chA: S.c + 1, chB: T.c, g, key: `${w.from}.${w.fromPort || ''}` });
|
|
223
|
+
}
|
|
224
|
+
// gutters first (their y feeds the channel intervals)
|
|
225
|
+
const gut = new Map();
|
|
226
|
+
for (const r of R) { if (r.direct) continue; const a = chanC(r.chA); const b = chanC(r.chB); r.gi = putIn(gut, r.g, Math.min(a, b) - RUN_PAD, Math.max(a, b) + RUN_PAD, r.key); }
|
|
227
|
+
const gn = new Map();
|
|
228
|
+
for (const [g, items] of gut) gn.set(g, allocLanes(items));
|
|
229
|
+
for (const r of R) if (!r.direct) r.yg = gutY(r.g, r.gi.lane, gn.get(r.g));
|
|
230
|
+
// channels: the source channel is keyed (trunk), the target channel never is
|
|
231
|
+
const chan = new Map();
|
|
232
|
+
for (const r of R) {
|
|
233
|
+
if (r.direct) r.ia = putIn(chan, r.chA, r.ys, r.yt, r.key);
|
|
234
|
+
else { r.ia = putIn(chan, r.chA, r.ys, r.yg, r.key); r.ib = putIn(chan, r.chB, r.yg, r.yt, null); }
|
|
235
|
+
}
|
|
236
|
+
for (const [ch, items] of chan) { const n = allocLanes(items); for (const it of items) it.x = chanX(ch, it.lane, n); }
|
|
237
|
+
const routes = new Map(); const badges = new Map();
|
|
238
|
+
for (const r of R) {
|
|
239
|
+
const pts = simplifyOrtho(r.direct
|
|
240
|
+
? [{ x: r.xs, y: r.ys }, { x: r.ia.x, y: r.ys }, { x: r.ia.x, y: r.yt }, { x: r.xt, y: r.yt }]
|
|
241
|
+
: [{ x: r.xs, y: r.ys }, { x: r.ia.x, y: r.ys }, { x: r.ia.x, y: r.yg }, { x: r.ib.x, y: r.yg }, { x: r.ib.x, y: r.yt }, { x: r.xt, y: r.yt }]);
|
|
242
|
+
routes.set(r.w.id, pts);
|
|
243
|
+
// Badge = the gutter run's midpoint (mockup §F). A direct wire has no gutter: the badge sits on its vertical jog
|
|
244
|
+
// (`ia.x`, ON the wire) where the mockup used (xs+xt)/2, which floats off the path — unreachable for loop wires anyway
|
|
245
|
+
// (a loop wire runs backwards in dispatch order and is never `direct`).
|
|
246
|
+
badges.set(r.w.id, r.direct ? { x: r.ia.x, y: (r.ys + r.yt) / 2 } : { x: (r.ia.x + r.ib.x) / 2, y: r.yg });
|
|
247
|
+
}
|
|
248
|
+
return { raw: routes, routes, badges };
|
|
249
|
+
}
|
|
@@ -17,6 +17,9 @@ export const BORDER = 1.5;
|
|
|
17
17
|
export const DOT = 10;
|
|
18
18
|
export const FOOT_H = 26;
|
|
19
19
|
export const EXEC_ROW_H = 22;
|
|
20
|
+
/** The model·effort chip band under an AGENT head (auto-proposal hosts only; the
|
|
21
|
+
* composer and the run monitor never enable it). Billed by nodeSize when `band`. */
|
|
22
|
+
export const BAND_H = 24;
|
|
20
23
|
/** Sub-agent fan squares per wrapped line. The squares are 7px + 3px gap, so a
|
|
21
24
|
* full line is 16·10 − 3 = 157px (--gv-fan-w), leaving the ×N tail its column. */
|
|
22
25
|
export const FAN_PER_ROW = 16;
|
|
@@ -27,22 +30,34 @@ export const WIRE_HIT_TOL = 6;
|
|
|
27
30
|
export const ZOOM_MIN = 0.4;
|
|
28
31
|
export const ZOOM_MAX = 1.6;
|
|
29
32
|
export const ZOOM_K = 0.002;
|
|
33
|
+
/** Multiplier per zoom-BUTTON press — the discrete step both canvases use; the
|
|
34
|
+
* wheel keeps its exponential ZOOM_K curve. */
|
|
35
|
+
export const ZOOM_STEP = 1.2;
|
|
30
36
|
/** First row centre from the top of the card: 1.5 + 34 + 8.5 + 12. */
|
|
31
37
|
export const ROW0 = BORDER + HEAD_H + PAD_T + ROW_H / 2;
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
const px3 = (v) => `${Math.round(v * 1000) / 1000}px`;
|
|
40
|
+
/** Every CSS-visible number at `scale`, as the custom properties style.css reads.
|
|
41
|
+
* `--gv-scale` is the multiplier the FONT rules apply (`max(9px, calc(13px * var(--gv-scale)))`),
|
|
42
|
+
* so a 0.65 static host keeps its type at the 9px floor instead of 8.45px. */
|
|
43
|
+
export function geometryCssVars(scale = 1) {
|
|
44
|
+
const s = Number(scale) > 0 ? Number(scale) : 1;
|
|
45
|
+
return Object.freeze({
|
|
46
|
+
'--gv-node-w': px3(NODE_W * s), '--gv-head-h': px3(HEAD_H * s), '--gv-row-h': px3(ROW_H * s),
|
|
47
|
+
'--gv-sep-h': px3(SEP_H * s), '--gv-pad-t': px3(PAD_T * s), '--gv-pad-b': px3(PAD_B * s),
|
|
48
|
+
'--gv-border': px3(BORDER * s), '--gv-dot': px3(DOT * s), '--gv-foot-h': px3(FOOT_H * s),
|
|
49
|
+
'--gv-exec-row-h': px3(EXEC_ROW_H * s), '--gv-fan-w': px3(FAN_ROW_W * s),
|
|
50
|
+
'--gv-band-h': px3(BAND_H * s), '--gv-scale': String(s),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Every CSS-visible number at 1×, as the custom properties style.css reads (frozen; tests import it). */
|
|
54
|
+
export const GEOMETRY_CSS_VARS = geometryCssVars(1);
|
|
40
55
|
|
|
41
56
|
/** Write the variables onto a host element at mount. Guarded: jsdom hosts and a
|
|
42
57
|
* missing element are both fine (the caller is a renderer, not a validator). */
|
|
43
|
-
export function injectGeometry(el) {
|
|
58
|
+
export function injectGeometry(el, scale = 1) {
|
|
44
59
|
if (!el || !el.style || typeof el.style.setProperty !== 'function') return;
|
|
45
|
-
for (const [name, value] of Object.entries(
|
|
60
|
+
for (const [name, value] of Object.entries(geometryCssVars(scale))) el.style.setProperty(name, value);
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
const CAPTION_SET = new Set(['task', 'end', 'or']);
|
|
@@ -64,9 +79,12 @@ function zones(node, ports) {
|
|
|
64
79
|
return z;
|
|
65
80
|
}
|
|
66
81
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
const bandOf = (node, band) => (band && node?.kind === 'agent' ? BAND_H : 0);
|
|
83
|
+
|
|
84
|
+
/** y offset of a zone's FIRST row centre, or null when the zone is not emitted.
|
|
85
|
+
* `band` (agents only) pushes every zone down by BAND_H — the chip band sits between the head and the body. */
|
|
86
|
+
function zoneTop(node, ports, kind, band = false) {
|
|
87
|
+
let y = ROW0 + bandOf(node, band);
|
|
70
88
|
for (const z of zones(node, ports)) {
|
|
71
89
|
if (z.kind === kind) return y;
|
|
72
90
|
y += z.n * ROW_H + SEP_H;
|
|
@@ -82,32 +100,34 @@ export function fanLines(n) {
|
|
|
82
100
|
/**
|
|
83
101
|
* @param {{kind:string}} node
|
|
84
102
|
* @param {{inputs:Array, outputs:Array}} ports RESOLVED ports (await included for agents)
|
|
85
|
-
* @param {{footerRows?:number}} [opts] footer LINES: 0 none · 1 collapsed
|
|
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.
|
|
103
|
+
* @param {{footerRows?:number, band?:boolean, scale?:number}} [opts] footer LINES: 0 none · 1 collapsed
|
|
104
|
+
* executions strip · more for extra lines (a wrapped fan line, a stacked exec row's extra
|
|
105
|
+
* lines). The first line is FOOT_H tall, every further one EXEC_ROW_H. `band` bills BAND_H
|
|
106
|
+
* under an agent head; `scale` multiplies the returned box (1 = today's numbers).
|
|
88
107
|
*/
|
|
89
|
-
export function nodeSize(node, ports, { footerRows = 0 } = {}) {
|
|
108
|
+
export function nodeSize(node, ports, { footerRows = 0, band = false, scale = 1 } = {}) {
|
|
90
109
|
const zs = zones(node, ports);
|
|
91
110
|
const rows = zs.reduce((s, z) => s + z.n, 0);
|
|
92
111
|
const seps = Math.max(0, zs.length - 1);
|
|
93
112
|
const footer = footerRows ? FOOT_H + (footerRows - 1) * EXEC_ROW_H : 0;
|
|
94
|
-
|
|
113
|
+
const h = 2 * BORDER + HEAD_H + bandOf(node, band) + PAD_T + rows * ROW_H + seps * SEP_H + PAD_B + footer;
|
|
114
|
+
return { w: NODE_W * scale, h: h * scale };
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
/** 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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
* The footer is the bottom-most box, so no anchor depends on it.
|
|
119
|
+
* Offsets scale; node.x/y do not. */
|
|
120
|
+
export function portAnchor(node, ports, portId, dir, { band = false, scale = 1 } = {}) {
|
|
121
|
+
const at = (dx, dy) => ({ x: node.x + dx * scale, y: node.y + dy * scale });
|
|
122
|
+
if (dir === 'in' && portId === 'await' && hasAwaitRow(ports)) return at(0, zoneTop(node, ports, 'await', band));
|
|
103
123
|
if (dir === 'in') {
|
|
104
124
|
const i = metaInputs(ports).findIndex((p) => p?.id === portId);
|
|
105
|
-
const top = zoneTop(node, ports, 'in');
|
|
106
|
-
return i < 0 || top === null ? null :
|
|
125
|
+
const top = zoneTop(node, ports, 'in', band);
|
|
126
|
+
return i < 0 || top === null ? null : at(0, top + ROW_H * i);
|
|
107
127
|
}
|
|
108
128
|
const j = outs(ports).findIndex((p) => p?.id === portId);
|
|
109
|
-
const top = zoneTop(node, ports, 'out');
|
|
110
|
-
return j < 0 || top === null ? null :
|
|
129
|
+
const top = zoneTop(node, ports, 'out', band);
|
|
130
|
+
return j < 0 || top === null ? null : at(NODE_W, top + ROW_H * j);
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
@@ -128,7 +148,7 @@ export function hitPort(anchor, pt, r = PORT_HIT_R) {
|
|
|
128
148
|
|
|
129
149
|
/** The union of the card boxes, optionally padded. `footerRowsOf(node)` lets the
|
|
130
150
|
* run monitor fit an expanded executions footer. null when there is nothing. */
|
|
131
|
-
export function graphBounds(tpl, portsFn, { pad = 0, footerRowsOf } = {}) {
|
|
151
|
+
export function graphBounds(tpl, portsFn, { pad = 0, footerRowsOf, band = false, scale = 1 } = {}) {
|
|
132
152
|
// OBJECTS only: `filter(Boolean)` kept a truthy non-object (`7`), sized it as a
|
|
133
153
|
// card at the origin and stretched the bounds of every fit built from it.
|
|
134
154
|
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : [])
|
|
@@ -137,7 +157,7 @@ export function graphBounds(tpl, portsFn, { pad = 0, footerRowsOf } = {}) {
|
|
|
137
157
|
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
|
|
138
158
|
for (const node of nodes) {
|
|
139
159
|
const ports = (typeof portsFn === 'function' ? portsFn(node) : null) || { inputs: [], outputs: [] };
|
|
140
|
-
const size = nodeSize(node, ports, { footerRows: footerRowsOf ? footerRowsOf(node) : 0 });
|
|
160
|
+
const size = nodeSize(node, ports, { footerRows: footerRowsOf ? footerRowsOf(node) : 0, band, scale });
|
|
141
161
|
const x = Number(node.x) || 0;
|
|
142
162
|
const y = Number(node.y) || 0;
|
|
143
163
|
minX = Math.min(minX, x); minY = Math.min(minY, y);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/shared/graph/isomorphic.mjs
|
|
2
|
+
// Exact-topology equality of two v2 templates as LABELLED DIRECTED MULTIGRAPHS.
|
|
3
|
+
// Used by the Auto matcher (spec §4.3, D8/D9): a saved workflow is reused only
|
|
4
|
+
// when agents, wiring, gates, loops and their budgets are identical. Tunables
|
|
5
|
+
// (model/effort/fanOut/askQuestions/subagentModel), positions, ids and names
|
|
6
|
+
// are deliberately invisible — they are per-run tuning, not topology.
|
|
7
|
+
// Pure, browser-safe, no imports.
|
|
8
|
+
|
|
9
|
+
/** The config keys that ARE topology, per kind (mirrors validate.mjs KNOWN_CONFIG minus the tunables). */
|
|
10
|
+
const TOPOLOGY_CONFIG = Object.freeze({
|
|
11
|
+
task: ['planStoreSeed'], agent: ['awaitAll'], and: ['arity'], or: ['arity'], combine: ['arity'], end: [],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export function nodeLabel(node) {
|
|
15
|
+
const kind = String(node?.kind ?? '');
|
|
16
|
+
const cfg = node && typeof node.config === 'object' && node.config ? node.config : {};
|
|
17
|
+
const parts = [kind, kind === 'agent' ? String(node.key ?? '') : ''];
|
|
18
|
+
for (const k of TOPOLOGY_CONFIG[kind] || []) {
|
|
19
|
+
const v = cfg[k];
|
|
20
|
+
// A gate's arity defaults to 2; an absent flag equals an explicit false.
|
|
21
|
+
const norm = k === 'arity' ? (Number.isInteger(v) ? v : 2) : (v === undefined ? false : v);
|
|
22
|
+
parts.push(`${k}=${JSON.stringify(norm)}`);
|
|
23
|
+
}
|
|
24
|
+
return parts.join('|');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function wireLabel(wire) {
|
|
28
|
+
const mc = wire?.config?.maxCycles;
|
|
29
|
+
return `${wire?.from?.port ?? ''}>${wire?.to?.port ?? ''}|${Number.isInteger(mc) ? mc : ''}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isNode = (n) => Boolean(n) && typeof n === 'object' && typeof n.id === 'string';
|
|
33
|
+
const isWire = (w) => Boolean(w) && typeof w === 'object' && typeof w?.from?.node === 'string' && typeof w?.to?.node === 'string';
|
|
34
|
+
|
|
35
|
+
function index(tpl) {
|
|
36
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
|
|
37
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
38
|
+
const wires = (Array.isArray(tpl?.wires) ? tpl.wires : []).filter((w) => isWire(w) && ids.has(w.from.node) && ids.has(w.to.node));
|
|
39
|
+
const label = new Map(nodes.map((n) => [n.id, nodeLabel(n)]));
|
|
40
|
+
const out = new Map(nodes.map((n) => [n.id, []])); // id -> [{ to, label }]
|
|
41
|
+
const inn = new Map(nodes.map((n) => [n.id, []])); // id -> [{ from, label }]
|
|
42
|
+
for (const w of wires) {
|
|
43
|
+
const l = wireLabel(w);
|
|
44
|
+
out.get(w.from.node).push({ to: w.to.node, label: l });
|
|
45
|
+
inn.get(w.to.node).push({ from: w.from.node, label: l });
|
|
46
|
+
}
|
|
47
|
+
// A node's signature: its label plus the sorted labels of its in/out wires.
|
|
48
|
+
const sig = new Map(nodes.map((n) => [n.id,
|
|
49
|
+
`${label.get(n.id)}#${out.get(n.id).map((e) => e.label).sort().join(',')}#${inn.get(n.id).map((e) => e.label).sort().join(',')}`]));
|
|
50
|
+
return { nodes, wires, label, out, inn, sig };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Sorted multiset key of the edges between u -> v. */
|
|
54
|
+
function edgesBetween(ix, u, v) {
|
|
55
|
+
return ix.out.get(u).filter((e) => e.to === v).map((e) => e.label).sort().join(',');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @returns {Map<string,string>|null} a-node id -> b-node id, or null when the
|
|
60
|
+
* two templates are not the same labelled graph.
|
|
61
|
+
*/
|
|
62
|
+
export function isomorphic(a, b) {
|
|
63
|
+
const A = index(a);
|
|
64
|
+
const B = index(b);
|
|
65
|
+
if (A.nodes.length !== B.nodes.length || A.wires.length !== B.wires.length) return null;
|
|
66
|
+
const multiset = (ix) => [...ix.sig.values()].sort().join('\n');
|
|
67
|
+
if (multiset(A) !== multiset(B)) return null;
|
|
68
|
+
const wireSet = (ix) => ix.wires.map((w) => `${ix.label.get(w.from.node)}|${ix.label.get(w.to.node)}|${wireLabel(w)}`).sort().join('\n');
|
|
69
|
+
if (wireSet(A) !== wireSet(B)) return null;
|
|
70
|
+
|
|
71
|
+
// Candidates per a-node: b-nodes with the same signature. Fewest first.
|
|
72
|
+
const bySig = new Map();
|
|
73
|
+
for (const n of B.nodes) {
|
|
74
|
+
if (!bySig.has(B.sig.get(n.id))) bySig.set(B.sig.get(n.id), []);
|
|
75
|
+
bySig.get(B.sig.get(n.id)).push(n.id);
|
|
76
|
+
}
|
|
77
|
+
const order = [...A.nodes.map((n) => n.id)].sort((x, y) =>
|
|
78
|
+
(bySig.get(A.sig.get(x)) || []).length - (bySig.get(A.sig.get(y)) || []).length || (x < y ? -1 : 1));
|
|
79
|
+
const map = new Map();
|
|
80
|
+
const used = new Set();
|
|
81
|
+
|
|
82
|
+
const consistent = (u, x) => {
|
|
83
|
+
for (const [v, y] of map) {
|
|
84
|
+
if (edgesBetween(A, u, v) !== edgesBetween(B, x, y)) return false;
|
|
85
|
+
if (edgesBetween(A, v, u) !== edgesBetween(B, y, x)) return false;
|
|
86
|
+
}
|
|
87
|
+
return edgesBetween(A, u, u) === edgesBetween(B, x, x); // self-wires
|
|
88
|
+
};
|
|
89
|
+
const walk = (i) => {
|
|
90
|
+
if (i === order.length) return true;
|
|
91
|
+
const u = order[i];
|
|
92
|
+
for (const x of bySig.get(A.sig.get(u)) || []) {
|
|
93
|
+
if (used.has(x) || !consistent(u, x)) continue;
|
|
94
|
+
map.set(u, x); used.add(x);
|
|
95
|
+
if (walk(i + 1)) return true;
|
|
96
|
+
map.delete(u); used.delete(x);
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
return walk(0) ? map : null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// src/shared/report-reasons.mjs
|
|
2
|
+
// The run-report vocabulary, shared by the core builder (src/core/run-report.mjs)
|
|
3
|
+
// and the browser (ui/public/report-run.mjs). Browser-safe: no node builtins, no
|
|
4
|
+
// imports. Kept here — not in src/core — because ui/public cannot import src/core
|
|
5
|
+
// but src/shared/** is served at /src/shared, so both sides read the SAME module.
|
|
6
|
+
//
|
|
7
|
+
// The six reasons and the two opt-in classes are a product contract, not an
|
|
8
|
+
// implementation detail: the reason id is also the GitHub issue LABEL, so renaming
|
|
9
|
+
// one silently re-labels every future issue.
|
|
10
|
+
|
|
11
|
+
/** The reason taxonomy, in the order the modal's <select> lists it. */
|
|
12
|
+
export const REPORT_REASONS = Object.freeze([
|
|
13
|
+
Object.freeze({ id: 'poor-quality', label: 'Poor quality', evidence: 'quality' }),
|
|
14
|
+
Object.freeze({ id: 'too-expensive', label: 'Too expensive', evidence: 'cost' }),
|
|
15
|
+
Object.freeze({ id: 'too-slow', label: 'Too slow', evidence: 'speed' }),
|
|
16
|
+
Object.freeze({ id: 'wrong-or-unsafe', label: 'Wrong or unsafe change', evidence: 'safety' }),
|
|
17
|
+
Object.freeze({ id: 'failed-or-stuck', label: 'It failed or got stuck', evidence: 'failure' }),
|
|
18
|
+
Object.freeze({ id: 'something-else', label: 'Something else', evidence: null }),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export const REPORT_REASON_IDS = Object.freeze(REPORT_REASONS.map((r) => r.id));
|
|
22
|
+
|
|
23
|
+
/** @returns {{id:string,label:string,evidence:string|null}|null} */
|
|
24
|
+
export function reasonById(id) {
|
|
25
|
+
return REPORT_REASONS.find((r) => r.id === id) || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The two excluded classes a reporter may opt back IN. The unified diff and the
|
|
29
|
+
// run's log lines are deliberately absent and must never be added here: they are
|
|
30
|
+
// not offered anywhere in the UI and the builder has no code path for them.
|
|
31
|
+
//
|
|
32
|
+
// NAMES ARE NOT AMONG THEM. The run title, project key, branch names, workspace name,
|
|
33
|
+
// workflow template name and guardrail set name are ALWAYS in the report: they are
|
|
34
|
+
// what makes a bug report actionable, and a maintainer reading "some custom workflow
|
|
35
|
+
// on some branch" can do nothing with it. The one thing that stayed out is the
|
|
36
|
+
// absolute worktree path, which no option has ever unlocked.
|
|
37
|
+
export const OPT_IN_CLASSES = Object.freeze([
|
|
38
|
+
Object.freeze({
|
|
39
|
+
key: 'paths',
|
|
40
|
+
label: 'File paths, review issue titles and the run narrative',
|
|
41
|
+
hint: 'Adds changed-file paths, each review issue’s title and location, and the cached overview narrative.',
|
|
42
|
+
}),
|
|
43
|
+
Object.freeze({
|
|
44
|
+
key: 'prompt',
|
|
45
|
+
label: 'The task prompt',
|
|
46
|
+
hint: 'Adds the prompt text you gave this run, verbatim.',
|
|
47
|
+
}),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export const OPT_IN_KEYS = Object.freeze(OPT_IN_CLASSES.map((c) => c.key));
|
|
51
|
+
|
|
52
|
+
/** Coerce an untrusted `include` bag to exactly the two booleans. */
|
|
53
|
+
export function normalizeInclude(raw) {
|
|
54
|
+
const src = raw && typeof raw === 'object' ? raw : {};
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const key of OPT_IN_KEYS) out[key] = src[key] === true;
|
|
57
|
+
return out;
|
|
58
|
+
}
|