meta(tasks): migration script v2 corrections (5-digit names, link-headers, Created-dedup, done/ scan, agenda pool)

This commit is contained in:
2026-08-23 10:06:01 +03:00
parent 816b67424d
commit bad3c3d074

View File

@@ -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-<n5>-<slug>.md`; 🟢/✅ done → .tasks/done/
// - STATUS.md: headers `## <emoji> [#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-<n1..5>-<slug>.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); });