@fanchao8609/agent_brain_sync 1.19.0 → 1.19.2
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/package.json +1 -1
- package/src/lint.js +6 -2
- package/src/lock.js +26 -1
- package/src/store.js +59 -11
- package/src/todo/spec.js +31 -3
package/package.json
CHANGED
package/src/lint.js
CHANGED
|
@@ -331,8 +331,12 @@ async function checkFiles(vault, pages, indexLinks) {
|
|
|
331
331
|
if (doneLines > DONE_MAX) {
|
|
332
332
|
issues.push(`DONE-PILED-UP: Done 区 ${doneLines} 行 > ${DONE_MAX}; 跑 \`abs todo archive\` 迁出旧日期组`);
|
|
333
333
|
}
|
|
334
|
+
// ★ 只数**顶格**任务行(缩进 0)—— 子项也是 `- [x]`,带 \s* 会把子项一起算进来,
|
|
335
|
+
// 于是「父任务有结语有日期、子项没有」也被报成缺结语(实测 DONE-NO-KIND 误报
|
|
336
|
+
// 一条子项 `- [x] 不急,等下次碰这块再拆`)。与 archive.js 数任务时的判据保持一致。
|
|
337
|
+
const isTopDone = (l) => /^- \[x\]/.test(l);
|
|
334
338
|
const doneBody = todoTxt.split('\n').slice(di + 1);
|
|
335
|
-
const noKind = doneBody.filter((l) =>
|
|
339
|
+
const noKind = doneBody.filter((l) => isTopDone(l) && !doneKindOf(l));
|
|
336
340
|
if (noKind.length) {
|
|
337
341
|
const sample = (noKind[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
|
|
338
342
|
issues.push(
|
|
@@ -340,7 +344,7 @@ async function checkFiles(vault, pages, indexLinks) {
|
|
|
340
344
|
`逐条补 \`--as 落地|否决|仅方案\`(新条目:abs todo done <id> --as …)`,
|
|
341
345
|
);
|
|
342
346
|
}
|
|
343
|
-
const noDate = doneBody.filter((l) =>
|
|
347
|
+
const noDate = doneBody.filter((l) => isTopDone(l) && !doneDateOf(l));
|
|
344
348
|
if (noDate.length) {
|
|
345
349
|
const sample = (noDate[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
|
|
346
350
|
issues.push(
|
package/src/lock.js
CHANGED
|
@@ -14,6 +14,24 @@ export class LockTimeout extends Error {}
|
|
|
14
14
|
* 返回 null = 该文件不归闸门管;否则返回整理后的 text。 */
|
|
15
15
|
let formatGate = null;
|
|
16
16
|
export function setFormatGate(fn) { formatGate = fn; }
|
|
17
|
+
|
|
18
|
+
/** 三个受管文件名 —— 它们存在就是为了当“数据库表”,格式必须恒定。 */
|
|
19
|
+
const MANAGED = new Set(['index.md', 'log.md', 'todo.md']);
|
|
20
|
+
|
|
21
|
+
/** 懒加载闸门:**漏了 setFormatGate 就等于没闸门**,而注册是 import 副作用,
|
|
22
|
+
* 谁少 import 一次 store.js 就静默裂开(2026-10-18 实测:只 import lock.js 时
|
|
23
|
+
* 裸散文直接写进了 index.md)。故这里不信任外部注册,缺失时自己补上。
|
|
24
|
+
*
|
|
25
|
+
* 补不上(= store.js 没调 setFormatGate)就对受管文件硬拒:宁可写失败也不要格崩坏
|
|
26
|
+
* ——写不进去能重试,写脏了没人知道。 */
|
|
27
|
+
async function gateFor(file) {
|
|
28
|
+
if (!formatGate) await import('./store.js'); // 它的模块顶层会调 setFormatGate
|
|
29
|
+
if (formatGate) return formatGate;
|
|
30
|
+
if (MANAGED.has(basename(file))) {
|
|
31
|
+
throw new Error(`✗ ${basename(file)} 是受管文件,但格式闸门未注册 —— 拒绝写入(防止格崩坏)`);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
17
35
|
const LOCK_WAIT_BASE_MS = 15; // 指数退避起始重试间隔
|
|
18
36
|
const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
|
|
19
37
|
// 抢锁总预算:排队等锁的进程须依次排完。多进程高并发(CLI/MCP/hook 同刻抢一文件)下,
|
|
@@ -76,7 +94,14 @@ export async function editFile(file, mutator) {
|
|
|
76
94
|
let write = typeof res === 'string' ? res : res && typeof res.text === 'string' ? res.text : null;
|
|
77
95
|
// 格式闸门:所有写 index/todo/log 的路径(CLI/MCP/hook 共 ~15 处)都在此收口,
|
|
78
96
|
// 不必逐个改调用点 —— 破坏格式的写入在这里被整理回标准形态(见 todo.js)。
|
|
79
|
-
|
|
97
|
+
//
|
|
98
|
+
// ★ 对三个受管文件,闸门**必须存在**:它们不是自由文本,是数据库表。
|
|
99
|
+
// 闸门缺失时以前会静默跳过(直写盘),现在改为自己补加载;补不上就硬拒,
|
|
100
|
+
// 宁可写失败也不要格崩坏——写不进去能重试,写脏了没人知道。
|
|
101
|
+
if (write) {
|
|
102
|
+
const gate = await gateFor(file);
|
|
103
|
+
if (gate) write = gate(file, write, current) ?? write;
|
|
104
|
+
}
|
|
80
105
|
if (write && write !== current) {
|
|
81
106
|
await fs.writeFile(file, write, 'utf8');
|
|
82
107
|
}
|
package/src/store.js
CHANGED
|
@@ -56,9 +56,11 @@ export async function cmdInit({ dir }) {
|
|
|
56
56
|
|
|
57
57
|
async function createSkeleton(root, brain) {
|
|
58
58
|
for (const d of BRAIN_DIRS) await fs.mkdir(join(brain, d), { recursive: true });
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// 走 editFile 而不是 fs.writeFile:三个文件是受管表,写入必须过同一道闸门
|
|
60
|
+
// (2026-10-18:原来直写盘,是个绕过口)。模板本身合法,闸门只做归一。
|
|
61
|
+
for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
|
|
62
|
+
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
63
|
+
}
|
|
62
64
|
// 建完就提示设姓名 —— 否则用户一路到第一次 todo add 才撞墙,
|
|
63
65
|
// 中间 init/load 都不提,根本不知道该设。
|
|
64
66
|
const who = await getUser();
|
|
@@ -116,7 +118,8 @@ export async function cmdRepair({ dir }) {
|
|
|
116
118
|
try {
|
|
117
119
|
await fs.access(join(brain, f));
|
|
118
120
|
} catch {
|
|
119
|
-
|
|
121
|
+
// 同 createSkeleton:补齐也走 editFile 的口子(已存在则 cur 非 null,不覆盖)
|
|
122
|
+
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
120
123
|
fixed.push(f);
|
|
121
124
|
}
|
|
122
125
|
}
|
|
@@ -983,6 +986,7 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
|
983
986
|
let changed = false;
|
|
984
987
|
let moved = null;
|
|
985
988
|
const kept = [];
|
|
989
|
+
const leftBehind = [];
|
|
986
990
|
const wantId = String(id).replace(/\u200b/g, '');
|
|
987
991
|
for (let i = 0; i < lines.length; i++) {
|
|
988
992
|
const l = lines[i];
|
|
@@ -1007,28 +1011,72 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
|
1007
1011
|
parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
|
|
1008
1012
|
const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
|
|
1009
1013
|
const bp = [];
|
|
1010
|
-
|
|
1011
|
-
//
|
|
1014
|
+
const leftover = [];
|
|
1015
|
+
// 子项搬运:已勾选的随任务进 Done;**未勾选的不搬**,留在 Todo 区。
|
|
1016
|
+
//
|
|
1017
|
+
// 为何(2026-10-18 实测):Done 区被 collapseDone 折叠成计数,搬进去即「看板里
|
|
1018
|
+
// 消失」。实测 factory-docs-slim 就带着「待做六链」的 `- [ ]` 落地,那条待办
|
|
1019
|
+
// 此后无处可寻,同时父任务盖着【落地】——看板与事实矛盾。
|
|
1020
|
+
//
|
|
1021
|
+
// 为何不是硬拦(A 方案被否):`abs todo note` 写的子项与断点记录**同形**(都是
|
|
1022
|
+
// ` - [ ] …`),无法从字段上区分「待办」与「过程记录」;硬拦会把所有带断点的
|
|
1023
|
+
// 任务一起拦住(实测弄挂两条既有测试)。改为「不搬 + 点名」,信息不丢且不误伤。
|
|
1012
1024
|
while (i + 1 < lines.length) {
|
|
1013
1025
|
const nx = lines[i + 1];
|
|
1014
1026
|
if (!nx.trim()) break;
|
|
1015
1027
|
const kid = parseChild(nx);
|
|
1016
|
-
if (kid && kid.indent > 0)
|
|
1017
|
-
|
|
1018
|
-
|
|
1028
|
+
if (kid && kid.indent > 0) {
|
|
1029
|
+
// 无论勾没勾都要 i++(否则原地打转 —— 2026-10-18 实测 OOM)
|
|
1030
|
+
const ch = lines[++i];
|
|
1031
|
+
if (kid.done) bp.push(ch);
|
|
1032
|
+
else leftover.push(kid.text);
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (nx.trimStart().startsWith('↳')) {
|
|
1036
|
+
const ch = lines[++i];
|
|
1037
|
+
if (/^\s*↳\s*\[ \]/.test(ch)) leftover.push(ch.trim().replace(/^\s*↳\s*\[ \]\s*/, ''));
|
|
1038
|
+
else bp.push(ch);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
break;
|
|
1019
1042
|
}
|
|
1020
1043
|
moved = [head, ...bp];
|
|
1044
|
+
// 还有未勾子项就**不许 done**(2026-10-18 用户定)。
|
|
1045
|
+
//
|
|
1046
|
+
// 规则:主任务与子任务都是任务,处理方式一样 —— 子任务也是看板上一行,
|
|
1047
|
+
// 也要自己完成。只有主子全部完成,主任务才能经 done。
|
|
1048
|
+
//
|
|
1049
|
+
// 为何不自己搬:上四版的四种做法全被否定——
|
|
1050
|
+
// ① 随父任务进 Done(原实现)→ Done 区被 collapseDone 折叠成计数,
|
|
1051
|
+
// 未勾子项进去即消失(实测 factory-docs-slim)
|
|
1052
|
+
// ② 升为顶层任务(<id>-restN)→ 用户:子任务不能升格
|
|
1053
|
+
// ③ 建占位主任务挂住 → 仍是无中生有,与 ② 同病
|
|
1054
|
+
// ④ 就这么留着不管 → 无主杂行,重建分区时被清掉
|
|
1055
|
+
// 正确做法是**不替用户决定**:告诉他还有哪些没完成,让他先勾或先删。
|
|
1056
|
+
if (leftover.length) {
|
|
1057
|
+
throw new Error(
|
|
1058
|
+
`✗ 任务 "${wantId}" 还有 ${leftover.length} 条子任务未完成,不能 done:\n` +
|
|
1059
|
+
leftover.map((p) => ` - [ ] ${p}`).join('\n') +
|
|
1060
|
+
`\n子任务也是任务,主子全部完成才能 done。先二选一:\n` +
|
|
1061
|
+
` • 做完了 → abs todo bp-done ${wantId} (勾最后一条;指定第 N 条:bp-done ${wantId} --n N)\n` +
|
|
1062
|
+
` • 不做了 → 删掉那几行子项,再 abs todo done ${wantId}`,
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1021
1065
|
continue;
|
|
1022
1066
|
}
|
|
1023
1067
|
kept.push(l);
|
|
1024
1068
|
}
|
|
1025
1069
|
if (!changed || !moved) return SKIP;
|
|
1026
1070
|
// 归位 + 按日期分组:insertDoneGrouped 一次重建 Done 区(新日期在前,未标日期归尾)
|
|
1027
|
-
return { text: insertDoneGrouped(kept.join('\n'), moved) };
|
|
1071
|
+
return { text: insertDoneGrouped(kept.join('\n'), moved), leftBehind };
|
|
1028
1072
|
});
|
|
1029
1073
|
return res === SKIP
|
|
1030
1074
|
? `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`
|
|
1031
|
-
: `✓ 已完成并归位 Done: ${id} 【${kind}
|
|
1075
|
+
: `✓ 已完成并归位 Done: ${id} 【${kind}】` +
|
|
1076
|
+
(res.leftBehind?.length
|
|
1077
|
+
? `\n ⚠ ${res.leftBehind.length} 条未勾子项留在 Todo 区(未跟入 Done,仍可在看板看到):\n` +
|
|
1078
|
+
res.leftBehind.map((p) => ` - [ ] ${p}`).join('\n')
|
|
1079
|
+
: '');
|
|
1032
1080
|
}
|
|
1033
1081
|
|
|
1034
1082
|
// ---------- show: 查看 index/todo/log(只读面) ----------
|
package/src/todo/spec.js
CHANGED
|
@@ -70,11 +70,28 @@ export const ENTRY_SHAPES = {
|
|
|
70
70
|
'index.md': [
|
|
71
71
|
{
|
|
72
72
|
name: 'INDEX-ENTRY',
|
|
73
|
-
// 条目行:`- [[页名]] — 一句话`(是
|
|
73
|
+
// 条目行:`- [[页名]] — 一句话`(是 5 个链接区里的一行)
|
|
74
|
+
//
|
|
75
|
+
// ★ 2026-10-18 补的白名单另一半:原规则只检查 `- ` 开头的行,**不以 `- ` 开头的
|
|
76
|
+
// 散文全部原样放行**。于是 Sources 区被塞进 `场景:…` 段落、裸编号列表项、
|
|
77
|
+
// `## 标题` 前缀也都存下来了(实测真库被污染 5 条,index 从 69 行膨到 88 行)。
|
|
78
|
+
// 链接区里只应有两种行:`- [[…]]` 条目、空行。其余一律拒。
|
|
74
79
|
test: (l) => {
|
|
75
|
-
if (
|
|
80
|
+
if (!l.trim()) return null; // 空行由 STRAY-BLANK 规则另判
|
|
81
|
+
if (!/^-\s/.test(l)) return '链接区只能放 `- [[页名]] — 一句话`(正文段落/编号列表/代码块都不行)';
|
|
76
82
|
if (!/^-\s*\[\[[^\]]+\]\]/.test(l)) return '应以 `- [[页名]] …` 开头';
|
|
77
83
|
if (!/^-\s*\[\[[^\]]+\]\]\s*[—-]\s*\S/.test(l)) return '`[[页名]]` 后要跟 `— 一句话` 描述';
|
|
84
|
+
// 摘要里不许带 Markdown 结构符(实测污染就是摘要取了页面正文开头)
|
|
85
|
+
if (/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*#+\s/.test(l)) return '摘要不能带 `## ` 标题前缀(取页面首行标题了)';
|
|
86
|
+
// ★ 2026-10-18 补:摘要正文本身必须是**纯文本一句话**。
|
|
87
|
+
// 原规则只拦了 `## ` 前缀,实测下列全放行:摘要写成 `- 列表项`(变成嵌套列
|
|
88
|
+
// 表)、```js(开代码围栏,把后面所有条目吞进去)、`| a | b |`(表格)。
|
|
89
|
+
// index 是表,摘要是一列纯文本,不能携带任何块级结构。
|
|
90
|
+
const desc = l.replace(/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*/, '');
|
|
91
|
+
if (/^(?:[-*+]\s|```|~~~|\||>\s|#{1,6}\s)/.test(desc)) {
|
|
92
|
+
return '摘要必须以文字开头(不能是列表项/代码围栏/表格/引用/标题)';
|
|
93
|
+
}
|
|
94
|
+
if (desc.includes('```')) return '摘要不能含代码围栏 ```(会吞掉后续所有条目)';
|
|
78
95
|
return null;
|
|
79
96
|
},
|
|
80
97
|
},
|
|
@@ -83,7 +100,15 @@ export const ENTRY_SHAPES = {
|
|
|
83
100
|
{
|
|
84
101
|
name: 'LOG-ENTRY',
|
|
85
102
|
test: (l) => {
|
|
86
|
-
|
|
103
|
+
// ★ 2026-10-18 补的白名单:原来只检查 `## ` 开头的行,**其余行全放行**,
|
|
104
|
+
// 于是裸散文/报告能直接写进 log(实测能落盘)。log 只应有四种行:
|
|
105
|
+
// H1、`## Log` 分区标题、`## [时间]` 条目、HTML 注释。
|
|
106
|
+
if (!l.trim()) return null; // 空行另由 STRAY-BLANK 判
|
|
107
|
+
if (/^#\s/.test(l)) return null; // H1
|
|
108
|
+
if (/^<!--/.test(l.trim())) return null; // 模板提示注释
|
|
109
|
+
if (/^##\s+Log\s*$/.test(l)) return null; // 唯一分区标题
|
|
110
|
+
if (!/^##\s/.test(l)) return 'log 里只能有 `## [时间] [[作者]] kind | 正文` 条目行(散文/报告请开一条条目)';
|
|
111
|
+
// 以下只对 `## ` 行判形状
|
|
87
112
|
// ★ log 没有分区概念:`## ` 开头的只能是条目。
|
|
88
113
|
// 否则 `## 2026-10-05 随便写点`(缺方括号/作者/竖线)会被当成「分区标题」滑过去 ——
|
|
89
114
|
// 而 log.md 的标签白名单是 null(不校验),这就是一个漏网口(实测)。
|
|
@@ -95,6 +120,9 @@ export const ENTRY_SHAPES = {
|
|
|
95
120
|
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
121
|
if (!m) return '应为 `## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文`(log 只能有这种行)';
|
|
97
122
|
if (!LOG_KINDS.includes(m[3])) return `kind 只能是 ${LOG_KINDS.join('/')}(收到 \`${m[3]}\`)`;
|
|
123
|
+
// ★ 2026-10-18:正文不能含代码围栏。一条 log 正文里出现 ``` 会把它之后的
|
|
124
|
+
// 所有条目都吞进代码块(渲染与 grep 双输),而这里本来就是单行条目。
|
|
125
|
+
if (m[4].includes('```')) return 'log 正文不能含 ``` —— 条目是单行摘要,代码请贴文件路径或拆成 source 页';
|
|
98
126
|
// 「正文只有个任务 id」不在这里拒(见下 lintOnly 规则):它是**内容质量**
|
|
99
127
|
// 问题不是**结构格式**问题,当写入闸门会把存量脏库锁死(实测:真库仅一条
|
|
100
128
|
// 就让所有 abs log 失败)。留在此处只会误伤正常短标题(如 `log-0`)。
|