@fanchao8609/agent_brain_sync 1.16.4 → 1.17.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.
@@ -0,0 +1,167 @@
1
+ // src/todo/tasks.js — 任务读写入口:读、加、改状态、断点、看板。
2
+
3
+ import { promises as fs } from 'node:fs';
4
+ import { brainPath } from '../index.js';
5
+ import { editFile, SKIP } from '../lock.js';
6
+ import { today, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf } from './common.js';
7
+ import { todoTemplate, normalizeTodo, stateOfTaskLine, stripStateMark } from './structure.js';
8
+ import { TASK_STATES } from './spec.js';
9
+ import { collapseDone, groupDoneSection } from './archive.js';
10
+
11
+ export async function readTodo(brainRoot) {
12
+ const p = brainPath(brainRoot, 'todo.md');
13
+ let text;
14
+ try {
15
+ text = await fs.readFile(p, 'utf8');
16
+ } catch {
17
+ return ''; // 尚未创建,按空处理(不在此创建,避免读操作写文件)
18
+ }
19
+ const norm = normalizeTodo(text);
20
+ const grouped = groupDoneSection(norm); // 平铺旧 Done → 按日期分组(幂等)
21
+ if (grouped !== text) {
22
+ // 惰性迁移也是写:走锁,避免与并发写互相覆盖(锁内 re-read 已是权威最新内容)
23
+ await editFile(p, (cur) => (cur === text ? { text: grouped } : SKIP));
24
+ return grouped;
25
+ }
26
+ return text;
27
+ }
28
+
29
+ export async function ensureTodo(brainRoot) {
30
+ const p = brainPath(brainRoot, 'todo.md');
31
+ // 缺失才建模板;经锁写盘保证原子性(避免与并发 editFile 读到半写内容)
32
+ await editFile(p, (cur) => (cur === null ? { text: todoTemplate() } : SKIP));
33
+ return p;
34
+ }
35
+
36
+ /** 分区名常量(单一真源)。改名时只改这里 —— 之前散在 20+ 处,一改就漏。
37
+ * 旧名(中文)留在 LEGACY_SECTION_RENAMES 作迁移用。 */
38
+
39
+ export function setStateMark(text, id, state) {
40
+ if (!TASK_STATES.includes(state)) return { text, changed: [] };
41
+ const lines = String(text ?? '').split('\n');
42
+ const idx = findTaskLine(lines, id);
43
+ if (idx === -1) return { text, changed: [] };
44
+ const cur = lines[idx];
45
+ if (stateOfTaskLine(cur) === state) return { text, changed: [] };
46
+ const raw = stripStateMark(cur); // 先剥旧标记,再补新的
47
+ lines[idx] = raw.replace(/^(- \[[ x]\] )/, `$1[${state}] `);
48
+ return { text: lines.join('\n'), changed: [`${id} 状态 → ${state}`] };
49
+ }
50
+
51
+ /** 剥掉行首状态标记。 */
52
+
53
+ export async function addTask(brainRoot, { section, text }) {
54
+ const p = await ensureTodo(brainRoot);
55
+ await editFile(p, (orig) => ({ text: insertTask(orig, section, text) }));
56
+ return { file: p, text };
57
+ }
58
+
59
+ /** 纯函数:把任务行插到分区标题后。供 editFile mutator 复用(upsert 也用它)。 */
60
+
61
+ function insertTask(orig, section, text) {
62
+ const base = normalizeTodo(orig);
63
+ const lines = base.split('\n');
64
+ const header = `## ${section}`;
65
+ const idx = lines.findIndex((l) => l.startsWith(header));
66
+ if (idx === -1) {
67
+ lines.push('', header, `- [ ] ${text}`);
68
+ } else {
69
+ // 在该分区标题后、下一个分区标题前插入
70
+ let insertAt = idx + 1;
71
+ while (insertAt < lines.length && !lines[insertAt].startsWith('## ')) insertAt++;
72
+ lines.splice(insertAt, 0, `- [ ] ${text}`);
73
+ }
74
+ return lines.join('\n');
75
+ }
76
+
77
+ /** 幂等登记:同 id 已有未完成任务行则原位更新(新 note/新认领日期),否则插入。 */
78
+
79
+ export async function upsertTask(brainRoot, { section, text, session = '' }) {
80
+ const p = await ensureTodo(brainRoot);
81
+ const id = extractId(text);
82
+ const note = extractNote(text);
83
+ const out = await editFile(p, (orig) => {
84
+ const base = normalizeTodo(orig);
85
+ const lines = base.split('\n');
86
+ // 在所有分区中找该 id 对应的未完成任务行(Done 的已完成行不重复动);全等比对
87
+ const idx = findTaskLine(lines, id);
88
+ // 状态标记:新建用 text 里带的(默认 进行中),更新保留原有的
89
+ const wanted = String(text).match(/^\[([^\]]+)\]/)?.[1];
90
+ const state = idx !== -1
91
+ ? (stateOfTaskLine(lines[idx]) || wanted || '进行中')
92
+ : (TASK_STATES.includes(wanted) ? wanted : '进行中');
93
+ if (idx !== -1) {
94
+ // 原位更新:保留断点附属行 + 原@作者 + 原状态,只换任务行本体
95
+ const raw = stripStateMark(lines[idx]).replace(/^- \[ \] /, '');
96
+ const author = extractAuthor(raw);
97
+ const legacy = isLegacyAuthorTag(raw); // 旧行保持旧形态,不静默改写
98
+ const oldNote = extractNote(raw);
99
+ const merged = note && oldNote && oldNote.startsWith(note) ? `${note}${oldNote.slice(note.length)}` : note || oldNote;
100
+ const at = author ? ` ${legacy ? '@' + author : '[['
101
+ + author + ']]'}` : '';
102
+ // 认领段整体搬回去(新的优:带了 sid 就用新的,旧行原本有就保留)——
103
+ // 不能无条件重写成 `(认领 today)`,否则重复 start 会把 sid 冲掉。
104
+ const sid = String(session || '').replace(/[^\w-]/g, '').slice(0, 16);
105
+ const claim = sid ? `(认领 ${today()} ${sid})` : (claimOf(raw) || `(认领 ${today()})`);
106
+ const newLine = `- [ ] [${state}] ${id}${at}${merged ? ' — ' + merged : ''} ${claim}`;
107
+ lines[idx] = newLine;
108
+ return { text: lines.join('\n'), updated: true };
109
+ }
110
+ return { text: insertTask(base, section, text), updated: false };
111
+ });
112
+ return out.updated
113
+ ? { file: p, text, updated: true }
114
+ : { file: p, text, updated: false };
115
+ }
116
+
117
+ /** 从任务文本提取幂等键(行首 id,如 TASK-1 / T-2 / fix-hook)。 */
118
+
119
+ export function idOfTaskLine(line) {
120
+ if (!line || !line.startsWith('- [ ]')) return null;
121
+ // 两区制下行首可带状态标记(`- [ ] [进行中] <id> …`)—— 必须先剥掉再取 id,
122
+ // 否则 id 会被读成 "[进行中]",findTaskLine 全等比对永远落空(2026-09-13 实测)。
123
+ const m = line.match(/^- \[ \] (?:\[[^\]]+\] )?(\S+)/);
124
+ return m ? m[1].replace(/\u200b/g, '') : null;
125
+ }
126
+
127
+ /** 定位 id 对应的未完成任务行下标;附属断点行(↳ 开头)不算任务行。
128
+ * 全等比对,不用 includes(否则 T1 会误命中 T11)。 */
129
+
130
+ export function findTaskLine(lines, id) {
131
+ if (!id) return -1;
132
+ const want = String(id).replace(/\u200b/g, '');
133
+ return lines.findIndex((l) => idOfTaskLine(l) === want);
134
+ }
135
+
136
+ /** 实时断点: 在 id 任务行下原位补/换 `↳ 断点:` 附属行(不挪任务位置)。 */
137
+
138
+ export async function setBreakpoint(brainRoot, { id, text }) {
139
+ const p = await ensureTodo(brainRoot);
140
+ const bp = ` ↳ 断点: ${text}`;
141
+ const res = await editFile(p, (orig) => {
142
+ const lines = orig.split('\n');
143
+ const idx = findTaskLine(lines, id);
144
+ if (idx === -1) return SKIP;
145
+ if (lines[idx + 1] && lines[idx + 1].trimStart().startsWith('↳ 断点:')) {
146
+ lines[idx + 1] = bp; // 幂等: 更新原附属行
147
+ } else {
148
+ lines.splice(idx + 1, 0, bp);
149
+ }
150
+ return { text: lines.join('\n'), ok: true };
151
+ });
152
+ return res === SKIP
153
+ ? { ok: false, msg: `[NO_MATCH] 未找到含 "${id}" 的未完成任务行` }
154
+ : { ok: true, msg: `✓ 断点已落 → ${id}\n ${bp.trim()}` };
155
+ }
156
+
157
+ // ---------- 看板输出 ----------
158
+
159
+ export async function boardText(brainRoot, textOverride, { full = false } = {}) {
160
+ const text = textOverride !== undefined ? textOverride : await readTodo(brainRoot);
161
+ const head = `📂 abs → 项目: ${brainRoot}`;
162
+ if (!text.trim()) return `${head}\n\n(todo.md 为空,先 abs todo add 登记任务)`;
163
+ // Done 区折叠:它无上限增长,全量打印会把上下文塞满(见 collapseDone 注释)
164
+ const body = full ? text.trim() : collapseDone(text).text;
165
+ const hint = full ? '' : '\n\n(Done 只给计数;看明细: abs todo --full)';
166
+ return `${head}\n\n${body}${hint}`;
167
+ }
@@ -0,0 +1,204 @@
1
+ // src/todo/validate.js — 条目级闸门:形状校验、长度限制、空行检查。
2
+
3
+ import { TODO_SECTIONS, isAllowedTodoSub, INDEX_SECTIONS, ENTRY_SHAPES, indexLinkSectionNeeds, BREAKPOINT_MAX, TASK_LINE_MAX } from './spec.js';
4
+ import { sigOfLine, REPORT_MARKERS, sentenceCount, isTaskLine } from './common.js';
5
+
6
+ export function checkFileShape(file, text, { lintMode = false } = {}) {
7
+ const issues = [];
8
+ const lines = String(text ?? '').split('\n');
9
+ const allowed = file === 'index.md' ? INDEX_SECTIONS : file === 'todo.md' ? TODO_SECTIONS : null;
10
+
11
+ if (allowed) {
12
+ for (let i = 0; i < lines.length; i++) {
13
+ const l = lines[i];
14
+ // `## [` 开头是 log 的条目行,不是分区标签(否则会把 238 条流水全判非法)
15
+ if (/^##\s+\[/.test(l)) continue;
16
+ const m = l.match(/^##\s+(\S.*)$/);
17
+ const sub = l.match(/^###\s+(\S.*)$/);
18
+ if (!m && !sub) continue;
19
+ const name = (m ? m[1] : sub[1]).trim();
20
+ // todo 的 `###` 标签单独判(只许日期组 + Archived)
21
+ if (sub && file === 'todo.md') {
22
+ if (!isAllowedTodoSub(name)) {
23
+ issues.push({
24
+ code: 'SECTIONS-NOT-ALLOWED',
25
+ line: i + 1,
26
+ msg: `todo.md:${i + 1} 不允许的小节 \`### ${name}\`;` +
27
+ `todo.md 的 ### 只允许 \`### YYYY-MM-DD\`(Done 区日期组)、\`### Undated\` 与 \`### Archived\``,
28
+ });
29
+ }
30
+ continue;
31
+ }
32
+ if (!allowed.includes(name)) {
33
+ issues.push({
34
+ code: 'SECTIONS-NOT-ALLOWED',
35
+ line: i + 1,
36
+ msg: `${file}:${i + 1} 不允许的标签 \`${m ? '##' : '###'} ${name}\`;` +
37
+ `${file} 只允许 ${allowed.map((s) => `\`## ${s}\``).join(' / ')}${
38
+ file === 'todo.md' ? '(`###` 只许日期组与 `### Archived`)' : ''}。` +
39
+ `内容按语义归入现有标签,确实需要例外请先改白名单(见 src/todo.js 的 SECTIONS 常量)`,
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ // 条目形状
46
+ const shapes = ENTRY_SHAPES[file] || [];
47
+ if (shapes.length) {
48
+ // 先算每个行所属分区(形状只对被要求的区生效)
49
+ let section = null;
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const l = lines[i];
52
+ // ★ 分区标题的识别不能吃掉 log 的条目行:log 的条目就是 `## [时间] …`,
53
+ // 若把它当分区标题就会 continue 掉本行,形状校验形同虚设(实测:
54
+ // kind=node / 缺竖线 全部漏放)。判据:`## [` 开头 = 条目,不是分区。
55
+ // ★ 分区标题的识别不能吃掉 log 的条目行:log 的条目就是 `## [时间] …`,
56
+ // 若把它当分区标题就会 continue 掉本行,形状校验形同虚设(实测:
57
+ // kind=node / 缺竖线 全部漏放)。
58
+ // ★ log.md 特殊:它**没有分区**,`## ` 开头的只能是条目(含写错的),
59
+ // 所以 log 里绝不能把 `## ` 行当分区标题 —— 全交给形状规则判。
60
+ const sm = file === 'log.md' ? null : (/^##\s+\[/.test(l) ? null : l.match(/^##\s+(\S.*)$/));
61
+ if (sm) { section = sm[1].trim(); continue; }
62
+ if (!l.trim()) continue;
63
+ const needShape = file === 'log.md'
64
+ ? true
65
+ : file === 'todo.md'
66
+ ? true // todo 的任务行形状与分区无关(Todo/Done 都要 `- [ ] …`)
67
+ : indexLinkSectionNeeds(section);
68
+ if (!needShape) continue;
69
+ for (const rule of shapes) {
70
+ const bad = rule.test(l);
71
+ if (bad) issues.push({ code: rule.name, line: i + 1, msg: `${file}:${i + 1} ${bad}` });
72
+ // lintOnly 规则只在体检时跑(不阻写入)—— 用于「内容质量」类问题,
73
+ // 那些问题当闸门会把存量脏库锁死。
74
+ if (!lintMode) continue;
75
+ const bad2 = rule.lintOnly ? rule.lintOnly(l) : null;
76
+ if (bad2) issues.push({ code: `${rule.name}-QUALITY`, line: i + 1, msg: `${file}:${i + 1} ${bad2}` });
77
+ }
78
+ }
79
+ }
80
+ // 条目之间的空行(与写入闸门 assertNoStrayBlank 同判据)。
81
+ // lint 只能「报」,不能抛 —— 体检不能因格式坏而挂。
82
+ const isEntryLine = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
83
+ const isHeadingLine = (l) => /^#{1,3}\s/.test(l);
84
+ for (let i = 0; i < lines.length; i++) {
85
+ if (lines[i].trim()) continue;
86
+ if (i > 0 && !lines[i - 1].trim()) continue; // 连续空行只报一次
87
+ let p = i - 1;
88
+ while (p >= 0 && !lines[p].trim()) p--;
89
+ let n = i + 1;
90
+ while (n < lines.length && !lines[n].trim()) n++;
91
+ const prev = p >= 0 ? lines[p] : null;
92
+ const next = n < lines.length ? lines[n] : null;
93
+ if (!prev || !next) continue;
94
+ if (isHeadingLine(prev)) continue; // 标签与首条目之间的空行是排版,不算乱
95
+ if (isEntryLine(prev) && isEntryLine(next)) {
96
+ issues.push({ code: 'STRAY-BLANK-LINE', line: i + 1, msg: `${file}:${i + 1} 条目之间不允许空行(条目必须紧贴)` });
97
+ }
98
+ }
99
+ return issues;
100
+ }
101
+
102
+ /** index 的某个区是否要求 `- [[页名]]` 形状(Rules 与未知区不要求)。 */
103
+
104
+ export function violationSignatures(text) {
105
+ const set = new Set();
106
+ const lines = String(text ?? '').split('\n');
107
+ for (const l of lines) set.add(sigOfLine(l));
108
+ const isEntry = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
109
+ for (let i = 1; i < lines.length - 1; i++) {
110
+ if (lines[i].trim()) continue;
111
+ if (isEntry(lines[i - 1]) && isEntry(lines[i + 1])) {
112
+ set.add('HAS-STRAY-BLANK');
113
+ break;
114
+ }
115
+ }
116
+ return set;
117
+ }
118
+ /** 断点里出现这些 = 明显是在塞报告(列表/代码块/表格)。 */
119
+
120
+ export function assertNoStrayBlank(text, file, prevSig = null) {
121
+ const lines = String(text ?? '').split('\n');
122
+ const isEntry = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
123
+ const isHeading = (l) => /^#{1,3}\s/.test(l);
124
+ for (let i = 0; i < lines.length; i++) {
125
+ if (lines[i].trim()) continue;
126
+ // 找上一个/下一个非空行
127
+ let p = i - 1;
128
+ while (p >= 0 && !lines[p].trim()) p--;
129
+ let n = i + 1;
130
+ while (n < lines.length && !lines[n].trim()) n++;
131
+ const prev = p >= 0 ? lines[p] : null;
132
+ const next = n < lines.length ? lines[n] : null;
133
+ if (prev === null || next === null) continue; // 文件头尾空行不算
134
+ // 连续多个空行:报一次就够
135
+ if (i > 0 && !lines[i - 1].trim()) continue;
136
+ if (!isEntry(prev) && !isEntry(next)) continue; // 正文段落之间允许
137
+ // ★ 标题与首个条目之间必留一个空行(排版必需)—— H1/标签后紧跟条目反而难看。
138
+ // 实测踩坑:log 模板就是 `# H1` + 空行 + `## [日期] …`,不放行会把模板本身卡死。
139
+ if (isHeading(prev)) continue;
140
+ // 两个条目之间(含「条目 → 空行 → 条目」)不允许
141
+ if (isEntry(prev) && isEntry(next)) {
142
+ // 存量空行(写入前就有)→ 放行,交给 lint 报
143
+ if (prevSig !== null && prevSig.has('HAS-STRAY-BLANK')) continue;
144
+ throw new Error(
145
+ `✗ ${file} 不允许条目之间的空行(第 ${i + 1} 行):\n` +
146
+ ` 上一行: ${prev.slice(0, 50)}\n` +
147
+ ` 下一行: ${next.slice(0, 50)}\n` +
148
+ ` 条目必须紧贴;标签与首条目之间才留一个空行。已拒绝写入 —— 请删掉这个空行再写。`,
149
+ );
150
+ }
151
+ }
152
+ return true;
153
+ }
154
+
155
+ /** 校验 todo 全文的条目内容。抛错 = 拒绝写入(不是修正)。 */
156
+
157
+ export function assertTodoContent(text, prevSig = null) {
158
+ const lines = String(text ?? '').split('\n');
159
+ /** 存量行(写入前就存在)→ 放行,交给 lint 报。 */
160
+ const isLegacy = (l) => prevSig !== null && prevSig.has(sigOfLine(l));
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const l = lines[i];
163
+ const lineNo = i + 1;
164
+ if (l.startsWith('## ')) continue;
165
+ // 任务行
166
+ if (isTaskLine(l)) {
167
+ if (l.length > TASK_LINE_MAX && !isLegacy(l)) {
168
+ throw new Error(
169
+ `✗ 任务行太长(第 ${lineNo} 行 ${l.length} 字 > ${TASK_LINE_MAX})—— todo 只放「要做什么」,一句话。\n` +
170
+ ` 这么长的内容要么拆成多条任务,要么走 abs note(经验)/ abs log(流水)。\n` +
171
+ ` 原文开头: ${l.slice(0, 60)}...`,
172
+ );
173
+ }
174
+ continue;
175
+ }
176
+ // 断点附属行
177
+ if (l.trimStart().startsWith('↳ 断点:')) {
178
+ if (isLegacy(l)) continue; // 存量超长断点:不阻写入(lint 报)
179
+ const body = l.trimStart().slice('↳ 断点:'.length).trim();
180
+ if (body.length > BREAKPOINT_MAX) {
181
+ throw new Error(
182
+ `✗ 断点太长(第 ${lineNo} 行 ${body.length} 字 > ${BREAKPOINT_MAX})—— 断点只写「改到哪个文件哪一步」。\n` +
183
+ ` 写不下的内容请分流:实施报告/验证数据 → abs log;经验与坑 → abs note;长设计 → .brain/sources/ 页。\n` +
184
+ ` 原文开头: ${body.slice(0, 60)}...`,
185
+ );
186
+ }
187
+ if (REPORT_MARKERS.some((re) => re.test(body)) || sentenceCount(body) > 2) {
188
+ throw new Error(
189
+ `✗ 断点里像是塞了报告/清单(第 ${lineNo} 行)—— 断点是一句话(最多两句),不是文档。\n` +
190
+ ` todo 只能加 todo:要做的拆成任务,做完的写 abs log,经验写 abs note。\n` +
191
+ ` 原文开头: ${body.slice(0, 60)}...`,
192
+ );
193
+ }
194
+ continue;
195
+ }
196
+ }
197
+ return true;
198
+ }
199
+
200
+ /** 删掉「条目之间的空行」(条目前后紧贴才是标准形态)。
201
+ * 在 enforceBrainFormat 里跑两次:assertShape 前(清存量,否则存量空行会把写入全卡死)、
202
+ * 重建后(重排会新建邻接关系)。幂等 —— 第二次通常零改动。
203
+ * 为什么删而不是拒:空行不携带信息,删了不丢东西;而断点/条目形状带着内容,
204
+ * 没有安全的自动修法,只能拒并让人处理。 */