@fanchao8609/agent_brain_sync 1.20.1 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/bin/abs.js +41 -1
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +21 -2
- package/src/brain/capture.js +175 -0
- package/src/brain/db-cmd.js +336 -0
- package/src/brain/db.js +583 -0
- package/src/brain/evolve.js +123 -0
- package/src/brain/gate.js +19 -0
- package/src/brain/init.js +135 -0
- package/src/brain/layers.js +112 -0
- package/src/brain/load.js +273 -0
- package/src/brain/maintain.js +199 -0
- package/src/brain/rules.js +98 -0
- package/src/brain/shape.js +102 -0
- package/src/brain/summon-cmd.js +62 -0
- package/src/brain/summon.js +303 -0
- package/src/install.js +20 -6
- package/src/note.js +31 -5
- package/src/store.js +36 -1104
- package/src/text.js +21 -1
- package/src/todo/task-cmd.js +274 -0
package/src/store.js
CHANGED
|
@@ -1,1114 +1,46 @@
|
|
|
1
|
-
// src/store.js —
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
1
|
+
// src/store.js — 命令聚合入口(2026-11-01 拆分后的壳)。
|
|
2
|
+
//
|
|
3
|
+
// 拆分前 1114 行、27 个导出,8 个互不相干的职责塞一起。现在按职责落到子模块:
|
|
4
|
+
// brain/init.js 建骨架、结构体检、三个受管文件模板
|
|
5
|
+
// brain/shape.js 标准形状表 + 结构核对(init 与 load 共用)
|
|
6
|
+
// brain/gate.js 写入侧格式闸门的全局注册(import 即生效)
|
|
7
|
+
// brain/load.js board / status / load —— 只读呈现
|
|
8
|
+
// brain/rules.js index.md 的 `## Rules` 区读写
|
|
9
|
+
// brain/maintain.js 滞留快照 / Done 归档 / 收尾体检
|
|
10
|
+
// todo/task-cmd.js abs todo 命令入口 + abs show
|
|
11
|
+
//
|
|
12
|
+
// 本文件只做转发,不含任何实现。bin/abs.js 与 bin/mcp.js 的导入路径不变。
|
|
13
|
+
// 新增命令时:实现放对应子模块,这里加一行 export。
|
|
14
|
+
|
|
15
|
+
// ---- 骨架与模板 ----
|
|
16
|
+
export { cmdInit, cmdRepair, reportBrain, indexTemplate, logTemplate } from './brain/init.js';
|
|
17
|
+
export { BRAIN_SHAPE, LEGACY_MARKS, checkBrainShape } from './brain/shape.js';
|
|
18
|
+
// 闸门靠 import 副作用注册,必须在此引入(谁先 import store.js 谁就完成注册)
|
|
19
|
+
import './brain/gate.js';
|
|
20
|
+
|
|
21
|
+
// ---- 只读呈现 ----
|
|
22
|
+
export { cmdBoard, cmdStatus, cmdLoad, cmdShow, extractBlocks } from './brain/load.js';
|
|
23
|
+
|
|
24
|
+
// ---- Rules ----
|
|
25
|
+
export { RULES_HEADING, RULES_MAX, RULE_BODY_MAX, readRules, cmdRule } from './brain/rules.js';
|
|
26
|
+
|
|
27
|
+
// ---- 维护 ----
|
|
28
|
+
export { cmdWrapup, cmdTodoArchive, cmdTeardownCheck } from './brain/maintain.js';
|
|
29
|
+
|
|
30
|
+
// ---- 按七层总结 ----
|
|
31
|
+
export { cmdCapture, layerGuide } from './brain/capture.js';
|
|
32
|
+
|
|
33
|
+
// ---- 任务 ----
|
|
34
|
+
export { cmdTask } from './todo/task-cmd.js';
|
|
35
|
+
|
|
36
|
+
// ---- 以下为既有模块的转发(拆分前就在此聚合,保留原样) ----
|
|
13
37
|
export { clip, slugOf } from './text.js';
|
|
14
38
|
|
|
15
|
-
// 页面生命周期已拆到 page.js(2026-10-05)。
|
|
16
|
-
// ★ 只用 `export {x} from` 转发 —— 本文件内部**不再**调用它们(2026-10-19 核对:
|
|
17
|
-
// 11 个符号在 store.js 里除了 import/export 两行之外零出现)。
|
|
18
|
-
// 历史注释说「必须先进 import 再 export」,那是拆分当时 idOfPage 等还在内部用的写法;
|
|
19
|
-
// 现在它们已全部只在 page.js/note.js 内部使用,多留一份 import 就是死代码。
|
|
20
|
-
// 若将来本文件又要内部调用某个符号,再单独 import 它(单独 import 才不触发 lint)。
|
|
21
39
|
export {
|
|
22
40
|
idOfPage, backfillPageId, PAGE_STATUS, statusOfPage, supersededByOf,
|
|
23
41
|
cmdSupersede, resolvePage, cmdReview, cmdResolve, collapseIndex,
|
|
24
42
|
} from './page.js';
|
|
25
|
-
// 本文件内部真在调用的两个,必须**另行 import**(`export {x} from` 不建立本地绑定)。
|
|
26
|
-
// 两者既转发又内部用,所以 export 行与 import 行各留一份——这不是冗余。
|
|
27
|
-
import { collapseIndex } from './page.js';
|
|
28
43
|
|
|
29
|
-
// 经验暂存/知识页脚手架已拆到 note.js(2026-10-05)—— 同样转发。
|
|
30
44
|
export { cmdLog, cmdNote, cmdConcept, ensurePersonPage, registerInIndex } from './note.js';
|
|
31
|
-
import { ensurePersonPage } from './note.js';
|
|
32
|
-
|
|
33
|
-
// Re-export lint.js symbols so external importers (e.g. bin/mcp.js, bin/abs.js) still work.
|
|
34
45
|
export { cmdLint, listPages, hasTail, PAGE_DIRS, foldSameKind } from './lint.js';
|
|
35
|
-
// SOURCES_MAX / listPages 本文件内部真在用(load 的 sources 提醒 + queryHint),
|
|
36
|
-
// 必须单独 import;PAGE_DIRS 内部零使用,已在上一行转发,不重复 import。
|
|
37
|
-
import { SOURCES_MAX, listPages } from './lint.js';
|
|
38
|
-
|
|
39
|
-
// ---------- init: 建 .brain/ 骨架 ----------
|
|
40
|
-
const BRAIN_DIRS = ['entities', 'concepts', 'sources', 'syntheses', 'sessions'];
|
|
41
|
-
const BRAIN_FILES = ['index.md', 'log.md', 'todo.md'];
|
|
42
|
-
|
|
43
|
-
export async function cmdInit({ dir }) {
|
|
44
|
-
const root = resolveProjectDir(dir);
|
|
45
|
-
const brain = join(root, '.brain');
|
|
46
|
-
try {
|
|
47
|
-
await fs.access(brain);
|
|
48
|
-
} catch (e) {
|
|
49
|
-
if (e && e.code !== 'ENOENT') throw e;
|
|
50
|
-
return createSkeleton(root, brain);
|
|
51
|
-
}
|
|
52
|
-
// 已存在 → 校验结构完整性;损坏则明确报错并指引 repair(不静默半初始化)
|
|
53
|
-
const report = await reportBrain({ dir: root });
|
|
54
|
-
if (report.ok) throw new Error(`已存在: ${brain} (abort)`);
|
|
55
|
-
throw new Error(
|
|
56
|
-
`已存在但结构不完整:\n ${report.problems.join('\n ')}\n` +
|
|
57
|
-
` → 轻则补齐: abs init --repair 重则重建: 删掉 ${brain} 后重跑 abs init`
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function createSkeleton(root, brain) {
|
|
62
|
-
for (const d of BRAIN_DIRS) await fs.mkdir(join(brain, d), { recursive: true });
|
|
63
|
-
// 走 editFile 而不是 fs.writeFile:三个文件是受管表,写入必须过同一道闸门
|
|
64
|
-
// (2026-10-18:原来直写盘,是个绕过口)。模板本身合法,闸门只做归一。
|
|
65
|
-
for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
|
|
66
|
-
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
67
|
-
}
|
|
68
|
-
// 建完就提示设姓名 —— 否则用户一路到第一次 todo add 才撞墙,
|
|
69
|
-
// 中间 init/load 都不提,根本不知道该设。
|
|
70
|
-
const who = await getUser();
|
|
71
|
-
const hint = who ? '' : `\n ⚠ 尚未设置使用者姓名,写操作(todo add/log/note)会先报错。\n 请先设置: abs config set user <你的名字>`;
|
|
72
|
-
return `✓ 已建图谱 ${brain}\n (模板见 SKILL.md「.brain/ 怎么组织」)${hint}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** 结构体检:6 目录 + 3 文件逐一核对。ok=false 时 problems 列出缺失项。 */
|
|
76
|
-
export async function reportBrain({ dir }) {
|
|
77
|
-
const root = resolveProjectDir(dir);
|
|
78
|
-
const brain = join(root, '.brain');
|
|
79
|
-
const problems = [];
|
|
80
|
-
for (const d of BRAIN_DIRS) {
|
|
81
|
-
try {
|
|
82
|
-
const st = await fs.stat(join(brain, d));
|
|
83
|
-
if (!st.isDirectory()) problems.push(`缺目录 ${d}/ (被文件占位)`);
|
|
84
|
-
} catch {
|
|
85
|
-
problems.push(`缺目录 ${d}/`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
for (const f of BRAIN_FILES) {
|
|
89
|
-
try {
|
|
90
|
-
await fs.access(join(brain, f));
|
|
91
|
-
} catch {
|
|
92
|
-
problems.push(`缺文件 ${f}`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return { ok: problems.length === 0, problems, brain };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** init --repair: 只补缺失骨架,绝不覆盖已有文件。 */
|
|
99
|
-
export async function cmdRepair({ dir }) {
|
|
100
|
-
const root = resolveProjectDir(dir);
|
|
101
|
-
const brain = join(root, '.brain');
|
|
102
|
-
try {
|
|
103
|
-
await fs.access(brain);
|
|
104
|
-
} catch {
|
|
105
|
-
return createSkeleton(root, brain); // 整个图谱都没有 = 全新建
|
|
106
|
-
}
|
|
107
|
-
const fixed = [];
|
|
108
|
-
for (const d of BRAIN_DIRS) {
|
|
109
|
-
try {
|
|
110
|
-
const st = await fs.stat(join(brain, d));
|
|
111
|
-
if (!st.isDirectory()) {
|
|
112
|
-
await fs.rm(join(brain, d), { force: true });
|
|
113
|
-
await fs.mkdir(join(brain, d), { recursive: true });
|
|
114
|
-
fixed.push(`${d}/`);
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
await fs.mkdir(join(brain, d), { recursive: true });
|
|
118
|
-
fixed.push(`${d}/`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
|
|
122
|
-
try {
|
|
123
|
-
await fs.access(join(brain, f));
|
|
124
|
-
} catch {
|
|
125
|
-
// 同 createSkeleton:补齐也走 editFile 的口子(已存在则 cur 非 null,不覆盖)
|
|
126
|
-
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
127
|
-
fixed.push(f);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
if (!fixed.length) return `✓ 结构完整,无需修复: ${brain}`;
|
|
131
|
-
return `✓ 已补齐 ${fixed.join(', ')} → ${brain}\n (已有文件一律不覆盖)`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function resolveProjectDir(dir) {
|
|
135
|
-
// 必须显式回退 cwd: resolve(undefined) 会抛 ERR_INVALID_ARG_TYPE,
|
|
136
|
-
// 并不像看上去那样「自动回退」。曾误删此分支为 resolve(dir),把 abs init/load/board
|
|
137
|
-
// 不带 --dir 全部变成裸栈崩溃。
|
|
138
|
-
return resolve(dir || process.cwd());
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** 全套旧标记 → 标准标记(含 H1)。enforceBrainFormat 按整行精确匹配,
|
|
142
|
-
* 所以 H1 也得在表里 —— 曾经只传 `## `/`### ` 前缀的那部分,
|
|
143
|
-
* 因为 H1 归一当时归 rebuildStructure 管;但 log.md 的 order 为空、不走 rebuildStructure,
|
|
144
|
-
* 于是它的旧 H1 成了唯一没人归一的路径(实测「# 🗒 操作日志」永不修正)。
|
|
145
|
-
* 现在统一由 enforceBrainFormat 归一,表就给全套。 */
|
|
146
|
-
const legacyRenames = () => LEGACY_MARKS;
|
|
147
|
-
|
|
148
|
-
/** 注册写入侧格式闸门:lock.js 的 editFile 是所有写入的唯一收口,
|
|
149
|
-
* 这里把「哪个文件名用哪套标准」告诉它 —— 三个目标文件之外的写入一律放行。
|
|
150
|
-
* 直接 mutate 传入的 text 再返回;非目标文件返回 null 表示不管。 */
|
|
151
|
-
setFormatGate((file, text, current) => {
|
|
152
|
-
const name = file.split('/').pop();
|
|
153
|
-
const spec = BRAIN_SHAPE[name];
|
|
154
|
-
if (!spec) return null;
|
|
155
|
-
const r = enforceBrainFormat(text, { ...spec, renames: legacyRenames() }, current);
|
|
156
|
-
return name === 'todo.md'
|
|
157
|
-
? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
158
|
-
: r.text;
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
export function indexTemplate() {
|
|
162
|
-
// 同 todoTemplate:由 rebuildStructure 生成,模板 = 重排结果,不会来回抖。
|
|
163
|
-
return rebuildStructure(
|
|
164
|
-
['# 🗂 Graph Index', '',
|
|
165
|
-
'本文件唯一入口。每新建/大改一页,同步在此分类下加一行 [[页面名]] — 一句话。', '',
|
|
166
|
-
'## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'].join('\n'),
|
|
167
|
-
{
|
|
168
|
-
h1: '# 🗂 Graph Index',
|
|
169
|
-
// 无 `## Roadmap`:它是 AI 自己写的方向总结,会被 load 反复读到并带偏后续会话
|
|
170
|
-
// (“看似是总结指引,其实是 AI 的总结指引” —— 2026-09-13 用户决策删除)。
|
|
171
|
-
order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
|
|
172
|
-
},
|
|
173
|
-
).text;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export function logTemplate() {
|
|
177
|
-
// ★ 占位符必须是注释形式,不能是真条目形状(2026-10-05 实测):
|
|
178
|
-
// 原来写的是 `## [YYYY-MM-DD] ingest | 沉淀 <slug>` —— 看起来像条目,
|
|
179
|
-
// 但它不是真日期、也不是真 kind,条目形状闸门上线后**模板自己就非法**,
|
|
180
|
-
// 新条目插到它上面后它掉到第 4 行,直接把写入卡死。
|
|
181
|
-
// 用 HTML 注释保留「怎么写」的提示,同时不参与任何解析。
|
|
182
|
-
return [
|
|
183
|
-
'# 🗒 Activity Log', '',
|
|
184
|
-
'<!-- 每条一行:## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文(kind: note|dev|concept|ingest) -->', ''
|
|
185
|
-
].join('\n');
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ---------- 结构核对: load 每次都读 index/log/todo,顺手核形状 ----------
|
|
189
|
-
/** 标准形状表(与 indexTemplate/logTemplate/todoTemplate 同源)。
|
|
190
|
-
* 标记不一致 = 直接改成标准(“能自己处理的先处理”)。
|
|
191
|
-
* 只按**整行精确匹配**改标题,绝不动正文 —— 不做模糊替换,否则正文里提到的旧名会被误改。 */
|
|
192
|
-
// sources/ 堆积阀值:超过就是「采了没消化」。lint 报 SOURCES-PILED-UP,load 顶部同步提示。
|
|
193
|
-
// 两处共用同一常量 —— 阀值只有一个真源(SOURCES_MAX 在 lint.js 定义,此处导入使用)。
|
|
194
|
-
|
|
195
|
-
export const BRAIN_SHAPE = {
|
|
196
|
-
'todo.md': {
|
|
197
|
-
h1: '# 📋 Todo Board',
|
|
198
|
-
order: ['## Todo', '## Done'],
|
|
199
|
-
},
|
|
200
|
-
'log.md': {
|
|
201
|
-
h1: '# 🗒 Activity Log',
|
|
202
|
-
order: [], // log 无固定分区(条目行自带时间,倒序)
|
|
203
|
-
},
|
|
204
|
-
'index.md': {
|
|
205
|
-
h1: '# 🗂 Graph Index',
|
|
206
|
-
order: ['## Rules', '## Concepts', '## Entities', '## Sources', '## Syntheses', '## Sessions'],
|
|
207
|
-
},
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
/** 旧标记 → 标准标记。load 发现就改(幂等)。含 H1 与分区/分组标题的旧名。 */
|
|
211
|
-
export const LEGACY_MARKS = [
|
|
212
|
-
['# 🗂 图谱索引', '# 🗂 Graph Index'],
|
|
213
|
-
['# 🗒 操作日志', '# 🗒 Activity Log'],
|
|
214
|
-
['# 📋 Todo 看板', '# 📋 Todo Board'],
|
|
215
|
-
['# 操作日志', '# 🗒 Activity Log'],
|
|
216
|
-
['# 图谱索引', '# 🗂 Graph Index'],
|
|
217
|
-
['# Todo 看板', '# 📋 Todo Board'],
|
|
218
|
-
// 四区 → 两区(2026-09-13 用户定)。**这条路径是 load 走的**(rebuildStructure),
|
|
219
|
-
// 与 todo.js 的 LEGACY_SECTION_RENAMES(normalizeTodo 用)是两条独立迁移路径 ——
|
|
220
|
-
// 只改一处会导致另一条认不出旧区名,把它们当"非标准分区"原样留末尾(未完成任务
|
|
221
|
-
// 留在文件里但不再被当 TODO)。测试 'load 也要能迁移' 钉住这一点。
|
|
222
|
-
['## Backlog', '## Todo'],
|
|
223
|
-
['## Today / In Progress', '## Todo'],
|
|
224
|
-
['## Blocked', '## Todo'],
|
|
225
|
-
['## Done(只留近期,旧的迁 log.md/快照)', '## Done'],
|
|
226
|
-
['### 归档', '### Archived'],
|
|
227
|
-
['### (未标日期)', '### Undated'],
|
|
228
|
-
];
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* 核对 index/log/todo 的结构,不符就按标准重建(B 档:重排分区 + 内容按归属回填)。
|
|
232
|
-
* 它每次 load 都跑,所以改动立即生效,不必等额外命令。
|
|
233
|
-
*
|
|
234
|
-
* 边界(内容安全):
|
|
235
|
-
* - 只动**标题行位置**与缺失分区的空位,已有内容行按原归属搬运,不改写;
|
|
236
|
-
* - **非标准分区原样留在末尾**(人自加的区不合入,机器不猜语义);
|
|
237
|
-
* - log.md 不做重排(无固定分区,条目自带时间倒序);
|
|
238
|
-
* - 锁内重算 + “改了才写盘”,幂等。
|
|
239
|
-
*
|
|
240
|
-
* 不抛错(load 不能因核对失败而挂)。返回 { fixed:[描述], warn:[描述] }。
|
|
241
|
-
*/
|
|
242
|
-
export async function checkBrainShape(root) {
|
|
243
|
-
const fixed = [];
|
|
244
|
-
const warn = [];
|
|
245
|
-
for (const [file, spec] of Object.entries(BRAIN_SHAPE)) {
|
|
246
|
-
const p = brainPath(root, file);
|
|
247
|
-
let text;
|
|
248
|
-
try { text = await fs.readFile(p, 'utf8'); } catch { continue; }
|
|
249
|
-
if (!text.trim()) continue;
|
|
250
|
-
const l1 = (text.split('\n')[0] || '').trim();
|
|
251
|
-
const knownH1 = l1 === spec.h1 || LEGACY_MARKS.some(([o]) => o === l1 && o.startsWith('# '));
|
|
252
|
-
let changed = [];
|
|
253
|
-
await editFile(p, (cur) => {
|
|
254
|
-
if (cur === null) return SKIP;
|
|
255
|
-
// 格式闸门收口在一处(见 todo.js enforceBrainFormat):样式/空行/无头 三条规则
|
|
256
|
-
// 与写入侧共用同一实现,写盘前顺手把破坏格式的部分整理回标准形态。
|
|
257
|
-
// 走 normalizeTodo 的语义(Blocked → [滞留中])由 enforceBrainFormat 内部保证。
|
|
258
|
-
const r = enforceBrainFormat(cur, { ...spec, renames: legacyRenames() });
|
|
259
|
-
// 兜底:仍无状态标记的未完成任务补默认值(新格式文件本就有标记,此处不触发)。
|
|
260
|
-
const next2 = file === 'todo.md'
|
|
261
|
-
? r.text.split('\n').map((l) => (l.startsWith('- [ ] ') ? ensureStateMark(l, '进行中') : l)).join('\n')
|
|
262
|
-
: r.text;
|
|
263
|
-
if (next2 === cur) return SKIP;
|
|
264
|
-
changed = r.fixed.length ? r.fixed : ['结构按标准重排'];
|
|
265
|
-
return { text: next2 };
|
|
266
|
-
}).catch((e) => {
|
|
267
|
-
// 不能吞:load 是开机第一入口,静默跳过会让人以为库是健康的。
|
|
268
|
-
// 典型触发:文件里有孤儿子任务(真错,不修而报)。
|
|
269
|
-
// 只报**具体问题行**(以 `·` 开头的那些),不把整个错误头拼进去。
|
|
270
|
-
const msg = e && e.message ? e.message : '未知错误';
|
|
271
|
-
const detail = msg.split('\n').filter((x) => x.trim().startsWith('·')).map((x) => x.trim().slice(1).trim());
|
|
272
|
-
warn.push(`${file}: 未能自动归一 —— ${detail.length ? detail.join('; ') : msg.split('\n')[0]}`.slice(0, 200));
|
|
273
|
-
});
|
|
274
|
-
if (changed.length) fixed.push(`${file}: ${changed.join('; ')}`);
|
|
275
|
-
if (!knownH1) warn.push(`${file}: 标题非标准(读到 "${clip(l1, 24) || '(空)'}")`);
|
|
276
|
-
}
|
|
277
|
-
return { fixed, warn };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ---------- board: 看板 ----------
|
|
281
|
-
export async function cmdBoard({ dir, full }) {
|
|
282
|
-
const root = await requireBrain(dir || process.cwd());
|
|
283
|
-
return boardText(root, undefined, { full: !!full });
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ---------- status: 定位报告 + 图谱概要 ----------
|
|
287
|
-
export async function cmdStatus({ dir }) {
|
|
288
|
-
const root = await requireBrain(dir || process.cwd());
|
|
289
|
-
const entries = await listBrainFiles(root);
|
|
290
|
-
return [
|
|
291
|
-
`📂 abs → 项目: ${root}`,
|
|
292
|
-
`图谱: ${brainPath(root)}`,
|
|
293
|
-
'',
|
|
294
|
-
entries.length ? entries.join('\n') : '(图谱为空)',
|
|
295
|
-
].join('\n');
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async function listBrainFiles(root) {
|
|
299
|
-
const out = [];
|
|
300
|
-
const dirs = ['concepts', 'entities', 'sources', 'syntheses', 'sessions'];
|
|
301
|
-
for (const d of dirs) {
|
|
302
|
-
const p = brainPath(root, d);
|
|
303
|
-
try {
|
|
304
|
-
const files = (await fs.readdir(p)).filter((f) => f.endsWith('.md'));
|
|
305
|
-
out.push(`${d}/: ${files.length} 页`);
|
|
306
|
-
} catch { out.push(`${d}/: 0 页`); }
|
|
307
|
-
}
|
|
308
|
-
return out;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// ---------- load: 开机读状态 ----------
|
|
312
|
-
export async function cmdLoad({ dir }) {
|
|
313
|
-
const root = await requireBrain(dir || process.cwd());
|
|
314
|
-
// 结构核对先跑:load 每回都要读这三个文件,顺手把它们形状摆正(缺分区)或提个醒(无头)。
|
|
315
|
-
// 正常时完全静默、零字节,不增加 load 体积(load 已被压缩到 ~1.8k token)。
|
|
316
|
-
const shape = await checkBrainShape(root).catch(() => ({ fixed: [], warn: [] }));
|
|
317
|
-
const todo = await readTodo(root);
|
|
318
|
-
const index = await readIfExists(brainPath(root, 'index.md'));
|
|
319
|
-
const log = await readIfExists(brainPath(root, 'log.md'));
|
|
320
|
-
const stranded = await strandedFor(root);
|
|
321
|
-
const sections = [
|
|
322
|
-
`📂 abs → 项目: ${root}`,
|
|
323
|
-
];
|
|
324
|
-
if (shape.fixed.length || shape.warn.length) {
|
|
325
|
-
const rows = [
|
|
326
|
-
...shape.fixed.map((a) => ` ✓ 已补: ${a}`),
|
|
327
|
-
...shape.warn.map((a) => ` ⚠ ${a}`),
|
|
328
|
-
];
|
|
329
|
-
// 那句提示只适用于「标题非标准」:其他 warn(如格式不合规)说这句会误导。
|
|
330
|
-
if (shape.warn.some((a) => a.includes('标题非标准'))) {
|
|
331
|
-
rows.push(' (无头文件不自动改:结构可能整体脱轨,请手工对齐 .brain/ 模板)');
|
|
332
|
-
}
|
|
333
|
-
sections.push('--- 文件形状核对 ---', ...rows, '');
|
|
334
|
-
}
|
|
335
|
-
// 未设姓名/姓名是占位名时开场就提醒 —— load 是开机第一屏,不在这里提,
|
|
336
|
-
// 用户要撞到第一次写操作才知道(init/load 一路沉默)。
|
|
337
|
-
// 占位名(历史遗留 tester/foo)必须提:配置里有值 ≠ 名字是对的。
|
|
338
|
-
// 一次调用拿两者(placeholderWarn 内部也要 getUser)—— 原先分两次读盘(2026-10-03 审查)。
|
|
339
|
-
const who = await getUser();
|
|
340
|
-
const ph = who ? await placeholderWarn() : null;
|
|
341
|
-
if (!who) {
|
|
342
|
-
sections.push(
|
|
343
|
-
'⚠ 尚未设置使用者姓名(写操作会先报错)',
|
|
344
|
-
'→ abs config set user <你的名字> (或临时: ABS_USER=<名字> abs ...)',
|
|
345
|
-
''
|
|
346
|
-
);
|
|
347
|
-
} else if (ph) {
|
|
348
|
-
sections.push(
|
|
349
|
-
`⚠ 当前作者名是占位名 "${ph}" —— 之后所有项目的 todo/log 都会标它。`,
|
|
350
|
-
'→ abs config set user <你的真名> (或临时: ABS_USER=<名字> abs ...)',
|
|
351
|
-
''
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
sections.push(
|
|
355
|
-
// Rules 单独成段且放在最前(仅次于项目行/滞留):它是硬规则,不是普通清单。
|
|
356
|
-
// 坑: 曾在 index 段内与页面清单平铺 —— AI 会当普通清单划过,而它每条都是付过代价的。
|
|
357
|
-
...(rulesSection(index) ? [rulesSection(index), ''] : []),
|
|
358
|
-
collapseIndex(index) || '(index.md 为空)',
|
|
359
|
-
'',
|
|
360
|
-
// 两区制:Todo(未完成,行首带状态标记)+ Done。
|
|
361
|
-
'--- Todo Board (todo.md) ---',
|
|
362
|
-
collapseDone(todo).text || '(todo.md 为空)',
|
|
363
|
-
'\n(Done 已按日期折叠计数;明细: abs todo --full)',
|
|
364
|
-
'',
|
|
365
|
-
'--- 最近动作 (log.md, 最新 5 条) ---',
|
|
366
|
-
recentLogLines(log, 5) || '(log.md 为空)',
|
|
367
|
-
);
|
|
368
|
-
if (stranded.length) {
|
|
369
|
-
const rows = stranded.map((t) => {
|
|
370
|
-
const bp = t.bp.length ? `\n ${t.bp.map((b) => (b.startsWith('[') ? `- ${b}` : `- [ ] ${b}`)).join('\n ')}` : '';
|
|
371
|
-
return ` - ${t.body}${bp}`;
|
|
372
|
-
});
|
|
373
|
-
// 插在项目行之后、其它内容之前
|
|
374
|
-
sections.splice(
|
|
375
|
-
1, 0,
|
|
376
|
-
'⏳ 上会话滞留(未 done,先对账)',
|
|
377
|
-
rows.join('\n'),
|
|
378
|
-
'→ 完成: abs todo done <id>;未完: abs todo note <id> --note 子项',
|
|
379
|
-
''
|
|
380
|
-
);
|
|
381
|
-
}
|
|
382
|
-
// 卡点提取(2026-09-16,定位 = 看板可见性):
|
|
383
|
-
// 从 todo 里抽出带 `阻塞:` / `等待:` 的断点行,集中展示。
|
|
384
|
-
// 为何需要:断点行在任务下方,一屏扫过去看不出"哪些事在等人/等外部"。
|
|
385
|
-
// 只提取不求全 —— 只有明确写了前缀的才被抽出,没写的不猜。
|
|
386
|
-
const blocks = extractBlocks(todo);
|
|
387
|
-
if (blocks.length) {
|
|
388
|
-
const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
|
|
389
|
-
sections.splice(at === -1 ? 1 : at, 0,
|
|
390
|
-
`⛔ 卡点 ${blocks.length} 条(在等外部条件)`,
|
|
391
|
-
...blocks.map((b) => ` - ${b.id}: ${b.why}`),
|
|
392
|
-
'');
|
|
393
|
-
}
|
|
394
|
-
// 相关页提示(2026-09-16):实测 abs_query 几乎不被调用(MCP 日志 task 234 / note 54 / load 53 vs query 2)。
|
|
395
|
-
// 根因:load 只列目录 → 模型觉得"我记过了" → 真需要时靠印象。模型不知道自己不知道什么。
|
|
396
|
-
// 解法(用户定):不替它查 —— 而是【把该查的词直接给出来】,降低发起查询的成本。
|
|
397
|
-
// 自动带出正文是错的方向:那会让 query 更没人用,且无法按需求变化调整词。
|
|
398
|
-
// 失败静默:这只是 additive 提示,出任何错都不应弄坏 load。
|
|
399
|
-
try {
|
|
400
|
-
const rel = await queryHintSection(root, todo);
|
|
401
|
-
if (rel) sections.push('', rel);
|
|
402
|
-
} catch { /* 提示失败不影响 load */ }
|
|
403
|
-
// sources 堆积提醒:与「滞留」同构 —— 放在每次开工必经的顶部,而不是等人跑 lint。
|
|
404
|
-
// 只数目录条目(不读文件),零成本。提炼仍手工:这里只负责送达,不替判断。
|
|
405
|
-
try {
|
|
406
|
-
const srcWarn = await sourcesCountSection(root);
|
|
407
|
-
if (srcWarn) {
|
|
408
|
-
const at = sections.findIndex((s) => String(s).startsWith('--- Rules'));
|
|
409
|
-
sections.splice(at === -1 ? 1 : at, 0, ...srcWarn);
|
|
410
|
-
}
|
|
411
|
-
} catch { /* 提醒失败不影响 load */ }
|
|
412
|
-
return sections.join('\n');
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* 从 todo 正文抽出"卡点"行,供 load 集中展示。
|
|
417
|
-
*
|
|
418
|
-
* 识别的前缀:`阻塞:` / `等待:` / `等 `(在断点/附属行里)。
|
|
419
|
-
* 为何只认前缀(不靠语义猜):没写就是没写,猜出来的卡点比不报更坑。
|
|
420
|
-
*
|
|
421
|
-
* @returns {{id:string, why:string}[]}
|
|
422
|
-
*/
|
|
423
|
-
export function extractBlocks(todoText) {
|
|
424
|
-
const out = [];
|
|
425
|
-
let curId = null;
|
|
426
|
-
for (const line of String(todoText || '').split('\n')) {
|
|
427
|
-
// 任务行(未完成):`- [ ] [状态] ID [[who]] — 描述`
|
|
428
|
-
const t = line.match(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(\S+)/);
|
|
429
|
-
if (t) { curId = t[1]; continue; }
|
|
430
|
-
// 完成行:不再跟踪
|
|
431
|
-
if (/^\s*-\s*\[x\]/.test(line)) { curId = null; continue; }
|
|
432
|
-
if (!curId) continue;
|
|
433
|
-
// 附属行(缩进子项)里找卡点前缀。
|
|
434
|
-
// 旧写法 `↳ 断点: 阻塞: …`(存量)也认 —— 保留一条前缀兼容,以免漏报历史卡点。
|
|
435
|
-
const m = line.match(/^\s*(?:↳\s*(?:断点|卡点)?[::]\s*)?(?:阻塞|等待|等)[::]\s*(.+)$/);
|
|
436
|
-
if (m && m[1].trim()) out.push({ id: curId, why: m[1].trim() });
|
|
437
|
-
}
|
|
438
|
-
return out;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* 开工提示:直接给出该查的几个词,降低发起 query 的成本。
|
|
443
|
-
*
|
|
444
|
-
* 为何不是"替它查"(2026-09-16 用户定):自动带出正文会让 query 更没人用,
|
|
445
|
-
* 且无法按需求变化调整词。正确做法是把词准备好,查询仍由调用方发。
|
|
446
|
-
*
|
|
447
|
-
* 词从两个环境事实推:活跃 todo 的关键词 + 最近改过的文件名。
|
|
448
|
-
*
|
|
449
|
-
* 挑词用【主题级强度】排序,不取词表前几个(2026-09-17 实测修正):
|
|
450
|
-
* 原实现 `pick = [...en.slice(0,2), ...zh.slice(0,1)]` 取的是最前面的词,
|
|
451
|
-
* 而 todo 行以 `<任务id> [[作者]]` 开头 → 任务 id 与用户名稳占前两位,
|
|
452
|
-
* 实测输出 `abs query queryhint-noise tester 进行`(强度 0/1/0,全是废词)。
|
|
453
|
-
* 现按 topicStrength(tag/页名命中数)降序取,强度 0 的一律不提示
|
|
454
|
-
* —— 命中不了任何页的词,建议去查它等于没建议。
|
|
455
|
-
* 失败静默:读页出错就当没有强词,退回不提示(不影响 load)。
|
|
456
|
-
*
|
|
457
|
-
* @param {string} root 项目根
|
|
458
|
-
* @param {string} todoText
|
|
459
|
-
* @param {{name:string,body:string}[]} [pages] 已读好的页(省一次磁盘扫描)
|
|
460
|
-
*/
|
|
461
|
-
async function queryHint(root, todoText, pages) {
|
|
462
|
-
const active = String(todoText || '')
|
|
463
|
-
.split('\n')
|
|
464
|
-
.filter((l) => /^\s*-\s*\[\s*\]/.test(l))
|
|
465
|
-
// 剔掉任务行的结构记号再分词:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
|
|
466
|
-
// 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
|
|
467
|
-
// 而作者名恰好有自己的 entities/<作者>.md 页 → 命中**页名** → 强度 1 过了门槛,
|
|
468
|
-
// 于是提示语变成「查一下你自己的名字」。建议查作者页不是“别重踩的经验”。
|
|
469
|
-
// 用 task 行自身的形状剥(与 idOfTaskLine 同源),不维护人名/任务名黑名单。
|
|
470
|
-
.map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
|
|
471
|
-
.join(' ');
|
|
472
|
-
const files = await recentFiles(root, { n: 5 }).catch(() => []);
|
|
473
|
-
const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
|
|
474
|
-
const kws = keywords(src);
|
|
475
|
-
if (!kws.length) return '';
|
|
476
|
-
const all = pages || await listPages(brainPath(root)).catch(() => []);
|
|
477
|
-
const strength = topicStrength(all.map((p) => ({ name: p.slug, body: p.body })), kws);
|
|
478
|
-
// 英文词优先(中文 2-gram 单看无意义,只做补充);每组内按强度降序
|
|
479
|
-
const byStrength = (a, b) => (strength.get(b) || 0) - (strength.get(a) || 0) || a.localeCompare(b);
|
|
480
|
-
const en = kws.filter((k) => /^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
|
|
481
|
-
const zh = kws.filter((k) => !/^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
|
|
482
|
-
const pick = [...en.slice(0, 2), ...zh.slice(0, 1)].slice(0, 3);
|
|
483
|
-
if (!pick.length) return '';
|
|
484
|
-
const rows = [
|
|
485
|
-
'--- 开工前建议查一次(图谱里有相关经验,别重踩)---',
|
|
486
|
-
` abs query ${pick.join(' ')}`,
|
|
487
|
-
];
|
|
488
|
-
if (files.length) rows.push(` (据最近改动: ${files.slice(0, 3).map((f) => f.name).join(', ')})`);
|
|
489
|
-
return rows.join('\n');
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/** 数 sources/ 下的 .md 文件数(只读目录,不解析内容)—— load 顶部提醒用。
|
|
493
|
-
* 坑: root 是【项目根】,图谱在 root/.brain/ 下 —— 必须走 brainPath,
|
|
494
|
-
* 直接用 join(root,'sources') 会 ENOENT 被 catch 吞成 0,成为又一个静默失效。 */
|
|
495
|
-
async function countSources(root) {
|
|
496
|
-
try {
|
|
497
|
-
const files = await fs.readdir(brainPath(root, 'sources'));
|
|
498
|
-
return files.filter((f) => f.endsWith('.md') && !f.startsWith('_')).length;
|
|
499
|
-
} catch { return 0; }
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/** queryHint 的 load 端封装:返回提示文本(含标题行),无强词则空串。失败静默。 */
|
|
503
|
-
async function queryHintSection(root, todo) {
|
|
504
|
-
return queryHint(root, todo);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** sources 堆积提醒:返回 [标题行, ...内容行] 或 null。失败静默。 */
|
|
508
|
-
async function sourcesCountSection(root) {
|
|
509
|
-
const nsrc = await countSources(root);
|
|
510
|
-
if (nsrc <= SOURCES_MAX) return null;
|
|
511
|
-
return [
|
|
512
|
-
`♻ 待消化: sources/ 有 ${nsrc} 条 > ${SOURCES_MAX}(采集了没提炼)`,
|
|
513
|
-
'→ abs lint 看明细;提炼成 concepts/ 后删 source 并清引用',
|
|
514
|
-
'',
|
|
515
|
-
];
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// ---------- Rules: index.md 里的硬规则区 ----------
|
|
519
|
-
/** index.md 的 `## Rules` 区名与上限。 */
|
|
520
|
-
export const RULES_HEADING = '## Rules';
|
|
521
|
-
export const RULES_MAX = 30; // 超过就 lint 报:它属于“被读到才有价值”的区,不能无界增长
|
|
522
|
-
/** 单条上限(中文按字算)。2026-10-05 从 cmdRule 内联数字抽出:lint 也要查同一个值 ——
|
|
523
|
-
* 曾经只在「abs rule add」写入时校验,手工编辑 index.md 完全绕过,而 lint 只查条数不查长度
|
|
524
|
-
* → 89 字的条目静默通过,同一内容用 add 写反而被拒(两个口子标准不一致)。 */
|
|
525
|
-
export const RULE_BODY_MAX = 42;
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
/** 提取 index.md 里的 Rules 区条目(不含标题)。返回 { items:[行], body, found }。 */
|
|
529
|
-
export function readRules(indexText) {
|
|
530
|
-
const lines = String(indexText || '').split('\n');
|
|
531
|
-
const i = lines.findIndex((l) => l.trim() === RULES_HEADING);
|
|
532
|
-
if (i === -1) return { items: [], body: '', found: false };
|
|
533
|
-
const rest = lines.slice(i + 1);
|
|
534
|
-
const j = rest.findIndex((l) => /^##\s/.test(l));
|
|
535
|
-
const body = rest.slice(0, j === -1 ? rest.length : j);
|
|
536
|
-
return { items: body.filter((l) => l.trim().startsWith('- ')), body: body.join('\n').trim(), found: true };
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/** load 里 Rules 的呈现:带独立段头,条目原样(不折、不截)。无条目则不占字节。 */
|
|
540
|
-
function rulesSection(indexText) {
|
|
541
|
-
const { items, body, found } = readRules(indexText);
|
|
542
|
-
if (!found || !body) return '';
|
|
543
|
-
// 有引言句(> 开头)时一并带上,它解释了这个区是干什么的。
|
|
544
|
-
const intro = body.split('\n').filter((l) => l.trim().startsWith('>')).join('\n');
|
|
545
|
-
return [
|
|
546
|
-
`--- Rules (硬规则,先读, index.md) — ${items.length} 条 ---`,
|
|
547
|
-
intro,
|
|
548
|
-
...items,
|
|
549
|
-
].filter((x) => x !== '').join('\n');
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* `abs rule` —— 读写 index.md 的 Rules 区。
|
|
554
|
-
* 无参 = 列出(只给规则,不被 load 的其它内容占上下文)。
|
|
555
|
-
* add <一句话> = 追加一条(走锁写、幂等去重)。
|
|
556
|
-
* 门槛:只该放“违反会丢数据/静默失效/白干活”级规律;长句会被拒并指向概念页。
|
|
557
|
-
*/
|
|
558
|
-
export async function cmdRule({ dir, action, text }) {
|
|
559
|
-
let root;
|
|
560
|
-
try { root = await requireBrain(dir || process.cwd()); } catch {
|
|
561
|
-
return '未找到 .brain/ 图谱。先在项目根运行: abs init';
|
|
562
|
-
}
|
|
563
|
-
const p = brainPath(root, 'index.md');
|
|
564
|
-
if (!action || action === 'list' || action === 'show') {
|
|
565
|
-
const { items, found } = readRules(await readFileOrNull(p));
|
|
566
|
-
if (!found) return `index.md 无 \`${RULES_HEADING}\` 区(跑 abs load 会自动补位)`;
|
|
567
|
-
if (!items.length) return `${RULES_HEADING} 区为空(add "一句话" 追加)`;
|
|
568
|
-
return [`${RULES_HEADING} — ${items.length} 条:`, ...items].join('\n');
|
|
569
|
-
}
|
|
570
|
-
if (action !== 'add') {
|
|
571
|
-
return `用法: abs rule 列出硬规则\n abs rule add "一句话" [--note "补充"]`;
|
|
572
|
-
}
|
|
573
|
-
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
|
574
|
-
if (!clean) return '用法: abs rule add "一句话硬规则"';
|
|
575
|
-
// 门槛:**纪律不是记事本**。Rules 是最前面的项目铁律,每条必须一眼扫完。
|
|
576
|
-
// 2026-09-13 用户定:不带链接、不带解释、不写细节 —— 细节进概念页,Rules 只留结论。
|
|
577
|
-
// 宽严:中文一字信息量大,按 40 字算(≈ 英文 80 字符的量)。实测现有 12 条最长 56 字符。
|
|
578
|
-
if (clean.length > RULE_BODY_MAX) {
|
|
579
|
-
return `✗ 太长(${clean.length} > ${RULE_BODY_MAX})—— Rules 是纪律不是记事本:\n`
|
|
580
|
-
+ ` 每条要一眼扫完。细节/出处/例子 → 写进 concepts/ 概念页,这里只留一句话结论。`;
|
|
581
|
-
}
|
|
582
|
-
if (/\[\[|\]\]|https?:\/\//.test(clean)) {
|
|
583
|
-
return '✗ 不带链接 —— Rules 区会被反复全量打印,链接占位且让纪律读起来像索引。\n'
|
|
584
|
-
+ ' 要挂概念页,去概念页自己的「## 关联连接」里挂。';
|
|
585
|
-
}
|
|
586
|
-
let added = null;
|
|
587
|
-
await editFile(p, (cur) => {
|
|
588
|
-
if (cur === null) return SKIP;
|
|
589
|
-
const lines = cur.split('\n');
|
|
590
|
-
const i = lines.findIndex((l) => l.trim() === RULES_HEADING);
|
|
591
|
-
if (i === -1) return SKIP; // 无该区不擅自建(load 会补位)
|
|
592
|
-
if (lines.some((l) => l.trim() === `- ${clean}`)) return SKIP; // 幂等:同句不重复
|
|
593
|
-
// 插在该区最后一条条目之后(保序、不搅动其它条目)
|
|
594
|
-
let at = i + 1;
|
|
595
|
-
for (let k = i + 1; k < lines.length; k++) {
|
|
596
|
-
if (/^##\s/.test(lines[k])) break;
|
|
597
|
-
if (lines[k].trim().startsWith('- ')) at = k + 1;
|
|
598
|
-
}
|
|
599
|
-
lines.splice(at, 0, `- ${clean}`);
|
|
600
|
-
added = clean;
|
|
601
|
-
return { text: lines.join('\n') };
|
|
602
|
-
});
|
|
603
|
-
if (added === null) return `• 已有同句或 index.md 无 ${RULES_HEADING} 区(跳过)`;
|
|
604
|
-
const { items } = readRules(await readFileOrNull(p));
|
|
605
|
-
const warn = items.length > RULES_MAX
|
|
606
|
-
? `\n⚠ Rules 已 ${items.length} 条 > ${RULES_MAX}:考虑把其中几条提炼成概念页(跑 abs lint 会报)`
|
|
607
|
-
: '';
|
|
608
|
-
return `✓ 已加硬规则(共 ${items.length} 条)\n - ${clean}${warn}`;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
async function readFileOrNull(p) {
|
|
612
|
-
try { return await fs.readFile(p, 'utf8'); } catch { return ''; }
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** index.md 在 `abs load` 里的折叠形态:把页面清单各分区折成计数。
|
|
616
|
-
*
|
|
617
|
-
* 坑: index 的 concept 清单带每一页的一句话描述,**隨图谱线性增长** —— 本仓库 17 条
|
|
618
|
-
* 占 load 输出 2286/3571 tok(64%),另一台 40 条的项目约 2.3 倍。它刚成了 Done 之后
|
|
619
|
-
* 最大的单体膨胀源(Done 已折叠,见 collapseDone)。
|
|
620
|
-
*
|
|
621
|
-
* 续接真正需要的只是「有哪些分区、各多少页」(据此知道去哪找),不需要每页写了什么 ——
|
|
622
|
-
* 要那个用 `abs index`(完整原文件)或按词 `abs query`。
|
|
623
|
-
*
|
|
624
|
-
* `## Rules` 也已不在本函数输出——它由 rulesSection 在**上方**单独成段(需要正文);
|
|
625
|
-
* 这里再原样吐一遍 = 同一段硬规则在首屏出现两次(2026-09-13 实测发现)。
|
|
626
|
-
* 曾另有 `## Roadmap`:AI 自己写的方向总结,会被反复读到并带偏会话,已删。 */
|
|
627
|
-
// ---------- wrapup: 滞留快照(B)/ load 内展示由 cmdLoad 完成(A) ----------
|
|
628
|
-
export async function cmdWrapup({ dir }) {
|
|
629
|
-
const root = await requireBrain(dir || process.cwd());
|
|
630
|
-
const snap = await appendWrapup(root);
|
|
631
|
-
// 顺手做 Done 归档。**不引入 cron/定时器**:Stop hook 已经会在会话结束时调
|
|
632
|
-
// `abs wrapup`,直接复用这个触发点(零新基础设施、零新失败面)。
|
|
633
|
-
// 只对「超过保留天数 且 整天都已完成」的日期组动手,平时无动作。
|
|
634
|
-
// 本函数 stdout 会被 hook 追写到 ~/.abs/log/hooks.log → 归档动作自动留痕。
|
|
635
|
-
let arch = '';
|
|
636
|
-
try {
|
|
637
|
-
const a = await cmdTodoArchive({ dir: root, keepDays: 3 });
|
|
638
|
-
if (a.startsWith('✓')) arch = '\n' + a;
|
|
639
|
-
} catch { /* 归档失败不影响快照本身 */ }
|
|
640
|
-
return snap + arch;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
/**
|
|
644
|
-
* `abs todo archive` —— 把 Done 区里「超过保留天数 且 整天都已完成」的日期组迁到归档页。
|
|
645
|
-
* 规则见 `archiveDoneInText`(① 只留近 N 天 ② 任一天有未完成则整天不归档 ③ 归档成一个文件
|
|
646
|
-
* 并在 Done 区尾部 `### 归档` 记 `- [[slug]] 完成任务 N 条`)。
|
|
647
|
-
* 幂等:同日重跑 = 追写到同一归档页 + 就地更新标记行。
|
|
648
|
-
*/
|
|
649
|
-
export async function cmdTodoArchive({ dir, keepDays = 3, dryRun = false } = {}) {
|
|
650
|
-
let root;
|
|
651
|
-
try { root = await requireBrain(dir || process.cwd()); } catch { return '未找到 .brain/ 图谱。先在项目根运行: abs init'; }
|
|
652
|
-
// 坑(2026-10-05 实测):原写法 `Math.max(1, Number(keepDays) || 3)` 有两个问题 ——
|
|
653
|
-
// ① JS falsy 陷阱:`0 || 3` 得 3,于是 `--keep-days 0` 被当成"没传值",静默变成
|
|
654
|
-
// 保留 3 天(用户以为归档了全部,实际一条没动,且输出还理直气壮说"保留近 3 天")。
|
|
655
|
-
// ② `Math.max(1, ...)` 会静默把 0 抬成 1 —— 即使修好 ①,用户也不知道自己被改了。
|
|
656
|
-
// 改为:显式校验 + 明确报错。宁可让用户改参数,也不静默改写他的意图。
|
|
657
|
-
const rawDays = Number(keepDays);
|
|
658
|
-
if (!Number.isFinite(rawDays) || rawDays < 1 || !Number.isInteger(rawDays)) {
|
|
659
|
-
return `✗ --keep-days 需为 ≥1 的整数(收到 "${keepDays}")\n`
|
|
660
|
-
+ ' 下限 1 天是为了不误删今天刚完成的记录;要清空 Done 请先确认后手动编辑 todo.md。';
|
|
661
|
-
}
|
|
662
|
-
const days = rawDays;
|
|
663
|
-
const todoP = brainPath(root, 'todo.md');
|
|
664
|
-
const raw = await fs.readFile(todoP, 'utf8').catch(() => '');
|
|
665
|
-
if (!raw.trim()) return '(todo.md 为空)';
|
|
666
|
-
|
|
667
|
-
const plan = archiveDoneInText(raw, { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
668
|
-
const why = plan.skipped.length
|
|
669
|
-
? `\n 跳过: ${plan.skipped.map((s) => `${s.date}(${s.reason})`).join(';')}`
|
|
670
|
-
: '';
|
|
671
|
-
if (!plan.archived.length) return `(无可归档:保留近 ${days} 天)${why}`;
|
|
672
|
-
const brief = plan.archived.map((g) => `${g.date}(${g.count})`).join(' ');
|
|
673
|
-
if (dryRun) {
|
|
674
|
-
return `[dry-run] 将归档 ${plan.archived.length} 天 / ${plan.count} 条(每天一个文件)\n ${brief}${why}`;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// 1) 归档目标 = 当天的会话快照文件(log-<日期>.md),写进其「任务归档」段。
|
|
678
|
-
// 为何合一(2026-09-15 用户定): 归档与会话快照是**同一天的记录**,分两个文件查着要开两处。
|
|
679
|
-
// 边界: ① 文件不存在→建只有归档段的页(那天可能没写快照)
|
|
680
|
-
// ② 文件已存在(含 AI 手写快照)→ 只替换归档段,段外逐字保留
|
|
681
|
-
// (硬规则「已存在的人工内容一律不覆盖」由 upsertArchiveSection 保证)。
|
|
682
|
-
const sessDir = brainPath(root, 'sessions');
|
|
683
|
-
for (const g of plan.archived) {
|
|
684
|
-
const pageP = join(sessDir, `${g.slug}.md`);
|
|
685
|
-
let page = null;
|
|
686
|
-
try { page = await fs.readFile(pageP, 'utf8'); } catch { /* 首次 */ }
|
|
687
|
-
const nextPage = upsertArchiveSection(page, g);
|
|
688
|
-
const tmp = join(sessDir, `.${g.slug}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
|
|
689
|
-
await fs.writeFile(tmp, nextPage, 'utf8');
|
|
690
|
-
await fs.rename(tmp, pageP);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// 2) todo.md:锁内重算(拿最新内容,避免与并发 done 互相覆盖)
|
|
694
|
-
await editFile(todoP, (cur) => {
|
|
695
|
-
// 坑(2026-10-05 实测抓到): 这里漏传 maxLines → 锁内重算的 archived 为空 →
|
|
696
|
-
// 返回 SKIP → 磁盘上的 todo.md 一个字没动(而 sessions/ 归档页已经写了,
|
|
697
|
-
// 两边不一致:"说归档了但看板没变")。**同参数的每个调用点都要一起改**。
|
|
698
|
-
const p2 = archiveDoneInText(cur ?? '', { keepDays: days, from: today(), maxLines: TODO_MAX_LINES });
|
|
699
|
-
return p2.archived.length ? { text: p2.text } : SKIP;
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
// 3) index 登记(每个日期页一行,幂等)
|
|
703
|
-
// 坑(2026-09-15 修): 原先把归档页登记到 `## Sources` 区 —— 但它们在 sessions/ 里,
|
|
704
|
-
// 旧数据实测就是错位的(09-10/09-12 两条曾键在 Sources 段,人工才发现)。现改登 `## Sessions`。
|
|
705
|
-
const iP = brainPath(root, 'index.md');
|
|
706
|
-
await editFile(iP, (index) => {
|
|
707
|
-
if (!index) return SKIP;
|
|
708
|
-
const missing = plan.archived.filter((g) => !index.includes(`[[${g.slug}]]`));
|
|
709
|
-
if (!missing.length) return SKIP;
|
|
710
|
-
const sIdx = index.indexOf('## Sessions');
|
|
711
|
-
if (sIdx === -1) return SKIP;
|
|
712
|
-
const after = index.indexOf('\n## ', sIdx + 1);
|
|
713
|
-
const add = missing.map((g) => `- [[${g.slug}]] — ${g.date}(会话快照 + 任务归档)`).join('\n');
|
|
714
|
-
const next = after === -1
|
|
715
|
-
? `${index.replace(/\s*$/, '')}\n${add}\n`
|
|
716
|
-
: index.slice(0, after) + `\n${add}` + index.slice(after);
|
|
717
|
-
return { text: next };
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
const files = plan.archived.map((g) => `sessions/${g.slug}.md`).join('\n ');
|
|
721
|
-
return `✓ 已归档 ${plan.archived.length} 天 / ${plan.count} 条\n ${brief}\n → ${files}${why}`;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/**
|
|
725
|
-
* Stop hook 收尾注入判定 —— CC/Codex 的"主动推"。
|
|
726
|
-
* 与 pi 插件四条件一致: ①本会话真改过文件 ②有 .brain ③log.md 今日无条目 ④每会话一次。
|
|
727
|
-
* 输出给 shell: `push:<json>` = 注入, 其它 = 放行。JSON 走 stdout 回宿主的 decision:block。
|
|
728
|
-
* 所有异常一律放行(`{}`), 绝不因判定失败而卡住用户会话。
|
|
729
|
-
*/
|
|
730
|
-
export async function cmdTeardownCheck({ dir, payload }) {
|
|
731
|
-
try {
|
|
732
|
-
let ev = {};
|
|
733
|
-
try { ev = JSON.parse(payload || '{}'); } catch { ev = {}; }
|
|
734
|
-
|
|
735
|
-
// 条件①: 本会话真改过文件。CC 的 Stop payload 不带工具足迹,
|
|
736
|
-
// 故以 transcript_path 为准; 取不到就退化为"本项目今日是否已有产物"判断。
|
|
737
|
-
const transcript = ev.transcript_path || ev.transcriptPath;
|
|
738
|
-
if (transcript) {
|
|
739
|
-
const txt = await readIfExists(transcript);
|
|
740
|
-
const wrote = /"(Write|Edit|MultiEdit|NotebookEdit)"/.test(txt);
|
|
741
|
-
if (!wrote) return '{}'; // 纯只读会话不打扰
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// 条件②: 本项目有 .brain
|
|
745
|
-
const dir0 = dir || ev.cwd || process.cwd();
|
|
746
|
-
let root;
|
|
747
|
-
try { root = await requireBrain(dir0); } catch { return '{}'; }
|
|
748
|
-
|
|
749
|
-
// 条件③: log.md 今日尚无【工作成果】条目(条目头 '## [YYYY-MM-DD HH:MM] [[name]] dev |')。
|
|
750
|
-
// 坑(2026-09-13 实测, pi 侧先修): `abs note` 也写 log.md(kind=note),
|
|
751
|
-
// 旧判据只看「今天有没有行」会把「沉淀了一条经验」误判成「今天已收尾」→ 整天不再提醒。
|
|
752
|
-
// 三宿主(pi/CC/opencode)判据必须一致 —— 见 [[hook-throttle-alignment]]。
|
|
753
|
-
const logTxt = await readIfExists(brainPath(root, 'log.md'));
|
|
754
|
-
const stamp = localStamp(); // 'YYYY-MM-DD HH:MM'
|
|
755
|
-
const day = stamp.slice(0, 10);
|
|
756
|
-
if (new RegExp('^## \\[' + day + ' \\d{2}:\\d{2}\\] (?:\\[\\[[^\\]]+\\]\\] )?dev \\|', 'm').test(logTxt)) return '{}';
|
|
757
|
-
|
|
758
|
-
// 条件④: 每会话一次。
|
|
759
|
-
// 双保险, 因为官方 stop_hook_active 有已知
|
|
760
|
-
// 不传播 bug(claude-code#54360): 同一 turn 内重复 fire 时它仍是 false。
|
|
761
|
-
// ① 官方契约: stop_hook_active=true = 本次 Stop 已是注入后的产物, 绝不再推(否则死循环)
|
|
762
|
-
if (ev.stop_hook_active === true) return '{}';
|
|
763
|
-
// ② 己方节流: 以 session_id 落 mark。缺 id 时退化为按项目+日期节流,
|
|
764
|
-
// 绝不"无节流"——否则一旦宿主张不到 id, decision:block 就会无限自激。
|
|
765
|
-
const sid = String(ev.session_id || ev.sessionId || '').replace(/[^\w-]/g, '');
|
|
766
|
-
// 兼底 key 只用【项目名】而非 full path。
|
|
767
|
-
// 坑(2026-09-16 实测): 原用 root.replace(/[^\w]/g,'_') 会把 cwd 的 tmp 路径
|
|
768
|
-
// (/var/folders/..._tmpXXXX) 也编进去 —— 而每个会话的 tmp 目录都不同,
|
|
769
|
-
// 于是"按项目+日期节流"根本没生效, 每次都新 key → ~/.abs/log/ 堆了 361 个 mark。
|
|
770
|
-
const projKey = root.split('/').filter(Boolean).pop() || 'unknown';
|
|
771
|
-
const key = sid || 'nosession-' + day + '-' + projKey;
|
|
772
|
-
const mark = join(absLogDir(), `teardown-${key}.mark`);
|
|
773
|
-
try {
|
|
774
|
-
await fs.access(mark);
|
|
775
|
-
return '{}'; // 本会话(或本项目今日)已推过
|
|
776
|
-
} catch { /* 未推过 */ }
|
|
777
|
-
await fs.mkdir(dirname(mark), { recursive: true });
|
|
778
|
-
await fs.writeFile(mark, stamp).catch(() => {});
|
|
779
|
-
// mark 只增不减会堆成垃圾(实测 361 个)。只清【带旧日期】的 mark ——
|
|
780
|
-
// 节流只在当天有意义(判据是 day), 旧日期 mark 不可能再命中;
|
|
781
|
-
// 无日期的会话 mark(teardown-<sid>.mark)保留, 它靠自身存在与否判重。
|
|
782
|
-
try {
|
|
783
|
-
for (const f of await fs.readdir(absLogDir())) {
|
|
784
|
-
const m = f.match(/^teardown-.*-(\d{4}-\d{2}-\d{2})-.*\.mark$/);
|
|
785
|
-
if (m && m[1] !== day) await fs.unlink(join(absLogDir(), f)).catch(() => {});
|
|
786
|
-
}
|
|
787
|
-
} catch { /* 清理失败不影响主流程 */ }
|
|
788
|
-
|
|
789
|
-
const msg = [
|
|
790
|
-
// 未设姓名时把设置指令插到第0条 —— 否则后续 todo add/log/note 全会被守卫拦下。
|
|
791
|
-
(await getUser() ? [] : [
|
|
792
|
-
'0) 本机尚未设置使用者姓名 —— 先跑 abs config set user <你的名字>,否则 todo/log/note 都会被拦下;',
|
|
793
|
-
]),
|
|
794
|
-
'[abs] 本会话改过文件,.brain/ 今日无记录。',
|
|
795
|
-
'这条是信息不是命令:该沉淀就沉淀,没有可沉淀的直接回一句「无可沉淀」,不用凑。',
|
|
796
|
-
'需要时:abs todo / abs todo done <id> / abs note "..." / abs log "..."',
|
|
797
|
-
].join('\n');
|
|
798
|
-
|
|
799
|
-
// Claude Code Stop hook 契约: {"decision":"block","reason":"..."} = 阻止结束并把 reason 回灌给 agent
|
|
800
|
-
return 'push:' + JSON.stringify({ decision: 'block', reason: msg });
|
|
801
|
-
} catch {
|
|
802
|
-
return '{}'; // 永不阻塞宿主
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
async function readIfExists(p) {
|
|
807
|
-
try { return (await fs.readFile(p, 'utf8')).trim(); } catch { return ''; }
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/** log.md 是"新在上"(cmdLog 把新行插在标题后)——取**最新** n 条,只认 `## [` 条目行。
|
|
811
|
-
* 坑: 曾用 tailLines 取末尾 → 拿到的永远是最旧几条,而标签写着"最近动作",
|
|
812
|
-
* 于是"开机读状态"最该看的一节长期显示两天前的旧记录(今天的新条目从未显示)。 */
|
|
813
|
-
function recentLogLines(text, n) {
|
|
814
|
-
return String(text || '')
|
|
815
|
-
.split('\n')
|
|
816
|
-
.filter((l) => /^##\s*\[/.test(l))
|
|
817
|
-
.slice(0, n)
|
|
818
|
-
// 每条按语义边界收口:load 是开机读状态,只求"上会话做到哪"的线索,不须全文。
|
|
819
|
-
// 坑: 不收口时 5 条能占 2.9KB(单条 800B),又是一处随日志变长而膨胀的上下文。
|
|
820
|
-
.map((l) => clip(l, 220))
|
|
821
|
-
.join('\n');
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
// ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
|
|
826
|
-
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
827
|
-
// log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
|
|
828
|
-
export async function cmdTask({ dir, action, id, section, note, as, session, n }) {
|
|
829
|
-
// ★ id 校验(2026-10-16 模糊测试找出来的真 bug):id 不能含空白/方括号。
|
|
830
|
-
// 不校就是**静默丢信息**:id="带 空格" 写进文件时被 extractId 截成 "带",
|
|
831
|
-
// 后面的 "空格" 与描述一起消失,不报错(实测:`abs todo start "带 空格"` 落成 `- [ ] 带`)。
|
|
832
|
-
// 宁可明确拒掉,也不替人猜哪个词算 id。
|
|
833
|
-
const idStr = id === undefined || id === null ? '' : String(id);
|
|
834
|
-
if (idStr && (idStr.includes(' ') || idStr.includes('\t'))) {
|
|
835
|
-
throw new Error(
|
|
836
|
-
`✗ 任务 id 不能含空格(收到 "${idStr}")—— id 是一个词,空白会被当分隔符。\n` +
|
|
837
|
-
` 想要带空格的名字:用连字符或下划线;完整名称写进 --note。`,
|
|
838
|
-
);
|
|
839
|
-
}
|
|
840
|
-
const root = await requireBrain(dir || process.cwd());
|
|
841
|
-
// 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
|
|
842
|
-
const who = await requireUser();
|
|
843
|
-
// 会话 id(可缺省):塞进已有的 `(认领 日期)` 字段而非另开新字段 ——
|
|
844
|
-
// 归档是整行搬运,塞在旧字段里就能自动跟着走,且 6 处解析里 5 处用的
|
|
845
|
-
// 是 `\(认领[^)]*\)` 通配,零改动兼容(只剩 hook 那处严格正则要改)。
|
|
846
|
-
// 取不到就不写:纯加法,旧行为不变。
|
|
847
|
-
const sid = String(session || process.env.ABS_SESSION_ID || '')
|
|
848
|
-
.replace(/[^\w-]/g, '').slice(0, 16);
|
|
849
|
-
const claim = `(认领 ${today()}${sid ? ' ' + sid : ''})`;
|
|
850
|
-
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
851
|
-
if (action === 'start') {
|
|
852
|
-
// 两区制:未完成一律进 Todo,行首带状态标记(默认 进行中)。
|
|
853
|
-
const st = section && TASK_STATES.includes(section) ? section : '进行中';
|
|
854
|
-
// 唯一进行中约束(2026-10-05 照 rpiv-todo):看板要能回答"现在在做什么",
|
|
855
|
-
// 多个 [进行中] 就答不了。**只提示,不擅自降级别人的任务** ——
|
|
856
|
-
// 机器替人改状态是静默篡改(同 [[falsy-or-eats-zero]] 的"别悄悄改写意图")。
|
|
857
|
-
let warning = '';
|
|
858
|
-
// 只在**新开**进行中时提示(幂等更新同一条不该重复唠叨)。
|
|
859
|
-
let alreadyThere = false;
|
|
860
|
-
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
861
|
-
if (cur === null) return SKIP;
|
|
862
|
-
alreadyThere = cur.split('\n').some((l) => idOfTaskLine(l) === String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, ''));
|
|
863
|
-
return SKIP;
|
|
864
|
-
});
|
|
865
|
-
if (st === '进行中' && !alreadyThere) {
|
|
866
|
-
const others = [];
|
|
867
|
-
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
868
|
-
if (cur === null) return SKIP;
|
|
869
|
-
for (const l of cur.split('\n')) {
|
|
870
|
-
const tid = idOfTaskLine(l);
|
|
871
|
-
if (tid && tid !== id && stateOfTaskLine(l) === '进行中') others.push(tid);
|
|
872
|
-
}
|
|
873
|
-
return SKIP;
|
|
874
|
-
});
|
|
875
|
-
if (others.length) {
|
|
876
|
-
// 提示要具体到「该转成哪个」:只报「已有 N 个进行中」实测不够 ——
|
|
877
|
-
// 用户改方向时,旧任务是「搁置」(不做了),与「滞留中」(还要做)不同,
|
|
878
|
-
// 选错会让下会话分不清该不该接着干。
|
|
879
|
-
warning = `\n ⚠ 已有 ${others.length} 个 [进行中]: ${others.join(', ')}\n`
|
|
880
|
-
+ ' 同时只有一个进行中才说得清"现在在做什么"。\n'
|
|
881
|
-
+ ` 旧任务怎么处理 ——\n`
|
|
882
|
-
+ ` • 用户改方向了/不做了 → abs todo state <id> --note 搁置\n`
|
|
883
|
-
+ ` • 还要做,只是卡住了 → abs todo state <id> --note 滞留中\n`
|
|
884
|
-
+ ` • 还想商量 → abs todo state <id> --note 讨论中`;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
// 接手提示(2026-10-09 用户要求):该任务已属于另一个会话时,接手要告知 ——
|
|
888
|
-
// 用户原话:「不属于我的会话的就别乱进行,但是用户强制要进行那也得接手」。
|
|
889
|
-
// **只提示不拦截**:接手是允许的,但得让它知道这条原来是谁的、自己接手了;
|
|
890
|
-
// 否则静默换了 sid,看板上无声无息地易主。
|
|
891
|
-
let takeover = '';
|
|
892
|
-
if (sid) {
|
|
893
|
-
let prevSid = '';
|
|
894
|
-
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
895
|
-
if (cur === null) return SKIP;
|
|
896
|
-
for (const l of cur.split('\n')) {
|
|
897
|
-
if (idOfTaskLine(l) === String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, '')) {
|
|
898
|
-
prevSid = sessionOf(l);
|
|
899
|
-
break;
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
return SKIP;
|
|
903
|
-
});
|
|
904
|
-
if (prevSid && prevSid !== sid) {
|
|
905
|
-
takeover = `\n ⚠ 接手 —— 该任务原属会话 ${prevSid}(不是本会话 ${sid})\n`
|
|
906
|
-
+ ` 已把会话标记改为 ${sid}\n`
|
|
907
|
-
+ ` 不想接手只想看看 → abs todo state <id> --note 滞留中(释放占用)`;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
const r = await upsertTask(root, {
|
|
911
|
-
section: SEC.todo,
|
|
912
|
-
text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} ${claim}`,
|
|
913
|
-
session: sid,
|
|
914
|
-
});
|
|
915
|
-
return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}${sid ? ' ' + claim : ''}${takeover}${warning}`;
|
|
916
|
-
}
|
|
917
|
-
if (action === 'rename') {
|
|
918
|
-
// 改任务 id(2026-10-05):人工起错名、或旧 id 不可读时用。
|
|
919
|
-
// (`fix-skill-trigger` 这种名字不是从用户字面抽出来的,是读懂在干什么之后起的。)
|
|
920
|
-
// --note 传新 id(沿用既有 CLI 习惯,免得再加一个 flag)。
|
|
921
|
-
if (!note) throw new Error('✗ 用法: abs todo rename <旧id> --note "<新id>"');
|
|
922
|
-
const newId = String(note).replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim();
|
|
923
|
-
if (!newId || /\s/.test(newId)) throw new Error(`✗ 新 id 不能为空或含空白: "${newId}"`);
|
|
924
|
-
const f = brainPath(root, 'todo.md');
|
|
925
|
-
let done = false;
|
|
926
|
-
await editFile(f, (cur) => {
|
|
927
|
-
if (cur === null) return SKIP;
|
|
928
|
-
const lines = cur.split('\n');
|
|
929
|
-
const want = String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, '');
|
|
930
|
-
for (let i = 0; i < lines.length; i++) {
|
|
931
|
-
if (idOfTaskLine(lines[i]) !== want) continue;
|
|
932
|
-
lines[i] = lines[i].replace(want, newId);
|
|
933
|
-
done = true;
|
|
934
|
-
break;
|
|
935
|
-
}
|
|
936
|
-
return done ? { text: lines.join('\n') } : SKIP;
|
|
937
|
-
});
|
|
938
|
-
return done ? `✓ 任务改名 ${id} → ${newId}` : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`;
|
|
939
|
-
}
|
|
940
|
-
if (action === 'state') {
|
|
941
|
-
// 改状态标记(原地,不搬区):进行中 / 讨论中 / 滞留中
|
|
942
|
-
if (!note) throw new Error(`✗ 用法: abs todo state <id> --note "${TASK_STATES.join('|')}"`);
|
|
943
|
-
if (!TASK_STATES.includes(note)) {
|
|
944
|
-
throw new Error(`✗ 状态只接受: ${TASK_STATES.join(' | ')}(收到 "${note}")`);
|
|
945
|
-
}
|
|
946
|
-
const f = brainPath(root, 'todo.md');
|
|
947
|
-
let cc = [];
|
|
948
|
-
await editFile(f, (cur) => {
|
|
949
|
-
if (cur === null) return SKIP;
|
|
950
|
-
const r = setStateMark(cur, id, note);
|
|
951
|
-
cc = r.changed;
|
|
952
|
-
return r.changed.length ? { text: r.text } : SKIP;
|
|
953
|
-
});
|
|
954
|
-
return cc.length ? `✓ ${id} 状态 → [${note}]` : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行,或状态未变`;
|
|
955
|
-
}
|
|
956
|
-
if (action === 'note') {
|
|
957
|
-
if (!note) return '用法: abs todo note <id> --note "进度"\n 建议前缀(load 会把它们单独抽出来,让人一眼看到):\n 验证: <跑过的命令/结果>\n 边界: <这方案治不了什么>\n 阻塞: <在等什么,卡在哪>';
|
|
958
|
-
const r = await setBreakpoint(root, { id, text: note });
|
|
959
|
-
return r.msg;
|
|
960
|
-
}
|
|
961
|
-
// 勾/取消勾子项:子项也是待办,做完要勾。
|
|
962
|
-
// 单独两个子命令而不是 `--done true/false` —— 勾与取消是两件事,各自一个动词更顺手。
|
|
963
|
-
// 2026-10-20:命令名从 bp-done/bp-undone 改为 child-done/child-undone
|
|
964
|
-
//(「断点」这个叫法已废弃,子项就是标准 md 嵌套列表项)。
|
|
965
|
-
if (action === 'child-done' || action === 'child-undone') {
|
|
966
|
-
const r = await setBreakpointDone(root, { id, n, done: action === 'child-done' });
|
|
967
|
-
return r.msg;
|
|
968
|
-
}
|
|
969
|
-
if (action === 'memo') {
|
|
970
|
-
// 任务随手记:多句/不定长/可追加,不占看板。无 note 时 = 查看已有笔记。
|
|
971
|
-
if (!id) return '用法: abs todo memo <id> "随手记(可多句)" | abs todo memo <id>(查看)';
|
|
972
|
-
if (!note) {
|
|
973
|
-
const body = await readMemo(root, id);
|
|
974
|
-
return body || `(${id} 没有随手记)`;
|
|
975
|
-
}
|
|
976
|
-
const r = await setMemo(root, { id, text: note });
|
|
977
|
-
return r.msg;
|
|
978
|
-
}
|
|
979
|
-
if (action === 'done') {
|
|
980
|
-
// 找到匹配 id 的行,勾选并归位 Done(简化:若行在某 section 则标记完成)
|
|
981
|
-
// --as 结语:落地(默认) / 否决 / 仅方案 —— 让 [x] 可被信任(见 todo.js DONE_KINDS)
|
|
982
|
-
if (as && !DONE_KINDS.includes(as)) {
|
|
983
|
-
throw new Error(`✗ --as 只接受: ${DONE_KINDS.join(' | ')}(收到 "${as}")`);
|
|
984
|
-
}
|
|
985
|
-
// 结语 = note(MCP 路径)或位置参数(CLI 路径)。坑(2026-09-16 实测):
|
|
986
|
-
// MCP abs_task schema 无 as,AI 按旧 SKILL.md 传 note 会被静默丢弃、行上默认盖【落地】
|
|
987
|
-
// —— 状态失真且无声。现在 note 也当结语;note 里自带【落地/否决/仅方案】时它就是 kind。
|
|
988
|
-
// as 与 note 里的 kind 矛盾 → 报错不猜(猜一侧就是静默篡改另一侧)。
|
|
989
|
-
const conclusion = note ? String(note).replace(/\u200b/g, '') : undefined;
|
|
990
|
-
const inlineKind = conclusion ? (conclusion.match(/【(落地|否决|仅方案)】/) || [])[1] : undefined;
|
|
991
|
-
if (inlineKind && as && inlineKind !== as) {
|
|
992
|
-
throw new Error(`✗ as="${as}" 与 note 里的【${inlineKind}】矛盾 —— 只留一个(改 as,或改 note 里的【】)`);
|
|
993
|
-
}
|
|
994
|
-
const res = await markDone(brainPath(root, 'todo.md'), id, as || inlineKind || '落地', conclusion, sid);
|
|
995
|
-
return res;
|
|
996
|
-
}
|
|
997
|
-
throw new Error(`unknown task action: ${action}`);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
1001
|
-
const res = await editFile(file, (text) => {
|
|
1002
|
-
const lines = text.split('\n');
|
|
1003
|
-
let changed = false;
|
|
1004
|
-
let moved = null;
|
|
1005
|
-
const kept = [];
|
|
1006
|
-
const wantId = String(id).replace(/\u200b/g, '');
|
|
1007
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1008
|
-
const l = lines[i];
|
|
1009
|
-
if (!moved && idOfTaskLine(l) === wantId) {
|
|
1010
|
-
changed = true;
|
|
1011
|
-
// 结语(MCP note / CLI 位置参数)拼在日期前 —— 与 CLI `abs todo done <id> [--as ..] <结语>` 同一落点。
|
|
1012
|
-
const tail = conclusion ? ` — ${conclusion}` : '';
|
|
1013
|
-
// 认领与完成可能是**不同会话**(A 认领、B 收尾)。两个都要留:
|
|
1014
|
-
// 只留完成者 → A 的线索断;只留认领者 → (完成 日期) 配着别人,语义拧。
|
|
1015
|
-
// 规则(2026-10-09 用户定):
|
|
1016
|
-
// 同一个 sid → 只写 `(完成 日期 sid)`
|
|
1017
|
-
// 不同 sid → `(认领 日期 A) (完成 日期 B)`
|
|
1018
|
-
// 认领无 sid → 只写 `(完成 日期 B)`
|
|
1019
|
-
// done 未给 sid → 沿用认领的(无新信息,不凭空编)
|
|
1020
|
-
const claimSid = sessionOf(l);
|
|
1021
|
-
const doneSid = bySid || claimSid;
|
|
1022
|
-
const base = checkTaskBox(stripStateMark(l)).replace(/\s*\(认领[^)]*\)/, '');
|
|
1023
|
-
const parts = [];
|
|
1024
|
-
if (claimSid && doneSid && claimSid !== doneSid) {
|
|
1025
|
-
parts.push(`(认领 ${today()} ${claimSid})`);
|
|
1026
|
-
}
|
|
1027
|
-
parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
|
|
1028
|
-
const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
|
|
1029
|
-
const bp = [];
|
|
1030
|
-
const leftover = [];
|
|
1031
|
-
// 子项搬运:**已勾选**的随任务进 Done;未勾选的不搬(下方直接拒)。
|
|
1032
|
-
// 已勾的必须搬:否则任务进 Done 了、子项剩在原区,就是无主孤儿行,
|
|
1033
|
-
// 重建分区时会被清掉(历史上就是这么丢过数据)。
|
|
1034
|
-
while (i + 1 < lines.length) {
|
|
1035
|
-
const nx = lines[i + 1];
|
|
1036
|
-
if (!nx.trim()) break;
|
|
1037
|
-
const kid = parseChild(nx);
|
|
1038
|
-
if (kid && kid.indent > 0) {
|
|
1039
|
-
// 无论勾没勾都要 i++(否则原地打转 —— 2026-10-18 实测 OOM)
|
|
1040
|
-
const ch = lines[++i];
|
|
1041
|
-
if (kid.done) bp.push(ch);
|
|
1042
|
-
else leftover.push(kid.text);
|
|
1043
|
-
continue;
|
|
1044
|
-
}
|
|
1045
|
-
// 防御:旧 `↳` 行本应已在 normalizeTodo 里迁掉。万一有路径绕过迁移,
|
|
1046
|
-
// 这里必须**消费掉该行**(i++),否则外层 while 原地打转(2026-10-18 OOM 实测)。
|
|
1047
|
-
const legacyChild = migrateLegacyChild(nx);
|
|
1048
|
-
if (legacyChild !== null) {
|
|
1049
|
-
i++; // 消费掉这一行,防止原地打转
|
|
1050
|
-
const parsed2 = parseChild(legacyChild);
|
|
1051
|
-
if (parsed2 && !parsed2.done) leftover.push(parsed2.text);
|
|
1052
|
-
else bp.push(legacyChild);
|
|
1053
|
-
continue;
|
|
1054
|
-
}
|
|
1055
|
-
break;
|
|
1056
|
-
}
|
|
1057
|
-
moved = [head, ...bp];
|
|
1058
|
-
// 还有未勾子项就**不许 done**(2026-10-18 用户定)。
|
|
1059
|
-
//
|
|
1060
|
-
// 规则:主任务与子任务都是任务,处理方式一样 —— 子任务也是看板上一行,
|
|
1061
|
-
// 也要自己完成。只有主子全部完成,主任务才能经 done。
|
|
1062
|
-
//
|
|
1063
|
-
// 为何不自己搬:上四版的四种做法全被否定——
|
|
1064
|
-
// ① 随父任务进 Done(原实现)→ Done 区被 collapseDone 折叠成计数,
|
|
1065
|
-
// 未勾子项进去即消失(实测 factory-docs-slim)
|
|
1066
|
-
// ② 升为顶层任务(<id>-restN)→ 用户:子任务不能升格
|
|
1067
|
-
// ③ 建占位主任务挂住 → 仍是无中生有,与 ② 同病
|
|
1068
|
-
// ④ 就这么留着不管 → 无主杂行,重建分区时被清掉
|
|
1069
|
-
// 正确做法是**不替用户决定**:告诉他还有哪些没完成,让他先勾或先删。
|
|
1070
|
-
if (leftover.length) {
|
|
1071
|
-
throw new Error(
|
|
1072
|
-
`✗ 任务 "${wantId}" 还有 ${leftover.length} 条子任务未完成,不能 done:\n` +
|
|
1073
|
-
leftover.map((p) => ` - [ ] ${p}`).join('\n') +
|
|
1074
|
-
`\n子任务也是任务,主子全部完成才能 done。先二选一:\n` +
|
|
1075
|
-
` • 做完了 → abs todo child-done ${wantId} (勾最后一条;指定第 N 条:child-done ${wantId} --n N)\n` +
|
|
1076
|
-
` • 不做了 → 删掉那几行子项,再 abs todo done ${wantId}`,
|
|
1077
|
-
);
|
|
1078
|
-
}
|
|
1079
|
-
continue;
|
|
1080
|
-
}
|
|
1081
|
-
kept.push(l);
|
|
1082
|
-
}
|
|
1083
|
-
if (!changed || !moved) return SKIP;
|
|
1084
|
-
// 归位 + 按日期分组:insertDoneGrouped 一次重建 Done 区(新日期在前,未标日期归尾)
|
|
1085
|
-
return { text: insertDoneGrouped(kept.join('\n'), moved) };
|
|
1086
|
-
});
|
|
1087
|
-
return res === SKIP
|
|
1088
|
-
? `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`
|
|
1089
|
-
: `✓ 已完成并归位 Done: ${id} 【${kind}】`;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// ---------- show: 查看 index/todo/log(只读面) ----------
|
|
1093
|
-
export async function cmdShow({ dir, view, full }) {
|
|
1094
|
-
const v = String(view || '').toLowerCase();
|
|
1095
|
-
if (!['todo', 'index', 'log'].includes(v)) {
|
|
1096
|
-
return '用法: abs <todo|index|log> — todo=看板(原 board), index=Graph Index, log=操作流水';
|
|
1097
|
-
}
|
|
1098
|
-
const root = await requireBrain(dir || process.cwd());
|
|
1099
|
-
const p = brainPath(root, `${v === 'todo' ? 'todo' : v}.md`);
|
|
1100
|
-
// todo 走 readTodo(含老格式惰性迁移写回);index/log 直接读
|
|
1101
|
-
let text;
|
|
1102
|
-
try {
|
|
1103
|
-
text = v === 'todo'
|
|
1104
|
-
? await readTodo(root) // 迁移老格式 In Progress/Todo → B4
|
|
1105
|
-
: (await fs.readFile(p, 'utf8')).trim();
|
|
1106
|
-
} catch {
|
|
1107
|
-
return `${v}.md 不存在于 ${brainPath(root)}。初始化/补齐: abs init --repair`;
|
|
1108
|
-
}
|
|
1109
|
-
if (!text) return `(${v}.md 为空)`;
|
|
1110
|
-
return v === 'todo' ? boardText(root, text, { full: !!full }) : text;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// Re-export query.js symbols so external importers still work.
|
|
1114
46
|
export { cmdQuery } from './query.js';
|