@blundergoat/goat-flow
Version:
AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.
227 lines • 8.67 kB
JavaScript
/**
* Plan and milestone state model behind the dashboard's `/api/plans` route.
*
* Reads `.goat-flow/plans`, parsing each `M*.md` milestone into a compact summary (title, status,
* objective, checkbox progress) so the UI never receives raw Markdown, and writes the `.active`
* marker to switch the selected plan. Plan-name inputs are validated to a single top-level directory
* segment before any write so a request cannot escape the plans root. Filesystem reads swallow
* missing paths into empty state; the mutation helpers throw on malformed input or a non-existent
* plan. Consumed by dashboard-project-routes.ts.
*/
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { resolveLocalStatePath } from "./local-paths.js";
/**
* Return filesystem stats; swallows missing-path and permission errors as `null`.
*/
function statOrNull(path) {
try {
return statSync(path);
}
catch {
return null;
}
}
/**
* Read optional dashboard state files, swallowing local churn as a `null` fallback.
*/
function readOptionalTextFile(path) {
try {
return readFileSync(path, "utf-8");
}
catch {
return null;
}
}
/**
* List a stable numeric sort of `M*.md` milestones; swallows absent plan directories.
*/
function listTaskMilestoneFilenames(planPath) {
try {
return readdirSync(planPath, { withFileTypes: true })
.filter((entry) => entry.isFile() && /^M.*\.md$/iu.test(entry.name))
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}
catch {
return [];
}
}
function readMarkdownField(content, pattern, fallback) {
return content.match(pattern)?.[1]?.trim() || fallback;
}
/**
* Count Markdown task checkboxes using the same shape goat-plan writes into milestones.
*/
function readTaskProgress(content) {
const taskMatches = Array.from(content.matchAll(/^\s*-\s+\[( |x|X)\]/gmu));
return {
totalTasks: taskMatches.length,
completedTasks: taskMatches.filter((match) => match[1]?.toLowerCase() === "x").length,
};
}
function parseTaskMilestone(planPath, filename) {
const path = join(planPath, filename);
const content = readOptionalTextFile(path) ?? "";
const modifiedAt = statOrNull(path)?.mtime.toISOString() ?? "";
const progress = readTaskProgress(content);
return {
filename,
path,
title: readMarkdownField(content, /^#\s+(.+)$/mu, filename),
status: readMarkdownField(content, /^\*\*Status:\*\*\s*(.+)$/mu, "unknown"),
objective: readMarkdownField(content, /^\*\*Objective:\*\*\s*(.+)$/mu, ""),
totalTasks: progress.totalTasks,
completedTasks: progress.completedTasks,
modifiedAt,
};
}
function buildTaskPlanSummary(taskRoot, name, active) {
const planPath = join(taskRoot, name);
const milestoneFilenames = listTaskMilestoneFilenames(planPath);
const newestMilestoneTime = milestoneFilenames.reduce((newest, filename) => {
const mtime = statOrNull(join(planPath, filename))?.mtime.getTime();
if (mtime === undefined)
return newest;
return newest === null ? mtime : Math.max(newest, mtime);
}, null);
const planMtime = statOrNull(planPath)?.mtime.getTime() ?? 0;
const modifiedAt = new Date(newestMilestoneTime ?? planMtime).toISOString();
return {
name,
path: planPath,
modifiedAt,
milestoneCount: milestoneFilenames.length,
active: active === name,
};
}
/**
* List top-level task plan directories while ignoring local dotfile markers.
*/
function listTaskPlanNames(taskRoot) {
return readdirSync(taskRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => entry.name);
}
function emptyDashboardTaskState(planRoot, active) {
return {
planRoot,
taskRoot: planRoot,
exists: false,
active,
activeExists: false,
selectedPlan: null,
plans: [],
milestones: [],
};
}
function selectDashboardTaskPlan(requestedPlan, active, activeExists, plans) {
const requestedExists = plans.some((plan) => plan.name === requestedPlan);
if (requestedPlan && requestedExists)
return requestedPlan;
if (activeExists)
return active;
return plans[0]?.name ?? null;
}
export function buildDashboardTaskState(projectPath, requestedPlan) {
const planRoot = resolveLocalStatePath(projectPath, "plans");
const planRootStats = statOrNull(planRoot);
const active = readOptionalTextFile(join(planRoot, ".active"))?.trim() || null;
if (!planRootStats?.isDirectory()) {
return emptyDashboardTaskState(planRoot, active);
}
const planNames = listTaskPlanNames(planRoot);
const plans = planNames
.map((name) => buildTaskPlanSummary(planRoot, name, active))
.sort((a, b) => {
const byMtime = new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime();
return byMtime !== 0 ? byMtime : a.name.localeCompare(b.name);
});
const activeExists = Boolean(active && plans.some((plan) => plan.name === active));
const selectedPlan = selectDashboardTaskPlan(requestedPlan, active, activeExists, plans);
const selectedPlanPath = selectedPlan ? join(planRoot, selectedPlan) : null;
const milestones = selectedPlanPath
? listTaskMilestoneFilenames(selectedPlanPath).map((filename) => parseTaskMilestone(selectedPlanPath, filename))
: [];
return {
planRoot,
taskRoot: planRoot,
exists: true,
active,
activeExists,
selectedPlan,
plans,
milestones,
};
}
/**
* Parse mutation request JSON before route handlers inspect path-like fields.
*
* Throws when the body is malformed JSON or is not a top-level object.
*/
function parseJsonObjectBody(body) {
let parsed;
try {
parsed = JSON.parse(body);
}
catch {
throw new Error("Request body must be valid JSON");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Request body must be a JSON object");
}
return parsed;
}
/**
* Reject plan names that could escape the `.goat-flow/plans` top level.
*
* Throws when the plan name is hidden, relative, or path-like.
*/
function assertTopLevelPlanName(planName) {
if (planName === "." ||
planName === ".." ||
planName.includes("/") ||
planName.includes("\\") ||
planName.startsWith(".")) {
throw new Error("body.plan must name a top-level plan directory");
}
}
/**
* Extract and validate the active task-plan name from the dashboard request body. Throws when
* `body.plan` is missing, blank, or not a safe top-level plan name, so a malformed POST cannot select
* or escape into an unintended directory.
*
* @param body - raw request body; must be JSON with a non-empty string `plan` field
* @returns the trimmed, validated plan name guaranteed to be a single top-level directory segment
*/
export function readActiveTaskPlanBody(body) {
const parsed = parseJsonObjectBody(body);
const plan = parsed["plan"];
if (typeof plan !== "string" || plan.trim().length === 0) {
throw new Error("body.plan must be a non-empty string");
}
const normalized = plan.trim();
assertTopLevelPlanName(normalized);
return normalized;
}
/**
* Persist the selected plan by writing the `.active` marker, but only for a plan that already
* exists, so the dashboard can switch the active plan without ever creating task structure. Throws
* when the plans directory is absent or the requested plan does not exist.
*
* @param projectPath - absolute project root whose `.goat-flow/plans` directory holds the plans
* @param planName - validated top-level plan directory name to mark active; must already exist on disk
*/
export function writeActiveTaskPlan(projectPath, planName) {
const planRoot = resolveLocalStatePath(projectPath, "plans");
const planRootStats = statOrNull(planRoot);
if (!planRootStats?.isDirectory()) {
throw new Error(".goat-flow/plans does not exist for the selected project");
}
const planNames = listTaskPlanNames(planRoot);
if (!planNames.includes(planName)) {
throw new Error(`plan not found: ${planName}`);
}
writeFileSync(resolveLocalStatePath(projectPath, "plans/.active"), `${planName}\n`);
}
//# sourceMappingURL=dashboard-task-state.js.map