@fanchao8609/agent_brain_sync 1.18.0 → 1.19.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/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 +11 -5
- package/src/lock.js +26 -1
- package/src/store.js +77 -10
- package/src/todo/archive.js +9 -4
- package/src/todo/common.js +59 -13
- package/src/todo/spec.js +33 -5
- 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.1",
|
|
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};` +
|
|
@@ -329,8 +331,12 @@ async function checkFiles(vault, pages, indexLinks) {
|
|
|
329
331
|
if (doneLines > DONE_MAX) {
|
|
330
332
|
issues.push(`DONE-PILED-UP: Done 区 ${doneLines} 行 > ${DONE_MAX}; 跑 \`abs todo archive\` 迁出旧日期组`);
|
|
331
333
|
}
|
|
334
|
+
// ★ 只数**顶格**任务行(缩进 0)—— 子项也是 `- [x]`,带 \s* 会把子项一起算进来,
|
|
335
|
+
// 于是「父任务有结语有日期、子项没有」也被报成缺结语(实测 DONE-NO-KIND 误报
|
|
336
|
+
// 一条子项 `- [x] 不急,等下次碰这块再拆`)。与 archive.js 数任务时的判据保持一致。
|
|
337
|
+
const isTopDone = (l) => /^- \[x\]/.test(l);
|
|
332
338
|
const doneBody = todoTxt.split('\n').slice(di + 1);
|
|
333
|
-
const noKind = doneBody.filter((l) =>
|
|
339
|
+
const noKind = doneBody.filter((l) => isTopDone(l) && !doneKindOf(l));
|
|
334
340
|
if (noKind.length) {
|
|
335
341
|
const sample = (noKind[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
|
|
336
342
|
issues.push(
|
|
@@ -338,7 +344,7 @@ async function checkFiles(vault, pages, indexLinks) {
|
|
|
338
344
|
`逐条补 \`--as 落地|否决|仅方案\`(新条目:abs todo done <id> --as …)`,
|
|
339
345
|
);
|
|
340
346
|
}
|
|
341
|
-
const noDate = doneBody.filter((l) =>
|
|
347
|
+
const noDate = doneBody.filter((l) => isTopDone(l) && !doneDateOf(l));
|
|
342
348
|
if (noDate.length) {
|
|
343
349
|
const sample = (noDate[0].match(/- \[x\] (\S+)/) || [, '?'])[1];
|
|
344
350
|
issues.push(
|
package/src/lock.js
CHANGED
|
@@ -14,6 +14,24 @@ export class LockTimeout extends Error {}
|
|
|
14
14
|
* 返回 null = 该文件不归闸门管;否则返回整理后的 text。 */
|
|
15
15
|
let formatGate = null;
|
|
16
16
|
export function setFormatGate(fn) { formatGate = fn; }
|
|
17
|
+
|
|
18
|
+
/** 三个受管文件名 —— 它们存在就是为了当“数据库表”,格式必须恒定。 */
|
|
19
|
+
const MANAGED = new Set(['index.md', 'log.md', 'todo.md']);
|
|
20
|
+
|
|
21
|
+
/** 懒加载闸门:**漏了 setFormatGate 就等于没闸门**,而注册是 import 副作用,
|
|
22
|
+
* 谁少 import 一次 store.js 就静默裂开(2026-10-18 实测:只 import lock.js 时
|
|
23
|
+
* 裸散文直接写进了 index.md)。故这里不信任外部注册,缺失时自己补上。
|
|
24
|
+
*
|
|
25
|
+
* 补不上(= store.js 没调 setFormatGate)就对受管文件硬拒:宁可写失败也不要格崩坏
|
|
26
|
+
* ——写不进去能重试,写脏了没人知道。 */
|
|
27
|
+
async function gateFor(file) {
|
|
28
|
+
if (!formatGate) await import('./store.js'); // 它的模块顶层会调 setFormatGate
|
|
29
|
+
if (formatGate) return formatGate;
|
|
30
|
+
if (MANAGED.has(basename(file))) {
|
|
31
|
+
throw new Error(`✗ ${basename(file)} 是受管文件,但格式闸门未注册 —— 拒绝写入(防止格崩坏)`);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
17
35
|
const LOCK_WAIT_BASE_MS = 15; // 指数退避起始重试间隔
|
|
18
36
|
const LOCK_WAIT_MAX_MS = 150; // 指数退避上限
|
|
19
37
|
// 抢锁总预算:排队等锁的进程须依次排完。多进程高并发(CLI/MCP/hook 同刻抢一文件)下,
|
|
@@ -76,7 +94,14 @@ export async function editFile(file, mutator) {
|
|
|
76
94
|
let write = typeof res === 'string' ? res : res && typeof res.text === 'string' ? res.text : null;
|
|
77
95
|
// 格式闸门:所有写 index/todo/log 的路径(CLI/MCP/hook 共 ~15 处)都在此收口,
|
|
78
96
|
// 不必逐个改调用点 —— 破坏格式的写入在这里被整理回标准形态(见 todo.js)。
|
|
79
|
-
|
|
97
|
+
//
|
|
98
|
+
// ★ 对三个受管文件,闸门**必须存在**:它们不是自由文本,是数据库表。
|
|
99
|
+
// 闸门缺失时以前会静默跳过(直写盘),现在改为自己补加载;补不上就硬拒,
|
|
100
|
+
// 宁可写失败也不要格崩坏——写不进去能重试,写脏了没人知道。
|
|
101
|
+
if (write) {
|
|
102
|
+
const gate = await gateFor(file);
|
|
103
|
+
if (gate) write = gate(file, write, current) ?? write;
|
|
104
|
+
}
|
|
80
105
|
if (write && write !== current) {
|
|
81
106
|
await fs.writeFile(file, write, 'utf8');
|
|
82
107
|
}
|
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';
|
|
@@ -56,9 +56,11 @@ export async function cmdInit({ dir }) {
|
|
|
56
56
|
|
|
57
57
|
async function createSkeleton(root, brain) {
|
|
58
58
|
for (const d of BRAIN_DIRS) await fs.mkdir(join(brain, d), { recursive: true });
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// 走 editFile 而不是 fs.writeFile:三个文件是受管表,写入必须过同一道闸门
|
|
60
|
+
// (2026-10-18:原来直写盘,是个绕过口)。模板本身合法,闸门只做归一。
|
|
61
|
+
for (const [f, tpl] of [['index.md', indexTemplate], ['log.md', logTemplate], ['todo.md', todoTemplate]]) {
|
|
62
|
+
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
63
|
+
}
|
|
62
64
|
// 建完就提示设姓名 —— 否则用户一路到第一次 todo add 才撞墙,
|
|
63
65
|
// 中间 init/load 都不提,根本不知道该设。
|
|
64
66
|
const who = await getUser();
|
|
@@ -116,7 +118,8 @@ export async function cmdRepair({ dir }) {
|
|
|
116
118
|
try {
|
|
117
119
|
await fs.access(join(brain, f));
|
|
118
120
|
} catch {
|
|
119
|
-
|
|
121
|
+
// 同 createSkeleton:补齐也走 editFile 的口子(已存在则 cur 非 null,不覆盖)
|
|
122
|
+
await editFile(join(brain, f), (cur) => (cur === null ? { text: tpl() } : SKIP));
|
|
120
123
|
fixed.push(f);
|
|
121
124
|
}
|
|
122
125
|
}
|
|
@@ -350,7 +353,7 @@ export async function cmdLoad({ dir }) {
|
|
|
350
353
|
);
|
|
351
354
|
if (stranded.length) {
|
|
352
355
|
const rows = stranded.map((t) => {
|
|
353
|
-
const bp = t.bp.length ? `\n ${t.bp.map((b) => `↳ 断点: ${b}`).join('\n ')}` : '';
|
|
356
|
+
const bp = t.bp.length ? `\n ${t.bp.map((b) => (b.startsWith('[') ? `↳ ${b}` : `↳ 断点: ${b}`)).join('\n ')}` : '';
|
|
354
357
|
return ` - ${t.body}${bp}`;
|
|
355
358
|
});
|
|
356
359
|
// 插在项目行之后、其它内容之前
|
|
@@ -807,7 +810,18 @@ function recentLogLines(text, n) {
|
|
|
807
810
|
// ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
|
|
808
811
|
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
809
812
|
// log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
|
|
810
|
-
export async function cmdTask({ dir, action, id, section, note, as, session }) {
|
|
813
|
+
export async function cmdTask({ dir, action, id, section, note, as, session, n }) {
|
|
814
|
+
// ★ id 校验(2026-10-16 模糊测试找出来的真 bug):id 不能含空白/方括号。
|
|
815
|
+
// 不校就是**静默丢信息**:id="带 空格" 写进文件时被 extractId 截成 "带",
|
|
816
|
+
// 后面的 "空格" 与描述一起消失,不报错(实测:`abs todo start "带 空格"` 落成 `- [ ] 带`)。
|
|
817
|
+
// 宁可明确拒掉,也不替人猜哪个词算 id。
|
|
818
|
+
const idStr = id === undefined || id === null ? '' : String(id);
|
|
819
|
+
if (idStr && (idStr.includes(' ') || idStr.includes('\t'))) {
|
|
820
|
+
throw new Error(
|
|
821
|
+
`✗ 任务 id 不能含空格(收到 "${idStr}")—— id 是一个词,空白会被当分隔符。\n` +
|
|
822
|
+
` 想要带空格的名字:用连字符或下划线;完整名称写进 --note。`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
811
825
|
const root = await requireBrain(dir || process.cwd());
|
|
812
826
|
// 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
|
|
813
827
|
const who = await requireUser();
|
|
@@ -929,6 +943,12 @@ export async function cmdTask({ dir, action, id, section, note, as, session }) {
|
|
|
929
943
|
const r = await setBreakpoint(root, { id, text: note });
|
|
930
944
|
return r.msg;
|
|
931
945
|
}
|
|
946
|
+
// 勾/取消勾断点:断点是待办,做完要勾(2026-10-16)。
|
|
947
|
+
// 单独两个子命令而不是 `--done true/false` —— 勾与取消是两件事,各自一个动词更顺手。
|
|
948
|
+
if (action === 'bp-done' || action === 'bp-undone') {
|
|
949
|
+
const r = await setBreakpointDone(root, { id, n, done: action === 'bp-done' });
|
|
950
|
+
return r.msg;
|
|
951
|
+
}
|
|
932
952
|
if (action === 'memo') {
|
|
933
953
|
// 任务随手记:多句/不定长/可追加,不占看板。无 note 时 = 查看已有笔记。
|
|
934
954
|
if (!id) return '用法: abs todo memo <id> "随手记(可多句)" | abs todo memo <id>(查看)';
|
|
@@ -966,6 +986,7 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
|
966
986
|
let changed = false;
|
|
967
987
|
let moved = null;
|
|
968
988
|
const kept = [];
|
|
989
|
+
const leftBehind = [];
|
|
969
990
|
const wantId = String(id).replace(/\u200b/g, '');
|
|
970
991
|
for (let i = 0; i < lines.length; i++) {
|
|
971
992
|
const l = lines[i];
|
|
@@ -990,19 +1011,65 @@ async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
|
990
1011
|
parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
|
|
991
1012
|
const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
|
|
992
1013
|
const bp = [];
|
|
993
|
-
|
|
1014
|
+
const leftover = [];
|
|
1015
|
+
// 子项搬运:已勾选的随任务进 Done;**未勾选的不搬**,留在 Todo 区。
|
|
1016
|
+
//
|
|
1017
|
+
// 为何(2026-10-18 实测):Done 区被 collapseDone 折叠成计数,搬进去即「看板里
|
|
1018
|
+
// 消失」。实测 factory-docs-slim 就带着「待做六链」的 `- [ ]` 落地,那条待办
|
|
1019
|
+
// 此后无处可寻,同时父任务盖着【落地】——看板与事实矛盾。
|
|
1020
|
+
//
|
|
1021
|
+
// 为何不是硬拦(A 方案被否):`abs todo note` 写的子项与断点记录**同形**(都是
|
|
1022
|
+
// ` - [ ] …`),无法从字段上区分「待办」与「过程记录」;硬拦会把所有带断点的
|
|
1023
|
+
// 任务一起拦住(实测弄挂两条既有测试)。改为「不搬 + 点名」,信息不丢且不误伤。
|
|
1024
|
+
while (i + 1 < lines.length) {
|
|
1025
|
+
const nx = lines[i + 1];
|
|
1026
|
+
if (!nx.trim()) break;
|
|
1027
|
+
const kid = parseChild(nx);
|
|
1028
|
+
if (kid && kid.indent > 0) {
|
|
1029
|
+
// 无论勾没勾都要 i++(否则原地打转 —— 2026-10-18 实测 OOM)
|
|
1030
|
+
const ch = lines[++i];
|
|
1031
|
+
if (kid.done) bp.push(ch);
|
|
1032
|
+
else leftover.push(kid.text);
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (nx.trimStart().startsWith('↳')) {
|
|
1036
|
+
const ch = lines[++i];
|
|
1037
|
+
if (/^\s*↳\s*\[ \]/.test(ch)) leftover.push(ch.trim().replace(/^\s*↳\s*\[ \]\s*/, ''));
|
|
1038
|
+
else bp.push(ch);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
994
1043
|
moved = [head, ...bp];
|
|
1044
|
+
// 未勾子项升为独立任务留在 Todo 区:原地留着不行 —— 子项行一旦失去父任务,
|
|
1045
|
+
// 重建分区时就是无主杂行(实测被清掉,待办静默消失)。
|
|
1046
|
+
//
|
|
1047
|
+
// ★ 2026-10-18 补:升格的行必须有**合法 id** 与**父任务线索**。
|
|
1048
|
+
// 原来直接写 `- [ ] ${txt}`,实测 idOfTaskLine 会把正文开头的几个字当成 id
|
|
1049
|
+
// (`- [ ] [进行中] 子任务带 id ABC-1` → id="子任务带"),后续 `abs todo done`
|
|
1050
|
+
// 靠 id 全等定位,于是变成靠句子前几个字匹配 —— 换个措辞就断,两条子项
|
|
1051
|
+
// 开头相同时互相误命中。作者标记沿用父任务的,不另取名(拿不到就留空)。
|
|
1052
|
+
const author = (l.match(/\[\[([^\]]+)\]\]/) || [])[1];
|
|
1053
|
+
leftover.forEach((txt, k) => {
|
|
1054
|
+
const restId = `${wantId}-rest${k + 1}`;
|
|
1055
|
+
kept.push(`- [ ] [进行中] ${restId}${author ? ` [[${author}]]` : ''} — ${txt}(原 ${wantId} 遗留)`);
|
|
1056
|
+
});
|
|
1057
|
+
leftBehind.push(...leftover);
|
|
995
1058
|
continue;
|
|
996
1059
|
}
|
|
997
1060
|
kept.push(l);
|
|
998
1061
|
}
|
|
999
1062
|
if (!changed || !moved) return SKIP;
|
|
1000
1063
|
// 归位 + 按日期分组:insertDoneGrouped 一次重建 Done 区(新日期在前,未标日期归尾)
|
|
1001
|
-
return { text: insertDoneGrouped(kept.join('\n'), moved) };
|
|
1064
|
+
return { text: insertDoneGrouped(kept.join('\n'), moved), leftBehind };
|
|
1002
1065
|
});
|
|
1003
1066
|
return res === SKIP
|
|
1004
1067
|
? `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`
|
|
1005
|
-
: `✓ 已完成并归位 Done: ${id} 【${kind}
|
|
1068
|
+
: `✓ 已完成并归位 Done: ${id} 【${kind}】` +
|
|
1069
|
+
(res.leftBehind?.length
|
|
1070
|
+
? `\n ⚠ ${res.leftBehind.length} 条未勾子项留在 Todo 区(未跟入 Done,仍可在看板看到):\n` +
|
|
1071
|
+
res.leftBehind.map((p) => ` - [ ] ${p}`).join('\n')
|
|
1072
|
+
: '');
|
|
1006
1073
|
}
|
|
1007
1074
|
|
|
1008
1075
|
// ---------- show: 查看 index/todo/log(只读面) ----------
|
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
|
@@ -70,11 +70,28 @@ export const ENTRY_SHAPES = {
|
|
|
70
70
|
'index.md': [
|
|
71
71
|
{
|
|
72
72
|
name: 'INDEX-ENTRY',
|
|
73
|
-
// 条目行:`- [[页名]] — 一句话`(是
|
|
73
|
+
// 条目行:`- [[页名]] — 一句话`(是 5 个链接区里的一行)
|
|
74
|
+
//
|
|
75
|
+
// ★ 2026-10-18 补的白名单另一半:原规则只检查 `- ` 开头的行,**不以 `- ` 开头的
|
|
76
|
+
// 散文全部原样放行**。于是 Sources 区被塞进 `场景:…` 段落、裸编号列表项、
|
|
77
|
+
// `## 标题` 前缀也都存下来了(实测真库被污染 5 条,index 从 69 行膨到 88 行)。
|
|
78
|
+
// 链接区里只应有两种行:`- [[…]]` 条目、空行。其余一律拒。
|
|
74
79
|
test: (l) => {
|
|
75
|
-
if (
|
|
80
|
+
if (!l.trim()) return null; // 空行由 STRAY-BLANK 规则另判
|
|
81
|
+
if (!/^-\s/.test(l)) return '链接区只能放 `- [[页名]] — 一句话`(正文段落/编号列表/代码块都不行)';
|
|
76
82
|
if (!/^-\s*\[\[[^\]]+\]\]/.test(l)) return '应以 `- [[页名]] …` 开头';
|
|
77
83
|
if (!/^-\s*\[\[[^\]]+\]\]\s*[—-]\s*\S/.test(l)) return '`[[页名]]` 后要跟 `— 一句话` 描述';
|
|
84
|
+
// 摘要里不许带 Markdown 结构符(实测污染就是摘要取了页面正文开头)
|
|
85
|
+
if (/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*#+\s/.test(l)) return '摘要不能带 `## ` 标题前缀(取页面首行标题了)';
|
|
86
|
+
// ★ 2026-10-18 补:摘要正文本身必须是**纯文本一句话**。
|
|
87
|
+
// 原规则只拦了 `## ` 前缀,实测下列全放行:摘要写成 `- 列表项`(变成嵌套列
|
|
88
|
+
// 表)、```js(开代码围栏,把后面所有条目吞进去)、`| a | b |`(表格)。
|
|
89
|
+
// index 是表,摘要是一列纯文本,不能携带任何块级结构。
|
|
90
|
+
const desc = l.replace(/^\s*-\s*\[\[[^\]]+\]\]\s*[—-]\s*/, '');
|
|
91
|
+
if (/^(?:[-*+]\s|```|~~~|\||>\s|#{1,6}\s)/.test(desc)) {
|
|
92
|
+
return '摘要必须以文字开头(不能是列表项/代码围栏/表格/引用/标题)';
|
|
93
|
+
}
|
|
94
|
+
if (desc.includes('```')) return '摘要不能含代码围栏 ```(会吞掉后续所有条目)';
|
|
78
95
|
return null;
|
|
79
96
|
},
|
|
80
97
|
},
|
|
@@ -83,7 +100,15 @@ export const ENTRY_SHAPES = {
|
|
|
83
100
|
{
|
|
84
101
|
name: 'LOG-ENTRY',
|
|
85
102
|
test: (l) => {
|
|
86
|
-
|
|
103
|
+
// ★ 2026-10-18 补的白名单:原来只检查 `## ` 开头的行,**其余行全放行**,
|
|
104
|
+
// 于是裸散文/报告能直接写进 log(实测能落盘)。log 只应有四种行:
|
|
105
|
+
// H1、`## Log` 分区标题、`## [时间]` 条目、HTML 注释。
|
|
106
|
+
if (!l.trim()) return null; // 空行另由 STRAY-BLANK 判
|
|
107
|
+
if (/^#\s/.test(l)) return null; // H1
|
|
108
|
+
if (/^<!--/.test(l.trim())) return null; // 模板提示注释
|
|
109
|
+
if (/^##\s+Log\s*$/.test(l)) return null; // 唯一分区标题
|
|
110
|
+
if (!/^##\s/.test(l)) return 'log 里只能有 `## [时间] [[作者]] kind | 正文` 条目行(散文/报告请开一条条目)';
|
|
111
|
+
// 以下只对 `## ` 行判形状
|
|
87
112
|
// ★ log 没有分区概念:`## ` 开头的只能是条目。
|
|
88
113
|
// 否则 `## 2026-10-05 随便写点`(缺方括号/作者/竖线)会被当成「分区标题」滑过去 ——
|
|
89
114
|
// 而 log.md 的标签白名单是 null(不校验),这就是一个漏网口(实测)。
|
|
@@ -95,6 +120,9 @@ export const ENTRY_SHAPES = {
|
|
|
95
120
|
const m = l.match(/^##\s*\[(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2})?)\]\s*\[\[([^\]]+)\]\]\s*([A-Za-z]+)\s*\|\s*(\S.*)$/);
|
|
96
121
|
if (!m) return '应为 `## [YYYY-MM-DD HH:MM] [[作者]] kind | 正文`(log 只能有这种行)';
|
|
97
122
|
if (!LOG_KINDS.includes(m[3])) return `kind 只能是 ${LOG_KINDS.join('/')}(收到 \`${m[3]}\`)`;
|
|
123
|
+
// ★ 2026-10-18:正文不能含代码围栏。一条 log 正文里出现 ``` 会把它之后的
|
|
124
|
+
// 所有条目都吞进代码块(渲染与 grep 双输),而这里本来就是单行条目。
|
|
125
|
+
if (m[4].includes('```')) return 'log 正文不能含 ``` —— 条目是单行摘要,代码请贴文件路径或拆成 source 页';
|
|
98
126
|
// 「正文只有个任务 id」不在这里拒(见下 lintOnly 规则):它是**内容质量**
|
|
99
127
|
// 问题不是**结构格式**问题,当写入闸门会把存量脏库锁死(实测:真库仅一条
|
|
100
128
|
// 就让所有 abs log 失败)。留在此处只会误伤正常短标题(如 `log-0`)。
|
|
@@ -140,8 +168,8 @@ export const ENTRY_SHAPES = {
|
|
|
140
168
|
if (m[1] === ' ' && !/^\[[^\]]+\]\s*\S/.test(rest)) {
|
|
141
169
|
return '未完成任务应为 `- [ ] [状态] …`(状态:进行中/讨论中/滞留中)';
|
|
142
170
|
}
|
|
143
|
-
// 断点附属行不能挤在同一行(应另起 ` ↳ 断点: `)
|
|
144
|
-
if (/↳\s
|
|
171
|
+
// 断点附属行不能挤在同一行(应另起 ` ↳ [ ] 断点: `)
|
|
172
|
+
if (/↳\s*(?:\[[ x]\]\s*)?断点[::]/.test(rest)) return '断点要另起一行写 ` ↳ [ ] 断点: …`';
|
|
145
173
|
return null;
|
|
146
174
|
},
|
|
147
175
|
},
|
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;
|