@fanchao8609/agent_brain_sync 1.16.4 → 1.17.1
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 +2 -1
- package/bin/abs.js +17 -4
- package/bin/mcp.js +10 -5
- package/hooks/abs.pi.ts +94 -13
- package/package.json +6 -1
- package/skill/abs-agent-brain-sync/SKILL.md +27 -2
- package/src/install.js +5 -9
- package/src/lint.js +14 -1
- package/src/lock.js +1 -1
- package/src/note.js +2 -4
- package/src/page.js +1 -1
- package/src/store.js +65 -34
- package/src/todo/archive.js +320 -0
- package/src/todo/common.js +210 -0
- package/src/todo/spec.js +218 -0
- package/src/todo/structure.js +324 -0
- package/src/todo/tasks.js +238 -0
- package/src/todo/validate.js +216 -0
- package/src/todo.js +10 -1319
- package/src/wrapup.js +1 -1
package/README.md
CHANGED
|
@@ -105,7 +105,8 @@ abs load # 开机读状态(Rules + 图谱计数 + 看板
|
|
|
105
105
|
abs todo # 看板 Todo / Done(行首带状态标记)
|
|
106
106
|
|
|
107
107
|
abs todo add TASK-1 --note "要做什么"
|
|
108
|
-
abs todo note TASK-1 --note "改到 X 文件 L40" #
|
|
108
|
+
abs todo note TASK-1 --note "改到 X 文件 L40" # 实时断点(一句话,上看板)
|
|
109
|
+
abs todo memo TASK-1 "随手记一段(可多句,不上看板)" # 任务笔记 → sessions/;无参=查看
|
|
109
110
|
abs todo state TASK-1 --note "进行中|讨论中|滞留中" # 改行首状态标记
|
|
110
111
|
abs todo done TASK-1
|
|
111
112
|
|
package/bin/abs.js
CHANGED
|
@@ -96,6 +96,9 @@ const FLAG_SPEC = {
|
|
|
96
96
|
'section': { type: 'string' },
|
|
97
97
|
'note': { type: 'string' },
|
|
98
98
|
'as': { type: 'string' },
|
|
99
|
+
// todo start 用:本会话 id(LLM 自己给,hook 会把当前 sid 摆进 system prompt)。
|
|
100
|
+
// ★ 必须在这声明 —— 未声明的选项会被静默丢掉(本文件自己的注释写过这个坑)。
|
|
101
|
+
'session': { type: 'string' },
|
|
99
102
|
'payload': { type: 'string' },
|
|
100
103
|
'tags': { type: 'string' },
|
|
101
104
|
'port': { type: 'string' },
|
|
@@ -138,7 +141,7 @@ function parseArgv(args) {
|
|
|
138
141
|
// 也不能简单地把所有非 `--` token 剔走 —— 那样 `--dir /x` 的值 `/x` 会被误剔。
|
|
139
142
|
// 解法: 先用 tokens 看清每个 token 的 kind,只把「`--` 开头且 name 在 FLAG_SPEC 里」当真选项,
|
|
140
143
|
// 其余(包括 `-x` 与 `--unknown`)一律按原序交回位置参数,复刻旧手写逻辑。
|
|
141
|
-
const {
|
|
144
|
+
const { tokens } = parseArgs({
|
|
142
145
|
args,
|
|
143
146
|
options: FLAG_SPEC,
|
|
144
147
|
allowPositionals: true,
|
|
@@ -307,6 +310,7 @@ const subUsage = {
|
|
|
307
310
|
' abs todo add <id> [--note "做什么"] [--section 讨论中|滞留中|搁置]',
|
|
308
311
|
' abs todo rename <旧id> --note "<新id>" 改任务 id(起错名/不可读时)',
|
|
309
312
|
' abs todo note <id> --note "断点/进度"',
|
|
313
|
+
' abs todo memo <id> "随手记(可多句)" 任务笔记 → sessions/,不上看板;无文字=查看',
|
|
310
314
|
' abs todo state <id> --note 进行中|讨论中|滞留中|搁置',
|
|
311
315
|
' abs todo done <id> [--as 落地|否决|仅方案] [结语文字]',
|
|
312
316
|
' abs todo archive [--keep-days N] [--dry-run]',
|
|
@@ -370,6 +374,7 @@ const TODO_ACTIONS = {
|
|
|
370
374
|
add: 'start',
|
|
371
375
|
start: 'start', // add 的别名(老习惯保留)
|
|
372
376
|
note: 'note',
|
|
377
|
+
memo: 'memo', // 任务随手记:多句/追加/不占看板(无文字时=查看)
|
|
373
378
|
state: 'state', // 改行首状态标记:进行中|讨论中|滞留中|搁置(原地,不搬区)
|
|
374
379
|
rename: 'rename', // 改任务 id:人工起错名、或旧 id 不可读时用
|
|
375
380
|
done: 'done',
|
|
@@ -464,7 +469,7 @@ async function main() {
|
|
|
464
469
|
if (!action) {
|
|
465
470
|
throw new Error(
|
|
466
471
|
`✗ 未知子命令 "${sub}"\n` +
|
|
467
|
-
` 可用: add / start / note / state / done / archive\n` +
|
|
472
|
+
` 可用: add / start / note / memo / state / done / archive\n` +
|
|
468
473
|
` 看板: abs todo(不带参数)\n` +
|
|
469
474
|
` 登记任务: abs todo add <id> --note "做什么"`,
|
|
470
475
|
);
|
|
@@ -472,16 +477,24 @@ async function main() {
|
|
|
472
477
|
// done 的结语:--note "结语" 或位置参数(`abs todo done X 验证: 全绿`)都收。
|
|
473
478
|
// 坑(2026-09-16 实测): --help 里写了 [结语文字] 但实现只读 --note,
|
|
474
479
|
// 位置参数被 rejectExtra 当多余参数拒掉 —— 文档与实现不一致。
|
|
480
|
+
// memo 同:随手记常是多句带空格,位置参数收全段比 --note 顺手。
|
|
475
481
|
const isDone = action === 'done';
|
|
482
|
+
const isMemo = action === 'memo';
|
|
476
483
|
const usage = `abs todo ${sub} <id>${isDone ? ' [--as 落地|否决|仅方案] [--note "结语"]' : ' --note "…"'}`;
|
|
477
484
|
if (isDone) {
|
|
478
485
|
const note = opts.note || (rest2.length ? rest2.join(' ') : undefined);
|
|
479
|
-
console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note, as: opts.as }));
|
|
486
|
+
console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note, as: opts.as, session: opts.session }));
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
if (isMemo) {
|
|
490
|
+
// 无文字 = 查看该任务所有随手记
|
|
491
|
+
const note = opts.note || (rest2.length ? rest2.join(' ') : undefined);
|
|
492
|
+
console.log(await cmdTask({ dir: opts.dir, action, id, note, session: opts.session }));
|
|
480
493
|
break;
|
|
481
494
|
}
|
|
482
495
|
rejectExtra(rest2, usage);
|
|
483
496
|
if (!id) throw new Error(`✗ 缺 <id>\n 用法: ${usage}`);
|
|
484
|
-
console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as }));
|
|
497
|
+
console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as, session: opts.session }));
|
|
485
498
|
break;
|
|
486
499
|
}
|
|
487
500
|
case 'note': {
|
package/bin/mcp.js
CHANGED
|
@@ -112,20 +112,25 @@ tool(
|
|
|
112
112
|
|
|
113
113
|
tool(
|
|
114
114
|
'abs_task',
|
|
115
|
-
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id
|
|
115
|
+
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补断点(改到哪文件哪行) / memo 任务随手记(多句/追加/不上看板)/ state 改状态 / done 完成归位 Done(done 时 note=结语文字,as=结语类型:落地|否决|仅方案)。必填: cwd, action, id。多会话并行时,start/add 带上 session(值见 system prompt 的 [会话] 段)以标记「这条任务谁在做」。',
|
|
116
116
|
{
|
|
117
|
-
action: z.enum(['add', 'start', 'done', 'note', 'state']),
|
|
117
|
+
action: z.enum(['add', 'start', 'done', 'note', 'memo', '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=断点(
|
|
120
|
+
note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步,一句话); memo=随手记(可多句、不定长,追加式;不传=查看已有笔记); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字'),
|
|
121
121
|
as: z.enum(['落地', '否决', '仅方案']).optional().describe('仅 done:结语类型。默认 落地。做了又撤/评估后不做用 否决,只设计过用 仅方案 —— 别让假【落地】污染看板'),
|
|
122
|
+
// 会话 id:多会话并行时标记「这条任务是谁在做」。
|
|
123
|
+
// 值取自 system prompt 里的 `[会话] 本会话 id = xxx`(hook 注入),由模型原样回传。
|
|
124
|
+
// ★ 为何走这个途径而不让 MCP 自己探测:MCP 进程拿不到宿主的 sessionId
|
|
125
|
+
// (协议没有,PI_* 环境变量也没有);模型本来就知道自己的 sid,直接收下最省。
|
|
126
|
+
session: z.string().optional().describe('仅 start/add:本会话 id(取自 system prompt 的 [会话] 段)。多会话并行时用来标记「这条任务谁在做」'),
|
|
122
127
|
},
|
|
123
|
-
async ({ action, id, cwd, note, as }) => {
|
|
128
|
+
async ({ action, id, cwd, note, as, session }) => {
|
|
124
129
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
125
130
|
if (!root) return errNoBrain(cwd || process.cwd());
|
|
126
131
|
// add 是 start 的别名(与 CLI `abs todo add` 对齐)
|
|
127
132
|
const act = action === 'add' ? 'start' : action;
|
|
128
|
-
return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act, id, note, as }) }] };
|
|
133
|
+
return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act, id, note, as, session }) }] };
|
|
129
134
|
}
|
|
130
135
|
);
|
|
131
136
|
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* 别再加回来(指 sendUserMessage 注入;静态 system prompt 内容不在此列,见下方 todo 指引)。
|
|
20
20
|
*/
|
|
21
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
22
|
-
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
22
|
+
import { appendFile, mkdir, readFile, rename, stat } from "node:fs/promises"
|
|
23
23
|
import { homedir } from "node:os"
|
|
24
24
|
import { join } from "node:path"
|
|
25
25
|
import { spawn } from "node:child_process"
|
|
@@ -28,14 +28,58 @@ import { spawn } from "node:child_process"
|
|
|
28
28
|
// 但允许 ABS_BIN_PATH 覆盖 —— 测试需隔离到沙盒副本,开发机也可能同时有多份。
|
|
29
29
|
const ABS_BIN = process.env.ABS_BIN_PATH || "@@ABS_BIN@@"
|
|
30
30
|
|
|
31
|
+
/** 日志写入串行队列。
|
|
32
|
+
*
|
|
33
|
+
* 为何需要(2026-10-07 实测):logHook 是 fire-and-forget 且会并发调用
|
|
34
|
+
* (一次 session_start 就调 2-3 次:nickname / panel drawn / panel skipped)。
|
|
35
|
+
* 而「检查大小 → 重命名 → 写入」跨 await,两个并发调用会交错:
|
|
36
|
+
* A: stat(1MiB) 超限 → rename(旧 → .1)
|
|
37
|
+
* B: stat 看到的是 A 刚建的小文件 → 不轮转
|
|
38
|
+
* 反过来则是 B 把 A 刚写好的新文件又移成 .1,**盖掉真正的旧内容**。
|
|
39
|
+
* 实测症状:.1 里是新日志(百来字节),而 1MiB 的旧内容消失。
|
|
40
|
+
* 同族坑见 .brain/concepts/guard-check-then-set-across-await.md。
|
|
41
|
+
*
|
|
42
|
+
* 修法:把整段「轮转 + 写入」排进一个 promise 链,同一时刻只跑一个。
|
|
43
|
+
* 日志量小、写入快,串行的代价可忽略;换掉的是「旧日志被静默吃掉」。 */
|
|
44
|
+
let logChain: Promise<void> = Promise.resolve()
|
|
45
|
+
|
|
31
46
|
async function logHook(evt: string): Promise<void> {
|
|
32
47
|
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
33
48
|
const d = new Date()
|
|
34
49
|
const pad = (n: number) => String(n).padStart(2, "0")
|
|
35
50
|
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
36
51
|
const line = "[" + stamp + "] pi:" + evt + "\n"
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
// 排队:前一步出错也不断链(catch 掉再继续),否则后续日志全丢。
|
|
53
|
+
logChain = logChain
|
|
54
|
+
.catch(() => {})
|
|
55
|
+
.then(async () => {
|
|
56
|
+
await mkdir(dir, { recursive: true })
|
|
57
|
+
const file = join(dir, "hooks.log")
|
|
58
|
+
await rotateIfTooBig(file)
|
|
59
|
+
await appendFile(file, line)
|
|
60
|
+
})
|
|
61
|
+
return logChain
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 日志轮转:hooks.log 是 append-only 热路径,无上限会无限长。
|
|
65
|
+
*
|
|
66
|
+
* 为何需要(2026-10-07 实测):本扩展原先只 append、**没有轮转**,
|
|
67
|
+
* 而轮转只写在 hooks/event.sh 里(那是 Claude Code / Codex 走的路)。
|
|
68
|
+
* 后果:pi 这条路上 hooks.log 无上限增长 —— 实测已 1.22MB,
|
|
69
|
+
* 而同一套阈值(1 MiB)在 event.sh 那边是生效的。
|
|
70
|
+
* 同族坑:靠一个副本里的逻辑保护另一条路径,等于没保护。
|
|
71
|
+
*
|
|
72
|
+
* 阈值与策略对齐 event.sh:ABS_LOG_MAX_BYTES 可覆盖;只留一份 .1。
|
|
73
|
+
* 失败静默:轮转出任何错都不能弄坏日志写入本身(与 event.sh 同纪律)。 */
|
|
74
|
+
async function rotateIfTooBig(file: string): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
const max = Number(process.env.ABS_LOG_MAX_BYTES || 1048576) // 1 MiB
|
|
77
|
+
const st = await stat(file)
|
|
78
|
+
if (!(max > 0) || st.size <= max) return
|
|
79
|
+
await rename(file, file + ".1")
|
|
80
|
+
} catch {
|
|
81
|
+
// 首次运行(文件不存在)/ 权限不足 / 重命名失败:都不拦日志写入
|
|
82
|
+
}
|
|
39
83
|
}
|
|
40
84
|
|
|
41
85
|
// 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
|
|
@@ -278,7 +322,7 @@ const BOARD_MARK = '[看板] '
|
|
|
278
322
|
* 仍然静默的情形:无图谱/读失败(readBoardForGuide 捕不到 todo.md);
|
|
279
323
|
* **以及看板里全是别人的任务**(那不是空,是「这看板不是我在用」——
|
|
280
324
|
* 摆个「(空)」会误导成「我可以去登记」,跟真的空看板不是一回事)。 */
|
|
281
|
-
export function boardGuideline(md: string, who: string, touched: string[] = []): string | null {
|
|
325
|
+
export function boardGuideline(md: string, who: string, touched: string[] = [], mySid = ''): string | null {
|
|
282
326
|
const { total, rows } = parseOpenTasks(md, PANEL_MAX_ROWS, who)
|
|
283
327
|
// 看板里有没有**任何**未完成条目(不分作者)。用来区分两种“过滤后为空”:
|
|
284
328
|
// 没有 → 真的空看板(摆「空」这个事实 + 它是异常信号)
|
|
@@ -287,7 +331,11 @@ export function boardGuideline(md: string, who: string, touched: string[] = []):
|
|
|
287
331
|
if (!total && anyOpen) return null
|
|
288
332
|
const lines = rows.map((r) => {
|
|
289
333
|
// desc 为空时不拖空破折号(MCP 登记只给 id 的常见情形)。
|
|
290
|
-
|
|
334
|
+
// ★ 会话归属标在行首:多会话并行时这是「这条能不能动」的唯一依据。
|
|
335
|
+
// 只摆事实(mine / 无 / 别的 sid),不写「别抢」那类劝告。
|
|
336
|
+
let tag = ''
|
|
337
|
+
if (r.sid) tag = r.sid === mySid ? '[本会话] ' : `[会话 ${r.sid}] `
|
|
338
|
+
const head = ` ${tag}${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
|
|
291
339
|
// 断点必须带上:它就是「改到哪一步」,丢了等于对齐了也接不上。
|
|
292
340
|
return r.note ? `${head}\n ↳ ${r.note}` : head
|
|
293
341
|
})
|
|
@@ -389,15 +437,38 @@ async function autoInit(cwd: string): Promise<boolean> {
|
|
|
389
437
|
}
|
|
390
438
|
|
|
391
439
|
/** 读看板拼成对齐段。失败/无图谱/看板空都返 null(静默降级,绝不阻断对话)。 */
|
|
392
|
-
async function readBoardForGuide(cwd: string): Promise<string | null> {
|
|
440
|
+
async function readBoardForGuide(cwd: string, mySid = ''): Promise<string | null> {
|
|
393
441
|
try {
|
|
394
442
|
const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
|
|
395
|
-
return boardGuideline(md, await currentUser(), [...filesSeen])
|
|
443
|
+
return boardGuideline(md, await currentUser(), [...filesSeen], mySid)
|
|
396
444
|
} catch {
|
|
397
445
|
return null
|
|
398
446
|
}
|
|
399
447
|
}
|
|
400
448
|
|
|
449
|
+
/** 本会话 id 在 guidelines 里的前缀。
|
|
450
|
+
*
|
|
451
|
+
* 为何要摆给 LLM(2026-10-09 用户定):todo 行尾的 `(认领 日期 <sid>)` 需要
|
|
452
|
+
* LLM 自己填 —— 用户原话「sid 让 llm 自己给答案吧」。
|
|
453
|
+
* 所以把本会话的 sid 摆进 system prompt,它登记时就能把这个值传给 `abs todo start --session`。
|
|
454
|
+
* 取不到 sid 时不注入(不写空值、不伪造)。 */
|
|
455
|
+
export const SESSION_MARK = '[会话] '
|
|
456
|
+
|
|
457
|
+
/** 注入本会话 id。每轮替换(值不变但保持与看板同构)。 */
|
|
458
|
+
export function injectSessionGuideline(options: any, sid: string): boolean {
|
|
459
|
+
const list: string[] = options.promptGuidelines || (options.promptGuidelines = [])
|
|
460
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
461
|
+
if (list[i].startsWith(SESSION_MARK)) list.splice(i, 1)
|
|
462
|
+
}
|
|
463
|
+
if (!sid) return false
|
|
464
|
+
list.push(
|
|
465
|
+
`${SESSION_MARK}本会话 id = ${sid}。登记任务时带上它 —— ` +
|
|
466
|
+
`MCP 工具:abs_task 的 session 参数;CLI:abs todo start <id> --session ${sid}。` +
|
|
467
|
+
`任务行尾会记成 \`(认领 日期 ${sid})\`,这样多会话并行时能看出哪条是谁在做。`,
|
|
468
|
+
)
|
|
469
|
+
return true
|
|
470
|
+
}
|
|
471
|
+
|
|
401
472
|
/** 注入 todo 指引。静态 6 条(行为要求) + 实时看板(对齐用)—— 两者职责不同:
|
|
402
473
|
* 静态条说「该怎么做」,看板说「现在是什么」。 */
|
|
403
474
|
export function injectTodoGuidelines(options: any, board?: string | null): boolean {
|
|
@@ -646,13 +717,13 @@ export function pickNickname(rnd: () => number = Math.random): string {
|
|
|
646
717
|
* 断点行(缩进的 ↳)归属到它上一条任务,不单独成条。 */
|
|
647
718
|
export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
648
719
|
total: number
|
|
649
|
-
rows: { state: string; id: string; desc: string; note: string }[]
|
|
720
|
+
rows: { state: string; id: string; desc: string; note: string; sid: string }[]
|
|
650
721
|
hidden: number
|
|
651
722
|
} {
|
|
652
723
|
// 两阶段:先无过滤地按序解析(断点归属需要“上一条”是原文里真正的前一条),
|
|
653
724
|
// 再按作者过滤。若边解析边过滤,被滤掉的条目下方的断点会挂到它上面那一条
|
|
654
725
|
// —— 实测:别人任务的断点会显示在我的任务下面(2026-10-03 审查发现)。
|
|
655
|
-
type Row = { state: string; id: string; desc: string; note: string; authors: string[] }
|
|
726
|
+
type Row = { state: string; id: string; desc: string; note: string; authors: string[]; sid: string }
|
|
656
727
|
const parsed: Row[] = []
|
|
657
728
|
let inTodo = false
|
|
658
729
|
for (const raw of String(md || '').split('\n')) {
|
|
@@ -675,17 +746,23 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
675
746
|
const body = m[3] || ''
|
|
676
747
|
const authors = [...body.matchAll(/\[\[([^\]]+)\]\]/g)].map((x) => x[1])
|
|
677
748
|
// 剥掉作者标记(面板上碍眼且无信息量),再取 `—` 后正文
|
|
749
|
+
// ★ 会话 id 要单独抽出来:它是「这条任务是谁在做」的唯一标识,
|
|
750
|
+
// 多会话并行时靠它区分自己的 / 别人的。不能跟认领日期一起被剥掉
|
|
751
|
+
// (2026-10-09 实测:只剥不抽 → sid 到不了看板,功能实际是断的)。
|
|
752
|
+
// 正则必须**先锚住日期再取后面的 token** —— 用宽松的 `[^)]*?\s(\S+)` 会把
|
|
753
|
+
// 日期本身当成 sid(实测:公海任务被误标成 `[会话 2026-10-09]`)。
|
|
754
|
+
const sid = (body.match(/\(认领\s*\d{4}-\d{2}-\d{2}\s+([\w-]+)\)/) || [])[1] || ''
|
|
678
755
|
const desc = body
|
|
679
756
|
.replace(/\[\[[^\]]+\]\]/g, '')
|
|
680
757
|
.replace(/^\s*—\s*/, '')
|
|
681
|
-
.replace(/\s*\(
|
|
758
|
+
.replace(/\s*\(认领[^)]*\)\s*$/, '')
|
|
682
759
|
.trim()
|
|
683
|
-
parsed.push({ state, id, desc, note: '', authors })
|
|
760
|
+
parsed.push({ state, id, desc, note: '', authors, sid })
|
|
684
761
|
}
|
|
685
762
|
const mine = who
|
|
686
763
|
? parsed.filter((r) => r.authors.length === 0 || r.authors.includes(who))
|
|
687
764
|
: parsed
|
|
688
|
-
const rows = mine.map(({ state, id, desc, note }) => ({ state, id, desc, note }))
|
|
765
|
+
const rows = mine.map(({ state, id, desc, note, sid }) => ({ state, id, desc, note, sid }))
|
|
689
766
|
return { total: rows.length, rows: rows.slice(0, max), hidden: Math.max(0, rows.length - max) }
|
|
690
767
|
}
|
|
691
768
|
|
|
@@ -1023,7 +1100,11 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
1023
1100
|
return
|
|
1024
1101
|
}
|
|
1025
1102
|
}
|
|
1026
|
-
const
|
|
1103
|
+
const sidNow = sessionIdFor(ctx)
|
|
1104
|
+
const ok = injectTodoGuidelines(event?.systemPromptOptions, await readBoardForGuide(cwd, sidNow))
|
|
1105
|
+
// 本会话 id 也摆进去 —— todo 行尾的 `(认领 日期 <sid>)` 要 LLM 自己填。
|
|
1106
|
+
injectSessionGuideline(event?.systemPromptOptions, sidNow)
|
|
1107
|
+
logHook(`before_agent_start session_guide=${sidNow ? 'on' : 'off'} sid=${sidNow || '-'}`).catch(() => {})
|
|
1027
1108
|
// 开学每个目录垫一次 load 全文(同目录只一次;之后靠上面那个看板段反复对齐)。
|
|
1028
1109
|
// 注意这里**不置 loadInjected** —— 注入点只是在 prompt 提交后、agent loop 前,
|
|
1029
1110
|
// 对话还没发出去;此刻置位会让被中断的那轮白烧掉标记(见 loadInjected 注解)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fanchao8609/agent_brain_sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.1",
|
|
4
4
|
"description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,12 @@
|
|
|
36
36
|
"abs": "node bin/abs.js",
|
|
37
37
|
"mcp": "node bin/mcp.js",
|
|
38
38
|
"test": "node --test 'test/*.test.js' 'skill/**/*.test.js'",
|
|
39
|
+
"lint": "eslint .",
|
|
40
|
+
"check": "node --experimental-vm-modules --no-warnings scripts/check-syntax.mjs && eslint .",
|
|
39
41
|
"prepack": "node -e \"require('fs').chmodSync('bin/abs.js',0o755);require('fs').chmodSync('bin/mcp.js',0o755)\"",
|
|
40
42
|
"pack:check": "npm pack --dry-run"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"eslint": "^9.39.5"
|
|
41
46
|
}
|
|
42
47
|
}
|
|
@@ -21,7 +21,7 @@ description: "USE FOR abs / agent-brain-sync 跨会话记忆与任务续接。
|
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
abs load # 开机读状态;abs query <词> 检索;abs resolve <页名> 反查路径
|
|
24
|
-
abs todo add <id> --note "做什么" # 登记(note 补断点 / state 改标记 / done 完成);abs log "完成 X" 记成果
|
|
24
|
+
abs todo add <id> --note "做什么" # 登记(note 补断点 / memo 随手记 / state 改标记 / done 完成);abs log "完成 X" 记成果
|
|
25
25
|
abs note "经验" [--tags a,b] # 暂存经验;abs concept <slug> --title "…" 建概念页骨架
|
|
26
26
|
abs rule [add "一句话"] # 读写 ## Rules;abs lint 体检;abs todo archive 归档;abs init 建图谱
|
|
27
27
|
```
|
|
@@ -109,10 +109,35 @@ abs rule [add "一句话"] # 读写 ## Rules;abs lint 体检;abs t
|
|
|
109
109
|
| 认领新任务 / 聊出一个话题 | `start` + note 做什么 | Todo `[进行中]` |
|
|
110
110
|
| 只在讨论、还没动手 | `state` + note 讨论中 | 原地改标记 |
|
|
111
111
|
| 卡住了/等人等数据 | `state` + note 滞留中 | 原地改标记 |
|
|
112
|
-
| 被打断/干到一半 | `note` + 改到哪个文件/到哪步 | 原地
|
|
112
|
+
| 被打断/干到一半 | `note` + 改到哪个文件/到哪步 | 原地 ↳断点(**一句话**) |
|
|
113
|
+
| 干活中想随手记一段(多句/发现/取舍/测试数据) | **`memo`** + 原文 | `sessions/log-<日期>-<id>.md`,**不上看板** |
|
|
113
114
|
| 子任务做完 | `done` + note 结语 + as 落地\|否决\|仅方案 | Done(别让假【落地】污染看板) |
|
|
114
115
|
| 总结出经验/坑/规律 | 改用 `abs_note` text + tags | sources/ |
|
|
115
116
|
|
|
117
|
+
**四个落点的分工(选错就会被闸门拒,或被 lint 报):**
|
|
118
|
+
|
|
119
|
+
| 想记什么 | 入口 | 形态 | 上看板? |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| 下一步做什么、卡在哪 | `note` | 一句话,**覆盖式**(旧值就该被替换) | 是 |
|
|
122
|
+
| 过程随手记:发现、取舍、测试数据、为什么这么做 | `memo` | 可多句、不定长,**追加式**(旧值不能丢) | **否** |
|
|
123
|
+
| 提炼过的经验/坑 | `abs_note` | 一句话结论 → sources/ 待提炼 | 否 |
|
|
124
|
+
| 一轮干完的成果流水 | `abs log` | 正式流水行(要带 kind) | 否 |
|
|
125
|
+
|
|
126
|
+
**为什么要有 `memo`(2026-10-11 用户实报「大量乱七八糟的总结都扔 todo 了」):**
|
|
127
|
+
断点写不下(设计上就只能一句话,用户明确要求保持),`abs log` 对「随手记一句」太重
|
|
128
|
+
(要起 kind、进 log.md)。结果是干活途中想记一段时**没地方放**,就顺手塞 `todo.md`。
|
|
129
|
+
实测 `.agents/.brain/todo.md` 被塞进 10 行实施报告,而 `abs lint` 报 0 问题;
|
|
130
|
+
更早一次清理时发现「原内容只存在 todo.md,删任务时差点永久丢失」。
|
|
131
|
+
|
|
132
|
+
现在 `todo.md` 的 Todo 区有**白名单闸门**:只能有任务行(`- [ ]`)、断点行(`↳ 断点:`)、
|
|
133
|
+
空行、标题。其余一律拒写并报错。所以别再试着往里写总结了 —— **走 `memo`**。
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
abs todo memo <id> "第一句:根因是判据极性。第二句:改成白名单就不漏了。" # 记(可多句)
|
|
137
|
+
abs todo memo <id> # 查看
|
|
138
|
+
```
|
|
139
|
+
任务 `done` 后笔记文件留在 `sessions/`(随当日归档),不会丢。
|
|
140
|
+
|
|
116
141
|
**硬规则(开工前触发器,别被状态表漏掉):**
|
|
117
142
|
|
|
118
143
|
1. **开工前先登记** —— 认领任何要动文件/跑命令的任务,**第一步是 `abs_task` action=start**,然后才读第一个文件。名字想不到就先起粗糙的(`fix-hook-nudge`)—— 名字可事后改,**未登记的开工补不回来**。
|
package/src/install.js
CHANGED
|
@@ -141,7 +141,6 @@ function mcpEntryPath() {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
const MARK = '// abs-managed (agent-brain-sync)'; // TS plugin 标记
|
|
144
|
-
const JSON_MARK_KEY = 'abs-managed'; // JSON 内我们的命名空间
|
|
145
144
|
|
|
146
145
|
// ============================ 宿主 config 根 / skill 落点(统一走 env 或 homedir,测试可注入) ============================
|
|
147
146
|
/** 某宿主的配置根目录(settings.json 所在目录;env 覆盖优先,默认 ~/.<host> 或 ~/.config/<host>)。 */
|
|
@@ -245,9 +244,6 @@ async function readJson(p, { strict = true } = {}) {
|
|
|
245
244
|
* 现在改为精确匹配本工具实际写入的路径前缀: <homedir>/.abs/hooks/<agentKey>/abs-
|
|
246
245
|
* (stageHookScripts 生成的脚本名统一是 abs-<Event>.sh)。
|
|
247
246
|
*/
|
|
248
|
-
function absHookDir(agentKey) {
|
|
249
|
-
return join(homedir(), '.abs', 'hooks', agentKey);
|
|
250
|
-
}
|
|
251
247
|
/** 本工具会生成的 hook 脚本名(与 stageHookScripts 的 abs-<Event>.sh 一致)。 */
|
|
252
248
|
const ABS_SCRIPT_NAMES = new Set(
|
|
253
249
|
[...new Set(HOSTS.flatMap((h) => h.events))].map((ev) => `abs-${ev}.sh`),
|
|
@@ -496,7 +492,7 @@ async function removeAbsFromForeignMcpStores() {
|
|
|
496
492
|
return steps;
|
|
497
493
|
}
|
|
498
494
|
|
|
499
|
-
async function installClaudeCode({ withMcp, withSkill
|
|
495
|
+
async function installClaudeCode({ withMcp, withSkill }) {
|
|
500
496
|
const steps = [];
|
|
501
497
|
// 1) hooks → settings.json (分区合并: 同事件可挂多个 hook 框架, 追加 abs 而非覆盖, 保留 moshi-hook 等)
|
|
502
498
|
const scriptMap = await stageHookScripts('claude-code', HOSTS[0].events);
|
|
@@ -565,7 +561,7 @@ async function uninstallClaudeCode() {
|
|
|
565
561
|
}
|
|
566
562
|
|
|
567
563
|
// ============================ Codex ============================
|
|
568
|
-
async function installCodex({ withMcp, withSkill
|
|
564
|
+
async function installCodex({ withMcp, withSkill }) {
|
|
569
565
|
const steps = [];
|
|
570
566
|
const scriptMap = await stageHookScripts('codex', HOSTS[1].events);
|
|
571
567
|
const p = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'hooks.json');
|
|
@@ -768,7 +764,7 @@ function renderPluginTemplate(name, vars) {
|
|
|
768
764
|
return t;
|
|
769
765
|
}
|
|
770
766
|
|
|
771
|
-
async function installOpenCode({ withMcp, withSkill
|
|
767
|
+
async function installOpenCode({ withMcp, withSkill }) {
|
|
772
768
|
const steps = [];
|
|
773
769
|
const dir = join(hostConfigRoot('opencode'), 'plugins');
|
|
774
770
|
const p = join(dir, 'abs.ts');
|
|
@@ -816,7 +812,7 @@ async function uninstallOpenCode() {
|
|
|
816
812
|
return steps;
|
|
817
813
|
}
|
|
818
814
|
|
|
819
|
-
async function installPi({ withMcp, withSkill
|
|
815
|
+
async function installPi({ withMcp, withSkill }) {
|
|
820
816
|
const steps = [];
|
|
821
817
|
const dir = join(hostConfigRoot('pi'), 'agent', 'extensions');
|
|
822
818
|
const p = join(dir, 'abs.ts');
|
|
@@ -910,7 +906,7 @@ export async function runInstall({ agent, mcp = true, skill = true, yes = false
|
|
|
910
906
|
console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
|
|
911
907
|
}
|
|
912
908
|
|
|
913
|
-
export async function runUninstall({ agent
|
|
909
|
+
export async function runUninstall({ agent } = {}) {
|
|
914
910
|
const targets = agent ? [agent] : Object.keys(INSTALLERS);
|
|
915
911
|
for (const key of targets) {
|
|
916
912
|
const inst = INSTALLERS[key];
|
package/src/lint.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
|
|
6
6
|
import { readRules, idOfPage, statusOfPage, supersededByOf, clip, RULE_BODY_MAX } from './store.js';
|
|
7
7
|
import { doneKindOf, doneDateOf, checkFileShape, TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES } from './todo.js';
|
|
8
|
+
import { isAllowedTodoLine, todoSectionOf } from './todo/common.js';
|
|
8
9
|
|
|
9
10
|
// ---------- 图谱遍历 ----------
|
|
10
11
|
|
|
@@ -135,6 +136,13 @@ async function checkRootFiles(vault) {
|
|
|
135
136
|
if (file !== 'todo.md') continue;
|
|
136
137
|
for (let i = 0; i < lines.length; i++) {
|
|
137
138
|
const l = lines[i];
|
|
139
|
+
// 裸文本行(与闸门 isAllowedTodoLine 同源,2026-10-11):Todo 区只能有任务行/断点/空行/标题。
|
|
140
|
+
if (todoSectionOf(lines, i) === 'Todo' && !isAllowedTodoLine(l)) {
|
|
141
|
+
issues.push(
|
|
142
|
+
`TODO-STRAY-PROSE: ${BRAIN_DIR}/${file}:${i + 1} (Todo 区里的裸文本,不是任务行也不是断点;` +
|
|
143
|
+
`总结/报告/清单请分流:流水 → abs log,经验 → abs note,长设计 → sources/)`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
138
146
|
if (!l.trimStart().startsWith('↳ 断点:')) continue;
|
|
139
147
|
const bp = l.trimStart().slice('↳ 断点:'.length).trim();
|
|
140
148
|
if (bp.length > LINT_BREAKPOINT_MAX) {
|
|
@@ -203,7 +211,12 @@ function checkPage(pg, names, linkedNames, inbound) {
|
|
|
203
211
|
if (pg.dir === 'concepts' && !hasTail(pg.body)) {
|
|
204
212
|
issues.push(`NO-TAIL: ${pg.rel}(无「做完怎么确认」;尾巴写清跑什么/看什么/按什么判据,别只有头)`);
|
|
205
213
|
}
|
|
206
|
-
|
|
214
|
+
// 「任务随手记」(abs todo memo)不登记 index:它是**某任务的过程草稿**,
|
|
215
|
+
// 数量随任务数增长且生命周期短(任务完就没人看),登记会把 index 冲垮 ——
|
|
216
|
+
// 而 index 的契约是「可扫描的图谱导航」。与 sources/ 同理豁免(都是「暂存待处理」)。
|
|
217
|
+
// 判据用 tag 而非文件名:文件名规则将来可能调,tag 是语义标记。
|
|
218
|
+
const isTaskMemo = pg.dir === 'sessions' && /task-memo/.test(pg.frontmatter);
|
|
219
|
+
if (pg.dir !== 'sources' && !isTaskMemo && !pg.indexed) {
|
|
207
220
|
issues.push(`INDEX-MISSING: ${pg.rel} not listed as [[${pg.slug}]] in index.md`);
|
|
208
221
|
}
|
|
209
222
|
return issues;
|
package/src/lock.js
CHANGED
|
@@ -65,7 +65,7 @@ async function releaseLock(lockPath) {
|
|
|
65
65
|
* - { text: <新内容>, ...meta } → 同上,把 meta 透传给调用方(结果回传用)
|
|
66
66
|
* - SKIP → 不写盘(幂等命中/未找到目标),返回 SKIP
|
|
67
67
|
* 返回:写盘后=mutator 返回值;SKIP 时原样返回 SKIP。抛错则锁内不落盘、锁释放、上抛。 */
|
|
68
|
-
export async function editFile(file, mutator
|
|
68
|
+
export async function editFile(file, mutator) {
|
|
69
69
|
const lockPath = join(dirname(file), `.${basename(file)}.lock`);
|
|
70
70
|
await acquireLock(lockPath);
|
|
71
71
|
try {
|
package/src/note.js
CHANGED
|
@@ -9,12 +9,10 @@ import { requireBrain, brainPath } from './index.js';
|
|
|
9
9
|
import { requireUser, atTag } from './userconfig.js';
|
|
10
10
|
import { today, localStamp, LOG_KINDS } from './todo.js';
|
|
11
11
|
import { editFile, SKIP } from './lock.js';
|
|
12
|
-
import { collapseIndex } from './page.js';
|
|
13
12
|
import { impactOf } from './codegraph.js';
|
|
14
13
|
import { clip, slugOf } from './text.js';
|
|
15
14
|
|
|
16
15
|
// ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
|
|
17
|
-
const NOTE_DEDUP_MS = 60 * 1000;
|
|
18
16
|
|
|
19
17
|
export async function cmdNote({ dir, text, tags, when, impact, type }) {
|
|
20
18
|
const clean = String(text || '').trim();
|
|
@@ -29,12 +27,12 @@ export async function cmdNote({ dir, text, tags, when, impact, type }) {
|
|
|
29
27
|
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
30
28
|
const srcDir = brainPath(root, 'sources');
|
|
31
29
|
await fs.mkdir(srcDir, { recursive: true });
|
|
32
|
-
// 幂等:
|
|
30
|
+
// 幂等: 同文本已落过就不再落一份(判据是内容包含,不看时间)
|
|
33
31
|
const existing = (await fs.readdir(srcDir).catch(() => [])).filter((f) => f.endsWith('.md'));
|
|
34
32
|
for (const f of existing) {
|
|
35
33
|
const body = await fs.readFile(join(srcDir, f), 'utf8').catch(() => '');
|
|
36
34
|
if (body.includes(clean)) {
|
|
37
|
-
return `•
|
|
35
|
+
return `• 已落过同文本 → ${f} (跳过重复)`;
|
|
38
36
|
}
|
|
39
37
|
}
|
|
40
38
|
// 影响面(可选):显式传 --impact <符号> 时,借本机 CodeGraph 拿「改它波及谁」。
|
package/src/page.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// 依赖方向:page.js ← store.js(不可反向)。store.js 里的 cmdSupersede /
|
|
8
8
|
// cmdReview / cmdResolve 会 re-export 本文件的实现,保持既有调用面不变。
|
|
9
9
|
import { promises as fs } from 'node:fs';
|
|
10
|
-
import { join
|
|
10
|
+
import { join } from 'node:path';
|
|
11
11
|
import { requireBrain, brainPath, BRAIN_DIR } from './index.js';
|
|
12
12
|
import { editFile, SKIP } from './lock.js';
|
|
13
13
|
// PAGE_DIRS / listPages 早先就在 lint.js(页面清单遍历是 lint 的职责),此处复用而非重写。
|