@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,210 @@
|
|
|
1
|
+
// src/todo/common.js — 跨模块共用的常量与纯工具(无业务状态,无跨依赖)。
|
|
2
|
+
|
|
3
|
+
export const TODO_MAX_LINES = 60;
|
|
4
|
+
|
|
5
|
+
export const LOG_MAX_LINES = 2000;
|
|
6
|
+
|
|
7
|
+
export const INDEX_MAX_LINES = 200;
|
|
8
|
+
|
|
9
|
+
// ---------- 本地日期 ----------
|
|
10
|
+
|
|
11
|
+
export function today() {
|
|
12
|
+
// 用本地时区取 YYYY-MM-DD(toISOString 是 UTC, 会跨天错一天)
|
|
13
|
+
const d = new Date();
|
|
14
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
15
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 本地日期时间 YYYY-MM-DD HH:MM(log 流水用)
|
|
19
|
+
|
|
20
|
+
export function localStamp() {
|
|
21
|
+
const d = new Date();
|
|
22
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
23
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------- 读取 todo.md ----------
|
|
27
|
+
|
|
28
|
+
export const SEC = {
|
|
29
|
+
todo: 'Todo',
|
|
30
|
+
done: 'Done',
|
|
31
|
+
archived: 'Archived', // Done 区内部的归档标记区(原 '### 归档')
|
|
32
|
+
undated: 'Undated', // Done 区内部无完成日期的尾组(原 '### (未标日期)')
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** 旧名 → 新名。供 `abs init --repair` 一次性迁移(幂等)。
|
|
36
|
+
* 只改匹配整行的标题,不动正文;不做模糊替换(防误改正文里提到的旧名)。 */
|
|
37
|
+
// 分区标题归一(旧名 → 标准名)
|
|
38
|
+
|
|
39
|
+
export function trimBlank(arr) {
|
|
40
|
+
const a = [...arr];
|
|
41
|
+
while (a.length && !a[0].trim()) a.shift();
|
|
42
|
+
while (a.length && !a[a.length - 1].trim()) a.pop();
|
|
43
|
+
return a;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 叶子条目行:log 的 `## [时间] …` 条目、todo/index 的 `- …` 行。
|
|
47
|
+
* 空行落在两个叶子条目之间 = 人为排版漂移,会随条目增长把文件撑成两倍行数。
|
|
48
|
+
* 注意(踩坑):标题行不算 —— `## Done` 与 `### 日期` 之间的空行是分区排版,
|
|
49
|
+
* 删了会把 Done 区挤成一片(首版误删)。 */
|
|
50
|
+
|
|
51
|
+
export function sigOfLine(l) {
|
|
52
|
+
return String(l).replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 一行落在 todo.md 的哪个分区(`## Todo` / `## Done`)。
|
|
56
|
+
* 返回 'Todo' | 'Done' | null。用于「按区判」—— Done/Archived 区的行不该按
|
|
57
|
+
* Todo 区的白名单判(日期分组、归档链接都是合法内容)。 */
|
|
58
|
+
export function todoSectionOf(lines, i) {
|
|
59
|
+
for (let k = i; k >= 0; k--) {
|
|
60
|
+
const m = String(lines[k]).match(/^##\s+(\S.*)$/);
|
|
61
|
+
if (m) return m[1].trim();
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 把一份文本里所有「违规行」的签名收成集合(供存量/新增比对)。
|
|
67
|
+
* 只有三个根文件用得上;非目标文件直接返回空集。
|
|
68
|
+
* ★ 空行特殊:签名取「上下两行拼接」不可靠 —— 插入一行就会让空行的
|
|
69
|
+
* 相邻行变样,导致存量空行被当成新引入(实测:cmdNote 插一行后,
|
|
70
|
+
* 同一处空行的签名就对不上了)。所以空行只记一个存在标记:
|
|
71
|
+
* 入库前就有「条目间空行」这个毛病 → 后续写入全放行(lint 报)。 */
|
|
72
|
+
|
|
73
|
+
export const REPORT_MARKERS = [
|
|
74
|
+
/^\s{0,4}[-*]\s+\S/m, // 列表项
|
|
75
|
+
/^\s{0,4}\d+[.)]\s+\S/m, // 有序列表
|
|
76
|
+
/```/, // 代码块
|
|
77
|
+
/^\s*\|.*\|\s*$/m, // 表格行
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** 数一个断点里有几个「句子」—— 断点是一件事,多句 = 在塞报告。
|
|
81
|
+
* 分隔符只用**句末标点**(。!?!?)与带圈编号 ①②③。
|
|
82
|
+
* ★ 分号(;/;)**不算**分隔符:中文技术写作里它常用来连接同一个意思的两部分
|
|
83
|
+
* (「改到哪个文件;同类约束见 xx」),算成两句会误伤正常断点(实测:
|
|
84
|
+
* 一条正常的「改到哪 + 同类约束 + 待确认」被判 3 句而拒绝写入)。
|
|
85
|
+
* 为什么不用关键词表:关键词命中不稳(「功能/方案」正常描述也会出现),
|
|
86
|
+
* 而「一句还是一段」是结构判据 —— 结构判据才可靠。 */
|
|
87
|
+
|
|
88
|
+
export function sentenceCount(s) {
|
|
89
|
+
return String(s)
|
|
90
|
+
.split(/[。!?!?]+|\s*[\u2460-\u2473]\s*/)
|
|
91
|
+
.map((x) => x.trim())
|
|
92
|
+
.filter(Boolean).length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 「绝对不允许乱空行」(2026-10-05 用户定):条目之间不能有空行。
|
|
96
|
+
* 为什么必须拒而不只是修:旧实现是「自动删」(fixed.push('删除条目之间的空行')),
|
|
97
|
+
* 但那是**默默改盘** —— 写进去的东西被改了而 AI 不知道,下次又写一遍。
|
|
98
|
+
* 现在直接报错,让写入方自己写对。
|
|
99
|
+
* 允许的位置:H1 与首个标签之间、标签与首条目之间、区与区之间(排版需要)。 */
|
|
100
|
+
|
|
101
|
+
export function isTaskLine(l) {
|
|
102
|
+
return /^\s*- \[[ x]\]/.test(l);
|
|
103
|
+
}
|
|
104
|
+
/** 判断一行是否为任务附属行(↳ 开头)。 */
|
|
105
|
+
|
|
106
|
+
export function isChildLine(l) {
|
|
107
|
+
return /^\s*↳/.test(l);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Todo 分区里「合法行」以外的东西 = 裸文本(总结/报告/清单)。
|
|
111
|
+
* 为什么补这个判据(2026-10-11,同一个坑第三次复发):
|
|
112
|
+
* 闸门原来只认 `- [ ]` 任务行与 `↳ 断点:` 行,**其余行直接跳过**(无 else 分支)。
|
|
113
|
+
* 于是把总结写成「紧跟任务行的裸文本行」就三层全部放行:闸门不看、形状规则不管、
|
|
114
|
+
* lint 只查断点行 —— 实测 .agents/.brain/todo.md 被塞进 10 行实施报告而 lint 报 0 问题。
|
|
115
|
+
* 前面的修法都挂在「行的形态」上(先修超长断点、再修多句断点),换个形态就绕过。
|
|
116
|
+
* 改成白名单:Todo 区里除了任务行/断点/空行/标题,别的都是非法。
|
|
117
|
+
* 只在 `## Todo` 区生效 —— Done/Archived 区有日期分组与归档链接,判据不同。
|
|
118
|
+
* 返回 false 才叫非法。 */
|
|
119
|
+
export function isAllowedTodoLine(l) {
|
|
120
|
+
const t = l.trim();
|
|
121
|
+
if (!t) return true; // 空行
|
|
122
|
+
if (/^#{1,3}\s/.test(t)) return true; // `## Todo` / `### …` 标题
|
|
123
|
+
if (isTaskLine(l)) return true; // 任务行
|
|
124
|
+
if (isChildLine(l)) return true; // ↳ 断点附属行
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 把 Done 区文本(bodyLines,不含 `## Done` 标题)解析成任务单元 [{ date, lines:[main,...children] }]。
|
|
129
|
+
* 剥 `### 日期` 分组标题与空行;任务行下紧跟的 ↳ 行并入该单元。 */
|
|
130
|
+
|
|
131
|
+
export function daysAgo(n, from) {
|
|
132
|
+
const [y, m, d] = String(from).split('-').map(Number);
|
|
133
|
+
const t = new Date(y, m - 1, d);
|
|
134
|
+
t.setDate(t.getDate() - n);
|
|
135
|
+
const pad = (x) => String(x).padStart(2, '0');
|
|
136
|
+
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 一行是否为未完成任务行(- [ ])。 */
|
|
140
|
+
|
|
141
|
+
export function isPlaceholder(line) {
|
|
142
|
+
const t = line.trim();
|
|
143
|
+
if (!/^- \[[ x]\]/.test(t)) return false; // 只判任务行
|
|
144
|
+
const body = t.replace(/^- \[[ x]\]\s*/, '').replace(/\(认领[^)]*\)/g, '').trim();
|
|
145
|
+
return !body || /^(无|(无)|None|null)$/.test(body);
|
|
146
|
+
}
|
|
147
|
+
/** 在指定分区段落后插入一行任务;找不到分区则在文件末尾追加回退。写前惰性迁移老格式。 */
|
|
148
|
+
|
|
149
|
+
export function extractId(text) {
|
|
150
|
+
// 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
|
|
151
|
+
const t = String(text).replace(/^\[[^\]]+\]\s+/, '');
|
|
152
|
+
const m = t.match(/^([A-Za-z][\w-]*)\b/);
|
|
153
|
+
return m ? m[1] : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 提取 " — " 后的 note 部分(剥掉紧跟在 id 后的 `@author` 标记及尾部 `(认领 ...)`)。
|
|
157
|
+
* 格式: `<id> @author — <note> (认领 date)`。@author 可选(旧行/未设置姓名时无)。 */
|
|
158
|
+
|
|
159
|
+
export function extractNote(text) {
|
|
160
|
+
const i = String(text).indexOf(' — ');
|
|
161
|
+
if (i === -1) return '';
|
|
162
|
+
return String(text).slice(i + 3).replace(/\s*\(认领[^)]*\)\s*$/, '').trim();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 取整段 `(认领 …)`(含日期与可选的会话 id)。无则空串。
|
|
166
|
+
* 为何要整段取:sid 塞在 `(认领 日期 <sid>)` 里,原位重建任务行时
|
|
167
|
+
* 必须把原值原样搬回去,否则重复 start 会把 sid 与认领日期一起冲掉。
|
|
168
|
+
* 用通配 `[^)]*` 而非写死日期格式 —— 尾部多一个 sid 也能吃到。 */
|
|
169
|
+
export function claimOf(text) {
|
|
170
|
+
const m = String(text).match(/\(认领[^)]*\)/);
|
|
171
|
+
return m ? m[0] : '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 取 `(认领 …)` 里的会话 id(末尾那个非日期 token)。无则空串。
|
|
175
|
+
* 形态:`(认领 2026-10-09)` 或 `(认领 2026-10-09 01a11eae)`。 */
|
|
176
|
+
export function sessionOf(text) {
|
|
177
|
+
const c = claimOf(text);
|
|
178
|
+
if (!c) return '';
|
|
179
|
+
const parts = c.replace(/^[((]认领\s*/, '').replace(/[))]$/, '').trim().split(/\s+/);
|
|
180
|
+
return parts.length > 1 ? parts[parts.length - 1] : '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 提取 id 后的作者标记(无则空)。供 upsertTask 原位重建行时保留作者。
|
|
184
|
+
* 两种形态都要认:
|
|
185
|
+
* - 新: `- [ ] ID [[fanchao]] — note` (wikilink 到人页)
|
|
186
|
+
* - 旧: `- [ ] ID @fanchao — note` (裸 at 标记, 历史行)
|
|
187
|
+
* 旧行只在原位更新时按原形态保留, 不做批量回填(原文/现场已不在, 回填等于编造)。
|
|
188
|
+
* 返回的是**纯姓名**(不含 [[ ]] 与 @),由调用方决定用什么形态重写。 */
|
|
189
|
+
|
|
190
|
+
export function extractAuthor(text) {
|
|
191
|
+
const s = String(text);
|
|
192
|
+
const link = s.match(/^\S+\s+\[\[([^\]]+)\]\]/);
|
|
193
|
+
if (link) return link[1].trim();
|
|
194
|
+
const at = s.match(/^\S+\s+@([\w\u4e00-\u9fff.-]+)/);
|
|
195
|
+
return at ? at[1] : '';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 判断行里的作者标记是旧裸 `@name` 形态(保留旧形态,不擅自升级)。 */
|
|
199
|
+
|
|
200
|
+
export function isLegacyAuthorTag(text) {
|
|
201
|
+
return !/^\S+\s+\[\[/.test(String(text)) && /^\S+\s+@/.test(String(text));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 从任务行取出行首 id token(`- [ ] <id> …` 里的 <id>)。
|
|
206
|
+
* 坑: 不能用 l.includes(id) 定位任务 —— 那是子串匹配,`T1` 会命中 `T11`(同理
|
|
207
|
+
* TASK-1/TASK-10、fix-hook/fix-hook-2),导致 upsert 误判"已存在"而原地改写别的任务、
|
|
208
|
+
* 新任务静默消失。必须取出 id token 做全等比对。
|
|
209
|
+
* id 里可能混入零宽字符(历史瑕疵),比对前先剥掉。
|
|
210
|
+
*/
|
package/src/todo/spec.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// src/todo/spec.js — 三份根文件的「规格数据」:分区名、标签白名单、条目形状、行数上限。
|
|
2
|
+
// 这里是纯数据 + 只读谓词。抽出来专为断开 structure ⇄ validate 的环:
|
|
3
|
+
// structure 要靠它重排、validate 要靠它校验,两边都只依赖它。
|
|
4
|
+
|
|
5
|
+
import { SEC } from './common.js';
|
|
6
|
+
|
|
7
|
+
export const LEGACY_SECTION_RENAMES = [
|
|
8
|
+
// H1(文件标题)
|
|
9
|
+
['# 🗂 图谱索引', '# 🗂 Graph Index'],
|
|
10
|
+
['# 🗒 操作日志', '# 🗒 Activity Log'],
|
|
11
|
+
['# 📋 Todo 看板', '# 📋 Todo Board'],
|
|
12
|
+
// ## 分区
|
|
13
|
+
// 四区 → 两区(2026-09-13 用户定:Backlog/Today 常年为空,进行时分区与实际工作流错配)。
|
|
14
|
+
// 未完成的一律归 Todo,状态改由**行首标记**表达(见 TASK_STATES)。
|
|
15
|
+
['## Backlog', '## Todo'],
|
|
16
|
+
['## Today / In Progress', '## Todo'],
|
|
17
|
+
['## Blocked', '## Todo'],
|
|
18
|
+
['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
|
|
19
|
+
// ### 区内分组标题
|
|
20
|
+
['### 归档', '### Archived'],
|
|
21
|
+
['### (未标日期)', '### Undated'],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const TODO_SECTIONS = ['Todo', 'Done'];
|
|
25
|
+
// todo 的 `###` 级标签(实测真实形态:`## Done` 下按日期分组 `### 2026-09-29`,
|
|
26
|
+
// 归位的进 `### Archived`)。白名单只限这两类:日期组 + Archived。
|
|
27
|
+
// 为什么必须列:`### ` 是 AI 最爱的「自建小节」位置(### 备忘 / ### 计划),
|
|
28
|
+
// 只查 `## ` 会把它放过去。
|
|
29
|
+
|
|
30
|
+
export const TODO_SUBSECTIONS = ['Archived'];
|
|
31
|
+
/** `###` 标签是否放行:归档区 / 未标日期组 / 日期组 `YYYY-MM-DD`。
|
|
32
|
+
* `Undated` 是 LEGACY_MARKS 里的正式标准名(旧 `### (未标日期)` → `### Undated`),
|
|
33
|
+
* 漏了它会把存量文件卡死(实测)。 */
|
|
34
|
+
|
|
35
|
+
export function isAllowedTodoSub(name) {
|
|
36
|
+
return TODO_SUBSECTIONS.includes(name) || name === 'Undated' || /^\d{4}-\d{2}-\d{2}$/.test(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------- 三份根文件的标签白名单与条目形状(2026-10-05 用户定)----------
|
|
40
|
+
// 用户原话:「index Rules Concepts Entities Sources Syntheses Sessions 包含这些标签
|
|
41
|
+
// 不允许增加新的标签,每个标签写内容的规则都是 - [[xx]]123 不允许乱写,同理 log todo 也是」
|
|
42
|
+
//
|
|
43
|
+
// 为什么需要:闸门只管骨架(分区顺序/H1/空行),**标签白名单与条目形状都没人管**。
|
|
44
|
+
// rebuildStructure 的规矩 3 更是「非标准分区原样保留在末尾」(为了防丢人自加的内容)
|
|
45
|
+
// → AI 新加一个 `## 备忘` 或 `## 计划` 永远合法,且会一直留在文件里。
|
|
46
|
+
//
|
|
47
|
+
// 注意 Rules 区是例外:它放的是**规矩短句**(「写代码前先读 docs/STRUCTURE.md」),
|
|
48
|
+
// 不是页链接 —— 实测 13 条规则全是这个形态。不能拿 [[页名]] 去卡它。
|
|
49
|
+
|
|
50
|
+
export const INDEX_SECTIONS = ['Rules', 'Concepts', 'Entities', 'Sources', 'Syntheses', 'Sessions'];
|
|
51
|
+
/** 需要 `- [[页名]] …` 形状的区(Rules 除外)。 */
|
|
52
|
+
|
|
53
|
+
export const INDEX_LINK_SECTIONS = ['Concepts', 'Entities', 'Sources', 'Syntheses', 'Sessions'];
|
|
54
|
+
/** log 行的 kind 枚举(与 cmdLog 写入端同源;实测 238 条只有这 4 种)。 */
|
|
55
|
+
|
|
56
|
+
export const LOG_KINDS = ['note', 'dev', 'concept', 'ingest'];
|
|
57
|
+
|
|
58
|
+
/** 条目形状规则表:文件 → 每行的预期形状。
|
|
59
|
+
* 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
|
|
60
|
+
/** 条目形状规则表:文件 → 每行的预期形状。
|
|
61
|
+
* 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
|
|
62
|
+
|
|
63
|
+
export const H1_TO_FILE = {
|
|
64
|
+
'# 🗂 Graph Index': 'index.md',
|
|
65
|
+
'# 🗒 Activity Log': 'log.md',
|
|
66
|
+
'# 📋 Todo Board': 'todo.md',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const ENTRY_SHAPES = {
|
|
70
|
+
'index.md': [
|
|
71
|
+
{
|
|
72
|
+
name: 'INDEX-ENTRY',
|
|
73
|
+
// 条目行:`- [[页名]] — 一句话`(是 6 个链接区里的一行)
|
|
74
|
+
test: (l) => {
|
|
75
|
+
if (!/^-\s/.test(l)) return null; // 非条目行(规则短句/说明)由分区规则另判
|
|
76
|
+
if (!/^-\s*\[\[[^\]]+\]\]/.test(l)) return '应以 `- [[页名]] …` 开头';
|
|
77
|
+
if (!/^-\s*\[\[[^\]]+\]\]\s*[—-]\s*\S/.test(l)) return '`[[页名]]` 后要跟 `— 一句话` 描述';
|
|
78
|
+
return null;
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
'log.md': [
|
|
83
|
+
{
|
|
84
|
+
name: 'LOG-ENTRY',
|
|
85
|
+
test: (l) => {
|
|
86
|
+
if (!/^##\s/.test(l)) return null; // 非 `## ` 行(H1/注释)不管
|
|
87
|
+
// ★ log 没有分区概念:`## ` 开头的只能是条目。
|
|
88
|
+
// 否则 `## 2026-10-05 随便写点`(缺方括号/作者/竖线)会被当成「分区标题」滑过去 ——
|
|
89
|
+
// 而 log.md 的标签白名单是 null(不校验),这就是一个漏网口(实测)。
|
|
90
|
+
// 形状:## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文
|
|
91
|
+
// ★ 放行历史旧模板的占位行 `## [YYYY-MM-DD] ingest | 沉淀 <slug>`:
|
|
92
|
+
// 它是早版 logTemplate 造出来的,不是人写的乱,而是模板自身的遗留。
|
|
93
|
+
// 新模板已改成 HTML 注释(见 store.js logTemplate)。存量这行由 lint 报。
|
|
94
|
+
if (/^##\s*\[YYYY-MM-DD\]/.test(l)) return null;
|
|
95
|
+
const m = l.match(/^##\s*\[(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2})?)\]\s*\[\[([^\]]+)\]\]\s*([A-Za-z]+)\s*\|\s*(\S.*)$/);
|
|
96
|
+
if (!m) return '应为 `## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文`(log 只能有这种行)';
|
|
97
|
+
if (!LOG_KINDS.includes(m[3])) return `kind 只能是 ${LOG_KINDS.join('/')}(收到 \`${m[3]}\`)`;
|
|
98
|
+
// 「正文只有个任务 id」不在这里拒(见下 lintOnly 规则):它是**内容质量**
|
|
99
|
+
// 问题不是**结构格式**问题,当写入闸门会把存量脏库锁死(实测:真库仅一条
|
|
100
|
+
// 就让所有 abs log 失败)。留在此处只会误伤正常短标题(如 `log-0`)。
|
|
101
|
+
return null;
|
|
102
|
+
},
|
|
103
|
+
// 仅在 lint 阶段生效的额外规则(不阻写入,只报存量)。
|
|
104
|
+
lintOnly: (l) => {
|
|
105
|
+
const m = l.match(/^##\s*\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*\[\[[^\]]+\]\]\s*[A-Za-z]+\s*\|\s*(\S.*)$/);
|
|
106
|
+
if (!m) return null;
|
|
107
|
+
if (/^[A-Z][A-Z0-9-]{2,}$/.test(m[1].trim())) return '正文只写了个任务 id(等于没记内容),补一句发生了什么';
|
|
108
|
+
return null;
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
'todo.md': [
|
|
113
|
+
{
|
|
114
|
+
name: 'TODO-ENTRY',
|
|
115
|
+
// 任务行形状(实测):
|
|
116
|
+
// 新写入(cmdTask):`- [ ] [状态] id [[作者]] — 描述 (认领 日期)`
|
|
117
|
+
// 旧存量(四区制迁移):`- [ ] 中文描述 (认领 日期)` ← 没有 id / 作者
|
|
118
|
+
// 已完成:`- [x] id …`(markDone 会 stripStateMark 去掉状态)
|
|
119
|
+
//
|
|
120
|
+
// ★ 为什么不强制要求 id:旧存量的 id 缺失是**历史事实**,机器不能凭空编造
|
|
121
|
+
// (编了就是静默改写语义)。闸门只确保「结构可解析」:
|
|
122
|
+
// 未完成必须有 `[状态]`(归一后的标志);已完成必须有内容。
|
|
123
|
+
// 而「新写入必须带 id」由 cmdTask 拼装保证 —— 那是入口的职责,
|
|
124
|
+
// 不是事后逐行猜(猜不出哪行是新的、哪行是旧的)。
|
|
125
|
+
test: (l) => {
|
|
126
|
+
if (/^#{1,3}\s/.test(l)) return null; // 标题行(## Done / ### 日期)不是条目
|
|
127
|
+
if (!/^-\s/.test(l)) return null; // 非条目行(如缩进的 ↳ 断点)由下面单独判
|
|
128
|
+
// Done 区的归档行:`- [[log-日期]] 完成任务 N 条`。
|
|
129
|
+
// ★ 历史命名不统一:还有 `[[2026-09-12-todo归档]]` 这种旧写法(实测),
|
|
130
|
+
// 只认 `log-` 前缀会把它误报为「缺状态标记」。状归档行与任务行的
|
|
131
|
+
// 区别不是命名而是**形态**:没有 `[ ]`/`[x]` 复选框的 [[链接]] 行。
|
|
132
|
+
if (/^-\s*\[\[[^\]]+\]\]\s+完成任务/.test(l)) return null;
|
|
133
|
+
if (/^-\s*\[\[log-\d{4}-\d{2}-\d{2}\]\]/.test(l)) return null;
|
|
134
|
+
const m = l.match(/^-\s*\[([ x])\]\s*(.*)$/);
|
|
135
|
+
if (!m) return '应以 `- [ ] ` 或 `- [x] ` 开头';
|
|
136
|
+
const rest = m[2].trim();
|
|
137
|
+
if (!rest) return '任务行不能只有复选框,要写做什么(已拒)';
|
|
138
|
+
// 未完成:必须有状态标记(新写法)——无标记的旧行由 enforceBrainFormat 自动补上,
|
|
139
|
+
// 走到这里还没标记 = 补不了(不是 `- [ ] ` 形状),报错。
|
|
140
|
+
if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
|
|
141
|
+
return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
|
|
142
|
+
}
|
|
143
|
+
// 断点附属行不能挤在同一行(应另起 ` ↳ 断点: `)
|
|
144
|
+
if (/↳\s*断点:/.test(rest)) return '断点要另起一行写 ` ↳ 断点: …`';
|
|
145
|
+
return null;
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
], // 任务行/断点行另由 assertTodoContent 卡长度与报告体
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** 校验单个文件的分区标签白名单 + 条目形状。返回问题列表(不抛错,供 lint 与写入闸门共用)。
|
|
152
|
+
* reason 文案直接把出路写清楚 —— 报错不给出路等于让人挖坑。 */
|
|
153
|
+
|
|
154
|
+
export function indexLinkSectionNeeds(section) {
|
|
155
|
+
return section !== null && INDEX_LINK_SECTIONS.includes(section);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 任务状态标记(行首,方括号)。替代原 Backlog/Today/Blocked 三区的区分作用。
|
|
159
|
+
* 放在 id **之前**,与 Done 结语的 `【落地】` 形态区分开(那是行尾、结语用)。
|
|
160
|
+
*
|
|
161
|
+
* 「搁置」与「滞留中」的区别(2026-10-05 用户定,别合并):
|
|
162
|
+
* 滞留中 = 还要做,只是卡住了(等外部/等信息) → 下会话该捡起来
|
|
163
|
+
* 搁置 = 不做了 / 用户改方向了 → 下会话不该捡,除非用户又提
|
|
164
|
+
* 混在一起会让下会话分不清「该不该接着干」。 */
|
|
165
|
+
|
|
166
|
+
export const TASK_STATES = ['进行中', '讨论中', '滞留中', '搁置'];
|
|
167
|
+
|
|
168
|
+
/** 结构重建(B 档):以标准分区表为准重排整个文件。
|
|
169
|
+
*
|
|
170
|
+
* 规则(保证内容不丢、不挪错):
|
|
171
|
+
* 1. 按 spec.order 顺序输出标准分区,每个分区下放**归属于它**的内容行;
|
|
172
|
+
* 2. 归属判定:按行所处的原分区归入对应标准分区;旧名先按 spec.renames 归一;
|
|
173
|
+
* 3. **非标准分区**(人自加的,如 `## 备忘`)→ 内容连同它自己的标题一起**原样保留在末尾**,
|
|
174
|
+
* 绝不合入已有标准分区(机器不知道它的语义,猜错就是挪错内容);
|
|
175
|
+
* 4. 自由正文(不属于任何分区的行,如 H1 后的说明句)保留在 H1 之后;
|
|
176
|
+
* 5. 幂等:已是标准结构 → 输出逐字节相同。
|
|
177
|
+
*
|
|
178
|
+
* 返回 { text, changed };changed 为空的描述列表(空=无需改盘)。 */
|
|
179
|
+
|
|
180
|
+
export const RETIRED_SECTIONS = ['## Roadmap'];
|
|
181
|
+
|
|
182
|
+
/** 删除废弃分区的整段(标题到下一个同级/更高级标题前)。
|
|
183
|
+
* 返回 { text, removed:[区名] };只按整行精确匹配标题,不碰正文。 */
|
|
184
|
+
|
|
185
|
+
export const BREAKPOINT_MAX = 200;
|
|
186
|
+
|
|
187
|
+
export const TASK_LINE_MAX = 400;
|
|
188
|
+
|
|
189
|
+
/** 一行的「违规签名」:去空白、截前 120 字。
|
|
190
|
+
* 用途:比对「这条违规是不是写入前就有的」——新增的拒,存量的放。
|
|
191
|
+
* 不按行号比对:插入一行会让后面所有行号位移,按行号会误判。 */
|
|
192
|
+
|
|
193
|
+
export const DONE_KINDS = ['落地', '否决', '仅方案'];
|
|
194
|
+
|
|
195
|
+
/** 从 Done 任务行提取结语标记;无标记返回 ''。 */
|
|
196
|
+
|
|
197
|
+
export const ARCHIVE_HEADING = `### ${SEC.archived}`;
|
|
198
|
+
|
|
199
|
+
/** 把 Done 主体行切成 { groupLines, archiveLines }:把 `### 归档` 区当"不透明块"原样保留。
|
|
200
|
+
* 注意不能简单"从标题切到文件尾" —— 否则一旦有日期组落在归档区之后(手改/旧数据),
|
|
201
|
+
* 它会被当不透明内容除在分组之外,永远不参与归档。故只取到下一个 ###/## 标题为止。 */
|
|
202
|
+
|
|
203
|
+
export const ARCHIVE_SECTION = '## 📦 任务归档';
|
|
204
|
+
|
|
205
|
+
/** 把「任务归档」段合并进已有正文。**纯函数**(不碰磁盘)。
|
|
206
|
+
*
|
|
207
|
+
* 为何不再「每天一个文件」(2026-09-15 用户定):
|
|
208
|
+
* 归档页与 `log-<日期>.md` 会话快照是**同一天的记录**,分两处查着要开两个文件。
|
|
209
|
+
* 改为:归档写进当天快照的 `ARCHIVE_SECTION` 段。
|
|
210
|
+
*
|
|
211
|
+
* 两个边界(都有测试钉住):
|
|
212
|
+
* ① 文件不存在 → 建一个**只有归档段**的页(那天可能没写快照,仍只落这一个文件)。
|
|
213
|
+
* ② 文件已存在(含 AI 手写的快照)→ **只替换归档段**,段外内容逐字保留。
|
|
214
|
+
* 同日重复归档(罕见)则把新日期组接在段内已有内容之后,不重复建段。
|
|
215
|
+
*
|
|
216
|
+
* @param body 现有全文(null = 文件不存在)
|
|
217
|
+
* @param group { date, lines, count }
|
|
218
|
+
*/
|