chore: initialize POM 2 repo (docs guards green; release gates recorded NOT RUN pending install)
This commit is contained in:
300
src/command.ts
Normal file
300
src/command.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import { join } from "node:path";
|
||||
import { loadPomConfig, updateProjectPomConfig } from "./config";
|
||||
import { stage, STAGES, type PomMotion, type PomToolProfile, type PomView, type ValidationScope } from "./domain";
|
||||
import { bootstrapProject, loadState, recordHiveEvent, syncKnowledgeBase, writeHiveMission } from "./persistence";
|
||||
import { createState, gotoStage, recordEvidence, startStage, summarizeState } from "./state";
|
||||
import { renderStagePrompt, getPrompt, listPrompts } from "./prompts";
|
||||
import { validatePom } from "./validators";
|
||||
import { applyToolProfile } from "./tool-profiles";
|
||||
import { routeStage } from "./router";
|
||||
import { ensureGit, enterStageBranch } from "./git";
|
||||
import { exportProject } from "./exporter";
|
||||
import { openCommandCenter, updateChrome } from "./ui";
|
||||
import type { CommandCenterAction } from "./visual";
|
||||
import type { PomRuntime } from "./runtime";
|
||||
import { buildHiveMission } from "./studio";
|
||||
import { writeSwarmDefinition, type PomSwarmMode } from "./swarm";
|
||||
|
||||
import { installBundledThemes } from "./themes";
|
||||
import { parseCommand } from "./parser";
|
||||
export { parseCommand } from "./parser";
|
||||
|
||||
const HELP = `POM · Produce Magnum Opus
|
||||
|
||||
/pom Open cinematic command center
|
||||
/pom new <title> Create a POM project, vault, ledgers, and Git root
|
||||
/pom run [next|all|0..8] Start authoritative production
|
||||
/pom hive [lean|stage|audit] Launch native task + IRC specialist flow
|
||||
/pom swarm [mode] [count] Generate an optional OMP Swarm DAG and queue /swarm run
|
||||
/pom evidence Record acceptance evidence interactively
|
||||
/pom check [scope] Run deterministic validation
|
||||
/pom vault Insert the production-board mention
|
||||
/pom export [checkpoint|final]
|
||||
/pom status [--json]
|
||||
/pom prompt [list|show|run] [id]
|
||||
/pom tools [show|reset|profile <name>]
|
||||
/pom theme install Install POM Nocturne/Parchment themes
|
||||
/pom settings
|
||||
/pom doctor
|
||||
/pom help`;
|
||||
|
||||
async function recordEvidenceInteractive(ctx: ExtensionContext, runtime: PomRuntime): Promise<void> {
|
||||
const state = runtime.get();
|
||||
const spec = stage(state.currentStage);
|
||||
const criterion = await ctx.ui.select("Record acceptance evidence", spec.acceptance.map((item) => ({ label: `${item.id} · ${item.label}`, value: item.id, description: item.description })), { outline: true });
|
||||
if (!criterion) return;
|
||||
const criterionId = criterion.split(" · ")[0];
|
||||
const summary = await ctx.ui.input("Evidence summary", "What was verified and why is it sufficient?");
|
||||
if (!summary) return;
|
||||
const pathsText = await ctx.ui.input("Evidence paths", "Comma-separated project-relative paths");
|
||||
const paths = (pathsText ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
||||
await runtime.set(recordEvidence(state, criterionId, summary, paths, "Main"));
|
||||
ctx.ui.notify(`Evidence recorded: ${criterionId}`, "info");
|
||||
}
|
||||
|
||||
async function experienceSettings(ctx: ExtensionContext, runtime: PomRuntime): Promise<void> {
|
||||
const state = structuredClone(runtime.get());
|
||||
const view = await ctx.ui.select("POM visual density", [
|
||||
{ label: "cinematic", description: "Three-line HUD, richer cards, IRC ticker, and thinking lane" },
|
||||
{ label: "rich", description: "Two-line HUD and evidence-focused expanded cards" },
|
||||
{ label: "compact", description: "One-line HUD and terse cards" },
|
||||
{ label: "minimal", description: "Status line plus critical milestones and failures" },
|
||||
], { initialIndex: ["cinematic", "rich", "compact", "minimal"].indexOf(state.view), outline: true });
|
||||
if (view) state.view = view as PomView;
|
||||
const motion = await ctx.ui.select("POM motion", [
|
||||
{ label: "off", description: "Static semantic symbols only" },
|
||||
{ label: "subtle", description: "Two-frame active-work pulse" },
|
||||
{ label: "full", description: "Four-frame tool activity and live coordination cues" },
|
||||
], { initialIndex: ["off", "subtle", "full"].indexOf(state.motion), outline: true });
|
||||
if (motion) state.motion = motion as PomMotion;
|
||||
|
||||
const currentConfig = runtime.getConfig();
|
||||
const lane = await ctx.ui.select("Thinking-lane annotation", [
|
||||
{ label: "on", description: "Show stage and execution lane below visible thinking" },
|
||||
{ label: "off", description: "Leave visible thinking unannotated" },
|
||||
], { initialIndex: currentConfig.showThinkingLane ? 0 : 1, outline: true });
|
||||
const placement = await ctx.ui.select("HUD placement", [
|
||||
{ label: "aboveEditor", description: "Production truth directly above the prompt editor" },
|
||||
{ label: "belowEditor", description: "Production truth beneath the prompt editor" },
|
||||
], { initialIndex: currentConfig.hudPlacement === "aboveEditor" ? 0 : 1, outline: true });
|
||||
|
||||
await runtime.set(state);
|
||||
if (state.initialized) {
|
||||
await updateProjectPomConfig(state.projectRoot, {
|
||||
showThinkingLane: lane ? lane === "on" : currentConfig.showThinkingLane,
|
||||
hudPlacement: (placement ?? currentConfig.hudPlacement) as "aboveEditor" | "belowEditor",
|
||||
});
|
||||
await runtime.refreshConfig(state.projectRoot);
|
||||
}
|
||||
updateChrome(ctx, runtime.get(), runtime.getConfig(), runtime);
|
||||
ctx.ui.notify(`POM experience · ${state.view} · motion ${state.motion} · lane ${runtime.getConfig().showThinkingLane ? "on" : "off"}`, "info");
|
||||
}
|
||||
|
||||
async function runStage(pi: ExtensionAPI, ctx: ExtensionContext, runtime: PomRuntime, targetRaw: string): Promise<void> {
|
||||
let state = runtime.get();
|
||||
const allStages = targetRaw === "all";
|
||||
const target = allStages || targetRaw === "next" ? state.currentStage : Number(targetRaw);
|
||||
if (!Number.isInteger(target) || target < 0 || target > 8) throw new Error("Run target must be next, all, or 0..8");
|
||||
state = startStage(gotoStage(state, target), target, allStages);
|
||||
await runtime.set(state);
|
||||
const config = await loadPomConfig(state.projectRoot);
|
||||
await enterStageBranch(state, config);
|
||||
await routeStage(pi, ctx, state);
|
||||
updateChrome(ctx, state, config, runtime);
|
||||
pi.sendUserMessage(await renderStagePrompt(state, allStages), { deliverAs: "followUp" });
|
||||
}
|
||||
|
||||
async function dispatchAction(pi: ExtensionAPI, ctx: ExtensionContext, runtime: PomRuntime, action: CommandCenterAction): Promise<void> {
|
||||
if (action === "run") return runStage(pi, ctx, runtime, "next");
|
||||
if (action === "hive") { pi.sendUserMessage("Call pom_hive with op=plan and preset=stage. Then call native task exactly once with the returned batch. After all agents finish, integrate and call pom_hive op=synthesize.", { deliverAs: "followUp" }); return; }
|
||||
if (action === "validate") {
|
||||
const report = await validatePom(runtime.get(), runtime.get().currentStage === 8 ? "delivery" : "stage");
|
||||
pi.sendMessage({ customType: "pom:validation", content: `${report.passed ? "PASS" : "FAIL"} ${report.scope}`, display: true, details: report }, { triggerTurn: false });
|
||||
return;
|
||||
}
|
||||
if (action === "evidence") return recordEvidenceInteractive(ctx, runtime);
|
||||
if (action === "vault") { ctx.ui.setEditorText("@content/production-board.md "); return; }
|
||||
if (action === "export") {
|
||||
const result = await exportProject(runtime.get(), runtime.get().currentStage === 8 ? "final" : "checkpoint");
|
||||
ctx.ui.notify(`Archive verified: ${result.zipPath}`, "info");
|
||||
return;
|
||||
}
|
||||
if (action === "theme") {
|
||||
const installed = await installBundledThemes();
|
||||
ctx.ui.setEditorText("/theme pom-nocturne");
|
||||
ctx.ui.notify(`Installed ${installed.map((item) => item.name).join(", ")}. Press Enter to activate POM Nocturne.`, "info");
|
||||
return;
|
||||
}
|
||||
if (action === "settings") return experienceSettings(ctx, runtime);
|
||||
}
|
||||
|
||||
export function registerPomCommand(pi: ExtensionAPI, runtime: PomRuntime): void {
|
||||
const completions = ["new", "run", "hive", "swarm", "evidence", "check", "vault", "export", "status", "prompt", "tools", "theme", "settings", "doctor", "help"];
|
||||
pi.registerCommand("pom", {
|
||||
description: "POM production command center",
|
||||
getArgumentCompletions: (prefix) => completions.filter((item) => item.startsWith(prefix)).map((item) => ({ value: item, label: item })),
|
||||
handler: async (raw, ctx: ExtensionCommandContext) => {
|
||||
const command = parseCommand(raw);
|
||||
if (command.verb === "dashboard") {
|
||||
const action = await openCommandCenter(ctx, runtime.get());
|
||||
await dispatchAction(pi, ctx, runtime, action);
|
||||
return;
|
||||
}
|
||||
if (command.verb === "help") { ctx.ui.notify(HELP, "info"); return; }
|
||||
|
||||
if (command.verb === "new") {
|
||||
const title = command.args.join(" ") || await ctx.ui.input("Project title", "Untitled Magnum Opus") || "Untitled Magnum Opus";
|
||||
const config = await loadPomConfig(ctx.cwd);
|
||||
const state = createState(title, ctx.cwd, config, true);
|
||||
await bootstrapProject(state);
|
||||
await ensureGit(state, config);
|
||||
await runtime.set(state);
|
||||
await pi.setSessionName(`POM · ${state.projectTitle}`);
|
||||
updateChrome(ctx, state, config, runtime);
|
||||
ctx.ui.notify(`POM project created at ${state.projectRoot}`, "info");
|
||||
await runStage(pi, ctx, runtime, "0");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "resume") {
|
||||
const path = command.args[0] ?? join(ctx.cwd, "00_admin", "project_state.json");
|
||||
await runtime.set(await loadState(path));
|
||||
const config = await loadPomConfig(runtime.get().projectRoot);
|
||||
updateChrome(ctx, runtime.get(), config, runtime);
|
||||
ctx.ui.notify(summarizeState(runtime.get()), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "run" || command.verb === "next") {
|
||||
await runStage(pi, ctx, runtime, command.verb === "next" ? "next" : command.args[0] ?? "next");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "hive") {
|
||||
const preset = command.args[0] ?? "stage";
|
||||
if (!["lean", "stage", "audit"].includes(preset)) throw new Error("Hive preset must be lean, stage, or audit");
|
||||
pi.sendUserMessage(`Call pom_hive with {"op":"plan","preset":"${preset}"}. Invoke native task exactly once with its returned batch. Use native IRC for live coordination and pom_hive signal for durable milestones. Integrate, then synthesize the mission.`, { deliverAs: "followUp" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "swarm") {
|
||||
const mode = (command.args[0] ?? "parallel") as PomSwarmMode;
|
||||
if (!["parallel", "sequential", "pipeline"].includes(mode)) throw new Error("Swarm mode must be parallel, sequential, or pipeline");
|
||||
const count = Number(command.args[1] ?? "1");
|
||||
const config = await loadPomConfig(runtime.get().projectRoot);
|
||||
const state = structuredClone(runtime.get());
|
||||
const mission = buildHiveMission(state, config, "stage");
|
||||
state.hive.activeMissionId = mission.id;
|
||||
state.hive.missions[mission.id] = mission;
|
||||
state.stageRuns[String(state.currentStage)].hiveMissionIds.push(mission.id);
|
||||
await runtime.set(state);
|
||||
const missionPath = await writeHiveMission(state, mission);
|
||||
const swarmPath = await writeSwarmDefinition(state, mission, mode, count);
|
||||
await recordHiveEvent(state, { type: "swarm.definition.created", missionId: mission.id, missionPath, swarmPath, mode, count });
|
||||
const swarmAvailable = pi.getCommands().some((item) => item.name === "swarm");
|
||||
const invocation = `/swarm run ${join(state.projectRoot, swarmPath)}`;
|
||||
if (swarmAvailable && ctx.hasUI) {
|
||||
ctx.ui.setEditorText(invocation);
|
||||
ctx.ui.notify(`OMP Swarm DAG prepared. Press Enter to run:
|
||||
${invocation}`, "info");
|
||||
} else {
|
||||
ctx.ui.notify(`Swarm YAML created at ${swarmPath}. Install/enable @oh-my-pi/swarm-extension, then run:
|
||||
${invocation}`, "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "evidence") { await recordEvidenceInteractive(ctx, runtime); return; }
|
||||
|
||||
if (command.verb === "check") {
|
||||
const scope = (command.args[0] ?? (runtime.get().currentStage === 8 ? "delivery" : "stage")) as ValidationScope;
|
||||
if (!["quick", "stage", "canon", "continuity", "knowledge", "files", "delivery", "all"].includes(scope)) throw new Error(`Invalid validation scope: ${scope}`);
|
||||
const previous = runtime.get().toolProfile;
|
||||
try {
|
||||
await applyToolProfile(pi, "audit");
|
||||
const report = await validatePom(runtime.get(), scope);
|
||||
pi.sendMessage({ customType: "pom:validation", content: `${report.passed ? "PASS" : "FAIL"} ${scope}`, display: true, details: report }, { triggerTurn: false });
|
||||
} finally {
|
||||
await applyToolProfile(pi, previous);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "vault") { ctx.ui.setEditorText("@content/production-board.md "); return; }
|
||||
if (command.verb === "status") { ctx.ui.notify(command.flags.has("json") ? JSON.stringify(runtime.get(), null, 2) : summarizeState(runtime.get()), "info"); return; }
|
||||
|
||||
if (command.verb === "export") {
|
||||
const kind = command.args[0] ?? (runtime.get().currentStage === 8 ? "final" : "checkpoint");
|
||||
if (kind !== "final" && kind !== "checkpoint") throw new Error("Export kind must be checkpoint or final");
|
||||
const result = await exportProject(runtime.get(), kind);
|
||||
ctx.ui.notify(`PASS · ${result.zipPath}\n${result.verification.sha256}`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "prompt") {
|
||||
const action = command.args[0] ?? "list";
|
||||
if (action === "list") {
|
||||
const prompts = await listPrompts(runtime.get().initialized ? runtime.get() : undefined);
|
||||
ctx.ui.notify(prompts.map((item) => `${item.id} · ${item.source} · ${item.sha256.slice(0, 12)}`).join("\n"), "info");
|
||||
return;
|
||||
}
|
||||
const id = command.args[1];
|
||||
if (!id) throw new Error("Prompt id required");
|
||||
const prompt = await getPrompt(runtime.get(), id);
|
||||
if (action === "show") await ctx.ui.editor(`POM prompt · ${id}`, prompt.content);
|
||||
else if (action === "run") pi.sendUserMessage(prompt.content, { deliverAs: "followUp" });
|
||||
else throw new Error("Prompt action must be list, show, or run");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "tools") {
|
||||
const action = command.args[0] ?? "show";
|
||||
if (action === "show") { ctx.ui.notify(pi.getActiveTools().join(", "), "info"); return; }
|
||||
const profile = action === "profile" ? command.args[1] : action === "reset" ? "auto" : undefined;
|
||||
if (!profile || !["auto", "creative", "research", "draft", "audit", "delivery"].includes(profile)) throw new Error("Invalid tool profile");
|
||||
const selected = await applyToolProfile(pi, profile as PomToolProfile);
|
||||
const state = structuredClone(runtime.get());
|
||||
state.toolProfile = profile as typeof state.toolProfile;
|
||||
await runtime.set(state);
|
||||
ctx.ui.notify(selected.join(", "), "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "theme") {
|
||||
const action = command.args[0] ?? "install";
|
||||
if (action !== "install") throw new Error("Theme action must be install");
|
||||
const installed = await installBundledThemes();
|
||||
if (ctx.hasUI) ctx.ui.setEditorText("/theme pom-nocturne");
|
||||
ctx.ui.notify(`Installed ${installed.map((item) => item.name).join(", ")}. Run /theme pom-nocturne or press Enter.`, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
if (command.verb === "settings") { await experienceSettings(ctx, runtime); return; }
|
||||
|
||||
if (command.verb === "doctor") {
|
||||
const report = await validatePom(runtime.get(), "quick");
|
||||
const all = new Set(pi.getAllTools());
|
||||
const required = ["read", "write", "edit", "task", "irc", "todo", "eval", "pom_stage", "pom_hive", "pom_validate"];
|
||||
const missing = required.filter((tool) => !all.has(tool));
|
||||
ctx.ui.notify([
|
||||
`State ${report.passed ? "PASS" : "FAIL"}`,
|
||||
`Tools ${missing.length ? `missing ${missing.join(", ")}` : "PASS"}`,
|
||||
`OMP ${pi.pi ? "API loaded" : "API unavailable"}`,
|
||||
`Project ${runtime.get().projectRoot}`,
|
||||
].join("\n"), report.passed && !missing.length ? "info" : "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`Unknown POM command: ${command.verb}\n\n${HELP}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut("alt+p", {
|
||||
description: "Open POM command center",
|
||||
handler: async (ctx) => {
|
||||
const action = await openCommandCenter(ctx, runtime.get());
|
||||
await dispatchAction(pi, ctx, runtime, action);
|
||||
},
|
||||
});
|
||||
}
|
||||
69
src/config.ts
Normal file
69
src/config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { DEFAULT_CONFIG, type PomConfig } from "./domain";
|
||||
import { pathExists } from "./persistence";
|
||||
|
||||
async function readJsonObject(path: string): Promise<Record<string, unknown>> {
|
||||
if (!(await pathExists(path))) return {};
|
||||
const text = await readFile(path, "utf8");
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid POM configuration at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`POM configuration must be an object: ${path}`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function pickEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T, key: string): T {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`Invalid POM setting ${key}: ${String(value)}`);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function pickBoolean(value: unknown, fallback: boolean, key: string): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "boolean") throw new Error(`Invalid POM setting ${key}: expected boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function pickInteger(value: unknown, fallback: number, key: string, min: number, max: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
if (!Number.isInteger(value) || Number(value) < min || Number(value) > max) throw new Error(`Invalid POM setting ${key}: expected ${min}..${max}`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export async function loadPomConfig(cwd: string): Promise<PomConfig> {
|
||||
const globalConfig = await readJsonObject(join(homedir(), ".omp", "agent", "pom.json"));
|
||||
const projectConfig = await readJsonObject(join(cwd, ".omp", "pom.json"));
|
||||
const merged = { ...globalConfig, ...projectConfig };
|
||||
return {
|
||||
autoToolProfiles: pickBoolean(merged.autoToolProfiles, DEFAULT_CONFIG.autoToolProfiles, "autoToolProfiles"),
|
||||
autoModelRouting: pickBoolean(merged.autoModelRouting, DEFAULT_CONFIG.autoModelRouting, "autoModelRouting"),
|
||||
dashboardWidget: pickBoolean(merged.dashboardWidget, DEFAULT_CONFIG.dashboardWidget, "dashboardWidget"),
|
||||
stopGuard: pickBoolean(merged.stopGuard, DEFAULT_CONFIG.stopGuard, "stopGuard"),
|
||||
defaultMode: pickEnum(merged.defaultMode, ["autopilot", "collaborative", "strict-autopilot"], DEFAULT_CONFIG.defaultMode, "defaultMode"),
|
||||
defaultView: pickEnum(merged.defaultView, ["cinematic", "rich", "compact", "minimal"], DEFAULT_CONFIG.defaultView, "defaultView"),
|
||||
defaultMotion: pickEnum(merged.defaultMotion, ["off", "subtle", "full"], DEFAULT_CONFIG.defaultMotion, "defaultMotion"),
|
||||
defaultToolProfile: pickEnum(merged.defaultToolProfile, ["auto", "creative", "research", "draft", "audit", "delivery"], DEFAULT_CONFIG.defaultToolProfile, "defaultToolProfile"),
|
||||
maxStudioAgents: pickInteger(merged.maxStudioAgents, DEFAULT_CONFIG.maxStudioAgents, "maxStudioAgents", 1, 12),
|
||||
projectRootMode: pickEnum(merged.projectRootMode, ["child", "cwd"], DEFAULT_CONFIG.projectRootMode, "projectRootMode"),
|
||||
destructiveProjectCommands: pickEnum(merged.destructiveProjectCommands, ["block", "allow"], DEFAULT_CONFIG.destructiveProjectCommands, "destructiveProjectCommands"),
|
||||
gitMode: pickEnum(merged.gitMode, ["off", "checkpoint", "branch-per-stage"], DEFAULT_CONFIG.gitMode, "gitMode"),
|
||||
hudPlacement: pickEnum(merged.hudPlacement, ["aboveEditor", "belowEditor"], DEFAULT_CONFIG.hudPlacement, "hudPlacement"),
|
||||
showThinkingLane: pickBoolean(merged.showThinkingLane, DEFAULT_CONFIG.showThinkingLane, "showThinkingLane"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateProjectPomConfig(projectRoot: string, patch: Partial<PomConfig>): Promise<PomConfig> {
|
||||
const path = join(projectRoot, ".omp", "pom.json");
|
||||
const current = await readJsonObject(path);
|
||||
const next = { ...current, ...patch };
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(temp, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
||||
await rename(temp, path);
|
||||
return loadPomConfig(projectRoot);
|
||||
}
|
||||
501
src/domain.ts
Normal file
501
src/domain.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
export const POM_VERSION = "2.0.0";
|
||||
export const POM_SCHEMA_VERSION = "2.0.0" as const;
|
||||
|
||||
export type PomMode = "autopilot" | "collaborative" | "strict-autopilot";
|
||||
export type PomView = "cinematic" | "rich" | "compact" | "minimal";
|
||||
export type PomMotion = "off" | "subtle" | "full";
|
||||
export type PomToolProfile = "auto" | "creative" | "research" | "draft" | "audit" | "delivery";
|
||||
export type PomStageStatus = "queued" | "active" | "blocked" | "complete";
|
||||
export type ValidationScope = "quick" | "stage" | "canon" | "continuity" | "knowledge" | "files" | "delivery" | "all";
|
||||
|
||||
export interface TodoPhase {
|
||||
phase: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface ArtifactRequirement {
|
||||
path: string;
|
||||
kind?: "file" | "directory";
|
||||
description: string;
|
||||
minBytes?: number;
|
||||
minEntries?: number;
|
||||
requiredHeadings?: string[];
|
||||
requiredPatterns?: string[];
|
||||
forbiddenPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface AcceptanceCriterion {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface StageSpec {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
toolProfile: PomToolProfile;
|
||||
modelRole: string;
|
||||
agents: string[];
|
||||
artifacts: ArtifactRequirement[];
|
||||
acceptance: AcceptanceCriterion[];
|
||||
todos: TodoPhase[];
|
||||
}
|
||||
|
||||
const phase = (phaseName: string, ...items: string[]): TodoPhase => ({ phase: phaseName, items });
|
||||
const criterion = (id: string, label: string, description: string): AcceptanceCriterion => ({ id, label, description });
|
||||
|
||||
export const STAGES: readonly StageSpec[] = [
|
||||
{
|
||||
id: 0,
|
||||
slug: "init",
|
||||
name: "Project Initialization",
|
||||
icon: "🏗",
|
||||
toolProfile: "creative",
|
||||
modelRole: "pi/slow",
|
||||
agents: ["pom-architect"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "01_planning/00_project_brief.md",
|
||||
description: "Authoritative project brief",
|
||||
minBytes: 900,
|
||||
requiredHeadings: ["Vision", "Audience", "Constraints", "Success Criteria", "Open Questions"],
|
||||
forbiddenPatterns: ["TBD", "TODO", "lorem ipsum"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("identity", "Identity resolved", "Title, format, audience, intended scope, and canonical root are explicit."),
|
||||
criterion("north-star", "North star defined", "The project has a concise creative promise and measurable success criteria."),
|
||||
criterion("ambiguity", "Foundational ambiguity cleared", "Blocking unknowns are resolved or represented as explicit blockers."),
|
||||
],
|
||||
todos: [
|
||||
phase("Foundation", "Resolve project identity", "Define creative north star", "Record constraints and assumptions"),
|
||||
phase("Artifacts", "Create project brief", "Initialize vault and ledgers", "Validate stage 0"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
slug: "genre",
|
||||
name: "Genre Architecture",
|
||||
icon: "🎭",
|
||||
toolProfile: "research",
|
||||
modelRole: "pi/slow",
|
||||
agents: ["pom-researcher", "pom-concept-forge", "pom-critic"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "01_planning/01_genre_grid.md",
|
||||
description: "Genre landscape and promise map",
|
||||
minBytes: 1400,
|
||||
requiredHeadings: ["Candidate Genres", "Audience Promise", "Cliche Risks", "Differentiation"],
|
||||
},
|
||||
{
|
||||
path: "01_planning/02_genre_fusion_matrix.md",
|
||||
description: "Ranked fusion matrix",
|
||||
minBytes: 1000,
|
||||
requiredHeadings: ["Scoring Model", "Matrix", "Recommendation"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("genre-selected", "Primary genre selected", "One primary genre and optional secondary genres are explicitly locked."),
|
||||
criterion("promise", "Genre promise recorded", "Reader expectations, pleasures, and boundaries are stated."),
|
||||
criterion("cliche-risk", "Cliche risks documented", "High-risk conventions and specific differentiation strategies are recorded."),
|
||||
],
|
||||
todos: [
|
||||
phase("Research", "Map genre candidates", "Research audience expectations", "Analyze cliches and differentiation"),
|
||||
phase("Selection", "Build fusion matrix", "Rank directions", "Lock genre promise"),
|
||||
phase("Gate", "Save artifacts", "Record acceptance evidence", "Validate stage 1"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
slug: "voice",
|
||||
name: "Author Persona and Voice",
|
||||
icon: "✒",
|
||||
toolProfile: "creative",
|
||||
modelRole: "pi/slow",
|
||||
agents: ["pom-voice-architect", "pom-rights-auditor"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "02_story_bible/01_author_persona.md",
|
||||
description: "Original author persona",
|
||||
minBytes: 1200,
|
||||
requiredHeadings: ["Creative Identity", "Audience Relationship", "Aesthetic Philosophy", "Boundaries"],
|
||||
},
|
||||
{
|
||||
path: "02_story_bible/02_voice_guide.md",
|
||||
description: "Testable voice system",
|
||||
minBytes: 1600,
|
||||
requiredHeadings: ["Voice Profile", "Always", "Avoid", "Syntax", "Diction", "Sample"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("persona", "Original persona stable", "The persona is internally coherent and not a disguised living-author imitation."),
|
||||
criterion("voice-laws", "Voice laws testable", "Voice rules can be checked against prose with concrete positive and negative examples."),
|
||||
criterion("rights", "Imitation risk cleared", "Protected-author imitation and misleading attribution risks are explicitly audited."),
|
||||
],
|
||||
todos: [
|
||||
phase("Persona", "Design original persona", "Define audience relationship", "Audit rights boundaries"),
|
||||
phase("Voice", "Define voice profile", "Write always/avoid laws", "Create original sample"),
|
||||
phase("Gate", "Save artifacts", "Record acceptance evidence", "Validate stage 2"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
slug: "store",
|
||||
name: "Story Store",
|
||||
icon: "📚",
|
||||
toolProfile: "creative",
|
||||
modelRole: "pi/slow",
|
||||
agents: ["pom-concept-forge", "pom-critic"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "01_planning/04_story_store.md",
|
||||
description: "Twenty structurally diverse concepts",
|
||||
minBytes: 3800,
|
||||
requiredHeadings: ["Concepts", "Scoring", "Top Five"],
|
||||
requiredPatterns: ["Concept 20"],
|
||||
},
|
||||
{
|
||||
path: "01_planning/05_selected_direction.md",
|
||||
description: "Selected and strengthened premise",
|
||||
minBytes: 1400,
|
||||
requiredHeadings: ["Premise", "Protagonist", "Conflict", "Stakes", "Distinctive Engine", "Why This Wins"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("twenty", "Twenty concepts produced", "Exactly twenty meaningfully different concepts exist and are individually inspectable."),
|
||||
criterion("winner", "Winner selected", "A transparent scoring and comparison process selects the strongest direction."),
|
||||
criterion("premise", "Premise stable", "The selected premise contains character, conflict, stakes, engine, and differentiation."),
|
||||
],
|
||||
todos: [
|
||||
phase("Generate", "Generate twenty diverse concepts", "Score concept potential", "Reject cosmetic duplicates"),
|
||||
phase("Select", "Rank strongest five", "Compare top three", "Strengthen winner"),
|
||||
phase("Gate", "Save story store", "Save selected direction", "Validate stage 3"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
slug: "bible",
|
||||
name: "Story Blueprint and Bible",
|
||||
icon: "🌐",
|
||||
toolProfile: "creative",
|
||||
modelRole: "pi/slow",
|
||||
agents: ["pom-story-architect", "pom-character-world", "pom-continuity-auditor", "pom-visual-director"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "02_story_bible/00_story_bible_index.md",
|
||||
description: "Story-bible map of content",
|
||||
minBytes: 800,
|
||||
requiredHeadings: ["Canon", "Characters", "World", "Structure", "Visual Language"],
|
||||
},
|
||||
{
|
||||
path: "02_story_bible/13_canon_ledger.md",
|
||||
description: "Atomic canon ledger",
|
||||
minBytes: 1600,
|
||||
requiredHeadings: ["Characters", "World Rules", "Timeline", "Objects", "Unresolved"],
|
||||
},
|
||||
{
|
||||
path: "02_story_bible/16_visual_bible.md",
|
||||
description: "Visual system and reference policy",
|
||||
minBytes: 1400,
|
||||
requiredHeadings: ["Visual Thesis", "Palette", "Composition", "Motifs", "Rights"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("ending", "Ending known", "The ending and its causal prerequisites are explicit."),
|
||||
criterion("arcs", "Major arcs defined", "Primary character, relationship, mystery, and thematic arcs have start, turns, and resolution."),
|
||||
criterion("world", "World rules stable", "Rules, limits, costs, and exceptions are documented without unresolved contradiction."),
|
||||
criterion("outline-ready", "Canon supports outline", "The bible contains enough stable information to build a causal outline."),
|
||||
],
|
||||
todos: [
|
||||
phase("Identity", "Finalize story identity", "Write ending synopsis", "Define dramatic questions"),
|
||||
phase("Bible", "Build character bible", "Build world rules", "Build themes and visual language"),
|
||||
phase("Canon", "Create timeline and ledgers", "Map mysteries and payoffs", "Resolve contradictions"),
|
||||
phase("Gate", "Record evidence", "Run continuity audit", "Validate stage 4"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
slug: "outline",
|
||||
name: "Master Story Outline",
|
||||
icon: "🧩",
|
||||
toolProfile: "creative",
|
||||
modelRole: "pi/slow",
|
||||
agents: ["pom-structure-engineer", "pom-continuity-auditor", "pom-critic"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "03_manuscript/00_master_outline.md",
|
||||
description: "Causal master outline",
|
||||
minBytes: 3200,
|
||||
requiredHeadings: ["Act I", "Act II", "Act III", "Ending", "Causality Audit"],
|
||||
},
|
||||
{
|
||||
path: "03_manuscript/02_scene_index.md",
|
||||
description: "Scene-level causal index",
|
||||
minBytes: 2600,
|
||||
requiredHeadings: ["Scene Index", "Setups", "Payoffs", "State Changes"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("ending-represented", "Ending represented", "The final outcome and its setup chain are represented in the outline."),
|
||||
criterion("causality", "Causality survives review", "Major beats arise from prior choices, pressures, and consequences rather than convenience."),
|
||||
criterion("setup-payoff", "Setups mapped", "Every major payoff has a setup, escalation, and resolution path."),
|
||||
],
|
||||
todos: [
|
||||
phase("Structure", "Design acts and sequences", "Map turning points", "Allocate narrative weight"),
|
||||
phase("Scenes", "Build causal scene index", "Track state changes", "Link setups and payoffs"),
|
||||
phase("Audit", "Audit agency and escalation", "Repair weak causality", "Validate stage 5"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
slug: "chapters",
|
||||
name: "Chapter Architecture",
|
||||
icon: "📑",
|
||||
toolProfile: "creative",
|
||||
modelRole: "pi/slow",
|
||||
agents: ["pom-structure-engineer", "pom-continuity-auditor", "pom-visual-director"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "03_manuscript/01_chapter_outline.md",
|
||||
description: "Complete chapter architecture",
|
||||
minBytes: 3000,
|
||||
requiredHeadings: ["Chapter Map", "POV Plan", "Pacing", "Continuity Audit"],
|
||||
},
|
||||
{
|
||||
path: "03_manuscript/02_scene_index.md",
|
||||
description: "Updated scene index",
|
||||
minBytes: 3200,
|
||||
requiredHeadings: ["Scene Index", "Chapter Allocation", "Reveals", "Payoffs"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("mapped", "All chapters mapped", "Every intended chapter has objective, conflict, change, and exit state."),
|
||||
criterion("pov", "POV logic stable", "POV assignment and transitions have explicit dramatic purpose."),
|
||||
criterion("gaps", "No narrative gaps", "Timeline, reveal order, and causal transitions survive review."),
|
||||
],
|
||||
todos: [
|
||||
phase("Architecture", "Map every chapter", "Define state changes", "Balance POV and pacing"),
|
||||
phase("Continuity", "Verify transitions", "Verify reveals and payoffs", "Repair gaps and redundancies"),
|
||||
phase("Gate", "Update scene index", "Record evidence", "Validate stage 6"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
slug: "draft",
|
||||
name: "Drafting and Revision",
|
||||
icon: "📜",
|
||||
toolProfile: "draft",
|
||||
modelRole: "pi/default",
|
||||
agents: ["pom-prose-smith", "pom-developmental-editor", "pom-continuity-auditor"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "03_manuscript/chapters",
|
||||
kind: "directory",
|
||||
description: "Individual drafted chapters",
|
||||
minEntries: 1,
|
||||
},
|
||||
{
|
||||
path: "03_manuscript/03_complete_manuscript.md",
|
||||
description: "Compiled complete manuscript",
|
||||
minBytes: 8000,
|
||||
requiredHeadings: ["Chapter"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("complete", "All planned chapters complete", "The chapter map and chapter files reconcile without missing planned content."),
|
||||
criterion("counts", "Word counts verified", "Measured word counts are recorded and reconcile with the compiled manuscript."),
|
||||
criterion("continuity-resolved", "Continuity resolved", "Known continuity defects are resolved or explicitly disclosed as blockers."),
|
||||
],
|
||||
todos: [
|
||||
phase("Preflight", "Load chapter dependencies", "Verify canon", "Set chapter objective"),
|
||||
phase("Draft", "Draft chapter", "Preserve voice and POV", "End in changed state"),
|
||||
phase("Postflight", "Measure words", "Run voice and repetition checks", "Update ledgers"),
|
||||
phase("Manuscript", "Compile manuscript", "Run developmental review", "Validate stage 7"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
slug: "delivery",
|
||||
name: "Final Compilation and Delivery",
|
||||
icon: "📦",
|
||||
toolProfile: "delivery",
|
||||
modelRole: "pi/slow",
|
||||
agents: ["pom-delivery-auditor", "pom-rights-auditor", "pom-continuity-auditor"],
|
||||
artifacts: [
|
||||
{
|
||||
path: "00_admin/artifact_manifest.json",
|
||||
description: "Curated delivery manifest",
|
||||
minBytes: 64,
|
||||
},
|
||||
{
|
||||
path: "00_admin/SHA256SUMS.txt",
|
||||
description: "Checksums for packaged artifacts",
|
||||
minBytes: 64,
|
||||
},
|
||||
{
|
||||
path: "07_exports/final_delivery_report.md",
|
||||
description: "Truthful delivery report",
|
||||
minBytes: 900,
|
||||
requiredHeadings: ["Contents", "Validation", "Warnings", "Omissions", "Archive"],
|
||||
},
|
||||
],
|
||||
acceptance: [
|
||||
criterion("requested", "Requested files exist", "All promised deliverables exist as non-empty regular files."),
|
||||
criterion("hashes", "Manifest and hashes match", "Manifest metadata and SHA-256 values match canonical files."),
|
||||
criterion("archive", "Fresh archive verified", "A newly created archive is parsed and every member is decompressed and checked."),
|
||||
criterion("disclosure", "Omissions disclosed", "Warnings, limitations, rights status, and omissions are explicit."),
|
||||
],
|
||||
todos: [
|
||||
phase("Compile", "Compile manuscript", "Assemble requested exports", "Assemble approved visuals"),
|
||||
phase("Validate", "Run structure checks", "Run continuity and rights checks", "Verify manifest and hashes"),
|
||||
phase("Package", "Create fresh archive", "Verify every member", "Write delivery report"),
|
||||
phase("Deliver", "Expose real paths", "Disclose warnings and omissions", "Validate stage 8"),
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export interface ArtifactRecord {
|
||||
path: string;
|
||||
category: string;
|
||||
stage: number;
|
||||
version: string;
|
||||
status: "draft" | "reviewed" | "approved" | "superseded";
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
updatedAt: string;
|
||||
rightsStatus: "original" | "licensed" | "public-domain" | "reference-only" | "unknown";
|
||||
source?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface EvidenceRecord {
|
||||
id: string;
|
||||
criterionId: string;
|
||||
summary: string;
|
||||
paths: string[];
|
||||
agent?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StageBlocker {
|
||||
id: string;
|
||||
reason: string;
|
||||
owner?: string;
|
||||
createdAt: string;
|
||||
resolvedAt?: string;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export interface StageRunState {
|
||||
status: PomStageStatus;
|
||||
attempts: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
blockers: StageBlocker[];
|
||||
evidence: EvidenceRecord[];
|
||||
validationReportPath?: string;
|
||||
hiveMissionIds: string[];
|
||||
}
|
||||
|
||||
export interface HiveTask {
|
||||
name: string;
|
||||
agent: string;
|
||||
objective: string;
|
||||
owns: string[];
|
||||
acceptance: string[];
|
||||
isolated?: boolean;
|
||||
}
|
||||
|
||||
export interface HiveMission {
|
||||
id: string;
|
||||
stage: number;
|
||||
title: string;
|
||||
status: "planned" | "running" | "synthesizing" | "complete" | "blocked";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tasks: HiveTask[];
|
||||
synthesis?: string;
|
||||
taskToolCallId?: string;
|
||||
}
|
||||
|
||||
export interface PomState {
|
||||
schemaVersion: typeof POM_SCHEMA_VERSION;
|
||||
pomVersion: string;
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
projectSlug: string;
|
||||
projectRoot: string;
|
||||
initialized: boolean;
|
||||
currentStage: number;
|
||||
stageRuns: Record<string, StageRunState>;
|
||||
mode: PomMode;
|
||||
view: PomView;
|
||||
motion: PomMotion;
|
||||
toolProfile: PomToolProfile;
|
||||
autoToolProfiles: boolean;
|
||||
autoModelRouting: boolean;
|
||||
completedStages: number[];
|
||||
decisions: string[];
|
||||
assumptions: string[];
|
||||
warnings: string[];
|
||||
activeRun?: { id: string; stage: number; startedAt: string; continuationCount: number; allStages: boolean };
|
||||
hive: {
|
||||
activeMissionId?: string;
|
||||
missions: Record<string, HiveMission>;
|
||||
signalsLogged: number;
|
||||
};
|
||||
artifactVersion: string;
|
||||
canonVersion: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PomConfig {
|
||||
autoToolProfiles: boolean;
|
||||
autoModelRouting: boolean;
|
||||
dashboardWidget: boolean;
|
||||
stopGuard: boolean;
|
||||
defaultMode: PomMode;
|
||||
defaultView: PomView;
|
||||
defaultMotion: PomMotion;
|
||||
defaultToolProfile: PomToolProfile;
|
||||
maxStudioAgents: number;
|
||||
projectRootMode: "child" | "cwd";
|
||||
destructiveProjectCommands: "block" | "allow";
|
||||
gitMode: "off" | "checkpoint" | "branch-per-stage";
|
||||
hudPlacement: "aboveEditor" | "belowEditor";
|
||||
showThinkingLane: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: PomConfig = {
|
||||
autoToolProfiles: true,
|
||||
autoModelRouting: true,
|
||||
dashboardWidget: true,
|
||||
stopGuard: true,
|
||||
defaultMode: "autopilot",
|
||||
defaultView: "cinematic",
|
||||
defaultMotion: "subtle",
|
||||
defaultToolProfile: "auto",
|
||||
maxStudioAgents: 6,
|
||||
projectRootMode: "child",
|
||||
destructiveProjectCommands: "block",
|
||||
gitMode: "branch-per-stage",
|
||||
hudPlacement: "aboveEditor",
|
||||
showThinkingLane: true,
|
||||
};
|
||||
|
||||
export function stage(id: number): StageSpec {
|
||||
const value = STAGES[id];
|
||||
if (!value) throw new Error(`Invalid POM stage: ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function freshStageRuns(): Record<string, StageRunState> {
|
||||
return Object.fromEntries(
|
||||
STAGES.map((item) => [
|
||||
String(item.id),
|
||||
{ status: "queued", attempts: 0, blockers: [], evidence: [], hiveMissionIds: [] } satisfies StageRunState,
|
||||
]),
|
||||
);
|
||||
}
|
||||
208
src/events.ts
Normal file
208
src/events.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { stage } from "./domain";
|
||||
import { addBlocker } from "./state";
|
||||
import { composePromptStack } from "./prompts";
|
||||
import { applyToolProfile } from "./tool-profiles";
|
||||
import { routeStage } from "./router";
|
||||
import { clearChrome, updateChrome } from "./ui";
|
||||
import { recordHiveEvent, syncKnowledgeBase } from "./persistence";
|
||||
import type { PomRuntime } from "./runtime";
|
||||
import { shouldBlockProjectCommand } from "./policy";
|
||||
|
||||
async function chrome(ctx: ExtensionContext, runtime: PomRuntime): Promise<void> {
|
||||
const state = runtime.get();
|
||||
const config = await runtime.refreshConfig(state.initialized ? state.projectRoot : ctx.cwd);
|
||||
updateChrome(ctx, state, config, runtime);
|
||||
}
|
||||
|
||||
export function registerPomEvents(pi: ExtensionAPI, runtime: PomRuntime): void {
|
||||
pi.on("resources_discover", async () => ({
|
||||
skillPaths: [fileURLToPath(new URL("../skills", import.meta.url))],
|
||||
promptPaths: [fileURLToPath(new URL("../prompts", import.meta.url))],
|
||||
themePaths: [fileURLToPath(new URL("../themes", import.meta.url))],
|
||||
}));
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const state = await runtime.restore(ctx);
|
||||
await chrome(ctx, runtime);
|
||||
if (state.initialized) {
|
||||
if (state.activeRun) await routeStage(pi, ctx, state);
|
||||
else if (state.autoToolProfiles && state.toolProfile !== "auto") await applyToolProfile(pi, state.toolProfile);
|
||||
const currentName = pi.getSessionName();
|
||||
if (!currentName) await pi.setSessionName(`POM · ${state.projectTitle}`);
|
||||
}
|
||||
});
|
||||
|
||||
const restoreChrome = async (_event: unknown, ctx: ExtensionContext): Promise<void> => {
|
||||
await runtime.restore(ctx);
|
||||
await chrome(ctx, runtime);
|
||||
};
|
||||
pi.on("session_branch", restoreChrome);
|
||||
pi.on("session_tree", restoreChrome);
|
||||
pi.on("session_switch", restoreChrome);
|
||||
|
||||
pi.on("before_agent_start", async (event) => {
|
||||
const state = runtime.get();
|
||||
if (!state.initialized || !state.activeRun) return;
|
||||
const stack = await composePromptStack(state, ["stage-run"], {
|
||||
STAGE_CONTRACT: JSON.stringify(stage(state.currentStage), null, 2),
|
||||
});
|
||||
return {
|
||||
systemPrompt: [...event.systemPrompt, stack.rendered],
|
||||
message: {
|
||||
customType: "pom:event",
|
||||
content: `Stage ${state.currentStage} production context injected`,
|
||||
display: false,
|
||||
details: { title: `POM stage ${state.currentStage} active`, level: "info", lines: [`root ${state.projectRoot}`, `prompt ${stack.sha256.slice(0, 12)}`] },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("tool_execution_start", async (event, ctx) => {
|
||||
runtime.telemetry.activeTool = event.toolName;
|
||||
runtime.telemetry.activeIntent = event.intent;
|
||||
runtime.telemetry.toolStartedAt = Date.now();
|
||||
ctx.ui.setWorkingMessage(`POM · ${event.toolName}${event.intent ? ` · ${event.intent}` : ""}`);
|
||||
await chrome(ctx, runtime);
|
||||
});
|
||||
|
||||
pi.on("tool_execution_update", async (event, ctx) => {
|
||||
if (event.toolName === "task") ctx.ui.setWorkingMessage("POM hive · specialists working…");
|
||||
else if (event.toolName === "pom_validate") ctx.ui.setWorkingMessage("POM gate · verifying evidence…");
|
||||
});
|
||||
|
||||
pi.on("tool_execution_end", async (event, ctx) => {
|
||||
runtime.telemetry.lastToolResult = event.isError ? "fail" : "pass";
|
||||
runtime.telemetry.activeTool = undefined;
|
||||
runtime.telemetry.activeIntent = undefined;
|
||||
runtime.telemetry.toolStartedAt = undefined;
|
||||
ctx.ui.setWorkingMessage();
|
||||
await chrome(ctx, runtime);
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
const state = runtime.get();
|
||||
if (!state.initialized) return;
|
||||
const config = await runtime.refreshConfig(state.projectRoot);
|
||||
if (event.toolName === "bash" && config.destructiveProjectCommands === "block") {
|
||||
const command = String(event.input.command ?? "");
|
||||
if (shouldBlockProjectCommand(command, state.projectRoot, state.projectSlug, String(event.input.cwd ?? ctx.cwd))) {
|
||||
ctx.ui.notify("POM blocked a destructive command against the canonical production root.", "error");
|
||||
return { block: true, reason: "POM canonical-root policy blocks destructive recursive deletion or history destruction. Use versioned, targeted repairs." };
|
||||
}
|
||||
}
|
||||
if (event.toolName === "task") {
|
||||
const next = structuredClone(state);
|
||||
const missionId = next.hive.activeMissionId;
|
||||
if (missionId && next.hive.missions[missionId]) {
|
||||
next.hive.missions[missionId].status = "running";
|
||||
next.hive.missions[missionId].taskToolCallId = event.toolCallId;
|
||||
next.hive.missions[missionId].updatedAt = new Date().toISOString();
|
||||
runtime.telemetry.activeAgents = next.hive.missions[missionId].tasks.length;
|
||||
runtime.telemetry.completedAgents = 0;
|
||||
await runtime.set(next);
|
||||
await recordHiveEvent(next, { type: "native.task.started", missionId, toolCallId: event.toolCallId, input: event.input });
|
||||
}
|
||||
}
|
||||
if (event.toolName === "irc") {
|
||||
const input = event.input;
|
||||
if (input.op === "send") {
|
||||
runtime.telemetry.lastIrcSignal = `${String(input.to ?? "peer")}: ${String(input.message ?? "")}`;
|
||||
await recordHiveEvent(state, { type: "native.irc.send", toolCallId: event.toolCallId, to: input.to, message: input.message, replyTo: input.replyTo });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("tool_result", async (event) => {
|
||||
const state = runtime.get();
|
||||
if (!state.initialized) return;
|
||||
if (event.toolName === "task") {
|
||||
const next = structuredClone(state);
|
||||
const missionId = next.hive.activeMissionId;
|
||||
if (missionId && next.hive.missions[missionId]) {
|
||||
next.hive.missions[missionId].status = event.isError ? "blocked" : "synthesizing";
|
||||
next.hive.missions[missionId].updatedAt = new Date().toISOString();
|
||||
runtime.telemetry.completedAgents = event.isError ? 0 : next.hive.missions[missionId].tasks.length;
|
||||
await runtime.set(next);
|
||||
await recordHiveEvent(next, { type: "native.task.finished", missionId, toolCallId: event.toolCallId, isError: event.isError, details: event.details });
|
||||
}
|
||||
}
|
||||
if (event.toolName === "irc") {
|
||||
await recordHiveEvent(state, { type: "native.irc.result", toolCallId: event.toolCallId, isError: event.isError, input: event.input, details: event.details });
|
||||
}
|
||||
if (event.isError && event.toolName.startsWith("pom_")) {
|
||||
const next = structuredClone(state);
|
||||
const warning = `${event.toolName} failed at ${new Date().toISOString()}`;
|
||||
if (!next.warnings.includes(warning)) next.warnings.push(warning);
|
||||
await runtime.set(next);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session.compacting", async () => {
|
||||
const state = runtime.get();
|
||||
if (!state.initialized) return;
|
||||
const spec = stage(state.currentStage);
|
||||
const run = state.stageRuns[String(state.currentStage)];
|
||||
return {
|
||||
context: [
|
||||
`POM project ${state.projectTitle} at ${state.projectRoot}`,
|
||||
`Stage ${spec.id}/8 ${spec.name}; status=${run.status}; evidence=${run.evidence.length}/${spec.acceptance.length}`,
|
||||
`Canonical state path: ${state.projectRoot}/00_admin/project_state.json`,
|
||||
`Active hive: ${state.hive.activeMissionId ?? "none"}`,
|
||||
],
|
||||
preserveData: { pomState: state },
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("auto_compaction_start", async (_event, ctx) => ctx.ui.setWorkingMessage("POM · preserving canon and production state…"));
|
||||
pi.on("auto_compaction_end", async (_event, ctx) => ctx.ui.setWorkingMessage());
|
||||
pi.on("auto_retry_start", async (event, ctx) => ctx.ui.setStatus("pom-retry", `↻ retry ${event.attempt}`));
|
||||
pi.on("auto_retry_end", async (_event, ctx) => ctx.ui.setStatus("pom-retry", undefined));
|
||||
pi.on("tool_approval_requested", async (event, ctx) => ctx.ui.setStatus("pom-approval", `? ${event.toolName}`));
|
||||
pi.on("tool_approval_resolved", async (_event, ctx) => ctx.ui.setStatus("pom-approval", undefined));
|
||||
pi.on("goal_updated", async (event, ctx) => ctx.ui.setStatus("pom-goal", event.goal ? "◎ goal active" : undefined));
|
||||
pi.on("todo_reminder", async (_event, ctx) => ctx.ui.notify("POM production still has open native todos. Complete or explicitly drop them before the gate.", "warning"));
|
||||
|
||||
pi.on("ttsr_triggered", async (event) => {
|
||||
const state = runtime.get();
|
||||
if (!state.initialized) return;
|
||||
const next = structuredClone(state);
|
||||
const warning = `TTSR triggered: ${event.rules.map((rule) => rule.name).join(", ")}`;
|
||||
if (!next.warnings.includes(warning)) next.warnings.push(warning);
|
||||
await runtime.set(next);
|
||||
});
|
||||
|
||||
pi.on("credential_disabled", async (event, ctx) => {
|
||||
const state = runtime.get();
|
||||
if (!state.initialized) return;
|
||||
const next = structuredClone(state);
|
||||
const warning = `Credential disabled: ${String(event.provider)}`;
|
||||
if (!next.warnings.includes(warning)) next.warnings.push(warning);
|
||||
await runtime.set(next);
|
||||
ctx.ui.notify(warning, "warning");
|
||||
});
|
||||
|
||||
pi.on("session_stop", async (_event, ctx) => {
|
||||
const state = runtime.get();
|
||||
if (!state.initialized || !state.activeRun) return;
|
||||
const config = await runtime.refreshConfig(state.projectRoot);
|
||||
if (!config.stopGuard) return;
|
||||
if (state.activeRun.continuationCount < 2) {
|
||||
const next = structuredClone(state);
|
||||
if (next.activeRun) next.activeRun.continuationCount += 1;
|
||||
await runtime.set(next);
|
||||
return {
|
||||
continue: true,
|
||||
additionalContext: "POM stage is still active. Finish the native todo, synthesize the hive, register artifacts, record every acceptance criterion, run pom_validate, then call pom_stage pass or block with exact evidence. Do not stop ambiguously.",
|
||||
};
|
||||
}
|
||||
const blocked = addBlocker(state, `Stage ${state.currentStage} stopped without PASS or an explicit blocker after two guarded continuations`, "Main");
|
||||
await runtime.set(blocked);
|
||||
await syncKnowledgeBase(blocked);
|
||||
pi.sendMessage({ customType: "pom:milestone", content: "Stage blocked by stop guard", display: true, details: { stage: state.currentStage, status: "BLOCKED" } }, { triggerTurn: false });
|
||||
await chrome(ctx, runtime);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (_event, ctx) => clearChrome(ctx));
|
||||
}
|
||||
106
src/exporter.ts
Normal file
106
src/exporter.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { atomicWrite, loadManifest, pathExists, sha256File } from "./persistence";
|
||||
import { validatePom } from "./validators";
|
||||
import { createZip, verifyZip, type ZipVerification } from "./zip";
|
||||
import type { PomState } from "./domain";
|
||||
|
||||
export interface ExportResult {
|
||||
zipPath: string;
|
||||
verificationPath: string;
|
||||
reportPath: string;
|
||||
verification: ZipVerification;
|
||||
}
|
||||
|
||||
async function collectKnowledgeFiles(state: PomState): Promise<string[]> {
|
||||
const root = join(state.projectRoot, "content");
|
||||
if (!(await pathExists(root))) return [];
|
||||
return (await readdir(root)).filter((name) => name.endsWith(".md")).map((name) => `content/${name}`);
|
||||
}
|
||||
|
||||
export async function exportProject(state: PomState, kind: "checkpoint" | "final"): Promise<ExportResult> {
|
||||
const manifest = await loadManifest(state);
|
||||
if (!manifest.length) throw new Error("Cannot export an empty artifact manifest");
|
||||
|
||||
const preflightScopes = kind === "final" ? (["files", "knowledge", "continuity"] as const) : (["files"] as const);
|
||||
const preflightReports = [];
|
||||
for (const scope of preflightScopes) preflightReports.push(await validatePom(state, scope, { writeReport: false }));
|
||||
const preflightFailures = preflightReports.flatMap((report) => report.checks.filter((item) => item.status === "fail"));
|
||||
if (kind === "final") {
|
||||
const incomplete = Array.from({ length: 8 }, (_value, index) => index).filter((id) => state.stageRuns[String(id)]?.status !== "complete");
|
||||
if (incomplete.length) throw new Error(`Final export requires stages 0–7 complete; incomplete: ${incomplete.join(", ")}`);
|
||||
const blockers = Object.values(state.stageRuns).flatMap((run) => run.blockers).filter((blocker) => !blocker.resolvedAt);
|
||||
if (blockers.length) throw new Error(`Final export blocked by ${blockers.length} unresolved blocker(s)`);
|
||||
}
|
||||
if (preflightFailures.length) throw new Error(`Export preflight failed with ${preflightFailures.length} error(s)`);
|
||||
const preflight = {
|
||||
scope: preflightScopes.join("+"),
|
||||
passed: preflightFailures.length === 0,
|
||||
checks: preflightReports.flatMap((report) => report.checks),
|
||||
};
|
||||
|
||||
const exportRoot = join(state.projectRoot, "07_exports");
|
||||
const base = `${state.projectSlug}-${kind}`;
|
||||
const reportRelative = kind === "final" ? "07_exports/final_delivery_report.md" : `07_exports/${base}-delivery-report.md`;
|
||||
const reportPath = join(state.projectRoot, reportRelative);
|
||||
const checksumsRelative = "00_admin/SHA256SUMS.txt";
|
||||
const checksumsPath = join(state.projectRoot, checksumsRelative);
|
||||
const memberPaths = [...new Set([
|
||||
...manifest.map((record) => record.path),
|
||||
"00_admin/artifact_manifest.json",
|
||||
"00_admin/project_state.json",
|
||||
...await collectKnowledgeFiles(state),
|
||||
])].sort();
|
||||
|
||||
const checksumLines: string[] = [];
|
||||
for (const path of memberPaths) checksumLines.push(`${await sha256File(join(state.projectRoot, path))} ${path}`);
|
||||
await atomicWrite(checksumsPath, `${checksumLines.join("\n")}\n`);
|
||||
|
||||
const report = [
|
||||
"---",
|
||||
`title: ${JSON.stringify(`${state.projectTitle} Delivery Report`)}`,
|
||||
"type: delivery-report",
|
||||
"tags: [pom, delivery]",
|
||||
"publish: true",
|
||||
"---",
|
||||
"",
|
||||
`# ${state.projectTitle} Delivery Report`,
|
||||
"",
|
||||
"## Contents",
|
||||
"",
|
||||
...memberPaths.map((path) => `- \`${path}\``),
|
||||
"",
|
||||
"## Validation",
|
||||
"",
|
||||
`Preflight scope: **${preflight.scope}**`,
|
||||
`Preflight result: **${preflight.passed ? "PASS" : "FAIL"}**`,
|
||||
`Checks: ${preflight.checks.length}`,
|
||||
"",
|
||||
"## Warnings",
|
||||
"",
|
||||
...(state.warnings.length ? state.warnings.map((warning) => `- ${warning}`) : ["- None recorded."]),
|
||||
"",
|
||||
"## Omissions",
|
||||
"",
|
||||
"- Archive SHA-256 and member-by-member decompression results are written to the adjacent verification sidecar after archive creation. They cannot be embedded in the archive without changing the archive hash.",
|
||||
"",
|
||||
"## Archive",
|
||||
"",
|
||||
`Planned archive: \`${base}.zip\``,
|
||||
"Verification requirement: central-directory parse, safe names, supported compression, decompression, size, CRC-32, member count, and archive SHA-256.",
|
||||
].join("\n");
|
||||
await atomicWrite(reportPath, `${report}\n`);
|
||||
|
||||
const packagePaths = [...memberPaths, checksumsRelative, reportRelative];
|
||||
const zipPath = join(exportRoot, `${base}.zip`);
|
||||
await createZip(zipPath, packagePaths.map((path) => ({ absolutePath: join(state.projectRoot, path), archivePath: path })));
|
||||
const verification = await verifyZip(zipPath);
|
||||
const verificationPath = join(exportRoot, `${base}.verification.json`);
|
||||
await atomicWrite(verificationPath, `${JSON.stringify({
|
||||
schemaVersion: "1.0.0",
|
||||
generatedAt: new Date().toISOString(),
|
||||
archive: basename(zipPath),
|
||||
...verification,
|
||||
}, null, 2)}\n`);
|
||||
return { zipPath, verificationPath, reportPath, verification };
|
||||
}
|
||||
43
src/git.ts
Normal file
43
src/git.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { join } from "node:path";
|
||||
import type { PomConfig, PomState } from "./domain";
|
||||
import { stage } from "./domain";
|
||||
import { pathExists } from "./persistence";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function git(root: string, args: string[]): Promise<string> {
|
||||
const result = await execFileAsync("git", ["-C", root, ...args], { maxBuffer: 4 * 1024 * 1024 });
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
export async function ensureGit(state: PomState, config: PomConfig): Promise<void> {
|
||||
if (config.gitMode === "off") return;
|
||||
if (!(await pathExists(join(state.projectRoot, ".git")))) {
|
||||
await git(state.projectRoot, ["init"]);
|
||||
await git(state.projectRoot, ["config", "user.name", "POM Production Kernel"]);
|
||||
await git(state.projectRoot, ["config", "user.email", "pom@local.invalid"]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function enterStageBranch(state: PomState, config: PomConfig): Promise<string | undefined> {
|
||||
if (config.gitMode !== "branch-per-stage") return undefined;
|
||||
await ensureGit(state, config);
|
||||
const spec = stage(state.currentStage);
|
||||
const branch = `pom/stage-${spec.id}-${spec.slug}`;
|
||||
const branches = await git(state.projectRoot, ["branch", "--format=%(refname:short)"]);
|
||||
if (branches.split("\n").includes(branch)) await git(state.projectRoot, ["switch", branch]);
|
||||
else await git(state.projectRoot, ["switch", "-c", branch]);
|
||||
return branch;
|
||||
}
|
||||
|
||||
export async function checkpointStage(state: PomState, config: PomConfig, label: string): Promise<string | undefined> {
|
||||
if (config.gitMode === "off") return undefined;
|
||||
await ensureGit(state, config);
|
||||
await git(state.projectRoot, ["add", "-A"]);
|
||||
const status = await git(state.projectRoot, ["status", "--porcelain"]);
|
||||
if (!status) return undefined;
|
||||
await git(state.projectRoot, ["commit", "-m", label]);
|
||||
return git(state.projectRoot, ["rev-parse", "--short", "HEAD"]);
|
||||
}
|
||||
50
src/index.ts
Normal file
50
src/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import { join } from "node:path";
|
||||
import { createState, restoreLatest } from "./state";
|
||||
import { loadPomConfig } from "./config";
|
||||
import { DEFAULT_CONFIG, type PomConfig, type PomState } from "./domain";
|
||||
import { loadState, pathExists, saveState, syncKnowledgeBase } from "./persistence";
|
||||
import { registerPomCommand } from "./command";
|
||||
import { registerPomEvents } from "./events";
|
||||
import { registerPomTools } from "./tools";
|
||||
import { registerPomRenderers } from "./renderers";
|
||||
import type { PomRuntime } from "./runtime";
|
||||
|
||||
export default function pomExtension(pi: ExtensionAPI): void {
|
||||
pi.setLabel("POM · Produce Magnum Opus");
|
||||
let config: PomConfig = DEFAULT_CONFIG;
|
||||
let state: PomState = createState("Untitled Magnum Opus", process.cwd(), config);
|
||||
const runtime: PomRuntime = {
|
||||
telemetry: { activeAgents: 0, completedAgents: 0 },
|
||||
get: () => state,
|
||||
getConfig: () => config,
|
||||
refreshConfig: async (cwd) => {
|
||||
config = await loadPomConfig(cwd);
|
||||
return config;
|
||||
},
|
||||
set: async (next) => {
|
||||
state = { ...structuredClone(next), updatedAt: new Date().toISOString() };
|
||||
if (state.initialized) {
|
||||
pi.appendEntry("pom-state", state);
|
||||
await saveState(state);
|
||||
await syncKnowledgeBase(state);
|
||||
}
|
||||
},
|
||||
restore: async (ctx: ExtensionContext) => {
|
||||
const sessionState = restoreLatest(ctx.sessionManager.getBranch());
|
||||
if (sessionState) state = sessionState;
|
||||
else {
|
||||
const direct = join(ctx.cwd, "00_admin", "project_state.json");
|
||||
if (await pathExists(direct)) state = await loadState(direct);
|
||||
else state = createState("Untitled Magnum Opus", ctx.cwd, await loadPomConfig(ctx.cwd));
|
||||
}
|
||||
config = await loadPomConfig(state.initialized ? state.projectRoot : ctx.cwd);
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
registerPomTools(pi, runtime);
|
||||
registerPomRenderers(pi, runtime);
|
||||
registerPomEvents(pi, runtime);
|
||||
registerPomCommand(pi, runtime);
|
||||
}
|
||||
47
src/parser.ts
Normal file
47
src/parser.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface ParsedCommand {
|
||||
verb: string;
|
||||
args: string[];
|
||||
flags: Map<string, string | true>;
|
||||
}
|
||||
|
||||
export function parseCommand(input: string): ParsedCommand {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | undefined;
|
||||
let escaping = false;
|
||||
for (const character of input.trim()) {
|
||||
if (escaping) { current += character; escaping = false; continue; }
|
||||
if (character === "\\") { escaping = true; continue; }
|
||||
if (quote) {
|
||||
if (character === quote) quote = undefined;
|
||||
else current += character;
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') { quote = character; continue; }
|
||||
if (/\s/.test(character)) {
|
||||
if (current) { tokens.push(current); current = ""; }
|
||||
continue;
|
||||
}
|
||||
current += character;
|
||||
}
|
||||
if (escaping) current += "\\";
|
||||
if (quote) throw new Error("Unterminated quote in /pom command");
|
||||
if (current) tokens.push(current);
|
||||
|
||||
const verb = tokens.shift() ?? "dashboard";
|
||||
const args: string[] = [];
|
||||
const flags = new Map<string, string | true>();
|
||||
let flagsEnabled = true;
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (token === "--") { flagsEnabled = false; continue; }
|
||||
if (flagsEnabled && token.startsWith("--")) {
|
||||
const [name, inline] = token.slice(2).split("=", 2);
|
||||
const next = tokens[index + 1];
|
||||
if (inline !== undefined) flags.set(name, inline);
|
||||
else if (next && !next.startsWith("--")) { flags.set(name, next); index += 1; }
|
||||
else flags.set(name, true);
|
||||
} else args.push(token);
|
||||
}
|
||||
return { verb, args, flags };
|
||||
}
|
||||
66
src/paths.ts
Normal file
66
src/paths.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { lstat, realpath } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
function inside(root: string, target: string): boolean {
|
||||
const rel = relative(root, target);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
async function nearestExisting(path: string): Promise<string> {
|
||||
let cursor = path;
|
||||
for (;;) {
|
||||
try {
|
||||
await lstat(cursor);
|
||||
return cursor;
|
||||
} catch (error) {
|
||||
const parent = dirname(cursor);
|
||||
if (parent === cursor) throw error;
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function canonicalRoot(root: string): Promise<string> {
|
||||
return realpath(resolve(root));
|
||||
}
|
||||
|
||||
export async function secureExistingPath(root: string, candidate: string, options: { allowDirectory?: boolean } = {}): Promise<string> {
|
||||
const rootReal = await canonicalRoot(root);
|
||||
const lexical = resolve(rootReal, candidate);
|
||||
if (!inside(rootReal, lexical)) throw new Error(`Path escapes project root: ${candidate}`);
|
||||
|
||||
const stat = await lstat(lexical);
|
||||
if (stat.isSymbolicLink()) throw new Error(`Symbolic links are not canonical artifacts: ${candidate}`);
|
||||
if (!options.allowDirectory && !stat.isFile()) throw new Error(`Expected a regular file: ${candidate}`);
|
||||
if (options.allowDirectory && !stat.isFile() && !stat.isDirectory()) throw new Error(`Unsupported filesystem object: ${candidate}`);
|
||||
|
||||
const targetReal = await realpath(lexical);
|
||||
if (!inside(rootReal, targetReal)) throw new Error(`Canonical path escapes project root: ${candidate}`);
|
||||
return targetReal;
|
||||
}
|
||||
|
||||
export async function secureFuturePath(root: string, candidate: string): Promise<string> {
|
||||
const rootReal = await canonicalRoot(root);
|
||||
const lexical = resolve(rootReal, candidate);
|
||||
if (!inside(rootReal, lexical)) throw new Error(`Path escapes project root: ${candidate}`);
|
||||
const existing = await nearestExisting(dirname(lexical));
|
||||
const parentReal = await realpath(existing);
|
||||
if (!inside(rootReal, parentReal)) throw new Error(`Parent path escapes project root: ${candidate}`);
|
||||
return lexical;
|
||||
}
|
||||
|
||||
export function relativeProjectPath(root: string, absolutePath: string): string {
|
||||
const rel = relative(resolve(root), resolve(absolutePath)).replaceAll("\\", "/");
|
||||
if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Path is not a project child: ${absolutePath}`);
|
||||
return rel;
|
||||
}
|
||||
|
||||
export function assertSafeArchivePath(path: string): void {
|
||||
const normalized = path.replaceAll("\\", "/");
|
||||
if (!normalized || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) {
|
||||
throw new Error(`Unsafe archive member: ${path}`);
|
||||
}
|
||||
if (normalized.split("/").some((part) => part === ".." || part === "")) {
|
||||
throw new Error(`Unsafe archive member: ${path}`);
|
||||
}
|
||||
}
|
||||
388
src/persistence.ts
Normal file
388
src/persistence.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
import {
|
||||
POM_SCHEMA_VERSION,
|
||||
STAGES,
|
||||
stage,
|
||||
type ArtifactRecord,
|
||||
type HiveMission,
|
||||
type PomState,
|
||||
} from "./domain";
|
||||
import { relativeProjectPath, secureExistingPath, secureFuturePath } from "./paths";
|
||||
import { assertState, statePath } from "./state";
|
||||
|
||||
const writeQueues = new Map<string, Promise<void>>();
|
||||
|
||||
async function serialize<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = writeQueues.get(key) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolvePromise) => { release = resolvePromise; });
|
||||
const chain = previous.catch(() => undefined).then(() => current);
|
||||
writeQueues.set(key, chain);
|
||||
await previous.catch(() => undefined);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
if (writeQueues.get(key) === chain) writeQueues.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function atomicWriteUnlocked(absolute: string, content: string | Uint8Array): Promise<void> {
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
const temporary = join(dirname(absolute), `.${basename(absolute)}.${process.pid}.${randomUUID()}.tmp`);
|
||||
try {
|
||||
await writeFile(temporary, content);
|
||||
await rename(temporary, absolute);
|
||||
} finally {
|
||||
await rm(temporary, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function atomicWrite(path: string, content: string | Uint8Array): Promise<void> {
|
||||
const absolute = resolve(path);
|
||||
await serialize(absolute, () => atomicWriteUnlocked(absolute, content));
|
||||
}
|
||||
|
||||
export async function readJson<T>(path: string): Promise<T> {
|
||||
const text = await readFile(path, "utf8");
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON in ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(path);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
await finished(stream);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export async function saveState(state: PomState): Promise<void> {
|
||||
assertState(state);
|
||||
await atomicWrite(statePath(state), `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function loadState(path: string): Promise<PomState> {
|
||||
const state = await readJson<unknown>(path);
|
||||
assertState(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export function manifestPath(state: PomState): string {
|
||||
return join(state.projectRoot, "00_admin", "artifact_manifest.json");
|
||||
}
|
||||
|
||||
export async function loadManifest(state: PomState): Promise<ArtifactRecord[]> {
|
||||
if (!(await pathExists(manifestPath(state)))) return [];
|
||||
const value = await readJson<unknown>(manifestPath(state));
|
||||
if (!Array.isArray(value)) throw new Error("Artifact manifest must be an array");
|
||||
const paths = new Set<string>();
|
||||
return value.map((item, index) => {
|
||||
if (!item || typeof item !== "object") throw new Error(`Manifest record ${index} is invalid`);
|
||||
const record = item as Partial<ArtifactRecord>;
|
||||
if (typeof record.path !== "string" || !record.path || record.path.startsWith("/") || record.path.includes("..")) {
|
||||
throw new Error(`Manifest record ${index} has an unsafe path`);
|
||||
}
|
||||
if (paths.has(record.path)) throw new Error(`Duplicate manifest path: ${record.path}`);
|
||||
paths.add(record.path);
|
||||
if (typeof record.category !== "string" || !record.category.trim()) throw new Error(`Invalid category for ${record.path}`);
|
||||
if (!Number.isInteger(record.stage) || Number(record.stage) < 0 || Number(record.stage) > 8) throw new Error(`Invalid stage for ${record.path}`);
|
||||
if (typeof record.version !== "string" || !/^\d+\.\d+\.\d+$/.test(record.version)) throw new Error(`Invalid version for ${record.path}`);
|
||||
if (!(["draft", "reviewed", "approved", "superseded"] as const).includes(record.status as ArtifactRecord["status"])) throw new Error(`Invalid status for ${record.path}`);
|
||||
if (typeof record.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.sha256)) throw new Error(`Invalid SHA-256 for ${record.path}`);
|
||||
if (typeof record.bytes !== "number" || !Number.isInteger(record.bytes) || record.bytes < 1) throw new Error(`Invalid byte count for ${record.path}`);
|
||||
if (typeof record.updatedAt !== "string" || Number.isNaN(Date.parse(record.updatedAt))) throw new Error(`Invalid updatedAt for ${record.path}`);
|
||||
if (!(["original", "licensed", "public-domain", "reference-only", "unknown"] as const).includes(record.rightsStatus as ArtifactRecord["rightsStatus"])) throw new Error(`Invalid rights status for ${record.path}`);
|
||||
if (record.source !== undefined && typeof record.source !== "string") throw new Error(`Invalid source for ${record.path}`);
|
||||
if (record.notes !== undefined && typeof record.notes !== "string") throw new Error(`Invalid notes for ${record.path}`);
|
||||
return record as ArtifactRecord;
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveManifest(state: PomState, records: ArtifactRecord[]): Promise<void> {
|
||||
const sorted = [...records].sort((left, right) => left.path.localeCompare(right.path));
|
||||
await atomicWrite(manifestPath(state), `${JSON.stringify(sorted, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function registerArtifact(
|
||||
state: PomState,
|
||||
candidate: string,
|
||||
metadata: {
|
||||
category: string;
|
||||
status: ArtifactRecord["status"];
|
||||
rightsStatus: ArtifactRecord["rightsStatus"];
|
||||
source?: string;
|
||||
notes?: string;
|
||||
stage?: number;
|
||||
version?: string;
|
||||
},
|
||||
): Promise<ArtifactRecord> {
|
||||
const absolute = await secureExistingPath(state.projectRoot, candidate);
|
||||
const fileStat = await stat(absolute);
|
||||
if (fileStat.size < 1) throw new Error("Empty files cannot be canonical artifacts");
|
||||
const projectPath = relativeProjectPath(state.projectRoot, absolute);
|
||||
const current = await loadManifest(state);
|
||||
const existing = current.find((item) => item.path === projectPath);
|
||||
const record: ArtifactRecord = {
|
||||
path: projectPath,
|
||||
category: metadata.category || existing?.category || "other",
|
||||
stage: metadata.stage ?? existing?.stage ?? state.currentStage,
|
||||
version: metadata.version ?? existing?.version ?? state.artifactVersion,
|
||||
status: metadata.status ?? existing?.status ?? "draft",
|
||||
bytes: fileStat.size,
|
||||
sha256: await sha256File(absolute),
|
||||
updatedAt: new Date().toISOString(),
|
||||
rightsStatus: metadata.rightsStatus ?? existing?.rightsStatus ?? "unknown",
|
||||
source: metadata.source ?? existing?.source,
|
||||
notes: metadata.notes ?? existing?.notes,
|
||||
};
|
||||
await saveManifest(state, [...current.filter((item) => item.path !== projectPath), record]);
|
||||
await appendJsonl(join(state.projectRoot, "00_admin", "artifact_events.jsonl"), {
|
||||
type: "artifact.registered",
|
||||
projectId: state.projectId,
|
||||
record,
|
||||
at: record.updatedAt,
|
||||
});
|
||||
await syncKnowledgeBase(state);
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function appendJsonl(path: string, value: unknown): Promise<void> {
|
||||
const absolute = resolve(path);
|
||||
await serialize(absolute, async () => {
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
const previous = (await pathExists(absolute)) ? await readFile(absolute, "utf8") : "";
|
||||
await atomicWriteUnlocked(absolute, `${previous}${JSON.stringify(value)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function appendMarkdownLog(root: string, fileName: string, title: string, body: string): Promise<string> {
|
||||
const path = await secureFuturePath(root, join("00_admin", fileName));
|
||||
const stamp = new Date().toISOString();
|
||||
const previous = (await pathExists(path)) ? await readFile(path, "utf8") : `---\ntags: [pom, ledger]\n---\n\n# ${title}\n`;
|
||||
await atomicWrite(path, `${previous.trimEnd()}\n\n## ${stamp} · ${title}\n\n${body.trim()}\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function recordHiveEvent(state: PomState, event: Record<string, unknown>): Promise<void> {
|
||||
await appendJsonl(join(state.projectRoot, "00_admin", "hive_ledger.jsonl"), {
|
||||
projectId: state.projectId,
|
||||
stage: state.currentStage,
|
||||
at: new Date().toISOString(),
|
||||
...event,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordPromptUse(state: PomState, entry: Record<string, unknown>): Promise<void> {
|
||||
await appendJsonl(join(state.projectRoot, "00_admin", "prompt_ledger.jsonl"), {
|
||||
projectId: state.projectId,
|
||||
stage: state.currentStage,
|
||||
at: new Date().toISOString(),
|
||||
...entry,
|
||||
});
|
||||
}
|
||||
|
||||
function frontmatter(title: string, type: string, tags: string[]): string {
|
||||
return [
|
||||
"---",
|
||||
`title: ${JSON.stringify(title)}`,
|
||||
`type: ${type}`,
|
||||
`tags: [${tags.join(", ")}]`,
|
||||
"publish: true",
|
||||
"---",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function ensureStarter(path: string, content: string): Promise<void> {
|
||||
if (!(await pathExists(path))) await atomicWrite(path, content);
|
||||
}
|
||||
|
||||
export async function bootstrapProject(state: PomState): Promise<void> {
|
||||
const root = state.projectRoot;
|
||||
if (await pathExists(root)) {
|
||||
const entries = await readdir(root);
|
||||
const marker = join(root, "00_admin", "project_state.json");
|
||||
if (entries.length > 0 && !(await pathExists(marker))) {
|
||||
throw new Error(`Refusing to initialize inside a non-empty unrecognized directory: ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
const directories = [
|
||||
"00_admin",
|
||||
"01_planning",
|
||||
"02_story_bible",
|
||||
"03_manuscript/chapters",
|
||||
"04_images/references",
|
||||
"04_images/generated",
|
||||
"05_research",
|
||||
"06_ledgers",
|
||||
"07_exports",
|
||||
".omp/prompts",
|
||||
"content",
|
||||
];
|
||||
for (const directory of directories) await mkdir(join(root, directory), { recursive: true });
|
||||
|
||||
await ensureStarter(join(root, ".gitignore"), ["node_modules/", ".DS_Store", "07_exports/*.tmp", "*.swp", ""].join("\n"));
|
||||
await ensureStarter(join(root, ".omp", "pom.json"), `${JSON.stringify({ dashboardWidget: true, hudPlacement: "aboveEditor", showThinkingLane: true }, null, 2)}\n`);
|
||||
await ensureStarter(join(root, ".omp", "config.yml"), [
|
||||
"theme:",
|
||||
" dark: pom-nocturne",
|
||||
" light: pom-parchment",
|
||||
"symbolPreset: unicode",
|
||||
"statusLine:",
|
||||
" preset: full",
|
||||
" separator: powerline-thin",
|
||||
" sessionAccent: true",
|
||||
" showHookStatus: true",
|
||||
"terminal:",
|
||||
" showImages: true",
|
||||
"images:",
|
||||
" autoResize: true",
|
||||
" blockImages: false",
|
||||
"tools:",
|
||||
" intentTracing: true",
|
||||
" artifactSpillThreshold: 50",
|
||||
" artifactHeadBytes: 20",
|
||||
" artifactTailBytes: 20",
|
||||
"task:",
|
||||
" eager: preferred",
|
||||
" batch: true",
|
||||
" maxConcurrency: 8",
|
||||
" maxRecursionDepth: 2",
|
||||
" showResolvedModelBadge: true",
|
||||
"async:",
|
||||
" enabled: true",
|
||||
"compaction:",
|
||||
" strategy: snapcompact",
|
||||
" midTurnEnabled: true",
|
||||
" autoContinue: true",
|
||||
"memory:",
|
||||
" backend: local",
|
||||
"steeringMode: one-at-a-time",
|
||||
"followUpMode: one-at-a-time",
|
||||
"interruptMode: immediate",
|
||||
"defaultThinkingLevel: high",
|
||||
"hideThinkingBlock: false",
|
||||
"",
|
||||
].join("\n"));
|
||||
await ensureStarter(join(root, "AGENTS.md"), `${frontmatter("POM Agent Contract", "agent-contract", ["pom", "agents"])}\n\n# POM Agent Contract\n\nCanonical files outrank chat. Use native \`task\`, \`irc\`, \`todo\`, and POM evidence tools. Never claim completion without deterministic validation.\n`);
|
||||
await ensureStarter(join(root, "POM.md"), `${frontmatter(state.projectTitle, "project", ["pom", "project"])}\n\n# ${state.projectTitle}\n\n> [!info] Production root\n> This repository is simultaneously a POM workspace, Obsidian vault, Quartz-compatible knowledge base, and Git project.\n\n- [[content/index|Open production dashboard]]\n- [[01_planning/00_project_brief|Project brief]]\n- [[02_story_bible/00_story_bible_index|Story bible]]\n- [[03_manuscript/00_master_outline|Master outline]]\n`);
|
||||
await ensureStarter(join(root, "01_planning", "00_project_brief.md"), `${frontmatter("Project Brief", "planning", ["pom", "stage-0"])}\n\n# Project Brief\n\n## Vision\n\n## Audience\n\n## Constraints\n\n## Success Criteria\n\n## Open Questions\n`);
|
||||
await ensureStarter(join(root, "content", "index.md"), `${frontmatter(state.projectTitle, "dashboard", ["pom", "dashboard"])}\n\n# ${state.projectTitle}\n\n> [!tip] Command center\n> Run \`/pom\` or press the POM shortcut to open the terminal command center.\n\n## Production Map\n\n- [[01-planning|Planning]]\n- [[02-story-bible|Story Bible]]\n- [[03-manuscript|Manuscript]]\n- [[04-visuals|Visuals]]\n- [[05-research|Research]]\n- [[06-ledgers|Ledgers]]\n- [[07-delivery|Delivery]]\n- [[artifact-index|Artifact Index]]\n`);
|
||||
|
||||
const maps = [
|
||||
["01-planning.md", "Planning", "../01_planning"],
|
||||
["02-story-bible.md", "Story Bible", "../02_story_bible"],
|
||||
["03-manuscript.md", "Manuscript", "../03_manuscript"],
|
||||
["04-visuals.md", "Visuals", "../04_images"],
|
||||
["05-research.md", "Research", "../05_research"],
|
||||
["06-ledgers.md", "Ledgers", "../06_ledgers"],
|
||||
["07-delivery.md", "Delivery", "../07_exports"],
|
||||
] as const;
|
||||
for (const [file, title, target] of maps) {
|
||||
await ensureStarter(join(root, "content", file), `${frontmatter(title, "map-of-content", ["pom", "moc"])}\n\n# ${title}\n\nCanonical directory: \`${target}\`\n\n## Index\n\n_Updated automatically by POM._\n`);
|
||||
}
|
||||
|
||||
await ensureStarter(manifestPath(state), "[]\n");
|
||||
await ensureStarter(join(root, "00_admin", "artifact_events.jsonl"), "");
|
||||
await ensureStarter(join(root, "00_admin", "hive_ledger.jsonl"), "");
|
||||
await ensureStarter(join(root, "00_admin", "prompt_ledger.jsonl"), "");
|
||||
await saveState(state);
|
||||
await syncKnowledgeBase(state);
|
||||
}
|
||||
|
||||
export async function syncKnowledgeBase(state: PomState): Promise<void> {
|
||||
const records = await loadManifest(state).catch(() => []);
|
||||
const lines = [
|
||||
frontmatter("Artifact Index", "generated-index", ["pom", "artifacts"]),
|
||||
"",
|
||||
"# Artifact Index",
|
||||
"",
|
||||
"> [!warning] Generated file",
|
||||
"> Update artifact metadata through POM; this page is regenerated.",
|
||||
"",
|
||||
`Updated: ${new Date().toISOString()}`,
|
||||
"",
|
||||
];
|
||||
if (!records.length) lines.push("_No canonical artifacts registered yet._");
|
||||
const grouped = new Map<string, ArtifactRecord[]>();
|
||||
for (const record of records) grouped.set(record.category, [...(grouped.get(record.category) ?? []), record]);
|
||||
for (const [category, items] of grouped) {
|
||||
lines.push("", `## ${category}`);
|
||||
for (const item of items) {
|
||||
lines.push(`- [[../${item.path}|${item.path}]] · ${item.status} · ${item.rightsStatus} · ${item.bytes} bytes · \`${item.sha256.slice(0, 12)}\``);
|
||||
}
|
||||
}
|
||||
await atomicWrite(join(state.projectRoot, "content", "artifact-index.md"), `${lines.join("\n")}\n`);
|
||||
|
||||
const board = [
|
||||
frontmatter("Production Board", "generated-board", ["pom", "dashboard"]),
|
||||
"",
|
||||
"# Production Board",
|
||||
"",
|
||||
`Project: **${state.projectTitle}**`,
|
||||
"",
|
||||
"## Stages",
|
||||
"",
|
||||
"| Stage | Status | Attempts | Evidence | Blockers |",
|
||||
"|---:|---|---:|---:|---:|",
|
||||
...STAGES.map((item) => {
|
||||
const run = state.stageRuns[String(item.id)];
|
||||
return `| ${item.id} ${item.icon} ${item.name} | ${run.status} | ${run.attempts} | ${run.evidence.length}/${item.acceptance.length} | ${run.blockers.filter((blocker) => !blocker.resolvedAt).length} |`;
|
||||
}),
|
||||
"",
|
||||
"## Active Stage",
|
||||
"",
|
||||
`[[../${stage(state.currentStage).artifacts[0]?.path ?? "POM.md"}|${stage(state.currentStage).name}]]`,
|
||||
];
|
||||
await atomicWrite(join(state.projectRoot, "content", "production-board.md"), `${board.join("\n")}\n`);
|
||||
}
|
||||
|
||||
export async function writeHiveMission(state: PomState, mission: HiveMission): Promise<string> {
|
||||
const path = join(state.projectRoot, "06_ledgers", `hive-${mission.id}.md`);
|
||||
const text = [
|
||||
frontmatter(mission.title, "hive-mission", ["pom", "hive", `stage-${mission.stage}`]),
|
||||
"",
|
||||
`# ${mission.title}`,
|
||||
"",
|
||||
`Status: **${mission.status}**`,
|
||||
"",
|
||||
"## Tasks",
|
||||
"",
|
||||
...mission.tasks.flatMap((task) => [
|
||||
`### ${task.name} · ${task.agent}`,
|
||||
"",
|
||||
task.objective,
|
||||
"",
|
||||
`Owns: ${task.owns.map((item) => `\`${item}\``).join(", ") || "integration only"}`,
|
||||
"",
|
||||
...task.acceptance.map((item) => `- [ ] ${item}`),
|
||||
"",
|
||||
]),
|
||||
"## Synthesis",
|
||||
"",
|
||||
mission.synthesis ?? "_Pending._",
|
||||
].join("\n");
|
||||
await atomicWrite(path, `${text}\n`);
|
||||
return relative(state.projectRoot, path).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
export function schemaStamp(): string {
|
||||
return POM_SCHEMA_VERSION;
|
||||
}
|
||||
36
src/policy.ts
Normal file
36
src/policy.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function destructiveCommand(command: string): boolean {
|
||||
const normalized = command.replace(/\s+/g, " ").trim();
|
||||
return [
|
||||
/\brm\b[^\n;&|]*(?:-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|--recursive[^\n;&|]*--force|--force[^\n;&|]*--recursive)/i,
|
||||
/\bgit\s+clean\b[^\n;&|]*-[a-z]*f/i,
|
||||
/\bgit\s+reset\s+--hard\b/i,
|
||||
/\bgit\s+(?:checkout|restore)\b[^\n;&|]*(?:\s--\s|\s\.\s*$)/i,
|
||||
/\bfind\b[^\n;&|]*\s-delete\b/i,
|
||||
/\bRemove-Item\b[^\n;&|]*(?:-Recurse[^\n;&|]*-Force|-Force[^\n;&|]*-Recurse)/i,
|
||||
/\bdel\b[^\n;&|]*\/s[^\n;&|]*\/q/i,
|
||||
/\brmdir\b[^\n;&|]*\/s[^\n;&|]*\/q/i,
|
||||
].some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function inside(child: string, root: string): boolean {
|
||||
const normalizedChild = resolve(child).replaceAll("\\", "/");
|
||||
const normalizedRoot = resolve(root).replaceAll("\\", "/");
|
||||
return normalizedChild === normalizedRoot || normalizedChild.startsWith(`${normalizedRoot}/`);
|
||||
}
|
||||
|
||||
function likelyTargetsProject(command: string, projectRoot: string, projectSlug: string): boolean {
|
||||
const normalized = command.replaceAll("\\", "/");
|
||||
const root = resolve(projectRoot).replaceAll("\\", "/");
|
||||
return normalized.includes(root)
|
||||
|| normalized.includes(projectSlug)
|
||||
|| /(?:^|\s)(?:\.|\.\/|\*|\.\*|\$PWD|%CD%|--)(?:\s|$|["'])/i.test(normalized)
|
||||
|| /\b(?:git\s+(?:clean|reset|restore|checkout)|find\s+\.)\b/i.test(normalized)
|
||||
|| /\bcd\s+[^;&|]+(?:&&|;).*\b(?:rm|Remove-Item|del|rmdir)\b/i.test(normalized);
|
||||
}
|
||||
|
||||
export function shouldBlockProjectCommand(command: string, projectRoot: string, projectSlug: string, executionCwd: string): boolean {
|
||||
if (!destructiveCommand(command)) return false;
|
||||
return inside(executionCwd, projectRoot) || likelyTargetsProject(command, projectRoot, projectSlug);
|
||||
}
|
||||
118
src/prompts.ts
Normal file
118
src/prompts.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { stage, type PomState } from "./domain";
|
||||
import { pathExists, recordPromptUse } from "./persistence";
|
||||
|
||||
export interface PromptFragment {
|
||||
id: string;
|
||||
source: "bundled" | "project";
|
||||
path: string;
|
||||
content: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface PromptStack {
|
||||
ids: string[];
|
||||
fragments: PromptFragment[];
|
||||
rendered: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const bundledRoot = fileURLToPath(new URL("../prompts", import.meta.url));
|
||||
|
||||
function hash(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
function substitute(text: string, variables: Record<string, string>): string {
|
||||
return text.replace(/\{\{\s*([A-Z0-9_]+)\s*\}\}/g, (_match, key: string) => variables[key] ?? `{{${key}}}`);
|
||||
}
|
||||
|
||||
async function loadDirectory(root: string, source: PromptFragment["source"]): Promise<PromptFragment[]> {
|
||||
if (!(await pathExists(root))) return [];
|
||||
const files = (await readdir(root)).filter((file) => file.endsWith(".md")).sort();
|
||||
return Promise.all(files.map(async (file) => {
|
||||
const path = join(root, file);
|
||||
const content = await readFile(path, "utf8");
|
||||
return { id: basename(file, ".md"), source, path, content, sha256: hash(content) };
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listPrompts(state?: PomState): Promise<PromptFragment[]> {
|
||||
const bundled = await loadDirectory(bundledRoot, "bundled");
|
||||
if (!state) return bundled;
|
||||
const project = await loadDirectory(join(state.projectRoot, ".omp", "prompts"), "project");
|
||||
const byId = new Map(bundled.map((fragment) => [fragment.id, fragment]));
|
||||
for (const fragment of project) byId.set(fragment.id, fragment);
|
||||
return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
export async function getPrompt(state: PomState | undefined, id: string): Promise<PromptFragment> {
|
||||
const prompt = (await listPrompts(state)).find((fragment) => fragment.id === id);
|
||||
if (!prompt) throw new Error(`Unknown POM prompt: ${id}`);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function immutableLaw(state: PomState): string {
|
||||
const spec = stage(state.currentStage);
|
||||
return [
|
||||
"# POM Production Law",
|
||||
"Canonical project files outrank chat, memory, and agent claims.",
|
||||
"OMP owns execution: use native task, IRC, todo, memory, checkpoints, tools, and approvals.",
|
||||
"POM owns contracts, provenance, canon, evidence, integration, validation, and delivery truth.",
|
||||
"Never claim a file, metric, test, citation, image, archive, or gate result that was not actually produced and checked.",
|
||||
`Active project root: ${state.projectRoot}`,
|
||||
`Active stage: ${spec.id}/8 · ${spec.name}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function composePromptStack(state: PomState, ids: string[], extraVariables: Record<string, string> = {}): Promise<PromptStack> {
|
||||
const spec = stage(state.currentStage);
|
||||
const variables = {
|
||||
PROJECT_TITLE: state.projectTitle,
|
||||
PROJECT_ROOT: state.projectRoot,
|
||||
PROJECT_ID: state.projectId,
|
||||
STAGE_ID: String(spec.id),
|
||||
STAGE_NAME: spec.name,
|
||||
STAGE_SLUG: spec.slug,
|
||||
MODE: state.mode,
|
||||
...extraVariables,
|
||||
};
|
||||
const personalityId = `personality-${state.mode}`;
|
||||
const resolvedIds = [...new Set([personalityId, ...ids])];
|
||||
const fragments = await Promise.all(resolvedIds.map((id) => getPrompt(state, id)));
|
||||
const renderedFragments = fragments.map((fragment) => `\n\n<!-- prompt:${fragment.id} source:${fragment.source} sha256:${fragment.sha256} -->\n${substitute(fragment.content, variables).trim()}`);
|
||||
const rendered = `${immutableLaw(state)}${renderedFragments.join("")}\n`;
|
||||
const stack: PromptStack = { ids: resolvedIds, fragments, rendered, sha256: hash(rendered) };
|
||||
await recordPromptUse(state, {
|
||||
type: "prompt.stack",
|
||||
ids: resolvedIds,
|
||||
stackSha256: stack.sha256,
|
||||
fragments: fragments.map(({ id, source, sha256 }) => ({ id, source, sha256 })),
|
||||
});
|
||||
return stack;
|
||||
}
|
||||
|
||||
export async function renderStagePrompt(state: PomState, allStages = false): Promise<string> {
|
||||
const spec = stage(state.currentStage);
|
||||
const stack = await composePromptStack(state, [allStages ? "full-autopilot" : "stage-run"], {
|
||||
STAGE_CONTRACT: JSON.stringify(spec, null, 2),
|
||||
});
|
||||
return [
|
||||
stack.rendered,
|
||||
"# Executable Stage Contract",
|
||||
JSON.stringify(spec, null, 2),
|
||||
"# Required Native OMP Loop",
|
||||
"1. Initialize the stage todo phases with the native todo tool.",
|
||||
"2. Read only the canonical dependencies required for this stage.",
|
||||
"3. Call pom_hive with op=plan, then invoke native task exactly once with the returned batch.",
|
||||
"4. Use native IRC for short CLAIM, QUERY, ANSWER, ALERT, HANDOFF, and DONE signals.",
|
||||
"5. Integrate specialist findings into canonical files; specialists do not decide final canon independently.",
|
||||
"6. Register every canonical artifact with pom_artifact.",
|
||||
"7. Record one pom_stage evidence entry per acceptance criterion.",
|
||||
"8. Run pom_validate scope=stage. Repair failures, then call pom_stage pass; otherwise block with exact evidence.",
|
||||
allStages ? "9. After PASS, continue to the next stage until stage 8 completes or a real blocker stops production." : "",
|
||||
].filter(Boolean).join("\n\n");
|
||||
}
|
||||
77
src/renderers.ts
Normal file
77
src/renderers.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Text } from "@oh-my-pi/pi-tui";
|
||||
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
||||
import type { PomView } from "./domain";
|
||||
import type { PomRuntime } from "./runtime";
|
||||
import { stage } from "./domain";
|
||||
|
||||
function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
try { return JSON.stringify(content, null, 2); } catch { return String(content); }
|
||||
}
|
||||
|
||||
function canExpand(view: PomView, expanded: boolean): boolean {
|
||||
return expanded && (view === "cinematic" || view === "rich");
|
||||
}
|
||||
|
||||
function maxLines(view: PomView): number {
|
||||
return view === "cinematic" ? 10 : view === "rich" ? 5 : 0;
|
||||
}
|
||||
|
||||
export function registerPomRenderers(pi: ExtensionAPI, runtime: PomRuntime): void {
|
||||
pi.registerMessageRenderer("pom:event", (message, options, theme) => {
|
||||
const view = runtime.get().view;
|
||||
const details = message.details as { title?: string; level?: string; lines?: string[] } | undefined;
|
||||
const mark = details?.level === "error" ? theme.fg("error", "×") : details?.level === "warning" ? theme.fg("warning", "!") : theme.fg("accent", "◆");
|
||||
const lines = canExpand(view, options.expanded) ? (details?.lines ?? []).slice(0, maxLines(view)) : [];
|
||||
const body = lines.length ? `\n${lines.map((line) => theme.fg("dim", line)).join("\n")}` : "";
|
||||
return new Text(`${mark} ${theme.bold(details?.title ?? textOf(message.content))}${body}`, view === "minimal" ? 0 : 1, 0);
|
||||
});
|
||||
|
||||
pi.registerMessageRenderer("pom:validation", (message, options, theme) => {
|
||||
const view = runtime.get().view;
|
||||
const report = message.details as { passed?: boolean; scope?: string; checks?: Array<{ status: string; message: string }> } | undefined;
|
||||
const failures = report?.checks?.filter((item) => item.status === "fail") ?? [];
|
||||
const warnings = report?.checks?.filter((item) => item.status === "warn") ?? [];
|
||||
const passed = Boolean(report?.passed);
|
||||
const mark = passed ? theme.fg("success", "✓") : theme.fg("error", "×");
|
||||
const limit = maxLines(view);
|
||||
const lines = canExpand(view, options.expanded)
|
||||
? [
|
||||
...failures.slice(0, Math.max(1, limit - 2)).map((item) => `${theme.fg("error", "×")} ${item.message}`),
|
||||
...warnings.slice(0, Math.min(3, limit)).map((item) => `${theme.fg("warning", "!")} ${item.message}`),
|
||||
].slice(0, limit)
|
||||
: [];
|
||||
const summary = `${mark} ${theme.bold(`POM validation · ${report?.scope ?? "unknown"}`)} · ${failures.length} failures · ${warnings.length} warnings`;
|
||||
return new Text(`${summary}${lines.length ? `\n${lines.join("\n")}` : ""}`, view === "minimal" ? 0 : 1, 0);
|
||||
});
|
||||
|
||||
pi.registerMessageRenderer("pom:hive", (message, options, theme) => {
|
||||
const view = runtime.get().view;
|
||||
const mission = message.details as { id?: string; status?: string; tasks?: Array<{ name: string; agent: string }> } | undefined;
|
||||
const roster = canExpand(view, options.expanded)
|
||||
? `\n${(mission?.tasks ?? []).slice(0, maxLines(view)).map((task) => ` ${theme.fg("accent", "⬡")} ${task.name} · ${theme.fg("dim", task.agent)}`).join("\n")}`
|
||||
: "";
|
||||
return new Text(`${theme.fg("accent", "⬡")} ${theme.bold(`Hive ${mission?.id ?? "mission"}`)} · ${mission?.status ?? "planned"} · ${mission?.tasks?.length ?? 0} agents${roster}`, view === "minimal" ? 0 : 1, 0);
|
||||
});
|
||||
|
||||
pi.registerMessageRenderer("pom:milestone", (message, options, theme) => {
|
||||
const view = runtime.get().view;
|
||||
const details = message.details as { stage?: number; status?: string; path?: string; commit?: string } | undefined;
|
||||
const stageId = details?.stage ?? runtime.get().currentStage;
|
||||
const spec = stage(stageId);
|
||||
const extra = canExpand(view, options.expanded)
|
||||
? [details?.path, details?.commit ? `commit ${details.commit}` : undefined].filter(Boolean).map((line) => theme.fg("dim", String(line))).join("\n")
|
||||
: "";
|
||||
return new Text(`${theme.fg("success", "◆")} ${theme.bold(`${spec.icon} Stage ${stageId} ${details?.status ?? "milestone"}`)}${extra ? `\n${extra}` : ""}`, view === "minimal" ? 0 : 1, 0);
|
||||
});
|
||||
|
||||
pi.registerAssistantThinkingRenderer((context, theme) => {
|
||||
const state = runtime.get();
|
||||
const config = runtime.getConfig();
|
||||
if (!state.initialized || !config.showThinkingLane || state.view === "minimal" || state.view === "compact") return undefined;
|
||||
const spec = stage(state.currentStage);
|
||||
const lane = runtime.telemetry.activeTool ? `tool:${runtime.telemetry.activeTool}` : state.hive.activeMissionId ? "hive-synthesis" : "synthesis";
|
||||
const suffix = state.view === "cinematic" ? ` · ${context.text.length} chars` : "";
|
||||
return new Text(theme.fg("dim", `╰─ POM lane · ${spec.slug} · ${lane}${suffix}`), 1, 0);
|
||||
});
|
||||
}
|
||||
16
src/router.ts
Normal file
16
src/router.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import { stage, type PomState } from "./domain";
|
||||
import { applyToolProfile } from "./tool-profiles";
|
||||
|
||||
export async function routeStage(pi: ExtensionAPI, ctx: ExtensionContext, state: PomState): Promise<{ tools: string[]; model?: string }> {
|
||||
const spec = stage(state.currentStage);
|
||||
const tools = state.autoToolProfiles ? await applyToolProfile(pi, spec.toolProfile) : pi.getActiveTools();
|
||||
let modelName: string | undefined;
|
||||
if (state.autoModelRouting) {
|
||||
const model = ctx.models.resolve(spec.modelRole);
|
||||
if (model && await pi.setModel(model)) modelName = `${model.provider}/${model.id}`;
|
||||
}
|
||||
const thinking = spec.id === 7 ? "high" : "xhigh";
|
||||
try { pi.setThinkingLevel(thinking as Parameters<ExtensionAPI["setThinkingLevel"]>[0]); } catch { /* model may not support this level */ }
|
||||
return { tools, model: modelName };
|
||||
}
|
||||
22
src/runtime.ts
Normal file
22
src/runtime.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import type { PomConfig, PomState } from "./domain";
|
||||
|
||||
export interface PomTelemetry {
|
||||
activeTool?: string;
|
||||
activeIntent?: string;
|
||||
toolStartedAt?: number;
|
||||
lastToolResult?: "pass" | "fail";
|
||||
lastIrcSignal?: string;
|
||||
activeAgents: number;
|
||||
completedAgents: number;
|
||||
validationFailures?: number;
|
||||
}
|
||||
|
||||
export interface PomRuntime {
|
||||
get(): PomState;
|
||||
set(state: PomState): Promise<void>;
|
||||
restore(ctx: ExtensionContext): Promise<PomState>;
|
||||
getConfig(): PomConfig;
|
||||
refreshConfig(cwd: string): Promise<PomConfig>;
|
||||
telemetry: PomTelemetry;
|
||||
}
|
||||
217
src/state.ts
Normal file
217
src/state.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join, resolve } from "node:path";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
POM_SCHEMA_VERSION,
|
||||
POM_VERSION,
|
||||
STAGES,
|
||||
freshStageRuns,
|
||||
stage,
|
||||
type EvidenceRecord,
|
||||
type PomConfig,
|
||||
type PomState,
|
||||
type StageBlocker,
|
||||
} from "./domain";
|
||||
|
||||
|
||||
export function bumpPatchVersion(version: string): string {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
||||
if (!match) throw new Error(`Invalid semantic version: ${version}`);
|
||||
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
|
||||
}
|
||||
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\x00-\x7F]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "magnum-opus";
|
||||
}
|
||||
|
||||
export function createState(title: string, cwd: string, config: PomConfig = DEFAULT_CONFIG, initialized = false): PomState {
|
||||
const projectSlug = slugify(title);
|
||||
const projectRoot = config.projectRootMode === "cwd" ? resolve(cwd) : resolve(cwd, projectSlug);
|
||||
return {
|
||||
schemaVersion: POM_SCHEMA_VERSION,
|
||||
pomVersion: POM_VERSION,
|
||||
projectId: randomUUID(),
|
||||
projectTitle: title.trim() || "Untitled Magnum Opus",
|
||||
projectSlug,
|
||||
projectRoot,
|
||||
initialized,
|
||||
currentStage: 0,
|
||||
stageRuns: freshStageRuns(),
|
||||
mode: config.defaultMode,
|
||||
view: config.defaultView,
|
||||
motion: config.defaultMotion,
|
||||
toolProfile: config.defaultToolProfile,
|
||||
autoToolProfiles: config.autoToolProfiles,
|
||||
autoModelRouting: config.autoModelRouting,
|
||||
completedStages: [],
|
||||
decisions: [],
|
||||
assumptions: [],
|
||||
warnings: [],
|
||||
hive: { missions: {}, signalsLogged: 0 },
|
||||
artifactVersion: "1.0.0",
|
||||
canonVersion: "1.0.0",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertState(value: unknown): asserts value is PomState {
|
||||
if (!value || typeof value !== "object") throw new Error("POM state must be an object");
|
||||
const state = value as Partial<PomState>;
|
||||
if (state.schemaVersion !== POM_SCHEMA_VERSION) throw new Error(`Unsupported POM state schema: ${String(state.schemaVersion)}`);
|
||||
if (typeof state.projectId !== "string" || typeof state.projectRoot !== "string" || typeof state.projectTitle !== "string") {
|
||||
throw new Error("POM state identity is invalid");
|
||||
}
|
||||
if (!Number.isInteger(state.currentStage) || Number(state.currentStage) < 0 || Number(state.currentStage) > 8) {
|
||||
throw new Error("POM current stage is invalid");
|
||||
}
|
||||
if (!state.stageRuns || typeof state.stageRuns !== "object") throw new Error("POM stage state is missing");
|
||||
for (const spec of STAGES) {
|
||||
const run = state.stageRuns[String(spec.id)];
|
||||
if (!run || !["queued", "active", "blocked", "complete"].includes(run.status)) {
|
||||
throw new Error(`POM stage ${spec.id} state is invalid`);
|
||||
}
|
||||
if (!Array.isArray(run.blockers) || !Array.isArray(run.evidence)) throw new Error(`POM stage ${spec.id} records are invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreLatest(entries: readonly unknown[]): PomState | undefined {
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index] as { type?: string; customType?: string; data?: unknown };
|
||||
if (entry?.type === "custom" && entry.customType === "pom-state") {
|
||||
assertState(entry.data);
|
||||
return structuredClone(entry.data);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function incompletePrerequisites(state: PomState, target: number): number[] {
|
||||
return STAGES.filter((item) => item.id < target && state.stageRuns[String(item.id)].status !== "complete").map((item) => item.id);
|
||||
}
|
||||
|
||||
export function gotoStage(state: PomState, target: number): PomState {
|
||||
stage(target);
|
||||
const missing = incompletePrerequisites(state, target);
|
||||
if (missing.length) throw new Error(`Cannot enter stage ${target}; incomplete prerequisites: ${missing.join(", ")}`);
|
||||
const next = structuredClone(state);
|
||||
next.currentStage = target;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function startStage(state: PomState, target = state.currentStage, allStages = false): PomState {
|
||||
const next = gotoStage(state, target);
|
||||
const run = next.stageRuns[String(target)];
|
||||
if (run.status === "complete") throw new Error(`Stage ${target} is already complete`);
|
||||
const unresolved = run.blockers.filter((blocker) => !blocker.resolvedAt);
|
||||
if (unresolved.length) throw new Error(`Stage ${target} has unresolved blockers`);
|
||||
run.status = "active";
|
||||
run.attempts += 1;
|
||||
run.startedAt = new Date().toISOString();
|
||||
next.activeRun = { id: randomUUID(), stage: target, startedAt: run.startedAt, continuationCount: 0, allStages };
|
||||
next.currentStage = target;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function addBlocker(state: PomState, reason: string, owner?: string): PomState {
|
||||
const text = reason.trim();
|
||||
if (!text) throw new Error("Blocker reason is required");
|
||||
const next = structuredClone(state);
|
||||
const run = next.stageRuns[String(next.currentStage)];
|
||||
const blocker: StageBlocker = { id: randomUUID(), reason: text, owner, createdAt: new Date().toISOString() };
|
||||
run.blockers.push(blocker);
|
||||
run.status = "blocked";
|
||||
delete next.activeRun;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resolveBlocker(state: PomState, blockerId: string, resolution: string): PomState {
|
||||
const next = structuredClone(state);
|
||||
const owner = Object.values(next.stageRuns).find((run) => run.blockers.some((item) => item.id === blockerId));
|
||||
const blocker = owner?.blockers.find((item) => item.id === blockerId);
|
||||
if (!owner || !blocker) throw new Error(`Unknown blocker: ${blockerId}`);
|
||||
if (blocker.resolvedAt) throw new Error(`Blocker already resolved: ${blockerId}`);
|
||||
blocker.resolvedAt = new Date().toISOString();
|
||||
blocker.resolution = resolution.trim() || "Resolved";
|
||||
if (owner.status === "blocked" && owner.blockers.every((item) => item.resolvedAt)) owner.status = "queued";
|
||||
return next;
|
||||
}
|
||||
|
||||
export function recordEvidence(
|
||||
state: PomState,
|
||||
criterionId: string,
|
||||
summary: string,
|
||||
paths: string[],
|
||||
agent?: string,
|
||||
): PomState {
|
||||
const spec = stage(state.currentStage);
|
||||
if (!spec.acceptance.some((criterion) => criterion.id === criterionId)) {
|
||||
throw new Error(`Criterion ${criterionId} does not belong to stage ${spec.id}`);
|
||||
}
|
||||
if (!summary.trim()) throw new Error("Evidence summary is required");
|
||||
if (!paths.length) throw new Error("Evidence must reference at least one project path");
|
||||
const next = structuredClone(state);
|
||||
const run = next.stageRuns[String(next.currentStage)];
|
||||
const record: EvidenceRecord = {
|
||||
id: randomUUID(),
|
||||
criterionId,
|
||||
summary: summary.trim(),
|
||||
paths: [...new Set(paths.map((path) => path.replaceAll("\\", "/")))],
|
||||
agent,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
run.evidence = [...run.evidence.filter((item) => item.criterionId !== criterionId), record];
|
||||
return next;
|
||||
}
|
||||
|
||||
export function passStage(state: PomState, validationReportPath: string): PomState {
|
||||
const next = structuredClone(state);
|
||||
const target = next.currentStage;
|
||||
const spec = stage(target);
|
||||
const run = next.stageRuns[String(target)];
|
||||
if (run.status !== "active" || next.activeRun?.stage !== target) throw new Error(`Stage ${target} is not actively running`);
|
||||
if (!validationReportPath.trim()) throw new Error("Validation report path is required");
|
||||
const unresolved = run.blockers.filter((blocker) => !blocker.resolvedAt);
|
||||
if (unresolved.length) throw new Error(`Stage ${target} has unresolved blockers`);
|
||||
const missingEvidence = spec.acceptance.filter((criterion) => !run.evidence.some((evidence) => evidence.criterionId === criterion.id));
|
||||
if (missingEvidence.length) throw new Error(`Stage ${target} lacks evidence: ${missingEvidence.map((item) => item.id).join(", ")}`);
|
||||
run.status = "complete";
|
||||
run.completedAt = new Date().toISOString();
|
||||
run.validationReportPath = validationReportPath;
|
||||
next.completedStages = [...new Set([...next.completedStages, target])].sort((a, b) => a - b);
|
||||
delete next.activeRun;
|
||||
if (target < 8) next.currentStage = target + 1;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function stageProgress(state: PomState): { complete: number; total: number; percent: number } {
|
||||
const complete = STAGES.filter((item) => state.stageRuns[String(item.id)].status === "complete").length;
|
||||
return { complete, total: STAGES.length, percent: Math.round((complete / STAGES.length) * 100) };
|
||||
}
|
||||
|
||||
export function openBlockers(state: PomState): StageBlocker[] {
|
||||
return Object.values(state.stageRuns).flatMap((run) => run.blockers).filter((item) => !item.resolvedAt);
|
||||
}
|
||||
|
||||
export function summarizeState(state: PomState): string {
|
||||
const spec = stage(state.currentStage);
|
||||
const run = state.stageRuns[String(state.currentStage)];
|
||||
const progress = stageProgress(state);
|
||||
const evidence = new Set(run.evidence.map((item) => item.criterionId)).size;
|
||||
return [
|
||||
`${spec.icon} POM · ${state.projectTitle}`,
|
||||
`Stage ${spec.id}/8 · ${spec.name} · ${run.status.toUpperCase()}`,
|
||||
`Progress ${progress.complete}/${progress.total} · ${progress.percent}%`,
|
||||
`Evidence ${evidence}/${spec.acceptance.length} · Blockers ${openBlockers(state).length}`,
|
||||
`Hive ${Object.keys(state.hive.missions).length} missions · ${state.hive.signalsLogged} signals`,
|
||||
`Root ${state.projectRoot}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function statePath(state: PomState): string {
|
||||
return join(state.projectRoot, "00_admin", "project_state.json");
|
||||
}
|
||||
70
src/studio.ts
Normal file
70
src/studio.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { stage, type HiveMission, type HiveTask, type PomConfig, type PomState } from "./domain";
|
||||
|
||||
export interface NativeTaskBatch {
|
||||
context: string;
|
||||
tasks: Array<{ name: string; agent: string; task: string; isolated?: boolean }>;
|
||||
}
|
||||
|
||||
function agentLabel(agent: string, index: number): string {
|
||||
return `${agent.replace(/^pom-/, "").split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join("")}${index + 1}`.slice(0, 80);
|
||||
}
|
||||
|
||||
export function buildHiveMission(state: PomState, config: PomConfig, preset: "lean" | "stage" | "audit" = "stage"): HiveMission {
|
||||
const spec = stage(state.currentStage);
|
||||
let agents = preset === "lean" ? spec.agents.slice(0, 2) : [...spec.agents];
|
||||
if (preset === "audit") agents = [...new Set(["pom-continuity-auditor", "pom-rights-auditor", "pom-critic", ...agents])];
|
||||
agents = agents.slice(0, config.maxStudioAgents);
|
||||
const artifacts = spec.artifacts.map((artifact) => artifact.path);
|
||||
const tasks: HiveTask[] = agents.map((agent, index) => ({
|
||||
name: agentLabel(agent, index),
|
||||
agent,
|
||||
objective: `Independently investigate and produce evidence for stage ${spec.id} (${spec.name}) from the perspective of ${agent}.`,
|
||||
owns: artifacts.filter((_path, artifactIndex) => artifactIndex % agents.length === index),
|
||||
acceptance: spec.acceptance.filter((_criterion, criterionIndex) => criterionIndex % agents.length === index).map((criterion) => `${criterion.id}: ${criterion.description}`),
|
||||
isolated: false,
|
||||
}));
|
||||
return {
|
||||
id: `s${spec.id}-${randomUUID().slice(0, 8)}`,
|
||||
stage: spec.id,
|
||||
title: `${spec.icon} Stage ${spec.id} Hive · ${spec.name}`,
|
||||
status: "planned",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
export function missionToTaskBatch(state: PomState, mission: HiveMission): NativeTaskBatch {
|
||||
const spec = stage(mission.stage);
|
||||
const shared = [
|
||||
`Project: ${state.projectTitle}`,
|
||||
`Canonical root: ${state.projectRoot}`,
|
||||
`Stage: ${spec.id}/8 · ${spec.name}`,
|
||||
"Canonical files outrank chat. Read before changing anything.",
|
||||
"Coordinate through native IRC using only short messages prefixed CLAIM, QUERY, ANSWER, ALERT, HANDOFF, or DONE.",
|
||||
"Before modifying a shared path, send CLAIM to all. On collision, negotiate ownership before writing.",
|
||||
"Use pom_hive op=signal for durable milestone evidence; IRC is ephemeral coordination, the Hive ledger is durable truth.",
|
||||
"Never mark the stage passed. Return findings and evidence to Main; Main integrates and validates.",
|
||||
].join("\n");
|
||||
return {
|
||||
context: shared,
|
||||
tasks: mission.tasks.map((task) => ({
|
||||
name: task.name,
|
||||
agent: task.agent,
|
||||
isolated: task.isolated,
|
||||
task: [
|
||||
`# Mission\n${task.objective}`,
|
||||
`# Ownership\n${task.owns.length ? task.owns.map((path) => `- ${path}`).join("\n") : "- No exclusive files; inspect and advise only."}`,
|
||||
`# Acceptance\n${task.acceptance.length ? task.acceptance.map((item) => `- ${item}`).join("\n") : "- Return evidence-backed findings relevant to the stage contract."}`,
|
||||
"# Execution Contract",
|
||||
"1. Read canonical dependencies.",
|
||||
"2. Announce CLAIM for owned paths through IRC.",
|
||||
"3. Work independently; do not overwrite another agent's claimed path.",
|
||||
"4. Record durable evidence with pom_hive op=signal.",
|
||||
"5. Send DONE or ALERT to Main through IRC.",
|
||||
"6. Finish with a compact evidence report containing paths, decisions, risks, and unresolved questions.",
|
||||
].join("\n\n"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
83
src/swarm.ts
Normal file
83
src/swarm.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { join } from "node:path";
|
||||
import type { HiveMission, PomState } from "./domain";
|
||||
import { atomicWrite } from "./persistence";
|
||||
import { secureFuturePath } from "./paths";
|
||||
|
||||
export type PomSwarmMode = "parallel" | "sequential" | "pipeline";
|
||||
|
||||
function yamlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function block(value: string, indent: number): string[] {
|
||||
const prefix = " ".repeat(indent);
|
||||
return value.replaceAll("\r\n", "\n").split("\n").map((line) => `${prefix}${line}`);
|
||||
}
|
||||
|
||||
function key(value: string, index: number): string {
|
||||
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return `${normalized || "agent"}_${index + 1}`;
|
||||
}
|
||||
|
||||
export async function writeSwarmDefinition(
|
||||
state: PomState,
|
||||
mission: HiveMission,
|
||||
mode: PomSwarmMode = "parallel",
|
||||
targetCount = 1,
|
||||
): Promise<string> {
|
||||
if (!Number.isInteger(targetCount) || targetCount < 1 || targetCount > 100) throw new Error("Swarm target count must be 1..100");
|
||||
const directory = join("00_admin", "swarms");
|
||||
const relativePath = join(directory, `${mission.id}.yaml`).replaceAll("\\", "/");
|
||||
const absolutePath = await secureFuturePath(state.projectRoot, relativePath);
|
||||
const names = mission.tasks.map((task, index) => key(task.name, index));
|
||||
const lines = [
|
||||
"swarm:",
|
||||
` name: ${yamlString(`pom-${mission.id}`)}`,
|
||||
` workspace: ${yamlString(state.projectRoot)}`,
|
||||
` mode: ${mode}`,
|
||||
...(mode === "pipeline" ? [` target_count: ${targetCount}`] : []),
|
||||
" agents:",
|
||||
];
|
||||
|
||||
mission.tasks.forEach((task, index) => {
|
||||
const agentName = names[index];
|
||||
const reportPath = `06_ledgers/swarm/${mission.id}/${agentName}.md`;
|
||||
lines.push(
|
||||
` ${agentName}:`,
|
||||
` role: ${yamlString(task.agent)}`,
|
||||
" task: |",
|
||||
...block([
|
||||
`Project: ${state.projectTitle}`,
|
||||
`Stage: ${mission.stage}`,
|
||||
`Objective: ${task.objective}`,
|
||||
"Canonical project files outrank chat and unsupported assumptions.",
|
||||
`Exclusive ownership: ${task.owns.join(", ") || "advisory only"}`,
|
||||
`Acceptance: ${task.acceptance.join(" | ") || "evidence-backed specialist findings"}`,
|
||||
`Write a durable report to ${reportPath}.`,
|
||||
"Do not pass the POM stage. Report paths, decisions, evidence, risks, and unresolved blockers.",
|
||||
].join("\n"), 8),
|
||||
" extra_context: |",
|
||||
...block("Use OMP tools autonomously. Read before editing. Never overwrite another agent's declared ownership. Durable coordination belongs in files; concise status belongs in the orchestrator log.", 8),
|
||||
" reports_to:",
|
||||
" - integrator",
|
||||
);
|
||||
});
|
||||
|
||||
lines.push(
|
||||
" integrator:",
|
||||
" role: pom-architect",
|
||||
" task: |",
|
||||
...block([
|
||||
`Read every specialist report under 06_ledgers/swarm/${mission.id}/.`,
|
||||
"Compare findings against canonical files and the active stage contract.",
|
||||
`Write an integration report to 06_ledgers/swarm/${mission.id}/integration.md.`,
|
||||
"Do not silently modify canon where specialists disagree; record the conflict and smallest repair.",
|
||||
"Return an evidence map suitable for Main to register through POM and validate.",
|
||||
].join("\n"), 8),
|
||||
" waits_for:",
|
||||
...names.map((name) => ` - ${name}`),
|
||||
);
|
||||
|
||||
await atomicWrite(absolutePath, `${lines.join("\n")}\n`);
|
||||
return relativePath;
|
||||
}
|
||||
23
src/themes.ts
Normal file
23
src/themes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { copyFile, mkdir, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getCustomThemesDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
export interface InstalledTheme {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function installBundledThemes(): Promise<InstalledTheme[]> {
|
||||
const sourceDir = fileURLToPath(new URL("../themes", import.meta.url));
|
||||
const targetDir = getCustomThemesDir();
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
const files = (await readdir(sourceDir)).filter((file) => file.endsWith(".json")).sort();
|
||||
const installed: InstalledTheme[] = [];
|
||||
for (const file of files) {
|
||||
const target = join(targetDir, file);
|
||||
await copyFile(join(sourceDir, file), target);
|
||||
installed.push({ name: file.slice(0, -5), path: target });
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
32
src/tool-profiles.ts
Normal file
32
src/tool-profiles.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
||||
import type { PomToolProfile } from "./domain";
|
||||
|
||||
const POM_TOOLS = ["pom_state", "pom_stage", "pom_artifact", "pom_validate", "pom_prompt", "pom_hive", "pom_export"];
|
||||
const PROFILES: Record<Exclude<PomToolProfile, "auto">, string[]> = {
|
||||
creative: ["read", "write", "edit", "grep", "glob", "eval", "task", "irc", "todo", "ask", "render_mermaid", "generate_image", "inspect_image"],
|
||||
research: ["read", "write", "edit", "grep", "glob", "eval", "task", "irc", "todo", "ask", "web_search", "browser", "github"],
|
||||
draft: ["read", "write", "edit", "grep", "glob", "eval", "task", "irc", "todo", "ask", "inspect_image"],
|
||||
audit: ["read", "grep", "glob", "eval", "lsp", "task", "irc", "todo", "ask", "inspect_image", "github"],
|
||||
delivery: ["read", "write", "edit", "grep", "glob", "eval", "bash", "task", "irc", "todo", "checkpoint", "github"],
|
||||
};
|
||||
|
||||
let baselines = new WeakMap<object, string[]>();
|
||||
|
||||
export async function applyToolProfile(pi: ExtensionAPI, profile: PomToolProfile): Promise<string[]> {
|
||||
const key = pi as unknown as object;
|
||||
let baseline = baselines.get(key);
|
||||
if (!baseline) {
|
||||
baseline = [...pi.getActiveTools()];
|
||||
baselines.set(key, baseline);
|
||||
}
|
||||
const all = new Set(pi.getAllTools());
|
||||
const requested = profile === "auto" ? baseline : [...PROFILES[profile], ...POM_TOOLS];
|
||||
const selected = [...new Set(requested)].filter((name) => all.has(name));
|
||||
if (!selected.includes("read") && all.has("read")) selected.unshift("read");
|
||||
await pi.setActiveTools(selected);
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function resetToolBaselineForTests(): void {
|
||||
baselines = new WeakMap<object, string[]>();
|
||||
}
|
||||
327
src/tools.ts
Normal file
327
src/tools.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
addBlocker,
|
||||
bumpPatchVersion,
|
||||
gotoStage,
|
||||
passStage,
|
||||
recordEvidence,
|
||||
resolveBlocker,
|
||||
startStage,
|
||||
summarizeState,
|
||||
} from "./state";
|
||||
import { stage, type ArtifactRecord, type PomState, type ValidationScope } from "./domain";
|
||||
import {
|
||||
appendMarkdownLog,
|
||||
recordHiveEvent,
|
||||
registerArtifact,
|
||||
syncKnowledgeBase,
|
||||
writeHiveMission,
|
||||
} from "./persistence";
|
||||
import { validatePom } from "./validators";
|
||||
import { getPrompt, listPrompts, renderStagePrompt } from "./prompts";
|
||||
import { buildHiveMission, missionToTaskBatch } from "./studio";
|
||||
import { exportProject } from "./exporter";
|
||||
import { loadPomConfig } from "./config";
|
||||
import { routeStage } from "./router";
|
||||
import { checkpointStage, enterStageBranch } from "./git";
|
||||
import { toolCallComponent, toolResultComponent, type PomTheme } from "./visual";
|
||||
import type { PomRuntime } from "./runtime";
|
||||
import { updateChrome } from "./ui";
|
||||
|
||||
function visual(runtime: PomRuntime, label: string) {
|
||||
return {
|
||||
renderCall(args: Record<string, unknown>, options: { spinnerFrame?: number }, theme: PomTheme) {
|
||||
const action = [args.action, args.op, args.scope, args.kind, args.path].find((value) => typeof value === "string") as string | undefined;
|
||||
return toolCallComponent(label, action, theme, runtime.get().motion, options.spinnerFrame);
|
||||
},
|
||||
renderResult(
|
||||
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
|
||||
options: { expanded: boolean; isPartial: boolean },
|
||||
theme: PomTheme,
|
||||
) {
|
||||
return toolResultComponent(result, options.expanded, options.isPartial, theme, runtime.get().view);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function textResult(text: string, details?: unknown, isError = false) {
|
||||
return { content: [{ type: "text" as const, text }], details, isError };
|
||||
}
|
||||
|
||||
async function refresh(_pi: ExtensionAPI, runtime: PomRuntime, ctx: ExtensionContext): Promise<void> {
|
||||
const state = runtime.get();
|
||||
const config = await loadPomConfig(state.projectRoot);
|
||||
updateChrome(ctx, state, config, runtime);
|
||||
}
|
||||
|
||||
export function registerPomTools(pi: ExtensionAPI, runtime: PomRuntime): void {
|
||||
const { z } = pi.zod;
|
||||
|
||||
// @ts-expect-error OMP 16.4.x + Zod v4 can exceed TypeScript instantiation depth while contextualizing this complete tool schema.
|
||||
pi.registerTool({
|
||||
...visual(runtime, "State"),
|
||||
name: "pom_state",
|
||||
label: "POM State",
|
||||
description: "Read or update canonical POM configuration, decisions, assumptions, and warnings.",
|
||||
defaultInactive: true,
|
||||
approval: "write",
|
||||
parameters: z.object({
|
||||
action: z.enum(["get", "configure", "decision", "assumption", "warning"]),
|
||||
value: z.string().optional(),
|
||||
mode: z.enum(["autopilot", "collaborative", "strict-autopilot"]).optional(),
|
||||
view: z.enum(["cinematic", "rich", "compact", "minimal"]).optional(),
|
||||
motion: z.enum(["off", "subtle", "full"]).optional(),
|
||||
}),
|
||||
async execute(_id, params, signal, _onUpdate, ctx) {
|
||||
if (signal?.aborted) return textResult("Cancelled", undefined, true);
|
||||
const state = structuredClone(runtime.get());
|
||||
if (params.action === "configure") {
|
||||
state.mode = params.mode ?? state.mode;
|
||||
state.view = params.view ?? state.view;
|
||||
state.motion = params.motion ?? state.motion;
|
||||
}
|
||||
if (params.action === "decision" && params.value?.trim() && !state.decisions.includes(params.value.trim())) state.decisions.push(params.value.trim());
|
||||
if (params.action === "assumption" && params.value?.trim() && !state.assumptions.includes(params.value.trim())) state.assumptions.push(params.value.trim());
|
||||
if (params.action === "warning" && params.value?.trim() && !state.warnings.includes(params.value.trim())) state.warnings.push(params.value.trim());
|
||||
if (params.action !== "get") await runtime.set(state);
|
||||
await refresh(pi, runtime, ctx);
|
||||
return textResult(summarizeState(runtime.get()), runtime.get());
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...visual(runtime, "Stage"),
|
||||
name: "pom_stage",
|
||||
label: "POM Stage",
|
||||
description: "Start, inspect, evidence, block, resolve, pass, or navigate the authoritative stage machine.",
|
||||
defaultInactive: true,
|
||||
approval: "write",
|
||||
parameters: z.object({
|
||||
action: z.enum(["inspect", "start", "evidence", "block", "resolve", "pass", "goto"]),
|
||||
stage: z.number().int().min(0).max(8).optional(),
|
||||
criterionId: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
paths: z.array(z.string()).optional(),
|
||||
agent: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
blockerId: z.string().optional(),
|
||||
resolution: z.string().optional(),
|
||||
allStages: z.boolean().optional(),
|
||||
validationScope: z.enum(["quick", "stage", "canon", "continuity", "knowledge", "files", "delivery", "all"]).optional(),
|
||||
}),
|
||||
async execute(_id, params, signal, onUpdate, ctx) {
|
||||
if (signal?.aborted) return textResult("Cancelled", undefined, true);
|
||||
let state = runtime.get();
|
||||
if (params.action === "start") {
|
||||
state = startStage(state, params.stage ?? state.currentStage, params.allStages ?? false);
|
||||
await runtime.set(state);
|
||||
const config = await loadPomConfig(state.projectRoot);
|
||||
await enterStageBranch(state, config);
|
||||
await routeStage(pi, ctx, state);
|
||||
} else if (params.action === "goto") {
|
||||
if (params.stage === undefined) throw new Error("stage is required");
|
||||
state = gotoStage(state, params.stage);
|
||||
await runtime.set(state);
|
||||
} else if (params.action === "evidence") {
|
||||
if (!params.criterionId || !params.summary) throw new Error("criterionId and summary are required");
|
||||
state = recordEvidence(state, params.criterionId, params.summary, params.paths ?? [], params.agent);
|
||||
await runtime.set(state);
|
||||
} else if (params.action === "block") {
|
||||
state = addBlocker(state, params.reason ?? "Unspecified blocker", params.owner);
|
||||
await runtime.set(state);
|
||||
} else if (params.action === "resolve") {
|
||||
if (!params.blockerId) throw new Error("blockerId is required");
|
||||
state = resolveBlocker(state, params.blockerId, params.resolution ?? "Resolved");
|
||||
await runtime.set(state);
|
||||
} else if (params.action === "pass") {
|
||||
onUpdate?.({ content: [{ type: "text", text: "Running deterministic stage gate…" }] });
|
||||
const wasAllStages = state.activeRun?.allStages ?? false;
|
||||
const scope = (params.validationScope ?? (state.currentStage === 8 ? "delivery" : "stage")) as ValidationScope;
|
||||
const report = await validatePom(state, scope);
|
||||
runtime.telemetry.validationFailures = report.checks.filter((item) => item.status === "fail").length;
|
||||
pi.sendMessage({ customType: "pom:validation", content: `${report.passed ? "PASS" : "FAIL"} ${scope}`, display: true, details: report }, { triggerTurn: false });
|
||||
if (!report.passed || !report.reportPath) return textResult(`Stage gate FAIL · ${runtime.telemetry.validationFailures} failures`, report, true);
|
||||
const passedStage = state.currentStage;
|
||||
state = passStage(state, report.reportPath);
|
||||
await runtime.set(state);
|
||||
const config = await loadPomConfig(state.projectRoot);
|
||||
const commit = await checkpointStage(state, config, `pom: complete stage ${passedStage} ${stage(passedStage).slug}`);
|
||||
pi.sendMessage({ customType: "pom:milestone", content: `Stage ${passedStage} complete`, display: true, details: { stage: passedStage, status: "PASS", path: report.reportPath, commit } }, { triggerTurn: false });
|
||||
if (wasAllStages && passedStage < 8) {
|
||||
state = startStage(state, state.currentStage, true);
|
||||
await runtime.set(state);
|
||||
await enterStageBranch(state, config);
|
||||
await routeStage(pi, ctx, state);
|
||||
pi.sendUserMessage(await renderStagePrompt(state, true), { deliverAs: "followUp" });
|
||||
}
|
||||
}
|
||||
await syncKnowledgeBase(runtime.get());
|
||||
await refresh(pi, runtime, ctx);
|
||||
return textResult(summarizeState(runtime.get()), runtime.get());
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...visual(runtime, "Artifact"),
|
||||
name: "pom_artifact",
|
||||
label: "POM Artifact",
|
||||
description: "Register a non-empty canonical artifact with measured size, SHA-256, status, source, and rights metadata.",
|
||||
defaultInactive: true,
|
||||
approval: "write",
|
||||
parameters: z.object({
|
||||
path: z.string(),
|
||||
category: z.string().default("other"),
|
||||
status: z.enum(["draft", "reviewed", "approved", "superseded"]).default("draft"),
|
||||
rightsStatus: z.enum(["original", "licensed", "public-domain", "reference-only", "unknown"]).default("unknown"),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
}),
|
||||
async execute(_id, params, signal) {
|
||||
if (signal?.aborted) return textResult("Cancelled", undefined, true);
|
||||
const state = runtime.get();
|
||||
const artifactVersion = bumpPatchVersion(state.artifactVersion);
|
||||
const record = await registerArtifact(state, params.path, {
|
||||
category: params.category,
|
||||
status: params.status as ArtifactRecord["status"],
|
||||
rightsStatus: params.rightsStatus as ArtifactRecord["rightsStatus"],
|
||||
source: params.source,
|
||||
notes: params.notes,
|
||||
version: artifactVersion,
|
||||
});
|
||||
const next = structuredClone(state);
|
||||
next.artifactVersion = artifactVersion;
|
||||
if (/canon|bible|continuity|timeline|world|character/i.test(params.category)) {
|
||||
next.canonVersion = bumpPatchVersion(next.canonVersion);
|
||||
}
|
||||
await runtime.set(next);
|
||||
return textResult(`Registered ${record.path} · v${record.version} · ${record.bytes} bytes · ${record.sha256.slice(0, 12)}`, record);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...visual(runtime, "Validate"),
|
||||
name: "pom_validate",
|
||||
label: "POM Validate",
|
||||
description: "Run deterministic state, artifact, evidence, manifest, knowledge, continuity, and delivery checks.",
|
||||
defaultInactive: true,
|
||||
approval: "read",
|
||||
parameters: z.object({
|
||||
scope: z.enum(["quick", "stage", "canon", "continuity", "knowledge", "files", "delivery", "all"]).default("stage"),
|
||||
}),
|
||||
async execute(_id, params, signal, onUpdate) {
|
||||
if (signal?.aborted) return textResult("Cancelled", undefined, true);
|
||||
onUpdate?.({ content: [{ type: "text", text: `Validating ${params.scope}…` }] });
|
||||
const report = await validatePom(runtime.get(), params.scope as ValidationScope);
|
||||
const failures = report.checks.filter((item) => item.status === "fail").length;
|
||||
runtime.telemetry.validationFailures = failures;
|
||||
pi.sendMessage({ customType: "pom:validation", content: `${report.passed ? "PASS" : "FAIL"} ${params.scope}`, display: true, details: report }, { triggerTurn: false });
|
||||
return textResult(`${report.passed ? "PASS" : "FAIL"} · ${failures} failures · ${report.checks.length} checks`, report, !report.passed);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...visual(runtime, "Prompt"),
|
||||
name: "pom_prompt",
|
||||
label: "POM Prompt",
|
||||
description: "List or retrieve versioned POM prompt fragments, including project-local overrides and provenance hashes.",
|
||||
defaultInactive: true,
|
||||
approval: "read",
|
||||
parameters: z.object({ action: z.enum(["list", "get"]), id: z.string().optional() }),
|
||||
async execute(_id, params) {
|
||||
const state = runtime.get().initialized ? runtime.get() : undefined;
|
||||
if (params.action === "list") {
|
||||
const prompts = await listPrompts(state);
|
||||
return textResult(prompts.map((item) => `${item.id}\t${item.source}\t${item.sha256.slice(0, 12)}`).join("\n"), prompts);
|
||||
}
|
||||
if (!params.id) throw new Error("id is required");
|
||||
const prompt = await getPrompt(state, params.id);
|
||||
return textResult(prompt.content, prompt);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...visual(runtime, "Hive"),
|
||||
name: "pom_hive",
|
||||
label: "POM Hive",
|
||||
description: "Plan native task batches, persist inter-agent signals, and record final synthesis without replacing OMP task or IRC.",
|
||||
defaultInactive: true,
|
||||
approval: "write",
|
||||
parameters: z.object({
|
||||
op: z.enum(["plan", "signal", "synthesize", "inspect"]),
|
||||
preset: z.enum(["lean", "stage", "audit"]).optional(),
|
||||
missionId: z.string().optional(),
|
||||
signal: z.enum(["CLAIM", "QUERY", "ANSWER", "ALERT", "HANDOFF", "DONE"]).optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
paths: z.array(z.string()).optional(),
|
||||
status: z.enum(["complete", "blocked"]).optional(),
|
||||
synthesis: z.string().optional(),
|
||||
}),
|
||||
async execute(_id, params, signal, _onUpdate, ctx) {
|
||||
if (signal?.aborted) return textResult("Cancelled", undefined, true);
|
||||
let state = structuredClone(runtime.get());
|
||||
if (params.op === "inspect") return textResult(JSON.stringify(state.hive, null, 2), state.hive);
|
||||
if (params.op === "plan") {
|
||||
const config = await loadPomConfig(state.projectRoot);
|
||||
const mission = buildHiveMission(state, config, params.preset ?? "stage");
|
||||
const batch = missionToTaskBatch(state, mission);
|
||||
state.hive.activeMissionId = mission.id;
|
||||
state.hive.missions[mission.id] = mission;
|
||||
state.stageRuns[String(state.currentStage)].hiveMissionIds.push(mission.id);
|
||||
await runtime.set(state);
|
||||
const path = await writeHiveMission(state, mission);
|
||||
await recordHiveEvent(state, { type: "mission.planned", mission, path });
|
||||
pi.sendMessage({ customType: "pom:hive", content: mission.title, display: true, details: mission }, { triggerTurn: false });
|
||||
await refresh(pi, runtime, ctx);
|
||||
return textResult(`Call native task exactly once with this batch:\n${JSON.stringify(batch, null, 2)}`, { mission, batch, path });
|
||||
}
|
||||
const missionId = params.missionId ?? state.hive.activeMissionId;
|
||||
if (!missionId || !state.hive.missions[missionId]) throw new Error("No matching hive mission");
|
||||
const mission = state.hive.missions[missionId];
|
||||
if (params.op === "signal") {
|
||||
if (!params.signal || !params.message) throw new Error("signal and message are required");
|
||||
state.hive.signalsLogged += 1;
|
||||
runtime.telemetry.lastIrcSignal = `${params.signal} ${params.from ?? "agent"}: ${params.message}`;
|
||||
await runtime.set(state);
|
||||
await recordHiveEvent(state, { type: "hive.signal", missionId, signal: params.signal, from: params.from, to: params.to, message: params.message, paths: params.paths ?? [] });
|
||||
return textResult(`${params.signal} recorded for ${missionId}`, { missionId, ...params });
|
||||
}
|
||||
mission.status = params.status === "blocked" ? "blocked" : "complete";
|
||||
mission.synthesis = params.synthesis?.trim() || "Synthesis recorded without narrative.";
|
||||
mission.updatedAt = new Date().toISOString();
|
||||
if (state.hive.activeMissionId === missionId) delete state.hive.activeMissionId;
|
||||
await runtime.set(state);
|
||||
const path = await writeHiveMission(state, mission);
|
||||
await recordHiveEvent(state, { type: "mission.synthesized", missionId, status: mission.status, synthesis: mission.synthesis, path });
|
||||
pi.sendMessage({ customType: "pom:hive", content: `${mission.title} ${mission.status}`, display: true, details: mission }, { triggerTurn: false });
|
||||
return textResult(`Hive ${missionId} ${mission.status}`, { mission, path });
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...visual(runtime, "Export"),
|
||||
name: "pom_export",
|
||||
label: "POM Export",
|
||||
description: "Create a fresh delivery archive, parse its central directory, decompress every member, verify sizes and CRC-32, and hash the archive.",
|
||||
defaultInactive: true,
|
||||
approval: "write",
|
||||
parameters: z.object({ kind: z.enum(["checkpoint", "final"]).default("checkpoint") }),
|
||||
async execute(_id, params, signal, onUpdate, ctx) {
|
||||
if (signal?.aborted) return textResult("Cancelled", undefined, true);
|
||||
onUpdate?.({ content: [{ type: "text", text: "Building and verifying archive…" }] });
|
||||
const result = await exportProject(runtime.get(), params.kind);
|
||||
await appendMarkdownLog(runtime.get().projectRoot, "delivery_ledger.md", "Archive verified", [
|
||||
`Archive: ${result.zipPath}`,
|
||||
`SHA-256: ${result.verification.sha256}`,
|
||||
`Members: ${result.verification.members.length}`,
|
||||
`Verification: ${result.verificationPath}`,
|
||||
].join("\n\n"));
|
||||
pi.sendMessage({ customType: "pom:milestone", content: "Archive verified", display: true, details: { stage: runtime.get().currentStage, status: "ARCHIVE PASS", path: result.verificationPath } }, { triggerTurn: false });
|
||||
await refresh(pi, runtime, ctx);
|
||||
return textResult(`PASS · ${result.zipPath} · ${result.verification.members.length} members`, result);
|
||||
},
|
||||
});
|
||||
}
|
||||
35
src/ui.ts
Normal file
35
src/ui.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
||||
import type { PomConfig, PomState } from "./domain";
|
||||
import { stage } from "./domain";
|
||||
import type { PomRuntime } from "./runtime";
|
||||
import { PomCommandCenter, PomHudComponent, type CommandCenterAction, type PomTheme } from "./visual";
|
||||
import { openBlockers, stageProgress } from "./state";
|
||||
|
||||
export function updateChrome(ctx: ExtensionContext, state: PomState, config: PomConfig, runtime: PomRuntime): void {
|
||||
if (!ctx.hasUI) return;
|
||||
const spec = stage(state.currentStage);
|
||||
const progress = stageProgress(state);
|
||||
const blockers = openBlockers(state).length;
|
||||
ctx.ui.setTitle(`POM · ${state.projectTitle} · ${spec.id}/8`);
|
||||
ctx.ui.setStatus("pom-stage", `${spec.icon} ${spec.id}/8 ${spec.slug}`);
|
||||
ctx.ui.setStatus("pom-progress", `${progress.percent}%`);
|
||||
ctx.ui.setStatus("pom-gate", blockers ? `⚠ ${blockers}` : `◆ ${state.stageRuns[String(spec.id)].evidence.length}/${spec.acceptance.length}`);
|
||||
ctx.ui.setStatus("pom-hive", state.hive.activeMissionId ? `⬡ ${state.hive.activeMissionId}` : undefined);
|
||||
if (config.dashboardWidget && state.view !== "minimal") {
|
||||
ctx.ui.setWidget("pom-hud", (_tui, theme) => new PomHudComponent(runtime, theme as unknown as PomTheme), { placement: config.hudPlacement });
|
||||
} else {
|
||||
ctx.ui.setWidget("pom-hud", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearChrome(ctx: ExtensionContext): void {
|
||||
if (!ctx.hasUI) return;
|
||||
for (const key of ["pom-stage", "pom-progress", "pom-gate", "pom-hive", "pom-retry", "pom-approval", "pom-goal"]) ctx.ui.setStatus(key, undefined);
|
||||
ctx.ui.setWidget("pom-hud", undefined);
|
||||
ctx.ui.setWorkingMessage();
|
||||
}
|
||||
|
||||
export async function openCommandCenter(ctx: ExtensionContext, state: PomState): Promise<CommandCenterAction> {
|
||||
if (!ctx.hasUI) return "close";
|
||||
return ctx.ui.custom<CommandCenterAction>((_tui, theme, keybindings, done) => new PomCommandCenter(state, theme as unknown as PomTheme, keybindings, done), { overlay: true });
|
||||
}
|
||||
220
src/validators.ts
Normal file
220
src/validators.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||
import { stage, STAGES, type PomState, type ValidationScope } from "./domain";
|
||||
import { atomicWrite, loadManifest, pathExists, sha256File } from "./persistence";
|
||||
import { secureExistingPath } from "./paths";
|
||||
|
||||
export interface ValidationCheck {
|
||||
id: string;
|
||||
status: "pass" | "fail" | "warn";
|
||||
message: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface ValidationReport {
|
||||
schemaVersion: "1.0.0";
|
||||
projectId: string;
|
||||
scope: ValidationScope;
|
||||
stage: number;
|
||||
generatedAt: string;
|
||||
passed: boolean;
|
||||
checks: ValidationCheck[];
|
||||
reportPath?: string;
|
||||
}
|
||||
|
||||
function check(checks: ValidationCheck[], id: string, passed: boolean, message: string, path?: string): void {
|
||||
checks.push({ id, status: passed ? "pass" : "fail", message, path });
|
||||
}
|
||||
|
||||
function warn(checks: ValidationCheck[], id: string, message: string, path?: string): void {
|
||||
checks.push({ id, status: "warn", message, path });
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
return text.trim() ? text.trim().split(/\s+/).length : 0;
|
||||
}
|
||||
|
||||
async function validateState(state: PomState, checks: ValidationCheck[]): Promise<void> {
|
||||
check(checks, "state.initialized", state.initialized, state.initialized ? "Project initialized" : "Project is not initialized");
|
||||
check(checks, "state.stage-range", Number.isInteger(state.currentStage) && state.currentStage >= 0 && state.currentStage <= 8, `Current stage is ${state.currentStage}`);
|
||||
const complete = new Set(state.completedStages);
|
||||
const gaps = state.completedStages.filter((item) => STAGES.some((candidate) => candidate.id < item && !complete.has(candidate.id)));
|
||||
check(checks, "state.sequential", gaps.length === 0, gaps.length ? `Completed-stage sequence has gaps before: ${gaps.join(", ")}` : "Completed stages are sequential");
|
||||
const active = Object.entries(state.stageRuns).filter(([, run]) => run.status === "active");
|
||||
check(checks, "state.single-active", active.length <= 1, active.length <= 1 ? "At most one stage is active" : `Multiple active stages: ${active.map(([id]) => id).join(", ")}`);
|
||||
const open = Object.values(state.stageRuns).flatMap((run) => run.blockers).filter((blocker) => !blocker.resolvedAt);
|
||||
if (open.length) warn(checks, "state.blockers", `${open.length} unresolved blocker(s) remain`);
|
||||
else check(checks, "state.blockers", true, "No unresolved blockers");
|
||||
}
|
||||
|
||||
async function validateArtifactRequirement(state: PomState, checks: ValidationCheck[], requirement: ReturnType<typeof stage>["artifacts"][number]): Promise<void> {
|
||||
const path = requirement.path;
|
||||
try {
|
||||
const absolute = await secureExistingPath(state.projectRoot, path, { allowDirectory: requirement.kind === "directory" });
|
||||
const fileStat = await stat(absolute);
|
||||
if (requirement.kind === "directory") {
|
||||
const entries = await readdir(absolute);
|
||||
const visible = entries.filter((entry) => !entry.startsWith("."));
|
||||
check(checks, `artifact.${path}.entries`, visible.length >= (requirement.minEntries ?? 1), `${path} contains ${visible.length} entries; requires ${requirement.minEntries ?? 1}`, path);
|
||||
return;
|
||||
}
|
||||
check(checks, `artifact.${path}.bytes`, fileStat.size >= (requirement.minBytes ?? 1), `${path} is ${fileStat.size} bytes; requires ${requirement.minBytes ?? 1}`, path);
|
||||
const text = await readFile(absolute, "utf8");
|
||||
for (const heading of requirement.requiredHeadings ?? []) {
|
||||
const found = new RegExp(`^#{1,6}\\s+.*${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*$`, "im").test(text);
|
||||
check(checks, `artifact.${path}.heading.${heading}`, found, found ? `Heading found: ${heading}` : `Missing heading: ${heading}`, path);
|
||||
}
|
||||
for (const pattern of requirement.requiredPatterns ?? []) {
|
||||
const found = new RegExp(pattern, "im").test(text);
|
||||
check(checks, `artifact.${path}.pattern.${pattern}`, found, found ? `Required pattern found: ${pattern}` : `Missing required pattern: ${pattern}`, path);
|
||||
}
|
||||
for (const pattern of requirement.forbiddenPatterns ?? []) {
|
||||
const found = new RegExp(pattern, "im").test(text);
|
||||
check(checks, `artifact.${path}.forbidden.${pattern}`, !found, found ? `Forbidden placeholder found: ${pattern}` : `No forbidden placeholder: ${pattern}`, path);
|
||||
}
|
||||
check(checks, `artifact.${path}.words`, countWords(text) > 0, `${path} contains ${countWords(text)} words`, path);
|
||||
} catch (error) {
|
||||
check(checks, `artifact.${path}.exists`, false, error instanceof Error ? error.message : String(error), path);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateStage(state: PomState, checks: ValidationCheck[]): Promise<void> {
|
||||
const spec = stage(state.currentStage);
|
||||
const run = state.stageRuns[String(spec.id)];
|
||||
const missingPrerequisites = STAGES.filter((item) => item.id < spec.id && state.stageRuns[String(item.id)].status !== "complete").map((item) => item.id);
|
||||
check(checks, "stage.prerequisites", missingPrerequisites.length === 0, missingPrerequisites.length ? `Incomplete prerequisite stages: ${missingPrerequisites.join(", ")}` : "All prerequisite stages are complete");
|
||||
for (const artifact of spec.artifacts) await validateArtifactRequirement(state, checks, artifact);
|
||||
for (const criterion of spec.acceptance) {
|
||||
const evidence = run.evidence.find((item) => item.criterionId === criterion.id);
|
||||
check(checks, `evidence.${criterion.id}`, Boolean(evidence), evidence ? `${criterion.label}: ${evidence.summary}` : `Missing evidence: ${criterion.label}`);
|
||||
if (evidence) {
|
||||
for (const evidencePath of evidence.paths) {
|
||||
try {
|
||||
await secureExistingPath(state.projectRoot, evidencePath, { allowDirectory: true });
|
||||
check(checks, `evidence.${criterion.id}.${evidencePath}`, true, `Evidence path exists: ${evidencePath}`, evidencePath);
|
||||
} catch (error) {
|
||||
check(checks, `evidence.${criterion.id}.${evidencePath}`, false, error instanceof Error ? error.message : String(error), evidencePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const unresolved = run.blockers.filter((blocker) => !blocker.resolvedAt);
|
||||
check(checks, "stage.blockers", unresolved.length === 0, unresolved.length ? `${unresolved.length} unresolved stage blocker(s)` : "No unresolved stage blockers");
|
||||
}
|
||||
|
||||
async function validateManifest(state: PomState, checks: ValidationCheck[]): Promise<void> {
|
||||
let records;
|
||||
try {
|
||||
records = await loadManifest(state);
|
||||
} catch (error) {
|
||||
check(checks, "manifest.schema", false, error instanceof Error ? error.message : String(error));
|
||||
return;
|
||||
}
|
||||
check(checks, "manifest.nonempty", records.length > 0, records.length ? `${records.length} artifact(s) registered` : "Artifact manifest is empty");
|
||||
for (const record of records) {
|
||||
try {
|
||||
const absolute = await secureExistingPath(state.projectRoot, record.path);
|
||||
const fileStat = await stat(absolute);
|
||||
check(checks, `manifest.${record.path}.bytes`, fileStat.size === record.bytes, fileStat.size === record.bytes ? `Size matches for ${record.path}` : `Size drift for ${record.path}: manifest ${record.bytes}, actual ${fileStat.size}`, record.path);
|
||||
const digest = await sha256File(absolute);
|
||||
check(checks, `manifest.${record.path}.sha256`, digest === record.sha256, digest === record.sha256 ? `Hash matches for ${record.path}` : `Hash drift for ${record.path}`, record.path);
|
||||
if (record.status === "approved") check(checks, `manifest.${record.path}.rights`, record.rightsStatus !== "unknown", record.rightsStatus === "unknown" ? `Approved artifact has unknown rights: ${record.path}` : `Rights recorded: ${record.rightsStatus}`, record.path);
|
||||
} catch (error) {
|
||||
check(checks, `manifest.${record.path}.exists`, false, error instanceof Error ? error.message : String(error), record.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function markdownFiles(root: string): Promise<string[]> {
|
||||
const output: string[] = [];
|
||||
async function walk(directory: string): Promise<void> {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) await walk(path);
|
||||
else if (entry.isFile() && extname(entry.name).toLowerCase() === ".md") output.push(path);
|
||||
}
|
||||
}
|
||||
if (await pathExists(root)) await walk(root);
|
||||
return output;
|
||||
}
|
||||
|
||||
async function validateKnowledge(state: PomState, checks: ValidationCheck[]): Promise<void> {
|
||||
const contentRoot = join(state.projectRoot, "content");
|
||||
const required = ["index.md", "production-board.md", "artifact-index.md", "01-planning.md", "02-story-bible.md", "03-manuscript.md", "04-visuals.md", "05-research.md", "06-ledgers.md", "07-delivery.md"];
|
||||
for (const file of required) check(checks, `knowledge.${file}`, await pathExists(join(contentRoot, file)), `Knowledge page ${file} ${await pathExists(join(contentRoot, file)) ? "exists" : "is missing"}`, `content/${file}`);
|
||||
for (const file of await markdownFiles(contentRoot)) {
|
||||
const text = await readFile(file, "utf8");
|
||||
check(checks, `knowledge.frontmatter.${relative(contentRoot, file)}`, text.startsWith("---\n"), `Frontmatter ${text.startsWith("---\n") ? "present" : "missing"}`, relative(state.projectRoot, file));
|
||||
const links = [...text.matchAll(/\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g)].map((match) => match[1].trim());
|
||||
for (const target of links) {
|
||||
if (/^[a-z]+:\/\//i.test(target)) continue;
|
||||
const base = target.startsWith("../") || target.startsWith("./") ? resolve(dirname(file), target) : resolve(contentRoot, target);
|
||||
const candidates = [base, `${base}.md`, join(base, "index.md")];
|
||||
const exists = (await Promise.all(candidates.map(pathExists))).some(Boolean);
|
||||
check(checks, `knowledge.link.${relative(contentRoot, file)}.${target}`, exists, exists ? `Wikilink resolves: ${target}` : `Broken wikilink: ${target}`, relative(state.projectRoot, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateContinuity(state: PomState, checks: ValidationCheck[]): Promise<void> {
|
||||
const paths = ["02_story_bible/13_canon_ledger.md", "06_ledgers/continuity_ledger.md", "06_ledgers/timeline.md"];
|
||||
for (const path of paths) {
|
||||
const exists = await pathExists(join(state.projectRoot, path));
|
||||
if (state.currentStage >= 5 || state.completedStages.includes(4)) check(checks, `continuity.${path}`, exists, exists ? `${path} exists` : `${path} is missing`, path);
|
||||
else if (!exists) warn(checks, `continuity.${path}`, `${path} not required before story-bible completion`, path);
|
||||
}
|
||||
const ledger = join(state.projectRoot, "06_ledgers", "continuity_ledger.md");
|
||||
if (await pathExists(ledger)) {
|
||||
const text = await readFile(ledger, "utf8");
|
||||
const openIssues = [...text.matchAll(/^- \[ \]/gm)].length;
|
||||
check(checks, "continuity.open-issues", openIssues === 0, openIssues ? `${openIssues} open continuity issue(s)` : "No open continuity issues", "06_ledgers/continuity_ledger.md");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDelivery(state: PomState, checks: ValidationCheck[]): Promise<void> {
|
||||
const incomplete = STAGES.filter((item) => item.id < 8 && state.stageRuns[String(item.id)].status !== "complete").map((item) => item.id);
|
||||
check(checks, "delivery.prerequisites", incomplete.length === 0, incomplete.length ? `Incomplete stages: ${incomplete.join(", ")}` : "Stages 0–7 complete");
|
||||
for (const artifact of stage(8).artifacts) await validateArtifactRequirement(state, checks, artifact);
|
||||
const exportRoot = join(state.projectRoot, "07_exports");
|
||||
let verificationFiles: string[] = [];
|
||||
if (await pathExists(exportRoot)) verificationFiles = (await readdir(exportRoot)).filter((name) => name.endsWith(".verification.json"));
|
||||
check(checks, "delivery.archive-verification", verificationFiles.length > 0, verificationFiles.length ? `Archive verification: ${verificationFiles.at(-1)}` : "No archive verification sidecar found", verificationFiles.at(-1) ? `07_exports/${verificationFiles.at(-1)}` : undefined);
|
||||
}
|
||||
|
||||
function scopesFor(scope: ValidationScope): Set<string> {
|
||||
if (scope === "quick") return new Set(["state"]);
|
||||
if (scope === "stage") return new Set(["state", "stage"]);
|
||||
if (scope === "canon") return new Set(["state", "stage", "continuity"]);
|
||||
if (scope === "continuity") return new Set(["state", "continuity"]);
|
||||
if (scope === "knowledge") return new Set(["state", "knowledge"]);
|
||||
if (scope === "files") return new Set(["state", "manifest"]);
|
||||
if (scope === "delivery") return new Set(["state", "manifest", "knowledge", "continuity", "delivery"]);
|
||||
return new Set(["state", "stage", "manifest", "knowledge", "continuity", "delivery"]);
|
||||
}
|
||||
|
||||
export async function validatePom(state: PomState, scope: ValidationScope, options: { writeReport?: boolean } = {}): Promise<ValidationReport> {
|
||||
const checks: ValidationCheck[] = [];
|
||||
const selected = scopesFor(scope);
|
||||
if (selected.has("state")) await validateState(state, checks);
|
||||
if (selected.has("stage")) await validateStage(state, checks);
|
||||
if (selected.has("manifest")) await validateManifest(state, checks);
|
||||
if (selected.has("knowledge")) await validateKnowledge(state, checks);
|
||||
if (selected.has("continuity")) await validateContinuity(state, checks);
|
||||
if (selected.has("delivery")) await validateDelivery(state, checks);
|
||||
const report: ValidationReport = {
|
||||
schemaVersion: "1.0.0",
|
||||
projectId: state.projectId,
|
||||
scope,
|
||||
stage: state.currentStage,
|
||||
generatedAt: new Date().toISOString(),
|
||||
passed: checks.every((item) => item.status !== "fail"),
|
||||
checks,
|
||||
};
|
||||
if (options.writeReport !== false && state.initialized) {
|
||||
const stamp = report.generatedAt.replace(/[:.]/g, "-");
|
||||
const relativePath = `00_admin/validation/stage-${state.currentStage}-${scope}-${stamp}.json`;
|
||||
await atomicWrite(join(state.projectRoot, relativePath), `${JSON.stringify(report, null, 2)}\n`);
|
||||
report.reportPath = relativePath;
|
||||
}
|
||||
return report;
|
||||
}
|
||||
193
src/visual.ts
Normal file
193
src/visual.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Text, truncateToWidth, visibleWidth, type Component, type KeybindingsManager } from "@oh-my-pi/pi-tui";
|
||||
import type { PomMotion, PomState, PomView } from "./domain";
|
||||
import { openBlockers, stageProgress } from "./state";
|
||||
import { stage } from "./domain";
|
||||
import type { PomRuntime } from "./runtime";
|
||||
|
||||
export type PomColor = "accent" | "muted" | "dim" | "success" | "warning" | "error" | "toolTitle" | "text";
|
||||
export interface PomTheme {
|
||||
fg(color: PomColor, text: string): string;
|
||||
bold(text: string): string;
|
||||
italic(text: string): string;
|
||||
}
|
||||
|
||||
function safeLine(line: string, width: number): string {
|
||||
return truncateToWidth(line.replaceAll("\t", " "), Math.max(1, width));
|
||||
}
|
||||
|
||||
function bar(percent: number, width: number): string {
|
||||
const size = Math.max(5, width);
|
||||
const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * size);
|
||||
return `${"━".repeat(filled)}${"─".repeat(size - filled)}`;
|
||||
}
|
||||
|
||||
function stageGlyph(status: string, active: boolean): string {
|
||||
if (active) return "◆";
|
||||
if (status === "complete") return "●";
|
||||
if (status === "blocked") return "×";
|
||||
if (status === "active") return "◉";
|
||||
return "○";
|
||||
}
|
||||
|
||||
function activityGlyph(state: PomState, runtime: PomRuntime): string {
|
||||
if (!runtime.telemetry.activeTool) return state.stageRuns[String(state.currentStage)].status === "blocked" ? "×" : "✓";
|
||||
if (state.motion === "off") return "◆";
|
||||
return state.motion === "full" ? "◌" : "◐";
|
||||
}
|
||||
|
||||
function hiveSummary(state: PomState, runtime: PomRuntime): string {
|
||||
const latest = Object.values(state.hive.missions).at(-1);
|
||||
const total = runtime.telemetry.activeAgents || latest?.tasks.length || 0;
|
||||
const active = state.hive.activeMissionId ? `${runtime.telemetry.completedAgents}/${total}` : total ? `${runtime.telemetry.completedAgents}/${total}` : "idle";
|
||||
return `Hive ${active}`;
|
||||
}
|
||||
|
||||
export function hudLines(state: PomState, runtime: PomRuntime, theme: PomTheme, width: number): string[] {
|
||||
const spec = stage(state.currentStage);
|
||||
const run = state.stageRuns[String(state.currentStage)];
|
||||
const progress = stageProgress(state);
|
||||
const blockers = openBlockers(state).length;
|
||||
const evidence = `${run.evidence.length}/${spec.acceptance.length}`;
|
||||
const stageTrack = Object.entries(state.stageRuns).map(([id, item]) => stageGlyph(item.status, Number(id) === state.currentStage)).join(" ");
|
||||
const tool = runtime.telemetry.activeTool
|
||||
? `${activityGlyph(state, runtime)} ${runtime.telemetry.activeTool}${runtime.telemetry.activeIntent ? ` · ${runtime.telemetry.activeIntent}` : ""}`
|
||||
: `${activityGlyph(state, runtime)} ${run.status}`;
|
||||
const signal = runtime.telemetry.lastIrcSignal ? ` · IRC ${runtime.telemetry.lastIrcSignal}` : "";
|
||||
|
||||
if (state.view === "compact") {
|
||||
return [safeLine(
|
||||
`${theme.fg("accent", theme.bold("POM"))} ${spec.icon} ${spec.id}/8 ${spec.slug} · ${progress.percent}% · ◆ ${evidence} · ${blockers ? `⚠ ${blockers}` : tool} · ${hiveSummary(state, runtime)}`,
|
||||
width,
|
||||
)];
|
||||
}
|
||||
|
||||
const left = `${theme.fg("accent", theme.bold("POM"))} ${theme.fg("muted", `${spec.icon} ${spec.id}/8 ${spec.name}`)}`;
|
||||
const right = `${progress.percent}% · ${evidence} evidence · ${blockers} blockers`;
|
||||
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
||||
const first = safeLine(`${left}${" ".repeat(gap)}${theme.fg(blockers ? "warning" : "dim", right)}`, width);
|
||||
const activity = safeLine(`${theme.fg(runtime.telemetry.activeTool ? "warning" : "muted", tool)} · ${theme.fg("dim", hiveSummary(state, runtime))}${state.view === "cinematic" ? theme.fg("dim", signal) : ""}`, width);
|
||||
|
||||
if (state.view === "rich") {
|
||||
return [first, safeLine(`${theme.fg("accent", bar(progress.percent, Math.max(10, Math.floor(width * 0.45))))} ${theme.fg("dim", stageTrack)} ${activity}`, width)];
|
||||
}
|
||||
|
||||
return [
|
||||
first,
|
||||
safeLine(theme.fg("accent", bar(progress.percent, Math.max(10, width - 2))), width),
|
||||
safeLine(`${theme.fg("dim", stageTrack)} ${activity}`, width),
|
||||
];
|
||||
}
|
||||
|
||||
export class PomHudComponent implements Component {
|
||||
#cacheKey = "";
|
||||
#cache: readonly string[] = [];
|
||||
constructor(private readonly runtime: PomRuntime, private readonly theme: PomTheme) {}
|
||||
render(width: number): readonly string[] {
|
||||
const state = this.runtime.get();
|
||||
const key = JSON.stringify({
|
||||
width,
|
||||
stage: state.currentStage,
|
||||
view: state.view,
|
||||
motion: state.motion,
|
||||
runs: Object.values(state.stageRuns).map((run) => [run.status, run.evidence.length, run.blockers.filter((item) => !item.resolvedAt).length]),
|
||||
telemetry: this.runtime.telemetry,
|
||||
});
|
||||
if (key === this.#cacheKey) return this.#cache;
|
||||
this.#cacheKey = key;
|
||||
this.#cache = hudLines(state, this.runtime, this.theme, width);
|
||||
return this.#cache;
|
||||
}
|
||||
invalidate(): void { this.#cacheKey = ""; }
|
||||
}
|
||||
|
||||
export type CommandCenterAction = "run" | "hive" | "validate" | "evidence" | "vault" | "export" | "theme" | "settings" | "close";
|
||||
|
||||
const ACTIONS: Array<{ id: CommandCenterAction; icon: string; label: string; detail: string }> = [
|
||||
{ id: "run", icon: "▶", label: "Run active stage", detail: "Route model/tools and launch the authoritative stage loop" },
|
||||
{ id: "hive", icon: "⬡", label: "Launch specialist hive", detail: "Prepare one native task batch with IRC ownership protocol" },
|
||||
{ id: "validate", icon: "✓", label: "Validate production", detail: "Run deterministic gates and render an evidence card" },
|
||||
{ id: "evidence", icon: "◆", label: "Record gate evidence", detail: "Attach a criterion to real artifact paths" },
|
||||
{ id: "vault", icon: "◫", label: "Open vault dashboard", detail: "Insert the generated production board into the editor" },
|
||||
{ id: "export", icon: "⇧", label: "Build verified archive", detail: "Create, decompress, CRC-check, and hash the delivery ZIP" },
|
||||
{ id: "theme", icon: "◈", label: "Install POM themes", detail: "Copy Nocturne and Parchment into OMP custom themes" },
|
||||
{ id: "settings", icon: "⚙", label: "Experience settings", detail: "Tune density, motion, routing, and HUD placement" },
|
||||
{ id: "close", icon: "×", label: "Close", detail: "Return to the editor" },
|
||||
];
|
||||
|
||||
export class PomCommandCenter implements Component {
|
||||
#index = 0;
|
||||
#cacheKey = "";
|
||||
#cache: readonly string[] = [];
|
||||
constructor(
|
||||
private readonly state: PomState,
|
||||
private readonly theme: PomTheme,
|
||||
private readonly keybindings: KeybindingsManager,
|
||||
private readonly done: (action: CommandCenterAction) => void,
|
||||
) {}
|
||||
handleInput(data: string): void {
|
||||
if (this.keybindings.matches(data, "app.interrupt")) { this.done("close"); return; }
|
||||
if (data === "k" || data === "\u001b[A") this.#index = (this.#index - 1 + ACTIONS.length) % ACTIONS.length;
|
||||
else if (data === "j" || data === "\u001b[B") this.#index = (this.#index + 1) % ACTIONS.length;
|
||||
else if (data === "\r" || data === "\n") { this.done(ACTIONS[this.#index].id); return; }
|
||||
else if (data === "q" || data === "\u001b") { this.done("close"); return; }
|
||||
this.invalidate();
|
||||
}
|
||||
render(width: number): readonly string[] {
|
||||
const run = this.state.stageRuns[String(this.state.currentStage)];
|
||||
const spec = stage(this.state.currentStage);
|
||||
const progress = stageProgress(this.state);
|
||||
const key = `${width}:${this.#index}:${this.state.updatedAt}`;
|
||||
if (key === this.#cacheKey) return this.#cache;
|
||||
const inner = Math.max(30, width - 4);
|
||||
const top = `╭${"─".repeat(Math.max(1, inner))}╮`;
|
||||
const bottom = `╰${"─".repeat(Math.max(1, inner))}╯`;
|
||||
const line = (content = "") => {
|
||||
const clipped = safeLine(content, inner - 2);
|
||||
return `│ ${clipped}${" ".repeat(Math.max(0, inner - 2 - visibleWidth(clipped)))} │`;
|
||||
};
|
||||
const lines = [
|
||||
top,
|
||||
line(`${this.theme.fg("accent", this.theme.bold("POM COMMAND CENTER"))} ${this.theme.fg("dim", this.state.projectTitle)}`),
|
||||
line(`${spec.icon} Stage ${spec.id}/8 · ${spec.name} · ${run.status.toUpperCase()}`),
|
||||
line(`${this.theme.fg("accent", bar(progress.percent, Math.max(10, inner - 16)))} ${progress.percent}%`),
|
||||
line(),
|
||||
];
|
||||
ACTIONS.forEach((action, index) => {
|
||||
const selected = index === this.#index;
|
||||
const cursor = selected ? this.theme.fg("accent", "❯") : " ";
|
||||
const label = selected ? this.theme.bold(action.label) : action.label;
|
||||
lines.push(line(`${cursor} ${action.icon} ${label}`));
|
||||
if (selected && inner >= 60) lines.push(line(` ${this.theme.fg("dim", action.detail)}`));
|
||||
});
|
||||
lines.push(line(), line(this.theme.fg("dim", "↑/k ↓/j navigate · Enter select · q/Esc close")), bottom);
|
||||
this.#cacheKey = key;
|
||||
this.#cache = lines.map((item) => safeLine(item, width));
|
||||
return this.#cache;
|
||||
}
|
||||
invalidate(): void { this.#cacheKey = ""; }
|
||||
}
|
||||
|
||||
function spinnerFor(motion: PomMotion, frame = 0): string {
|
||||
if (motion === "off") return "◆";
|
||||
const frames = motion === "subtle" ? ["◐", "◑"] : ["◐", "◓", "◑", "◒"];
|
||||
return frames[frame % frames.length];
|
||||
}
|
||||
|
||||
export function toolCallComponent(label: string, action: string | undefined, theme: PomTheme, motion: PomMotion, spinnerFrame?: number): Component {
|
||||
return new Text(`${theme.fg("accent", spinnerFor(motion, spinnerFrame))} ${theme.fg("toolTitle", theme.bold(`POM ${label}`))}${action ? ` ${theme.fg("muted", action)}` : ""}`, 0, 0);
|
||||
}
|
||||
|
||||
export function toolResultComponent(
|
||||
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
|
||||
expanded: boolean,
|
||||
partial: boolean,
|
||||
theme: PomTheme,
|
||||
view: PomView,
|
||||
): Component {
|
||||
if (partial) return new Text(`${theme.fg("warning", view === "minimal" ? "◌" : "◐")} ${theme.fg("muted", "Working…")}`, 0, 0);
|
||||
const text = result.content.find((item) => item.type === "text")?.text ?? (result.isError ? "POM operation failed" : "Done");
|
||||
const mark = result.isError ? theme.fg("error", "×") : theme.fg("success", "✓");
|
||||
const allowDetails = expanded && result.details && view !== "minimal" && view !== "compact";
|
||||
const detail = allowDetails ? `\n${theme.fg("dim", JSON.stringify(result.details, null, view === "cinematic" ? 2 : 0))}` : "";
|
||||
return new Text(`${mark} ${text}${detail}`, 0, 0);
|
||||
}
|
||||
109
src/zip.ts
Normal file
109
src/zip.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { mkdir, stat } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { finished } from "node:stream/promises";
|
||||
import CRC32 from "crc-32";
|
||||
import yauzl, { type Entry, type ZipFile } from "yauzl";
|
||||
import yazl from "yazl";
|
||||
import { assertSafeArchivePath } from "./paths";
|
||||
import { sha256File } from "./persistence";
|
||||
|
||||
export interface ZipMemberVerification {
|
||||
path: string;
|
||||
compressedBytes: number;
|
||||
bytes: number;
|
||||
crc32: string;
|
||||
method: number;
|
||||
}
|
||||
|
||||
export interface ZipVerification {
|
||||
path: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
members: ZipMemberVerification[];
|
||||
}
|
||||
|
||||
export async function createZip(outputPath: string, members: Array<{ absolutePath: string; archivePath: string }>): Promise<void> {
|
||||
if (!members.length) throw new Error("Refusing to create an empty archive");
|
||||
const seen = new Set<string>();
|
||||
const zip = new yazl.ZipFile();
|
||||
for (const member of members) {
|
||||
assertSafeArchivePath(member.archivePath);
|
||||
if (seen.has(member.archivePath)) throw new Error(`Duplicate archive member: ${member.archivePath}`);
|
||||
seen.add(member.archivePath);
|
||||
zip.addFile(member.absolutePath, member.archivePath, { compress: true });
|
||||
}
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
const destination = createWriteStream(outputPath, { flags: "w" });
|
||||
zip.outputStream.pipe(destination);
|
||||
zip.end();
|
||||
await finished(destination);
|
||||
}
|
||||
|
||||
function openZip(path: string): Promise<ZipFile> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
yauzl.open(path, { lazyEntries: true, decodeStrings: true, validateEntrySizes: true, strictFileNames: true }, (error, zip) => {
|
||||
if (error || !zip) reject(error ?? new Error("Unable to open ZIP"));
|
||||
else resolvePromise(zip);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readEntry(zip: ZipFile, entry: Entry): Promise<ZipMemberVerification> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
zip.openReadStream(entry, (error, stream) => {
|
||||
if (error || !stream) { reject(error ?? new Error(`Unable to read ${entry.fileName}`)); return; }
|
||||
let bytes = 0;
|
||||
let crc = 0;
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
crc = CRC32.buf(chunk, crc);
|
||||
});
|
||||
stream.once("error", reject);
|
||||
stream.once("end", () => {
|
||||
const unsigned = crc >>> 0;
|
||||
const expected = entry.crc32 >>> 0;
|
||||
if (bytes !== entry.uncompressedSize) { reject(new Error(`Size mismatch for ${entry.fileName}`)); return; }
|
||||
if (unsigned !== expected) { reject(new Error(`CRC mismatch for ${entry.fileName}`)); return; }
|
||||
resolvePromise({
|
||||
path: entry.fileName,
|
||||
compressedBytes: entry.compressedSize,
|
||||
bytes,
|
||||
crc32: unsigned.toString(16).padStart(8, "0"),
|
||||
method: entry.compressionMethod,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyZip(path: string): Promise<ZipVerification> {
|
||||
const zip = await openZip(path);
|
||||
const members: ZipMemberVerification[] = [];
|
||||
const seen = new Set<string>();
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
zip.once("error", reject);
|
||||
zip.once("end", resolvePromise);
|
||||
zip.on("entry", (entry: Entry) => {
|
||||
void (async () => {
|
||||
try {
|
||||
assertSafeArchivePath(entry.fileName.replace(/\/$/, ""));
|
||||
if (/\/$/.test(entry.fileName)) { zip.readEntry(); return; }
|
||||
if (seen.has(entry.fileName)) throw new Error(`Duplicate ZIP member: ${entry.fileName}`);
|
||||
seen.add(entry.fileName);
|
||||
if ((entry.generalPurposeBitFlag & 0x1) !== 0) throw new Error(`Encrypted ZIP member is not allowed: ${entry.fileName}`);
|
||||
if (![0, 8].includes(entry.compressionMethod)) throw new Error(`Unsupported compression method ${entry.compressionMethod}: ${entry.fileName}`);
|
||||
members.push(await readEntry(zip, entry));
|
||||
zip.readEntry();
|
||||
} catch (error) {
|
||||
zip.close();
|
||||
reject(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
zip.readEntry();
|
||||
});
|
||||
if (!members.length) throw new Error("ZIP contains no file members");
|
||||
const fileStat = await stat(path);
|
||||
return { path, bytes: fileStat.size, sha256: await sha256File(path), members };
|
||||
}
|
||||
Reference in New Issue
Block a user