diff --git a/scripts/migrate-tasks-v2.mjs b/scripts/migrate-tasks-v2.mjs new file mode 100644 index 0000000..399b9df --- /dev/null +++ b/scripts/migrate-tasks-v2.mjs @@ -0,0 +1,357 @@ +// 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 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 (fs.existsSync(path.join(ROOT, d.name, ".tasks"))) candidates.push(d.name); + } + for (const extra of EXTRA) { + 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; + } + 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)) { + 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); });