@fanchao8609/agent_brain_sync 1.10.1 → 1.13.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 +45 -3
- package/bin/abs.js +5 -1
- package/hooks/abs.pi.ts +11 -3
- package/package.json +1 -1
- package/src/lint.js +57 -1
- package/src/lock.js +10 -1
- package/src/store.js +55 -25
- package/src/todo.js +555 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ abs install --agent pi --no-mcp # 只装 hook + skill,不要 MCP
|
|
|
32
32
|
| claude-code | `~/.claude/settings.json` hooks | `mcpServers.abs` (stdio) | `~/.claude/skills/abs-agent-brain-sync/` |
|
|
33
33
|
| codex | `~/.codex/hooks.json` | config.toml `[mcp_servers.abs]` | `~/.codex/skills/` |
|
|
34
34
|
| opencode | `~/.config/opencode/plugins/abs.ts` | opencode.json mcp.abs | skills/ |
|
|
35
|
-
| pi | `~/.pi/agent/extensions/abs.ts` | `~/.pi/agent/mcp.json` `mcpServers.abs` | `~/.pi/agent/skills/` |
|
|
35
|
+
| pi | `~/.pi/agent/extensions/abs.ts` | `~/.pi/agent/mcp-adapter.json` `mcpServers.abs` | `~/.pi/agent/skills/` |
|
|
36
36
|
|
|
37
37
|
> **skill 规则**:`skill/` 下每个含 `SKILL.md` 的子目录 = 一个 skill,
|
|
38
38
|
> **目录名即安装名**(须与 frontmatter `name` 一致,否则 pi 会告警)。
|
|
@@ -53,6 +53,44 @@ npm install && npm link # 之后全局就有 abs
|
|
|
53
53
|
|
|
54
54
|
## 怎么用
|
|
55
55
|
|
|
56
|
+
### pi:编辑器上的 todo 面板
|
|
57
|
+
|
|
58
|
+
pi 宿主额外多一层 UI(其它宿主没有):**编辑器上方常驻一块 todo 面板**,直接读当前项目 `.brain/todo.md`。
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
─────────────────────────────────────────────────
|
|
62
|
+
📋 todo (3) — fanchao · ☕ 靠咖啡续命
|
|
63
|
+
├─ [进行中] some-task ●●○ — 干活中
|
|
64
|
+
│ ↳ 断点: hooks/abs.pi.ts:120
|
|
65
|
+
├─ [滞留中] waiting — 等外部输入
|
|
66
|
+
└─ [进行中] another ●●● — …
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- **只显示未完成**,Done 归计数不占位(数量在标题里)
|
|
70
|
+
- **按当前使用者过滤**:显示 `[[我]]` 的 + 没标作者的(老任务/手写),别人的不显示
|
|
71
|
+
- **断点行 `↳` 挂在父任务下**;`├─` / `└─` 表结构
|
|
72
|
+
- **「进行中」带三点动画**`○○○ → ●●○ → ●●●`(250ms/帧)—— 一眼看出哪条在跑
|
|
73
|
+
- **实时刷新**:我调 `abs_task` / `abs_board` 后立即重画,不用等我讲完话
|
|
74
|
+
- **上描边与输入框同色满宽**(主题色 `thinkingOff`,跟 pi 输入框的边框一致)
|
|
75
|
+
- 窄终端按显示宽度截断(CJK 计 2 列),不溢出
|
|
76
|
+
|
|
77
|
+
**每次启动随机昵称**(132 条,附在作者名后)—— 每次打开 pi 换一条;池子分三批:
|
|
78
|
+
日常作息吃喝摸鱼 / 职场抱怨 / 自嘲(`编程全靠蒙`、`AI救我狗命`)。
|
|
79
|
+
|
|
80
|
+
| 环境变量 | 作用 |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `ABS_TODO_PANEL=0` | 关掉面板 |
|
|
83
|
+
| `ABS_TODO_NICK=0` | 关掉随机昵称 |
|
|
84
|
+
| `ABS_TODO_GUIDE=0` | 关掉 system prompt 里的 todo 登记指引 |
|
|
85
|
+
|
|
86
|
+
> **设计取舍**:面板是**纯展示层**,只读 `todo.md` 不写任何东西,也不建第二套状态 ——
|
|
87
|
+
> 数据源就是 abs 自己的看板(磁盘文件,跨会话可续接)。所以没有折叠快捷键、没有依赖图,
|
|
88
|
+
> 只有 ~50 行渲染代码;对比 rpiv-todo 的 ~1800 行(它把状态存会话 transcript,新会话会丢)。
|
|
89
|
+
>
|
|
90
|
+
> 另一层是**触发指引**:扩展往 system prompt 的 Guidelines 段注入 3 条静态条目
|
|
91
|
+
> (动手前 `start` / 完成立刻 `done` / 断点及时 `note`)。这是**静态 prompt 内容**,
|
|
92
|
+
> 不是往对话里插消息 —— 本项目删过两次「插话式提醒」(会抢 turn 打断用户)。
|
|
93
|
+
|
|
56
94
|
### 项目里开一次
|
|
57
95
|
|
|
58
96
|
```bash
|
|
@@ -89,6 +127,9 @@ abs update # 升级到最新版并刷新四宿主
|
|
|
89
127
|
> `abs todo start` 与 `abs todo add` 等价(都登记任务)。
|
|
90
128
|
> 旧版 `abs task ...` / `abs board` 已改名,会报错并提示新写法。
|
|
91
129
|
> `abs wrapup` / `abs teardown-check` 是 hook 内部命令,无需手动调用。
|
|
130
|
+
> **作者名要填真的**:`tester` / `foo` / `aaa` 这类占位名会被拒 —— 因为 `{user}` 是全局单值,
|
|
131
|
+
> 填错会污染之后所有项目的 `[[作者]]` 标记(`aaa` 这类堆字也拒,但 `oo`/`ee` 这种两字母缩写放行)。
|
|
132
|
+
> `abs load` 对已落盘的占位名会给出警告,提示改回真名。
|
|
92
133
|
> **升级后分区名自动归一**:`abs load` 每次都会顺手核对 `index/log/todo` 三文件结构,旧的分区名(如 `# 🗂 图谱索引` → `# 🗂 Graph Index`)会被自动改回标准;缺分区自动补建,无头文件只提醒不自动改。
|
|
93
134
|
|
|
94
135
|
### 工作流
|
|
@@ -151,11 +192,12 @@ agent_brain_sync/
|
|
|
151
192
|
├── src/store.js CLI 命令实现
|
|
152
193
|
├── src/hosts.js 四宿主接入定义
|
|
153
194
|
├── src/install.js 安装/卸载(分区共存合并)
|
|
154
|
-
├── src/userconfig.js
|
|
195
|
+
├── src/userconfig.js 使用者姓名配置(作者标记;占位名如 tester/foo 会被拒)
|
|
155
196
|
├── src/wrapup.js Stop 收尾快照/归档
|
|
156
197
|
├── hooks/event.sh hook 模板
|
|
198
|
+
├── hooks/abs.pi.ts pi 扩展模板(含 todo 面板 / 随机昵称 / 常驻指引)
|
|
157
199
|
├── skill/<名称>/SKILL.md 技能(每个子目录 = 一个 skill,装到各智能体)
|
|
158
|
-
└── test/
|
|
200
|
+
└── test/ 单测(400+,node:test)
|
|
159
201
|
```
|
|
160
202
|
|
|
161
203
|
MIT
|
package/bin/abs.js
CHANGED
|
@@ -88,6 +88,10 @@ const FLAG_SPEC = {
|
|
|
88
88
|
'title': { type: 'string' },
|
|
89
89
|
'desc': { type: 'string' },
|
|
90
90
|
'state': { type: 'string' },
|
|
91
|
+
// log 流水类型:note|dev|concept|ingest(cmdLog 会校枚举,非枚举直接报错)。
|
|
92
|
+
// ★ 漏声明就是真 bug(2026-10-05 本机实测):不在 FLAG_SPEC 的选项会被静默丢掉,
|
|
93
|
+
// 于是 `abs log "x" --kind note` 全写成默认的 dev,而 cmdLog 的枚举校验看不见它。
|
|
94
|
+
'kind': { type: 'string' },
|
|
91
95
|
'by': { type: 'string' },
|
|
92
96
|
'all': { type: 'boolean' },
|
|
93
97
|
'keep-days': { type: 'string' },
|
|
@@ -407,7 +411,7 @@ async function main() {
|
|
|
407
411
|
case 'log': {
|
|
408
412
|
// 无参=查看 log.md;带参=追加一行 (用户/AI 主动记; hook 生命周期事件走技术日志 hooks.log, 不经这里)
|
|
409
413
|
if (opts._.length) {
|
|
410
|
-
console.log(await cmdLog({ dir: opts.dir, title: opts._.join(' ') }));
|
|
414
|
+
console.log(await cmdLog({ dir: opts.dir, title: opts._.join(' '), kind: opts.kind }));
|
|
411
415
|
} else {
|
|
412
416
|
console.log(await cmdShow({ dir: opts.dir, view: 'log' }));
|
|
413
417
|
}
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -82,10 +82,18 @@ function resetThrottle(): void {
|
|
|
82
82
|
// 若将来要扩展,也绝不能退化成插话。
|
|
83
83
|
//
|
|
84
84
|
// 关掉即设 ABS_TODO_GUIDE=0。
|
|
85
|
-
//
|
|
86
|
-
//
|
|
85
|
+
//
|
|
86
|
+
// 工具名**不能写死**(2026-10-04 实报修正):abs 走 MCP,而 MCP 工具在模型侧的
|
|
87
|
+
// 名字随宿主的 exposure 配置变,至少三种实测形态 ——
|
|
88
|
+
// ① pi-mcp-adapter 的 namespace 模式:顶层只有代理入口 `mcp__abs`,
|
|
89
|
+
// 子工具名要作为 `tool` 参数传(namespace-tools.ts 只注册 mcp__<server> 一个)。
|
|
90
|
+
// ② 内建 MCP 直出:工具名就是 `mcp__abs__abs_task`。
|
|
91
|
+
// ③ codemode / 直出形态:子工具直接叫 `abs_task`。
|
|
92
|
+
// 上一版写死 `abs_task` → 在 ① 下模型去找一个不存在的顶层工具,指引等于空转
|
|
93
|
+
// (表现:嘴上说"先登记",实际没落盘)。故改成描述**意图 + 名字规律**,
|
|
94
|
+
// 让模型按当前会话实际可见的形态自己挑,不去猜死一个。
|
|
87
95
|
const TODO_GUIDELINES = [
|
|
88
|
-
'Use
|
|
96
|
+
'Use the abs task tool to track multi-step work **before** you start it, not after: on the first file edit of a task, call it with action "start" and a short id. (Name varies by host: `mcp__abs` with tool="abs_task", or `mcp__abs__abs_task`, or `abs_task` — use whichever form this session exposes.)',
|
|
89
97
|
'Mark a task "done" immediately when it finishes — never batch completions at the end of a session.',
|
|
90
98
|
'Before starting a task, record the checkpoint with action "note" (which file, which step) so a later session can resume.',
|
|
91
99
|
]
|
package/package.json
CHANGED
package/src/lint.js
CHANGED
|
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
|
|
6
6
|
import { readRules, idOfPage, statusOfPage, supersededByOf, clip } from './store.js';
|
|
7
|
-
import { doneKindOf, doneDateOf } from './todo.js';
|
|
7
|
+
import { doneKindOf, doneDateOf, checkFileShape } from './todo.js';
|
|
8
8
|
|
|
9
9
|
// ---------- 图谱遍历 ----------
|
|
10
10
|
|
|
@@ -101,6 +101,61 @@ export async function listPages(vault) {
|
|
|
101
101
|
return pages;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// ---------- 根文件检查(todo.md / log.md / index.md)----------
|
|
105
|
+
// 为什么补这段(2026-10-05):lint 此前只看 PAGE_DIRS 子目录,三个根文件不在扫描范围,
|
|
106
|
+
// OVER-SIZE 也只对 concepts/entities/syntheses 生效。于是「todo.md 涨到上百行、
|
|
107
|
+
// 断点行里塞实施报告」可以 lint 报 0 问题 —— 看着健康,实际已经变成笔记本。
|
|
108
|
+
const TODO_MAX_LINES = 60;
|
|
109
|
+
const LOG_MAX_LINES = 2000;
|
|
110
|
+
const INDEX_MAX_LINES = 200;
|
|
111
|
+
const LINT_BREAKPOINT_MAX = 200;
|
|
112
|
+
|
|
113
|
+
async function checkRootFiles(vault) {
|
|
114
|
+
const issues = [];
|
|
115
|
+
const specs = [
|
|
116
|
+
['todo.md', TODO_MAX_LINES, '任务看板;长内容迁 sessions/log-<日期>.md 或 sources/'],
|
|
117
|
+
['log.md', LOG_MAX_LINES, '流水;旧段按日期切到 sessions/'],
|
|
118
|
+
['index.md', INDEX_MAX_LINES, '只做索引;正文归各页'],
|
|
119
|
+
];
|
|
120
|
+
for (const [file, maxLines, hint] of specs) {
|
|
121
|
+
let body = '';
|
|
122
|
+
try {
|
|
123
|
+
body = await fs.readFile(join(vault, file), 'utf8');
|
|
124
|
+
} catch {
|
|
125
|
+
continue; // 文件尚未建 = 无内容,不算问题
|
|
126
|
+
}
|
|
127
|
+
const lines = body.split('\n');
|
|
128
|
+
if (lines.length > maxLines) {
|
|
129
|
+
issues.push(`ROOT-OVER-SIZE: ${BRAIN_DIR}/${file} (${lines.length}L > ${maxLines}L; ${hint})`);
|
|
130
|
+
}
|
|
131
|
+
// 标签白名单 + 条目形状(2026-10-05):index/log/todo 的标签写死、
|
|
132
|
+
// 每类内容的行格式固定。与写入闸门同源(checkFileShape)—— lint 抓存量。
|
|
133
|
+
for (const it of checkFileShape(file, body, { lintMode: true })) {
|
|
134
|
+
issues.push(`${it.code}: ${it.msg}`);
|
|
135
|
+
}
|
|
136
|
+
// 条目级:todo 的任务行/断点行不能是报告(与写入闸门 assertTodoContent 同判据,
|
|
137
|
+
// 但 lint 是「事后发现」—— 历史遗留的乱写靠它暴露,新写入靠闸门拦)。
|
|
138
|
+
if (file !== 'todo.md') continue;
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
const l = lines[i];
|
|
141
|
+
if (!l.trimStart().startsWith('↳ 断点:')) continue;
|
|
142
|
+
const bp = l.trimStart().slice('↳ 断点:'.length).trim();
|
|
143
|
+
if (bp.length > LINT_BREAKPOINT_MAX) {
|
|
144
|
+
issues.push(
|
|
145
|
+
`TODO-BREAKPOINT-BLOAT: ${BRAIN_DIR}/${file}:${i + 1} (断点 ${bp.length} 字 > ${LINT_BREAKPOINT_MAX};` +
|
|
146
|
+
`断点只写「改到哪个文件哪一步」,报告迁 sessions/ 或 sources/)`,
|
|
147
|
+
);
|
|
148
|
+
} else if (bp.split(/[。!?;!?;]+|\s*[\u2460-\u2473]\s*/).map((x) => x.trim()).filter(Boolean).length > 2) {
|
|
149
|
+
issues.push(
|
|
150
|
+
`TODO-BREAKPOINT-PROSE: ${BRAIN_DIR}/${file}:${i + 1} (断点写了多句 = 报告体;` +
|
|
151
|
+
`todo 只放待办,长文迁 sessions/ 或 sources/)`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return issues;
|
|
157
|
+
}
|
|
158
|
+
|
|
104
159
|
// ---------- 页级检查 ----------
|
|
105
160
|
|
|
106
161
|
/** 单页检查:frontmatter / 链接 / 容量 / 尾巴 / index 登记。 */
|
|
@@ -316,6 +371,7 @@ export async function cmdLint({ dir }) {
|
|
|
316
371
|
for (const pg of pages) issues.push(...checkPage(pg, names, linkedNames, inbound));
|
|
317
372
|
issues.push(...await checkGraph(root, pages));
|
|
318
373
|
issues.push(...await checkFiles(vault, pages, indexLinks));
|
|
374
|
+
issues.push(...await checkRootFiles(vault));
|
|
319
375
|
|
|
320
376
|
const n = issues.length;
|
|
321
377
|
return [
|
package/src/lock.js
CHANGED
|
@@ -8,6 +8,12 @@ import { join, dirname, basename } from 'node:path';
|
|
|
8
8
|
export const SKIP = Symbol('editFile.skip');
|
|
9
9
|
|
|
10
10
|
export class LockTimeout extends Error {}
|
|
11
|
+
|
|
12
|
+
/** 写入侧格式闸门(由 todo.js 注入,避免循环依赖)。
|
|
13
|
+
* 对 .brain 的 index/todo/log 三个文件,写盘前统一过一遍格式校验。
|
|
14
|
+
* 返回 null = 该文件不归闸门管;否则返回整理后的 text。 */
|
|
15
|
+
let formatGate = null;
|
|
16
|
+
export function setFormatGate(fn) { formatGate = fn; }
|
|
11
17
|
const LOCK_WAIT_BASE_MS = 15; // 指数退避起始重试间隔
|
|
12
18
|
const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
|
|
13
19
|
// 抢锁总预算:排队等锁的进程须依次排完。多进程高并发(CLI/MCP/hook 同刻抢一文件)下,
|
|
@@ -67,7 +73,10 @@ export async function editFile(file, mutator, { maxWaitMs = LOCK_MAX_WAIT_MS } =
|
|
|
67
73
|
try { current = await fs.readFile(file, 'utf8'); } catch { /* 尚无文件 */ }
|
|
68
74
|
const res = await mutator(current);
|
|
69
75
|
if (res === SKIP) return SKIP;
|
|
70
|
-
|
|
76
|
+
let write = typeof res === 'string' ? res : res && typeof res.text === 'string' ? res.text : null;
|
|
77
|
+
// 格式闸门:所有写 index/todo/log 的路径(CLI/MCP/hook 共 ~15 处)都在此收口,
|
|
78
|
+
// 不必逐个改调用点 —— 破坏格式的写入在这里被整理回标准形态(见 todo.js)。
|
|
79
|
+
if (write && formatGate) write = formatGate(file, write, current) ?? write;
|
|
71
80
|
if (write && write !== current) {
|
|
72
81
|
await fs.writeFile(file, write, 'utf8');
|
|
73
82
|
}
|
package/src/store.js
CHANGED
|
@@ -4,8 +4,8 @@ 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, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, doneKindOf, doneDateOf, collapseDone, SEC, rebuildStructure } from './todo.js';
|
|
8
|
-
import { editFile, SKIP } from './lock.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';
|
|
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';
|
|
@@ -113,6 +113,26 @@ function resolveProjectDir(dir) {
|
|
|
113
113
|
return resolve(dir || process.cwd());
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** 全套旧标记 → 标准标记(含 H1)。enforceBrainFormat 按整行精确匹配,
|
|
117
|
+
* 所以 H1 也得在表里 —— 曾经只传 `## `/`### ` 前缀的那部分,
|
|
118
|
+
* 因为 H1 归一当时归 rebuildStructure 管;但 log.md 的 order 为空、不走 rebuildStructure,
|
|
119
|
+
* 于是它的旧 H1 成了唯一没人归一的路径(实测「# 🗒 操作日志」永不修正)。
|
|
120
|
+
* 现在统一由 enforceBrainFormat 归一,表就给全套。 */
|
|
121
|
+
const legacyRenames = () => LEGACY_MARKS;
|
|
122
|
+
|
|
123
|
+
/** 注册写入侧格式闸门:lock.js 的 editFile 是所有写入的唯一收口,
|
|
124
|
+
* 这里把「哪个文件名用哪套标准」告诉它 —— 三个目标文件之外的写入一律放行。
|
|
125
|
+
* 直接 mutate 传入的 text 再返回;非目标文件返回 null 表示不管。 */
|
|
126
|
+
setFormatGate((file, text, current) => {
|
|
127
|
+
const name = file.split('/').pop();
|
|
128
|
+
const spec = BRAIN_SHAPE[name];
|
|
129
|
+
if (!spec) return null;
|
|
130
|
+
const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() }, current);
|
|
131
|
+
return name === 'todo.md'
|
|
132
|
+
? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
133
|
+
: r.text;
|
|
134
|
+
});
|
|
135
|
+
|
|
116
136
|
export function indexTemplate() {
|
|
117
137
|
// 同 todoTemplate:由 rebuildStructure 生成,模板 = 重排结果,不会来回抖。
|
|
118
138
|
return rebuildStructure(
|
|
@@ -129,7 +149,15 @@ export function indexTemplate() {
|
|
|
129
149
|
}
|
|
130
150
|
|
|
131
151
|
export function logTemplate() {
|
|
132
|
-
|
|
152
|
+
// ★ 占位符必须是注释形式,不能是真条目形状(2026-10-05 实测):
|
|
153
|
+
// 原来写的是 `## [YYYY-MM-DD] ingest | 沉淀 <slug>` —— 看起来像条目,
|
|
154
|
+
// 但它不是真日期、也不是真 kind,条目形状闸门上线后**模板自己就非法**,
|
|
155
|
+
// 新条目插到它上面后它掉到第 4 行,直接把写入卡死。
|
|
156
|
+
// 用 HTML 注释保留「怎么写」的提示,同时不参与任何解析。
|
|
157
|
+
return [
|
|
158
|
+
'# 🗒 Activity Log', '',
|
|
159
|
+
'<!-- 每条一行:## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文(kind: note|dev|concept|ingest) -->', ''
|
|
160
|
+
].join('\n');
|
|
133
161
|
}
|
|
134
162
|
|
|
135
163
|
// ---------- 结构核对: load 每次都读 index/log/todo,顺手核形状 ----------
|
|
@@ -220,27 +248,17 @@ export async function checkBrainShape(root) {
|
|
|
220
248
|
let changed = [];
|
|
221
249
|
await editFile(p, (cur) => {
|
|
222
250
|
if (cur === null) return SKIP;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
next2 = file === 'todo.md'
|
|
235
|
-
? r0.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
236
|
-
: r0.text;
|
|
237
|
-
} else {
|
|
238
|
-
next2 = fixMarks(cur, spec).text;
|
|
239
|
-
}
|
|
240
|
-
const r = { text: next2, changed: next2 === cur ? [] : ['结构按标准重排'] };
|
|
241
|
-
if (!r.changed.length) return SKIP;
|
|
242
|
-
changed = r.changed;
|
|
243
|
-
return { text: r.text };
|
|
251
|
+
// 格式闸门收口在一处(见 todo.js enforceBrainFormat):样式/空行/无头 三条规则
|
|
252
|
+
// 与写入侧共用同一实现,写盘前顺手把破坏格式的部分整理回标准形态。
|
|
253
|
+
// 走 normalizeTodo 的语义(Blocked → [滞留中])由 enforceBrainFormat 内部保证。
|
|
254
|
+
const r = enforceBrainFormat(cur, { ...spec, renames: legacyRenames() });
|
|
255
|
+
// 兜底:仍无状态标记的未完成任务补默认值(新格式文件本就有标记,此处不触发)。
|
|
256
|
+
const next2 = file === 'todo.md'
|
|
257
|
+
? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
258
|
+
: r.text;
|
|
259
|
+
if (next2 === cur) return SKIP;
|
|
260
|
+
changed = r.fixed.length ? r.fixed : ['结构按标准重排'];
|
|
261
|
+
return { text: next2 };
|
|
244
262
|
}).catch(() => {});
|
|
245
263
|
if (changed.length) fixed.push(`${file}: ${changed.join('; ')}`);
|
|
246
264
|
if (!knownH1) warn.push(`${file}: 标题非标准(读到 "${clip(l1, 24) || '(空)'}")`);
|
|
@@ -1056,6 +1074,15 @@ function slugOf(text, n = 24) {
|
|
|
1056
1074
|
export async function cmdLog({ dir, title, kind = 'dev' }) {
|
|
1057
1075
|
const root = await requireBrain(dir || process.cwd());
|
|
1058
1076
|
const who = await requireUser(); // 写操作守卫
|
|
1077
|
+
// ★ kind 必须是枚举值(2026-10-05 加):此前无校验,传什么写什么 ——
|
|
1078
|
+
// 实测有测试传 kind:'test' 写进去,而形状闸门上线后才暴露。
|
|
1079
|
+
// 枚举内校在**入口**(这里)比事后 lint 更早,且报错能直接告诉可用值。
|
|
1080
|
+
if (!LOG_KINDS.includes(String(kind))) {
|
|
1081
|
+
throw new Error(
|
|
1082
|
+
`✗ log 的 kind 只能是 ${LOG_KINDS.join(' / ')}(收到 "${kind}")\n` +
|
|
1083
|
+
` note=经验/踩坑 / dev=完成的工作 / concept=新建概念页 / ingest=沉淀资料`,
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1059
1086
|
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
1060
1087
|
const p = brainPath(root, 'log.md');
|
|
1061
1088
|
const stamp = localStamp();
|
|
@@ -1350,7 +1377,10 @@ export async function cmdConcept({ dir, slug, title, tags, desc }) {
|
|
|
1350
1377
|
}
|
|
1351
1378
|
const oneLine = String(desc || '').trim() || clip(head, 60);
|
|
1352
1379
|
await registerInIndex(root, 'Concepts', name, oneLine);
|
|
1353
|
-
|
|
1380
|
+
// 不写 log.md(2026-10-05 用户定):`新建概念页 x` 是**命令的副作用**不是成果 ——
|
|
1381
|
+
// 38 字符、零信息量,且「该页存在」已由 registerInIndex 落在 index.md 的 Concepts 区
|
|
1382
|
+
// (那是 index 的职责)。同件事落两处,且建 10 个页 = 10 行流水噪声自动重现,
|
|
1383
|
+
// 靠事后清理治不了。故删掉这次调用,不加开关(没人需要读「某页被创建了」)。
|
|
1354
1384
|
return `✓ 概念页骨架 → .brain/concepts/${name}.md ${atTag(who)}\n` +
|
|
1355
1385
|
' 已给好四段位置;填完内容后:删掉 <!-- --> 占位、按需改 status: active、挂双链。\n' +
|
|
1356
1386
|
' 尾部「## 验证」必须填(留空会被 abs lint 报 NO-TAIL)。';
|
package/src/todo.js
CHANGED
|
@@ -109,6 +109,224 @@ export function todoTemplate() {
|
|
|
109
109
|
// 结论:进行时分区是符合直觉但不符合实际工作流的抽象 → 删掉,
|
|
110
110
|
// 未完成的一律进 Todo,状态用**行首标记**表达(不靠分区区分)。
|
|
111
111
|
export const TODO_SECTIONS = ['Todo', 'Done'];
|
|
112
|
+
// todo 的 `###` 级标签(实测真实形态:`## Done` 下按日期分组 `### 2026-09-29`,
|
|
113
|
+
// 归位的进 `### Archived`)。白名单只限这两类:日期组 + Archived。
|
|
114
|
+
// 为什么必须列:`### ` 是 AI 最爱的「自建小节」位置(### 备忘 / ### 计划),
|
|
115
|
+
// 只查 `## ` 会把它放过去。
|
|
116
|
+
export const TODO_SUBSECTIONS = ['Archived'];
|
|
117
|
+
/** `###` 标签是否放行:归档区 / 未标日期组 / 日期组 `YYYY-MM-DD`。
|
|
118
|
+
* `Undated` 是 LEGACY_MARKS 里的正式标准名(旧 `### (未标日期)` → `### Undated`),
|
|
119
|
+
* 漏了它会把存量文件卡死(实测)。 */
|
|
120
|
+
export function isAllowedTodoSub(name) {
|
|
121
|
+
return TODO_SUBSECTIONS.includes(name) || name === 'Undated' || /^\d{4}-\d{2}-\d{2}$/.test(name);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------- 三份根文件的标签白名单与条目形状(2026-10-05 用户定)----------
|
|
125
|
+
// 用户原话:「index Rules Concepts Entities Sources Syntheses Sessions 包含这些标签
|
|
126
|
+
// 不允许增加新的标签,每个标签写内容的规则都是 - [[xx]]123 不允许乱写,同理 log todo 也是」
|
|
127
|
+
//
|
|
128
|
+
// 为什么需要:闸门只管骨架(分区顺序/H1/空行),**标签白名单与条目形状都没人管**。
|
|
129
|
+
// rebuildStructure 的规矩 3 更是「非标准分区原样保留在末尾」(为了防丢人自加的内容)
|
|
130
|
+
// → AI 新加一个 `## 备忘` 或 `## 计划` 永远合法,且会一直留在文件里。
|
|
131
|
+
//
|
|
132
|
+
// 注意 Rules 区是例外:它放的是**规矩短句**(「写代码前先读 docs/STRUCTURE.md」),
|
|
133
|
+
// 不是页链接 —— 实测 13 条规则全是这个形态。不能拿 [[页名]] 去卡它。
|
|
134
|
+
export const INDEX_SECTIONS = ['Rules', 'Concepts', 'Entities', 'Sources', 'Syntheses', 'Sessions'];
|
|
135
|
+
/** 需要 `- [[页名]] …` 形状的区(Rules 除外)。 */
|
|
136
|
+
export const INDEX_LINK_SECTIONS = ['Concepts', 'Entities', 'Sources', 'Syntheses', 'Sessions'];
|
|
137
|
+
/** log 行的 kind 枚举(与 cmdLog 写入端同源;实测 238 条只有这 4 种)。 */
|
|
138
|
+
export const LOG_KINDS = ['note', 'dev', 'concept', 'ingest'];
|
|
139
|
+
|
|
140
|
+
/** 条目形状规则表:文件 → 每行的预期形状。
|
|
141
|
+
* 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
|
|
142
|
+
/** 条目形状规则表:文件 → 每行的预期形状。
|
|
143
|
+
* 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
|
|
144
|
+
export const H1_TO_FILE = {
|
|
145
|
+
'# 🗂 Graph Index': 'index.md',
|
|
146
|
+
'# 🗒 Activity Log': 'log.md',
|
|
147
|
+
'# 📋 Todo Board': 'todo.md',
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const ENTRY_SHAPES = {
|
|
151
|
+
'index.md': [
|
|
152
|
+
{
|
|
153
|
+
name: 'INDEX-ENTRY',
|
|
154
|
+
// 条目行:`- [[页名]] — 一句话`(是 6 个链接区里的一行)
|
|
155
|
+
test: (l) => {
|
|
156
|
+
if (!/^-\s/.test(l)) return null; // 非条目行(规则短句/说明)由分区规则另判
|
|
157
|
+
if (!/^-\s*\[\[[^\]]+\]\]/.test(l)) return '应以 `- [[页名]] …` 开头';
|
|
158
|
+
if (!/^-\s*\[\[[^\]]+\]\]\s*[—-]\s*\S/.test(l)) return '`[[页名]]` 后要跟 `— 一句话` 描述';
|
|
159
|
+
return null;
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
'log.md': [
|
|
164
|
+
{
|
|
165
|
+
name: 'LOG-ENTRY',
|
|
166
|
+
test: (l) => {
|
|
167
|
+
if (!/^##\s/.test(l)) return null; // 非 `## ` 行(H1/注释)不管
|
|
168
|
+
// ★ log 没有分区概念:`## ` 开头的只能是条目。
|
|
169
|
+
// 否则 `## 2026-10-05 随便写点`(缺方括号/作者/竖线)会被当成「分区标题」滑过去 ——
|
|
170
|
+
// 而 log.md 的标签白名单是 null(不校验),这就是一个漏网口(实测)。
|
|
171
|
+
// 形状:## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文
|
|
172
|
+
// ★ 放行历史旧模板的占位行 `## [YYYY-MM-DD] ingest | 沉淀 <slug>`:
|
|
173
|
+
// 它是早版 logTemplate 造出来的,不是人写的乱,而是模板自身的遗留。
|
|
174
|
+
// 新模板已改成 HTML 注释(见 store.js logTemplate)。存量这行由 lint 报。
|
|
175
|
+
if (/^##\s*\[YYYY-MM-DD\]/.test(l)) return null;
|
|
176
|
+
const m = l.match(/^##\s*\[(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2})?)\]\s*\[\[([^\]]+)\]\]\s*([A-Za-z]+)\s*\|\s*(\S.*)$/);
|
|
177
|
+
if (!m) return '应为 `## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文`(log 只能有这种行)';
|
|
178
|
+
if (!LOG_KINDS.includes(m[3])) return `kind 只能是 ${LOG_KINDS.join('/')}(收到 \`${m[3]}\`)`;
|
|
179
|
+
// 「正文只有个任务 id」不在这里拒(见下 lintOnly 规则):它是**内容质量**
|
|
180
|
+
// 问题不是**结构格式**问题,当写入闸门会把存量脏库锁死(实测:真库仅一条
|
|
181
|
+
// 就让所有 abs log 失败)。留在此处只会误伤正常短标题(如 `log-0`)。
|
|
182
|
+
return null;
|
|
183
|
+
},
|
|
184
|
+
// 仅在 lint 阶段生效的额外规则(不阻写入,只报存量)。
|
|
185
|
+
lintOnly: (l) => {
|
|
186
|
+
const m = l.match(/^##\s*\[\d{4}-\d{2}-\d{2}[^\]]*\]\s*\[\[[^\]]+\]\]\s*[A-Za-z]+\s*\|\s*(\S.*)$/);
|
|
187
|
+
if (!m) return null;
|
|
188
|
+
if (/^[A-Z][A-Z0-9-]{2,}$/.test(m[1].trim())) return '正文只写了个任务 id(等于没记内容),补一句发生了什么';
|
|
189
|
+
return null;
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
'todo.md': [
|
|
194
|
+
{
|
|
195
|
+
name: 'TODO-ENTRY',
|
|
196
|
+
// 任务行形状(实测):
|
|
197
|
+
// 新写入(cmdTask):`- [ ] [状态] id [[作者]] — 描述 (认领 日期)`
|
|
198
|
+
// 旧存量(四区制迁移):`- [ ] 中文描述 (认领 日期)` ← 没有 id / 作者
|
|
199
|
+
// 已完成:`- [x] id …`(markDone 会 stripStateMark 去掉状态)
|
|
200
|
+
//
|
|
201
|
+
// ★ 为什么不强制要求 id:旧存量的 id 缺失是**历史事实**,机器不能凭空编造
|
|
202
|
+
// (编了就是静默改写语义)。闸门只确保「结构可解析」:
|
|
203
|
+
// 未完成必须有 `[状态]`(归一后的标志);已完成必须有内容。
|
|
204
|
+
// 而「新写入必须带 id」由 cmdTask 拼装保证 —— 那是入口的职责,
|
|
205
|
+
// 不是事后逐行猜(猜不出哪行是新的、哪行是旧的)。
|
|
206
|
+
test: (l) => {
|
|
207
|
+
if (/^#{1,3}\s/.test(l)) return null; // 标题行(## Done / ### 日期)不是条目
|
|
208
|
+
if (!/^-\s/.test(l)) return null; // 非条目行(如缩进的 ↳ 断点)由下面单独判
|
|
209
|
+
// Done 区的归档行是 `- [[log-日期]] 完成任务 N 条`,不是任务行
|
|
210
|
+
if (/^-\s*\[\[log-\d{4}-\d{2}-\d{2}\]\]/.test(l)) return null;
|
|
211
|
+
const m = l.match(/^-\s*\[([ x])\]\s*(.*)$/);
|
|
212
|
+
if (!m) return '应以 `- [ ] ` 或 `- [x] ` 开头';
|
|
213
|
+
const rest = m[2].trim();
|
|
214
|
+
if (!rest) return '任务行不能只有复选框,要写做什么(已拒)';
|
|
215
|
+
// 未完成:必须有状态标记(新写法)——无标记的旧行由 enforceBrainFormat 自动补上,
|
|
216
|
+
// 走到这里还没标记 = 补不了(不是 `- [ ] ` 形状),报错。
|
|
217
|
+
if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
|
|
218
|
+
return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
|
|
219
|
+
}
|
|
220
|
+
// 断点附属行不能挤在同一行(应另起 ` ↳ 断点: `)
|
|
221
|
+
if (/↳\s*断点:/.test(rest)) return '断点要另起一行写 ` ↳ 断点: …`';
|
|
222
|
+
return null;
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
], // 任务行/断点行另由 assertTodoContent 卡长度与报告体
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** 校验单个文件的分区标签白名单 + 条目形状。返回问题列表(不抛错,供 lint 与写入闸门共用)。
|
|
229
|
+
* reason 文案直接把出路写清楚 —— 报错不给出路等于让人挖坑。 */
|
|
230
|
+
export function checkFileShape(file, text, { lintMode = false } = {}) {
|
|
231
|
+
const issues = [];
|
|
232
|
+
const lines = String(text ?? '').split('\n');
|
|
233
|
+
const allowed = file === 'index.md' ? INDEX_SECTIONS : file === 'todo.md' ? TODO_SECTIONS : null;
|
|
234
|
+
|
|
235
|
+
if (allowed) {
|
|
236
|
+
for (let i = 0; i < lines.length; i++) {
|
|
237
|
+
const l = lines[i];
|
|
238
|
+
// `## [` 开头是 log 的条目行,不是分区标签(否则会把 238 条流水全判非法)
|
|
239
|
+
if (/^##\s+\[/.test(l)) continue;
|
|
240
|
+
const m = l.match(/^##\s+(\S.*)$/);
|
|
241
|
+
const sub = l.match(/^###\s+(\S.*)$/);
|
|
242
|
+
if (!m && !sub) continue;
|
|
243
|
+
const name = (m ? m[1] : sub[1]).trim();
|
|
244
|
+
// todo 的 `###` 标签单独判(只许日期组 + Archived)
|
|
245
|
+
if (sub && file === 'todo.md') {
|
|
246
|
+
if (!isAllowedTodoSub(name)) {
|
|
247
|
+
issues.push({
|
|
248
|
+
code: 'SECTIONS-NOT-ALLOWED',
|
|
249
|
+
line: i + 1,
|
|
250
|
+
msg: `todo.md:${i + 1} 不允许的小节 \`### ${name}\`;` +
|
|
251
|
+
`todo.md 的 ### 只允许 \`### YYYY-MM-DD\`(Done 区日期组)、\`### Undated\` 与 \`### Archived\``,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!allowed.includes(name)) {
|
|
257
|
+
issues.push({
|
|
258
|
+
code: 'SECTIONS-NOT-ALLOWED',
|
|
259
|
+
line: i + 1,
|
|
260
|
+
msg: `${file}:${i + 1} 不允许的标签 \`${m ? '##' : '###'} ${name}\`;` +
|
|
261
|
+
`${file} 只允许 ${allowed.map((s) => `\`## ${s}\``).join(' / ')}${
|
|
262
|
+
file === 'todo.md' ? '(`###` 只许日期组与 `### Archived`)' : ''}。` +
|
|
263
|
+
`内容按语义归入现有标签,确实需要例外请先改白名单(见 src/todo.js 的 SECTIONS 常量)`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 条目形状
|
|
270
|
+
const shapes = ENTRY_SHAPES[file] || [];
|
|
271
|
+
if (shapes.length) {
|
|
272
|
+
// 先算每个行所属分区(形状只对被要求的区生效)
|
|
273
|
+
let section = null;
|
|
274
|
+
for (let i = 0; i < lines.length; i++) {
|
|
275
|
+
const l = lines[i];
|
|
276
|
+
// ★ 分区标题的识别不能吃掉 log 的条目行:log 的条目就是 `## [时间] …`,
|
|
277
|
+
// 若把它当分区标题就会 continue 掉本行,形状校验形同虚设(实测:
|
|
278
|
+
// kind=node / 缺竖线 全部漏放)。判据:`## [` 开头 = 条目,不是分区。
|
|
279
|
+
// ★ 分区标题的识别不能吃掉 log 的条目行:log 的条目就是 `## [时间] …`,
|
|
280
|
+
// 若把它当分区标题就会 continue 掉本行,形状校验形同虚设(实测:
|
|
281
|
+
// kind=node / 缺竖线 全部漏放)。
|
|
282
|
+
// ★ log.md 特殊:它**没有分区**,`## ` 开头的只能是条目(含写错的),
|
|
283
|
+
// 所以 log 里绝不能把 `## ` 行当分区标题 —— 全交给形状规则判。
|
|
284
|
+
const sm = file === 'log.md' ? null : (/^##\s+\[/.test(l) ? null : l.match(/^##\s+(\S.*)$/));
|
|
285
|
+
if (sm) { section = sm[1].trim(); continue; }
|
|
286
|
+
if (!l.trim()) continue;
|
|
287
|
+
const needShape = file === 'log.md'
|
|
288
|
+
? true
|
|
289
|
+
: file === 'todo.md'
|
|
290
|
+
? true // todo 的任务行形状与分区无关(Todo/Done 都要 `- [ ] …`)
|
|
291
|
+
: indexLinkSectionNeeds(section);
|
|
292
|
+
if (!needShape) continue;
|
|
293
|
+
for (const rule of shapes) {
|
|
294
|
+
const bad = rule.test(l);
|
|
295
|
+
if (bad) issues.push({ code: rule.name, line: i + 1, msg: `${file}:${i + 1} ${bad}` });
|
|
296
|
+
// lintOnly 规则只在体检时跑(不阻写入)—— 用于「内容质量」类问题,
|
|
297
|
+
// 那些问题当闸门会把存量脏库锁死。
|
|
298
|
+
if (!lintMode) continue;
|
|
299
|
+
const bad2 = rule.lintOnly ? rule.lintOnly(l) : null;
|
|
300
|
+
if (bad2) issues.push({ code: `${rule.name}-QUALITY`, line: i + 1, msg: `${file}:${i + 1} ${bad2}` });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// 条目之间的空行(与写入闸门 assertNoStrayBlank 同判据)。
|
|
305
|
+
// lint 只能「报」,不能抛 —— 体检不能因格式坏而挂。
|
|
306
|
+
const isEntryLine = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
|
|
307
|
+
const isHeadingLine = (l) => /^#{1,3}\s/.test(l);
|
|
308
|
+
for (let i = 0; i < lines.length; i++) {
|
|
309
|
+
if (lines[i].trim()) continue;
|
|
310
|
+
if (i > 0 && !lines[i - 1].trim()) continue; // 连续空行只报一次
|
|
311
|
+
let p = i - 1;
|
|
312
|
+
while (p >= 0 && !lines[p].trim()) p--;
|
|
313
|
+
let n = i + 1;
|
|
314
|
+
while (n < lines.length && !lines[n].trim()) n++;
|
|
315
|
+
const prev = p >= 0 ? lines[p] : null;
|
|
316
|
+
const next = n < lines.length ? lines[n] : null;
|
|
317
|
+
if (!prev || !next) continue;
|
|
318
|
+
if (isHeadingLine(prev)) continue; // 标签与首条目之间的空行是排版,不算乱
|
|
319
|
+
if (isEntryLine(prev) && isEntryLine(next)) {
|
|
320
|
+
issues.push({ code: 'STRAY-BLANK-LINE', line: i + 1, msg: `${file}:${i + 1} 条目之间不允许空行(条目必须紧贴)` });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return issues;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** index 的某个区是否要求 `- [[页名]]` 形状(Rules 与未知区不要求)。 */
|
|
327
|
+
function indexLinkSectionNeeds(section) {
|
|
328
|
+
return section !== null && INDEX_LINK_SECTIONS.includes(section);
|
|
329
|
+
}
|
|
112
330
|
|
|
113
331
|
/** 任务状态标记(行首,方括号)。替代原 Backlog/Today/Blocked 三区的区分作用。
|
|
114
332
|
* 放在 id **之前**,与 Done 结语的 `【落地】` 形态区分开(那是行尾、结语用)。 */
|
|
@@ -161,10 +379,15 @@ export function rebuildStructure(text, spec) {
|
|
|
161
379
|
// 空分区之间不插空行(否则每次首跑都会“把空行加进去”而写盘一次,
|
|
162
380
|
// 而 load 是好读命令 —— 不该因纯排版差异去改文件)。
|
|
163
381
|
// 有内容的第一个分区与前言之间保留一个空行(排版),其余紧凑。
|
|
382
|
+
// 但 H1 后必须恒有一个空行(2026-10-05 实测漏网):前言为空且首个分区为空时
|
|
383
|
+
// (如 H1 缺失被补回、而 `## Rules` 还没条目),两个条件都不满足 → `# H1`
|
|
384
|
+
// 和 `## Rules` 直接相贴。首行是这个文件的门面,不容忍这种粘贴。
|
|
385
|
+
let first = true;
|
|
164
386
|
for (const [idx, name] of spec.order.entries()) {
|
|
165
387
|
const body = trimBlank(bucket.get(name) || []);
|
|
166
|
-
if (body.length ||
|
|
388
|
+
if (body.length || first) out.push('', name, ...body);
|
|
167
389
|
else out.push(name);
|
|
390
|
+
first = false;
|
|
168
391
|
}
|
|
169
392
|
for (const e of extras) {
|
|
170
393
|
const body = trimBlank(e.lines);
|
|
@@ -183,6 +406,337 @@ function trimBlank(arr) {
|
|
|
183
406
|
return a;
|
|
184
407
|
}
|
|
185
408
|
|
|
409
|
+
/** 叶子条目行:log 的 `## [时间] …` 条目、todo/index 的 `- …` 行。
|
|
410
|
+
* 空行落在两个叶子条目之间 = 人为排版漂移,会随条目增长把文件撑成两倍行数。
|
|
411
|
+
* 注意(踩坑):标题行不算 —— `## Done` 与 `### 日期` 之间的空行是分区排版,
|
|
412
|
+
* 删了会把 Done 区挤成一片(首版误删)。 */
|
|
413
|
+
const isLeafEntry = (l) => /^- |^#{1,3}\s+\[/.test(l);
|
|
414
|
+
|
|
415
|
+
/** 已废弃的标准分区(2026-10-05 用户定,白名单只一条)。
|
|
416
|
+
* `## Roadmap` 是 2026-09-13 用户亲手删的分区(`store.js` 注释写明原因:
|
|
417
|
+
* “AI 自己写的方向总结,会被 load 反复读到并带偏后续会话”)。
|
|
418
|
+
* 但 `rebuildStructure` 的规矩 3 是「非标准分区原样保留在末尾」——那条护栏是为了
|
|
419
|
+
* 防丢失人自加的区(如 `## 备忘`),机器不猜语义。结果是 Roadmap 被当成“人自加的区”
|
|
420
|
+
* 留了下来,而且 AI 每次重写 index 都能把它加回来:删一个分区的决策根本没生效。
|
|
421
|
+
* 所以这里单列一张白名单,只放**被正式删过的标准分区**——它们进闸门就被整段丢弃;
|
|
422
|
+
* 从没见过的(`## 备忘` 类)仍按护栏原样保留。不做通用机制,出现第二个再添。 */
|
|
423
|
+
export const RETIRED_SECTIONS = ['## Roadmap'];
|
|
424
|
+
|
|
425
|
+
/** 删除废弃分区的整段(标题到下一个同级/更高级标题前)。
|
|
426
|
+
* 返回 { text, removed:[区名] };只按整行精确匹配标题,不碰正文。 */
|
|
427
|
+
export function dropRetiredSections(text) {
|
|
428
|
+
const lines = String(text ?? '').split('\n');
|
|
429
|
+
const removed = [];
|
|
430
|
+
const out = [];
|
|
431
|
+
let dropping = false;
|
|
432
|
+
let dropLevel = 0;
|
|
433
|
+
for (const l of lines) {
|
|
434
|
+
const m = l.match(/^(#{2,3})\s+(.*)$/);
|
|
435
|
+
if (m) {
|
|
436
|
+
const title = `${m[1]} ${m[2].trim()}`;
|
|
437
|
+
if (RETIRED_SECTIONS.includes(title)) {
|
|
438
|
+
removed.push(title);
|
|
439
|
+
dropping = true;
|
|
440
|
+
dropLevel = m[1].length;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
// 遇到同级或更高级的标题 = 废弃区结束
|
|
444
|
+
if (dropping && m[1].length <= dropLevel) dropping = false;
|
|
445
|
+
}
|
|
446
|
+
if (!dropping) out.push(l);
|
|
447
|
+
}
|
|
448
|
+
return { text: out.join('\n'), removed };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 写入侧格式闸门:三个文件在**每次写盘前**都过这里,不是只在 load 时修。
|
|
452
|
+
*
|
|
453
|
+
* 规则(用户 2026-10-05 定):
|
|
454
|
+
* 1. 固定样式 — 标题/分区名必须逐字对标准,不符按 LEGACY_MARKS 归一;
|
|
455
|
+
* 2. 不允许空行 — 条目之间不留空行(正文段落内的空行保留);
|
|
456
|
+
* 3. 结构固定 — 无 H1 的「无头文件」补回标准 H1,分区按标准顺序重排(rebuildStructure)。
|
|
457
|
+
*
|
|
458
|
+
* 入参 spec 与 checkBrainShape 的 BRAIN_SHAPE 同源(见 store.js)。
|
|
459
|
+
* log.md 无分区(order 为空)→ 只做 H1/归一/去空行,不重排条目顺序(时间倒序自带语义)。
|
|
460
|
+
* 返回 { text, fixed:[描述] };fixed 为空 = 无需改盘。 */
|
|
461
|
+
// ---------- 条目级闸门(2026-10-05 加)----------
|
|
462
|
+
// 为什么加:闸门此前只管**骨架形状**(分区/H1/空行),不管条目**内容**。
|
|
463
|
+
// 于是 AI 把 todo 当笔记本 —— 实施报告、测试数据、需求清单、架构分析全塞进
|
|
464
|
+
// 断点行,文件涨到 232 行/上万字,而闸门与 lint 双双报「0 问题」。
|
|
465
|
+
// 这里补上内容约束:todo 只能放 todo。长内容走 abs note(经验)/ abs log(流水)/ sources 页。
|
|
466
|
+
//
|
|
467
|
+
// 注意:**超长一律抛错,不静默截断**。截断会丢数据且无声 —— 正是要防的那种失效。
|
|
468
|
+
export const BREAKPOINT_MAX = 200;
|
|
469
|
+
export const TASK_LINE_MAX = 400;
|
|
470
|
+
|
|
471
|
+
/** 一行的「违规签名」:去空白、截前 120 字。
|
|
472
|
+
* 用途:比对「这条违规是不是写入前就有的」——新增的拒,存量的放。
|
|
473
|
+
* 不按行号比对:插入一行会让后面所有行号位移,按行号会误判。 */
|
|
474
|
+
function sigOfLine(l) {
|
|
475
|
+
return String(l).replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 把一份文本里所有「违规行」的签名收成集合(供存量/新增比对)。
|
|
479
|
+
* 只有三个根文件用得上;非目标文件直接返回空集。
|
|
480
|
+
* ★ 空行特殊:签名取「上下两行拼接」不可靠 —— 插入一行就会让空行的
|
|
481
|
+
* 相邻行变样,导致存量空行被当成新引入(实测:cmdNote 插一行后,
|
|
482
|
+
* 同一处空行的签名就对不上了)。所以空行只记一个存在标记:
|
|
483
|
+
* 入库前就有「条目间空行」这个毛病 → 后续写入全放行(lint 报)。 */
|
|
484
|
+
export function violationSignatures(text) {
|
|
485
|
+
const set = new Set();
|
|
486
|
+
const lines = String(text ?? '').split('\n');
|
|
487
|
+
for (const l of lines) set.add(sigOfLine(l));
|
|
488
|
+
const isEntry = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
|
|
489
|
+
for (let i = 1; i < lines.length - 1; i++) {
|
|
490
|
+
if (lines[i].trim()) continue;
|
|
491
|
+
if (isEntry(lines[i - 1]) && isEntry(lines[i + 1])) {
|
|
492
|
+
set.add('HAS-STRAY-BLANK');
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return set;
|
|
497
|
+
}
|
|
498
|
+
/** 断点里出现这些 = 明显是在塞报告(列表/代码块/表格)。 */
|
|
499
|
+
const REPORT_MARKERS = [
|
|
500
|
+
/^\s{0,4}[-*]\s+\S/m, // 列表项
|
|
501
|
+
/^\s{0,4}\d+[.)]\s+\S/m, // 有序列表
|
|
502
|
+
/```/, // 代码块
|
|
503
|
+
/^\s*\|.*\|\s*$/m, // 表格行
|
|
504
|
+
];
|
|
505
|
+
|
|
506
|
+
/** 数一个断点里有几个「句子」—— 断点是一件事,多句 = 在塞报告。
|
|
507
|
+
* 中文按 。!?;/ 英文按 .!?; 切;带圈编号 ①②③ 也算分隔符。
|
|
508
|
+
* 为什么不用关键词表:关键词命中不稳(「功能/方案」正常描述也会出现),
|
|
509
|
+
* 而「一句还是一段」是结构判据 —— 结构判据才可靠。 */
|
|
510
|
+
function sentenceCount(s) {
|
|
511
|
+
return String(s)
|
|
512
|
+
.split(/[。!?;!?;]+|\s*[\u2460-\u2473]\s*/)
|
|
513
|
+
.map((x) => x.trim())
|
|
514
|
+
.filter(Boolean).length;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** 「绝对不允许乱空行」(2026-10-05 用户定):条目之间不能有空行。
|
|
518
|
+
* 为什么必须拒而不只是修:旧实现是「自动删」(fixed.push('删除条目之间的空行')),
|
|
519
|
+
* 但那是**默默改盘** —— 写进去的东西被改了而 AI 不知道,下次又写一遍。
|
|
520
|
+
* 现在直接报错,让写入方自己写对。
|
|
521
|
+
* 允许的位置:H1 与首个标签之间、标签与首条目之间、区与区之间(排版需要)。 */
|
|
522
|
+
export function assertNoStrayBlank(text, file, prevSig = null) {
|
|
523
|
+
const lines = String(text ?? '').split('\n');
|
|
524
|
+
const isEntry = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
|
|
525
|
+
const isHeading = (l) => /^#{1,3}\s/.test(l);
|
|
526
|
+
for (let i = 0; i < lines.length; i++) {
|
|
527
|
+
if (lines[i].trim()) continue;
|
|
528
|
+
// 找上一个/下一个非空行
|
|
529
|
+
let p = i - 1;
|
|
530
|
+
while (p >= 0 && !lines[p].trim()) p--;
|
|
531
|
+
let n = i + 1;
|
|
532
|
+
while (n < lines.length && !lines[n].trim()) n++;
|
|
533
|
+
const prev = p >= 0 ? lines[p] : null;
|
|
534
|
+
const next = n < lines.length ? lines[n] : null;
|
|
535
|
+
if (prev === null || next === null) continue; // 文件头尾空行不算
|
|
536
|
+
// 连续多个空行:报一次就够
|
|
537
|
+
if (i > 0 && !lines[i - 1].trim()) continue;
|
|
538
|
+
if (!isEntry(prev) && !isEntry(next)) continue; // 正文段落之间允许
|
|
539
|
+
// ★ 标题与首个条目之间必留一个空行(排版必需)—— H1/标签后紧跟条目反而难看。
|
|
540
|
+
// 实测踩坑:log 模板就是 `# H1` + 空行 + `## [日期] …`,不放行会把模板本身卡死。
|
|
541
|
+
if (isHeading(prev)) continue;
|
|
542
|
+
// 两个条目之间(含「条目 → 空行 → 条目」)不允许
|
|
543
|
+
if (isEntry(prev) && isEntry(next)) {
|
|
544
|
+
// 存量空行(写入前就有)→ 放行,交给 lint 报
|
|
545
|
+
if (prevSig !== null && prevSig.has('HAS-STRAY-BLANK')) continue;
|
|
546
|
+
throw new Error(
|
|
547
|
+
`✗ ${file} 不允许条目之间的空行(第 ${i + 1} 行):\n` +
|
|
548
|
+
` 上一行: ${prev.slice(0, 50)}\n` +
|
|
549
|
+
` 下一行: ${next.slice(0, 50)}\n` +
|
|
550
|
+
` 条目必须紧贴;标签与首条目之间才留一个空行。已拒绝写入 —— 请删掉这个空行再写。`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** 校验 todo 全文的条目内容。抛错 = 拒绝写入(不是修正)。 */
|
|
558
|
+
export function assertTodoContent(text, prevSig = null) {
|
|
559
|
+
const lines = String(text ?? '').split('\n');
|
|
560
|
+
/** 存量行(写入前就存在)→ 放行,交给 lint 报。 */
|
|
561
|
+
const isLegacy = (l) => prevSig !== null && prevSig.has(sigOfLine(l));
|
|
562
|
+
for (let i = 0; i < lines.length; i++) {
|
|
563
|
+
const l = lines[i];
|
|
564
|
+
const lineNo = i + 1;
|
|
565
|
+
if (l.startsWith('## ')) continue;
|
|
566
|
+
// 任务行
|
|
567
|
+
if (isTaskLine(l)) {
|
|
568
|
+
if (l.length > TASK_LINE_MAX && !isLegacy(l)) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
`✗ 任务行太长(第 ${lineNo} 行 ${l.length} 字 > ${TASK_LINE_MAX})—— todo 只放「要做什么」,一句话。\n` +
|
|
571
|
+
` 这么长的内容要么拆成多条任务,要么走 abs note(经验)/ abs log(流水)。\n` +
|
|
572
|
+
` 原文开头: ${l.slice(0, 60)}...`,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// 断点附属行
|
|
578
|
+
if (l.trimStart().startsWith('↳ 断点:')) {
|
|
579
|
+
if (isLegacy(l)) continue; // 存量超长断点:不阻写入(lint 报)
|
|
580
|
+
const body = l.trimStart().slice('↳ 断点:'.length).trim();
|
|
581
|
+
if (body.length > BREAKPOINT_MAX) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
`✗ 断点太长(第 ${lineNo} 行 ${body.length} 字 > ${BREAKPOINT_MAX})—— 断点只写「改到哪个文件哪一步」。\n` +
|
|
584
|
+
` 写不下的内容请分流:实施报告/验证数据 → abs log;经验与坑 → abs note;长设计 → .brain/sources/ 页。\n` +
|
|
585
|
+
` 原文开头: ${body.slice(0, 60)}...`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
if (REPORT_MARKERS.some((re) => re.test(body)) || sentenceCount(body) > 2) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
`✗ 断点里像是塞了报告/清单(第 ${lineNo} 行)—— 断点是一句话(最多两句),不是文档。\n` +
|
|
591
|
+
` todo 只能加 todo:要做的拆成任务,做完的写 abs log,经验写 abs note。\n` +
|
|
592
|
+
` 原文开头: ${body.slice(0, 60)}...`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** 删掉「条目之间的空行」(条目前后紧贴才是标准形态)。
|
|
602
|
+
* 在 enforceBrainFormat 里跑两次:assertShape 前(清存量,否则存量空行会把写入全卡死)、
|
|
603
|
+
* 重建后(重排会新建邻接关系)。幂等 —— 第二次通常零改动。
|
|
604
|
+
* 为什么删而不是拒:空行不携带信息,删了不丢东西;而断点/条目形状带着内容,
|
|
605
|
+
* 没有安全的自动修法,只能拒并让人处理。 */
|
|
606
|
+
function stripStrayBlanks(body, fixed) {
|
|
607
|
+
const lines = String(body).split('\n');
|
|
608
|
+
const kept = [];
|
|
609
|
+
for (let i = 0; i < lines.length; i++) {
|
|
610
|
+
const l = lines[i];
|
|
611
|
+
if (!l.trim()) {
|
|
612
|
+
const prev = kept[kept.length - 1];
|
|
613
|
+
// 前瞻要跳过连续空行,否则「两个空行」里只有第一个被删(两个空行是常见形态,
|
|
614
|
+
// 首版留下一个 → 规则形同虚设)。
|
|
615
|
+
let j = i + 1;
|
|
616
|
+
while (j < lines.length && !lines[j].trim()) j++;
|
|
617
|
+
const next = lines[j];
|
|
618
|
+
if (prev !== undefined && next !== undefined && isLeafEntry(prev) && isLeafEntry(next)) {
|
|
619
|
+
fixed.push('删除条目之间的空行');
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
kept.push(l);
|
|
624
|
+
}
|
|
625
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function enforceBrainFormat(text, spec, prev) {
|
|
629
|
+
const src = String(text ?? '');
|
|
630
|
+
if (!src.trim()) return { text: src, fixed: [] };
|
|
631
|
+
// 内容闸门:形状归一之前先拒掉乱塞(超长断点/报告体),否则下面的 rebuild
|
|
632
|
+
// 会把它当「人自加的正文」好好保留下来 —— 越规整越难发现。
|
|
633
|
+
// ★ 存量宽容(2026-10-05 本机实测踩到):只拒「本次写入新引入」的违规;
|
|
634
|
+
// 写入前就存在的违规放行(由 lint 报出)。
|
|
635
|
+
// 为什么:不做这个区分,库里只要有一条历史脏行,**所有写入全部失败** ——
|
|
636
|
+
// 实测真库(corp_agent)就因一条超长断点 + 8 条 `[]()` 条目,
|
|
637
|
+
// 连 `abs rule add` 都做不到,整个图谱变成只读。
|
|
638
|
+
const prevSig = prev == null ? null : violationSignatures(String(prev));
|
|
639
|
+
if (spec?.h1 === '# 📋 Todo Board') assertTodoContent(src, prevSig);
|
|
640
|
+
// ★ 标签白名单闸门放在**旧标记归一之后**(见下面 legacy 段之后)——
|
|
641
|
+
// 旧标签(`## Backlog` / `## Today / In Progress`)是**待迁移**的,不是非法新标签。
|
|
642
|
+
// 首版把校验放在归一之前,把存量老文件全卡死(7 个测试红,2026-10-05 实测)。
|
|
643
|
+
const fname = H1_TO_FILE[spec?.h1];
|
|
644
|
+
let checked = false;
|
|
645
|
+
const assertShape = (t) => {
|
|
646
|
+
if (!fname || checked) return;
|
|
647
|
+
checked = true;
|
|
648
|
+
// ★ 空行:直接拒绝(用户 2026-10-05:「绝对不允许乱空行」)。
|
|
649
|
+
// 存量库里本来就有空行的,在**写入前**先被归一清除(见 enforceBrainFormat 尾部),
|
|
650
|
+
// 所以走到这里仍有空行 = 本次写入引入的 → 拒。
|
|
651
|
+
assertNoStrayBlank(t, fname);
|
|
652
|
+
// ★ 标签/条目形状:存量脏行放行(不因历史数据锁死库),新增的拒。
|
|
653
|
+
// 为什么这类要宽容而空行不要:超长断点/`[]()` 条目**没有安全的自动修法**
|
|
654
|
+
// (截断会丢数据、改格式会改语义),只能报给人看;而空行删了不丢任何信息。
|
|
655
|
+
const tLines = t.split('\n');
|
|
656
|
+
const bad = checkFileShape(fname, t).filter(
|
|
657
|
+
(it) => it.code !== 'STRAY-BLANK-LINE' &&
|
|
658
|
+
!(prevSig !== null && prevSig.has(sigOfLine(tLines[it.line - 1] || ''))),
|
|
659
|
+
);
|
|
660
|
+
if (bad.length) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`✗ ${fname} 格式不合规(写入被拒):\n` +
|
|
663
|
+
bad.map((it) => ` · ${it.msg}`).join('\n') +
|
|
664
|
+
`\n 出路:内容归入现有标签,或走对应入口(页 → concepts/ 等子目录 + index 登记;流水 → abs log;经验 → abs note)。`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
const fixed = [];
|
|
669
|
+
// 旧标记 → 标准标记(H1 与分区/分组标题)。与 store.js 的 LEGACY_MARKS 同源,
|
|
670
|
+
// 由调用方通过 spec.renames 注入(todo.js 不反向依赖 store.js)。
|
|
671
|
+
|
|
672
|
+
// (0) 废弃分区:被正式删过的标准分区(如 Roadmap)整段丢弃,不等 rebuildStructure
|
|
673
|
+
// 把它当“人自加的区”留到末尾 —— 否则删分区的决策每次都被 AI 重写覆盖回去。
|
|
674
|
+
const retired = dropRetiredSections(src);
|
|
675
|
+
if (retired.removed.length) {
|
|
676
|
+
fixed.push(`删除已废弃分区: ${retired.removed.join(', ')}`);
|
|
677
|
+
}
|
|
678
|
+
// (1) 样式:旧标题名先归一到标准(LOG 无分区、不走 rebuildStructure,这条是它的唯一归一者)。
|
|
679
|
+
let body = retired.text;
|
|
680
|
+
const renames = spec.renames || [];
|
|
681
|
+
if (renames.length) {
|
|
682
|
+
const ls = body.split('\n');
|
|
683
|
+
for (let i = 0; i < ls.length; i++) {
|
|
684
|
+
const t = ls[i].trim();
|
|
685
|
+
const hit = renames.find(([o]) => o === t);
|
|
686
|
+
if (hit && ls[i] !== hit[1]) { ls[i] = hit[1]; fixed.push(`${hit[0]} → ${hit[1]}`); }
|
|
687
|
+
}
|
|
688
|
+
body = ls.join('\n');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ★ 旧格式自动升级(2026-10-05):闸门不能只会拒,还得会**修**。
|
|
692
|
+
// 用户要求「旧版本旧样式要能自动更新为新样式」。
|
|
693
|
+
// 这里把已知的旧形态归一为新形态,**归一之后再校验白名单** ——
|
|
694
|
+
// 否则存量老文件(无状态标记的裸任务行)会被当成违规而卡死(实测:
|
|
695
|
+
// 底层 addTask/upsertTask 传的就是裸文本,它们是内部 API,不是 AI 入口)。
|
|
696
|
+
if (fname === 'todo.md') {
|
|
697
|
+
const ls = body.split('\n');
|
|
698
|
+
for (let i = 0; i < ls.length; i++) {
|
|
699
|
+
// 裸任务行(`- [ ] id …` 无状态标记)→ 补默认「进行中」
|
|
700
|
+
if (/^- \[ \] /.test(ls[i]) && !/^- \[ \] \[[^\]]+\]/.test(ls[i])) {
|
|
701
|
+
const fixed2 = ensureStateMark(ls[i], '进行中');
|
|
702
|
+
if (fixed2 !== ls[i]) { ls[i] = fixed2; fixed.push('补回缺失的状态标记'); }
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
body = ls.join('\n');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ★ 存量空行先清掉(2026-10-05):必须跑在 assertShape **之前** ——
|
|
709
|
+
// 否则 assertShape 会看到存量空行而拒绝写入(实测把整个脏库锁成只读)。
|
|
710
|
+
// 删空行不丢任何信息,所以这里直接修,而不是报错。
|
|
711
|
+
body = stripStrayBlanks(body, fixed);
|
|
712
|
+
|
|
713
|
+
// ★ 白名单闸门在这里跑:旧格式已升级、旧标记已归一、存量空行已清,
|
|
714
|
+
// 剩下的违规才是真的本次引入。
|
|
715
|
+
assertShape(body);
|
|
716
|
+
|
|
717
|
+
// (3) 结构:先补 H1,再按标准重排分区。
|
|
718
|
+
const hasH1 = body.split('\n').some((l) => l.trim().startsWith('# '));
|
|
719
|
+
if (!hasH1) {
|
|
720
|
+
body = [spec.h1, '', body.replace(/^\n+/, '')].join('\n');
|
|
721
|
+
fixed.push(`补回缺失的 H1: ${spec.h1}`);
|
|
722
|
+
}
|
|
723
|
+
if (spec.order?.length) {
|
|
724
|
+
const r = rebuildStructure(normalizeTodoIf(body, spec), spec);
|
|
725
|
+
if (r.changed.length) fixed.push(...r.changed);
|
|
726
|
+
body = r.text;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// (2) 去条目间空行:同 stripStrayBlanks,但重建后再跑一次(重排会新建邻接关系)。
|
|
730
|
+
const out = stripStrayBlanks(body, fixed);
|
|
731
|
+
return { text: out, fixed };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** todo.md 在重排前必须先走 normalizeTodo(知道旧的 Blocked → 滞留中 语义),
|
|
735
|
+
* 其余文件原样进(rebuildStructure 自带旧分区名归一)。 */
|
|
736
|
+
function normalizeTodoIf(body, spec) {
|
|
737
|
+
return spec.h1 === '# 📋 Todo Board' && spec.order?.[0] === '## Todo' ? normalizeTodo(body) : body;
|
|
738
|
+
}
|
|
739
|
+
|
|
186
740
|
export function normalizeTodo(text) {
|
|
187
741
|
const lines = text.split('\n');
|
|
188
742
|
const has = (name) => lines.some((l) => l.trim() === `## ${name}`);
|