@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
714 lines (713 loc) • 45.1 kB
JavaScript
import { taskView, taskCounts, filterTaskDoc } from "../data/task-contract.js";
import { mcpResponse, resolveStoreForProject } from "./types.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { isValidProjectName } from "../utils.js";
import { addTask as addTaskStore, addTasks as addTasksBatch, taskMarkdown, completeTask as completeTaskStore, completeTasks as completeTasksBatch, removeTask as removeTaskStore, removeTasks as removeTasksBatch, linkTaskIssue, pinTask, unpinTask, workNextTask, tidyDoneTasks, readTasks, readTasksAcrossProjects, resolveTaskItem, TASKS_FILENAME, updateTask as updateTaskStore, promoteTask, } from "../data/access.js";
import { applyGravity } from "../data/tasks.js";
import { captureTaskWrites } from "../data/task-receipts.js";
import { buildTaskIssueBody, createGithubIssueForTask, parseGithubIssueUrl, resolveProjectGithubRepo, } from "../task/github.js";
import { clearTaskCheckpoint } from "../session/checkpoints.js";
import { incrementSessionTasksCompleted } from "./session.js";
import { normalizeMemoryScope } from "../shared.js";
import { permissionDeniedError } from "../governance/rbac.js";
import { getMachineName } from "../machine-identity.js";
import { claimTaskSynced } from "../sync/task-claim.js";
import { CLANKER_FETCH_HINT, clankerEnabled, rowKeywords, rowTitle } from "../clanker.js";
import { LIST_RESPONSE_MAX_CHARS, LIST_TEXT_MAX_CHARS, clipTail, shrinkToBudget } from "../response-budget.js";
const TASK_SECTION_ORDER = ["Active", "Queue", "Done"];
const DEFAULT_TASK_LIMIT = 20;
/** Done items are historical — cap tightly by default to avoid large responses. */
const DEFAULT_DONE_LIMIT = 5;
function refreshTaskIndex(updateFileInIndex, phrenPath, project) {
updateFileInIndex(path.join(phrenPath, project, TASKS_FILENAME));
}
function buildTaskView(doc, status, limit, doneLimit, offset) {
let includedSections;
if (status === "all") {
includedSections = TASK_SECTION_ORDER;
}
else if (status === "done") {
includedSections = ["Done"];
}
else if (status === "active") {
includedSections = ["Active"];
}
else if (status === "queue") {
includedSections = ["Queue"];
}
else {
includedSections = ["Active", "Queue"];
}
const effectiveLimit = limit ?? DEFAULT_TASK_LIMIT;
const effectiveDoneLimit = doneLimit ?? DEFAULT_DONE_LIMIT;
const effectiveOffset = offset ?? 0;
let truncated = false;
const items = {
Active: [],
Queue: [],
Done: [],
};
let totalUnpaged = 0;
for (const section of includedSections) {
// Apply gravity to Active and Queue items so stale tasks drift down
const rawItems = doc.items[section];
const sectionItems = (section === "Active" || section === "Queue") ? applyGravity(rawItems) : rawItems;
const cap = section === "Done" ? effectiveDoneLimit : effectiveLimit;
const sliced = effectiveOffset > 0 ? sectionItems.slice(effectiveOffset) : sectionItems;
totalUnpaged += sectionItems.length;
if (sliced.length > cap) {
items[section] = sliced.slice(0, cap);
truncated = true;
}
else {
items[section] = sliced;
}
// Context is an append-only log that can run to many KB; lists show its
// latest notes and a single-task lookup returns it whole.
items[section] = items[section].map((item) => item.context && item.context.length > LIST_TEXT_MAX_CHARS
? { ...item, context: clipTail(item.context, LIST_TEXT_MAX_CHARS), contextTruncated: true }
: item);
}
const totalItems = TASK_SECTION_ORDER.reduce((sum, section) => sum + items[section].length, 0);
return {
doc: { ...doc, items },
includedSections,
totalItems,
totalUnpaged,
truncated,
};
}
function buildTaskSummary(doc, includedSections, base, original = doc) {
const counts = taskCounts(base, original);
const lines = [`## ${doc.project}`, `Human: ${counts.human}; Agent ready: ${counts.agentReady}; Agent waiting on human: ${counts.agentWaitingOnHuman}; Agent waiting on task: ${counts.agentWaitingOnTask}.`];
for (const section of includedSections) {
const items = doc.items[section];
const highCount = items.filter(i => i.priority === "high").length;
const medCount = items.filter(i => i.priority === "medium").length;
lines.push(`**${section}**: ${items.length} items${highCount ? ` (${highCount} high` + (medCount ? `, ${medCount} medium` : "") + ")" : ""}`);
// Show first 3 items as preview
for (const item of items.slice(0, 3)) {
const prio = item.priority ? ` [${item.priority}]` : "";
const bidPrefix = item.stableId ? `bid:${item.stableId} ` : "";
const githubTag = item.githubIssue ? ` [gh:#${item.githubIssue}]` : item.githubUrl ? " [gh]" : "";
const claimTag = item.claim ? ` [claimed: ${item.claim.computer}]` : "";
lines.push(` - ${bidPrefix}${item.line.slice(0, 80)}${item.line.length > 80 ? "\u2026" : ""}${prio}${githubTag}${claimTag}`);
}
if (items.length > 3)
lines.push(` ... and ${items.length - 3} more`);
}
return lines.join("\n");
}
/** Clanker mode: one line per task (id, title, keywords, priority, claim). */
function buildClankerTaskRows(doc, includedSections) {
const lines = [`## ${doc.project}`];
for (const section of includedSections) {
const items = doc.items[section];
if (!items.length)
continue;
lines.push(`${section}:`);
for (const item of items) {
const id = item.stableId ? `bid:${item.stableId}` : item.id;
const tags = [item.priority, item.claim ? `claimed: ${item.claim.computer}` : undefined].filter(Boolean);
lines.push(`${id} ${rowTitle(item.line)} [${rowKeywords(item.line).join(", ")}]${tags.length ? ` (${tags.join("; ")})` : ""}`);
}
}
return lines;
}
export function register(server, ctx) {
const { phrenPath, profile, updateFileInIndex } = ctx;
const withWriteQueue = (fn) => ctx.withWriteQueue(async () => {
const { result, write } = await captureTaskWrites(fn);
// Preserve each handler's existing data, including per-item batch errors.
const payload = JSON.parse(result.content[0].text);
if (write || payload.ok)
payload.data = { ...payload.data, write };
return mcpResponse(payload);
});
server.registerTool("get_tasks", {
title: "◆ phren · tasks",
description: "Get tasks. Defaults to Active and Queue sections only. Pass status='all' to include Done items. Lists show the latest part of a long Context (contextTruncated: true); look up one task by project and id for its full context.",
inputSchema: z.object({
project: z.string().optional().describe("Project name. Omit to get all projects."),
id: z.string().optional().describe("Task ID like A1, Q3, D2. Requires project."),
item: z.string().optional().describe("Exact task text. Requires project."),
responsibility: z.enum(["human", "agent"]).optional().describe("Independent responsibility lane filter."),
readiness: z.enum(["ready", "waiting-on-human", "waiting-on-task"]).optional().describe("Derived readiness filter; does not change task section."),
status: z.enum(["all", "active", "queue", "done", "active+queue"]).optional().describe("Which task sections to include. Defaults to 'active+queue'."),
limit: z.number().int().min(1).max(200).optional().describe("Max items per Active/Queue section to return. Default 20."),
done_limit: z.number().int().min(1).max(200).optional().describe("Max Done items to return (most recent). Default 5. Done sections are capped tightly to avoid large responses."),
offset: z.number().int().min(0).optional().describe("Skip the first N items in each section before applying limit. Use with limit for pagination (e.g. offset:20, limit:20 for page 2)."),
summary: z.boolean().optional().describe("If true, return counts and titles only (no full content). Reduces token usage."),
}),
}, async ({ project, id, item, status, responsibility, readiness, limit, done_limit, offset, summary }) => {
// Single item lookup
if (id || item) {
if (!project)
return mcpResponse({ ok: false, error: "Provide `project` when looking up a single item." });
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const resolvedPath = resolveStoreForProject(ctx, project, "read").phrenPath;
const result = readTasks(resolvedPath, project);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
const doc = result.data;
const all = [...doc.items.Active, ...doc.items.Queue, ...doc.items.Done];
const bidLookup = id && /^(?:bid:)?[a-f0-9]{8}$/.test(id) ? id.replace(/^bid:/, "") : null;
const matches = all.filter((entry) => (bidLookup && entry.stableId === bidLookup) ||
(id && !bidLookup && entry.id.toLowerCase() === id.toLowerCase()) ||
(item && entry.line.trim() === item.trim()));
if (matches.length > 1)
return mcpResponse({ ok: false, error: "Task identity is ambiguous." });
const match = matches[0];
if (!match)
return mcpResponse({ ok: false, error: `No task found in ${project} for ${id ? `id=${id}` : `item="${item}"`}.` });
return mcpResponse({
ok: true,
message: `${match.id}: ${match.line} (${match.section})`,
data: {
...taskView(phrenPath, doc, match),
project,
id: match.id,
stableId: match.stableId || null,
section: match.section,
checked: match.checked,
line: match.line,
context: match.context || null,
priority: match.priority || null,
githubIssue: match.githubIssue ?? null,
githubUrl: match.githubUrl || null,
},
});
}
// Full task list for one project
if (project) {
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const resolvedPath = resolveStoreForProject(ctx, project, "read").phrenPath;
const result = readTasks(resolvedPath, project);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
const doc = result.data;
const filtered = filterTaskDoc(phrenPath, doc, { responsibility, readiness });
const view = buildTaskView(filtered, status, limit, done_limit, offset);
if (!fs.existsSync(doc.path)) {
return mcpResponse({
ok: true,
message: `No tasks found for "${project}".`,
data: { project, counts: taskCounts(phrenPath, doc), items: Object.fromEntries(TASK_SECTION_ORDER.map(section => [section, view.doc.items[section].map(entry => taskView(phrenPath, doc, entry))])), includedSections: view.includedSections, totalItems: view.totalItems },
});
}
if (summary) {
return mcpResponse({
ok: true,
message: buildTaskSummary(view.doc, view.includedSections, phrenPath, doc),
data: { project, counts: taskCounts(phrenPath, doc), includedSections: view.includedSections, totalItems: view.totalItems, summary: true },
});
}
const requested = limit ?? DEFAULT_TASK_LIMIT;
const counts = taskCounts(phrenPath, doc);
if (clankerEnabled(phrenPath)) {
const buildRows = (size) => {
const page = size < requested ? buildTaskView(filtered, status, size, Math.min(done_limit ?? DEFAULT_DONE_LIMIT, size), offset) : view;
const more = page.truncated ? `\nMore: offset=${(offset ?? 0) + size}` : "";
return {
ok: true,
message: `${buildClankerTaskRows(page.doc, page.includedSections).join("\n")}${more}\n${CLANKER_FETCH_HINT}`,
data: { project, counts, includedSections: page.includedSections, totalItems: page.totalItems, totalUnpaged: page.totalUnpaged, offset: offset ?? 0, truncated: page.truncated },
};
};
return mcpResponse(shrinkToBudget(requested, buildRows).payload, { compact: true });
}
const buildPage = (size) => {
const page = size < requested ? buildTaskView(filtered, status, size, Math.min(done_limit ?? DEFAULT_DONE_LIMIT, size), offset) : view;
const sectionCounts = page.includedSections
.map((s) => `${s}: ${page.doc.items[s].length}/${doc.items[s].length}`)
.join(", ");
const budgetNote = size < requested ? ` Cut to ${size} per section to fit the response budget.` : "";
const paginationNote = page.truncated
? `\n\n_${sectionCounts} (offset ${offset ?? 0}).${budgetNote} Use offset/limit to page._`
: (offset ? `\n\n_Page offset: ${offset}. ${sectionCounts}._` : "");
return {
ok: true,
message: `## ${project}\n${taskMarkdown(page.doc)}${paginationNote}`,
data: { project, counts, items: Object.fromEntries(TASK_SECTION_ORDER.map(section => [section, page.doc.items[section].map(entry => taskView(phrenPath, doc, entry))])), issues: doc.issues, includedSections: page.includedSections, totalItems: page.totalItems, totalUnpaged: page.totalUnpaged, offset: offset ?? 0, truncated: page.truncated },
};
};
return mcpResponse(shrinkToBudget(requested, buildPage).payload);
}
// All projects
const docs = readTasksAcrossProjects(phrenPath, profile);
if (!docs.length)
return mcpResponse({ ok: true, message: "No tasks found.", data: { projects: [] } });
const filteredDocs = docs.map((doc) => ({ doc, filtered: filterTaskDoc(phrenPath, doc, { responsibility, readiness }) }));
const requested = limit ?? DEFAULT_TASK_LIMIT;
const buildPage = (size, asSummary, note = "") => {
const doneCap = size < requested ? Math.min(done_limit ?? DEFAULT_DONE_LIMIT, size) : done_limit ?? DEFAULT_DONE_LIMIT;
const views = filteredDocs.map(({ doc, filtered }) => ({ project: doc.project, doc, view: buildTaskView(filtered, status, size, doneCap, offset), issues: doc.issues }));
const anyTruncated = views.some(({ view }) => view.truncated);
let parts;
if (asSummary) {
parts = views.map(({ view, doc }) => buildTaskSummary(view.doc, view.includedSections, phrenPath, doc));
}
else {
parts = views.map(({ project, view }) => `## ${project}\n${taskMarkdown(view.doc)}`);
}
const budgetNote = size < requested ? " Cut to fit the response budget; pass project to see one project's tasks." : "";
const truncationNote = anyTruncated && !asSummary ? `\n\n_Results capped (Active/Queue: ${size}, Done: ${doneCap}).${budgetNote} Pass limit/done_limit to see more._` : "";
// Summaries are counts and titles only, so they carry no item rows.
const projectData = views.map(({ project, doc, view, issues }) => ({
project,
counts: taskCounts(phrenPath, doc),
...(asSummary ? {} : { items: Object.fromEntries(TASK_SECTION_ORDER.map(section => [section, view.doc.items[section].map(entry => taskView(phrenPath, doc, entry))])) }),
issues,
includedSections: view.includedSections,
totalItems: view.totalItems,
truncated: view.truncated,
}));
return { ok: true, message: parts.join("\n\n") + truncationNote + note, data: { projects: projectData, summary: asSummary } };
};
if (summary)
return mcpResponse(buildPage(requested, true));
if (clankerEnabled(phrenPath)) {
const buildRows = (size) => {
const built = filteredDocs.map(({ doc, filtered }) => {
const view = buildTaskView(filtered, status, size, Math.min(done_limit ?? DEFAULT_DONE_LIMIT, size), offset);
return { project: doc.project, view, lines: buildClankerTaskRows(view.doc, view.includedSections) };
});
const anyTruncated = built.some(({ view }) => view.truncated);
return {
ok: true,
message: `${built.map(({ lines }) => lines.join("\n")).join("\n\n")}${anyTruncated ? "\nSome lists are capped; pass project, limit or offset for more." : ""}\n${CLANKER_FETCH_HINT}`,
data: { projects: built.map(({ project, view }) => ({ project, includedSections: view.includedSections, totalItems: view.totalItems, truncated: view.truncated })) },
};
};
// Every project at once is an overview: keep it smaller than one project's list.
return mcpResponse(shrinkToBudget(requested, buildRows, LIST_RESPONSE_MAX_CHARS / 4).payload, { compact: true });
}
const fitted = shrinkToBudget(requested, (size) => buildPage(size, false));
if (JSON.stringify(fitted.payload, null, 2).length <= LIST_RESPONSE_MAX_CHARS)
return mcpResponse(fitted.payload);
// Too many projects to list in full: fall back to per-project summaries.
return mcpResponse(buildPage(requested, true, "\n\n_Every project's full task list does not fit one response, so this is the summary. Pass project for a project's tasks._"));
});
server.registerTool("add_task", {
title: "◆ phren · add task",
description: "Append one or more tasks to a project's tasks.md file. Adds to the Queue section. Pass a single string or an array of strings.",
inputSchema: z.object({
project: z.string().describe("Project name (must match a directory in your phren)."),
item: z.preprocess((v) => (typeof v === "string" ? [v] : v), z.array(z.string()).min(1)).describe("The task(s) to add. Always pass as an array of strings — use a single-element array for one task."),
scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."),
}),
}, async ({ project: projectInput, item, scope }) => {
// Resolve store-qualified project names (e.g., "team/forge")
let targetPhrenPath;
let project;
try {
const resolved = resolveStoreForProject(ctx, projectInput);
targetPhrenPath = resolved.phrenPath;
project = resolved.project;
}
catch (err) {
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
}
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const addTaskDenied = permissionDeniedError(targetPhrenPath, "add_task", project);
if (addTaskDenied)
return mcpResponse({ ok: false, error: addTaskDenied });
const normalizedScope = normalizeMemoryScope(scope ?? "shared");
if (!normalizedScope)
return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
if (Array.isArray(item)) {
return withWriteQueue(async () => {
const result = addTasksBatch(targetPhrenPath, project, item, { scope: normalizedScope });
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
const { added, errors } = result.data;
if (added.length > 0)
refreshTaskIndex(updateFileInIndex, targetPhrenPath, project);
return mcpResponse({ ok: added.length > 0, ...(added.length === 0 ? { error: `No tasks added: ${errors.join("; ")}` } : {}), message: `Added ${added.length} of ${item.length} tasks to ${project}`, data: { project, added, errors } });
});
}
return withWriteQueue(async () => {
const result = addTaskStore(targetPhrenPath, project, item, { scope: normalizedScope });
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
refreshTaskIndex(updateFileInIndex, targetPhrenPath, project);
return mcpResponse({ ok: true, message: `Task added: ${result.data.line}`, data: { project, item, scope: normalizedScope } });
});
});
server.registerTool("complete_task", {
title: "◆ phren · done",
description: "Move one or more tasks to the Done section by matching text. Pass a single string or an array of strings.",
inputSchema: z.object({
project: z.string().describe("Project name."),
item: z.preprocess((v) => (typeof v === "string" ? [v] : v), z.array(z.string()).min(1)).describe("The task(s) to complete. Always pass as an array of strings — use a single-element array for one."),
sessionId: z.string().optional().describe("Optional session ID from session_start. Pass this to track per-session task completion metrics."),
}),
}, async ({ project: projectInput, item, sessionId }) => {
const resolved = resolveStoreForProject(ctx, projectInput);
const project = resolved.project;
const targetPath = resolved.phrenPath;
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const completeTaskDenied = permissionDeniedError(targetPath, "complete_task", project);
if (completeTaskDenied)
return mcpResponse({ ok: false, error: completeTaskDenied });
if (Array.isArray(item)) {
return withWriteQueue(async () => {
const resolvedItems = item
.map((match) => {
const resolvedItem = resolveTaskItem(targetPath, project, match);
return resolvedItem.ok ? resolvedItem.data : null;
})
.filter((task) => task !== null);
const result = completeTasksBatch(targetPath, project, item);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
const { completed, errors } = result.data;
if (completed.length > 0) {
const completedSet = new Set(completed);
for (const task of resolvedItems) {
if (!completedSet.has(task.line))
continue;
clearTaskCheckpoint(targetPath, {
project,
taskId: task.stableId ?? task.id,
stableId: task.stableId,
positionalId: task.id,
taskLine: task.line,
});
}
incrementSessionTasksCompleted(targetPath, completed.length, sessionId, project);
}
if (completed.length > 0)
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: completed.length > 0, ...(completed.length === 0 ? { error: `No tasks completed: ${errors.join("; ")}` } : {}), message: `Completed ${completed.length}/${item.length} items`, data: { project, completed, errors } });
});
}
return withWriteQueue(async () => {
const before = resolveTaskItem(targetPath, project, item);
const result = completeTaskStore(targetPath, project, item);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
if (before.ok) {
clearTaskCheckpoint(targetPath, {
project,
taskId: before.data.stableId ?? before.data.id,
stableId: before.data.stableId,
positionalId: before.data.id,
taskLine: before.data.line,
});
}
incrementSessionTasksCompleted(targetPath, 1, sessionId, project);
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: true, message: result.data, data: { project, item } });
});
});
server.registerTool("remove_task", {
title: "◆ phren · remove task",
description: "Remove one or more tasks from a project's tasks.md file by matching text or ID. Pass a single string or an array of strings.",
inputSchema: z.object({
project: z.string().describe("Project name."),
item: z.preprocess((v) => (typeof v === "string" ? [v] : v), z.array(z.string()).min(1)).describe("The task(s) to remove. Always pass as an array of strings (or task IDs like A1/Q3/D2) — use a single-element array for one."),
}),
}, async ({ project: projectInput, item }) => {
const resolved = resolveStoreForProject(ctx, projectInput);
const project = resolved.project;
const targetPath = resolved.phrenPath;
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const removeTaskDenied = permissionDeniedError(targetPath, "remove_task", project);
if (removeTaskDenied)
return mcpResponse({ ok: false, error: removeTaskDenied });
if (Array.isArray(item)) {
return withWriteQueue(async () => {
const result = removeTasksBatch(targetPath, project, item);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
const { removed, errors } = result.data;
if (removed.length > 0)
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: removed.length > 0, ...(removed.length === 0 ? { error: `No tasks removed: ${errors.join("; ")}` } : {}), message: `Removed ${removed.length}/${item.length} items`, data: { project, removed, errors } });
});
}
return withWriteQueue(async () => {
const result = removeTaskStore(targetPath, project, item);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: true, message: result.data, data: { project, item } });
});
});
server.registerTool("update_task", {
title: "◆ phren · update task",
description: "Update a task's text, priority, context, section, GitHub metadata, pin status, or promote it. " +
"Also supports work_next (pick highest-priority Queue item) and promote (clear speculative flag). " +
"When work_next is true, item is not needed.",
inputSchema: z.object({
project: z.string().describe("Project name."),
item: z.string().optional().describe("Partial text to match against existing tasks. Required unless work_next is true."),
updates: z.object({
text: z.string().optional().describe("Replacement text for the task line."),
priority: z.enum(["high", "medium", "low"]).optional().describe("New priority tag: high, medium, or low."),
context: z.string().optional().describe("Text to set on the Context: line below the task."),
replace_context: z.boolean().optional().describe("If true, replace the existing Context: value instead of appending."),
responsibility: z.enum(["human", "agent"]).optional().describe("Who must act; independent of task section. Legacy tasks default to agent."),
dependencies: z.array(z.object({ storeId: z.string().regex(/^[a-f0-9]{8}$/), project: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/), stableId: z.string().regex(/^[a-f0-9]{8}$/) }).strict()).max(100).optional().describe("Replace prerequisites with immutable store/project/task identities; [] clears. Rejects self-links, cycles and missing targets."),
section: z.enum(["queue", "active", "done", "Queue", "Active", "Done"]).optional().describe("Move item to this section: Queue, Active, or Done."),
github_issue: z.union([z.number().int().positive(), z.string()]).optional().describe("GitHub issue number (for example 14 or '#14')."),
github_url: z.string().optional().describe("GitHub issue URL to associate with the task item."),
unlink_github: z.boolean().optional().describe("If true, remove any linked GitHub issue metadata from the item."),
create_issue: z.boolean().optional().describe("If true, create a GitHub issue for this task and link it."),
pin: z.boolean().optional().describe("If true, pin the task so it floats to the top of its section."),
promote: z.boolean().optional().describe("If true, clear the speculative flag on this task (confirm the user wants it)."),
move_to_active: z.boolean().optional().describe("Used with promote: also move the task to the Active section."),
work_next: z.boolean().optional().describe("If true, pick the highest-priority Queue item and move it to Active. Ignores item param."),
item: z.unknown().optional().describe("Not read here: item is a top-level parameter beside updates."),
}).describe("Fields to update. All are optional."),
}),
}, async ({ project: projectInput, item, updates: given }) => {
// A caller that nests item inside updates would otherwise hear only that item is missing.
const { item: misplacedItem, ...updates } = given;
if (misplacedItem !== undefined) {
return mcpResponse({ ok: false, error: "item is a top-level parameter, not a field of updates. Pass it beside updates: { project, item, updates }." });
}
const resolved = resolveStoreForProject(ctx, projectInput);
const project = resolved.project;
const targetPath = resolved.phrenPath;
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const updateTaskDenied = permissionDeniedError(targetPath, "update_task", project);
if (updateTaskDenied)
return mcpResponse({ ok: false, error: updateTaskDenied });
// Runtime validation: item is required unless work_next is true
if (!updates.work_next && !item) {
return mcpResponse({ ok: false, error: "item is required unless updates.work_next is true." });
}
if (updates.create_issue) {
const extraUpdates = [
updates.text,
updates.priority,
updates.context,
updates.section,
updates.github_issue,
updates.github_url,
updates.unlink_github,
updates.pin,
updates.promote,
updates.move_to_active,
updates.work_next,
updates.replace_context,
updates.responsibility,
updates.dependencies,
].some((value) => value !== undefined);
if (extraUpdates) {
return mcpResponse({ ok: false, error: "create_issue must be used by itself." });
}
}
// Cross-validate github_issue and github_url
if (updates.github_url) {
const parsed = parseGithubIssueUrl(updates.github_url);
if (!parsed)
return mcpResponse({ ok: false, error: "github_url must be a valid GitHub issue URL." });
if (updates.github_issue !== undefined) {
const normalizedIssue = Number.parseInt(String(updates.github_issue).replace(/^#/, ""), 10);
if (normalizedIssue !== parsed.issueNumber) {
return mcpResponse({ ok: false, error: "github_issue and github_url refer to different issues." });
}
}
}
return withWriteQueue(async () => {
// Handle work_next: pick highest-priority Queue item, move to Active
if (updates.work_next) {
const result = workNextTask(targetPath, project, phrenPath);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: true, message: result.data, data: { project } });
}
// Handle pin
if (updates.pin) {
const result = pinTask(targetPath, project, item);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: true, message: result.data, data: { project, item } });
}
// Handle promote (clear speculative flag)
if (updates.promote) {
const result = promoteTask(targetPath, project, item, updates.move_to_active ?? false);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({
ok: true,
message: `Promoted task "${result.data.line}" in ${project}${updates.move_to_active ? " (moved to Active)" : ""}.`,
data: { project, item: result.data },
});
}
if (updates.create_issue) {
const resolvedItem = resolveTaskItem(targetPath, project, item);
if (!resolvedItem.ok)
return mcpResponse({ ok: false, error: resolvedItem.error });
const repo = resolveProjectGithubRepo(targetPath, project);
if (!repo) {
return mcpResponse({
ok: false,
error: "Could not infer a GitHub repo. Add a GitHub URL to AGENTS.md or summary.md, or link an existing issue instead.",
});
}
const created = createGithubIssueForTask({
repo,
title: resolvedItem.data.line.replace(/\s*\[(high|medium|low)\]\s*$/i, "").trim(),
body: buildTaskIssueBody(project, resolvedItem.data),
});
if (!created.ok)
return mcpResponse({ ok: false, error: created.error, errorCode: created.code });
const linked = linkTaskIssue(targetPath, project, item, {
github_issue: created.data.issueNumber,
github_url: created.data.url,
});
if (!linked.ok)
return mcpResponse({ ok: false, error: linked.error, errorCode: linked.code });
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({
ok: true,
message: `Created GitHub issue ${created.data.issueNumber ? `#${created.data.issueNumber}` : created.data.url} for ${project} task.`,
data: {
project,
item,
issue_number: created.data.issueNumber ?? null,
issue_url: created.data.url,
githubIssue: linked.data.githubIssue ?? null,
githubUrl: linked.data.githubUrl || null,
stableId: linked.data.stableId || null,
},
});
}
// Handle github issue linking via update_task when github_issue or github_url is set (and no other field updates)
if ((updates.github_issue !== undefined || updates.github_url || updates.unlink_github) && !updates.text && !updates.priority && !updates.context && !updates.section && updates.responsibility === undefined && updates.dependencies === undefined) {
if (updates.unlink_github && (updates.github_issue !== undefined || updates.github_url)) {
return mcpResponse({ ok: false, error: "Use either unlink_github=true or github_issue/github_url, not both." });
}
const result = linkTaskIssue(targetPath, project, item, {
github_issue: updates.github_issue,
github_url: updates.github_url,
unlink: updates.unlink_github ?? false,
});
if (!result.ok)
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({
ok: true,
message: updates.unlink_github
? `Removed GitHub link from ${project} task.`
: `Linked ${project} task to ${result.data.githubIssue ? `#${result.data.githubIssue}` : result.data.githubUrl}.`,
data: {
project,
item,
stableId: result.data.stableId || null,
githubIssue: result.data.githubIssue ?? null,
githubUrl: result.data.githubUrl || null,
},
});
}
// Standard update path
const result = updateTaskStore(targetPath, project, item, updates, phrenPath);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: true, message: result.data, data: { project, item, updates } });
});
});
server.registerTool("tidy_done_tasks", {
title: "◆ phren · tidy done",
description: "Archive old Done items beyond the keep limit to keep the task list tidy.",
inputSchema: z.object({
project: z.string().describe("Project name."),
keep: z.number().optional().describe("Number of recent Done items to keep. Default 30."),
dry_run: z.boolean().optional().describe("If true, preview changes without writing."),
}),
}, async ({ project: projectInput, keep, dry_run }) => {
const resolved = resolveStoreForProject(ctx, projectInput);
const project = resolved.project;
const targetPath = resolved.phrenPath;
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
return withWriteQueue(async () => {
const result = tidyDoneTasks(targetPath, project, keep ?? 30, dry_run ?? false);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
if (!dry_run)
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: true, message: result.data, data: { project, keep: keep ?? 30, dryRun: dry_run ?? false } });
});
});
server.registerTool("claim_task", {
title: "◆ phren · claim task",
description: "Claim a task for this computer so conductors that are not linked do not take the same work: " +
"syncs the store, moves the task to Active with a `Claimed:` line naming this computer, then commits and pushes. " +
"Refuses a task another computer holds. With release, clears this computer's claim and returns the task to the Queue. " +
"Conductors skip tasks claimed by other computers.",
inputSchema: z.object({
project: z.string().describe("Project name."),
item: z.string().describe("Task to claim: bid:XXXXXXXX, a positional ID (Q3) or its text."),
session: z.string().regex(/^[A-Za-z0-9._:-]{1,128}$/).optional().describe("The claiming conductor's session id, recorded with the claim."),
release: z.boolean().optional().describe("Release this computer's claim instead of taking one."),
force: z.boolean().optional().describe("Take over another computer's claim once it is more than a day old."),
}),
}, async ({ project: projectInput, item, session, release, force }) => {
const resolved = resolveStoreForProject(ctx, projectInput);
const project = resolved.project;
const targetPath = resolved.phrenPath;
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const denied = permissionDeniedError(targetPath, "claim_task", project);
if (denied)
return mcpResponse({ ok: false, error: denied });
return withWriteQueue(async () => {
// The Claimed line takes a plain token; a name with spaces would never parse back.
const computer = getMachineName().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "") || "computer";
const claim = { computer, at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), ...(session ? { session } : {}) };
const outcome = await claimTaskSynced(targetPath, project, item, claim, { release, force, graphRoot: phrenPath });
if (outcome.error)
return mcpResponse({ ok: false, error: outcome.error });
refreshTaskIndex(updateFileInIndex, targetPath, project);
const message = release ? `Released in ${project}. ${outcome.detail}`
: outcome.claimed ? `Claimed for ${claim.computer} in ${project}. ${outcome.detail}` : `Not claimed: ${outcome.detail}`;
return mcpResponse({ ok: release ? true : outcome.claimed, message,
data: { project, item: outcome.item?.stableId ? `bid:${outcome.item.stableId}` : item, claim: outcome.item?.claim, heldBy: outcome.heldBy, synced: outcome.synced } });
});
});
server.registerTool("pin_task", {
title: "◆ phren · pin task",
description: "Pin or unpin a task. Pinned tasks always appear in hook context regardless of priority, " +
"so they stay visible across every prompt.",
inputSchema: z.object({
project: z.string().describe("Project name."),
item: z.string().describe("Partial text or task ID (A1, Q3) to match."),
unpin: z.boolean().optional().describe("If true, unpin instead of pin."),
}),
}, async ({ project: projectInput, item, unpin: shouldUnpin }) => {
const resolved = resolveStoreForProject(ctx, projectInput);
const project = resolved.project;
const targetPath = resolved.phrenPath;
if (!isValidProjectName(project))
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
const denied = permissionDeniedError(targetPath, "pin_task", project);
if (denied)
return mcpResponse({ ok: false, error: denied });
return withWriteQueue(async () => {
const result = shouldUnpin
? unpinTask(targetPath, project, item)
: pinTask(targetPath, project, item);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error });
refreshTaskIndex(updateFileInIndex, targetPath, project);
return mcpResponse({ ok: true, message: result.data, data: { project, item, pinned: !shouldUnpin } });
});
});
}