@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
162 lines (161 loc) • 8.74 kB
JavaScript
import * as path from "node:path";
import * as fs from "node:fs";
import { withSafeLock } from "../shared/data-utils.js";
import { taskStores, taskStoreHasProject } from "./task-store-directory.js";
import { isTaskDependency } from "./task-metadata.js";
export { taskStores, taskStoreHasProject } from "./task-store-directory.js";
import { readTasks, stripBid, parseTaskContent } from "./tasks.js";
/** Writers that can create cycles lock every writable attached store in path order.
* Reciprocal links edited from different store roots serialize on the same locks. */
export function withTaskGraphLock(base, fn) {
const roots = [...new Set(taskStores(base).filter(s => s.role !== "readonly" && s.available !== false).map(s => path.resolve(s.path)))].sort();
const lock = (at) => at === roots.length ? fn() : withSafeLock(path.join(roots[at], ".runtime", "task-dependencies"), () => lock(at + 1));
return lock(0);
}
const key = (ref) => `${ref.storeId}/${ref.project}/${ref.stableId}`;
export function taskIdentity(phrenPath, doc, item) {
const stores = taskStores(phrenPath);
const owner = stores.filter(s => path.resolve(s.path) === path.dirname(path.dirname(path.resolve(doc.path))));
const matches = [...doc.items.Active, ...doc.items.Queue, ...doc.items.Done].filter(i => i.stableId === item.stableId);
if (owner.length !== 1 || !item.stableId || !/^[a-f0-9]{8}$/.test(item.stableId) || item.identityAmbiguous || matches.length !== 1
|| !owner[0].taskStoreId || !taskStoreHasProject(owner[0], doc.project))
return undefined;
const storeId = owner[0].taskStoreId;
return stores.filter(s => s.taskStoreId === storeId).length === 1 ? { storeId, project: doc.project, stableId: item.stableId } : undefined;
}
function resolver(phrenPath, current) {
const stores = taskStores(phrenPath);
const cache = new Map();
return (ref) => {
const owners = stores.filter(s => s.taskStoreId === ref.storeId);
if (owners.length !== 1 || owners[0].available === false || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(ref.project))
return undefined;
if (!taskStoreHasProject(owners[0], ref.project))
return undefined;
const file = path.join(owners[0].path, ref.project, "tasks.md");
let doc;
if (current && path.resolve(current.path) === path.resolve(file))
doc = current;
else {
if (!cache.has(file)) {
const result = readTasks(owners[0].path, ref.project);
cache.set(file, result.ok && path.resolve(result.data.path) === path.resolve(file) ? result.data : undefined);
}
doc = cache.get(file);
}
const matches = doc ? [...doc.items.Active, ...doc.items.Queue, ...doc.items.Done].filter(i => i.stableId === ref.stableId) : [];
if (matches.length === 1)
return matches[0].identityAmbiguous ? undefined : matches[0];
if (matches.length > 1)
return undefined;
// Completed prerequisites remain satisfied after tidy archives their history.
const archive = path.join(owners[0].path, ".config", "task-archive", `${ref.project}.md`);
try {
const source = fs.readFileSync(archive, "utf8").replace(/\r\n/g, "\n").split("\n"), records = [];
for (let i = 0; i < source.length; i++) {
if (!source[i].startsWith("- "))
continue;
const comments = [...source[i].matchAll(/<!--\s*bid:([a-f0-9]{8})\b[^>]*-->/g)];
if (!comments.some(comment => comment[1] === ref.stableId))
continue;
if (comments.length !== 1)
return undefined;
if (stripBid(source[i]).bid !== ref.stableId)
continue;
const lines = [source[i]];
while (source[i + 1]?.startsWith(" "))
lines.push(source[++i]);
records.push(lines);
}
if (records.length === 1 && /^- \[[xX]\]\s/.test(records[0][0]))
return parseTaskContent(ref.project, archive, `# Archive\n\n## Done\n\n${records[0].join("\n")}`).items.Done[0];
}
catch { /* An absent archive is not completion evidence. */ }
return undefined;
};
}
/** Derived, never persisted. Missing or unknown metadata blocks autonomous selection. */
export function taskReadiness(phrenPath, doc, item) {
const resolve = resolver(phrenPath, doc);
const prerequisites = (item.dependencies ?? []).map(ref => {
const target = resolve(ref);
return { ...ref, title: target?.line ?? `Unavailable prerequisite ${key(ref)}`, responsibility: target?.responsibility ?? "agent", completed: target?.section === "Done" || target?.checked === true, missing: !target };
});
const responsibility = item.responsibility ?? "agent";
const waiting = prerequisites.filter(p => !p.completed);
const ambiguous = item.identityAmbiguous || (item.stableId && [...doc.items.Active, ...doc.items.Queue, ...doc.items.Done].filter(i => i.stableId === item.stableId).length !== 1);
const invalid = item.dependencies?.length ? validateTaskDependencies(phrenPath, doc, item, item.dependencies) : undefined;
const readiness = responsibility === "human" || waiting.some(p => p.responsibility === "human") ? "waiting-on-human"
: item.taskContractRaw !== undefined || ambiguous || invalid || waiting.length ? "waiting-on-task" : "ready";
return { responsibility, readiness, prerequisites };
}
export function taskView(phrenPath, doc, item) {
return { ...item, ...taskReadiness(phrenPath, doc, item), identity: taskIdentity(phrenPath, doc, item) ?? null };
}
export function validateTaskDependencies(phrenPath, doc, item, refs) {
if (!Array.isArray(refs) || refs.length > 100 || refs.some(r => !isTaskDependency(r)))
return "Dependencies require storeId, project and stableId (at most 100).";
const own = taskIdentity(phrenPath, doc, item);
if (!own)
return "The task needs an unambiguous store identity and stable ID.";
const resolve = resolver(phrenPath, doc);
const complete = new Set();
const visiting = new Set([key(own)]);
let visited = 0;
const visit = (ref) => {
const id = key(ref);
if (visiting.has(id))
return "Task dependencies cannot contain self-links or cycles.";
if (complete.has(id))
return undefined;
if (++visited > 10000 || visiting.size > 200)
return "Dependency graph is too large to validate safely.";
const target = resolve(ref);
if (!target)
return `Prerequisite ${id} is missing, unavailable or ambiguous.`;
if (target.taskContractRaw !== undefined)
return `Prerequisite ${id} has unsupported task metadata.`;
if (new Set((target.dependencies ?? []).map(key)).size !== (target.dependencies ?? []).length)
return `Prerequisite ${id} has duplicate dependencies.`;
visiting.add(id);
for (const child of target.dependencies ?? []) {
const error = visit(child);
if (error)
return error;
}
visiting.delete(id);
complete.add(id);
return undefined;
};
if (new Set(refs.map(key)).size !== refs.length)
return "Duplicate task dependencies are not allowed.";
for (const ref of refs) {
const error = visit(ref);
if (error)
return error;
}
return undefined;
}
export function filterTaskDoc(base, doc, filter) {
if (!filter.responsibility && !filter.readiness)
return doc;
return { ...doc, items: Object.fromEntries(Object.entries(doc.items).map(([section, items]) => [section, items.filter(item => {
const ready = taskReadiness(base, doc, item);
return (!filter.responsibility || ready.responsibility === filter.responsibility) && (!filter.readiness || ready.readiness === filter.readiness);
})])) };
}
export function taskCounts(base, doc) {
const counts = { human: 0, agentReady: 0, agentWaitingOnHuman: 0, agentWaitingOnTask: 0, done: doc.items.Done.length };
for (const item of [...doc.items.Active, ...doc.items.Queue]) {
const ready = taskReadiness(base, doc, item);
if (ready.responsibility === "human")
counts.human++;
else if (ready.readiness === "ready")
counts.agentReady++;
else if (ready.readiness === "waiting-on-human")
counts.agentWaitingOnHuman++;
else
counts.agentWaitingOnTask++;
}
return counts;
}