UNPKG

@phren/cli

Version:

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

1,189 lines • 58.5 kB
import { parseTaskMetadata } from "./task-metadata.js";
import { taskFormatStatus, taskFormatMigrationHint } from "./task-format.js";
import { moduleEnabled } from "../modules/runtime.js";
import { disabledHint } from "../modules/registry.js";
import * as fs from "fs";
import * as path from "path";
import { createHash, randomBytes, randomUUID } from "crypto";
import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, } from "../shared.js";
import { validateTaskFormat } from "../shared/content.js";
import { withSafeLock, ensureProject } from "../shared/data-utils.js";
import { getNonPrimaryStores, getStoreProjectDirs } from "../store-registry.js";
import { storeAwareProjectPath } from "../store-routing.js";
import { TASKS_FILENAME } from "../filenames.js";
import { taskIdentity, taskReadiness, validateTaskDependencies, withTaskGraphLock } from "./task-contract.js";
import { recordTaskWrite } from "./task-receipts.js";
const ACTIVE_HEADINGS = new Set(["active", "in progress", "in-progress", "current", "wip"]);
const QUEUE_HEADINGS = new Set(["queue", "queued", "task", "todo", "upcoming", "next"]);
const DONE_HEADINGS = new Set(["done", "completed", "finished", "archived"]);
export { TASK_FILE_ALIASES, TASKS_FILENAME, isTaskFileName } from "../filenames.js";
const CLAIM_LINE = /^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}))?$/;
function formatClaim(claim) {
    return `Claimed: ${claim.computer} ${claim.at}${claim.session ? ` session:${claim.session}` : ""}`;
}
const TASK_SECTIONS = ["Active", "Queue", "Done"];
function normalizePriority(text) {
    const m = text.replace(/\s*\[pinned\]/gi, "").match(/\[(high|medium|low)\]\s*$/i);
    if (!m)
        return undefined;
    return m[1].toLowerCase();
}
function stripPriorityTag(text) {
    // Strip ALL trailing priority tags (with optional [pinned] interleaved or at the end).
    // Previous regex only stripped the last one and required [pinned] (if present) to be the
    // final token — meaning accumulated `[high] [high] ... [pinned]` only lost one tag per
    // call, so each updateTask grew the line by one tag (observed: 48× [high] on one task).
    let prev;
    do {
        prev = text;
        text = text.replace(/\s*\[(high|medium|low)\](?=\s*(?:\[pinned\])?\s*$)/gi, "");
    } while (text !== prev);
    return text.replace(/\s{2,}/g, " ").trim();
}
function detectPinned(text) {
    return /\[pinned\]/i.test(text);
}
function stripPinnedTag(text) {
    return text.replace(/\s*\[pinned\]/gi, "").trim();
}
export function stripBulletPrefix(line) {
    const checked = /^-\s*\[[xX]\]\s+/.test(line);
    const body = line
        .replace(/^-\s*\[[ xX]\]\s+/, "")
        .replace(/^-\s+/, "")
        .trim();
    return { checked, body };
}
function parseGitHubIssueReference(raw) {
    const trimmed = raw.trim();
    if (!trimmed)
        return {};
    const urlMatch = trimmed.match(/https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/(\d+)(?:[?#][^\s]*)?/);
    const issueMatch = trimmed.match(/#?(\d+)/);
    const githubIssue = urlMatch
        ? Number.parseInt(urlMatch[1], 10)
        : issueMatch
            ? Number.parseInt(issueMatch[1], 10)
            : undefined;
    const githubUrl = urlMatch ? urlMatch[0] : undefined;
    return {
        githubIssue: Number.isFinite(githubIssue) ? githubIssue : undefined,
        githubUrl,
    };
}
function isValidGitHubIssueUrl(raw) {
    return Boolean(parseGitHubIssueReference(raw).githubUrl);
}
function formatGitHubIssueReference(item) {
    if (!item.githubIssue && !item.githubUrl)
        return undefined;
    if (item.githubIssue && item.githubUrl)
        return `#${item.githubIssue} ${item.githubUrl}`;
    if (item.githubIssue)
        return `#${item.githubIssue}`;
    return item.githubUrl;
}
function parseContinuation(lines, idx) {
    let contract;
    let taskContractRaw;
    const taskContractRecords = [];
    const taskContractLines = [];
    const opaqueContinuationLines = [];
    let context;
    let claim;
    let githubIssue;
    let githubUrl;
    let linesToSkip = 0;
    for (let cursor = idx + 1; cursor < lines.length; cursor++) {
        const raw = lines[cursor];
        if (!raw.startsWith("  "))
            break;
        const trimmed = raw.trim();
        if (!trimmed) {
            linesToSkip++;
            continue;
        }
        if (trimmed.startsWith("Task:")) {
            taskContractRecords.push(trimmed.slice(5).trim());
            taskContractLines.push(raw);
            linesToSkip++;
            continue;
        }
        if (trimmed.startsWith("Context:")) {
            context = trimmed.slice("Context:".length).trim();
            linesToSkip++;
            continue;
        }
        const claimed = CLAIM_LINE.exec(trimmed);
        if (claimed) {
            claim = { computer: claimed[1], at: claimed[2], ...(claimed[3] ? { session: claimed[3] } : {}) };
            linesToSkip++;
            continue;
        }
        if (trimmed.startsWith("GitHub:")) {
            const parsed = parseGitHubIssueReference(trimmed.slice("GitHub:".length));
            githubIssue = parsed.githubIssue;
            githubUrl = parsed.githubUrl;
            linesToSkip++;
            continue;
        }
        opaqueContinuationLines.push(raw);
        linesToSkip++;
    }
    if (taskContractRecords.length === 1) {
        contract = parseTaskMetadata(taskContractRecords[0]);
        if (!contract)
            taskContractRaw = taskContractRecords[0];
    }
    else if (taskContractRecords.length > 1)
        taskContractRaw = taskContractRecords[0];
    return { context, githubIssue, githubUrl, claim, contract, taskContractRaw,
        ...(taskContractRaw !== undefined ? { taskContractLines } : {}),
        ...(taskContractRecords.length > 1 ? { taskContractRecords } : {}),
        ...(opaqueContinuationLines.length ? { opaqueContinuationLines } : {}), linesToSkip };
}
/** Pattern that matches the task metadata comment embedded in task item lines.
 *  Format: <!-- bid:HASH [rank:N] [lastActivity:ISO] -->
 */
const METADATA_PATTERN = /\s*<!--\s*bid:([a-z0-9]{8})(?:\s+rank:(\d+))?(?:\s+lastActivity:([^\s>]+))?(?:\s+created:([^\s>]+))?(?:\s+session:([^\s>]+))?(?:\s+scope:([^\s>]+))?(?:\s+findings:((?:[a-z0-9]{8}(?::[a-z0-9]{8})?|fid:[a-z0-9]{8})(?:,[a-z0-9a-z:]{3,})*))?(?:\s+parentFinding:([^\s>]+))?(\s+speculative)?\s*-->/;
/** Generate a new 8-character random stable ID. */
function newBid() {
    return randomBytes(4).toString("hex");
}
/** Strip the metadata comment from a raw line, returning the clean text and any extracted fields. */
export function stripBid(text) {
    const m = text.match(METADATA_PATTERN);
    if (!m)
        return { clean: text };
    const rankNum = m[2] ? Number.parseInt(m[2], 10) : undefined;
    const childFindings = m[7] ? m[7].split(",").filter(Boolean) : undefined;
    return {
        clean: text.replace(METADATA_PATTERN, "").trimEnd(),
        bid: m[1],
        rank: Number.isFinite(rankNum) ? rankNum : undefined,
        lastActivity: m[3] || undefined,
        createdAt: m[4] || undefined,
        sessionId: m[5] || undefined,
        scope: m[6] || undefined,
        childFindings: childFindings && childFindings.length > 0 ? childFindings : undefined,
        parentFinding: m[8] || undefined,
        speculative: m[9] ? true : undefined,
    };
}
/**
 * Auto-assign numeric ranks to items without a rank.
 * high-priority items get lowest numbers, then medium, then low, then unranked.
 */
function assignMissingRanks(items) {
    const unranked = items.filter((item) => item.rank === undefined);
    if (!unranked.length)
        return;
    const maxExisting = items.reduce((max, item) => (item.rank !== undefined && item.rank > max ? item.rank : max), 0);
    const priorityOrder = { high: 0, medium: 1, low: 2 };
    unranked.sort((a, b) => (priorityOrder[a.priority ?? ""] ?? 3) - (priorityOrder[b.priority ?? ""] ?? 3));
    let next = maxExisting + 1;
    for (const item of unranked) {
        item.rank = next++;
    }
}
/**
 * Apply gravity to tasks: items with stale lastActivity drift toward higher rank numbers.
 * Only affects display order — does not mutate the file.
 */
export function applyGravity(items) {
    const now = Date.now();
    const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000;
    const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
    return items.map((item) => {
        if (!item.lastActivity || item.rank === undefined)
            return item;
        const age = now - new Date(item.lastActivity).getTime();
        if (age <= TWO_WEEKS_MS)
            return item;
        const weeksStale = Math.floor((age - TWO_WEEKS_MS) / ONE_WEEK_MS);
        return { ...item, rank: item.rank + Math.min(weeksStale, 10) };
    });
}
export function canonicalTaskFilePath(phrenPath, project) {
    // Store-aware: locks and writes must target the owning store's file, not a
    // phantom primary-store path.
    return storeAwareProjectPath(phrenPath, project, TASKS_FILENAME);
}
export function resolveTaskFilePath(phrenPath, project) {
    return canonicalTaskFilePath(phrenPath, project);
}
function normalizeTaskItemLine(item) {
    // Strip pinned first so the priority strip can clear ALL trailing priority tags
    // (otherwise [pinned] sat between accumulated [high]s and the end-anchor, leaking
    // one tag per render — see stripPriorityTag for the pre-fix shape).
    let text = stripPinnedTag(item.line).replace(/(\s*\[(high|medium|low)\])+\s*$/gi, "").trim();
    if (item.priority)
        text = `${text} [${item.priority}]`;
    if (item.pinned)
        text = `${text} [pinned]`;
    const prefix = item.checked || item.section === "Done" ? "- [x] " : "- [ ] ";
    const bid = item.stableId ?? newBid();
    const rankPart = item.rank !== undefined ? ` rank:${item.rank}` : "";
    const activityPart = item.lastActivity ? ` lastActivity:${item.lastActivity}` : "";
    const createdPart = item.createdAt ? ` created:${item.createdAt}` : "";
    const sessionPart = item.sessionId ? ` session:${item.sessionId}` : "";
    const scopePart = item.scope ? ` scope:${item.scope}` : "";
    const findingsPart = item.childFindings && item.childFindings.length > 0 ? ` findings:${item.childFindings.join(",")}` : "";
    const parentFindingPart = item.parentFinding ? ` parentFinding:${item.parentFinding}` : "";
    const speculativePart = item.speculative ? " speculative" : "";
    return `${prefix}${text} <!-- bid:${bid}${rankPart}${activityPart}${createdPart}${sessionPart}${scopePart}${findingsPart}${parentFindingPart}${speculativePart} -->`;
}
export function parseTaskContent(project, taskPath, content) {
    const lines = content.split("\n");
    const title = lines[0]?.trim() || `# ${project} tasks`;
    const items = {
        Active: [],
        Queue: [],
        Done: [],
    };
    let section = "Queue";
    const sectionCounters = { Active: 0, Queue: 0, Done: 0 };
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        const heading = line.trim().match(/^##\s+(.+?)[\s]*$/);
        if (heading) {
            const token = heading[1].replace(/\s+/g, " ").trim().toLowerCase();
            if (ACTIVE_HEADINGS.has(token)) {
                section = "Active";
            }
            else if (QUEUE_HEADINGS.has(token)) {
                section = "Queue";
            }
            else if (DONE_HEADINGS.has(token)) {
                section = "Done";
            }
            continue;
        }
        if (!line.startsWith("- "))
            continue;
        const parsed = stripBulletPrefix(line);
        // Extract and strip the metadata comment before further parsing.
        const { clean: cleanBody, bid, rank, lastActivity, createdAt, sessionId, scope, childFindings, parentFinding, speculative } = stripBid(parsed.body);
        const pinned = detectPinned(cleanBody);
        const priority = normalizePriority(cleanBody);
        const continuation = parseContinuation(lines, i);
        const sectionPrefix = section === "Active" ? "A" : section === "Queue" ? "Q" : "D";
        sectionCounters[section]++;
        items[section].push({
            id: `${sectionPrefix}${sectionCounters[section]}`,
            stableId: bid,
            identityAmbiguous: [...line.matchAll(/<!--\s*bid:([a-f0-9]{8})\b[^>]*-->/g)].length > 1 || undefined,
            section,
            line: cleanBody,
            checked: parsed.checked || section === "Done",
            priority,
            rank,
            lastActivity,
            createdAt,
            sessionId,
            scope,
            childFindings,
            parentFinding,
            speculative,
            context: continuation.context,
            pinned: pinned || undefined,
            githubIssue: continuation.githubIssue,
            githubUrl: continuation.githubUrl,
            claim: continuation.claim,
            responsibility: continuation.contract?.responsibility,
            dependencies: continuation.contract?.dependencies,
            history: continuation.contract?.history,
            taskContractRaw: continuation.taskContractRaw,
            taskContractRecords: continuation.taskContractRecords,
            taskContractLines: continuation.taskContractLines,
            opaqueContinuationLines: continuation.opaqueContinuationLines,
        });
        i += continuation.linesToSkip;
    }
    // Assign ranks to items that don't have one yet (migration from priority-only files)
    for (const section of TASK_SECTIONS) {
        assignMissingRanks(items[section]);
    }
    return {
        project,
        title,
        path: taskPath,
        items,
        revision: createHash("sha256").update(content).digest("hex"),
        issues: validateTaskFormat(content),
    };
}
function renderTask(doc) {
    const out = [doc.title, ""];
    for (const section of TASK_SECTIONS) {
        out.push(`## ${section}`, "");
        for (const item of doc.items[section]) {
            out.push(normalizeTaskItemLine(item));
            if (item.taskContractLines?.length)
                out.push(...item.taskContractLines);
            else if (item.taskContractRecords?.length)
                out.push(...item.taskContractRecords.map(raw => `  Task: ${raw}`));
            else if (item.taskContractRaw !== undefined)
                out.push(`  Task: ${item.taskContractRaw}`);
            else if (item.responsibility !== undefined || item.dependencies?.length || item.history?.length)
                out.push(`  Task: ${JSON.stringify({ version: 1, responsibility: item.responsibility ?? "agent", dependencies: item.dependencies ?? [], history: item.history ?? [] })}`);
            if (item.context)
                out.push(`  Context: ${item.context}`);
            const githubRef = formatGitHubIssueReference(item);
            if (githubRef)
                out.push(`  GitHub: ${githubRef}`);
            if (item.claim)
                out.push(`  ${formatClaim(item.claim)}`);
            if (item.opaqueContinuationLines?.length)
                out.push(...item.opaqueContinuationLines);
        }
        out.push("");
    }
    return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
}
function findItemByMatch(doc, match) {
    const needle = match.trim().toLowerCase();
    if (!needle)
        return { error: `${PhrenError.EMPTY_INPUT}: Please provide the item text or ID to match against.`, errorCode: PhrenError.EMPTY_INPUT };
    const unique = (matched) => {
        const item = doc.items[matched.section][matched.index];
        const duplicates = item.stableId && TASK_SECTIONS.flatMap(section => doc.items[section]).filter(other => other.stableId === item.stableId).length > 1;
        return item.identityAmbiguous || duplicates
            ? { error: "Stable task identity is ambiguous; repair the conflicting records before editing.", errorCode: PhrenError.AMBIGUOUS_MATCH }
            : { match: matched };
    };
    // 1a) Stable ID match (bid:XXXX or just the 8-char hex).
    const bidNeedle = needle.replace(/^bid:/, "");
    if (/^[a-f0-9]{8}$/.test(bidNeedle)) {
        const stableMatches = [];
        for (const section of TASK_SECTIONS)
            doc.items[section].forEach((item, index) => {
                if (item.stableId === bidNeedle)
                    stableMatches.push({ section, index });
            });
        if (stableMatches.length === 1)
            return unique(stableMatches[0]);
        if (stableMatches.length > 1)
            return { error: "Stable task ID is duplicated; repair the conflicting records before editing.", errorCode: PhrenError.AMBIGUOUS_MATCH };
        return { error: `No task with stable ID ${bidNeedle}.`, errorCode: PhrenError.NOT_FOUND };
    }
    // 1b) Positional ID match (A1, Q2, D3).
    for (const section of TASK_SECTIONS) {
        const idx = doc.items[section].findIndex((item) => item.id.toLowerCase() === needle);
        if (idx !== -1)
            return unique({ section, index: idx });
    }
    // 2) Exact line match.
    const exact = [];
    for (const section of TASK_SECTIONS) {
        doc.items[section].forEach((item, index) => {
            if (item.line.trim().toLowerCase() === needle)
                exact.push({ section, index });
        });
    }
    if (exact.length === 1)
        return unique(exact[0]);
    if (exact.length > 1) {
        return { error: `${PhrenError.AMBIGUOUS_MATCH}: "${match}" is ambiguous (${exact.length} exact matches). Use item ID.`, errorCode: PhrenError.AMBIGUOUS_MATCH };
    }
    // 3) Substring fallback, but only when unique.
    const partial = [];
    for (const section of TASK_SECTIONS) {
        doc.items[section].forEach((item, index) => {
            if (item.line.toLowerCase().includes(needle))
                partial.push({ section, index });
        });
    }
    if (partial.length === 1)
        return unique(partial[0]);
    if (partial.length > 1) {
        return { error: `${PhrenError.AMBIGUOUS_MATCH}: "${match}" is ambiguous (${partial.length} partial matches). Use item ID.`, errorCode: PhrenError.AMBIGUOUS_MATCH };
    }
    return { error: `${PhrenError.NOT_FOUND}: Item not found — no task matching "${match}".`, errorCode: PhrenError.NOT_FOUND };
}
function taskItemNotFound(project, match) {
    return phrenErr(`Item not found: no task matching "${match}" in project "${project}". Check the item text or use its ID (shown in the tasks view).`, PhrenError.NOT_FOUND);
}
function writeTaskDoc(doc) {
    const tmpPath = `${doc.path}.tmp-${randomUUID()}`;
    const content = renderTask(doc);
    fs.writeFileSync(tmpPath, content);
    fs.renameSync(tmpPath, doc.path);
    recordTaskWrite(doc.path, content);
}
function taskArchivePath(phrenPath, project) {
    return path.join(phrenPath, ".config", "task-archive", `${project}.md`);
}
export function readTasks(phrenPath, project) {
    if (!moduleEnabled(phrenPath, "tasks"))
        return phrenErr(disabledHint("tasks"), PhrenError.VALIDATION_ERROR);
    const ensured = ensureProject(phrenPath, project);
    if (!ensured.ok)
        return forwardErr(ensured);
    const taskPath = canonicalTaskFilePath(phrenPath, project);
    if (!taskPath)
        return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
    if (!fs.existsSync(taskPath)) {
        return phrenOk({
            project,
            title: `# ${project} tasks`,
            revision: createHash("sha256").update("").digest("hex"),
            path: taskPath,
            issues: [],
            items: { Active: [], Queue: [], Done: [] },
        });
    }
    const content = fs.readFileSync(taskPath, "utf8");
    return phrenOk(parseTaskContent(project, taskPath, content));
}
export function readTasksAcrossProjects(phrenPath, profile) {
    const result = [];
    const seen = new Set();
    // Primary store projects (with profile filtering)
    const primaryProjects = getProjectDirs(phrenPath, profile).map((dir) => path.basename(dir)).sort();
    for (const project of primaryProjects) {
        const file = canonicalTaskFilePath(phrenPath, project);
        if (!file || !fs.existsSync(file))
            continue;
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            continue;
        result.push(parsed.data);
        seen.add(project);
    }
    // Non-primary store projects (no profile — team stores don't have profiles)
    try {
        for (const store of getNonPrimaryStores(phrenPath)) {
            if (!fs.existsSync(store.path))
                continue;
            const storeProjects = getStoreProjectDirs(store).map((dir) => path.basename(dir));
            for (const project of storeProjects) {
                if (seen.has(project))
                    continue;
                seen.add(project);
                const file = canonicalTaskFilePath(store.path, project);
                if (!file || !fs.existsSync(file))
                    continue;
                const parsed = readTasks(store.path, project);
                if (!parsed.ok)
                    continue;
                result.push(parsed.data);
            }
        }
    }
    catch { /* store-registry not available — primary only */ }
    return result;
}
export function resolveTaskItem(phrenPath, project, match) {
    const parsed = readTasks(phrenPath, project);
    if (!parsed.ok)
        return forwardErr(parsed);
    const found = findItemByMatch(parsed.data, match);
    if (found.error)
        return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
    if (!found.match)
        return taskItemNotFound(project, match);
    return phrenOk(parsed.data.items[found.match.section][found.match.index]);
}
/** Validate raw input before normalization can hide a newline or identity. */
export function validTaskTitle(value) {
    return typeof value === "string" && !!value.trim() && !/[\x00-\x1f\x7f]|<!--\s*bid:/i.test(value);
}
const invalidTaskTitle = "Task text must be one nonempty line without control characters or embedded task identities.";
export function addTask(phrenPath, project, item, opts) {
    if (!validTaskTitle(item))
        return phrenErr(invalidTaskTitle, PhrenError.VALIDATION_ERROR);
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
    // Validate project exists before acquiring the lock — withFileLock creates the parent
    // directory via mkdirSync, which would silently create an unintended project directory.
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    const create = () => withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const line = item.replace(/^-\s*/, "").trim();
        if (!line)
            return phrenErr(invalidTaskTitle, PhrenError.VALIDATION_ERROR);
        if (opts?.responsibility !== undefined) {
            if (!taskFormatStatus(phrenPath).enabled)
                return phrenErr(taskFormatMigrationHint, PhrenError.VALIDATION_ERROR);
            if (!["human", "agent"].includes(opts.responsibility) || !opts.stableId || !/^[a-f0-9]{8}$/.test(opts.stableId))
                return phrenErr("Atomic creation requires a valid responsibility and stable ID.", PhrenError.VALIDATION_ERROR);
            if (!line || /[\r\n]/.test(line) || /<!--\s*bid:/i.test(line))
                return phrenErr("Task title must be one nonempty line.", PhrenError.VALIDATION_ERROR);
            const existing = [...parsed.data.items.Active, ...parsed.data.items.Queue, ...parsed.data.items.Done].filter(task => task.stableId === opts.stableId);
            if (existing.length) {
                const task = existing[0];
                if (existing.length === 1 && !task.identityAmbiguous && task.taskContractRaw === undefined && task.line === line && task.responsibility === opts.responsibility)
                    return phrenOk(task);
                return phrenErr("This creation identity already exists with different task content. Refresh before continuing; no task was created.", PhrenError.VALIDATION_ERROR);
            }
            const archive = path.join(phrenPath, ".config", "task-archive", `${project}.md`);
            try {
                if (new RegExp(`<!--\\s*bid:${opts.stableId}(?=\\s|-->)`, "i").test(fs.readFileSync(archive, "utf8")))
                    return phrenErr("This creation identity is already archived. Refresh before continuing; no task was created.", PhrenError.VALIDATION_ERROR);
            }
            catch (error) {
                if (error.code !== "ENOENT")
                    throw error;
            }
        }
        else if (opts?.stableId !== undefined)
            return phrenErr("A supplied creation identity requires explicit responsibility.", PhrenError.VALIDATION_ERROR);
        const newItem = {
            id: `Q${parsed.data.items.Queue.length + 1}`,
            stableId: opts?.stableId ?? newBid(),
            section: "Queue",
            line,
            ...(opts?.responsibility !== undefined ? { responsibility: opts.responsibility, dependencies: [], history: [{ at: new Date().toISOString(), change: `created as ${opts.responsibility}` }] } : {}),
            checked: false,
            priority: normalizePriority(line),
            createdAt: opts?.createdAt ?? new Date().toISOString(),
            sessionId: opts?.sessionId,
            scope: opts?.scope,
            parentFinding: opts?.parentFinding,
            speculative: opts?.speculative || undefined,
        };
        parsed.data.items.Queue.push(newItem);
        writeTaskDoc(parsed.data);
        return phrenOk(newItem);
    });
    return opts?.responsibility !== undefined ? withTaskGraphLock(opts.graphRoot ?? phrenPath, create) : create();
}
export function addTasks(phrenPath, project, items, opts) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const added = [];
        const errors = [];
        for (const item of items) {
            if (!validTaskTitle(item)) {
                errors.push(item);
                continue;
            }
            const line = item.replace(/^-\s*/, "").trim();
            if (!line) {
                errors.push(item);
                continue;
            }
            parsed.data.items.Queue.push({
                id: `Q${parsed.data.items.Queue.length + 1}`,
                stableId: newBid(),
                section: "Queue",
                line,
                checked: false,
                priority: normalizePriority(line),
                scope: opts?.scope,
                createdAt: new Date().toISOString(),
            });
            added.push(line);
        }
        if (added.length)
            writeTaskDoc(parsed.data);
        return phrenOk({ added, errors });
    });
}
export function completeTasks(phrenPath, project, matches) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const completed = [];
        const errors = [];
        for (const match of matches) {
            const found = findItemByMatch(parsed.data, match);
            if (found.error || !found.match) {
                errors.push(match);
                continue;
            }
            const [item] = parsed.data.items[found.match.section].splice(found.match.index, 1);
            item.section = "Done";
            item.checked = true;
            if (item.history !== undefined)
                item.history.push({ at: new Date().toISOString(), change: `completed${item.claim ? `; released claim by ${item.claim.computer}` : ""}` });
            item.claim = undefined;
            parsed.data.items.Done.unshift(item);
            completed.push(item.line);
        }
        writeTaskDoc(parsed.data);
        return phrenOk({ completed, errors });
    });
}
export function completeTask(phrenPath, project, match) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const [item] = parsed.data.items[found.match.section].splice(found.match.index, 1);
        item.section = "Done";
        item.checked = true;
        if (item.history !== undefined)
            item.history.push({ at: new Date().toISOString(), change: `completed${item.claim ? `; released claim by ${item.claim.computer}` : ""}` });
        item.claim = undefined;
        parsed.data.items.Done.unshift(item);
        writeTaskDoc(parsed.data);
        return phrenOk(`Marked done in ${project}: ${item.line}`);
    });
}
export function removeTask(phrenPath, project, match) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const [item] = parsed.data.items[found.match.section].splice(found.match.index, 1);
        writeTaskDoc(parsed.data);
        return phrenOk(`Removed task from ${project}: ${item.line}`);
    });
}
export function removeTasks(phrenPath, project, matches) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const removed = [];
        const errors = [];
        for (const match of matches) {
            const found = findItemByMatch(parsed.data, match);
            if (found.error || !found.match) {
                errors.push(match);
                continue;
            }
            const [item] = parsed.data.items[found.match.section].splice(found.match.index, 1);
            removed.push(item.line);
        }
        writeTaskDoc(parsed.data);
        return phrenOk({ removed, errors });
    });
}
export function updateTask(phrenPath, project, match, updates, graphRoot = phrenPath, expectedRevision) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withTaskGraphLock(graphRoot, () => withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        if (expectedRevision !== undefined && parsed.data.revision !== expectedRevision) {
            return phrenErr("Task revision changed; refresh and review your draft before saving.", PhrenError.VALIDATION_ERROR);
        }
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const item = parsed.data.items[found.match.section][found.match.index];
        if (expectedRevision !== undefined && (!taskFormatStatus(phrenPath).enabled
            || !taskIdentity(graphRoot, parsed.data, item) || item.taskContractRaw !== undefined || item.taskContractRecords !== undefined)) {
            return phrenErr("Task metadata is disabled, unsupported or ambiguous; preserve your draft.", PhrenError.VALIDATION_ERROR);
        }
        const changes = [];
        if (updates.responsibility !== undefined || updates.dependencies !== undefined) {
            const storeRoot = path.dirname(path.dirname(parsed.data.path));
            if (!taskFormatStatus(storeRoot).enabled)
                return phrenErr(taskFormatMigrationHint, PhrenError.VALIDATION_ERROR);
            if (item.taskContractRaw !== undefined)
                return phrenErr("Task metadata is invalid or from a newer version; preserve it and repair before editing responsibility/dependencies.", PhrenError.VALIDATION_ERROR);
            if (updates.responsibility !== undefined && !["human", "agent"].includes(updates.responsibility))
                return phrenErr("Responsibility must be human or agent.", PhrenError.VALIDATION_ERROR);
            item.stableId ??= newBid();
            if (updates.dependencies !== undefined) {
                const error = validateTaskDependencies(graphRoot, parsed.data, item, updates.dependencies);
                if (error)
                    return phrenErr(error, PhrenError.VALIDATION_ERROR);
                item.dependencies = updates.dependencies;
                changes.push("dependencies updated");
            }
            if (updates.responsibility !== undefined && updates.responsibility !== (item.responsibility ?? "agent")) {
                changes.push(`responsibility ${(item.responsibility ?? "agent")} -> ${updates.responsibility}`);
                item.responsibility = updates.responsibility;
                if (updates.responsibility === "human" && item.claim) {
                    changes.push(`released claim by ${item.claim.computer} at ${item.claim.at}`);
                    item.claim = undefined;
                }
            }
        }
        if (updates.text !== undefined) {
            if (typeof updates.text === "string" && !updates.text.trim())
                return phrenErr("Task text cannot be empty.", PhrenError.EMPTY_INPUT);
            if (!validTaskTitle(updates.text))
                return phrenErr(invalidTaskTitle, PhrenError.VALIDATION_ERROR);
            const nextText = updates.text.trim();
            if (!nextText)
                return phrenErr("Task text cannot be empty.", PhrenError.EMPTY_INPUT);
            const previousText = item.line;
            item.line = nextText;
            item.priority = normalizePriority(nextText);
            item.pinned = detectPinned(nextText) || undefined;
            changes.push(`text updated from ${JSON.stringify(previousText)}`);
        }
        if (updates.priority) {
            const priority = updates.priority.toLowerCase();
            if (["high", "medium", "low"].includes(priority)) {
                item.priority = priority;
                item.line = stripPriorityTag(item.line);
                item.line = `${item.line} [${item.priority}]`;
                changes.push(`priority -> ${priority}`);
            }
        }
        if (updates.context !== undefined && (updates.context !== "" || updates.replace_context)) {
            if (typeof updates.context !== "string" || /[\x00-\x1f\x7f]/.test(updates.context))
                return phrenErr("Task context must be one line without control characters.", PhrenError.VALIDATION_ERROR);
            if (updates.replace_context || !item.context)
                item.context = updates.context;
            // Agents sometimes send the existing context with their new note. Do
            // not append the complete history to itself on every such update.
            else if (updates.context.startsWith(`${item.context}; `))
                item.context = updates.context;
            else if (updates.context !== item.context && !item.context.endsWith(`; ${updates.context}`)) {
                item.context = `${item.context}; ${updates.context}`;
            }
            changes.push("context updated");
        }
        if (updates.unlink_github) {
            item.githubIssue = undefined;
            item.githubUrl = undefined;
            changes.push("github link removed");
        }
        else if (updates.github_issue !== undefined || updates.github_url !== undefined) {
            if (updates.github_url && !isValidGitHubIssueUrl(updates.github_url)) {
                return phrenErr("github_url must be a valid GitHub issue URL.", PhrenError.VALIDATION_ERROR);
            }
            const githubIssueRaw = typeof updates.github_issue === "string"
                ? updates.github_issue.trim()
                : updates.github_issue !== undefined
                    ? String(updates.github_issue)
                    : "";
            const parsedIssue = parseGitHubIssueReference([
                githubIssueRaw,
                updates.github_url?.trim() || "",
            ].filter(Boolean).join(" "));
            if (!parsedIssue.githubIssue && !parsedIssue.githubUrl) {
                return phrenErr("GitHub link update requires a valid issue number and/or GitHub issue URL.", PhrenError.VALIDATION_ERROR);
            }
            item.githubIssue = parsedIssue.githubIssue;
            item.githubUrl = parsedIssue.githubUrl;
            changes.push(item.githubIssue ? `github -> #${item.githubIssue}` : "github link updated");
        }
        if (updates.section) {
            const target = updates.section[0].toUpperCase() + updates.section.slice(1).toLowerCase();
            if (["Active", "Queue", "Done"].includes(target)) {
                parsed.data.items[found.match.section].splice(found.match.index, 1);
                const section = target;
                item.section = section;
                item.checked = section === "Done";
                parsed.data.items[section].unshift(item);
                changes.push(`moved ${found.match.section} -> ${section}`);
            }
        }
        if (changes.length && (item.responsibility !== undefined || item.dependencies !== undefined || item.history !== undefined))
            item.history = [...(item.history ?? []), { at: new Date().toISOString(), change: changes.join(", ") }];
        writeTaskDoc(parsed.data);
        return phrenOk(`Updated item in ${project}: ${changes.join(", ") || "no changes"}`);
    }));
}
/** Reserve exactly the saved task before external launch work. The durable claim
 * survives errors/restarts: a retry must not silently start a second worker.
 * The returned snapshot is the only source of the worker's prompt. */
export function reserveTaskLaunch(phrenPath, project, stableId, expectedRevision, computer, graphRoot = phrenPath) {
    const file = canonicalTaskFilePath(phrenPath, project);
    if (!file || !/^[a-f0-9]{8}$/.test(stableId) || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/.test(computer)) {
        return phrenErr("Invalid task launch identity.", PhrenError.VALIDATION_ERROR);
    }
    return withTaskGraphLock(graphRoot, () => withSafeLock(file, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        if (parsed.data.revision !== expectedRevision)
            return phrenErr("Task revision changed; refresh before starting.", PhrenError.VALIDATION_ERROR);
        const found = findItemByMatch(parsed.data, `bid:${stableId}`);
        if (found.error || !found.match)
            return phrenErr(found.error ?? "Task is missing.", PhrenError.VALIDATION_ERROR);
        const item = parsed.data.items[found.match.section][found.match.index];
        const identity = taskIdentity(graphRoot, parsed.data, item);
        if (!identity || !taskFormatStatus(phrenPath).enabled || item.taskContractRaw !== undefined || item.taskContractRecords !== undefined
            || item.checked || item.section === "Done" || taskReadiness(graphRoot, parsed.data, item).readiness !== "ready") {
            return phrenErr("Task is not eligible for agent launch. Refresh responsibility and prerequisites.", PhrenError.VALIDATION_ERROR);
        }
        if (item.claim)
            return phrenErr("Task already has a claim. Review the existing worker before explicitly releasing it.", PhrenError.VALIDATION_ERROR);
        if (!validTaskTitle(item.line) || (item.context && /[\x00-\x1f\x7f]/.test(item.context))
            || item.line.length + (item.context?.length ?? 0) > 30000)
            return phrenErr("Task text/context cannot be carried safely in a launch brief.", PhrenError.VALIDATION_ERROR);
        const launchId = randomUUID(), at = new Date().toISOString();
        item.claim = { computer, at, session: `task-launch:${launchId}` };
        item.responsibility ??= "agent";
        item.history = [...(item.history ?? []), { at, change: `reserved task launch ${launchId}` }];
        parsed.data.items[found.match.section].splice(found.match.index, 1);
        item.section = "Active";
        parsed.data.items.Active.unshift(item);
        writeTaskDoc(parsed.data);
        return phrenOk({ identity, revision: expectedRevision, text: item.line, context: item.context, launchId });
    }));
}
export function pinTask(phrenPath, project, match) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const section = found.match.section;
        const item = parsed.data.items[section][found.match.index];
        if (item.pinned)
            return phrenOk(`Already pinned in ${project}: ${item.line}`);
        item.pinned = true;
        item.line = stripPinnedTag(item.line);
        parsed.data.items[section].splice(found.match.index, 1);
        parsed.data.items[section].unshift(item);
        writeTaskDoc(parsed.data);
        return phrenOk(`Pinned in ${project}: ${item.line}`);
    });
}
export function unpinTask(phrenPath, project, match) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const item = parsed.data.items[found.match.section][found.match.index];
        if (!item.pinned)
            return phrenOk(`Not pinned in ${project}: ${item.line}`);
        item.pinned = undefined;
        item.line = stripPinnedTag(item.line);
        writeTaskDoc(parsed.data);
        return phrenOk(`Unpinned in ${project}: ${item.line}`);
    });
}
export function reorderTask(phrenPath, project, match, targetRank) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const section = found.match.section;
        const items = parsed.data.items[section];
        const item = items[found.match.index];
        const oldRank = item.rank ?? found.match.index + 1;
        const clampedTarget = Math.max(1, Math.min(targetRank, items.length));
        for (const other of items) {
            if (other === item || other.rank === undefined)
                continue;
            if (clampedTarget <= oldRank) {
                if (other.rank >= clampedTarget && other.rank < oldRank)
                    other.rank++;
            }
            else {
                if (other.rank > oldRank && other.rank <= clampedTarget)
                    other.rank--;
            }
        }
        item.rank = clampedTarget;
        // Re-sort by rank so file order reflects new priority order
        items.sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
        writeTaskDoc(parsed.data);
        return phrenOk(`Reordered in ${project}: "${item.line}" moved to rank ${clampedTarget}`);
    });
}
export function appendChildFinding(phrenPath, project, match, findingId) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const item = parsed.data.items[found.match.section][found.match.index];
        item.childFindings = [...(item.childFindings ?? []), findingId];
        item.lastActivity = new Date().toISOString();
        writeTaskDoc(parsed.data);
        return phrenOk(`Linked finding ${findingId} to task in ${project}: ${item.line}`);
    });
}
export function promoteTask(phrenPath, project, match, moveToActive) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const item = parsed.data.items[found.match.section][found.match.index];
        item.speculative = undefined;
        if (moveToActive && item.section !== "Active") {
            parsed.data.items[found.match.section].splice(found.match.index, 1);
            item.section = "Active";
            item.checked = false;
            parsed.data.items.Active.unshift(item);
        }
        writeTaskDoc(parsed.data);
        return phrenOk(item);
    });
}
/** A claim this old may be taken over with `force`: its conductor has likely gone. */
export const STALE_CLAIM_MS = 24 * 60 * 60 * 1000;
/**
 * Takes a task for `claim.computer`: moves it to Active and records the claim.
 * Refuses a task another computer holds unless `force` is set and that claim
 * is stale; claiming again from the same computer refreshes it. With
 * `release`, clears this computer's claim and returns the task to the Queue.
 */
export function claimTask(phrenPath, project, match, claim, opts = {}) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withTaskGraphLock(opts.graphRoot ?? phrenPath, () => withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const item = parsed.data.items[found.match.section][found.match.index];
        if (!opts.release && taskReadiness(opts.graphRoot ?? phrenPath, parsed.data, item).readiness !== "ready")
            return phrenErr("This task awaits a human or prerequisite task.", PhrenError.VALIDATION_ERROR);
        if (item.section === "Done")
            return phrenErr(`This task is already done: ${item.line}`, PhrenError.VALIDATION_ERROR);
        const held = item.claim && item.claim.computer !== claim.computer ? item.claim : undefined;
        if (held) {
            const stale = (opts.now ?? Date.now()) - Date.parse(held.at) > STALE_CLAIM_MS;
            if (!opts.force || !stale) {
                return phrenErr(`${held.computer} claimed this task at ${held.at}${stale ? "; the claim is over a day old, so force can take it over" : ""}.`, PhrenError.PERMISSION_DENIED);
            }
        }
        if (item.history !== undefined)
            item.history.push({ at: new Date().toISOString(), change: opts.release ? `released claim by ${claim.computer}` : `claimed by ${claim.computer}` });
        if (opts.release) {
            if (!item.claim)
                return phrenErr(`This task is not claimed: ${item.line}`, PhrenError.VALIDATION_ERROR);
            item.claim = undefined;
            parsed.data.items[item.section].splice(found.match.index, 1);
            item.section = "Queue";
            parsed.data.items.Queue.unshift(item);
        }
        else {
            item.claim = claim;
            if (item.section !== "Active") {
                parsed.data.items[item.section].splice(found.match.index, 1);
                item.section = "Active";
                item.checked = false;
                parsed.data.items.Active.unshift(item);
            }
        }
        writeTaskDoc(parsed.data);
        return phrenOk(item);
    }));
}
export function workNextTask(phrenPath, project, graphRoot = phrenPath) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withTaskGraphLock(graphRoot, () => withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        if (!parsed.data.items.Queue.length) {
            return phrenErr(`No queued tasks in "${project}". Add items with :add or the add_task tool.`, PhrenError.NOT_FOUND);
        }
        const priorityOrder = { high: 0, medium: 1, low: 2 };
        parsed.data.items.Queue.sort((a, b) => {
            const pa = priorityOrder[a.priority ?? ""] ?? 3;
            const pb = priorityOrder[b.priority ?? ""] ?? 3;
            return pa - pb;
        });
        const nextIndex = parsed.data.items.Queue.findIndex(item => taskReadiness(graphRoot, parsed.data, item).readiness === "ready");
        if (nextIndex < 0)
            return phrenErr("No ready agent tasks; queued tasks await a human or prerequisite task.", PhrenError.NOT_FOUND);
        const [item] = parsed.data.items.Queue.splice(nextIndex, 1);
        if (item.history !== undefined)
            item.history.push({ at: new Date().toISOString(), change: "moved Queue -> Active by next" });
        item.section = "Active";
        item.checked = false;
        parsed.data.items.Active.push(item);
        writeTaskDoc(parsed.data);
        return phrenOk(`Moved next queue item to Active in ${project}: ${item.line}`);
    }));
}
export function tidyDoneTasks(phrenPath, project, keep = 30, dryRun) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const safeKeep = Number.isFinite(keep) ? Math.max(0, Math.floor(keep)) : 30;
        if (parsed.data.items.Done.length <= safeKeep) {
            return phrenOk(`No tidy needed for ${project}. Done=${parsed.data.items.Done.length}, keep=${safeKeep}.`);
        }
        const archived = parsed.data.items.Done.slice(safeKeep);
        if (dryRun) {
            return phrenOk(`[dry-run] Would archive ${archived.length} done item(s) for ${project}, keeping ${safeKeep}.`);
        }
        parsed.data.items.Done = parsed.data.items.Done.slice(0, safeKeep);
        // Archive into the store that owns the tasks file (bPath = <store>/<project>/tasks.md),
        // so secondary-store task history never lands in the primary store's .config.
        const archiveFile = taskArchivePath(path.dirname(path.dirname(bPath)), project);
        fs.mkdirSync(path.dirname(archiveFile), { recursive: true });
        const stamp = new Date().toISOString();
        const lines = archived.map((item) => renderTask({ ...parsed.data, items: { Active: [], Queue: [], Done: [item] } }).split("## Done\n\n")[1].trimEnd());
        const block = `## ${stamp}\n\n${lines.join("\n")}\n\n`;
        const prior = fs.existsSync(archiveFile) ? fs.readFileSync(archiveFile, "utf8") : `# ${project} tasks archive\n\n`;
        const tmpPath = `${archiveFile}.tmp-${randomUUID()}`;
        fs.writeFileSync(tmpPath, prior + block);
        fs.renameSync(tmpPath, archiveFile);
        writeTaskDoc(parsed.data);
        return phrenOk(`Tidied ${project}: archived ${archived.length} done item(s), kept ${safeKeep}.`);
    });
}
export function taskMarkdown(doc) {
    return renderTask(doc);
}
export function linkTaskIssue(phrenPath, project, match, link) {
    const bPath = canonicalTaskFilePath(phrenPath, project);
    if (!bPath)
        return phrenErr(`Project name "${project}" is not valid.`, PhrenError.INVALID_PROJECT_NAME);
    const preCheck = ensureProject(phrenPath, project);
    if (!preCheck.ok)
        return forwardErr(preCheck);
    return withSafeLock(bPath, () => {
        const parsed = readTasks(phrenPath, project);
        if (!parsed.ok)
            return forwardErr(parsed);
        const found = findItemByMatch(parsed.data, match);
        if (found.error)
            return phrenErr(found.error, found.errorCode ?? PhrenError.AMBIGUOUS_MATCH);
        if (!found.match)
            return taskItemNotFound(project, match);
        const item = parsed.data.items[found.match.section][found.match.index];
        if (link.unlink) {
            item.githubIssue = undefined;
            item.githubUrl = undefined;
        }
        else {
            if (link.github_url && !isValidGitHubIssueUrl(link.github_url)) {
                return phrenErr("github_url must be a valid GitHub issue URL.", PhrenError.VALIDATION_ERROR);
            }
            const githubIssueRaw = typeof link.github_issue === "string"
                ? link.github_issue.trim()
                : link.github_issue !== undefined
                    ? String(link.github_issue)
                    : "";
            const parsedLink = parseGitHubIssueReference([
                githubIssueRaw,
                link.github_url?.trim() || "",
            ].filter(Boolean).join(" "));
            if (!parsedLink.githubIssue && !parsedLink.githubUrl) {
                return phrenErr("GitHub link update requires a valid issue number and/or GitHub issue URL.", PhrenError.VALIDATION_ERROR);
            }
            item.githubIssue = parsedLink.githubIssue;
            item.githubUrl = parsedLink.githubUrl;
        }
        writeTaskDoc(parsed.data);
        return phrenOk(item);
    });
}