/** * pi-goal-list-loop-audit — v0.1.0 * extensions/goal-loop-core.ts * * Shared types, state machine, JSONL persistence, helpers. * * Design: see docs/DESIGN.md */ import * as fs from "node:fs"; import * as path from "node:path"; // ================================================================= // Types // ================================================================= export type Status = | "active" | "auditing" | "complete" | "paused" | "aborted"; export type Policy = "goal" | "list"; // v0.3.0: "loop". export interface Task { id: string; title: string; status: "pending" | "in_progress" | "complete"; subtasks?: Task[]; } export interface TaskList { version: 1; tasks: Task[]; } // ================================================================= // Task-list proposal validation (used by the propose_task_list tool) // // The caps are the fix for pi-goal-x flaw #4: the agent could grow subtasks // indefinitely, drifting into self-generated busywork. Hard limits keep a // breakdown a breakdown. // ================================================================= export const MAX_TOP_LEVEL_TASKS = 20; export const MAX_SUBTASKS_PER_TASK = 5; export interface TaskProposal { title: string; subtasks?: string[]; } /** Validate a proposed breakdown. Returns an error string or null. */ export function validateTaskProposal(tasks: TaskProposal[]): string | null { if (!Array.isArray(tasks) || tasks.length === 0) return "Empty task list."; if (tasks.length > MAX_TOP_LEVEL_TASKS) { return `Too many top-level tasks (${tasks.length}); max ${MAX_TOP_LEVEL_TASKS}. Coarser granularity, please.`; } for (const t of tasks) { if (!t.title || !t.title.trim()) return "Every task needs a non-empty title."; const n = t.subtasks?.length ?? 0; if (n > MAX_SUBTASKS_PER_TASK) { return `Task "${t.title}" has ${n} subtasks; max ${MAX_SUBTASKS_PER_TASK}. Merge or split into coarser tasks.`; } } return null; } /** Assign hierarchical ids ("1", "1.1", …) and pending statuses to a proposal. */ export function buildTaskList(tasks: TaskProposal[]): TaskList { return { version: 1, tasks: tasks.map((t, i) => ({ id: String(i + 1), title: t.title.trim(), status: "pending" as const, subtasks: (t.subtasks ?? []).map((s, j) => ({ id: `${i + 1}.${j + 1}`, title: s.trim(), status: "pending" as const, })), })), }; } export interface AuditVerdict { at: string; approved: boolean; disapproved: boolean; model: string; thinkingLevel?: string; report?: string; /** Infrastructure failure detail (abort, auth, no model). Verdicts only — an entry with error and no report is not a real audit. */ error?: string; /** regression_shield outcome when the goal had a verification contract. */ regressionShieldPassed?: boolean; } /** * Sum token usage across assistant messages, counting each message once. * `agent_end` events may include already-seen history, so callers pass a * dedup set keyed by timestamp+tokens (good-enough identity for counting). * * v0.12.0: counts input+output (real spend) when the usage object carries * the split; totalTokens includes cache reads, which inflate 10-50× on long * sessions (a day-long goal "used" 216M while real spend was a fraction). */ export function sumNewAssistantTokens(messages: unknown[], seen: Set): number { let total = 0; for (const m of messages) { const msg = m as { role?: string; timestamp?: unknown; usage?: { input?: unknown; output?: unknown; totalTokens?: unknown }; }; if (msg?.role !== "assistant") continue; const u = msg.usage; const split = (typeof u?.input === "number" ? u.input : 0) + (typeof u?.output === "number" ? u.output : 0); const tokens = split > 0 ? split : (typeof u?.totalTokens === "number" ? u.totalTokens : 0); if (tokens <= 0) continue; const key = `${String(msg.timestamp ?? "?")}:${tokens}`; if (seen.has(key)) continue; seen.add(key); total += tokens; } return total; } export interface Goal { id: string; objective: string; status: Status; policy: Policy; verificationContract?: string; autoContinue: boolean; taskList?: TaskList; auditHistory?: AuditVerdict[]; stopReason?: string; pauseReason?: string; pauseSuggestedAction?: string; activePath?: string; archivedPath?: string; usage: { tokensUsed: number; tokensLimit: number; }; createdAt: string; updatedAt: string; } /** * Route `/goal` args (v0.8.0 top-level consolidation). Subcommands match ONLY * on exact word (except tweak/archive which take args) — an objective that * starts with "pause" ("/goal pause the pipeline and fix it") must set a * goal, not pause one. */ export type GoalRoute = | { kind: "draft" } | { kind: "set"; text: string } | { kind: "sub"; name: "status" | "pause" | "resume" | "cancel" | "tweak" | "archive"; rest: string }; const GOAL_EXACT_SUBS = new Set(["status", "pause", "resume", "cancel"]); const GOAL_ARG_SUBS = new Set(["tweak", "archive"]); export function routeGoalArgs(raw: string): GoalRoute { const trimmed = raw.trim(); if (!trimmed) return { kind: "draft" }; const space = trimmed.indexOf(" "); const first = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase(); const rest = space === -1 ? "" : trimmed.slice(space + 1).trim(); if (GOAL_EXACT_SUBS.has(first) && rest === "") { return { kind: "sub", name: first as "status" | "pause" | "resume" | "cancel", rest: "" }; } if (GOAL_ARG_SUBS.has(first)) { return { kind: "sub", name: first as "tweak" | "archive", rest }; } return { kind: "set", text: trimmed }; } /** * Parse a bulk list-import file (v0.8.1): markdown checklists (`- [ ]`, * `- [x]`), bullets (`-`, `*`, `•`), numbered items (`1.`, `2)`), and plain * lines all become queue items. Headings (`# …`), blank lines, and HTML * comments are skipped. A sisyphus-style plan file should import clean. */ export function parseListImport(content: string): string[] { const items: string[] = []; for (const line of content.split("\n")) { let t = line.trim(); if (!t) continue; if (t.startsWith("#")) continue; // headings if (t.startsWith("