@jabworks/condux 0.20.0 → 0.22.0

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/index.js CHANGED
@@ -13,13 +13,18 @@
13
13
  // separate `npx skills add` step for condux. This works because the config hook
14
14
  // mutates the cached config before OpenCode lazily discovers skills.
15
15
  //
16
- // It also pushes workflow's routing.md onto config.instructions, giving
17
- // OpenCode the same `/workflow`-as-entry-point enforcement Claude Code and
18
- // Codex get from their SessionStart hook — OpenCode has no hook system for
19
- // that, so `instructions` (ambient every turn, verified via `opencode debug
20
- // config` against a config-hook mutation) is the closest equivalent. Costs
21
- // ~390 tokens/turn, permanently; that trade was made deliberately, not by
22
- // default (docket #38).
16
+ // It also carries workflow's routing.md — the payload Claude Code and Codex
17
+ // get from their SessionStart hook — into the session as a `synthetic: true`
18
+ // `<system-reminder>` text part on the first user message of every *main*
19
+ // session (`chat.message`), re-injected after compaction. That is the shape
20
+ // OpenCode's own plan-mode reminders use (session/reminders.ts): it reaches
21
+ // the model in the user turn rather than mid-system-prompt, and it never
22
+ // reaches subagent sessions. Verified live on OpenCode 1.18.25 — the part
23
+ // persists (`chat.message` runs before the message is saved, and its parts
24
+ // array is the one written) and the model sees it. Per main-session turn it
25
+ // costs the same ~390 tokens as the `config.instructions` channel it replaces
26
+ // (docket #38 ratified that trade; docket #72 relocated the payload out of
27
+ // the system prompt and out of subagents).
23
28
  //
24
29
  // Optional plan-review listener (CONDUX_PLAN_REVIEW=1): when the primary
25
30
  // `plan` agent finishes a turn (session.idle), spawns the plan-review annotate
@@ -29,6 +34,7 @@
29
34
 
30
35
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
31
36
  import { spawn } from 'node:child_process';
37
+ import { randomBytes } from 'node:crypto';
32
38
  import path from 'node:path';
33
39
  import os from 'node:os';
34
40
  import { fileURLToPath } from 'node:url';
@@ -62,6 +68,56 @@ function loadBundledAgents() {
62
68
  return agents;
63
69
  }
64
70
 
71
+ // The routing payload, framed the way OpenCode frames its own reminders. Null
72
+ // when routing.md is missing or empty — then the hook is a no-op, the same
73
+ // fail-open contract the Claude Code and Codex SessionStart scripts follow.
74
+ function loadRoutingReminder() {
75
+ if (!existsSync(ROUTING_MD)) return null;
76
+ const payload = readFileSync(ROUTING_MD, 'utf8').trim();
77
+
78
+ return payload ? `<system-reminder>\n${payload}\n</system-reminder>` : null;
79
+ }
80
+
81
+ // What the compaction summariser is asked to keep. The full reminder is
82
+ // re-injected on the next user message; this only stops the summary from
83
+ // describing a session in which the router never existed.
84
+ const COMPACTION_CONTEXT =
85
+ 'Carry this rule into the summary: every implementation request in this session starts by loading the ' +
86
+ '`workflow` skill (`skill(name="workflow")`) before any other condux skill or any edit.';
87
+
88
+ // A part id in OpenCode's own ascending format — `prt_` + six big-endian bytes
89
+ // of (ms << 12 | counter) + fourteen base62 characters — so the reminder sorts
90
+ // among the parts OpenCode minted. The counter is pinned to its maximum: a
91
+ // part created in the same millisecond as the user's text must still order
92
+ // after it. The reminder follows the prompt, never precedes it.
93
+ const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
94
+
95
+ function partId() {
96
+ const stamp = BigInt(Date.now()) * 0x1000n + 0xfffn;
97
+ const bytes = Buffer.alloc(6);
98
+ for (let i = 0; i < 6; i++) bytes[i] = Number((stamp >> BigInt(40 - 8 * i)) & 0xffn);
99
+ let suffix = '';
100
+ for (const byte of randomBytes(14)) suffix += BASE62[byte % 62];
101
+
102
+ return `prt_${bytes.toString('hex')}${suffix}`;
103
+ }
104
+
105
+ // True when the session's stored history still shows the reminder to the
106
+ // model: a synthetic text part with exactly this text on a user message that
107
+ // comes after the last compaction. Compaction narrows the model's view to the
108
+ // summary plus a short tail, so a reminder on the far side of one counts as
109
+ // absent — the next user message gets it again.
110
+ export function historyIsRouted(messages, reminder) {
111
+ let routed = false;
112
+ for (const { info, parts = [] } of messages) {
113
+ if (parts.some((part) => part.type === 'compaction')) routed = false;
114
+ if (info?.role !== 'user') continue;
115
+ if (parts.some((part) => part.type === 'text' && part.synthetic === true && part.text === reminder)) routed = true;
116
+ }
117
+
118
+ return routed;
119
+ }
120
+
65
121
  // The annotate server ships inside the plan-review skill; look for it in every
66
122
  // skill tree OpenCode discovers (project then global).
67
123
  function findAnnotateServer(worktree) {
@@ -82,11 +138,54 @@ function findAnnotateServer(worktree) {
82
138
  return null;
83
139
  }
84
140
 
85
- export const ConduxPlugin = async ({ worktree }) => {
141
+ export const ConduxPlugin = async ({ worktree, client }) => {
86
142
  const bundled = loadBundledAgents();
143
+ const reminder = loadRoutingReminder();
87
144
  const activeAgent = new Map();
145
+ // Sessions whose live history carries the reminder, as far as this process
146
+ // knows. A compaction clears the entry so the next user message re-injects.
147
+ const routed = new Set();
148
+ const isChild = new Map();
88
149
  let warnedPayloadShape = false;
89
150
 
151
+ // Subagent sessions never get the router: their brief is the task the parent
152
+ // handed them, and "every request starts at workflow" contradicts coder's.
153
+ // The bundled agent names are the free path; the parent lookup catches every
154
+ // other child (OpenCode's own `general`, user-defined subagents) — `task`
155
+ // creates them with parentID set, and `chat.message` carries their agent
156
+ // name. Unknown counts as main: the instructions channel this replaces
157
+ // reached subagents too, so leaning toward injection is the known state.
158
+ async function isSubagentSession(input) {
159
+ if (input.agent && bundled[input.agent]?.mode === 'subagent') return true;
160
+ if (typeof client?.session?.get !== 'function') return false;
161
+ if (!isChild.has(input.sessionID)) {
162
+ try {
163
+ const { data } = await client.session.get({ path: { id: input.sessionID } });
164
+ isChild.set(input.sessionID, Boolean(data?.parentID));
165
+ } catch {
166
+ isChild.set(input.sessionID, false);
167
+ }
168
+ }
169
+
170
+ return isChild.get(input.sessionID);
171
+ }
172
+
173
+ // On first sight of a session in this process — fresh, resumed, or after a
174
+ // compaction cleared it — ask the server whether the reminder is already in
175
+ // the model's view instead of trusting process memory: a resumed session
176
+ // would otherwise be injected twice, a compacted one never.
177
+ async function alreadyRouted(sessionID) {
178
+ if (routed.has(sessionID)) return true;
179
+ if (typeof client?.session?.messages !== 'function') return false;
180
+ try {
181
+ const { data } = await client.session.messages({ path: { id: sessionID } });
182
+
183
+ return Array.isArray(data) && historyIsRouted(data, reminder);
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
90
189
  return {
91
190
  config: async (cfg) => {
92
191
  cfg.agent ??= {};
@@ -116,13 +215,40 @@ export const ConduxPlugin = async ({ worktree }) => {
116
215
  cfg.skills.paths ??= [];
117
216
  if (!cfg.skills.paths.includes(SKILLS_DIR)) cfg.skills.paths.push(SKILLS_DIR);
118
217
  }
218
+ // routing.md deliberately does NOT go on cfg.instructions any more: that
219
+ // channel is global (every subagent session read it) and lands mid
220
+ // system prompt. The reminder below is the replacement (docket #72).
221
+ },
222
+
223
+ // Route through workflow the way Claude Code and Codex do at SessionStart:
224
+ // one reminder on the first user message of a main session. Runs before
225
+ // the message is saved, so the pushed part is persisted with the rest.
226
+ 'chat.message': async (input, output) => {
227
+ if (!reminder || !input?.sessionID || !output?.message?.id || !Array.isArray(output.parts)) return;
228
+ if (await isSubagentSession(input)) return;
229
+ if (await alreadyRouted(input.sessionID)) {
230
+ routed.add(input.sessionID);
119
231
 
120
- // Route through /workflow the same way Claude Code and Codex do —
121
- // see the file-header comment for why `instructions` is the mechanism.
122
- if (existsSync(ROUTING_MD)) {
123
- cfg.instructions ??= [];
124
- if (!cfg.instructions.includes(ROUTING_MD)) cfg.instructions.push(ROUTING_MD);
232
+ return;
125
233
  }
234
+
235
+ output.parts.push({
236
+ id: partId(),
237
+ messageID: output.message.id,
238
+ sessionID: input.sessionID,
239
+ type: 'text',
240
+ synthetic: true,
241
+ text: reminder,
242
+ });
243
+ routed.add(input.sessionID);
244
+ },
245
+
246
+ // Compaction narrows the model's view to a summary plus a short tail. Ask
247
+ // the summariser to keep the rule; the next user message re-injects the
248
+ // full reminder (the `session.compacted` event below clears the session).
249
+ 'experimental.session.compacting': async (input, output) => {
250
+ if (!reminder || !Array.isArray(output?.context)) return;
251
+ if (input?.sessionID && routed.has(input.sessionID)) output.context.push(COMPACTION_CONTEXT);
126
252
  },
127
253
 
128
254
  'chat.params': async (input) => {
@@ -130,6 +256,11 @@ export const ConduxPlugin = async ({ worktree }) => {
130
256
  },
131
257
 
132
258
  event: async ({ event }) => {
259
+ if (event.type === 'session.compacted') {
260
+ routed.delete(event.properties?.sessionID);
261
+
262
+ return;
263
+ }
133
264
  if (process.env.CONDUX_PLAN_REVIEW !== '1') return;
134
265
  if (event.type !== 'session.idle') return;
135
266
  const sessionID = event.properties?.sessionID;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jabworks/condux",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "OpenCode plugin for the condux workflow toolkit — bundles the condux skills and injects the specialist agents (coder, explorer, planner, researcher) plus an opt-in plan-review listener.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -39,8 +39,11 @@ product family.
39
39
  │ Wireframes follow references/wireframe-kit.md — wireframe mode │
40
40
  │ by default; render mode when structure is signed off or the │
41
41
  │ user asks for fidelity. Diagrams follow references/ │
42
- │ diagram-kit.md. All styling from references/token-core.css. │
43
- │ No external assets, no CDN, no font files. │
42
+ │ diagram-kit.md. Diagrams are checked with references/ │
43
+ │ diagram-check.mjs before Step 3 and delivered only clean; a │
44
+ │ skipped check is said, never silent. All styling from │
45
+ │ references/token-core.css. No external assets, no CDN, no │
46
+ │ font files. │
44
47
  │ │
45
48
  │ Step 3: DELIVER │
46
49
  │ Standalone: open in the browser (xdg-open / open). If neither │
@@ -145,4 +148,5 @@ mockup into the spec directory first and cite the committed copy.
145
148
  both kits; a box the reader can't disagree with isn't evidence
146
149
  ✗ Opening a browser tab while running inside /discovery
147
150
  ✗ Editing the mockups after design sign-off without saying so
151
+ ✗ Delivering a diagram the checker flags, or skipping the check without saying so
148
152
  ```
@@ -0,0 +1,815 @@
1
+ // diagram-check.mjs
2
+ //
3
+ // The eyes of an agent that hand-authors inline SVG diagrams (see
4
+ // diagram-kit.md) and cannot see its own render. It reads one file, finds
5
+ // every inline <svg>…</svg> block, and reports the geometry mistakes a human
6
+ // would catch on sight: an edge that runs through a box it does not connect
7
+ // to, a label sitting on another label or on a node's own title text, an
8
+ // edge nobody labeled, and text that falls outside the canvas.
9
+ //
10
+ // This is a regex-level reader, not a DOM. It understands exactly the
11
+ // idioms diagram-kit.md teaches — `<g transform="translate(x,y)">` offsets,
12
+ // `var(--…)` fills/strokes (never resolved — colors play no part in any
13
+ // check), one shared `<marker>` — and two known approximations:
14
+ //
15
+ // - text width is estimated at 0.6 em per character (no font metrics);
16
+ // - Bézier and arc commands (C S Q T A) contribute only their final
17
+ // endpoint as a straight segment — curvature itself is invisible to it.
18
+ //
19
+ // No dependencies, no network, no shelling out, no filesystem writes.
20
+
21
+ import fs from 'node:fs';
22
+ import { pathToFileURL } from 'node:url';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Small geometry helpers. A "box" is always { x0, y0, x1, y1 } in viewBox
26
+ // units; nodes/boundaries are boxes with a `line` and (once assigned) a
27
+ // `title`; texts are boxes with `text`, `line`, `free`, `ownEdge`.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const shrinkBox = (box, n) => ({ x0: box.x0 + n, y0: box.y0 + n, x1: box.x1 - n, y1: box.y1 - n });
31
+
32
+ const expandBox = (box, n) => shrinkBox(box, -n);
33
+
34
+ const rectIntersect = (a, b) => a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0;
35
+
36
+ const pointInBox = (px, py, box) => px >= box.x0 && px <= box.x1 && py >= box.y0 && py <= box.y1;
37
+
38
+ // inner fits inside outer, both ways rounded by `tol` viewBox units.
39
+ const fullyInside = (inner, outer, tol) =>
40
+ inner.x0 >= outer.x0 - tol && inner.x1 <= outer.x1 + tol && inner.y0 >= outer.y0 - tol && inner.y1 <= outer.y1 + tol;
41
+
42
+ const containsBox = (outer, inner) => fullyInside(inner, outer, 0);
43
+
44
+ // Liang-Barsky clip of a segment against a box. Returns the clipped segment
45
+ // and its length, or null when the segment misses the box entirely.
46
+ const liangBarsky = (x1, y1, x2, y2, box) => {
47
+ const dx = x2 - x1;
48
+ const dy = y2 - y1;
49
+ const p = [-dx, dx, -dy, dy];
50
+ const q = [x1 - box.x0, box.x1 - x1, y1 - box.y0, box.y1 - y1];
51
+ let t0 = 0;
52
+ let t1 = 1;
53
+
54
+ for (let i = 0; i < 4; i++) {
55
+ if (p[i] === 0) {
56
+ if (q[i] < 0) return null;
57
+
58
+ continue;
59
+ }
60
+
61
+ const r = q[i] / p[i];
62
+ if (p[i] < 0) {
63
+ if (r > t1) return null;
64
+ if (r > t0) t0 = r;
65
+ } else {
66
+ if (r < t0) return null;
67
+ if (r < t1) t1 = r;
68
+ }
69
+ }
70
+
71
+ if (t0 > t1) return null;
72
+
73
+ const cx1 = x1 + t0 * dx;
74
+ const cy1 = y1 + t0 * dy;
75
+ const cx2 = x1 + t1 * dx;
76
+ const cy2 = y1 + t1 * dy;
77
+
78
+ return { length: Math.hypot(cx2 - cx1, cy2 - cy1) };
79
+ };
80
+
81
+ const distPointToSegment = (px, py, x1, y1, x2, y2) => {
82
+ const dx = x2 - x1;
83
+ const dy = y2 - y1;
84
+ const lenSq = dx * dx + dy * dy;
85
+ // A zero-length segment is a point — clamp collapses to it on its own,
86
+ // but guard the division explicitly rather than relying on NaN behavior.
87
+ const t = lenSq === 0 ? 0 : Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
88
+ const nx = x1 + t * dx;
89
+ const ny = y1 + t * dy;
90
+
91
+ return Math.hypot(px - nx, py - ny);
92
+ };
93
+
94
+ // Distance from a box to a segment: zero when the segment enters the box,
95
+ // else the nearest of the box's corners. Label attribution measures this, not
96
+ // the box centre — a long start-anchored label beside a vertical edge has its
97
+ // centre far from the line and its near edge right on it, and real diagrams
98
+ // (the 2026-09-08 reporting one, six of seven labels) are drawn that way.
99
+ const distBoxToSegment = (box, x1, y1, x2, y2) => {
100
+ if (liangBarsky(x1, y1, x2, y2, box)) return 0;
101
+
102
+ const corners = [
103
+ [box.x0, box.y0],
104
+ [box.x1, box.y0],
105
+ [box.x0, box.y1],
106
+ [box.x1, box.y1],
107
+ ];
108
+
109
+ return Math.min(...corners.map(([px, py]) => distPointToSegment(px, py, x1, y1, x2, y2)));
110
+ };
111
+
112
+ const fmt = (n) => Math.round(n * 10) / 10;
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Line-number lookup. Built once per source string and reused across every
116
+ // block and element in it, rather than rescanning from zero each time.
117
+ // ---------------------------------------------------------------------------
118
+
119
+ const makeLineFinder = (source) => {
120
+ const offsets = [0];
121
+ for (let i = 0; i < source.length; i++) if (source[i] === '\n') offsets.push(i + 1);
122
+
123
+ return (idx) => {
124
+ let lo = 0;
125
+ let hi = offsets.length - 1;
126
+ while (lo < hi) {
127
+ const mid = (lo + hi + 1) >> 1;
128
+ if (offsets[mid] <= idx) lo = mid;
129
+ else hi = mid - 1;
130
+ }
131
+
132
+ return lo + 1;
133
+ };
134
+ };
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Attribute and transform parsing.
138
+ // ---------------------------------------------------------------------------
139
+
140
+ const getAttr = (tag, name) => {
141
+ const dq = new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`).exec(tag);
142
+ if (dq) return dq[1];
143
+
144
+ const sq = new RegExp(`(?:^|\\s)${name}\\s*=\\s*'([^']*)'`).exec(tag);
145
+
146
+ return sq ? sq[1] : undefined;
147
+ };
148
+
149
+ // translate(x,y) / translate(x y) / translate(x) — the only transform kind
150
+ // diagram-kit.md uses. Anything else is reported once and treated as identity.
151
+ const parseTranslate = (transform) => {
152
+ const m = /translate\(\s*(-?[\d.]+)(?:[ ,]+(-?[\d.]+))?\s*\)/.exec(transform);
153
+ if (!m) return null;
154
+
155
+ return { dx: parseFloat(m[1]), dy: m[2] !== undefined ? parseFloat(m[2]) : 0 };
156
+ };
157
+
158
+ // The kit's labels lean on entities (`HTTP &#183; ReportingBatchRequest`);
159
+ // left undecoded, `&#183;` counts as five characters and every label box
160
+ // grows by four em-fifths per dot — wide enough to invent overlaps.
161
+ const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', middot: '·', rarr: '→' };
162
+
163
+ const decodeEntities = (s) =>
164
+ s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, body) => {
165
+ if (body[0] === '#') {
166
+ const hex = body[1] === 'x' || body[1] === 'X';
167
+ const code = hex ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
168
+
169
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
170
+ }
171
+
172
+ return NAMED_ENTITIES[body.toLowerCase()] ?? whole;
173
+ });
174
+
175
+ const stripTags = (s) =>
176
+ decodeEntities(s.replace(/<[^>]+>/g, ''))
177
+ .replace(/\s+/g, ' ')
178
+ .trim();
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // <path d="…"> grammar: M L H V Z absolute/relative, one straight segment per
182
+ // command; C S Q T A keep only the final endpoint (curvature is invisible to
183
+ // this checker — see the header). A second M starts a new subpath, returned
184
+ // as a separate segment list per diagram-kit's "multiple edges in one path"
185
+ // idiom.
186
+ // ---------------------------------------------------------------------------
187
+
188
+ const CURVE_ARG_COUNTS = { C: 6, S: 4, Q: 4, T: 2, A: 7 };
189
+
190
+ const parsePathSegments = (d, warnCurve) => {
191
+ const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e-?\d+)?/g) || [];
192
+ let i = 0;
193
+ let cx = 0;
194
+ let cy = 0;
195
+ let startX = 0;
196
+ let startY = 0;
197
+ let cmd = null;
198
+ let seenM = false;
199
+ let current = [];
200
+ const subpaths = [];
201
+
202
+ while (i < tokens.length) {
203
+ if (/^[a-zA-Z]$/.test(tokens[i])) {
204
+ cmd = tokens[i];
205
+ i++;
206
+ }
207
+
208
+ if (!cmd) break;
209
+
210
+ const letter = cmd.toUpperCase();
211
+ const isRel = cmd !== letter;
212
+
213
+ if (letter === 'M') {
214
+ if (seenM) {
215
+ subpaths.push(current);
216
+ current = [];
217
+ }
218
+
219
+ seenM = true;
220
+ const x = parseFloat(tokens[i++]);
221
+ const y = parseFloat(tokens[i++]);
222
+ cx = isRel ? cx + x : x;
223
+ cy = isRel ? cy + y : y;
224
+ startX = cx;
225
+ startY = cy;
226
+ // Bare coordinate pairs after M are an implicit lineto.
227
+ cmd = isRel ? 'l' : 'L';
228
+
229
+ continue;
230
+ }
231
+
232
+ if (letter === 'L') {
233
+ const x = parseFloat(tokens[i++]);
234
+ const y = parseFloat(tokens[i++]);
235
+ const nx = isRel ? cx + x : x;
236
+ const ny = isRel ? cy + y : y;
237
+ current.push({ x1: cx, y1: cy, x2: nx, y2: ny });
238
+ cx = nx;
239
+ cy = ny;
240
+
241
+ continue;
242
+ }
243
+
244
+ if (letter === 'H') {
245
+ const x = parseFloat(tokens[i++]);
246
+ const nx = isRel ? cx + x : x;
247
+ current.push({ x1: cx, y1: cy, x2: nx, y2: cy });
248
+ cx = nx;
249
+
250
+ continue;
251
+ }
252
+
253
+ if (letter === 'V') {
254
+ const y = parseFloat(tokens[i++]);
255
+ const ny = isRel ? cy + y : y;
256
+ current.push({ x1: cx, y1: cy, x2: cx, y2: ny });
257
+ cy = ny;
258
+
259
+ continue;
260
+ }
261
+
262
+ if (letter === 'Z') {
263
+ current.push({ x1: cx, y1: cy, x2: startX, y2: startY });
264
+ cx = startX;
265
+ cy = startY;
266
+
267
+ continue;
268
+ }
269
+
270
+ if (CURVE_ARG_COUNTS[letter]) {
271
+ warnCurve();
272
+ const n = CURVE_ARG_COUNTS[letter];
273
+ const args = [];
274
+ for (let k = 0; k < n; k++) args.push(tokens[i++]);
275
+
276
+ const x = parseFloat(args[n - 2]);
277
+ const y = parseFloat(args[n - 1]);
278
+ const nx = isRel ? cx + x : x;
279
+ const ny = isRel ? cy + y : y;
280
+ current.push({ x1: cx, y1: cy, x2: nx, y2: ny });
281
+ cx = nx;
282
+ cy = ny;
283
+
284
+ continue;
285
+ }
286
+
287
+ // Unknown command letter — skip it rather than spin forever.
288
+ i++;
289
+ }
290
+
291
+ subpaths.push(current);
292
+
293
+ return subpaths.filter((s) => s.length > 0);
294
+ };
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Per-<svg>-block walk. A single left-to-right tag scan tracks a stack of
298
+ // <g> offsets (and font-size inheritance), skips <defs>/<marker> subtrees
299
+ // entirely, and special-cases <text>…</text> to pull out its raw content
300
+ // (and any positioned <tspan> children) without a general text-node model.
301
+ // ---------------------------------------------------------------------------
302
+
303
+ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
304
+ const rects = [];
305
+ const texts = [];
306
+ const edges = [];
307
+
308
+ const svgOpenMatch = /^<svg\b[^>]*>/.exec(blockSource);
309
+ const svgOpenTag = svgOpenMatch ? svgOpenMatch[0] : '<svg>';
310
+ const viewBoxAttr = getAttr(svgOpenTag, 'viewBox');
311
+ let viewBox = null;
312
+ if (viewBoxAttr) {
313
+ const nums = viewBoxAttr.trim().split(/[\s,]+/).map(Number);
314
+ if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
315
+ const [minX, minY, w, h] = nums;
316
+ viewBox = { minX, minY, w, h, x0: minX, y0: minY, x1: minX + w, y1: minY + h };
317
+ }
318
+ }
319
+
320
+ let curOffsetX = 0;
321
+ let curOffsetY = 0;
322
+ let curFontSize = parseFloat(getAttr(svgOpenTag, 'font-size') ?? '') || 13;
323
+ let skipDepth = 0;
324
+ const stack = [];
325
+ // The frame a closing tag restores: offsets, inherited font size, and
326
+ // whether the subtree was inside <defs>/<marker>.
327
+ const snapshot = () => ({ prevX: curOffsetX, prevY: curOffsetY, prevFontSize: curFontSize, skip: skipDepth > 0 });
328
+
329
+ const tagRe = /<\/?[a-zA-Z][^>]*>/g;
330
+ let m;
331
+
332
+ while ((m = tagRe.exec(blockSource))) {
333
+ const tag = m[0];
334
+ const idx = m.index;
335
+ const lineNo = findLine(blockOffset + idx);
336
+ const closing = tag.startsWith('</');
337
+ const selfClosing = /\/\s*>$/.test(tag);
338
+ const nameMatch = /^<\/?([a-zA-Z][\w:-]*)/.exec(tag);
339
+ const name = nameMatch ? nameMatch[1] : '';
340
+
341
+ if (closing) {
342
+ const frame = stack.pop();
343
+ if (frame) {
344
+ curOffsetX = frame.prevX;
345
+ curOffsetY = frame.prevY;
346
+ curFontSize = frame.prevFontSize;
347
+ if (frame.skip) skipDepth--;
348
+ }
349
+
350
+ continue;
351
+ }
352
+
353
+ if (name === 'text' && !selfClosing) {
354
+ const contentStart = tagRe.lastIndex;
355
+ const closeIdx = blockSource.indexOf('</text>', contentStart);
356
+ const rawContent = closeIdx === -1 ? blockSource.slice(contentStart) : blockSource.slice(contentStart, closeIdx);
357
+
358
+ if (skipDepth === 0) {
359
+ processTextElement({
360
+ tag,
361
+ rawContent,
362
+ contentOffset: blockOffset + contentStart,
363
+ findLine,
364
+ offX: curOffsetX,
365
+ offY: curOffsetY,
366
+ curFontSize,
367
+ texts,
368
+ lineNo,
369
+ });
370
+ }
371
+
372
+ tagRe.lastIndex = closeIdx === -1 ? blockSource.length : closeIdx + '</text>'.length;
373
+
374
+ continue;
375
+ }
376
+
377
+ if (name === 'defs' || name === 'marker') {
378
+ if (!selfClosing) {
379
+ stack.push({ prevX: curOffsetX, prevY: curOffsetY, prevFontSize: curFontSize, skip: true });
380
+ skipDepth++;
381
+ }
382
+
383
+ continue;
384
+ }
385
+
386
+ if (name === 'g') {
387
+ if (!selfClosing) {
388
+ const transform = getAttr(tag, 'transform');
389
+ let dx = 0;
390
+ let dy = 0;
391
+ if (transform) {
392
+ const parsed = parseTranslate(transform);
393
+ if (parsed) {
394
+ dx = parsed.dx;
395
+ dy = parsed.dy;
396
+ } else {
397
+ notify(`diagram-check: unsupported transform "${transform}" at line ${lineNo} — treated as identity`);
398
+ }
399
+ }
400
+
401
+ const fontSizeAttr = getAttr(tag, 'font-size');
402
+ stack.push(snapshot());
403
+
404
+ if (skipDepth === 0) {
405
+ curOffsetX += dx;
406
+ curOffsetY += dy;
407
+ if (fontSizeAttr) curFontSize = parseFloat(fontSizeAttr);
408
+ }
409
+ }
410
+
411
+ continue;
412
+ }
413
+
414
+ if (name === 'svg') {
415
+ if (!selfClosing) stack.push(snapshot());
416
+
417
+ continue;
418
+ }
419
+
420
+ if (skipDepth === 0) {
421
+ if (name === 'rect') processRect(tag, lineNo, curOffsetX, curOffsetY, rects);
422
+ else if (name === 'line') processLine(tag, lineNo, curOffsetX, curOffsetY, edges, notify);
423
+ else if (name === 'polyline') processPolyline(tag, lineNo, curOffsetX, curOffsetY, edges, notify);
424
+ else if (name === 'path') processPath(tag, lineNo, curOffsetX, curOffsetY, edges, notify);
425
+ }
426
+
427
+ // Any other container tag (unlikely inside a diagram-kit SVG, but a
428
+ // stray <a> or <clipPath> would otherwise desync the stack) — push a
429
+ // neutral frame so its closing tag pops correctly.
430
+ if (!selfClosing) stack.push(snapshot());
431
+ }
432
+
433
+ return { rects, texts, edges, viewBox };
434
+ };
435
+
436
+ const processRect = (tag, lineNo, offX, offY, rects) => {
437
+ const x = parseFloat(getAttr(tag, 'x') ?? '0');
438
+ const y = parseFloat(getAttr(tag, 'y') ?? '0');
439
+ const w = parseFloat(getAttr(tag, 'width') ?? '0');
440
+ const h = parseFloat(getAttr(tag, 'height') ?? '0');
441
+ const stroke = getAttr(tag, 'stroke');
442
+
443
+ // No stroke, or an explicit "none", means a decoration (a label halo) —
444
+ // it never obstructs an edge and never receives a label.
445
+ if (!stroke || stroke === 'none') return;
446
+
447
+ rects.push({ x0: x + offX, y0: y + offY, x1: x + offX + w, y1: y + offY + h, line: lineNo });
448
+ };
449
+
450
+ // A NaN coordinate compares false against everything, so an edge carrying
451
+ // one would pass every check unseen. Report it and leave it out instead.
452
+ const pushEdge = (edges, edge, notify) => {
453
+ const finite = edge.segments.every((s) => [s.x1, s.y1, s.x2, s.y2].every(Number.isFinite));
454
+ if (!finite) {
455
+ notify(`diagram-check: ${edge.kind} at line ${edge.line} has a non-numeric coordinate — skipped`);
456
+
457
+ return;
458
+ }
459
+
460
+ edges.push(edge);
461
+ };
462
+
463
+ const processLine = (tag, lineNo, offX, offY, edges, notify) => {
464
+ const x1 = parseFloat(getAttr(tag, 'x1') ?? '0') + offX;
465
+ const y1 = parseFloat(getAttr(tag, 'y1') ?? '0') + offY;
466
+ const x2 = parseFloat(getAttr(tag, 'x2') ?? '0') + offX;
467
+ const y2 = parseFloat(getAttr(tag, 'y2') ?? '0') + offY;
468
+
469
+ pushEdge(edges, { kind: 'line', segments: [{ x1, y1, x2, y2 }], line: lineNo }, notify);
470
+ };
471
+
472
+ const processPolyline = (tag, lineNo, offX, offY, edges, notify) => {
473
+ const pointsStr = getAttr(tag, 'points');
474
+ if (!pointsStr) return;
475
+
476
+ const nums = pointsStr.trim().split(/[\s,]+/).map(Number);
477
+ const points = [];
478
+ for (let i = 0; i + 1 < nums.length; i += 2) points.push({ x: nums[i] + offX, y: nums[i + 1] + offY });
479
+
480
+ const segments = [];
481
+ for (let i = 0; i + 1 < points.length; i++) {
482
+ segments.push({ x1: points[i].x, y1: points[i].y, x2: points[i + 1].x, y2: points[i + 1].y });
483
+ }
484
+
485
+ if (segments.length > 0) pushEdge(edges, { kind: 'polyline', segments, line: lineNo }, notify);
486
+ };
487
+
488
+ const processPath = (tag, lineNo, offX, offY, edges, notify) => {
489
+ const fill = getAttr(tag, 'fill');
490
+ if (fill && fill !== 'none') return; // fill !== none/absent is a filled shape, not an edge
491
+
492
+ const d = getAttr(tag, 'd');
493
+ if (!d) return;
494
+
495
+ for (const segs of parsePathSegments(d, () => notify.curve())) {
496
+ const offsetSegs = segs.map((s) => ({ x1: s.x1 + offX, y1: s.y1 + offY, x2: s.x2 + offX, y2: s.y2 + offY }));
497
+ pushEdge(edges, { kind: 'path', segments: offsetSegs, line: lineNo }, notify);
498
+ }
499
+ };
500
+
501
+ const pushTextBox = (texts, text, absX, absY, anchor, fontSize, lineNo) => {
502
+ if (!text) return;
503
+
504
+ const width = text.length * fontSize * 0.6;
505
+ const top = absY - 0.75 * fontSize;
506
+ const bottom = absY + 0.25 * fontSize;
507
+ let x0;
508
+ let x1;
509
+
510
+ if (anchor === 'middle') {
511
+ x0 = absX - width / 2;
512
+ x1 = absX + width / 2;
513
+ } else if (anchor === 'end') {
514
+ x0 = absX - width;
515
+ x1 = absX;
516
+ } else {
517
+ x0 = absX;
518
+ x1 = absX + width;
519
+ }
520
+
521
+ texts.push({ x0, y0: top, x1, y1: bottom, text, line: lineNo });
522
+ };
523
+
524
+ const processTextElement = ({ tag, rawContent, contentOffset, findLine, offX, offY, curFontSize, texts, lineNo }) => {
525
+ const x = parseFloat(getAttr(tag, 'x') ?? '0');
526
+ const y = parseFloat(getAttr(tag, 'y') ?? '0');
527
+ const anchor = getAttr(tag, 'text-anchor') ?? 'start';
528
+ const fontSizeAttr = getAttr(tag, 'font-size');
529
+ const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : curFontSize;
530
+
531
+ const tspanRe = /<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/g;
532
+ const tspans = [];
533
+ let tm;
534
+ while ((tm = tspanRe.exec(rawContent))) {
535
+ const tspanTag = `<tspan ${tm[1]}>`;
536
+ const tx = getAttr(tspanTag, 'x');
537
+ const ty = getAttr(tspanTag, 'y');
538
+ const text = stripTags(tm[2]);
539
+
540
+ if (tx !== undefined && ty !== undefined && text) {
541
+ tspans.push({ tag: tspanTag, x: parseFloat(tx), y: parseFloat(ty), text, index: tm.index });
542
+ }
543
+ }
544
+
545
+ if (tspans.length > 0) {
546
+ for (const t of tspans) {
547
+ const tAnchor = getAttr(t.tag, 'text-anchor') ?? anchor;
548
+ const tFontSizeAttr = getAttr(t.tag, 'font-size');
549
+ const tFontSize = tFontSizeAttr ? parseFloat(tFontSizeAttr) : fontSize;
550
+ const tLine = findLine(contentOffset + t.index);
551
+ pushTextBox(texts, t.text, t.x + offX, t.y + offY, tAnchor, tFontSize, tLine);
552
+ }
553
+
554
+ return;
555
+ }
556
+
557
+ const content = stripTags(rawContent);
558
+ pushTextBox(texts, content, x + offX, y + offY, anchor, fontSize, lineNo);
559
+ };
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // Findings for one parsed block.
563
+ // ---------------------------------------------------------------------------
564
+
565
+ const describeEdge = (edge) => {
566
+ const first = edge.segments[0];
567
+ const last = edge.segments[edge.segments.length - 1];
568
+
569
+ return `${edge.kind} (${fmt(first.x1)},${fmt(first.y1)})→(${fmt(last.x2)},${fmt(last.y2)})`;
570
+ };
571
+
572
+ const describeNode = (node) => (node.title ? `"${node.title}"` : `rect (${fmt(node.x0)},${fmt(node.y0)})`);
573
+
574
+ const edgeEndpoints = (edge) => {
575
+ const first = edge.segments[0];
576
+ const last = edge.segments[edge.segments.length - 1];
577
+
578
+ return { start: { x: first.x1, y: first.y1 }, end: { x: last.x2, y: last.y2 } };
579
+ };
580
+
581
+ const crossesBox = (edge, box) =>
582
+ edge.segments.some((seg) => {
583
+ const clip = liangBarsky(seg.x1, seg.y1, seg.x2, seg.y2, box);
584
+
585
+ return clip && clip.length > 2;
586
+ });
587
+
588
+ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
589
+ const findings = [];
590
+
591
+ // Classify rects: a stroked rect that fully contains another stroked rect
592
+ // is a boundary and is never an obstacle; the rest are nodes.
593
+ for (const r of rects) r.isBoundary = rects.some((o) => o !== r && containsBox(r, o));
594
+ const nodes = rects.filter((r) => !r.isBoundary);
595
+
596
+ // Owned vs. free text, and each node's title (first owned text, in
597
+ // document order).
598
+ for (const text of texts) {
599
+ const owner = nodes.find((node) => fullyInside(text, node, 1));
600
+ if (owner) {
601
+ text.free = false;
602
+ text.owner = owner;
603
+ if (!owner.title) owner.title = text.text;
604
+ } else {
605
+ text.free = true;
606
+ }
607
+ }
608
+
609
+ // Each free text's own edge: the edge whose nearest segment comes closest
610
+ // to the text box, when that distance is within labeling range.
611
+ for (const text of texts) {
612
+ if (!text.free) continue;
613
+
614
+ let best = null;
615
+ let bestDist = Infinity;
616
+
617
+ for (const edge of edges) {
618
+ for (const seg of edge.segments) {
619
+ const d = distBoxToSegment(text, seg.x1, seg.y1, seg.x2, seg.y2);
620
+ if (d < bestDist) {
621
+ bestDist = d;
622
+ best = edge;
623
+ }
624
+ }
625
+ }
626
+
627
+ text.ownEdge = best && bestDist <= 24 ? best : null;
628
+ }
629
+
630
+ // An edge attaches to a node when either endpoint lands on it (4 units of
631
+ // slack for arrowheads that stop short of the border).
632
+ const isAttached = (edge, node) => {
633
+ const { start, end } = edgeEndpoints(edge);
634
+ const expanded = expandBox(node, 4);
635
+
636
+ return pointInBox(start.x, start.y, expanded) || pointInBox(end.x, end.y, expanded);
637
+ };
638
+
639
+ // 1. edge-through-box
640
+ for (const edge of edges) {
641
+ for (const node of nodes) {
642
+ if (isAttached(edge, node)) continue;
643
+
644
+ if (crossesBox(edge, shrinkBox(node, 1))) {
645
+ findings.push({
646
+ code: 'edge-through-box',
647
+ line: edge.line,
648
+ detail: `${describeEdge(edge)} crosses ${describeNode(node)}`,
649
+ });
650
+ }
651
+ }
652
+ }
653
+
654
+ // 2. label-over-label
655
+ for (let i = 0; i < texts.length; i++) {
656
+ for (let j = i + 1; j < texts.length; j++) {
657
+ const a = texts[i];
658
+ const b = texts[j];
659
+ if (rectIntersect(shrinkBox(a, 1), shrinkBox(b, 1))) {
660
+ findings.push({
661
+ code: 'label-over-label',
662
+ line: Math.min(a.line, b.line),
663
+ detail: `"${a.text}" overlaps "${b.text}"`,
664
+ });
665
+ }
666
+ }
667
+ }
668
+
669
+ // 3. label-over-box (free text straddling a node's border)
670
+ for (const text of texts) {
671
+ if (!text.free) continue;
672
+
673
+ for (const node of nodes) {
674
+ if (rectIntersect(shrinkBox(text, 1), node)) {
675
+ findings.push({
676
+ code: 'label-over-box',
677
+ line: text.line,
678
+ detail: `"${text.text}" straddles ${describeNode(node)}`,
679
+ });
680
+ }
681
+ }
682
+ }
683
+
684
+ // 4. edge-through-label (excluding a free text's own edge)
685
+ for (const edge of edges) {
686
+ for (const text of texts) {
687
+ if (text.ownEdge === edge) continue;
688
+
689
+ // A node's own text under an edge that does not attach to the node is
690
+ // the edge-through-box finding already reported — naming the title too
691
+ // would print one defect twice.
692
+ if (text.owner && !isAttached(edge, text.owner)) continue;
693
+
694
+ if (crossesBox(edge, shrinkBox(text, 1))) {
695
+ findings.push({
696
+ code: 'edge-through-label',
697
+ line: edge.line,
698
+ detail: `${describeEdge(edge)} crosses label "${text.text}"`,
699
+ });
700
+ }
701
+ }
702
+ }
703
+
704
+ // 5. unlabeled-edge
705
+ const labeled = new Set(texts.filter((t) => t.ownEdge).map((t) => t.ownEdge));
706
+ for (const edge of edges) {
707
+ if (!labeled.has(edge)) {
708
+ findings.push({
709
+ code: 'unlabeled-edge',
710
+ line: edge.line,
711
+ detail: `${describeEdge(edge)} has no label within 24px`,
712
+ });
713
+ }
714
+ }
715
+
716
+ // 6. text-outside-canvas
717
+ if (viewBox) {
718
+ const canvas = `${viewBox.minX} ${viewBox.minY} ${viewBox.w} ${viewBox.h}`;
719
+
720
+ for (const text of texts) {
721
+ const fully = fullyInside(text, viewBox, 0);
722
+ if (!fully) {
723
+ findings.push({
724
+ code: 'text-outside-canvas',
725
+ line: text.line,
726
+ detail: `"${text.text}" falls outside the canvas (viewBox ${canvas})`,
727
+ });
728
+ }
729
+ }
730
+ }
731
+
732
+ return findings;
733
+ };
734
+
735
+ // ---------------------------------------------------------------------------
736
+ // Public API
737
+ // ---------------------------------------------------------------------------
738
+
739
+ // Finds every <svg>…</svg> block in `source` and runs all six checks against
740
+ // each. Returns Finding[] = { code, line, detail }[], sorted by line then code.
741
+ export const checkSvg = (rawSource) => {
742
+ // The kit's own marker block carries `<!-- usage: <line … /> -->`, and the
743
+ // kit says to paste it verbatim — scanned raw, that comment is a phantom
744
+ // zero-length edge at the origin and every diagram fails "clean". Blank
745
+ // comments character-for-character (newlines kept) so line numbers hold.
746
+ const source = rawSource.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, ' '));
747
+ const findLine = makeLineFinder(source);
748
+ let curveWarned = false;
749
+ const notify = (msg) => process.stderr.write(msg + '\n');
750
+ notify.curve = () => {
751
+ if (curveWarned) return;
752
+ curveWarned = true;
753
+ notify('diagram-check: curve command (C/S/Q/T/A) approximated as its endpoint only');
754
+ };
755
+
756
+ const findings = [];
757
+ const blockRe = /<svg\b[^>]*>[\s\S]*?<\/svg>/g;
758
+ let m;
759
+
760
+ while ((m = blockRe.exec(source))) {
761
+ const parsed = walkBlock(source, m.index, m[0], findLine, notify);
762
+ findings.push(...findingsForBlock(parsed));
763
+ }
764
+
765
+ findings.sort((a, b) => a.line - b.line || a.code.localeCompare(b.code));
766
+
767
+ return findings;
768
+ };
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // CLI
772
+ // ---------------------------------------------------------------------------
773
+
774
+ const runCli = (argv) => {
775
+ const jsonMode = argv.includes('--json');
776
+ const file = argv.find((a) => !a.startsWith('--'));
777
+
778
+ if (!file) {
779
+ process.stderr.write('usage: diagram-check.mjs <file.html|file.svg> [--json]\n');
780
+
781
+ return 2;
782
+ }
783
+
784
+ let source;
785
+ try {
786
+ source = fs.readFileSync(file, 'utf8');
787
+ } catch (err) {
788
+ process.stderr.write(`diagram-check: cannot read ${file}: ${err.message}\n`);
789
+
790
+ return 2;
791
+ }
792
+
793
+ if (!/<svg\b[^>]*>[\s\S]*?<\/svg>/.test(source)) {
794
+ process.stderr.write(`diagram-check: no <svg> block found in ${file}\n`);
795
+
796
+ return 2;
797
+ }
798
+
799
+ const findings = checkSvg(source);
800
+
801
+ if (jsonMode) {
802
+ process.stdout.write(JSON.stringify({ file, findings }) + '\n');
803
+ } else if (findings.length === 0) {
804
+ process.stdout.write('clean\n');
805
+ } else {
806
+ for (const f of findings) process.stdout.write(`${f.code} line ${f.line} ${f.detail}\n`);
807
+ process.stdout.write(`${findings.length} finding(s)\n`);
808
+ }
809
+
810
+ return findings.length === 0 ? 0 : 1;
811
+ };
812
+
813
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
814
+ process.exit(runCli(process.argv.slice(2)));
815
+ }
@@ -28,12 +28,72 @@ of the `<style>` block, verbatim.
28
28
  <path d="M0,0 L10,5 L0,10 z" fill="var(--subtle)"/>
29
29
  </marker>
30
30
  </defs>
31
- <!-- usage: <line ... stroke="var(--subtle)" marker-end="url(#arrow)"/> -->
31
+ <!-- usage: <line ... stroke="var(--subtle)" marker-end="url(#arrow)"/> or
32
+ <path ... stroke="var(--subtle)" marker-end="url(#arrow)"/> — the marker
33
+ works the same on both elements. -->
32
34
  ```
33
35
 
34
36
  - Every relationship line gets a label. An unlabeled arrow is a guess the
35
37
  reader has to make.
36
38
 
39
+ ## Layout and Routing
40
+
41
+ 1. *Grid first.* Assign every node a column and a row before drawing anything.
42
+ Column pitch is box width plus a gutter of at least 72; row pitch is box
43
+ height plus a gutter of at least 72. Gutters are edge corridors — wide
44
+ enough for a label with its halo and two staggered edges. Boundaries wrap
45
+ whole column/row spans and are drawn behind their members.
46
+ 2. *Edges are orthogonal paths.* `<path d="M … H … V … H …">` with the shared
47
+ arrow marker (it works on `<path>` exactly as on `<line>`). Never a
48
+ diagonal. An edge leaves the side of its source that faces the target and
49
+ enters the facing side of the target.
50
+ 3. *Ports.* Each side of a box has three ports, at 25%, 50% and 75% of its
51
+ length. Two edges on the same side take different ports — edges never
52
+ share a port, so parallel edges never stack.
53
+ 4. *Corridors only.* A segment may run only through gutters and across
54
+ boundaries, never through a box's footprint. If a third box sits in the
55
+ straight corridor between source and target, either move it to another row
56
+ or column (preferred — rearranging is free before the drawing exists) or
57
+ dog-leg through the adjacent gutter.
58
+ 5. *Labels.* One label per segment, on the edge's longest free segment,
59
+ `--mono` 11px: on a horizontal segment it sits 4px above the line, on a
60
+ vertical segment it is centred on the line over its halo.
61
+ Every label gets a halo so a crossing edge stays legible: a stroke-less
62
+ `<rect>` in the fill the label sits on (`var(--background)` on open
63
+ canvas, `var(--muted)` inside a filled boundary) drawn behind the text —
64
+ the checker ignores stroke-less rects, so the halo is never mistaken for a
65
+ node. Two labels never share a corridor position: stagger by 14px or move
66
+ one to another segment.
67
+ 6. *Fan-in is a smell.* More than four edges into one box, or more than three
68
+ edges in one gutter, means the diagram is answering two questions — split
69
+ it (same rule as "Choosing the Shape").
70
+ 7. *Check before delivering.* Run
71
+ `node /PATH/TO/blueprint/references/diagram-check.mjs <file>` (the
72
+ `/PATH/TO/` idiom is the one `plan-review` uses for its scripts — the
73
+ skill's install location). A finding is a defect in the drawing, never a
74
+ tolerance to argue with: fix, re-run, deliver only `clean`. If `node` is
75
+ unavailable, say the check was skipped — never silently.
76
+
77
+ | Code | Meaning |
78
+ |---|---|
79
+ | `edge-through-box` | an edge segment crosses a node rect it doesn't attach to |
80
+ | `label-over-label` | two text boxes intersect |
81
+ | `label-over-box` | a text box straddles a node rect border |
82
+ | `edge-through-label` | an edge crosses a text box that isn't its own label |
83
+ | `unlabeled-edge` | no text within 24px of the edge |
84
+ | `text-outside-canvas` | text escapes the viewBox |
85
+
86
+ Two boxes on different rows and columns, joined by a three-segment path
87
+ with one haloed label and distinct ports:
88
+
89
+ ```html
90
+ <!-- orders → invoices: leave the right side at the 50% port, run the gutter,
91
+ enter the left side at its 25% port. Label centred on the vertical segment, haloed. -->
92
+ <path d="M 240 92 H 300 V 180 H 340" fill="none" stroke="var(--subtle)" marker-end="url(#arrow)"/>
93
+ <rect x="262" y="120" width="76" height="14" fill="var(--background)"/>
94
+ <text x="300" y="131" text-anchor="middle" font-size="11" fill="var(--muted-foreground)">1..* invoices</text>
95
+ ```
96
+
37
97
  ## The Specificity Rule
38
98
 
39
99
  Every label must name something real from *this* design — a path, a command, a
@@ -46,7 +46,10 @@ else on the machine competes with it. Only `broken` affects the exit code.
46
46
  - **plan-review's Codex Stop hook** — resolved, never executed. It blocks a
47
47
  turn for up to four days waiting on a human by design.
48
48
  - **OpenCode** — `@jabworks/condux` in the plugin array, and when a local
49
- copy exists, that it ships its bundled `agents/` and `skills/`.
49
+ copy exists, that it ships its bundled `agents/` and `skills/` and that the
50
+ routing reminder it injects names the OpenCode verb, `skill(name="workflow")`
51
+ — a payload still saying `/condux:workflow` is the docket #72 under-firing
52
+ shape, reported broken with an upgrade as the fix.
50
53
  - **The four specialist agents** — `coder`, `explorer`, `planner`,
51
54
  `researcher`. They live in a plugin-level directory reached by its own sync
52
55
  step, which is exactly the mirror that drifted once before.
@@ -338,12 +338,29 @@ function probeOpencode(hosts) {
338
338
  return {
339
339
  host: 'opencode',
340
340
  status: 'broken',
341
- detail: 'the installed package ships no workflow/hooks/routing.md — /workflow routing enforcement is missing',
341
+ detail: 'the installed package ships no workflow/hooks/routing.md — the plugin has no routing reminder to inject',
342
342
  fix: `reinstall ${PACKAGE}`,
343
343
  };
344
344
  }
345
345
 
346
- return { host: 'opencode', status: 'done', detail: 'registered, and the installed package ships its agents, skills, and /workflow routing enforcement' };
346
+ // Since @jabworks/condux 0.21.0 the payload is rewritten for OpenCode:
347
+ // `/condux:workflow` is a Claude Code command the host cannot run, and a
348
+ // payload still naming it is the docket #72 under-firing shape — installed,
349
+ // but asking for a verb the model has to translate on its own.
350
+ if (!fs.readFileSync(routing, 'utf8').includes('skill(name="workflow")')) {
351
+ return {
352
+ host: 'opencode',
353
+ status: 'broken',
354
+ detail: 'the installed routing payload names /condux:workflow, which OpenCode cannot run — the reminder under-fires',
355
+ fix: `upgrade ${PACKAGE} to 0.21.0 or later`,
356
+ };
357
+ }
358
+
359
+ return {
360
+ host: 'opencode',
361
+ status: 'done',
362
+ detail: 'registered, and the installed package ships its agents, skills, and a routing reminder that names the OpenCode verb',
363
+ };
347
364
  }
348
365
 
349
366
  // The four specialist agents are plugin-level and reached by their own sync
@@ -1,7 +1,7 @@
1
1
  <EXTREMELY_IMPORTANT>
2
2
  You have condux — this session's agentic workflow system.
3
3
 
4
- **Every implementation request starts at `/condux:workflow`.** Feature, bug fix,
4
+ **Every implementation request starts at the `workflow` skill — `skill(name="workflow")`.** Feature, bug fix,
5
5
  refactor, new endpoint, UI change, "can you add…", "can you fix…" — all of it
6
6
  routes there first. The router infers the tier (Small / Medium / Large), confirms
7
7
  it with the user, then executes the matching flow, loading downstream skills only
@@ -13,7 +13,7 @@ when a step needs them.
13
13
  `root-cause-analysis`, `live-verification`, `plan-review`, `technical-spec`.
14
14
 
15
15
  If you find yourself reaching for one of those as the *first* skill of a task,
16
- you have skipped the router — go back to `/condux:workflow`. Debugging is the
16
+ you have skipped the router — go back to the `workflow` skill — `skill(name="workflow")`. Debugging is the
17
17
  most common miss: a crash report, a stack trace, or "why does this fail" is
18
18
  still a dev task, so it routes through workflow, which loads
19
19
  `root-cause-analysis` at the right moment.
@@ -25,4 +25,6 @@ user instructions — "skip workflow", "just do it", "I've already planned this"
25
25
  **Not everything is a dev task.** Questions, explanations, code reading, research,
26
26
  and one-line factual answers route nowhere — answer them directly. Routing a
27
27
  conversation through a workflow is as wrong as skipping the router on a feature.
28
+
29
+ **On OpenCode, a `/name` mention of any condux skill means the `skill` tool:** `/workflow` → `skill(name="workflow")`, `/finalize` → `skill(name="finalize")`, and so on. None of them is a slash command here.
28
30
  </EXTREMELY_IMPORTANT>