@ai-setting/roy-plugin-task-show 0.6.12 → 0.8.5
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/dist/cli-tasks-tree-adapter.d.ts +143 -0
- package/dist/cli-tasks-tree-adapter.d.ts.map +1 -0
- package/dist/cli-tasks-tree-adapter.js +400 -0
- package/dist/cli-tasks-tree-adapter.js.map +1 -0
- package/dist/plugin.d.ts +62 -6
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +147 -29
- package/dist/plugin.js.map +1 -1
- package/dist/server.d.ts +19 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +211 -89
- package/dist/server.js.map +1 -1
- package/dist/task-detail-mermaid.d.ts +171 -0
- package/dist/task-detail-mermaid.d.ts.map +1 -0
- package/dist/task-detail-mermaid.js +697 -0
- package/dist/task-detail-mermaid.js.map +1 -0
- package/dist/task-operations-html.d.ts.map +1 -1
- package/dist/task-operations-html.js +31 -8
- package/dist/task-operations-html.js.map +1 -1
- package/dist/tasks-tree-cache.d.ts +44 -0
- package/dist/tasks-tree-cache.d.ts.map +1 -0
- package/dist/tasks-tree-cache.js +111 -0
- package/dist/tasks-tree-cache.js.map +1 -0
- package/package.json +6 -2
- package/plugin.json +2 -2
- package/public/app.js +534 -34
- package/public/index.html +54 -21
- package/public/style.css +502 -0
- package/public/task-operations.js +27 -0
- package/public/tasks-tree.js +449 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Mermaid "task lifecycle + tool calls" diagram generator.
|
|
3
|
+
*
|
|
4
|
+
* v0.8.0: replaces the per-task page's old "Execution flow (Mermaid)"
|
|
5
|
+
* section (which only listed tool calls in a flat sequence) with a
|
|
6
|
+
* two-level view that nests each tool call under the operation record
|
|
7
|
+
* that produced it. The structure is:
|
|
8
|
+
*
|
|
9
|
+
* Start([Task #X start]) --> opN subgraph --> End([Task #X end])
|
|
10
|
+
* │
|
|
11
|
+
* ├── tool_1["🔧 edit_file / adapter.ts"]
|
|
12
|
+
* └── tool_2["🔧 bash / ls -la"]
|
|
13
|
+
*
|
|
14
|
+
* Each tool node has a `click tool_1 callback` directive so the browser
|
|
15
|
+
* navigates to the tool table row when the user clicks the node. The
|
|
16
|
+
* callback is `window.__toolClick(toolId)` — see `public/app.js` and
|
|
17
|
+
* `public/mermaid-renderer.js` for the implementation.
|
|
18
|
+
*
|
|
19
|
+
* v0.8.2 (fix/mermaid-readable-labels): replaces the raw
|
|
20
|
+
* `<seq>. <title>` operation-subgraph labels (which leaked technical
|
|
21
|
+
* details such as `plan_doc: docs/.../plan.md - 485 - worktree: HEAD
|
|
22
|
+
* a487b25f from main`) with a concise, human-readable label derived
|
|
23
|
+
* from a `PHASE_LABELS` metadata map. The map is keyed by a canonical
|
|
24
|
+
* phase id (`init-task`, `setup-worktree`, `plan`, `tdd-implement`,
|
|
25
|
+
* `code-review`, `cli-e2e-test`, `final-verify`, `merge-to-main`,
|
|
26
|
+
* `summary-report`, `task-complete`) and each entry has an emoji, a
|
|
27
|
+
* friendly name, and a short description. `getPhaseLabel(op)` parses
|
|
28
|
+
* the operation title for a `Phase N: <name>` pattern, looks up the
|
|
29
|
+
* matching phaseId via `PHASE_ALIASES`, and returns
|
|
30
|
+
* `<emoji> Phase N: <friendly-name><br/><description>`. Unknown
|
|
31
|
+
* titles fall back to a sanitized version (technical fields stripped
|
|
32
|
+
* at the first ` - <field>:` marker). The same logic is mirrored in
|
|
33
|
+
* `public/app.js` so the client-side re-render stays in sync.
|
|
34
|
+
*
|
|
35
|
+
* This module is a pure function. No DOM access. No I/O. Easy to unit
|
|
36
|
+
* test and to compose with other renderers.
|
|
37
|
+
*/
|
|
38
|
+
export const PHASE_LABELS = {
|
|
39
|
+
"init-task": {
|
|
40
|
+
emoji: "📋",
|
|
41
|
+
name: "任务管理",
|
|
42
|
+
description: "task_search + task_create",
|
|
43
|
+
},
|
|
44
|
+
"setup-worktree": {
|
|
45
|
+
emoji: "🌿",
|
|
46
|
+
name: "Worktree 隔离",
|
|
47
|
+
description: "git worktree add",
|
|
48
|
+
},
|
|
49
|
+
"plan": {
|
|
50
|
+
emoji: "📝",
|
|
51
|
+
name: "Plan",
|
|
52
|
+
description: "brainstorming + writing-plans",
|
|
53
|
+
},
|
|
54
|
+
"tdd-implement": {
|
|
55
|
+
emoji: "🧪",
|
|
56
|
+
name: "TDD 循环",
|
|
57
|
+
description: "RED → GREEN → REFACTOR",
|
|
58
|
+
},
|
|
59
|
+
"code-review": {
|
|
60
|
+
emoji: "🔍",
|
|
61
|
+
name: "Code Review",
|
|
62
|
+
description: "BLOCKING/IMPORTANT/MINOR/NIT",
|
|
63
|
+
},
|
|
64
|
+
"cli-e2e-test": {
|
|
65
|
+
emoji: "⚙️",
|
|
66
|
+
name: "CLI E2E",
|
|
67
|
+
description: "build + test + cli smoke",
|
|
68
|
+
},
|
|
69
|
+
"final-verify": {
|
|
70
|
+
emoji: "✅",
|
|
71
|
+
name: "最终验证",
|
|
72
|
+
description: "8 项 checklist",
|
|
73
|
+
},
|
|
74
|
+
"merge-to-main": {
|
|
75
|
+
emoji: "🔀",
|
|
76
|
+
name: "Merge",
|
|
77
|
+
description: "auto_merge 铁律",
|
|
78
|
+
},
|
|
79
|
+
"summary-report": {
|
|
80
|
+
emoji: "📊",
|
|
81
|
+
name: "总结报告",
|
|
82
|
+
description: "reports/ summary",
|
|
83
|
+
},
|
|
84
|
+
"task-complete": {
|
|
85
|
+
emoji: "🏁",
|
|
86
|
+
name: "任务闭环",
|
|
87
|
+
description: "task_complete",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Aliases that map the various spellings the workflow uses in
|
|
92
|
+
* `milestoneTitle` to a canonical phaseId. The lookup is performed
|
|
93
|
+
* case-insensitively, with whitespace + punctuation normalised, so
|
|
94
|
+
* "Plan" / "plan" / "(Plan)" / "Plan — ..." all resolve the same way.
|
|
95
|
+
* Extend this map (rather than special-casing in `getPhaseLabel`) when
|
|
96
|
+
* the workflow grows new phase aliases.
|
|
97
|
+
*/
|
|
98
|
+
export const PHASE_ALIASES = {
|
|
99
|
+
// init-task
|
|
100
|
+
"init-task": "init-task",
|
|
101
|
+
"init_task": "init-task",
|
|
102
|
+
"inittask": "init-task",
|
|
103
|
+
"任务管理": "init-task",
|
|
104
|
+
"任务创建": "init-task",
|
|
105
|
+
"task create": "init-task",
|
|
106
|
+
"task-create": "init-task",
|
|
107
|
+
"create task": "init-task",
|
|
108
|
+
// setup-worktree
|
|
109
|
+
"setup-worktree": "setup-worktree",
|
|
110
|
+
"setup_worktree": "setup-worktree",
|
|
111
|
+
"setupworktree": "setup-worktree",
|
|
112
|
+
"worktree": "setup-worktree",
|
|
113
|
+
"worktree 隔离": "setup-worktree",
|
|
114
|
+
"worktree隔离": "setup-worktree",
|
|
115
|
+
"worktree isolation": "setup-worktree",
|
|
116
|
+
// plan
|
|
117
|
+
"plan": "plan",
|
|
118
|
+
"planning": "plan",
|
|
119
|
+
"计划": "plan",
|
|
120
|
+
"设计与计划": "plan",
|
|
121
|
+
"plan/design": "plan",
|
|
122
|
+
"plan doc": "plan",
|
|
123
|
+
"planner": "plan",
|
|
124
|
+
// tdd-implement
|
|
125
|
+
"tdd": "tdd-implement",
|
|
126
|
+
"tdd-implement": "tdd-implement",
|
|
127
|
+
"tdd_implement": "tdd-implement",
|
|
128
|
+
"tddimplement": "tdd-implement",
|
|
129
|
+
"tdd 循环": "tdd-implement",
|
|
130
|
+
"tdd cycle": "tdd-implement",
|
|
131
|
+
"red → green → refactor": "tdd-implement",
|
|
132
|
+
"red green refactor": "tdd-implement",
|
|
133
|
+
"red→green→refactor": "tdd-implement",
|
|
134
|
+
"implement": "tdd-implement",
|
|
135
|
+
// code-review
|
|
136
|
+
"code-review": "code-review",
|
|
137
|
+
"code_review": "code-review",
|
|
138
|
+
"codereview": "code-review",
|
|
139
|
+
"code review": "code-review",
|
|
140
|
+
"review": "code-review",
|
|
141
|
+
"审查": "code-review",
|
|
142
|
+
// cli-e2e-test
|
|
143
|
+
"cli-e2e-test": "cli-e2e-test",
|
|
144
|
+
"cli_e2e_test": "cli-e2e-test",
|
|
145
|
+
"clie2etest": "cli-e2e-test",
|
|
146
|
+
"cli e2e": "cli-e2e-test",
|
|
147
|
+
"cli e2e test": "cli-e2e-test",
|
|
148
|
+
"e2e": "cli-e2e-test",
|
|
149
|
+
"e2e test": "cli-e2e-test",
|
|
150
|
+
"smoke": "cli-e2e-test",
|
|
151
|
+
// final-verify
|
|
152
|
+
"final-verify": "final-verify",
|
|
153
|
+
"final_verify": "final-verify",
|
|
154
|
+
"finalverify": "final-verify",
|
|
155
|
+
"verify": "final-verify",
|
|
156
|
+
"最终验证": "final-verify",
|
|
157
|
+
"verification": "final-verify",
|
|
158
|
+
// merge-to-main
|
|
159
|
+
"merge-to-main": "merge-to-main",
|
|
160
|
+
"merge_to_main": "merge-to-main",
|
|
161
|
+
"mergetomain": "merge-to-main",
|
|
162
|
+
"merge": "merge-to-main",
|
|
163
|
+
"merge to main": "merge-to-main",
|
|
164
|
+
"merge-to-main.": "merge-to-main",
|
|
165
|
+
// summary-report
|
|
166
|
+
"summary-report": "summary-report",
|
|
167
|
+
"summary_report": "summary-report",
|
|
168
|
+
"summaryreport": "summary-report",
|
|
169
|
+
"summary": "summary-report",
|
|
170
|
+
"总结": "summary-report",
|
|
171
|
+
"总结报告": "summary-report",
|
|
172
|
+
"report": "summary-report",
|
|
173
|
+
// task-complete
|
|
174
|
+
"task-complete": "task-complete",
|
|
175
|
+
"task_complete": "task-complete",
|
|
176
|
+
"taskcomplete": "task-complete",
|
|
177
|
+
"完成": "task-complete",
|
|
178
|
+
"闭环": "task-complete",
|
|
179
|
+
"done": "task-complete",
|
|
180
|
+
"completed": "task-complete",
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Normalise a phase-name string for alias lookup. We lowercase,
|
|
184
|
+
* strip surrounding punctuation, and collapse internal whitespace
|
|
185
|
+
* + dash runs to a single hyphen, so "Plan — ..." / "(plan)" /
|
|
186
|
+
* "Code Review" all match the same canonical key.
|
|
187
|
+
*/
|
|
188
|
+
function normaliseAliasKey(raw) {
|
|
189
|
+
return raw
|
|
190
|
+
.toLowerCase()
|
|
191
|
+
.replace(/[—–_]+/g, "-")
|
|
192
|
+
.replace(/[()()【】\[\]「」『』、,,.。::;;]/g, " ")
|
|
193
|
+
.replace(/\s+/g, " ")
|
|
194
|
+
.trim();
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Try to extract "Phase N" + name from an operation title. Returns
|
|
198
|
+
* `null` if no `Phase N` token is present. Exported for tests so the
|
|
199
|
+
* parser is independently testable.
|
|
200
|
+
*
|
|
201
|
+
* Recognised shapes:
|
|
202
|
+
* "Phase 3: Plan - plan_doc: ..."
|
|
203
|
+
* "Phase 3 (Plan) 完成 — ..."
|
|
204
|
+
* "Phase 3 Plan 完整循环 ..."
|
|
205
|
+
* "Phase 3. Plan: ..."
|
|
206
|
+
* "phase 5 code-review: PASS" (lowercase ok)
|
|
207
|
+
* "Task #X 创建成功(Phase 1 任务管理完成)" (Phase N anywhere in the title)
|
|
208
|
+
*
|
|
209
|
+
* The name is captured up to the first stop-token (`-`, `—`, `:`,
|
|
210
|
+
* `,`, `。`, `完成`, `pass`, newline), so trailing technical details
|
|
211
|
+
* like `plan_doc:` or `worktree: HEAD ...` don't leak in. Hyphens
|
|
212
|
+
* inside the name (e.g. `setup-worktree`, `code-review`) are
|
|
213
|
+
* preserved.
|
|
214
|
+
*/
|
|
215
|
+
export function extractPhaseInfo(title) {
|
|
216
|
+
if (!title)
|
|
217
|
+
return null;
|
|
218
|
+
// We look for `Phase <N>` anywhere in the title, then capture the
|
|
219
|
+
// name up to the first stop-token. The pattern is permissive on
|
|
220
|
+
// the separator between "Phase N" and the name (`:`, `(`, ` `, or
|
|
221
|
+
// a combination).
|
|
222
|
+
const m = title.match(/phase\s+(\d+)\s*[::(\s]*\s*([^\n::(\)、,,]+?)(?=$|\s*(?:[::(\)、,,]|完成|pass|fail|running|pending|[\r\n]))/i);
|
|
223
|
+
if (!m)
|
|
224
|
+
return null;
|
|
225
|
+
const num = m[1];
|
|
226
|
+
let rawName = m[2].trim();
|
|
227
|
+
if (!rawName)
|
|
228
|
+
return null;
|
|
229
|
+
// Defensively strip a trailing stop-token if the regex's
|
|
230
|
+
// lookahead didn't catch it.
|
|
231
|
+
for (const stop of ["完成", "pass", "fail", "pending", "running", ":", ":", "—", "-", ",", ",", "。"]) {
|
|
232
|
+
const i = rawName.toLowerCase().lastIndexOf(stop.toLowerCase());
|
|
233
|
+
if (i >= 0 && i >= 2 && i >= rawName.length - stop.length - 1) {
|
|
234
|
+
rawName = rawName.substring(0, i).trim();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!rawName)
|
|
238
|
+
return null;
|
|
239
|
+
// Strip a trailing status keyword like "完成" or "pass".
|
|
240
|
+
const cleaned = rawName.replace(/\s*(完成|pass|fail|pending|running)\s*$/i, "").trim();
|
|
241
|
+
return { num, name: cleaned || rawName };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Map a raw phase name to a canonical phaseId via `PHASE_ALIASES`.
|
|
245
|
+
* Returns `null` if no alias matches.
|
|
246
|
+
*/
|
|
247
|
+
export function resolvePhaseId(phaseName) {
|
|
248
|
+
if (!phaseName)
|
|
249
|
+
return null;
|
|
250
|
+
const exact = PHASE_ALIASES[phaseName];
|
|
251
|
+
if (exact)
|
|
252
|
+
return exact;
|
|
253
|
+
const norm = normaliseAliasKey(phaseName);
|
|
254
|
+
if (!norm)
|
|
255
|
+
return null;
|
|
256
|
+
if (PHASE_ALIASES[norm])
|
|
257
|
+
return PHASE_ALIASES[norm];
|
|
258
|
+
// Also try without any trailing space + token.
|
|
259
|
+
for (const key of Object.keys(PHASE_ALIASES)) {
|
|
260
|
+
if (normaliseAliasKey(key) === norm)
|
|
261
|
+
return PHASE_ALIASES[key];
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Strip a noisy title of trailing technical fields. We look for the
|
|
267
|
+
* first ` - <known-marker>:` or ` - <known-marker> ` pattern and
|
|
268
|
+
* truncate there. This keeps "Phase 3: Plan - plan_doc: docs/.../plan.md
|
|
269
|
+
* - 485 - worktree: HEAD a487b25f from main" from leaking into the
|
|
270
|
+
* Mermaid label, leaving just "Phase 3: Plan".
|
|
271
|
+
*/
|
|
272
|
+
const NOISE_FIELD_MARKERS = [
|
|
273
|
+
"plan_doc",
|
|
274
|
+
"plan_doc:",
|
|
275
|
+
"plan_doc ",
|
|
276
|
+
"worktree:",
|
|
277
|
+
"worktree ",
|
|
278
|
+
"worktree 已",
|
|
279
|
+
"commits:",
|
|
280
|
+
"commits_reviewed",
|
|
281
|
+
"commits reviewed",
|
|
282
|
+
"commit ",
|
|
283
|
+
"duration",
|
|
284
|
+
"files_changed",
|
|
285
|
+
"files changed",
|
|
286
|
+
"tests_passed",
|
|
287
|
+
"tests_pass",
|
|
288
|
+
"tests/cli/",
|
|
289
|
+
"tests/",
|
|
290
|
+
"test_hello_world",
|
|
291
|
+
"baseline",
|
|
292
|
+
"modified:",
|
|
293
|
+
"modified ",
|
|
294
|
+
"new:",
|
|
295
|
+
"tdd-implement",
|
|
296
|
+
"tdd_implement",
|
|
297
|
+
"tdd ",
|
|
298
|
+
"tdd-",
|
|
299
|
+
"code-review",
|
|
300
|
+
"code review",
|
|
301
|
+
"code-review:",
|
|
302
|
+
"(test_",
|
|
303
|
+
"scripts/",
|
|
304
|
+
"verify-installed",
|
|
305
|
+
"verify-installed:",
|
|
306
|
+
"HEAD=",
|
|
307
|
+
"HEAD:",
|
|
308
|
+
];
|
|
309
|
+
export function sanitiseTitleForLabel(title) {
|
|
310
|
+
if (!title)
|
|
311
|
+
return "";
|
|
312
|
+
let s = title;
|
|
313
|
+
// Only consider cutting at a noise marker if it appears AFTER a
|
|
314
|
+
// phase-name-like prefix, i.e. somewhere in the first ~80 chars.
|
|
315
|
+
const limit = Math.min(s.length, 80);
|
|
316
|
+
let cutAt = -1;
|
|
317
|
+
// 1. Cut at the first ": commit " / " commit " (with trailing
|
|
318
|
+
// space) — common in milestone titles like
|
|
319
|
+
// "Phase 4 tdd-implement: commit 18c2ee2b landed".
|
|
320
|
+
const commitMarkers = [": commit ", " commit "];
|
|
321
|
+
for (const m of commitMarkers) {
|
|
322
|
+
const idx = s.indexOf(m);
|
|
323
|
+
if (idx > 0 && idx < limit && (cutAt < 0 || idx < cutAt)) {
|
|
324
|
+
cutAt = idx;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// 2. Cut at the first noise-marker after a known separator
|
|
328
|
+
// (` - `, ` — `, ` | `, ` (`). This catches patterns like
|
|
329
|
+
// "Phase 3: Plan - plan_doc: docs/..." or
|
|
330
|
+
// "RED: 3 failing tests added (test_hello_world.py)".
|
|
331
|
+
const seps = [" - ", " — ", " | ", " (", ": "];
|
|
332
|
+
for (const marker of NOISE_FIELD_MARKERS) {
|
|
333
|
+
for (const sep of seps) {
|
|
334
|
+
const idx = s.indexOf(sep + marker, 0);
|
|
335
|
+
if (idx > 0 && idx < limit && (cutAt < 0 || idx < cutAt)) {
|
|
336
|
+
cutAt = idx;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// 3. Cut at the first ": <commit-hash>" or " <commit-hash>"
|
|
341
|
+
// pattern, since most commit-style noise has a hash right after
|
|
342
|
+
// a colon or space.
|
|
343
|
+
const hashMatch = s.match(/[:\s]\s*[a-f0-9]{7,}\b/i);
|
|
344
|
+
if (hashMatch && hashMatch.index != null && hashMatch.index < limit && (cutAt < 0 || hashMatch.index < cutAt)) {
|
|
345
|
+
cutAt = hashMatch.index + (hashMatch[0].startsWith(":") ? 1 : 0);
|
|
346
|
+
}
|
|
347
|
+
if (cutAt > 0)
|
|
348
|
+
s = s.substring(0, cutAt);
|
|
349
|
+
return escapeMermaidLabel(s);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Build a human-readable Mermaid label for a single operation
|
|
353
|
+
* record. The output is a single line (with `<br/>` for visual
|
|
354
|
+
* line-break in the Mermaid render) and contains no Mermaid-hostile
|
|
355
|
+
* characters. Examples:
|
|
356
|
+
*
|
|
357
|
+
* getPhaseLabel({ title: "Phase 3: Plan - plan_doc: ... - worktree: HEAD ..." })
|
|
358
|
+
* -> "📝 Phase 3: Plan<br/>brainstorming + writing-plans"
|
|
359
|
+
*
|
|
360
|
+
* getPhaseLabel({ title: "Phase 5 code-review: PASS" })
|
|
361
|
+
* -> "🔍 Phase 5: Code Review<br/>BLOCKING/IMPORTANT/MINOR/NIT"
|
|
362
|
+
*
|
|
363
|
+
* getPhaseLabel({ title: "TDD 完整循环结束" })
|
|
364
|
+
* -> "🧪 TDD 循环<br/>RED → GREEN → REFACTOR"
|
|
365
|
+
*
|
|
366
|
+
* getPhaseLabel({ title: "RED: 3 failing tests added" })
|
|
367
|
+
* -> "⚙️ RED: 3 failing tests added" (fallback, no Phase prefix)
|
|
368
|
+
*
|
|
369
|
+
* getPhaseLabel({ title: "" })
|
|
370
|
+
* -> "⚙️ op" (milestoneType fallback)
|
|
371
|
+
*/
|
|
372
|
+
export function getPhaseLabel(op) {
|
|
373
|
+
const title = (op.title ?? "").toString();
|
|
374
|
+
const milestoneType = (op.milestoneType ?? "op").toString();
|
|
375
|
+
// 1. Try the structured "Phase N: <name>" path. This works
|
|
376
|
+
// regardless of where in the title the "Phase N" appears, so
|
|
377
|
+
// titles like "Task #X 创建成功(Phase 1 任务管理完成)" are
|
|
378
|
+
// handled correctly.
|
|
379
|
+
const info = extractPhaseInfo(title);
|
|
380
|
+
if (info) {
|
|
381
|
+
const phaseId = resolvePhaseId(info.name);
|
|
382
|
+
if (phaseId && PHASE_LABELS[phaseId]) {
|
|
383
|
+
const meta = PHASE_LABELS[phaseId];
|
|
384
|
+
return `${meta.emoji} Phase ${info.num}: ${meta.name}<br/>${meta.description}`;
|
|
385
|
+
}
|
|
386
|
+
// Phase prefix matched but the name doesn't resolve to a
|
|
387
|
+
// canonical phase. Render the raw name (sanitised) so the user
|
|
388
|
+
// still sees the phase number.
|
|
389
|
+
const cleanName = escapeMermaidLabel(info.name);
|
|
390
|
+
return `⚙️ Phase ${info.num}: ${cleanName}`;
|
|
391
|
+
}
|
|
392
|
+
// 2. Try matching the entire title (or first word) as a phase
|
|
393
|
+
// alias. This handles titles like "TDD 完整循环结束" where
|
|
394
|
+
// there's no "Phase N" prefix but the title IS a phase description.
|
|
395
|
+
// Only do this for short titles — long titles are usually verbose
|
|
396
|
+
// descriptions that happen to contain phase keywords.
|
|
397
|
+
if (title.length <= 60) {
|
|
398
|
+
const fullId = resolvePhaseId(title);
|
|
399
|
+
if (fullId && PHASE_LABELS[fullId]) {
|
|
400
|
+
const meta = PHASE_LABELS[fullId];
|
|
401
|
+
return `${meta.emoji} ${meta.name}<br/>${meta.description}`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// 3. Substring scan: we only fire this for titles that start
|
|
405
|
+
// with a clear phase keyword. This avoids false positives in
|
|
406
|
+
// long descriptive titles that happen to contain phase words
|
|
407
|
+
// mid-sentence (e.g. "Plan completed — 4 tasks, 3 TDD tests" is
|
|
408
|
+
// about Plan, not TDD; "Atomic commit ... no merge to main" is
|
|
409
|
+
// about a commit, not a merge phase).
|
|
410
|
+
const lowerStart = title.toLowerCase();
|
|
411
|
+
const startsWithPhase = (needle) => lowerStart.startsWith(needle.toLowerCase());
|
|
412
|
+
const startsWithPhaseHits = [
|
|
413
|
+
["init-task", "init-task"],
|
|
414
|
+
["任务管理", "init-task"],
|
|
415
|
+
["setup-worktree", "setup-worktree"],
|
|
416
|
+
["worktree", "setup-worktree"],
|
|
417
|
+
["plan", "plan"],
|
|
418
|
+
["计划", "plan"],
|
|
419
|
+
["设计与计划", "plan"],
|
|
420
|
+
["tdd", "tdd-implement"],
|
|
421
|
+
["tdd-implement", "tdd-implement"],
|
|
422
|
+
["review", "code-review"],
|
|
423
|
+
["code review", "code-review"],
|
|
424
|
+
["code-review", "code-review"],
|
|
425
|
+
["cli e2e", "cli-e2e-test"],
|
|
426
|
+
["cli-e2e-test", "cli-e2e-test"],
|
|
427
|
+
["smoke", "cli-e2e-test"],
|
|
428
|
+
["verify", "final-verify"],
|
|
429
|
+
["最终验证", "final-verify"],
|
|
430
|
+
["merge-to-main", "merge-to-main"],
|
|
431
|
+
["merge to main", "merge-to-main"],
|
|
432
|
+
["merge", "merge-to-main"],
|
|
433
|
+
["summary", "summary-report"],
|
|
434
|
+
["总结报告", "summary-report"],
|
|
435
|
+
["总结", "summary-report"],
|
|
436
|
+
["报告", "summary-report"],
|
|
437
|
+
["完成", "task-complete"],
|
|
438
|
+
["闭环", "task-complete"],
|
|
439
|
+
];
|
|
440
|
+
for (const [needle, phaseId] of startsWithPhaseHits) {
|
|
441
|
+
if (startsWithPhase(needle)) {
|
|
442
|
+
const meta = PHASE_LABELS[phaseId];
|
|
443
|
+
return `${meta.emoji} ${meta.name}<br/>${meta.description}`;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// 4. Last resort: sanitise the raw title (drop technical fields)
|
|
447
|
+
// and prefix with a neutral ⚙️ emoji.
|
|
448
|
+
const sanitised = sanitiseTitleForLabel(title);
|
|
449
|
+
if (sanitised) {
|
|
450
|
+
return `⚙️ ${sanitised}`;
|
|
451
|
+
}
|
|
452
|
+
// 5. Absolute fallback: use milestoneType as the label.
|
|
453
|
+
return `⚙️ ${escapeMermaidLabel(milestoneType || "op")}`;
|
|
454
|
+
}
|
|
455
|
+
/** What the Mermaid node identifier looks like for a given tool call. */
|
|
456
|
+
export function toolNodeId(sequence) {
|
|
457
|
+
// Mermaid ids are [A-Za-z0-9_]+
|
|
458
|
+
return `t${Math.max(0, Math.floor(sequence))}`;
|
|
459
|
+
}
|
|
460
|
+
/** Mermaid node identifier for an operation record (by sequence). */
|
|
461
|
+
export function opNodeId(sequence) {
|
|
462
|
+
return `op${Math.max(0, Math.floor(sequence))}`;
|
|
463
|
+
}
|
|
464
|
+
/** Mermaid node identifier for the "pre-task" / "post-task" bookends. */
|
|
465
|
+
export const PRE_TASK_NODE = "pre_task";
|
|
466
|
+
export const POST_TASK_NODE = "post_task";
|
|
467
|
+
export const START_NODE = "task_start";
|
|
468
|
+
export const END_NODE = "task_end";
|
|
469
|
+
/**
|
|
470
|
+
* Decide which operation (if any) "owns" a given tool call. The
|
|
471
|
+
* heuristic: a tool with timestamp T belongs to opN if
|
|
472
|
+
* opN.timestamp <= T < opN+1.timestamp
|
|
473
|
+
* (sorted ascending by timestamp). Tools before the first op go to
|
|
474
|
+
* "pre-task", tools after the last op go to "post-task".
|
|
475
|
+
*
|
|
476
|
+
* Exported so tests can verify the bucketing independently of the
|
|
477
|
+
* Mermaid rendering.
|
|
478
|
+
*/
|
|
479
|
+
export function assignToolsToOps(operations, toolCalls) {
|
|
480
|
+
if (operations.length === 0) {
|
|
481
|
+
return toolCalls.map((t) => ({
|
|
482
|
+
toolSequence: t.sequence,
|
|
483
|
+
opSequence: null,
|
|
484
|
+
bucket: "pre-task",
|
|
485
|
+
}));
|
|
486
|
+
}
|
|
487
|
+
// Make sure ops are sorted ascending by timestamp. OperationRecord.timestamp
|
|
488
|
+
// is an ISO 8601 string, ToolCallRecord.timestamp is Unix ms — we
|
|
489
|
+
// normalise ops to ms for comparison.
|
|
490
|
+
const sortedOps = [...operations]
|
|
491
|
+
.map((op) => ({ op, ms: parseIsoToMs(op.timestamp) }))
|
|
492
|
+
.sort((a, b) => a.ms - b.ms);
|
|
493
|
+
return toolCalls.map((t) => {
|
|
494
|
+
if (sortedOps.length === 0) {
|
|
495
|
+
return { toolSequence: t.sequence, opSequence: null, bucket: "pre-task" };
|
|
496
|
+
}
|
|
497
|
+
if (t.timestamp < sortedOps[0].ms) {
|
|
498
|
+
return { toolSequence: t.sequence, opSequence: null, bucket: "pre-task" };
|
|
499
|
+
}
|
|
500
|
+
if (t.timestamp >= sortedOps[sortedOps.length - 1].ms) {
|
|
501
|
+
return { toolSequence: t.sequence, opSequence: null, bucket: "post-task" };
|
|
502
|
+
}
|
|
503
|
+
// Find the op whose timestamp is the largest one <= t.timestamp.
|
|
504
|
+
let owner = null;
|
|
505
|
+
for (const o of sortedOps) {
|
|
506
|
+
if (o.ms <= t.timestamp)
|
|
507
|
+
owner = o.op;
|
|
508
|
+
else
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
toolSequence: t.sequence,
|
|
513
|
+
opSequence: owner ? owner.sequence : null,
|
|
514
|
+
bucket: "in-op",
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/** Best-effort parse of an ISO 8601 timestamp to Unix ms. Falls back to
|
|
519
|
+
* `Number(s)` and finally to `0` so a malformed timestamp never crashes
|
|
520
|
+
* the renderer — it just gets bucketed into pre-task. */
|
|
521
|
+
function parseIsoToMs(s) {
|
|
522
|
+
if (!s)
|
|
523
|
+
return 0;
|
|
524
|
+
const d = new Date(s);
|
|
525
|
+
if (!isNaN(d.getTime()))
|
|
526
|
+
return d.getTime();
|
|
527
|
+
const n = Number(s);
|
|
528
|
+
return Number.isFinite(n) ? n : 0;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Pick the most descriptive path label from a tool's args. We try a
|
|
532
|
+
* bunch of canonical field names; if none matches, return the empty
|
|
533
|
+
* string (the caller will decide whether to show just the tool name).
|
|
534
|
+
*/
|
|
535
|
+
export function pickPathLabel(args) {
|
|
536
|
+
if (!args || typeof args !== "object")
|
|
537
|
+
return "";
|
|
538
|
+
const keys = [
|
|
539
|
+
"file_path",
|
|
540
|
+
"filepath",
|
|
541
|
+
"path",
|
|
542
|
+
"target_file",
|
|
543
|
+
"uri",
|
|
544
|
+
"filename",
|
|
545
|
+
"file",
|
|
546
|
+
];
|
|
547
|
+
for (const k of keys) {
|
|
548
|
+
const v = args[k];
|
|
549
|
+
if (typeof v === "string" && v.length > 0) {
|
|
550
|
+
// Show only the basename so labels stay short in the diagram.
|
|
551
|
+
const parts = v.split(/[\\/]/).filter(Boolean);
|
|
552
|
+
return parts.length > 0 ? parts[parts.length - 1] : v;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return "";
|
|
556
|
+
}
|
|
557
|
+
/** Replace Mermaid-unfriendly characters in a label with safe equivalents. */
|
|
558
|
+
export function escapeMermaidLabel(s) {
|
|
559
|
+
return s
|
|
560
|
+
.replace(/[<>"#|]/g, "_")
|
|
561
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
562
|
+
.replace(/\s+/g, " ")
|
|
563
|
+
.trim();
|
|
564
|
+
}
|
|
565
|
+
/** Build the diagram source. */
|
|
566
|
+
export function buildTaskLifecycleDiagram(opts) {
|
|
567
|
+
const { taskId, operations, toolCalls, withHeader = true, endLabel = "end", } = opts;
|
|
568
|
+
const lines = [];
|
|
569
|
+
if (withHeader) {
|
|
570
|
+
lines.push(`flowchart TD`);
|
|
571
|
+
lines.push(` classDef ok fill:#dcfce7,stroke:#16a34a,color:#064e3b`);
|
|
572
|
+
lines.push(` classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d`);
|
|
573
|
+
lines.push(` classDef op fill:#dbeafe,stroke:#2563eb,color:#1e3a8a`);
|
|
574
|
+
lines.push(` classDef runtime fill:#ede9fe,stroke:#6d28d9,color:#3b0764`);
|
|
575
|
+
lines.push(` classDef bookend fill:#f1f5f9,stroke:#475569,color:#0f172a`);
|
|
576
|
+
}
|
|
577
|
+
lines.push(` ${START_NODE}([Task #${taskId} start]):::runtime`);
|
|
578
|
+
// Bucket tools.
|
|
579
|
+
const assignments = assignToolsToOps(operations, toolCalls);
|
|
580
|
+
const toolsByBucket = {
|
|
581
|
+
"pre-task": [],
|
|
582
|
+
"post-task": [],
|
|
583
|
+
};
|
|
584
|
+
const toolsByOp = {};
|
|
585
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
586
|
+
const t = toolCalls[i];
|
|
587
|
+
const a = assignments[i];
|
|
588
|
+
if (a.bucket === "in-op" && a.opSequence != null) {
|
|
589
|
+
if (!toolsByOp[a.opSequence])
|
|
590
|
+
toolsByOp[a.opSequence] = [];
|
|
591
|
+
toolsByOp[a.opSequence].push(t);
|
|
592
|
+
}
|
|
593
|
+
else if (a.bucket === "pre-task") {
|
|
594
|
+
toolsByBucket["pre-task"].push(t);
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
toolsByBucket["post-task"].push(t);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Pre-task subgraph.
|
|
601
|
+
if (toolsByBucket["pre-task"].length > 0) {
|
|
602
|
+
lines.push(` subgraph ${PRE_TASK_NODE}["pre-task"]`);
|
|
603
|
+
for (const t of toolsByBucket["pre-task"]) {
|
|
604
|
+
const id = toolNodeId(t.sequence);
|
|
605
|
+
const label = makeToolLabel(t);
|
|
606
|
+
lines.push(` ${id}["${label}"]`);
|
|
607
|
+
applyToolClass(lines, t, id);
|
|
608
|
+
// v0.8.1: Mermaid 10 click-directive grammar requires the `call`
|
|
609
|
+
// keyword to invoke a JavaScript callback with arguments. The bare
|
|
610
|
+
// `click id __toolClick("N")` form is rejected by the parser
|
|
611
|
+
// (parse error: got 'PS'). The `call` keyword enters Mermaid's
|
|
612
|
+
// callbackname lexer state which then handles the function name +
|
|
613
|
+
// parenthesised args. See flow.jison in mermaid-js/mermaid.
|
|
614
|
+
lines.push(` click ${id} call __toolClick("${t.sequence}")`);
|
|
615
|
+
}
|
|
616
|
+
lines.push(` end`);
|
|
617
|
+
lines.push(` ${START_NODE} --> ${PRE_TASK_NODE}`);
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
// No pre-task tools — still create a tiny stub so the start -> pre chain
|
|
621
|
+
// is implicit. We skip it to keep the diagram uncluttered.
|
|
622
|
+
}
|
|
623
|
+
// Operation subgraphs. Iterate in ascending sequence so the left-to-right
|
|
624
|
+
// flow matches the timeline.
|
|
625
|
+
const sortedOps = [...operations].sort((a, b) => a.sequence - b.sequence);
|
|
626
|
+
let prev = null;
|
|
627
|
+
if (toolsByBucket["pre-task"].length > 0) {
|
|
628
|
+
prev = PRE_TASK_NODE;
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
prev = START_NODE;
|
|
632
|
+
}
|
|
633
|
+
for (const op of sortedOps) {
|
|
634
|
+
const opId = opNodeId(op.sequence);
|
|
635
|
+
// v0.8.2 (fix/mermaid-readable-labels): replace the raw
|
|
636
|
+
// `<seq>. <title>` label (which leaked technical details like
|
|
637
|
+
// `plan_doc: docs/.../plan.md - 485 - worktree: HEAD <hash> from
|
|
638
|
+
// main`) with a concise, emoji + friendly-name + short-description
|
|
639
|
+
// label derived from `PHASE_LABELS` via `getPhaseLabel`.
|
|
640
|
+
const opLabel = getPhaseLabel(op);
|
|
641
|
+
const sub = `op${op.sequence}`;
|
|
642
|
+
lines.push(` subgraph ${sub}["${opLabel}"]`);
|
|
643
|
+
lines.push(` direction TB`);
|
|
644
|
+
const ownTools = toolsByOp[op.sequence] ?? [];
|
|
645
|
+
if (ownTools.length === 0) {
|
|
646
|
+
lines.push(` ${opId}_body["(无工具调用)"]:::bookend`);
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
for (const t of ownTools) {
|
|
650
|
+
const id = toolNodeId(t.sequence);
|
|
651
|
+
const label = makeToolLabel(t);
|
|
652
|
+
lines.push(` ${id}["${label}"]`);
|
|
653
|
+
applyToolClass(lines, t, id);
|
|
654
|
+
lines.push(` click ${id} call __toolClick("${t.sequence}")`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
lines.push(` end`);
|
|
658
|
+
lines.push(` ${prev} --> ${sub}`);
|
|
659
|
+
prev = sub;
|
|
660
|
+
}
|
|
661
|
+
// Post-task subgraph.
|
|
662
|
+
if (toolsByBucket["post-task"].length > 0) {
|
|
663
|
+
lines.push(` subgraph ${POST_TASK_NODE}["post-task"]`);
|
|
664
|
+
for (const t of toolsByBucket["post-task"]) {
|
|
665
|
+
const id = toolNodeId(t.sequence);
|
|
666
|
+
const label = makeToolLabel(t);
|
|
667
|
+
lines.push(` ${id}["${label}"]`);
|
|
668
|
+
applyToolClass(lines, t, id);
|
|
669
|
+
lines.push(` click ${id} call __toolClick("${t.sequence}")`);
|
|
670
|
+
}
|
|
671
|
+
lines.push(` end`);
|
|
672
|
+
lines.push(` ${prev} --> ${POST_TASK_NODE}`);
|
|
673
|
+
prev = POST_TASK_NODE;
|
|
674
|
+
}
|
|
675
|
+
// End node.
|
|
676
|
+
lines.push(` ${END_NODE}([${escapeMermaidLabel(endLabel)}]):::runtime`);
|
|
677
|
+
lines.push(` ${prev} --> ${END_NODE}`);
|
|
678
|
+
return lines.join("\n");
|
|
679
|
+
}
|
|
680
|
+
/** Compose a short label for a tool call node. */
|
|
681
|
+
function makeToolLabel(t) {
|
|
682
|
+
const status = t.success ? "✓" : "✗";
|
|
683
|
+
const path = pickPathLabel(t.args);
|
|
684
|
+
const base = path ? `${t.toolName} / ${path}` : t.toolName;
|
|
685
|
+
const head = escapeMermaidLabel(`🔧 ${base}`);
|
|
686
|
+
return `${head}<br/><small>${status} · ${t.durationMs}ms</small>`;
|
|
687
|
+
}
|
|
688
|
+
/** Append a `class` line for a tool call. */
|
|
689
|
+
function applyToolClass(lines, t, id) {
|
|
690
|
+
if (!t.success) {
|
|
691
|
+
lines.push(` class ${id} fail`);
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
lines.push(` class ${id} ok`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
//# sourceMappingURL=task-detail-mermaid.js.map
|