#!/usr/bin/env node
/* Gantora MCP server — one file, zero dependencies, stdio.
 *
 * Gives any MCP-speaking assistant (Claude Desktop, Cursor, Codex, Gemini…)
 * four sharp tools over the .gantora plans in one or more DB folders:
 *
 *   list_plans  · read_plan  · add_task  · set_status
 *
 * The format and the rules are the ones in skill/gantora-plans/SKILL.md — the
 * skill says them in prose, this server enforces them in code: exactly six
 * status words, History append-only, UIDs never change, #-lines carried
 * through verbatim, the read-only flag honoured, every write stamped, every
 * write atomic, and nothing outside the configured folders is ever touched.
 * Reading is tolerant (an unknown column or #-line is passed through, a newer
 * writer is never refused); writing is strict (a tool that cannot keep the
 * invariants says so and writes nothing).
 *
 * It edits ROWS, not files: a write rewrites only the lines it changed and
 * copies every other byte of the file through — the strongest form of "never
 * lose what you don't understand".
 *
 *   node gantora-mcp.mjs /path/to/DB [/another/DB …] [--read-only]
 *
 * No network. No token. The folders are the whole permission.
 */
import { readFileSync, writeFileSync, renameSync, readdirSync, statSync, realpathSync, existsSync, unlinkSync } from "node:fs";
import { join, basename, resolve } from "node:path";
import { randomBytes } from "node:crypto";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";

export const SERVER_NAME = "gantora-mcp";
export const SERVER_VERSION = "0.2.0";
const WRITER = "gantora-mcp " + SERVER_VERSION;   // the #META|writer stamp
const WRITER_ID = "mcp";                          // the short id inside UIDs and Created/Modified
const SPEC_URL = "https://gantora.net/format.md"; // the file carries its own manual (#META|spec)
const WRITER_NAME = "Gantora MCP";
const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];

/* ============================ the format ============================ */

/* Format v2 (2026-08-23, Daniel's itemised decision): the first column is
   "Seq" — an ordinal, which is what it always was; "ID" promised an identity
   it never kept — and membership is the CHILD row's "Parent" cell, holding
   the parent's UID (variant C: structure by identity, dependencies stay
   ordinals). The v1 names still READ (see NAME2KEY below); new files and new
   columns are written in v2. */
export const HEADER = ["Seq", "Activity Name", "Duration", "Start", "Hour (Start)", "End", "Hour (End)",
  "Predecessors", "Team", "System", "Accountable", "Responsible", "Parent", "Milestone", "Notes", "Status",
  "History", "Event", "Emoji", "Not Finalized", "Baseline End", "Date History", "UID", "Created", "Modified",
  "History By", "Reported", "Labels"];
export const KEYS = ["id", "name", "duration", "start", "hourStart", "end", "hourEnd", "preds", "team", "system",
  "accountable", "responsible", "parent", "milestone", "notes", "status", "history", "event", "emoji", "notFinal",
  "baselineEnd", "dateHist", "uid", "created", "modified", "historyBy", "reported", "labels"];
export const FORMAT_VERSION = "2";
/* Header-name → key, with the v1 names kept readable forever. "Group" maps to
   its own key — a v1 file's parent-side list is a DIFFERENT fact from a v2
   Parent cell, and the reader takes whichever the file has. */
const NAME2KEY = new Map(HEADER.map((h, i) => [h.trim().toLowerCase(), KEYS[i]]));
NAME2KEY.set("id", "id");
NAME2KEY.set("group", "group");
/* Six since 2026-08-22. `Cancelled` is work that will not happen: it counts
   towards nothing, it can never be late, and the row is kept rather than
   deleted so the plan still records that somebody meant to do it. */
export const STATUSES = ["Planned", "In Progress", "On Hold", "Blocked", "Done", "Cancelled"];
const DELIM = "|";
const PLAN_EXTS = [".gantora", ".csv"];
const UID_RE = /^[A-Za-z0-9_-]{4,40}$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const HOUR_RE = /^([01]\d|2[0-3]):[0-5]\d$/;

export const cellEnc = s => String(s == null ? "" : s).replace(/~/g, "~t").replace(/\|/g, "~p").replace(/\r\n|[\r\n]/g, "~n");
export const cellDec = s => String(s == null ? "" : s).replace(/~([tpn])/g, (m, c) => c === "p" ? "|" : c === "n" ? "\n" : "~");
const yes = s => /^\s*(y|i|1|t)/i.test(String(s || ""));
const csvKey = s => String(s || "").trim().toLowerCase();
const isPlanFile = name => PLAN_EXTS.some(x => name.toLowerCase().endsWith(x)) && !name.startsWith("_") && !name.startsWith(".");
const today = () => { const d = new Date(); return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); };
const utcNow = () => new Date().toISOString().replace(/\.\d+Z$/, "Z");
const stamp = (name) => utcNow() + " " + WRITER_ID + " " + (name || WRITER_NAME);
let uidCount = 0;
const newUid = () => WRITER_ID + "-" + Date.now().toString(36) + "-" + (uidCount++).toString(36) + "-" + randomBytes(3).toString("hex").slice(0, 4);
const idList = s => String(s || "").split(";").map(x => x.trim()).filter(Boolean).map(x => parseInt(x, 10)).filter(n => Number.isInteger(n) && n > 0);
const parsePairs = s => String(s || "").split(";").map(x => x.trim()).filter(Boolean).map(p => { const i = p.indexOf(" "); return i < 0 ? null : { date: p.slice(0, i), value: p.slice(i + 1) }; }).filter(Boolean);
const daysBetween = (a, b) => Math.round((Date.UTC(+b.slice(0, 4), +b.slice(5, 7) - 1, +b.slice(8, 10)) - Date.UTC(+a.slice(0, 4), +a.slice(5, 7) - 1, +a.slice(8, 10))) / 86400000);
const normStatus = s => { const k = String(s || "").toLowerCase().replace(/[^a-z]/g, ""); return STATUSES.find(x => x.toLowerCase().replace(/[^a-z]/g, "") === k) || ""; };

/* A file, read as lines: the header (with the index of every known column in
   THIS file), the task rows (kept with their line number and raw cells), and
   every other line verbatim. Nothing is normalised on the way in — what is
   written back is what was read, except the rows a tool changed. */
export function parsePlan(text) {
  const raw = String(text || "").replace(/^\uFEFF/, "");
  const eol = raw.includes("\r\n") ? "\r\n" : "\n";
  const lines = raw.split(/\r?\n/);
  if (lines.length && lines[lines.length - 1] === "") lines.pop();     // the final break
  const out = { eol, lines, headAt: -1, idx: {}, width: 0, rows: [], meta: {}, del: [], view: [] };
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!line.trim()) continue;
    const cells = line.split(DELIM);
    if (out.headAt < 0 && i === 0 && (csvKey(cells[0]) === "seq" || csvKey(cells[0]) === "id")) {
      out.headAt = i; out.width = cells.length;
      cells.forEach((h, j) => { const k = NAME2KEY.get(csvKey(h)); if (k && out.idx[k] == null) out.idx[k] = j; });
      continue;
    }
    if (cells[0] === "#META") { out.meta[cells[1] || ""] = cells[2] || ""; continue; }
    if (cells[0] === "#DEL") { out.del.push({ uid: cells[1], when: cells[2], name: cellDec(cells[3] || "") }); continue; }
    if (cells[0] === "#VIEW") { out.view.push(line); continue; }
    if (!/^\d+$/.test(cells[0].trim())) continue;                      // not a row: passed through untouched
    out.rows.push({ at: i, cells });
  }
  return out;
}

const at = (plan, row, key) => plan.idx[key] == null ? "" : cellDec(row.cells[plan.idx[key]] || "").trim();

/* One row as a structured object — what read_plan hands over. */
export function taskOf(plan, row) {
  const g = k => at(plan, row, k);
  const history = parsePairs(g("history")).map(p => ({ date: p.date, status: p.value }));
  const by = parsePairs(g("historyBy"));
  history.forEach((h, i) => { if (by[i] && by[i].date === h.date && by[i].value !== "-") h.by = by[i].value; });
  return {
    id: parseInt(g("id"), 10), uid: g("uid"), name: g("name"),
    start: g("start"), hourStart: g("hourStart"), end: g("end"), hourEnd: g("hourEnd"),
    duration: g("start") && g("end") && DATE_RE.test(g("start")) && DATE_RE.test(g("end")) ? daysBetween(g("start"), g("end")) + 1 : null,
    predecessors: idList(g("preds")), team: g("team"), system: g("system"),
    accountable: g("accountable"), responsible: g("responsible").split(";").map(x => x.trim()).filter(Boolean),
    /* v1's parent-side list — a v2 file's child-side Parent cells are folded
       into the same `members` arrays by resolveParents(), right after every
       row has been read. */
    members: idList(g("group")), parentUid: g("parent"), milestone: yes(g("milestone")), event: yes(g("event")), emoji: g("emoji"),
    notes: g("notes"), status: g("status"), history, notFinalized: yes(g("notFinal")),
    baselineEnd: g("baselineEnd"), dateHistory: parsePairs(g("dateHist")).map(p => ({ date: p.date, end: p.value })),
    created: g("created"), modified: g("modified"), reported: yes(g("reported")),
    labels: g("labels").split(";").map(x => x.trim()).filter(Boolean),
  };
}

/* v2's child-side Parent cells become the same `members` arrays a v1 Group
   list makes, so everything downstream — structure(), the roll-ups, the
   ordering — works on one model. A UID nothing answers to resolves to no
   membership, the same fate a dangling child id has always met. */
export function resolveParents(tasks) {
  const byUid = new Map(tasks.map(t => [t.uid, t]));
  for (const t of tasks) {
    if (t.parentUid) {
      const p = byUid.get(t.parentUid);
      if (p && p !== t && !p.members.includes(t.id)) p.members.push(t.id);
    }
    delete t.parentUid;
  }
  for (const t of tasks) if (t.members.length > 1) t.members.sort((a, b) => a - b);
  return tasks;
}

/* The derived facts the skill teaches (§6): tree, group roll-up, drift, overdue. */
export function structure(tasks) {
  const byId = new Map(tasks.map(t => [t.id, t]));
  const parent = new Map();
  for (const t of tasks) for (const m of t.members) if (byId.has(m) && !parent.has(m)) parent.set(m, t.id);
  for (const t of tasks) {
    t.parent = parent.get(t.id) ?? null;
    let lvl = 0, p = t.parent; while (p != null && lvl < 10) { lvl++; p = parent.get(p) ?? null; }
    t.level = lvl;
    t.isGroup = t.members.length > 0;
    const base = t.baselineEnd || (t.dateHistory[0] && t.dateHistory[0].end) || t.end;
    t.drift = !t.event && !t.notFinalized && t.end && base && DATE_RE.test(t.end) && DATE_RE.test(base) ? daysBetween(base, t.end) : null;
  }
  const now = today();
  const bucket = t => {
    if (t.event) return "event";
    if (t.status === "Done") return "done";
    if (t.status === "Blocked") return "blocked";
    if (t.status === "On Hold") return "onHold";
    if (t.end && t.end < now) return "overdue";
    if (t.status === "In Progress") return "inProgress";
    if (t.start && t.start < now) return "lateStart";
    return "upcoming";
  };
  // group status: the loudest member (Blocked > On Hold > In Progress > Planned > Done), deepest first
  for (const t of [...tasks].sort((a, b) => b.level - a.level)) {
    if (!t.isGroup) continue;
    const kids = t.members.map(m => byId.get(m)).filter(k => k && !k.event);
    if (!kids.length) continue;
    let s = "Done";
    if (kids.some(k => k.status === "Blocked")) s = "Blocked";
    else if (kids.some(k => k.status === "On Hold")) s = "On Hold";
    else if (kids.some(k => k.status === "In Progress" || k.status === "Done") && !kids.every(k => k.status === "Done")) s = "In Progress";
    else if (kids.every(k => k.status === "Done")) s = "Done";
    else s = "Planned";
    t.rolledUpStatus = s;
  }
  for (const t of tasks) t.bucket = bucket(t);
  const leaves = tasks.filter(t => !t.isGroup && !t.milestone);
  const summary = { tasks: leaves.length, byBucket: {} };
  for (const t of leaves) summary.byBucket[t.bucket] = (summary.byBucket[t.bucket] || 0) + 1;
  summary.overdue = leaves.filter(t => t.bucket === "overdue").map(t => t.id);
  summary.blocked = leaves.filter(t => t.bucket === "blocked").map(t => t.id);
  summary.lateStart = leaves.filter(t => t.bucket === "lateStart").map(t => t.id);
  summary.milestones = tasks.filter(t => t.milestone && !t.event).map(t => ({ id: t.id, name: t.name, date: t.start, status: t.status }));
  return summary;
}

/* ============================ the store ============================ */

export class Store {
  constructor(dirs, opts = {}) {
    this.dirs = dirs.map(d => realpathSync(resolve(d)));
    this.readOnly = !!opts.readOnly;
    for (const d of this.dirs) if (!statSync(d).isDirectory()) throw new Error("not a folder: " + d);
  }
  /* A plan name is a bare file name in one of the folders — never a path. */
  resolvePlan(name) {
    const n = String(name || "").trim();
    if (!n || n !== basename(n) || n.includes("/") || n.includes("\\") || n.includes("..")) throw new Error("a plan is named by its file name alone, e.g. \"Kitchen.gantora\"");
    const cands = PLAN_EXTS.some(x => n.toLowerCase().endsWith(x)) ? [n] : PLAN_EXTS.map(x => n + x);
    for (const d of this.dirs) for (const c of cands) {
      const p = join(d, c);
      if (existsSync(p) && isPlanFile(c)) {
        const real = realpathSync(p);
        if (!this.dirs.some(dd => real.startsWith(dd + "/") || real.startsWith(dd + "\\"))) throw new Error("outside the configured folders: " + c);
        return { path: real, name: c, dir: d };
      }
    }
    throw new Error("no such plan: " + n + " — list_plans shows what there is");
  }
  list() {
    const out = [];
    for (const d of this.dirs) for (const f of readdirSync(d)) {
      if (!isPlanFile(f)) continue;
      const p = join(d, f);
      let st; try { st = statSync(p); } catch { continue; }
      if (!st.isFile()) continue;
      let text = ""; try { text = readFileSync(p, "utf8"); } catch { continue; }
      const plan = parsePlan(text);
      out.push({ name: f, folder: d, rows: plan.rows.length, bytes: st.size, modified: st.mtime.toISOString().replace(/\.\d+Z$/, "Z"),
                 readOnly: yes(plan.meta.readOnly), writer: plan.meta.writer || "", hasHeader: plan.headAt >= 0 });
    }
    return out.sort((a, b) => a.name.localeCompare(b.name));
  }
  /* detail: "compact" (the default) answers most questions in a fraction of
     the size — the full shape of the 89-row roadmap came to ~100 KB of JSON,
     which eats a chat context or gets truncated (measured on the first real
     client, 2026-08-19). Compact keeps the summary whole and cuts each task to
     the fields a conversation actually uses, dropping empty ones. "full" is
     the old answer, for when a row is being inspected closely. */
  read(name, detail) {
    const f = this.resolvePlan(name);
    const text = readFileSync(f.path, "utf8");
    const plan = parsePlan(text);
    const tasks = resolveParents(plan.rows.map(r => taskOf(plan, r)));
    const summary = structure(tasks);
    const full = detail === "full";
    const compact = t => {
      const o = { uid: t.uid, id: t.id, name: t.name };
      if (t.start) o.start = t.start;
      if (t.end && t.end !== t.start) o.end = t.end;
      if (t.status) o.status = t.status;
      if (t.rolledUpStatus && t.rolledUpStatus !== t.status) o.rolledUp = t.rolledUpStatus;
      if (t.bucket && !["upcoming", "done", "event"].includes(t.bucket)) o.bucket = t.bucket;
      if (t.parent != null) o.parent = t.parent;
      if (t.isGroup) o.members = t.members;
      if (t.milestone) o.milestone = true;
      if (t.event) o.event = true;
      if (t.notFinalized) o.notFinalized = true;
      if (t.drift) o.drift = t.drift;
      if (t.labels.length) o.labels = t.labels;
      if (t.predecessors.length) o.predecessors = t.predecessors;
      return o;
    };
    return { name: f.name, folder: f.dir, readOnly: yes(plan.meta.readOnly), writer: plan.meta.writer || "",
             detail: full ? "full" : "compact",
             tasks: full ? tasks : tasks.map(compact),
             summary, deleted: plan.del, viewLines: plan.view.length, meta: plan.meta };
  }
  /* Where a NEW plan may be made: the first configured folder, a bare human
     name, both extensions checked so "Kitchen" cannot sit beside "Kitchen.csv"
     under two spellings. A taken name is an error, never an overwrite —
     creating is how a new plan starts, and the two things a tool could do on
     its own (overwrite, or quietly open the existing one) are both wrong. */
  placeForNew(name) {
    const n = String(name || "").trim();
    if (!n || n !== basename(n) || /[\\/:*?"<>|]/.test(n)) throw new Error("a plan is named by a plain file name, e.g. \"Kitchen renovation\"");
    if (n.startsWith("_") || n.startsWith(".")) throw new Error("names starting with _ or . are the app's own");
    const base = PLAN_EXTS.some(x => n.toLowerCase().endsWith(x)) ? n.slice(0, n.lastIndexOf(".")) : n;
    const file = base + PLAN_EXTS[0];
    for (const d of this.dirs) for (const ext of PLAN_EXTS)
      if (existsSync(join(d, base + ext))) throw new Error("a plan of that name exists: " + base + ext + " — pick another name; nothing is ever overwritten");
    return { path: join(this.dirs[0], file), name: file, dir: this.dirs[0] };
  }
  /* The one write path. `edit(plan, text)` returns the lines to replace/append; the
     file is re-read here, immediately before, so the edit is made against what is on
     disk now — and written whole, to a temp file, then renamed over. */
  write(name, edit) {
    if (this.readOnly) throw new Error("this server was started --read-only");
    const f = this.resolvePlan(name);
    const text = readFileSync(f.path, "utf8");                // fresh, every time
    const plan = parsePlan(text);
    if (yes(plan.meta.readOnly)) throw new Error("this plan declares itself read-only (#META|readOnly|yes) — it is not written; ask for a copy under another name");
    if (plan.headAt < 0) throw new Error("this file has no header row — open and save it once in Gantora first");
    for (const k of ["id", "name", "start", "end", "status", "history", "uid", "created", "modified", "historyBy"])
      if (plan.idx[k] == null) throw new Error("this file's header lacks the " + HEADER[KEYS.indexOf(k)] + " column — open and save it once in Gantora first");
    // membership travels one of two ways: v2's Parent cell or v1's Group list
    if (plan.idx.parent == null && plan.idx.group == null)
      throw new Error("this file's header lacks both Parent and Group — open and save it once in Gantora first");
    const result = edit(plan);
    let lines;
    if (result.rowsAll) {
      /* The whole row block, in the order given — how a write that renumbers
         lands (see addTask): every non-row line keeps its place relative to
         the block, each row line's untouched cells stay byte-identical, and
         the rows sit in id order, which is what the format promises. */
      const rowAt = new Set(plan.rows.map(r => r.at));
      const firstRow = plan.rows.length ? plan.rows[0].at : plan.headAt + 1;
      lines = [];
      for (let i = 0; i < plan.lines.length; i++) {
        if (i === firstRow) lines.push(...result.rowsAll);
        if (!rowAt.has(i)) lines.push(plan.lines[i]);
      }
      if (firstRow >= plan.lines.length) lines.push(...result.rowsAll);
    } else {
      lines = plan.lines.slice();
      for (const [i, line] of result.replace || []) lines[i] = line;
      // new rows go after the last row (before the #-lines), so the tail of the file keeps its order
      const lastRow = plan.rows.length ? plan.rows[plan.rows.length - 1].at : plan.headAt;
      lines.splice(lastRow + 1, 0, ...(result.append || []));
    }
    // the writer and spec stamps: replaced where they are, added at the end where they are not
    for (const [key, val] of [["writer", WRITER], ["spec", SPEC_URL]]) {
      const i = lines.findIndex(l => l.split(DELIM)[0] === "#META" && l.split(DELIM)[1] === key);
      const line = ["#META", key, val].join(DELIM);
      if (i >= 0) lines[i] = line; else lines.push(line);
    }
    const out = lines.join(plan.eol) + plan.eol;
    const tmp = f.path + ".mcp-" + process.pid + ".tmp";
    writeFileSync(tmp, out);
    try { renameSync(tmp, f.path); } catch (e) { try { unlinkSync(tmp); } catch {} throw e; }
    return result.info;
  }
}

/* A row written in THIS file's column order, from a field map. Columns the file
   does not have are dropped (they could not be stored anyway); columns the map
   does not mention are empty. */
function rowLine(plan, fields) {
  const cells = new Array(plan.width).fill("");
  for (const [k, j] of Object.entries(plan.idx)) if (fields[k] != null) cells[j] = cellEnc(fields[k]);
  return cells.join(DELIM);
}
function setCell(plan, row, key, value) { row.cells[plan.idx[key]] = cellEnc(value); }

/* The app's own ordering, reproduced: ids are positions in time order —
   events at the foot, a group directly above its members, members walked in
   time order, ties by old id. The APP repairs a mis-numbered file on open but
   cannot save the repair into a read-only plan — so a writer that appends out
   of order would leave the flaw in an AI-kept file for good (Daniel,
   2026-08-20). This writer therefore inserts in order and renumbers. */
function timeOrderRows(items) {
  // items: { oldId, start, hourStart, end, hourEnd, event, members:[oldIds] }
  const dt = (d, h) => d ? d + "T" + (h || "00:00") : "";
  const cmp = (a, b) => {
    if (a.event !== b.event) return a.event ? 1 : -1;
    const sa = dt(a.start, a.hourStart), sb = dt(b.start, b.hourStart);
    if (sa && sb && sa !== sb) return sa < sb ? -1 : 1;
    if (!sa !== !sb) return sa ? -1 : 1;
    const ea = dt(a.end, a.hourEnd), eb = dt(b.end, b.hourEnd);
    if (ea && eb && ea !== eb) return ea < eb ? -1 : 1;
    return a.oldId - b.oldId;
  };
  const byId = new Map(items.map(t => [t.oldId, t]));
  const held = new Set();
  for (const t of items) for (const m of t.members) if (byId.has(m)) held.add(m);
  const seen = new Set(), out = [];
  const walk = t => {
    if (seen.has(t.oldId)) return;
    seen.add(t.oldId); out.push(t);
    for (const k of t.members.map(m => byId.get(m)).filter(Boolean).sort(cmp)) walk(k);
  };
  for (const t of items.filter(x => !held.has(x.oldId)).sort(cmp)) walk(t);
  return out.length === items.length ? out : items;   // a cycle: leave the order alone, the app repairs it
}

/* ============================ the tools ============================ */

export function addTask(store, a) {
  const name = String(a.name || "").trim();
  if (!name) throw new Error("a task needs a name");
  if (!DATE_RE.test(a.start || "")) throw new Error("start must be YYYY-MM-DD");
  const end = a.milestone ? a.start : (a.end || a.start);
  if (!DATE_RE.test(end)) throw new Error("end must be YYYY-MM-DD");
  if (end < a.start) throw new Error("end is before start");
  for (const h of ["hourStart", "hourEnd"]) if (a[h] && !HOUR_RE.test(a[h])) throw new Error(h + " must be HH:MM");
  const status = a.status ? normStatus(a.status) : "Planned";
  if (!status) throw new Error("status must be one of: " + STATUSES.join(" · "));
  return store.write(a.plan, plan => {
    const tasks = resolveParents(plan.rows.map(r => taskOf(plan, r)));
    const ids = new Set(tasks.map(t => t.id));
    const id = (tasks.length ? Math.max(...tasks.map(t => t.id)) : 0) + 1;
    const preds = (a.predecessors || []).map(Number);
    for (const p of preds) if (!ids.has(p)) throw new Error("predecessor " + p + " is not a task in this plan");
    let parentRow = null;
    if (a.group != null) {
      parentRow = plan.rows.find(r => parseInt(at(plan, r, "id"), 10) === Number(a.group));
      if (!parentRow) throw new Error("group " + a.group + " is not a task in this plan");
      if (yes(at(plan, parentRow, "milestone"))) throw new Error("a milestone cannot hold members");
      if (a.milestone) throw new Error("a milestone is project-wide and cannot be inside a group");
    }
    const uids = new Set(tasks.map(t => t.uid));
    let uid = newUid(); while (uids.has(uid)) uid = newUid();
    const day = today(), by = a.by || WRITER_NAME, st = stamp(a.by);
    const fields = {
      id, name, duration: a.milestone ? 0 : daysBetween(a.start, end) + 1, start: a.start, hourStart: a.hourStart || "",
      end, hourEnd: a.milestone ? (a.hourStart || "") : (a.hourEnd || ""), preds: preds.join(";"),
      team: a.milestone ? "" : (a.team || ""), system: a.milestone ? "" : (a.system || ""),
      accountable: a.milestone ? "" : (a.accountable || ""), responsible: a.milestone ? "" : (Array.isArray(a.responsible) ? a.responsible.join(";") : (a.responsible || "")),
      group: "", parent: "", milestone: a.milestone ? "yes" : "no", notes: String(a.notes || "").slice(0, 500),
      status, history: day + " " + status, event: "no", emoji: "", notFinal: a.notFinalized && !a.milestone ? "yes" : "no",
      baselineEnd: "", dateHist: "", uid, created: st, modified: st, historyBy: day + " " + by,
      reported: a.group != null || a.milestone ? "no" : "yes", labels: (a.labels || []).map(s => String(s).trim()).filter(Boolean).join(";"),
    };
    if (parentRow) {
      /* v2: the CHILD names its parent, by UID — the parent row is not even
         touched, which is the whole point of the direction flip. A file still
         wearing the v1 header gets the old parent-side edit, unchanged. */
      if (plan.idx.parent != null) {
        fields.parent = at(plan, parentRow, "uid");
      } else {
        const members = idList(at(plan, parentRow, "group")); members.push(id);
        setCell(plan, parentRow, "group", [...new Set(members)].sort((x, y) => x - y).join(";"));
        setCell(plan, parentRow, "modified", st);
      }
    }
    /* Where the row BELONGS, not where appending is cheap: the whole row block
       is renumbered into the app's own time order (timeOrderRows above), the
       references remapped with it. A row whose id and references are untouched
       comes out byte-identical, just possibly on a different line. */
    const newLine = rowLine(plan, fields).split(DELIM);
    const every = plan.rows.map(r => ({ cells: r.cells })).concat([{ cells: newLine }]);
    /* Membership for the ordering, whichever way the file spells it: v1's
       parent-side Group lists, v2's child-side Parent UIDs — folded into one
       oldId → member-ids map. Both can occur in one file; they union. */
    const idAt = c => parseInt(cellDec(c.cells[plan.idx.id] || ""), 10);
    const membersOf = new Map();
    const addMember = (pid, cid) => { if (!membersOf.has(pid)) membersOf.set(pid, []); if (!membersOf.get(pid).includes(cid)) membersOf.get(pid).push(cid); };
    if (plan.idx.group != null)
      for (const c of every) for (const m of idList(cellDec(c.cells[plan.idx.group] || ""))) addMember(idAt(c), m);
    if (plan.idx.parent != null) {
      const idByUid = new Map(every.map(c => [cellDec(c.cells[plan.idx.uid] || "").trim(), idAt(c)]));
      for (const c of every) {
        const pu = cellDec(c.cells[plan.idx.parent] || "").trim();
        if (pu && idByUid.has(pu)) addMember(idByUid.get(pu), idAt(c));
      }
    }
    const item = c => ({ oldId: idAt(c),
      start: cellDec(c.cells[plan.idx.start] || "").trim(), hourStart: cellDec(c.cells[plan.idx.hourStart] || "").trim(),
      end: cellDec(c.cells[plan.idx.end] || "").trim(), hourEnd: cellDec(c.cells[plan.idx.hourEnd] || "").trim(),
      event: plan.idx.event != null && /^\s*(y|i|1|t)/i.test(c.cells[plan.idx.event] || ""),
      members: (membersOf.get(idAt(c)) || []).slice().sort((x, y) => x - y), row: c });
    const ordered = timeOrderRows(every.map(item));
    const map = new Map(ordered.map((t, i) => [t.oldId, i + 1]));
    const remap = list => list.map(x => map.get(x) || x);
    for (const t of ordered) {
      const c = t.row.cells;
      c[plan.idx.id] = String(map.get(t.oldId));
      if (plan.idx.preds != null && c[plan.idx.preds]) c[plan.idx.preds] = remap(idList(cellDec(c[plan.idx.preds]))).join(";");
      // v1 lists carry ids and are remapped with them; a v2 Parent cell is a
      // UID and needs nothing — identity is the point of it
      if (plan.idx.group != null && c[plan.idx.group]) c[plan.idx.group] = remap(idList(cellDec(c[plan.idx.group]))).sort((x, y) => x - y).join(";");
    }
    const newId = map.get(id);
    /* uid before id, deliberately: the id is a position and the app renumbers
       it into time order on the next open — the uid is the handle to remember
       a row by, so it comes first in what the model reads back. */
    return { rowsAll: ordered.map(t => t.row.cells.join(DELIM)),
             info: { uid, id: newId, name, status, group: a.group != null ? map.get(Number(a.group)) : null } };
  });
}

export function setStatus(store, a) {
  const to = normStatus(a.status);
  if (!to) throw new Error("status must be one of: " + STATUSES.join(" · "));
  return store.write(a.plan, plan => {
    const row = plan.rows.find(r => (a.uid ? at(plan, r, "uid") === a.uid : parseInt(at(plan, r, "id"), 10) === Number(a.id)));
    if (!row) throw new Error("no such task: " + (a.uid || a.id));
    if (yes(at(plan, row, "event"))) throw new Error("an event has no status");
    const myUid = at(plan, row, "uid");
    const isGroup = idList(at(plan, row, "group")).length > 0
      || (plan.idx.parent != null && plan.rows.some(r2 => r2 !== row && at(plan, r2, "parent") === myUid));
    if (isGroup) throw new Error("a group's status is its members' — move the members");
    const from = at(plan, row, "status");
    if (from === to) return { replace: [], info: { uid: at(plan, row, "uid"), id: parseInt(at(plan, row, "id"), 10), from, to, changed: false } };
    const day = today(), by = a.by || WRITER_NAME;
    const hist = at(plan, row, "history"); const histBy = at(plan, row, "historyBy");
    setCell(plan, row, "status", to);
    setCell(plan, row, "history", (hist ? hist + ";" : "") + day + " " + to);
    /* History By stays in step with History, one entry per entry: a file whose names
       were already out of step (or absent) gets "-" for the unknown ones first. */
    const nHist = parsePairs(hist).length, byList = parsePairs(histBy);
    const pad = byList.map(p => p.date + " " + p.value);
    while (pad.length < nHist) pad.push((parsePairs(hist)[pad.length] || { date: day }).date + " -");
    pad.push(day + " " + by);
    setCell(plan, row, "historyBy", pad.join(";"));
    setCell(plan, row, "modified", stamp(a.by));
    return { replace: [[row.at, row.cells.join(DELIM)]], info: { uid: at(plan, row, "uid"), id: parseInt(at(plan, row, "id"), 10), from, to, changed: true } };
  });
}

/* A new plan, from nothing: the header, the writer stamp, and optionally its
   first tasks — each one added through addTask itself, so every row passes
   exactly the validation a row added later would. The file is created empty-
   but-valid first and the rows follow one write each; if a row is refused the
   file stays a valid plan holding the rows before it, and the error names the
   row, so nothing half-written ever exists. */
export function createPlan(store, a) {
  if (store.readOnly) throw new Error("this server was started --read-only");
  const f = store.placeForNew(a.name);
  const head = HEADER.join(DELIM) + "\r\n" + ["#META", "format", FORMAT_VERSION].join(DELIM) + "\r\n" +
    ["#META", "writer", WRITER].join(DELIM) + "\r\n" +
    ["#META", "spec", SPEC_URL].join(DELIM) + "\r\n";
  const tmp = f.path + ".mcp-" + process.pid + ".tmp";
  writeFileSync(tmp, head);
  renameSync(tmp, f.path);
  /* Because every add renumbers into time order, an id the caller planned with
     may have moved by the time a later first-task names it — so here, and only
     here, predecessors/group are 1-based POSITIONS in this tasks array, and
     each is resolved to the current id of that task's uid at add time. */
  const added = [];
  const idOfPos = (store2, plan, pos, what, i) => {
    const t = added[pos - 1];
    if (!t) throw new Error(what + " " + pos + " does not name an earlier task in this list (tasks are referenced by their 1-based position here)");
    const r = store2.read(plan, "compact").tasks.find(x => x.uid === t.uid);
    if (!r) throw new Error(what + " " + pos + ": its row is gone");
    return r.id;
  };
  for (const [i, row] of (a.tasks || []).entries()) {
    try {
      const req = { ...row, plan: f.name, by: row.by || a.by };
      if (req.predecessors) req.predecessors = req.predecessors.map(p => idOfPos(store, f.name, Number(p), "predecessor", i));
      if (req.group != null) req.group = idOfPos(store, f.name, Number(req.group), "group", i);
      added.push(addTask(store, req));
    }
    catch (e) { throw new Error("task " + (i + 1) + " (\"" + (row && row.name || "?") + "\"): " + e.message + " — the plan was created and holds the " + added.length + " task(s) before it"); }
  }
  return { name: f.name, folder: f.dir, tasks: added };
}

/* ============================ the protocol ============================ */

export const TOOLS = [
  { name: "list_plans", description: "The Gantora plans in the configured DB folder(s): name, rows, last modified, whether the file declares itself read-only, which program wrote it last.",
    inputSchema: { type: "object", properties: {}, additionalProperties: false } },
  { name: "read_plan", description: "One plan as structured JSON: the tasks, a summary (leaf-task counts by schedule bucket, overdue/blocked/late-start ids, milestones), and the file's #META facts. Compact by default — per task the essential fields only, empties dropped — which answers status questions at a fraction of the size; ask detail:\"full\" only when inspecting rows closely (history, people, notes; a large plan in full can be ~100 KB). NOTE: a task's id is its position and the app renumbers ids into time order — remember and refer to tasks by uid. Read-only.",
    inputSchema: { type: "object", properties: { plan: { type: "string", description: "file name, e.g. Kitchen.gantora (extension optional)" }, detail: { type: "string", enum: ["compact", "full"], description: "default compact" } }, required: ["plan"], additionalProperties: false } },
  { name: "create_plan", description: "Create a NEW plan file, optionally with its first tasks (each validated exactly like add_task, in order). In THIS tool predecessors/group refer to the 1-based POSITION of an earlier task in the tasks array (ids renumber as rows land in time order). The name is the file name: use a human name agreed in the conversation, in the user's own language (\"Kitchen renovation\"), no version numbers or jargon; a taken name is an error, nothing is ever overwritten. Refused on a --read-only server.",
    inputSchema: { type: "object", properties: {
      name: { type: "string", description: "the plan's human name; .gantora is added" },
      by: { type: "string", description: "who is creating it (a display name)" },
      tasks: { type: "array", description: "optional first tasks, in order; same fields as add_task minus plan", items: { type: "object" } } },
      required: ["name"], additionalProperties: false } },
  { name: "add_task", description: "Append one task to a plan: dates, optional people/team/system/notes/labels/predecessors, optionally inside a group (by the group's id). Status starts Planned (or as given) with its dated History, a fresh UID, Created/Modified stamps. The row is inserted at its place in time order and the plan renumbered the way the app itself would (references remapped) — so the answer's id is the row's real position, and the uid, given first, is what to remember it by. Refused on a read-only plan.",
    inputSchema: { type: "object", properties: {
      plan: { type: "string" }, name: { type: "string" }, start: { type: "string", description: "YYYY-MM-DD" }, end: { type: "string", description: "YYYY-MM-DD (defaults to start)" },
      hourStart: { type: "string", description: "HH:MM" }, hourEnd: { type: "string", description: "HH:MM" },
      team: { type: "string" }, system: { type: "string" }, accountable: { type: "string", description: "Name or Name:hoursPerDay" },
      responsible: { type: "array", items: { type: "string" }, description: "Name or Name:hoursPerDay each" },
      notes: { type: "string" }, labels: { type: "array", items: { type: "string" } },
      predecessors: { type: "array", items: { type: "integer" }, description: "ids of tasks this one waits for" },
      group: { type: "integer", description: "id of the group row to put it in" },
      milestone: { type: "boolean" }, notFinalized: { type: "boolean", description: "the dates are still a guess" },
      status: { type: "string", enum: STATUSES }, by: { type: "string", description: "who is making this change (a display name)" } },
      required: ["plan", "name", "start"], additionalProperties: false } },
  { name: "set_status", description: "Move one task's status to Planned · In Progress · On Hold · Blocked · Done · Cancelled (work that will not happen — never use it to mean finished). Prefer naming the task by uid — ids renumber when the app time-orders the plan, and a remembered id can hit the wrong row. Appends to History and History By, stamps Modified, touches nothing else. No-op (and says so) if it is already there. Not on a group (its status is its members') or an event.",
    inputSchema: { type: "object", properties: { plan: { type: "string" }, id: { type: "integer" }, uid: { type: "string" }, status: { type: "string", enum: STATUSES }, by: { type: "string" } },
      required: ["plan", "status"], additionalProperties: false } },
];

export function handle(store, msg) {
  const { id, method, params } = msg;
  const reply = result => ({ jsonrpc: "2.0", id, result });
  const fail = (code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
  if (method === "initialize") {
    const want = params && params.protocolVersion;
    return reply({ protocolVersion: PROTOCOLS.includes(want) ? want : PROTOCOLS[0], capabilities: { tools: {} },
                   serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
                   instructions: "Gantora plans are plain pipe-separated text files; these tools read and carefully extend them. Prefer read_plan for any question; add_task and set_status are the only writes, and both refuse a plan that declares itself read-only." });
  }
  if (method === "ping") return reply({});
  if (method === "tools/list") return reply({ tools: store.readOnly ? TOOLS.filter(t => t.name === "list_plans" || t.name === "read_plan") : TOOLS });
  if (method === "tools/call") {
    const name = params && params.name, a = (params && params.arguments) || {};
    const text = v => ({ content: [{ type: "text", text: JSON.stringify(v, null, 2) }] });
    try {
      if (name === "list_plans") return reply(text({ plans: store.list() }));
      if (name === "read_plan") return reply(text(store.read(a.plan, a.detail)));
      if (name === "create_plan") { if (store.readOnly) throw new Error("read-only server"); return reply(text(createPlan(store, a))); }
      if (name === "add_task") { if (store.readOnly) throw new Error("read-only server"); return reply(text(addTask(store, a))); }
      if (name === "set_status") { if (store.readOnly) throw new Error("read-only server"); return reply(text(setStatus(store, a))); }
      return fail(-32602, "unknown tool: " + name);
    } catch (e) {
      return reply({ content: [{ type: "text", text: "Error: " + (e && e.message || e) }], isError: true });
    }
  }
  if (id === undefined || id === null) return null;          // a notification: nothing to answer
  return fail(-32601, "method not found: " + method);
}

/* stdio: one JSON-RPC message per line, in and out. */
export function serve(store, input = process.stdin, output = process.stdout) {
  const rl = createInterface({ input, crlfDelay: Infinity });
  rl.on("line", line => {
    line = line.trim();
    if (!line) return;
    let msg;
    try { msg = JSON.parse(line); } catch { output.write(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }) + "\n"); return; }
    const res = handle(store, msg);
    if (res) output.write(JSON.stringify(res) + "\n");
  });
}

const isMain = (() => { try { return process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
if (isMain) {
  const args = process.argv.slice(2);
  const readOnly = args.includes("--read-only");
  const dirs = args.filter(a => !a.startsWith("--"));
  if (!dirs.length) { process.stderr.write("usage: node gantora-mcp.mjs /path/to/DB [more folders] [--read-only]\n"); process.exit(2); }
  try { serve(new Store(dirs, { readOnly })); }
  catch (e) { process.stderr.write(SERVER_NAME + ": " + e.message + "\n"); process.exit(2); }
}
