UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

206 lines (205 loc) • 9.48 kB
import { parseTaskMetadata } from "../data/task-metadata.js"; /** * Three-way merge of a tasks.md file by task id, for store sync conflicts. * * Each task is keyed by its `bid:` comment (or its normalized text when it has * none) and compared across the merge base, the local side and the incoming * side. The side that changed a task wins; when both changed it, a completed * side wins, then an edit beats a removal, then the incoming side wins. The * result keeps the incoming file's layout and section order. */ const BID_PATTERN = /<!--\s*bid:([a-z0-9]{8})\b[^>]*-->/; const COMPLETED_PATTERN = /^- \[[xX]\]/; function taskKey(bullet) { const bid = BID_PATTERN.exec(bullet); if (bid) return `bid:${bid[1]}`; return `text:${bullet.replace(/^- \[[ xX]\]\s*/, "").replace(/<!--[\s\S]*?-->/g, "").trim().toLowerCase()}`; } /** Splits a tasks file into a preamble and `## ` sections of task records and other lines. */ function parse(content) { const sections = [{ heading: null, name: "", rows: [] }]; let current = sections[0]; let task = null; for (const line of content.replace(/\r\n/g, "\n").split("\n")) { if (line.startsWith("## ")) { task = null; current = { heading: line, name: line.slice(3).trim(), rows: [] }; sections.push(current); } else if (line.startsWith("- ") && current.heading !== null) { task = { key: taskKey(line), section: current.name, lines: [line] }; current.rows.push({ kind: "task", entry: task }); } else if (task && line.startsWith(" ") && line.trim()) { task.lines.push(line); } else { task = null; current.rows.push({ kind: "line", text: line }); } } return sections; } function index(sections) { const map = new Map(); for (const section of sections) { for (const row of section.rows) { if (row.kind === "task" && !map.has(row.entry.key)) map.set(row.entry.key, row.entry); } } return map; } const value = (entry) => entry ? `${entry.section}\n${entry.lines.join("\n")}` : undefined; const completed = (entry) => !!entry && COMPLETED_PATTERN.test(entry.lines[0]); /** The winning version of one task, or undefined when it is removed. */ export function pickTask(base, ours, theirs) { const [b, o, t] = [value(base), value(ours), value(theirs)]; if (o === t) return theirs; if (o === b) return theirs; if (t === b) return ours; if (completed(ours) !== completed(theirs)) return completed(ours) ? ours : theirs; if (!theirs) return ours; return theirs; } /** Merge independent responsibility/dependency edits and retain both histories. * Ordinary task conflict precedence still controls title, claim and section. */ function mergeContract(base, ours, theirs, winner) { const read = (entry) => { const records = entry?.lines.filter(line => line.trimStart().startsWith("Task:")) ?? []; if (!records.length) return { value: { version: 1, responsibility: "agent", dependencies: [], history: [] } }; if (records.length !== 1) return { raw: records.join("\n") }; return { raw: records[0], value: parseTaskMetadata(records[0].trim().slice(5).trim()) }; }; const b = read(base), o = read(ours), t = read(theirs); if (o.raw === undefined && t.raw === undefined) return winner; if (!b.value || !o.value || !t.value) return winner; const baseValue = b.value, ourValue = o.value, theirValue = t.value; const pick = (field) => JSON.stringify(theirValue[field]) === JSON.stringify(baseValue[field]) ? ourValue[field] : theirValue[field]; const history = [...new Map([...o.value.history, ...t.value.history].map(h => [JSON.stringify(h), h])).values()]; const contract = { version: 1, responsibility: pick("responsibility"), dependencies: pick("dependencies"), history }; let lines = winner.lines.filter(line => !line.trimStart().startsWith("Task:")); if (contract.responsibility === "human") { lines = lines.filter(line => { const claim = /^Claimed:\s+([A-Za-z0-9][A-Za-z0-9._-]{0,252})\s+(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)(?:\s+session:[A-Za-z0-9._:-]{1,128})?$/.exec(line.trim()); if (!claim) return true; history.push({ at: history.at(-1)?.at ?? claim[2], change: `released claim by ${claim[1]} during human responsibility merge` }); return false; }); } return { ...winner, lines: [...lines, ` Task: ${JSON.stringify(contract)}`] }; } /** * Merges three versions of a tasks.md. `base` is empty when the file was * added on both sides. */ export function mergeTasksByBid(base, ours, theirs) { const oursSections = parse(ours); const theirsSections = parse(theirs); const b = index(parse(base)); const o = index(oursSections); const t = index(theirsSections); // A duplicated identity cannot be reconciled by first-win indexing. Leave // the sync conflict unresolved, retaining both original files for repair. for (const sections of [parse(base), oursSections, theirsSections]) { const seen = new Set(); for (const section of sections) for (const row of section.rows) { if (row.kind !== "task" || !row.entry.key.startsWith("bid:")) continue; if (seen.has(row.entry.key) || [...row.entry.lines[0].matchAll(/<!--\s*bid:([a-f0-9]{8})\b[^>]*-->/g)].length !== 1) { throw new Error("Task merge requires unique, unambiguous stable IDs; repair the conflicting records first."); } seen.add(row.entry.key); } } const chosen = new Map(); for (const key of new Set([...b.keys(), ...o.keys(), ...t.keys()])) { const winner = pickTask(b.get(key), o.get(key), t.get(key)); if (winner) chosen.set(key, mergeContract(b.get(key), o.get(key), t.get(key), winner)); } // Local order, per section, so a task only the local side placed there lands after its local predecessor. const localOrder = new Map(); for (const section of oursSections) { localOrder.set(section.name, section.rows.flatMap((row) => row.kind === "task" ? [row.entry.key] : [])); } const emitted = new Set(); const out = []; const renderSection = (section) => { if (section.heading !== null) out.push(section.heading); const rows = section.rows.filter((row) => { if (row.kind !== "task") return true; const winner = chosen.get(row.entry.key); return !!winner && winner.section === section.name && !emitted.has(row.entry.key); }).map((row) => row.kind === "task" ? { kind: "task", entry: chosen.get(row.entry.key) } : row); for (const row of rows) if (row.kind === "task") emitted.add(row.entry.key); if (section.heading !== null) { const missing = [...chosen.values()].filter((entry) => entry.section === section.name && !emitted.has(entry.key)); const order = localOrder.get(section.name) ?? []; for (const entry of missing) { const at = order.indexOf(entry.key); const before = order.slice(0, Math.max(at, 0)).reverse().find((key) => rows.some((row) => row.kind === "task" && row.entry.key === key)); let position; if (before) { position = rows.findIndex((row) => row.kind === "task" && row.entry.key === before) + 1; } else { const first = rows.findIndex((row) => row.kind === "task"); if (at >= 0 && first >= 0) { position = first; } else { let last = -1; rows.forEach((row, i) => { if (row.kind === "task") last = i; }); position = last >= 0 ? last + 1 : (rows.length > 0 && rows[0].kind === "line" && rows[0].text === "" ? 1 : 0); } } rows.splice(position, 0, { kind: "task", entry }); emitted.add(entry.key); } } for (const row of rows) { if (row.kind === "task") out.push(...row.entry.lines); else out.push(row.text); } }; for (const section of theirsSections) renderSection(section); // Sections only the local side has, in local order. const theirNames = new Set(theirsSections.map((section) => section.name)); for (const section of oursSections) { if (section.heading === null || theirNames.has(section.name)) continue; const entries = [...chosen.values()].filter((entry) => entry.section === section.name && !emitted.has(entry.key)); if (entries.length === 0) continue; if (out.length > 0 && out[out.length - 1] !== "") out.push(""); out.push(section.heading, ""); for (const entry of entries) { out.push(...entry.lines); emitted.add(entry.key); } out.push(""); } return out.join("\n"); }