@fanchao8609/agent_brain_sync 1.20.0 → 1.21.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 +6 -0
- package/bin/abs.js +39 -0
- package/hooks/abs.omp.ts +2 -0
- package/hooks/abs.pi.ts +4 -5
- package/package.json +1 -1
- package/skill/abs-agent-brain-sync/SKILL.md +122 -98
- package/src/brain/capture.js +170 -0
- package/src/brain/db-cmd.js +244 -0
- package/src/brain/db.js +406 -0
- package/src/brain/gate.js +19 -0
- package/src/brain/init.js +135 -0
- package/src/brain/layers.js +112 -0
- package/src/brain/load.js +273 -0
- package/src/brain/maintain.js +199 -0
- package/src/brain/rules.js +98 -0
- package/src/brain/shape.js +102 -0
- package/src/brain/summon-cmd.js +62 -0
- package/src/brain/summon.js +236 -0
- package/src/note.js +15 -0
- package/src/store.js +36 -1104
- package/src/text.js +21 -1
- package/src/todo/spec.js +0 -2
- package/src/todo/task-cmd.js +274 -0
package/src/text.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// src/text.js —
|
|
1
|
+
// src/text.js — 纯文本工具。从 store.js 拆出(2026-10-05)。
|
|
2
2
|
//
|
|
3
3
|
// 为何单独一个文件:clip / slugOf 被 log、note、concept 三处共用,而它们本身
|
|
4
4
|
// 零依赖。放在任何业务文件里都会逼出循环 import(note.js ⇄ store.js)。
|
|
5
5
|
// 纯工具独立成格,是谁都能往下依赖的底。
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
6
7
|
|
|
7
8
|
/** 摘要收口:超过 n 码点在**语义边界**收尾(标点 → 空格 → 硬切),加省略号。
|
|
8
9
|
* 坑: 曾直接 `.slice(0, n)` 硬切 → log.md 34/85 条断在词中间(revert-c / file-write-lockin /
|
|
@@ -42,3 +43,22 @@ export function slugOf(text, n = 24) {
|
|
|
42
43
|
}
|
|
43
44
|
return head.replace(/[^\w一-鿿]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
/** 读文件,不存在/读失败返回空串(不抛)。被 load / maintain 共用。 */
|
|
48
|
+
export async function readIfExists(p) {
|
|
49
|
+
try { return (await fs.readFile(p, 'utf8')).trim(); } catch { return ''; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** log.md 是"新在上"(cmdLog 把新行插在标题后)——取**最新** n 条,只认 `## [` 条目行。
|
|
53
|
+
* 坑: 曾用 tailLines 取末尾 → 拿到的永远是最旧几条,而标签写着"最近动作",
|
|
54
|
+
* 于是"开机读状态"最该看的一节长期显示两天前的旧记录(今天的新条目从未显示)。 */
|
|
55
|
+
export function recentLogLines(text, n) {
|
|
56
|
+
return String(text || '')
|
|
57
|
+
.split('\n')
|
|
58
|
+
.filter((l) => /^##\s*\[/.test(l))
|
|
59
|
+
.slice(0, n)
|
|
60
|
+
// 每条按语义边界收口:load 是开机读状态,只求"上会话做到哪"的线索,不须全文。
|
|
61
|
+
// 坑: 不收口时 5 条能占 2.9KB(单条 800B),又是一处随日志变长而膨胀的上下文。
|
|
62
|
+
.map((l) => clip(l, 220))
|
|
63
|
+
.join('\n');
|
|
64
|
+
}
|
package/src/todo/spec.js
CHANGED
|
@@ -55,8 +55,6 @@ export const INDEX_LINK_SECTIONS = ['Concepts', 'Entities', 'Sources', 'Synthese
|
|
|
55
55
|
|
|
56
56
|
export const LOG_KINDS = ['note', 'dev', 'concept', 'ingest'];
|
|
57
57
|
|
|
58
|
-
/** 条目形状规则表:文件 → 每行的预期形状。
|
|
59
|
-
* 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
|
|
60
58
|
/** 条目形状规则表:文件 → 每行的预期形状。
|
|
61
59
|
* 校验器只报告「不像这个形状」的行,不自动改写内容(机器不猜语义)。 */
|
|
62
60
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// src/todo/task-cmd.js — `abs todo` 的命令入口(登记/推进/完成)+ `abs show`。
|
|
2
|
+
// 从 store.js 拆出(2026-11-01):task 就是 todo 的事,归回 todo/ 目录。
|
|
3
|
+
// 行级操作在 tasks.js,本文件只做流程编排与落盘。
|
|
4
|
+
import { brainPath, requireBrain } from '../index.js';
|
|
5
|
+
import { editFile, SKIP } from '../lock.js';
|
|
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';
|
|
8
|
+
import { ensurePersonPage } from '../note.js';
|
|
9
|
+
|
|
10
|
+
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
11
|
+
// log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
|
|
12
|
+
export async function cmdTask({ dir, action, id, section, note, as, session, n }) {
|
|
13
|
+
// ★ id 校验(2026-10-16 模糊测试找出来的真 bug):id 不能含空白/方括号。
|
|
14
|
+
// 不校就是**静默丢信息**:id="带 空格" 写进文件时被 extractId 截成 "带",
|
|
15
|
+
// 后面的 "空格" 与描述一起消失,不报错(实测:`abs todo start "带 空格"` 落成 `- [ ] 带`)。
|
|
16
|
+
// 宁可明确拒掉,也不替人猜哪个词算 id。
|
|
17
|
+
const idStr = id === undefined || id === null ? '' : String(id);
|
|
18
|
+
if (idStr && (idStr.includes(' ') || idStr.includes('\t'))) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`✗ 任务 id 不能含空格(收到 "${idStr}")—— id 是一个词,空白会被当分隔符。\n` +
|
|
21
|
+
` 想要带空格的名字:用连字符或下划线;完整名称写进 --note。`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const root = await requireBrain(dir || process.cwd());
|
|
25
|
+
// 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
|
|
26
|
+
const who = await requireUser();
|
|
27
|
+
// 会话 id(可缺省):塞进已有的 `(认领 日期)` 字段而非另开新字段 ——
|
|
28
|
+
// 归档是整行搬运,塞在旧字段里就能自动跟着走,且 6 处解析里 5 处用的
|
|
29
|
+
// 是 `\(认领[^)]*\)` 通配,零改动兼容(只剩 hook 那处严格正则要改)。
|
|
30
|
+
// 取不到就不写:纯加法,旧行为不变。
|
|
31
|
+
const sid = String(session || process.env.ABS_SESSION_ID || '')
|
|
32
|
+
.replace(/[^\w-]/g, '').slice(0, 16);
|
|
33
|
+
const claim = `(认领 ${today()}${sid ? ' ' + sid : ''})`;
|
|
34
|
+
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
35
|
+
if (action === 'start') {
|
|
36
|
+
// 两区制:未完成一律进 Todo,行首带状态标记(默认 进行中)。
|
|
37
|
+
const st = section && TASK_STATES.includes(section) ? section : '进行中';
|
|
38
|
+
// 唯一进行中约束(2026-10-05 照 rpiv-todo):看板要能回答"现在在做什么",
|
|
39
|
+
// 多个 [进行中] 就答不了。**只提示,不擅自降级别人的任务** ——
|
|
40
|
+
// 机器替人改状态是静默篡改(同 [[falsy-or-eats-zero]] 的"别悄悄改写意图")。
|
|
41
|
+
let warning = '';
|
|
42
|
+
// 只在**新开**进行中时提示(幂等更新同一条不该重复唠叨)。
|
|
43
|
+
let alreadyThere = false;
|
|
44
|
+
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
45
|
+
if (cur === null) return SKIP;
|
|
46
|
+
alreadyThere = cur.split('\n').some((l) => idOfTaskLine(l) === String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, ''));
|
|
47
|
+
return SKIP;
|
|
48
|
+
});
|
|
49
|
+
if (st === '进行中' && !alreadyThere) {
|
|
50
|
+
const others = [];
|
|
51
|
+
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
52
|
+
if (cur === null) return SKIP;
|
|
53
|
+
for (const l of cur.split('\n')) {
|
|
54
|
+
const tid = idOfTaskLine(l);
|
|
55
|
+
if (tid && tid !== id && stateOfTaskLine(l) === '进行中') others.push(tid);
|
|
56
|
+
}
|
|
57
|
+
return SKIP;
|
|
58
|
+
});
|
|
59
|
+
if (others.length) {
|
|
60
|
+
// 提示要具体到「该转成哪个」:只报「已有 N 个进行中」实测不够 ——
|
|
61
|
+
// 用户改方向时,旧任务是「搁置」(不做了),与「滞留中」(还要做)不同,
|
|
62
|
+
// 选错会让下会话分不清该不该接着干。
|
|
63
|
+
warning = `\n ⚠ 已有 ${others.length} 个 [进行中]: ${others.join(', ')}\n`
|
|
64
|
+
+ ' 同时只有一个进行中才说得清"现在在做什么"。\n'
|
|
65
|
+
+ ` 旧任务怎么处理 ——\n`
|
|
66
|
+
+ ` • 用户改方向了/不做了 → abs todo state <id> --note 搁置\n`
|
|
67
|
+
+ ` • 还要做,只是卡住了 → abs todo state <id> --note 滞留中\n`
|
|
68
|
+
+ ` • 还想商量 → abs todo state <id> --note 讨论中`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 接手提示(2026-10-09 用户要求):该任务已属于另一个会话时,接手要告知 ——
|
|
72
|
+
// 用户原话:「不属于我的会话的就别乱进行,但是用户强制要进行那也得接手」。
|
|
73
|
+
// **只提示不拦截**:接手是允许的,但得让它知道这条原来是谁的、自己接手了;
|
|
74
|
+
// 否则静默换了 sid,看板上无声无息地易主。
|
|
75
|
+
let takeover = '';
|
|
76
|
+
if (sid) {
|
|
77
|
+
let prevSid = '';
|
|
78
|
+
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
79
|
+
if (cur === null) return SKIP;
|
|
80
|
+
for (const l of cur.split('\n')) {
|
|
81
|
+
if (idOfTaskLine(l) === String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, '')) {
|
|
82
|
+
prevSid = sessionOf(l);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return SKIP;
|
|
87
|
+
});
|
|
88
|
+
if (prevSid && prevSid !== sid) {
|
|
89
|
+
takeover = `\n ⚠ 接手 —— 该任务原属会话 ${prevSid}(不是本会话 ${sid})\n`
|
|
90
|
+
+ ` 已把会话标记改为 ${sid}\n`
|
|
91
|
+
+ ` 不想接手只想看看 → abs todo state <id> --note 滞留中(释放占用)`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const r = await upsertTask(root, {
|
|
95
|
+
section: SEC.todo,
|
|
96
|
+
text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} ${claim}`,
|
|
97
|
+
session: sid,
|
|
98
|
+
});
|
|
99
|
+
return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}${sid ? ' ' + claim : ''}${takeover}${warning}`;
|
|
100
|
+
}
|
|
101
|
+
if (action === 'rename') {
|
|
102
|
+
// 改任务 id(2026-10-05):人工起错名、或旧 id 不可读时用。
|
|
103
|
+
// (`fix-skill-trigger` 这种名字不是从用户字面抽出来的,是读懂在干什么之后起的。)
|
|
104
|
+
// --note 传新 id(沿用既有 CLI 习惯,免得再加一个 flag)。
|
|
105
|
+
if (!note) throw new Error('✗ 用法: abs todo rename <旧id> --note "<新id>"');
|
|
106
|
+
const newId = String(note).replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim();
|
|
107
|
+
if (!newId || /\s/.test(newId)) throw new Error(`✗ 新 id 不能为空或含空白: "${newId}"`);
|
|
108
|
+
const f = brainPath(root, 'todo.md');
|
|
109
|
+
let done = false;
|
|
110
|
+
await editFile(f, (cur) => {
|
|
111
|
+
if (cur === null) return SKIP;
|
|
112
|
+
const lines = cur.split('\n');
|
|
113
|
+
const want = String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, '');
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
if (idOfTaskLine(lines[i]) !== want) continue;
|
|
116
|
+
lines[i] = lines[i].replace(want, newId);
|
|
117
|
+
done = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
return done ? { text: lines.join('\n') } : SKIP;
|
|
121
|
+
});
|
|
122
|
+
return done ? `✓ 任务改名 ${id} → ${newId}` : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`;
|
|
123
|
+
}
|
|
124
|
+
if (action === 'state') {
|
|
125
|
+
// 改状态标记(原地,不搬区):进行中 / 讨论中 / 滞留中
|
|
126
|
+
if (!note) throw new Error(`✗ 用法: abs todo state <id> --note "${TASK_STATES.join('|')}"`);
|
|
127
|
+
if (!TASK_STATES.includes(note)) {
|
|
128
|
+
throw new Error(`✗ 状态只接受: ${TASK_STATES.join(' | ')}(收到 "${note}")`);
|
|
129
|
+
}
|
|
130
|
+
const f = brainPath(root, 'todo.md');
|
|
131
|
+
let cc = [];
|
|
132
|
+
await editFile(f, (cur) => {
|
|
133
|
+
if (cur === null) return SKIP;
|
|
134
|
+
const r = setStateMark(cur, id, note);
|
|
135
|
+
cc = r.changed;
|
|
136
|
+
return r.changed.length ? { text: r.text } : SKIP;
|
|
137
|
+
});
|
|
138
|
+
return cc.length ? `✓ ${id} 状态 → [${note}]` : `[NO_MATCH] 未找到含 "${id}" 的未完成任务行,或状态未变`;
|
|
139
|
+
}
|
|
140
|
+
if (action === 'note') {
|
|
141
|
+
if (!note) return '用法: abs todo note <id> --note "进度"\n 建议前缀(load 会把它们单独抽出来,让人一眼看到):\n 验证: <跑过的命令/结果>\n 边界: <这方案治不了什么>\n 阻塞: <在等什么,卡在哪>';
|
|
142
|
+
const r = await setBreakpoint(root, { id, text: note });
|
|
143
|
+
return r.msg;
|
|
144
|
+
}
|
|
145
|
+
// 勾/取消勾子项:子项也是待办,做完要勾。
|
|
146
|
+
// 单独两个子命令而不是 `--done true/false` —— 勾与取消是两件事,各自一个动词更顺手。
|
|
147
|
+
// 2026-10-20:命令名从 bp-done/bp-undone 改为 child-done/child-undone
|
|
148
|
+
//(「断点」这个叫法已废弃,子项就是标准 md 嵌套列表项)。
|
|
149
|
+
if (action === 'child-done' || action === 'child-undone') {
|
|
150
|
+
const r = await setBreakpointDone(root, { id, n, done: action === 'child-done' });
|
|
151
|
+
return r.msg;
|
|
152
|
+
}
|
|
153
|
+
if (action === 'memo') {
|
|
154
|
+
// 任务随手记:多句/不定长/可追加,不占看板。无 note 时 = 查看已有笔记。
|
|
155
|
+
if (!id) return '用法: abs todo memo <id> "随手记(可多句)" | abs todo memo <id>(查看)';
|
|
156
|
+
if (!note) {
|
|
157
|
+
const body = await readMemo(root, id);
|
|
158
|
+
return body || `(${id} 没有随手记)`;
|
|
159
|
+
}
|
|
160
|
+
const r = await setMemo(root, { id, text: note });
|
|
161
|
+
return r.msg;
|
|
162
|
+
}
|
|
163
|
+
if (action === 'done') {
|
|
164
|
+
// 找到匹配 id 的行,勾选并归位 Done(简化:若行在某 section 则标记完成)
|
|
165
|
+
// --as 结语:落地(默认) / 否决 / 仅方案 —— 让 [x] 可被信任(见 todo.js DONE_KINDS)
|
|
166
|
+
if (as && !DONE_KINDS.includes(as)) {
|
|
167
|
+
throw new Error(`✗ --as 只接受: ${DONE_KINDS.join(' | ')}(收到 "${as}")`);
|
|
168
|
+
}
|
|
169
|
+
// 结语 = note(MCP 路径)或位置参数(CLI 路径)。坑(2026-09-16 实测):
|
|
170
|
+
// MCP abs_task schema 无 as,AI 按旧 SKILL.md 传 note 会被静默丢弃、行上默认盖【落地】
|
|
171
|
+
// —— 状态失真且无声。现在 note 也当结语;note 里自带【落地/否决/仅方案】时它就是 kind。
|
|
172
|
+
// as 与 note 里的 kind 矛盾 → 报错不猜(猜一侧就是静默篡改另一侧)。
|
|
173
|
+
const conclusion = note ? String(note).replace(/\u200b/g, '') : undefined;
|
|
174
|
+
const inlineKind = conclusion ? (conclusion.match(/【(落地|否决|仅方案)】/) || [])[1] : undefined;
|
|
175
|
+
if (inlineKind && as && inlineKind !== as) {
|
|
176
|
+
throw new Error(`✗ as="${as}" 与 note 里的【${inlineKind}】矛盾 —— 只留一个(改 as,或改 note 里的【】)`);
|
|
177
|
+
}
|
|
178
|
+
const res = await markDone(brainPath(root, 'todo.md'), id, as || inlineKind || '落地', conclusion, sid);
|
|
179
|
+
return res;
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`unknown task action: ${action}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
185
|
+
const res = await editFile(file, (text) => {
|
|
186
|
+
const lines = text.split('\n');
|
|
187
|
+
let changed = false;
|
|
188
|
+
let moved = null;
|
|
189
|
+
const kept = [];
|
|
190
|
+
const wantId = String(id).replace(/\u200b/g, '');
|
|
191
|
+
for (let i = 0; i < lines.length; i++) {
|
|
192
|
+
const l = lines[i];
|
|
193
|
+
if (!moved && idOfTaskLine(l) === wantId) {
|
|
194
|
+
changed = true;
|
|
195
|
+
// 结语(MCP note / CLI 位置参数)拼在日期前 —— 与 CLI `abs todo done <id> [--as ..] <结语>` 同一落点。
|
|
196
|
+
const tail = conclusion ? ` — ${conclusion}` : '';
|
|
197
|
+
// 认领与完成可能是**不同会话**(A 认领、B 收尾)。两个都要留:
|
|
198
|
+
// 只留完成者 → A 的线索断;只留认领者 → (完成 日期) 配着别人,语义拧。
|
|
199
|
+
// 规则(2026-10-09 用户定):
|
|
200
|
+
// 同一个 sid → 只写 `(完成 日期 sid)`
|
|
201
|
+
// 不同 sid → `(认领 日期 A) (完成 日期 B)`
|
|
202
|
+
// 认领无 sid → 只写 `(完成 日期 B)`
|
|
203
|
+
// done 未给 sid → 沿用认领的(无新信息,不凭空编)
|
|
204
|
+
const claimSid = sessionOf(l);
|
|
205
|
+
const doneSid = bySid || claimSid;
|
|
206
|
+
const base = checkTaskBox(stripStateMark(l)).replace(/\s*\(认领[^)]*\)/, '');
|
|
207
|
+
const parts = [];
|
|
208
|
+
if (claimSid && doneSid && claimSid !== doneSid) {
|
|
209
|
+
parts.push(`(认领 ${today()} ${claimSid})`);
|
|
210
|
+
}
|
|
211
|
+
parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
|
|
212
|
+
const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
|
|
213
|
+
const bp = [];
|
|
214
|
+
const leftover = [];
|
|
215
|
+
// 子项搬运:**已勾选**的随任务进 Done;未勾选的不搬(下方直接拒)。
|
|
216
|
+
// 已勾的必须搬:否则任务进 Done 了、子项剩在原区,就是无主孤儿行,
|
|
217
|
+
// 重建分区时会被清掉(历史上就是这么丢过数据)。
|
|
218
|
+
while (i + 1 < lines.length) {
|
|
219
|
+
const nx = lines[i + 1];
|
|
220
|
+
if (!nx.trim()) break;
|
|
221
|
+
const kid = parseChild(nx);
|
|
222
|
+
if (kid && kid.indent > 0) {
|
|
223
|
+
// 无论勾没勾都要 i++(否则原地打转 —— 2026-10-18 实测 OOM)
|
|
224
|
+
const ch = lines[++i];
|
|
225
|
+
if (kid.done) bp.push(ch);
|
|
226
|
+
else leftover.push(kid.text);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// 防御:旧 `↳` 行本应已在 normalizeTodo 里迁掉。万一有路径绕过迁移,
|
|
230
|
+
// 这里必须**消费掉该行**(i++),否则外层 while 原地打转(2026-10-18 OOM 实测)。
|
|
231
|
+
const legacyChild = migrateLegacyChild(nx);
|
|
232
|
+
if (legacyChild !== null) {
|
|
233
|
+
i++; // 消费掉这一行,防止原地打转
|
|
234
|
+
const parsed2 = parseChild(legacyChild);
|
|
235
|
+
if (parsed2 && !parsed2.done) leftover.push(parsed2.text);
|
|
236
|
+
else bp.push(legacyChild);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
moved = [head, ...bp];
|
|
242
|
+
// 还有未勾子项就**不许 done**(2026-10-18 用户定)。
|
|
243
|
+
//
|
|
244
|
+
// 规则:主任务与子任务都是任务,处理方式一样 —— 子任务也是看板上一行,
|
|
245
|
+
// 也要自己完成。只有主子全部完成,主任务才能经 done。
|
|
246
|
+
//
|
|
247
|
+
// 为何不自己搬:上四版的四种做法全被否定——
|
|
248
|
+
// ① 随父任务进 Done(原实现)→ Done 区被 collapseDone 折叠成计数,
|
|
249
|
+
// 未勾子项进去即消失(实测 factory-docs-slim)
|
|
250
|
+
// ② 升为顶层任务(<id>-restN)→ 用户:子任务不能升格
|
|
251
|
+
// ③ 建占位主任务挂住 → 仍是无中生有,与 ② 同病
|
|
252
|
+
// ④ 就这么留着不管 → 无主杂行,重建分区时被清掉
|
|
253
|
+
// 正确做法是**不替用户决定**:告诉他还有哪些没完成,让他先勾或先删。
|
|
254
|
+
if (leftover.length) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`✗ 任务 "${wantId}" 还有 ${leftover.length} 条子任务未完成,不能 done:\n` +
|
|
257
|
+
leftover.map((p) => ` - [ ] ${p}`).join('\n') +
|
|
258
|
+
`\n子任务也是任务,主子全部完成才能 done。先二选一:\n` +
|
|
259
|
+
` • 做完了 → abs todo child-done ${wantId} (勾最后一条;指定第 N 条:child-done ${wantId} --n N)\n` +
|
|
260
|
+
` • 不做了 → 删掉那几行子项,再 abs todo done ${wantId}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
kept.push(l);
|
|
266
|
+
}
|
|
267
|
+
if (!changed || !moved) return SKIP;
|
|
268
|
+
// 归位 + 按日期分组:insertDoneGrouped 一次重建 Done 区(新日期在前,未标日期归尾)
|
|
269
|
+
return { text: insertDoneGrouped(kept.join('\n'), moved) };
|
|
270
|
+
});
|
|
271
|
+
return res === SKIP
|
|
272
|
+
? `[NO_MATCH] 未找到含 "${id}" 的未完成任务行`
|
|
273
|
+
: `✓ 已完成并归位 Done: ${id} 【${kind}】`;
|
|
274
|
+
}
|