@fanchao8609/agent_brain_sync 1.7.8 → 1.8.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.
- package/README.md +5 -3
- package/bin/abs.js +24 -34
- package/bin/mcp.js +102 -22
- package/hooks/abs.opencode.ts +60 -8
- package/hooks/abs.pi.ts +74 -13
- package/package.json +1 -1
- package/skill/SKILL.md +87 -20
- package/src/store.js +290 -129
- package/src/todo.js +81 -308
- package/src/wrapup.js +4 -0
package/src/store.js
CHANGED
|
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
|
|
|
4
4
|
import { join, resolve, dirname } from 'node:path';
|
|
5
5
|
import { requireBrain, brainPath, absLogDir, BRAIN_DIR } from './index.js';
|
|
6
6
|
import { requireUser, atTag, getUser } from './userconfig.js';
|
|
7
|
-
import { addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint,
|
|
7
|
+
import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, renderArchivePage, renderArchiveBody, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure } from './todo.js';
|
|
8
8
|
import { editFile, SKIP } from './lock.js';
|
|
9
9
|
import { appendWrapup, strandedFor } from './wrapup.js';
|
|
10
10
|
|
|
@@ -133,10 +133,7 @@ export function logTemplate() {
|
|
|
133
133
|
export const BRAIN_SHAPE = {
|
|
134
134
|
'todo.md': {
|
|
135
135
|
h1: '# 📋 Todo Board',
|
|
136
|
-
|
|
137
|
-
order: ['## Topics', '## Backlog', '## Today / In Progress', '## Blocked', '## Done'],
|
|
138
|
-
// 旧文件标题归一(已在 todo.js 的 LEGACY_SECTION_RENAMES 里给出,这里引用以免双源漂移)
|
|
139
|
-
renames: [['## Topic', '## Topics'], ['## 话题', '## Topics'], ['## 话题树', '## Topics'], ['## 分区', '## Topics']],
|
|
136
|
+
order: ['## Todo', '## Done'],
|
|
140
137
|
},
|
|
141
138
|
'log.md': {
|
|
142
139
|
h1: '# 🗒 Activity Log',
|
|
@@ -156,6 +153,13 @@ export const LEGACY_MARKS = [
|
|
|
156
153
|
['# 操作日志', '# 🗒 Activity Log'],
|
|
157
154
|
['# 图谱索引', '# 🗂 Graph Index'],
|
|
158
155
|
['# Todo 看板', '# 📋 Todo Board'],
|
|
156
|
+
// 四区 → 两区(2026-09-13 用户定)。**这条路径是 load 走的**(rebuildStructure),
|
|
157
|
+
// 与 todo.js 的 LEGACY_SECTION_RENAMES(normalizeTodo 用)是两条独立迁移路径 ——
|
|
158
|
+
// 只改一处会导致另一条认不出旧区名,把它们当"非标准分区"原样留末尾(未完成任务
|
|
159
|
+
// 留在文件里但不再被当 TODO)。测试 'load 也要能迁移' 钉住这一点。
|
|
160
|
+
['## Backlog', '## Todo'],
|
|
161
|
+
['## Today / In Progress', '## Todo'],
|
|
162
|
+
['## Blocked', '## Todo'],
|
|
159
163
|
['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
|
|
160
164
|
['### 归档', '### Archived'],
|
|
161
165
|
['### (未标日期)', '### Undated'],
|
|
@@ -207,9 +211,24 @@ export async function checkBrainShape(root) {
|
|
|
207
211
|
let changed = [];
|
|
208
212
|
await editFile(p, (cur) => {
|
|
209
213
|
if (cur === null) return SKIP;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
214
|
+
let next2 = cur;
|
|
215
|
+
if (spec.order.length) {
|
|
216
|
+
// todo.md 的四区 → 两区迁移**必须先走 normalizeTodo**:只有它知道
|
|
217
|
+
// `## Blocked` 区的任务该标 [滞留中](语义信息),而 rebuildStructure 只按
|
|
218
|
+
// renames 改标题、看不到来源分区,只能一律给 [进行中]。
|
|
219
|
+
// 2026-09-13 实测坑:cmdLoad 先跑 checkBrainShape、后跑 readTodo,于是
|
|
220
|
+
// normalizeTodo 的 [滞留中] 映射在 load 路径上永远走不到 → 卡住的任务
|
|
221
|
+
// 被静默标成进行中,两条路径语义不一致。
|
|
222
|
+
const srcText = file === 'todo.md' ? normalizeTodo(cur) : cur;
|
|
223
|
+
const r0 = rebuildStructure(srcText, { ...spec, renames: LEGACY_MARKS.filter(([o]) => o.startsWith('## ') || o.startsWith('### ')) });
|
|
224
|
+
// 兜底:仍无状态标记的未完成任务补默认值(新格式文件本就有标记,此处不触发)。
|
|
225
|
+
next2 = file === 'todo.md'
|
|
226
|
+
? r0.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
227
|
+
: r0.text;
|
|
228
|
+
} else {
|
|
229
|
+
next2 = fixMarks(cur, spec).text;
|
|
230
|
+
}
|
|
231
|
+
const r = { text: next2, changed: next2 === cur ? [] : ['结构按标准重排'] };
|
|
213
232
|
if (!r.changed.length) return SKIP;
|
|
214
233
|
changed = r.changed;
|
|
215
234
|
return { text: r.text };
|
|
@@ -252,29 +271,6 @@ async function listBrainFiles(root) {
|
|
|
252
271
|
}
|
|
253
272
|
|
|
254
273
|
// ---------- load: 开机读状态 ----------
|
|
255
|
-
/// 从看板文本里剔掉 `## Topics` 区段(已由「当前话题」单独渲染)。
|
|
256
|
-
/// 只删区标题到下一个 `## ` 之间;末尾多余空行一并收掉。
|
|
257
|
-
export function stripTopics(text) {
|
|
258
|
-
const lines = String(text ?? '').split('\n');
|
|
259
|
-
const at = lines.findIndex((l) => l.trim() === `## ${SEC.topics}`);
|
|
260
|
-
if (at === -1) return text;
|
|
261
|
-
let end = at + 1;
|
|
262
|
-
while (end < lines.length && !/^## /.test(lines[end])) end++;
|
|
263
|
-
lines.splice(at, end - at);
|
|
264
|
-
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** 「当前话题」一行文本 —— load 首屏用。只给最深的活话题 + 它的父链,
|
|
268
|
-
* 让人(和 AI)一眼看到“此刻在哪条线上”,而不摄入整棵树。 */
|
|
269
|
-
export function currentTopicText(text) {
|
|
270
|
-
const cur = currentTopic(text);
|
|
271
|
-
if (!cur) return '';
|
|
272
|
-
const byId = new Map(topicTree(text).map((n) => [n.id, n]));
|
|
273
|
-
const chain = [];
|
|
274
|
-
for (let n = cur; n; n = n.parent ? byId.get(n.parent) : null) chain.unshift(n);
|
|
275
|
-
return `⌖ ${chain.map((n) => `${n.id} ${n.title}`).join(' › ')}`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
274
|
export async function cmdLoad({ dir }) {
|
|
279
275
|
const root = await requireBrain(dir || process.cwd());
|
|
280
276
|
// 结构核对先跑:load 每回都要读这三个文件,顺手把它们形状摆正(缺分区)或提个醒(无头)。
|
|
@@ -310,15 +306,9 @@ export async function cmdLoad({ dir }) {
|
|
|
310
306
|
...(rulesSection(index) ? [rulesSection(index), ''] : []),
|
|
311
307
|
collapseIndex(index) || '(index.md 为空)',
|
|
312
308
|
'',
|
|
313
|
-
//
|
|
314
|
-
// 全貌用 `abs topic` 主动查——它是查看命令,不进开机首屏。
|
|
315
|
-
...(currentTopic(todo) ? ['--- 当前话题 (todo.md) ---', currentTopicText(todo), ''] : []),
|
|
316
|
-
// 开局三类(2026-09-13 用户澄清):①正在讨论=上面 Topics ②待办/已开工/已收尾=
|
|
317
|
-
// 下面 Board(Done 区就是已结束,折叠只是显示形式——归档才管前几天)。
|
|
318
|
-
// 曾加过「今日已结束」单列段:多余,撤掉(Done 区已表达同一信息)。
|
|
309
|
+
// 两区制:Todo(未完成,行首带状态标记)+ Done。
|
|
319
310
|
'--- Todo Board (todo.md) ---',
|
|
320
|
-
|
|
321
|
-
stripTopics(collapseDone(todo).text) || '(todo.md 为空)',
|
|
311
|
+
collapseDone(todo).text || '(todo.md 为空)',
|
|
322
312
|
'\n(Done 已按日期折叠计数;明细: abs todo --full)',
|
|
323
313
|
'',
|
|
324
314
|
'--- 最近动作 (log.md, 最新 5 条) ---',
|
|
@@ -393,9 +383,16 @@ export async function cmdRule({ dir, action, text }) {
|
|
|
393
383
|
}
|
|
394
384
|
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
|
395
385
|
if (!clean) return '用法: abs rule add "一句话硬规则"';
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
386
|
+
// 门槛:**纪律不是记事本**。Rules 是最前面的项目铁律,每条必须一眼扫完。
|
|
387
|
+
// 2026-09-13 用户定:不带链接、不带解释、不写细节 —— 细节进概念页,Rules 只留结论。
|
|
388
|
+
// 宽严:中文一字信息量大,按 40 字算(≈ 英文 80 字符的量)。实测现有 12 条最长 56 字符。
|
|
389
|
+
if (clean.length > 42) {
|
|
390
|
+
return `✗ 太长(${clean.length} > 42)—— Rules 是纪律不是记事本:\n`
|
|
391
|
+
+ ` 每条要一眼扫完。细节/出处/例子 → 写进 concepts/ 概念页,这里只留一句话结论。`;
|
|
392
|
+
}
|
|
393
|
+
if (/\[\[|\]\]|https?:\/\//.test(clean)) {
|
|
394
|
+
return '✗ 不带链接 —— Rules 区会被反复全量打印,链接占位且让纪律读起来像索引。\n'
|
|
395
|
+
+ ' 要挂概念页,去概念页自己的「## 关联连接」里挂。';
|
|
399
396
|
}
|
|
400
397
|
let added = null;
|
|
401
398
|
await editFile(p, (cur) => {
|
|
@@ -433,11 +430,164 @@ async function readFileOrNull(p) {
|
|
|
433
430
|
* 最大的单体膨胀源(Done 已折叠,见 collapseDone)。
|
|
434
431
|
*
|
|
435
432
|
* 续接真正需要的只是「有哪些分区、各多少页」(据此知道去哪找),不需要每页写了什么 ——
|
|
436
|
-
* 要那个用 `abs index
|
|
433
|
+
* 要那个用 `abs index`(完整原文件)或按词 `abs query`。
|
|
437
434
|
*
|
|
438
435
|
* `## Rules` 也已不在本函数输出——它由 rulesSection 在**上方**单独成段(需要正文);
|
|
439
436
|
* 这里再原样吐一遍 = 同一段硬规则在首屏出现两次(2026-09-13 实测发现)。
|
|
440
437
|
* 曾另有 `## Roadmap`:AI 自己写的方向总结,会被反复读到并带偏会话,已删。 */
|
|
438
|
+
// ---------- page id: 改名不改引用 ----------
|
|
439
|
+
// 问题:`.brain` 内部引用靠 [[slug]],而 slug 就是文件名 —— 改一次文件名,
|
|
440
|
+
// 所有指向它的链接静默变成 DEAD-LINK,只能靠 lint 事后抓。
|
|
441
|
+
// 解法(最小代价):页面 frontmatter 写一行 `id:`,建页时冻结。
|
|
442
|
+
// • 不发明新编号:id 默认等于建页时的 slug(人可读、可手写、无需迁移)
|
|
443
|
+
// • 旧页无 id → 回退用 slug,因此存量 31 页零迁移
|
|
444
|
+
// • 改名后 slug 变而 id 不变 → lint 报 ID-DRIFT,提示改成谁
|
|
445
|
+
// 为什么不上内容哈希/uuid:哈希一改内容就变(比文件名还不稳定),
|
|
446
|
+
// uuid 不可读不可手写且要全量迁移。slug 就是最合适的 id,只要不再跟文件名跑。
|
|
447
|
+
const ID_RE = /^id:\s*(.+)$/m;
|
|
448
|
+
|
|
449
|
+
/** 读页面 id;无 id 行则回退 slug(存量页零迁移)。 */
|
|
450
|
+
export function idOfPage(body, slug) {
|
|
451
|
+
const m = String(body || '').match(ID_RE);
|
|
452
|
+
return m ? m[1].trim() : slug;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 给存量页补 id(只在缺时写),落 frontmatter。返回 'added' | 'exists' | 'no-fm'。 */
|
|
456
|
+
export async function backfillPageId(full, slug) {
|
|
457
|
+
const res = await editFile(full, (cur) => {
|
|
458
|
+
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
459
|
+
if (ID_RE.test(cur.split('\n---')[0])) return SKIP; // 已有 id 不动
|
|
460
|
+
const end = cur.indexOf('\n---', 3);
|
|
461
|
+
if (end === -1) return SKIP;
|
|
462
|
+
return { text: `${cur.slice(0, end)}\nid: ${slug}${cur.slice(end)}` };
|
|
463
|
+
});
|
|
464
|
+
return res === SKIP ? 'exists' : 'added';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------- page status: 经验/知识页的生命周期 ----------
|
|
468
|
+
// 问题:经验写进去就永远躺在那里 —— 推翻时删不掉(skill 里写着"人工内容一律不覆盖",
|
|
469
|
+
// AI 不敢删)、读的时候又看不见(load 只给分区计数)→ 旧经验持续骗下一个会话。
|
|
470
|
+
// 解法:给已有的 `status:` 字段(字段本来就存在,20 页在用)定死三个值:
|
|
471
|
+
// active 当前有效(缺字段的默认值 —— 存量 22 页零迁移)
|
|
472
|
+
// superseded 已被推翻,别再依据它 —— 配 superseded-by 指向取代它的页
|
|
473
|
+
// draft 待核实(abs note 新落的经验就是这个)
|
|
474
|
+
// 关键:推翻 = 改一行 frontmatter,**不删文件不丢历史** —— AI 敢做,人也能反悔。
|
|
475
|
+
// 为什么不用新字段/新目录:字段已存在且有存量值,重命名会另起一套双轨(同 OPTS-DOUBLE-KEYS 之病)。
|
|
476
|
+
export const PAGE_STATUS = ['active', 'superseded', 'draft'];
|
|
477
|
+
const STATUS_RE = /^status:\s*(\S+)\s*$/m;
|
|
478
|
+
const SUPERSEDED_BY_RE = /^superseded-by:\s*(.+)$/m;
|
|
479
|
+
|
|
480
|
+
/** 读页面 status;无字段或是未知值时当 active(存量页零迁移)。 */
|
|
481
|
+
export function statusOfPage(body, frontmatter) {
|
|
482
|
+
const fm = frontmatter !== undefined
|
|
483
|
+
? frontmatter
|
|
484
|
+
: (String(body || '').match(/^---\n([\s\S]*?)\n---/) || ['', ''])[1];
|
|
485
|
+
const m = String(fm).match(STATUS_RE);
|
|
486
|
+
const v = m ? m[1].trim() : '';
|
|
487
|
+
return PAGE_STATUS.includes(v) ? v : 'active';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** 读 superseded-by(只在 status=superseded 时有意义)。无则空串。 */
|
|
491
|
+
export function supersededByOf(body) {
|
|
492
|
+
const m = String(body || '').match(SUPERSEDED_BY_RE);
|
|
493
|
+
return m ? m[1].trim() : '';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------- supersede: 标记一条经验被推翻(回退的写入端) ----------
|
|
497
|
+
// 为什么是标记而不是删除:
|
|
498
|
+
// ① 删除后下一个会话会重新踩同一个坑并重新记一遍(历史本身是资产)
|
|
499
|
+
// ② AI 不敢删(人工内容不覆盖),但敢改一行 frontmatter
|
|
500
|
+
// ③ 反悔只需把 status 改回 active
|
|
501
|
+
export async function cmdSupersede({ dir, refs, by }) {
|
|
502
|
+
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
503
|
+
if (!list.length) return '用法: abs supersede <页名或id> [更多…] [--by <取代它的页>] — 标记经验已失效(不删文件)';
|
|
504
|
+
let root;
|
|
505
|
+
try {
|
|
506
|
+
root = await requireBrain(dir || process.cwd());
|
|
507
|
+
} catch {
|
|
508
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
509
|
+
}
|
|
510
|
+
const byRef = String(by || '').trim();
|
|
511
|
+
// 取代者必须先存在 —— 否则写下一个永远悬空的引用(lint 会报,不如现在拒)。
|
|
512
|
+
if (byRef) {
|
|
513
|
+
const target = await resolvePage(root, byRef);
|
|
514
|
+
if (!target) return `✗ --by ${byRef}: 图谱里没有这页(先用 abs resolve 确认页名)`;
|
|
515
|
+
}
|
|
516
|
+
const out = [];
|
|
517
|
+
// 取代者 slug 在循环外解析一次(锁内 mutator 不能 await)
|
|
518
|
+
const bySlug = byRef ? (await resolvePage(root, byRef)).slug : '';
|
|
519
|
+
for (const r of list) {
|
|
520
|
+
const hit = await resolvePage(root, r);
|
|
521
|
+
if (!hit) { out.push(`✗ ${r}: 未找到(试 abs query <词> 或 abs index 看清单)`); continue; }
|
|
522
|
+
const res = await editFile(hit.full, (cur) => {
|
|
523
|
+
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
524
|
+
const end = cur.indexOf('\n---', 3);
|
|
525
|
+
if (end === -1) return SKIP;
|
|
526
|
+
let fm = cur.slice(0, end);
|
|
527
|
+
// 幂等:已是 superseded 且 superseded-by 一致 → 不写盘
|
|
528
|
+
const curSt = statusOfPage('', fm);
|
|
529
|
+
const curBy = supersededByOf(cur);
|
|
530
|
+
if (curSt === 'superseded' && curBy === bySlug) return SKIP;
|
|
531
|
+
// 去掉旧的 superseded-by(不论换不换取代者,旧值都作废)
|
|
532
|
+
fm = fm.replace(/\nsuperseded-by:.*(?=\n|$)/g, '');
|
|
533
|
+
fm = STATUS_RE.test(fm)
|
|
534
|
+
? fm.replace(STATUS_RE, 'status: superseded')
|
|
535
|
+
: `${fm}\nstatus: superseded`;
|
|
536
|
+
// superseded-by 用 slug(不是 id):人直接能按名找页,lint 能直接比对文件名
|
|
537
|
+
if (bySlug) fm = `${fm}\nsuperseded-by: ${bySlug}`;
|
|
538
|
+
return { text: fm + cur.slice(end) };
|
|
539
|
+
});
|
|
540
|
+
out.push(res === SKIP
|
|
541
|
+
? `= ${hit.slug}: 已是 superseded(无变化)`
|
|
542
|
+
: `✓ ${hit.slug} → superseded${byRef ? ` (被 [[${byRef}]] 取代)` : ''}`);
|
|
543
|
+
}
|
|
544
|
+
return out.join('\n');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** 把 id(或 slug)解析为页面路径。命中返回 {slug, dir, full, id},否则 null。 */
|
|
548
|
+
export async function resolvePage(root, idOrSlug) {
|
|
549
|
+
const want = String(idOrSlug || '').trim();
|
|
550
|
+
if (!want) return null;
|
|
551
|
+
const vault = brainPath(root);
|
|
552
|
+
for (const d of PAGE_DIRS) {
|
|
553
|
+
const p = join(vault, d);
|
|
554
|
+
let files;
|
|
555
|
+
try { files = await fs.readdir(p); } catch { continue; }
|
|
556
|
+
for (const f of files) {
|
|
557
|
+
if (!f.endsWith('.md') || f.startsWith('_')) continue;
|
|
558
|
+
const slug = f.replace(/\.md$/, '');
|
|
559
|
+
const full = join(p, f);
|
|
560
|
+
// slug 直接命中就够快(绝大多数调用走这条),不命中才去读 frontmatter 比 id
|
|
561
|
+
if (slug === want) return { slug, dir: d, full, id: want };
|
|
562
|
+
const body = await fs.readFile(full, 'utf8').catch(() => '');
|
|
563
|
+
const id = idOfPage(body, slug);
|
|
564
|
+
if (id === want) return { slug, dir: d, full, id };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------- resolve: id/slug → 页面路径(引用的反查端) ----------
|
|
571
|
+
// 配合 frontmatter 的 id: 使用。页改名后 id 不变,靠本命令仍能找回来。
|
|
572
|
+
export async function cmdResolve({ dir, refs }) {
|
|
573
|
+
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
574
|
+
if (!list.length) return '用法: abs resolve <id-or-slug> [更多…] — 按 id/页面名反查路径';
|
|
575
|
+
let root;
|
|
576
|
+
try {
|
|
577
|
+
root = await requireBrain(dir || process.cwd());
|
|
578
|
+
} catch {
|
|
579
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
580
|
+
}
|
|
581
|
+
const lines = [];
|
|
582
|
+
for (const r of list) {
|
|
583
|
+
const hit = await resolvePage(root, r);
|
|
584
|
+
lines.push(hit
|
|
585
|
+
? `✓ ${r} → ${BRAIN_DIR}/${hit.dir}/${hit.slug}.md${hit.id !== hit.slug ? ` (id=${hit.id})` : ''}`
|
|
586
|
+
: `✗ ${r}: 未找到(试 abs index 看完整清单,或 abs query <词> 全文搜)`);
|
|
587
|
+
}
|
|
588
|
+
return lines.join('\n');
|
|
589
|
+
}
|
|
590
|
+
|
|
441
591
|
export function collapseIndex(text) {
|
|
442
592
|
const s = String(text || '').trim();
|
|
443
593
|
if (!s) return '';
|
|
@@ -605,7 +755,7 @@ export async function cmdTeardownCheck({ dir, payload }) {
|
|
|
605
755
|
'0) 本机尚未设置使用者姓名 —— 先跑 abs config set user <你的名字>,否则下面 2/3/4 都会被拦下;',
|
|
606
756
|
]),
|
|
607
757
|
'[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:',
|
|
608
|
-
'1) 跑 abs load 看
|
|
758
|
+
'1) 跑 abs load 看 Todo 还有哪些未完成;',
|
|
609
759
|
'2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note "断点";',
|
|
610
760
|
'3) 值得留的经验 abs note "..."(宁少勿滥,能从代码 grep 到的不记);',
|
|
611
761
|
'4) abs log "完成 X:..." 记一行工作成果,新页同步进 index。',
|
|
@@ -702,15 +852,29 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
|
|
|
702
852
|
const who = await requireUser();
|
|
703
853
|
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
704
854
|
if (action === 'start') {
|
|
855
|
+
// 两区制:未完成一律进 Todo,行首带状态标记(默认 进行中)。
|
|
856
|
+
const st = section && TASK_STATES.includes(section) ? section : '进行中';
|
|
705
857
|
const r = await upsertTask(root, {
|
|
706
|
-
section:
|
|
707
|
-
text:
|
|
858
|
+
section: SEC.todo,
|
|
859
|
+
text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} (认领 ${today()})`,
|
|
708
860
|
});
|
|
709
|
-
return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n ${id} ${atTag(who)}${note ? ' — ' + note : ''}`;
|
|
861
|
+
return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}`;
|
|
710
862
|
}
|
|
711
|
-
if (action === '
|
|
712
|
-
|
|
713
|
-
|
|
863
|
+
if (action === 'state') {
|
|
864
|
+
// 改状态标记(原地,不搬区):进行中 / 讨论中 / 滞留中
|
|
865
|
+
if (!note) throw new Error(`✗ 用法: abs todo state <id> --note "${TASK_STATES.join('|')}"`);
|
|
866
|
+
if (!TASK_STATES.includes(note)) {
|
|
867
|
+
throw new Error(`✗ 状态只接受: ${TASK_STATES.join(' | ')}(收到 "${note}")`);
|
|
868
|
+
}
|
|
869
|
+
const f = brainPath(root, 'todo.md');
|
|
870
|
+
let cc = [];
|
|
871
|
+
await editFile(f, (cur) => {
|
|
872
|
+
if (cur === null) return SKIP;
|
|
873
|
+
const r = setStateMark(cur, id, note);
|
|
874
|
+
cc = r.changed;
|
|
875
|
+
return r.changed.length ? { text: r.text } : SKIP;
|
|
876
|
+
});
|
|
877
|
+
return cc.length ? `✓ ${id} 状态 → [${note}]` : `(未找到含 "${id}" 的未完成任务行,或状态未变)`;
|
|
714
878
|
}
|
|
715
879
|
if (action === 'note') {
|
|
716
880
|
if (!note) return '用法: abs todo note <id> --note "断点/进度"(实时落 ↳ 断点 行)';
|
|
@@ -741,7 +905,7 @@ async function markDone(file, id, kind = '落地') {
|
|
|
741
905
|
if (!moved && idOfTaskLine(l) === wantId) {
|
|
742
906
|
changed = true;
|
|
743
907
|
const head = withDoneKind(
|
|
744
|
-
l.replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`,
|
|
908
|
+
stripStateMark(l).replace('- [ ]', '- [x]').replace(/\(认领[^)]*\)/, '') + ` (完成 ${today()})`,
|
|
745
909
|
kind,
|
|
746
910
|
);
|
|
747
911
|
const bp = [];
|
|
@@ -783,7 +947,7 @@ export async function cmdShow({ dir, view, full }) {
|
|
|
783
947
|
// ---------- query: 检索知识图谱(多词 OR,扫全 .md 页) ----------
|
|
784
948
|
const KNOWN_SLUG_HINT = /模板残留|\[\[slug\]\]/;
|
|
785
949
|
|
|
786
|
-
export async function cmdQuery({ dir, terms }) {
|
|
950
|
+
export async function cmdQuery({ dir, terms, includeSuperseded }) {
|
|
787
951
|
const words = (terms || []).map((w) => String(w).trim()).filter(Boolean);
|
|
788
952
|
if (!words.length) {
|
|
789
953
|
return '用法: abs query <词1> [词2 …] — 多词 OR 检索 .brain/ 全部知识页';
|
|
@@ -813,20 +977,42 @@ export async function cmdQuery({ dir, terms }) {
|
|
|
813
977
|
// 作者从本页 frontmatter 读(权威来源)。不在 index 行里重复 ——
|
|
814
978
|
// index 行是覆盖式更新的,作者会从"创建者"漂成"最后改的人"。
|
|
815
979
|
const au = body.match(/^author:\s*(.+)$/m);
|
|
980
|
+
const st = statusOfPage(body);
|
|
981
|
+
// 被推翻的经验默认不出现在检索结果里 —— 它的存在意义是"别再用它",
|
|
982
|
+
// 而不是回答"我上次怎么解決 X"(那会拿到一个已知错误的答案)。
|
|
983
|
+
// 但它不是静默消失:计数里告知还有几条被隐藏(带 --all 能看)。
|
|
984
|
+
if (st === 'superseded' && !includeSuperseded) {
|
|
985
|
+
hits.push({ hidden: true, slug: f.replace(/\.md$/, '') });
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
816
988
|
hits.push({
|
|
817
989
|
full, slug: f.replace(/\.md$/, ''), matched,
|
|
818
990
|
author: au ? au[1].trim() : '',
|
|
991
|
+
id: idOfPage(body, f.replace(/\.md$/, '')),
|
|
992
|
+
status: st,
|
|
993
|
+
supersededBy: supersededByOf(body),
|
|
819
994
|
snippet: firstHitLine(body, words),
|
|
820
995
|
});
|
|
821
996
|
}
|
|
822
997
|
}
|
|
823
998
|
}
|
|
824
|
-
|
|
825
|
-
const
|
|
999
|
+
const hidden = hits.filter((h) => h.hidden).length;
|
|
1000
|
+
const shown = hits.filter((h) => !h.hidden);
|
|
1001
|
+
if (!shown.length) {
|
|
1002
|
+
const extra = hidden ? `(另外 ${hidden} 页已标记 superseded,用 abs query ${words.join(' ')} --all 查看)` : '';
|
|
1003
|
+
return `query [${words.join(', ')}]: 无命中。${extra}用 abs lint 看图谱健康;首次使用先 abs init。`;
|
|
1004
|
+
}
|
|
1005
|
+
const lines = shown.map((h) => {
|
|
826
1006
|
const by = h.author ? ` @${h.author}` : '';
|
|
827
|
-
|
|
1007
|
+
// id 只在≠slug 时显示 —— 相同时再印一遗就是纯噪音(绝大多数页)。
|
|
1008
|
+
// 目的:让 AI 拿到一个改名也不漂的引用句柄(abs resolve <id> 能反查回来)。
|
|
1009
|
+
const id = h.id && h.id !== h.slug ? ` [id: ${h.id}]` : '';
|
|
1010
|
+
// draft = 未经核实。不拦使用,但必须让 AI 知道这是它自己没验证过的。
|
|
1011
|
+
const st = h.status === 'draft' ? ' [draft 未核实]' : '';
|
|
1012
|
+
return `📄 ${h.slug}${by}${id}${st} (命中: ${h.matched.join(', ')})\n ${h.snippet}`;
|
|
828
1013
|
});
|
|
829
|
-
|
|
1014
|
+
const tail = hidden ? [``, `(${hidden} 页 superseded 已隐藏;--all 可看)`] : [];
|
|
1015
|
+
return [`query [${words.join(', ')}] → ${shown.length} 页:`, '', ...lines, ...tail].join('\n');
|
|
830
1016
|
}
|
|
831
1017
|
|
|
832
1018
|
function firstHitLine(body, words) {
|
|
@@ -872,6 +1058,7 @@ export async function cmdNote({ dir, text, tags }) {
|
|
|
872
1058
|
const body = [
|
|
873
1059
|
'---',
|
|
874
1060
|
`tags: [${fmTags}]`,
|
|
1061
|
+
`id: ${file.replace(/\.md$/, '')}`,
|
|
875
1062
|
`author: ${who}`,
|
|
876
1063
|
`updated: ${today()}`,
|
|
877
1064
|
'status: draft',
|
|
@@ -913,6 +1100,7 @@ export async function ensurePersonPage(root, name) {
|
|
|
913
1100
|
const body = [
|
|
914
1101
|
'---',
|
|
915
1102
|
'tags: [entity, person]',
|
|
1103
|
+
`id: ${nm}`,
|
|
916
1104
|
`author: ${nm}`,
|
|
917
1105
|
`updated: ${today()}`,
|
|
918
1106
|
'status: draft',
|
|
@@ -991,6 +1179,19 @@ export async function cmdLint({ dir }) {
|
|
|
991
1179
|
|
|
992
1180
|
for (const pg of pages) {
|
|
993
1181
|
if (!pg.hasFrontmatter) issues.push(`NO-FRONTMATTER: ${pg.rel}`);
|
|
1182
|
+
// ID-DRIFT: 页面写了 id 但已跟文件名(slug)不一致 = 改过名或改过 id。
|
|
1183
|
+
// 不是错误(id 就是用来固定身份的),但必须提示:[[slug]] 形式的引用指向的是**文件名**,
|
|
1184
|
+
// 改名后旧引用全变 DEAD-LINK;本检查让「静默断链」变成「一条可执行的提示」。
|
|
1185
|
+
// 优先看「有没有别的页 id 指向旧名」→ 那才是真正的改名现场。
|
|
1186
|
+
if (pg.hasFrontmatter) {
|
|
1187
|
+
const id = idOfPage(pg.body, pg.slug);
|
|
1188
|
+
// 反向查:有别的页声明 id = 本页 slug,说明本页是从那个 id 改名过来的。
|
|
1189
|
+
// 这种才是「改名没同步引用」的真信号;单纯 id≠slug 也可能只是手写的 id。
|
|
1190
|
+
if (id !== pg.slug) {
|
|
1191
|
+
issues.push(`ID-DRIFT: ${pg.rel} (frontmatter id=${id} ≠ 文件名 ${pg.slug};` +
|
|
1192
|
+
`引用请用 [[${id}]] 或改回文件名)`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
994
1195
|
for (const ln of pg.links) {
|
|
995
1196
|
// 两类都不是真链接,只报 TEMPLATE-LINK(且不短路就会再报一次 DEAD-LINK,同一条报两遍):
|
|
996
1197
|
// ① 模板占位: `[[页面名]]` / `[[slug]]` / `[[Name]]` —— 模板没填
|
|
@@ -1053,18 +1254,44 @@ export async function cmdLint({ dir }) {
|
|
|
1053
1254
|
}
|
|
1054
1255
|
}
|
|
1055
1256
|
|
|
1257
|
+
// SUPERSEDED-DANGLING: superseded 页声明的取代者也必须存在。
|
|
1258
|
+
// 它跟 DEAD-LINK 同性质(指向不存在的页),但后果更重:
|
|
1259
|
+
// 读者被引导去找一个不存在的"新版本",比单纯断链更容易让人以为"没新页就是没替代"。
|
|
1260
|
+
for (const pg of pages) {
|
|
1261
|
+
if (pg.status !== 'superseded') continue;
|
|
1262
|
+
if (!pg.supersededBy) continue; // 无取代者也是合法状态(就是弃用,没替代)
|
|
1263
|
+
const by = pg.supersededBy.replace(/^\[\[|\]\]$/g, '').trim();
|
|
1264
|
+
if (!names.has(by)) {
|
|
1265
|
+
issues.push(`SUPERSEDED-DANGLING: ${pg.rel} (superseded-by: ${by} —— 该页不存在,删页后未同步)`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// DRAFT-STALE: draft 停太久 = 既没核实也没被推翻,实质是写了没人看的堆积。
|
|
1270
|
+
// 不报 sources(它们由 SOURCE-UNDISTILLED 管),只报 concepts/entities/syntheses ——
|
|
1271
|
+
// 那些页是"应该已经被确认过"的长期资产,长期 draft 说明核实环节缺位。
|
|
1272
|
+
for (const pg of pages) {
|
|
1273
|
+
if (pg.status !== 'draft') continue;
|
|
1274
|
+
if (pg.dir === 'sources') continue;
|
|
1275
|
+
let ageMs = 0;
|
|
1276
|
+
try { ageMs = Date.now() - (await fs.stat(join(root, pg.rel))).mtimeMs; } catch { continue; }
|
|
1277
|
+
if (ageMs > DRAFT_STALE_DAYS * 86400 * 1000) {
|
|
1278
|
+
const days = Math.floor(ageMs / 86400000);
|
|
1279
|
+
issues.push(`DRAFT-STALE: ${pg.rel}(已 ${days} 天停在 draft;核实后改 status: active,推翻则 abs supersede)`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1056
1283
|
// Rules 区:它的价值在“少而重”,且不被折叠(load 每次都全量读)。
|
|
1057
1284
|
// 无上限增长 = 把 load 又撑回去(同 Done / index 清单的膨胀根因)。
|
|
1058
1285
|
{
|
|
1059
1286
|
const idxTxt = await readFileOrNull(join(vault, 'index.md'));
|
|
1060
1287
|
const { items, found } = readRules(idxTxt);
|
|
1061
1288
|
if (found && items.length > RULES_MAX) {
|
|
1062
|
-
issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX}
|
|
1289
|
+
issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${RULES_MAX};把长条目提炼成概念页,这里只留一句话`);
|
|
1063
1290
|
}
|
|
1064
1291
|
// 该区是 load 必读的硬规则清单,条目却写得像段落 → 提醒改短句。
|
|
1065
1292
|
const longOnes = items.filter((l) => l.trim().length > 160);
|
|
1066
1293
|
if (longOnes.length) {
|
|
1067
|
-
issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 160 字符(如 "${clip(longOnes[0].trim(), 40)}"
|
|
1294
|
+
issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 160 字符(如 "${clip(longOnes[0].trim(), 40)}");展开写进概念页,这里只留一句话(不带链接)`);
|
|
1068
1295
|
}
|
|
1069
1296
|
}
|
|
1070
1297
|
|
|
@@ -1133,6 +1360,9 @@ const PAGE_MAX_BYTES = 8 * 1024;
|
|
|
1133
1360
|
// source 页超龄未提炼的天数阈值(SOURCE-UNDISTILLED)。
|
|
1134
1361
|
// 7 天 = 跨过至少一个完整工作周还没人提炼,基本等于被遗忘。
|
|
1135
1362
|
const SOURCE_STALE_DAYS = 7;
|
|
1363
|
+
// draft 超龄阈值:新落的经验(abs note)默认 draft,指的是"还没核实过"。
|
|
1364
|
+
// 长期停在 draft = 既没被核实也没被推翻,属"写了没人看"的堆积 —— 不自动改,只报。
|
|
1365
|
+
const DRAFT_STALE_DAYS = 14;
|
|
1136
1366
|
|
|
1137
1367
|
async function listPages(vault) {
|
|
1138
1368
|
let indexText = '';
|
|
@@ -1167,79 +1397,10 @@ async function listPages(vault) {
|
|
|
1167
1397
|
lines: body.split('\n').length,
|
|
1168
1398
|
bytes: Buffer.byteLength(body, 'utf8'),
|
|
1169
1399
|
indexed: indexText.includes(`[[${f.replace(/\.md$/, '')}]]`),
|
|
1400
|
+
status: statusOfPage(body, fm ? fm[1] : ''),
|
|
1401
|
+
supersededBy: supersededByOf(body),
|
|
1170
1402
|
});
|
|
1171
1403
|
}
|
|
1172
1404
|
}
|
|
1173
1405
|
return pages;
|
|
1174
1406
|
}
|
|
1175
|
-
|
|
1176
|
-
/** abs topic —— 分区(话题树)读写。
|
|
1177
|
-
* 两个关键修正(2026-09-13 实测踩坑):
|
|
1178
|
-
* ① `id` 参数可能是“"#1" 标题”合一串 —— 必须切开,否则整串被当作 id,
|
|
1179
|
-
* upsert 找不到行 → 新插一行重复话题。
|
|
1180
|
-
* ② 未给标题时必须**保留现有标题**(不能回退成 id):
|
|
1181
|
-
* `abs topic conclude "#1"` 曾把标题写成 `#1` 并丢掉结论。 */
|
|
1182
|
-
export async function cmdTopic({ dir, action, id, title, state, conclusion, full, clear }) {
|
|
1183
|
-
const root = await requireBrain(dir || process.cwd());
|
|
1184
|
-
const p = brainPath(root, 'todo.md');
|
|
1185
|
-
if (!action || action === 'tree' || action === 'list') {
|
|
1186
|
-
const text = await readTodo(root);
|
|
1187
|
-
const t = topicTreeText(text);
|
|
1188
|
-
return t || '(无话题。登记: abs topic new "#1 标题")';
|
|
1189
|
-
}
|
|
1190
|
-
// 写操作:需姓名(与 todo 同守卫);姓名同时写进话题行([[name]])
|
|
1191
|
-
const who = await requireUser();
|
|
1192
|
-
await ensureTodo(root);
|
|
1193
|
-
// 拆 “#1 标题” → id / title(id 只取 # 开头的第一个 token)
|
|
1194
|
-
let useId = id;
|
|
1195
|
-
let useTitle = title;
|
|
1196
|
-
if (useId && !/^#[\w.]+$/.test(useId)) {
|
|
1197
|
-
const m = String(useId).match(/^(#[\w.]+)\s+(.*)$/);
|
|
1198
|
-
if (m) { useId = m[1]; useTitle = (useTitle ? m[2] + ' ' + useTitle : m[2]).trim(); }
|
|
1199
|
-
}
|
|
1200
|
-
if (!useId) throw new Error(`✗ 缺 <id>\n 用法: abs topic ${action} "#1 标题" [--state 状态] [--note 结论]`);
|
|
1201
|
-
if (!/^#[\w.]+$/.test(useId)) {
|
|
1202
|
-
throw new Error(`✗ id 需以 # 开头(如 #1 / #2.1),收到 "${useId}"`);
|
|
1203
|
-
}
|
|
1204
|
-
const st = state || (action === 'reject' ? '已否决' : action === 'conclude' ? '已结论' : action === 'done' ? '已落地' : '进行中');
|
|
1205
|
-
// `abs topic promote #N` —— 讨论成熟、要动手了 → 移进 Today(同 id 连续追踪)。
|
|
1206
|
-
// 不是复制两份:话题变身任务,Topics 退位。
|
|
1207
|
-
if (action === 'promote') {
|
|
1208
|
-
if (!useId) throw new Error('✗ 缺 <id>\n 用法: abs topic promote "#1" [--to Today]');
|
|
1209
|
-
const cur = await readTodo(root);
|
|
1210
|
-
const section = state || SEC.today; // --state 可指定目标分区(默认 Today)
|
|
1211
|
-
const r = promoteTopic(cur, useId, section);
|
|
1212
|
-
if (!r.changed.length) {
|
|
1213
|
-
return `• 话题 ${useId} 未登记(或目标分区 ${section} 不存在),未动`;
|
|
1214
|
-
}
|
|
1215
|
-
await editFile(p, () => ({ text: r.text }));
|
|
1216
|
-
return `✓ 话题 ${useId} 已移进 ${section}(同 id 连续追踪,不再是话题)`;
|
|
1217
|
-
}
|
|
1218
|
-
if (!TOPIC_STATES.includes(st)) {
|
|
1219
|
-
throw new Error(`✗ --state 只接受: ${TOPIC_STATES.join(' | ')}(收到 "${st}")`);
|
|
1220
|
-
}
|
|
1221
|
-
// 终止态不入 Topics 区(用户定:这里只放正在讨论的)。
|
|
1222
|
-
// 不默默拒绝而是给明确出路:先把结论落 sources/,再从树上摘掉。
|
|
1223
|
-
if (TOPIC_CLOSED_STATES.includes(st)) {
|
|
1224
|
-
const cur = await readTodo(root);
|
|
1225
|
-
const node = topicTree(cur).find((n) => n.id === useId);
|
|
1226
|
-
if (node) {
|
|
1227
|
-
await editFile(p, (c) => ({ text: removeTopicLine(c, useId) }));
|
|
1228
|
-
return (
|
|
1229
|
-
`✓ 话题 ${useId} [${st}] 已从 Topics 移出(终止态不留树上)\n` +
|
|
1230
|
-
` ⚠ 结论请用 \`abs note "..."\` 落 sources/,否则就丢了`
|
|
1231
|
-
);
|
|
1232
|
-
}
|
|
1233
|
-
return `• 话题 ${useId} [${st}] 未登记(终止态不入 Topics 区)\n ⚠ 结论请用 \`abs note "..."\` 落 sources/`;
|
|
1234
|
-
}
|
|
1235
|
-
const orig = await readTodo(root);
|
|
1236
|
-
// 未给标题 → 沿用现有行(“只改状态”场景)
|
|
1237
|
-
const existing = topicTree(orig).find((n) => n.id === useId);
|
|
1238
|
-
const title2 = useTitle || existing?.title;
|
|
1239
|
-
if (!title2) throw new Error(`✗ 话题 ${useId} 不存在,需给标题\n 用法: abs topic new "${useId} 标题"`);
|
|
1240
|
-
const concl = conclusion !== undefined ? conclusion : existing?.conclusion;
|
|
1241
|
-
const r = await editFile(p, (cur) => ({
|
|
1242
|
-
text: upsertTopicLine(cur, { id: useId, title: title2, state: st, conclusion: concl, author: who }).text,
|
|
1243
|
-
}));
|
|
1244
|
-
return `✓ 话题 ${useId} [${st}] → ${p}\n ${title2}${concl ? ' — ' + concl : ''}`;
|
|
1245
|
-
}
|