// Migration: .tasks v1 (slug boards) → v2 (global numbering). // Spec: .workshop/.brainstorm/tasks-global-numbering.md (этап 3, .admin). // Corrections 2026-08-23 (workshop inbox): // 1) filename number = 5 digits, leading zeros, NO '#': 2026-06-05-00019-slug.md // 2) TASK_HEADER accepts [slug](slug.md) link-style headers (parser was losing blocks) // 3) rewriteBoard: do NOT insert **Created:** if block already has it (MCP-created blocks) // 4) counter = post-step only (script does not touch it; operator sets counter=N after) // 5) fileForSlug also searches .tasks/done/; agenda included in pool via --extra= // // Two phases so numbers are DETERMINISTIC: // collect (default): parse all boards, resolve creation dates (git history), // assign global numbers 1..N, save plan to tmp JSON, print plan. // --apply : read the plan, rename files, rewrite STATUS.md, commit // .tasks/ changes per repo (never sweeps unrelated files). // --verify : (with --apply) re-parse boards with the MCP v2 parser. // --repos=a,b,c : scope to specific repos (smoke runs). // --extra=path : additional repo dir (outside ROOT) in the pool, e.g. agenda clone. // // Per repo: // - per-task files renamed `yyyy-mm-dd--.md`; 🟢/✅ done → .tasks/done/ // - STATUS.md: headers `## [#n slug] — desc`, `**Created:**` after // **Status:** (unless already present), `**Blocker:**` slugs → #n (global map); ✅ → 🟢 // - ARCHIVE.md / .archive/ / orphaned files (no STATUS.md block): UNTOUCHED // // Creation date priority: git first-add of per-task file (--follow through // renames) → earliest `[slug]` mention in .tasks/STATUS.md history (one-pass // per repo, git show with early break) → created-by comment → file/STATUS mtime. import { execSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; const ROOT = "C:/Users/vitya/projects"; const APPLY = process.argv.includes("--apply"); const VERIFY = process.argv.includes("--verify"); const scopeArg = process.argv.find((a) => a.startsWith("--repos=")); const SCOPE = scopeArg ? scopeArg.split("=")[1].split(",") : null; const skipArg = process.argv.find((a) => a.startsWith("--skip=")); const SKIP = skipArg ? new Set(skipArg.split("=")[1].split(",")) : new Set(); const extraArg = process.argv.find((a) => a.startsWith("--extra=")); const EXTRA = extraArg ? extraArg.split("=")[1].split(",").map((p) => path.resolve(p)) : []; const PLAN_FILE = path.join(os.tmpdir(), "tasks-v2-plan.json"); const CACHE_FILE = path.join(os.tmpdir(), "tasks-v2-date-cache.json"); let dateCache = {}; try { dateCache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")); } catch {} function saveCache() { try { fs.writeFileSync(CACHE_FILE, JSON.stringify(dateCache)); } catch {} } function cachedDate(key, fn) { if (dateCache[key] !== undefined) return dateCache[key]; const v = fn(); dateCache[key] = v; saveCache(); return v; } // g1=emoji g2=id? g3=slug g4=desc. `](slug.md)` suffix tolerated (books/heart-and-mask v1 style). const TASK_HEADER = /^##\s+(\S+)\s+\[(?:#(\d+)\s+)?([^\]]+)\](?:\([^)]*\))?\s+(?:—\s+)?(.*)$/u; const CREATED_BY_RE = /created-by:\s*[^/]*\/\s*\d{4}-\d{2}-\d{2}/; const DATE_ISO = /(\d{4}-\d{2}-\d{2})T?/; const BLOCKER_FIELD = /^\*\*Blocker:\*\*\s*(.*)$/i; const STATUS_FIELD = /^\*\*Status:\*\*\s*(.+)$/i; const CREATED_FIELD = /^\*\*Created:\*\*\s*(.+)$/i; const NON_CANON = { "✅": "🟢" }; function git(repoDir, args) { try { return execSync(`git -C "${repoDir}" ${args}`, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } } function dateOf(iso) { if (!iso) return null; const d = iso instanceof Date ? iso.toISOString() : String(iso); return d.slice(0, 10); } function firstCommitDate(repoDir, relFile) { return cachedDate(`file:${repoDir}:${relFile}`, () => { const out = git(repoDir, `log --follow --diff-filter=A --format=%aI -- "${relFile}"`); const lines = out.split("\n").filter(Boolean); return lines.length ? lines[lines.length - 1] : null; // earliest }); } /** Earliest commit date where `[slug]` appears in .tasks/STATUS.md history. */ function scanSlugHistory(repoDir, slugs) { const need = new Set(slugs); const found = new Map(); const hist = git(repoDir, `log --reverse --format=%aI%x1f%H -- .tasks/STATUS.md`) .split("\n") .filter(Boolean) .map((l) => { const i = l.indexOf("\x1f"); return { date: l.slice(0, i), hash: l.slice(i + 1) }; }); for (const { date, hash } of hist) { if (need.size === 0) break; const content = git(repoDir, `show ${hash}:.tasks/STATUS.md`); if (!content) continue; for (const slug of [...need]) { if (content.includes(`[${slug}]`)) { found.set(slug, date.slice(0, 10)); need.delete(slug); } } } return found; } /** Per-slug cached slug-mention dates (set-independent — re-runs stay stable). */ function slugDatesFor(repoDir, slugs) { const missing = slugs.filter((s) => dateCache[`slug:${repoDir}:${s}`] === undefined); if (missing.length) { const m = scanSlugHistory(repoDir, missing); for (const [s, d] of m) dateCache[`slug:${repoDir}:${s}`] = d; saveCache(); } return new Map(slugs.map((s) => [s, dateCache[`slug:${repoDir}:${s}`] ?? null])); } // ---------- parse one board ---------- function parseBoard(td) { const statusPath = path.join(td, "STATUS.md"); if (!fs.existsSync(statusPath)) return null; const text = fs.readFileSync(statusPath, "utf8"); const lines = text.split(/\r?\n/); const blocks = []; let cur = null; for (const line of lines) { const m = TASK_HEADER.exec(line); if (m) { if (cur) blocks.push(cur); cur = { headerLine: line, emoji: m[1], slug: m[3], desc: m[4] || "", createdBy: null, blocker: null }; continue; } if (cur) { const cb = line.match(CREATED_BY_RE); if (cb) { const d = line.match(DATE_ISO); if (d) cur.createdBy = d[1]; } const bl = line.match(BLOCKER_FIELD); if (bl) cur.blocker = bl[1].trim(); } } if (cur) blocks.push(cur); return { text, blocks }; } const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // v1 name `slug.md` in root, or already-migrated name `yyyy-mm-dd--.md` // in root OR .tasks/done/ (done files were already moved there by a previous smoke run). function fileForSlug(td, slug) { const p = path.join(td, `${slug}.md`); if (fs.existsSync(p)) return p; const re = new RegExp(`^\\d{4}-\\d{2}-\\d{2}-\\d{1,5}-${escapeRe(slug)}\\.md$`); for (const dir of [td, path.join(td, "done")]) { if (!fs.existsSync(dir)) continue; const name = fs.readdirSync(dir).find((f) => re.test(f)); if (name) return path.join(dir, name); } return null; } function repoDirFor(name) { const extra = EXTRA.find((p) => path.basename(p) === name); return extra ? extra : path.join(ROOT, name); } // ---------- collect: dates + global numbering, save plan ---------- function collect() { const dirs = fs.readdirSync(ROOT, { withFileTypes: true }).filter((d) => d.isDirectory()); const byRepo = {}; // repo -> tasks in FILE order (headers pair by position) const candidates = []; for (const d of dirs) { if (SCOPE && !SCOPE.includes(d.name)) continue; if (SKIP.has(d.name)) continue; if (fs.existsSync(path.join(ROOT, d.name, ".tasks"))) candidates.push(d.name); } for (const extra of EXTRA) { if (SKIP.has(path.basename(extra))) continue; if (fs.existsSync(path.join(extra, ".tasks"))) candidates.push(path.basename(extra)); } const all = []; for (const name of candidates) { const repoDir = repoDirFor(name); const td = path.join(repoDir, ".tasks"); const board = parseBoard(td); if (!board) continue; const isGit = fs.existsSync(path.join(repoDir, ".git")); const mtime = fs.statSync(path.join(td, "STATUS.md")).mtime; const noFileSlugs = board.blocks.filter((b) => !fileForSlug(td, b.slug)).map((b) => b.slug); const slugDates = isGit ? slugDatesFor(repoDir, noFileSlugs) : new Map(); const claimed = new Set(); // dup slug: first occurrence owns the file const repoList = []; for (const b of board.blocks) { const fp = fileForSlug(td, b.slug); const hasFile = !!fp && !claimed.has(b.slug); claimed.add(b.slug); let date = null; if (isGit && hasFile && fp) { const rel = path.relative(repoDir, fp).replace(/\\/g, "/"); date = dateOf(firstCommitDate(repoDir, rel)); } if (!date && slugDates.has(b.slug)) date = slugDates.get(b.slug); if (!date) date = b.createdBy; if (!date) date = dateOf(fp ? fs.statSync(fp).mtime : mtime); const t = { repo: name, slug: b.slug, emoji: b.emoji, date: date || "0000-00-00", hasFile, blocker: b.blocker, }; all.push(t); repoList.push(t); } byRepo[name] = repoList; } const sorted = [...all].sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : a.repo < b.repo ? -1 : a.repo > b.repo ? 1 : a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0); sorted.forEach((t, i) => (t.number = i + 1)); fs.writeFileSync(PLAN_FILE, JSON.stringify({ generatedAt: new Date().toISOString(), repos: byRepo }, null, 1)); return { byRepo }; } function printPlan(byRepo) { const repoNames = Object.keys(byRepo).sort(); const total = repoNames.reduce((s, r) => s + byRepo[r].length, 0); console.log(`repos: ${repoNames.length}, tasks: ${total}`); for (const repo of repoNames) { const ts = byRepo[repo]; const done = ts.filter((t) => t.emoji === "🟢" || t.emoji === "✅").length; const files = ts.filter((t) => t.hasFile).length; const noDate = ts.filter((t) => t.date === "0000-00-00").length; console.log(` ${repo.padEnd(24)} ${String(ts.length).padStart(3)} задач (${done} done, ${files} файлов)${noDate ? ` ⚠️${noDate} без даты` : ""}`); for (const t of ts) { const flag = t.emoji === "🟢" || t.emoji === "✅" ? "🟢" : t.emoji; const rename = t.hasFile ? ` → ${t.date}-${String(t.number).padStart(5, "0")}-${t.slug}.md` : ""; console.log(` ${String(t.number).padStart(4)} ${t.date} ${flag} ${t.slug}${rename}`); } } } // ---------- apply ---------- function rewriteBoard(td, repoTasks, globalBySlug) { const text = fs.readFileSync(path.join(td, "STATUS.md"), "utf8"); const lines = text.split(/\r?\n/); const gnum = new Map(); // repo-local slugs first (unambiguous), then global map (first-wins) for cross-repo refs for (const t of repoTasks) if (!gnum.has(t.slug)) gnum.set(t.slug, t.number); for (const t of globalBySlug.values()) if (!gnum.has(t.slug)) gnum.set(t.slug, t.number); const resolveBlocker = (raw) => raw.split(",").map((tok) => tok.trim()).filter(Boolean) .map((tok) => (gnum.has(tok) ? `#${gnum.get(tok)}` : tok)).join(", "); const createdLine = (t) => `**Created:** ${t.date === "0000-00-00" ? "unknown" : t.date}`; const out = []; let idx = 0; let cur = null; // current task let buf = []; // body lines of current block let hasStatus = false, hasCreated = false; const flush = () => { if (!cur) return; let inserted = false; for (const line of buf) { const bf = line.match(BLOCKER_FIELD); if (bf) { out.push(`**Blocker:** ${resolveBlocker(bf[1])}`); continue; } if (!inserted && hasStatus && !hasCreated && STATUS_FIELD.test(line)) { out.push(line); out.push(createdLine(cur)); inserted = true; continue; } out.push(line); } // block with no **Status:** and no **Created:** — append Created: at end (v1 style) if (!hasStatus && !hasCreated) out.push(createdLine(cur)); buf = []; hasStatus = false; hasCreated = false; cur = null; }; for (const line of lines) { const m = TASK_HEADER.exec(line); if (m) { flush(); const t = repoTasks[idx]; if (!t || t.slug !== m[3]) { out.push(line); continue; } // safety: unknown block passes through idx++; const emoji = NON_CANON[t.emoji] || t.emoji; out.push(`## ${emoji} [#${t.number} ${t.slug}]${m[4] ? " — " + m[4] : ""}`); cur = t; continue; } if (cur) { if (CREATED_FIELD.test(line)) hasCreated = true; if (STATUS_FIELD.test(line)) hasStatus = true; buf.push(line); continue; } out.push(line); } flush(); // last block at EOF return out.join("\n"); } function applyOne(repoDir, td, repoTasks, globalBySlug) { if (!fs.existsSync(path.join(repoDir, ".git"))) { console.log(` skip ${path.basename(repoDir)}: not a git repo (no commit safety)`); return; } const conflicted = git(repoDir, "status --porcelain").split("\n").some((l) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(l)); if (conflicted || git(repoDir, "rev-parse -q --verify MERGE_HEAD")) { console.log(` skip ${path.basename(repoDir)}: unresolved merge/rebase (git commit unsafe)`); return; } let renamed = 0; for (const t of repoTasks) { if (!t.hasFile) continue; const src = fileForSlug(td, t.slug); // root or done/, v1 or already-v2 name if (!src) continue; const done = t.emoji === "🟢" || t.emoji === "✅"; const targetDir = done ? path.join(td, "done") : td; fs.mkdirSync(targetDir, { recursive: true }); const target = path.join(targetDir, `${t.date === "0000-00-00" ? "unknown" : t.date}-${String(t.number).padStart(5, "0")}-${t.slug}.md`); if (target !== src) { fs.renameSync(src, target); renamed++; } } fs.writeFileSync(path.join(td, "STATUS.md"), rewriteBoard(td, repoTasks, globalBySlug)); const dirty = git(repoDir, "status --short -- .tasks/").split("\n").filter(Boolean); if (dirty.length) { git(repoDir, "add -- .tasks/"); git(repoDir, `commit -m "meta(tasks): migrate .tasks to v2 (global numbering ${repoTasks.length} tasks)" -q`); } else if (renamed > 0) { console.log(` ⚠ ${path.basename(repoDir)}: renamed ${renamed} files but nothing staged — .tasks/ gitignored?`); } } async function verify(byRepo) { let bad = 0; const { parseStatusMd } = await import("file:///C:/Users/vitya/projects/.common/lib/projects-meta-mcp/dist/lib/status-md.js"); for (const repo of Object.keys(byRepo)) { const repoDir = repoDirFor(repo); const td = path.join(repoDir, ".tasks"); if (!fs.existsSync(path.join(td, "STATUS.md"))) continue; const parsed = parseStatusMd(fs.readFileSync(path.join(td, "STATUS.md"), "utf8")); for (const t of parsed.tasks) { if (t.id == null) { console.log(` ✖ ${repo}/${t.slug}: no id`); bad++; } if (!t.createdAt) { console.log(` ✖ ${repo}/${t.slug}: no Created:`); bad++; } } } console.log(bad === 0 ? `verify: OK (${Object.keys(byRepo).length} досок, парсер MCP v2)` : `verify: ${bad} проблем`); } async function main() { if (APPLY) { const plan = JSON.parse(fs.readFileSync(PLAN_FILE, "utf8")); const bySlug = new Map(); const byRepo = {}; for (const [repo, ts] of Object.entries(plan.repos)) { byRepo[repo] = ts; for (const t of ts) if (!bySlug.has(t.slug)) bySlug.set(t.slug, t); } for (const [repo, ts] of Object.entries(byRepo)) { if (SKIP.has(repo)) { console.log(`skip (--skip): ${repo}`); continue; } applyOne(repoDirFor(repo), path.join(repoDirFor(repo), ".tasks"), ts, bySlug); console.log(`applied: ${repo}`); } if (VERIFY) verify(byRepo); return; } const { byRepo } = collect(); printPlan(byRepo); console.log(`\nплан сохранён: ${PLAN_FILE}. --apply для применения (детерминировано по плану).`); } main().catch((e) => { console.error(e); process.exit(1); });