meta(tasks): migration script v2 corrections (5-digit names, link-headers, Created-dedup, done/ scan, agenda pool)
This commit is contained in:
357
scripts/migrate-tasks-v2.mjs
Normal file
357
scripts/migrate-tasks-v2.mjs
Normal 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); });
|
||||||
Reference in New Issue
Block a user