@fanchao8609/agent_brain_sync 1.16.4 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/bin/abs.js +17 -4
- package/bin/mcp.js +10 -5
- package/hooks/abs.pi.ts +94 -13
- package/package.json +6 -1
- package/skill/abs-agent-brain-sync/SKILL.md +27 -2
- package/src/install.js +5 -9
- package/src/lint.js +14 -1
- package/src/lock.js +1 -1
- package/src/note.js +2 -4
- package/src/page.js +1 -1
- package/src/store.js +65 -34
- package/src/todo/archive.js +320 -0
- package/src/todo/common.js +210 -0
- package/src/todo/spec.js +218 -0
- package/src/todo/structure.js +324 -0
- package/src/todo/tasks.js +238 -0
- package/src/todo/validate.js +216 -0
- package/src/todo.js +10 -1319
- package/src/wrapup.js +1 -1
package/src/store.js
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
// 命令: init / board / status / load / task / query / lint
|
|
3
3
|
import { promises as fs } from 'node:fs';
|
|
4
4
|
import { join, resolve, dirname } from 'node:path';
|
|
5
|
-
import { requireBrain, brainPath, absLogDir
|
|
5
|
+
import { requireBrain, brainPath, absLogDir } from './index.js';
|
|
6
6
|
import { requireUser, atTag, getUser, placeholderWarn } from './userconfig.js';
|
|
7
|
-
import { stripStateMark, ensureStateMark, stateOfTaskLine,
|
|
7
|
+
import { stripStateMark, 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';
|
|
8
8
|
import { setFormatGate, editFile, SKIP } from './lock.js';
|
|
9
9
|
import { appendWrapup, strandedFor } from './wrapup.js';
|
|
10
|
-
import { keywords,
|
|
11
|
-
import { impactOf } from './codegraph.js';
|
|
10
|
+
import { keywords, recentFiles, topicStrength } from './relevant.js';
|
|
12
11
|
// 纯文本工具已拆到 text.js(2026-10-05)—— 零依赖,note/log/concept 共用。
|
|
13
12
|
import { clip, slugOf } from './text.js';
|
|
14
13
|
export { clip, slugOf } from './text.js';
|
|
@@ -221,27 +220,6 @@ export const LEGACY_MARKS = [
|
|
|
221
220
|
['### (未标日期)', '### Undated'],
|
|
222
221
|
];
|
|
223
222
|
|
|
224
|
-
/** 把不符标准的标记改成标准(按整行精确匹配,不碰正文)。
|
|
225
|
-
* log.md 特殊:它没有固定分区,只有条目行 —— 不做结构重排,只改 H1。
|
|
226
|
-
* 返回 { text, changed };无不一致时 changed 为空。 */
|
|
227
|
-
export function fixMarks(text, spec) {
|
|
228
|
-
const lines = String(text ?? '').split('\n');
|
|
229
|
-
const changed = [];
|
|
230
|
-
const h1At = lines.findIndex((l) => l.trim().startsWith('# '));
|
|
231
|
-
if (h1At !== -1 && lines[h1At].trim() !== spec.h1) {
|
|
232
|
-
const cur = lines[h1At].trim();
|
|
233
|
-
const hit = LEGACY_MARKS.find(([o]) => o === cur && o.startsWith('# '));
|
|
234
|
-
if (hit) { lines[h1At] = spec.h1; changed.push(`${cur} → ${spec.h1}`); }
|
|
235
|
-
}
|
|
236
|
-
for (let i = 0; i < lines.length; i++) {
|
|
237
|
-
if (i === h1At) continue;
|
|
238
|
-
const t = lines[i].trim();
|
|
239
|
-
const hit = LEGACY_MARKS.find(([o]) => o === t && !o.startsWith('# '));
|
|
240
|
-
if (hit) { lines[i] = hit[1]; changed.push(`${hit[0]} → ${hit[1]}`); }
|
|
241
|
-
}
|
|
242
|
-
return { text: lines.join('\n'), changed };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
223
|
/**
|
|
246
224
|
* 核对 index/log/todo 的结构,不符就按标准重建(B 档:重排分区 + 内容按归属回填)。
|
|
247
225
|
* 它每次 load 都跑,所以改动立即生效,不必等额外命令。
|
|
@@ -829,10 +807,17 @@ function recentLogLines(text, n) {
|
|
|
829
807
|
// ---------- task: 登记/推进(幂等键 = 行首 id;hook 也调这个) ----------
|
|
830
808
|
// 纪律: task 过程动作(start/done/note/blocked)只改 todo.md, 不写 log.md。
|
|
831
809
|
// log.md 是「工作成果沉淀摘要」(用户/AI 主动 abs log "..." 记), 不收工具动作流水。
|
|
832
|
-
export async function cmdTask({ dir, action, id, section, note, as }) {
|
|
810
|
+
export async function cmdTask({ dir, action, id, section, note, as, session }) {
|
|
833
811
|
const root = await requireBrain(dir || process.cwd());
|
|
834
812
|
// 写操作守卫:无姓名不落盘(hook 调的 wrapup/teardown-check 不经过这里,不受影响)
|
|
835
813
|
const who = await requireUser();
|
|
814
|
+
// 会话 id(可缺省):塞进已有的 `(认领 日期)` 字段而非另开新字段 ——
|
|
815
|
+
// 归档是整行搬运,塞在旧字段里就能自动跟着走,且 6 处解析里 5 处用的
|
|
816
|
+
// 是 `\(认领[^)]*\)` 通配,零改动兼容(只剩 hook 那处严格正则要改)。
|
|
817
|
+
// 取不到就不写:纯加法,旧行为不变。
|
|
818
|
+
const sid = String(session || process.env.ABS_SESSION_ID || '')
|
|
819
|
+
.replace(/[^\w-]/g, '').slice(0, 16);
|
|
820
|
+
const claim = `(认领 ${today()}${sid ? ' ' + sid : ''})`;
|
|
836
821
|
await ensurePersonPage(root, who); // 首次写操作即建人页(已存在不动)
|
|
837
822
|
if (action === 'start') {
|
|
838
823
|
// 两区制:未完成一律进 Todo,行首带状态标记(默认 进行中)。
|
|
@@ -870,11 +855,35 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
|
|
|
870
855
|
+ ` • 还想商量 → abs todo state <id> --note 讨论中`;
|
|
871
856
|
}
|
|
872
857
|
}
|
|
858
|
+
// 接手提示(2026-10-09 用户要求):该任务已属于另一个会话时,接手要告知 ——
|
|
859
|
+
// 用户原话:「不属于我的会话的就别乱进行,但是用户强制要进行那也得接手」。
|
|
860
|
+
// **只提示不拦截**:接手是允许的,但得让它知道这条原来是谁的、自己接手了;
|
|
861
|
+
// 否则静默换了 sid,看板上无声无息地易主。
|
|
862
|
+
let takeover = '';
|
|
863
|
+
if (sid) {
|
|
864
|
+
let prevSid = '';
|
|
865
|
+
await editFile(brainPath(root, 'todo.md'), (cur) => {
|
|
866
|
+
if (cur === null) return SKIP;
|
|
867
|
+
for (const l of cur.split('\n')) {
|
|
868
|
+
if (idOfTaskLine(l) === String(id).replace(/[\u200b\u200c\u200d\ufeff]/g, '')) {
|
|
869
|
+
prevSid = sessionOf(l);
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return SKIP;
|
|
874
|
+
});
|
|
875
|
+
if (prevSid && prevSid !== sid) {
|
|
876
|
+
takeover = `\n ⚠ 接手 —— 该任务原属会话 ${prevSid}(不是本会话 ${sid})\n`
|
|
877
|
+
+ ` 已把会话标记改为 ${sid}\n`
|
|
878
|
+
+ ` 不想接手只想看看 → abs todo state <id> --note 滞留中(释放占用)`;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
873
881
|
const r = await upsertTask(root, {
|
|
874
882
|
section: SEC.todo,
|
|
875
|
-
text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}
|
|
883
|
+
text: `[${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''} ${claim}`,
|
|
884
|
+
session: sid,
|
|
876
885
|
});
|
|
877
|
-
return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}${warning}`;
|
|
886
|
+
return `✓ 任务${r.updated ? '更新(幂等)' : '登记'} → ${brainPath(root, 'todo.md')}\n [${st}] ${id} ${atTag(who)}${note ? ' — ' + note : ''}${sid ? ' ' + claim : ''}${takeover}${warning}`;
|
|
878
887
|
}
|
|
879
888
|
if (action === 'rename') {
|
|
880
889
|
// 改任务 id(2026-10-05):人工起错名、或旧 id 不可读时用。
|
|
@@ -920,6 +929,16 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
|
|
|
920
929
|
const r = await setBreakpoint(root, { id, text: note });
|
|
921
930
|
return r.msg;
|
|
922
931
|
}
|
|
932
|
+
if (action === 'memo') {
|
|
933
|
+
// 任务随手记:多句/不定长/可追加,不占看板。无 note 时 = 查看已有笔记。
|
|
934
|
+
if (!id) return '用法: abs todo memo <id> "随手记(可多句)" | abs todo memo <id>(查看)';
|
|
935
|
+
if (!note) {
|
|
936
|
+
const body = await readMemo(root, id);
|
|
937
|
+
return body || `(${id} 没有随手记)`;
|
|
938
|
+
}
|
|
939
|
+
const r = await setMemo(root, { id, text: note });
|
|
940
|
+
return r.msg;
|
|
941
|
+
}
|
|
923
942
|
if (action === 'done') {
|
|
924
943
|
// 找到匹配 id 的行,勾选并归位 Done(简化:若行在某 section 则标记完成)
|
|
925
944
|
// --as 结语:落地(默认) / 否决 / 仅方案 —— 让 [x] 可被信任(见 todo.js DONE_KINDS)
|
|
@@ -935,13 +954,13 @@ export async function cmdTask({ dir, action, id, section, note, as }) {
|
|
|
935
954
|
if (inlineKind && as && inlineKind !== as) {
|
|
936
955
|
throw new Error(`✗ as="${as}" 与 note 里的【${inlineKind}】矛盾 —— 只留一个(改 as,或改 note 里的【】)`);
|
|
937
956
|
}
|
|
938
|
-
const res = await markDone(brainPath(root, 'todo.md'), id, as || inlineKind || '落地', conclusion);
|
|
957
|
+
const res = await markDone(brainPath(root, 'todo.md'), id, as || inlineKind || '落地', conclusion, sid);
|
|
939
958
|
return res;
|
|
940
959
|
}
|
|
941
960
|
throw new Error(`unknown task action: ${action}`);
|
|
942
961
|
}
|
|
943
962
|
|
|
944
|
-
async function markDone(file, id, kind = '落地', conclusion) {
|
|
963
|
+
async function markDone(file, id, kind = '落地', conclusion, bySid = '') {
|
|
945
964
|
const res = await editFile(file, (text) => {
|
|
946
965
|
const lines = text.split('\n');
|
|
947
966
|
let changed = false;
|
|
@@ -954,10 +973,22 @@ async function markDone(file, id, kind = '落地', conclusion) {
|
|
|
954
973
|
changed = true;
|
|
955
974
|
// 结语(MCP note / CLI 位置参数)拼在日期前 —— 与 CLI `abs todo done <id> [--as ..] <结语>` 同一落点。
|
|
956
975
|
const tail = conclusion ? ` — ${conclusion}` : '';
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
)
|
|
976
|
+
// 认领与完成可能是**不同会话**(A 认领、B 收尾)。两个都要留:
|
|
977
|
+
// 只留完成者 → A 的线索断;只留认领者 → (完成 日期) 配着别人,语义拧。
|
|
978
|
+
// 规则(2026-10-09 用户定):
|
|
979
|
+
// 同一个 sid → 只写 `(完成 日期 sid)`
|
|
980
|
+
// 不同 sid → `(认领 日期 A) (完成 日期 B)`
|
|
981
|
+
// 认领无 sid → 只写 `(完成 日期 B)`
|
|
982
|
+
// done 未给 sid → 沿用认领的(无新信息,不凭空编)
|
|
983
|
+
const claimSid = sessionOf(l);
|
|
984
|
+
const doneSid = bySid || claimSid;
|
|
985
|
+
const base = stripStateMark(l).replace('- [ ]', '- [x]').replace(/\s*\(认领[^)]*\)/, '');
|
|
986
|
+
const parts = [];
|
|
987
|
+
if (claimSid && doneSid && claimSid !== doneSid) {
|
|
988
|
+
parts.push(`(认领 ${today()} ${claimSid})`);
|
|
989
|
+
}
|
|
990
|
+
parts.push(`(完成 ${today()}${doneSid ? ' ' + doneSid : ''})`);
|
|
991
|
+
const head = withDoneKind(`${base}${tail} ${parts.join(' ')}`, kind);
|
|
961
992
|
const bp = [];
|
|
962
993
|
while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('↳')) bp.push(lines[++i]);
|
|
963
994
|
moved = [head, ...bp];
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// src/todo/archive.js — Done 结语契约与任务归档(分组、折叠、迁归档区)。
|
|
2
|
+
|
|
3
|
+
import { today, SEC, isTaskLine, isChildLine, daysAgo } from './common.js';
|
|
4
|
+
import { ARCHIVE_HEADING, ARCHIVE_SECTION } from './spec.js';
|
|
5
|
+
|
|
6
|
+
export function doneDateOf(line) {
|
|
7
|
+
const m = String(line).match(/\(完成\s*(\d{4}-\d{2}-\d{2})[^)]*\)/);
|
|
8
|
+
return m ? m[1] : '';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ---------- Done 结语契约 ----------
|
|
12
|
+
// 为什么需要: `[x]` 原本同时表示「真落地」「评估后不做」「仅设计过」三种完全不同的状态,
|
|
13
|
+
// 读的人(下一个会话/未来的自己)无法区分。实测翻车: 读 daemon 条的 [x] 当成已落地,
|
|
14
|
+
// 实际它跑通后被撤销、代码全删 —— 基于假记录得出「daemon 是过配项」的错误结论。
|
|
15
|
+
// 结论: 图谱的价值取决于「可被信任」,一条状态失真的记录比一百条冗长记录的危害大一个量级。
|
|
16
|
+
|
|
17
|
+
export function doneKindOf(line) {
|
|
18
|
+
const m = String(line).match(/【(落地|否决|仅方案)】/);
|
|
19
|
+
return m ? m[1] : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 追写结语到任务行:插在 `(完成 …)` 之前,幂等(已有则原位替换)。 */
|
|
23
|
+
|
|
24
|
+
export function withDoneKind(line, kind) {
|
|
25
|
+
if (!kind) return line;
|
|
26
|
+
const base = String(line).replace(/【(落地|否决|仅方案)】/g, '').replace(/\s{2,}/g, ' ').trimEnd();
|
|
27
|
+
const i = base.lastIndexOf(' (完成 ');
|
|
28
|
+
if (i === -1) return `${base} 【${kind}】`;
|
|
29
|
+
return `${base.slice(0, i)} 【${kind}】${base.slice(i)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 判断一行是否为任务行(- [x] / - [ ],允许缩进)。 */
|
|
33
|
+
|
|
34
|
+
function parseDoneUnits(bodyLines) {
|
|
35
|
+
const units = [];
|
|
36
|
+
let cur = null;
|
|
37
|
+
for (const l of bodyLines) {
|
|
38
|
+
if (/^### /.test(l.trim()) || l.trim() === '') { cur = null; continue; } // 分组标题/空行断开会话
|
|
39
|
+
if (isTaskLine(l)) {
|
|
40
|
+
cur = { date: doneDateOf(l), lines: [l] };
|
|
41
|
+
units.push(cur);
|
|
42
|
+
} else if (isChildLine(l) && cur) {
|
|
43
|
+
cur.lines.push(l); // 附属行挂到上一个任务
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return units;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 按 (完成 date) 分组 Done 任务单元并重建文本行:新日期在前,未标日期归尾组。
|
|
50
|
+
* 同组内保持输入顺序(幂等)。返回不含 `## Done` 标题的主体行。 */
|
|
51
|
+
|
|
52
|
+
function renderDoneGroups(units) {
|
|
53
|
+
const byDate = new Map();
|
|
54
|
+
const undated = [];
|
|
55
|
+
for (const u of units) {
|
|
56
|
+
if (!u.date) undated.push(u);
|
|
57
|
+
else {
|
|
58
|
+
if (!byDate.has(u.date)) byDate.set(u.date, []);
|
|
59
|
+
byDate.get(u.date).push(u);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const dates = [...byDate.keys()].sort().reverse(); // 新日期在前
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const d of dates) out.push(`### ${d}`, '', ...byDate.get(d).flatMap((u) => u.lines), '');
|
|
65
|
+
if (undated.length) out.push(`### ${SEC.undated}`, '', ...undated.flatMap((u) => u.lines), '');
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 把 Done 区从「全量行」折叠为「按日期计数」,供 `abs load` / `abs todo` 这类
|
|
70
|
+
* 给人(和 AI 上下文)看的视图用。
|
|
71
|
+
*
|
|
72
|
+
* 坑: 这两个命令此前直接 `todo.trim()` 全量打印,而 Done 区**无上限增长** ——
|
|
73
|
+
* 本仓库一处就占 load 输出的 68.8%(19.6KB/28.4KB)。长历史机器上直接把
|
|
74
|
+
* 上下文塞满(实报:「另一台机器 abs load 塞了 40%」)。
|
|
75
|
+
* 这与 hooks.log / wrapup.log 同类问题(那两处有轮转),故这里只给计数,
|
|
76
|
+
* 要看明细用 `abs todo` 加 `--full`,或直接看 todo.md / 归档页。
|
|
77
|
+
*
|
|
78
|
+
* `### 归档` 区(历史的「归档 N 条」标记行)体积小且是长期引用,原样保留。
|
|
79
|
+
* 返回 { text, doneCount };text 不含 `## Done` 标题行。 */
|
|
80
|
+
|
|
81
|
+
export function collapseDone(text) {
|
|
82
|
+
const lines = String(text).split('\n');
|
|
83
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
84
|
+
if (di === -1) return { text: String(text).trim(), doneCount: 0 };
|
|
85
|
+
const head = lines.slice(0, di);
|
|
86
|
+
const rest = lines.slice(di + 1);
|
|
87
|
+
const { groupLines, archiveLines } = splitDoneBody(rest);
|
|
88
|
+
const units = parseDoneUnits(groupLines);
|
|
89
|
+
// 按日期归组计数(新日期在前),未标日期的归尾
|
|
90
|
+
const byDate = new Map();
|
|
91
|
+
let undated = 0;
|
|
92
|
+
for (const u of units) {
|
|
93
|
+
if (!u.date) undated++;
|
|
94
|
+
else byDate.set(u.date, (byDate.get(u.date) || 0) + 1);
|
|
95
|
+
}
|
|
96
|
+
const counts = [...byDate.entries()]
|
|
97
|
+
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
|
|
98
|
+
.map(([d, n]) => `${d} ${n} 条`);
|
|
99
|
+
if (undated) counts.push(`未标日期 ${undated} 条`);
|
|
100
|
+
// 只列最近 MAX_DONE_DATE_LINES 个日期组:日期组本身也随历史增长(实测 300 条跨 28 天
|
|
101
|
+
// 会把 load 输出从 660B 拉到 1203B)。载入视图只需"最近做了多少",早期历史看归档页。
|
|
102
|
+
const MAX_DONE_DATE_LINES = 7;
|
|
103
|
+
const shown = counts.slice(0, MAX_DONE_DATE_LINES);
|
|
104
|
+
if (counts.length > MAX_DONE_DATE_LINES) {
|
|
105
|
+
const rest = counts.length - MAX_DONE_DATE_LINES;
|
|
106
|
+
shown.push(`… 另有 ${rest} 个更早日期组(abs todo --full 看全量)`);
|
|
107
|
+
}
|
|
108
|
+
const doneLines = units.length
|
|
109
|
+
? [`## Done(${units.length} 条,按日期折叠)`, ...shown.map((c) => `- ${c}`)]
|
|
110
|
+
: ['## Done(0 条)'];
|
|
111
|
+
const out = [...head, ...doneLines, '', ...archiveLines].join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
112
|
+
return { text: out, doneCount: units.length };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 归档标记区标题。它在 Done 区内部、日期分组之后,形如:
|
|
116
|
+
* ### Archived
|
|
117
|
+
* - [[2026-09-10-todo归档]] 完成任务 10 条
|
|
118
|
+
* 这区不是任务行,不能被 parseDoneUnits 吃挂,否则 markDone 重建 Done 时会把它丢掉。
|
|
119
|
+
* 故先切出去当"不透明区域"原样保留。 */
|
|
120
|
+
|
|
121
|
+
function splitDoneBody(bodyLines) {
|
|
122
|
+
const ai = bodyLines.findIndex((l) => l.trim() === ARCHIVE_HEADING);
|
|
123
|
+
if (ai === -1) return { groupLines: bodyLines, archiveLines: [] };
|
|
124
|
+
let end = ai + 1;
|
|
125
|
+
while (end < bodyLines.length && !/^#{2,3} \S/.test(bodyLines[end].trim())) end++;
|
|
126
|
+
return {
|
|
127
|
+
groupLines: [...bodyLines.slice(0, ai), ...bodyLines.slice(end)],
|
|
128
|
+
archiveLines: bodyLines.slice(ai, end),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 合并归档标记行:**每天一行** `- [[<日期>-todo归档]] 完成任务 N 条`。
|
|
133
|
+
* 计数按 slug **累计**(同一天分两批归档时叠加,否则标记行与归档页实际内容不符);
|
|
134
|
+
* 已存在的其他天的标记行原样保留。返回含标题的行数组。 */
|
|
135
|
+
|
|
136
|
+
function mergeArchiveLines(existing, entries) {
|
|
137
|
+
const out = existing.length ? [...existing] : [ARCHIVE_HEADING];
|
|
138
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
139
|
+
for (const { slug, count } of entries) {
|
|
140
|
+
const i = out.findIndex((l) => l.includes(`[[${slug}]]`));
|
|
141
|
+
const prev = i !== -1 ? Number((out[i].match(/完成任务 (\d+) 条/) || [])[1] || 0) : 0;
|
|
142
|
+
const line = `- [[${slug}]] 完成任务 ${prev + count} 条`;
|
|
143
|
+
if (i !== -1) out[i] = line;
|
|
144
|
+
else out.push(line);
|
|
145
|
+
}
|
|
146
|
+
out.push('');
|
|
147
|
+
// 标记行按日期倒序(与 Done 日期组同风格:新的在上)。
|
|
148
|
+
// 否则顺序 = 归档先后,多次归档后读起来是乱的(如 08/09/07)。
|
|
149
|
+
const dateOf = (l) => (String(l).match(/(\d{4}-\d{2}-\d{2})/) || [''])[0];
|
|
150
|
+
const body = out.slice(1).filter((l) => l.trim());
|
|
151
|
+
body.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
|
|
152
|
+
return [out[0], ...body, ''];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 本地日期减 n 天(YYYY-MM-DD)。纯字符串入出,避免时区漂移。 */
|
|
156
|
+
|
|
157
|
+
function isUndoneLine(l) {
|
|
158
|
+
return /^\s*- \[ \]/.test(l);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 把 Done 区里"可归档"的日期组摘出来,并追回归档标记行。**纯函数**(不碰磁盘)。
|
|
163
|
+
* 规则(用户定):
|
|
164
|
+
* ① 只保留近 keepDays 天(含今天);更早的才归档。
|
|
165
|
+
* ② 某一天只要还有未完成(- [ ])任务,**整天都不归档**(不拆半天)。
|
|
166
|
+
* ③ 每**天**一个文件,slug 为 `log-<日期>`(由 slugFor 给)—— 即**归进那天的会话快照**
|
|
167
|
+
* (同一天的东西放一处,别为归档另建文件)。本函数只负责从 todo 文本里移除
|
|
168
|
+
* + 在 Done 区尾部的 `### 归档` 区**每天记一行** `- [[log-<日期>]] 完成任务 N 条`。
|
|
169
|
+
* 无日期组(### (未标日期))无法判天数,**保守不归档**。
|
|
170
|
+
* @returns {{text:string, archived:{date:string,lines:string[],slug:string,count:number}[], skipped:{date:string,reason:string}[], count:number}}
|
|
171
|
+
*/
|
|
172
|
+
|
|
173
|
+
export function archiveDoneInText(text, { keepDays = 3, from = today(), maxLines = 0, slugFor = (d) => `log-${d}` } = {}) {
|
|
174
|
+
const lines = String(text || '').split('\n');
|
|
175
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
176
|
+
if (di === -1) return { text, archived: [], skipped: [], count: 0 };
|
|
177
|
+
const head = lines.slice(0, di + 1);
|
|
178
|
+
const { groupLines, archiveLines } = splitDoneBody(lines.slice(di + 1));
|
|
179
|
+
const units = parseDoneUnits(groupLines);
|
|
180
|
+
if (!units.length) return { text, archived: [], skipped: [], count: 0 };
|
|
181
|
+
|
|
182
|
+
const keep = Math.max(1, Number(keepDays) || 3);
|
|
183
|
+
// 保留 cutoff..from 这 keep 天;比 cutoff 更早的才归档
|
|
184
|
+
let cutoff = daysAgo(keep - 1, from);
|
|
185
|
+
// 超限放宽(2026-10-05 加):ROOT-OVER-SIZE 限看板 ≤60 行,而 keepDays≥1 意味着
|
|
186
|
+
// 密集工作日(一天几十条 Done)必然超限 —— 两条规则打架,且 archive 拒绝动,
|
|
187
|
+
// 于是每天提交都被 lint 拦一次。修法:**看板已超行数上限时,把 cutoff 推到当天**,
|
|
188
|
+
// 让「整天已完成」的组可以立刻归档(仍有 ② 未完成则整天不归档 的保护)。
|
|
189
|
+
// 注意裁到「今天之后」:判断是 `date >= cutoff → 保留`,若 cutoff = 今天,
|
|
190
|
+
// 今天的组仍被保留(首版就栽在这,dry-run 一看当天没进归档列表)。
|
|
191
|
+
if (maxLines && lines.length > maxLines) cutoff = daysAgo(-1, from);
|
|
192
|
+
|
|
193
|
+
const byDate = new Map();
|
|
194
|
+
const undated = [];
|
|
195
|
+
for (const u of units) {
|
|
196
|
+
if (!u.date) { undated.push(u); continue; }
|
|
197
|
+
if (!byDate.has(u.date)) byDate.set(u.date, []);
|
|
198
|
+
byDate.get(u.date).push(u);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const archived = [];
|
|
202
|
+
const skipped = [];
|
|
203
|
+
const keepUnits = [...undated];
|
|
204
|
+
if (undated.length) skipped.push({ date: '(未标日期)', reason: '无完成日期,无法判断天数(保守不归档)' });
|
|
205
|
+
|
|
206
|
+
for (const date of [...byDate.keys()].sort()) {
|
|
207
|
+
const us = byDate.get(date);
|
|
208
|
+
if (date >= cutoff) { keepUnits.push(...us); continue; } // ① 近 N 天:保留
|
|
209
|
+
const undone = us.filter((u) => isUndoneLine(u.lines[0]));
|
|
210
|
+
if (undone.length) { // ② 有未完成:整天不归档
|
|
211
|
+
skipped.push({ date, reason: `有 ${undone.length} 条未完成,整天不归档` });
|
|
212
|
+
keepUnits.push(...us);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const gLines = us.flatMap((u) => u.lines); // ③ 可归档
|
|
216
|
+
archived.push({
|
|
217
|
+
date,
|
|
218
|
+
lines: gLines,
|
|
219
|
+
slug: slugFor(date),
|
|
220
|
+
count: gLines.filter((l) => /^\s*- \[x\]/.test(l)).length,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!archived.length) return { text, archived: [], skipped, count: 0 };
|
|
225
|
+
|
|
226
|
+
const count = archived.reduce((n, g) => n + g.count, 0);
|
|
227
|
+
const entries = archived.map((g) => ({ slug: g.slug, count: g.count }));
|
|
228
|
+
const rebuilt = [...head, ...renderDoneGroups(keepUnits), ...mergeArchiveLines(archiveLines, entries)];
|
|
229
|
+
return { text: rebuilt.join('\n').replace(/\n+$/, '\n'), archived, skipped, count };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 归档页正文(按日期分组,原文保留)。供同日追写复用。 */
|
|
233
|
+
|
|
234
|
+
export function renderArchiveBody(groups) {
|
|
235
|
+
const out = [];
|
|
236
|
+
for (const g of groups) out.push(`### ${g.date}`, '', ...g.lines, '');
|
|
237
|
+
return out.join('\n').replace(/\n+$/, '\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 归档区标题。归档内容全部落在这个二级标题下,**与 AI 手写的会话快照共存**:
|
|
241
|
+
* 同一天的记录(快照 + 任务明细)放一个文件里,而不是分两个文件。
|
|
242
|
+
* 写入侧只动这一段:标题之前的内容原样保留(已存在的人工内容一律不覆盖)。 */
|
|
243
|
+
|
|
244
|
+
export function upsertArchiveSection(body, group) {
|
|
245
|
+
const chunk = renderArchiveBody([group]);
|
|
246
|
+
if (body == null) {
|
|
247
|
+
// ① 新文件:只有归档段(无手写快照也合法 —— 那天本来就只有任务记录)
|
|
248
|
+
const fm = [
|
|
249
|
+
'---',
|
|
250
|
+
'tags: [session-log, todo-archive, 历史]',
|
|
251
|
+
`updated: ${group.date}`,
|
|
252
|
+
'status: reviewed',
|
|
253
|
+
'---',
|
|
254
|
+
'',
|
|
255
|
+
`# ${group.date} 记录`,
|
|
256
|
+
'',
|
|
257
|
+
'> 本页 = 该日的会话快照(若有)+ 从 `todo.md` Done 区迁出的任务明细。',
|
|
258
|
+
'',
|
|
259
|
+
ARCHIVE_SECTION,
|
|
260
|
+
'',
|
|
261
|
+
chunk.trimEnd(),
|
|
262
|
+
'',
|
|
263
|
+
];
|
|
264
|
+
return fm.join('\n');
|
|
265
|
+
}
|
|
266
|
+
// ② 已存在:只动归档段,段外原样
|
|
267
|
+
const lines = body.split('\n');
|
|
268
|
+
const i = lines.findIndex((l) => l.trim() === ARCHIVE_SECTION);
|
|
269
|
+
if (i === -1) {
|
|
270
|
+
// 有文件但还没归档段 → 追加到末尾(不搅动已有内容)
|
|
271
|
+
return `${body.replace(/\s*$/, '')}\n\n${ARCHIVE_SECTION}\n\n${chunk.trimEnd()}\n`;
|
|
272
|
+
}
|
|
273
|
+
// 找到本段结束(下一个同级或更高级标题)
|
|
274
|
+
let j = i + 1;
|
|
275
|
+
while (j < lines.length && !/^#{1,2} \S/.test(lines[j].trim())) j++;
|
|
276
|
+
const head = lines.slice(0, i + 1);
|
|
277
|
+
const tailPart = lines.slice(j);
|
|
278
|
+
const existingInner = lines.slice(i + 1, j).join('\n').trim();
|
|
279
|
+
const merged = existingInner ? `${existingInner}\n\n${chunk.trimEnd()}` : chunk.trimEnd();
|
|
280
|
+
return [...head, '', merged, '', ...tailPart].join('\n').replace(/\n{3,}/g, '\n\n');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 幂等:把 todo 全文里平铺的旧 Done 区按日期分组(新日期在前,未标日期归尾)。
|
|
284
|
+
* 已是分组态(### date)则原样返回——惰性迁移用,避免每次读都动文件。 */
|
|
285
|
+
|
|
286
|
+
export function groupDoneSection(text) {
|
|
287
|
+
const lines = String(text || '').split('\n');
|
|
288
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
289
|
+
if (di === -1) return text;
|
|
290
|
+
const body = lines.slice(di + 1);
|
|
291
|
+
const first = body.find((l) => l.trim());
|
|
292
|
+
if (first && /^### /.test(first.trim())) return text; // 已分组,幂等不动
|
|
293
|
+
const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
|
|
294
|
+
const units = parseDoneUnits(groupLines);
|
|
295
|
+
if (!units.length) return text;
|
|
296
|
+
return [...lines.slice(0, di + 1), ...renderDoneGroups(units), ...archiveLines]
|
|
297
|
+
.join('\n').replace(/\n+$/, '\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 把 moved 单元(已完成任务行+附属行)放入 Done 区并整体按日期分组重建。
|
|
301
|
+
* 供 markDone 用,锁内一次完成「归位 + 分组」。无 Done 区则文件尾补建。 */
|
|
302
|
+
|
|
303
|
+
export function insertDoneGrouped(text, movedLines) {
|
|
304
|
+
const date = doneDateOf(movedLines[0]) || today();
|
|
305
|
+
const lines = String(text || '').split('\n');
|
|
306
|
+
const di = lines.findIndex((l) => l.startsWith('## Done'));
|
|
307
|
+
const newUnit = { date, lines: movedLines };
|
|
308
|
+
if (di === -1) {
|
|
309
|
+
const header = `## ${SEC.done}`;
|
|
310
|
+
return [...lines, '', header, '', ...renderDoneGroups([newUnit])].join('\n').replace(/\n+$/, '\n');
|
|
311
|
+
}
|
|
312
|
+
const head = lines.slice(0, di + 1);
|
|
313
|
+
const body = lines.slice(di + 1);
|
|
314
|
+
const { groupLines, archiveLines } = splitDoneBody(body); // 归档标记区原样保留
|
|
315
|
+
// 新完成单元置前:同日期组内新在最上(renderDoneGroups 按 encounter 顺序保持,日期再倒序排)
|
|
316
|
+
const units = [newUnit, ...parseDoneUnits(groupLines)];
|
|
317
|
+
return [...head, ...renderDoneGroups(units), ...archiveLines].join('\n').replace(/\n+$/, '\n');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 占位/空任务行(老模板的 "(无)"/"无"/纯 - [ ])不迁移
|