@fanchao8609/agent_brain_sync 1.10.3 → 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/bin/abs.js +5 -1
- package/package.json +1 -1
- package/src/lint.js +57 -1
- package/src/lock.js +1 -1
- package/src/store.js +21 -4
- package/src/todo.js +451 -21
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/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
|
@@ -76,7 +76,7 @@ export async function editFile(file, mutator, { maxWaitMs = LOCK_MAX_WAIT_MS } =
|
|
|
76
76
|
let write = typeof res === 'string' ? res : res && typeof res.text === 'string' ? res.text : null;
|
|
77
77
|
// 格式闸门:所有写 index/todo/log 的路径(CLI/MCP/hook 共 ~15 处)都在此收口,
|
|
78
78
|
// 不必逐个改调用点 —— 破坏格式的写入在这里被整理回标准形态(见 todo.js)。
|
|
79
|
-
if (write && formatGate) write = formatGate(file, write) ?? write;
|
|
79
|
+
if (write && formatGate) write = formatGate(file, write, current) ?? write;
|
|
80
80
|
if (write && write !== current) {
|
|
81
81
|
await fs.writeFile(file, write, 'utf8');
|
|
82
82
|
}
|
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, 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, enforceBrainFormat } from './todo.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
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';
|
|
@@ -123,11 +123,11 @@ const legacyRenames = () => LEGACY_MARKS;
|
|
|
123
123
|
/** 注册写入侧格式闸门:lock.js 的 editFile 是所有写入的唯一收口,
|
|
124
124
|
* 这里把「哪个文件名用哪套标准」告诉它 —— 三个目标文件之外的写入一律放行。
|
|
125
125
|
* 直接 mutate 传入的 text 再返回;非目标文件返回 null 表示不管。 */
|
|
126
|
-
setFormatGate((file, text) => {
|
|
126
|
+
setFormatGate((file, text, current) => {
|
|
127
127
|
const name = file.split('/').pop();
|
|
128
128
|
const spec = BRAIN_SHAPE[name];
|
|
129
129
|
if (!spec) return null;
|
|
130
|
-
const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() });
|
|
130
|
+
const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() }, current);
|
|
131
131
|
return name === 'todo.md'
|
|
132
132
|
? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
133
133
|
: r.text;
|
|
@@ -149,7 +149,15 @@ export function indexTemplate() {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
export function logTemplate() {
|
|
152
|
-
|
|
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');
|
|
153
161
|
}
|
|
154
162
|
|
|
155
163
|
// ---------- 结构核对: load 每次都读 index/log/todo,顺手核形状 ----------
|
|
@@ -1066,6 +1074,15 @@ function slugOf(text, n = 24) {
|
|
|
1066
1074
|
export async function cmdLog({ dir, title, kind = 'dev' }) {
|
|
1067
1075
|
const root = await requireBrain(dir || process.cwd());
|
|
1068
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
|
+
}
|
|
1069
1086
|
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
1070
1087
|
const p = brainPath(root, 'log.md');
|
|
1071
1088
|
const stamp = localStamp();
|
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 结语的 `【落地】` 形态区分开(那是行尾、结语用)。 */
|
|
@@ -240,9 +458,213 @@ export function dropRetiredSections(text) {
|
|
|
240
458
|
* 入参 spec 与 checkBrainShape 的 BRAIN_SHAPE 同源(见 store.js)。
|
|
241
459
|
* log.md 无分区(order 为空)→ 只做 H1/归一/去空行,不重排条目顺序(时间倒序自带语义)。
|
|
242
460
|
* 返回 { text, fixed:[描述] };fixed 为空 = 无需改盘。 */
|
|
243
|
-
|
|
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) {
|
|
244
629
|
const src = String(text ?? '');
|
|
245
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
|
+
};
|
|
246
668
|
const fixed = [];
|
|
247
669
|
// 旧标记 → 标准标记(H1 与分区/分组标题)。与 store.js 的 LEGACY_MARKS 同源,
|
|
248
670
|
// 由调用方通过 spec.renames 注入(todo.js 不反向依赖 store.js)。
|
|
@@ -266,6 +688,32 @@ export function enforceBrainFormat(text, spec) {
|
|
|
266
688
|
body = ls.join('\n');
|
|
267
689
|
}
|
|
268
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
|
+
|
|
269
717
|
// (3) 结构:先补 H1,再按标准重排分区。
|
|
270
718
|
const hasH1 = body.split('\n').some((l) => l.trim().startsWith('# '));
|
|
271
719
|
if (!hasH1) {
|
|
@@ -278,26 +726,8 @@ export function enforceBrainFormat(text, spec) {
|
|
|
278
726
|
body = r.text;
|
|
279
727
|
}
|
|
280
728
|
|
|
281
|
-
// (2)
|
|
282
|
-
const
|
|
283
|
-
const kept = [];
|
|
284
|
-
for (let i = 0; i < lines.length; i++) {
|
|
285
|
-
const l = lines[i];
|
|
286
|
-
if (!l.trim()) {
|
|
287
|
-
const prev = kept[kept.length - 1];
|
|
288
|
-
// 前瞻要跳过连续空行,否则「两个空行」里只有第一个被删(两个空行是常见形态,
|
|
289
|
-
// 首版留下一个 → 规则形同虚设)。
|
|
290
|
-
let j = i + 1;
|
|
291
|
-
while (j < lines.length && !lines[j].trim()) j++;
|
|
292
|
-
const next = lines[j];
|
|
293
|
-
if (prev !== undefined && next !== undefined && isLeafEntry(prev) && isLeafEntry(next)) {
|
|
294
|
-
fixed.push('删除条目之间的空行');
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
kept.push(l);
|
|
299
|
-
}
|
|
300
|
-
const out = kept.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
|
|
729
|
+
// (2) 去条目间空行:同 stripStrayBlanks,但重建后再跑一次(重排会新建邻接关系)。
|
|
730
|
+
const out = stripStrayBlanks(body, fixed);
|
|
301
731
|
return { text: out, fixed };
|
|
302
732
|
}
|
|
303
733
|
|