@fanchao8609/agent_brain_sync 1.29.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -9
- package/bin/abs.js +9 -64
- package/bin/mcp.js +2 -34
- package/hooks/abs-rounded-editor/format.ts +26 -37
- package/hooks/abs-rounded-editor/index.ts +34 -55
- package/hooks/abs.omp.ts +8 -50
- package/hooks/abs.pi.ts +67 -106
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +11 -9
- package/src/brain/capture.js +0 -6
- package/src/brain/load.js +11 -244
- package/src/note.js +0 -14
- package/src/store.js +1 -1
- package/src/todo/common.js +19 -4
- package/src/todo/spec.js +3 -1
- package/src/todo/task-cmd.js +11 -5
- package/src/todo/tasks.js +32 -2
- package/src/todo.js +1 -1
- package/src/brain/db-cmd.js +0 -387
- package/src/brain/db.js +0 -667
- package/src/brain/evolve.js +0 -135
package/src/brain/load.js
CHANGED
|
@@ -5,12 +5,9 @@ import { brainPath, requireBrain } from '../index.js';
|
|
|
5
5
|
import { getUser, placeholderWarn } from '../userconfig.js';
|
|
6
6
|
import { boardText, readTodo, collapseDone } from '../todo.js';
|
|
7
7
|
import { strandedFor } from '../wrapup.js';
|
|
8
|
-
import { keywords, recentFiles, topicStrength, digest } from '../relevant.js';
|
|
9
8
|
import { readIfExists, recentLogLines } from '../text.js';
|
|
10
|
-
import { listPages } from '../lint.js';
|
|
11
9
|
import { SOURCES_MAX } from '../lint.js';
|
|
12
|
-
// 判「会话页骨架填没填」的唯一真源在 layers.js
|
|
13
|
-
// 两处必须同一个判据 —— 否则会出现「load 说不算未填、检索却把它排除掉」的矛盾。
|
|
10
|
+
// 判「会话页骨架填没填」的唯一真源在 layers.js。
|
|
14
11
|
import { digestFilled } from './layers.js';
|
|
15
12
|
|
|
16
13
|
/** 未填会话页在 load 里最多列几个页名(其余折叠成条数)。
|
|
@@ -181,246 +178,6 @@ export function extractBlocks(todoText) {
|
|
|
181
178
|
return out;
|
|
182
179
|
}
|
|
183
180
|
|
|
184
|
-
/** 从「活跃 todo + 最近改动的文件」推出当前上下文的关键词。
|
|
185
|
-
*
|
|
186
|
-
* 抽成公共函数(2026-11-02):queryHint(给查询词)与 hotPages(给正文)
|
|
187
|
-
* 都要用,两处各算一遍必然漂移。
|
|
188
|
-
*
|
|
189
|
-
* 已剔掉任务行的结构记号:`- [ ] [状态] <id> [[作者]] —` 里只有「—」后面是真内容。
|
|
190
|
-
* 实测教训(2026-09-17):不剔的话 `<id>` 与 `[[作者]]` 会进入词表,
|
|
191
|
-
* 而作者名恰好有自己的 entities/<作者>.md 页 → 命中页名 → 提出「查你自己的名字」。
|
|
192
|
-
*/
|
|
193
|
-
async function contextKeywords(root, todoText) {
|
|
194
|
-
const active = String(todoText || '')
|
|
195
|
-
.split('\n')
|
|
196
|
-
.filter((l) => /^\s*-\s*\[\s*\]/.test(l))
|
|
197
|
-
.map((l) => l.replace(/^\s*-\s*\[\s*\]\s*(?:\[[^\]]*\]\s*)?(?:\S+\s*)?/, '').replace(/\[\[[^\]]*\]\]/g, ' '))
|
|
198
|
-
.join(' ');
|
|
199
|
-
const files = await recentFiles(root, { n: 5 }).catch(() => []);
|
|
200
|
-
const src = [active, files.map((f) => f.name).join(' ')].filter(Boolean).join(' ');
|
|
201
|
-
return { kws: keywords(src), files };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* 开机带出「当前上下文最相关的几页」—— 只给页名 + 一句话摘要,不贴正文。
|
|
206
|
-
*
|
|
207
|
-
* 与 queryHint 的分工(2026-11-02 用户定):
|
|
208
|
-
* queryHint 给「该查什么词」,本函数给「该读哪几页」。两者都只是**送达线索**,
|
|
209
|
-
* 不替代 query / db search,也不贴正文。
|
|
210
|
-
*
|
|
211
|
-
* 为何不贴正文:既有判据是「自动带出正文会让 query 更没人用,且无法按需求变化调整词」。
|
|
212
|
-
* 只给页名 + 摘要把选择权留给人:想看再 abs resolve / 读文件。
|
|
213
|
-
*
|
|
214
|
-
* 输入是环境事实(活跃 todo 的关键词 + 最近改过的文件),不需要人先想到搜索词 ——
|
|
215
|
-
* 这正是不依赖「主动查询」的那条路。
|
|
216
|
-
*
|
|
217
|
-
* 降级:库不存在 / 库坏了 / 任何异常 → 返回空串,不影响 load。
|
|
218
|
-
*
|
|
219
|
-
* @param {string} root 项目根
|
|
220
|
-
* @param {string} todoText
|
|
221
|
-
* @param {number} limit 最多几页
|
|
222
|
-
*/
|
|
223
|
-
export async function hotPagesSection(root, todoText, { limit = 3 } = {}) {
|
|
224
|
-
try {
|
|
225
|
-
const { kws } = await contextKeywords(root, todoText);
|
|
226
|
-
if (!kws.length) return '';
|
|
227
|
-
const { openDb, search, illuminate, dbPath } = await import('./db.js');
|
|
228
|
-
const { promises: fsp } = await import('node:fs');
|
|
229
|
-
const exists = await fsp.stat(dbPath()).then(() => true).catch(() => false);
|
|
230
|
-
if (!exists) return ''; // 库没建 → 静默跳过(不是错误)
|
|
231
|
-
|
|
232
|
-
// 项目名要与入库时一致 —— 铁律按项目隔离,读错项目就是串味
|
|
233
|
-
const project = String(root).split('/').filter(Boolean).pop() || '';
|
|
234
|
-
const h = openDb(dbPath());
|
|
235
|
-
try {
|
|
236
|
-
// 用几个强词合并成一组种子:逐个查再按 id 去重取最高亮度
|
|
237
|
-
const seeds = new Map();
|
|
238
|
-
for (const kw of kws.slice(0, 5)) {
|
|
239
|
-
for (const s of search(h, kw, { limit: 6 })) {
|
|
240
|
-
const prev = seeds.get(s.id);
|
|
241
|
-
if (!prev || s.bright > prev.bright) seeds.set(s.id, s);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
if (!seeds.size) return '';
|
|
245
|
-
const lit = illuminate(h, [...seeds.values()], { limit: limit + 6 })
|
|
246
|
-
// 只留本项目的页:跨项目的经验可以查,但不该在开机首屏默认塞进来
|
|
247
|
-
.filter((r) => r.project === project)
|
|
248
|
-
// 再按**纯亮度**重排 —— illuminate 用的是「层权重 × 亮度」,
|
|
249
|
-
// 那是为了让知识页压过流水页(搜索场景对);但开机首屏要看的是
|
|
250
|
-
// 「跟我现在干的事最贴的」,不是「层级最高的」。
|
|
251
|
-
// 实测:不重排时 L3 的 0.925 会排在 L4 的 1.000 前面 —— 读起来反直觉。
|
|
252
|
-
.sort((a, b) => b.bright - a.bright)
|
|
253
|
-
.slice(0, limit);
|
|
254
|
-
if (!lit.length) return '';
|
|
255
|
-
|
|
256
|
-
const rows = ['--- 当前最相关(按亮度,纯线索不贴正文;要看用 abs resolve <页名>)---'];
|
|
257
|
-
for (const r of lit) {
|
|
258
|
-
const one = await fsp.readFile(brainPath(root, r.dir, `${r.slug}.md`), 'utf8').catch(() => '');
|
|
259
|
-
const about = one ? digest(one, 80) : '';
|
|
260
|
-
const tag = r.from === 'spread' ? ' ↝' : '';
|
|
261
|
-
rows.push(` ${r.bright.toFixed(2)} L${r.layer} [[${r.slug}]]${tag}${about ? ' — ' + about : ''}`);
|
|
262
|
-
}
|
|
263
|
-
return rows.join('\n');
|
|
264
|
-
} finally {
|
|
265
|
-
h.close();
|
|
266
|
-
}
|
|
267
|
-
} catch {
|
|
268
|
-
return ''; // 任何异常都不该弄坏 load
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* 开工提示:直接给出该查的几个词,降低发起 query 的成本。
|
|
274
|
-
*
|
|
275
|
-
* 为何不是"替它查"(2026-09-16 用户定):自动带出正文会让 query 更没人用,
|
|
276
|
-
* 且无法按需求变化调整词。正确做法是把词准备好,查询仍由调用方发。
|
|
277
|
-
*
|
|
278
|
-
* 词从两个环境事实推:活跃 todo 的关键词 + 最近改过的文件名。
|
|
279
|
-
*
|
|
280
|
-
* 挑词用【主题级强度】排序,不取词表前几个(2026-09-17 实测修正):
|
|
281
|
-
* 原实现 `pick = [...en.slice(0,2), ...zh.slice(0,1)]` 取的是最前面的词,
|
|
282
|
-
* 而 todo 行以 `<任务id> [[作者]]` 开头 → 任务 id 与用户名稳占前两位,
|
|
283
|
-
* 实测输出 `abs query queryhint-noise tester 进行`(强度 0/1/0,全是废词)。
|
|
284
|
-
* 现按 topicStrength(tag/页名命中数)降序取,强度 0 的一律不提示
|
|
285
|
-
* —— 命中不了任何页的词,建议去查它等于没建议。
|
|
286
|
-
* 失败静默:读页出错就当没有强词,退回不提示(不影响 load)。
|
|
287
|
-
*
|
|
288
|
-
* @param {string} root 项目根
|
|
289
|
-
* @param {string} todoText
|
|
290
|
-
* @param {{name:string,body:string}[]} [pages] 已读好的页(省一次磁盘扫描)
|
|
291
|
-
*/
|
|
292
|
-
export async function queryHint(root, todoText, pages) {
|
|
293
|
-
const { kws, files } = await contextKeywords(root, todoText);
|
|
294
|
-
if (!kws.length) return '';
|
|
295
|
-
const all = pages || await listPages(brainPath(root)).catch(() => []);
|
|
296
|
-
const strength = topicStrength(all.map((p) => ({ name: p.slug, body: p.body })), kws);
|
|
297
|
-
// 英文词优先(中文 2-gram 单看无意义,只做补充);每组内按强度降序
|
|
298
|
-
const byStrength = (a, b) => (strength.get(b) || 0) - (strength.get(a) || 0) || a.localeCompare(b);
|
|
299
|
-
const en = kws.filter((k) => /^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
|
|
300
|
-
const zh = kws.filter((k) => !/^[a-z][a-z0-9_-]+$/.test(k)).sort(byStrength).filter((k) => strength.get(k) > 0);
|
|
301
|
-
const pick = [...en.slice(0, 2), ...zh.slice(0, 1)].slice(0, 3);
|
|
302
|
-
if (!pick.length) return '';
|
|
303
|
-
const rows = [
|
|
304
|
-
'--- 开工前建议查一次(图谱里有相关经验,别重踩)---',
|
|
305
|
-
` abs query ${pick.join(' ')}`,
|
|
306
|
-
];
|
|
307
|
-
if (files.length) rows.push(` (据最近改动: ${files.slice(0, 3).map((f) => f.name).join(', ')})`);
|
|
308
|
-
return rows.join('\n');
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** 数 sources/ 下的 .md 文件数(只读目录,不解析内容)—— load 顶部提醒用。
|
|
312
|
-
* 坑: root 是【项目根】,图谱在 root/.brain/ 下 —— 必须走 brainPath,
|
|
313
|
-
* 直接用 join(root,'sources') 会 ENOENT 被 catch 吞成 0,成为又一个静默失效。 */
|
|
314
|
-
async function countSources(root) {
|
|
315
|
-
try {
|
|
316
|
-
const files = await fs.readdir(brainPath(root, 'sources'));
|
|
317
|
-
return files.filter((f) => f.endsWith('.md') && !f.startsWith('_')).length;
|
|
318
|
-
} catch { return 0; }
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* recall:卡住时主动取「跟当前处境相关的几页」,不需要先想到检索词。
|
|
325
|
-
*
|
|
326
|
-
* 为何要它(2026-11-03 实测):hotPages 只挂在 load 上,于是只有开工那一刻会送页。
|
|
327
|
-
* 实测一场完整会话从头到尾没调过一次 abs —— 不是检索不准(命中确实准),
|
|
328
|
-
* 而是「要人先想到发起查询」这一步天然被跳过。一个出口只开一次,就等于没开。
|
|
329
|
-
*
|
|
330
|
-
* 与 hotPages 的分工:hotPages 是开机首屏(自动、固定 3 页);
|
|
331
|
-
* recall 是随时可调(agent 卡住/换方向时自己跑),且能补词。
|
|
332
|
-
* 两者共用 contextKeywords + 亮度搜索,不各算一套。
|
|
333
|
-
*
|
|
334
|
-
* 词从哪来:
|
|
335
|
-
* - 传了词 → 用它(这时它就是个带上下文的搜索,仍会补处境词)
|
|
336
|
-
* - 没传词 → 纯靠处境(活跃 todo 的关键词 + 最近改过的文件)
|
|
337
|
-
* 全部凭空无词 → 说清楚缺什么,不返回空列表充数。
|
|
338
|
-
*
|
|
339
|
-
* @param {{dir?:string, terms?:string[], limit?:number}} opts
|
|
340
|
-
*/
|
|
341
|
-
export async function cmdRecall({ dir, terms, limit = 5 } = {}) {
|
|
342
|
-
const given = (terms || []).flatMap((t) => String(t).trim().split(/\s+/)).filter(Boolean);
|
|
343
|
-
let root;
|
|
344
|
-
try {
|
|
345
|
-
root = await requireBrain(dir || process.cwd());
|
|
346
|
-
} catch {
|
|
347
|
-
return '未找到 .brain/ 图谱(无记忆可查)。先在项目根运行: abs init';
|
|
348
|
-
}
|
|
349
|
-
const todo = await readTodo(root).catch(() => '');
|
|
350
|
-
let kws = [];
|
|
351
|
-
let files = [];
|
|
352
|
-
try {
|
|
353
|
-
({ kws, files } = await contextKeywords(root, todo));
|
|
354
|
-
} catch {
|
|
355
|
-
// 拔词失败不该拦住整个 recall
|
|
356
|
-
}
|
|
357
|
-
// 手动传的词优先级最高:用户/agent 已经想清楚要什么,就别把处境词混进去稀释
|
|
358
|
-
const words = given.length ? given : kws.slice(0, 5);
|
|
359
|
-
if (!words.length) {
|
|
360
|
-
return 'recall: 无从判断当前处境(todo 空、近期无改动、也没给词)。\n'
|
|
361
|
-
+ '→ 直接给词: abs recall <词1> <词2>;或先 abs todo add 把在干的事登记上。';
|
|
362
|
-
}
|
|
363
|
-
try {
|
|
364
|
-
const { openDb, search, illuminate, dbPath } = await import('./db.js');
|
|
365
|
-
const { promises: fsp } = await import('node:fs');
|
|
366
|
-
const exists = await fsp.stat(dbPath()).then(() => true).catch(() => false);
|
|
367
|
-
if (!exists) return 'recall: 图数据库还没建(亮度检索需要它)。先跑: abs db rebuild';
|
|
368
|
-
|
|
369
|
-
const project = String(root).split('/').filter(Boolean).pop() || '';
|
|
370
|
-
const h = openDb(dbPath());
|
|
371
|
-
let lit;
|
|
372
|
-
try {
|
|
373
|
-
const seeds = new Map();
|
|
374
|
-
// 整串先查(实测:`db search "并发 锁"` 能出 file-write-locking,
|
|
375
|
-
// 而逐词分开查的种子表里根本没它 —— 2-gram 索引对整串和单词的切法不同,
|
|
376
|
-
// 拆开就把最有用的命中拆没了。所以整串优先,再用逐词兜底补漏。
|
|
377
|
-
const whole = given.length ? words.join(' ') : words.join(' ');
|
|
378
|
-
for (const s of search(h, whole, { limit: 12 })) seeds.set(s.id, s);
|
|
379
|
-
for (const w of words) {
|
|
380
|
-
for (const s of search(h, w, { limit: 6 })) {
|
|
381
|
-
const prev = seeds.get(s.id);
|
|
382
|
-
if (!prev || s.bright > prev.bright) seeds.set(s.id, s);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
if (!seeds.size) {
|
|
386
|
-
return `recall [${words.join(', ')}]: 库里没搜到相关页。\n`
|
|
387
|
-
+ '→ 换个说法: abs recall <别的词>;看图谱全貌: abs query <词>。';
|
|
388
|
-
}
|
|
389
|
-
// 流水页降权(2026-11-03 实测):不加时前 5 位里挤进 2 页 sessions/log-*,
|
|
390
|
-
// 还被排到第一,把 file-write-locking 这种真经验页顶下去。
|
|
391
|
-
// 与 query 用同一套判据(lint.js 的 isTerminal),不另造。只降权不剔除。
|
|
392
|
-
const isFlow = (r) => r.dir === 'sources' || (r.dir === 'sessions' && /^log-/.test(r.slug));
|
|
393
|
-
lit = illuminate(h, [...seeds.values()], { limit: limit * 8 })
|
|
394
|
-
.filter((r) => r.project === project)
|
|
395
|
-
.sort((a, b) => (b.bright - (isFlow(b) ? 1 : 0)) - (a.bright - (isFlow(a) ? 1 : 0)))
|
|
396
|
-
.slice(0, limit);
|
|
397
|
-
} finally {
|
|
398
|
-
h.close();
|
|
399
|
-
}
|
|
400
|
-
if (!lit.length) {
|
|
401
|
-
return `recall [${words.join(', ')}]: 搜到了种子,但本项目的页没有亮起来。\n`
|
|
402
|
-
+ '→ 试试 abs query <词> 看全图命中。';
|
|
403
|
-
}
|
|
404
|
-
const rows = [`recall [${words.join(', ')}] → ${lit.length} 页(按亮度;看全文: abs resolve <页名>)`];
|
|
405
|
-
if (!given.length) {
|
|
406
|
-
const why = [
|
|
407
|
-
files.length ? `近期改动 ${files.length} 个文件` : '',
|
|
408
|
-
todo ? '活跃 todo' : '',
|
|
409
|
-
].filter(Boolean).join(' + ');
|
|
410
|
-
if (why) rows.push(` 处境: ${why}`);
|
|
411
|
-
}
|
|
412
|
-
rows.push('');
|
|
413
|
-
for (const r of lit) {
|
|
414
|
-
const one = await fsp.readFile(brainPath(root, r.dir, `${r.slug}.md`), 'utf8').catch(() => '');
|
|
415
|
-
const about = one ? digest(one, 80) : '';
|
|
416
|
-
rows.push(` ${r.bright.toFixed(2)} L${r.layer} [[${r.slug}]]${about ? ' — ' + about : ''}`);
|
|
417
|
-
}
|
|
418
|
-
return rows.join('\n');
|
|
419
|
-
} catch (e) {
|
|
420
|
-
return `recall 失败: ${e instanceof Error ? e.message : String(e)}`;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
181
|
/** 会话页 fill 判据:骨架段是否还没人填。
|
|
425
182
|
*
|
|
426
183
|
* 为何要这个判据(2026-11-05):`abs ingest` / hook 的 --auto 落的是**空骨架**,
|
|
@@ -478,6 +235,16 @@ async function unfilledSessionSection(root, { oneLine = false } = {}) {
|
|
|
478
235
|
}
|
|
479
236
|
|
|
480
237
|
/** sources 堆积提醒:返回 [标题行, ...内容行] 或 null。失败静默。 */
|
|
238
|
+
/** 数 sources/ 下的 .md 文件数(只读目录,不解析内容)—— load 顶部提醒用。
|
|
239
|
+
* 坑: root 是【项目根】,图谱在 root/.brain/ 下 —— 必须走 brainPath,
|
|
240
|
+
* 直接用 join(root,'sources') 会 ENOENT 被 catch 吞成 0,成为又一个静默失效。 */
|
|
241
|
+
async function countSources(root) {
|
|
242
|
+
try {
|
|
243
|
+
const files = await fs.readdir(brainPath(root, 'sources'));
|
|
244
|
+
return files.filter((f) => f.endsWith('.md') && !f.startsWith('_')).length;
|
|
245
|
+
} catch { return 0; }
|
|
246
|
+
}
|
|
247
|
+
|
|
481
248
|
async function sourcesCountSection(root, { oneLine = false } = {}) {
|
|
482
249
|
const nsrc = await countSources(root);
|
|
483
250
|
if (nsrc <= SOURCES_MAX) return null;
|
package/src/note.js
CHANGED
|
@@ -14,17 +14,6 @@ import { clip, slugOf } from './text.js';
|
|
|
14
14
|
import { summonTail } from './brain/summon.js';
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
/** 惰性调 autoIndex —— 断循环 import:note.js ← capture.js ← db-cmd.js ← note.js。
|
|
18
|
-
* 动态 import 只在真正写入时解析,静态加载期不受影响。失败一律吞掉(见 autoIndex 边界)。 */
|
|
19
|
-
async function autoIndexLazy() {
|
|
20
|
-
try {
|
|
21
|
-
const { autoIndex } = await import('./brain/db-cmd.js');
|
|
22
|
-
return await autoIndex();
|
|
23
|
-
} catch {
|
|
24
|
-
return { ok: false, reason: '入库模块不可用' };
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
17
|
// ---------- note: 经验实时暂存(source 页,一念一落,防流失) ----------
|
|
29
18
|
|
|
30
19
|
export async function cmdNote({ dir, text, tags, when, impact, type }) {
|
|
@@ -104,8 +93,6 @@ export async function cmdNote({ dir, text, tags, when, impact, type }) {
|
|
|
104
93
|
const slug = file.replace(/\.md$/, '');
|
|
105
94
|
await registerInIndex(root, 'Sources', slug, heading);
|
|
106
95
|
await cmdLog({ dir: root, title: clean, kind: 'note', summon: false });
|
|
107
|
-
// 落盘即入库(库已存在时)。失败不阻断 —— 见 autoIndex 的边界说明。
|
|
108
|
-
await autoIndexLazy();
|
|
109
96
|
return `✓ 经验暂存 → sources/${file}\n ${clean} ${atTag(who)}`;
|
|
110
97
|
}
|
|
111
98
|
|
|
@@ -189,7 +176,6 @@ export async function cmdConcept({ dir, slug, title, tags, desc }) {
|
|
|
189
176
|
}
|
|
190
177
|
const oneLine = String(desc || '').trim() || clip(head, 60);
|
|
191
178
|
await registerInIndex(root, 'Concepts', name, oneLine);
|
|
192
|
-
await autoIndexLazy();
|
|
193
179
|
// 不写 log.md(2026-10-05 用户定):`新建概念页 x` 是**命令的副作用**不是成果 ——
|
|
194
180
|
// 38 字符、零信息量,且「该页存在」已由 registerInIndex 落在 index.md 的 Concepts 区
|
|
195
181
|
// (那是 index 的职责)。同件事落两处,且建 10 个页 = 10 行流水噪声自动重现,
|
package/src/store.js
CHANGED
|
@@ -19,7 +19,7 @@ export { BRAIN_SHAPE, LEGACY_MARKS, checkBrainShape } from './brain/shape.js';
|
|
|
19
19
|
import './brain/gate.js';
|
|
20
20
|
|
|
21
21
|
// ---- 只读呈现 ----
|
|
22
|
-
export { cmdBoard, cmdStatus, cmdLoad, cmdShow,
|
|
22
|
+
export { cmdBoard, cmdStatus, cmdLoad, cmdShow, extractBlocks } from './brain/load.js';
|
|
23
23
|
|
|
24
24
|
// ---- Rules ----
|
|
25
25
|
export { RULES_HEADING, RULES_MAX, RULE_BODY_MAX, readRules, cmdRule } from './brain/rules.js';
|
package/src/todo/common.js
CHANGED
|
@@ -127,6 +127,19 @@ export function isChildLine(l) {
|
|
|
127
127
|
* 存量靠 migrateLegacyChild 一次性迁移(那是全仓唯一还认得 `↳` 的地方)。 */
|
|
128
128
|
export const CHILD_INDENT = ' ';
|
|
129
129
|
|
|
130
|
+
/** 行首状态标记的四个词(任务行与子项共用;2026-11-09 起子项也能标)。
|
|
131
|
+
* 放在本文件而不是 spec.js:parseChild / renderChild 要用它判「行首 [xx] 是不是状态」,
|
|
132
|
+
* 而 common.js 是不 import 任何模块的叶子(spec.js 反向 re-export),放 spec 会成环。 */
|
|
133
|
+
export const TASK_STATES = ['进行中', '讨论中', '滞留中', '搁置'];
|
|
134
|
+
|
|
135
|
+
/** 把 `[xx] 正文` 拆成 { state, text }:只认 TASK_STATES 里的词,
|
|
136
|
+
* 别的方括号(如 `[WIP]`)是正文的一部分,原样留在 text 里。 */
|
|
137
|
+
function splitStateMark(rest) {
|
|
138
|
+
const raw = String(rest ?? '');
|
|
139
|
+
const m = raw.match(/^\[([^\]]+)\]\s*(.*)$/);
|
|
140
|
+
return m && TASK_STATES.includes(m[1]) ? { state: m[1], text: m[2] } : { state: null, text: raw };
|
|
141
|
+
}
|
|
142
|
+
|
|
130
143
|
/** 唯一还认得旧 `↳` 写法的地方 —— 存量迁移。
|
|
131
144
|
*
|
|
132
145
|
* 为何保留这一个函数(2026-10-20 用户定:彻底去掉断点写法,但存量要自动迁移):
|
|
@@ -155,13 +168,15 @@ export function migrateLegacyChild(l) {
|
|
|
155
168
|
export function parseChild(l) {
|
|
156
169
|
const m = String(l || '').match(/^(\s+)-\s*\[([ x])\]\s*(.*)$/);
|
|
157
170
|
if (!m) return null;
|
|
158
|
-
|
|
171
|
+
const { state, text } = splitStateMark(m[3]);
|
|
172
|
+
return { done: m[2] === 'x', text: text.trim(), indent: m[1].length, state };
|
|
159
173
|
}
|
|
160
174
|
|
|
161
175
|
/** 生成子项行(标准 md,统一入口 —— 格式只有这一个真源)。
|
|
162
|
-
* indent 默认 2(挂在顶层任务下);多层嵌套时传更深的值。
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
* indent 默认 2(挂在顶层任务下);多层嵌套时传更深的值。
|
|
177
|
+
* state 传四态之一则写成 ` - [ ] [滞留中] 正文`(与任务行同形)。 */
|
|
178
|
+
export function renderChild(text, { done = false, indent = CHILD_INDENT, state = null } = {}) {
|
|
179
|
+
return `${indent}- ${done ? '[x]' : '[ ]'} ${state ? `[${state}] ` : ''}${text}`;
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
/** Todo 分区里「合法行」以外的东西 = 裸文本(总结/报告/清单)。
|
package/src/todo/spec.js
CHANGED
|
@@ -199,7 +199,9 @@ export function indexLinkSectionNeeds(section) {
|
|
|
199
199
|
* 搁置 = 不做了 / 用户改方向了 → 下会话不该捡,除非用户又提
|
|
200
200
|
* 混在一起会让下会话分不清「该不该接着干」。 */
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
// 四个状态词已挪到 common.js(2026-11-09:子项也能标状态,parseChild/renderChild 要用它)——
|
|
203
|
+
// 这里只做转发,别处的 import 路径不变。
|
|
204
|
+
export { TASK_STATES } from './common.js';
|
|
203
205
|
|
|
204
206
|
/** 结构重建(B 档):以标准分区表为准重排整个文件。
|
|
205
207
|
*
|
package/src/todo/task-cmd.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { brainPath, requireBrain } from '../index.js';
|
|
5
5
|
import { editFile, SKIP } from '../lock.js';
|
|
6
6
|
import { requireUser, atTag } from '../userconfig.js';
|
|
7
|
-
import { stripStateMark, checkTaskBox, stateOfTaskLine, upsertTask, today, setBreakpoint, setBreakpointDone, setMemo, readMemo, setStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, DONE_KINDS, withDoneKind, sessionOf, parseChild, migrateLegacyChild, SEC } from '../todo.js';
|
|
7
|
+
import { stripStateMark, checkTaskBox, stateOfTaskLine, upsertTask, today, setBreakpoint, setBreakpointDone, setMemo, readMemo, setStateMark, setChildStateMark, TASK_STATES, insertDoneGrouped, idOfTaskLine, DONE_KINDS, withDoneKind, sessionOf, parseChild, migrateLegacyChild, SEC } from '../todo.js';
|
|
8
8
|
import { ensurePersonPage } from '../note.js';
|
|
9
9
|
|
|
10
10
|
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
@@ -131,8 +131,9 @@ export async function cmdTask({ dir, action, id, section, note, as, session, n }
|
|
|
131
131
|
return done ? `✓ 任务改名 ${id} → ${newId}` : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`;
|
|
132
132
|
}
|
|
133
133
|
if (action === 'state') {
|
|
134
|
-
// 改状态标记(原地,不搬区):进行中 / 讨论中 / 滞留中
|
|
135
|
-
|
|
134
|
+
// 改状态标记(原地,不搬区):进行中 / 讨论中 / 滞留中 / 搁置。
|
|
135
|
+
// 带 n = 改某任务的第 n 条子项(2026-11-09 用户要求给子项加状态);不带给 n = 改任务本身。
|
|
136
|
+
if (!note) throw new Error(`✗ 用法: abs todo state <id> --note "${TASK_STATES.join('|')}" [--n N]`);
|
|
136
137
|
if (!TASK_STATES.includes(note)) {
|
|
137
138
|
throw new Error(`✗ 状态只接受: ${TASK_STATES.join(' | ')}(收到 "${note}")`);
|
|
138
139
|
}
|
|
@@ -140,11 +141,16 @@ export async function cmdTask({ dir, action, id, section, note, as, session, n }
|
|
|
140
141
|
let cc = [];
|
|
141
142
|
await editFile(f, (cur) => {
|
|
142
143
|
if (cur === null) return SKIP;
|
|
143
|
-
const r = setStateMark(cur, id, note);
|
|
144
|
+
const r = n ? setChildStateMark(cur, id, n, note) : setStateMark(cur, id, note);
|
|
144
145
|
cc = r.changed;
|
|
145
146
|
return r.changed.length ? { text: r.text } : SKIP;
|
|
146
147
|
});
|
|
147
|
-
|
|
148
|
+
if (!cc.length) {
|
|
149
|
+
return n
|
|
150
|
+
? `[NO_MATCH] "${id}" 下没有第 ${n} 条子项(或任务不存在),或状态未变`
|
|
151
|
+
: `[NO_MATCH] 未找到含 "${id}" 的未完成任务行,或状态未变`;
|
|
152
|
+
}
|
|
153
|
+
return `✓ ${id} ${n ? `第 ${n} 条子项` : ''}状态 → [${note}]`;
|
|
148
154
|
}
|
|
149
155
|
// 删除任务(2026-11-05 用户要求「每次删除任务可烦人了」)。
|
|
150
156
|
// 与 state=搁置 的区别(别混):
|
package/src/todo/tasks.js
CHANGED
|
@@ -198,13 +198,43 @@ export async function setBreakpointDone(brainRoot, { id, n, done = true }) {
|
|
|
198
198
|
if (target === undefined) return SKIP;
|
|
199
199
|
const child = parseChild(lines[target]);
|
|
200
200
|
picked = child;
|
|
201
|
-
|
|
201
|
+
// 保留印过的状态标记(2026-11-09):只翻复选框,不能把 [滞留中] 这类标记抹掉。
|
|
202
|
+
lines[target] = renderChild(child.text, { done, indent: ' '.repeat(child.indent), state: child.state });
|
|
202
203
|
return { text: lines.join('\n'), ok: true };
|
|
203
204
|
});
|
|
204
205
|
if (res === SKIP || !picked) {
|
|
205
206
|
return { ok: false, msg: `[NO_MATCH] "${id}" 下没有第 ${n || '(最后)'} 条子项(或任务不存在)` };
|
|
206
207
|
}
|
|
207
|
-
return { ok: true, msg: `✓ 子项 ${done ? '已勾' : '已取消勾'} → ${id}\n ${renderChild(picked.text, { done })}` };
|
|
208
|
+
return { ok: true, msg: `✓ 子项 ${done ? '已勾' : '已取消勾'} → ${id}\n ${renderChild(picked.text, { done, state: picked.state })}` };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 改**子项**的状态标记(2026-11-09 用户要求:子项也要能标四态)。
|
|
212
|
+
*
|
|
213
|
+
* 为何不复用 setStateMark:子项没有自己的 id,findTaskLine 只能定位任务行
|
|
214
|
+
* (idOfTaskLine 取的是行首第一个 token,子项那行取到的是正文首字),
|
|
215
|
+
* 所以子项只能按「某任务的第 n 条」定位(与 child-done 同一套坐标)。
|
|
216
|
+
* 任务行:`- [ ] [进行中] TASK-1 …`
|
|
217
|
+
* 子项行:` - [ ] [滞留中] 改到哪一步`
|
|
218
|
+
* n 必填(1-based):不带 n 的「改状态」= 改任务本身(setStateMark),
|
|
219
|
+
* 子项路径只由带 n 的调用进来。非正整数或越界 → 当没匹配,不静默改到别的行。 */
|
|
220
|
+
export function setChildStateMark(text, id, n, state) {
|
|
221
|
+
if (!TASK_STATES.includes(state)) return { text, changed: [] };
|
|
222
|
+
if (!Number.isInteger(n) || n < 1) return { text, changed: [] };
|
|
223
|
+
const lines = String(text ?? '').split('\n');
|
|
224
|
+
const idx = findTaskLine(lines, id);
|
|
225
|
+
if (idx === -1) return { text, changed: [] };
|
|
226
|
+
const kids = childRange(lines, idx);
|
|
227
|
+
const target = kids[n - 1];
|
|
228
|
+
if (target === undefined) return { text, changed: [] };
|
|
229
|
+
const child = parseChild(lines[target]);
|
|
230
|
+
if (!child || child.state === state) return { text, changed: [] };
|
|
231
|
+
const ord = kids.indexOf(target) + 1;
|
|
232
|
+
lines[target] = renderChild(child.text, {
|
|
233
|
+
done: child.done,
|
|
234
|
+
indent: ' '.repeat(child.indent),
|
|
235
|
+
state,
|
|
236
|
+
});
|
|
237
|
+
return { text: lines.join('\n'), changed: [`${id} 第 ${ord} 条子项状态 → ${state}`] };
|
|
208
238
|
}
|
|
209
239
|
|
|
210
240
|
// ---------- 任务随手记(memo)----------
|
package/src/todo.js
CHANGED
|
@@ -7,5 +7,5 @@ export { LEGACY_SECTION_RENAMES, TODO_SECTIONS, TODO_SUBSECTIONS, isAllowedTodoS
|
|
|
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, archiveBrief } from './todo/archive.js';
|
|
10
|
-
export { readTodo, ensureTodo, setStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setBreakpointDone, setMemo, readMemo, boardText } from './todo/tasks.js';
|
|
10
|
+
export { readTodo, ensureTodo, setStateMark, setChildStateMark, addTask, upsertTask, idOfTaskLine, findTaskLine, setBreakpoint, setBreakpointDone, setMemo, readMemo, boardText } from './todo/tasks.js';
|
|
11
11
|
export { migrateLegacyChild, isTaskLine, isChildLine, CHILD_INDENT } from './todo/common.js';
|