@fanchao8609/agent_brain_sync 1.16.4 → 1.17.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.
- package/README.md +2 -1
- package/bin/abs.js +17 -4
- package/bin/mcp.js +10 -5
- package/hooks/abs.pi.ts +94 -13
- package/package.json +6 -1
- package/skill/abs-agent-brain-sync/SKILL.md +27 -2
- package/src/install.js +5 -9
- package/src/lint.js +14 -1
- package/src/lock.js +1 -1
- package/src/note.js +2 -4
- package/src/page.js +1 -1
- package/src/store.js +65 -34
- package/src/todo/archive.js +320 -0
- package/src/todo/common.js +210 -0
- package/src/todo/spec.js +218 -0
- package/src/todo/structure.js +324 -0
- package/src/todo/tasks.js +238 -0
- package/src/todo/validate.js +216 -0
- package/src/todo.js +10 -1319
- package/src/wrapup.js +1 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// src/todo/structure.js — 结构重排与格式归一(rebuildStructure / enforceBrainFormat)。
|
|
2
|
+
|
|
3
|
+
import { TODO_SECTIONS, H1_TO_FILE, TASK_STATES, RETIRED_SECTIONS } from './spec.js';
|
|
4
|
+
import { checkFileShape, violationSignatures, assertNoStrayBlank, assertTodoContent } from './validate.js';
|
|
5
|
+
import { trimBlank, sigOfLine, isPlaceholder } from './common.js';
|
|
6
|
+
|
|
7
|
+
export function todoTemplate() {
|
|
8
|
+
// 由 rebuildStructure 生成,保证“模板”与“重排结果”逐字节一致
|
|
9
|
+
// (否则 load 会把新建的模板又重排一次 = 无意义的写盘)。
|
|
10
|
+
// 注意(2026-09-13 踩坑):rebuildStructure 的 order 要**带 `## ` 前缀**,
|
|
11
|
+
// 而 TODO_SECTIONS 是裸名(两者用途不同,不能复用 —— 曾误传裸名导致
|
|
12
|
+
// 生成出没有 `##` 的裸标题行,模板直接损坏、Backlog 分区消失)。
|
|
13
|
+
return rebuildStructure(
|
|
14
|
+
['# 📋 Todo Board', ...TODO_SECTIONS.map((s) => `## ${s}`)].join('\n'),
|
|
15
|
+
{ h1: '# 📋 Todo Board', order: TODO_SECTIONS.map((s) => `## ${s}`) },
|
|
16
|
+
).text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 只有两区(2026-09-13 用户定:精简)。为什么砍掉 Backlog/Today/Blocked:
|
|
20
|
+
// 实测跨 4 个项目,Backlog 与 Today 常年为 **0 条** —— 而 log.md 有 120 条。
|
|
21
|
+
// 根因:AI 的工作方式是「一口气做完」,任务从开始到完成都在同一会话内走完,
|
|
22
|
+
// 中间那个「挂到进行时分区上」的动作既来不及也不需要发生。
|
|
23
|
+
// 而 log 是在**结束时**写的,那个时点真实存在,所以它记满了。
|
|
24
|
+
// 结论:进行时分区是符合直觉但不符合实际工作流的抽象 → 删掉,
|
|
25
|
+
// 未完成的一律进 Todo,状态用**行首标记**表达(不靠分区区分)。
|
|
26
|
+
|
|
27
|
+
export function rebuildStructure(text, spec) {
|
|
28
|
+
const s = String(text ?? '');
|
|
29
|
+
const lines = s.split('\n');
|
|
30
|
+
const h1At = lines.findIndex((l) => l.trim().startsWith('# '));
|
|
31
|
+
const bodyStart = h1At === -1 ? 0 : h1At + 1;
|
|
32
|
+
const bucket = new Map(); // 标准分区名 -> 内容行
|
|
33
|
+
const extras = []; // [{ title, lines }] 非标准分区,原样保留到末尾
|
|
34
|
+
const preamble = []; // H1 与第一个 ## 之间的自由正文
|
|
35
|
+
let curStd = null; // 当前在的标准分区名(null = 前言)
|
|
36
|
+
let curExtra = null; // 当前在的额外分区对象
|
|
37
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
38
|
+
const l = lines[i];
|
|
39
|
+
if (/^#{2,3}\s/.test(l)) {
|
|
40
|
+
const t = l.trim();
|
|
41
|
+
const renamed = (spec.renames?.find(([o]) => o === t) || [, t])[1];
|
|
42
|
+
if (spec.order.includes(renamed)) {
|
|
43
|
+
curStd = renamed;
|
|
44
|
+
curExtra = null;
|
|
45
|
+
if (!bucket.has(curStd)) bucket.set(curStd, []);
|
|
46
|
+
} else {
|
|
47
|
+
curExtra = { title: renamed, lines: [] };
|
|
48
|
+
extras.push(curExtra);
|
|
49
|
+
curStd = null;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (curExtra) curExtra.lines.push(l);
|
|
54
|
+
else if (curStd) bucket.get(curStd).push(l);
|
|
55
|
+
else preamble.push(l);
|
|
56
|
+
}
|
|
57
|
+
const out = [spec.h1];
|
|
58
|
+
const pre = trimBlank(preamble);
|
|
59
|
+
if (pre.length) out.push('', ...pre);
|
|
60
|
+
// 每个分区标题前恒有一个空行(含空分区)。
|
|
61
|
+
// 坑(2026-10-06 定位):旧写法对空分区走 `else out.push(name)` —— 不补空行,
|
|
62
|
+
// 于是「有内容分区 → 空分区」的接缝被挤成 `- [[页]] …` 紧贴 `## Sources`。
|
|
63
|
+
// 表现成「index 里那个空行反复丢」:人工补回 → 下次任意写操作(过 enforceBrainFormat)
|
|
64
|
+
// 又归一掉,来回拉锯(本机 corp_agent 实测,17961e5 补、f3f7e84 又丢)。
|
|
65
|
+
// 原注释担心「每次首跑都写盘一次」——实测不会:归一后自身就是稳定形态,
|
|
66
|
+
// 第二次跑 fixed 为空、零字节改动(幂等,见 test/todo 的自检)。
|
|
67
|
+
// H1 后必须恒有空行:`# H1` 与首个分区相贴是门面事故,不容忍。
|
|
68
|
+
for (const name of spec.order) {
|
|
69
|
+
const body = trimBlank(bucket.get(name) || []);
|
|
70
|
+
out.push('', name, ...body);
|
|
71
|
+
}
|
|
72
|
+
for (const e of extras) {
|
|
73
|
+
const body = trimBlank(e.lines);
|
|
74
|
+
out.push('', e.title);
|
|
75
|
+
if (body.length) out.push(...body);
|
|
76
|
+
}
|
|
77
|
+
const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
|
|
78
|
+
return { text: next, changed: next === s ? [] : ['结构按标准重排'] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 去掉首尾空行,不动中间(保留用户的分段)。 */
|
|
82
|
+
|
|
83
|
+
const isLeafEntry = (l) => /^- |^#{1,3}\s+\[/.test(l);
|
|
84
|
+
|
|
85
|
+
/** 已废弃的标准分区(2026-10-05 用户定,白名单只一条)。
|
|
86
|
+
* `## Roadmap` 是 2026-09-13 用户亲手删的分区(`store.js` 注释写明原因:
|
|
87
|
+
* “AI 自己写的方向总结,会被 load 反复读到并带偏后续会话”)。
|
|
88
|
+
* 但 `rebuildStructure` 的规矩 3 是「非标准分区原样保留在末尾」——那条护栏是为了
|
|
89
|
+
* 防丢失人自加的区(如 `## 备忘`),机器不猜语义。结果是 Roadmap 被当成“人自加的区”
|
|
90
|
+
* 留了下来,而且 AI 每次重写 index 都能把它加回来:删一个分区的决策根本没生效。
|
|
91
|
+
* 所以这里单列一张白名单,只放**被正式删过的标准分区**——它们进闸门就被整段丢弃;
|
|
92
|
+
* 从没见过的(`## 备忘` 类)仍按护栏原样保留。不做通用机制,出现第二个再添。 */
|
|
93
|
+
|
|
94
|
+
export function dropRetiredSections(text) {
|
|
95
|
+
const lines = String(text ?? '').split('\n');
|
|
96
|
+
const removed = [];
|
|
97
|
+
const out = [];
|
|
98
|
+
let dropping = false;
|
|
99
|
+
let dropLevel = 0;
|
|
100
|
+
for (const l of lines) {
|
|
101
|
+
const m = l.match(/^(#{2,3})\s+(.*)$/);
|
|
102
|
+
if (m) {
|
|
103
|
+
const title = `${m[1]} ${m[2].trim()}`;
|
|
104
|
+
if (RETIRED_SECTIONS.includes(title)) {
|
|
105
|
+
removed.push(title);
|
|
106
|
+
dropping = true;
|
|
107
|
+
dropLevel = m[1].length;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
// 遇到同级或更高级的标题 = 废弃区结束
|
|
111
|
+
if (dropping && m[1].length <= dropLevel) dropping = false;
|
|
112
|
+
}
|
|
113
|
+
if (!dropping) out.push(l);
|
|
114
|
+
}
|
|
115
|
+
return { text: out.join('\n'), removed };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 写入侧格式闸门:三个文件在**每次写盘前**都过这里,不是只在 load 时修。
|
|
119
|
+
*
|
|
120
|
+
* 规则(用户 2026-10-05 定):
|
|
121
|
+
* 1. 固定样式 — 标题/分区名必须逐字对标准,不符按 LEGACY_MARKS 归一;
|
|
122
|
+
* 2. 不允许空行 — 条目之间不留空行(正文段落内的空行保留);
|
|
123
|
+
* 3. 结构固定 — 无 H1 的「无头文件」补回标准 H1,分区按标准顺序重排(rebuildStructure)。
|
|
124
|
+
*
|
|
125
|
+
* 入参 spec 与 checkBrainShape 的 BRAIN_SHAPE 同源(见 store.js)。
|
|
126
|
+
* log.md 无分区(order 为空)→ 只做 H1/归一/去空行,不重排条目顺序(时间倒序自带语义)。
|
|
127
|
+
* 返回 { text, fixed:[描述] };fixed 为空 = 无需改盘。 */
|
|
128
|
+
// ---------- 条目级闸门(2026-10-05 加)----------
|
|
129
|
+
// 为什么加:闸门此前只管**骨架形状**(分区/H1/空行),不管条目**内容**。
|
|
130
|
+
// 于是 AI 把 todo 当笔记本 —— 实施报告、测试数据、需求清单、架构分析全塞进
|
|
131
|
+
// 断点行,文件涨到 232 行/上万字,而闸门与 lint 双双报「0 问题」。
|
|
132
|
+
// 这里补上内容约束:todo 只能放 todo。长内容走 abs note(经验)/ abs log(流水)/ sources 页。
|
|
133
|
+
//
|
|
134
|
+
// 注意:**超长一律抛错,不静默截断**。截断会丢数据且无声 —— 正是要防的那种失效。
|
|
135
|
+
|
|
136
|
+
function stripStrayBlanks(body, fixed) {
|
|
137
|
+
const lines = String(body).split('\n');
|
|
138
|
+
const kept = [];
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
const l = lines[i];
|
|
141
|
+
if (!l.trim()) {
|
|
142
|
+
const prev = kept[kept.length - 1];
|
|
143
|
+
// 前瞻要跳过连续空行,否则「两个空行」里只有第一个被删(两个空行是常见形态,
|
|
144
|
+
// 首版留下一个 → 规则形同虚设)。
|
|
145
|
+
let j = i + 1;
|
|
146
|
+
while (j < lines.length && !lines[j].trim()) j++;
|
|
147
|
+
const next = lines[j];
|
|
148
|
+
if (prev !== undefined && next !== undefined && isLeafEntry(prev) && isLeafEntry(next)) {
|
|
149
|
+
fixed.push('删除条目之间的空行');
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
kept.push(l);
|
|
154
|
+
}
|
|
155
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function enforceBrainFormat(text, spec, prev) {
|
|
159
|
+
const src = String(text ?? '');
|
|
160
|
+
if (!src.trim()) return { text: src, fixed: [] };
|
|
161
|
+
// 内容闸门:形状归一之前先拒掉乱塞(超长断点/报告体),否则下面的 rebuild
|
|
162
|
+
// 会把它当「人自加的正文」好好保留下来 —— 越规整越难发现。
|
|
163
|
+
// ★ 存量宽容(2026-10-05 本机实测踩到):只拒「本次写入新引入」的违规;
|
|
164
|
+
// 写入前就存在的违规放行(由 lint 报出)。
|
|
165
|
+
// 为什么:不做这个区分,库里只要有一条历史脏行,**所有写入全部失败** ——
|
|
166
|
+
// 实测真库(corp_agent)就因一条超长断点 + 8 条 `[]()` 条目,
|
|
167
|
+
// 连 `abs rule add` 都做不到,整个图谱变成只读。
|
|
168
|
+
const prevSig = prev == null ? null : violationSignatures(String(prev));
|
|
169
|
+
if (spec?.h1 === '# 📋 Todo Board') assertTodoContent(src, prevSig);
|
|
170
|
+
// ★ 标签白名单闸门放在**旧标记归一之后**(见下面 legacy 段之后)——
|
|
171
|
+
// 旧标签(`## Backlog` / `## Today / In Progress`)是**待迁移**的,不是非法新标签。
|
|
172
|
+
// 首版把校验放在归一之前,把存量老文件全卡死(7 个测试红,2026-10-05 实测)。
|
|
173
|
+
const fname = H1_TO_FILE[spec?.h1];
|
|
174
|
+
let checked = false;
|
|
175
|
+
const assertShape = (t) => {
|
|
176
|
+
if (!fname || checked) return;
|
|
177
|
+
checked = true;
|
|
178
|
+
// ★ 空行:直接拒绝(用户 2026-10-05:「绝对不允许乱空行」)。
|
|
179
|
+
// 存量库里本来就有空行的,在**写入前**先被归一清除(见 enforceBrainFormat 尾部),
|
|
180
|
+
// 所以走到这里仍有空行 = 本次写入引入的 → 拒。
|
|
181
|
+
assertNoStrayBlank(t, fname);
|
|
182
|
+
// ★ 标签/条目形状:存量脏行放行(不因历史数据锁死库),新增的拒。
|
|
183
|
+
// 为什么这类要宽容而空行不要:超长断点/`[]()` 条目**没有安全的自动修法**
|
|
184
|
+
// (截断会丢数据、改格式会改语义),只能报给人看;而空行删了不丢任何信息。
|
|
185
|
+
const tLines = t.split('\n');
|
|
186
|
+
const bad = checkFileShape(fname, t).filter(
|
|
187
|
+
(it) => it.code !== 'STRAY-BLANK-LINE' &&
|
|
188
|
+
!(prevSig !== null && prevSig.has(sigOfLine(tLines[it.line - 1] || ''))),
|
|
189
|
+
);
|
|
190
|
+
if (bad.length) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`✗ ${fname} 格式不合规(写入被拒):\n` +
|
|
193
|
+
bad.map((it) => ` · ${it.msg}`).join('\n') +
|
|
194
|
+
`\n 出路:内容归入现有标签,或走对应入口(页 → concepts/ 等子目录 + index 登记;流水 → abs log;经验 → abs note)。`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const fixed = [];
|
|
199
|
+
// 旧标记 → 标准标记(H1 与分区/分组标题)。与 store.js 的 LEGACY_MARKS 同源,
|
|
200
|
+
// 由调用方通过 spec.renames 注入(todo.js 不反向依赖 store.js)。
|
|
201
|
+
|
|
202
|
+
// (0) 废弃分区:被正式删过的标准分区(如 Roadmap)整段丢弃,不等 rebuildStructure
|
|
203
|
+
// 把它当“人自加的区”留到末尾 —— 否则删分区的决策每次都被 AI 重写覆盖回去。
|
|
204
|
+
const retired = dropRetiredSections(src);
|
|
205
|
+
if (retired.removed.length) {
|
|
206
|
+
fixed.push(`删除已废弃分区: ${retired.removed.join(', ')}`);
|
|
207
|
+
}
|
|
208
|
+
// (1) 样式:旧标题名先归一到标准(LOG 无分区、不走 rebuildStructure,这条是它的唯一归一者)。
|
|
209
|
+
let body = retired.text;
|
|
210
|
+
const renames = spec.renames || [];
|
|
211
|
+
if (renames.length) {
|
|
212
|
+
const ls = body.split('\n');
|
|
213
|
+
for (let i = 0; i < ls.length; i++) {
|
|
214
|
+
const t = ls[i].trim();
|
|
215
|
+
const hit = renames.find(([o]) => o === t);
|
|
216
|
+
if (hit && ls[i] !== hit[1]) { ls[i] = hit[1]; fixed.push(`${hit[0]} → ${hit[1]}`); }
|
|
217
|
+
}
|
|
218
|
+
body = ls.join('\n');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ★ 旧格式自动升级(2026-10-05):闸门不能只会拒,还得会**修**。
|
|
222
|
+
// 用户要求「旧版本旧样式要能自动更新为新样式」。
|
|
223
|
+
// 这里把已知的旧形态归一为新形态,**归一之后再校验白名单** ——
|
|
224
|
+
// 否则存量老文件(无状态标记的裸任务行)会被当成违规而卡死(实测:
|
|
225
|
+
// 底层 addTask/upsertTask 传的就是裸文本,它们是内部 API,不是 AI 入口)。
|
|
226
|
+
if (fname === 'todo.md') {
|
|
227
|
+
const ls = body.split('\n');
|
|
228
|
+
for (let i = 0; i < ls.length; i++) {
|
|
229
|
+
// 裸任务行(`- [ ] id …` 无状态标记)→ 补默认「进行中」
|
|
230
|
+
if (/^- \[ \] /.test(ls[i]) && !/^- \[ \] \[[^\]]+\]/.test(ls[i])) {
|
|
231
|
+
const fixed2 = ensureStateMark(ls[i], '进行中');
|
|
232
|
+
if (fixed2 !== ls[i]) { ls[i] = fixed2; fixed.push('补回缺失的状态标记'); }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
body = ls.join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ★ 存量空行先清掉(2026-10-05):必须跑在 assertShape **之前** ——
|
|
239
|
+
// 否则 assertShape 会看到存量空行而拒绝写入(实测把整个脏库锁成只读)。
|
|
240
|
+
// 删空行不丢任何信息,所以这里直接修,而不是报错。
|
|
241
|
+
body = stripStrayBlanks(body, fixed);
|
|
242
|
+
|
|
243
|
+
// ★ 白名单闸门在这里跑:旧格式已升级、旧标记已归一、存量空行已清,
|
|
244
|
+
// 剩下的违规才是真的本次引入。
|
|
245
|
+
assertShape(body);
|
|
246
|
+
|
|
247
|
+
// (3) 结构:先补 H1,再按标准重排分区。
|
|
248
|
+
const hasH1 = body.split('\n').some((l) => l.trim().startsWith('# '));
|
|
249
|
+
if (!hasH1) {
|
|
250
|
+
body = [spec.h1, '', body.replace(/^\n+/, '')].join('\n');
|
|
251
|
+
fixed.push(`补回缺失的 H1: ${spec.h1}`);
|
|
252
|
+
}
|
|
253
|
+
if (spec.order?.length) {
|
|
254
|
+
const r = rebuildStructure(normalizeTodoIf(body, spec), spec);
|
|
255
|
+
if (r.changed.length) fixed.push(...r.changed);
|
|
256
|
+
body = r.text;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// (2) 去条目间空行:同 stripStrayBlanks,但重建后再跑一次(重排会新建邻接关系)。
|
|
260
|
+
const out = stripStrayBlanks(body, fixed);
|
|
261
|
+
return { text: out, fixed };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** todo.md 在重排前必须先走 normalizeTodo(知道旧的 Blocked → 滞留中 语义),
|
|
265
|
+
* 其余文件原样进(rebuildStructure 自带旧分区名归一)。 */
|
|
266
|
+
|
|
267
|
+
function normalizeTodoIf(body, spec) {
|
|
268
|
+
return spec.h1 === '# 📋 Todo Board' && spec.order?.[0] === '## Todo' ? normalizeTodo(body) : body;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function normalizeTodo(text) {
|
|
272
|
+
const lines = text.split('\n');
|
|
273
|
+
const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
|
|
274
|
+
// 已是标准两区制 = 有 Todo 且**没有**任何进行时老分区。
|
|
275
|
+
// 坑(2026-09-13):曾只判 has('Todo') 就 return —— 而最老格式里也可能有 `## Todo`
|
|
276
|
+
// (与 `## In Progress` 混用),于是旧文件永不迁移。
|
|
277
|
+
const legacy = ['Backlog', 'Today / In Progress', 'In Progress', 'Blocked'].some(has);
|
|
278
|
+
if (has('Todo') && !legacy) return text;
|
|
279
|
+
const out = ['# 📋 Todo Board'];
|
|
280
|
+
const grab = (name) => {
|
|
281
|
+
const items = [];
|
|
282
|
+
let inSec = false;
|
|
283
|
+
for (const l of lines) {
|
|
284
|
+
if (l.startsWith('## ')) { inSec = l.trim() === `## ${name}`; continue; }
|
|
285
|
+
if (inSec && l.trim() && !isPlaceholder(l)) items.push(l);
|
|
286
|
+
}
|
|
287
|
+
return items;
|
|
288
|
+
};
|
|
289
|
+
const done = grab('Done');
|
|
290
|
+
// 未完成的一切(不论原来在 Backlog/In Progress/Todo/Blocked)→ 一律进 Todo。
|
|
291
|
+
// 状态信息由行首标记承接:原 Blocked 区的任务补 [滞留中](其余默认 [进行中],写入时补)。
|
|
292
|
+
const open = [];
|
|
293
|
+
for (const name of ['Backlog', 'Today / In Progress', 'In Progress', 'Todo']) {
|
|
294
|
+
open.push(...grab(name).map((l) => ensureStateMark(l, '进行中')));
|
|
295
|
+
}
|
|
296
|
+
open.push(...grab('Blocked').map((l) => ensureStateMark(l, '滞留中')));
|
|
297
|
+
out.push('## Todo', ...open);
|
|
298
|
+
out.push('## Done', ...done);
|
|
299
|
+
return out.join('\n');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 给任务行补行首状态标记(已有则不覆盖)。`- [ ] id …` → `- [ ] [状态] id …` */
|
|
303
|
+
|
|
304
|
+
export function ensureStateMark(line, state) {
|
|
305
|
+
const m = String(line).match(/^(- \[[ x]\] )(\[[^\]]+\]\s+)?(.*)$/);
|
|
306
|
+
if (!m) return line;
|
|
307
|
+
if (m[2] && TASK_STATES.includes(m[2].trim().replace(/[\x5B\x5D]/g, ''))) return line;
|
|
308
|
+
return `${m[1]}[${state}] ${m[3]}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 取任务行的状态标记;无标记返回 null。 */
|
|
312
|
+
|
|
313
|
+
export function stateOfTaskLine(line) {
|
|
314
|
+
const m = String(line).match(/^- \[[ x]\] \[([^\]]+)\]/);
|
|
315
|
+
return m && TASK_STATES.includes(m[1]) ? m[1] : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 改任务行的状态标记(原地)。未找到或非法状态返回原样。 */
|
|
319
|
+
|
|
320
|
+
export function stripStateMark(line) {
|
|
321
|
+
return String(line).replace(/^(- \[[ x]\] )\[[^\]]+\] /, '$1');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 从已完成任务行提取 `(完成 YYYY-MM-DD)` 日期;无则返回 ''。兼容中英文括号。 */
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// src/todo/tasks.js — 任务读写入口:读、加、改状态、断点、看板。
|
|
2
|
+
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { brainPath } from '../index.js';
|
|
6
|
+
import { editFile, SKIP } from '../lock.js';
|
|
7
|
+
import { today, localStamp, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf } from './common.js';
|
|
8
|
+
import { todoTemplate, normalizeTodo, stateOfTaskLine, stripStateMark } from './structure.js';
|
|
9
|
+
import { TASK_STATES } from './spec.js';
|
|
10
|
+
import { collapseDone, groupDoneSection } from './archive.js';
|
|
11
|
+
|
|
12
|
+
export async function readTodo(brainRoot) {
|
|
13
|
+
const p = brainPath(brainRoot, 'todo.md');
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = await fs.readFile(p, 'utf8');
|
|
17
|
+
} catch {
|
|
18
|
+
return ''; // 尚未创建,按空处理(不在此创建,避免读操作写文件)
|
|
19
|
+
}
|
|
20
|
+
const norm = normalizeTodo(text);
|
|
21
|
+
const grouped = groupDoneSection(norm); // 平铺旧 Done → 按日期分组(幂等)
|
|
22
|
+
if (grouped !== text) {
|
|
23
|
+
// 惰性迁移也是写:走锁,避免与并发写互相覆盖(锁内 re-read 已是权威最新内容)
|
|
24
|
+
await editFile(p, (cur) => (cur === text ? { text: grouped } : SKIP));
|
|
25
|
+
return grouped;
|
|
26
|
+
}
|
|
27
|
+
return text;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function ensureTodo(brainRoot) {
|
|
31
|
+
const p = brainPath(brainRoot, 'todo.md');
|
|
32
|
+
// 缺失才建模板;经锁写盘保证原子性(避免与并发 editFile 读到半写内容)
|
|
33
|
+
await editFile(p, (cur) => (cur === null ? { text: todoTemplate() } : SKIP));
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 分区名常量(单一真源)。改名时只改这里 —— 之前散在 20+ 处,一改就漏。
|
|
38
|
+
* 旧名(中文)留在 LEGACY_SECTION_RENAMES 作迁移用。 */
|
|
39
|
+
|
|
40
|
+
export function setStateMark(text, id, state) {
|
|
41
|
+
if (!TASK_STATES.includes(state)) return { text, changed: [] };
|
|
42
|
+
const lines = String(text ?? '').split('\n');
|
|
43
|
+
const idx = findTaskLine(lines, id);
|
|
44
|
+
if (idx === -1) return { text, changed: [] };
|
|
45
|
+
const cur = lines[idx];
|
|
46
|
+
if (stateOfTaskLine(cur) === state) return { text, changed: [] };
|
|
47
|
+
const raw = stripStateMark(cur); // 先剥旧标记,再补新的
|
|
48
|
+
lines[idx] = raw.replace(/^(- \[[ x]\] )/, `$1[${state}] `);
|
|
49
|
+
return { text: lines.join('\n'), changed: [`${id} 状态 → ${state}`] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 剥掉行首状态标记。 */
|
|
53
|
+
|
|
54
|
+
export async function addTask(brainRoot, { section, text }) {
|
|
55
|
+
const p = await ensureTodo(brainRoot);
|
|
56
|
+
await editFile(p, (orig) => ({ text: insertTask(orig, section, text) }));
|
|
57
|
+
return { file: p, text };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 纯函数:把任务行插到分区标题后。供 editFile mutator 复用(upsert 也用它)。 */
|
|
61
|
+
|
|
62
|
+
function insertTask(orig, section, text) {
|
|
63
|
+
const base = normalizeTodo(orig);
|
|
64
|
+
const lines = base.split('\n');
|
|
65
|
+
const header = `## ${section}`;
|
|
66
|
+
const idx = lines.findIndex((l) => l.startsWith(header));
|
|
67
|
+
if (idx === -1) {
|
|
68
|
+
lines.push('', header, `- [ ] ${text}`);
|
|
69
|
+
} else {
|
|
70
|
+
// 在该分区标题后、下一个分区标题前插入
|
|
71
|
+
let insertAt = idx + 1;
|
|
72
|
+
while (insertAt < lines.length && !lines[insertAt].startsWith('## ')) insertAt++;
|
|
73
|
+
lines.splice(insertAt, 0, `- [ ] ${text}`);
|
|
74
|
+
}
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 幂等登记:同 id 已有未完成任务行则原位更新(新 note/新认领日期),否则插入。 */
|
|
79
|
+
|
|
80
|
+
export async function upsertTask(brainRoot, { section, text, session = '' }) {
|
|
81
|
+
const p = await ensureTodo(brainRoot);
|
|
82
|
+
const id = extractId(text);
|
|
83
|
+
const note = extractNote(text);
|
|
84
|
+
const out = await editFile(p, (orig) => {
|
|
85
|
+
const base = normalizeTodo(orig);
|
|
86
|
+
const lines = base.split('\n');
|
|
87
|
+
// 在所有分区中找该 id 对应的未完成任务行(Done 的已完成行不重复动);全等比对
|
|
88
|
+
const idx = findTaskLine(lines, id);
|
|
89
|
+
// 状态标记:新建用 text 里带的(默认 进行中),更新保留原有的
|
|
90
|
+
const wanted = String(text).match(/^\[([^\]]+)\]/)?.[1];
|
|
91
|
+
const state = idx !== -1
|
|
92
|
+
? (stateOfTaskLine(lines[idx]) || wanted || '进行中')
|
|
93
|
+
: (TASK_STATES.includes(wanted) ? wanted : '进行中');
|
|
94
|
+
if (idx !== -1) {
|
|
95
|
+
// 原位更新:保留断点附属行 + 原@作者 + 原状态,只换任务行本体
|
|
96
|
+
const raw = stripStateMark(lines[idx]).replace(/^- \[ \] /, '');
|
|
97
|
+
const author = extractAuthor(raw);
|
|
98
|
+
const legacy = isLegacyAuthorTag(raw); // 旧行保持旧形态,不静默改写
|
|
99
|
+
const oldNote = extractNote(raw);
|
|
100
|
+
const merged = note && oldNote && oldNote.startsWith(note) ? `${note}${oldNote.slice(note.length)}` : note || oldNote;
|
|
101
|
+
const at = author ? ` ${legacy ? '@' + author : '[['
|
|
102
|
+
+ author + ']]'}` : '';
|
|
103
|
+
// 认领段整体搬回去(新的优:带了 sid 就用新的,旧行原本有就保留)——
|
|
104
|
+
// 不能无条件重写成 `(认领 today)`,否则重复 start 会把 sid 冲掉。
|
|
105
|
+
const sid = String(session || '').replace(/[^\w-]/g, '').slice(0, 16);
|
|
106
|
+
const claim = sid ? `(认领 ${today()} ${sid})` : (claimOf(raw) || `(认领 ${today()})`);
|
|
107
|
+
const newLine = `- [ ] [${state}] ${id}${at}${merged ? ' — ' + merged : ''} ${claim}`;
|
|
108
|
+
lines[idx] = newLine;
|
|
109
|
+
return { text: lines.join('\n'), updated: true };
|
|
110
|
+
}
|
|
111
|
+
return { text: insertTask(base, section, text), updated: false };
|
|
112
|
+
});
|
|
113
|
+
return out.updated
|
|
114
|
+
? { file: p, text, updated: true }
|
|
115
|
+
: { file: p, text, updated: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。 */
|
|
119
|
+
|
|
120
|
+
export function idOfTaskLine(line) {
|
|
121
|
+
if (!line || !line.startsWith('- [ ]')) return null;
|
|
122
|
+
// 两区制下行首可带状态标记(`- [ ] [进行中] <id> …`)—— 必须先剥掉再取 id,
|
|
123
|
+
// 否则 id 会被读成 "[进行中]",findTaskLine 全等比对永远落空(2026-09-13 实测)。
|
|
124
|
+
const m = line.match(/^- \[ \] (?:\[[^\]]+\] )?(\S+)/);
|
|
125
|
+
return m ? m[1].replace(/\u200b/g, '') : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 定位 id 对应的未完成任务行下标;附属断点行(↳ 开头)不算任务行。
|
|
129
|
+
* 全等比对,不用 includes(否则 T1 会误命中 T11)。 */
|
|
130
|
+
|
|
131
|
+
export function findTaskLine(lines, id) {
|
|
132
|
+
if (!id) return -1;
|
|
133
|
+
const want = String(id).replace(/\u200b/g, '');
|
|
134
|
+
return lines.findIndex((l) => idOfTaskLine(l) === want);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 实时断点: 在 id 任务行下原位补/换 `↳ 断点:` 附属行(不挪任务位置)。 */
|
|
138
|
+
|
|
139
|
+
export async function setBreakpoint(brainRoot, { id, text }) {
|
|
140
|
+
const p = await ensureTodo(brainRoot);
|
|
141
|
+
const bp = ` ↳ 断点: ${text}`;
|
|
142
|
+
const res = await editFile(p, (orig) => {
|
|
143
|
+
const lines = orig.split('\n');
|
|
144
|
+
const idx = findTaskLine(lines, id);
|
|
145
|
+
if (idx === -1) return SKIP;
|
|
146
|
+
if (lines[idx + 1] && lines[idx + 1].trimStart().startsWith('↳ 断点:')) {
|
|
147
|
+
lines[idx + 1] = bp; // 幂等: 更新原附属行
|
|
148
|
+
} else {
|
|
149
|
+
lines.splice(idx + 1, 0, bp);
|
|
150
|
+
}
|
|
151
|
+
return { text: lines.join('\n'), ok: true };
|
|
152
|
+
});
|
|
153
|
+
return res === SKIP
|
|
154
|
+
? { ok: false, msg: `[NO_MATCH] 未找到含 "${id}" 的未完成任务行` }
|
|
155
|
+
: { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- 任务随手记(memo)----------
|
|
159
|
+
/** 任务进行中的随手记:多句、不定长、可追加,**不占看板**。
|
|
160
|
+
*
|
|
161
|
+
* 为何需要它(2026-10-11,用户定):断点只该一句话(用户明确要求保持),
|
|
162
|
+
* 而 `abs log` 要起 kind、进 log.md,对「随手记一句」太重;`abs note` 只管
|
|
163
|
+
* 经验/坑且落 sources/。于是任务干到一半想记「现在做到哪、发现了什么」时
|
|
164
|
+
* **没有轻档位**,AI 就顺手塞进 todo.md —— 实测 .agents/.brain/todo.md 被塞进
|
|
165
|
+
* 10 行实施报告,而清理时发现「原内容只存在 todo.md,删任务时差点永久丢失」。
|
|
166
|
+
* 堵(白名单闸门)只治标:不给出路,下次还会换个写法再塞。本命令就是那条出路。
|
|
167
|
+
*
|
|
168
|
+
* 落点选 sessions/(不是新目录):那里已有目录遍历/frontmatter/归档机制,
|
|
169
|
+
* lint 的豁免判据也是现成的(两条契约都已核实):
|
|
170
|
+
* · `archive` tag → SESSIONS-SPLIT / SESSIONS-NAMING 豁免(它们只算无 archive 的)
|
|
171
|
+
* · `log-` 前缀 → ORPHAN-PAGE 豁免(sessions 里非 log- 开头要求有链接)
|
|
172
|
+
* 所以文件名取 `log-<日期>-<id>.md`,两个条件同时满足,**不改 lint 任何契约**。
|
|
173
|
+
*
|
|
174
|
+
* 追加式(不是覆盖):每次调用多一条带时间戳的条目,与断点的语义有意不同 ——
|
|
175
|
+
* 断点是「现在到哪了」(旧值就该被替换),memo 是「过程流水」(旧值不能丢)。 */
|
|
176
|
+
export async function setMemo(brainRoot, { id, text, at }) {
|
|
177
|
+
const stamp = at || localStamp();
|
|
178
|
+
const dir = brainPath(brainRoot, 'sessions');
|
|
179
|
+
const file = join(dir, `log-${today()}-${id}.md`);
|
|
180
|
+
const rel = `sessions/log-${today()}-${id}.md`;
|
|
181
|
+
const line = `- ${stamp} ${text}`;
|
|
182
|
+
const head = [
|
|
183
|
+
'---',
|
|
184
|
+
'tags: [archive, task-memo]', // archive → 豁免一天一文件;task-memo → 标明用途
|
|
185
|
+
`id: log-${today()}-${id}`,
|
|
186
|
+
`updated: ${today()}`,
|
|
187
|
+
'---',
|
|
188
|
+
'',
|
|
189
|
+
`# 任务随手记:${id}`,
|
|
190
|
+
'',
|
|
191
|
+
`━━ ${id} 的过程笔记(不上看板;任务结束后随 sessions 留存)`,
|
|
192
|
+
'',
|
|
193
|
+
].join('\n');
|
|
194
|
+
await fs.mkdir(dir, { recursive: true });
|
|
195
|
+
// 全程走 editFile 的锁:同一任务的并发 memo 不能丢条目(append 是 read-modify-write)。
|
|
196
|
+
// 初次创建也走它 —— editFile 对「文件尚不存在」已经处理(current=null),
|
|
197
|
+
// 自己再写一份 tmp+rename 分支反而绕过锁,首次并发两个 memo 会互相覆盖。
|
|
198
|
+
await editFile(file, (cur) => {
|
|
199
|
+
if (cur === null) return { text: `${head}${line}\n` };
|
|
200
|
+
const t = cur.replace(/\n+$/, '');
|
|
201
|
+
const next = /^updated:\s*.+$/m.test(t) ? t.replace(/^updated:\s*.+$/m, `updated: ${today()}`) : t;
|
|
202
|
+
return { text: `${next}\n${line}\n` };
|
|
203
|
+
});
|
|
204
|
+
return { ok: true, msg: `✓ 随手记 → ${rel}\n ${line}` };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 读回某任务的随手记(无则空)。供 CLI/MCP 查看与 done 时提醒。
|
|
208
|
+
* 按**完整文件名后缀**匹配而不是 id 后缀:`endsWith('-${id}.md')` 会让
|
|
209
|
+
* `t-1` 误命中 `task-t-1`(同族坑见 idOfTaskLine 的全等比对注释)。
|
|
210
|
+
* 文件名形如 `log-<日期>-<id>.md`,所以匹配 `-<id>.md` 时必须确认 id 是**最后一段**。 */
|
|
211
|
+
export async function readMemo(brainRoot, id) {
|
|
212
|
+
const dir = brainPath(brainRoot, 'sessions');
|
|
213
|
+
const files = await fs.readdir(dir).catch(() => []);
|
|
214
|
+
const want = String(id).replace(/\u200b/g, '');
|
|
215
|
+
const hit = files.filter((f) => {
|
|
216
|
+
const m = f.match(/^log-\d{4}-\d{2}-\d{2}-(.+)\.md$/);
|
|
217
|
+
return m && m[1] === want;
|
|
218
|
+
}).sort();
|
|
219
|
+
if (!hit.length) return '';
|
|
220
|
+
const parts = [];
|
|
221
|
+
for (const f of hit) {
|
|
222
|
+
const body = await fs.readFile(join(dir, f), 'utf8').catch(() => '');
|
|
223
|
+
parts.push(body.trim());
|
|
224
|
+
}
|
|
225
|
+
return parts.join('\n\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- 看板输出 ----------
|
|
229
|
+
|
|
230
|
+
export async function boardText(brainRoot, textOverride, { full = false } = {}) {
|
|
231
|
+
const text = textOverride !== undefined ? textOverride : await readTodo(brainRoot);
|
|
232
|
+
const head = `📂 abs → 项目: ${brainRoot}`;
|
|
233
|
+
if (!text.trim()) return `${head}\n\n(todo.md 为空,先 abs todo add 登记任务)`;
|
|
234
|
+
// Done 区折叠:它无上限增长,全量打印会把上下文塞满(见 collapseDone 注释)
|
|
235
|
+
const body = full ? text.trim() : collapseDone(text).text;
|
|
236
|
+
const hint = full ? '' : '\n\n(Done 只给计数;看明细: abs todo --full)';
|
|
237
|
+
return `${head}\n\n${body}${hint}`;
|
|
238
|
+
}
|