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.
- package/.claude-plugin/plugin.json +13 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/build/dashboard.d.ts +4 -0
- package/build/dashboard.d.ts.map +1 -0
- package/build/dashboard.js +91 -0
- package/build/dashboard.js.map +1 -0
- package/build/engine.d.ts +55 -0
- package/build/engine.d.ts.map +1 -0
- package/build/engine.js +486 -0
- package/build/engine.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +60 -0
- package/build/index.js.map +1 -0
- package/build/loader.d.ts +29 -0
- package/build/loader.d.ts.map +1 -0
- package/build/loader.js +166 -0
- package/build/loader.js.map +1 -0
- package/build/modifier.d.ts +42 -0
- package/build/modifier.d.ts.map +1 -0
- package/build/modifier.js +96 -0
- package/build/modifier.js.map +1 -0
- package/build/storage.d.ts +12 -0
- package/build/storage.d.ts.map +1 -0
- package/build/storage.js +62 -0
- package/build/storage.js.map +1 -0
- package/build/tools.d.ts +7 -0
- package/build/tools.d.ts.map +1 -0
- package/build/tools.js +316 -0
- package/build/tools.js.map +1 -0
- package/build/types.d.ts +417 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +82 -0
- package/build/types.js.map +1 -0
- package/dashboard/dagre.min.js +801 -0
- package/dashboard/index.html +652 -0
- package/hooks/hooks.json +24 -0
- package/hooks/workflow-cleanup.sh +51 -0
- package/hooks/workflow-start.sh +79 -0
- package/package.json +44 -0
- package/templates/bug-fix.yaml +283 -0
- package/templates/code-review.yaml +164 -0
- package/templates/coding.yaml +176 -0
- package/templates/debugging.yaml +162 -0
- package/templates/explore.yaml +90 -0
- package/templates/file-code.yaml +69 -0
- package/templates/file-review.yaml +164 -0
- package/templates/investigate.yaml +84 -0
- package/templates/master.yaml +202 -0
- package/templates/new-feature.yaml +41 -0
- package/templates/planning.yaml +85 -0
- package/templates/refactoring.yaml +56 -0
- package/templates/reflection.yaml +61 -0
- package/templates/skills/architecture/SKILL.md +55 -0
- package/templates/skills/coding-skill-selector/SKILL.md +25 -0
- package/templates/skills/lang-haxe/SKILL.md +257 -0
- package/templates/skills/lang-python/SKILL.md +16 -0
- package/templates/skills/math/SKILL.md +14 -0
- package/templates/skills/preferences/SKILL.md +25 -0
- package/templates/skills/task-delegation/SKILL.md +53 -0
- package/templates/skills/web-reading/SKILL.md +62 -0
- package/templates/subagent.yaml +67 -0
- package/templates/testing.yaml +120 -0
- 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>
|
package/hooks/hooks.json
ADDED
|
@@ -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
|
+
}
|