@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user