claude-mcp-workflow 0.1.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.
Files changed (66) hide show
  1. package/.claude-plugin/plugin.json +13 -0
  2. package/.mcp.json +9 -0
  3. package/LICENSE +21 -0
  4. package/README.md +260 -0
  5. package/build/dashboard.d.ts +4 -0
  6. package/build/dashboard.d.ts.map +1 -0
  7. package/build/dashboard.js +91 -0
  8. package/build/dashboard.js.map +1 -0
  9. package/build/engine.d.ts +55 -0
  10. package/build/engine.d.ts.map +1 -0
  11. package/build/engine.js +486 -0
  12. package/build/engine.js.map +1 -0
  13. package/build/index.d.ts +2 -0
  14. package/build/index.d.ts.map +1 -0
  15. package/build/index.js +60 -0
  16. package/build/index.js.map +1 -0
  17. package/build/loader.d.ts +29 -0
  18. package/build/loader.d.ts.map +1 -0
  19. package/build/loader.js +166 -0
  20. package/build/loader.js.map +1 -0
  21. package/build/modifier.d.ts +42 -0
  22. package/build/modifier.d.ts.map +1 -0
  23. package/build/modifier.js +96 -0
  24. package/build/modifier.js.map +1 -0
  25. package/build/storage.d.ts +12 -0
  26. package/build/storage.d.ts.map +1 -0
  27. package/build/storage.js +62 -0
  28. package/build/storage.js.map +1 -0
  29. package/build/tools.d.ts +7 -0
  30. package/build/tools.d.ts.map +1 -0
  31. package/build/tools.js +316 -0
  32. package/build/tools.js.map +1 -0
  33. package/build/types.d.ts +417 -0
  34. package/build/types.d.ts.map +1 -0
  35. package/build/types.js +82 -0
  36. package/build/types.js.map +1 -0
  37. package/dashboard/dagre.min.js +801 -0
  38. package/dashboard/index.html +652 -0
  39. package/hooks/hooks.json +24 -0
  40. package/hooks/workflow-cleanup.sh +51 -0
  41. package/hooks/workflow-start.sh +79 -0
  42. package/package.json +44 -0
  43. package/templates/bug-fix.yaml +283 -0
  44. package/templates/code-review.yaml +164 -0
  45. package/templates/coding.yaml +176 -0
  46. package/templates/debugging.yaml +162 -0
  47. package/templates/explore.yaml +90 -0
  48. package/templates/file-code.yaml +69 -0
  49. package/templates/file-review.yaml +164 -0
  50. package/templates/investigate.yaml +84 -0
  51. package/templates/master.yaml +202 -0
  52. package/templates/new-feature.yaml +41 -0
  53. package/templates/planning.yaml +85 -0
  54. package/templates/refactoring.yaml +56 -0
  55. package/templates/reflection.yaml +61 -0
  56. package/templates/skills/architecture/SKILL.md +55 -0
  57. package/templates/skills/coding-skill-selector/SKILL.md +25 -0
  58. package/templates/skills/lang-haxe/SKILL.md +257 -0
  59. package/templates/skills/lang-python/SKILL.md +16 -0
  60. package/templates/skills/math/SKILL.md +14 -0
  61. package/templates/skills/preferences/SKILL.md +25 -0
  62. package/templates/skills/task-delegation/SKILL.md +53 -0
  63. package/templates/skills/web-reading/SKILL.md +62 -0
  64. package/templates/subagent.yaml +67 -0
  65. package/templates/testing.yaml +120 -0
  66. package/templates/web-research.yaml +53 -0
@@ -0,0 +1,652 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>MCP Workflow Engine</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; }
10
+ .container { max-width: 1400px; margin: 0 auto; padding: 20px; }
11
+ h1 { color: #58a6ff; margin-bottom: 20px; font-size: 24px; }
12
+ h2 { color: #8b949e; font-size: 16px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
13
+ .grid { display: grid; grid-template-columns: 350px 1fr; gap: 20px; min-height: calc(100vh - 100px); }
14
+ .panel { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
15
+ .session-item { padding: 10px 12px; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s; }
16
+ .session-item:hover { border-color: #58a6ff; }
17
+ .session-item.active { border-color: #58a6ff; background: #1c2333; }
18
+ .session-id { font-weight: 600; color: #58a6ff; font-size: 14px; }
19
+ .session-name { font-weight: 600; color: #e6edf3; font-size: 15px; }
20
+ .session-uuid { font-size: 11px; color: #484f58; font-family: monospace; margin-top: 2px; }
21
+ .session-info { color: #8b949e; font-size: 12px; margin-top: 4px; }
22
+ .badge-main { background: #1a2a3a; color: #58a6ff; }
23
+ .badge-sub { background: #2a1a3a; color: #d2a8ff; }
24
+ .stack-frame { padding: 8px 12px; background: #1c2333; border-left: 3px solid #30363d; margin-bottom: 6px; font-family: monospace; font-size: 13px; }
25
+ .stack-frame.active-frame { border-left-color: #3fb950; background: #1a2a1a; }
26
+ .state-name { color: #ffa657; font-weight: 600; }
27
+ .workflow-name { color: #d2a8ff; }
28
+ .transition-list { margin-top: 12px; }
29
+ .transition-item { padding: 6px 10px; background: #21262d; border-radius: 4px; margin-bottom: 4px; font-family: monospace; font-size: 13px; }
30
+ .transition-arrow { color: #3fb950; }
31
+ .prompt-box { background: #1c2333; border: 1px solid #30363d; border-radius: 6px; padding: 14px; margin-top: 12px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
32
+ .history-entry { font-family: monospace; font-size: 12px; color: #8b949e; padding: 3px 0; }
33
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
34
+ .badge-active { background: #1a3a1a; color: #3fb950; }
35
+ .badge-completed { background: #1a2a3a; color: #58a6ff; }
36
+ .badge-abandoned { background: #3a1a1a; color: #f85149; }
37
+ .badge-depth { background: #3a2a1a; color: #ffa657; }
38
+ .badge-child { background: #2a2a1a; color: #d2a8ff; }
39
+ .parent-link { color: #58a6ff; cursor: pointer; text-decoration: underline; }
40
+ .parent-link:hover { color: #79c0ff; }
41
+ .graph-container { margin-top: 16px; }
42
+ .graph-container svg { display: block; width: 100%; height: auto; }
43
+ svg text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; pointer-events: none; user-select: none; }
44
+ .empty-state { text-align: center; padding: 40px; color: #484f58; }
45
+ .refresh-indicator { position: fixed; top: 10px; right: 10px; font-size: 11px; color: #484f58; }
46
+ #context-panel { margin-top: 12px; }
47
+ .context-entry { font-family: monospace; font-size: 12px; padding: 2px 0; }
48
+ .context-key { color: #ffa657; }
49
+ .context-value { color: #c9d1d9; }
50
+ .actor-tag { padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; margin-left: 4px; }
51
+ .state-item { cursor: pointer; transition: border-left-color 0.2s; }
52
+ .state-item:hover { border-left-color: #58a6ff; }
53
+ .state-item.selected { border-left-color: #ffa657; background: #1a2a1a; }
54
+ .state-prompt { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 14px; margin: 4px 0 8px 0; font-size: 13px; line-height: 1.5; white-space: pre-wrap; color: #c9d1d9; }
55
+ .state-prompt-label { font-size: 11px; color: #8b949e; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
56
+ .state-flags { color: #8b949e; font-size: 11px; margin-left: 6px; }
57
+ </style>
58
+ </head>
59
+ <body>
60
+ <div class="container">
61
+ <h1>MCP Workflow Engine</h1>
62
+ <div class="grid">
63
+ <div>
64
+ <div class="panel" id="sessions-panel">
65
+ <h2>Sessions</h2>
66
+ <div id="sessions-list"><div class="empty-state">No active sessions</div></div>
67
+ </div>
68
+ <div class="panel" id="workflows-panel" style="margin-top: 16px;">
69
+ <h2>Workflows</h2>
70
+ <div id="workflows-list"><div class="empty-state">No workflows loaded</div></div>
71
+ </div>
72
+ </div>
73
+ <div>
74
+ <div class="panel" id="detail-panel">
75
+ <h2>Session Detail</h2>
76
+ <div id="detail-content"><div class="empty-state">Select a session</div></div>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ <div class="refresh-indicator" id="refresh-indicator">Auto-refresh: 2s</div>
82
+
83
+ <script src="/dagre.min.js"></script>
84
+ <script>
85
+ let selectedSessionId = null;
86
+ let allWorkflows = {};
87
+ let allSessions = [];
88
+
89
+ const ACTOR_COLORS = ['#58a6ff', '#3fb950', '#d2a8ff', '#ffa657', '#f85149', '#79c0ff', '#56d364', '#e3b341'];
90
+ function actorColor(name) {
91
+ let hash = 0;
92
+ for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
93
+ return ACTOR_COLORS[Math.abs(hash) % ACTOR_COLORS.length];
94
+ }
95
+
96
+ async function fetchJSON(url) {
97
+ try {
98
+ const res = await fetch(url);
99
+ if (!res.ok) return null;
100
+ return res.json();
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function escapeHtml(str) {
107
+ const div = document.createElement('div');
108
+ div.textContent = str;
109
+ return div.innerHTML;
110
+ }
111
+
112
+ async function refresh() {
113
+ const [sessions, workflows] = await Promise.all([
114
+ fetchJSON('/api/sessions'),
115
+ fetchJSON('/api/workflows'),
116
+ ]);
117
+
118
+ const indicator = document.getElementById('refresh-indicator');
119
+ if (sessions === null && workflows === null) {
120
+ indicator.textContent = 'Reconnecting...';
121
+ indicator.style.color = '#f85149';
122
+ return;
123
+ }
124
+ indicator.textContent = 'Auto-refresh: 2s';
125
+ indicator.style.color = '#484f58';
126
+
127
+ allSessions = sessions || [];
128
+ allWorkflows = workflows || {};
129
+ renderSessions(allSessions);
130
+ renderWorkflows(workflows || {});
131
+
132
+ if (selectedSessionId) {
133
+ const session = (sessions || []).find(s => s.session_id === selectedSessionId);
134
+ if (session) renderDetail(session);
135
+ else document.getElementById('detail-content').innerHTML = '<div class="empty-state">Session not found</div>';
136
+ }
137
+ }
138
+
139
+ function getDisplayName(s) {
140
+ if (s.context?.display_name) return s.context.display_name;
141
+ if (s.context?.cwd) return s.context.cwd.split('/').pop();
142
+ return s.session_id.slice(0, 8);
143
+ }
144
+
145
+ function getAgentBadge(s) {
146
+ const startEntry = s.history?.find(h => h.event === 'start');
147
+ const actor = startEntry?.actor;
148
+ if (!actor) return '';
149
+ return ` <span class="badge badge-sub">${escapeHtml(actor)}</span>`;
150
+ }
151
+
152
+ function renderSessionItem(s, depth = 0) {
153
+ const isActive = s.stack.length > 0;
154
+ const isAbandoned = !isActive && s.outcome === 'abandoned';
155
+ const frame = isActive ? s.stack[s.active_frame] : null;
156
+ const stateInfo = frame ? `${frame.workflow} @ ${frame.current_state}` : isAbandoned ? 'abandoned' : 'completed';
157
+ const statusBadge = isActive
158
+ ? `<span class="badge badge-active">active</span>`
159
+ : isAbandoned
160
+ ? `<span class="badge badge-abandoned">abandoned</span>`
161
+ : `<span class="badge badge-completed">done</span>`;
162
+ const depthBadge = s.stack.length > 1
163
+ ? ` <span class="badge badge-depth">depth: ${s.stack.length}</span>`
164
+ : '';
165
+ const childBadge = s.parent_session_id
166
+ ? ` <span class="badge badge-child">child</span>`
167
+ : '';
168
+ const selected = s.session_id === selectedSessionId ? ' active' : '';
169
+ const displayName = getDisplayName(s);
170
+ const agentBadge = getAgentBadge(s);
171
+ const indent = depth > 0 ? `margin-left:${depth * 20}px` : '';
172
+
173
+ return `<div class="session-item${selected}" style="${indent}" onclick="selectSession('${escapeHtml(s.session_id)}')">
174
+ <div class="session-name">${escapeHtml(displayName)} ${statusBadge}${agentBadge}${depthBadge}${childBadge}</div>
175
+ <div class="session-uuid">${escapeHtml(s.session_id)}</div>
176
+ <div class="session-info">${escapeHtml(stateInfo)}</div>
177
+ </div>`;
178
+ }
179
+
180
+ function buildChildrenMap(sessions) {
181
+ const map = {};
182
+ for (const s of sessions)
183
+ if (s.parent_session_id) {
184
+ if (!map[s.parent_session_id]) map[s.parent_session_id] = [];
185
+ map[s.parent_session_id].push(s);
186
+ }
187
+ return map;
188
+ }
189
+
190
+ function renderSessionTree(session, childrenMap, depth) {
191
+ let html = renderSessionItem(session, depth);
192
+ const children = childrenMap[session.session_id] || [];
193
+ for (const child of children)
194
+ html += renderSessionTree(child, childrenMap, depth + 1);
195
+ return html;
196
+ }
197
+
198
+ function renderSessions(sessions) {
199
+ const container = document.getElementById('sessions-list');
200
+ if (sessions.length === 0) {
201
+ container.innerHTML = '<div class="empty-state">No active sessions</div>';
202
+ return;
203
+ }
204
+
205
+ const childrenMap = buildChildrenMap(sessions);
206
+ const childIds = new Set(sessions.filter(s => s.parent_session_id).map(s => s.session_id));
207
+
208
+ const active = sessions.filter(s => s.stack.length > 0 && !childIds.has(s.session_id));
209
+ const inactive = sessions.filter(s => s.stack.length === 0 && !childIds.has(s.session_id));
210
+ inactive.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
211
+ const abandoned = inactive.filter(s => s.outcome === 'abandoned');
212
+ const completed = inactive.filter(s => s.outcome !== 'abandoned');
213
+ const recentAbandoned = abandoned.slice(0, 3);
214
+ const recentCompleted = completed.slice(0, 3);
215
+
216
+ let html = '';
217
+
218
+ if (active.length > 0)
219
+ html += active.map(s => renderSessionTree(s, childrenMap, 0)).join('');
220
+ else
221
+ html += '<div class="empty-state" style="padding:12px">No active sessions</div>';
222
+
223
+ if (recentAbandoned.length > 0) {
224
+ const countLabel = abandoned.length > recentAbandoned.length ? ` (${recentAbandoned.length} of ${abandoned.length})` : '';
225
+ html += `<h2 style="margin-top:12px">Abandoned${countLabel}</h2>`;
226
+ html += recentAbandoned.map(s => renderSessionTree(s, childrenMap, 0)).join('');
227
+ }
228
+
229
+ if (recentCompleted.length > 0) {
230
+ const countLabel = completed.length > recentCompleted.length ? ` (${recentCompleted.length} of ${completed.length})` : '';
231
+ html += `<h2 style="margin-top:12px">Recent completed${countLabel}</h2>`;
232
+ html += recentCompleted.map(s => renderSessionTree(s, childrenMap, 0)).join('');
233
+ }
234
+
235
+ container.innerHTML = html;
236
+ }
237
+
238
+ function renderWorkflows(workflows) {
239
+ const container = document.getElementById('workflows-list');
240
+ const names = Object.keys(workflows);
241
+ if (names.length === 0) {
242
+ container.innerHTML = '<div class="empty-state">No workflows loaded</div>';
243
+ return;
244
+ }
245
+
246
+ container.innerHTML = names.map(name => {
247
+ const wf = workflows[name];
248
+ const stateCount = Object.keys(wf.states).length;
249
+ const desc = wf.description ? ` — ${escapeHtml(wf.description)}` : '';
250
+ return `<div class="session-item" onclick="showWorkflowGraph('${escapeHtml(name)}')">
251
+ <div class="session-id">${escapeHtml(name)}</div>
252
+ <div class="session-info">${stateCount} states${desc}</div>
253
+ </div>`;
254
+ }).join('');
255
+ }
256
+
257
+ function selectSession(id) {
258
+ selectedSessionId = id;
259
+ refresh();
260
+ }
261
+
262
+ function renderDetail(session) {
263
+ const container = document.getElementById('detail-content');
264
+ const isActive = session.stack.length > 0;
265
+
266
+ let html = '';
267
+
268
+ // Parent/child links
269
+ if (session.parent_session_id) {
270
+ html += `<div style="margin-bottom:12px"><span style="color:#8b949e">Parent:</span> <span class="parent-link" onclick="selectSession('${escapeHtml(session.parent_session_id)}')">${escapeHtml(session.parent_session_id)}</span></div>`;
271
+ }
272
+ const children = allSessions.filter(s => s.parent_session_id === session.session_id);
273
+ if (children.length > 0) {
274
+ html += '<div style="margin-bottom:12px"><span style="color:#8b949e">Children:</span> ';
275
+ html += children.map(c => `<span class="parent-link" onclick="selectSession('${escapeHtml(c.session_id)}')">${escapeHtml(c.session_id)}</span>`).join(', ');
276
+ html += '</div>';
277
+ }
278
+
279
+ // Stack
280
+ html += '<h2 style="margin-top:0">Stack</h2>';
281
+ if (!isActive) {
282
+ const label = session.outcome === 'abandoned' ? 'Workflow abandoned' : 'Workflow completed';
283
+ html += `<div class="stack-frame">${label}</div>`;
284
+ } else {
285
+ html += session.stack.map((frame, i) => {
286
+ const cls = i === session.active_frame ? ' active-frame' : '';
287
+ const visits = frame.state_visits[frame.current_state] || 0;
288
+ const visitStr = visits > 1 ? ` (visit ${visits})` : '';
289
+ const marker = i === session.active_frame ? ' ← ACTIVE' : '';
290
+ const waiting = i < session.active_frame ? ' (waiting)' : '';
291
+ return `<div class="stack-frame${cls}">[${i}] <span class="workflow-name">${escapeHtml(frame.workflow)}</span> @ <span class="state-name">${escapeHtml(frame.current_state)}</span>${visitStr}${waiting}${marker}</div>`;
292
+ }).join('');
293
+ }
294
+
295
+ // Prompt
296
+ if (isActive) {
297
+ const frame = session.stack[session.active_frame];
298
+ const wf = allWorkflows[frame.workflow];
299
+ const state = wf?.states?.[frame.current_state];
300
+ const prompt = state?.prompt || '(sub_workflow state)';
301
+ html += '<h2 style="margin-top:16px">Current Prompt</h2>';
302
+ html += `<div class="prompt-box">${escapeHtml(prompt)}</div>`;
303
+
304
+ // Transitions
305
+ const transitions = state?.transitions || {};
306
+ const entries = Object.entries(transitions);
307
+ if (entries.length > 0) {
308
+ html += '<h2 style="margin-top:16px">Available Transitions</h2>';
309
+ html += '<div class="transition-list">';
310
+ html += entries.map(([name, target]) =>
311
+ `<div class="transition-item">${escapeHtml(name)} <span class="transition-arrow">→</span> ${escapeHtml(target)}</div>`
312
+ ).join('');
313
+ html += '</div>';
314
+ }
315
+ }
316
+
317
+ // Context
318
+ const ctxEntries = Object.entries(session.context || {});
319
+ if (ctxEntries.length > 0) {
320
+ html += '<h2 style="margin-top:16px">Context</h2>';
321
+ html += '<div id="context-panel">';
322
+ html += ctxEntries.map(([k, v]) =>
323
+ `<div class="context-entry"><span class="context-key">${escapeHtml(k)}</span>: <span class="context-value">${escapeHtml(JSON.stringify(v))}</span></div>`
324
+ ).join('');
325
+ html += '</div>';
326
+ }
327
+
328
+ // Graph
329
+ if (isActive) {
330
+ const frame = session.stack[session.active_frame];
331
+ html += '<h2 style="margin-top:16px">Workflow Graph</h2>';
332
+ html += '<div class="graph-container">';
333
+ html += renderGraph(frame.workflow, frame.current_state, session.stack);
334
+ html += '</div>';
335
+ }
336
+
337
+ // History
338
+ html += '<h2 style="margin-top:16px">History</h2>';
339
+ const history = (session.history || []).slice(-20);
340
+ html += history.map(h => {
341
+ const actorHtml = h.actor ? ` <span class="actor-tag" style="background:${actorColor(h.actor)}20;color:${actorColor(h.actor)}">${escapeHtml(h.actor)}</span>` : '';
342
+ if (h.event) return `<div class="history-entry">[${h.frame}] ${escapeHtml(h.event)}${h.workflow ? ' (' + escapeHtml(h.workflow) + ')' : ''}${actorHtml}</div>`;
343
+ return `<div class="history-entry">[${h.frame}] ${escapeHtml(h.from || '')} → ${escapeHtml(h.to || '')} via "${escapeHtml(h.via || '')}"${actorHtml}</div>`;
344
+ }).join('');
345
+
346
+ container.innerHTML = html;
347
+ }
348
+
349
+ function pointsToPath(points) {
350
+ if (points.length < 2) return '';
351
+ if (points.length === 2) {
352
+ return `M${points[0].x},${points[0].y}L${points[1].x},${points[1].y}`;
353
+ }
354
+ // Catmull-Rom to cubic bezier through dagre control points
355
+ let d = `M${points[0].x},${points[0].y}`;
356
+ for (let i = 0; i < points.length - 1; i++) {
357
+ const p0 = points[Math.max(0, i - 1)];
358
+ const p1 = points[i];
359
+ const p2 = points[i + 1];
360
+ const p3 = points[Math.min(points.length - 1, i + 2)];
361
+ const t = 1 / 6;
362
+ const cp1x = p1.x + (p2.x - p0.x) * t;
363
+ const cp1y = p1.y + (p2.y - p0.y) * t;
364
+ const cp2x = p2.x - (p3.x - p1.x) * t;
365
+ const cp2y = p2.y - (p3.y - p1.y) * t;
366
+ d += `C${cp1x},${cp1y},${cp2x},${cp2y},${p2.x},${p2.y}`;
367
+ }
368
+ return d;
369
+ }
370
+
371
+ function renderGraph(workflowName, currentState, stack) {
372
+ const wf = allWorkflows[workflowName];
373
+ if (!wf) return '<div class="empty-state">Workflow definition not available</div>';
374
+
375
+ const states = Object.entries(wf.states);
376
+ const nodeW = 160;
377
+ const nodeH = 48;
378
+
379
+ // Auto-detect layout direction: wide fan-out → left-to-right
380
+ let maxFanOut = 0;
381
+ for (const [, state] of states)
382
+ if (state.transitions) maxFanOut = Math.max(maxFanOut, Object.keys(state.transitions).length);
383
+ const rankdir = maxFanOut > 4 ? 'LR' : 'TB';
384
+
385
+ // Build dagre graph
386
+ const g = new dagre.graphlib.Graph({ multigraph: true });
387
+ g.setGraph({ rankdir, nodesep: 60, ranksep: 80, marginx: 30, marginy: 30 });
388
+ g.setDefaultEdgeLabel(() => ({}));
389
+
390
+ for (const [name, state] of states) {
391
+ g.setNode(name, { width: nodeW, height: nodeH, state });
392
+ }
393
+
394
+ // Edges: transitions, on_complete, on_fail
395
+ for (const [name, state] of states) {
396
+ if (state.transitions) {
397
+ for (const [label, target] of Object.entries(state.transitions)) {
398
+ if (g.hasNode(target))
399
+ g.setEdge(name, target, { label, type: 'transition' }, `t:${name}:${label}`);
400
+ }
401
+ }
402
+ if (state.sub_workflow) {
403
+ if (state.on_complete && g.hasNode(state.on_complete))
404
+ g.setEdge(name, state.on_complete, { label: 'on_complete', type: 'on_complete' }, `oc:${name}`);
405
+ if (state.on_fail && g.hasNode(state.on_fail))
406
+ g.setEdge(name, state.on_fail, { label: 'on_fail', type: 'on_fail' }, `of:${name}`);
407
+ }
408
+ }
409
+
410
+ dagre.layout(g);
411
+
412
+ const graphInfo = g.graph();
413
+ const svgW = Math.ceil(graphInfo.width || 400);
414
+ const svgH = Math.ceil(graphInfo.height || 300);
415
+
416
+ // Collect active states from stack for highlighting
417
+ const activeStates = new Set();
418
+ for (const frame of stack) {
419
+ if (frame.workflow === workflowName) activeStates.add(frame.current_state);
420
+ }
421
+
422
+ // Unique ID suffix to avoid filter collisions when multiple graphs on page
423
+ const uid = workflowName.replace(/[^a-zA-Z0-9]/g, '');
424
+
425
+ let svg = `<svg viewBox="0 0 ${svgW} ${svgH}" width="${svgW}" style="max-width:${svgW}px" xmlns="http://www.w3.org/2000/svg">`;
426
+
427
+ // Defs: markers and glow filters
428
+ svg += '<defs>';
429
+ svg += `<marker id="arr-${uid}" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="10" markerHeight="6" orient="auto"><path d="M0,0 L10,3 L0,6z" fill="#484f58"/></marker>`;
430
+ svg += `<marker id="arr-green-${uid}" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="10" markerHeight="6" orient="auto"><path d="M0,0 L10,3 L0,6z" fill="#3fb950"/></marker>`;
431
+ svg += `<marker id="arr-red-${uid}" viewBox="0 0 10 6" refX="10" refY="3" markerWidth="10" markerHeight="6" orient="auto"><path d="M0,0 L10,3 L0,6z" fill="#f85149"/></marker>`;
432
+ svg += `<filter id="glow-green-${uid}" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="4" result="blur"/><feFlood flood-color="#3fb950" flood-opacity="0.4" result="color"/><feComposite in="color" in2="blur" operator="in" result="glow"/><feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`;
433
+ svg += `<filter id="glow-blue-${uid}" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="4" result="blur"/><feFlood flood-color="#58a6ff" flood-opacity="0.4" result="color"/><feComposite in="color" in2="blur" operator="in" result="glow"/><feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`;
434
+ svg += '</defs>';
435
+
436
+ // Draw edges
437
+ for (const e of g.edges()) {
438
+ const edgeData = g.edge(e);
439
+ const points = edgeData.points || [];
440
+ if (points.length === 0) continue;
441
+
442
+ const isSelfLoop = e.v === e.w;
443
+ const edgeType = edgeData.type || 'transition';
444
+
445
+ let strokeColor = '#484f58';
446
+ let dash = '';
447
+ let markerEnd = `url(#arr-${uid})`;
448
+
449
+ if (edgeType === 'on_complete') {
450
+ strokeColor = '#3fb950';
451
+ dash = ' stroke-dasharray="4,4"';
452
+ markerEnd = `url(#arr-green-${uid})`;
453
+ } else if (edgeType === 'on_fail') {
454
+ strokeColor = '#f85149';
455
+ dash = ' stroke-dasharray="6,3"';
456
+ markerEnd = `url(#arr-red-${uid})`;
457
+ }
458
+
459
+ if (isSelfLoop) {
460
+ // Self-loop: arc above the node
461
+ const node = g.node(e.v);
462
+ const cx = node.x;
463
+ const cy = node.y - nodeH / 2;
464
+ const r = 22;
465
+ svg += `<path d="M${cx - 12},${cy} A${r},${r} 0 1,1 ${cx + 12},${cy}" fill="none" stroke="${strokeColor}" stroke-width="1.5"${dash} marker-end="${markerEnd}"/>`;
466
+ } else {
467
+ const d = pointsToPath(points);
468
+ svg += `<path d="${d}" fill="none" stroke="${strokeColor}" stroke-width="1.5"${dash} marker-end="${markerEnd}"/>`;
469
+ }
470
+
471
+ // Edge label
472
+ if (edgeData.label && edgeData.x != null && !isSelfLoop) {
473
+ const lx = edgeData.x;
474
+ const ly = edgeData.y;
475
+ svg += `<text x="${lx}" y="${ly - 5}" text-anchor="middle" fill="${strokeColor}" font-size="10" opacity="0.8">${escapeHtml(edgeData.label)}</text>`;
476
+ }
477
+ }
478
+
479
+ // Draw nodes
480
+ for (const name of g.nodes()) {
481
+ const node = g.node(name);
482
+ const state = node.state || wf.states[name];
483
+ const x = node.x;
484
+ const y = node.y;
485
+ const w = nodeW;
486
+ const h = nodeH;
487
+ const isInitial = name === wf.initial;
488
+ const isTerminal = !!state.terminal;
489
+ const hasSubWorkflow = !!state.sub_workflow;
490
+ const isCurrent = name === currentState;
491
+ const isInStack = !isCurrent && activeStates.has(name);
492
+
493
+ let fill = '#21262d';
494
+ let stroke = '#30363d';
495
+ let textColor = '#c9d1d9';
496
+ let strokeWidth = 2;
497
+ let filterAttr = '';
498
+
499
+ if (isCurrent) {
500
+ fill = '#1a3a1a'; stroke = '#3fb950'; textColor = '#3fb950'; strokeWidth = 2.5;
501
+ filterAttr = ` filter="url(#glow-green-${uid})"`;
502
+ } else if (isInStack) {
503
+ fill = '#1c2333'; stroke = '#58a6ff'; textColor = '#58a6ff'; strokeWidth = 2.5;
504
+ filterAttr = ` filter="url(#glow-blue-${uid})"`;
505
+ } else if (isTerminal) {
506
+ fill = '#1a2a3a'; stroke = '#58a6ff';
507
+ }
508
+
509
+ const rx = isTerminal ? 24 : 6;
510
+
511
+ // Initial state: outer halo rect
512
+ if (isInitial && !isCurrent && !isInStack) {
513
+ svg += `<rect x="${x - w / 2 - 4}" y="${y - h / 2 - 4}" width="${w + 8}" height="${h + 8}" rx="${rx + 2}" fill="none" stroke="#58a6ff" stroke-width="1" opacity="0.5"/>`;
514
+ }
515
+
516
+ // Main node rect
517
+ svg += `<rect id="gnode-${escapeHtml(name)}" x="${x - w / 2}" y="${y - h / 2}" width="${w}" height="${h}" rx="${rx}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${filterAttr} data-fill="${fill}" data-stroke="${stroke}" style="cursor:pointer" onclick="selectGraphNode('${escapeHtml(name)}')"/>`;
518
+
519
+ // Sub-workflow: inner nested rect
520
+ if (hasSubWorkflow) {
521
+ svg += `<rect x="${x - w / 2 + 5}" y="${y - h / 2 + 5}" width="${w - 10}" height="${h - 10}" rx="3" fill="none" stroke="${stroke}" stroke-width="1" opacity="0.4"/>`;
522
+ }
523
+
524
+ // Node label
525
+ const fontSize = name.length > 16 ? 11 : 13;
526
+ const fontWeight = isCurrent ? '700' : '400';
527
+ svg += `<text x="${x}" y="${y + 1}" text-anchor="middle" dominant-baseline="middle" fill="${textColor}" font-size="${fontSize}" font-weight="${fontWeight}">${escapeHtml(name)}</text>`;
528
+
529
+ // Sub-workflow name below main label
530
+ if (hasSubWorkflow) {
531
+ svg += `<text x="${x}" y="${y + 14}" text-anchor="middle" dominant-baseline="middle" fill="#d2a8ff" font-size="9" opacity="0.7">${escapeHtml(state.sub_workflow)}</text>`;
532
+ }
533
+ }
534
+
535
+ svg += '</svg>';
536
+ return svg;
537
+ }
538
+
539
+ let selectedWorkflowState = null;
540
+
541
+ function showWorkflowGraph(name) {
542
+ selectedSessionId = null;
543
+ selectedWorkflowState = null;
544
+ const container = document.getElementById('detail-content');
545
+ container.innerHTML = '<h2>Workflow: ' + escapeHtml(name) + '</h2><div class="graph-container">' + renderGraph(name, null, []) + '</div>';
546
+
547
+ const wf = allWorkflows[name];
548
+ if (wf) {
549
+ let stateList = '<h2 style="margin-top:16px">States</h2>';
550
+ stateList += '<div id="workflow-states-list">';
551
+ for (const [sn, state] of Object.entries(wf.states)) {
552
+ const flags = [];
553
+ if (sn === wf.initial) flags.push('initial');
554
+ if (state.terminal) flags.push('terminal');
555
+ if (state.sub_workflow) flags.push('sub: ' + state.sub_workflow);
556
+ const flagStr = flags.length > 0 ? `<span class="state-flags">[${flags.join(', ')}]</span>` : '';
557
+ stateList += `<div class="stack-frame state-item" data-state="${escapeHtml(sn)}" data-workflow="${escapeHtml(name)}" onclick="toggleStatePrompt(this, '${escapeHtml(name)}', '${escapeHtml(sn)}')"><span class="state-name">${escapeHtml(sn)}</span>${flagStr}</div>`;
558
+ }
559
+ stateList += '</div>';
560
+ container.innerHTML += stateList;
561
+ }
562
+ }
563
+
564
+ function highlightGraphNode(stateName) {
565
+ // Reset all highlighted nodes
566
+ document.querySelectorAll('svg rect[id^="gnode-"]').forEach(rect => {
567
+ rect.setAttribute('stroke', rect.dataset.stroke);
568
+ rect.setAttribute('stroke-width', '2');
569
+ rect.style.filter = '';
570
+ });
571
+ if (!stateName) return;
572
+ const rect = document.getElementById('gnode-' + stateName);
573
+ if (!rect) return;
574
+ rect.setAttribute('stroke', '#ffa657');
575
+ rect.setAttribute('stroke-width', '3');
576
+ }
577
+
578
+ function selectGraphNode(stateName) {
579
+ const list = document.getElementById('workflow-states-list');
580
+ if (!list) return;
581
+ const item = list.querySelector(`.state-item[data-state="${stateName}"]`);
582
+ if (!item) return;
583
+
584
+ // If it's a sub_workflow state, open that workflow instead
585
+ const wfName = item.dataset.workflow;
586
+ const wf = allWorkflows[wfName];
587
+ if (wf) {
588
+ const state = wf.states[stateName];
589
+ if (state && state.sub_workflow && allWorkflows[state.sub_workflow]) {
590
+ showWorkflowGraph(state.sub_workflow);
591
+ return;
592
+ }
593
+ }
594
+
595
+ toggleStatePrompt(item, wfName, stateName);
596
+ item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
597
+ }
598
+
599
+ function toggleStatePrompt(el, workflowName, stateName) {
600
+ const wf = allWorkflows[workflowName];
601
+ if (!wf) return;
602
+ const state = wf.states[stateName];
603
+ if (!state) return;
604
+
605
+ // Deselect if clicking same state
606
+ if (selectedWorkflowState === stateName) {
607
+ selectedWorkflowState = null;
608
+ el.classList.remove('selected');
609
+ highlightGraphNode(null);
610
+ const existing = el.nextElementSibling;
611
+ if (existing && existing.classList.contains('state-prompt-wrapper'))
612
+ existing.remove();
613
+ return;
614
+ }
615
+
616
+ // Clear previous selection
617
+ const list = document.getElementById('workflow-states-list');
618
+ if (list) {
619
+ list.querySelectorAll('.state-item.selected').forEach(item => item.classList.remove('selected'));
620
+ list.querySelectorAll('.state-prompt-wrapper').forEach(item => item.remove());
621
+ }
622
+
623
+ selectedWorkflowState = stateName;
624
+ el.classList.add('selected');
625
+ highlightGraphNode(stateName);
626
+
627
+ const prompt = state.prompt || '(no prompt)';
628
+ const wrapper = document.createElement('div');
629
+ wrapper.className = 'state-prompt-wrapper';
630
+
631
+ let html = `<div class="state-prompt-label">Prompt</div><div class="state-prompt">${escapeHtml(prompt)}</div>`;
632
+
633
+ // Show transitions if any
634
+ const transitions = state.transitions ? Object.entries(state.transitions) : [];
635
+ if (transitions.length > 0) {
636
+ html += '<div class="transition-list">';
637
+ html += transitions.map(([tName, target]) =>
638
+ `<div class="transition-item">${escapeHtml(tName)} <span class="transition-arrow">→</span> ${escapeHtml(target)}</div>`
639
+ ).join('');
640
+ html += '</div>';
641
+ }
642
+
643
+ wrapper.innerHTML = html;
644
+ el.after(wrapper);
645
+ }
646
+
647
+ // Auto-refresh
648
+ setInterval(refresh, 2000);
649
+ refresh();
650
+ </script>
651
+ </body>
652
+ </html>
@@ -0,0 +1,24 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/workflow-start.sh"
9
+ }
10
+ ]
11
+ }
12
+ ],
13
+ "SessionEnd": [
14
+ {
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/workflow-cleanup.sh"
19
+ }
20
+ ]
21
+ }
22
+ ]
23
+ }
24
+ }