// 看板归档器:把 docs/todo.md 中「已完成」内容分离到 docs/07-项目管理/todo归档/.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 :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}`);