@fanchao8609/agent_brain_sync 1.15.3 → 1.16.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/README.md +1 -1
- package/bin/abs.js +8 -11
- package/bin/mcp.js +7 -7
- package/hooks/abs.pi.ts +121 -4
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +1 -1
- package/src/lint.js +6 -7
- package/src/note.js +275 -0
- package/src/page.js +263 -0
- package/src/store.js +67 -552
- package/src/text.js +37 -0
- package/src/todo.js +23 -4
package/src/store.js
CHANGED
|
@@ -4,11 +4,30 @@ 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, placeholderWarn } from './userconfig.js';
|
|
7
|
-
import { stripStateMark, ensureStateMark, normalizeTodo, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, LOG_KINDS, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure, enforceBrainFormat } from './todo.js';
|
|
7
|
+
import { stripStateMark, ensureStateMark, stateOfTaskLine, normalizeTodo, TODO_MAX_LINES, addTask, upsertTask, boardText, readTodo, ensureTodo, todoTemplate, today, localStamp, setBreakpoint, setStateMark, TASK_STATES, LOG_KINDS, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure, enforceBrainFormat } from './todo.js';
|
|
8
8
|
import { setFormatGate, editFile, SKIP } from './lock.js';
|
|
9
9
|
import { appendWrapup, strandedFor } from './wrapup.js';
|
|
10
10
|
import { keywords, pickRelevant, renderRelevant, recentFiles, rankPage, topicStrength } from './relevant.js';
|
|
11
11
|
import { impactOf } from './codegraph.js';
|
|
12
|
+
// 纯文本工具已拆到 text.js(2026-10-05)—— 零依赖,note/log/concept 共用。
|
|
13
|
+
import { clip, slugOf } from './text.js';
|
|
14
|
+
export { clip, slugOf } from './text.js';
|
|
15
|
+
|
|
16
|
+
// 页面生命周期已拆到 page.js(2026-10-05)。
|
|
17
|
+
// 必须【先 import 再 export】—— 只写 `export {x} from` 只是转发,本文件作用域里
|
|
18
|
+
// 拿不到 x,内部调用会 ReferenceError(拆完先跑测试就抓到了这个)。
|
|
19
|
+
import {
|
|
20
|
+
idOfPage, backfillPageId, statusOfPage, supersededByOf,
|
|
21
|
+
cmdSupersede, resolvePage, cmdReview, cmdResolve, collapseIndex,
|
|
22
|
+
} from './page.js';
|
|
23
|
+
export {
|
|
24
|
+
idOfPage, backfillPageId, PAGE_STATUS, statusOfPage, supersededByOf,
|
|
25
|
+
cmdSupersede, resolvePage, cmdReview, cmdResolve, collapseIndex,
|
|
26
|
+
} from './page.js';
|
|
27
|
+
|
|
28
|
+
// 经验暂存/知识页脚手架已拆到 note.js(2026-10-05)—— 同样先 import 再 export。
|
|
29
|
+
import { cmdLog, cmdNote, cmdConcept, ensurePersonPage, registerInIndex } from './note.js';
|
|
30
|
+
export { cmdLog, cmdNote, cmdConcept, ensurePersonPage, registerInIndex } from './note.js';
|
|
12
31
|
|
|
13
32
|
// Re-export lint.js symbols so external importers (e.g. bin/mcp.js, bin/abs.js) still work.
|
|
14
33
|
export { cmdLint, listPages, hasTail, PAGE_DIRS, foldSameKind } from './lint.js';
|
|
@@ -504,6 +523,10 @@ async function sourcesCountSection(root) {
|
|
|
504
523
|
/** index.md 的 `## Rules` 区名与上限。 */
|
|
505
524
|
export const RULES_HEADING = '## Rules';
|
|
506
525
|
export const RULES_MAX = 30; // 超过就 lint 报:它属于“被读到才有价值”的区,不能无界增长
|
|
526
|
+
/** 单条上限(中文按字算)。2026-10-05 从 cmdRule 内联数字抽出:lint 也要查同一个值 ——
|
|
527
|
+
* 曾经只在「abs rule add」写入时校验,手工编辑 index.md 完全绕过,而 lint 只查条数不查长度
|
|
528
|
+
* → 89 字的条目静默通过,同一内容用 add 写反而被拒(两个口子标准不一致)。 */
|
|
529
|
+
export const RULE_BODY_MAX = 42;
|
|
507
530
|
|
|
508
531
|
|
|
509
532
|
/** 提取 index.md 里的 Rules 区条目(不含标题)。返回 { items:[行], body, found }。 */
|
|
@@ -556,8 +579,8 @@ export async function cmdRule({ dir, action, text }) {
|
|
|
556
579
|
// 门槛:**纪律不是记事本**。Rules 是最前面的项目铁律,每条必须一眼扫完。
|
|
557
580
|
// 2026-09-13 用户定:不带链接、不带解释、不写细节 —— 细节进概念页,Rules 只留结论。
|
|
558
581
|
// 宽严:中文一字信息量大,按 40 字算(≈ 英文 80 字符的量)。实测现有 12 条最长 56 字符。
|
|
559
|
-
if (clean.length >
|
|
560
|
-
return `✗ 太长(${clean.length} >
|
|
582
|
+
if (clean.length > RULE_BODY_MAX) {
|
|
583
|
+
return `✗ 太长(${clean.length} > ${RULE_BODY_MAX})—— Rules 是纪律不是记事本:\n`
|
|
561
584
|
+ ` 每条要一眼扫完。细节/出处/例子 → 写进 concepts/ 概念页,这里只留一句话结论。`;
|
|
562
585
|
}
|
|
563
586
|
if (/\[\[|\]\]|https?:\/\//.test(clean)) {
|
|
@@ -605,255 +628,6 @@ async function readFileOrNull(p) {
|
|
|
605
628
|
* `## Rules` 也已不在本函数输出——它由 rulesSection 在**上方**单独成段(需要正文);
|
|
606
629
|
* 这里再原样吐一遍 = 同一段硬规则在首屏出现两次(2026-09-13 实测发现)。
|
|
607
630
|
* 曾另有 `## Roadmap`:AI 自己写的方向总结,会被反复读到并带偏会话,已删。 */
|
|
608
|
-
// ---------- page id: 改名不改引用 ----------
|
|
609
|
-
// 问题:`.brain` 内部引用靠 [[slug]],而 slug 就是文件名 —— 改一次文件名,
|
|
610
|
-
// 所有指向它的链接静默变成 DEAD-LINK,只能靠 lint 事后抓。
|
|
611
|
-
// 解法(最小代价):页面 frontmatter 写一行 `id:`,建页时冻结。
|
|
612
|
-
// • 不发明新编号:id 默认等于建页时的 slug(人可读、可手写、无需迁移)
|
|
613
|
-
// • 旧页无 id → 回退用 slug,因此存量 31 页零迁移
|
|
614
|
-
// • 改名后 slug 变而 id 不变 → lint 报 ID-DRIFT,提示改成谁
|
|
615
|
-
// 为什么不上内容哈希/uuid:哈希一改内容就变(比文件名还不稳定),
|
|
616
|
-
// uuid 不可读不可手写且要全量迁移。slug 就是最合适的 id,只要不再跟文件名跑。
|
|
617
|
-
const ID_RE = /^id:\s*(.+)$/m;
|
|
618
|
-
|
|
619
|
-
/** 读页面 id;无 id 行则回退 slug(存量页零迁移)。 */
|
|
620
|
-
export function idOfPage(body, slug) {
|
|
621
|
-
const m = String(body || '').match(ID_RE);
|
|
622
|
-
return m ? m[1].trim() : slug;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/** 给存量页补 id(只在缺时写),落 frontmatter。返回 'added' | 'exists' | 'no-fm'。 */
|
|
626
|
-
export async function backfillPageId(full, slug) {
|
|
627
|
-
const res = await editFile(full, (cur) => {
|
|
628
|
-
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
629
|
-
if (ID_RE.test(cur.split('\n---')[0])) return SKIP; // 已有 id 不动
|
|
630
|
-
const end = cur.indexOf('\n---', 3);
|
|
631
|
-
if (end === -1) return SKIP;
|
|
632
|
-
return { text: `${cur.slice(0, end)}\nid: ${slug}${cur.slice(end)}` };
|
|
633
|
-
});
|
|
634
|
-
return res === SKIP ? 'exists' : 'added';
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// ---------- page status: 经验/知识页的生命周期 ----------
|
|
638
|
-
// 问题:经验写进去就永远躺在那里 —— 推翻时删不掉(skill 里写着"人工内容一律不覆盖",
|
|
639
|
-
// AI 不敢删)、读的时候又看不见(load 只给分区计数)→ 旧经验持续骗下一个会话。
|
|
640
|
-
// 解法:给已有的 `status:` 字段(字段本来就存在,20 页在用)定死三个值:
|
|
641
|
-
// active 当前有效(缺字段的默认值 —— 存量 22 页零迁移)
|
|
642
|
-
// superseded 已被推翻,别再依据它 —— 配 superseded-by 指向取代它的页
|
|
643
|
-
// draft 待核实(abs note 新落的经验就是这个)
|
|
644
|
-
// 关键:推翻 = 改一行 frontmatter,**不删文件不丢历史** —— AI 敢做,人也能反悔。
|
|
645
|
-
// 为什么不用新字段/新目录:字段已存在且有存量值,重命名会另起一套双轨(同 OPTS-DOUBLE-KEYS 之病)。
|
|
646
|
-
export const PAGE_STATUS = ['active', 'superseded', 'draft'];
|
|
647
|
-
const STATUS_RE = /^status:\s*(\S+)\s*$/m;
|
|
648
|
-
const SUPERSEDED_BY_RE = /^superseded-by:\s*(.+)$/m;
|
|
649
|
-
|
|
650
|
-
/** 读页面 status;无字段或是未知值时当 active(存量页零迁移)。 */
|
|
651
|
-
export function statusOfPage(body, frontmatter) {
|
|
652
|
-
const fm = frontmatter !== undefined
|
|
653
|
-
? frontmatter
|
|
654
|
-
: (String(body || '').match(/^---\n([\s\S]*?)\n---/) || ['', ''])[1];
|
|
655
|
-
const m = String(fm).match(STATUS_RE);
|
|
656
|
-
const v = m ? m[1].trim() : '';
|
|
657
|
-
return PAGE_STATUS.includes(v) ? v : 'active';
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/** 读 superseded-by(只在 status=superseded 时有意义)。无则空串。 */
|
|
661
|
-
export function supersededByOf(body) {
|
|
662
|
-
const m = String(body || '').match(SUPERSEDED_BY_RE);
|
|
663
|
-
return m ? m[1].trim() : '';
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// ---------- supersede: 标记一条经验被推翻(回退的写入端) ----------
|
|
667
|
-
// 为什么是标记而不是删除:
|
|
668
|
-
// ① 删除后下一个会话会重新踩同一个坑并重新记一遍(历史本身是资产)
|
|
669
|
-
// ② AI 不敢删(人工内容不覆盖),但敢改一行 frontmatter
|
|
670
|
-
// ③ 反悔只需把 status 改回 active
|
|
671
|
-
export async function cmdSupersede({ dir, refs, by }) {
|
|
672
|
-
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
673
|
-
if (!list.length) return '用法: abs supersede <页名或id> [更多…] [--by <取代它的页>] — 标记经验已失效(不删文件)';
|
|
674
|
-
let root;
|
|
675
|
-
try {
|
|
676
|
-
root = await requireBrain(dir || process.cwd());
|
|
677
|
-
} catch {
|
|
678
|
-
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
679
|
-
}
|
|
680
|
-
const byRef = String(by || '').trim();
|
|
681
|
-
// 取代者必须先存在 —— 否则写下一个永远悬空的引用(lint 会报,不如现在拒)。
|
|
682
|
-
if (byRef) {
|
|
683
|
-
const target = await resolvePage(root, byRef);
|
|
684
|
-
if (!target) return `✗ --by ${byRef}: 图谱里没有这页(先用 abs resolve 确认页名)`;
|
|
685
|
-
}
|
|
686
|
-
const out = [];
|
|
687
|
-
// 取代者 slug 在循环外解析一次(锁内 mutator 不能 await)
|
|
688
|
-
const bySlug = byRef ? (await resolvePage(root, byRef)).slug : '';
|
|
689
|
-
for (const r of list) {
|
|
690
|
-
const hit = await resolvePage(root, r);
|
|
691
|
-
if (!hit) { out.push(`✗ ${r}: 未找到(试 abs query <词> 或 abs index 看清单)`); continue; }
|
|
692
|
-
const res = await editFile(hit.full, (cur) => {
|
|
693
|
-
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
694
|
-
const end = cur.indexOf('\n---', 3);
|
|
695
|
-
if (end === -1) return SKIP;
|
|
696
|
-
let fm = cur.slice(0, end);
|
|
697
|
-
// 幂等:已是 superseded 且 superseded-by 一致 → 不写盘
|
|
698
|
-
const curSt = statusOfPage('', fm);
|
|
699
|
-
const curBy = supersededByOf(cur);
|
|
700
|
-
if (curSt === 'superseded' && curBy === bySlug) return SKIP;
|
|
701
|
-
// 去掉旧的 superseded-by(不论换不换取代者,旧值都作废)
|
|
702
|
-
fm = fm.replace(/\nsuperseded-by:.*(?=\n|$)/g, '');
|
|
703
|
-
fm = STATUS_RE.test(fm)
|
|
704
|
-
? fm.replace(STATUS_RE, 'status: superseded')
|
|
705
|
-
: `${fm}\nstatus: superseded`;
|
|
706
|
-
// superseded-by 用 slug(不是 id):人直接能按名找页,lint 能直接比对文件名
|
|
707
|
-
if (bySlug) fm = `${fm}\nsuperseded-by: ${bySlug}`;
|
|
708
|
-
return { text: fm + cur.slice(end) };
|
|
709
|
-
});
|
|
710
|
-
out.push(res === SKIP
|
|
711
|
-
? `= ${hit.slug}: 已是 superseded(无变化)`
|
|
712
|
-
: `✓ ${hit.slug} → superseded${byRef ? ` (被 [[${byRef}]] 取代)` : ''}`);
|
|
713
|
-
}
|
|
714
|
-
return out.join('\n');
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/** 把 id(或 slug)解析为页面路径。命中返回 {slug, dir, full, id},否则 null。 */
|
|
718
|
-
export async function resolvePage(root, idOrSlug) {
|
|
719
|
-
const want = String(idOrSlug || '').trim();
|
|
720
|
-
if (!want) return null;
|
|
721
|
-
const vault = brainPath(root);
|
|
722
|
-
for (const d of PAGE_DIRS) {
|
|
723
|
-
const p = join(vault, d);
|
|
724
|
-
let files;
|
|
725
|
-
try { files = await fs.readdir(p); } catch { continue; }
|
|
726
|
-
for (const f of files) {
|
|
727
|
-
if (!f.endsWith('.md') || f.startsWith('_')) continue;
|
|
728
|
-
const slug = f.replace(/\.md$/, '');
|
|
729
|
-
const full = join(p, f);
|
|
730
|
-
// slug 直接命中就够快(绝大多数调用走这条),不命中才去读 frontmatter 比 id
|
|
731
|
-
if (slug === want) return { slug, dir: d, full, id: want };
|
|
732
|
-
const body = await fs.readFile(full, 'utf8').catch(() => '');
|
|
733
|
-
const id = idOfPage(body, slug);
|
|
734
|
-
if (id === want) return { slug, dir: d, full, id };
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
return null;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// ---------- review: 待确认页队列(draft → active/superseded) ----------
|
|
741
|
-
// 为何需要:abs note 落的是 status: draft(未经核实),但之前没有「确认」这一步 ——
|
|
742
|
-
// draft 只是标签,没人管,经验就永远停在「待核实」状态,从不正式化。
|
|
743
|
-
// 借鉴 TencentDB 的 review/route 治理环节:提取后必经审查,防止脏知识进入正式图谱。
|
|
744
|
-
// 本命令只做「把 draft 显式升为 active 或否决为 superseded」,不替人判断内容好坏。
|
|
745
|
-
// 动作收口在一处(editFile 锁内),并发安全同 supersede。
|
|
746
|
-
export async function cmdReview({ dir, refs, action }) {
|
|
747
|
-
let root;
|
|
748
|
-
try {
|
|
749
|
-
root = await requireBrain(dir || process.cwd());
|
|
750
|
-
} catch {
|
|
751
|
-
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
752
|
-
}
|
|
753
|
-
const act = String(action || '').toLowerCase();
|
|
754
|
-
if (act && !['accept', 'reject'].includes(act)) {
|
|
755
|
-
return '用法: abs review [--accept <页名…>] [--reject <页名…>] — 无参数列出全部 draft 页';
|
|
756
|
-
}
|
|
757
|
-
// 无动作 → 列出所有 draft 页(待确认队列)
|
|
758
|
-
if (!act) {
|
|
759
|
-
const pages = await listPages(brainPath(root));
|
|
760
|
-
// 只扫经验/知识目录(concepts/sources)。entities 是人页、sessions 是日志,
|
|
761
|
-
// 它们不是「待核实的经验」,不该进 review 队列(拉进来会把人页/日志当经验误确认)。
|
|
762
|
-
const REVIEW_DIRS = ['concepts', 'sources'];
|
|
763
|
-
const drafts = pages.filter((p) => REVIEW_DIRS.includes(p.dir) && statusOfPage(p.body) === 'draft');
|
|
764
|
-
if (!drafts.length) return '✓ 没有待确认的 draft 页。';
|
|
765
|
-
const lines = drafts.map((p) => {
|
|
766
|
-
const t = p.body.match(/^#\s*(.+)$/m);
|
|
767
|
-
const title = t ? t[1].trim() : p.slug;
|
|
768
|
-
return ` [draft] ${p.slug} — ${title}`;
|
|
769
|
-
});
|
|
770
|
-
return [
|
|
771
|
-
`待确认 draft 页 ${drafts.length} 条:`,
|
|
772
|
-
...lines,
|
|
773
|
-
'',
|
|
774
|
-
'确认: abs review --accept <页名> [更多…] 否决: abs review --reject <页名> [更多…]',
|
|
775
|
-
].join('\n');
|
|
776
|
-
}
|
|
777
|
-
// 有动作 → 对每个 ref 改 status
|
|
778
|
-
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
779
|
-
if (!list.length) return `✗ --${act} 需要至少一个页名。用法: abs review --${act} <页名…>`;
|
|
780
|
-
const target = act === 'accept' ? 'active' : 'superseded';
|
|
781
|
-
const out = [];
|
|
782
|
-
for (const r of list) {
|
|
783
|
-
const hit = await resolvePage(root, r);
|
|
784
|
-
if (!hit) { out.push(`✗ ${r}: 未找到(试 abs review 看清单)`); continue; }
|
|
785
|
-
const res = await editFile(hit.full, (cur) => {
|
|
786
|
-
if (!cur || !cur.startsWith('---\n')) return SKIP;
|
|
787
|
-
const end = cur.indexOf('\n---', 3);
|
|
788
|
-
if (end === -1) return SKIP;
|
|
789
|
-
let fm = cur.slice(0, end);
|
|
790
|
-
const curSt = statusOfPage('', fm);
|
|
791
|
-
// 幂等:已是目标状态 → 不写盘
|
|
792
|
-
if (curSt === target) return SKIP;
|
|
793
|
-
fm = STATUS_RE.test(fm)
|
|
794
|
-
? fm.replace(STATUS_RE, `status: ${target}`)
|
|
795
|
-
: `${fm}\nstatus: ${target}`;
|
|
796
|
-
return { text: fm + cur.slice(end) };
|
|
797
|
-
});
|
|
798
|
-
out.push(res === SKIP
|
|
799
|
-
? `= ${hit.slug}: 已是 ${target}(无变化)`
|
|
800
|
-
: `✓ ${hit.slug} → ${target}`);
|
|
801
|
-
}
|
|
802
|
-
return out.join('\n');
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// ---------- resolve: id/slug → 页面路径(引用的反查端) ----------
|
|
806
|
-
// 配合 frontmatter 的 id: 使用。页改名后 id 不变,靠本命令仍能找回来。
|
|
807
|
-
export async function cmdResolve({ dir, refs }) {
|
|
808
|
-
const list = (refs || []).map((r) => String(r).trim()).filter(Boolean);
|
|
809
|
-
if (!list.length) return '用法: abs resolve <id-or-slug> [更多…] — 按 id/页面名反查路径';
|
|
810
|
-
let root;
|
|
811
|
-
try {
|
|
812
|
-
root = await requireBrain(dir || process.cwd());
|
|
813
|
-
} catch {
|
|
814
|
-
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
815
|
-
}
|
|
816
|
-
const lines = [];
|
|
817
|
-
for (const r of list) {
|
|
818
|
-
const hit = await resolvePage(root, r);
|
|
819
|
-
lines.push(hit
|
|
820
|
-
? `✓ ${r} → ${BRAIN_DIR}/${hit.dir}/${hit.slug}.md${hit.id !== hit.slug ? ` (id=${hit.id})` : ''}`
|
|
821
|
-
: `✗ ${r}: 未找到(试 abs index 看完整清单,或 abs query <词> 全文搜)`);
|
|
822
|
-
}
|
|
823
|
-
return lines.join('\n');
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
export function collapseIndex(text) {
|
|
827
|
-
const s = String(text || '').trim();
|
|
828
|
-
if (!s) return '';
|
|
829
|
-
const out = [];
|
|
830
|
-
let mode = null; // null=逐行透传(文件头);字符串=当前在计数的分区名
|
|
831
|
-
let n = 0; // mode 非 null 时的清单行计数
|
|
832
|
-
const flush = () => {
|
|
833
|
-
if (mode !== null) out.push(n ? `## ${mode}(${n} 页)` : `## ${mode}`);
|
|
834
|
-
mode = null;
|
|
835
|
-
n = 0;
|
|
836
|
-
};
|
|
837
|
-
let skipping = false; // 跳过 Rules 正文(已在上方单独成段)
|
|
838
|
-
for (const l of s.split('\n')) {
|
|
839
|
-
const m = l.match(/^##\s+(.+?)\s*$/);
|
|
840
|
-
if (m) {
|
|
841
|
-
flush();
|
|
842
|
-
const name = m[1].trim();
|
|
843
|
-
skipping = /Rules?|规则/i.test(name);
|
|
844
|
-
if (skipping) continue;
|
|
845
|
-
mode = name; // 页面清单分区:只计数
|
|
846
|
-
continue;
|
|
847
|
-
}
|
|
848
|
-
if (skipping) continue;
|
|
849
|
-
if (mode === null) out.push(l); // 透传区(含文件头 H1)
|
|
850
|
-
else if (l.trim().startsWith('-')) n++;
|
|
851
|
-
}
|
|
852
|
-
flush();
|
|
853
|
-
// 文件头与首个分区之间可能因跳过 Rules 而留下多余空行
|
|
854
|
-
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
855
|
-
}
|
|
856
|
-
|
|
857
631
|
// ---------- wrapup: 滞留快照(B)/ load 内展示由 cmdLoad 完成(A) ----------
|
|
858
632
|
export async function cmdWrapup({ dir }) {
|
|
859
633
|
const root = await requireBrain(dir || process.cwd());
|
|
@@ -894,7 +668,7 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
894
668
|
const raw = await fs.readFile(todoP, 'utf8').catch(() => '');
|
|
895
669
|
if (!raw.trim()) return '(todo.md 为空)';
|
|
896
670
|
|
|
897
|
-
const plan = archiveDoneInText(raw, { keepDays: days, from: today() });
|
|
671
|
+
const plan = archiveDoneInText(raw, { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
898
672
|
const why = plan.skipped.length
|
|
899
673
|
? `\n 跳过: ${plan.skipped.map((s) => `${s.date}(${s.reason})`).join(';')}`
|
|
900
674
|
: '';
|
|
@@ -922,7 +696,10 @@ export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {})
|
|
|
922
696
|
|
|
923
697
|
// 2) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖)
|
|
924
698
|
await editFile(todoP, (cur) => {
|
|
925
|
-
|
|
699
|
+
// 坑(2026-10-05 实测抓到): 这里漏传 maxLines → 锁内重算的 archived 为空 →
|
|
700
|
+
// 返回 SKIP → 磁盘上的 todo.md 一个字没动(而 sessions/ 归档页已经写了,
|
|
701
|
+
// 两边不一致:"说归档了但看板没变")。**同参数的每个调用点都要一起改**。
|
|
702
|
+
const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
926
703
|
return p2.archived.length ? { text: p2.text } : SKIP;
|
|
927
704
|
});
|
|
928
705
|
|
|
@@ -1048,70 +825,6 @@ function recentLogLines(text, n) {
|
|
|
1048
825
|
.join('\n');
|
|
1049
826
|
}
|
|
1050
827
|
|
|
1051
|
-
/** 摘要收口:超过 n 码点在**语义边界**收尾(标点 → 空格 → 硬切),加省略号。
|
|
1052
|
-
* 坑: 曾直接 `.slice(0, n)` 硬切 → log.md 34/85 条断在词中间(revert-c / file-write-lockin /
|
|
1053
|
-
* ~/.claude/ski), 文件名也被切成 ...-decision-blo。而 abs load 开机读的就是这份残句,
|
|
1054
|
-
* 用户与后续会话看到的天然是半句 —— "摘要读起来抽象"的真因在写入口, 不在表述能力。 */
|
|
1055
|
-
export function clip(text, n) {
|
|
1056
|
-
const s = String(text || '').trim();
|
|
1057
|
-
if (s.length <= n) return s;
|
|
1058
|
-
const head = s.slice(0, n);
|
|
1059
|
-
// 优先在标点处断开(中文句读 + 英文句读),其次空格,最后才硬切
|
|
1060
|
-
const cut = Math.max(
|
|
1061
|
-
head.lastIndexOf('。'), head.lastIndexOf(';'), head.lastIndexOf('!'), head.lastIndexOf('?'),
|
|
1062
|
-
head.lastIndexOf(','), head.lastIndexOf('、'), head.lastIndexOf(';'), head.lastIndexOf(','),
|
|
1063
|
-
head.lastIndexOf('.'), head.lastIndexOf(' '),
|
|
1064
|
-
);
|
|
1065
|
-
// 边界太靠前(< 一半)说明这一段本就是长句,宁可硬切也不留个残破的短头
|
|
1066
|
-
const keep = cut > n / 2 ? cut : n;
|
|
1067
|
-
return `${s.slice(0, keep).replace(/[\s,,、;;.。]+$/, '')}…`;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/** slug:取前 n 码点 → 非词字符折叠为 '-'。只用于**文件名**,完整标题另存 TITLE 行。
|
|
1071
|
-
* 在标点/空格边界收口,不在字中间切断(否则出 `...-硬切-不` 这种残尾)。 */
|
|
1072
|
-
function slugOf(text, n = 24) {
|
|
1073
|
-
const s = String(text || '').trim();
|
|
1074
|
-
let head = s.slice(0, n);
|
|
1075
|
-
if (s.length > n) {
|
|
1076
|
-
const cut = Math.max(head.lastIndexOf(','), head.lastIndexOf('。'), head.lastIndexOf('、'),
|
|
1077
|
-
head.lastIndexOf(':'), head.lastIndexOf(','), head.lastIndexOf('.'), head.lastIndexOf(' '));
|
|
1078
|
-
if (cut > n / 2) head = head.slice(0, cut);
|
|
1079
|
-
}
|
|
1080
|
-
return head.replace(/[^\w一-鿿]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// ---------- log: 追加工作成果沉淀摘要(用户/AI 主动 abs log "..." 记, 不收工具动作流水) ----------
|
|
1084
|
-
export async function cmdLog({ dir, title, kind = 'dev' }) {
|
|
1085
|
-
const root = await requireBrain(dir || process.cwd());
|
|
1086
|
-
const who = await requireUser(); // 写操作守卫
|
|
1087
|
-
// ★ kind 必须是枚举值(2026-10-05 加):此前无校验,传什么写什么 ——
|
|
1088
|
-
// 实测有测试传 kind:'test' 写进去,而形状闸门上线后才暴露。
|
|
1089
|
-
// 枚举内校在**入口**(这里)比事后 lint 更早,且报错能直接告诉可用值。
|
|
1090
|
-
if (!LOG_KINDS.includes(String(kind))) {
|
|
1091
|
-
throw new Error(
|
|
1092
|
-
`✗ log 的 kind 只能是 ${LOG_KINDS.join(' / ')}(收到 "${kind}")\n` +
|
|
1093
|
-
` note=经验/踩坑 / dev=完成的工作 / concept=新建概念页 / ingest=沉淀资料`,
|
|
1094
|
-
);
|
|
1095
|
-
}
|
|
1096
|
-
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
1097
|
-
const p = brainPath(root, 'log.md');
|
|
1098
|
-
const stamp = localStamp();
|
|
1099
|
-
// 不硬切: log.md 是人类读的成果摘要, 也是 abs load 的开机入口。600 码点够一条完整小结,
|
|
1100
|
-
// 超出才在语义边界收口(曾 slice(0,100) → 34/85 条断在词中间)
|
|
1101
|
-
const clean = clip(String(title || '').replace(/\n/g, ' '), 600);
|
|
1102
|
-
// 作者前置于 kind:`## [时间] @name dev | 内容`。
|
|
1103
|
-
// 一眼先看到谁做的(与 todo 行 `ID @name — 说明` 排版对齐)。
|
|
1104
|
-
const line = `## [${stamp}] ${atTag(who)} ${kind} | ${clean}`;
|
|
1105
|
-
await editFile(p, (cur) => {
|
|
1106
|
-
const text = cur ?? '# 🗒 Activity Log\n';
|
|
1107
|
-
// 倒序:新行插在标题后(若已是模板占位行则替换它)
|
|
1108
|
-
const lines = text.split('\n');
|
|
1109
|
-
const headerIdx = lines.findIndex((l) => l.startsWith('#'));
|
|
1110
|
-
lines.splice(headerIdx + 1, 0, line);
|
|
1111
|
-
return { text: lines.join('\n') };
|
|
1112
|
-
});
|
|
1113
|
-
return `✓ log → ${p}\n ${line}`;
|
|
1114
|
-
}
|
|
1115
828
|
|
|
1116
829
|
// ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
|
|
1117
830
|
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
@@ -1124,15 +837,48 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
|
|
|
1124
837
|
if (action === 'start') {
|
|
1125
838
|
// 两区制:未完成一律进 Todo,行首带状态标记(默认 进行中)。
|
|
1126
839
|
const st = section && TASK_STATES.includes(section) ? section : '进行中';
|
|
840
|
+
// 唯一进行中约束(2026-10-05 照 rpiv-todo):看板要能回答"现在在做什么",
|
|
841
|
+
// 多个 [进行中] 就答不了。**只提示,不擅自降级别人的任务** ——
|
|
842
|
+
// 机器替人改状态是静默篡改(同 [[falsy-or-eats-zero]] 的"别悄悄改写意图")。
|
|
843
|
+
let warning = '';
|
|
844
|
+
// 只在**新开**进行中时提示(幂等更新同一条不该重复唠叨)。
|
|
845
|
+
let alreadyThere = false;
|
|
846
|
+
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
847
|
+
if (cur === null) return SKIP;
|
|
848
|
+
alreadyThere = cur.split('\n').some((l) => idOfTaskLine(l) === String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, ''));
|
|
849
|
+
return SKIP;
|
|
850
|
+
});
|
|
851
|
+
if (st === '进行中' && !alreadyThere) {
|
|
852
|
+
const others = [];
|
|
853
|
+
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
854
|
+
if (cur === null) return SKIP;
|
|
855
|
+
for (const l of cur.split('\n')) {
|
|
856
|
+
const tid = idOfTaskLine(l);
|
|
857
|
+
if (tid && tid !== id && stateOfTaskLine(l) === '进行中') others.push(tid);
|
|
858
|
+
}
|
|
859
|
+
return SKIP;
|
|
860
|
+
});
|
|
861
|
+
if (others.length) {
|
|
862
|
+
// 提示要具体到「该转成哪个」:只报「已有 N 个进行中」实测不够 ——
|
|
863
|
+
// 用户改方向时,旧任务是「搁置」(不做了),与「滞留中」(还要做)不同,
|
|
864
|
+
// 选错会让下会话分不清该不该接着干。
|
|
865
|
+
warning = `\n ⚠ 已有 ${others.length} 个 [进行中]: ${others.join(', ')}\n`
|
|
866
|
+
+ ' 同时只有一个进行中才说得清"现在在做什么"。\n'
|
|
867
|
+
+ ` 旧任务怎么处理 ——\n`
|
|
868
|
+
+ ` • 用户改方向了/不做了 → abs todo state <id> --note 搁置\n`
|
|
869
|
+
+ ` • 还要做,只是卡住了 → abs todo state <id> --note 滞留中\n`
|
|
870
|
+
+ ` • 还想商量 → abs todo state <id> --note 讨论中`;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
1127
873
|
const r = await upsertTask(root, {
|
|
1128
874
|
section: SEC.todo,
|
|
1129
875
|
text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} (认领 ${today()})`,
|
|
1130
876
|
});
|
|
1131
|
-
return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}`;
|
|
877
|
+
return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}${warning}`;
|
|
1132
878
|
}
|
|
1133
879
|
if (action === 'rename') {
|
|
1134
|
-
//
|
|
1135
|
-
// (`fix-skill-trigger`
|
|
880
|
+
// 改任务 id(2026-10-05):人工起错名、或旧 id 不可读时用。
|
|
881
|
+
// (`fix-skill-trigger` 这种名字不是从用户字面抽出来的,是读懂在干什么之后起的。)
|
|
1136
882
|
// --note 传新 id(沿用既有 CLI 习惯,免得再加一个 flag)。
|
|
1137
883
|
if (!note) throw new Error('✗ 用法: abs todo rename <旧id> --note "<新id>"');
|
|
1138
884
|
const newId = String(note).replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim();
|
|
@@ -1146,8 +892,6 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
|
|
|
1146
892
|
for (let i = 0; i < lines.length; i++) {
|
|
1147
893
|
if (idOfTaskLine(lines[i]) !== want) continue;
|
|
1148
894
|
lines[i] = lines[i].replace(want, newId);
|
|
1149
|
-
// 占位描述「待命名」随之换掉 —— 改完名还写着"待命名"会让人以为没改。
|
|
1150
|
-
lines[i] = lines[i].replace(/—\s*待命名\s*/, `— ${newId.replace(/^(auto-|fix-|feat-)/, '')} `);
|
|
1151
895
|
done = true;
|
|
1152
896
|
break;
|
|
1153
897
|
}
|
|
@@ -1253,232 +997,3 @@ export async function cmdShow({ dir, view, full }) {
|
|
|
1253
997
|
|
|
1254
998
|
// Re-export query.js symbols so external importers still work.
|
|
1255
999
|
export { cmdQuery } from './query.js';
|
|
1256
|
-
|
|
1257
|
-
// ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
|
|
1258
|
-
const NOTE_DEDUP_MS = 60 * 1000;
|
|
1259
|
-
|
|
1260
|
-
export async function cmdNote({ dir, text, tags, when, impact, type }) {
|
|
1261
|
-
const clean = String(text || '').trim();
|
|
1262
|
-
if (!clean) return '用法: abs note "经验/坑/技巧一句话" [--when "何时该读它"](落 sources/ 暂存页,实时不流失)';
|
|
1263
|
-
let root;
|
|
1264
|
-
try {
|
|
1265
|
-
root = await requireBrain(dir || process.cwd());
|
|
1266
|
-
} catch {
|
|
1267
|
-
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
1268
|
-
}
|
|
1269
|
-
const who = await requireUser(); // 写操作守卫
|
|
1270
|
-
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
1271
|
-
const srcDir = brainPath(root, 'sources');
|
|
1272
|
-
await fs.mkdir(srcDir, { recursive: true });
|
|
1273
|
-
// 幂等: 同文本 60s 内只落一份
|
|
1274
|
-
const existing = (await fs.readdir(srcDir).catch(() => [])).filter((f) => f.endsWith('.md'));
|
|
1275
|
-
for (const f of existing) {
|
|
1276
|
-
const body = await fs.readFile(join(srcDir, f), 'utf8').catch(() => '');
|
|
1277
|
-
if (body.includes(clean)) {
|
|
1278
|
-
return `• 60s 内已落同文本 → ${f} (跳过重复)`;
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
// 影响面(可选):显式传 --impact <符号> 时,借本机 CodeGraph 拿「改它波及谁」。
|
|
1282
|
-
// 失败/未装 codegraph 静默降级为无,绝不阻断 note 落盘。
|
|
1283
|
-
const impactText = impact ? await impactOf(impact, root) : null;
|
|
1284
|
-
// 类型(可选):借鉴 TencentDB 的 L1 四分类,把自由文本经验分成可分类的资产。
|
|
1285
|
-
// 默认不强制(自由文本仍是主体);显式 --type 时才写进 frontmatter,供检索/load 区分。
|
|
1286
|
-
// 合法值对齐 L1 四类:fact 事实 / pref 偏好 / constraint 约束 / event 事件。
|
|
1287
|
-
const NOTE_TYPES = ['fact', 'pref', 'constraint', 'event'];
|
|
1288
|
-
const noteType = NOTE_TYPES.includes(String(type || '').trim().toLowerCase())
|
|
1289
|
-
? String(type).trim().toLowerCase() : '';
|
|
1290
|
-
const tagList = String(tags || '').split(',').map((t) => t.trim()).filter(Boolean);
|
|
1291
|
-
const fmTags = ['source', ...tagList].join(', ');
|
|
1292
|
-
const slugSrc = slugOf(clean);
|
|
1293
|
-
const file = `${today()}-${slugSrc || 'note'}.md`;
|
|
1294
|
-
const heading = clip(clean, 80); // 页面标题: 完整优先, 超长才收口
|
|
1295
|
-
// 触发条件(2026-09-16):经验"写入多读得少"的根因之一是存的是结论、不是"何时该看"。
|
|
1296
|
-
// 带上 --when 后,load 的相关页推荐能按当前在做的事匹配,而不是按主题词。
|
|
1297
|
-
const whenText = String(when || '').trim();
|
|
1298
|
-
const body = [
|
|
1299
|
-
'---',
|
|
1300
|
-
`tags: [${fmTags}]`,
|
|
1301
|
-
`id: ${file.replace(/\.md$/, '')}`,
|
|
1302
|
-
`author: ${who}`,
|
|
1303
|
-
`updated: ${today()}`,
|
|
1304
|
-
'status: draft',
|
|
1305
|
-
...(noteType ? [`type: ${noteType}`] : []),
|
|
1306
|
-
'---',
|
|
1307
|
-
'',
|
|
1308
|
-
`# 来源:${heading}`,
|
|
1309
|
-
'',
|
|
1310
|
-
`TITLE: ${clean}`,
|
|
1311
|
-
...(whenText ? ['', `WHEN: ${whenText}`] : []),
|
|
1312
|
-
...(impactText ? ['', '## 影响面(本机 CodeGraph 自动带出)', '```', impactText, '```'] : []),
|
|
1313
|
-
'',
|
|
1314
|
-
`## 记录(实时暂存,Teardown 时提炼进 concepts/ 后本页可删)`,
|
|
1315
|
-
`- ${clean}`,
|
|
1316
|
-
...(whenText ? [`- 何时读:${whenText}`] : []),
|
|
1317
|
-
'',
|
|
1318
|
-
'## 关联连接',
|
|
1319
|
-
`- ${atTag(who)} — 本页沉淀者`,
|
|
1320
|
-
'(提炼成 concepts 规律页后,在此挂双链到该页)',
|
|
1321
|
-
'',
|
|
1322
|
-
].join('\n');
|
|
1323
|
-
// 源文件是新写唯一文件:tmp+rename 原子落盘(避免并发读读到半写文件)
|
|
1324
|
-
const srcFile = join(srcDir, file);
|
|
1325
|
-
const tmp = join(srcDir, `.${file}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
|
|
1326
|
-
await fs.writeFile(tmp, body, 'utf8');
|
|
1327
|
-
await fs.rename(tmp, srcFile);
|
|
1328
|
-
// index Sources 区登记(锁内幂等:别页已登记则跳过,防并发重复) + log 一行
|
|
1329
|
-
const slug = file.replace(/\.md$/, '');
|
|
1330
|
-
await registerInIndex(root, 'Sources', slug, heading);
|
|
1331
|
-
await cmdLog({ dir: root, title: clean, kind: 'note' });
|
|
1332
|
-
return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
// ---------- concept: 概念页脚手架(给「写入」定结构,不替人做判断) ----------
|
|
1336
|
-
/** 建一张带骨架的概念页。
|
|
1337
|
-
*
|
|
1338
|
-
* 为何需要它(实测 2026-09-15): concepts/ 页原来**没有任何代码写入路径** ——
|
|
1339
|
-
* 全靠人/AI 手写 markdown,结果 26 页里 2 页完全没有「做完怎么确认」。
|
|
1340
|
-
* 而骨架只写在 skill 的**文字里**("触发场景/表现/解法/验证命令"),没有执行点 → 看运气。
|
|
1341
|
-
* 对照: `abs note` 落的 source 页结构整齐,因为模板在**代码里**。
|
|
1342
|
-
*
|
|
1343
|
-
* 边界(关键): 它只给**结构**,不给**内容**。
|
|
1344
|
-
* 「这条值不值得留 / 归哪一页」仍靠人判断 —— 那是 skill 明写的分工(深提炼不自动化)。
|
|
1345
|
-
* 所以本命令不猜语义、不自动提炼,只在你要新建页时把该有的位置摆好。
|
|
1346
|
-
*
|
|
1347
|
-
* 尾巴用「占位符」而非真实值: 这样 lint 的 NO-TAIL 判据在占位未填时仍会报
|
|
1348
|
-
* (骨架≠完成)。填完删掉占位行即可。 */
|
|
1349
|
-
export async function cmdConcept({ dir, slug, title, tags, desc }) {
|
|
1350
|
-
let root;
|
|
1351
|
-
try {
|
|
1352
|
-
root = await requireBrain(dir || process.cwd());
|
|
1353
|
-
} catch {
|
|
1354
|
-
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
1355
|
-
}
|
|
1356
|
-
const raw = String(slug || '').trim();
|
|
1357
|
-
if (!raw) {
|
|
1358
|
-
return [
|
|
1359
|
-
'用法: abs concept <slug> --title "一句话标题" [--tags a,b] [--desc "index 里的一句话"]',
|
|
1360
|
-
' 例: abs concept docker-prisma-429 --title "Docker 内存超限导致 Prisma 429"',
|
|
1361
|
-
' 说明: 只给骨架(头/中/尾位置),内容仍由你写 —— 判断不自动化。',
|
|
1362
|
-
].join('\n');
|
|
1363
|
-
}
|
|
1364
|
-
// slug 即文件名(命名即链接)。收口掉路径分隔符与空白,防逃出 concepts/。
|
|
1365
|
-
const name = raw.replace(/[\s/\\]+/g, '-').replace(/[^\w\u4e00-\u9fff.-]/g, '').replace(/^-+|-+$/g, '');
|
|
1366
|
-
if (!name) return `✗ slug 无效(清洗后为空): ${raw}`;
|
|
1367
|
-
const who = await requireUser();
|
|
1368
|
-
await ensurePersonPage(root, who);
|
|
1369
|
-
const dirP = brainPath(root, 'concepts');
|
|
1370
|
-
await fs.mkdir(dirP, { recursive: true });
|
|
1371
|
-
const file = join(dirP, `${name}.md`);
|
|
1372
|
-
const head = String(title || '').trim() || name;
|
|
1373
|
-
const tagList = ['concept', ...String(tags || '').split(',').map((t) => t.trim()).filter(Boolean)];
|
|
1374
|
-
const body = [
|
|
1375
|
-
'---',
|
|
1376
|
-
`tags: [${tagList.join(', ')}]`,
|
|
1377
|
-
`id: ${name}`,
|
|
1378
|
-
`author: ${who}`,
|
|
1379
|
-
`updated: ${today()}`,
|
|
1380
|
-
'status: draft',
|
|
1381
|
-
'---',
|
|
1382
|
-
'',
|
|
1383
|
-
`# 概念:${head}`,
|
|
1384
|
-
'',
|
|
1385
|
-
'## 触发场景',
|
|
1386
|
-
'<!-- 什么情况下该想起这条?(写可检索的词,别只写“遇到问题”) -->',
|
|
1387
|
-
'',
|
|
1388
|
-
'## ❌ 表现',
|
|
1389
|
-
'<!-- 具体症状 / 贴报错 / 复现条件 -->',
|
|
1390
|
-
'',
|
|
1391
|
-
'## 🛠 解法',
|
|
1392
|
-
'<!-- 根因 + 修复 -->',
|
|
1393
|
-
'',
|
|
1394
|
-
'## 验证',
|
|
1395
|
-
'<!-- 做完怎么确认?跑什么命令 / 看什么信号 / 用什么判据。必须填 —— 没尾巴的经验只能被“相信”,不能被“验证” -->',
|
|
1396
|
-
'',
|
|
1397
|
-
'## 关联连接',
|
|
1398
|
-
`- ${atTag(who)} — 本页沉淀者`,
|
|
1399
|
-
'(在这挂相关页双链,别留孤岛)',
|
|
1400
|
-
'',
|
|
1401
|
-
].join('\n');
|
|
1402
|
-
// 独占写(wx):已存在则 EEXIST —— 与 ensurePersonPage 同路数。
|
|
1403
|
-
// 不用「先查后写」:那有 TOCTOU 竞态,且已有人工内容一律不覆盖是本仓硬规则。
|
|
1404
|
-
// 也不走 tmp+rename:rename 会默默覆盖已存在文件,而这里必须「存在就拒绝」。
|
|
1405
|
-
try {
|
|
1406
|
-
await fs.writeFile(file, body, { encoding: 'utf8', flag: 'wx' });
|
|
1407
|
-
} catch (e) {
|
|
1408
|
-
if (e.code === 'EEXIST') {
|
|
1409
|
-
return `• 已存在,不覆盖 → .brain/concepts/${name}.md\n 要改请直接编辑(或先删页);新建请换个 slug。`;
|
|
1410
|
-
}
|
|
1411
|
-
throw e;
|
|
1412
|
-
}
|
|
1413
|
-
const oneLine = String(desc || '').trim() || clip(head, 60);
|
|
1414
|
-
await registerInIndex(root, 'Concepts', name, oneLine);
|
|
1415
|
-
// 不写 log.md(2026-10-05 用户定):`新建概念页 x` 是**命令的副作用**不是成果 ——
|
|
1416
|
-
// 38 字符、零信息量,且「该页存在」已由 registerInIndex 落在 index.md 的 Concepts 区
|
|
1417
|
-
// (那是 index 的职责)。同件事落两处,且建 10 个页 = 10 行流水噪声自动重现,
|
|
1418
|
-
// 靠事后清理治不了。故删掉这次调用,不加开关(没人需要读「某页被创建了」)。
|
|
1419
|
-
return `✓ 概念页骨架 → .brain/concepts/${name}.md ${atTag(who)}\n` +
|
|
1420
|
-
' 已给好四段位置;填完内容后:删掉 <!-- --> 占位、按需改 status: active、挂双链。\n' +
|
|
1421
|
-
' 尾部「## 验证」必须填(留空会被 abs lint 报 NO-TAIL)。';
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
// ---------- person: 使用者实体页(首次需要时创建,已存在则不动) ----------
|
|
1425
|
-
/** 确保 entities/<name>.md 存在。已存在一律不动(里面的技术栈/特点是人工沉淀的)。
|
|
1426
|
-
* 用 `wx` 独占写:并发下后到者拿到 EEXIST 就静默跳过,不覆盖。
|
|
1427
|
-
* 失败不抛:建页是附带动作,不能因为它让 todo/log 写不进去。
|
|
1428
|
-
* 返回 'created' | 'exists' | 'skip'。 */
|
|
1429
|
-
export async function ensurePersonPage(root, name) {
|
|
1430
|
-
const nm = String(name || '').trim();
|
|
1431
|
-
if (!nm || !/^[\w\u4e00-\u9fff.-]+$/.test(nm)) return 'skip';
|
|
1432
|
-
const dir = brainPath(root, 'entities');
|
|
1433
|
-
const file = join(dir, `${nm}.md`);
|
|
1434
|
-
const body = [
|
|
1435
|
-
'---',
|
|
1436
|
-
'tags: [entity, person]',
|
|
1437
|
-
`id: ${nm}`,
|
|
1438
|
-
`author: ${nm}`,
|
|
1439
|
-
`updated: ${today()}`,
|
|
1440
|
-
'status: draft',
|
|
1441
|
-
'---',
|
|
1442
|
-
'',
|
|
1443
|
-
`# ${nm}`,
|
|
1444
|
-
'',
|
|
1445
|
-
'## 技术栈',
|
|
1446
|
-
'<!-- 沉淀时填: 主力语言/框架/工具链。例: TypeScript + Node, 熟悉 MCP 协议与 CLI 工具链 -->',
|
|
1447
|
-
'',
|
|
1448
|
-
'## 特点 / 工作习惯',
|
|
1449
|
-
'<!-- 沉淀时填: 决策偏好、沟通习惯、反复出现的判断倾向。例: 先要方案后动手; 质疑"这需求是否需要存在" -->',
|
|
1450
|
-
'',
|
|
1451
|
-
'## 名下踩过的坑',
|
|
1452
|
-
'(本页被 [[todo]] / [[log]] 里的作者标记引用;沉淀经验时在此挂双链)',
|
|
1453
|
-
'',
|
|
1454
|
-
].join('\n');
|
|
1455
|
-
try {
|
|
1456
|
-
await fs.mkdir(dir, { recursive: true });
|
|
1457
|
-
await fs.writeFile(file, body, { encoding: 'utf8', flag: 'wx' });
|
|
1458
|
-
} catch (e) {
|
|
1459
|
-
if (e.code === 'EEXIST') return 'exists';
|
|
1460
|
-
return 'skip';
|
|
1461
|
-
}
|
|
1462
|
-
// 只有真建成才登记 index(否则 index 指向不存在的页 → INDEX-DEAD-LINK)。
|
|
1463
|
-
// 放这里而非各调用点:todo/log/note 三条写路径都要登记,抄三遍必漂。
|
|
1464
|
-
await registerInIndex(root, 'Entities', nm, `${nm} — 使用者;技术栈 / 特点 / 名下踩过的坑`);
|
|
1465
|
-
return 'created';
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
/** 把新页登记进 index.md 的指定分区(幂等)。供人页/其它程序建页用。 */
|
|
1469
|
-
export async function registerInIndex(root, section, slug, desc) {
|
|
1470
|
-
const iP = brainPath(root, 'index.md');
|
|
1471
|
-
await editFile(iP, (index) => {
|
|
1472
|
-
if (!index || index.includes(`[[${slug}]]`)) return SKIP;
|
|
1473
|
-
const sIdx = index.indexOf(`## ${section}`);
|
|
1474
|
-
if (sIdx === -1) return SKIP;
|
|
1475
|
-
const after = index.indexOf('\n## ', sIdx + 1);
|
|
1476
|
-
const line = `- [[${slug}]] — ${desc}`;
|
|
1477
|
-
const next = after === -1
|
|
1478
|
-
? `${index.replace(/\s*$/, '')}\n${line}\n`
|
|
1479
|
-
: index.slice(0, after) + `\n${line}` + index.slice(after);
|
|
1480
|
-
// 归一空行:历史手工编辑会留 3+ 空行(load 时 collapseIndex 会压掉,但文件本身没清)。
|
|
1481
|
-
// 追加新条目的同时顺手压一次,既清旧债又不改内容(与 collapseIndex 同一判据)。
|
|
1482
|
-
return { text: next.replace(/\n{3,}/g, '\n\n') };
|
|
1483
|
-
});
|
|
1484
|
-
}
|