@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.
575 lines (574 loc) • 26.7 kB
JavaScript
"use strict";
/**
* Data-loading and detection fragments of the dashboard Alpine app. dashboardMergeAppFragments
* stitches these into one app object. These fragments own the async methods that talk to the
* dashboard server - installed-agent detection, plans/tasks, hooks, stack detection, setup-prompt
* generation, and the quality surfaces. Each method is a thin `this`-bound shim over a shared
* `dashboard*` helper that holds the real fetch/parse logic and its error handling, so the fragments
* stay small and the network behaviour lives in one place per concern.
*/
/** Confirm disabling a guarded hook before removing that safety surface. */
function dashboardConfirmHookToggle(hook, shouldEnable) {
if (shouldEnable || !hook.requiresConfirmDialog)
return true;
return window.confirm(`Disabling ${hook.name} removes the guardrail. Continue?`);
}
/** Replace one hook row after the server accepts a toggle request. */
function dashboardApplyHookToggleResult(ctx, hook, shouldEnable) {
ctx.hooksState = ctx.hooksState.map((item) => item.id === hook.id ? hook : item);
ctx.showToast(`${hook.name} ${shouldEnable ? "enabled" : "disabled"}`);
}
/**
* Persist one hook toggle. Stale project responses are ignored, and request failures recover into
* the Hooks banner/toast because guardrail rows must remain visible when a save fails.
*/
async function dashboardToggleHookState(ctx, hook, shouldEnable) {
if (!hook.togglable || ctx.hookSavingId)
return;
if (!dashboardConfirmHookToggle(hook, shouldEnable))
return;
ctx.hookSavingId = hook.id;
ctx.hooksError = "";
const requestProjectPath = ctx.projectPath;
try {
const res = await dashboardFetch(`/api/hooks/${encodeURIComponent(hook.id)}/toggle?path=${encodeURIComponent(requestProjectPath)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: shouldEnable }),
});
const payload = readRecord(await res.json(), "Hook toggle response");
const error = readErrorMessage(payload);
if (error)
throw new Error(error);
if (ctx.projectPath !== requestProjectPath)
return;
dashboardApplyHookToggleResult(ctx, payload.hook, shouldEnable);
}
catch (err) {
if (ctx.projectPath !== requestProjectPath)
return;
ctx.hooksError = err instanceof Error ? err.message : String(err);
ctx.showToast(ctx.hooksError || "Hook update failed", true);
}
finally {
if (ctx.hookSavingId === hook.id)
ctx.hookSavingId = null;
}
}
/** Return whether a quality request still belongs to the current project and runner. */
function dashboardIsCurrentQualityRequest(ctx, projectPath, runner) {
return ctx.projectPath === projectPath && ctx.activeRunner === runner;
}
/** Load the latest home-page quality summary and recover failures into the shared toast channel. */
async function dashboardGenerateHomeQualitySummary(ctx) {
ctx.homeQualityLoading = true;
ctx.homeQualityLatest = null;
const requestProjectPath = ctx.projectPath;
const requestAgent = ctx.activeRunner;
try {
const res = await dashboardFetch(`/api/quality/history?path=${encodeURIComponent(requestProjectPath)}&agent=${encodeURIComponent(requestAgent)}&mode=agent-setup&limit=1`);
const payload = readRecord(await res.json(), "Home quality response");
if (!dashboardIsCurrentQualityRequest(ctx, requestProjectPath, requestAgent))
return;
const error = readErrorMessage(payload);
if (error) {
ctx.showToast(error, true);
}
else {
ctx.homeQualityLatest = readQualityHistoryLatest(payload.latest);
}
}
catch (err) {
if (!dashboardIsCurrentQualityRequest(ctx, requestProjectPath, requestAgent))
return;
const msg = err instanceof Error ? err.message : String(err);
ctx.showToast(msg || "Home quality loading failed", true);
}
if (dashboardIsCurrentQualityRequest(ctx, requestProjectPath, requestAgent)) {
ctx.homeQualityLoading = false;
}
}
/** Convert a raw skill-inventory payload into valid skill artifact rows. */
function dashboardReadSkillQualityArtifacts(payload) {
return Array.isArray(payload.artifacts)
? payload.artifacts.filter((artifact) => isRecord(artifact) &&
artifact.kind === "skill" &&
typeof artifact.id === "string" &&
typeof artifact.name === "string" &&
typeof artifact.path === "string" &&
typeof artifact.source === "string")
: [];
}
/** Clear a selected skill-quality report when the refreshed inventory no longer contains it. */
function dashboardPruneMissingSkillQualitySelection(ctx) {
if (!ctx.skillQualitySelectedId)
return;
const stillExists = ctx.skillQualityArtifacts.some((artifact) => artifact.id === ctx.skillQualitySelectedId);
if (stillExists)
return;
ctx.skillQualitySelectedId = null;
ctx.skillQualityReport = null;
}
/** Return whether a skill-quality inventory response still belongs to the visible request. */
function dashboardIsCurrentSkillInventoryRequest(ctx, projectPath, runner, generation) {
return (ctx.projectPath === projectPath &&
ctx.activeRunner === runner &&
ctx.skillQualityPrefetchGeneration === generation);
}
/**
* Load skill-quality inventory. Endpoint failures recover through toasts, and stale project/runner
* responses are ignored so a late fetch cannot overwrite the currently visible Skills tab state.
*/
async function dashboardLoadSkillQualityInventory(ctx) {
const requestProjectPath = ctx.projectPath;
const requestRunner = ctx.activeRunner;
const requestGeneration = Number(ctx.skillQualityPrefetchGeneration) + 1;
ctx.skillQualityPrefetchGeneration = requestGeneration;
ctx.skillQualityPrefetching = false;
try {
const res = await dashboardFetch(`/api/skill-quality/inventory?path=${encodeURIComponent(requestProjectPath)}&agent=${encodeURIComponent(requestRunner)}`);
const payload = readRecord(await res.json(), "Skill quality inventory");
if (!dashboardIsCurrentSkillInventoryRequest(ctx, requestProjectPath, requestRunner, requestGeneration)) {
return;
}
const error = readErrorMessage(payload);
if (error) {
ctx.showToast(error, true);
return;
}
ctx.skillQualityArtifacts = dashboardReadSkillQualityArtifacts(payload);
dashboardPruneMissingSkillQualitySelection(ctx);
ctx.skillQualityReports = {};
ctx.skillQualityAuditedAt = null;
ctx.skillQualityPrefetching = false;
void ctx.prefetchSkillReports(requestProjectPath, requestRunner, requestGeneration);
}
catch (err) {
// Failures are staleness-guarded like successes: a late error from a
// superseded project/runner request must not toast over the current view.
if (!dashboardIsCurrentSkillInventoryRequest(ctx, requestProjectPath, requestRunner, requestGeneration)) {
return;
}
const msg = err instanceof Error ? err.message : String(err);
ctx.showToast(msg || "Skill quality inventory failed", true);
}
}
/**
* Fetch one skill-quality report during sidebar prefetch. Per-artifact fetch/decode failures are
* swallowed as a best-effort fallback so one bad skill report does not block the rest of the list.
*/
async function dashboardPrefetchOneSkillReport(ctx, art, projectPath, runner, generation) {
try {
const res = await dashboardFetch(`/api/skill-quality?path=${encodeURIComponent(projectPath)}&agent=${encodeURIComponent(runner)}&artifact=${encodeURIComponent(art.id)}`);
const payload = readRecord(await res.json(), "Skill quality report");
if (readErrorMessage(payload))
return;
if (ctx.projectPath !== projectPath ||
ctx.activeRunner !== runner ||
ctx.skillQualityPrefetchGeneration !== generation) {
return;
}
// /api/skill-quality returns this app's own SkillQualityReport shape; JsonRecord does not
// structurally overlap it, so TS requires the assertion go through unknown. Source is same-origin.
ctx.skillQualityReports[art.id] = payload;
}
catch {
// Best-effort sidebar grades: one failed artifact falls back to no cached grade.
return;
}
}
/** Finalise a matching prefetch batch and auto-select the first skill when none is selected. */
function dashboardCompleteSkillReportPrefetch(ctx, projectPath, runner, generation) {
if (ctx.projectPath !== projectPath ||
ctx.activeRunner !== runner ||
ctx.skillQualityPrefetchGeneration !== generation) {
return;
}
ctx.skillQualityAuditedAt = Date.now();
ctx.skillQualityPrefetching = false;
if (!ctx.skillQualitySelectedId && ctx.skillQualityArtifacts.length > 0) {
const first = ctx.skillQualityArtifacts[0];
if (first)
void ctx.loadSkillQualityReport(first.id);
}
}
/**
* Prefetch reports for every skill artifact. Empty inventories return early, and per-artifact
* failures are swallowed by dashboardPrefetchOneSkillReport so sidebar grades stay best effort.
*/
async function dashboardPrefetchSkillReports(ctx, projectPath, runner, generation) {
const artifacts = [...ctx.skillQualityArtifacts];
if (artifacts.length === 0)
return;
ctx.skillQualityPrefetching = true;
await Promise.all(artifacts.map((art) => dashboardPrefetchOneSkillReport(ctx, art, projectPath, runner, generation)));
dashboardCompleteSkillReportPrefetch(ctx, projectPath, runner, generation);
}
/**
* Build the agent-detection / plans / hooks fragment of the app's async data-loading methods.
* One input to dashboardMergeAppFragments; the methods delegate to shared helpers that own the
* fetch and its recover-on-failure handling, so this fragment only wires names to those helpers.
*
* @param supportedAgents - agents the server can launch, used to scope installed-agent detection
* @returns the fragment object of agent/plans/hooks loader methods merged into the Alpine app
*/
function dashboardAgentPlanHookLoadersFragment(supportedAgents) {
return {
/** Refresh installed-agent detection for launcher defaults; uses a recover fallback on fetch/decode failure. */
async fetchInstalledAgents() {
try {
const res = await dashboardFetch("/api/agents/installed");
if (!res.ok)
return false;
const payload = readRecord(await res.json(), "Agent detection response");
const agents = Array.isArray(payload.agents)
? payload.agents
.map((agent) => readAgentInfo(agent))
.filter((agent) => agent !== null)
: [];
if (this.supportedAgents.length === 0)
this.supportedAgents = agents;
this.allAgents = agents;
this.installedAgents = agents.filter((agent) => agent.installed);
this.agentsLoaded = true;
if (this.installedAgents.length > 0 &&
!this.installedAgents.find((agent) => agent.id === this.activeRunner)) {
const [firstInstalled] = this.installedAgents;
if (firstInstalled)
this.activeRunner = firstInstalled.id;
}
return true;
}
catch {
return false;
}
},
/** Open the project browser at the current workspace path. */
async openBrowser() {
await dashboardOpenBrowser(this);
},
/** Load child directories for the requested browser path. */
async browseTo(path) {
await dashboardBrowseTo(this, path);
},
/** Set a browsed directory as the active project. */
selectDir(dir) {
dashboardSelectDir(this, dir);
},
// -- Plans --
/** Load plan state; reports endpoint errors and preserves newer project state because requests race. */
async loadTasks(planName) {
this.tasksLoading = true;
this.tasksError = "";
const requestProjectPath = this.projectPath;
const requestedPlan = planName ?? this.selectedTaskPlan;
const planParam = requestedPlan
? `&plan=${encodeURIComponent(requestedPlan)}`
: "";
try {
const res = await dashboardFetch(`/api/plans?path=${encodeURIComponent(requestProjectPath)}${planParam}`);
const payload = readRecord(await res.json(), "Tasks response");
const error = readErrorMessage(payload);
if (error)
throw new Error(error);
if (this.projectPath !== requestProjectPath)
return;
const state = readTaskState(payload);
this.tasksState = state;
this.selectedTaskPlan = state.selectedPlan;
}
catch (err) {
if (this.projectPath !== requestProjectPath)
return;
this.tasksState = null;
this.tasksError = err instanceof Error ? err.message : String(err);
}
finally {
if (this.projectPath === requestProjectPath)
this.tasksLoading = false;
}
},
/** Select a plan and reload milestones for that plan. */
selectTaskPlan(planName) {
this.selectedTaskPlan = planName;
void this.loadTasks(planName);
},
/** Persist the active plan; reports endpoint errors and preserves newer project state because saves race. */
async setActiveTaskPlan(planName) {
if (!planName || this.tasksActivePlanSaving)
return;
this.tasksActivePlanSaving = planName;
this.tasksError = "";
const requestProjectPath = this.projectPath;
try {
const res = await dashboardFetch(`/api/plans?path=${encodeURIComponent(requestProjectPath)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan: planName }),
});
const payload = readRecord(await res.json(), "Tasks response");
const error = readErrorMessage(payload);
if (error)
throw new Error(error);
if (this.projectPath !== requestProjectPath)
return;
const state = readTaskState(payload);
this.tasksState = state;
this.selectedTaskPlan = state.selectedPlan;
this.showToast(`Active plan set to ${planName}`);
}
catch (err) {
if (this.projectPath !== requestProjectPath)
return;
this.tasksError = err instanceof Error ? err.message : String(err);
this.showToast(this.tasksError || "Active plan update failed", true);
}
finally {
if (this.projectPath === requestProjectPath &&
this.tasksActivePlanSaving === planName) {
this.tasksActivePlanSaving = null;
}
}
},
/** Format completed and total task counts for one milestone row. */
taskProgressLabel(milestone) {
return `${milestone.completedTasks}/${milestone.totalTasks}`;
},
/** Convert milestone checkbox progress to a percent for progress bars. */
taskProgressPct(milestone) {
if (milestone.totalTasks <= 0)
return 0;
return Math.round((milestone.completedTasks / milestone.totalTasks) * 100);
},
/** Format milestone modified time, falling back when the timestamp is invalid. */
taskModifiedLabel(value) {
if (!value)
return "unknown";
const date = new Date(value);
if (Number.isNaN(date.getTime()))
return "unknown";
return date.toLocaleString();
},
// -- Hooks --
/** Load hook state for the selected project; reports errors in the Hooks banner because rows may be stale. */
async loadHooks() {
this.hooksLoading = true;
this.hooksError = "";
const requestProjectPath = this.projectPath;
try {
const res = await dashboardFetch(`/api/hooks?path=${encodeURIComponent(requestProjectPath)}`);
const payload = readRecord(await res.json(), "Hooks response");
const error = readErrorMessage(payload);
if (error)
throw new Error(error);
if (this.projectPath !== requestProjectPath)
return;
this.hooksState = Array.isArray(payload.hooks)
? payload.hooks
: [];
}
catch (err) {
if (this.projectPath !== requestProjectPath)
return;
this.hooksState = [];
this.hooksError = err instanceof Error ? err.message : String(err);
}
finally {
if (this.projectPath === requestProjectPath)
this.hooksLoading = false;
}
},
};
}
function dashboardHookSetupActionsFragment(supportedAgents) {
return {
/** Return hook state rows for every supported agent, filling absent payloads explicitly. */
hookAgents(hook) {
return this.supportedAgents.map((agent) => [
agent.id,
hook.agents[agent.id] ?? {
supported: false,
installed: false,
scriptPath: null,
configPath: null,
reason: "Agent state unavailable.",
},
]);
},
/** Group a hook into the section that owns its primary risk surface. */
hookSectionFor(hook) {
if (hook.id === "gruff-code-quality")
return "quality";
return "safety";
},
/** Return the visual tone for a hook based on its dashboard section. */
hookTone(hook) {
const section = this.hookSectionFor(hook);
if (section === "workflow")
return "workflow";
if (section === "git")
return "warning";
if (section === "quality")
return "neutral";
return "danger";
},
/** Return true when any agent's installed hook state differs from desired state. */
hookHasDrift(hook) {
return Object.values(hook.agents).some((state) => Boolean(state.drift));
},
/** Count agent surfaces where the hook is currently installed. */
hookInstalledSurfaceCount(hook) {
return this.hookAgents(hook).filter(([, state]) => state.installed).length;
},
/** Return unsupported agent surfaces with explicit reasons for inline dashboard disclosure. */
unsupportedHookAgents(hook) {
return this.hookAgents(hook).filter(([, state]) => !state.supported && Boolean(state.reason));
},
/** Count hooks whose desired dashboard state is enabled. */
hooksEnabledCount() {
return this.hooksState.filter((hook) => hook.enabled).length;
},
/** Count hooks with at least one agent surface in drift. */
hooksDriftCount() {
return this.hooksState.filter((hook) => this.hookHasDrift(hook)).length;
},
/** Count installed hook surfaces across all hook and agent combinations. */
hooksInstalledSurfaceCount() {
return this.hooksState.reduce((total, hook) => total + Number(this.hookInstalledSurfaceCount(hook)), 0);
},
/** Apply the current hook filter predicate to one hook. */
hookMatchesFilter(hook, filter) {
if (filter === "enabled")
return hook.enabled;
if (filter === "disabled")
return !hook.enabled;
if (filter === "drift")
return this.hookHasDrift(hook);
return true;
},
/** Count hooks that would appear under one filter chip. */
hookFilterCount(filter) {
return this.hooksState.filter((hook) => this.hookMatchesFilter(hook, filter)).length;
},
/** Return hooks matching the selected filter and search query. */
filteredHooks() {
const query = this.hooksSearch.trim().toLowerCase();
return this.hooksState.filter((hook) => {
if (!this.hookMatchesFilter(hook, this.hooksFilter))
return false;
if (!query)
return true;
return [hook.name, hook.id, hook.description].some((value) => value.toLowerCase().includes(query));
});
},
/** Return filtered hooks that belong to one dashboard section. */
hooksForSection(section) {
return this.filteredHooks().filter((hook) => this.hookSectionFor(hook) === section);
},
/** Count filtered hooks in one dashboard section. */
hookSectionCount(section) {
return this.hooksForSection(section).length;
},
/** Format one agent hook state for the hook table. */
hookAgentStatusLabel(state) {
if (!state.supported)
return "unsupported";
if (state.drift === "desired-on-actual-off")
return "drift: missing";
if (state.drift === "desired-off-actual-on")
return "drift: installed";
return state.installed ? "installed" : "not installed";
},
/** Return the CSS status class for one agent hook state. */
hookAgentStatusClass(state) {
if (!state.supported)
return "gf-hook-status-muted";
if (state.drift)
return "gf-hook-status-warn";
return state.installed ? "gf-hook-status-ok" : "gf-hook-status-muted";
},
/** Persist one hook toggle; reports failed requests while preserving rows because guardrail state is sensitive. */
async toggleHook(hook, shouldEnable) {
await dashboardToggleHookState(this, hook, shouldEnable);
},
/** Reapply the current desired hook state to repair installed drift. */
async resyncHook(hook) {
await this.toggleHook(hook, hook.enabled);
},
// -- Setup --
async detectStack() {
await dashboardDetectStack(this);
},
/** Generate setup output for the agent selected in the setup view. */
async generateSetupPrompt(shouldForce = false) {
await dashboardGenerateSetupPrompt(this, { force: shouldForce });
},
/** Generate setup output for a specific setup target agent. */
async generateSetupPromptForAgent(targetAgent, shouldForce = false) {
return dashboardGenerateSetupPromptForAgent(this, targetAgent, {
force: shouldForce,
});
},
};
}
/**
* Build the setup-scheduling and quality fragment: debounced setup-prompt scheduling plus the
* quality-report generate/history/home-summary loaders. Most methods delegate to shared `dashboard*`
* helpers, but the inline loaders here catch a fetch/parse failure: each recovers by showing a
* dashboard toast (it reports the message in-view) instead of propagating, so a transient quality
* fetch never breaks the view. They
* also guard against stale responses with a current-request check because the user can switch
* project/agent mid-flight and a late reply must not overwrite newer state. Merged by
* dashboardMergeAppFragments.
*/
function dashboardSetupQualityLoadersFragment() {
return {
/** Generate setup output after setup detection gets a paint. */
scheduleSetupPrompt() {
dashboardScheduleSetupPrompt(this);
},
// -- Quality --
async generateQuality(qualityOptions = {}) {
await dashboardGenerateQuality(this, qualityOptions);
},
/** Load persisted quality-history rows for the selected project and agent. */
async generateQualityHistory() {
await dashboardGenerateQualityHistory(this);
},
/** Load quality history after first prompt paint. */
scheduleQualityHistory() {
dashboardScheduleQualityHistory(this);
},
/** Load the latest quality-history summary; reports errors as toasts and ignores stale responses. */
async generateHomeQualitySummary() {
await dashboardGenerateHomeQualitySummary(this);
},
/** Copy the current quality prompt to the clipboard. */
copyQuality() {
dashboardCopyQuality(this);
},
};
}
/**
* Build skill-quality inventory loaders.
*
* Inventory and prefetch live together because both share the same project/runner generation guard:
* stale responses must not overwrite the Skills tab after the user switches workspace or runner.
* Prefetch swallows per-artifact failures as a best-effort fallback so one bad report does not hide
* the rest of the inventory.
*/
function dashboardSkillQualityInventoryLoadersFragment() {
return {
// -- Skill quality --
/** Load skill-quality inventory; reports endpoint errors and resets stale caches because reports key by artifact. */
async loadSkillQualityInventory() {
await dashboardLoadSkillQualityInventory(this);
},
/** Fetch reports for every artifact in parallel so the sidebar can show
* a per-skill grade without requiring the user to click each one first.
* Aborts silently if the project/runner changes mid-flight. */
async prefetchSkillReports(projectPath, runner, generation) {
await dashboardPrefetchSkillReports(this, projectPath, runner, generation);
},
};
}