arkaos 4.32.0 → 4.34.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/README.md +1 -1
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/arka/skills/forge/references/workflows.md +10 -0
- package/config/claude-agents/architect.md +1 -1
- package/config/claude-agents/backend-dev.md +1 -1
- package/config/claude-agents/devops-eng.md +1 -1
- package/config/claude-agents/frontend-dev.md +1 -1
- package/config/claude-agents/ops-lead.md +1 -1
- package/config/claude-agents/pm-director.md +1 -1
- package/config/claude-agents/strategy-director.md +1 -1
- package/config/hooks/session-start.sh +1 -1
- package/config/skills-curated.yaml +2 -2
- package/config/skills-provenance.yaml +7 -0
- package/config/statusline.sh +6 -1
- package/core/hooks/session_start.py +69 -28
- package/departments/dev/agents/architect.yaml +1 -0
- package/departments/dev/agents/backend-dev.yaml +1 -0
- package/departments/dev/agents/devops-eng.yaml +1 -0
- package/departments/dev/agents/frontend-dev.yaml +1 -0
- package/departments/dev/agents/tech-lead.yaml +1 -0
- package/departments/dev/skills/diagram/SKILL.md +175 -0
- package/departments/dev/skills/diagram/examples/archify-repo-grid.architecture.json +57 -0
- package/departments/dev/skills/diagram/vendor/LICENSE +22 -0
- package/departments/dev/skills/diagram/vendor/SKILL.md +367 -0
- package/departments/dev/skills/diagram/vendor/assets/template.html +11470 -0
- package/departments/dev/skills/diagram/vendor/bin/archify.mjs +600 -0
- package/departments/dev/skills/diagram/vendor/bin/open-artifact.mjs +67 -0
- package/departments/dev/skills/diagram/vendor/examples/agent-run.lifecycle.json +71 -0
- package/departments/dev/skills/diagram/vendor/examples/agent-tool-call.workflow.json +111 -0
- package/departments/dev/skills/diagram/vendor/examples/async-job-roundtrip.sequence.json +62 -0
- package/departments/dev/skills/diagram/vendor/examples/cache-miss-request.sequence.json +82 -0
- package/departments/dev/skills/diagram/vendor/examples/deployment-release.lifecycle.json +50 -0
- package/departments/dev/skills/diagram/vendor/examples/event-stream.dataflow.json +58 -0
- package/departments/dev/skills/diagram/vendor/examples/incident-response.workflow.json +65 -0
- package/departments/dev/skills/diagram/vendor/examples/product-analytics.dataflow.json +77 -0
- package/departments/dev/skills/diagram/vendor/examples/production-deployment.architecture.json +70 -0
- package/departments/dev/skills/diagram/vendor/examples/release-delivery.workflow.json +63 -0
- package/departments/dev/skills/diagram/vendor/examples/web-app.architecture.json +47 -0
- package/departments/dev/skills/diagram/vendor/package-lock.json +80 -0
- package/departments/dev/skills/diagram/vendor/package.json +28 -0
- package/departments/dev/skills/diagram/vendor/recipes/scenarios.mjs +350 -0
- package/departments/dev/skills/diagram/vendor/renderers/architecture/grid.mjs +62 -0
- package/departments/dev/skills/diagram/vendor/renderers/architecture/render-architecture.mjs +490 -0
- package/departments/dev/skills/diagram/vendor/renderers/dataflow/README.md +93 -0
- package/departments/dev/skills/diagram/vendor/renderers/dataflow/render-dataflow.mjs +396 -0
- package/departments/dev/skills/diagram/vendor/renderers/lifecycle/README.md +103 -0
- package/departments/dev/skills/diagram/vendor/renderers/lifecycle/render-lifecycle.mjs +482 -0
- package/departments/dev/skills/diagram/vendor/renderers/sequence/README.md +96 -0
- package/departments/dev/skills/diagram/vendor/renderers/sequence/render-sequence.mjs +357 -0
- package/departments/dev/skills/diagram/vendor/renderers/shared/cli.mjs +176 -0
- package/departments/dev/skills/diagram/vendor/renderers/shared/generated-validators.mjs +13 -0
- package/departments/dev/skills/diagram/vendor/renderers/shared/geometry.mjs +992 -0
- package/departments/dev/skills/diagram/vendor/renderers/shared/layout-report.mjs +40 -0
- package/departments/dev/skills/diagram/vendor/renderers/shared/utils.mjs +156 -0
- package/departments/dev/skills/diagram/vendor/renderers/shared/validator.mjs +40 -0
- package/departments/dev/skills/diagram/vendor/renderers/workflow/README.md +114 -0
- package/departments/dev/skills/diagram/vendor/renderers/workflow/render-workflow.mjs +598 -0
- package/departments/dev/skills/diagram/vendor/schemas/README.md +93 -0
- package/departments/dev/skills/diagram/vendor/schemas/architecture.schema.json +125 -0
- package/departments/dev/skills/diagram/vendor/schemas/common.schema.json +62 -0
- package/departments/dev/skills/diagram/vendor/schemas/dataflow.schema.json +240 -0
- package/departments/dev/skills/diagram/vendor/schemas/lifecycle.schema.json +260 -0
- package/departments/dev/skills/diagram/vendor/schemas/sequence.schema.json +205 -0
- package/departments/dev/skills/diagram/vendor/schemas/workflow.schema.json +351 -0
- package/departments/dev/skills/diagram/vendor/scripts/check-render-output.mjs +774 -0
- package/departments/dev/skills/diagram/vendor/scripts/generate-validators.mjs +66 -0
- package/departments/dev/skills/diagram/vendor/scripts/render-examples.mjs +26 -0
- package/departments/dev/skills/spec/SKILL.md +19 -0
- package/departments/ops/agents/ops-lead.yaml +1 -0
- package/departments/pm/agents/pm-director.yaml +1 -0
- package/departments/strategy/agents/strategy-director.yaml +1 -0
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/zed/.rules +1 -1
- package/installer/autoupdate.js +249 -0
- package/installer/cli.js +7 -0
- package/installer/core-snapshot.js +5 -1
- package/installer/doctor.js +11 -0
- package/installer/index.js +11 -0
- package/installer/update.js +11 -0
- package/knowledge/agents-registry-v2.json +9 -1
- package/knowledge/skills-manifest.json +14 -1
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/scripts/auto-update.sh +188 -0
- package/scripts/skill_validator.py +10 -2
- package/scripts/tools/docs_stats.py +8 -2
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
|
|
4
|
+
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagram, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
|
|
5
|
+
import {
|
|
6
|
+
asArray,
|
|
7
|
+
isFinitePoint,
|
|
8
|
+
rectsOverlap,
|
|
9
|
+
cleanFlowProblems,
|
|
10
|
+
cleanCrossingProblems,
|
|
11
|
+
cleanAmbiguousCorridorProblems,
|
|
12
|
+
cleanBorderRunProblems,
|
|
13
|
+
cleanRouteRhythmProblems,
|
|
14
|
+
cleanLabelRouteClearanceProblems,
|
|
15
|
+
suggestLabelObstacleFix,
|
|
16
|
+
suggestLabelPairFix,
|
|
17
|
+
anchor,
|
|
18
|
+
automaticPortSpread,
|
|
19
|
+
defaultFromSide,
|
|
20
|
+
defaultToSide,
|
|
21
|
+
chosenSide,
|
|
22
|
+
roundedPath,
|
|
23
|
+
routePointsValue,
|
|
24
|
+
labelPoint,
|
|
25
|
+
arrowClassMap,
|
|
26
|
+
variantAccent
|
|
27
|
+
} from '../shared/geometry.mjs';
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const { diagram: lifecycle, template, outPath } = loadDiagram({
|
|
31
|
+
rendererDir: __dirname,
|
|
32
|
+
diagramType: 'lifecycle',
|
|
33
|
+
defaultExample: 'agent-run.lifecycle.json'
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const viewBox = lifecycle.meta?.viewBox || [980, 660];
|
|
37
|
+
const layout = {
|
|
38
|
+
phaseY: 126,
|
|
39
|
+
eventY: 278,
|
|
40
|
+
outcomeY: 450,
|
|
41
|
+
phaseW: 118,
|
|
42
|
+
phaseH: 62,
|
|
43
|
+
eventW: 126,
|
|
44
|
+
eventH: 58,
|
|
45
|
+
outcomeW: 118,
|
|
46
|
+
outcomeH: 58,
|
|
47
|
+
phaseXs: [94, 248, 402, 556, 710],
|
|
48
|
+
eventXs: [402, 556, 710],
|
|
49
|
+
outcomeXs: [402, 556, 710]
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const typeClass = {
|
|
53
|
+
start: 'c-frontend',
|
|
54
|
+
active: 'c-backend',
|
|
55
|
+
waiting: 'c-cloud',
|
|
56
|
+
decision: 'c-security',
|
|
57
|
+
success: 'c-database',
|
|
58
|
+
failure: 'c-security',
|
|
59
|
+
neutral: 'c-external',
|
|
60
|
+
external: 'c-external'
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const textClass = {
|
|
64
|
+
start: 't-frontend',
|
|
65
|
+
active: 't-backend',
|
|
66
|
+
waiting: 't-cloud',
|
|
67
|
+
decision: 't-security',
|
|
68
|
+
success: 't-database',
|
|
69
|
+
failure: 't-security',
|
|
70
|
+
neutral: 't-muted',
|
|
71
|
+
external: 't-muted'
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function legendY() {
|
|
75
|
+
return viewBox[1] - 98;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
|
|
79
|
+
// "terminal" maps to the bottom outcome band, and every other lane shares the
|
|
80
|
+
// middle event band (separated visually via yOffset).
|
|
81
|
+
function bandFor(lane) {
|
|
82
|
+
if (lane === 'main') return 'phase';
|
|
83
|
+
if (lane === 'terminal') return 'outcome';
|
|
84
|
+
return 'event';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function measureState(state) {
|
|
88
|
+
const isPhase = bandFor(state.lane) === 'phase';
|
|
89
|
+
const isOutcome = bandFor(state.lane) === 'outcome';
|
|
90
|
+
const width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
|
|
91
|
+
const height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
|
|
92
|
+
const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
|
|
93
|
+
const cx = xs[state.col] ?? xs[xs.length - 1];
|
|
94
|
+
const y = (
|
|
95
|
+
isPhase ? layout.phaseY :
|
|
96
|
+
isOutcome ? layout.outcomeY :
|
|
97
|
+
layout.eventY
|
|
98
|
+
) + (state.yOffset || 0);
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
width,
|
|
102
|
+
height,
|
|
103
|
+
x: cx - width / 2,
|
|
104
|
+
y,
|
|
105
|
+
cx,
|
|
106
|
+
cy: y + height / 2
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const states = new Map(asArray(lifecycle.states).map((state) => [state.id, measureState(state)]));
|
|
111
|
+
const laneLabels = new Map(asArray(lifecycle.lanes).map((lane) => [lane.id, lane.label]));
|
|
112
|
+
const stateSteps = new Map();
|
|
113
|
+
for (const [index, transition] of asArray(lifecycle.transitions).entries()) {
|
|
114
|
+
if (!stateSteps.has(transition.from)) stateSteps.set(transition.from, index);
|
|
115
|
+
if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
|
|
116
|
+
}
|
|
117
|
+
for (const [index, state] of asArray(lifecycle.states).entries()) {
|
|
118
|
+
if (!stateSteps.has(state.id)) stateSteps.set(state.id, index);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateLifecycle() {
|
|
122
|
+
const problems = [];
|
|
123
|
+
if (lifecycle.schema_version !== 1) problems.push('Lifecycle files must set "schema_version": 1.');
|
|
124
|
+
if (lifecycle.diagram_type !== 'lifecycle') problems.push('Lifecycle files must set "diagram_type": "lifecycle".');
|
|
125
|
+
if (!lifecycle.meta?.title) problems.push('Lifecycle files must include meta.title.');
|
|
126
|
+
if (!Array.isArray(lifecycle.lanes) || lifecycle.lanes.length < 1) problems.push('Lifecycle diagrams need at least one lane.');
|
|
127
|
+
if (!Array.isArray(lifecycle.states) || lifecycle.states.length < 2) problems.push('Lifecycle diagrams need at least two states.');
|
|
128
|
+
if (!Array.isArray(lifecycle.transitions)) problems.push('Lifecycle diagrams must include a transitions array.');
|
|
129
|
+
if (lifecycle.cards !== undefined && !Array.isArray(lifecycle.cards)) problems.push('Lifecycle "cards" must be an array.');
|
|
130
|
+
if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
|
|
131
|
+
|
|
132
|
+
// The three bands are fixed at y=112/264/436; the legend (viewBox[1] - 98)
|
|
133
|
+
// must clear the outcome band's header zone.
|
|
134
|
+
if (legendY() - 20 < 448) {
|
|
135
|
+
problems.push(`viewBox height ${viewBox[1]} is too short for the fixed band layout — set meta.viewBox[1] to at least 566.`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const laneIds = new Set(asArray(lifecycle.lanes).map((lane) => lane.id));
|
|
139
|
+
if (laneIds.size !== asArray(lifecycle.lanes).length) problems.push('Lane ids must be unique.');
|
|
140
|
+
if (!laneIds.has('main')) {
|
|
141
|
+
problems.push('Lifecycle diagrams need a lane with id "main" (the phase rail). Lane ids "main" and "terminal" are reserved: "main" maps to the top phase band, "terminal" to the bottom outcome band, and all other lanes share the middle event band.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const state of states.values()) {
|
|
145
|
+
if (!laneIds.has(state.lane)) {
|
|
146
|
+
problems.push(`State "${state.id}" uses unknown lane "${state.lane}".`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const band = bandFor(state.lane);
|
|
150
|
+
const maxCol = band === 'phase'
|
|
151
|
+
? layout.phaseXs.length
|
|
152
|
+
: band === 'outcome'
|
|
153
|
+
? layout.outcomeXs.length
|
|
154
|
+
: layout.eventXs.length;
|
|
155
|
+
if (!Number.isInteger(state.col) || state.col < 0 || state.col >= maxCol) {
|
|
156
|
+
problems.push(`State "${state.id}" uses invalid column ${state.col} — the ${band} band has integer columns 0..${maxCol - 1}.`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!isFinitePoint(state.x, state.y, state.cx, state.cy)) {
|
|
160
|
+
problems.push(`State "${state.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (state.x < 32 || state.x + state.width > viewBox[0] - 32) {
|
|
164
|
+
problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram — reduce state.width or increase meta.viewBox[0].`);
|
|
165
|
+
}
|
|
166
|
+
if (state.y < 64 || state.y + state.height > legendY() - 24) {
|
|
167
|
+
problems.push(`State "${state.id}" exceeds the vertical lifecycle area — keep y between 64 and ${legendY() - 24} (adjust yOffset or increase meta.viewBox[1]).`);
|
|
168
|
+
}
|
|
169
|
+
const estLabelW = textUnits(state.label) * 6.2;
|
|
170
|
+
if (estLabelW > state.width + 6) {
|
|
171
|
+
problems.push(`Label "${state.label}" (~${Math.round(estLabelW)}px) is wider than state "${state.id}" (${state.width}px) — shorten the label, move detail to sublabel, or increase state.width.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// All non-main/non-terminal lanes share the same y band, so the overlap
|
|
176
|
+
// check must run across lanes — not per-lane.
|
|
177
|
+
const allStates = [...states.values()];
|
|
178
|
+
for (let i = 0; i < allStates.length; i += 1) {
|
|
179
|
+
for (let j = i + 1; j < allStates.length; j += 1) {
|
|
180
|
+
if (rectsOverlap(allStates[i], allStates[j], 10)) {
|
|
181
|
+
problems.push(`States "${allStates[i].id}" and "${allStates[j].id}" are less than 10px apart — move one to another col or separate them with yOffset (lanes other than "main"/"terminal" share one band).`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const transition of asArray(lifecycle.transitions)) {
|
|
187
|
+
if (!states.has(transition.from)) problems.push(`Transition "${transition.label || transition.from}" references unknown source "${transition.from}".`);
|
|
188
|
+
if (!states.has(transition.to)) problems.push(`Transition "${transition.label || transition.to}" references unknown target "${transition.to}".`);
|
|
189
|
+
if (states.has(transition.from) && states.has(transition.to)) {
|
|
190
|
+
const routed = pathFor(transition);
|
|
191
|
+
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
|
|
192
|
+
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
|
|
193
|
+
if (distance < 32) problems.push(`Transition "${transition.label || `${transition.from}->${transition.to}`}" is too short (${Math.round(distance)}px; minimum 32px) — route it through a channel or drop its label.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
problems.push(...cleanFlowProblems({
|
|
198
|
+
relations: lifecycle.transitions,
|
|
199
|
+
endpointIds: new Set(states.keys()),
|
|
200
|
+
obstacles: states.values(),
|
|
201
|
+
pathFor,
|
|
202
|
+
diagramType: 'lifecycle',
|
|
203
|
+
relationCollection: 'transitions',
|
|
204
|
+
obstacleKind: 'state',
|
|
205
|
+
profile: lifecycle.meta?.quality_profile,
|
|
206
|
+
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
|
|
207
|
+
}));
|
|
208
|
+
problems.push(...cleanCrossingProblems({
|
|
209
|
+
relations: lifecycle.transitions,
|
|
210
|
+
endpointIds: new Set(states.keys()),
|
|
211
|
+
pathFor,
|
|
212
|
+
diagramType: 'lifecycle',
|
|
213
|
+
relationCollection: 'transitions',
|
|
214
|
+
profile: lifecycle.meta?.quality_profile,
|
|
215
|
+
routeHint: 'adjust route/via or channelX/channelY so the transitions use separate lifecycle corridors'
|
|
216
|
+
}));
|
|
217
|
+
problems.push(...cleanAmbiguousCorridorProblems({
|
|
218
|
+
relations: lifecycle.transitions,
|
|
219
|
+
endpointIds: new Set(states.keys()),
|
|
220
|
+
pathFor,
|
|
221
|
+
diagramType: 'lifecycle',
|
|
222
|
+
relationCollection: 'transitions',
|
|
223
|
+
profile: lifecycle.meta?.quality_profile,
|
|
224
|
+
routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
|
|
225
|
+
}));
|
|
226
|
+
// Lifecycle bands are dashed reading guides, not closed containers. Keep the
|
|
227
|
+
// shared contract wired with an explicit empty frame set so future typed
|
|
228
|
+
// lifecycle containers cannot accidentally inherit presentation geometry.
|
|
229
|
+
problems.push(...cleanBorderRunProblems({
|
|
230
|
+
relations: lifecycle.transitions,
|
|
231
|
+
endpointIds: new Set(states.keys()),
|
|
232
|
+
frames: [],
|
|
233
|
+
pathFor,
|
|
234
|
+
diagramType: 'lifecycle',
|
|
235
|
+
relationCollection: 'transitions',
|
|
236
|
+
profile: lifecycle.meta?.quality_profile
|
|
237
|
+
}));
|
|
238
|
+
problems.push(...cleanRouteRhythmProblems({
|
|
239
|
+
relations: lifecycle.transitions,
|
|
240
|
+
endpointIds: new Set(states.keys()),
|
|
241
|
+
pathFor,
|
|
242
|
+
diagramType: 'lifecycle',
|
|
243
|
+
relationCollection: 'transitions',
|
|
244
|
+
profile: lifecycle.meta?.quality_profile,
|
|
245
|
+
routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
const labelRects = [];
|
|
249
|
+
for (const [transitionIndex, transition] of asArray(lifecycle.transitions).entries()) {
|
|
250
|
+
if (!transition.label || !states.has(transition.from) || !states.has(transition.to)) continue;
|
|
251
|
+
const [lx, ly] = labelPoint(transition, pathFor(transition).points);
|
|
252
|
+
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
|
253
|
+
const width = Math.max(32, longestLine * 4.9 + 12);
|
|
254
|
+
const height = transition.note ? 27 : 16;
|
|
255
|
+
labelRects.push({ relation: transition, relationIndex: transitionIndex, label: transition.label, x: lx - width / 2, y: ly - 11, width, height, lx, ly });
|
|
256
|
+
}
|
|
257
|
+
for (const rect of labelRects) {
|
|
258
|
+
for (const state of states.values()) {
|
|
259
|
+
if (rectsOverlap(rect, state, -2)) {
|
|
260
|
+
problems.push(`Label "${rect.label}" overlaps state "${state.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state')}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (let i = 0; i < labelRects.length; i += 1) {
|
|
265
|
+
for (let j = i + 1; j < labelRects.length; j += 1) {
|
|
266
|
+
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
|
|
267
|
+
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
problems.push(...cleanLabelRouteClearanceProblems({
|
|
272
|
+
relations: lifecycle.transitions,
|
|
273
|
+
labels: labelRects,
|
|
274
|
+
endpointIds: new Set(states.keys()),
|
|
275
|
+
pathFor,
|
|
276
|
+
diagramType: 'lifecycle',
|
|
277
|
+
relationCollection: 'transitions',
|
|
278
|
+
profile: lifecycle.meta?.quality_profile,
|
|
279
|
+
}));
|
|
280
|
+
|
|
281
|
+
if (problems.length) {
|
|
282
|
+
throw new Error(`Lifecycle layout validation failed:\n- ${problems.join('\n- ')}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function routeVia(transition, from, to, start, end) {
|
|
287
|
+
if (transition.via) return transition.via;
|
|
288
|
+
switch (transition.route || 'auto') {
|
|
289
|
+
case 'straight':
|
|
290
|
+
return [];
|
|
291
|
+
case 'drop': {
|
|
292
|
+
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
|
293
|
+
return [[start[0], y], [end[0], y]];
|
|
294
|
+
}
|
|
295
|
+
case 'bottom-channel': {
|
|
296
|
+
const y = transition.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 34;
|
|
297
|
+
return [[start[0], y], [end[0], y]];
|
|
298
|
+
}
|
|
299
|
+
case 'top-channel': {
|
|
300
|
+
const y = transition.channelY ?? Math.min(from.y, to.y) - 28;
|
|
301
|
+
return [[start[0], y], [end[0], y]];
|
|
302
|
+
}
|
|
303
|
+
case 'right-channel': {
|
|
304
|
+
const x = transition.channelX ?? Math.max(from.x + from.width, to.x + to.width) + 36;
|
|
305
|
+
return [[x, start[1]], [x, end[1]]];
|
|
306
|
+
}
|
|
307
|
+
case 'left-channel': {
|
|
308
|
+
const x = transition.channelX ?? Math.min(from.x, to.x) - 36;
|
|
309
|
+
return [[x, start[1]], [x, end[1]]];
|
|
310
|
+
}
|
|
311
|
+
case 'auto':
|
|
312
|
+
default: {
|
|
313
|
+
if (from.lane === to.lane) return [];
|
|
314
|
+
const y = transition.channelY ?? (start[1] + end[1]) / 2;
|
|
315
|
+
return [[start[0], y], [end[0], y]];
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const pathCache = new Map();
|
|
321
|
+
const automaticPorts = automaticPortSpread(lifecycle.transitions, states);
|
|
322
|
+
|
|
323
|
+
function pathFor(transition) {
|
|
324
|
+
if (pathCache.has(transition)) return pathCache.get(transition);
|
|
325
|
+
const from = states.get(transition.from);
|
|
326
|
+
const to = states.get(transition.to);
|
|
327
|
+
const ports = automaticPorts.get(transition);
|
|
328
|
+
const start = ports?.from || anchor(from, chosenSide(transition.fromSide, defaultFromSide(from, to)));
|
|
329
|
+
const end = ports?.to || anchor(to, chosenSide(transition.toSide, defaultToSide(from, to)));
|
|
330
|
+
let via = routeVia(transition, from, to, start, end);
|
|
331
|
+
if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
|
|
332
|
+
const midX = (start[0] + end[0]) / 2;
|
|
333
|
+
via = [[midX, start[1]], [midX, end[1]]];
|
|
334
|
+
}
|
|
335
|
+
const points = [start, ...via, end];
|
|
336
|
+
const routed = {
|
|
337
|
+
d: roundedPath(points, transition.cornerRadius ?? 10),
|
|
338
|
+
points
|
|
339
|
+
};
|
|
340
|
+
pathCache.set(transition, routed);
|
|
341
|
+
return routed;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function bandTitles() {
|
|
345
|
+
const lanes = asArray(lifecycle.lanes);
|
|
346
|
+
const mainLane = lanes.find((lane) => lane.id === 'main');
|
|
347
|
+
const terminalLane = lanes.find((lane) => lane.id === 'terminal');
|
|
348
|
+
const eventLanes = lanes.filter((lane) => lane.id !== 'main' && lane.id !== 'terminal');
|
|
349
|
+
return [
|
|
350
|
+
mainLane?.label || 'Lifecycle phases',
|
|
351
|
+
eventLanes.length ? eventLanes.map((lane) => lane.label).join(' + ') : 'Interruptions + recovery',
|
|
352
|
+
terminalLane?.label || 'Outcomes'
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function renderBands() {
|
|
357
|
+
const right = viewBox[0] - 72;
|
|
358
|
+
const titles = bandTitles();
|
|
359
|
+
return ` <path d="M 72 112 L ${right} 112" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
|
360
|
+
<text x="72" y="100" class="t-dim" font-size="10" font-weight="600">01 / ${esc(titles[0])}</text>
|
|
361
|
+
<path d="M 72 264 L ${right} 264" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
|
362
|
+
<text x="72" y="252" class="t-dim" font-size="10" font-weight="600">02 / ${esc(titles[1])}</text>
|
|
363
|
+
<path d="M 72 436 L ${right} 436" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
|
|
364
|
+
<text x="72" y="424" class="t-dim" font-size="10" font-weight="600">03 / ${esc(titles[2])}</text>`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderState(state) {
|
|
368
|
+
const fill = typeClass[state.type] || typeClass.neutral;
|
|
369
|
+
const accent = textClass[state.type] || 't-muted';
|
|
370
|
+
const hasSub = state.sublabel != null && state.sublabel !== '';
|
|
371
|
+
const sub = hasSub
|
|
372
|
+
? `\n <text data-detail="context" x="${state.cx}" y="${state.y + 37}" class="t-muted" font-size="7" text-anchor="middle">${esc(state.sublabel)}</text>`
|
|
373
|
+
: '';
|
|
374
|
+
const tag = state.tag
|
|
375
|
+
? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + state.height - 11}" class="${accent}" font-size="7" text-anchor="middle">${esc(state.tag)}</text>`
|
|
376
|
+
: '';
|
|
377
|
+
const step = state.step
|
|
378
|
+
? `\n <text data-detail="fine" x="${state.x + 10}" y="${state.y + 14}" class="${accent}" font-size="7" font-weight="700">${esc(state.step)}</text>`
|
|
379
|
+
: '';
|
|
380
|
+
const passport = { kind: state.type, sublabel: state.sublabel, tag: state.tag, context: laneLabels.get(state.lane) || 'Lifecycle state' };
|
|
381
|
+
return ` <g ${focusNodeAttrs(state.id, state.label, passport)}>
|
|
382
|
+
${focusNodeTitle(state.label, passport)}
|
|
383
|
+
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
|
|
384
|
+
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>
|
|
385
|
+
${renderSemanticSigil(state.type, { x: state.x + state.width - 17, y: state.y + 6 })}${step}
|
|
386
|
+
<text${hasSub ? ' data-detail-anchor' : ''} x="${state.cx}" y="${state.y + 21}" class="t-primary" font-size="10" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
|
|
387
|
+
</g>`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function renderTransitionPath(transition, index) {
|
|
391
|
+
const [cls, marker] = arrowClassMap[transition.variant || 'default'] || arrowClassMap.default;
|
|
392
|
+
const routed = pathFor(transition);
|
|
393
|
+
const strokeWidth = transition.width || (transition.variant === 'emphasis' ? 2 : 1.1);
|
|
394
|
+
return ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}" d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function renderTransitionLabel(transition, index) {
|
|
398
|
+
if (!transition.label) return '';
|
|
399
|
+
const routed = pathFor(transition);
|
|
400
|
+
const [lx, ly] = labelPoint(transition, routed.points);
|
|
401
|
+
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
|
|
402
|
+
const labelW = Math.max(32, longestLine * 4.9 + 12);
|
|
403
|
+
const labelH = transition.note ? 27 : 16;
|
|
404
|
+
const note = transition.note
|
|
405
|
+
? `\n <text data-detail="fine" x="${lx}" y="${ly + 11}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
|
|
406
|
+
: '';
|
|
407
|
+
return ` <g data-detail="context" ${focusEdgeAttrs(transition.from, transition.to, transition.label, index, transition.id)}>
|
|
408
|
+
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>
|
|
409
|
+
<text x="${lx}" y="${ly}" class="${variantAccent(transition.variant)}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>${note}
|
|
410
|
+
</g>`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function renderLegend() {
|
|
414
|
+
const y = legendY();
|
|
415
|
+
return ` <g data-legend-bridge>
|
|
416
|
+
<text x="220" y="${y - 20}" class="t-primary" font-size="10" font-weight="600">Legend</text>
|
|
417
|
+
<g data-legend-kind="active">
|
|
418
|
+
<rect x="220" y="${y - 8}" width="14" height="9" rx="2" class="c-backend" stroke-width="1"/>
|
|
419
|
+
<text x="240" y="${y}" class="t-muted" font-size="7">active state</text>
|
|
420
|
+
</g>
|
|
421
|
+
<g data-legend-kind="waiting">
|
|
422
|
+
<rect x="325" y="${y - 8}" width="14" height="9" rx="2" class="c-cloud" stroke-width="1"/>
|
|
423
|
+
<text x="345" y="${y}" class="t-muted" font-size="7">waiting</text>
|
|
424
|
+
</g>
|
|
425
|
+
<g data-legend-kind="success">
|
|
426
|
+
<rect x="415" y="${y - 8}" width="14" height="9" rx="2" class="c-database" stroke-width="1"/>
|
|
427
|
+
<text x="435" y="${y}" class="t-muted" font-size="7">terminal success</text>
|
|
428
|
+
</g>
|
|
429
|
+
<g data-legend-kind="failure">
|
|
430
|
+
<rect x="560" y="${y - 8}" width="14" height="9" rx="2" class="c-security" stroke-width="1"/>
|
|
431
|
+
<text x="580" y="${y}" class="t-muted" font-size="7">failure / exit</text>
|
|
432
|
+
</g>
|
|
433
|
+
</g>`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function renderLifecycleRail() {
|
|
437
|
+
const mainCols = [...states.values()]
|
|
438
|
+
.filter((state) => bandFor(state.lane) === 'phase')
|
|
439
|
+
.map((state) => state.col);
|
|
440
|
+
if (!mainCols.length) return '';
|
|
441
|
+
const railEnd = layout.phaseXs[Math.max(...mainCols)] + 38;
|
|
442
|
+
return ` <path d="M 154 ${layout.phaseY + 31} L ${railEnd} ${layout.phaseY + 31}" class="a-emphasis" stroke-width="2.2" marker-end="url(#arrowhead-emphasis)"/>`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function renderSvg() {
|
|
446
|
+
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(lifecycle.meta, 'lifecycle diagram')}>
|
|
447
|
+
${svgAccessibleText(lifecycle.meta, 'lifecycle diagram')}
|
|
448
|
+
${renderDefinitions()}
|
|
449
|
+
|
|
450
|
+
<!-- Background Grid -->
|
|
451
|
+
<rect width="100%" height="100%" fill="url(#grid)" />
|
|
452
|
+
|
|
453
|
+
<!-- Lifecycle bands -->
|
|
454
|
+
${renderBands()}
|
|
455
|
+
|
|
456
|
+
<!-- Primary lifecycle rail -->
|
|
457
|
+
${renderLifecycleRail()}
|
|
458
|
+
|
|
459
|
+
<!-- Transition paths -->
|
|
460
|
+
${asArray(lifecycle.transitions).map(renderTransitionPath).join('\n')}
|
|
461
|
+
|
|
462
|
+
<!-- States -->
|
|
463
|
+
${[...states.values()].map(renderState).join('\n\n')}
|
|
464
|
+
|
|
465
|
+
<!-- Transition labels -->
|
|
466
|
+
${asArray(lifecycle.transitions).map(renderTransitionLabel).join('\n')}
|
|
467
|
+
|
|
468
|
+
<!-- Legend -->
|
|
469
|
+
${renderLegend()}
|
|
470
|
+
</svg>`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
validateLifecycle();
|
|
474
|
+
writeDiagram({
|
|
475
|
+
outPath,
|
|
476
|
+
template,
|
|
477
|
+
diagramType: 'lifecycle',
|
|
478
|
+
meta: lifecycle.meta,
|
|
479
|
+
footerLabel: 'Lifecycle diagram',
|
|
480
|
+
svg: renderSvg(),
|
|
481
|
+
cards: lifecycle.cards,
|
|
482
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Sequence Renderer
|
|
2
|
+
|
|
3
|
+
Render `diagram_type: "sequence"` JSON files into the standard Archify HTML
|
|
4
|
+
template.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
node archify/renderers/sequence/render-sequence.mjs input.sequence.json output.html
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The renderer validates input against `archify/schemas/sequence.schema.json`
|
|
11
|
+
with the bundled standalone validator. No dependency installation is required.
|
|
12
|
+
|
|
13
|
+
If `output.html` is omitted, the renderer uses `meta.output` from the JSON file
|
|
14
|
+
or falls back to `sequence.html` in the current working directory.
|
|
15
|
+
|
|
16
|
+
## Input
|
|
17
|
+
|
|
18
|
+
Sequence JSON files must set:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"schema_version": 1,
|
|
23
|
+
"diagram_type": "sequence",
|
|
24
|
+
"meta": {
|
|
25
|
+
"title": "Cache Miss Request Sequence",
|
|
26
|
+
"subtitle": "Frontend request path with auth and cache fallback",
|
|
27
|
+
"viewBox": [920, 760]
|
|
28
|
+
},
|
|
29
|
+
"participants": [],
|
|
30
|
+
"segments": [],
|
|
31
|
+
"messages": [],
|
|
32
|
+
"activations": [],
|
|
33
|
+
"cards": []
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The timeline scales with the viewBox height: a taller `meta.viewBox` buys more
|
|
38
|
+
message room, a shorter one shrinks the readable band instead of clipping. A
|
|
39
|
+
complete worked example lives at
|
|
40
|
+
`archify/examples/cache-miss-request.sequence.json`.
|
|
41
|
+
|
|
42
|
+
The schema lives at:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
archify/schemas/sequence.schema.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Layout budget
|
|
49
|
+
|
|
50
|
+
| Constant | Value |
|
|
51
|
+
|----------|-------|
|
|
52
|
+
| viewBox | default `[920, 760]`; schema minimum `[480, 480]` |
|
|
53
|
+
| Participant boxes | 86×54 at y 72; centers at x = 62 + index×108 |
|
|
54
|
+
| Participant count | last center + 43 must be ≤ width − 40 (8 fit at width 920) |
|
|
55
|
+
| Lifelines | from y 142 down to height − 65; band must be ≥120px tall |
|
|
56
|
+
| Message `y` range | `[160, height − 83]` |
|
|
57
|
+
| Message spacing | ≥28px vertical between messages that share horizontal space |
|
|
58
|
+
| Arrow span | ≥60px horizontal between the two participants |
|
|
59
|
+
| Segments | y pixel ranges with `to > from`, inside `[72, lifeline bottom + 20]` |
|
|
60
|
+
| Legend row | y = height − 54 |
|
|
61
|
+
|
|
62
|
+
`segments[].from/to` and `activations[].from/to` are y pixel coordinates, not
|
|
63
|
+
participant ids; activations also require `to > from`.
|
|
64
|
+
|
|
65
|
+
## Design Rules
|
|
66
|
+
|
|
67
|
+
- Put participants across the top, ordered by the story the reader should
|
|
68
|
+
follow.
|
|
69
|
+
- Time moves downward.
|
|
70
|
+
- Use `emphasis` for the main request path.
|
|
71
|
+
- Use `security` for auth, consent, permission, and policy calls.
|
|
72
|
+
- Use `return` for quiet response messages.
|
|
73
|
+
- Use `dashed` for async trace, event, logging, and non-blocking work.
|
|
74
|
+
- Use segments as light background guides; keep segment labels short.
|
|
75
|
+
- Keep labels short enough to fit in narrow previews.
|
|
76
|
+
|
|
77
|
+
Schema violations exit non-zero with path-prefixed messages annotated with the
|
|
78
|
+
element's id or label. The renderer additionally fails when it can detect
|
|
79
|
+
layout problems, including missing participants, duplicate participant IDs,
|
|
80
|
+
participant labels wider than their box, unknown message endpoints, messages
|
|
81
|
+
outside the readable timeline, overly tight vertical spacing between messages
|
|
82
|
+
that overlap horizontally, invalid segment or activation ranges, or
|
|
83
|
+
participants that exceed the viewBox. The shared Clean Flow contract treats
|
|
84
|
+
participant headers as semantic boxes while explicitly allowing messages to
|
|
85
|
+
cross intermediate lifelines, activation bars, and segment frames. Text width is estimated CJK-aware:
|
|
86
|
+
fullwidth glyphs count as two units.
|
|
87
|
+
|
|
88
|
+
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
|
|
89
|
+
message X crossings then fail with `composition/proper-crossing`; default
|
|
90
|
+
`standard` keeps them as artifact-receipt warnings. Messages may still cross
|
|
91
|
+
intermediate lifelines. Collinear corridors remain outside the proper-X rule,
|
|
92
|
+
but a separate gate warns in `standard` and fails in `showcase` when unrelated
|
|
93
|
+
messages overlap for at least 8px. Shared semantic endpoints, point touches,
|
|
94
|
+
and shorter overlaps remain valid. Showcase also rejects any route segment
|
|
95
|
+
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
|
|
96
|
+
stubs remain valid.
|