@fanchao8609/agent_brain_sync 1.18.0 → 1.19.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/LICENSE +21 -0
- package/README.md +6 -1
- package/bin/abs.js +16 -1
- package/bin/mcp.js +8 -6
- package/hooks/abs.omp.ts +38 -14
- package/hooks/abs.pi.ts +50 -16
- package/package.json +2 -1
- package/src/lint.js +5 -3
- package/src/store.js +30 -4
- package/src/todo/archive.js +9 -4
- package/src/todo/common.js +59 -13
- package/src/todo/spec.js +2 -2
- package/src/todo/tasks.js +64 -12
- package/src/todo/validate.js +28 -4
- package/src/todo.js +2 -2
- package/src/wrapup.js +18 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fanchao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -108,7 +108,9 @@ abs load # 开机读状态(Rules + 图谱计数 + 看板
|
|
|
108
108
|
abs todo # 看板 Todo / Done(行首带状态标记)
|
|
109
109
|
|
|
110
110
|
abs todo add TASK-1 --note "要做什么"
|
|
111
|
-
abs todo note TASK-1 --note "改到 X 文件 L40" #
|
|
111
|
+
abs todo note TASK-1 --note "改到 X 文件 L40" # 追加一条子项(标准 md 嵌套列表,可多条)
|
|
112
|
+
abs todo bp-done TASK-1 [--n 2] # 勾子项(n=第几条,缺省最后一条)
|
|
113
|
+
abs todo bp-undone TASK-1 [--n 2] # 取消勾(勾错了退回)
|
|
112
114
|
abs todo memo TASK-1 "随手记一段(可多句,不上看板)" # 任务笔记 → sessions/;无参=查看
|
|
113
115
|
abs todo state TASK-1 --note "进行中|讨论中|滞留中" # 改行首状态标记
|
|
114
116
|
abs todo done TASK-1
|
|
@@ -129,6 +131,9 @@ abs update # 升级到最新版并刷新四宿主
|
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
> `abs todo start` 与 `abs todo add` 等价(都登记任务)。
|
|
134
|
+
> **子项就是标准 Markdown 嵌套列表**:任务下缩进 2 空格的 `- [ ] …`,可多条、可各自勾选;
|
|
135
|
+
> 面板按缩进画树线,归档时原样保留缩进。旧格式 `↳ 断点:` 仍能读(存量不坏)。
|
|
136
|
+
> 任务 id 不能含空格(用连字符),含空格会被明确拒掉。
|
|
132
137
|
> 旧版 `abs task ...` / `abs board` 已废,打它们会报「未知命令」并退出码 1。
|
|
133
138
|
> `abs wrapup` / `abs teardown-check` 是 hook 内部命令,无需手动调用。
|
|
134
139
|
> **作者名要填真的**:`tester` / `foo` / `aaa` 这类占位名会被拒 —— 因为 `{user}` 是全局单值,
|
package/bin/abs.js
CHANGED
|
@@ -116,6 +116,8 @@ const FLAG_SPEC = {
|
|
|
116
116
|
'title': { type: 'string' },
|
|
117
117
|
'desc': { type: 'string' },
|
|
118
118
|
'state': { type: 'string' },
|
|
119
|
+
// 勾子项用:第几条(1-based,缺省=最后一条)
|
|
120
|
+
'n': { type: 'string' },
|
|
119
121
|
// log 流水类型:note|dev|concept|ingest(cmdLog 会校枚举,非枚举直接报错)。
|
|
120
122
|
// ★ 漏声明就是真 bug(2026-10-05 本机实测):不在 FLAG_SPEC 的选项会被静默丢掉,
|
|
121
123
|
// 于是 `abs log "x" --kind note` 全写成默认的 dev,而 cmdLog 的枚举校验看不见它。
|
|
@@ -310,6 +312,8 @@ const subUsage = {
|
|
|
310
312
|
' abs todo add <id> [--note "做什么"] [--section 讨论中|滞留中|搁置]',
|
|
311
313
|
' abs todo rename <旧id> --note "<新id>" 改任务 id(起错名/不可读时)',
|
|
312
314
|
' abs todo note <id> --note "断点/进度"',
|
|
315
|
+
' abs todo bp-done <id> 勾断点 → `↳ [x] 断点:`(做完落一句)',
|
|
316
|
+
' abs todo bp-undone <id> 取消勾(勾错了退回)',
|
|
313
317
|
' abs todo memo <id> "随手记(可多句)" 任务笔记 → sessions/,不上看板;无文字=查看',
|
|
314
318
|
' abs todo state <id> --note 进行中|讨论中|滞留中|搁置',
|
|
315
319
|
' abs todo done <id> [--as 落地|否决|仅方案] [结语文字]',
|
|
@@ -374,6 +378,8 @@ const TODO_ACTIONS = {
|
|
|
374
378
|
add: 'start',
|
|
375
379
|
start: 'start', // add 的别名(老习惯保留)
|
|
376
380
|
note: 'note',
|
|
381
|
+
'bp-done': 'bp-done', // 勾断点:`↳ [ ] 断点:` → `↳ [x] 断点:`
|
|
382
|
+
'bp-undone': 'bp-undone', // 取消勾断点(勾错了退回)
|
|
377
383
|
memo: 'memo', // 任务随手记:多句/追加/不占看板(无文字时=查看)
|
|
378
384
|
state: 'state', // 改行首状态标记:进行中|讨论中|滞留中|搁置(原地,不搬区)
|
|
379
385
|
rename: 'rename', // 改任务 id:人工起错名、或旧 id 不可读时用
|
|
@@ -469,7 +475,7 @@ async function main() {
|
|
|
469
475
|
if (!action) {
|
|
470
476
|
throw new Error(
|
|
471
477
|
`✗ 未知子命令 "${sub}"\n` +
|
|
472
|
-
` 可用: add / start / note / memo / state / done / archive\n` +
|
|
478
|
+
` 可用: add / start / note / bp-done / bp-undone / memo / state / done / archive\n` +
|
|
473
479
|
` 看板: abs todo(不带参数)\n` +
|
|
474
480
|
` 登记任务: abs todo add <id> --note "做什么"`,
|
|
475
481
|
);
|
|
@@ -492,6 +498,15 @@ async function main() {
|
|
|
492
498
|
console.log(await cmdTask({ dir: opts.dir, action, id, note, session: opts.session }));
|
|
493
499
|
break;
|
|
494
500
|
}
|
|
501
|
+
// 勾/取消勾子项:只收 <id>(子项位置 = 「某任务的第 N 条」,没有自己的 id)
|
|
502
|
+
if (action === 'bp-done' || action === 'bp-undone') {
|
|
503
|
+
rejectExtra(rest2, `abs todo ${sub} <id> [--n N]`);
|
|
504
|
+
if (!id) throw new Error(`✗ 缺 <id>\n 用法: abs todo ${sub} <id> [--n N]`);
|
|
505
|
+
const n = opts.n ? Number(opts.n) : undefined;
|
|
506
|
+
if (opts.n && (!Number.isInteger(n) || n < 1)) throw new Error(`✗ --n 要是正整数(收到 "${opts.n}")`);
|
|
507
|
+
console.log(await cmdTask({ dir: opts.dir, action, id, n }));
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
495
510
|
rejectExtra(rest2, usage);
|
|
496
511
|
if (!id) throw new Error(`✗ 缺 <id>\n 用法: ${usage}`);
|
|
497
512
|
console.log(await cmdTask({ dir: opts.dir, action, id, section: opts.section, note: opts.note, as: opts.as, session: opts.session }));
|
package/bin/mcp.js
CHANGED
|
@@ -112,12 +112,12 @@ tool(
|
|
|
112
112
|
|
|
113
113
|
tool(
|
|
114
114
|
'abs_task',
|
|
115
|
-
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note
|
|
115
|
+
'任务实时落盘(幂等键 = id)。start 登记进 Todo / note 补子项(**追加**,标准 md 嵌套列表,`abs todo note`)/ bp_done、bp_undone 勾/取消勾子项(n=第几条,缺省最后一条)/ 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', 'memo', 'state']),
|
|
117
|
+
action: z.enum(['add', 'start', 'done', 'note', 'bp_done', 'bp_undone', 'memo', 'state']),
|
|
118
118
|
id: z.string().describe('任务幂等键,如 TASK-xxx 或子任务名'),
|
|
119
|
-
|
|
120
|
-
note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步,一句话); memo=随手记(可多句、不定长,追加式;不传=查看已有笔记); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字'),
|
|
119
|
+
n: z.number().int().positive().optional().describe('bp_done/bp_undone 用:第几条子项(1-based,缺省=最后一条)'),
|
|
120
|
+
cwd: z.string().describe('项目根目录(.brain/ 所在处)'), note: z.string().optional().describe('add/start=做什么; note=断点(文件/到哪步,一句话); memo=随手记(可多句、不定长,追加式;不传=查看已有笔记); state=进行中|讨论中|滞留中|搁置(搁置=用户改方向/不做了;滞留中=还要做只是卡住); done=结语文字'),
|
|
121
121
|
as: z.enum(['落地', '否决', '仅方案']).optional().describe('仅 done:结语类型。默认 落地。做了又撤/评估后不做用 否决,只设计过用 仅方案 —— 别让假【落地】污染看板'),
|
|
122
122
|
// 会话 id:多会话并行时标记「这条任务是谁在做」。
|
|
123
123
|
// 值取自 system prompt 里的 `[会话] 本会话 id = xxx`(hook 注入),由模型原样回传。
|
|
@@ -125,12 +125,14 @@ tool(
|
|
|
125
125
|
// (协议没有,PI_* 环境变量也没有);模型本来就知道自己的 sid,直接收下最省。
|
|
126
126
|
session: z.string().optional().describe('仅 start/add:本会话 id(取自 system prompt 的 [会话] 段)。多会话并行时用来标记「这条任务谁在做」'),
|
|
127
127
|
},
|
|
128
|
-
async ({ action, id, cwd, note, as, session }) => {
|
|
128
|
+
async ({ action, id, cwd, note, as, session, n }) => {
|
|
129
129
|
const root = await findBrainRoot(cwd || process.cwd());
|
|
130
130
|
if (!root) return errNoBrain(cwd || process.cwd());
|
|
131
131
|
// add 是 start 的别名(与 CLI `abs todo add` 对齐)
|
|
132
132
|
const act = action === 'add' ? 'start' : action;
|
|
133
|
-
|
|
133
|
+
// bp_done → bp-done(MCP 参数名不用连字符,内部 action 用)
|
|
134
|
+
const act2 = act === 'bp_done' ? 'bp-done' : act === 'bp_undone' ? 'bp-undone' : act;
|
|
135
|
+
return { content: [{ type: 'text', text: await cmdTask({ dir: root, action: act2, id, note, as, session, n }) }] };
|
|
134
136
|
}
|
|
135
137
|
);
|
|
136
138
|
|
package/hooks/abs.omp.ts
CHANGED
|
@@ -230,7 +230,9 @@ export function boardGuideline(md: string, who: string, touched: string[] = [],
|
|
|
230
230
|
let tag = ''
|
|
231
231
|
if (r.sid) tag = r.sid === mySid ? '[本会话] ' : `[会话 ${r.sid}] `
|
|
232
232
|
const head = ` ${tag}${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
|
|
233
|
-
|
|
233
|
+
// 子项带上(与面板同形),丢了等于对齐了也接不上
|
|
234
|
+
if (!r.kids || !r.kids.length) return head
|
|
235
|
+
return [head, ...r.kids.map((k) => ` - ${k.done ? '[x]' : '[ ]'} ${k.text}`)].join('\n')
|
|
234
236
|
})
|
|
235
237
|
const more = total > rows.length ? ` …另 ${total - rows.length} 条` : ''
|
|
236
238
|
const touchLines = touched.length
|
|
@@ -349,12 +351,12 @@ export function pickNickname(rnd: () => number = Math.random): string {
|
|
|
349
351
|
* (老任务/手写的没标,漏掉比多显示更糟)。标了别人的则不收。 */
|
|
350
352
|
export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
351
353
|
total: number
|
|
352
|
-
rows: { state: string; id: string; desc: string;
|
|
354
|
+
rows: { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; sid: string }[]
|
|
353
355
|
hidden: number
|
|
354
356
|
} {
|
|
355
357
|
// 两阶段:先无过滤地按序解析(断点归属需要「上一条」是原文里真正的前一条),
|
|
356
358
|
// 再按作者过滤。若边解析边过滤,被滤掉的条目下方的断点会挂到它上面那一条。
|
|
357
|
-
type Row = { state: string; id: string; desc: string;
|
|
359
|
+
type Row = { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; authors: string[]; sid: string }
|
|
358
360
|
const parsed: Row[] = []
|
|
359
361
|
let inTodo = false
|
|
360
362
|
for (const raw of String(md || '').split('\n')) {
|
|
@@ -362,11 +364,11 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
362
364
|
if (/^##\s+Todo\s*$/.test(line)) { inTodo = true; continue }
|
|
363
365
|
if (/^##\s+/.test(line)) { inTodo = false; continue }
|
|
364
366
|
if (!inTodo) continue
|
|
365
|
-
//
|
|
366
|
-
const
|
|
367
|
-
if (
|
|
367
|
+
// 子项行(标准 md:缩进的 `- [ ]`;旧格式 `↳ 断点:` 继续认)→ 挂到上一条任务
|
|
368
|
+
const kid = parseChildLine(line)
|
|
369
|
+
if (kid && kid.indent > 0) {
|
|
368
370
|
const last = parsed[parsed.length - 1]
|
|
369
|
-
if (last
|
|
371
|
+
if (last) last.kids.push(kid)
|
|
370
372
|
continue
|
|
371
373
|
}
|
|
372
374
|
// `- [ ] [状态] <id> …`;状态标记可能缺省。
|
|
@@ -386,12 +388,12 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
386
388
|
.replace(/^\s*—\s*/, '')
|
|
387
389
|
.replace(/\s*\(认领[^)]*\)\s*$/, '')
|
|
388
390
|
.trim()
|
|
389
|
-
parsed.push({ state, id, desc,
|
|
391
|
+
parsed.push({ state, id, desc, kids: [], authors, sid })
|
|
390
392
|
}
|
|
391
393
|
const mine = who
|
|
392
394
|
? parsed.filter((r) => r.authors.length === 0 || r.authors.includes(who))
|
|
393
395
|
: parsed
|
|
394
|
-
const rows = mine.map(({ state, id, desc,
|
|
396
|
+
const rows = mine.map(({ state, id, desc, kids, sid }) => ({ state, id, desc, kids, sid }))
|
|
395
397
|
return { total: rows.length, rows: rows.slice(0, max), hidden: Math.max(0, rows.length - max) }
|
|
396
398
|
}
|
|
397
399
|
|
|
@@ -413,6 +415,19 @@ async function currentUser(): Promise<string> {
|
|
|
413
415
|
/** 按显示宽度截断(自己实现,不 import 宿主 tui 包)。
|
|
414
416
|
* 依赖宿主内部 alias 太脆 —— pi 版实测在测试环境会 ERR_MODULE_NOT_FOUND。
|
|
415
417
|
* 宽度规则:CJK/全角记 2 列,其余记 1 列。 */
|
|
418
|
+
/** 解析子项行(与 src/todo/common.js 的 parseChild 同规则,双份实现 —— 见 pi 版说明)。 */
|
|
419
|
+
function parseChildLine(l: string): { done: boolean; text: string; indent: number } | null {
|
|
420
|
+
const s = String(l || '')
|
|
421
|
+
const old = s.match(/^(\s*)↳\s*(?:\[[ x]\]\s*)?(?:断点[::]\s*)?(.*)$/)
|
|
422
|
+
if (old) {
|
|
423
|
+
if (!old[2].trim()) return null
|
|
424
|
+
return { done: /^\s*↳\s*\[x\]/.test(s), text: old[2].trim(), indent: old[1].length }
|
|
425
|
+
}
|
|
426
|
+
const m = s.match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/)
|
|
427
|
+
if (!m) return null
|
|
428
|
+
return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length }
|
|
429
|
+
}
|
|
430
|
+
|
|
416
431
|
function dispWidth(s: string): number {
|
|
417
432
|
let w = 0
|
|
418
433
|
let i = 0
|
|
@@ -493,7 +508,7 @@ export function todoAnimFrame(phase: number): string {
|
|
|
493
508
|
}
|
|
494
509
|
|
|
495
510
|
export function renderPanelLines(
|
|
496
|
-
data: { total: number; rows: { state: string; id: string; desc: string;
|
|
511
|
+
data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string }[] }[]; hidden: number },
|
|
497
512
|
who: string,
|
|
498
513
|
width: number,
|
|
499
514
|
fg: (color: string, s: string) => string,
|
|
@@ -521,12 +536,21 @@ export function renderPanelLines(
|
|
|
521
536
|
const isLast = i === lastIdx
|
|
522
537
|
const branch = isLast ? '└─' : '├─'
|
|
523
538
|
const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
|
|
524
|
-
|
|
539
|
+
// 复选框 `[ ]`:面板是 todo.md 的只读视图,就该长成 todo 原貌。
|
|
540
|
+
const box = fg('dim', '[ ]')
|
|
541
|
+
const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
|
|
525
542
|
const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
|
|
526
543
|
lines.push(clipToWidth(head + desc, width))
|
|
527
|
-
if (t.
|
|
528
|
-
|
|
529
|
-
|
|
544
|
+
if (t.kids && t.kids.length) {
|
|
545
|
+
// 子项:标准 md 嵌套列表 → 画成树
|
|
546
|
+
const stem = isLast ? ' ' : fg('dim', '│ ')
|
|
547
|
+
const n = t.kids.length
|
|
548
|
+
t.kids.forEach((k, ki) => {
|
|
549
|
+
const kb = ki === n - 1 ? '└─' : '├─'
|
|
550
|
+
const kbox = fg('dim', k.done ? '[x]' : '[ ]')
|
|
551
|
+
const ktext = fg(k.done ? 'dim' : 'muted', k.text)
|
|
552
|
+
lines.push(clipToWidth(`${stem} ${fg('dim', kb)} ${kbox} ${ktext}`, width))
|
|
553
|
+
})
|
|
530
554
|
}
|
|
531
555
|
})
|
|
532
556
|
|
package/hooks/abs.pi.ts
CHANGED
|
@@ -336,8 +336,11 @@ export function boardGuideline(md: string, who: string, touched: string[] = [],
|
|
|
336
336
|
let tag = ''
|
|
337
337
|
if (r.sid) tag = r.sid === mySid ? '[本会话] ' : `[会话 ${r.sid}] `
|
|
338
338
|
const head = ` ${tag}${r.state ? `[${r.state}] ` : ''}${r.id}${r.desc ? ` — ${r.desc}` : ''}`
|
|
339
|
-
//
|
|
340
|
-
|
|
339
|
+
// 子项必须带上:它就是「改到哪一步」,丢了等于对齐了也接不上(2026-10-05 实测)。
|
|
340
|
+
// 与面板同形(`- [ ]` 缩进),让 LLM 看到的就是文件里的结构。
|
|
341
|
+
if (!r.kids || !r.kids.length) return head
|
|
342
|
+
const kidLines = r.kids.map((k) => ` - ${k.done ? '[x]' : '[ ]'} ${k.text}`)
|
|
343
|
+
return [head, ...kidLines].join('\n')
|
|
341
344
|
})
|
|
342
345
|
const more = total > rows.length ? ` …另 ${total - rows.length} 条` : ''
|
|
343
346
|
// 本会话动过的文件 —— 与看板并列摆出,让 LLM 自己对照(不判断算不算任务)。
|
|
@@ -717,13 +720,13 @@ export function pickNickname(rnd: () => number = Math.random): string {
|
|
|
717
720
|
* 断点行(缩进的 ↳)归属到它上一条任务,不单独成条。 */
|
|
718
721
|
export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
719
722
|
total: number
|
|
720
|
-
rows: { state: string; id: string; desc: string;
|
|
723
|
+
rows: { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; sid: string }[]
|
|
721
724
|
hidden: number
|
|
722
725
|
} {
|
|
723
726
|
// 两阶段:先无过滤地按序解析(断点归属需要“上一条”是原文里真正的前一条),
|
|
724
727
|
// 再按作者过滤。若边解析边过滤,被滤掉的条目下方的断点会挂到它上面那一条
|
|
725
728
|
// —— 实测:别人任务的断点会显示在我的任务下面(2026-10-03 审查发现)。
|
|
726
|
-
type Row = { state: string; id: string; desc: string;
|
|
729
|
+
type Row = { state: string; id: string; desc: string; kids: { done: boolean; text: string }[]; authors: string[]; sid: string }
|
|
727
730
|
const parsed: Row[] = []
|
|
728
731
|
let inTodo = false
|
|
729
732
|
for (const raw of String(md || '').split('\n')) {
|
|
@@ -731,11 +734,12 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
731
734
|
if (/^##\s+Todo\s*$/.test(line)) { inTodo = true; continue }
|
|
732
735
|
if (/^##\s+/.test(line)) { inTodo = false; continue }
|
|
733
736
|
if (!inTodo) continue
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
737
|
+
// 子项行(标准 md:缩进的 `- [ ]`;旧格式 `↳ 断点:` 继续认)→ 挂到上一条任务。
|
|
738
|
+
// 子项可多条、可各自勾选(2026-10-16 用户定)。
|
|
739
|
+
const kid = parseChildLine(line)
|
|
740
|
+
if (kid && kid.indent > 0) {
|
|
737
741
|
const last = parsed[parsed.length - 1]
|
|
738
|
-
if (last
|
|
742
|
+
if (last) last.kids.push(kid)
|
|
739
743
|
continue
|
|
740
744
|
}
|
|
741
745
|
// `- [ ] [状态] <id> …`;状态标记可能缺省。
|
|
@@ -760,12 +764,12 @@ export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
|
760
764
|
.replace(/^\s*—\s*/, '')
|
|
761
765
|
.replace(/\s*\(认领[^)]*\)\s*$/, '')
|
|
762
766
|
.trim()
|
|
763
|
-
parsed.push({ state, id, desc,
|
|
767
|
+
parsed.push({ state, id, desc, kids: [], authors, sid })
|
|
764
768
|
}
|
|
765
769
|
const mine = who
|
|
766
770
|
? parsed.filter((r) => r.authors.length === 0 || r.authors.includes(who))
|
|
767
771
|
: parsed
|
|
768
|
-
const rows = mine.map(({ state, id, desc,
|
|
772
|
+
const rows = mine.map(({ state, id, desc, kids, sid }) => ({ state, id, desc, kids, sid }))
|
|
769
773
|
return { total: rows.length, rows: rows.slice(0, max), hidden: Math.max(0, rows.length - max) }
|
|
770
774
|
}
|
|
771
775
|
|
|
@@ -792,6 +796,23 @@ async function currentUser(): Promise<string> {
|
|
|
792
796
|
*
|
|
793
797
|
* 宽度规则:CJK/全角记 2 列,其余记 1 列。足够让面板不溢出(不能处理 emoji ZWJ 序列,
|
|
794
798
|
* 但面板里没有—— todo 内容是任务名与说明)。 */
|
|
799
|
+
/** 解析子项行(与 src/todo/common.js 的 parseChild 同规则,双份实现)。
|
|
800
|
+
*
|
|
801
|
+
* 为何不 import src:面板是宿主扩展,单独装到 ~/.pi/extensions/,与包内 src/ 不同目录
|
|
802
|
+
* (装完就断了)。故两端各一份 —— 规则简单(两步正则),且两边测试都盖。
|
|
803
|
+
* 标准 md:缩进的 `- [ ] 内容`;旧格式 `↳ [ ] 断点: 内容` / `↳ 断点: 内容` 继续认。 */
|
|
804
|
+
function parseChildLine(l: string): { done: boolean; text: string; indent: number } | null {
|
|
805
|
+
const s = String(l || '')
|
|
806
|
+
const old = s.match(/^(\s*)↳\s*(?:\[[ x]\]\s*)?(?:断点[::]\s*)?(.*)$/)
|
|
807
|
+
if (old) {
|
|
808
|
+
if (!old[2].trim()) return null
|
|
809
|
+
return { done: /^\s*↳\s*\[x\]/.test(s), text: old[2].trim(), indent: old[1].length }
|
|
810
|
+
}
|
|
811
|
+
const m = s.match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/)
|
|
812
|
+
if (!m) return null
|
|
813
|
+
return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length }
|
|
814
|
+
}
|
|
815
|
+
|
|
795
816
|
function dispWidth(s: string): number {
|
|
796
817
|
let w = 0
|
|
797
818
|
let i = 0
|
|
@@ -887,7 +908,7 @@ export function todoAnimFrame(phase: number): string {
|
|
|
887
908
|
}
|
|
888
909
|
|
|
889
910
|
export function renderPanelLines(
|
|
890
|
-
data: { total: number; rows: { state: string; id: string; desc: string;
|
|
911
|
+
data: { total: number; rows: { state: string; id: string; desc: string; kids?: { done: boolean; text: string }[] }[]; hidden: number },
|
|
891
912
|
who: string,
|
|
892
913
|
width: number,
|
|
893
914
|
fg: (color: string, s: string) => string,
|
|
@@ -931,13 +952,26 @@ export function renderPanelLines(
|
|
|
931
952
|
const branch = isLast ? '└─' : '├─'
|
|
932
953
|
// 进行中的行带一个小动画(其余行不加,避免满屏都在跳)
|
|
933
954
|
const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
|
|
934
|
-
|
|
955
|
+
// 复选框 `[ ]`(2026-10-16 用户要求):面板是 todo.md 的只读视图,
|
|
956
|
+
// 就该长成 todo 原貌 —— 摆成与文件里 `- [ ]` 同形,读到就认得出对应哪一行。
|
|
957
|
+
const box = fg('dim', '[ ]')
|
|
958
|
+
const head = `${fg('dim', branch)} ${box} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
|
|
935
959
|
const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
|
|
936
960
|
lines.push(clipToWidth(head + desc, width))
|
|
937
|
-
if (t.
|
|
938
|
-
//
|
|
939
|
-
|
|
940
|
-
|
|
961
|
+
if (t.kids && t.kids.length) {
|
|
962
|
+
// 子项:标准 md 嵌套列表 → 画成树
|
|
963
|
+
// ├─ [ ] 子项一
|
|
964
|
+
// └─ [x] 子项二
|
|
965
|
+
// 树线前缀 = 父任务的 │(非末条)或空格(末条),与父任务分支对齐。
|
|
966
|
+
// 勾过的用 dim 压暗(一眼看出哪些已完成),未勾用正常色。
|
|
967
|
+
const stem = isLast ? ' ' : fg('dim', '│ ')
|
|
968
|
+
const n = t.kids.length
|
|
969
|
+
t.kids.forEach((k, ki) => {
|
|
970
|
+
const kb = ki === n - 1 ? '└─' : '├─'
|
|
971
|
+
const kbox = fg('dim', k.done ? '[x]' : '[ ]')
|
|
972
|
+
const ktext = fg(k.done ? 'dim' : 'muted', k.text)
|
|
973
|
+
lines.push(clipToWidth(`${stem} ${fg('dim', kb)} ${kbox} ${ktext}`, width))
|
|
974
|
+
})
|
|
941
975
|
}
|
|
942
976
|
})
|
|
943
977
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fanchao8609/agent_brain_sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,6 +36,7 @@
|
|
|
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
|
+
"fuzz": "node scripts/fuzz.mjs 800",
|
|
39
40
|
"lint": "eslint .",
|
|
40
41
|
"check": "node --experimental-vm-modules --no-warnings scripts/check-syntax.mjs && eslint .",
|
|
41
42
|
"prepack": "node -e \"require('fs').chmodSync('bin/abs.js',0o755);require('fs').chmodSync('bin/mcp.js',0o755)\"",
|
package/src/lint.js
CHANGED
|
@@ -5,7 +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
|
+
import { isAllowedTodoLine, todoSectionOf, parseChild } from './todo/common.js';
|
|
9
9
|
|
|
10
10
|
// ---------- 图谱遍历 ----------
|
|
11
11
|
|
|
@@ -143,8 +143,10 @@ async function checkRootFiles(vault) {
|
|
|
143
143
|
`总结/报告/清单请分流:流水 → abs log,经验 → abs note,长设计 → sources/)`,
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
|
-
|
|
147
|
-
const
|
|
146
|
+
// 断点行(新旧格式同一个解析器 —— 不再自己切前缀)
|
|
147
|
+
const bpLine = parseChild(l);
|
|
148
|
+
if (!bpLine) continue;
|
|
149
|
+
const bp = bpLine.text;
|
|
148
150
|
if (bp.length > LINT_BREAKPOINT_MAX) {
|
|
149
151
|
issues.push(
|
|
150
152
|
`TODO-BREAKPOINT-BLOAT: ${BRAIN_DIR}/${file}:${i + 1} (断点 ${bp.length} 字 > ${LINT_BREAKPOINT_MAX};` +
|
package/src/store.js
CHANGED
|
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
|
|
|
4
4
|
import { join, resolve, dirname } from 'node:path';
|
|
5
5
|
import { requireBrain, brainPath, absLogDir } from './index.js';
|
|
6
6
|
import { requireUser, atTag, getUser, placeholderWarn } from './userconfig.js';
|
|
7
|
-
import { stripStateMark, checkTaskBox, ensureStateMark, stateOfTaskLine, TODO_MAX_LINES, upsertTask, boardText, readTodo, todoTemplate, today, localStamp, setBreakpoint, setMemo, readMemo, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, collapseDone, SEC, rebuildStructure, enforceBrainFormat, sessionOf } from './todo.js';
|
|
7
|
+
import { stripStateMark, checkTaskBox, ensureStateMark, stateOfTaskLine, TODO_MAX_LINES, upsertTask, boardText, readTodo, todoTemplate, today, localStamp, setBreakpoint, setBreakpointDone, setMemo, readMemo, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, archiveDoneInText, upsertArchiveSection, DONE_KINDS, withDoneKind, collapseDone, SEC, rebuildStructure, enforceBrainFormat, sessionOf, parseChild } from './todo.js';
|
|
8
8
|
import { setFormatGate, editFile, SKIP } from './lock.js';
|
|
9
9
|
import { appendWrapup, strandedFor } from './wrapup.js';
|
|
10
10
|
import { keywords, recentFiles, topicStrength } from './relevant.js';
|
|
@@ -350,7 +350,7 @@ export async function cmdLoad({ dir }) {
|
|
|
350
350
|
);
|
|
351
351
|
if (stranded.length) {
|
|
352
352
|
const rows = stranded.map((t) => {
|
|
353
|
-
const bp = t.bp.length ? `\n ${t.bp.map((b) => `↳ 断点: ${b}`).join('\n ')}` : '';
|
|
353
|
+
const bp = t.bp.length ? `\n ${t.bp.map((b) => (b.startsWith('[') ? `↳ ${b}` : `↳ 断点: ${b}`)).join('\n ')}` : '';
|
|
354
354
|
return ` - ${t.body}${bp}`;
|
|
355
355
|
});
|
|
356
356
|
// 插在项目行之后、其它内容之前
|
|
@@ -807,7 +807,18 @@ function recentLogLines(text, n) {
|
|
|
807
807
|
// ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
|
|
808
808
|
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
809
809
|
// log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
|
|
810
|
-
export async function cmdTask({ dir, action, id, section, note, as, session }) {
|
|
810
|
+
export async function cmdTask({ dir, action, id, section, note, as, session, n }) {
|
|
811
|
+
// ★ id 校验(2026-10-16 模糊测试找出来的真 bug):id 不能含空白/方括号。
|
|
812
|
+
// 不校就是**静默丢信息**:id="带 空格" 写进文件时被 extractId 截成 "带",
|
|
813
|
+
// 后面的 "空格" 与描述一起消失,不报错(实测:`abs todo start "带 空格"` 落成 `- [ ] 带`)。
|
|
814
|
+
// 宁可明确拒掉,也不替人猜哪个词算 id。
|
|
815
|
+
const idStr = id === undefined || id === null ? '' : String(id);
|
|
816
|
+
if (idStr && (idStr.includes(' ') || idStr.includes('\t'))) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`✗ 任务 id 不能含空格(收到 "${idStr}")—— id 是一个词,空白会被当分隔符。\n` +
|
|
819
|
+
` 想要带空格的名字:用连字符或下划线;完整名称写进 --note。`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
811
822
|
const root = await requireBrain(dir || process.cwd());
|
|
812
823
|
// 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
|
|
813
824
|
const who = await requireUser();
|
|
@@ -929,6 +940,12 @@ export async function cmdTask({ dir, action, id, section, note, as, session }) {
|
|
|
929
940
|
const r = await setBreakpoint(root, { id, text: note });
|
|
930
941
|
return r.msg;
|
|
931
942
|
}
|
|
943
|
+
// 勾/取消勾断点:断点是待办,做完要勾(2026-10-16)。
|
|
944
|
+
// 单独两个子命令而不是 `--done true/false` —— 勾与取消是两件事,各自一个动词更顺手。
|
|
945
|
+
if (action === 'bp-done' || action === 'bp-undone') {
|
|
946
|
+
const r = await setBreakpointDone(root, { id, n, done: action === 'bp-done' });
|
|
947
|
+
return r.msg;
|
|
948
|
+
}
|
|
932
949
|
if (action === 'memo') {
|
|
933
950
|
// 任务随手记:多句/不定长/可追加,不占看板。无 note 时 = 查看已有笔记。
|
|
934
951
|
if (!id) return '用法: abs todo memo <id> "随手记(可多句)" | abs todo memo <id>(查看)';
|
|
@@ -990,7 +1007,16 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
|
990
1007
|
parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
|
|
991
1008
|
const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
|
|
992
1009
|
const bp = [];
|
|
993
|
-
|
|
1010
|
+
// 子项随任务一起搬运(标准 md:缩进的 `- [ ]`;旧格式 `↳ …` 也认)。
|
|
1011
|
+
// 原实现只认 `↳`,换成标准 md 后子项会留在原区——即「任务走了子项没走」。
|
|
1012
|
+
while (i + 1 < lines.length) {
|
|
1013
|
+
const nx = lines[i + 1];
|
|
1014
|
+
if (!nx.trim()) break;
|
|
1015
|
+
const kid = parseChild(nx);
|
|
1016
|
+
if (kid && kid.indent > 0) bp.push(lines[++i]);
|
|
1017
|
+
else if (nx.trimStart().startsWith('↳')) bp.push(lines[++i]);
|
|
1018
|
+
else break;
|
|
1019
|
+
}
|
|
994
1020
|
moved = [head, ...bp];
|
|
995
1021
|
continue;
|
|
996
1022
|
}
|
package/src/todo/archive.js
CHANGED
|
@@ -41,11 +41,13 @@ function parseDoneUnits(bodyLines) {
|
|
|
41
41
|
let cur = null;
|
|
42
42
|
for (const l of bodyLines) {
|
|
43
43
|
if (/^### /.test(l.trim()) || l.trim() === '') { cur = null; continue; } // 分组标题/空行断开会话
|
|
44
|
-
|
|
44
|
+
// 先判子项再判任务:缩进的 `- [ ]` 两个判据都为真,而它应挂到父任务。
|
|
45
|
+
// (标准 md 嵌套下子项本身就是任务行 —— 靠缩进区分层级,不靠形态。)
|
|
46
|
+
if (cur && isChildLine(l)) {
|
|
47
|
+
cur.lines.push(l);
|
|
48
|
+
} else if (isTaskLine(l)) {
|
|
45
49
|
cur = { date: doneDateOf(l), lines: [l] };
|
|
46
50
|
units.push(cur);
|
|
47
|
-
} else if (isChildLine(l) && cur) {
|
|
48
|
-
cur.lines.push(l); // 附属行挂到上一个任务
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
return units;
|
|
@@ -222,7 +224,10 @@ export function archiveDoneInText(text, { keepDays = 3, from = today(), maxLines
|
|
|
222
224
|
date,
|
|
223
225
|
lines: gLines,
|
|
224
226
|
slug: slugFor(date),
|
|
225
|
-
|
|
227
|
+
// 只数顶层任务(缩进 0)—— 子项也是 `- [x]`,用 /^\s*- \[x\]/
|
|
228
|
+
// 会把子项一起计数(实测:1 任务 + 1 子项 → 报 "完成任务 2 条")。
|
|
229
|
+
// 归档标记的语义是「完成了多少条任务」,子项不算。
|
|
230
|
+
count: gLines.filter((l) => /^- \[x\]/.test(l)).length,
|
|
226
231
|
});
|
|
227
232
|
}
|
|
228
233
|
|
package/src/todo/common.js
CHANGED
|
@@ -98,22 +98,61 @@ export function sentenceCount(s) {
|
|
|
98
98
|
* 现在直接报错,让写入方自己写对。
|
|
99
99
|
* 允许的位置:H1 与首个标签之间、标签与首条目之间、区与区之间(排版需要)。 */
|
|
100
100
|
|
|
101
|
-
/** 任务行:`- [ ] …` / `- [x]
|
|
101
|
+
/** 任务行:`- [ ] …` / `- [x] …`。
|
|
102
102
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* 注意:`↳ 断点: …`(冒号形态)**不变**,仍是附属行、不算任务(用户 2026-10-16 明确保留)。 */
|
|
103
|
+
* 顶层任务:顶格(缩进 0)的 `- [ ]` 行。
|
|
104
|
+
* 子任务:缩进的 `- [ ]` 行(标准 Markdown 嵌套列表,2 空格一层)。
|
|
105
|
+
* 两者**都是任务行**(子任务可各自勾选);层级的区分看缩进,不在本函数。
|
|
106
|
+
*
|
|
107
|
+
* 为何也收 `↳ [ ]`:2026-10-16 早上刚把断点任务升级成一等任务(存量数据里有),
|
|
108
|
+
* 现在改用标准 md 嵌套列表,旧形态继续认(存量不能变“看不见”)。 */
|
|
110
109
|
export function isTaskLine(l) {
|
|
110
|
+
// 先排旧式断点行:`↳ [ ] 断点: x` 形似任务行,但它带 `断点:` ⇒ 是子项文本,不是任务。
|
|
111
|
+
if (/^\s*↳\s*\[[ x]\]\s*断点[::]/.test(l)) return false;
|
|
111
112
|
return /^\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])/.test(l);
|
|
112
113
|
}
|
|
113
|
-
/** 判断一行是否为任务附属行(↳ 开头,且**不是**断点任务 —— `↳ [ ]` 已是任务行)。 */
|
|
114
114
|
|
|
115
|
+
/** 一行是否任务附属行(子项):缩进的 `- [ ]`,或旧的 `↳ …` 形态。
|
|
116
|
+
*
|
|
117
|
+
* 标准 md 下子项就是缩进的列表项 —— 它本身也是任务行(isTaskLine 为真),
|
|
118
|
+
* 但**不单独成条**:它挂在父任务下,不占顶层 id、不参与 `abs todo done`。
|
|
119
|
+
* 故 isChild 的判据是「有缩进」,不是「形态不同」。 */
|
|
115
120
|
export function isChildLine(l) {
|
|
116
|
-
|
|
121
|
+
if (/^\s*↳/.test(l)) return true; // 旧形态(含 `↳ [ ]` 断点任务)
|
|
122
|
+
return /^\s+-\s*\[[ x]\]/.test(l); // 缩进的 `- [ ]`(新形态)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- 子项行(标准 md 嵌套列表:缩进 2 空格 + `- [ ]`) ----------
|
|
126
|
+
/** 子项 = 挂在任务下的可勾条目(旧的「断点」就是一条子项)。
|
|
127
|
+
*
|
|
128
|
+
* 格式(2026-10-16 用户定):标准 Markdown 嵌套列表,不搞自定义前缀。
|
|
129
|
+
* - [ ] [进行中] 主任务
|
|
130
|
+
* - [x] 已完成的子项
|
|
131
|
+
* - [ ] 还没做的子项
|
|
132
|
+
* 旧格式 `↳ 断点: 内容` **继续承认**(存量数据不能坏),新写入一律用标准 md。 */
|
|
133
|
+
export const CHILD_INDENT = ' ';
|
|
134
|
+
|
|
135
|
+
/** 解析子项行 → { done, text, indent };不是子项返回 null。
|
|
136
|
+
* 两种形态都收:新的 ` - [ ] 内容` 与旧的 ` ↳ [ ] 断点: 内容` / ` ↳ 断点: 内容`。 */
|
|
137
|
+
export function parseChild(l) {
|
|
138
|
+
const s = String(l || '');
|
|
139
|
+
// 旧形态(↳ 开头):剥掉 ↳ 与可选复选框及可选的 `断点:` 标签
|
|
140
|
+
const old = s.match(/^(\s*)↳\s*(\[[ x]\])?\s*(?:断点[::]\s*)?(.*)$/);
|
|
141
|
+
if (old) {
|
|
142
|
+
// 勾选看 ↳ 后的那个复选框;旧格式无勾 ⇒ false
|
|
143
|
+
if (!old[3].trim()) return null; // `↳ [ ]` 后面没内容 = 不是子项(是旧一等任务的空壳)
|
|
144
|
+
return { done: old[2] === '[x]', text: old[3].trim(), indent: old[1].length };
|
|
145
|
+
}
|
|
146
|
+
// 新形态(缩进的 `- [ ]`)—— 必须缩进,否则那是顶层任务
|
|
147
|
+
const m = s.match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/);
|
|
148
|
+
if (!m) return null;
|
|
149
|
+
return { done: m[2] === 'x', text: m[3].trim(), indent: m[1].length };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 生成子项行(标准 md,统一入口 —— 格式只有这一个真源)。
|
|
153
|
+
* indent 默认 2(挂在顶层任务下);多层嵌套时传更深的值。 */
|
|
154
|
+
export function renderChild(text, { done = false, indent = CHILD_INDENT } = {}) {
|
|
155
|
+
return `${indent}- ${done ? '[x]' : '[ ]'} ${text}`;
|
|
117
156
|
}
|
|
118
157
|
|
|
119
158
|
/** Todo 分区里「合法行」以外的东西 = 裸文本(总结/报告/清单)。
|
|
@@ -157,9 +196,16 @@ export function isPlaceholder(line) {
|
|
|
157
196
|
|
|
158
197
|
export function extractId(text) {
|
|
159
198
|
// 先剥行首状态标记(`[进行中] <id>`),否则 id 会取成 "[进行中]"(2026-09-13 实测)。
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
199
|
+
// 再剥具体的 `- [ ] ` / `↳ [ ] ` 前缀(CLI 传完整行时)。
|
|
200
|
+
const t = String(text)
|
|
201
|
+
.replace(/^\s*(?:-\s*\[[ x]\]|↳\s*\[[ x]\])\s*/, '')
|
|
202
|
+
.replace(/^\[[^\]]+\]\s+/, '');
|
|
203
|
+
// id = 到第一个空白 / `[` 为止的连续字符(id 不允许含空格或方括号)。
|
|
204
|
+
// ★ 必须支持**中文 id**(2026-10-16 实测):原正则 `^[A-Za-z][\w-]*` 要求英文开头,
|
|
205
|
+
// 中文 id 一律返回 null → findTaskLine 永远找不到 → 同 id 重复 start 每次都新建
|
|
206
|
+
// (幂等静默失效,实测同一 id 落了两行)。中文写作里拿短语当 id 很自然,不能只收 ASCII。
|
|
207
|
+
const m = t.match(/^([^\s[\]]+)/);
|
|
208
|
+
return m ? m[1].replace(/\u200b/g, '') : null;
|
|
163
209
|
}
|
|
164
210
|
|
|
165
211
|
/** 提取 " — " 后的 note 部分(剥掉紧跟在 id 后的 `@author` 标记及尾部 `(认领 ...)`)。
|
package/src/todo/spec.js
CHANGED
|
@@ -140,8 +140,8 @@ export const ENTRY_SHAPES = {
|
|
|
140
140
|
if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
|
|
141
141
|
return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
|
|
142
142
|
}
|
|
143
|
-
// 断点附属行不能挤在同一行(应另起 ` ↳ 断点: `)
|
|
144
|
-
if (/↳\s
|
|
143
|
+
// 断点附属行不能挤在同一行(应另起 ` ↳ [ ] 断点: `)
|
|
144
|
+
if (/↳\s*(?:\[[ x]\]\s*)?断点[::]/.test(rest)) return '断点要另起一行写 ` ↳ [ ] 断点: …`';
|
|
145
145
|
return null;
|
|
146
146
|
},
|
|
147
147
|
},
|
package/src/todo/tasks.js
CHANGED
|
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { brainPath } from '../index.js';
|
|
6
6
|
import { editFile, SKIP } from '../lock.js';
|
|
7
|
-
import { today, localStamp, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf } from './common.js';
|
|
7
|
+
import { today, localStamp, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf, parseChild, renderChild } from './common.js';
|
|
8
8
|
import { todoTemplate, normalizeTodo, stateOfTaskLine, stripStateMark } from './structure.js';
|
|
9
9
|
import { TASK_STATES } from './spec.js';
|
|
10
10
|
import { collapseDone, groupDoneSection } from './archive.js';
|
|
@@ -69,9 +69,13 @@ function insertTask(orig, section, text) {
|
|
|
69
69
|
if (idx === -1) {
|
|
70
70
|
lines.push('', header, `- [ ] ${text}`);
|
|
71
71
|
} else {
|
|
72
|
-
//
|
|
72
|
+
// 在该分区标题后、下一个分区标题前插入。
|
|
73
|
+
// ★ 插入点要**跳过尾部空行**:分区末尾本来就有个排版空行(`## Done` 前的那个),
|
|
74
|
+
// 若插到它后面,新任务与前面的内容之间就隔了个空行 —— 每建一条多一个空行,
|
|
75
|
+
// 而闸门明写「条目之间不允许空行」(实测:建 4 条就有 3 个空行)。
|
|
73
76
|
let insertAt = idx + 1;
|
|
74
77
|
while (insertAt < lines.length && !lines[insertAt].startsWith('## ')) insertAt++;
|
|
78
|
+
while (insertAt > idx + 1 && !lines[insertAt - 1].trim()) insertAt--;
|
|
75
79
|
lines.splice(insertAt, 0, `- [ ] ${text}`);
|
|
76
80
|
}
|
|
77
81
|
return lines.join('\n');
|
|
@@ -138,25 +142,73 @@ export function findTaskLine(lines, id) {
|
|
|
138
142
|
return lines.findIndex((l) => idOfTaskLine(l) === want);
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
/**
|
|
145
|
+
/** 子项行解析:从 `idx+1` 起扫连续的缩进行(子项),返回它们的下标。
|
|
146
|
+
* 遇到非子项(下一个顶层任务 / 空行 / 分区标题)就停。 */
|
|
147
|
+
function childRange(lines, idx) {
|
|
148
|
+
const out = [];
|
|
149
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
150
|
+
const line = lines[i];
|
|
151
|
+
if (!line.trim()) break; // 空行 = 子项块结束
|
|
152
|
+
const child = parseChild(line);
|
|
153
|
+
if (child && child.indent > 0) { out.push(i); continue; }
|
|
154
|
+
break; // 另一个顶层任务行 / 标题
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
142
158
|
|
|
159
|
+
/** 追加/更新一条子项(标准 md:缩进 2 空格 + `- [ ]`)。
|
|
160
|
+
*
|
|
161
|
+
* 语义(2026-10-16 用户定):子项就是 todo 的嵌套子任务 —— 可多条、可各自勾选。
|
|
162
|
+
* `abs todo note <id> "…"` **追加**一条(不再覆盖)—— 用户原话「不限,写多少算多少」。
|
|
163
|
+
* 仍保留幂等:与最后一条完全同文就不重复追加(防手滑重复调)。
|
|
164
|
+
* 旧的 `↳ 断点:` 存量行不动(parseChild 认得,继续显示);新写一律用标准 md。 */
|
|
143
165
|
export async function setBreakpoint(brainRoot, { id, text }) {
|
|
144
166
|
const p = await ensureTodo(brainRoot);
|
|
145
|
-
|
|
167
|
+
let added = false;
|
|
146
168
|
const res = await editFile(p, (orig) => {
|
|
147
169
|
const lines = orig.split('\n');
|
|
148
170
|
const idx = findTaskLine(lines, id);
|
|
149
171
|
if (idx === -1) return SKIP;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
const kids = childRange(lines, idx);
|
|
173
|
+
const last = kids.length ? parseChild(lines[kids[kids.length - 1]]) : null;
|
|
174
|
+
if (last && last.text === String(text).trim()) return SKIP; // 幂等:同文不重复追加
|
|
175
|
+
lines.splice(idx + 1 + kids.length, 0, renderChild(text));
|
|
176
|
+
added = true;
|
|
177
|
+
return { text: lines.join('\n'), ok: true };
|
|
178
|
+
});
|
|
179
|
+
if (res === SKIP) {
|
|
180
|
+
return added
|
|
181
|
+
? { ok: true, msg: `✓ 子项已追加 → ${id}` }
|
|
182
|
+
: { ok: false, msg: `[NO_MATCH] 未找到含 "${id}" 的未完成任务行(或子项已存在)` };
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, msg: `✓ 子项已追加 → ${id}\n ${renderChild(text)}` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 勾/取消勾子项:`- [ ] …` ↔ `- [x] …`。
|
|
188
|
+
*
|
|
189
|
+
* 定位:子项没有自己的 id(身份 = 「某任务的第 N 条子项」),故按 n 定位(1-based)。
|
|
190
|
+
* n 缺省 = 勾最后一条(最常用的「刚写的这条做完了」)。
|
|
191
|
+
* 旧格式 `↳ 断点:` 也能勾 —— 勾一下就地升级成标准 md 形态。 */
|
|
192
|
+
export async function setBreakpointDone(brainRoot, { id, n, done = true }) {
|
|
193
|
+
const p = await ensureTodo(brainRoot);
|
|
194
|
+
let picked = null;
|
|
195
|
+
const res = await editFile(p, (orig) => {
|
|
196
|
+
const lines = orig.split('\n');
|
|
197
|
+
const idx = findTaskLine(lines, id);
|
|
198
|
+
if (idx === -1) return SKIP;
|
|
199
|
+
const kids = childRange(lines, idx);
|
|
200
|
+
if (!kids.length) return SKIP;
|
|
201
|
+
const target = n ? kids[n - 1] : kids[kids.length - 1];
|
|
202
|
+
if (target === undefined) return SKIP;
|
|
203
|
+
const child = parseChild(lines[target]);
|
|
204
|
+
picked = child;
|
|
205
|
+
lines[target] = renderChild(child.text, { done });
|
|
155
206
|
return { text: lines.join('\n'), ok: true };
|
|
156
207
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
208
|
+
if (res === SKIP || !picked) {
|
|
209
|
+
return { ok: false, msg: `[NO_MATCH] "${id}" 下没有第 ${n || '(最后)'} 条子项(或任务不存在)` };
|
|
210
|
+
}
|
|
211
|
+
return { ok: true, msg: `✓ 子项 ${done ? '已勾' : '已取消勾'} → ${id}\n ${renderChild(picked.text, { done })}` };
|
|
160
212
|
}
|
|
161
213
|
|
|
162
214
|
// ---------- 任务随手记(memo)----------
|
package/src/todo/validate.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/todo/validate.js — 条目级闸门:形状校验、长度限制、空行检查。
|
|
2
2
|
|
|
3
3
|
import { TODO_SECTIONS, isAllowedTodoSub, INDEX_SECTIONS, ENTRY_SHAPES, indexLinkSectionNeeds, BREAKPOINT_MAX, TASK_LINE_MAX } from './spec.js';
|
|
4
|
-
import { sigOfLine, REPORT_MARKERS, sentenceCount, isTaskLine, isAllowedTodoLine, todoSectionOf } from './common.js';
|
|
4
|
+
import { sigOfLine, REPORT_MARKERS, sentenceCount, isTaskLine, isAllowedTodoLine, todoSectionOf, parseChild } from './common.js';
|
|
5
5
|
|
|
6
6
|
export function checkFileShape(file, text, { lintMode = false } = {}) {
|
|
7
7
|
const issues = [];
|
|
@@ -162,6 +162,29 @@ export function assertTodoContent(text, prevSig = null) {
|
|
|
162
162
|
const l = lines[i];
|
|
163
163
|
const lineNo = i + 1;
|
|
164
164
|
if (l.startsWith('## ')) continue;
|
|
165
|
+
// 子项行要比任务行先判:标准 md 下子项(缩进的 `- [ ]`)**也是任务行**,
|
|
166
|
+
// 若让任务行分支先 continue,子项就只受 TASK_LINE_MAX(400) 管,
|
|
167
|
+
// BREAKPOINT_MAX(200) 的闸门永远走不到(实测:300 字子项静默写入)。
|
|
168
|
+
const kidEarly = parseChild(l);
|
|
169
|
+
if (kidEarly && kidEarly.indent > 0) {
|
|
170
|
+
if (isLegacy(l)) continue; // 存量:不阻写入(lint 报)
|
|
171
|
+
const kbody = kidEarly.text;
|
|
172
|
+
if (kbody.length > BREAKPOINT_MAX) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`✗ 子项太长(第 ${lineNo} 行 ${kbody.length} 字 > ${BREAKPOINT_MAX})—— 子项只写「改到哪个文件哪一步」。\n` +
|
|
175
|
+
` 写不下的内容请分流:实施报告/验证数据 → abs log;经验与坑 → abs note;长设计 → .brain/sources/ 页。\n` +
|
|
176
|
+
` 原文开头: ${kbody.slice(0, 60)}...`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (REPORT_MARKERS.some((re) => re.test(kbody)) || sentenceCount(kbody) > 2) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`✗ 子项里像是塞了报告/清单(第 ${lineNo} 行)—— 子项是一句话(最多两句),不是文档。\n` +
|
|
182
|
+
` todo 只能加 todo:要做的拆成任务,做完的写 abs log,经验写 abs note。\n` +
|
|
183
|
+
` 原文开头: ${kbody.slice(0, 60)}...`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
165
188
|
// 任务行
|
|
166
189
|
if (isTaskLine(l)) {
|
|
167
190
|
if (l.length > TASK_LINE_MAX && !isLegacy(l)) {
|
|
@@ -173,10 +196,11 @@ export function assertTodoContent(text, prevSig = null) {
|
|
|
173
196
|
}
|
|
174
197
|
continue;
|
|
175
198
|
}
|
|
176
|
-
//
|
|
177
|
-
|
|
199
|
+
// 断点附属行(新旧格式用同一个解析器 —— 格式知识只有 common.js 一个真源)
|
|
200
|
+
const bp = parseChild(l);
|
|
201
|
+
if (bp) {
|
|
178
202
|
if (isLegacy(l)) continue; // 存量超长断点:不阻写入(lint 报)
|
|
179
|
-
const body =
|
|
203
|
+
const body = bp.text;
|
|
180
204
|
if (body.length > BREAKPOINT_MAX) {
|
|
181
205
|
throw new Error(
|
|
182
206
|
`✗ 断点太长(第 ${lineNo} 行 ${body.length} 字 > ${BREAKPOINT_MAX})—— 断点只写「改到哪个文件哪一步」。\n` +
|
package/src/todo.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// 保留这一层是为了对外接口稳定:store.js / lint.js / note.js / wrapup.js / 测试的
|
|
3
3
|
// import 路径与名字都不用改。
|
|
4
4
|
|
|
5
|
-
export { TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES, today, localStamp, SEC, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf, sessionOf } from './todo/common.js';
|
|
5
|
+
export { TODO_MAX_LINES, LOG_MAX_LINES, INDEX_MAX_LINES, today, localStamp, SEC, extractId, extractNote, extractAuthor, isLegacyAuthorTag, claimOf, sessionOf, parseChild, renderChild } from './todo/common.js';
|
|
6
6
|
export { LEGACY_SECTION_RENAMES, TODO_SECTIONS, TODO_SUBSECTIONS, isAllowedTodoSub, INDEX_SECTIONS, INDEX_LINK_SECTIONS, LOG_KINDS, H1_TO_FILE, ENTRY_SHAPES, TASK_STATES, RETIRED_SECTIONS, BREAKPOINT_MAX, TASK_LINE_MAX, DONE_KINDS, ARCHIVE_SECTION } from './todo/spec.js';
|
|
7
7
|
export { todoTemplate, rebuildStructure, dropRetiredSections, enforceBrainFormat, normalizeTodo, ensureStateMark, stateOfTaskLine, stripStateMark, checkTaskBox } from './todo/structure.js';
|
|
8
8
|
export { checkFileShape, violationSignatures, assertNoStrayBlank, assertTodoContent } from './todo/validate.js';
|
|
9
9
|
export { doneDateOf, doneKindOf, withDoneKind, collapseDone, archiveDoneInText, renderArchiveBody, upsertArchiveSection, groupDoneSection, insertDoneGrouped } from './todo/archive.js';
|
|
10
|
-
export { readTodo, ensureTodo, setStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setMemo, readMemo, boardText } from './todo/tasks.js';
|
|
10
|
+
export { readTodo, ensureTodo, setStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setBreakpointDone, setMemo, readMemo, boardText } from './todo/tasks.js';
|
package/src/wrapup.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { promises as fs } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { absLogDir } from './index.js';
|
|
11
|
-
import { readTodo, localStamp } from './todo.js';
|
|
11
|
+
import { readTodo, localStamp, parseChild } from './todo.js';
|
|
12
12
|
|
|
13
13
|
export function wrapupLogPath() {
|
|
14
14
|
return join(absLogDir(), 'wrapup.log');
|
|
@@ -62,7 +62,15 @@ export function extractOpenTasks(todoText) {
|
|
|
62
62
|
const bp = [];
|
|
63
63
|
while (i + 1 < lines.length && /^\s*↳/.test(lines[i + 1])) {
|
|
64
64
|
i++;
|
|
65
|
-
|
|
65
|
+
// 断点快照带上勾选标记(`[ ] 内容` / `[x] 内容`)—— 快照是给人看的,
|
|
66
|
+
// 不保留勾选就分不清「当时已完成」与「还没做」(2026-10-16 断点可勾后必需)。
|
|
67
|
+
const parsed = parseChild(lines[i]);
|
|
68
|
+
if (parsed) {
|
|
69
|
+
bp.push(`${parsed.done ? '[x]' : '[ ]'} ${parsed.text}`);
|
|
70
|
+
} else {
|
|
71
|
+
// 非断点的 ↳ 行(如 `↳ [ ] 子任务`)仍照旧剥前缀
|
|
72
|
+
bp.push(lines[i].trim().replace(/^↳\s*(断点|卡点)?[::]?\s*/, ''));
|
|
73
|
+
}
|
|
66
74
|
}
|
|
67
75
|
out.push({ body: taskBody, bp });
|
|
68
76
|
}
|
|
@@ -77,12 +85,13 @@ function isPlaceholderBody(body) {
|
|
|
77
85
|
return /^(待办任务|无|(无)|None|null)$/.test(body) || /^待办任务/.test(body);
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
/** 序列化一个快照块:首行时间戳+归属,后随未完成任务(主体+断点)。
|
|
88
|
+
/** 序列化一个快照块:首行时间戳+归属,后随未完成任务(主体+断点)。
|
|
89
|
+
* 断点 bp 元素已自带勾选前缀(`[ ] 内容` / `[x] 内容`,见 extractOpenTasks)。 */
|
|
81
90
|
function blockText(root, tasks) {
|
|
82
91
|
const head = `[${localStamp()}] wrapup proj=${root}`;
|
|
83
92
|
if (!tasks.length) return head; // 无滞留,仍记一行「干净结束」
|
|
84
93
|
const rows = tasks.map((t) => {
|
|
85
|
-
const bps = t.bp.map((b) => ` ↳ 断点: ${b}`);
|
|
94
|
+
const bps = t.bp.map((b) => (b.startsWith('[') ? ` ↳ ${b}` : ` ↳ 断点: ${b}`));
|
|
86
95
|
return ` - [ ] ${t.body}${bps.length ? '\n' + bps.join('\n') : ''}`;
|
|
87
96
|
});
|
|
88
97
|
return [head, ...rows].join('\n');
|
|
@@ -112,7 +121,11 @@ export function parseWrapup(text) {
|
|
|
112
121
|
}
|
|
113
122
|
const bm = line.match(/^\s*↳/);
|
|
114
123
|
if (bm && cur.tasks.length) {
|
|
115
|
-
|
|
124
|
+
// 回读时统一成 `[ ] 内容` / `[x] 内容` 形态(新写的就是这个;旧快照无勾 → 当未勾)
|
|
125
|
+
const parsed = parseChild(line);
|
|
126
|
+
cur.tasks[cur.tasks.length - 1].bp.push(parsed
|
|
127
|
+
? `${parsed.done ? '[x]' : '[ ]'} ${parsed.text}`
|
|
128
|
+
: line.trim().replace(/^↳\s*(断点|卡点)?[::]?\s*/, ''));
|
|
116
129
|
}
|
|
117
130
|
}
|
|
118
131
|
return byProj;
|