@fanchao8609/agent_brain_sync 1.15.3 → 1.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/abs.js +8 -11
- package/bin/mcp.js +7 -7
- package/hooks/abs.pi.ts +121 -4
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +1 -1
- package/src/lint.js +6 -7
- package/src/note.js +275 -0
- package/src/page.js +263 -0
- package/src/store.js +67 -552
- package/src/text.js +37 -0
- package/src/todo.js +23 -4
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ abs update # 升级到最新版并刷新四宿主
|
|
|
125
125
|
```
|
|
126
126
|
|
|
127
127
|
> `abs todo start` 与 `abs todo add` 等价(都登记任务)。
|
|
128
|
-
> 旧版 `abs task ...` / `abs board`
|
|
128
|
+
> 旧版 `abs task ...` / `abs board` 已废,打它们会报「未知命令」并退出码 1。
|
|
129
129
|
> `abs wrapup` / `abs teardown-check` 是 hook 内部命令,无需手动调用。
|
|
130
130
|
> **作者名要填真的**:`tester` / `foo` / `aaa` 这类占位名会被拒 —— 因为 `{user}` 是全局单值,
|
|
131
131
|
> 填错会污染之后所有项目的 `[[作者]]` 标记(`aaa` 这类堆字也拒,但 `oo`/`ee` 这种两字母缩写放行)。
|
package/bin/abs.js
CHANGED
|
@@ -279,10 +279,10 @@ const subUsage = {
|
|
|
279
279
|
'',
|
|
280
280
|
'用法:',
|
|
281
281
|
' abs todo 看板',
|
|
282
|
-
' abs todo add <id> [--note "做什么"] [--section
|
|
283
|
-
' abs todo rename <旧id> --note "<新id>"
|
|
282
|
+
' abs todo add <id> [--note "做什么"] [--section 讨论中|滞留中|搁置]',
|
|
283
|
+
' abs todo rename <旧id> --note "<新id>" 改任务 id(起错名/不可读时)',
|
|
284
284
|
' abs todo note <id> --note "断点/进度"',
|
|
285
|
-
' abs todo state <id> --note
|
|
285
|
+
' abs todo state <id> --note 进行中|讨论中|滞留中|搁置',
|
|
286
286
|
' abs todo done <id> [--as 落地|否决|仅方案] [结语文字]',
|
|
287
287
|
' abs todo archive [--keep-days N] [--dry-run]',
|
|
288
288
|
'',
|
|
@@ -336,19 +336,17 @@ function rejectExtra(extra, fix) {
|
|
|
336
336
|
throw new Error(`✗ 不认识多余参数 ${q}\n 正确用法: ${fix}`);
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
board: () => '命令已改名: abs board → abs todo',
|
|
343
|
-
};
|
|
339
|
+
// 已废弃命令 → 报错(而非静默)。曾用 RENAMED 表做"已改名"提示,
|
|
340
|
+
// 2026-10-05 删掉 task/board 两条:未知命令分支本来就报错+退出码 1+打印帮助,
|
|
341
|
+
// 别名不提供额外信息,只多一处要维护的文案。
|
|
344
342
|
|
|
345
343
|
// task/todo 共用的子命令 → 归一化后的 action
|
|
346
344
|
const TODO_ACTIONS = {
|
|
347
345
|
add: 'start',
|
|
348
346
|
start: 'start', // add 的别名(老习惯保留)
|
|
349
347
|
note: 'note',
|
|
350
|
-
state: 'state', //
|
|
351
|
-
rename: 'rename', // 改任务 id
|
|
348
|
+
state: 'state', // 改行首状态标记:进行中|讨论中|滞留中|搁置(原地,不搬区)
|
|
349
|
+
rename: 'rename', // 改任务 id:人工起错名、或旧 id 不可读时用
|
|
352
350
|
done: 'done',
|
|
353
351
|
};
|
|
354
352
|
|
|
@@ -387,7 +385,6 @@ async function cmdConfig({ sub, value }) {
|
|
|
387
385
|
async function main() {
|
|
388
386
|
try {
|
|
389
387
|
const opts = parseArgv(rest);
|
|
390
|
-
if (RENAMED[cmd]) throw new Error(RENAMED[cmd]());
|
|
391
388
|
// 子命令级 --help: 有用法页的命令在此一处拦截(此前只在 install/uninstall 分支里判,
|
|
392
389
|
// 其余命令的 --help 被忽略 —— 曾出现子命令 --help 直接执行动作、挂住终端)。
|
|
393
390
|
if (opts.help && subUsage[cmd] && !(cmd === 'todo' && opts._.length)) {
|
package/bin/mcp.js
CHANGED
|
@@ -112,12 +112,12 @@ tool(
|
|
|
112
112
|
|
|
113
113
|
tool(
|
|
114
114
|
'abs_task',
|
|
115
|
-
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / state 改状态 / done 完成归位 Done(done 时 note=结语文字,as
|
|
115
|
+
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。',
|
|
116
116
|
{
|
|
117
117
|
action: z.enum(['add', 'start', 'done', 'note', 'state']),
|
|
118
118
|
id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
|
|
119
119
|
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
120
|
-
note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步); state
|
|
120
|
+
note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字'),
|
|
121
121
|
as: z.enum(['落地', '否决', '仅方案']).optional().describe('仅 done:结语类型。默认 落地。做了又撤/评估后不做用 否决,只设计过用 仅方案 —— 别让假【落地】污染看板'),
|
|
122
122
|
},
|
|
123
123
|
async ({ action, id, cwd, note, as }) => {
|
|
@@ -131,7 +131,7 @@ tool(
|
|
|
131
131
|
|
|
132
132
|
tool(
|
|
133
133
|
'abs_resolve',
|
|
134
|
-
'按页面 id(或页面名 slug)反查文件路径。页改名后 id 不变,引用请用返回的路径/id
|
|
134
|
+
'按页面 id(或页面名 slug)反查文件路径。页改名后 id 不变,引用请用返回的路径/id。必填: cwd, refs(数组,一次可查多个)。',
|
|
135
135
|
{
|
|
136
136
|
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
137
137
|
refs: z.array(z.string()).min(1).describe('页面 id 或页面名,如 "file-write-locking"'),
|
|
@@ -151,7 +151,7 @@ tool(
|
|
|
151
151
|
|
|
152
152
|
tool(
|
|
153
153
|
'abs_supersede',
|
|
154
|
-
'标记一条经验/知识页已失效(被推翻),不删文件、保留历史。之后 abs_query 默认不再返回它。核实后发现仍有效可手动把 status 改回 active
|
|
154
|
+
'标记一条经验/知识页已失效(被推翻),不删文件、保留历史。之后 abs_query 默认不再返回它。核实后发现仍有效可手动把 status 改回 active。必填: cwd, refs(数组)。',
|
|
155
155
|
{
|
|
156
156
|
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
157
157
|
refs: z.array(z.string()).min(1).describe('页名或 id,如 "old-approach"'),
|
|
@@ -181,7 +181,7 @@ tool(
|
|
|
181
181
|
|
|
182
182
|
tool(
|
|
183
183
|
'abs_query',
|
|
184
|
-
'检索当前项目 .brain/ 知识页(多词 OR):以前踩过什么坑、哪页记了 X
|
|
184
|
+
'检索当前项目 .brain/ 知识页(多词 OR):以前踩过什么坑、哪页记了 X。必填: cwd, terms(数组,不是 query;可传多个词)。',
|
|
185
185
|
{ cwd: z.string().describe('项目根目录(.brain/ 所在处)'), terms: z.array(z.string()).min(1).describe('检索词'), include_superseded: z.boolean().optional().describe('true=连已标记失效的经验一起返回(默认隐藏)') },
|
|
186
186
|
async ({ cwd, terms, include_superseded }) => {
|
|
187
187
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
@@ -233,7 +233,7 @@ tool(
|
|
|
233
233
|
|
|
234
234
|
tool(
|
|
235
235
|
'abs_note',
|
|
236
|
-
'经验实时暂存:把刚踩的坑/技巧/结论一句话落进 sources/(防 context 断了流失)。Teardown 时再提炼进 concepts
|
|
236
|
+
'经验实时暂存:把刚踩的坑/技巧/结论一句话落进 sources/(防 context 断了流失)。Teardown 时再提炼进 concepts/。必填: cwd, text。',
|
|
237
237
|
{
|
|
238
238
|
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
239
239
|
text: z.string().min(1).describe('经验/坑/技巧一句话'),
|
|
@@ -248,7 +248,7 @@ tool(
|
|
|
248
248
|
|
|
249
249
|
tool(
|
|
250
250
|
'abs_concept',
|
|
251
|
-
'建概念页骨架(给写入定结构:触发场景/表现/解法/验证)。只给结构不给内容 ——
|
|
251
|
+
'建概念页骨架(给写入定结构:触发场景/表现/解法/验证)。只给结构不给内容 —— 值不值得留、归哪页仍靠人判断。必填: cwd, slug。',
|
|
252
252
|
{
|
|
253
253
|
cwd: z.string().describe('项目根目录(.brain/ 所在处)'),
|
|
254
254
|
slug: z.string().min(1).describe('文件名/slug,如 "docker-prisma-429"(命名即链接)'),
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -99,6 +99,31 @@ let pendingTodoTool = false
|
|
|
99
99
|
* 只记第一次写文件,不是每次都记:断点是「记到哪」不是流水账。 */
|
|
100
100
|
let autoTaskSeen = false
|
|
101
101
|
|
|
102
|
+
/** 本会话动过的文件(去重,最多记 FILES_SEEN_MAX 个)。
|
|
103
|
+
*
|
|
104
|
+
* ★ 定位(用户 2026-10-05 定,别改):这**是「摆事实」,不是「发现问题」**。
|
|
105
|
+
* 它只把我自己刚干过的事(改过哪些文件)与看板并列摆出来,
|
|
106
|
+
* **不判断这些改动算不算任务、不提醒、不追踪、不逼登记**。
|
|
107
|
+
* 是否意识到「我干的和看板对不上」、要不要补记 —— 全部由 LLM 自己反思。
|
|
108
|
+
*
|
|
109
|
+
* 为何不挂在「改文件/一轮结束」两个时机:那两个钩子改不了 system prompt
|
|
110
|
+
* (tool_call/agent_end 拿不到 systemPromptOptions,只有 before_agent_start 有);
|
|
111
|
+
* 而 sendUserMessage 插话已被否决(打断用户)。故记在这里、下轮带出。
|
|
112
|
+
*
|
|
113
|
+
* 今日最大的教训:**不要用技术手段替 LLM 发现 todo 问题。**
|
|
114
|
+
* 一侧(摆事实)有效,另一侧(替它发现)十二次全败。详见 concept remind-vs-gate。 */
|
|
115
|
+
const filesSeen = new Set<string>()
|
|
116
|
+
const FILES_SEEN_MAX = 8
|
|
117
|
+
|
|
118
|
+
/** 写类工具调用的文件路径:edit/write 直接取 input.path,bash 取命令里像路径的片段。 */
|
|
119
|
+
function touchedPath(name: string, input: any): string | null {
|
|
120
|
+
const p = input?.path ?? input?.file_path ?? input?.filename
|
|
121
|
+
if (typeof p === 'string' && p.trim()) return p.trim()
|
|
122
|
+
// bash 的写入靠命令字符串抻出路径(重定向/ sed -i / tee 等)
|
|
123
|
+
const cmd = String(input?.command ?? '')
|
|
124
|
+
const m = cmd.match(/(?:>>?|tee|sed -i[^ ]*)\s+([^\s|;&<>]+)/)
|
|
125
|
+
return m ? m[1] : null
|
|
126
|
+
}
|
|
102
127
|
|
|
103
128
|
/** 最近一次渲染拿到的 tui 引用。hidePanel 用它请求重绘 —— 面板消失时动画
|
|
104
129
|
* 定时器已停,没有别的重绘通道(见 hidePanel 的坑注解)。 */
|
|
@@ -136,9 +161,23 @@ function resetThrottle(): void {
|
|
|
136
161
|
// (表现:嘴上说"先登记",实际没落盘)。故改成描述**意图 + 名字规律**,
|
|
137
162
|
// 让模型按当前会话实际可见的形态自己挑,不去猜死一个。
|
|
138
163
|
const TODO_GUIDELINES = [
|
|
139
|
-
|
|
164
|
+
// ① 锚点(2026-10-05 照 rpiv-todo 改写):原写 `on the first file edit of a task` ——
|
|
165
|
+
// 实测太晚:那一刻注意力全在"要改什么"上,没人会想起登记。用户实报两次
|
|
166
|
+
// ("侦察阶段全漏"、另一个 pi 会话干脆没登)。rpiv-todo 挂在
|
|
167
|
+
// `immediately after receiving new instructions`(收到指令时)—— 早得多,且
|
|
168
|
+
// 侦察/只读工作(读代码、问需求)也落在"收到指令"之后,能被盖住。
|
|
169
|
+
'Use the abs task tool as soon as you receive instructions that involve changing this project — including investigation work where you read code or ask questions before any edit. Call it with action "start" and a short id before the work itself, not after. Skip it for pure questions about general knowledge, one-line answers, and conversation. (Tool name varies by host: `mcp__abs` with tool="abs_task", or `mcp__abs__abs_task`, or `abs_task` — use whichever form this session exposes.)',
|
|
170
|
+
// ② 完成即结:不许攒
|
|
140
171
|
'Mark a task "done" immediately when it finishes — never batch completions at the end of a session.',
|
|
172
|
+
// ③ 断点:跨会话接力靠它
|
|
141
173
|
'Before starting a task, record the checkpoint with action "note" (which file, which step) so a later session can resume.',
|
|
174
|
+
// ④ 反例(照 rpiv-todo):明确什么情况【不许】标完成 —— 只给正向要求时,
|
|
175
|
+
// 模型倾向于把"我以为做完了"当成完成,结语失真会污染下个会话的判断。
|
|
176
|
+
'Never mark a task "done" when tests are failing, the implementation is partial, or an error is unresolved — keep it in progress and add a task for the blocker instead.',
|
|
177
|
+
// ⑤ 唯一进行中(同 rpiv-todo):看板要能回答"现在在做什么",多的应转走。
|
|
178
|
+
'Keep exactly one task in "进行中" at a time; when the user switches direction or drops a task, use action "state" with 搁置 (dropped/not doing it); 滞留中 for still-wanted-but-stuck; 讨论中 for still-open. If you finish one and another is ready, promote it explicitly.',
|
|
179
|
+
// ⑥ 结语要真实:三种结语各有含义,别一律写落地。
|
|
180
|
+
'When completing, pick the honest conclusion via the `as` field: 落地 (built and verified) / 否决 (decided against, or built then reverted) / 仅方案 (designed only). A wrong conclusion makes the next session treat "considered" as "completed".',
|
|
142
181
|
]
|
|
143
182
|
|
|
144
183
|
/** 当前项目是否有 .brain 图谱 —— 有才加指引(没图谱的项目里这指引是噪音)。
|
|
@@ -159,12 +198,83 @@ async function hasBrain(cwd: string): Promise<boolean> {
|
|
|
159
198
|
}
|
|
160
199
|
}
|
|
161
200
|
|
|
162
|
-
|
|
201
|
+
/** 看板快照在 guidelines 里的前缀 —— 用于每轮替换上轮快照(内容会变,不能用 includes 去重)。 */
|
|
202
|
+
const BOARD_MARK = '[看板] '
|
|
203
|
+
|
|
204
|
+
/** 把当前看板拼成 Guidelines 里的一段(对齐用)。
|
|
205
|
+
*
|
|
206
|
+
* 设计(用户 2026-10-05 定):**只对齐,不多说** —— 把「现在看板上有什么」摆在
|
|
207
|
+
* 面前让 LLM 自己对照,不写「记得登记」这类劝告(那些已证明会被忽略)。
|
|
208
|
+
*
|
|
209
|
+
* ★ 铁律(2026-10-05 用户定,别改):
|
|
210
|
+
* - **只用对齐,不用技术手段替 LLM 发现 todo 问题。**
|
|
211
|
+
* 今天最大的收获:技术只负责「摆事实」,不负责「发现问题、提醒、追踪、逼补记」。
|
|
212
|
+
* 让 LLM 自己看到、自己反思、自己决定 —— 这是唯一活下来的方向。
|
|
213
|
+
* - **反复对齐,不厌其烦**:不是一次性提示,是**每一轮都摆**。
|
|
214
|
+
* 用户原话:「对齐永远是反反复复的对齐,不厌其烦」。
|
|
215
|
+
* 别用节流/去重/「刚说过就不重复」去优化掉它 —— 反复就是它的工作方式。
|
|
216
|
+
* - **尺度:只说 todo.md 本身**。不夹带判断、不写劝告、不评价好坏。
|
|
217
|
+
* 只把「看板上现在有什么」摆出来。
|
|
218
|
+
* - **是否添加/完成/删除/转状态,全部由 LLM 自己判断** —— 不替它决定,
|
|
219
|
+
* 不自动登记、不自动完成、不写看板。
|
|
220
|
+
* - **不做重任务**:不起进程、不 spawn、不写盘;只读一个本地文件。
|
|
221
|
+
* 理由:前 12 次尝试都失败,根因是「让不掌握判据的一方启动动作」。
|
|
222
|
+
* 判据在 LLM 手里,这里只负责保证它**每轮都看得见**。
|
|
223
|
+
* 详见 concept remind-vs-gate(对齐必做 + 门禁兜底)。
|
|
224
|
+
*
|
|
225
|
+
* 与以前删掉的两种注入的本质区别:那不调 sendUserMessage(不插话、不抢 turn),
|
|
226
|
+
* 只改 system prompt 内容 —— 和静默的 TODO_GUIDELINES 走同一个通道。
|
|
227
|
+
*
|
|
228
|
+
* 看板空或全完成时不拼(没有可对齐的东西,拼个空列表纯占位)。 */
|
|
229
|
+
export function boardGuideline(md: string, who: string, touched: string[] = []): string | null {
|
|
230
|
+
const { total, rows } = parseOpenTasks(md, PANEL_MAX_ROWS, who)
|
|
231
|
+
// 看板空但动过文件:仍要输出(「改了文件却没任务」正是最该对齐的时刻)。
|
|
232
|
+
if ((!total || !rows.length) && !touched.length) return null
|
|
233
|
+
const lines = rows.map((r) => {
|
|
234
|
+
// desc 为空时不拖空破折号(MCP 登记只给 id 的常见情形)。
|
|
235
|
+
const head = ` ${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
|
|
236
|
+
// 断点必须带上:它就是「改到哪一步」,丢了等于对齐了也接不上。
|
|
237
|
+
return r.note ? `${head}\n ↳ ${r.note}` : head
|
|
238
|
+
})
|
|
239
|
+
const more = total > rows.length ? ` …另 ${total - rows.length} 条` : ''
|
|
240
|
+
// 本会话动过的文件 —— 与看板并列摆出,让 LLM 自己对照(不判断算不算任务)。
|
|
241
|
+
const touchLines = touched.length
|
|
242
|
+
? ['本会话改过:', ...touched.map((f) => ` ${f}`)]
|
|
243
|
+
: []
|
|
244
|
+
return [
|
|
245
|
+
'当前 .brain/todo.md 看板(开工前对齐:正在做的算哪条?有新任务要加吗?有该删的吗?):',
|
|
246
|
+
...(lines.length ? lines : [' (空)']),
|
|
247
|
+
...(more ? [more] : []),
|
|
248
|
+
...touchLines,
|
|
249
|
+
].join('\n')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 读看板拼成对齐段。失败/无图谱/看板空都返 null(静默降级,绝不阻断对话)。 */
|
|
253
|
+
async function readBoardForGuide(cwd: string): Promise<string | null> {
|
|
254
|
+
try {
|
|
255
|
+
const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
|
|
256
|
+
return boardGuideline(md, await currentUser(), [...filesSeen])
|
|
257
|
+
} catch {
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 注入 todo 指引。静态 6 条(行为要求) + 实时看板(对齐用)—— 两者职责不同:
|
|
263
|
+
* 静态条说「该怎么做」,看板说「现在是什么」。 */
|
|
264
|
+
export function injectTodoGuidelines(options: any, board?: string | null): boolean {
|
|
163
265
|
if (String(process.env.ABS_TODO_GUIDE || '') === '0') return false
|
|
164
266
|
const list: string[] = options.promptGuidelines || (options.promptGuidelines = [])
|
|
165
267
|
for (const g of TODO_GUIDELINES) {
|
|
166
268
|
if (!list.includes(g)) list.push(g)
|
|
167
269
|
}
|
|
270
|
+
// 看板内容每轮会变(任务增减/状态转换),不能用 includes 去重 —— 先删上轮的旧快照再推。
|
|
271
|
+
// ★ 必须先删:board 为 null(看板空了/全完成)时同样要删 ——
|
|
272
|
+
// 曾只在 board 非空时删,结果看板清空后旧快照残留在 prompt 里,
|
|
273
|
+
// LLM 会继续看到已经不存在的任务(对齐撒谎)。
|
|
274
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
275
|
+
if (list[i].startsWith(BOARD_MARK)) list.splice(i, 1)
|
|
276
|
+
}
|
|
277
|
+
if (board) list.push(BOARD_MARK + board)
|
|
168
278
|
return true
|
|
169
279
|
}
|
|
170
280
|
|
|
@@ -504,7 +614,7 @@ export function renderPanelLines(
|
|
|
504
614
|
nickname = '',
|
|
505
615
|
): string[] {
|
|
506
616
|
if (data.total === 0) return []
|
|
507
|
-
const colorOf = (state: string): string => (state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
|
|
617
|
+
const colorOf = (state: string): string => (state === '搁置' ? 'dim' : state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
|
|
508
618
|
const lines: string[] = []
|
|
509
619
|
// 上描边:用 `thinkingOff` —— 跟 Pi 输入框描边**完全同一个色**。
|
|
510
620
|
// 排查过程(2026-10-03 用户两轮反馈):
|
|
@@ -606,6 +716,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
606
716
|
pi.on("session_start", (event: any, ctx: any) => {
|
|
607
717
|
// 新会话 = 重新登记:不重置则同进程的第二个会话永远不再落断点
|
|
608
718
|
autoTaskSeen = false
|
|
719
|
+
filesSeen.clear() // 同理:新会话的文件痕迹不能带到下个会话
|
|
609
720
|
resetThrottle()
|
|
610
721
|
// 本会话的随机昵称 —— 在这里抽一次(不是每帧抽,否则面板会疯狂闪)。
|
|
611
722
|
// 每次 session_start(startup/reload/new/resume/fork)重抽 → “每次打开 pi 都是随机的”。
|
|
@@ -649,6 +760,12 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
649
760
|
const sid = sessionIdFor(ctx)
|
|
650
761
|
logHook(`tool_call first_write name=${name} sid=${sid || '-'}`).catch(() => {})
|
|
651
762
|
}
|
|
763
|
+
// 记下动过的文件(去重)—— 下一轮对齐时摆给 LLM(见 filesSeen 注释)。
|
|
764
|
+
// 每轮都记(不限首次):一轮里改多个文件都要能看到。
|
|
765
|
+
if (isWriteCall(name, input)) {
|
|
766
|
+
const p = touchedPath(name, input)
|
|
767
|
+
if (p && filesSeen.size < FILES_SEEN_MAX) filesSeen.add(p)
|
|
768
|
+
}
|
|
652
769
|
})
|
|
653
770
|
|
|
654
771
|
pi.on("tool_execution_end", (_event: any, ctx: any) => {
|
|
@@ -676,7 +793,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
676
793
|
logHook(`before_agent_start todo_guide=off reason=no_brain`).catch(() => {})
|
|
677
794
|
return
|
|
678
795
|
}
|
|
679
|
-
const ok = injectTodoGuidelines(event?.systemPromptOptions)
|
|
796
|
+
const ok = injectTodoGuidelines(event?.systemPromptOptions, await readBoardForGuide(cwd))
|
|
680
797
|
logHook(`before_agent_start todo_guide=${ok ? 'on' : 'off'} cwd=${cwd}`).catch(() => {})
|
|
681
798
|
|
|
682
799
|
// 自动登记已整体移除(2026-10-05 用户实报两点):
|
package/package.json
CHANGED
|
@@ -121,7 +121,7 @@ abs rule [add "一句话"] # 读写 ## Rules;abs lint 体检;abs t
|
|
|
121
121
|
结论:hook 只会"看到动过文件",不知道"这是件什么事";名字必须由读懂上下文的你起。
|
|
122
122
|
- 名字要像 `fix-skill-trigger` / `fix-opencode-v2-plugin` 那样说清**在干什么**,
|
|
123
123
|
不是抄用户的原话(实测抄出来的是 `我已经重启测试一下` 这种开场白)。
|
|
124
|
-
- 需要改已有任务的 id:`abs todo rename <旧id> --note "<新id>"
|
|
124
|
+
- 需要改已有任务的 id:`abs todo rename <旧id> --note "<新id>"`。
|
|
125
125
|
2. **一段活儿干完立刻 done** —— 不是等整个需求收尾。宁可拆成 5 条小的,别攒成 1 条大的。
|
|
126
126
|
3. **动手超过两三轮还没登记 = 已在失控路上** —— 立刻补 `start`。
|
|
127
127
|
|
package/src/lint.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
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
|
-
import { readRules, idOfPage, statusOfPage, supersededByOf, clip } from './store.js';
|
|
7
|
-
import { doneKindOf, doneDateOf, checkFileShape } from './todo.js';
|
|
6
|
+
import { readRules, idOfPage, statusOfPage, supersededByOf, clip, RULE_BODY_MAX } from './store.js';
|
|
7
|
+
import { doneKindOf, doneDateOf, checkFileShape, TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES } from './todo.js';
|
|
8
8
|
|
|
9
9
|
// ---------- 图谱遍历 ----------
|
|
10
10
|
|
|
@@ -105,9 +105,6 @@ export async function listPages(vault) {
|
|
|
105
105
|
// 为什么补这段(2026-10-05):lint 此前只看 PAGE_DIRS 子目录,三个根文件不在扫描范围,
|
|
106
106
|
// OVER-SIZE 也只对 concepts/entities/syntheses 生效。于是「todo.md 涨到上百行、
|
|
107
107
|
// 断点行里塞实施报告」可以 lint 报 0 问题 —— 看着健康,实际已经变成笔记本。
|
|
108
|
-
const TODO_MAX_LINES = 60;
|
|
109
|
-
const LOG_MAX_LINES = 2000;
|
|
110
|
-
const INDEX_MAX_LINES = 200;
|
|
111
108
|
const LINT_BREAKPOINT_MAX = 200;
|
|
112
109
|
|
|
113
110
|
async function checkRootFiles(vault) {
|
|
@@ -345,9 +342,11 @@ async function checkFiles(vault, pages, indexLinks) {
|
|
|
345
342
|
if (found && items.length > LINT_RULES_MAX) {
|
|
346
343
|
issues.push(`RULES-PILED-UP: Rules 区 ${items.length} 条 > ${LINT_RULES_MAX};把长条目提炼成概念页,这里只留一句话`);
|
|
347
344
|
}
|
|
348
|
-
|
|
345
|
+
// 单条长度:与 cmdRule 用同一个阈值(RULE_BODY_MAX)—— 写入侧和体检侧必须同标准,
|
|
346
|
+
// 否则「手工编辑绕过写入校验」的条目静默通过,而 lint 报 0 问题(假健康)。
|
|
347
|
+
const longOnes = items.filter((l) => l.trim().length > RULE_BODY_MAX);
|
|
349
348
|
if (longOnes.length) {
|
|
350
|
-
issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超
|
|
349
|
+
issues.push(`RULES-TOO-LONG: Rules 区 ${longOnes.length} 条超 ${RULE_BODY_MAX} 字(如 "${clip(longOnes[0].trim(), 40)}",${longOnes[0].trim().length} 字);展开写进概念页,这里只留一句话`);
|
|
351
350
|
}
|
|
352
351
|
}
|
|
353
352
|
return issues;
|
package/src/note.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// src/note.js — 经验实时暂存与知识页脚手架:note(一念一落)/ concept(骨架)/ person(人页)
|
|
2
|
+
// + registerInIndex(把新页登记进 index.md 清单区,三块都用到)。
|
|
3
|
+
//
|
|
4
|
+
// 从 store.js 拆出(2026-10-05):原来 18 个职责挤在一个 1500+ 行文件里。
|
|
5
|
+
// 依赖方向:note.js ← store.js(store 先 import 再 re-export,保持既有调用面)。
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { requireBrain, brainPath } from './index.js';
|
|
9
|
+
import { requireUser, atTag } from './userconfig.js';
|
|
10
|
+
import { today, localStamp, LOG_KINDS } from './todo.js';
|
|
11
|
+
import { editFile, SKIP } from './lock.js';
|
|
12
|
+
import { collapseIndex } from './page.js';
|
|
13
|
+
import { impactOf } from './codegraph.js';
|
|
14
|
+
import { clip, slugOf } from './text.js';
|
|
15
|
+
|
|
16
|
+
// ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
|
|
17
|
+
const NOTE_DEDUP_MS = 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export async function cmdNote({ dir, text, tags, when, impact, type }) {
|
|
20
|
+
const clean = String(text || '').trim();
|
|
21
|
+
if (!clean) return '用法: abs note "经验/坑/技巧一句话" [--when "何时该读它"](落 sources/ 暂存页,实时不流失)';
|
|
22
|
+
let root;
|
|
23
|
+
try {
|
|
24
|
+
root = await requireBrain(dir || process.cwd());
|
|
25
|
+
} catch {
|
|
26
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
27
|
+
}
|
|
28
|
+
const who = await requireUser(); // 写操作守卫
|
|
29
|
+
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
30
|
+
const srcDir = brainPath(root, 'sources');
|
|
31
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
32
|
+
// 幂等: 同文本 60s 内只落一份
|
|
33
|
+
const existing = (await fs.readdir(srcDir).catch(() => [])).filter((f) => f.endsWith('.md'));
|
|
34
|
+
for (const f of existing) {
|
|
35
|
+
const body = await fs.readFile(join(srcDir, f), 'utf8').catch(() => '');
|
|
36
|
+
if (body.includes(clean)) {
|
|
37
|
+
return `• 60s 内已落同文本 → ${f} (跳过重复)`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// 影响面(可选):显式传 --impact <符号> 时,借本机 CodeGraph 拿「改它波及谁」。
|
|
41
|
+
// 失败/未装 codegraph 静默降级为无,绝不阻断 note 落盘。
|
|
42
|
+
const impactText = impact ? await impactOf(impact, root) : null;
|
|
43
|
+
// 类型(可选):借鉴 TencentDB 的 L1 四分类,把自由文本经验分成可分类的资产。
|
|
44
|
+
// 默认不强制(自由文本仍是主体);显式 --type 时才写进 frontmatter,供检索/load 区分。
|
|
45
|
+
// 合法值对齐 L1 四类:fact 事实 / pref 偏好 / constraint 约束 / event 事件。
|
|
46
|
+
const NOTE_TYPES = ['fact', 'pref', 'constraint', 'event'];
|
|
47
|
+
const noteType = NOTE_TYPES.includes(String(type || '').trim().toLowerCase())
|
|
48
|
+
? String(type).trim().toLowerCase() : '';
|
|
49
|
+
const tagList = String(tags || '').split(',').map((t) => t.trim()).filter(Boolean);
|
|
50
|
+
const fmTags = ['source', ...tagList].join(', ');
|
|
51
|
+
const slugSrc = slugOf(clean);
|
|
52
|
+
const file = `${today()}-${slugSrc || 'note'}.md`;
|
|
53
|
+
const heading = clip(clean, 80); // 页面标题: 完整优先, 超长才收口
|
|
54
|
+
// 触发条件(2026-09-16):经验"写入多读得少"的根因之一是存的是结论、不是"何时该看"。
|
|
55
|
+
// 带上 --when 后,load 的相关页推荐能按当前在做的事匹配,而不是按主题词。
|
|
56
|
+
const whenText = String(when || '').trim();
|
|
57
|
+
const body = [
|
|
58
|
+
'---',
|
|
59
|
+
`tags: [${fmTags}]`,
|
|
60
|
+
`id: ${file.replace(/\.md$/, '')}`,
|
|
61
|
+
`author: ${who}`,
|
|
62
|
+
`updated: ${today()}`,
|
|
63
|
+
'status: draft',
|
|
64
|
+
...(noteType ? [`type: ${noteType}`] : []),
|
|
65
|
+
'---',
|
|
66
|
+
'',
|
|
67
|
+
`# 来源:${heading}`,
|
|
68
|
+
'',
|
|
69
|
+
`TITLE: ${clean}`,
|
|
70
|
+
...(whenText ? ['', `WHEN: ${whenText}`] : []),
|
|
71
|
+
...(impactText ? ['', '## 影响面(本机 CodeGraph 自动带出)', '```', impactText, '```'] : []),
|
|
72
|
+
'',
|
|
73
|
+
`## 记录(实时暂存,Teardown 时提炼进 concepts/ 后本页可删)`,
|
|
74
|
+
`- ${clean}`,
|
|
75
|
+
...(whenText ? [`- 何时读:${whenText}`] : []),
|
|
76
|
+
'',
|
|
77
|
+
'## 关联连接',
|
|
78
|
+
`- ${atTag(who)} — 本页沉淀者`,
|
|
79
|
+
'(提炼成 concepts 规律页后,在此挂双链到该页)',
|
|
80
|
+
'',
|
|
81
|
+
].join('\n');
|
|
82
|
+
// 源文件是新写唯一文件:tmp+rename 原子落盘(避免并发读读到半写文件)
|
|
83
|
+
const srcFile = join(srcDir, file);
|
|
84
|
+
const tmp = join(srcDir, `.${file}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`);
|
|
85
|
+
await fs.writeFile(tmp, body, 'utf8');
|
|
86
|
+
await fs.rename(tmp, srcFile);
|
|
87
|
+
// index Sources 区登记(锁内幂等:别页已登记则跳过,防并发重复) + log 一行
|
|
88
|
+
const slug = file.replace(/\.md$/, '');
|
|
89
|
+
await registerInIndex(root, 'Sources', slug, heading);
|
|
90
|
+
await cmdLog({ dir: root, title: clean, kind: 'note' });
|
|
91
|
+
return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------- concept: 概念页脚手架(给「写入」定结构,不替人做判断) ----------
|
|
95
|
+
/** 建一张带骨架的概念页。
|
|
96
|
+
*
|
|
97
|
+
* 为何需要它(实测 2026-09-15): concepts/ 页原来**没有任何代码写入路径** ——
|
|
98
|
+
* 全靠人/AI 手写 markdown,结果 26 页里 2 页完全没有「做完怎么确认」。
|
|
99
|
+
* 而骨架只写在 skill 的**文字里**("触发场景/表现/解法/验证命令"),没有执行点 → 看运气。
|
|
100
|
+
* 对照: `abs note` 落的 source 页结构整齐,因为模板在**代码里**。
|
|
101
|
+
*
|
|
102
|
+
* 边界(关键): 它只给**结构**,不给**内容**。
|
|
103
|
+
* 「这条值不值得留 / 归哪一页」仍靠人判断 —— 那是 skill 明写的分工(深提炼不自动化)。
|
|
104
|
+
* 所以本命令不猜语义、不自动提炼,只在你要新建页时把该有的位置摆好。
|
|
105
|
+
*
|
|
106
|
+
* 尾巴用「占位符」而非真实值: 这样 lint 的 NO-TAIL 判据在占位未填时仍会报
|
|
107
|
+
* (骨架≠完成)。填完删掉占位行即可。 */
|
|
108
|
+
export async function cmdConcept({ dir, slug, title, tags, desc }) {
|
|
109
|
+
let root;
|
|
110
|
+
try {
|
|
111
|
+
root = await requireBrain(dir || process.cwd());
|
|
112
|
+
} catch {
|
|
113
|
+
return `未找到 .brain/ 图谱。先在项目根运行: abs init`;
|
|
114
|
+
}
|
|
115
|
+
const raw = String(slug || '').trim();
|
|
116
|
+
if (!raw) {
|
|
117
|
+
return [
|
|
118
|
+
'用法: abs concept <slug> --title "一句话标题" [--tags a,b] [--desc "index 里的一句话"]',
|
|
119
|
+
' 例: abs concept docker-prisma-429 --title "Docker 内存超限导致 Prisma 429"',
|
|
120
|
+
' 说明: 只给骨架(头/中/尾位置),内容仍由你写 —— 判断不自动化。',
|
|
121
|
+
].join('\n');
|
|
122
|
+
}
|
|
123
|
+
// slug 即文件名(命名即链接)。收口掉路径分隔符与空白,防逃出 concepts/。
|
|
124
|
+
const name = raw.replace(/[\s/\\]+/g, '-').replace(/[^\w\u4e00-\u9fff.-]/g, '').replace(/^-+|-+$/g, '');
|
|
125
|
+
if (!name) return `✗ slug 无效(清洗后为空): ${raw}`;
|
|
126
|
+
const who = await requireUser();
|
|
127
|
+
await ensurePersonPage(root, who);
|
|
128
|
+
const dirP = brainPath(root, 'concepts');
|
|
129
|
+
await fs.mkdir(dirP, { recursive: true });
|
|
130
|
+
const file = join(dirP, `${name}.md`);
|
|
131
|
+
const head = String(title || '').trim() || name;
|
|
132
|
+
const tagList = ['concept', ...String(tags || '').split(',').map((t) => t.trim()).filter(Boolean)];
|
|
133
|
+
const body = [
|
|
134
|
+
'---',
|
|
135
|
+
`tags: [${tagList.join(', ')}]`,
|
|
136
|
+
`id: ${name}`,
|
|
137
|
+
`author: ${who}`,
|
|
138
|
+
`updated: ${today()}`,
|
|
139
|
+
'status: draft',
|
|
140
|
+
'---',
|
|
141
|
+
'',
|
|
142
|
+
`# 概念:${head}`,
|
|
143
|
+
'',
|
|
144
|
+
'## 触发场景',
|
|
145
|
+
'<!-- 什么情况下该想起这条?(写可检索的词,别只写“遇到问题”) -->',
|
|
146
|
+
'',
|
|
147
|
+
'## ❌ 表现',
|
|
148
|
+
'<!-- 具体症状 / 贴报错 / 复现条件 -->',
|
|
149
|
+
'',
|
|
150
|
+
'## 🛠 解法',
|
|
151
|
+
'<!-- 根因 + 修复 -->',
|
|
152
|
+
'',
|
|
153
|
+
'## 验证',
|
|
154
|
+
'<!-- 做完怎么确认?跑什么命令 / 看什么信号 / 用什么判据。必须填 —— 没尾巴的经验只能被“相信”,不能被“验证” -->',
|
|
155
|
+
'',
|
|
156
|
+
'## 关联连接',
|
|
157
|
+
`- ${atTag(who)} — 本页沉淀者`,
|
|
158
|
+
'(在这挂相关页双链,别留孤岛)',
|
|
159
|
+
'',
|
|
160
|
+
].join('\n');
|
|
161
|
+
// 独占写(wx):已存在则 EEXIST —— 与 ensurePersonPage 同路数。
|
|
162
|
+
// 不用「先查后写」:那有 TOCTOU 竞态,且已有人工内容一律不覆盖是本仓硬规则。
|
|
163
|
+
// 也不走 tmp+rename:rename 会默默覆盖已存在文件,而这里必须「存在就拒绝」。
|
|
164
|
+
try {
|
|
165
|
+
await fs.writeFile(file, body, { encoding: 'utf8', flag: 'wx' });
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (e.code === 'EEXIST') {
|
|
168
|
+
return `• 已存在,不覆盖 → .brain/concepts/${name}.md\n 要改请直接编辑(或先删页);新建请换个 slug。`;
|
|
169
|
+
}
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
const oneLine = String(desc || '').trim() || clip(head, 60);
|
|
173
|
+
await registerInIndex(root, 'Concepts', name, oneLine);
|
|
174
|
+
// 不写 log.md(2026-10-05 用户定):`新建概念页 x` 是**命令的副作用**不是成果 ——
|
|
175
|
+
// 38 字符、零信息量,且「该页存在」已由 registerInIndex 落在 index.md 的 Concepts 区
|
|
176
|
+
// (那是 index 的职责)。同件事落两处,且建 10 个页 = 10 行流水噪声自动重现,
|
|
177
|
+
// 靠事后清理治不了。故删掉这次调用,不加开关(没人需要读「某页被创建了」)。
|
|
178
|
+
return `✓ 概念页骨架 → .brain/concepts/${name}.md ${atTag(who)}\n` +
|
|
179
|
+
' 已给好四段位置;填完内容后:删掉 <!-- --> 占位、按需改 status: active、挂双链。\n' +
|
|
180
|
+
' 尾部「## 验证」必须填(留空会被 abs lint 报 NO-TAIL)。';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------- person: 使用者实体页(首次需要时创建,已存在则不动) ----------
|
|
184
|
+
/** 确保 entities/<name>.md 存在。已存在一律不动(里面的技术栈/特点是人工沉淀的)。
|
|
185
|
+
* 用 `wx` 独占写:并发下后到者拿到 EEXIST 就静默跳过,不覆盖。
|
|
186
|
+
* 失败不抛:建页是附带动作,不能因为它让 todo/log 写不进去。
|
|
187
|
+
* 返回 'created' | 'exists' | 'skip'。 */
|
|
188
|
+
export async function ensurePersonPage(root, name) {
|
|
189
|
+
const nm = String(name || '').trim();
|
|
190
|
+
if (!nm || !/^[\w\u4e00-\u9fff.-]+$/.test(nm)) return 'skip';
|
|
191
|
+
const dir = brainPath(root, 'entities');
|
|
192
|
+
const file = join(dir, `${nm}.md`);
|
|
193
|
+
const body = [
|
|
194
|
+
'---',
|
|
195
|
+
'tags: [entity, person]',
|
|
196
|
+
`id: ${nm}`,
|
|
197
|
+
`author: ${nm}`,
|
|
198
|
+
`updated: ${today()}`,
|
|
199
|
+
'status: draft',
|
|
200
|
+
'---',
|
|
201
|
+
'',
|
|
202
|
+
`# ${nm}`,
|
|
203
|
+
'',
|
|
204
|
+
'## 技术栈',
|
|
205
|
+
'<!-- 沉淀时填: 主力语言/框架/工具链。例: TypeScript + Node, 熟悉 MCP 协议与 CLI 工具链 -->',
|
|
206
|
+
'',
|
|
207
|
+
'## 特点 / 工作习惯',
|
|
208
|
+
'<!-- 沉淀时填: 决策偏好、沟通习惯、反复出现的判断倾向。例: 先要方案后动手; 质疑"这需求是否需要存在" -->',
|
|
209
|
+
'',
|
|
210
|
+
'## 名下踩过的坑',
|
|
211
|
+
'(本页被 [[todo]] / [[log]] 里的作者标记引用;沉淀经验时在此挂双链)',
|
|
212
|
+
'',
|
|
213
|
+
].join('\n');
|
|
214
|
+
try {
|
|
215
|
+
await fs.mkdir(dir, { recursive: true });
|
|
216
|
+
await fs.writeFile(file, body, { encoding: 'utf8', flag: 'wx' });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (e.code === 'EEXIST') return 'exists';
|
|
219
|
+
return 'skip';
|
|
220
|
+
}
|
|
221
|
+
// 只有真建成才登记 index(否则 index 指向不存在的页 → INDEX-DEAD-LINK)。
|
|
222
|
+
// 放这里而非各调用点:todo/log/note 三条写路径都要登记,抄三遍必漂。
|
|
223
|
+
await registerInIndex(root, 'Entities', nm, `${nm} — 使用者;技术栈 / 特点 / 名下踩过的坑`);
|
|
224
|
+
return 'created';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 把新页登记进 index.md 的指定分区(幂等)。供人页/其它程序建页用。 */
|
|
228
|
+
export async function registerInIndex(root, section, slug, desc) {
|
|
229
|
+
const iP = brainPath(root, 'index.md');
|
|
230
|
+
await editFile(iP, (index) => {
|
|
231
|
+
if (!index || index.includes(`[[${slug}]]`)) return SKIP;
|
|
232
|
+
const sIdx = index.indexOf(`## ${section}`);
|
|
233
|
+
if (sIdx === -1) return SKIP;
|
|
234
|
+
const after = index.indexOf('\n## ', sIdx + 1);
|
|
235
|
+
const line = `- [[${slug}]] — ${desc}`;
|
|
236
|
+
const next = after === -1
|
|
237
|
+
? `${index.replace(/\s*$/, '')}\n${line}\n`
|
|
238
|
+
: index.slice(0, after) + `\n${line}` + index.slice(after);
|
|
239
|
+
// 归一空行:历史手工编辑会留 3+ 空行(load 时 collapseIndex 会压掉,但文件本身没清)。
|
|
240
|
+
// 追加新条目的同时顺手压一次,既清旧债又不改内容(与 collapseIndex 同一判据)。
|
|
241
|
+
return { text: next.replace(/\n{3,}/g, '\n\n') };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// ---------- log: 追加工作成果沉淀摘要(用户/AI 主动 abs log "..." 记, 不收工具动作流水) ----------
|
|
245
|
+
export async function cmdLog({ dir, title, kind = 'dev' }) {
|
|
246
|
+
const root = await requireBrain(dir || process.cwd());
|
|
247
|
+
const who = await requireUser(); // 写操作守卫
|
|
248
|
+
// ★ kind 必须是枚举值(2026-10-05 加):此前无校验,传什么写什么 ——
|
|
249
|
+
// 实测有测试传 kind:'test' 写进去,而形状闸门上线后才暴露。
|
|
250
|
+
// 枚举内校在**入口**(这里)比事后 lint 更早,且报错能直接告诉可用值。
|
|
251
|
+
if (!LOG_KINDS.includes(String(kind))) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`✗ log 的 kind 只能是 ${LOG_KINDS.join(' / ')}(收到 "${kind}")\n` +
|
|
254
|
+
` note=经验/踩坑 / dev=完成的工作 / concept=新建概念页 / ingest=沉淀资料`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
258
|
+
const p = brainPath(root, 'log.md');
|
|
259
|
+
const stamp = localStamp();
|
|
260
|
+
// 不硬切: log.md 是人类读的成果摘要, 也是 abs load 的开机入口。600 码点够一条完整小结,
|
|
261
|
+
// 超出才在语义边界收口(曾 slice(0,100) → 34/85 条断在词中间)
|
|
262
|
+
const clean = clip(String(title || '').replace(/\n/g, ' '), 600);
|
|
263
|
+
// 作者前置于 kind:`## [时间] @name dev | 内容`。
|
|
264
|
+
// 一眼先看到谁做的(与 todo 行 `ID @name — 说明` 排版对齐)。
|
|
265
|
+
const line = `## [${stamp}] ${atTag(who)} ${kind} | ${clean}`;
|
|
266
|
+
await editFile(p, (cur) => {
|
|
267
|
+
const text = cur ?? '# 🗒 Activity Log\n';
|
|
268
|
+
// 倒序:新行插在标题后(若已是模板占位行则替换它)
|
|
269
|
+
const lines = text.split('\n');
|
|
270
|
+
const headerIdx = lines.findIndex((l) => l.startsWith('#'));
|
|
271
|
+
lines.splice(headerIdx + 1, 0, line);
|
|
272
|
+
return { text: lines.join('\n') };
|
|
273
|
+
});
|
|
274
|
+
return `✓ log → ${p}\n ${line}`;
|
|
275
|
+
}
|