feat(extensions): establish canonical goal source (#54, #55)

This commit is contained in:
2026-09-06 02:32:32 -05:00
parent 44f257cb06
commit d4696d09eb
43 changed files with 6845 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import type { GoalState } from "./state.ts";
export type GoalDisplay = {
label: "Active" | "Waiting" | "Paused" | "Blocked" | "Complete" | "None";
color: "accent" | "warning" | "error" | "success" | "muted";
};
export function goalDisplay(state: GoalState): GoalDisplay {
if (state.status === "none") return state.lastOutcome
? { label: "Complete", color: "success" }
: { label: "None", color: "muted" };
if (state.status === "blocked" ||
(state.status === "paused" && state.pausedReason?.startsWith("blocked:"))) {
return { label: "Blocked", color: "error" };
}
if (state.status === "paused") return { label: "Paused", color: "warning" };
if (state.activeWait && !state.activeWait.wakeSent) return { label: "Waiting", color: "warning" };
return { label: "Active", color: "accent" };
}
/** Full recall is deliberately separate from short transient notifications. */
export function goalDetails(state: GoalState): string {
const display = goalDisplay(state);
if (display.label === "None") return "Goal: None\nUse /goal <text> to set a goal.";
const completed = state.status === "none" ? state.lastOutcome : undefined;
const lines = [`Goal: ${display.label}`, "", completed?.text ?? state.text, ""];
if (completed) {
lines.push(`Completed: ${completed.at}`, `Evidence: ${completed.evidence}`);
} else {
if (state.pausedReason) lines.push(`Reason: ${state.pausedReason}`);
if (state.status === "active" && state.activeWait) {
lines.push(`Wait owner: ${state.activeWait.owner}`);
if (state.activeWait.nextCheck) lines.push(`Next check: ${state.activeWait.nextCheck}`);
if (state.activeWait.watchId) lines.push(`Watch: ${state.activeWait.watchId}`);
if (state.activeWait.deadlineAt !== undefined) {
const deadline = new Date(state.activeWait.deadlineAt);
lines.push(`Deadline: ${Number.isFinite(deadline.getTime()) ? deadline.toISOString() : "invalid"}`);
}
}
lines.push(`Checks: ${state.checks}/${state.maxChecks}`, `No-progress reports: ${state.noProgressReports}/${state.maxNoProgressReports}`);
}
lines.push("", "/goal stop | /goal resume | /goal clear");
return lines.join("\n");
}
+185
View File
@@ -0,0 +1,185 @@
// lib/executive-update.ts — parser for the cited ms-executive-update Machine contract.
//
// The contract bytes are pinned and verified by Mosaic Core before this parser
// is reachable for a version-4 role. This module accepts no alternate format,
// heading, whitespace form, or identifier spelling.
import {
GOAL_REPORT_CONTRACT_BLOB,
GOAL_REPORT_CONTRACT_PATH,
GOAL_REPORT_CONTRACT_SECTION,
GOAL_REPORT_CONTRACT_SHA256,
GOAL_REPORT_FORMAT,
type GoalItemResolutionV1,
type GoalPolicyPublication,
type GoalTrackedItem,
validateGoalItemResolution,
} from "../../mosaic-core/lib/goal-policy.ts";
export {
GOAL_REPORT_FORMAT,
GOAL_REPORT_CONTRACT_PATH,
GOAL_REPORT_CONTRACT_SECTION,
GOAL_REPORT_CONTRACT_BLOB,
GOAL_REPORT_CONTRACT_SHA256,
};
export type ExecutiveUpdateSection = GoalTrackedItem["section"];
export type ExecutiveUpdateParseResult =
| Readonly<{ ok: true; kind: "update" | "no-change"; items: readonly GoalTrackedItem[] }>
| Readonly<{ ok: false; reason: string }>;
const SECTIONS: readonly ExecutiveUpdateSection[] = ["Just Completed", "Next Step", "Blocked"];
const CONTROL_RE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/;
const TASK_RE = /^T[0-9]+$/;
const PR_RE = /^#[0-9]+$/;
const ROW_RE = /^[A-Z]{1,4}\.?[0-9]+(?:\.[0-9]+)*$/;
const MARKER_RE = /^[A-Z0-9]+(?:-[A-Z0-9]+)*-Q[0-9]+$/;
const SHA_RE = /^[0-9a-f]{40}$/;
const PATH_RE = /^`[^`\s]+`$/;
function invalid(reason: string): ExecutiveUpdateParseResult {
return Object.freeze({ ok: false, reason });
}
function valid(items: GoalTrackedItem[], kind: "update" | "no-change" = "update"): ExecutiveUpdateParseResult {
return Object.freeze({ ok: true, kind, items: Object.freeze(items) });
}
function isContractItemToken(token: string): boolean {
if (TASK_RE.test(token) || PR_RE.test(token) || ROW_RE.test(token) || MARKER_RE.test(token) || SHA_RE.test(token)) return true;
if (!PATH_RE.test(token)) return false;
const path = token.slice(1, -1);
return !path.startsWith("/") && !path.startsWith("./") && !path.startsWith("../") && !/(?:^|\/)\.\.(?:\/|$)/.test(path);
}
function hasValidBlockedSuffix(text: string): boolean {
return text.endsWith("\u2014 nothing from you")
|| /\u2014 Decision needed: \(1\) .+ \(2\) .+; recommend [1-9][0-9]*$/.test(text);
}
/** The contract's distinct single-line update form. */
function parseNoChange(raw: string): ExecutiveUpdateParseResult | undefined {
const match = /^No change since ([^;\n]+); still waiting on (.+)\.$/.exec(raw);
if (!match) return undefined;
const [, since, waiting] = match;
if (!isContractItemToken(since) || !isContractItemToken(waiting)) return invalid("No change: invalid item identifier");
// The trusted resolver's public input is deliberately the existing closed
// GoalTrackedItem shape. Both references are waiting-state identifiers; the
// no-change kind below supplies the stricter unchanged-state rule.
return valid([
Object.freeze({ token: since, section: "Next Step" }),
Object.freeze({ token: waiting, section: "Next Step" }),
], "no-change");
}
function parseSection(section: ExecutiveUpdateSection, body: string): ExecutiveUpdateParseResult | GoalTrackedItem[] {
if (body === "* none\n") return [];
if (!body.endsWith("\n")) return invalid(`${section}: final bullet must end with LF`);
const lines = body.slice(0, -1).split("\n");
if (lines.length < 1 || lines.length > 5) return invalid(`${section}: requires one to five bullets or * none`);
const items: GoalTrackedItem[] = [];
for (const line of lines) {
if (line === "" || /[ \t]$/.test(line)) return invalid(`${section}: blank or trailing-whitespace line`);
const match = /^\* ([^:\n]+): ([^\n]+)$/.exec(line);
if (!match) return invalid(`${section}: invalid bullet grammar`);
const [, token, text] = match;
if (!isContractItemToken(token)) return invalid(`${section}: invalid item identifier`);
if (text.startsWith("-")) return invalid(`${section}: bullet text may not start with -`);
if (section === "Blocked" && !hasValidBlockedSuffix(text)) return invalid("Blocked: required terminal disposition missing");
items.push(Object.freeze({ token, section }));
}
return items;
}
/** Parse the exact three-section Machine contract before any Goal state mutation. */
export function parseExecutiveUpdate(raw: unknown): ExecutiveUpdateParseResult {
if (typeof raw !== "string") return invalid("payload must be a string");
if (raw.length === 0) return invalid("payload is empty");
if (raw !== raw.normalize("NFC")) return invalid("payload is not NFC-normalized");
if (CONTROL_RE.test(raw)) return invalid("payload contains control characters or non-LF line endings");
if (/(^|\n)[^\n]*[ \t](?=\n|$)/.test(raw)) return invalid("payload contains trailing whitespace");
const noChange = parseNoChange(raw);
if (noChange) return noChange;
const first = "Just Completed:\n\n";
const second = "\nNext Step:\n\n";
const third = "\nBlocked:\n\n";
if (!raw.startsWith(first)) return invalid("first heading must be Just Completed");
const nextIndex = raw.indexOf(second, first.length);
if (nextIndex < 0 || raw.indexOf(second, nextIndex + 1) !== -1) return invalid("Next Step heading must appear exactly once after Just Completed");
const blockedIndex = raw.indexOf(third, nextIndex + second.length);
if (blockedIndex < 0 || raw.indexOf(third, blockedIndex + 1) !== -1) return invalid("Blocked heading must appear exactly once after Next Step");
if (raw.includes("\nJust Completed:\n", first.length) || raw.includes("\nNext Step:\n", 0) && raw.indexOf(second) !== nextIndex) return invalid("heading duplication or order violation");
const bodies = [
raw.slice(first.length, nextIndex),
raw.slice(nextIndex + second.length, blockedIndex),
raw.slice(blockedIndex + third.length),
];
const parsed: GoalTrackedItem[] = [];
for (let index = 0; index < SECTIONS.length; index++) {
const sectionItems = parseSection(SECTIONS[index], bodies[index]);
if (!Array.isArray(sectionItems)) return sectionItems;
parsed.push(...sectionItems);
}
return valid(parsed);
}
export type AttestedGoalReportResult =
| Readonly<{ ok: true; items: readonly GoalTrackedItem[] }>
| Readonly<{ ok: false; reason: string }>;
function resolutionError(item: GoalTrackedItem, result: GoalItemResolutionV1): AttestedGoalReportResult | undefined {
if (result.outcome !== "resolved") return Object.freeze({ ok: false, reason: `resolver-${result.outcome}` });
if (item.section === "Just Completed" && !result.changedSincePreviousAcceptedReport) {
return Object.freeze({ ok: false, reason: "unchanged-completion" });
}
if (item.section === "Just Completed" && result.completionEvidenceId === null) {
return Object.freeze({ ok: false, reason: "completion-evidence-missing" });
}
return undefined;
}
export type AttestedGoalReportStatus = "satisfied" | "blocked" | "in_progress";
/** Resolve each parser-produced item through the attested, read-only resolver. */
export async function validateAttestedGoalReport(
raw: unknown,
publication: GoalPolicyPublication,
status: AttestedGoalReportStatus,
): Promise<AttestedGoalReportResult> {
const parsed = parseExecutiveUpdate(raw);
if (!parsed.ok) return parsed;
if (parsed.kind === "no-change" && status !== "in_progress") {
return Object.freeze({ ok: false, reason: "no-change-status" });
}
for (const item of parsed.items) {
let resolution: unknown;
try {
resolution = await publication.resolver(item);
} catch {
return Object.freeze({ ok: false, reason: "resolver-unavailable" });
}
if (!validateGoalItemResolution(resolution)) {
return Object.freeze({ ok: false, reason: "resolver-malformed" });
}
if (parsed.kind === "no-change") {
if (resolution.outcome !== "resolved") return Object.freeze({ ok: false, reason: `resolver-${resolution.outcome}` });
if (resolution.changedSincePreviousAcceptedReport) return Object.freeze({ ok: false, reason: "no-change-state-changed" });
continue;
}
const error = resolutionError(item, resolution);
if (error) return error;
}
return Object.freeze({ ok: true, items: parsed.items });
}
/** Stable code for Core's payload-free denial journal. */
export function goalPolicyDenialCode(reason: string): string {
return ["resolver-zero", "resolver-multiple", "resolver-unavailable", "resolver-stale", "resolver-malformed", "unchanged-completion", "completion-evidence-missing", "no-change-status", "no-change-state-changed"].includes(reason)
? reason
: "parser-rejected";
}
+78
View File
@@ -0,0 +1,78 @@
// Pure /goal command parsing. No I/O, no pi imports.
//
// Verbs (Q1, locked with Jason 2026-08-28):
// bare -> status
// stop -> pause the loop, goal retained
// clear -> remove the goal entirely
// resume -> continue a paused goal
// <text> -> set (or replace) the active goal
// --max N / --max=N run-limit option on set (Q4); remainder is the goal text
//
// Only the exact words stop/clear/resume are verbs; anything else is goal text.
export type GoalCommand =
| { kind: "status" }
| { kind: "stop" }
| { kind: "clear" }
| { kind: "resume" }
| { kind: "set"; text: string; max?: number; waitTimeoutSeconds?: number }
| { kind: "error"; message: string };
export function parseGoalCommand(raw: string): GoalCommand {
const input = (raw ?? "").trim();
if (input === "") return { kind: "status" };
const lower = input.toLowerCase();
if (lower === "stop") return { kind: "stop" };
if (lower === "clear") return { kind: "clear" };
if (lower === "resume") return { kind: "resume" };
let max: number | undefined;
let text = input;
let waitTimeoutSeconds: number | undefined;
const waitOption = text.match(/(?:^|\s)--wait-timeout(?:=|\s+)([^\s]+)(?=\s|$)/);
if (waitOption) {
const n = Number(waitOption[1]);
if (!Number.isInteger(n) || n < 10 || n > 86400 || String(n) !== waitOption[1]) {
return { kind: "error", message: "--wait-timeout must be an integer from 10 to 86400 seconds" };
}
waitTimeoutSeconds = n;
text = text.replace(waitOption[0], " ").trim();
}
if (/(?:^|\s)--wait-timeout(?:=|\s|$)/.test(text)) {
return { kind: "error", message: "--wait-timeout requires one value from 10 to 86400 seconds" };
}
const inline = text.match(/(?:^|\s)--max=([^\s]+)(?:\s|$)/);
if (inline) {
const parsed = parseMax(inline[1]);
if (typeof parsed === "string") return { kind: "error", message: parsed };
max = parsed;
text = text.replace(inline[0], " ").trim();
} else {
const spaced = text.match(/(?:^|\s)--max\s+([^\s]+)(?:\s|$)/);
if (spaced) {
const parsed = parseMax(spaced[1]);
if (typeof parsed === "string") return { kind: "error", message: parsed };
max = parsed;
text = text.replace(spaced[0], " ").trim();
} else if (/(?:^|\s)--max(?:\s|$)/.test(text)) {
return { kind: "error", message: '--max requires a positive integer (e.g. "--max 40")' };
}
}
text = text.trim();
if (text === "") {
return { kind: "error", message: "goal text required (verbs: stop, clear, resume)" };
}
return { kind: "set", text, max, ...(waitTimeoutSeconds === undefined ? {} : { waitTimeoutSeconds }) };
}
function parseMax(raw: string): number | string {
const n = Number.parseInt(raw, 10);
if (!Number.isInteger(n) || n < 1 || String(n) !== raw) {
return `--max must be a positive integer, got "${raw}"`;
}
return n;
}
+30
View File
@@ -0,0 +1,30 @@
// Pure settle decision for the goal loop — extracted so the abort/error/cap
// paths (AC4, AC5) are hermetically testable without a live agent run.
import { checkLimitReached, type GoalState } from "./state.ts";
export type SettleDecision = { action: "inject" } | { action: "wait" } | { action: "pause"; reason: string };
/**
* Decide what the loop does when the agent settles while a goal is active.
* stopReason comes from the last assistant message ("stop", "toolUse",
* "aborted", "error", ...).
*/
export function decideSettle(state: GoalState, stopReason: string | undefined): SettleDecision {
if (stopReason === "aborted") {
return { action: "pause", reason: "paused: run aborted (Esc)" };
}
if (stopReason === "error") {
return { action: "pause", reason: "paused: run error" };
}
if (state.waitTimeoutSeconds && state.activeWait && !state.activeWait.wakeSent) {
return { action: "wait" };
}
if (checkLimitReached(state)) {
return {
action: "pause",
reason: `paused: cap of ${state.maxChecks} checks reached without a report`,
};
}
return { action: "inject" };
}
+348
View File
@@ -0,0 +1,348 @@
// Pure goal state machine for the pi /goal extension.
// No I/O, no pi imports — hermetically testable.
import { createHash } from "node:crypto";
export type GoalStatus = "active" | "paused" | "blocked" | "none";
export type GoalTerminalStatus = "complete";
export type ProgressKind = "measurement" | "action" | "delegation" | "wait" | "non_tool";
export interface GoalTerminalOutcome {
text: string;
status: GoalTerminalStatus;
evidence: string;
at: string;
}
export interface ProgressDetails {
kind: ProgressKind;
/** Nearest incomplete acceptance or delivery gate. */
gate: string;
/** Current owner of that gate or external wait. */
owner: string;
/** Most recent live measurement, including what and when. */
lastMeasurement: string;
/** Next concrete action or check. */
nextAction: string;
/** Concrete artifact produced by legitimate non-tool work. */
artifact?: string;
/** Approved agent-watch identifier for a waiting cycle. */
watchId?: string;
/** Concrete condition that ends or rechecks a waiting cycle. */
nextCheck?: string;
}
export interface InProgressReport {
evidence: string;
progress?: ProgressDetails;
}
export type InProgressClassification = "progress" | "waiting" | "no_progress";
export interface InProgressOutcome {
state: GoalState;
classification: InProgressClassification;
reason: string;
}
export interface GoalState {
version: 1;
text: string;
status: GoalStatus;
/** Check prompts injected since the last accepted progress or waiting report. */
checks: number;
/** Cap on consecutive checks without an accepted report (Q4: default 25, per-goal --max). */
maxChecks: number;
/** Consecutive empty, malformed, unsupported, or duplicate in_progress reports. */
noProgressReports: number;
/** Bounded report-only loop threshold. */
maxNoProgressReports: number;
/** A successful non-goal_report tool result is available to back one progress report. */
workEventSinceReport: boolean;
lastActivityTool?: string;
lastActivityAt?: string;
/** Digest only: do not persist free-form evidence in the state file. */
lastProgressFingerprint?: string;
/** Bounded next action shown in the next forced-check prompt. */
lastNextAction?: string;
/** Explicit wait state. Repeated reports keep it active but do not claim new progress. */
activeWait?: {
owner: string;
watchId?: string;
nextCheck?: string;
deadlineAt?: number;
/** Dispatch receipt; wakeObserved confirms before_agent_start, not task completion. */
wakeSent?: boolean;
wakeRequestId?: string;
wakeDispatchedAt?: number;
wakeObserved?: boolean;
};
/** Operator opt-in: suspend waits, with one deadline wake per goal/resume. */
waitTimeoutSeconds?: number;
waitWakeUsed?: boolean;
setAt: string;
/** Why the goal is paused or blocked: operator stop, abort, error, cap, no-progress, or blocker reason. */
pausedReason?: string;
/** Most recent completed goal, retained for status recall without keeping its loop active. */
lastOutcome?: GoalTerminalOutcome;
}
export const DEFAULT_MAX_CHECKS = 25;
export const DEFAULT_MAX_NO_PROGRESS_REPORTS = 3;
/** Default cap for new goals; GOAL_MAX_CHECKS env overrides (invalid values fall back). */
export function envDefaultMaxChecks(env: NodeJS.ProcessEnv = process.env): number {
return positiveInteger(env.GOAL_MAX_CHECKS, DEFAULT_MAX_CHECKS);
}
/** Report-only loop cap; intentionally separate from the no-report check cap. */
export function envDefaultMaxNoProgressReports(env: NodeJS.ProcessEnv = process.env): number {
return positiveInteger(env.GOAL_MAX_NO_PROGRESS_REPORTS, DEFAULT_MAX_NO_PROGRESS_REPORTS);
}
function positiveInteger(raw: string | undefined, fallback: number): number {
if (!raw) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isInteger(n) && n >= 1 ? n : fallback;
}
export function initialState(
maxChecks: number = envDefaultMaxChecks(),
maxNoProgressReports: number = envDefaultMaxNoProgressReports(),
): GoalState {
return {
version: 1,
text: "",
status: "none",
checks: 0,
maxChecks,
noProgressReports: 0,
maxNoProgressReports,
workEventSinceReport: false,
setAt: "",
};
}
export function setGoal(state: GoalState, text: string, maxChecks?: number, waitTimeoutSeconds?: number): GoalState {
return {
...initialState(maxChecks ?? envDefaultMaxChecks(), envDefaultMaxNoProgressReports()),
text,
...(waitTimeoutSeconds === undefined ? {} : { waitTimeoutSeconds, waitWakeUsed: false }),
status: "active",
setAt: new Date().toISOString(),
};
}
export function pauseGoal(state: GoalState, reason: string): GoalState {
if (state.status !== "active") return state;
return { ...state, status: "paused", pausedReason: reason };
}
export function blockGoal(state: GoalState, reason: string): GoalState {
if (state.status !== "active") return state;
return { ...state, status: "blocked", pausedReason: reason };
}
export function resumeGoal(state: GoalState): GoalState {
if (state.status !== "paused" && state.status !== "blocked") return state;
return {
...state,
status: "active",
checks: 0,
noProgressReports: 0,
workEventSinceReport: false,
pausedReason: undefined,
activeWait: state.waitTimeoutSeconds ? undefined : state.activeWait,
waitWakeUsed: state.waitTimeoutSeconds ? false : state.waitWakeUsed,
};
}
export function clearGoal(state: GoalState): GoalState {
return { ...initialState(state.maxChecks, state.maxNoProgressReports) };
}
export function completeGoal(state: GoalState, evidence: string, at: string = new Date().toISOString()): GoalState {
const cleared = initialState(state.maxChecks, state.maxNoProgressReports);
return {
...cleared,
lastOutcome: {
text: state.text,
status: "complete",
evidence: bounded(evidence, 1000),
at,
},
};
}
/** Terminal reports retain the original reset behavior. */
export function recordReport(state: GoalState): GoalState {
return {
...state,
checks: 0,
noProgressReports: 0,
workEventSinceReport: false,
};
}
/** Record one successful tool result that may back exactly one progress report. */
export function recordWorkEvent(state: GoalState, toolName: string, at: string = new Date().toISOString()): GoalState {
if (state.status !== "active") return state;
return {
...state,
workEventSinceReport: true,
lastActivityTool: bounded(toolName, 80),
lastActivityAt: at,
};
}
/**
* Classify an in_progress report mechanically.
*
* Tool-backed progress requires a successful work event. Non-tool work requires
* a concrete artifact. Waits require an owner plus a watch id or next-check
* condition. Duplicate evidence is never progress. Empty/malformed/unsupported
* reports increment a persisted counter and pause at the bound.
*/
export function recordInProgressReport(state: GoalState, report: InProgressReport, now: number = Date.now()): InProgressOutcome {
if (state.status !== "active") {
return { state, classification: "no_progress", reason: "goal is not active" };
}
const evidence = report.evidence.trim();
const progress = normalizeProgress(report.progress);
if (evidence === "" || !progress) {
return recordNoProgress(state, evidence === "" ? "empty evidence" : "missing structured progress");
}
const missing = requiredProgressField(progress);
if (missing) return recordNoProgress(state, `missing ${missing}`);
const fingerprint = progressFingerprint(evidence, progress);
if (progress.kind === "wait") {
if (!progress.watchId && !progress.nextCheck) {
return recordNoProgress(state, "wait requires an approved watch id or concrete next-check condition");
}
if (state.waitTimeoutSeconds && state.waitWakeUsed) {
return {
state: pauseGoal(state, "wait deadline wake exhausted; resolve the dependency before /goal resume"),
classification: "no_progress",
reason: "one automatic deadline wake per goal/resume has already been used",
};
}
const sameWait = state.lastProgressFingerprint === fingerprint;
return {
state: {
...state,
checks: 0,
workEventSinceReport: false,
lastProgressFingerprint: fingerprint,
lastNextAction: bounded(progress.nextAction, 240),
activeWait: {
...(state.waitTimeoutSeconds ? {
deadlineAt: state.activeWait?.deadlineAt ?? now + state.waitTimeoutSeconds * 1000,
wakeSent: false,
} : {}),
owner: bounded(progress.owner, 120),
watchId: progress.watchId ? bounded(progress.watchId, 120) : undefined,
nextCheck: progress.nextCheck ? bounded(progress.nextCheck, 240) : undefined,
},
},
classification: "waiting",
reason: sameWait ? "approved wait remains active" : "approved wait recorded",
};
}
if (progress.kind === "non_tool") {
if (!progress.artifact) return recordNoProgress(state, "non-tool work requires a concrete artifact");
} else if (!state.workEventSinceReport) {
return recordNoProgress(state, `${progress.kind} lacks a successful work event`);
}
if (state.lastProgressFingerprint === fingerprint) {
return recordNoProgress(state, "duplicate evidence");
}
return {
state: {
...state,
checks: 0,
noProgressReports: 0,
workEventSinceReport: false,
lastProgressFingerprint: fingerprint,
lastNextAction: bounded(progress.nextAction, 240),
activeWait: undefined,
},
classification: "progress",
reason: `${progress.kind} recorded`,
};
}
function recordNoProgress(state: GoalState, reason: string): InProgressOutcome {
const nextNoProgress = state.noProgressReports + 1; // NG8_COUNT_GATE
let next: GoalState = {
...state,
noProgressReports: nextNoProgress,
workEventSinceReport: false,
};
if (nextNoProgress >= state.maxNoProgressReports) { // NG8_PAUSE_GATE
next = pauseGoal(next, `paused: no substantive progress after ${nextNoProgress} in_progress reports`);
}
return { state: next, classification: "no_progress", reason };
}
function normalizeProgress(progress: ProgressDetails | undefined): ProgressDetails | undefined {
if (!progress) return undefined;
return {
kind: progress.kind,
gate: progress.gate?.trim() ?? "",
owner: progress.owner?.trim() ?? "",
lastMeasurement: progress.lastMeasurement?.trim() ?? "",
nextAction: progress.nextAction?.trim() ?? "",
artifact: progress.artifact?.trim() || undefined,
watchId: progress.watchId?.trim() || undefined,
nextCheck: progress.nextCheck?.trim() || undefined,
};
}
function requiredProgressField(progress: ProgressDetails): string | undefined {
if (!progress.gate) return "nearest gate";
if (!progress.owner) return "gate owner";
if (!progress.lastMeasurement) return "last live measurement";
if (!progress.nextAction) return "next action";
return undefined;
}
function progressFingerprint(evidence: string, progress: ProgressDetails): string {
return createHash("sha256")
.update(JSON.stringify({ evidence: evidence.trim(), progress }))
.digest("hex");
}
function bounded(value: string, max: number): string {
return value.length <= max ? value : value.slice(0, max);
}
/** True when the check cap is exhausted and the loop must auto-pause instead of injecting. */
export function checkLimitReached(state: GoalState): boolean {
return state.checks >= state.maxChecks;
}
/** Count one injected check prompt. Callers guard with checkLimitReached first. */
export function recordCheckInjected(state: GoalState): GoalState {
return { ...state, checks: state.checks + 1 };
}
/**
* FR3: satisfied requires evidence, blocked requires a reason.
* in_progress is intentionally accepted here so empty/malformed reports reach
* the no-progress counter instead of being rejected before enforcement.
*/
export function validateReport(status: string, evidence: string | undefined): string | null {
const e = (evidence ?? "").trim();
if (status === "satisfied" && e === "") {
return 'goal_report status "satisfied" requires non-empty evidence describing how the goal is met';
}
if (status === "blocked" && e === "") {
return 'goal_report status "blocked" requires a non-empty reason';
}
return null;
}
+167
View File
@@ -0,0 +1,167 @@
// Seat-durable goal state persistence (Q7b: survives /new, /resume, restarts).
// The state file lives beside the pi agent home: $PI_CODING_AGENT_DIR/goal-state.json
// for fleet seats, ~/.pi/agent/goal-state.json for operator sessions.
//
// NG-7 INCARNATION FENCING (Mercer F1, board T129): the seat-level file was
// ONE shared mutable object — two incarnations of a seat (marcie + marcie-2)
// inherited and rewrote each other's goal state on session_start. State is
// now FENCED per process-launch incarnation identity (mosaic-core
// lib/incarnation.ts — the same identity the R6 journal keys on; NOT
// PI_SESSION_ID, which is session identity):
//
// <agentDir>/goal-state.<incarnationId>.json
//
// Migration (NG-7 remediation F4): an existing unfenced goal-state.json has
// an UNKNOWN OWNER — for the live canary case it is legacy AND ACTIVE (a
// previous incarnation's focus), and velma B1 forbids a fresh incarnation
// inheriting another incarnation's active focus. So the legacy file is
// QUARANTINED, never claimed: atomically renamed aside with its bytes
// preserved (goal-state.legacy.json, then .legacy.1.json, .legacy.2.json…),
// and the resolving incarnation starts FRESH. Nobody inherits an active
// focus; nobody loses the evidence.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { initialState, type GoalState } from "./state.ts";
import { incarnationIdentity } from "../../mosaic-core/lib/incarnation.ts";
export const LEGACY_STATE_FILENAME = "goal-state.json";
/** Pi agent home for the current process (seat dir for fleet seats, ~/.pi/agent otherwise). */
export function agentStateDir(env: NodeJS.ProcessEnv = process.env): string {
const dir = env.PI_CODING_AGENT_DIR;
if (dir && dir.trim() !== "") return dir;
return join(homedir(), ".pi", "agent");
}
export function stateFilePath(dir: string = agentStateDir()): string {
return join(dir, LEGACY_STATE_FILENAME);
}
/** The incarnation-fenced state file for a given agent dir + incarnation. */
export function fencedStateFilePath(dir: string, incarnationId: string): string {
return join(dir, `goal-state.${incarnationId}.json`);
}
export interface ResolveIO {
existsSync(path: string): boolean;
renameSync(from: string, to: string): void;
}
function defaultResolveIO(): ResolveIO {
return { existsSync, renameSync };
}
/** First free quarantine slot for the unknown-owner legacy file. */
export function quarantinePath(dir: string, io: ResolveIO): string {
const first = join(dir, "goal-state.legacy.json");
if (!io.existsSync(first)) return first;
for (let n = 1; ; n++) {
const candidate = join(dir, `goal-state.legacy.${n}.json`);
if (!io.existsSync(candidate)) return candidate;
}
}
/**
* Resolve the state path for THIS process incarnation, quarantining any
* unknown-owner legacy state (bytes preserved; the resolver starts fresh).
* Inject `incarnationId` and `io` for tests; production callers take the
* defaults.
*/
export function resolveStatePath(
dir: string = agentStateDir(),
opts: { incarnationId?: string; io?: ResolveIO } = {},
): string {
const io = opts.io ?? defaultResolveIO();
const incarnationId = opts.incarnationId ?? incarnationIdentity();
const fenced = fencedStateFilePath(dir, incarnationId);
if (io.existsSync(fenced)) return fenced;
const legacy = join(dir, LEGACY_STATE_FILENAME);
if (io.existsSync(legacy)) {
try {
io.renameSync(legacy, quarantinePath(dir, io)); // atomic, bytes preserved
} catch {
// raced by a sibling incarnation or fs trouble: start fresh rather
// than ever reading a file another incarnation may still rewrite
return fenced;
}
}
return fenced;
}
export function loadState(path: string = stateFilePath()): GoalState {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<GoalState>;
if (parsed?.version !== 1 || typeof parsed.text !== "string" || !parsed.status) {
return initialState();
}
return normalize(parsed as GoalState);
} catch {
return initialState();
}
}
export function saveState(state: GoalState, path: string = stateFilePath()): void {
mkdirSync(dirname(path), { recursive: true });
const temporary = `${path}.${randomUUID()}.tmp`;
try {
writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", flag: "wx", mode: 0o600 });
renameSync(temporary, path);
} finally {
if (existsSync(temporary)) unlinkSync(temporary);
}
}
function normalize(state: GoalState): GoalState {
const defaults = initialState();
const maxChecks =
Number.isInteger(state.maxChecks) && state.maxChecks >= 1
? state.maxChecks
: defaults.maxChecks;
const checks = Number.isInteger(state.checks) && state.checks >= 0 ? state.checks : 0;
const maxNoProgressReports =
Number.isInteger(state.maxNoProgressReports) && state.maxNoProgressReports >= 1
? state.maxNoProgressReports
: defaults.maxNoProgressReports;
const noProgressReports =
Number.isInteger(state.noProgressReports) && state.noProgressReports >= 0
? state.noProgressReports
: 0;
const normalized: GoalState = {
...defaults,
...state,
maxChecks,
checks,
maxNoProgressReports,
noProgressReports,
workEventSinceReport: state.workEventSinceReport === true,
};
const outcome = state.lastOutcome;
delete normalized.lastOutcome;
if (state.status === "none" && outcome?.status === "complete" &&
typeof outcome.text === "string" && outcome.text.length > 0 &&
typeof outcome.evidence === "string" && outcome.evidence.length > 0 &&
typeof outcome.at === "string" && Number.isFinite(Date.parse(outcome.at))) {
normalized.lastOutcome = { text: outcome.text, status: "complete", evidence: outcome.evidence.slice(0, 1000), at: outcome.at };
}
if (!["active", "paused", "blocked", "none"].includes(state.status)) {
normalized.status = "paused";
normalized.pausedReason = "invalid persisted status; inspect before /goal resume";
}
if (typeof normalized.pausedReason !== "string") delete normalized.pausedReason;
if (state.waitTimeoutSeconds !== undefined) {
const timeoutValid = Number.isInteger(state.waitTimeoutSeconds) && state.waitTimeoutSeconds >= 10 && state.waitTimeoutSeconds <= 86400;
const wait = state.activeWait;
const validTime = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= 8640000000000000;
const deadlineValid = !wait || (validTime(wait.deadlineAt) && wait.deadlineAt <= Date.now() + 86400000);
const flagsValid = [state.waitWakeUsed, wait?.wakeSent, wait?.wakeObserved].every(value => value === undefined || typeof value === "boolean");
const dispatchValid = !wait?.wakeSent || (validTime(wait.wakeDispatchedAt) &&
typeof wait.wakeRequestId === "string" && wait.wakeRequestId.length > 0 && state.waitWakeUsed === true);
if (!timeoutValid || !deadlineValid || !dispatchValid || !flagsValid) {
return { ...normalized, status: state.status === "none" ? "none" : "paused", pausedReason: "invalid persisted wait state; inspect before /goal resume" };
}
}
return normalized;
}