@fanchao8609/agent_brain_sync 1.20.1 → 1.22.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,199 @@
1
+ // src/brain/maintain.js — 维护类命令:滞留快照、Done 归档、收尾体检。
2
+ // 从 store.js 拆出(2026-11-01):都属"活干完了怎么收",与看板读写分开。
3
+ import { promises as fs } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { brainPath, requireBrain, absLogDir } from '../index.js';
6
+ import { editFile, SKIP } from '../lock.js';
7
+ import { appendWrapup } from '../wrapup.js';
8
+ import { readIfExists } from '../text.js';
9
+ import { getUser } from '../userconfig.js';
10
+ import { today, localStamp, archiveDoneInText, upsertArchiveSection, TODO_MAX_LINES } from '../todo.js';
11
+ export async function cmdWrapup({ dir }) {
12
+ const root = await requireBrain(dir || process.cwd());
13
+ const snap = await appendWrapup(root);
14
+ // 顺手做 Done 归档。**不引入 cron/定时器**:Stop hook 已经会在会话结束时调
15
+ // `abs wrapup`,直接复用这个触发点(零新基础设施、零新失败面)。
16
+ // 只对「超过保留天数 且 整天都已完成」的日期组动手,平时无动作。
17
+ // 本函数 stdout 会被 hook 追写到 ~/.abs/log/hooks.log → 归档动作自动留痕。
18
+ let arch = '';
19
+ try {
20
+ const a = await cmdTodoArchive({ dir: root, keepDays: 3 });
21
+ if (a.startsWith('✓')) arch = '\n' + a;
22
+ } catch { /* 归档失败不影响快照本身 */ }
23
+ return snap + arch;
24
+ }
25
+
26
+ /**
27
+ * `abs todo archive` —— 把 Done 区里「超过保留天数 且 整天都已完成」的日期组迁到归档页。
28
+ * 规则见 `archiveDoneInText`(① 只留近 N 天 ② 任一天有未完成则整天不归档 ③ 归档成一个文件
29
+ * 并在 Done 区尾部 `### 归档` 记 `- [[slug]] 完成任务 N 条`)。
30
+ * 幂等:同日重跑 = 追写到同一归档页 + 就地更新标记行。
31
+ */
32
+ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {}) {
33
+ let root;
34
+ try { root = await requireBrain(dir || process.cwd()); } catch { return '未找到 .brain/ 图谱。先在项目根运行: abs init'; }
35
+ // 坑(2026-10-05 实测):原写法 `Math.max(1, Number(keepDays) || 3)` 有两个问题 ——
36
+ // ① JS falsy 陷阱:`0 || 3` 得 3,于是 `--keep-days 0` 被当成"没传值",静默变成
37
+ // 保留 3 天(用户以为归档了全部,实际一条没动,且输出还理直气壮说"保留近 3 天")。
38
+ // ② `Math.max(1, ...)` 会静默把 0 抬成 1 —— 即使修好 ①,用户也不知道自己被改了。
39
+ // 改为:显式校验 + 明确报错。宁可让用户改参数,也不静默改写他的意图。
40
+ const rawDays = Number(keepDays);
41
+ if (!Number.isFinite(rawDays) || rawDays < 1 || !Number.isInteger(rawDays)) {
42
+ return `✗ --keep-days 需为 ≥1 的整数(收到 "${keepDays}")\n`
43
+ + ' 下限 1 天是为了不误删今天刚完成的记录;要清空 Done 请先确认后手动编辑 todo.md。';
44
+ }
45
+ const days = rawDays;
46
+ const todoP = brainPath(root, 'todo.md');
47
+ const raw = await fs.readFile(todoP, 'utf8').catch(() => '');
48
+ if (!raw.trim()) return '(todo.md 为空)';
49
+
50
+ const plan = archiveDoneInText(raw, { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
51
+ const why = plan.skipped.length
52
+ ? `\n 跳过: ${plan.skipped.map((s) => `${s.date}(${s.reason})`).join(';')}`
53
+ : '';
54
+ if (!plan.archived.length) return `(无可归档:保留近 ${days} 天)${why}`;
55
+ const brief = plan.archived.map((g) => `${g.date}(${g.count})`).join(' ');
56
+ if (dryRun) {
57
+ return `[dry-run] 将归档 ${plan.archived.length} 天 / ${plan.count} 条(每天一个文件)\n ${brief}${why}`;
58
+ }
59
+
60
+ // 1) 归档目标 = 当天的会话快照文件(log-<日期>.md),写进其「任务归档」段。
61
+ // 为何合一(2026-09-15 用户定): 归档与会话快照是**同一天的记录**,分两个文件查着要开两处。
62
+ // 边界: ① 文件不存在→建只有归档段的页(那天可能没写快照)
63
+ // ② 文件已存在(含 AI 手写快照)→ 只替换归档段,段外逐字保留
64
+ // (硬规则「已存在的人工内容一律不覆盖」由 upsertArchiveSection 保证)。
65
+ const sessDir = brainPath(root, 'sessions');
66
+ for (const g of plan.archived) {
67
+ const pageP = join(sessDir, `${g.slug}.md`);
68
+ let page = null;
69
+ try { page = await fs.readFile(pageP, 'utf8'); } catch { /* 首次 */ }
70
+ const nextPage = upsertArchiveSection(page, g);
71
+ const tmp = join(sessDir, `.${g.slug}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
72
+ await fs.writeFile(tmp, nextPage, 'utf8');
73
+ await fs.rename(tmp, pageP);
74
+ }
75
+
76
+ // 2) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖)
77
+ await editFile(todoP, (cur) => {
78
+ // 坑(2026-10-05 实测抓到): 这里漏传 maxLines → 锁内重算的 archived 为空 →
79
+ // 返回 SKIP → 磁盘上的 todo.md 一个字没动(而 sessions/ 归档页已经写了,
80
+ // 两边不一致:"说归档了但看板没变")。**同参数的每个调用点都要一起改**。
81
+ const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
82
+ return p2.archived.length ? { text: p2.text } : SKIP;
83
+ });
84
+
85
+ // 3) index 登记(每个日期页一行,幂等)
86
+ // 坑(2026-09-15 修): 原先把归档页登记到 `## Sources` 区 —— 但它们在 sessions/ 里,
87
+ // 旧数据实测就是错位的(09-10/09-12 两条曾键在 Sources 段,人工才发现)。现改登 `## Sessions`。
88
+ const iP = brainPath(root, 'index.md');
89
+ await editFile(iP, (index) => {
90
+ if (!index) return SKIP;
91
+ const missing = plan.archived.filter((g) => !index.includes(`[[${g.slug}]]`));
92
+ if (!missing.length) return SKIP;
93
+ const sIdx = index.indexOf('## Sessions');
94
+ if (sIdx === -1) return SKIP;
95
+ const after = index.indexOf('\n## ', sIdx + 1);
96
+ const add = missing.map((g) => `- [[${g.slug}]] — ${g.date}(会话快照 + 任务归档)`).join('\n');
97
+ const next = after === -1
98
+ ? `${index.replace(/\s*$/, '')}\n${add}\n`
99
+ : index.slice(0, after) + `\n${add}` + index.slice(after);
100
+ return { text: next };
101
+ });
102
+
103
+ const files = plan.archived.map((g) => `sessions/${g.slug}.md`).join('\n ');
104
+ return `✓ 已归档 ${plan.archived.length} 天 / ${plan.count} 条\n ${brief}\n → ${files}${why}`;
105
+ }
106
+
107
+ /**
108
+ * Stop hook 收尾注入判定 —— CC/Co​dex 的"主动推"。
109
+ * 与 pi 插件四条件一致: ①本会话真改过文件 ②有 .brain ③log.md 今日无条目 ④每会话一次。
110
+ * 输出给 shell: `push:<json>` = 注入, 其它 = 放行。JSON 走 stdout 回宿主的 decision:block。
111
+ * 所有异常一律放行(`{}`), 绝不因判定失败而卡住用户会话。
112
+ */
113
+ export async function cmdTeardownCheck({ dir, payload }) {
114
+ try {
115
+ let ev = {};
116
+ try { ev = JSON.parse(payload || '{}'); } catch { ev = {}; }
117
+
118
+ // 条件①: 本会话真改过文件。CC 的 Stop payload 不带工具足迹,
119
+ // 故以 transcript_path 为准; 取不到就退化为"本项目今日是否已有产物"判断。
120
+ const transcript = ev.transcript_path || ev.transcriptPath;
121
+ if (transcript) {
122
+ const txt = await readIfExists(transcript);
123
+ const wrote = /"(Write|Edit|MultiEdit|NotebookEdit)"/.test(txt);
124
+ if (!wrote) return '{}'; // 纯只读会话不打扰
125
+ }
126
+
127
+ // 条件②: 本项目有 .brain
128
+ const dir0 = dir || ev.cwd || process.cwd();
129
+ let root;
130
+ try { root = await requireBrain(dir0); } catch { return '{}'; }
131
+
132
+ // 条件③: log.md 今日尚无【工作成果】条目(条目头 '## [YYYY-MM-DD HH:MM] [[name]] dev |')。
133
+ // 坑(2026-09-13 实测, pi 侧先修): `abs note` 也写 log.md(kind=note),
134
+ // 旧判据只看「今天有没有行」会把「沉淀了一条经验」误判成「今天已收尾」→ 整天不再提醒。
135
+ // 三宿主(pi/CC/op​encode)判据必须一致 —— 见 [[hook-throttle-alignment]]。
136
+ const logTxt = await readIfExists(brainPath(root, 'log.md'));
137
+ const stamp = localStamp(); // 'YYYY-MM-DD HH:MM'
138
+ const day = stamp.slice(0, 10);
139
+ if (new RegExp('^## \\[' + day + ' \\d{2}:\\d{2}\\] (?:\\[\\[[^\\]]+\\]\\] )?dev \\|', 'm').test(logTxt)) return '{}';
140
+
141
+ // 条件④: 每会话一次。
142
+ // 双保险, 因为官方 stop_hook_active 有已知
143
+ // 不传播 bug(claude-code#54360): 同一 turn 内重复 fire 时它仍是 false。
144
+ // ① 官方契约: stop_hook_active=true = 本次 Stop 已是注入后的产物, 绝不再推(否则死循环)
145
+ if (ev.stop_hook_active === true) return '{}';
146
+ // ② 己方节流: 以 session_id 落 mark。缺 id 时退化为按项目+日期节流,
147
+ // 绝不"无节流"——否则一旦宿主张不到 id, decision:block 就会无限自激。
148
+ const sid = String(ev.session_id || ev.sessionId || '').replace(/[^\w-]/g, '');
149
+ // 兼底 key 只用【项目名】而非 full path。
150
+ // 坑(2026-09-16 实测): 原用 root.replace(/[^\w]/g,'_') 会把 cwd 的 tmp 路径
151
+ // (/var/folders/..._tmpXXXX) 也编进去 —— 而每个会话的 tmp 目录都不同,
152
+ // 于是"按项目+日期节流"根本没生效, 每次都新 key → ~/.abs/log/ 堆了 361 个 mark。
153
+ const projKey = root.split('/').filter(Boolean).pop() || 'unknown';
154
+ const key = sid || 'nosession-' + day + '-' + projKey;
155
+ const mark = join(absLogDir(), `teardown-${key}.mark`);
156
+ try {
157
+ await fs.access(mark);
158
+ return '{}'; // 本会话(或本项目今日)已推过
159
+ } catch { /* 未推过 */ }
160
+ await fs.mkdir(dirname(mark), { recursive: true });
161
+ await fs.writeFile(mark, stamp).catch(() => {});
162
+ // mark 只增不减会堆成垃圾(实测 361 个)。只清【带旧日期】的 mark ——
163
+ // 节流只在当天有意义(判据是 day), 旧日期 mark 不可能再命中;
164
+ // 无日期的会话 mark(teardown-<sid>.mark)保留, 它靠自身存在与否判重。
165
+ try {
166
+ for (const f of await fs.readdir(absLogDir())) {
167
+ const m = f.match(/^teardown-.*-(\d{4}-\d{2}-\d{2})-.*\.mark$/);
168
+ if (m && m[1] !== day) await fs.unlink(join(absLogDir(), f)).catch(() => {});
169
+ }
170
+ } catch { /* 清理失败不影响主流程 */ }
171
+
172
+ // 会话总结托底:算出「还有几场会话没总结」。
173
+ // 只给条数不给内容 —— 守「读取侧输出不得随规模增长」;要看明细人自己跑 abs summon。
174
+ // 计算失败一律吞掉:托底提示不能因为它自己算不出来就不提醒。
175
+ let pendingSessions = 0;
176
+ try {
177
+ const { scanProject } = await import('./summon.js');
178
+ const r = await scanProject(root);
179
+ pendingSessions = (r.fresh?.length || 0) + (r.changed?.length || 0);
180
+ } catch { /* 会话目录不存在 / 别的宿主 → 没有这项提示 */ }
181
+
182
+ const msg = [
183
+ // 未设姓名时把设置指令插到第0条 —— 否则后续 todo add/log/note 全会被守卫拦下。
184
+ (await getUser() ? [] : [
185
+ '0) 本机尚未设置使用者姓名 —— 先跑 abs config set user <你的名字>,否则 todo/log/note 都会被拦下;',
186
+ ]),
187
+ '[abs] 本会话改过文件,.brain/ 今日无记录。',
188
+ '这条是信息不是命令:该沉淀就沉淀,没有可沉淀的直接回一句「无可沉淀」,不用凑。',
189
+ '需要时:abs todo / abs todo done <id> / abs capture --layer N "..." / abs log "..."',
190
+ ...(pendingSessions ? [`另有 ${pendingSessions} 场历史会话从未总结(明细: abs summon)`] : []),
191
+ ].join('\n');
192
+
193
+ // Cl​aude Code Stop hook 契约: {"decision":"block","reason":"..."} = 阻止结束并把 reason 回灌给 agent
194
+ return 'push:' + JSON.stringify({ decision: 'block', reason: msg });
195
+ } catch {
196
+ return '{}'; // 永不阻塞宿主
197
+ }
198
+ }
199
+
@@ -0,0 +1,98 @@
1
+ // src/brain/rules.js — index.md 的 `## Rules` 区:读写与呈现。
2
+ // 从 store.js 拆出(2026-11-01):Rules 有自己的一条独立规则链(长度门槛、禁链接),
3
+ // 与建骨架/看板/任务无关。
4
+ import { brainPath, requireBrain } from '../index.js';
5
+ import { editFile, SKIP } from '../lock.js';
6
+ import { readIfExists } from '../text.js';
7
+
8
+ /** index.md 的 `## Rules` 区名与上限。 */
9
+ export const RULES_HEADING = '## Rules';
10
+ export const RULES_MAX = 30; // 超过就 lint 报:它属于“被读到才有价值”的区,不能无界增长
11
+ /** 单条上限(中文按字算)。2026-10-05 从 cmdRule 内联数字抽出:lint 也要查同一个值 ——
12
+ * 曾经只在「abs rule add」写入时校验,手工编辑 index.md 完全绕过,而 lint 只查条数不查长度
13
+ * → 89 字的条目静默通过,同一内容用 add 写反而被拒(两个口子标准不一致)。 */
14
+ export const RULE_BODY_MAX = 42;
15
+
16
+
17
+ /** 提取 index.md 里的 Rules 区条目(不含标题)。返回 { items:[行], body, found }。 */
18
+ export function readRules(indexText) {
19
+ const lines = String(indexText || '').split('\n');
20
+ const i = lines.findIndex((l) => l.trim() === RULES_HEADING);
21
+ if (i === -1) return { items: [], body: '', found: false };
22
+ const rest = lines.slice(i + 1);
23
+ const j = rest.findIndex((l) => /^##\s/.test(l));
24
+ const body = rest.slice(0, j === -1 ? rest.length : j);
25
+ return { items: body.filter((l) => l.trim().startsWith('- ')), body: body.join('\n').trim(), found: true };
26
+ }
27
+
28
+ /** load 里 Rules 的呈现:带独立段头,条目原样(不折、不截)。无条目则不占字节。 */
29
+ export function rulesSection(indexText) {
30
+ const { items, body, found } = readRules(indexText);
31
+ if (!found || !body) return '';
32
+ // 有引言句(> 开头)时一并带上,它解释了这个区是干什么的。
33
+ const intro = body.split('\n').filter((l) => l.trim().startsWith('>')).join('\n');
34
+ return [
35
+ `--- Rules (硬规则,先读, index.md) — ${items.length} 条 ---`,
36
+ intro,
37
+ ...items,
38
+ ].filter((x) => x !== '').join('\n');
39
+ }
40
+
41
+ /**
42
+ * `abs rule` —— 读写 index.md 的 Rules 区。
43
+ * 无参 = 列出(只给规则,不被 load 的其它内容占上下文)。
44
+ * add <一句话> = 追加一条(走锁写、幂等去重)。
45
+ * 门槛:只该放“违反会丢数据/静默失效/白干活”级规律;长句会被拒并指向概念页。
46
+ */
47
+ export async function cmdRule({ dir, action, text }) {
48
+ let root;
49
+ try { root = await requireBrain(dir || process.cwd()); } catch {
50
+ return '未找到 .brain/ 图谱。先在项目根运行: abs init';
51
+ }
52
+ const p = brainPath(root, 'index.md');
53
+ if (!action || action === 'list' || action === 'show') {
54
+ const { items, found } = readRules(await readIfExists(p));
55
+ if (!found) return `index.md 无 \`${RULES_HEADING}\` 区(跑 abs load 会自动补位)`;
56
+ if (!items.length) return `${RULES_HEADING} 区为空(add "一句话" 追加)`;
57
+ return [`${RULES_HEADING} — ${items.length} 条:`, ...items].join('\n');
58
+ }
59
+ if (action !== 'add') {
60
+ return `用法: abs rule 列出硬规则\n abs rule add "一句话" [--note "补充"]`;
61
+ }
62
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
63
+ if (!clean) return '用法: abs rule add "一句话硬规则"';
64
+ // 门槛:**纪律不是记事本**。Rules 是最前面的项目铁律,每条必须一眼扫完。
65
+ // 2026-09-13 用户定:不带链接、不带解释、不写细节 —— 细节进概念页,Rules 只留结论。
66
+ // 宽严:中文一字信息量大,按 40 字算(≈ 英文 80 字符的量)。实测现有 12 条最长 56 字符。
67
+ if (clean.length > RULE_BODY_MAX) {
68
+ return `✗ 太长(${clean.length} > ${RULE_BODY_MAX})—— Rules 是纪律不是记事本:\n`
69
+ + ` 每条要一眼扫完。细节/出处/例子 → 写进 concepts/ 概念页,这里只留一句话结论。`;
70
+ }
71
+ if (/\[\[|\]\]|https?:\/\//.test(clean)) {
72
+ return '✗ 不带链接 —— Rules 区会被反复全量打印,链接占位且让纪律读起来像索引。\n'
73
+ + ' 要挂概念页,去概念页自己的「## 关联连接」里挂。';
74
+ }
75
+ let added = null;
76
+ await editFile(p, (cur) => {
77
+ if (cur === null) return SKIP;
78
+ const lines = cur.split('\n');
79
+ const i = lines.findIndex((l) => l.trim() === RULES_HEADING);
80
+ if (i === -1) return SKIP; // 无该区不擅自建(load 会补位)
81
+ if (lines.some((l) => l.trim() === `- ${clean}`)) return SKIP; // 幂等:同句不重复
82
+ // 插在该区最后一条条目之后(保序、不搅动其它条目)
83
+ let at = i + 1;
84
+ for (let k = i + 1; k < lines.length; k++) {
85
+ if (/^##\s/.test(lines[k])) break;
86
+ if (lines[k].trim().startsWith('- ')) at = k + 1;
87
+ }
88
+ lines.splice(at, 0, `- ${clean}`);
89
+ added = clean;
90
+ return { text: lines.join('\n') };
91
+ });
92
+ if (added === null) return `• 已有同句或 index.md 无 ${RULES_HEADING} 区(跳过)`;
93
+ const { items } = readRules(await readIfExists(p));
94
+ const warn = items.length > RULES_MAX
95
+ ? `\n⚠ Rules 已 ${items.length} 条 > ${RULES_MAX}:考虑把其中几条提炼成概念页(跑 abs lint 会报)`
96
+ : '';
97
+ return `✓ 已加硬规则(共 ${items.length} 条)\n - ${clean}${warn}`;
98
+ }
@@ -0,0 +1,102 @@
1
+ // src/brain/shape.js — .brain/ 三个受管文件的标准形状 + 结构核对。
2
+ // 从 store.js 拆出(2026-11-01):init 与 load 共用同一份形状表,
3
+ // 单独立文件避免二者互相 import。
4
+ import { promises as fs } from 'node:fs';
5
+ import { brainPath } from '../index.js';
6
+ import { editFile, SKIP } from '../lock.js';
7
+ import { enforceBrainFormat, ensureStateMark } from '../todo.js';
8
+ import { clip } from '../text.js';
9
+
10
+ /** 标准形状表(与 indexTemplate/logTemplate/todoTemplate 同源)。
11
+ * 标记不一致 = 直接改成标准(“能自己处理的先处理”)。
12
+ * 只按**整行精确匹配**改标题,绝不动正文 —— 不做模糊替换,否则正文里提到的旧名会被误改。 */
13
+ // sources/ 堆积阀值:超过就是「采了没消化」。lint 报 SOURCES-PILED-UP,load 顶部同步提示。
14
+ // 两处共用同一常量 —— 阀值只有一个真源(SOURCES_MAX 在 lint.js 定义,此处导入使用)。
15
+
16
+ export const BRAIN_SHAPE = {
17
+ 'todo.md': {
18
+ h1: '# 📋 Todo Board',
19
+ order: ['## Todo', '## Done'],
20
+ },
21
+ 'log.md': {
22
+ h1: '# 🗒 Activity Log',
23
+ order: [], // log 无固定分区(条目行自带时间,倒序)
24
+ },
25
+ 'index.md': {
26
+ h1: '# 🗂 Graph Index',
27
+ order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
28
+ },
29
+ };
30
+
31
+ /** 旧标记 → 标准标记。load 发现就改(幂等)。含 H1 与分区/分组标题的旧名。 */
32
+ export const LEGACY_MARKS = [
33
+ ['# 🗂 图谱索引', '# 🗂 Graph Index'],
34
+ ['# 🗒 操作日志', '# 🗒 Activity Log'],
35
+ ['# 📋 Todo 看板', '# 📋 Todo Board'],
36
+ ['# 操作日志', '# 🗒 Activity Log'],
37
+ ['# 图谱索引', '# 🗂 Graph Index'],
38
+ ['# Todo 看板', '# 📋 Todo Board'],
39
+ // 四区 → 两区(2026-09-13 用户定)。**这条路径是 load 走的**(rebuildStructure),
40
+ // 与 todo.js 的 LEGACY_SECTION_RENAMES(normalizeTodo 用)是两条独立迁移路径 ——
41
+ // 只改一处会导致另一条认不出旧区名,把它们当"非标准分区"原样留末尾(未完成任务
42
+ // 留在文件里但不再被当 TODO)。测试 'load 也要能迁移' 钉住这一点。
43
+ ['## Backlog', '## Todo'],
44
+ ['## Today / In Progress', '## Todo'],
45
+ ['## Blocked', '## Todo'],
46
+ ['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
47
+ ['### 归档', '### Archived'],
48
+ ['### (未标日期)', '### Undated'],
49
+ ];
50
+
51
+ /** 全套旧标记 → 标准标记(含 H1)。shape 核对与 gate 写入两处共用,故放在这里。 */
52
+ export const legacyRenames = () => LEGACY_MARKS;
53
+
54
+ /**
55
+ * 核对 index/log/todo 的结构,不符就按标准重建(B 档:重排分区 + 内容按归属回填)。
56
+ * 它每次 load 都跑,所以改动立即生效,不必等额外命令。
57
+ *
58
+ * 边界(内容安全):
59
+ * - 只动**标题行位置**与缺失分区的空位,已有内容行按原归属搬运,不改写;
60
+ * - **非标准分区原样留在末尾**(人自加的区不合入,机器不猜语义);
61
+ * - log.md 不做重排(无固定分区,条目自带时间倒序);
62
+ * - 锁内重算 + “改了才写盘”,幂等。
63
+ *
64
+ * 不抛错(load 不能因核对失败而挂)。返回 { fixed:[描述], warn:[描述] }。
65
+ */
66
+ export async function checkBrainShape(root) {
67
+ const fixed = [];
68
+ const warn = [];
69
+ for (const [file, spec] of Object.entries(BRAIN_SHAPE)) {
70
+ const p = brainPath(root, file);
71
+ let text;
72
+ try { text = await fs.readFile(p, 'utf8'); } catch { continue; }
73
+ if (!text.trim()) continue;
74
+ const l1 = (text.split('\n')[0] || '').trim();
75
+ const knownH1 = l1 === spec.h1 || LEGACY_MARKS.some(([o]) => o === l1 && o.startsWith('# '));
76
+ let changed = [];
77
+ await editFile(p, (cur) => {
78
+ if (cur === null) return SKIP;
79
+ // 格式闸门收口在一处(见 todo.js enforceBrainFormat):样式/空行/无头 三条规则
80
+ // 与写入侧共用同一实现,写盘前顺手把破坏格式的部分整理回标准形态。
81
+ // 走 normalizeTodo 的语义(Blocked → [滞留中])由 enforceBrainFormat 内部保证。
82
+ const r = enforceBrainFormat(cur, { ...spec, renames: legacyRenames() });
83
+ // 兜底:仍无状态标记的未完成任务补默认值(新格式文件本就有标记,此处不触发)。
84
+ const next2 = file === 'todo.md'
85
+ ? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
86
+ : r.text;
87
+ if (next2 === cur) return SKIP;
88
+ changed = r.fixed.length ? r.fixed : ['结构按标准重排'];
89
+ return { text: next2 };
90
+ }).catch((e) => {
91
+ // 不能吞:load 是开机第一入口,静默跳过会让人以为库是健康的。
92
+ // 典型触发:文件里有孤儿子任务(真错,不修而报)。
93
+ // 只报**具体问题行**(以 `·` 开头的那些),不把整个错误头拼进去。
94
+ const msg = e && e.message ? e.message : '未知错误';
95
+ const detail = msg.split('\n').filter((x) => x.trim().startsWith('·')).map((x) => x.trim().slice(1).trim());
96
+ warn.push(`${file}: 未能自动归一 —— ${detail.length ? detail.join('; ') : msg.split('\n')[0]}`.slice(0, 200));
97
+ });
98
+ if (changed.length) fixed.push(`${file}: ${changed.join('; ')}`);
99
+ if (!knownH1) warn.push(`${file}: 标题非标准(读到 "${clip(l1, 24) || '(空)'}")`);
100
+ }
101
+ return { fixed, warn };
102
+ }
@@ -0,0 +1,62 @@
1
+ // src/brain/summon-cmd.js — `abs summon` 命令族:会话总结的托底扫描。
2
+ //
3
+ // 定位:正常路径是收尾时 `abs capture` / `abs log`。这条路被跳过时,本命令把
4
+ // 「有实质内容但还没总结」的会话提出来。它是托底,不是替代。
5
+ //
6
+ // 三个动作:
7
+ // abs summon 扫当前项目,列出未总结 / 有更新的会话(只读,不写盘)
8
+ // abs summon --mark 把本次列出的记为「已提取」,下次不再列
9
+ // abs summon --status 看账本统计
10
+ import { resolve } from 'node:path';
11
+ import { findBrainRoot } from '../index.js';
12
+ import {
13
+ scanProject, renderScan, readLedger, writeLedger, markSummarized, ledgerPath,
14
+ } from './summon.js';
15
+
16
+ /** 解析项目根:显式 --dir 优先,否则用当前目录的图谱根。 */
17
+ async function resolveRoot(dir) {
18
+ if (dir) {
19
+ const r = await findBrainRoot(resolve(dir));
20
+ if (!r) throw new Error(`✗ ${resolve(dir)} 下没有 .brain/ 图谱`);
21
+ return r;
22
+ }
23
+ const here = await findBrainRoot(process.cwd());
24
+ if (!here) throw new Error('✗ 当前目录没有 .brain/ 图谱(用 --dir 指定,或先 abs init)');
25
+ return here;
26
+ }
27
+
28
+ export async function cmdSummon({ dir, mark = false, full = false, limit = 15, status = false } = {}) {
29
+ const path = ledgerPath();
30
+
31
+ if (status) {
32
+ const book = await readLedger(path);
33
+ const n = Object.keys(book.files).length;
34
+ const days = new Set(Object.values(book.files).map((r) => r.day).filter(Boolean));
35
+ return [
36
+ `📒 总结账本: ${path}`,
37
+ `已记录 ${n} 个会话,跨 ${days.size} 天`,
38
+ n ? `最近一条: ${Object.entries(book.files).sort((a, b) => (b[1].summarizedAt || '').localeCompare(a[1].summarizedAt || ''))[0].map((x) => Array.isArray(x) ? JSON.stringify(x).slice(0, 90) : x).join(' · ')}` : '',
39
+ ].filter(Boolean).join('\n');
40
+ }
41
+
42
+ let root;
43
+ try { root = await resolveRoot(dir); } catch (e) { return e.message; }
44
+
45
+ const book = await readLedger(path);
46
+ const result = await scanProject(root, { ledger: book });
47
+
48
+ const pending = [...result.fresh, ...result.changed];
49
+ if (!mark) return renderScan(result, { full, limit });
50
+
51
+ // --mark:记为已提取。不写知识页 —— 那是人的判断,不是本命令的。
52
+ if (!pending.length) return '✓ 没有待提取的会话,账本未改动。';
53
+ markSummarized(book, pending);
54
+ await writeLedger(book, path);
55
+ return [
56
+ `✓ 已记账 ${pending.length} 个会话(下次不再列出)`,
57
+ ` 账本: ${path}`,
58
+ '',
59
+ '注意:这一步只记「这些会话我提过了」,**没有写任何知识页**。',
60
+ '要沉淀的仍要显式落: abs capture --layer N "…" / abs log "…"',
61
+ ].join('\n');
62
+ }