@yeaft/webchat-agent 1.0.79 → 1.0.81
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/connection/buffer.js +2 -6
- package/connection/message-router.js +1 -64
- package/conversation.js +4 -32
- package/history.js +2 -2
- package/index.js +0 -6
- package/package.json +1 -1
- package/providers/claude-code.js +1 -1
- package/yeaft/attachments.js +2 -2
- package/yeaft/config.js +1 -1
- package/yeaft/conversation/persist.js +458 -112
- package/yeaft/conversation/search.js +51 -15
- package/yeaft/session.js +27 -18
- package/yeaft/sessions/session-config.js +2 -0
- package/yeaft/sessions/session-crud.js +3 -2
- package/yeaft/web-bridge.js +27 -15
- package/crew/builtin-actions.js +0 -154
- package/crew/context-loader.js +0 -171
- package/crew/control.js +0 -444
- package/crew/human-interaction.js +0 -195
- package/crew/persistence.js +0 -295
- package/crew/role-management.js +0 -182
- package/crew/role-output.js +0 -461
- package/crew/role-query.js +0 -406
- package/crew/role-states.js +0 -180
- package/crew/routing-fallback.js +0 -64
- package/crew/routing-metrics.js +0 -215
- package/crew/routing.js +0 -951
- package/crew/session.js +0 -648
- package/crew/shared-dir.js +0 -266
- package/crew/task-files.js +0 -554
- package/crew/ui-messages.js +0 -274
- package/crew/worktree.js +0 -130
- package/crew-i18n.js +0 -579
- package/crew.js +0 -48
package/crew/task-files.js
DELETED
|
@@ -1,554 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Crew — Task 文件管理(系统自动管理)
|
|
3
|
-
* ensureTaskFile, appendTaskRecord, readTaskFile, parseCompletedTasks,
|
|
4
|
-
* updateFeatureIndex, appendChangelog, saveRoleWorkSummary,
|
|
5
|
-
* updateKanban, readKanban
|
|
6
|
-
*/
|
|
7
|
-
import { promises as fs } from 'fs';
|
|
8
|
-
import { join } from 'path';
|
|
9
|
-
import { getMessages } from '../crew-i18n.js';
|
|
10
|
-
|
|
11
|
-
/** Format role label */
|
|
12
|
-
function roleLabel(r) {
|
|
13
|
-
return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 自动创建 task 进度文件
|
|
18
|
-
*/
|
|
19
|
-
export async function ensureTaskFile(session, taskId, taskTitle, assignee, summary) {
|
|
20
|
-
const featuresDir = join(session.sharedDir, 'context', 'features');
|
|
21
|
-
const filePath = join(featuresDir, `${taskId}.md`);
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
await fs.access(filePath);
|
|
25
|
-
// 文件已存在,不覆盖
|
|
26
|
-
return;
|
|
27
|
-
} catch {
|
|
28
|
-
// 文件不存在,创建
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
await fs.mkdir(featuresDir, { recursive: true });
|
|
32
|
-
|
|
33
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
34
|
-
const now = new Date().toISOString();
|
|
35
|
-
const content = `# ${m.featureLabel}: ${taskTitle}
|
|
36
|
-
- task-id: ${taskId}
|
|
37
|
-
- ${m.statusPending}
|
|
38
|
-
- ${m.assigneeLabel}: ${assignee}
|
|
39
|
-
- ${m.createdAtLabel}: ${now}
|
|
40
|
-
|
|
41
|
-
${m.requirementDesc}
|
|
42
|
-
${summary}
|
|
43
|
-
|
|
44
|
-
${m.workRecord}
|
|
45
|
-
`;
|
|
46
|
-
|
|
47
|
-
await fs.writeFile(filePath, content);
|
|
48
|
-
|
|
49
|
-
// 同步到 session.features
|
|
50
|
-
if (!session.features.has(taskId)) {
|
|
51
|
-
session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
console.log(`[Crew] Task file created: ${taskId} (${taskTitle})`);
|
|
55
|
-
|
|
56
|
-
// 更新 feature 索引
|
|
57
|
-
updateFeatureIndex(session).catch(e => console.warn('[Crew] Failed to update feature index:', e.message));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* 追加工作记录到 task 文件
|
|
62
|
-
*
|
|
63
|
-
* task-321: auto-create the feature file if it doesn't exist yet. Previously
|
|
64
|
-
* a missing file silently dropped the record, which meant that whenever a
|
|
65
|
-
* non-PM role was first to mention a taskId, the file was never created and
|
|
66
|
-
* every subsequent record — including from PM — would also be dropped. We
|
|
67
|
-
* now recover a title from opts.taskTitle → session.features cache → taskId
|
|
68
|
-
* itself, and create the file on the fly.
|
|
69
|
-
*
|
|
70
|
-
* @param {object} session
|
|
71
|
-
* @param {string} taskId
|
|
72
|
-
* @param {string} roleName
|
|
73
|
-
* @param {string} summary
|
|
74
|
-
* @param {{ taskTitle?: string|null, assignee?: string|null }} [opts]
|
|
75
|
-
*/
|
|
76
|
-
export async function appendTaskRecord(session, taskId, roleName, summary, opts = {}) {
|
|
77
|
-
const filePath = join(session.sharedDir, 'context', 'features', `${taskId}.md`);
|
|
78
|
-
|
|
79
|
-
let exists = true;
|
|
80
|
-
try {
|
|
81
|
-
await fs.access(filePath);
|
|
82
|
-
} catch {
|
|
83
|
-
exists = false;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (!exists) {
|
|
87
|
-
const recoveredTitle = opts.taskTitle
|
|
88
|
-
|| session.features?.get(taskId)?.taskTitle
|
|
89
|
-
|| taskId;
|
|
90
|
-
const assignee = opts.assignee || roleName;
|
|
91
|
-
try {
|
|
92
|
-
await ensureTaskFile(session, taskId, recoveredTitle, assignee, summary);
|
|
93
|
-
} catch (e) {
|
|
94
|
-
console.warn(`[Crew] Failed to auto-create task file ${taskId} on append:`, e.message);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const role = session.roles.get(roleName);
|
|
100
|
-
const label = role ? roleLabel(role) : roleName;
|
|
101
|
-
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
|
102
|
-
const record = `\n### ${label} - ${now}\n${summary}\n`;
|
|
103
|
-
|
|
104
|
-
await fs.appendFile(filePath, record);
|
|
105
|
-
console.log(`[Crew] Task record appended: ${taskId} by ${roleName}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* 读取 task 文件内容(用于注入上下文)
|
|
110
|
-
*/
|
|
111
|
-
export async function readTaskFile(session, taskId) {
|
|
112
|
-
const filePath = join(session.sharedDir, 'context', 'features', `${taskId}.md`);
|
|
113
|
-
try {
|
|
114
|
-
return await fs.readFile(filePath, 'utf-8');
|
|
115
|
-
} catch {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* 从文本中提取已完成任务的 taskId 集合。
|
|
122
|
-
*
|
|
123
|
-
* Primary: parse TASKS block (---TASKS--- / ---END_TASKS---) for checked items with #taskId.
|
|
124
|
-
* Fallback: when no TASKS block is found AND knownTaskIds are provided,
|
|
125
|
-
* scan the full text for known taskId + completion keyword combinations.
|
|
126
|
-
* This handles cases where the PM mentions task completion in prose without
|
|
127
|
-
* emitting a formal TASKS block.
|
|
128
|
-
*
|
|
129
|
-
* @param {string} text - accumulated role output text
|
|
130
|
-
* @param {string[]} [knownTaskIds] - list of known task IDs to search for in fallback mode
|
|
131
|
-
* @returns {Set<string>} completed task IDs
|
|
132
|
-
*/
|
|
133
|
-
export function parseCompletedTasks(text, knownTaskIds) {
|
|
134
|
-
const ids = new Set();
|
|
135
|
-
|
|
136
|
-
// Primary: TASKS block parsing
|
|
137
|
-
const match = text.match(/---TASKS---([\s\S]*?)---END_TASKS---/);
|
|
138
|
-
if (match) {
|
|
139
|
-
for (const line of match[1].split('\n')) {
|
|
140
|
-
const m = line.match(/^-\s*\[[xX]\]\s*.+#(\S+)/);
|
|
141
|
-
if (m) ids.add(m[1]);
|
|
142
|
-
}
|
|
143
|
-
return ids;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Fallback: scan plain text for known taskId + completion keywords
|
|
147
|
-
if (!knownTaskIds || knownTaskIds.length === 0) return ids;
|
|
148
|
-
|
|
149
|
-
const completionPatterns = [
|
|
150
|
-
/已完成/, /完成/, /已合并/, /合并/, /DONE/, /DONE_MERGED/, /MERGED/,
|
|
151
|
-
/已关闭/, /关闭/, /✅/, /通过/, /passed/i, /merged/i, /completed/i
|
|
152
|
-
];
|
|
153
|
-
|
|
154
|
-
for (const taskId of knownTaskIds) {
|
|
155
|
-
// Search for lines/sentences containing the taskId
|
|
156
|
-
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
157
|
-
const re = new RegExp(`^.*${escaped}.*$`, 'gm');
|
|
158
|
-
let lineMatch;
|
|
159
|
-
while ((lineMatch = re.exec(text)) !== null) {
|
|
160
|
-
const line = lineMatch[0];
|
|
161
|
-
if (completionPatterns.some(p => p.test(line))) {
|
|
162
|
-
ids.add(taskId);
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return ids;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* 更新 feature 索引文件 .crew/context/features/index.md
|
|
173
|
-
*/
|
|
174
|
-
export async function updateFeatureIndex(session) {
|
|
175
|
-
const featuresDir = join(session.sharedDir, 'context', 'features');
|
|
176
|
-
await fs.mkdir(featuresDir, { recursive: true });
|
|
177
|
-
|
|
178
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
179
|
-
const completed = session._completedTaskIds || new Set();
|
|
180
|
-
const allFeatures = Array.from(session.features.values());
|
|
181
|
-
|
|
182
|
-
allFeatures.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
183
|
-
|
|
184
|
-
const inProgress = allFeatures.filter(f => !completed.has(f.taskId));
|
|
185
|
-
const done = allFeatures.filter(f => completed.has(f.taskId));
|
|
186
|
-
|
|
187
|
-
const locale = (session.language === 'en') ? 'en-US' : 'zh-CN';
|
|
188
|
-
const now = new Date().toLocaleString(locale, { timeZone: 'Asia/Shanghai' });
|
|
189
|
-
let content = `${m.featureIndex}\n> ${m.lastUpdated}: ${now}\n`;
|
|
190
|
-
|
|
191
|
-
content += `\n${m.inProgressGroup(inProgress.length)}\n`;
|
|
192
|
-
if (inProgress.length > 0) {
|
|
193
|
-
content += `| ${m.colTaskId} | ${m.colTitle} | ${m.colCreatedAt} |\n|---------|------|----------|\n`;
|
|
194
|
-
for (const f of inProgress) {
|
|
195
|
-
const date = f.createdAt ? new Date(f.createdAt).toLocaleDateString(locale) : '-';
|
|
196
|
-
content += `| ${f.taskId} | ${f.taskTitle} | ${date} |\n`;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
content += `\n${m.completedGroup(done.length)}\n`;
|
|
201
|
-
if (done.length > 0) {
|
|
202
|
-
content += `| ${m.colTaskId} | ${m.colTitle} | ${m.colCreatedAt} |\n|---------|------|----------|\n`;
|
|
203
|
-
for (const f of done) {
|
|
204
|
-
const date = f.createdAt ? new Date(f.createdAt).toLocaleDateString(locale) : '-';
|
|
205
|
-
content += `| ${f.taskId} | ${f.taskTitle} | ${date} |\n`;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
await fs.writeFile(join(featuresDir, 'index.md'), content);
|
|
210
|
-
console.log(`[Crew] Feature index updated: ${inProgress.length} in progress, ${done.length} completed`);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* 追加完成汇总到 .crew/context/changelog.md
|
|
215
|
-
*/
|
|
216
|
-
export async function appendChangelog(session, taskId, taskTitle) {
|
|
217
|
-
const contextDir = join(session.sharedDir, 'context');
|
|
218
|
-
await fs.mkdir(contextDir, { recursive: true });
|
|
219
|
-
const changelogPath = join(contextDir, 'changelog.md');
|
|
220
|
-
|
|
221
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
222
|
-
|
|
223
|
-
// 读取 feature 文件提取最后一条工作记录作为摘要
|
|
224
|
-
const taskContent = await readTaskFile(session, taskId);
|
|
225
|
-
let summaryText = '';
|
|
226
|
-
if (taskContent) {
|
|
227
|
-
const records = taskContent.split(/\n### /);
|
|
228
|
-
if (records.length > 1) {
|
|
229
|
-
const lastRecord = records[records.length - 1];
|
|
230
|
-
const lines = lastRecord.split('\n');
|
|
231
|
-
summaryText = lines.slice(1).join('\n').trim();
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (!summaryText) {
|
|
235
|
-
summaryText = m.noSummary;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 限制摘要长度
|
|
239
|
-
if (summaryText.length > 500) {
|
|
240
|
-
summaryText = summaryText.substring(0, 497) + '...';
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const locale = (session.language === 'en') ? 'en-US' : 'zh-CN';
|
|
244
|
-
const now = new Date().toLocaleString(locale, { timeZone: 'Asia/Shanghai' });
|
|
245
|
-
const entry = `\n## ${taskId}: ${taskTitle}\n- ${m.completedAt}: ${now}\n- ${m.summaryLabel}: ${summaryText}\n`;
|
|
246
|
-
|
|
247
|
-
let exists = false;
|
|
248
|
-
try {
|
|
249
|
-
await fs.access(changelogPath);
|
|
250
|
-
exists = true;
|
|
251
|
-
} catch {}
|
|
252
|
-
|
|
253
|
-
if (!exists) {
|
|
254
|
-
await fs.writeFile(changelogPath, `${m.changelogTitle}\n${entry}`);
|
|
255
|
-
} else {
|
|
256
|
-
await fs.appendFile(changelogPath, entry);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
console.log(`[Crew] Changelog appended: ${taskId} (${taskTitle})`);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Context 超限 clear 前,将角色当前输出摘要保存到 feature 文件
|
|
264
|
-
*/
|
|
265
|
-
export async function saveRoleWorkSummary(session, roleName, accumulatedText) {
|
|
266
|
-
const roleState = session.roleStates.get(roleName);
|
|
267
|
-
const taskId = roleState?.currentTask?.taskId;
|
|
268
|
-
if (!taskId || !accumulatedText) return;
|
|
269
|
-
|
|
270
|
-
// 截取最后 2000 字符作为工作摘要
|
|
271
|
-
const summary = accumulatedText.length > 2000
|
|
272
|
-
? '...' + accumulatedText.slice(-2000)
|
|
273
|
-
: accumulatedText;
|
|
274
|
-
|
|
275
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
276
|
-
await appendTaskRecord(session, taskId, roleName,
|
|
277
|
-
`[${m.kanbanAutoSave}] ${summary}`);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// 看板写入锁:防止并发写入
|
|
281
|
-
let _kanbanWriteLock = Promise.resolve();
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* 校验 taskId 是否合法。
|
|
285
|
-
* 拒绝:占位符(<task-id>、task-XX)、纯数字、空/带尖括号。
|
|
286
|
-
* 允许:以字母开头、字母数字/连字符/下划线,例如 task-289、fix-crew-xxx。
|
|
287
|
-
*
|
|
288
|
-
* @param {string} id
|
|
289
|
-
* @returns {boolean}
|
|
290
|
-
*/
|
|
291
|
-
export function isValidTaskId(id) {
|
|
292
|
-
if (typeof id !== 'string') return false;
|
|
293
|
-
const s = id.trim();
|
|
294
|
-
if (!s) return false;
|
|
295
|
-
if (s.includes('<') || s.includes('>')) return false;
|
|
296
|
-
// explicit placeholder
|
|
297
|
-
if (/^task-x+$/i.test(s)) return false;
|
|
298
|
-
// pure digits (e.g. bare "279")
|
|
299
|
-
if (/^\d+$/.test(s)) return false;
|
|
300
|
-
// must start with a letter and contain only alnum/_/-
|
|
301
|
-
if (!/^[a-zA-Z][a-zA-Z0-9_-]{1,79}$/.test(s)) return false;
|
|
302
|
-
return true;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* 规范化 "最新进展" 摘要:
|
|
307
|
-
* - 换行/回车合并为单空格
|
|
308
|
-
* - 去掉 markdown 分隔符 `---`、heading `##`
|
|
309
|
-
* - 转义 `|` 为 `\|`(避免撑坏表格)
|
|
310
|
-
* - 合并多余空白
|
|
311
|
-
* - 截断到 maxLen 字符(默认 80),超出追加 …
|
|
312
|
-
*
|
|
313
|
-
* @param {string} summary
|
|
314
|
-
* @param {number} [maxLen=80]
|
|
315
|
-
* @returns {string}
|
|
316
|
-
*/
|
|
317
|
-
export function sanitizeKanbanSummary(summary, maxLen = 120) {
|
|
318
|
-
if (!summary || typeof summary !== 'string') return '-';
|
|
319
|
-
let s = summary;
|
|
320
|
-
// strip horizontal rule lines and heading markers (start-of-line form)
|
|
321
|
-
s = s.replace(/^\s*-{3,}\s*$/gm, ' ');
|
|
322
|
-
s = s.replace(/^\s*#{1,6}\s+/gm, '');
|
|
323
|
-
// strip leading list/quote markers on each line
|
|
324
|
-
s = s.replace(/^\s*[-*>]\s+/gm, '');
|
|
325
|
-
// newlines → space
|
|
326
|
-
s = s.replace(/[\r\n]+/g, ' ');
|
|
327
|
-
// Also strip markdown structural artifacts that may appear mid-string
|
|
328
|
-
// after newline-folding (e.g. "priority: high --- ## obs").
|
|
329
|
-
s = s.replace(/(^|\s)-{3,}(\s|$)/g, ' ');
|
|
330
|
-
s = s.replace(/(^|\s)#{1,6}(\s|$)/g, ' ');
|
|
331
|
-
// escape pipe chars so table doesn't break
|
|
332
|
-
s = s.replace(/\|/g, '\\|');
|
|
333
|
-
// collapse whitespace
|
|
334
|
-
s = s.replace(/\s+/g, ' ').trim();
|
|
335
|
-
if (!s) return '-';
|
|
336
|
-
if (s.length > maxLen) {
|
|
337
|
-
s = s.substring(0, Math.max(1, maxLen - 1)) + '…';
|
|
338
|
-
}
|
|
339
|
-
return s;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* 规范化非 summary 单元格(taskId / title / assignee / status):
|
|
344
|
-
* 折行、转义 `|`、去首尾空白、限制到 120 字符。
|
|
345
|
-
*
|
|
346
|
-
* @param {string} v
|
|
347
|
-
* @returns {string}
|
|
348
|
-
*/
|
|
349
|
-
export function sanitizeKanbanCell(v) {
|
|
350
|
-
if (v === null || v === undefined) return '-';
|
|
351
|
-
let s = String(v);
|
|
352
|
-
s = s.replace(/[\r\n]+/g, ' ');
|
|
353
|
-
s = s.replace(/\|/g, '\\|');
|
|
354
|
-
s = s.replace(/\s+/g, ' ').trim();
|
|
355
|
-
if (!s) return '-';
|
|
356
|
-
if (s.length > 120) s = s.substring(0, 119) + '…';
|
|
357
|
-
return s;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* 更新工作看板 .crew/context/kanban.md
|
|
362
|
-
*
|
|
363
|
-
* @param {object} session
|
|
364
|
-
* @param {object} [opts]
|
|
365
|
-
* @param {string} [opts.taskId] - 要更新的任务 ID
|
|
366
|
-
* @param {string} [opts.assignee] - 负责人
|
|
367
|
-
* @param {string} [opts.status] - 当前状态
|
|
368
|
-
* @param {string} [opts.summary] - 最新进展摘要
|
|
369
|
-
* @param {boolean} [opts.completed] - 是否标记为已完成
|
|
370
|
-
*/
|
|
371
|
-
export async function updateKanban(session, opts = {}) {
|
|
372
|
-
const doUpdate = async () => {
|
|
373
|
-
const contextDir = join(session.sharedDir, 'context');
|
|
374
|
-
await fs.mkdir(contextDir, { recursive: true });
|
|
375
|
-
const kanbanPath = join(contextDir, 'kanban.md');
|
|
376
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
377
|
-
|
|
378
|
-
// 加载现有看板数据
|
|
379
|
-
let entries = new Map(); // taskId → { taskId, taskTitle, assignee, status, summary }
|
|
380
|
-
let completedEntries = new Map();
|
|
381
|
-
try {
|
|
382
|
-
const existing = await fs.readFile(kanbanPath, 'utf-8');
|
|
383
|
-
// 解析表格行
|
|
384
|
-
const lines = existing.split('\n');
|
|
385
|
-
let section = null;
|
|
386
|
-
for (const line of lines) {
|
|
387
|
-
if (line.startsWith('## ') && line.includes('🔨')) section = 'active';
|
|
388
|
-
else if (line.startsWith('## ') && line.includes('✅')) section = 'completed';
|
|
389
|
-
else if (line.startsWith('|') && !line.startsWith('|--') && section) {
|
|
390
|
-
const cols = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
391
|
-
if (cols.length >= 3 && cols[0] !== m.colTaskId && cols[0] !== 'task-id') {
|
|
392
|
-
// Skip illegal / placeholder rows (e.g. <task-id>, task-XX, bare "279")
|
|
393
|
-
if (!isValidTaskId(cols[0])) continue;
|
|
394
|
-
const entry = {
|
|
395
|
-
taskId: cols[0],
|
|
396
|
-
taskTitle: cols[1] || cols[0],
|
|
397
|
-
assignee: cols[2] || '-',
|
|
398
|
-
status: cols[3] || '-',
|
|
399
|
-
summary: sanitizeKanbanSummary(cols[4] || '-')
|
|
400
|
-
};
|
|
401
|
-
if (section === 'completed') {
|
|
402
|
-
completedEntries.set(entry.taskId, entry);
|
|
403
|
-
} else {
|
|
404
|
-
entries.set(entry.taskId, entry);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
} catch { /* 文件不存在 */ }
|
|
410
|
-
|
|
411
|
-
// 从 session.features 补充缺失的任务
|
|
412
|
-
const completed = session._completedTaskIds || new Set();
|
|
413
|
-
for (const [taskId, feature] of session.features) {
|
|
414
|
-
if (!isValidTaskId(taskId)) continue;
|
|
415
|
-
if (completed.has(taskId)) {
|
|
416
|
-
if (!completedEntries.has(taskId)) {
|
|
417
|
-
completedEntries.set(taskId, {
|
|
418
|
-
taskId,
|
|
419
|
-
taskTitle: feature.taskTitle,
|
|
420
|
-
assignee: '-',
|
|
421
|
-
status: '✅',
|
|
422
|
-
summary: '-'
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
entries.delete(taskId);
|
|
426
|
-
} else if (!entries.has(taskId)) {
|
|
427
|
-
entries.set(taskId, {
|
|
428
|
-
taskId,
|
|
429
|
-
taskTitle: feature.taskTitle,
|
|
430
|
-
assignee: '-',
|
|
431
|
-
status: '-',
|
|
432
|
-
summary: '-'
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// 应用更新
|
|
438
|
-
if (opts.taskId) {
|
|
439
|
-
// Reject illegal task ids early (placeholders, bare numbers, angle brackets, etc.)
|
|
440
|
-
if (!isValidTaskId(opts.taskId)) {
|
|
441
|
-
console.warn(`[Crew] updateKanban: rejected invalid taskId "${opts.taskId}"`);
|
|
442
|
-
} else if (opts.completed) {
|
|
443
|
-
const entry = entries.get(opts.taskId) || completedEntries.get(opts.taskId);
|
|
444
|
-
if (entry) {
|
|
445
|
-
entry.status = '✅';
|
|
446
|
-
if (opts.summary) entry.summary = sanitizeKanbanSummary(opts.summary);
|
|
447
|
-
completedEntries.set(opts.taskId, entry);
|
|
448
|
-
entries.delete(opts.taskId);
|
|
449
|
-
}
|
|
450
|
-
} else {
|
|
451
|
-
let entry = entries.get(opts.taskId);
|
|
452
|
-
if (!entry) {
|
|
453
|
-
const feature = session.features.get(opts.taskId);
|
|
454
|
-
entry = {
|
|
455
|
-
taskId: opts.taskId,
|
|
456
|
-
taskTitle: opts.taskTitle || feature?.taskTitle || opts.taskId,
|
|
457
|
-
assignee: '-',
|
|
458
|
-
status: '-',
|
|
459
|
-
summary: '-'
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
if (opts.assignee) entry.assignee = opts.assignee;
|
|
463
|
-
if (opts.status) entry.status = opts.status;
|
|
464
|
-
if (opts.summary) {
|
|
465
|
-
// 单行化、去 markdown、转义 |、截断到 80
|
|
466
|
-
entry.summary = sanitizeKanbanSummary(opts.summary);
|
|
467
|
-
}
|
|
468
|
-
entries.set(opts.taskId, entry);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// Final safety: drop any lingering invalid ids before writing
|
|
473
|
-
for (const id of Array.from(entries.keys())) {
|
|
474
|
-
if (!isValidTaskId(id)) entries.delete(id);
|
|
475
|
-
}
|
|
476
|
-
for (const id of Array.from(completedEntries.keys())) {
|
|
477
|
-
if (!isValidTaskId(id)) completedEntries.delete(id);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// 生成看板文件
|
|
481
|
-
const locale = (session.language === 'en') ? 'en-US' : 'zh-CN';
|
|
482
|
-
const now = new Date().toLocaleString(locale, { timeZone: 'Asia/Shanghai' });
|
|
483
|
-
let content = `${m.kanbanTitle}\n> ${m.lastUpdated}: ${now}\n`;
|
|
484
|
-
|
|
485
|
-
// Serialize-layer defense: sanitize every cell one last time so nothing
|
|
486
|
-
// bypasses the table via an unsanitized upstream write path.
|
|
487
|
-
const serializeCell = (v) => sanitizeKanbanCell(v);
|
|
488
|
-
const serializeSummary = (v) => {
|
|
489
|
-
const s = sanitizeKanbanSummary(v);
|
|
490
|
-
// sanitizeKanbanSummary returns "-" for empty, keep that
|
|
491
|
-
return s;
|
|
492
|
-
};
|
|
493
|
-
|
|
494
|
-
const activeArr = Array.from(entries.values());
|
|
495
|
-
content += `\n## 🔨 ${m.kanbanActive} (${activeArr.length})\n`;
|
|
496
|
-
if (activeArr.length > 0) {
|
|
497
|
-
content += `| ${m.colTaskId} | ${m.colTitle} | ${m.kanbanColAssignee} | ${m.kanbanColStatus} | ${m.kanbanColSummary} |\n|---------|------|--------|------|----------|\n`;
|
|
498
|
-
for (const e of activeArr) {
|
|
499
|
-
content += `| ${serializeCell(e.taskId)} | ${serializeCell(e.taskTitle)} | ${serializeCell(e.assignee)} | ${serializeCell(e.status)} | ${serializeSummary(e.summary)} |\n`;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const doneArr = Array.from(completedEntries.values());
|
|
504
|
-
content += `\n## ✅ ${m.kanbanCompleted} (${doneArr.length})\n`;
|
|
505
|
-
if (doneArr.length > 0) {
|
|
506
|
-
content += `| ${m.colTaskId} | ${m.colTitle} | ${m.kanbanColAssignee} |\n|---------|------|--------|\n`;
|
|
507
|
-
for (const e of doneArr) {
|
|
508
|
-
content += `| ${serializeCell(e.taskId)} | ${serializeCell(e.taskTitle)} | ${serializeCell(e.assignee)} |\n`;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
await fs.writeFile(kanbanPath, content);
|
|
513
|
-
console.log(`[Crew] Kanban updated: ${activeArr.length} active, ${doneArr.length} completed`);
|
|
514
|
-
};
|
|
515
|
-
|
|
516
|
-
// 串行化写入
|
|
517
|
-
_kanbanWriteLock = _kanbanWriteLock.then(doUpdate, doUpdate);
|
|
518
|
-
return _kanbanWriteLock;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* 启动自愈迁移:立即重写 kanban.md 一次。
|
|
523
|
-
* 触发完整 parse → filter → sanitize → serialize 流程,
|
|
524
|
-
* 不依赖下次 task-update 就能清除历史脏数据。
|
|
525
|
-
*
|
|
526
|
-
* 幂等:文件不存在时不创建。
|
|
527
|
-
*
|
|
528
|
-
* @param {object} session
|
|
529
|
-
* @returns {Promise<boolean>} 是否执行了清理
|
|
530
|
-
*/
|
|
531
|
-
export async function sanitizeKanbanFile(session) {
|
|
532
|
-
if (!session?.sharedDir) return false;
|
|
533
|
-
const kanbanPath = join(session.sharedDir, 'context', 'kanban.md');
|
|
534
|
-
try {
|
|
535
|
-
await fs.access(kanbanPath);
|
|
536
|
-
} catch {
|
|
537
|
-
return false; // 文件不存在,无需清理
|
|
538
|
-
}
|
|
539
|
-
await updateKanban(session, {}); // 无 opts → 纯重写
|
|
540
|
-
console.log(`[Crew] Kanban startup migration completed: ${kanbanPath}`);
|
|
541
|
-
return true;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* 读取看板文件内容
|
|
546
|
-
*/
|
|
547
|
-
export async function readKanban(session) {
|
|
548
|
-
const kanbanPath = join(session.sharedDir, 'context', 'kanban.md');
|
|
549
|
-
try {
|
|
550
|
-
return await fs.readFile(kanbanPath, 'utf-8');
|
|
551
|
-
} catch {
|
|
552
|
-
return null;
|
|
553
|
-
}
|
|
554
|
-
}
|