新增: 看板归档器 + 已完成内容再分离

- scripts/archive-todo.cjs: 把看板已完成内容分离到归档(条目块 [x] 自动移出;历史/导航段走标题白名单整段移出,不靠启发式猜--本板存在大量无勾选的行内待办,启发式会误移)
- 安全闸: 未完成项零丢失断言(失败即中止不写);默认 dry-run;--list 从 UTF-8 文件读白名单(规避 Windows CLI 中文编码);容器标题(下挂更深子节)不当作空标题删
- 本次移出 6 段(统一规划 2026-06-26 全景/交接状态 2026-06-14/编排推进总览 2026-06-18)+ 909 条目;主板 847→736 行
- 核对: 未完成项 109 项零丢失,主板 [x] 清零,归档收 909 及其验收块
This commit is contained in:
lxy
2026-10-10 00:47:10 +08:00
parent 73dec866a4
commit e61fcdea79
3 changed files with 246 additions and 110 deletions
+135
View File
@@ -0,0 +1,135 @@
// 看板归档器:把 docs/todo.md 中「已完成」内容分离到 docs/07-项目管理/todo归档/<date>.md。
//
// 移动规则(纯行搬运,零措辞改动):
// ① 条目块为 [x] → 自动移出(其后续非条目行为同块延续,含缩进证据/引用块);
// ② **白名单整段移出**:命令行给出的标题(用引号原样传入) → 该段连同其子段(级别更深的后续段)
// 一并移出。历史/导航/已完成段走此路——**不靠启发式猜「已完成」**(本板存在大量无勾选的
// 行内待办,启发式会误移)。
// ③ 移出后残留的空标题(段内仅空行)自动删除,防悬空标题。
//
// 安全闸:未完成项([ ]/[~]/[→]/[!])零丢失断言失败即中止不写;默认 dry-run。
//
// 用法:
// node scripts/archive-todo.cjs # dry-run
// node scripts/archive-todo.cjs "## 🎯 统一规划(2026-06-26 全景)" # dry-run + 试移该段
// node scripts/archive-todo.cjs --apply "## 交接状态(2026-06-14)" # 实际写入
// node scripts/archive-todo.cjs --apply --date 2026-10-11 ...
const fs = require('fs');
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const di = argv.indexOf('--date');
const DATE = di >= 0 ? argv[di + 1] : new Date().toISOString().slice(0, 10);
const li = argv.indexOf('--list');
// 白名单来源:① 命令行位置参数(注意 Windows Git Bash 下中文会被编码破坏,慎用)
// ② --list <file>:UTF-8 文件,每行一个精确标题(推荐,避免编码问题)
const WHITELIST =
li >= 0
? fs.readFileSync(argv[li + 1], 'utf8').split('\n').map((s) => s.trim()).filter(Boolean)
: argv.filter((a, i) => !a.startsWith('--') && i !== di + 1 && i !== li + 1);
const SRC = 'docs/todo.md';
const ARC = `docs/07-项目管理/todo归档/${DATE}.md`;
const UNDONE = /^\s*- \[[ ~→!]\]|^\d+\. \[[ ~→!]\]/;
const DONE = /^\s*- \[x\]|^\d+\. \[x\]/i;
const BLOCK = /^(\s*- \[|\d+\. \[)/;
const HEAD = /^#{1,3} /;
const level = (h) => (h.match(/^#+/) || [''])[0].length;
// 行尾归一:工作区可能是 CRLF,按 \r?\n 切(否则标题比对带 \r 恒不等)。写回用 LF,git 提交时归一。
const lines = fs.readFileSync(SRC, 'utf8').split(/\r?\n/);
// 打标:``` 围栏内一律 text(防 mermaid/示例里的 ## 误导分段)
const kinds = [];
let inFence = false;
for (const ln of lines) {
if (ln.trim().startsWith('```')) { kinds.push('text'); inFence = !inFence; continue; }
kinds.push(inFence ? 'text' : HEAD.test(ln) ? 'head' : BLOCK.test(ln) ? 'block' : 'text');
}
// 分段(首个 head 前为 preamble,永留主板)
const segs = [];
let cur = { head: null, headLevel: 0, items: [] };
for (let i = 0; i < lines.length; i++) {
if (kinds[i] === 'head') { segs.push(cur); cur = { head: lines[i], headLevel: level(lines[i]), items: [] }; }
else cur.items.push(lines[i]);
}
segs.push(cur);
const preamble = segs[0].head === null ? segs.shift() : null;
// 块归并(块 = 条目起始行 + 其后非条目行)
function blocksOf(items) {
const out = [];
let curB = null;
for (const ln of items) {
if (BLOCK.test(ln)) {
if (curB) out.push(curB);
curB = { status: DONE.test(ln) ? 'done' : UNDONE.test(ln) ? 'undone' : 'other', lines: [ln] };
} else if (curB) curB.lines.push(ln);
else out.push({ status: 'text', lines: [ln] });
}
if (curB) out.push(curB);
return out;
}
const moved = [];
const kept = [];
if (preamble) kept.push(...preamble.items);
let treeLevel = null; // 白名单段的级别:更深的后续段随其移出
let movedSegs = 0, movedBlocks = 0;
for (const seg of segs) {
const whitelisted = seg.head !== null && WHITELIST.includes(seg.head);
const inTree = treeLevel !== null && seg.head !== null && seg.headLevel > treeLevel;
if (!seg.head) { /* 不可能:非 preamble 段必有 head */ }
if (whitelisted || inTree) {
if (whitelisted) treeLevel = seg.headLevel;
movedSegs++;
if (seg.head) moved.push(seg.head);
for (const b of blocksOf(seg.items)) moved.push(...b.lines);
continue;
}
treeLevel = null;
if (seg.head) kept.push(seg.head);
for (const b of blocksOf(seg.items)) {
if (b.status === 'done') { movedBlocks++; moved.push(...b.lines); }
else kept.push(...b.lines);
}
}
// 删残留空标题:仅当「段内只剩空行」且「下一个标题级别 ≤ 当前」才删——
// 若下一个标题更深,说明它是**容器标题**(如 `## 待办` 下挂 `### 子节`),必须保留。
const cleaned = [];
for (let i = 0; i < kept.length; i++) {
if (HEAD.test(kept[i])) {
let j = i + 1;
while (j < kept.length && kept[j].trim() === '') j++;
const nextIsDeeper = j < kept.length && HEAD.test(kept[j]) && level(kept[j]) > level(kept[i]);
if (j >= kept.length || (HEAD.test(kept[j]) && !nextIsDeeper)) continue;
}
cleaned.push(kept[i]);
}
// 零丢失断言
const origUndone = lines.filter((l) => UNDONE.test(l));
const newUndone = new Set(cleaned.filter((l) => UNDONE.test(l)));
const lost = origUndone.filter((l) => !newUndone.has(l));
console.log(`[archive-todo] ${APPLY ? 'APPLY' : 'DRY-RUN'} date=${DATE} whitelist=${WHITELIST.length}`);
console.log(` 整段移出 ${movedSegs} 段,条目块移出 ${movedBlocks} 个,移出 ${moved.length} 行,主板 ${cleaned.length} 行(原 ${lines.length})`);
console.log(` 未完成项 原 ${origUndone.length} → 主板 ${newUndone.size},丢失 ${lost.length}`);
if (lost.length) {
console.error('✗ 零丢失断言失败,不写入:');
for (const l of lost.slice(0, 10)) console.error(' ' + l.slice(0, 100));
process.exit(1);
}
console.log(' 移出段标题:');
for (const l of moved.filter((x) => HEAD.test(x))) console.log(' - ' + l.slice(0, 90));
if (!APPLY) { console.log('(dry-run,加 --apply 写入)'); process.exit(0); }
if (moved.length === 0) { console.log('无内容可移,跳过写入'); process.exit(0); }
let arc = fs.existsSync(ARC) ? fs.readFileSync(ARC, 'utf8').trimEnd() : `# todo 归档(${DATE})`;
fs.writeFileSync(ARC, `${arc}\n\n---\n\n## ${DATE} 分拆(已完成内容再分离)\n\n${moved.join('\n')}\n`, 'utf8');
fs.writeFileSync(SRC, cleaned.join('\n'), 'utf8');
console.log(`[archive-todo] 已写入 ${SRC} 与 ${ARC}`);