@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
156 lines (155 loc) • 6.32 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { safeProjectPath } from "../utils.js";
import { resolveTaskFilePath, stripBid, stripBulletPrefix } from "../data/tasks.js";
import { runtimeSessionsDir } from "../session/utils.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"]);
function normalizePriority(text) {
const match = text.replace(/\s*\[pinned\]/gi, "").match(/\[(high|medium|low)\]\s*$/i);
if (!match)
return undefined;
return match[1].toLowerCase();
}
function detectPinned(text) {
return /\[pinned\]/i.test(text);
}
function parseTaskItems(taskPath) {
if (!fs.existsSync(taskPath))
return [];
const lines = fs.readFileSync(taskPath, "utf8").split("\n");
let section = "Queue";
const counters = { Active: 0, Queue: 0, Done: 0 };
const items = [];
for (const line of lines) {
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;
counters[section] += 1;
const itemId = `${section === "Active" ? "A" : section === "Queue" ? "Q" : "D"}${counters[section]}`;
const stripped = stripBulletPrefix(line).body;
const { clean, bid } = stripBid(stripped);
items.push({
id: itemId,
stableId: bid,
section,
line: clean,
priority: normalizePriority(clean),
pinned: detectPinned(clean) || undefined,
});
}
return items;
}
function resolveTaskItemMatch(items, match) {
const needle = match.trim().toLowerCase();
if (!needle)
return { error: "task reference must not be empty." };
const bidNeedle = needle.replace(/^bid:/, "");
if (/^[a-f0-9]{8}$/.test(bidNeedle)) {
const stable = items.find((item) => item.stableId === bidNeedle);
if (stable?.stableId)
return { stableId: stable.stableId };
}
const byId = items.find((item) => item.id.toLowerCase() === needle);
if (byId?.stableId)
return { stableId: byId.stableId };
const exact = items.filter((item) => item.line.trim().toLowerCase() === needle);
if (exact.length === 1) {
if (!exact[0].stableId)
return { error: `Task "${match}" does not have a stable ID yet.` };
return { stableId: exact[0].stableId };
}
if (exact.length > 1) {
return { error: `Task "${match}" is ambiguous (${exact.length} exact matches). Use item ID or stable ID.` };
}
const partial = items.filter((item) => item.line.toLowerCase().includes(needle));
if (partial.length === 1) {
if (!partial[0].stableId)
return { error: `Task "${match}" does not have a stable ID yet.` };
return { stableId: partial[0].stableId };
}
if (partial.length > 1) {
return { error: `Task "${match}" is ambiguous (${partial.length} partial matches). Use item ID or stable ID.` };
}
return { error: `No task matching "${match}" in project tasks.` };
}
export function resolveFindingTaskReference(phrenPath, project, match) {
const projectDir = safeProjectPath(phrenPath, project);
if (!projectDir)
return { error: `Invalid project name: "${project}".` };
const taskPath = resolveTaskFilePath(phrenPath, project);
const items = parseTaskItems(taskPath ?? path.join(projectDir, "tasks.md"));
return resolveTaskItemMatch(items, match);
}
export function resolveAutoFindingTaskItem(phrenPath, project) {
const projectDir = safeProjectPath(phrenPath, project);
if (!projectDir)
return undefined;
const taskPath = resolveTaskFilePath(phrenPath, project);
const active = parseTaskItems(taskPath ?? path.join(projectDir, "tasks.md")).filter((item) => item.section === "Active" && item.stableId);
if (active.length === 1)
return active[0].stableId;
const pinned = active.filter((item) => item.pinned);
if (pinned.length === 1)
return pinned[0].stableId;
const high = active.filter((item) => item.priority === "high");
if (high.length === 1)
return high[0].stableId;
return undefined;
}
function listActiveSessions(phrenPath) {
const dir = runtimeSessionsDir(phrenPath);
if (!fs.existsSync(dir))
return [];
const sessions = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.startsWith("session-") || !entry.name.endsWith(".json"))
continue;
const fullPath = path.join(dir, entry.name);
try {
const parsed = JSON.parse(fs.readFileSync(fullPath, "utf8"));
if (!parsed.sessionId || parsed.endedAt)
continue;
sessions.push(parsed);
}
catch {
continue;
}
}
return sessions;
}
function sessionSortValue(state) {
if (state.startedAt) {
const parsed = Date.parse(state.startedAt);
if (!Number.isNaN(parsed))
return parsed;
}
return 0;
}
export function resolveFindingSessionId(phrenPath, project, explicitSessionId) {
const trimmed = explicitSessionId?.trim();
if (trimmed)
return trimmed;
const active = listActiveSessions(phrenPath);
if (active.length === 0)
return undefined;
const matchingProject = active
.filter((session) => session.project === project)
.sort((a, b) => sessionSortValue(b) - sessionSortValue(a));
if (matchingProject.length > 0)
return matchingProject[0].sessionId;
// Do not attribute findings to an unrelated active session from another project.
// Missing session provenance is safer than cross-project contamination.
return undefined;
}