@fanchao8609/agent_brain_sync 1.7.8 → 1.8.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 +5 -3
- package/bin/abs.js +24 -34
- package/bin/mcp.js +102 -22
- package/hooks/abs.opencode.ts +60 -8
- package/hooks/abs.pi.ts +74 -13
- package/package.json +1 -1
- package/skill/SKILL.md +87 -20
- package/src/store.js +290 -129
- package/src/todo.js +81 -308
- package/src/wrapup.js +4 -0
package/src/todo.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/todo.js — todo.md 读写。CLI 的核心纯读写层(被 MCP 转接、可被 hook 直接调)。
|
|
2
|
-
//
|
|
2
|
+
// 格式:两区 `## Todo`(未完成,行首 `[进行中|讨论中|滞留中]`)+ `## Done`;半成品附属行 `↳ 断点:`。
|
|
3
3
|
import { promises as fs } from 'node:fs';
|
|
4
4
|
import { brainPath } from './index.js';
|
|
5
5
|
import { editFile, SKIP } from './lock.js';
|
|
@@ -48,32 +48,27 @@ export async function ensureTodo(brainRoot) {
|
|
|
48
48
|
/** 分区名常量(单一真源)。改名时只改这里 —— 之前散在 20+ 处,一改就漏。
|
|
49
49
|
* 旧名(中文)留在 LEGACY_SECTION_RENAMES 作迁移用。 */
|
|
50
50
|
export const SEC = {
|
|
51
|
-
|
|
52
|
-
today: 'Today / In Progress',
|
|
53
|
-
blocked: 'Blocked',
|
|
51
|
+
todo: 'Todo',
|
|
54
52
|
done: 'Done',
|
|
55
|
-
// 分区(话题树总看板):讨论层面的话题状态机,与「要落实的事」(Backlog/Today)分层。
|
|
56
|
-
// 讨论出话题 → 记分区;成熟到要动手 → 才落一行任务。
|
|
57
|
-
// 中文标题「分区」比 Topic 更直白;LEGACY 里把英文 Topic 也归一过来。
|
|
58
|
-
topics: 'Topics',
|
|
59
53
|
archived: 'Archived', // Done 区内部的归档标记区(原 '### 归档')
|
|
60
54
|
undated: 'Undated', // Done 区内部无完成日期的尾组(原 '### (未标日期)')
|
|
61
55
|
};
|
|
62
56
|
|
|
63
57
|
/** 旧名 → 新名。供 `abs init --repair` 一次性迁移(幂等)。
|
|
64
58
|
* 只改匹配整行的标题,不动正文;不做模糊替换(防误改正文里提到的旧名)。 */
|
|
65
|
-
//
|
|
59
|
+
// 分区标题归一(旧名 → 标准名)
|
|
66
60
|
export const LEGACY_SECTION_RENAMES = [
|
|
67
61
|
// H1(文件标题)
|
|
68
62
|
['# 🗂 图谱索引', '# 🗂 Graph Index'],
|
|
69
63
|
['# 🗒 操作日志', '# 🗒 Activity Log'],
|
|
70
64
|
['# 📋 Todo 看板', '# 📋 Todo Board'],
|
|
71
65
|
// ## 分区
|
|
66
|
+
// 四区 → 两区(2026-09-13 用户定:Backlog/Today 常年为空,进行时分区与实际工作流错配)。
|
|
67
|
+
// 未完成的一律归 Todo,状态改由**行首标记**表达(见 TASK_STATES)。
|
|
68
|
+
['## Backlog', '## Todo'],
|
|
69
|
+
['## Today / In Progress', '## Todo'],
|
|
70
|
+
['## Blocked', '## Todo'],
|
|
72
71
|
['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
|
|
73
|
-
['## Topic', '## Topics'],
|
|
74
|
-
['## 话题', '## Topics'],
|
|
75
|
-
['## 话题树', '## Topics'],
|
|
76
|
-
['## 分区', '## Topics'],
|
|
77
72
|
// ### 区内分组标题
|
|
78
73
|
['### 归档', '### Archived'],
|
|
79
74
|
['### (未标日期)', '### Undated'],
|
|
@@ -106,12 +101,18 @@ export function todoTemplate() {
|
|
|
106
101
|
).text;
|
|
107
102
|
}
|
|
108
103
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
// 只有两区(2026-09-13 用户定:精简)。为什么砍掉 Backlog/Today/Blocked:
|
|
105
|
+
// 实测跨 4 个项目,Backlog 与 Today 常年为 **0 条** —— 而 log.md 有 120 条。
|
|
106
|
+
// 根因:AI 的工作方式是「一口气做完」,任务从开始到完成都在同一会话内走完,
|
|
107
|
+
// 中间那个「挂到进行时分区上」的动作既来不及也不需要发生。
|
|
108
|
+
// 而 log 是在**结束时**写的,那个时点真实存在,所以它记满了。
|
|
109
|
+
// 结论:进行时分区是符合直觉但不符合实际工作流的抽象 → 删掉,
|
|
110
|
+
// 未完成的一律进 Todo,状态用**行首标记**表达(不靠分区区分)。
|
|
111
|
+
export const TODO_SECTIONS = ['Todo', 'Done'];
|
|
112
|
+
|
|
113
|
+
/** 任务状态标记(行首,方括号)。替代原 Backlog/Today/Blocked 三区的区分作用。
|
|
114
|
+
* 放在 id **之前**,与 Done 结语的 `【落地】` 形态区分开(那是行尾、结语用)。 */
|
|
115
|
+
export const TASK_STATES = ['进行中', '讨论中', '滞留中'];
|
|
115
116
|
|
|
116
117
|
/** 结构重建(B 档):以标准分区表为准重排整个文件。
|
|
117
118
|
*
|
|
@@ -185,8 +186,11 @@ function trimBlank(arr) {
|
|
|
185
186
|
export function normalizeTodo(text) {
|
|
186
187
|
const lines = text.split('\n');
|
|
187
188
|
const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
// 已是标准两区制 = 有 Todo 且**没有**任何进行时老分区。
|
|
190
|
+
// 坑(2026-09-13):曾只判 has('Todo') 就 return —— 而最老格式里也可能有 `## Todo`
|
|
191
|
+
// (与 `## In Progress` 混用),于是旧文件永不迁移。
|
|
192
|
+
const legacy = ['Backlog', 'Today / In Progress', 'In Progress', 'Blocked'].some(has);
|
|
193
|
+
if (has('Todo') && !legacy) return text;
|
|
190
194
|
const out = ['# 📋 Todo Board'];
|
|
191
195
|
const grab = (name) => {
|
|
192
196
|
const items = [];
|
|
@@ -198,16 +202,50 @@ export function normalizeTodo(text) {
|
|
|
198
202
|
return items;
|
|
199
203
|
};
|
|
200
204
|
const done = grab('Done');
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
// 未完成的一切(不论原来在 Backlog/In Progress/Todo/Blocked)→ 一律进 Todo。
|
|
206
|
+
// 状态信息由行首标记承接:原 Blocked 区的任务补 [滞留中](其余默认 [进行中],写入时补)。
|
|
207
|
+
const open = [];
|
|
208
|
+
for (const name of ['Backlog', 'Today / In Progress', 'In Progress', 'Todo']) {
|
|
209
|
+
open.push(...grab(name).map((l) => ensureStateMark(l, '进行中')));
|
|
210
|
+
}
|
|
211
|
+
open.push(...grab('Blocked').map((l) => ensureStateMark(l, '滞留中')));
|
|
212
|
+
out.push('## Todo', ...open);
|
|
213
|
+
out.push('## Done', ...done);
|
|
208
214
|
return out.join('\n');
|
|
209
215
|
}
|
|
210
216
|
|
|
217
|
+
/** 给任务行补行首状态标记(已有则不覆盖)。`- [ ] id …` → `- [ ] [状态] id …` */
|
|
218
|
+
export function ensureStateMark(line, state) {
|
|
219
|
+
const m = String(line).match(/^(- \[[ x]\] )(\[[^\]]+\]\s+)?(.*)$/);
|
|
220
|
+
if (!m) return line;
|
|
221
|
+
if (m[2] && TASK_STATES.includes(m[2].trim().replace(/[\[\]]/g, ''))) return line;
|
|
222
|
+
return `${m[1]}[${state}] ${m[3]}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 取任务行的状态标记;无标记返回 null。 */
|
|
226
|
+
export function stateOfTaskLine(line) {
|
|
227
|
+
const m = String(line).match(/^- \[[ x]\] \[([^\]]+)\]/);
|
|
228
|
+
return m && TASK_STATES.includes(m[1]) ? m[1] : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 改任务行的状态标记(原地)。未找到或非法状态返回原样。 */
|
|
232
|
+
export function setStateMark(text, id, state) {
|
|
233
|
+
if (!TASK_STATES.includes(state)) return { text, changed: [] };
|
|
234
|
+
const lines = String(text ?? '').split('\n');
|
|
235
|
+
const idx = findTaskLine(lines, id);
|
|
236
|
+
if (idx === -1) return { text, changed: [] };
|
|
237
|
+
const cur = lines[idx];
|
|
238
|
+
if (stateOfTaskLine(cur) === state) return { text, changed: [] };
|
|
239
|
+
const raw = stripStateMark(cur); // 先剥旧标记,再补新的
|
|
240
|
+
lines[idx] = raw.replace(/^(- \[[ x]\] )/, `$1[${state}] `);
|
|
241
|
+
return { text: lines.join('\n'), changed: [`${id} 状态 → ${state}`] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 剥掉行首状态标记。 */
|
|
245
|
+
export function stripStateMark(line) {
|
|
246
|
+
return String(line).replace(/^(- \[[ x]\] )\[[^\]]+\] /, '$1');
|
|
247
|
+
}
|
|
248
|
+
|
|
211
249
|
/** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */
|
|
212
250
|
export function doneDateOf(line) {
|
|
213
251
|
const m = String(line).match(/\(完成\s*(\d{4}-\d{2}-\d{2})[^)]*\)/);
|
|
@@ -497,7 +535,7 @@ export function insertDoneGrouped(text, movedLines) {
|
|
|
497
535
|
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
498
536
|
const newUnit = { date, lines: movedLines };
|
|
499
537
|
if (di === -1) {
|
|
500
|
-
const header =
|
|
538
|
+
const header = `## ${SEC.done}`;
|
|
501
539
|
return [...lines, '', header, '', ...renderDoneGroups([newUnit])].join('\n').replace(/\n+$/, '\n');
|
|
502
540
|
}
|
|
503
541
|
const head = lines.slice(0, di + 1);
|
|
@@ -539,12 +577,6 @@ function insertTask(orig, section, text) {
|
|
|
539
577
|
return lines.join('\n');
|
|
540
578
|
}
|
|
541
579
|
|
|
542
|
-
/** 任务行解析:`- [ ] <id> — note (认领 date)` / 附属断点行 `↳ 断点:`。 */
|
|
543
|
-
export function parseTaskLine(line) {
|
|
544
|
-
const m = line.match(/^- \[( |x)\] (.*?)(?: — (.*?))? \(?(认领|完成 \d{4}-\d{2}-\d{2}[^)]*)?\)?$/);
|
|
545
|
-
return m; // 保守解析;不匹配返回 null(附属行等)
|
|
546
|
-
}
|
|
547
|
-
|
|
548
580
|
/** 幂等登记:同 id 已有未完成任务行则原位更新(新 note/新认领日期),否则插入。 */
|
|
549
581
|
export async function upsertTask(brainRoot, { section, text }) {
|
|
550
582
|
const p = await ensureTodo(brainRoot);
|
|
@@ -555,16 +587,21 @@ export async function upsertTask(brainRoot, { section, text }) {
|
|
|
555
587
|
const lines = base.split('\n');
|
|
556
588
|
// 在所有分区中找该 id 对应的未完成任务行(Done 的已完成行不重复动);全等比对
|
|
557
589
|
const idx = findTaskLine(lines, id);
|
|
590
|
+
// 状态标记:新建用 text 里带的(默认 进行中),更新保留原有的
|
|
591
|
+
const wanted = String(text).match(/^\[([^\]]+)\]/)?.[1];
|
|
592
|
+
const state = idx !== -1
|
|
593
|
+
? (stateOfTaskLine(lines[idx]) || wanted || '进行中')
|
|
594
|
+
: (TASK_STATES.includes(wanted) ? wanted : '进行中');
|
|
558
595
|
if (idx !== -1) {
|
|
559
|
-
// 原位更新:保留断点附属行 +
|
|
560
|
-
const raw = lines[idx].replace(/^- \[ \] /, '');
|
|
596
|
+
// 原位更新:保留断点附属行 + 原@作者 + 原状态,只换任务行本体
|
|
597
|
+
const raw = stripStateMark(lines[idx]).replace(/^- \[ \] /, '');
|
|
561
598
|
const author = extractAuthor(raw);
|
|
562
599
|
const legacy = isLegacyAuthorTag(raw); // 旧行保持旧形态,不静默改写
|
|
563
600
|
const oldNote = extractNote(raw);
|
|
564
601
|
const merged = note && oldNote && oldNote.startsWith(note) ? `${note}${oldNote.slice(note.length)}` : note || oldNote;
|
|
565
602
|
const at = author ? ` ${legacy ? '@' + author : '[['
|
|
566
603
|
+ author + ']]'}` : '';
|
|
567
|
-
const newLine = `- [ ] ${id}${at}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
|
|
604
|
+
const newLine = `- [ ] [${state}] ${id}${at}${merged ? ' — ' + merged : ''} (认领 ${today()})`;
|
|
568
605
|
lines[idx] = newLine;
|
|
569
606
|
return { text: lines.join('\n'), updated: true };
|
|
570
607
|
}
|
|
@@ -577,7 +614,9 @@ export async function upsertTask(brainRoot, { section, text }) {
|
|
|
577
614
|
|
|
578
615
|
/** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。 */
|
|
579
616
|
export function extractId(text) {
|
|
580
|
-
|
|
617
|
+
// 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
|
|
618
|
+
const t = String(text).replace(/^\[[^\]]+\]\s+/, '');
|
|
619
|
+
const m = t.match(/^([A-Za-z][\w-]*)\b/);
|
|
581
620
|
return m ? m[1] : null;
|
|
582
621
|
}
|
|
583
622
|
|
|
@@ -617,7 +656,9 @@ export function isLegacyAuthorTag(text) {
|
|
|
617
656
|
*/
|
|
618
657
|
export function idOfTaskLine(line) {
|
|
619
658
|
if (!line || !line.startsWith('- [ ]')) return null;
|
|
620
|
-
|
|
659
|
+
// 两区制下行首可带状态标记(`- [ ] [进行中] <id> …`)—— 必须先剥掉再取 id,
|
|
660
|
+
// 否则 id 会被读成 "[进行中]",findTaskLine 全等比对永远落空(2026-09-13 实测)。
|
|
661
|
+
const m = line.match(/^- \[ \] (?:\[[^\]]+\] )?(\S+)/);
|
|
621
662
|
return m ? m[1].replace(/\u200b/g, '') : null;
|
|
622
663
|
}
|
|
623
664
|
|
|
@@ -649,35 +690,6 @@ export async function setBreakpoint(brainRoot, { id, text }) {
|
|
|
649
690
|
: { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
|
|
650
691
|
}
|
|
651
692
|
|
|
652
|
-
/** 实时碰壁: 任务行原位勾成 blocked 语义(移入 Blocked 区 + 附原因)。 */
|
|
653
|
-
export async function moveBlocked(brainRoot, { id, reason }) {
|
|
654
|
-
const p = await ensureTodo(brainRoot);
|
|
655
|
-
const res = await editFile(p, (orig) => {
|
|
656
|
-
const lines = orig.split('\n');
|
|
657
|
-
const idx = findTaskLine(lines, id);
|
|
658
|
-
if (idx === -1) return SKIP;
|
|
659
|
-
const taskLine = lines[idx];
|
|
660
|
-
const kept = [];
|
|
661
|
-
const moved = [taskLine];
|
|
662
|
-
for (let i = 0; i < lines.length; i++) {
|
|
663
|
-
if (i === idx) {
|
|
664
|
-
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) moved.push(lines[++i]);
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
kept.push(lines[i]);
|
|
668
|
-
}
|
|
669
|
-
const bIdx = kept.findIndex((l) => l.startsWith('## Blocked'));
|
|
670
|
-
if (reason) moved.push(` ↳ 卡点: ${reason}`);
|
|
671
|
-
const out = bIdx === -1
|
|
672
|
-
? [...kept, '## Blocked', ...moved]
|
|
673
|
-
: [...kept.slice(0, bIdx + 1), ...moved, ...kept.slice(bIdx + 1)];
|
|
674
|
-
return { text: out.join('\n'), ok: true };
|
|
675
|
-
});
|
|
676
|
-
return res === SKIP
|
|
677
|
-
? { ok: false, msg: `(未找到含 "${id}" 的未完成任务行)` }
|
|
678
|
-
: { ok: true, msg: `✓ 已标阻塞 → Blocked 区: ${id}${reason ? `\n 卡点: ${reason}` : ''}` };
|
|
679
|
-
}
|
|
680
|
-
|
|
681
693
|
// ---------- 看板输出 ----------
|
|
682
694
|
export async function boardText(brainRoot, textOverride, { full = false } = {}) {
|
|
683
695
|
const text = textOverride !== undefined ? textOverride : await readTodo(brainRoot);
|
|
@@ -688,242 +700,3 @@ export async function boardText(brainRoot, textOverride, { full = false } = {})
|
|
|
688
700
|
const hint = full ? '' : '\n\n(Done 只给计数;看明细: abs todo --full)';
|
|
689
701
|
return `${head}\n\n${body}${hint}`;
|
|
690
702
|
}
|
|
691
|
-
|
|
692
|
-
// ---------- 分区(话题树) ----------
|
|
693
|
-
// 设计动机(2026-09-13 实测教训):一轮长会话讨论了 14 条话题线,而看板只能反映 1 条
|
|
694
|
-
// (记录率 7%)——因为「已结论/已证伪」的话题不该再占待办位,但也不该消失(否则下个
|
|
695
|
-
// 会话重走死路)。故把「讨论轨道」独立成 `## 分区`:话题带状态,与「执行轨道」
|
|
696
|
-
// (Backlog/Today)分层。讨论出话题 → 记分区;成熟到要动手 → 才落一行任务。
|
|
697
|
-
|
|
698
|
-
/** 话题状态(与执行轨道共享的语义,但用于话题层面)。 */
|
|
699
|
-
export const TOPIC_STATES = ['进行中', '已结论', '已否决', '待验证', '已落地', '未落地'];
|
|
700
|
-
|
|
701
|
-
/** 终止态:这些话题**不该留在 Topics 区**(用户定:只放正在讨论的)。
|
|
702
|
-
* 理由:树的价值是「当前在哪」;死话题会把活话题淹掉。
|
|
703
|
-
* 归档去向:结论写进 sources/(abs note),那里可检索、不挤占 load 首屏。
|
|
704
|
-
*
|
|
705
|
-
* 2026-09-13 修正:`未落地`/`待验证` **也算离开 Topics**。
|
|
706
|
-
* 先前误把它们当“活跃”留在树上 → 结果 #8(方案已定、等客户端配合)/#3.4(等数据)
|
|
707
|
-
* 长期挂在“正在讨论”里变成僵尸。它们的真实语义是**暂停**(等人/等数据),
|
|
708
|
-
* 应落 Backlog/Blocked 当成任务追踪,而不是冒充“当前话题”。 */
|
|
709
|
-
export const TOPIC_CLOSED_STATES = ['已结论', '已否决', '已落地', '待验证', '未落地'];
|
|
710
|
-
|
|
711
|
-
/** 从 Topics 区移除一个话题(含其父指针行)。终止态话题用 —— 树只留活跃话题。 */
|
|
712
|
-
export function removeTopicLine(text, id) {
|
|
713
|
-
const lines = String(text ?? '').split('\n');
|
|
714
|
-
const { at, lines: seg } = topicLines(lines.join('\n'));
|
|
715
|
-
if (at === -1) return text;
|
|
716
|
-
const rel = seg.findIndex((l) => {
|
|
717
|
-
const t = parseTopicLine(l);
|
|
718
|
-
return t && t.id === id;
|
|
719
|
-
});
|
|
720
|
-
if (rel === -1) return text;
|
|
721
|
-
const abs = at + 1 + rel;
|
|
722
|
-
const drop = 1 + (/^\s*└─\s*父:/.test(lines[abs + 1] || '') ? 1 : 0);
|
|
723
|
-
lines.splice(abs, drop);
|
|
724
|
-
return lines.join('\n');
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/** 话题行格式(刻意做得极简、可手写可机器解析):
|
|
728
|
-
* - [ ] #12 [[name]] 减少决策步往返 [进行中] — 结论
|
|
729
|
-
* - [x] #11 [[name]] 任务拆分 [已否决] — 子任务必 miss
|
|
730
|
-
* └─ 父: #9
|
|
731
|
-
* 缩进 = 父子关系(纯缩进即树,不引入新语法);[[name]] = 登记人(与 todo 行同约定)。
|
|
732
|
-
*
|
|
733
|
-
* 解析要点(2026-09-13 踩坑):状态标记是**末尾** `[状态]`,不能用非贪婪匹配 ——
|
|
734
|
-
* 否则标题含 `[` 时会被第一个 `[` 截断。故:先切结论,再从剩余尾部抽状态。 */
|
|
735
|
-
const TOPIC_RE = /^(\s*)- \[( |x)\]\s+(#[\w.]+)\s+(.*)$/;
|
|
736
|
-
const TOPIC_STATE_TAIL = /\s*\[([^\]]+)\]\s*$/;
|
|
737
|
-
const TOPIC_AUTHOR = /^\[\[([^\]]+)\]\]\s*/;
|
|
738
|
-
|
|
739
|
-
/** 解析一行话题。返回 null 表示不是话题行(普通任务行/正文行)。 */
|
|
740
|
-
export function parseTopicLine(line) {
|
|
741
|
-
const m = String(line).match(TOPIC_RE);
|
|
742
|
-
if (!m) return null;
|
|
743
|
-
const [, indent, box, id, rest] = m;
|
|
744
|
-
const body = rest.trim();
|
|
745
|
-
// 顺序很重要(2026-09-13 踩坑):先切结论、再抽状态。
|
|
746
|
-
// 若颠倒,`… [已否决] — 结论` 的末尾是结论而非状态,会抽不到状态。
|
|
747
|
-
// 结论分隔:` — ` / ` -- `(全角/半角破折号、双连字符)
|
|
748
|
-
let head = body;
|
|
749
|
-
let conclusion = '';
|
|
750
|
-
const sep = body.match(/\s(?:—|--)\s/);
|
|
751
|
-
if (sep) {
|
|
752
|
-
head = body.slice(0, sep.index).trim();
|
|
753
|
-
conclusion = body.slice(sep.index + sep[0].length).trim();
|
|
754
|
-
}
|
|
755
|
-
// 抽作者标记(紧跟 id 后的 [[name]])
|
|
756
|
-
let author = null;
|
|
757
|
-
const am = head.match(TOPIC_AUTHOR);
|
|
758
|
-
if (am) {
|
|
759
|
-
author = am[1];
|
|
760
|
-
head = head.slice(am[0].length).trim();
|
|
761
|
-
}
|
|
762
|
-
// 从 head 尾部抽 [状态];只有识别为已知状态名才切除,否则归回标题
|
|
763
|
-
let state = null;
|
|
764
|
-
const st = head.match(TOPIC_STATE_TAIL);
|
|
765
|
-
if (st && TOPIC_STATES.includes(st[1])) {
|
|
766
|
-
state = st[1];
|
|
767
|
-
head = head.slice(0, st.index).trim();
|
|
768
|
-
}
|
|
769
|
-
return {
|
|
770
|
-
indent: indent.length,
|
|
771
|
-
done: box === 'x',
|
|
772
|
-
id,
|
|
773
|
-
author,
|
|
774
|
-
title: head,
|
|
775
|
-
state: state || (box === 'x' ? '已结论' : '进行中'),
|
|
776
|
-
conclusion,
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/** 渲染一行话题(与 parseTopicLine 互逆,便于测试)。
|
|
781
|
-
* 结论用 ` — ` 分隔(与 parseTopicLine 的 sep 规则对称),否则回读时会把结论
|
|
782
|
-
* 误并回标题(先前实测:`#1 … [已否决] 六条…` → 标题被截断、状态被误读)。 */
|
|
783
|
-
export function renderTopicLine(t) {
|
|
784
|
-
const ind = ' '.repeat(t.indent || 0);
|
|
785
|
-
const closed = ['已结论', '已否决', '已落地'].includes(t.state);
|
|
786
|
-
const b = closed ? 'x' : t.done ? 'x' : ' ';
|
|
787
|
-
const who = t.author ? ` ${`[[${t.author}]]`}` : '';
|
|
788
|
-
const head = `${ind}- [${b}] ${t.id}${who} ${t.title} [${t.state}]`;
|
|
789
|
-
return t.conclusion ? `${head} — ${t.conclusion}` : head;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/** 取 `## 分区` 区段的行(不含标题)。 */
|
|
793
|
-
export function topicLines(text) {
|
|
794
|
-
const lines = String(text ?? '').split('\n');
|
|
795
|
-
const at = lines.findIndex((l) => l.trim() === `## ${SEC.topics}`);
|
|
796
|
-
if (at === -1) return { at: -1, lines: [] };
|
|
797
|
-
let end = at + 1;
|
|
798
|
-
while (end < lines.length && !/^## /.test(lines[end])) end++;
|
|
799
|
-
return { at, lines: lines.slice(at + 1, end) };
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/** 幂等登记/更新一个话题。同 id 已存在则原位更新(标题/状态/结论),否则按缩进插入。 */
|
|
803
|
-
export function upsertTopicLine(text, { id, title, state, conclusion, indent = 0, author = null }) {
|
|
804
|
-
const lines = String(text ?? '').split('\n');
|
|
805
|
-
const { at, lines: seg } = topicLines(lines.join('\n'));
|
|
806
|
-
const existing = seg.findIndex((l) => {
|
|
807
|
-
const t = parseTopicLine(l);
|
|
808
|
-
return t && t.id === id;
|
|
809
|
-
});
|
|
810
|
-
// 作者:优先新值,否则沿用已存在的行(“只改状态”不该把登记人抹掉)
|
|
811
|
-
const prev = existing === -1 ? null : parseTopicLine(seg[existing]);
|
|
812
|
-
const rendered = renderTopicLine({
|
|
813
|
-
id, title, state, conclusion, indent: prev ? prev.indent : indent,
|
|
814
|
-
author: author || prev?.author || null,
|
|
815
|
-
});
|
|
816
|
-
if (at === -1) {
|
|
817
|
-
// 无分区 → 补建在**最前**(Topics 是总览,必须先被看到)。
|
|
818
|
-
// 坑(2026-09-13 实测):曾插到 `## Done` 之前 → 反而排在 Backlog/Today 之后,
|
|
819
|
-
// 与「置顶」意图相反。正确位置 = H1/前言之后的第一个分区位。
|
|
820
|
-
let insertAt = 0;
|
|
821
|
-
while (insertAt < lines.length && !/^## /.test(lines[insertAt])) insertAt++;
|
|
822
|
-
lines.splice(insertAt, 0, `## ${SEC.topics}`, rendered, '');
|
|
823
|
-
return { text: lines.join('\n'), changed: ['新增 Topics 区'] };
|
|
824
|
-
}
|
|
825
|
-
if (existing !== -1) {
|
|
826
|
-
const abs = at + 1 + existing;
|
|
827
|
-
if (lines[abs] === rendered) return { text, changed: [] };
|
|
828
|
-
lines[abs] = rendered;
|
|
829
|
-
} else {
|
|
830
|
-
let insertAt = at + 1;
|
|
831
|
-
while (insertAt < lines.length && !/^## /.test(lines[insertAt])) insertAt++;
|
|
832
|
-
// 插到该分区末尾(去掉尾部空行,保持紧凑)
|
|
833
|
-
while (insertAt - 1 > at && !lines[insertAt - 1].trim()) insertAt--;
|
|
834
|
-
lines.splice(insertAt, 0, rendered);
|
|
835
|
-
}
|
|
836
|
-
return { text: lines.join('\n'), changed: [`话题 ${id} → ${state}`] };
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
/** 把话题从 Topics 区**移进**指定分区(默认 Today),保留同一个 id。
|
|
840
|
-
*
|
|
841
|
-
* 模型根基(2026-09-13 用户定):话题有生命周期,Topics 不是终点而是**入口**。
|
|
842
|
-
* 「讨论」与「执行」是**同一件事的两态**,不是两份记录——所以是**移动**(带同 id),
|
|
843
|
-
* 不是复制。这样 `#1.2` 从话题变成任务后,仍然能一路追到 Done。
|
|
844
|
-
*
|
|
845
|
-
* 落单格式用普通任务行(与 todo 同构),以便复用看板/归档/lint 全套;
|
|
846
|
-
* 原话题的**结论**捎带过去(丢了就没上下文)。
|
|
847
|
-
* 返回新文本;话题不存在则原样返回。 */
|
|
848
|
-
export function promoteTopic(text, id, section = SEC.today) {
|
|
849
|
-
const src = String(text ?? '');
|
|
850
|
-
const nodes = topicTree(src);
|
|
851
|
-
const node = nodes.find((n) => n.id === id);
|
|
852
|
-
if (!node) return { text: src, changed: [] };
|
|
853
|
-
let out = removeTopicLine(src, id);
|
|
854
|
-
const lines = out.split('\n');
|
|
855
|
-
const at = lines.findIndex((l) => l.trim() === `## ${section}`);
|
|
856
|
-
if (at === -1) return { text: src, changed: [] }; // 目标分区不存在 → 不动(不猜)
|
|
857
|
-
let end = at + 1;
|
|
858
|
-
while (end < lines.length && !/^## /.test(lines[end])) end++;
|
|
859
|
-
while (end - 1 > at && !lines[end - 1].trim()) end--;
|
|
860
|
-
const who = node.author ? ` [[${node.author}]]` : '';
|
|
861
|
-
const tail = node.conclusion ? ` — ${node.conclusion}` : '';
|
|
862
|
-
lines.splice(end, 0, `- [ ] ${node.id}${who} ${node.title}${tail}`);
|
|
863
|
-
return { text: lines.join('\n'), changed: [`话题 ${id} → ${section}`] };
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
/** 取话题树(扁平列表 + 父指针)。父由 `└─ 父: #x` 行给出;未显式给出时
|
|
867
|
-
* 按 id 的层级默认(`#1.2` 的父是 `#1`)—— 否则 `abs topic new "#1.2 x"` 会白成一个孤根。
|
|
868
|
-
* 显式 `└─ 父:` 优先(允许手工把子话题挂到非 id 前缀的父上)。 */
|
|
869
|
-
export function topicTree(text) {
|
|
870
|
-
const { lines: seg } = topicLines(text);
|
|
871
|
-
const out = [];
|
|
872
|
-
for (let i = 0; i < seg.length; i++) {
|
|
873
|
-
const t = parseTopicLine(seg[i]);
|
|
874
|
-
if (!t) continue;
|
|
875
|
-
const pm = seg[i + 1]?.match(/^\s*└─\s*父:\s*(#[\w.]+)/);
|
|
876
|
-
// 无显式父指针 → 从点分层级推(#a.b.c 的父 = #a.b)
|
|
877
|
-
const dot = t.id.lastIndexOf('.');
|
|
878
|
-
const implied = dot === -1 ? null : t.id.slice(0, dot);
|
|
879
|
-
out.push({ ...t, parent: pm ? pm[1] : implied });
|
|
880
|
-
}
|
|
881
|
-
return out;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/** 此刻正在推进的那条话题(供 load 首屏「当前话题」一行)。
|
|
885
|
-
* 判据:`进行中` 且是**叶子**(没有进行中的子话题)—— 最深的活话题才是当前在说的。
|
|
886
|
-
* 无活话题返回 null。
|
|
887
|
-
*
|
|
888
|
-
* 为什么不打印整棵树(2026-09-13 用户定):整棵树会被 load 反复读取并**带偏后续会话**
|
|
889
|
-
* (跟刚删的 Roadmap 同一个病)。当前态要常驻眼前,全貌用 `abs topic` 主动查。 */
|
|
890
|
-
export function currentTopic(text) {
|
|
891
|
-
const nodes = topicTree(text).filter((n) => n.state === '进行中');
|
|
892
|
-
if (!nodes.length) return null;
|
|
893
|
-
const hasActiveKid = new Set(
|
|
894
|
-
nodes.map((n) => n.parent).filter((p) => p && nodes.some((n) => n.id === p)),
|
|
895
|
-
);
|
|
896
|
-
return nodes.filter((n) => !hasActiveKid.has(n.id)).pop() || null;
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
/** 话题树的文本视图:**只输出话题行**,不写任何解释/统计标题。
|
|
900
|
-
* 理由(用户定):这一段是状态数据,不是文档 —— 正文说明会被 load 反复打印、
|
|
901
|
-
* 挤占上下文,也让人分不清哪些是话题、哪些是说明。注释只留在源码里。
|
|
902
|
-
* 已证伪的用 ✘ 标出并在末尾单列一块(防下个会话重走死路)。 */
|
|
903
|
-
export function topicTreeText(text) {
|
|
904
|
-
const nodes = topicTree(text);
|
|
905
|
-
if (!nodes.length) return '';
|
|
906
|
-
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
907
|
-
const kids = new Map();
|
|
908
|
-
const roots = [];
|
|
909
|
-
for (const n of nodes) {
|
|
910
|
-
if (n.parent && byId.has(n.parent)) {
|
|
911
|
-
if (!kids.has(n.parent)) kids.set(n.parent, []);
|
|
912
|
-
kids.get(n.parent).push(n);
|
|
913
|
-
} else roots.push(n);
|
|
914
|
-
}
|
|
915
|
-
const icon = (s) => ({ 进行中: '▶', 已结论: '✔', 已否决: '✘', 待验证: '?', 已落地: '✓', 未落地: '·' }[s] || '·');
|
|
916
|
-
const out = [];
|
|
917
|
-
const draw = (n, depth) => {
|
|
918
|
-
// 格式与存储行一致(单层分隔,不用双空格夹状态),便于人眼与文件对照
|
|
919
|
-
out.push(`${' '.repeat(depth)}${icon(n.state)} ${n.id} ${n.title} [${n.state}]${n.conclusion ? ' — ' + n.conclusion : ''}`);
|
|
920
|
-
for (const k of kids.get(n.id) || []) draw(k, depth + 1);
|
|
921
|
-
};
|
|
922
|
-
for (const r of roots) draw(r, 0);
|
|
923
|
-
const dead = nodes.filter((n) => n.state === '已否决' && !roots.includes(n) && !kids.has(n.parent));
|
|
924
|
-
if (dead.length) {
|
|
925
|
-
out.push('');
|
|
926
|
-
for (const d of dead) out.push(`✘ ${d.id} ${d.title}${d.conclusion ? ' — ' + d.conclusion : ''}`);
|
|
927
|
-
}
|
|
928
|
-
return out.join('\n');
|
|
929
|
-
}
|
package/src/wrapup.js
CHANGED
|
@@ -51,6 +51,10 @@ export function extractOpenTasks(todoText) {
|
|
|
51
51
|
const m = l.match(/^\s*- \[ \]\s*(.*)$/);
|
|
52
52
|
if (m) {
|
|
53
53
|
const taskBody = m[1]
|
|
54
|
+
// 先剥行首状态标记(`[进行中] <id> …`)—— 它是**易变**的:任务状态一改
|
|
55
|
+
// (abs todo state)body 就变,而 strandedFor 用 body 精确比对 →
|
|
56
|
+
// 跨会话滞留提醒会静默消失(2026-09-13 实测复现)。body 必须状态无关。
|
|
57
|
+
.replace(/^\[[^\]]+\]\s+/, '')
|
|
54
58
|
.replace(/\s*\(认领[^)]*\)\s*$/, '')
|
|
55
59
|
.replace(/\s*\(完成[^)]*\)\s*$/, '')
|
|
56
60
|
.trim();
|