@fanchao8609/agent_brain_sync 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/bin/abs.js +112 -0
- package/bin/mcp.js +167 -0
- package/hooks/event.sh +58 -0
- package/package.json +33 -0
- package/skill/SKILL.md +200 -0
- package/src/brainio.js +66 -0
- package/src/hosts.js +65 -0
- package/src/index.js +40 -0
- package/src/install.js +416 -0
- package/src/lock.js +78 -0
- package/src/store.js +499 -0
- package/src/todo.js +308 -0
- package/src/wrapup.js +137 -0
package/src/todo.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// src/todo.js — todo.md 读写。CLI 的核心纯读写层(被 MCP 转接、可被 hook 直接调)。
|
|
2
|
+
// 格式沿用 SKILL.md 契约:Backlog → Today / In Progress → Blocked → Done,半成品 `↳ 断点:`。
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { brainPath } from './index.js';
|
|
5
|
+
import { editFile, SKIP } from './lock.js';
|
|
6
|
+
|
|
7
|
+
// ---------- 本地日期 ----------
|
|
8
|
+
export function today() {
|
|
9
|
+
// 用本地时区取 YYYY-MM-DD(toISOString 是 UTC, 会跨天错一天)
|
|
10
|
+
const d = new Date();
|
|
11
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
12
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 本地日期时间 YYYY-MM-DD HH:MM(log 流水用)
|
|
16
|
+
export function localStamp() {
|
|
17
|
+
const d = new Date();
|
|
18
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
19
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------- 读取 todo.md ----------
|
|
23
|
+
export async function readTodo(brainRoot) {
|
|
24
|
+
const p = brainPath(brainRoot, 'todo.md');
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = await fs.readFile(p, 'utf8');
|
|
28
|
+
} catch {
|
|
29
|
+
return ''; // 尚未创建,按空处理(不在此创建,避免读操作写文件)
|
|
30
|
+
}
|
|
31
|
+
const norm = normalizeTodo(text);
|
|
32
|
+
const grouped = groupDoneSection(norm); // 平铺旧 Done → 按日期分组(幂等)
|
|
33
|
+
if (grouped !== text) {
|
|
34
|
+
// 惰性迁移也是写:走锁,避免与并发写互相覆盖(锁内 re-read 已是权威最新内容)
|
|
35
|
+
await editFile(p, (cur) => (cur === text ? { text: grouped } : SKIP));
|
|
36
|
+
return grouped;
|
|
37
|
+
}
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function ensureTodo(brainRoot) {
|
|
42
|
+
const p = brainPath(brainRoot, 'todo.md');
|
|
43
|
+
// 缺失才建模板;经锁写盘保证原子性(避免与并发 editFile 读到半写内容)
|
|
44
|
+
await editFile(p, (cur) => (cur === null ? { text: todoTemplate() } : SKIP));
|
|
45
|
+
return p;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function todoTemplate() {
|
|
49
|
+
return [
|
|
50
|
+
'# 📋 Todo 看板',
|
|
51
|
+
'## Backlog',
|
|
52
|
+
'- [ ] 待办任务',
|
|
53
|
+
'## Today / In Progress',
|
|
54
|
+
'## Blocked',
|
|
55
|
+
'## Done(只留近期,旧的迁 log.md/快照)',
|
|
56
|
+
'',
|
|
57
|
+
].join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 归一化 todo.md 分区:老格式(In Progress/Todo)迁移为 B4 定稿格式(Backlog→Today / In Progress→Blocked→Done)。
|
|
61
|
+
* 幂等:已是新格式则原样返回。迁移原则——老 "In Progress" 内容进 "Today / In Progress",老 "Todo" 内容进 "Backlog"。 */
|
|
62
|
+
export const TODO_SECTIONS = ['Backlog', 'Today / In Progress', 'Blocked', 'Done'];
|
|
63
|
+
|
|
64
|
+
export function normalizeTodo(text) {
|
|
65
|
+
const lines = text.split('\n');
|
|
66
|
+
const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
|
|
67
|
+
if (has('Backlog') || has('Today / In Progress')) return text; // 已是新格式
|
|
68
|
+
if (!has('In Progress') && !has('Todo')) return text; // 不是老格式,不动
|
|
69
|
+
const out = ['# 📋 Todo 看板'];
|
|
70
|
+
const grab = (name) => {
|
|
71
|
+
const items = [];
|
|
72
|
+
let inSec = false;
|
|
73
|
+
for (const l of lines) {
|
|
74
|
+
if (l.startsWith('## ')) { inSec = l.trim() === `## ${name}`; continue; }
|
|
75
|
+
if (inSec && l.trim() && !isPlaceholder(l)) items.push(l);
|
|
76
|
+
}
|
|
77
|
+
return items;
|
|
78
|
+
};
|
|
79
|
+
const done = grab('Done');
|
|
80
|
+
const blocked = grab('Blocked');
|
|
81
|
+
const inprog = grab('In Progress');
|
|
82
|
+
const todo = grab('Todo');
|
|
83
|
+
out.push('## Backlog', ...todo.length ? todo : []);
|
|
84
|
+
out.push('## Today / In Progress', ...inprog.length ? inprog : []);
|
|
85
|
+
out.push('## Blocked', ...blocked.length ? blocked : []);
|
|
86
|
+
out.push('## Done(只留近期,旧的迁 log.md/快照)', ...done.length ? done : []);
|
|
87
|
+
return out.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */
|
|
91
|
+
export function doneDateOf(line) {
|
|
92
|
+
const m = String(line).match(/\(完成\s*(\d{4}-\d{2}-\d{2})[^)]*\)/);
|
|
93
|
+
return m ? m[1] : '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 判断一行是否为任务行(- [x] / - [ ],允许缩进)。 */
|
|
97
|
+
function isTaskLine(l) {
|
|
98
|
+
return /^\s*- \[[ x]\]/.test(l);
|
|
99
|
+
}
|
|
100
|
+
/** 判断一行是否为任务附属行(↳ 开头)。 */
|
|
101
|
+
function isChildLine(l) {
|
|
102
|
+
return /^\s*↳/.test(l);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 把 Done 区文本(bodyLines,不含 `## Done` 标题)解析成任务单元 [{ date, lines:[main,...children] }]。
|
|
106
|
+
* 剥 `### 日期` 分组标题与空行;任务行下紧跟的 ↳ 行并入该单元。 */
|
|
107
|
+
function parseDoneUnits(bodyLines) {
|
|
108
|
+
const units = [];
|
|
109
|
+
let cur = null;
|
|
110
|
+
for (const l of bodyLines) {
|
|
111
|
+
if (/^### /.test(l.trim()) || l.trim() === '') { cur = null; continue; } // 分组标题/空行断开会话
|
|
112
|
+
if (isTaskLine(l)) {
|
|
113
|
+
cur = { date: doneDateOf(l), lines: [l] };
|
|
114
|
+
units.push(cur);
|
|
115
|
+
} else if (isChildLine(l) && cur) {
|
|
116
|
+
cur.lines.push(l); // 附属行挂到上一个任务
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return units;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 按 (完成 date) 分组 Done 任务单元并重建文本行:新日期在前,未标日期归尾组。
|
|
123
|
+
* 同组内保持输入顺序(幂等)。返回不含 `## Done` 标题的主体行。 */
|
|
124
|
+
function renderDoneGroups(units) {
|
|
125
|
+
const byDate = new Map();
|
|
126
|
+
const undated = [];
|
|
127
|
+
for (const u of units) {
|
|
128
|
+
if (!u.date) undated.push(u);
|
|
129
|
+
else {
|
|
130
|
+
if (!byDate.has(u.date)) byDate.set(u.date, []);
|
|
131
|
+
byDate.get(u.date).push(u);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const dates = [...byDate.keys()].sort().reverse(); // 新日期在前
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const d of dates) out.push(`### ${d}`, '', ...byDate.get(d).flatMap((u) => u.lines), '');
|
|
137
|
+
if (undated.length) out.push('### (未标日期)', '', ...undated.flatMap((u) => u.lines), '');
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 幂等:把 todo 全文里平铺的旧 Done 区按日期分组(新日期在前,未标日期归尾)。
|
|
142
|
+
* 已是分组态(### date)则原样返回——惰性迁移用,避免每次读都动文件。 */
|
|
143
|
+
export function groupDoneSection(text) {
|
|
144
|
+
const lines = String(text || '').split('\n');
|
|
145
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
146
|
+
if (di === -1) return text;
|
|
147
|
+
const body = lines.slice(di + 1);
|
|
148
|
+
const first = body.find((l) => l.trim());
|
|
149
|
+
if (first && /^### /.test(first.trim())) return text; // 已分组,幂等不动
|
|
150
|
+
const units = parseDoneUnits(body);
|
|
151
|
+
if (!units.length) return text;
|
|
152
|
+
return [...lines.slice(0, di + 1), ...renderDoneGroups(units)].join('\n').replace(/\n+$/, '\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 把 moved 单元(已完成任务行+附属行)放入 Done 区并整体按日期分组重建。
|
|
156
|
+
* 供 markDone 用,锁内一次完成「归位 + 分组」。无 Done 区则文件尾补建。 */
|
|
157
|
+
export function insertDoneGrouped(text, movedLines) {
|
|
158
|
+
const date = doneDateOf(movedLines[0]) || today();
|
|
159
|
+
const lines = String(text || '').split('\n');
|
|
160
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
161
|
+
const newUnit = { date, lines: movedLines };
|
|
162
|
+
if (di === -1) {
|
|
163
|
+
const header = '## Done(只留近期,旧的迁 log.md/快照)';
|
|
164
|
+
return [...lines, '', header, '', ...renderDoneGroups([newUnit])].join('\n').replace(/\n+$/, '\n');
|
|
165
|
+
}
|
|
166
|
+
const head = lines.slice(0, di + 1);
|
|
167
|
+
const body = lines.slice(di + 1);
|
|
168
|
+
// 新完成单元置前:同日期组内新在最上(renderDoneGroups 按 encounter 顺序保持,日期再倒序排)
|
|
169
|
+
const units = [newUnit, ...parseDoneUnits(body)];
|
|
170
|
+
return [...head, ...renderDoneGroups(units)].join('\n').replace(/\n+$/, '\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 占位/空任务行(老模板的 "(无)"/"无"/纯 - [ ])不迁移
|
|
174
|
+
function isPlaceholder(line) {
|
|
175
|
+
const t = line.trim();
|
|
176
|
+
if (!/^- \[[ x]\]/.test(t)) return false; // 只判任务行
|
|
177
|
+
const body = t.replace(/^- \[[ x]\]\s*/, '').replace(/\(认领[^)]*\)/g, '').trim();
|
|
178
|
+
return !body || /^(无|(无)|None|null)$/.test(body);
|
|
179
|
+
}
|
|
180
|
+
/** 在指定分区段落后插入一行任务;找不到分区则在文件末尾追加回退。写前惰性迁移老格式。 */
|
|
181
|
+
export async function addTask(brainRoot, { section, text }) {
|
|
182
|
+
const p = await ensureTodo(brainRoot);
|
|
183
|
+
await editFile(p, (orig) => ({ text: insertTask(orig, section, text) }));
|
|
184
|
+
return { file: p, text };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 纯函数:把任务行插到分区标题后。供 editFile mutator 复用(upsert 也用它)。 */
|
|
188
|
+
function insertTask(orig, section, text) {
|
|
189
|
+
const base = normalizeTodo(orig);
|
|
190
|
+
const lines = base.split('\n');
|
|
191
|
+
const header = `## ${section}`;
|
|
192
|
+
let idx = lines.findIndex((l) => l.startsWith(header));
|
|
193
|
+
if (idx === -1) {
|
|
194
|
+
lines.push('', header, `- [ ] ${text}`);
|
|
195
|
+
} else {
|
|
196
|
+
// 在该分区标题后、下一个分区标题前插入
|
|
197
|
+
let insertAt = idx + 1;
|
|
198
|
+
while (insertAt < lines.length && !lines[insertAt].startsWith('## ')) insertAt++;
|
|
199
|
+
lines.splice(insertAt, 0, `- [ ] ${text}`);
|
|
200
|
+
}
|
|
201
|
+
return lines.join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 任务行解析:`- [ ] <id> — note (认领 date)` / 附属断点行 `↳ 断点:`。 */
|
|
205
|
+
export function parseTaskLine(line) {
|
|
206
|
+
const m = line.match(/^- \[( |x)\] (.*?)(?: — (.*?))? \(?(认领|完成 \d{4}-\d{2}-\d{2}[^)]*)?\)?$/);
|
|
207
|
+
return m; // 保守解析;不匹配返回 null(附属行等)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 幂等登记:同 id 已有未完成任务行则原位更新(新 note/新认领日期),否则插入。 */
|
|
211
|
+
export async function upsertTask(brainRoot, { section, text }) {
|
|
212
|
+
const p = await ensureTodo(brainRoot);
|
|
213
|
+
const id = extractId(text);
|
|
214
|
+
const note = extractNote(text);
|
|
215
|
+
const out = await editFile(p, (orig) => {
|
|
216
|
+
const base = normalizeTodo(orig);
|
|
217
|
+
const lines = base.split('\n');
|
|
218
|
+
// 在所有分区中找含该 id 的未完成任务行(Done 的已完成行不重复动)
|
|
219
|
+
const idx = lines.findIndex((l) => l.startsWith('- [ ]') && id && l.includes(id));
|
|
220
|
+
if (idx !== -1) {
|
|
221
|
+
// 原位更新:保留断点附属行,只换任务行本体
|
|
222
|
+
const oldNote = extractNote(lines[idx].replace(/^- \[ \] /, ''));
|
|
223
|
+
const merged = note && oldNote && oldNote.startsWith(note) ? `${note}${oldNote.slice(note.length)}` : note || oldNote;
|
|
224
|
+
const newLine = `- [ ] ${id}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
|
|
225
|
+
lines[idx] = newLine;
|
|
226
|
+
return { text: lines.join('\n'), updated: true };
|
|
227
|
+
}
|
|
228
|
+
return { text: insertTask(base, section, text), updated: false };
|
|
229
|
+
});
|
|
230
|
+
return out.updated
|
|
231
|
+
? { file: p, text, updated: true }
|
|
232
|
+
: { file: p, text, updated: false };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。 */
|
|
236
|
+
export function extractId(text) {
|
|
237
|
+
const m = String(text).match(/^([A-Za-z][\w-]*)\b/);
|
|
238
|
+
return m ? m[1] : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 提取 " — " 后的 note 部分。 */
|
|
242
|
+
export function extractNote(text) {
|
|
243
|
+
const i = String(text).indexOf(' — ');
|
|
244
|
+
return i === -1 ? '' : String(text).slice(i + 3).replace(/\s*\(认领[^)]*\)\s*$/, '');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 定位含 id 的未完成任务行下标;附属断点行(↳ 开头)不算任务行。 */
|
|
248
|
+
export function findTaskLine(lines, id) {
|
|
249
|
+
if (!id) return -1;
|
|
250
|
+
return lines.findIndex((l) => l.startsWith('- [ ]') && l.includes(id));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 实时断点: 在 id 任务行下原位补/换 `↳ 断点:` 附属行(不挪任务位置)。 */
|
|
254
|
+
export async function setBreakpoint(brainRoot, { id, text }) {
|
|
255
|
+
const p = await ensureTodo(brainRoot);
|
|
256
|
+
const bp = ` ↳ 断点: ${text}`;
|
|
257
|
+
const res = await editFile(p, (orig) => {
|
|
258
|
+
const lines = orig.split('\n');
|
|
259
|
+
const idx = findTaskLine(lines, id);
|
|
260
|
+
if (idx === -1) return SKIP;
|
|
261
|
+
if (lines[idx + 1] && lines[idx + 1].trimStart().startsWith('↳ 断点:')) {
|
|
262
|
+
lines[idx + 1] = bp; // 幂等: 更新原附属行
|
|
263
|
+
} else {
|
|
264
|
+
lines.splice(idx + 1, 0, bp);
|
|
265
|
+
}
|
|
266
|
+
return { text: lines.join('\n'), ok: true };
|
|
267
|
+
});
|
|
268
|
+
return res === SKIP
|
|
269
|
+
? { ok: false, msg: `(未找到含 "${id}" 的未完成任务行)` }
|
|
270
|
+
: { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 实时碰壁: 任务行原位勾成 blocked 语义(移入 Blocked 区 + 附原因)。 */
|
|
274
|
+
export async function moveBlocked(brainRoot, { id, reason }) {
|
|
275
|
+
const p = await ensureTodo(brainRoot);
|
|
276
|
+
const res = await editFile(p, (orig) => {
|
|
277
|
+
const lines = orig.split('\n');
|
|
278
|
+
const idx = findTaskLine(lines, id);
|
|
279
|
+
if (idx === -1) return SKIP;
|
|
280
|
+
const taskLine = lines[idx];
|
|
281
|
+
const kept = [];
|
|
282
|
+
const moved = [taskLine];
|
|
283
|
+
for (let i = 0; i < lines.length; i++) {
|
|
284
|
+
if (i === idx) {
|
|
285
|
+
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) moved.push(lines[++i]);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
kept.push(lines[i]);
|
|
289
|
+
}
|
|
290
|
+
const bIdx = kept.findIndex((l) => l.startsWith('## Blocked'));
|
|
291
|
+
if (reason) moved.push(` ↳ 卡点: ${reason}`);
|
|
292
|
+
const out = bIdx === -1
|
|
293
|
+
? [...kept, '## Blocked', ...moved]
|
|
294
|
+
: [...kept.slice(0, bIdx + 1), ...moved, ...kept.slice(bIdx + 1)];
|
|
295
|
+
return { text: out.join('\n'), ok: true };
|
|
296
|
+
});
|
|
297
|
+
return res === SKIP
|
|
298
|
+
? { ok: false, msg: `(未找到含 "${id}" 的未完成任务行)` }
|
|
299
|
+
: { ok: true, msg: `✓ 已标阻塞 → Blocked 区: ${id}${reason ? `\n 卡点: ${reason}` : ''}` };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------- 看板输出 ----------
|
|
303
|
+
export async function boardText(brainRoot, textOverride) {
|
|
304
|
+
const text = textOverride !== undefined ? textOverride : await readTodo(brainRoot);
|
|
305
|
+
const head = `📂 abs → 项目: ${brainRoot}`;
|
|
306
|
+
if (!text.trim()) return `${head}\n\n(todo.md 为空,先 abs task start 登记任务)`;
|
|
307
|
+
return `${head}\n\n${text.trim()}`;
|
|
308
|
+
}
|
package/src/wrapup.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/wrapup.js — 收尾保险:把「某会话结束时 Today 仍滞留的任务」快照到 ~/.abs/log/wrapup.log。
|
|
2
|
+
// 目标: 解决「任务做完了但没标 done 进 Done」的间歇性。根因是 done 判定只靠 agent 自觉,
|
|
3
|
+
// Stop 若来不及/没意识到就滞留。这里不做判定(不替 agent 判断完成与否), 只做两件事:
|
|
4
|
+
// A) 机械快照(agent_end/Stop 触发 abs wrapup): 会话暂停时把当前未完成任务落盘一份结构清单;
|
|
5
|
+
// B) 开场对账(abs load 读取): 下会话 load 时把「上会话滞留、且当前仍未 done」的任务顶出来,
|
|
6
|
+
// 让收尾成为开场的默认动作而非自愿的日志行。
|
|
7
|
+
// wrapup.log 是全局技术日志(~/.abs/log/), 跨项目共用, 故每块带 proj=<root> 归属。
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { brainPath } from './index.js';
|
|
12
|
+
import { readTodo, localStamp } from './todo.js';
|
|
13
|
+
|
|
14
|
+
export function wrapupLogPath() {
|
|
15
|
+
return join(process.env.ABS_LOG_DIR || join(homedir(), '.abs', 'log'), 'wrapup.log');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 同项目两次快照的最小间隔(秒)。agent_end 会逐 turn 触发, 无变化时不刷屏。
|
|
19
|
+
export const WRAPUP_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/** 从 todo 文本/快照块提取任务清单。兼容顶层(todo.md `- [ ]`)与缩进(快照 ` - [ ]`)两种行。
|
|
22
|
+
* body = 去掉 `- [ ]` 前缀、`(认领|完成 date)` 标注后的核心文本。bp 去掉 `↳ 断点|卡点: ` 前缀。
|
|
23
|
+
* Done 区(## Done 下)不采。返回 [{ body, bp }],bp 为附属断点数组(或空)。 */
|
|
24
|
+
export function extractOpenTasks(todoText) {
|
|
25
|
+
const lines = String(todoText || '').split('\n');
|
|
26
|
+
const out = [];
|
|
27
|
+
let inDone = false;
|
|
28
|
+
for (let i = 0; i < lines.length; i++) {
|
|
29
|
+
const l = lines[i];
|
|
30
|
+
if (/^##\s+Done/.test(l)) { inDone = true; continue; }
|
|
31
|
+
if (/^##\s+/.test(l)) { inDone = false; continue; }
|
|
32
|
+
if (inDone) continue;
|
|
33
|
+
const m = l.match(/^\s*- \[ \]\s*(.*)$/);
|
|
34
|
+
if (m) {
|
|
35
|
+
const taskBody = m[1]
|
|
36
|
+
.replace(/\s*\(认领[^)]*\)\s*$/, '')
|
|
37
|
+
.replace(/\s*\(完成[^)]*\)\s*$/, '')
|
|
38
|
+
.trim();
|
|
39
|
+
if (taskBody && !isPlaceholderBody(taskBody)) {
|
|
40
|
+
const bp = [];
|
|
41
|
+
while (i + 1 < lines.length && /^\s*↳/.test(lines[i + 1])) {
|
|
42
|
+
i++;
|
|
43
|
+
bp.push(lines[i].trim().replace(/^↳\s*(断点|卡点)?[::]?\s*/, ''));
|
|
44
|
+
}
|
|
45
|
+
out.push({ body: taskBody, bp });
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 模板占位任务行(“待办任务”/无)不算真任务,快照/对账都排除。 */
|
|
54
|
+
function isPlaceholderBody(body) {
|
|
55
|
+
return /^(待办任务|无|(无)|None|null)$/.test(body) || /^待办任务/.test(body);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 序列化一个快照块:首行时间戳+归属,后随未完成任务(主体+断点)。 */
|
|
59
|
+
function blockText(root, tasks) {
|
|
60
|
+
const head = `[${localStamp()}] wrapup proj=${root}`;
|
|
61
|
+
if (!tasks.length) return head; // 无滞留,仍记一行「干净结束」
|
|
62
|
+
const rows = tasks.map((t) => {
|
|
63
|
+
const bps = t.bp.map((b) => ` ↳ 断点: ${b}`);
|
|
64
|
+
return ` - [ ] ${t.body}${bps.length ? '\n' + bps.join('\n') : ''}`;
|
|
65
|
+
});
|
|
66
|
+
return [head, ...rows].join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 从已有 wrapup.log 解析:按 proj 分组返回每个项目最近一次快照。逐行解析,鲁棒于多块/异常行。 */
|
|
70
|
+
export function parseWrapup(text) {
|
|
71
|
+
const byProj = new Map(); // proj -> { stamp, proj, tasks:[{body,bp}] }
|
|
72
|
+
let cur = null;
|
|
73
|
+
let curProj = null;
|
|
74
|
+
for (const line of String(text || '').split('\n')) {
|
|
75
|
+
const h = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\] wrapup proj=(.+?)\s*$/);
|
|
76
|
+
if (h) {
|
|
77
|
+
curProj = h[2].trim();
|
|
78
|
+
cur = { stamp: h[1], proj: curProj, tasks: [] };
|
|
79
|
+
byProj.set(curProj, cur);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!cur) continue;
|
|
83
|
+
const tm = line.match(/^\s*- \[ \]\s*(.*)$/);
|
|
84
|
+
if (tm) {
|
|
85
|
+
const body = tm[1].replace(/\s*\(认领[^)]*\)\s*$/, '').trim();
|
|
86
|
+
if (body && !isPlaceholderBody(body)) {
|
|
87
|
+
cur.tasks.push({ body, bp: [] });
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const bm = line.match(/^\s*↳/);
|
|
92
|
+
if (bm && cur.tasks.length) {
|
|
93
|
+
cur.tasks[cur.tasks.length - 1].bp.push(line.trim().replace(/^↳\s*(断点|卡点)?[::]?\s*/, ''));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return byProj;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** B: 追加一次当前项目的滞留快照(幂等去重:同内容/短间隔内不重复写)。 */
|
|
100
|
+
export async function appendWrapup(root) {
|
|
101
|
+
const p = wrapupLogPath();
|
|
102
|
+
await fs.mkdir(join(p, '..'), { recursive: true });
|
|
103
|
+
const todo = await readTodo(root); // readTodo 幂等迁移,拿到权威内容
|
|
104
|
+
const tasks = extractOpenTasks(todo);
|
|
105
|
+
let prev = '';
|
|
106
|
+
try { prev = await fs.readFile(p, 'utf8'); } catch { /* 尚无文件 */ }
|
|
107
|
+
const snap = parseWrapup(prev);
|
|
108
|
+
const last = snap.get(root);
|
|
109
|
+
if (last) {
|
|
110
|
+
const sameBody = JSON.stringify(last.tasks.map((t) => [t.body, ...t.bp]))
|
|
111
|
+
=== JSON.stringify(tasks.map((t) => [t.body, ...t.bp]));
|
|
112
|
+
const lastMs = Date.parse(last.stamp.replace(' ', 'T'));
|
|
113
|
+
const fresh = !Number.isNaN(lastMs) && Date.now() - lastMs < WRAPUP_MIN_INTERVAL_MS;
|
|
114
|
+
if (sameBody && fresh) {
|
|
115
|
+
return `(同内容已落,跳过): ${root}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const line = blockText(root, tasks) + '\n';
|
|
119
|
+
await fs.appendFile(p, line, 'utf8');
|
|
120
|
+
return tasks.length
|
|
121
|
+
? `✓ 滞留快照已落 wrapup.log (${tasks.length} 项未完成): ${root}`
|
|
122
|
+
: `✓ 干净结束已记 wrapup.log (无滞留): ${root}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A: 读 wrapup.log 里本项目的最近快照,返回其中「当前 todo 仍勾着未完成」的任务(已 done 自动消失)。
|
|
126
|
+
* 用于 load 开场展示滞留,跨 todo 交叉核对 → 无 false callout、自清理、不需 reconcile 标记。 */
|
|
127
|
+
export async function strandedFor(root) {
|
|
128
|
+
const p = wrapupLogPath();
|
|
129
|
+
let text;
|
|
130
|
+
try { text = await fs.readFile(p, 'utf8'); } catch { return []; }
|
|
131
|
+
const snap = parseWrapup(text).get(root);
|
|
132
|
+
if (!snap || !snap.tasks.length) return [];
|
|
133
|
+
const todo = await readTodo(root).catch(() => '');
|
|
134
|
+
const openBodies = new Set(extractOpenTasks(todo).map((t) => t.body));
|
|
135
|
+
// 快照里那些「现在仍开着」的任务才是滞留;已 done(不在 openBodies)的自动剔除
|
|
136
|
+
return snap.tasks.filter((t) => openBodies.has(t.body));
|
|
137
|
+
}
|