agentic-flow-kit 0.2.0 → 0.2.1

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 (56) hide show
  1. package/README.md +3 -2
  2. package/modules/gates/dotnet-ca/check-architecture.sh +14 -10
  3. package/modules/hosts/trae/commands/wf-build.md +14 -14
  4. package/modules/hosts/trae/commands/wf-deploy.md +13 -13
  5. package/modules/hosts/trae/commands/wf-design.md +13 -13
  6. package/modules/hosts/trae/commands/wf-maintain.md +13 -13
  7. package/modules/hosts/trae/commands/wf-new-task.md +13 -13
  8. package/modules/hosts/trae/commands/wf-plan.md +13 -13
  9. package/modules/hosts/trae/commands/wf-review.md +13 -13
  10. package/modules/hosts/trae/commands/wf-test.md +14 -14
  11. package/modules/hosts/trae/hooks/post-edit-check.cjs +10 -0
  12. package/modules/hosts/trae/hooks/pre-shell-check.cjs +14 -4
  13. package/modules/hosts/trae/hooks/resolve-shell.cjs +30 -21
  14. package/modules/hosts/trae/rules/beautify-input-chinese.md +6 -6
  15. package/modules/hosts/trae/rules/git-commit-message.md +10 -10
  16. package/package.json +10 -2
  17. package/src/add-gate.mjs +8 -1
  18. package/src/add-host.mjs +4 -0
  19. package/src/cli.mjs +4 -3
  20. package/src/doctor.mjs +9 -2
  21. package/src/render.mjs +2 -0
  22. package/src/sync.mjs +20 -4
  23. package/src/sync.test.mjs +40 -1
  24. package/templates/AGENTS.md +2 -2
  25. package/templates/_agents/board/index.html +382 -382
  26. package/templates/_agents/board/marked.min.js +6 -6
  27. package/templates/_agents/cache/kb-index.json +1 -1
  28. package/templates/_agents/commands/build.md +1 -1
  29. package/templates/_agents/commands/design.md +1 -1
  30. package/templates/_agents/commands/maintain.md +1 -1
  31. package/templates/_agents/commands/new-task.md +73 -73
  32. package/templates/_agents/commands/plan.md +2 -2
  33. package/templates/_agents/commands/test.md +1 -1
  34. package/templates/_agents/hooks/check-pairing-incremental.sh +30 -29
  35. package/templates/_agents/hooks/commit-check.cjs +6 -1
  36. package/templates/_agents/scripts/agg-delegations.cjs +35 -18
  37. package/templates/_agents/scripts/agg-delegations.test.mjs +95 -0
  38. package/templates/_agents/scripts/check-loop.sh +6 -0
  39. package/templates/_agents/scripts/check-loop.test.sh +1042 -1042
  40. package/templates/_agents/scripts/commit-check-trigger.test.mjs +94 -0
  41. package/templates/_agents/scripts/ensure-board.ps1 +1 -1
  42. package/templates/_agents/scripts/gen-wiki-board.mjs +13 -3
  43. package/templates/_agents/scripts/gen-wiki-board.test.mjs +22 -0
  44. package/templates/_agents/scripts/gen-workflow-index.mjs +7 -2
  45. package/templates/_agents/scripts/gen-workflow-index.test.mjs +27 -0
  46. package/templates/_agents/scripts/gen-workflow-metrics.mjs +17 -3
  47. package/templates/_agents/scripts/gen-workflow-metrics.test.mjs +21 -0
  48. package/templates/_agents/scripts/kb-search.mjs +2 -1
  49. package/templates/_agents/scripts/rule-budget.sh +8 -2
  50. package/templates/_agents/scripts/verify-wiki-consistency.mjs +6 -1
  51. package/templates/_agents/scripts/workflow-board-server.mjs +245 -244
  52. package/templates/_githooks/commit-msg +4 -2
  53. package/templates/_githooks/pre-push +32 -32
  54. package/templates/workflow/README.md +2 -2
  55. package/templates/workflow/intents/_TEMPLATE.md +40 -40
  56. package/templates/workflow/specs/_TEMPLATE.md +41 -41
@@ -1,382 +1,382 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Workflow 看板 — AI 执行流程</title>
6
- <script src="/marked.min.js"></script>
7
- <style>
8
- :root {
9
- --bg: #14171c; --panel: #1c2027; --card: #232833; --card-hover: #2b3140;
10
- --border: #2e3542; --text: #d7dce4; --dim: #8b93a1;
11
- --intent: #4f8ef7; --incident: #e0674f; --spec: #9a6ff0; --plan: #2fa87c;
12
- --ok: #3fbf7f; --warn: #d9a53a; --bad: #e0674f;
13
- }
14
- * { box-sizing: border-box; margin: 0; }
15
- body { background: var(--bg); color: var(--text); font: 14px/1.6 "Segoe UI", "Microsoft YaHei", sans-serif; }
16
- header { display: flex; align-items: center; gap: 14px; padding: 12px 18px; border-bottom: 1px solid var(--border); }
17
- header h1 { font-size: 16px; font-weight: 600; }
18
- .chip { font-size: 12px; color: var(--dim); background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1px 9px; }
19
- #sse { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--dim); margin-left: auto; }
20
- #sse i { width: 8px; height: 8px; border-radius: 50%; background: var(--bad); }
21
- #sse.on i { background: var(--ok); }
22
- #board { display: flex; gap: 10px; padding: 14px 18px; overflow-x: auto; height: calc(100vh - 53px); }
23
- .col { flex: 1 1 0; min-width: 215px; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; }
24
- .col h2 { font-size: 13px; font-weight: 600; padding: 9px 12px; border-bottom: 1px solid var(--border); color: var(--dim); }
25
- .col h2 b { color: var(--text); margin-right: 4px; }
26
- .col .list { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 7px; }
27
- .card { background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: 6px; padding: 8px 10px; cursor: pointer; }
28
- .card:hover { background: var(--card-hover); border-color: var(--dim); }
29
- .row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
30
- .type { font-size: 10px; font-weight: 700; letter-spacing: .5px; color: #fff; border-radius: 3px; padding: 0 5px; }
31
- .lv { font-size: 10px; font-weight: 700; border: 1px solid var(--dim); color: var(--dim); border-radius: 3px; padding: 0 4px; }
32
- .lv.hi { border-color: var(--warn); color: var(--warn); }
33
- .date { font-size: 11px; color: var(--dim); margin-left: auto; }
34
- .acpt { font-size: 10px; border: 1px solid var(--border); color: var(--dim); border-radius: 3px; padding: 0 4px; }
35
- .acpt.full { color: var(--ok); border-color: var(--ok); }
36
- .age { font-size: 10px; font-weight: 700; border-radius: 3px; padding: 0 4px; }
37
- .age.a1 { color: var(--warn); border: 1px solid var(--warn); }
38
- .age.a2 { color: #fff; background: var(--bad); }
39
- .card.age1 { outline: 1px solid var(--warn); outline-offset: -1px; }
40
- .card.age2 { outline: 1px solid var(--bad); outline-offset: -1px; }
41
- .alrt { color: var(--bad); font-size: 11px; cursor: help; }
42
- #q { background: var(--card); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 3px 10px; font-size: 12px; width: 180px; }
43
- #q:focus { outline: none; border-color: var(--intent); }
44
- .hold { font-size: 10px; font-weight: 700; color: var(--bad); border: 1px solid var(--bad); border-radius: 3px; padding: 0 4px; margin-right: 5px; }
45
- .ct { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
46
- #toast { position: fixed; top: 54px; right: 18px; background: var(--card); border: 1px solid var(--ok); color: var(--text); border-radius: 6px; padding: 6px 14px; font-size: 12.5px; z-index: 99; opacity: 0; transition: opacity .2s; pointer-events: none; }
47
- #toast.show { opacity: 1; }
48
- .card .title { font-size: 12.5px; line-height: 1.45; }
49
- /* 任务卡:作者 + 展开的文档清单 */
50
- .auth { font-size: 11px; color: var(--dim); background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0 7px; }
51
- .type.mini { font-size: 9px; padding: 0 4px; }
52
- .docs { display: none; border-top: 1px dashed var(--border); margin-top: 6px; padding-top: 4px; flex-direction: column; gap: 2px; }
53
- .card.open .docs { display: flex; }
54
- .doc-item { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: 4px; cursor: pointer; font-size: 12px; }
55
- .doc-item:hover { background: var(--card-hover); }
56
- .doc-item .doc-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
- .doc-item .chip { font-size: 10px; padding: 0 5px; flex-shrink: 0; }
58
- .empty { color: var(--dim); font-size: 12px; text-align: center; padding: 18px 0; }
59
- /* 抽屉 */
60
- #drawer { position: fixed; top: 0; right: -780px; width: min(760px, 92vw); height: 100vh; background: var(--panel); border-left: 1px solid var(--border); transition: right .18s ease; display: flex; flex-direction: column; box-shadow: -8px 0 24px rgba(0,0,0,.4); z-index: 10; }
61
- #drawer.open { right: 0; }
62
- #d-head { padding: 14px 18px 8px; }
63
- #d-title { font-size: 16px; font-weight: 600; margin: 6px 0 8px; }
64
- #d-close { float: right; background: none; border: none; color: var(--dim); font-size: 20px; cursor: pointer; }
65
- #d-close:hover { color: var(--text); }
66
- #d-related { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
67
- #d-related a { font-size: 11px; color: var(--intent); background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1px 9px; cursor: pointer; text-decoration: none; }
68
- #d-related a:hover { border-color: var(--intent); }
69
- #d-tabs { display: flex; gap: 2px; padding: 0 18px; border-bottom: 1px solid var(--border); }
70
- #d-tabs button { background: none; border: none; border-bottom: 2px solid transparent; color: var(--dim); font-size: 13px; padding: 7px 12px; cursor: pointer; }
71
- #d-tabs button.on { color: var(--text); border-bottom-color: var(--intent); }
72
- #d-body { flex: 1; overflow-y: auto; padding: 16px 20px 40px; }
73
- #sse.poll i { background: var(--warn); }
74
- /* md 渲染 */
75
- .md h1, .md h2, .md h3 { margin: 18px 0 8px; line-height: 1.35; } .md h1 { font-size: 19px; } .md h2 { font-size: 16px; } .md h3 { font-size: 14.5px; }
76
- .md p, .md ul, .md ol { margin: 6px 0; } .md li { margin: 2px 0; }
77
- .md code { background: var(--card); border-radius: 3px; padding: 1px 5px; font-size: 12.5px; }
78
- .md pre { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 8px 0; } .md pre code { background: none; padding: 0; }
79
- .md table { border-collapse: collapse; margin: 8px 0; font-size: 12.5px; } .md th, .md td { border: 1px solid var(--border); padding: 4px 10px; } .md th { background: var(--card); }
80
- .md blockquote { border-left: 3px solid var(--border); color: var(--dim); padding-left: 12px; margin: 8px 0; }
81
- .md a { color: var(--intent); }
82
- .md hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
83
- /* 历史时间线 */
84
- .tl { position: relative; padding-left: 22px; }
85
- .tl::before { content: ""; position: absolute; left: 6px; top: 6px; bottom: 6px; width: 1px; background: var(--border); }
86
- .tl-item { position: relative; padding: 4px 0 10px; }
87
- .tl-item::before { content: ""; position: absolute; left: -20px; top: 11px; width: 9px; height: 9px; border-radius: 50%; background: var(--panel); border: 2px solid var(--intent); }
88
- .tl-sub { color: var(--text); font-size: 13px; }
89
- .tl-meta { color: var(--dim); font-size: 12px; }
90
- .tl-meta code { cursor: pointer; } .tl-meta code:hover { color: var(--text); }
91
- .none { color: var(--dim); font-size: 13px; }
92
- </style>
93
- </head>
94
- <body>
95
- <header>
96
- <h1>Workflow 看板</h1>
97
- <span class="chip" id="c-intents"></span><span class="chip" id="c-incidents"></span>
98
- <span class="chip" id="c-plans"></span><span class="chip" id="c-specs"></span>
99
- <span class="chip" id="c-alerts" style="display:none"></span>
100
- <input id="q" placeholder="过滤 标题 / slug / 文件…" spellcheck="false">
101
- <span id="sse"><i></i><span>连接断开</span></span>
102
- </header>
103
- <div id="board"></div>
104
-
105
- <div id="drawer">
106
- <div id="d-head">
107
- <button id="d-close" title="关闭 (Esc)">×</button>
108
- <div id="d-badges"></div>
109
- <div id="d-title"></div>
110
- <div id="d-related"></div>
111
- </div>
112
- <div id="d-tabs">
113
- <button data-tab="doc" class="on">文档</button>
114
- <button data-tab="hist">修改历史</button>
115
- </div>
116
- <div id="d-body"></div>
117
- </div>
118
- <div id="toast"></div>
119
-
120
- <script>
121
- const COLS = [
122
- { key: 'draft', name: '起草', statuses: ['draft'] },
123
- { key: 'active', name: '进行中', statuses: ['approved', 'open'] },
124
- { key: 'done', name: '完成', statuses: ['done', 'fixed'] },
125
- { key: 'closed', name: '关闭', statuses: ['closed'] },
126
- { key: 'gone', name: '废弃', statuses: ['superseded', 'cancelled'] },
127
- ];
128
- const TYPE_LABEL = { intents: 'INTENT', incidents: 'INCIDENT', plans: 'PLAN', specs: 'SPEC' };
129
- const TYPE_COLOR = { intents: 'var(--intent)', incidents: 'var(--incident)', plans: 'var(--plan)', specs: 'var(--spec)' };
130
-
131
- let CARDS = []; // 最近一次 /api/board 的卡片
132
- let CURRENT = null; // 抽屉当前打开的卡片
133
- let TAB = 'doc';
134
- let retryTimer = null; // /api/board 失败后的重试定时器
135
- let ROOT = ''; // 仓库根(/api/board 返回,内链 vscode 协议用)
136
- let Q = ''; // 搜索过滤关键字
137
-
138
- const $ = (s) => document.querySelector(s);
139
- const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
140
- // —— 精细化徽标:验收进度 / 配对断裂 ⚠ / 进行中老化 ——
141
- const ACTIVE_STATUSES = ['draft', 'approved', 'open'];
142
- const ageClass = (c) => {
143
- if (!ACTIVE_STATUSES.includes(c.status)) return '';
144
- const d = new Date(c.date);
145
- if (isNaN(d)) return '';
146
- const days = Math.floor((Date.now() - d.getTime()) / 864e5);
147
- return days >= 14 ? 'age2' : days >= 7 ? 'age1' : '';
148
- };
149
- const ageBadge = (c) => {
150
- const cls = ageClass(c);
151
- if (!cls) return '';
152
- const days = Math.floor((Date.now() - new Date(c.date).getTime()) / 864e5);
153
- return `<span class="age ${cls === 'age2' ? 'a2' : 'a1'}" title="进行中已 ${days} 天">${days}天</span>`;
154
- };
155
- const acceptBadge = (c) =>
156
- !c.accept ? '' :
157
- `<span class="acpt ${c.accept.done === c.accept.total ? 'full' : ''}" title="验收标准勾验 ${c.accept.done}/${c.accept.total}">☑${c.accept.done}/${c.accept.total}</span>`;
158
- const alertBadge = (c) =>
159
- !c.alerts?.length ? '' :
160
- `<span class="alrt" title="${esc(c.alerts.join(';'))}">⚠${c.alerts.length > 1 ? c.alerts.length : ''}</span>`;
161
-
162
- const badge = (c) =>
163
- (TYPE_LABEL[c.type]
164
- ? `<span class="type" style="background:${TYPE_COLOR[c.type]}">${TYPE_LABEL[c.type]}</span>`
165
- : `<span class="type" style="background:var(--dim)">MD</span>`) +
166
- (c.level ? `<span class="lv ${['L2','L3'].includes(c.level) ? 'hi' : ''}">${esc(c.level)}</span>` : '') +
167
- acceptBadge(c) + alertBadge(c) + ageBadge(c) +
168
- `<span class="date">${esc(c.date)}</span>`;
169
-
170
- // —— 任务族聚合:同 slug 一卡;head 取入口文档(intent 优先、其次 incident),孤儿用首文档 ——
171
- const TYPE_ORDER = { intents: 0, incidents: 1, plans: 2, specs: 3 };
172
- const TYPE_ABBR = { intents: 'IN', incidents: 'IC', plans: 'PL', specs: 'SP' };
173
- const EXPANDED = new Set(); // 展开中的任务 slug(SSE 刷新后保持)
174
- const familyOf = (cards) => {
175
- const bySlug = new Map();
176
- for (const c of cards) {
177
- if (!bySlug.has(c.slug)) bySlug.set(c.slug, []);
178
- bySlug.get(c.slug).push(c);
179
- }
180
- const fams = [];
181
- for (const docs of bySlug.values()) {
182
- const head = docs.find((d) => d.type === 'intents') || docs.find((d) => d.type === 'incidents') || docs[0];
183
- docs.sort((a, b) => (TYPE_ORDER[a.type] - TYPE_ORDER[b.type]) || a.date.localeCompare(b.date));
184
- fams.push({
185
- slug: docs[0].slug, head, docs,
186
- alerts: docs.flatMap((d) => d.alerts || []),
187
- author: head.author || docs.map((d) => d.author).find(Boolean) || '',
188
- });
189
- }
190
- return fams.sort((a, b) => b.head.date.localeCompare(a.head.date));
191
- };
192
- const famTypeChips = (fam) =>
193
- [...new Set(fam.docs.map((d) => d.type))].map((t) =>
194
- `<span class="type mini" style="background:${TYPE_COLOR[t]}" title="${TYPE_LABEL[t]}">${TYPE_ABBR[t]}</span>`).join('');
195
-
196
- async function refreshBoard() {
197
- let data;
198
- try {
199
- const resp = await fetch('/api/board');
200
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
201
- data = await resp.json();
202
- } catch (e) {
203
- // 服务不可达:头部提示 + 3s 重试(避免静默保留旧内容而不提示)
204
- const chip = $('#c-alerts');
205
- chip.style.display = ''; chip.textContent = '看板服务不可达,重试中'; chip.style.color = 'var(--bad)';
206
- clearTimeout(retryTimer);
207
- retryTimer = setTimeout(refreshBoard, 3000);
208
- return false;
209
- }
210
- CARDS = data.cards;
211
- ROOT = data.root || ROOT;
212
- CURRENT = CARDS.find((c) => c.file === CURRENT?.file) ?? CURRENT; // 重定位抽屉卡片,避免刷新后徽标/状态陈旧
213
- for (const t of Object.keys(TYPE_LABEL)) $(`#c-${t}`).textContent = `${TYPE_LABEL[t]} ${data.counts[t] ?? 0}`;
214
- const groups = Object.fromEntries(COLS.map((c) => [c.key, []]));
215
- const rest = [];
216
- const q = Q.trim().toLowerCase();
217
- // 过滤在族级做:任一成员命中即整卡保留;有过滤词时全部展开便于定位命中文档
218
- const fams = familyOf(CARDS).filter((f) =>
219
- !q || f.docs.some((d) => (d.title + ' ' + d.name + ' ' + d.file).toLowerCase().includes(q)));
220
- for (const fam of fams) {
221
- const col = COLS.find((c) => c.statuses.includes(fam.head.status));
222
- (col ? groups[col.key] : rest).push(fam);
223
- }
224
- const nAlerts = CARDS.filter((c) => c.alerts?.length).length;
225
- const ca = $('#c-alerts');
226
- ca.style.display = nAlerts ? '' : 'none';
227
- if (nAlerts) { ca.textContent = `⚠ 断档 ${nAlerts}`; ca.style.color = 'var(--bad)'; }
228
- const cardHtml = (fam, extra = '') => {
229
- const h = fam.head;
230
- return `<div class="card ${ageClass(h)}${EXPANDED.has(fam.slug) || q ? ' open' : ''}" data-slug="${esc(fam.slug)}" style="border-left-color:${TYPE_COLOR[h.type]}">` +
231
- `<div class="row">${famTypeChips(fam)}` +
232
- (h.level ? `<span class="lv ${['L2','L3'].includes(h.level) ? 'hi' : ''}">${esc(h.level)}</span>` : '') +
233
- acceptBadge(h) + alertBadge(fam) + ageBadge(h) + extra +
234
- (fam.author ? `<span class="auth" title="作者(git 最早提交)">${esc(fam.author)}</span>` : '') +
235
- `<span class="date">${esc(h.date)}</span></div>` +
236
- `<div class="title">${h.status === 'open' ? '<span class="hold">挂起</span>' : ''}${esc(h.title)}</div>` +
237
- `<div class="docs">${fam.docs.map((d) =>
238
- `<div class="doc-item" data-file="${esc(d.file)}" title="打开文档">` +
239
- `<span class="type" style="background:${TYPE_COLOR[d.type]}">${TYPE_LABEL[d.type]}</span>` +
240
- `<span class="chip">${esc(d.status)}</span>${alertBadge(d)}` +
241
- `<span class="doc-title">${esc(d.title)}</span></div>`).join('')}</div></div>`;
242
- };
243
- let html = COLS.map((c) => {
244
- const items = groups[c.key].map((fam) => cardHtml(fam)).join('');
245
- return `<div class="col"><h2><b>${c.name}</b>${groups[c.key].length}</h2><div class="list">${
246
- items || '<div class="empty">—</div>'}</div></div>`;
247
- }).join('');
248
- if (rest.length) {
249
- html += `<div class="col"><h2><b>其他</b>${rest.length}</h2><div class="list">${rest.map((fam) =>
250
- cardHtml(fam, `<span class="chip">${esc(fam.head.status)}</span>`)).join('')}</div></div>`;
251
- }
252
- $('#board').innerHTML = html;
253
- return true;
254
- }
255
-
256
- function renderDrawer() {
257
- if (!CURRENT) return;
258
- $('#d-badges').innerHTML = badge(CURRENT) +
259
- (CURRENT.author ? ` <span class="chip" title="git 最早提交">✎ ${esc(CURRENT.author)}</span>` : '') +
260
- ` <span class="chip">${esc(CURRENT.status)}</span>`;
261
- $('#d-title').textContent = CURRENT.title;
262
- const related = CARDS.filter((c) => c.slug === CURRENT.slug && c.file !== CURRENT.file);
263
- $('#d-related').innerHTML = related.map((c) =>
264
- `<a data-file="${esc(c.file)}">${TYPE_LABEL[c.type]}${c.status !== CURRENT.status ? ' · ' + esc(c.status) : ''}</a>`).join('');
265
- document.querySelectorAll('#d-tabs button').forEach((b) => b.classList.toggle('on', b.dataset.tab === TAB));
266
- if (TAB === 'doc') loadDoc(); else loadHist();
267
- }
268
-
269
- async function loadDoc() {
270
- $('#d-body').innerHTML = '<div class="none">加载中…</div>';
271
- const { text, error } = await (await fetch(`/api/doc?file=${encodeURIComponent(CURRENT.file)}`)).json();
272
- if (error) { $('#d-body').innerHTML = `<div class="none">${esc(error)}</div>`; return; }
273
- // 裸 .md 相对路径引用 → 可点链接(workflow 文档互引多为纯文本路径而非 md 链接语法;
274
- // 已有 ](path) 链接语法因前缀为 ] 不会被重复包裹)
275
- const linked = text.replace(
276
- /(^|[\s((【>])((?:\.\.\/|\.\/)?(?:intents|incidents|plans|specs)\/[A-Za-z0-9_\-./]+\.md)/g,
277
- '$1[$2]($2)'
278
- );
279
- $('#d-body').innerHTML = `<div class="md">${marked.parse(linked)}</div>`;
280
- }
281
-
282
- // —— commit 类型色点(Conventional Commits 首词)——
283
- const CT_COLOR = { feat: 'var(--ok)', fix: 'var(--bad)', docs: 'var(--dim)', test: 'var(--spec)', refactor: 'var(--intent)', chore: 'var(--warn)', style: 'var(--spec)', perf: 'var(--warn)' };
284
- const ctDot = (subject) => {
285
- const t = (subject.match(/^(\w+)[(:]/) || [])[1];
286
- return `<span class="ct" style="background:${CT_COLOR[t] || 'var(--dim)'}" title="${t || 'commit'}"></span>`;
287
- };
288
-
289
- async function loadHist() {
290
- $('#d-body').innerHTML = '<div class="none">加载中…</div>';
291
- const { commits } = await (await fetch(`/api/history?file=${encodeURIComponent(CURRENT.file)}`)).json();
292
- $('#d-body').innerHTML = commits.length
293
- ? `<div class="tl">${commits.map((c) =>
294
- `<div class="tl-item"><div class="tl-sub">${ctDot(c.subject)}${esc(c.subject)}</div>` +
295
- `<div class="tl-meta">${esc(c.date)} · <code class="hash" data-h="${esc(c.hash)}" title="点击复制完整 hash">${esc(c.hash.slice(0, 9))}</code></div></div>`).join('')}</div>`
296
- : '<div class="none">暂无提交记录(文件尚未 commit;git 历史只包含已提交的修改)</div>';
297
- }
298
-
299
- function openCard(card) { CURRENT = card; TAB = 'doc'; $('#drawer').classList.add('open'); renderDrawer(); }
300
-
301
- // —— toast 反馈 ——
302
- let toastTimer = null;
303
- function toast(msg) {
304
- const t = $('#toast');
305
- t.textContent = msg; t.classList.add('show');
306
- clearTimeout(toastTimer);
307
- toastTimer = setTimeout(() => t.classList.remove('show'), 1800);
308
- }
309
-
310
- // —— 抽屉内文档内链:md 相对链接 → 抽屉内打开;外链新窗口;仓库代码路径 → vscode 协议 ——
311
- function viewFile(rel) {
312
- const hit = CARDS.find((c) => c.file === rel);
313
- if (hit) { openCard(hit); return; }
314
- // 自由文档(不在看板卡范围,如 workflow/README.md、同名以外的引用):伪卡视图
315
- CURRENT = { file: rel, name: rel, slug: rel, title: rel, type: 'link', status: '', level: '', date: '' };
316
- TAB = 'doc';
317
- $('#drawer').classList.add('open');
318
- renderDrawer();
319
- }
320
-
321
- function onDocLink(e, a) {
322
- const href = a.getAttribute('href');
323
- if (!href || href.startsWith('#')) return; // 页内锚点走默认行为
324
- if (/^https?:/i.test(href)) { e.preventDefault(); window.open(href, '_blank', 'noopener'); return; }
325
- e.preventDefault();
326
- const dir = CURRENT.file.includes('/') ? CURRENT.file.replace(/[^/]*$/, '') : '';
327
- let rel;
328
- try { rel = decodeURIComponent(new URL(href, 'http://x/' + dir).pathname.slice(1)); } catch { return; }
329
- if (/\.md$/i.test(rel)) { viewFile(rel); return; }
330
- if (ROOT && !/^[a-z]+:/i.test(rel)) { window.open(`vscode://file/${ROOT}/${rel}`); toast('已用编辑器打开 ' + rel); }
331
- }
332
-
333
- document.addEventListener('click', (e) => {
334
- const dLink = e.target.closest('#d-body a');
335
- if (dLink) { onDocLink(e, dLink); return; }
336
- // 展开区文档条目 → 开抽屉(先于卡片命中,条目在卡内)
337
- const item = e.target.closest('.doc-item');
338
- if (item) {
339
- const hit = CARDS.find((c) => c.file === item.dataset.file);
340
- if (hit) openCard(hit);
341
- return;
342
- }
343
- // 卡片点击 → 就地展开/收起该任务
344
- const card = e.target.closest('.card');
345
- if (card) {
346
- const slug = card.dataset.slug;
347
- if (EXPANDED.has(slug)) EXPANDED.delete(slug); else EXPANDED.add(slug);
348
- card.classList.toggle('open');
349
- return;
350
- }
351
- const rel = e.target.closest('#d-related a');
352
- if (rel) {
353
- const hit = CARDS.find((c) => c.file === rel.dataset.file);
354
- if (hit) { CURRENT = hit; renderDrawer(); }
355
- return;
356
- }
357
- const hash = e.target.closest('.hash');
358
- if (hash) navigator.clipboard.writeText(hash.dataset.h)
359
- .then(() => toast('已复制 ' + hash.dataset.h.slice(0, 9)))
360
- .catch(() => toast('复制失败'));
361
- });
362
- $('#d-close').onclick = () => { $('#drawer').classList.remove('open'); };
363
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') $('#drawer').classList.remove('open'); });
364
- document.querySelectorAll('#d-tabs button').forEach((b) => b.onclick = () => { TAB = b.dataset.tab; renderDrawer(); });
365
-
366
- // SSE:服务端 fs.watch 防抖后推送 changed → 看板与打开中的抽屉自动刷新
367
- const sse = new EventSource('/api/events');
368
- sse.addEventListener('changed', () => { refreshBoard().then((ok) => { if (ok) renderDrawer(); }); });
369
- sse.addEventListener('watchdead', () => { $('#sse').classList.remove('on'); $('#sse').classList.add('poll'); $('#sse span:last-child').textContent = '监听失效 · 60s 轮询'; });
370
- sse.onopen = () => { $('#sse').classList.add('on'); $('#sse').classList.remove('poll'); $('#sse span:last-child').textContent = '实时'; };
371
- sse.onerror = () => { $('#sse').classList.remove('on'); $('#sse span:last-child').textContent = '连接断开,自动重连中'; };
372
-
373
- // 60s 兜底轮询:SSE/watch 任何环节失效时数据仍保鲜(正常时多拉一次无害)
374
- setInterval(() => refreshBoard(), 60000);
375
-
376
- // 搜索过滤:本地过滤标题/slug/文件,SSE 刷新时保持关键字
377
- $('#q').addEventListener('input', (e) => { Q = e.target.value; refreshBoard(); });
378
-
379
- refreshBoard();
380
- </script>
381
- </body>
382
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>Workflow 看板 — AI 执行流程</title>
6
+ <script src="/marked.min.js"></script>
7
+ <style>
8
+ :root {
9
+ --bg: #14171c; --panel: #1c2027; --card: #232833; --card-hover: #2b3140;
10
+ --border: #2e3542; --text: #d7dce4; --dim: #8b93a1;
11
+ --intent: #4f8ef7; --incident: #e0674f; --spec: #9a6ff0; --plan: #2fa87c;
12
+ --ok: #3fbf7f; --warn: #d9a53a; --bad: #e0674f;
13
+ }
14
+ * { box-sizing: border-box; margin: 0; }
15
+ body { background: var(--bg); color: var(--text); font: 14px/1.6 "Segoe UI", "Microsoft YaHei", sans-serif; }
16
+ header { display: flex; align-items: center; gap: 14px; padding: 12px 18px; border-bottom: 1px solid var(--border); }
17
+ header h1 { font-size: 16px; font-weight: 600; }
18
+ .chip { font-size: 12px; color: var(--dim); background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1px 9px; }
19
+ #sse { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--dim); margin-left: auto; }
20
+ #sse i { width: 8px; height: 8px; border-radius: 50%; background: var(--bad); }
21
+ #sse.on i { background: var(--ok); }
22
+ #board { display: flex; gap: 10px; padding: 14px 18px; overflow-x: auto; height: calc(100vh - 53px); }
23
+ .col { flex: 1 1 0; min-width: 215px; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; }
24
+ .col h2 { font-size: 13px; font-weight: 600; padding: 9px 12px; border-bottom: 1px solid var(--border); color: var(--dim); }
25
+ .col h2 b { color: var(--text); margin-right: 4px; }
26
+ .col .list { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 7px; }
27
+ .card { background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--border); border-radius: 6px; padding: 8px 10px; cursor: pointer; }
28
+ .card:hover { background: var(--card-hover); border-color: var(--dim); }
29
+ .row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
30
+ .type { font-size: 10px; font-weight: 700; letter-spacing: .5px; color: #fff; border-radius: 3px; padding: 0 5px; }
31
+ .lv { font-size: 10px; font-weight: 700; border: 1px solid var(--dim); color: var(--dim); border-radius: 3px; padding: 0 4px; }
32
+ .lv.hi { border-color: var(--warn); color: var(--warn); }
33
+ .date { font-size: 11px; color: var(--dim); margin-left: auto; }
34
+ .acpt { font-size: 10px; border: 1px solid var(--border); color: var(--dim); border-radius: 3px; padding: 0 4px; }
35
+ .acpt.full { color: var(--ok); border-color: var(--ok); }
36
+ .age { font-size: 10px; font-weight: 700; border-radius: 3px; padding: 0 4px; }
37
+ .age.a1 { color: var(--warn); border: 1px solid var(--warn); }
38
+ .age.a2 { color: #fff; background: var(--bad); }
39
+ .card.age1 { outline: 1px solid var(--warn); outline-offset: -1px; }
40
+ .card.age2 { outline: 1px solid var(--bad); outline-offset: -1px; }
41
+ .alrt { color: var(--bad); font-size: 11px; cursor: help; }
42
+ #q { background: var(--card); border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 3px 10px; font-size: 12px; width: 180px; }
43
+ #q:focus { outline: none; border-color: var(--intent); }
44
+ .hold { font-size: 10px; font-weight: 700; color: var(--bad); border: 1px solid var(--bad); border-radius: 3px; padding: 0 4px; margin-right: 5px; }
45
+ .ct { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; }
46
+ #toast { position: fixed; top: 54px; right: 18px; background: var(--card); border: 1px solid var(--ok); color: var(--text); border-radius: 6px; padding: 6px 14px; font-size: 12.5px; z-index: 99; opacity: 0; transition: opacity .2s; pointer-events: none; }
47
+ #toast.show { opacity: 1; }
48
+ .card .title { font-size: 12.5px; line-height: 1.45; }
49
+ /* 任务卡:作者 + 展开的文档清单 */
50
+ .auth { font-size: 11px; color: var(--dim); background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0 7px; }
51
+ .type.mini { font-size: 9px; padding: 0 4px; }
52
+ .docs { display: none; border-top: 1px dashed var(--border); margin-top: 6px; padding-top: 4px; flex-direction: column; gap: 2px; }
53
+ .card.open .docs { display: flex; }
54
+ .doc-item { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: 4px; cursor: pointer; font-size: 12px; }
55
+ .doc-item:hover { background: var(--card-hover); }
56
+ .doc-item .doc-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
+ .doc-item .chip { font-size: 10px; padding: 0 5px; flex-shrink: 0; }
58
+ .empty { color: var(--dim); font-size: 12px; text-align: center; padding: 18px 0; }
59
+ /* 抽屉 */
60
+ #drawer { position: fixed; top: 0; right: -780px; width: min(760px, 92vw); height: 100vh; background: var(--panel); border-left: 1px solid var(--border); transition: right .18s ease; display: flex; flex-direction: column; box-shadow: -8px 0 24px rgba(0,0,0,.4); z-index: 10; }
61
+ #drawer.open { right: 0; }
62
+ #d-head { padding: 14px 18px 8px; }
63
+ #d-title { font-size: 16px; font-weight: 600; margin: 6px 0 8px; }
64
+ #d-close { float: right; background: none; border: none; color: var(--dim); font-size: 20px; cursor: pointer; }
65
+ #d-close:hover { color: var(--text); }
66
+ #d-related { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
67
+ #d-related a { font-size: 11px; color: var(--intent); background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1px 9px; cursor: pointer; text-decoration: none; }
68
+ #d-related a:hover { border-color: var(--intent); }
69
+ #d-tabs { display: flex; gap: 2px; padding: 0 18px; border-bottom: 1px solid var(--border); }
70
+ #d-tabs button { background: none; border: none; border-bottom: 2px solid transparent; color: var(--dim); font-size: 13px; padding: 7px 12px; cursor: pointer; }
71
+ #d-tabs button.on { color: var(--text); border-bottom-color: var(--intent); }
72
+ #d-body { flex: 1; overflow-y: auto; padding: 16px 20px 40px; }
73
+ #sse.poll i { background: var(--warn); }
74
+ /* md 渲染 */
75
+ .md h1, .md h2, .md h3 { margin: 18px 0 8px; line-height: 1.35; } .md h1 { font-size: 19px; } .md h2 { font-size: 16px; } .md h3 { font-size: 14.5px; }
76
+ .md p, .md ul, .md ol { margin: 6px 0; } .md li { margin: 2px 0; }
77
+ .md code { background: var(--card); border-radius: 3px; padding: 1px 5px; font-size: 12.5px; }
78
+ .md pre { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 8px 0; } .md pre code { background: none; padding: 0; }
79
+ .md table { border-collapse: collapse; margin: 8px 0; font-size: 12.5px; } .md th, .md td { border: 1px solid var(--border); padding: 4px 10px; } .md th { background: var(--card); }
80
+ .md blockquote { border-left: 3px solid var(--border); color: var(--dim); padding-left: 12px; margin: 8px 0; }
81
+ .md a { color: var(--intent); }
82
+ .md hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
83
+ /* 历史时间线 */
84
+ .tl { position: relative; padding-left: 22px; }
85
+ .tl::before { content: ""; position: absolute; left: 6px; top: 6px; bottom: 6px; width: 1px; background: var(--border); }
86
+ .tl-item { position: relative; padding: 4px 0 10px; }
87
+ .tl-item::before { content: ""; position: absolute; left: -20px; top: 11px; width: 9px; height: 9px; border-radius: 50%; background: var(--panel); border: 2px solid var(--intent); }
88
+ .tl-sub { color: var(--text); font-size: 13px; }
89
+ .tl-meta { color: var(--dim); font-size: 12px; }
90
+ .tl-meta code { cursor: pointer; } .tl-meta code:hover { color: var(--text); }
91
+ .none { color: var(--dim); font-size: 13px; }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <header>
96
+ <h1>Workflow 看板</h1>
97
+ <span class="chip" id="c-intents"></span><span class="chip" id="c-incidents"></span>
98
+ <span class="chip" id="c-plans"></span><span class="chip" id="c-specs"></span>
99
+ <span class="chip" id="c-alerts" style="display:none"></span>
100
+ <input id="q" placeholder="过滤 标题 / slug / 文件…" spellcheck="false">
101
+ <span id="sse"><i></i><span>连接断开</span></span>
102
+ </header>
103
+ <div id="board"></div>
104
+
105
+ <div id="drawer">
106
+ <div id="d-head">
107
+ <button id="d-close" title="关闭 (Esc)">×</button>
108
+ <div id="d-badges"></div>
109
+ <div id="d-title"></div>
110
+ <div id="d-related"></div>
111
+ </div>
112
+ <div id="d-tabs">
113
+ <button data-tab="doc" class="on">文档</button>
114
+ <button data-tab="hist">修改历史</button>
115
+ </div>
116
+ <div id="d-body"></div>
117
+ </div>
118
+ <div id="toast"></div>
119
+
120
+ <script>
121
+ const COLS = [
122
+ { key: 'draft', name: '起草', statuses: ['draft'] },
123
+ { key: 'active', name: '进行中', statuses: ['approved', 'open'] },
124
+ { key: 'done', name: '完成', statuses: ['done', 'fixed'] },
125
+ { key: 'closed', name: '关闭', statuses: ['closed'] },
126
+ { key: 'gone', name: '废弃', statuses: ['superseded', 'cancelled'] },
127
+ ];
128
+ const TYPE_LABEL = { intents: 'INTENT', incidents: 'INCIDENT', plans: 'PLAN', specs: 'SPEC' };
129
+ const TYPE_COLOR = { intents: 'var(--intent)', incidents: 'var(--incident)', plans: 'var(--plan)', specs: 'var(--spec)' };
130
+
131
+ let CARDS = []; // 最近一次 /api/board 的卡片
132
+ let CURRENT = null; // 抽屉当前打开的卡片
133
+ let TAB = 'doc';
134
+ let retryTimer = null; // /api/board 失败后的重试定时器
135
+ let ROOT = ''; // 仓库根(/api/board 返回,内链 vscode 协议用)
136
+ let Q = ''; // 搜索过滤关键字
137
+
138
+ const $ = (s) => document.querySelector(s);
139
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
140
+ // —— 精细化徽标:验收进度 / 配对断裂 ⚠ / 进行中老化 ——
141
+ const ACTIVE_STATUSES = ['draft', 'approved', 'open'];
142
+ const ageClass = (c) => {
143
+ if (!ACTIVE_STATUSES.includes(c.status)) return '';
144
+ const d = new Date(c.date);
145
+ if (isNaN(d)) return '';
146
+ const days = Math.floor((Date.now() - d.getTime()) / 864e5);
147
+ return days >= 14 ? 'age2' : days >= 7 ? 'age1' : '';
148
+ };
149
+ const ageBadge = (c) => {
150
+ const cls = ageClass(c);
151
+ if (!cls) return '';
152
+ const days = Math.floor((Date.now() - new Date(c.date).getTime()) / 864e5);
153
+ return `<span class="age ${cls === 'age2' ? 'a2' : 'a1'}" title="进行中已 ${days} 天">${days}天</span>`;
154
+ };
155
+ const acceptBadge = (c) =>
156
+ !c.accept ? '' :
157
+ `<span class="acpt ${c.accept.done === c.accept.total ? 'full' : ''}" title="验收标准勾验 ${c.accept.done}/${c.accept.total}">☑${c.accept.done}/${c.accept.total}</span>`;
158
+ const alertBadge = (c) =>
159
+ !c.alerts?.length ? '' :
160
+ `<span class="alrt" title="${esc(c.alerts.join(';'))}">⚠${c.alerts.length > 1 ? c.alerts.length : ''}</span>`;
161
+
162
+ const badge = (c) =>
163
+ (TYPE_LABEL[c.type]
164
+ ? `<span class="type" style="background:${TYPE_COLOR[c.type]}">${TYPE_LABEL[c.type]}</span>`
165
+ : `<span class="type" style="background:var(--dim)">MD</span>`) +
166
+ (c.level ? `<span class="lv ${['L2','L3'].includes(c.level) ? 'hi' : ''}">${esc(c.level)}</span>` : '') +
167
+ acceptBadge(c) + alertBadge(c) + ageBadge(c) +
168
+ `<span class="date">${esc(c.date)}</span>`;
169
+
170
+ // —— 任务族聚合:同 slug 一卡;head 取入口文档(intent 优先、其次 incident),孤儿用首文档 ——
171
+ const TYPE_ORDER = { intents: 0, incidents: 1, plans: 2, specs: 3 };
172
+ const TYPE_ABBR = { intents: 'IN', incidents: 'IC', plans: 'PL', specs: 'SP' };
173
+ const EXPANDED = new Set(); // 展开中的任务 slug(SSE 刷新后保持)
174
+ const familyOf = (cards) => {
175
+ const bySlug = new Map();
176
+ for (const c of cards) {
177
+ if (!bySlug.has(c.slug)) bySlug.set(c.slug, []);
178
+ bySlug.get(c.slug).push(c);
179
+ }
180
+ const fams = [];
181
+ for (const docs of bySlug.values()) {
182
+ const head = docs.find((d) => d.type === 'intents') || docs.find((d) => d.type === 'incidents') || docs[0];
183
+ docs.sort((a, b) => (TYPE_ORDER[a.type] - TYPE_ORDER[b.type]) || a.date.localeCompare(b.date));
184
+ fams.push({
185
+ slug: docs[0].slug, head, docs,
186
+ alerts: docs.flatMap((d) => d.alerts || []),
187
+ author: head.author || docs.map((d) => d.author).find(Boolean) || '',
188
+ });
189
+ }
190
+ return fams.sort((a, b) => b.head.date.localeCompare(a.head.date));
191
+ };
192
+ const famTypeChips = (fam) =>
193
+ [...new Set(fam.docs.map((d) => d.type))].map((t) =>
194
+ `<span class="type mini" style="background:${TYPE_COLOR[t]}" title="${TYPE_LABEL[t]}">${TYPE_ABBR[t]}</span>`).join('');
195
+
196
+ async function refreshBoard() {
197
+ let data;
198
+ try {
199
+ const resp = await fetch('/api/board');
200
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
201
+ data = await resp.json();
202
+ } catch (e) {
203
+ // 服务不可达:头部提示 + 3s 重试(避免静默保留旧内容而不提示)
204
+ const chip = $('#c-alerts');
205
+ chip.style.display = ''; chip.textContent = '看板服务不可达,重试中'; chip.style.color = 'var(--bad)';
206
+ clearTimeout(retryTimer);
207
+ retryTimer = setTimeout(refreshBoard, 3000);
208
+ return false;
209
+ }
210
+ CARDS = data.cards;
211
+ ROOT = data.root || ROOT;
212
+ CURRENT = CARDS.find((c) => c.file === CURRENT?.file) ?? CURRENT; // 重定位抽屉卡片,避免刷新后徽标/状态陈旧
213
+ for (const t of Object.keys(TYPE_LABEL)) $(`#c-${t}`).textContent = `${TYPE_LABEL[t]} ${data.counts[t] ?? 0}`;
214
+ const groups = Object.fromEntries(COLS.map((c) => [c.key, []]));
215
+ const rest = [];
216
+ const q = Q.trim().toLowerCase();
217
+ // 过滤在族级做:任一成员命中即整卡保留;有过滤词时全部展开便于定位命中文档
218
+ const fams = familyOf(CARDS).filter((f) =>
219
+ !q || f.docs.some((d) => (d.title + ' ' + d.name + ' ' + d.file).toLowerCase().includes(q)));
220
+ for (const fam of fams) {
221
+ const col = COLS.find((c) => c.statuses.includes(fam.head.status));
222
+ (col ? groups[col.key] : rest).push(fam);
223
+ }
224
+ const nAlerts = CARDS.filter((c) => c.alerts?.length).length;
225
+ const ca = $('#c-alerts');
226
+ ca.style.display = nAlerts ? '' : 'none';
227
+ if (nAlerts) { ca.textContent = `⚠ 断档 ${nAlerts}`; ca.style.color = 'var(--bad)'; }
228
+ const cardHtml = (fam, extra = '') => {
229
+ const h = fam.head;
230
+ return `<div class="card ${ageClass(h)}${EXPANDED.has(fam.slug) || q ? ' open' : ''}" data-slug="${esc(fam.slug)}" style="border-left-color:${TYPE_COLOR[h.type]}">` +
231
+ `<div class="row">${famTypeChips(fam)}` +
232
+ (h.level ? `<span class="lv ${['L2','L3'].includes(h.level) ? 'hi' : ''}">${esc(h.level)}</span>` : '') +
233
+ acceptBadge(h) + alertBadge(fam) + ageBadge(h) + extra +
234
+ (fam.author ? `<span class="auth" title="作者(git 最早提交)">${esc(fam.author)}</span>` : '') +
235
+ `<span class="date">${esc(h.date)}</span></div>` +
236
+ `<div class="title">${h.status === 'open' ? '<span class="hold">挂起</span>' : ''}${esc(h.title)}</div>` +
237
+ `<div class="docs">${fam.docs.map((d) =>
238
+ `<div class="doc-item" data-file="${esc(d.file)}" title="打开文档">` +
239
+ `<span class="type" style="background:${TYPE_COLOR[d.type]}">${TYPE_LABEL[d.type]}</span>` +
240
+ `<span class="chip">${esc(d.status)}</span>${alertBadge(d)}` +
241
+ `<span class="doc-title">${esc(d.title)}</span></div>`).join('')}</div></div>`;
242
+ };
243
+ let html = COLS.map((c) => {
244
+ const items = groups[c.key].map((fam) => cardHtml(fam)).join('');
245
+ return `<div class="col"><h2><b>${c.name}</b>${groups[c.key].length}</h2><div class="list">${
246
+ items || '<div class="empty">—</div>'}</div></div>`;
247
+ }).join('');
248
+ if (rest.length) {
249
+ html += `<div class="col"><h2><b>其他</b>${rest.length}</h2><div class="list">${rest.map((fam) =>
250
+ cardHtml(fam, `<span class="chip">${esc(fam.head.status)}</span>`)).join('')}</div></div>`;
251
+ }
252
+ $('#board').innerHTML = html;
253
+ return true;
254
+ }
255
+
256
+ function renderDrawer() {
257
+ if (!CURRENT) return;
258
+ $('#d-badges').innerHTML = badge(CURRENT) +
259
+ (CURRENT.author ? ` <span class="chip" title="git 最早提交">✎ ${esc(CURRENT.author)}</span>` : '') +
260
+ ` <span class="chip">${esc(CURRENT.status)}</span>`;
261
+ $('#d-title').textContent = CURRENT.title;
262
+ const related = CARDS.filter((c) => c.slug === CURRENT.slug && c.file !== CURRENT.file);
263
+ $('#d-related').innerHTML = related.map((c) =>
264
+ `<a data-file="${esc(c.file)}">${TYPE_LABEL[c.type]}${c.status !== CURRENT.status ? ' · ' + esc(c.status) : ''}</a>`).join('');
265
+ document.querySelectorAll('#d-tabs button').forEach((b) => b.classList.toggle('on', b.dataset.tab === TAB));
266
+ if (TAB === 'doc') loadDoc(); else loadHist();
267
+ }
268
+
269
+ async function loadDoc() {
270
+ $('#d-body').innerHTML = '<div class="none">加载中…</div>';
271
+ const { text, error } = await (await fetch(`/api/doc?file=${encodeURIComponent(CURRENT.file)}`)).json();
272
+ if (error) { $('#d-body').innerHTML = `<div class="none">${esc(error)}</div>`; return; }
273
+ // 裸 .md 相对路径引用 → 可点链接(workflow 文档互引多为纯文本路径而非 md 链接语法;
274
+ // 已有 ](path) 链接语法因前缀为 ] 不会被重复包裹)
275
+ const linked = text.replace(
276
+ /(^|[\s((【>])((?:\.\.\/|\.\/)?(?:intents|incidents|plans|specs)\/[A-Za-z0-9_\-./]+\.md)/g,
277
+ '$1[$2]($2)'
278
+ );
279
+ $('#d-body').innerHTML = `<div class="md">${marked.parse(linked)}</div>`;
280
+ }
281
+
282
+ // —— commit 类型色点(Conventional Commits 首词)——
283
+ const CT_COLOR = { feat: 'var(--ok)', fix: 'var(--bad)', docs: 'var(--dim)', test: 'var(--spec)', refactor: 'var(--intent)', chore: 'var(--warn)', style: 'var(--spec)', perf: 'var(--warn)' };
284
+ const ctDot = (subject) => {
285
+ const t = (subject.match(/^(\w+)[(:]/) || [])[1];
286
+ return `<span class="ct" style="background:${CT_COLOR[t] || 'var(--dim)'}" title="${t || 'commit'}"></span>`;
287
+ };
288
+
289
+ async function loadHist() {
290
+ $('#d-body').innerHTML = '<div class="none">加载中…</div>';
291
+ const { commits } = await (await fetch(`/api/history?file=${encodeURIComponent(CURRENT.file)}`)).json();
292
+ $('#d-body').innerHTML = commits.length
293
+ ? `<div class="tl">${commits.map((c) =>
294
+ `<div class="tl-item"><div class="tl-sub">${ctDot(c.subject)}${esc(c.subject)}</div>` +
295
+ `<div class="tl-meta">${esc(c.date)} · <code class="hash" data-h="${esc(c.hash)}" title="点击复制完整 hash">${esc(c.hash.slice(0, 9))}</code></div></div>`).join('')}</div>`
296
+ : '<div class="none">暂无提交记录(文件尚未 commit;git 历史只包含已提交的修改)</div>';
297
+ }
298
+
299
+ function openCard(card) { CURRENT = card; TAB = 'doc'; $('#drawer').classList.add('open'); renderDrawer(); }
300
+
301
+ // —— toast 反馈 ——
302
+ let toastTimer = null;
303
+ function toast(msg) {
304
+ const t = $('#toast');
305
+ t.textContent = msg; t.classList.add('show');
306
+ clearTimeout(toastTimer);
307
+ toastTimer = setTimeout(() => t.classList.remove('show'), 1800);
308
+ }
309
+
310
+ // —— 抽屉内文档内链:md 相对链接 → 抽屉内打开;外链新窗口;仓库代码路径 → vscode 协议 ——
311
+ function viewFile(rel) {
312
+ const hit = CARDS.find((c) => c.file === rel);
313
+ if (hit) { openCard(hit); return; }
314
+ // 自由文档(不在看板卡范围,如 workflow/README.md、同名以外的引用):伪卡视图
315
+ CURRENT = { file: rel, name: rel, slug: rel, title: rel, type: 'link', status: '', level: '', date: '' };
316
+ TAB = 'doc';
317
+ $('#drawer').classList.add('open');
318
+ renderDrawer();
319
+ }
320
+
321
+ function onDocLink(e, a) {
322
+ const href = a.getAttribute('href');
323
+ if (!href || href.startsWith('#')) return; // 页内锚点走默认行为
324
+ if (/^https?:/i.test(href)) { e.preventDefault(); window.open(href, '_blank', 'noopener'); return; }
325
+ e.preventDefault();
326
+ const dir = CURRENT.file.includes('/') ? CURRENT.file.replace(/[^/]*$/, '') : '';
327
+ let rel;
328
+ try { rel = decodeURIComponent(new URL(href, 'http://x/' + dir).pathname.slice(1)); } catch { return; }
329
+ if (/\.md$/i.test(rel)) { viewFile(rel); return; }
330
+ if (ROOT && !/^[a-z]+:/i.test(rel)) { window.open(`vscode://file/${ROOT}/${rel}`); toast('已用编辑器打开 ' + rel); }
331
+ }
332
+
333
+ document.addEventListener('click', (e) => {
334
+ const dLink = e.target.closest('#d-body a');
335
+ if (dLink) { onDocLink(e, dLink); return; }
336
+ // 展开区文档条目 → 开抽屉(先于卡片命中,条目在卡内)
337
+ const item = e.target.closest('.doc-item');
338
+ if (item) {
339
+ const hit = CARDS.find((c) => c.file === item.dataset.file);
340
+ if (hit) openCard(hit);
341
+ return;
342
+ }
343
+ // 卡片点击 → 就地展开/收起该任务
344
+ const card = e.target.closest('.card');
345
+ if (card) {
346
+ const slug = card.dataset.slug;
347
+ if (EXPANDED.has(slug)) EXPANDED.delete(slug); else EXPANDED.add(slug);
348
+ card.classList.toggle('open');
349
+ return;
350
+ }
351
+ const rel = e.target.closest('#d-related a');
352
+ if (rel) {
353
+ const hit = CARDS.find((c) => c.file === rel.dataset.file);
354
+ if (hit) { CURRENT = hit; renderDrawer(); }
355
+ return;
356
+ }
357
+ const hash = e.target.closest('.hash');
358
+ if (hash) navigator.clipboard.writeText(hash.dataset.h)
359
+ .then(() => toast('已复制 ' + hash.dataset.h.slice(0, 9)))
360
+ .catch(() => toast('复制失败'));
361
+ });
362
+ $('#d-close').onclick = () => { $('#drawer').classList.remove('open'); };
363
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') $('#drawer').classList.remove('open'); });
364
+ document.querySelectorAll('#d-tabs button').forEach((b) => b.onclick = () => { TAB = b.dataset.tab; renderDrawer(); });
365
+
366
+ // SSE:服务端 fs.watch 防抖后推送 changed → 看板与打开中的抽屉自动刷新
367
+ const sse = new EventSource('/api/events');
368
+ sse.addEventListener('changed', () => { refreshBoard().then((ok) => { if (ok) renderDrawer(); }); });
369
+ sse.addEventListener('watchdead', () => { $('#sse').classList.remove('on'); $('#sse').classList.add('poll'); $('#sse span:last-child').textContent = '监听失效 · 60s 轮询'; });
370
+ sse.onopen = () => { $('#sse').classList.add('on'); $('#sse').classList.remove('poll'); $('#sse span:last-child').textContent = '实时'; };
371
+ sse.onerror = () => { $('#sse').classList.remove('on'); $('#sse span:last-child').textContent = '连接断开,自动重连中'; };
372
+
373
+ // 60s 兜底轮询:SSE/watch 任何环节失效时数据仍保鲜(正常时多拉一次无害)
374
+ setInterval(() => refreshBoard(), 60000);
375
+
376
+ // 搜索过滤:本地过滤标题/slug/文件,SSE 刷新时保持关键字
377
+ $('#q').addEventListener('input', (e) => { Q = e.target.value; refreshBoard(); });
378
+
379
+ refreshBoard();
380
+ </script>
381
+ </body>
382
+ </html>