@@ -0,0 +1,129 @@
|
||||
# goal — pi extension
|
||||
|
||||
Operator-set goal with a forced check<->proceed loop. `/goal <text>` activates a goal; the
|
||||
extension keeps the agent working and reporting until it declares the goal `satisfied` via the
|
||||
`goal_report` tool, then retains a recallable completed outcome and returns the session to normal operation.
|
||||
|
||||
Requirements record: `docs/PRD.md` in mosaic-brain (issue #52). Design locked with Jason
|
||||
2026-08-28 (Q1-Q8).
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/goal full current or completed goal, state, and counters
|
||||
/goal <text> set (or replace, with a notify) the active goal
|
||||
/goal --max N <text> set with a non-default run limit (default 25)
|
||||
/goal stop pause the loop, goal retained
|
||||
/goal resume continue a paused goal (kicks a check turn)
|
||||
/goal clear remove the goal entirely
|
||||
```
|
||||
|
||||
`GOAL_MAX_CHECKS` env overrides the default cap for new goals (invalid values fall back to 25).
|
||||
|
||||
## Loop mechanics
|
||||
|
||||
1. `before_agent_start` appends the active goal and reporting instructions to the system
|
||||
prompt every turn, so the goal survives context growth and compaction.
|
||||
2. When the agent settles without the goal being satisfied, the extension injects a check
|
||||
prompt (`sendUserMessage`), forcing the next turn: the check<->proceed loop. Before reporting,
|
||||
follow the continuation loop in `skills-local/ms-proactive-agent/SKILL.md`.
|
||||
3. The agent reports via `goal_report`, the loop's only exit:
|
||||
- `satisfied` + evidence: completed outcome retained for recall, normal operation
|
||||
(`evidence`, `reason`, and `in_progress` structured progress use the `ms-executive-update`
|
||||
format — `skills-local/ms-executive-update/SKILL.md` — so the operator reads one shape everywhere)
|
||||
- `blocked` + reason: loop paused, reason surfaced to the operator
|
||||
- `in_progress`: evidence plus a structured progress record naming the nearest gate,
|
||||
owner, last live measurement, and next action
|
||||
4. Successful non-`goal_report` tool results create one observable work event. Measurement,
|
||||
action, and delegation reports require that event. Non-tool work requires a concrete artifact.
|
||||
Waiting requires an approved watch id or concrete next-check condition.
|
||||
5. Empty, malformed, unsupported, or duplicate `in_progress` reports do not reset the check
|
||||
counter. They increment a persisted no-progress counter and pause after three reports by
|
||||
default (`GOAL_MAX_NO_PROGRESS_REPORTS`). Repeated valid wait reports remain active without
|
||||
claiming new progress or triggering a false-positive pause.
|
||||
6. Esc (run abort) and run errors pause the loop instead of re-injecting; `/goal resume`
|
||||
continues. Operator-typed messages always flow through unchanged.
|
||||
7. No-report cap: N consecutive check turns without an accepted report auto-pause the loop and
|
||||
notify. Default 25 (`GOAL_MAX_CHECKS`), per-goal `--max N`.
|
||||
|
||||
The loop needs a persistent session (TUI or RPC). In print mode (`pi -p`) the goal directive
|
||||
and seat-durable state still apply, but check turns are not forced — print mode exits when the
|
||||
prompt pipeline completes, and a forced turn there races session teardown (measured).
|
||||
|
||||
## State
|
||||
|
||||
Incarnation-durable state survives `/new`, `/resume`, and reloads within one process. This
|
||||
repository's native development installation stores it under `.pi/state/goal/`. The identity is
|
||||
the process-launch incarnation, never `PI_SESSION_ID`. Unknown-owner legacy `goal-state.json`
|
||||
files are quarantined rather than inherited. One active goal exists per incarnation; setting a
|
||||
new goal replaces that incarnation's goal.
|
||||
|
||||
State stores counters, bounded routing fields, and a SHA-256 evidence fingerprint. It does not
|
||||
persist free-form progress evidence.
|
||||
|
||||
## Development installation
|
||||
|
||||
This directory is canonical source. Do not edit `.pi/extensions/`; generate that native-test
|
||||
installation with `scripts/sync-dev-extensions.sh`. The sync copies `goal/` and its
|
||||
`mosaic-core/lib/` dependency as ordinary files, verifies content hashes, and refuses to
|
||||
overwrite installation drift. It also enforces that `goal/index.ts` is the only extension
|
||||
entrypoint in this bounded package.
|
||||
|
||||
Start the isolated native test with `bash scripts/goal-dev.sh`. It runs the sync and passes the
|
||||
generated entrypoint explicitly to Pi while disabling ambient extension discovery. This does
|
||||
not install or reload the live fleet extension and does not add the extension to Docker.
|
||||
|
||||
Directory layout: `index.ts` for wiring, `lib/` for logic, and `test/` for hermetic checks.
|
||||
Pi supplies the extension API, TypeBox, and pi-ai imports at runtime.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
node --test extensions/goal/test/*.test.ts
|
||||
bash scripts/test-extension-package.sh
|
||||
```
|
||||
|
||||
The suite includes pure state, persistence, incarnation fencing, exact report-only loop,
|
||||
duplicate evidence, legitimate wait, headless extension-runtime, and enforcement-neutralization
|
||||
red controls.
|
||||
|
||||
## Bounded waits (operator opt-in)
|
||||
|
||||
Use `/goal --wait-timeout 60 <goal text>` to suspend automatic checks during a
|
||||
reported wait. The flag accepts 10–86400 seconds and combines with `--max N`.
|
||||
Omitting it preserves the previous wait-loop behavior. Existing running sessions
|
||||
must `/reload` to load this patch; do not reload unrelated seats for a trial.
|
||||
|
||||
A valid `goal_report` with `status: "in_progress"`, `progress.kind: "wait"`, an
|
||||
owner, and a watch id or concrete `nextCheck` persists the deadline and yields.
|
||||
There are no model heartbeats during that wait. Repeated wait reports preserve
|
||||
its original deadline. A substantive progress report clears the wait. The
|
||||
extension neither evaluates arbitrary shell conditions nor sends messages to
|
||||
other agents: a relevant incoming message can start an ordinary turn, or the
|
||||
one-shot deadline asks the agent to reconcile the dependency once.
|
||||
|
||||
There is **one automatic deadline wake per goal or explicit `/goal resume`**.
|
||||
Reporting another wait after that wake pauses the goal, including when its
|
||||
wording changes. This intentionally limits the initial rollout; multiple
|
||||
successive timed dependencies require explicit resumption. Deadline expiry
|
||||
never supplies approval or permits the agent to invent missing input.
|
||||
|
||||
The timer belongs to this Pi process. Reload within the same incarnation
|
||||
restores the original deadline; shutdown cancels timers. A new process has a
|
||||
new fenced goal identity and does not inherit another incarnation's goal.
|
||||
This patch is not a process supervisor. Print mode records a manual wait and
|
||||
never schedules a wake. No external systemd timer is installed.
|
||||
|
||||
If the runtime is busy at expiry, readiness is checked in code every second
|
||||
for at most 60 seconds; this also covers manual compaction with no settle event.
|
||||
If still busy, the goal pauses with a UI notice. A dispatched deadline wake
|
||||
must appear in `before_agent_start` within 30 seconds or the goal pauses without
|
||||
resending. The matching event confirms turn start, not successful task execution.
|
||||
Stop, clear, replacement, satisfaction, and blocking cancel owned timers.
|
||||
Late reports cannot settle a paused goal. Reports also refuse if the goal changes
|
||||
while an asynchronous policy check is running. State snapshots use atomic file
|
||||
replacement; failed saves stop continuation instead of claiming a saved result.
|
||||
|
||||
The tests exercise unchanged waits, deadline dispatch and observation, unresolved
|
||||
pause, compaction, reload, cancellation, print mode, malformed persisted waits,
|
||||
and actual filesystem write failure. This is separate from live seat acceptance.
|
||||
@@ -0,0 +1,504 @@
|
||||
// pi /goal extension — operator-set goal with a forced check<->proceed loop.
|
||||
//
|
||||
// Source of truth for requirements: docs/PRD.md (mosaic-brain, issue #52).
|
||||
// Design locked with Jason 2026-08-28 (Q1-Q8): verb set, agent-self-declared
|
||||
// satisfaction via goal_report, abort pauses, seat-durable state, runaway cap.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { goalDisplay, goalDetails } from "./lib/display.ts";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
clearGoal,
|
||||
completeGoal,
|
||||
blockGoal,
|
||||
pauseGoal,
|
||||
recordCheckInjected,
|
||||
recordInProgressReport,
|
||||
recordReport,
|
||||
recordWorkEvent,
|
||||
resumeGoal,
|
||||
setGoal,
|
||||
validateReport,
|
||||
type GoalState,
|
||||
type ProgressDetails,
|
||||
} from "./lib/state.ts";
|
||||
import { decideSettle } from "./lib/settle.ts";
|
||||
import { parseGoalCommand } from "./lib/parse.ts";
|
||||
import { loadState, saveState, resolveStatePath } from "./lib/store.ts";
|
||||
import { goalPolicyDenialCode, validateAttestedGoalReport } from "./lib/executive-update.ts";
|
||||
import { incarnationIdentity } from "../mosaic-core/lib/incarnation.ts";
|
||||
import { goalPolicyPublication, recordGoalPolicyDenial, registerGoalPolicyConsumer } from "../mosaic-core/lib/goal-policy.ts";
|
||||
|
||||
const WIDGET_ID = "goal";
|
||||
|
||||
export default function goalExtension(pi: ExtensionAPI) {
|
||||
// Register before role-specific adapters and Mosaic Core publish the one
|
||||
// trusted version-4 policy attestation for this process incarnation.
|
||||
const incarnationId = incarnationIdentity();
|
||||
registerGoalPolicyConsumer(incarnationId);
|
||||
|
||||
// NG-7 incarnation fencing (Mercer F1): state is scoped to THIS process
|
||||
// launch incarnation — a sibling incarnation of the same seat (marcie-2)
|
||||
// resolves its own fenced file and can never inherit, replace, pause, or
|
||||
// settle this incarnation's active focus. Legacy unfenced state is
|
||||
// quarantined without adoption (store.ts).
|
||||
// NG development state belongs to this project copy, never the live fleet agent home.
|
||||
const statePath = resolveStatePath(fileURLToPath(new URL("../../state/goal/", import.meta.url)));
|
||||
let state: GoalState = loadState(statePath);
|
||||
// Set on session_shutdown: after teardown, any ctx/runner use throws.
|
||||
let dead = false;
|
||||
let waitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let armedDeadline: string | undefined;
|
||||
let deadlinePending = false;
|
||||
|
||||
function cancelWaitTimer(): void {
|
||||
if (waitTimer !== undefined) clearTimeout(waitTimer);
|
||||
waitTimer = undefined;
|
||||
armedDeadline = undefined;
|
||||
deadlinePending = false;
|
||||
}
|
||||
|
||||
// A deadline is a single reconciliation wake, never an approval or a heartbeat.
|
||||
function wakeDeadline(ctx: ExtensionContext): void {
|
||||
if (dead || state.status !== "active" || !state.waitTimeoutSeconds ||
|
||||
!state.activeWait || state.activeWait.wakeSent) return;
|
||||
state = { ...state, waitWakeUsed: true,
|
||||
activeWait: { ...state.activeWait, wakeSent: true, wakeRequestId: randomUUID(), wakeDispatchedAt: Date.now(), wakeObserved: false } };
|
||||
if (!persist(ctx)) return;
|
||||
beginCheck(ctx);
|
||||
}
|
||||
|
||||
function syncWaitTimer(ctx: ExtensionContext): void {
|
||||
const wait = state.activeWait;
|
||||
if (dead || ctx.mode === "print" || state.status !== "active" || !state.waitTimeoutSeconds ||
|
||||
!wait || !Number.isFinite(wait.deadlineAt) || (wait.wakeSent && wait.wakeObserved)) {
|
||||
cancelWaitTimer();
|
||||
return;
|
||||
}
|
||||
const deadline = wait.wakeSent ? wait.wakeDispatchedAt! + 30000 : wait.deadlineAt!;
|
||||
const key = `${wait.wakeSent ? wait.wakeRequestId : "wait"}:${deadline}`;
|
||||
if (armedDeadline === key) return;
|
||||
cancelWaitTimer();
|
||||
armedDeadline = key;
|
||||
const tick = () => {
|
||||
waitTimer = undefined;
|
||||
if (dead || state.status !== "active" || armedDeadline !== key) return;
|
||||
if (wait.wakeSent) {
|
||||
pauseWith(ctx, "deadline wake was not observed within 30s", "wake delivery unconfirmed; paused — /goal resume after checking the session");
|
||||
return;
|
||||
}
|
||||
if (!ctx.isIdle()) {
|
||||
// Compaction can make a session busy without ever emitting agent_settled.
|
||||
// Poll runtime readiness in code only, for at most 60s; never call the model.
|
||||
if (Date.now() >= deadline + 60000) {
|
||||
pauseWith(ctx, "session remained busy after wait deadline", "deadline could not start work; paused — /goal resume when ready");
|
||||
return;
|
||||
}
|
||||
deadlinePending = true;
|
||||
waitTimer = setTimeout(tick, 1000);
|
||||
waitTimer.unref?.();
|
||||
return;
|
||||
}
|
||||
wakeDeadline(ctx);
|
||||
};
|
||||
waitTimer = setTimeout(tick, Math.max(1, deadline - Date.now()));
|
||||
waitTimer.unref?.();
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function safeNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error"): void {
|
||||
try {
|
||||
ctx.ui.notify(message, level);
|
||||
} catch {
|
||||
// stale ctx or no UI — never break a turn over a notification
|
||||
}
|
||||
}
|
||||
|
||||
function persist(ctx: ExtensionContext): boolean {
|
||||
try {
|
||||
saveState(state, statePath);
|
||||
syncWaitTimer(ctx);
|
||||
return true;
|
||||
} catch (err) {
|
||||
state = pauseGoal(state, "state save failed; continuation stopped");
|
||||
cancelWaitTimer();
|
||||
try {
|
||||
safeNotify(ctx, `goal: state save failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} catch {
|
||||
// no UI — nothing else to do
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWidget(ctx: ExtensionContext): void {
|
||||
if (!ctx.hasUI) return;
|
||||
try {
|
||||
// Clear a stale widget after upgrading/reloading; never register widget content.
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
const display = goalDisplay(state);
|
||||
const label = `Goal: ${display.label} (/goal, Alt+G)`;
|
||||
ctx.ui.setStatus("goal", process.env.NO_COLOR !== undefined
|
||||
? label : ctx.ui.theme.fg(display.color, label));
|
||||
} catch {
|
||||
// Rendering is best-effort; never break the session over it.
|
||||
}
|
||||
}
|
||||
|
||||
function notifyStatus(ctx: ExtensionContext): void {
|
||||
safeNotify(ctx, goalDetails(state), "info");
|
||||
}
|
||||
|
||||
function checkPrompt(): string {
|
||||
const deadline = state.waitTimeoutSeconds && state.activeWait?.wakeSent
|
||||
? " Wait deadline reached. Reconcile the dependency once. A deadline is not approval. If still unresolved and no independent work remains, report blocked; do not re-arm or poll."
|
||||
: "";
|
||||
const next = state.lastNextAction ? ` Execute the recorded next action first: ${state.lastNextAction}.` : "";
|
||||
return (
|
||||
`[goal check ${state.checks}/${state.maxChecks}; no-progress ${state.noProgressReports}/${state.maxNoProgressReports}]` +
|
||||
`${state.activeWait?.wakeRequestId ? `[goal wake ${state.activeWait.wakeRequestId}] ` : ""}${deadline}${next} Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` +
|
||||
`Report with goal_report exactly once. Use "satisfied" with evidence when fully met or "blocked" with the reason when no meaningful next step exists. For "in_progress", do not submit ` +
|
||||
`status alone: provide structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked) ` +
|
||||
`with evidence plus progress.kind, nearest gate, owner, last live measurement, and next action. ` +
|
||||
`Measurement/action/delegation requires a successful work tool event; non-tool work ` +
|
||||
`requires an artifact; waiting requires an approved watch id or concrete next-check condition. ` +
|
||||
`Empty, malformed, or duplicate reports increment the no-progress pause counter.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Inject one check prompt: immediately when idle, queued behind a running agent otherwise. */
|
||||
function beginCheck(ctx: ExtensionContext): void {
|
||||
if (dead || state.status !== "active") return;
|
||||
// The loop needs a persistent session. Print mode exits after the prompt
|
||||
// pipeline completes; a forced turn there races teardown (measured). The
|
||||
// goal directive (before_agent_start) still applies in print mode.
|
||||
if (ctx.mode === "print") return;
|
||||
state = recordCheckInjected(state);
|
||||
if (!persist(ctx)) return;
|
||||
renderWidget(ctx);
|
||||
const msg = checkPrompt();
|
||||
if (ctx.isIdle()) {
|
||||
try {
|
||||
pi.sendUserMessage(msg);
|
||||
return;
|
||||
} catch {
|
||||
// fall through to the queued form
|
||||
}
|
||||
}
|
||||
try {
|
||||
pi.sendUserMessage(msg, { deliverAs: "followUp" });
|
||||
} catch (err) {
|
||||
pauseWith(ctx, "check injection failed", "check injection failed — /goal resume after resolving delivery");
|
||||
safeNotify(ctx, `goal: check injection failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop reason of the most recent assistant message, or undefined when none. */
|
||||
function lastAssistantStopReason(ctx: ExtensionContext): string | undefined {
|
||||
try {
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
for (let i = branch.length - 1; i >= 0; i--) {
|
||||
const entry = branch[i];
|
||||
if (entry?.type === "message") {
|
||||
const msg = entry.message;
|
||||
if (msg?.role === "assistant") return msg.stopReason;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// session introspection is best-effort
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pauseWith(ctx: ExtensionContext, reason: string, notice: string): void {
|
||||
state = pauseGoal(state, reason);
|
||||
persist(ctx);
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx, `goal: ${notice}`, "warning");
|
||||
}
|
||||
|
||||
// ---------- /goal command ----------
|
||||
|
||||
pi.registerCommand("goal", {
|
||||
description: "Set or manage the session goal: <text> | stop | clear | resume | --max N | --wait-timeout S",
|
||||
handler: async (args, ctx) => {
|
||||
const cmd = parseGoalCommand(args ?? "");
|
||||
switch (cmd.kind) {
|
||||
case "error":
|
||||
safeNotify(ctx, `goal: ${cmd.message}`, "error");
|
||||
return;
|
||||
case "status":
|
||||
notifyStatus(ctx);
|
||||
return;
|
||||
case "stop":
|
||||
if (state.status !== "active") {
|
||||
safeNotify(ctx, "goal: nothing active to stop", "warning");
|
||||
return;
|
||||
}
|
||||
pauseWith(ctx, "stopped by operator", "paused — /goal resume to continue");
|
||||
return;
|
||||
case "clear":
|
||||
if (state.status === "none" && !state.lastOutcome) {
|
||||
safeNotify(ctx, "goal: no goal set", "warning");
|
||||
return;
|
||||
}
|
||||
state = clearGoal(state);
|
||||
if (!persist(ctx)) return;
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx, "goal: cleared — normal operation", "info");
|
||||
return;
|
||||
case "resume":
|
||||
if (state.status !== "paused" && state.status !== "blocked") {
|
||||
safeNotify(ctx, "goal: nothing paused to resume", "warning");
|
||||
return;
|
||||
}
|
||||
state = resumeGoal(state);
|
||||
if (!persist(ctx)) return;
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx, "goal: resumed", "info");
|
||||
beginCheck(ctx);
|
||||
return;
|
||||
case "set": {
|
||||
const replacing = state.status !== "none";
|
||||
state = setGoal(state, cmd.text, cmd.max, cmd.waitTimeoutSeconds);
|
||||
if (!persist(ctx)) return;
|
||||
renderWidget(ctx);
|
||||
safeNotify(
|
||||
ctx,
|
||||
`${replacing ? "goal: replaced" : "goal: set"} — ${truncate(cmd.text, 60)} (cap ${state.maxChecks})`,
|
||||
"info",
|
||||
);
|
||||
beginCheck(ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut("alt+g", {
|
||||
description: "Show the full NG goal text and status",
|
||||
handler: async (ctx) => { notifyStatus(ctx); },
|
||||
});
|
||||
|
||||
// ---------- goal_report tool (the loop's only exit) ----------
|
||||
|
||||
pi.registerTool({
|
||||
name: "goal_report",
|
||||
label: "Goal Report",
|
||||
description:
|
||||
"Report progress on the active operator goal. Required at every goal check-in. " +
|
||||
"Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. " +
|
||||
'Use "satisfied" with evidence, "blocked" with the reason, or "in_progress" with ' +
|
||||
"mechanically backed structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked) or an explicit waiting state. Status-only and duplicate " +
|
||||
"in_progress reports count toward an automatic no-progress pause.",
|
||||
promptSnippet: "Report goal status with evidence and mechanically backed progress",
|
||||
parameters: Type.Object({
|
||||
status: StringEnum(["satisfied", "blocked", "in_progress"] as const),
|
||||
evidence: Type.Optional(
|
||||
Type.String({
|
||||
description: "Completion evidence, blocker reason, or concise evidence for this work cycle",
|
||||
}),
|
||||
),
|
||||
progress: Type.Optional(
|
||||
Type.Object({
|
||||
kind: StringEnum(["measurement", "action", "delegation", "wait", "non_tool"] as const),
|
||||
gate: Type.String({ description: "Nearest incomplete gate" }),
|
||||
owner: Type.String({ description: "Current gate or external-wait owner" }),
|
||||
lastMeasurement: Type.String({ description: "Most recent live measurement, including what and when" }),
|
||||
nextAction: Type.String({ description: "Next concrete action or check" }),
|
||||
artifact: Type.Optional(Type.String({ description: "Required artifact locator for non_tool work" })),
|
||||
watchId: Type.Optional(Type.String({ description: "Approved active watch identifier" })),
|
||||
nextCheck: Type.Optional(Type.String({ description: "Concrete condition that ends or rechecks a wait" })),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
if (state.status === "none") {
|
||||
return {
|
||||
content: [{ type: "text", text: "No goal is active. Nothing to report." }],
|
||||
details: { goalStatus: "none" },
|
||||
};
|
||||
}
|
||||
if (state.status !== "active") {
|
||||
throw new Error("Goal is paused; only /goal resume or a new operator goal permits reports");
|
||||
}
|
||||
const reportingState = state;
|
||||
// A v4 policy parses and resolves the untrimmed payload before any
|
||||
// existing validation, state transition, persistence, or UI operation.
|
||||
const policy = goalPolicyPublication(incarnationId);
|
||||
if (policy) {
|
||||
const attested = await validateAttestedGoalReport(params.evidence, policy, params.status);
|
||||
if (!attested.ok) {
|
||||
const denial = goalPolicyDenialCode(attested.reason);
|
||||
recordGoalPolicyDenial(incarnationId, denial);
|
||||
throw new Error(`goal_report rejected by attested policy: ${denial}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (state !== reportingState || state.status !== "active") {
|
||||
throw new Error("Goal changed while validating report; report was not applied");
|
||||
}
|
||||
const evidence = (params.evidence ?? "").trim();
|
||||
const reportError = validateReport(params.status, evidence);
|
||||
if (reportError) {
|
||||
throw new Error(reportError);
|
||||
}
|
||||
|
||||
if (params.status === "satisfied") {
|
||||
state = completeGoal(state, evidence);
|
||||
if (!persist(ctx)) {
|
||||
state = pauseGoal(reportingState, "state save failed; completion not recorded");
|
||||
throw new Error("Goal completion could not be saved; continuation stopped");
|
||||
}
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx,
|
||||
`goal: satisfied and cleared${evidence ? ` — ${truncate(evidence, 80)}` : ""}`,
|
||||
"info",
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Goal recorded as SATISFIED. The goal is cleared; normal operation resumes. Do not call goal_report again unless the operator sets a new goal.",
|
||||
},
|
||||
],
|
||||
details: { goalStatus: "none" },
|
||||
};
|
||||
}
|
||||
|
||||
if (params.status === "blocked") {
|
||||
const reason = evidence || "no reason given";
|
||||
state = blockGoal(recordReport(state), truncate(reason, 120));
|
||||
if (!persist(ctx)) throw new Error("Goal blocker could not be saved; continuation stopped");
|
||||
renderWidget(ctx);
|
||||
safeNotify(ctx, `goal: BLOCKED — ${truncate(reason, 100)} — /goal resume after resolving`, "warning");
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Goal recorded as BLOCKED. The loop is paused and the operator has been notified. Do not call goal_report again until the operator resumes or sets a new goal.",
|
||||
},
|
||||
],
|
||||
details: { goalStatus: "blocked" },
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = recordInProgressReport(state, {
|
||||
evidence,
|
||||
progress: params.progress as ProgressDetails | undefined,
|
||||
});
|
||||
state = outcome.state;
|
||||
if (!persist(ctx)) throw new Error("Goal state could not be saved; continuation stopped");
|
||||
renderWidget(ctx);
|
||||
|
||||
if (state.status === "paused") {
|
||||
safeNotify(ctx, `${state.pausedReason} — /goal resume after resolving`, "warning");
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Goal paused: ${state.pausedReason} (${outcome.reason}). The operator has been notified. Do not continue this goal until the operator resumes it.`,
|
||||
},
|
||||
],
|
||||
details: { goalStatus: "paused", classification: outcome.classification },
|
||||
};
|
||||
}
|
||||
|
||||
const text =
|
||||
outcome.classification === "progress"
|
||||
? `Recorded substantive progress (${outcome.reason}). Continue with the recorded next action.`
|
||||
: outcome.classification === "waiting"
|
||||
? (state.waitTimeoutSeconds && ctx.mode !== "print"
|
||||
? `Waiting recorded. Automatic goal checks are suspended until a relevant incoming message or the deadline at ${new Date(state.activeWait!.deadlineAt!).toISOString()}. The extension owns this single deadline wake; do not add timers or report unchanged status. Checkpoint and yield now.`
|
||||
: (ctx.mode === "print" ? "Waiting recorded. Print mode cannot schedule a wake; resume this goal in a persistent session." : `Recorded explicit waiting state (${outcome.reason}). Keep the approved watch or next-check condition active.`))
|
||||
: `No new progress recorded (${outcome.reason}). No-progress count is ${state.noProgressReports}/${state.maxNoProgressReports}; take the next safe action before reporting again.`;
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { goalStatus: "active", classification: outcome.classification },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------- loop wiring ----------
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (state.status !== "active") {
|
||||
if (event.prompt.startsWith("[goal check ")) {
|
||||
return { systemPrompt: `${event.systemPrompt}\nThis queued goal check is obsolete: the goal is paused or cleared. Do not execute it or call goal_report.` };
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.activeWait?.wakeRequestId && event.prompt.includes(`[goal wake ${state.activeWait.wakeRequestId}]`)) {
|
||||
state = { ...state, activeWait: { ...state.activeWait, wakeObserved: true } };
|
||||
if (!persist(ctx)) return { systemPrompt: `${event.systemPrompt}\nGoal state save failed. Stop this goal and report the recording failure.` };
|
||||
}
|
||||
const waitingDirective = state.waitTimeoutSeconds && ctx.mode !== "print"
|
||||
? " Bounded waits enabled: report in_progress with progress.kind=wait and a concrete nextCheck when no ready work remains. The extension suspends checks and supplies one deadline wake per goal/resume. Do not create a duplicate timer, sleep loop, or heartbeat. On unrelated incoming messages, keep the wait; on a relevant event verify the dependency. After the deadline wake, an unresolved wait pauses."
|
||||
: "";
|
||||
const directive =
|
||||
`[goal] ACTIVE OPERATOR GOAL: ${state.text}\n` +
|
||||
`Work toward this goal. Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop. ` +
|
||||
`At every check-in you MUST call goal_report exactly once. Use ` +
|
||||
`"satisfied" with evidence when fully met or "blocked" with the reason when no meaningful next step ` +
|
||||
`exists. An "in_progress" report MUST follow a concrete action and use structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked), or ` +
|
||||
`declare an explicit approved wait/non-tool artifact. Status-only and duplicate reports are no progress. ` +
|
||||
`Do not end a turn without reporting.${waitingDirective}`;
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${directive}` };
|
||||
});
|
||||
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
if (state.status !== "active" || event.isError || event.toolName === "goal_report") return;
|
||||
state = recordWorkEvent(state, event.toolName);
|
||||
persist(ctx);
|
||||
renderWidget(ctx);
|
||||
});
|
||||
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
if (state.status !== "active") return;
|
||||
|
||||
// Abort/error pause (Q3i) and runaway cap (Q4) live in lib/settle.ts (pure, tested).
|
||||
const decision = decideSettle(state, lastAssistantStopReason(ctx));
|
||||
if (decision.action === "pause") {
|
||||
pauseWith(ctx, decision.reason, `${decision.reason} — /goal resume to continue`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.action === "wait") {
|
||||
if (deadlinePending) wakeDeadline(ctx);
|
||||
else syncWaitTimer(ctx);
|
||||
return;
|
||||
}
|
||||
beginCheck(ctx);
|
||||
});
|
||||
|
||||
// Teardown guard: after session replacement/reload/quit, stop touching ctx.
|
||||
pi.on("session_shutdown", async () => {
|
||||
dead = true;
|
||||
cancelWaitTimer();
|
||||
});
|
||||
|
||||
// Seat-durable state (Q7b): reload from disk on every session start/reload/resume/new
|
||||
// so a fresh session in the same seat picks up the active goal.
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
dead = false;
|
||||
state = loadState(statePath);
|
||||
syncWaitTimer(ctx);
|
||||
renderWidget(ctx);
|
||||
if (state.status === "active") {
|
||||
safeNotify(ctx, `goal: active — ${truncate(state.text, 60)}`, "info");
|
||||
// Durability (Q7b): a fresh session with an active goal re-engages the
|
||||
// loop immediately — no prior turn exists to emit agent_settled.
|
||||
if (!(state.waitTimeoutSeconds && state.activeWait && !state.activeWait.wakeSent)) beginCheck(ctx);
|
||||
} else if (state.status === "paused" || state.status === "blocked") {
|
||||
safeNotify(ctx, "goal: paused — /goal resume to continue", "info");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = process.env.MOSAIC_CONTRACT_ROOT ?? join(HERE, "fixtures");
|
||||
|
||||
const proactive = readFileSync(join(ROOT, "skills-local", "ms-proactive-agent", "SKILL.md"), "utf8");
|
||||
const executive = readFileSync(join(ROOT, "skills-local", "ms-executive-update", "SKILL.md"), "utf8");
|
||||
const honesty = readFileSync(join(ROOT, "skills-local", "ms-honesty", "SKILL.md"), "utf8");
|
||||
|
||||
test("cross-seat waits require communication rather than agent-watch polling", () => {
|
||||
assert.equal(
|
||||
proactive.includes("A row waiting on someone else is not a candidate; it gets a watch"),
|
||||
false,
|
||||
"proactive contract must not contradict ms-agent-watch",
|
||||
);
|
||||
assert.match(proactive, /Use existing message delivery for another agent.s response/);
|
||||
assert.match(proactive, /Do not poll its private\s+files or pane/);
|
||||
assert.match(proactive, /external\s+conditions with no existing wake owner/);
|
||||
});
|
||||
|
||||
test("proactive turns close every communication obligation before reporting", () => {
|
||||
assert.match(proactive, /Close communication obligations/);
|
||||
for (const required of [
|
||||
"persist any required",
|
||||
"exact receipt",
|
||||
"return event",
|
||||
"escalation owner",
|
||||
"A report to the user is not delivery to another agent",
|
||||
]) {
|
||||
assert.equal(proactive.includes(required), true, `missing proactive closeout requirement: ${required}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("executive updates cannot replace direct owner communication", () => {
|
||||
assert.match(executive, /## Communication closeout before reporting/);
|
||||
assert.match(executive, /An executive update does not replace direct communication/);
|
||||
assert.match(executive, /A no-change line does not satisfy an unsent acknowledgement, handoff, review request,\s+blocker, or result/);
|
||||
});
|
||||
|
||||
test("honesty classifies communication state and routes access limits", () => {
|
||||
assert.match(honesty, /## Communication evidence/);
|
||||
assert.match(honesty, /Drafted is not sent\. Sent is not delivered\. Delivered is not acknowledged\. Acknowledged is not completed\./);
|
||||
assert.match(honesty, /A credential refusal is correct behavior, but it is not a terminal communication state/);
|
||||
assert.equal(
|
||||
honesty.includes("actually re-measured the thing you are waiting on, or a watch is armed to do so"),
|
||||
false,
|
||||
"another-seat waits must not inherit the stale remeasure-or-watch binary",
|
||||
);
|
||||
assert.match(
|
||||
honesty,
|
||||
/Another-seat and operator waits use the\s+recorded delivery receipt, required return event, escalation owner, and existing wake\s+path\. They do not require polling or an `agent-watch`\./,
|
||||
);
|
||||
assert.match(honesty, /External-condition waits require\s+a fresh measurement or a permitted watch\./);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { goalDisplay, goalDetails } from "../lib/display.ts";
|
||||
import { initialState, setGoal, pauseGoal, blockGoal, resumeGoal, completeGoal, clearGoal } from "../lib/state.ts";
|
||||
import { saveState, loadState } from "../lib/store.ts";
|
||||
|
||||
const text = "Long goal\n" + "Full acceptance criteria, never truncate. ".repeat(100) + "\nEND-OF-GOAL";
|
||||
|
||||
test("footer maps each lifecycle state to text and a theme role", () => {
|
||||
const active = setGoal(initialState(), text);
|
||||
for (const [state, label, color] of [
|
||||
[initialState(), "None", "muted"],
|
||||
[active, "Active", "accent"],
|
||||
[{ ...active, activeWait: { owner: "reviewer" } }, "Waiting", "warning"],
|
||||
[{ ...active, activeWait: { owner: "reviewer", wakeSent: true } }, "Active", "accent"],
|
||||
[pauseGoal(active, "operator stop"), "Paused", "warning"],
|
||||
[pauseGoal(active, "blocked: legacy reason"), "Blocked", "error"],
|
||||
[blockGoal(active, "missing permission"), "Blocked", "error"],
|
||||
[completeGoal(active, "verified"), "Complete", "success"],
|
||||
] as const) assert.deepEqual(goalDisplay(state), { label, color });
|
||||
});
|
||||
|
||||
test("full multiline goal survives completion, persistence, and read-only recall", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-display-"));
|
||||
try {
|
||||
const active = setGoal(initialState(), text);
|
||||
const complete = completeGoal(active, "verified ".repeat(300));
|
||||
assert.equal(complete.lastOutcome?.text, text);
|
||||
assert.equal(complete.lastOutcome?.evidence.length, 1000);
|
||||
assert.equal(complete.status, "none");
|
||||
assert.deepEqual(resumeGoal(complete), complete);
|
||||
const path = join(dir, "state.json");
|
||||
saveState(complete, path);
|
||||
assert.deepEqual(loadState(path), complete);
|
||||
for (const state of [active, blockGoal(active, "denied"), pauseGoal(active, "stop"), loadState(path)]) {
|
||||
const before = JSON.stringify(state);
|
||||
assert.ok(goalDetails(state).includes(text));
|
||||
assert.equal(JSON.stringify(state), before);
|
||||
}
|
||||
assert.equal(goalDisplay(clearGoal(complete)).label, "None");
|
||||
assert.equal(setGoal(complete, "replacement").lastOutcome, undefined);
|
||||
assert.equal(resumeGoal(blockGoal(active, "denied")).status, "active");
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("old records and malformed outcome fields are safe to recall", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-display-"));
|
||||
const path = join(dir, "state.json");
|
||||
try {
|
||||
const old = pauseGoal(setGoal(initialState(), text), "blocked: legacy");
|
||||
saveState(old, path);
|
||||
assert.equal(goalDisplay(loadState(path)).label, "Blocked");
|
||||
for (const lastOutcome of [null, {}, { status: "complete", text: 123 }, { status: "complete", text, evidence: "yes", at: "invalid" }]) {
|
||||
saveState({ ...initialState(), lastOutcome } as any, path);
|
||||
assert.equal(goalDisplay(loadState(path)).label, "None");
|
||||
assert.doesNotThrow(() => goalDetails(loadState(path)));
|
||||
}
|
||||
saveState({ ...old, pausedReason: {} } as any, path);
|
||||
assert.doesNotThrow(() => goalDetails(loadState(path)));
|
||||
assert.doesNotThrow(() => goalDetails({ ...setGoal(initialState(), text), activeWait: { owner: "test", deadlineAt: 1e100 } }));
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// T165 WP1 parser conformance fixtures derived from the cited Machine contract.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { parseExecutiveUpdate, validateAttestedGoalReport } from "../lib/executive-update.ts";
|
||||
import type { GoalPolicyPublication } from "../../mosaic-core/lib/goal-policy.ts";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const CONTRACT = readFileSync(join(HERE, "fixtures", "skills-local", "ms-executive-update", "SKILL.md"), "utf8");
|
||||
const NO_CHANGE = "No change since T165; still waiting on T165.";
|
||||
|
||||
const UPDATE = [
|
||||
"Just Completed:",
|
||||
"",
|
||||
"* T165: completed contracts",
|
||||
"",
|
||||
"Next Step:",
|
||||
"",
|
||||
"* `fleet/extensions/goal/index.ts`: validate policy",
|
||||
"",
|
||||
"Blocked:",
|
||||
"",
|
||||
"* GOAL-PROACTIVE-LOOP-REV-Q107: review complete \u2014 nothing from you",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
function publication(resolver: GoalPolicyPublication["resolver"]): GoalPolicyPublication {
|
||||
return {
|
||||
attestation: Object.freeze({
|
||||
schemaVersion: 1,
|
||||
role: "plan-ng",
|
||||
roleRevision: 4,
|
||||
manifestSha256: "a".repeat(64),
|
||||
format: "ms-executive-update/v1",
|
||||
contractPath: "skills-local/ms-executive-update/SKILL.md",
|
||||
contractSection: "Machine contract (for `goal_report` payloads and any parser)",
|
||||
contractBlob: "df30c6fbb54b4a65a298c9e51c07f742610d171c",
|
||||
contractSha256: "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319",
|
||||
enforcement: "pre-state-change-fail-closed",
|
||||
identifierResolution: "consumer-fail-closed",
|
||||
launchGeneration: 1,
|
||||
incarnationId: "inc-goal-parser",
|
||||
}),
|
||||
resolver,
|
||||
};
|
||||
}
|
||||
|
||||
function resolved(options: { changed?: boolean; evidence?: string | null; frozen?: boolean } = {}) {
|
||||
const value = {
|
||||
outcome: "resolved" as const,
|
||||
objectId: "tracked-object",
|
||||
objectSha256: "b".repeat(64),
|
||||
changedSincePreviousAcceptedReport: options.changed ?? true,
|
||||
completionEvidenceId: options.evidence === undefined ? "immutable-evidence" : options.evidence,
|
||||
};
|
||||
return options.frozen === false ? value : Object.freeze(value);
|
||||
}
|
||||
|
||||
test("C7: cited Machine contract remains the parser's one input authority", () => {
|
||||
assert.equal(CONTRACT.includes("## Machine contract (for `goal_report` payloads and any parser)"), true);
|
||||
assert.equal(CONTRACT.includes("update := section(\"Just Completed\")"), true);
|
||||
const parsed = parseExecutiveUpdate(UPDATE);
|
||||
assert.equal(parsed.ok, true, parsed.ok ? "" : parsed.reason);
|
||||
if (parsed.ok) {
|
||||
assert.deepEqual(parsed.items.map((item) => [item.section, item.token]), [
|
||||
["Just Completed", "T165"],
|
||||
["Next Step", "`fleet/extensions/goal/index.ts`"],
|
||||
["Blocked", "GOAL-PROACTIVE-LOOP-REV-Q107"],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("C7: heading, LF, identifier, bullet count, and Blocked disposition violations reject", () => {
|
||||
const tooMany = [
|
||||
"Just Completed:", "", "* none", "", "Next Step:", "",
|
||||
"* T1: one", "* T2: two", "* T3: three", "* T4: four", "* T5: five", "* T6: six",
|
||||
"", "Blocked:", "", "* T1: none \u2014 nothing from you", "",
|
||||
].join("\n");
|
||||
for (const invalid of [
|
||||
UPDATE.replace("Just Completed:", "just completed:"),
|
||||
UPDATE.replace("\n", "\r\n"),
|
||||
UPDATE.replace("T165", "task-165"),
|
||||
UPDATE.replace("\u2014 nothing from you", "waiting"),
|
||||
UPDATE.slice(0, -1),
|
||||
tooMany,
|
||||
]) {
|
||||
assert.equal(parseExecutiveUpdate(invalid).ok, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("C8: zero, multiple, unavailable, stale, malformed, and unproven completions reject", async () => {
|
||||
for (const outcome of ["zero", "multiple", "unavailable", "stale"] as const) {
|
||||
const result = await validateAttestedGoalReport(UPDATE, publication(async () => Object.freeze({ outcome })), "in_progress");
|
||||
assert.equal(result.ok, false, outcome);
|
||||
}
|
||||
assert.equal((await validateAttestedGoalReport(UPDATE, publication(async () => resolved({ frozen: false }) as never), "in_progress")).ok, false);
|
||||
assert.equal((await validateAttestedGoalReport(UPDATE, publication(async () => resolved({ evidence: null })), "in_progress")).ok, false);
|
||||
assert.equal((await validateAttestedGoalReport(UPDATE, publication(async () => resolved({ changed: false })), "in_progress")).ok, false);
|
||||
assert.equal((await validateAttestedGoalReport(UPDATE, publication(async () => resolved()), "in_progress")).ok, true);
|
||||
});
|
||||
|
||||
test("Q118/F5: contract-authorized no-change form is status-aware and requires unchanged tracked state", async () => {
|
||||
const parsed = parseExecutiveUpdate(NO_CHANGE);
|
||||
assert.equal(parsed.ok, true);
|
||||
if (parsed.ok) {
|
||||
assert.equal(parsed.kind, "no-change");
|
||||
assert.deepEqual(parsed.items.map((item) => [item.section, item.token]), [["Next Step", "T165"], ["Next Step", "T165"]]);
|
||||
}
|
||||
|
||||
let resolverCalls = 0;
|
||||
const unchanged = publication(async () => {
|
||||
resolverCalls += 1;
|
||||
return resolved({ changed: false, evidence: null });
|
||||
});
|
||||
assert.equal((await validateAttestedGoalReport(NO_CHANGE, unchanged, "in_progress")).ok, true);
|
||||
assert.equal(resolverCalls, 2, "both no-change references resolve through the attested consumer");
|
||||
|
||||
resolverCalls = 0;
|
||||
const terminal = await validateAttestedGoalReport(NO_CHANGE, unchanged, "satisfied");
|
||||
assert.equal(terminal.ok, false, "no-change is never a terminal report");
|
||||
assert.equal(resolverCalls, 0, "wrong status rejects before resolver or Goal state mutation");
|
||||
|
||||
assert.equal((await validateAttestedGoalReport(NO_CHANGE, publication(async () => resolved({ changed: true })), "in_progress")).ok, false);
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// Hermetic NG-7 tests: goal-state incarnation fencing (Mercer F1 class).
|
||||
// Run: node --test test/fencing.test.ts
|
||||
//
|
||||
// SAFETY (NG7-SAFETY-V1W2): every arm uses an ISOLATED temp directory —
|
||||
// no live seat state, no Mercer/Marcie/marcie-2 files, no running sessions.
|
||||
// The extension source edit is confined to store resolution (store.ts) and
|
||||
// the one-line index.ts wiring.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
resolveStatePath,
|
||||
fencedStateFilePath,
|
||||
loadState,
|
||||
saveState,
|
||||
stateFilePath,
|
||||
} from "../lib/store.ts";
|
||||
import { setGoal, pauseGoal, initialState } from "../lib/state.ts";
|
||||
|
||||
function fixtureDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "goal-fence-"));
|
||||
}
|
||||
|
||||
function writeLegacy(dir: string, text: string): void {
|
||||
writeFileSync(stateFilePath(dir), JSON.stringify(setGoal(initialState(), text), null, 2) + "\n");
|
||||
}
|
||||
|
||||
// ---- (a) hostile two-incarnation collision: the measured class ---------------
|
||||
|
||||
test("(a) two incarnations of one seat never share or clobber state", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-marcie" });
|
||||
const b = resolveStatePath(dir, { incarnationId: "inc-marcie-2" });
|
||||
assert.notEqual(a, b, "distinct incarnations must resolve distinct files");
|
||||
|
||||
// incarnation A sets an active goal
|
||||
const stateA = setGoal(initialState(), "ship the canary");
|
||||
saveState(stateA, a);
|
||||
// incarnation B (a second process of the SAME seat) loads ITS state
|
||||
const stateB = loadState(b);
|
||||
assert.equal(stateB.status, "none", "B must not inherit A's active focus");
|
||||
assert.equal(stateB.text, "", "B sees no goal text of A's");
|
||||
// B sets its own goal; A's file is untouched
|
||||
saveState(setGoal(initialState(), "B's own goal"), b);
|
||||
assert.equal(loadState(a).text, "ship the canary", "A's focus survives B's activity");
|
||||
assert.equal(loadState(b).text, "B's own goal");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(a) B cannot pause or settle A's focus through any store operation", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-a" });
|
||||
const b = resolveStatePath(dir, { incarnationId: "inc-b" });
|
||||
saveState(setGoal(initialState(), "A active"), a);
|
||||
|
||||
// Every mutation B performs addresses b's path only; A's bytes cannot
|
||||
// change through B's store API — proven by writing from B and re-reading A.
|
||||
saveState(pauseGoal(loadState(b), "blocked:sibling", "cap"), b);
|
||||
const aNow = loadState(a);
|
||||
assert.equal(aNow.status, "active", "A's focus is still active after B's pause");
|
||||
assert.equal(aNow.pausedReason, undefined);
|
||||
|
||||
// And the directory contains exactly the two fenced files.
|
||||
const names = readdirSync(dir).filter((n) => n.startsWith("goal-state"));
|
||||
assert.equal(names.length, 2);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(a) session_start-style reload stays incarnation-scoped", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-a" });
|
||||
saveState(setGoal(initialState(), "A goal v1"), a);
|
||||
// simulate: A's session reloads (new/resume/reload all re-load from ITS path)
|
||||
const reloaded = loadState(resolveStatePath(dir, { incarnationId: "inc-a" }));
|
||||
assert.equal(reloaded.text, "A goal v1");
|
||||
// a sibling's state never leaks into A's reload
|
||||
saveState(setGoal(initialState(), "sibling"), resolveStatePath(dir, { incarnationId: "inc-sib" }));
|
||||
assert.equal(loadState(resolveStatePath(dir, { incarnationId: "inc-a" })).text, "A goal v1");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- (b) legacy migration fails safe -------------------------------------------
|
||||
|
||||
test("(b) legacy unfenced state is QUARANTINED, never claimed (NG-7 F4)", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
writeLegacy(dir, "legacy active goal");
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-first" });
|
||||
// FAIL SAFE: the first incarnation must NOT inherit the unknown owner's
|
||||
// ACTIVE focus — it starts fresh.
|
||||
const state = loadState(a);
|
||||
assert.equal(state.status, "none", "no active focus inherited from legacy");
|
||||
assert.equal(state.text, "");
|
||||
// EVIDENCE PRESERVED: the legacy bytes live on in the quarantine file.
|
||||
const quarantined = JSON.parse(readFileSync(join(dir, "goal-state.legacy.json"), "utf8")) as { text: string };
|
||||
assert.equal(quarantined.text, "legacy active goal");
|
||||
const names = readdirSync(dir);
|
||||
assert.equal(names.includes("goal-state.json"), false, "legacy name vacated");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(b/F4) ACTIVE legacy + fresh incarnation: no active focus inherited, evidence preserved", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
// the live canary shape: legacy state carrying an ACTIVE focus of a
|
||||
// previous (unknown-owner) incarnation
|
||||
writeLegacy(dir, "mercer's previous focus");
|
||||
for (const inc of ["inc-canary-1", "inc-canary-2"]) {
|
||||
const p = resolveStatePath(dir, { incarnationId: inc });
|
||||
assert.equal(loadState(p).status, "none", `${inc} inherits no active focus`);
|
||||
}
|
||||
// second quarantine slot when another legacy reappears (counter path)
|
||||
writeLegacy(dir, "second legacy");
|
||||
resolveStatePath(dir, { incarnationId: "inc-third" });
|
||||
assert.equal(
|
||||
readFileSync(join(dir, "goal-state.legacy.1.json"), "utf8").includes("second legacy"),
|
||||
true,
|
||||
"counter slot preserves the second legacy",
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(b/F3) migration race: rename attempted with legacy present, failure -> fresh", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
writeLegacy(dir, "legacy goal");
|
||||
let renameCalls = 0;
|
||||
const path = resolveStatePath(dir, {
|
||||
incarnationId: "inc-raced",
|
||||
io: {
|
||||
// sequenced: fenced absent (first probe), legacy present (second)
|
||||
existsSync: (p: string) => p === stateFilePath(dir),
|
||||
renameSync: () => {
|
||||
renameCalls += 1;
|
||||
throw new Error("EEXIST: sibling quarantined it first");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(path, fencedStateFilePath(dir, "inc-raced"));
|
||||
assert.equal(renameCalls, 1, "the rename branch EXECUTED (not the fenced early-return)");
|
||||
assert.equal(loadState(path).status, "none", "race loses to fresh");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("(b/F2) traversal-shaped launcher claim is rejected at the identity source", async () => {
|
||||
const { incarnationIdentity, resetIncarnationGlobal } = await import("../../mosaic-core/lib/incarnation.ts");
|
||||
const prev = process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
resetIncarnationGlobal();
|
||||
try {
|
||||
for (const hostile of ["../../etc/pwn", "..", "/abs", "a/b", ".hidden", "x".repeat(200)]) {
|
||||
process.env.MOSAIC_LAUNCH_INCARNATION = hostile;
|
||||
const id = incarnationIdentity(() => `minted-for-${hostile.slice(0, 3)}`);
|
||||
assert.notEqual(id, hostile, `claim ${JSON.stringify(hostile)} must not become the identity`);
|
||||
assert.equal(id.startsWith("minted-for-"), true, `falls through to mint for ${JSON.stringify(hostile)}`);
|
||||
resetIncarnationGlobal();
|
||||
}
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
else process.env.MOSAIC_LAUNCH_INCARNATION = prev;
|
||||
resetIncarnationGlobal();
|
||||
}
|
||||
});
|
||||
|
||||
test("(b/F2) hostile claim keeps BOTH consumer paths inside the state root", async () => {
|
||||
const { createJournal } = await import("../../mosaic-core/lib/journal.ts");
|
||||
const { incarnationIdentity, resetIncarnationGlobal } = await import("../../mosaic-core/lib/incarnation.ts");
|
||||
const prev = process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
resetIncarnationGlobal();
|
||||
try {
|
||||
process.env.MOSAIC_LAUNCH_INCARNATION = "../../outside";
|
||||
const id = incarnationIdentity(); // no-arg production form (F1)
|
||||
assert.notEqual(id, "../../outside");
|
||||
const j = createJournal({ incarnationId: id, stateHome: "/xdg/state", io: { mkdirSync() {}, appendFileSync() {} } });
|
||||
assert.equal(j.path.startsWith("/xdg/state/mosaic-core/"), true, "journal contained");
|
||||
assert.equal(j.path.includes(".."), false);
|
||||
const dir = fixtureDir();
|
||||
const gp = resolveStatePath(dir, { incarnationId: id });
|
||||
assert.equal(gp.startsWith(dir), true, "goal path contained");
|
||||
assert.equal(gp.includes(".."), false);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
else process.env.MOSAIC_LAUNCH_INCARNATION = prev;
|
||||
resetIncarnationGlobal();
|
||||
}
|
||||
});
|
||||
|
||||
test("(b) corrupt legacy never blocks fencing", () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
writeFileSync(stateFilePath(dir), "{corrupt");
|
||||
const a = resolveStatePath(dir, { incarnationId: "inc-x" });
|
||||
assert.equal(loadState(a).status, "none", "corrupt legacy -> fresh initial state");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- (c) PI_SESSION_ID is never the incarnation --------------------------------
|
||||
|
||||
test("(c) fenced paths key on the process-launch identity, not PI_SESSION_ID", async () => {
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const prev = process.env.PI_SESSION_ID;
|
||||
process.env.PI_SESSION_ID = "session-should-not-appear";
|
||||
const { incarnationIdentity } = await import("../../mosaic-core/lib/incarnation.ts");
|
||||
const id = incarnationIdentity(() => "minted-check");
|
||||
process.env.PI_SESSION_ID = prev === undefined ? "" : prev;
|
||||
if (prev === undefined) delete process.env.PI_SESSION_ID;
|
||||
assert.notEqual(id, "session-should-not-appear");
|
||||
const p = resolveStatePath(dir, { incarnationId: id });
|
||||
assert.doesNotMatch(p, /session-should-not-appear/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- compatibility ---------------------------------------------------------------
|
||||
|
||||
test("goal_report compatibility: the state machine surface is unchanged", async () => {
|
||||
// The pure state machine (setGoal/pauseGoal/resume/recordReport/validateReport)
|
||||
// is untouched by fencing; the existing goal suite covers it. Here we pin
|
||||
// the compatibility contract fencing must not break: a state saved and
|
||||
// loaded through the fenced path round-trips exactly as the legacy path did.
|
||||
const dir = fixtureDir();
|
||||
try {
|
||||
const p = resolveStatePath(dir, { incarnationId: "inc-compat" });
|
||||
const s = setGoal(initialState(), "round trip");
|
||||
saveState(s, p);
|
||||
assert.deepEqual(loadState(p), s);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
skills-local/**/SKILL.md whitespace=-trailing-space
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
name: ms-executive-update
|
||||
description: "Use for every user-bound status update, progress report, and at the end of a user-originated operation."
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Executive Update
|
||||
|
||||
The operator reads status to decide what to do next, not to relive the work. An update is
|
||||
three short lists derived from the tracking files, not from memory. If an item is not in a
|
||||
tracking file, it is not done — write the file first, then report.
|
||||
|
||||
Applies to every agent reporting upward: a seat to its orchestrator, an orchestrator to the
|
||||
operator, a `/goal` loop reporting through `goal_report`, a sub-agent returning to its parent.
|
||||
|
||||
## When to use
|
||||
|
||||
- Any time you would otherwise narrate progress: after a task lands, after a fleet event
|
||||
(monitor tick, agent message, review verdict, CI result) changes tracked state, at the end
|
||||
of a turn with work in flight, or when asked "status", "update", "where are we".
|
||||
- Every `goal_report` call: the `evidence` (satisfied) or `reason` (blocked / in_progress)
|
||||
payload is an executive update in this format, so the operator sees the same shape from a
|
||||
goal loop as from a conversation.
|
||||
- Do **not** emit a full update when nothing changed. One line with
|
||||
`No change since <anchor>; still waiting on <item>.` is the whole reply.
|
||||
|
||||
## Communication closeout before reporting
|
||||
|
||||
An executive update does not replace direct communication. Before writing the update:
|
||||
|
||||
1. Reply to every inbound actionable message with the action taken, routed owner, or
|
||||
precise blocker.
|
||||
2. Send every new tasking, handoff, review request, blocker, result, correction, and
|
||||
decision request directly to the responsible seat or operator through the required
|
||||
path in `docs/guides/FLEET-COMMS.md`.
|
||||
3. Commit the durable artifact before sending its pointer when the communication needs to
|
||||
survive a pane or session.
|
||||
4. Record the destination, request or task id, exact delivery receipt, required return
|
||||
event, and escalation owner in the tracking file.
|
||||
|
||||
A no-change line does not satisfy an unsent acknowledgement, handoff, review request,
|
||||
blocker, or result. Neither does a `goal_report`, lane edit, board row, or operator-facing
|
||||
reply. Finish communication closeout first, then report the resulting state.
|
||||
|
||||
## Format: exact, no preamble, no epilogue
|
||||
|
||||
``` markdown
|
||||
Just Completed:
|
||||
* <item>: short description
|
||||
|
||||
Next Step:
|
||||
* <item>: short description
|
||||
|
||||
Blocked:
|
||||
* <item>: short description
|
||||
```
|
||||
|
||||
- `<item>` is the tracked identifier: task id (`T158`), ledger row (`E6`), PR (`#1491`),
|
||||
review marker, file, or commit. Never a vague noun ("the fix").
|
||||
- One line per bullet. ≤5 bullets per section; if you have more, the lower ones belong in
|
||||
the ledger, not the update. Keep all three headings; write `* none` when a section is empty.
|
||||
- Paths as clickable links; commits and SHAs short (8 chars).
|
||||
- Only items whose state **changed since your previous update** go under Just Completed.
|
||||
Next Step lists what happens next and who does it. Blocked names the blocker, who
|
||||
unblocks it, and what (if anything) is needed from the reader.
|
||||
|
||||
## Source of truth — mandatory before writing
|
||||
|
||||
Read, in this order, whatever applies to the work in flight; the update is a projection of
|
||||
these files (paths relative to the brain, `$MOSAIC_BRAIN_HOME`, per `docs/STRUCTURE-CANON.md`):
|
||||
|
||||
1. Lane ledger: `fleet/lanes/<lane>/TASKS.md` (open rows, dated entries) and
|
||||
`TASKS-DONE.md` (what moved to done).
|
||||
2. Seat files for every agent involved: `fleet/agents/<seat>/TASKS.md`, `STATE.md`,
|
||||
`work/` artifacts (review verdicts, plans, evidence records).
|
||||
3. Board: `fleet/board/MASTER-TASKS.md` and `fleet/board/taskings/<date>_T<n>_*.md` for
|
||||
task ids, owners, and reassignments.
|
||||
4. Project-level `docs/TASKS.md` when the orchestrator protocol makes it the control plane.
|
||||
5. Live evidence for anything claimed done: `git log` on the target branch/trunk, the PR,
|
||||
CI state, the seat's pane (per `docs/guides/FLEET-COMMS.md`), delivery receipts.
|
||||
|
||||
Rules:
|
||||
|
||||
- **Record, then report.** A state change learned from a pane, a monitor event, or a
|
||||
message is written into the owning tracking file (dated, with ids and SHAs) *before* it
|
||||
appears in the update. An item in the update with no tracking-file row is a defect.
|
||||
- **Evidence for "completed".** A Just Completed bullet cites its evidence: commit, merge
|
||||
SHA, verdict artifact path, receipt. "Delivered" is not "done"; "dispatched" is not
|
||||
"done"; a message sent is Next Step for the recipient, not a completion.
|
||||
- **Events are not the operator.** Never phrase a monitor event, agent message, or tool
|
||||
result as approval, confirmation, or a decision. Decisions come only from the operator's
|
||||
own messages.
|
||||
- **Blocked is precise.** `* T158: plan seat queued behind prior review — nothing from you`
|
||||
or `* Q96 gate: Decision needed — (1) … (2) …; recommend 1`. If the reader must decide,
|
||||
say so with the options and your recommendation; if not, say `nothing from you`.
|
||||
- **Honest state.** Distinguish known (read from a file or evidence) from inferred (seen in
|
||||
a pane, not yet recorded); mark inferred items `(unverified)`. Report failures with the
|
||||
output, skipped steps as skipped. Apply `ms-honesty`.
|
||||
- **Staleness anchor.** Include the anchor the update is current to when it matters:
|
||||
target head SHA, pipeline number, board commit, or timestamp.
|
||||
- **Delegation stays visible.** Work handed to a seat is reported with the seat and task
|
||||
id. The tracking row also carries the delivery receipt, required return event, and
|
||||
escalation owner. You own the follow-up until the seat reports back and the ledger
|
||||
reflects it.
|
||||
|
||||
## Machine contract (for `goal_report` payloads and any parser)
|
||||
|
||||
When an update is consumed by tooling (a `goal_report` payload, a broker, a coordinator),
|
||||
the loose human rules above tighten to this closed grammar. Human-facing replies may add
|
||||
Markdown links; payloads may not.
|
||||
|
||||
```
|
||||
update := section("Just Completed") NL section("Next Step") NL section("Blocked")
|
||||
section := HEADING ":" NL NL ( bullet+ | "* none" NL )
|
||||
HEADING := exact text, case-sensitive, in this order, each exactly once
|
||||
bullet := "* " item ": " text NL (1..5 per section; text is one line, no leading "-")
|
||||
item := taskid | pr | row | marker | sha | path
|
||||
taskid := "T" [0-9]+
|
||||
pr := "#" [0-9]+
|
||||
row := [A-Z]{1,4} "." ? [0-9]+ ("." [0-9]+)* e.g. E6, A7, GOV.5
|
||||
marker := [A-Z0-9]+ ("-" [A-Z0-9]+)* "-Q" [0-9]+ e.g. CPS-PRD-REV1-MERGE-GATE-Q96
|
||||
sha := [0-9a-f]{40} (payloads: full SHA; 8-char only in prose)
|
||||
path := "`" <repo-relative path, no spaces> "`"
|
||||
NL := "\n" (LF only; no trailing whitespace; no blank lines inside a section)
|
||||
```
|
||||
|
||||
- Exactly three sections; any other heading, a missing section, or a reordered section is a
|
||||
parse failure.
|
||||
- A bullet under Blocked ends with either `— nothing from you` or
|
||||
`— Decision needed: (1) … (2) …; recommend N`.
|
||||
- Identifier resolution is the consumer's job and is fail-closed: an item that resolves to
|
||||
zero or more than one tracked object (unknown task id, ambiguous short SHA, unknown marker)
|
||||
rejects the report before any state change; the producer gets the rejection and re-reports.
|
||||
- A one-line "no change" reply (`No change since <item>; still waiting on <item>.`) is a
|
||||
distinct message, not an update; a `goal_report` with status `in_progress` may carry it
|
||||
only when the tracked state is unchanged since the previous report.
|
||||
- This grammar is the format's single definition. Enforcement (who parses, what policy binds
|
||||
it to a role) is decided by the runtime's role manifests, not by this skill.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Narrative paragraphs, "I'll continue by…", recaps of what the reader already knows.
|
||||
- Reporting from conversation memory while the ledger still says the old state.
|
||||
- Listing the same item under two sections.
|
||||
- Padding Just Completed with process ("read the file", "checked the pane").
|
||||
- Hiding a needed decision inside Next Step.
|
||||
- Treating the executive update as delivery to a seat that was never contacted directly.
|
||||
- Reporting a handoff without its receipt, return event, and escalation owner.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
Just Completed:
|
||||
|
||||
* #1491: round-2 fix pushed at 3ae1411d; independent exact-head APPROVE (brain 0199a866); CI 3058 green
|
||||
* E6 ledger: gate seat result recorded as BLOCKED (capability), per roles/gate.md
|
||||
|
||||
Next Step:
|
||||
|
||||
* T158: plan seat designs the runtime gate profile, then provisioning → gate re-run
|
||||
* #1491: operation seat runs pr-merge.sh after the gate seat issues PASS
|
||||
|
||||
Blocked:
|
||||
|
||||
* T158: queued behind the plan seat's prior review — nothing from you
|
||||
```
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: ms-honesty
|
||||
description: "Load before answering, reporting, or ruling; must always apply."
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# Honesty
|
||||
|
||||
An agent that always has an answer is not a reliable agent. Training rewards a fluent,
|
||||
confident reply over a correct one, so the default reflex under pressure is to produce
|
||||
something that sounds like an answer even when nothing was measured. This skill replaces
|
||||
that reflex with a procedure. It applies to every reply, every report, every message to
|
||||
another seat, and every rule you put on a board.
|
||||
|
||||
The two operator rules this skill enforces:
|
||||
|
||||
- It is acceptable not to have an answer. "I do not know" followed by what would settle it
|
||||
is a complete, correct reply.
|
||||
- A statement carries its evidence with it. The reader must be able to tell, from the
|
||||
sentence alone, whether you measured it, read it, inferred it, or guessed.
|
||||
|
||||
## Where the pressure comes from
|
||||
|
||||
Recognize the moment. Each of these is a point where the reflex fires:
|
||||
|
||||
1. The reader asked a direct question and you have a plausible answer in memory.
|
||||
2. A number, an id, a path, or a name would make the sentence complete.
|
||||
3. A tool returned nothing, an error, or something you did not expect, and a reason
|
||||
suggests itself.
|
||||
4. A prior record (a document, a ledger row, your own earlier message) says the answer.
|
||||
5. The reply is almost done and one more fact would close it neatly.
|
||||
6. You are reporting upward and a clean "done" reads better than "partly done".
|
||||
|
||||
At each of these points, stop and classify the claim before writing it.
|
||||
|
||||
## The evidence ladder
|
||||
|
||||
Every factual claim sits on exactly one rung. Name the rung when it is not obvious from
|
||||
the sentence.
|
||||
|
||||
| Rung | Meaning | How to write it |
|
||||
|---|---|---|
|
||||
| Measured | You ran the command, read the file, called the API, in this session, and the output says this | State the fact and the source: "main tip is 0bbb997d (branches/main API, 13:05)" |
|
||||
| Read | A record says it (a ledger, a doc, a pane, a message from another seat); you did not confirm it live | "STATE.md says X"; "orch-01 reports X"; mark `(unverified)` in updates |
|
||||
| Inferred | Measured facts plus a reasoning step you can show | "X, so probably Y" with the step visible; never as a flat fact |
|
||||
| Recalled | It is in your context or training but not in this session's evidence | "I recall X; not measured" or leave it out |
|
||||
| Guessed | None of the above | Do not write it as a fact. Write "I do not know" and what would settle it |
|
||||
|
||||
Rules that follow:
|
||||
|
||||
- A number, id, SHA, path, version, or quote is Measured or it is not in the reply. A
|
||||
half-remembered value is worse than none because it looks the same as a real one.
|
||||
- A tool result you did not read is not Measured. Empty output, a 403 body, an exit code
|
||||
from the wrong process in a pipe, and a cached ref are the four ways a measurement lies;
|
||||
show the control (the way the check could have come out differently) before trusting a
|
||||
zero, a green, or an empty result.
|
||||
- A prior record, including one you wrote, is Read, not Measured, until you re-measure it.
|
||||
- Another agent's claim is Read. Repeating it does not promote it.
|
||||
- Inference is allowed and useful. It is dishonest only when the reasoning step is hidden
|
||||
and the conclusion is written as if measured.
|
||||
|
||||
## "I do not know" is a full answer
|
||||
|
||||
When the honest rung is Guessed, reply with these three parts and nothing else:
|
||||
|
||||
1. What you do not know, in one sentence.
|
||||
2. What would settle it: the command, the file, the person, or the access needed.
|
||||
3. Whether you can run that now. If yes, do it instead of writing the reply. If no, say
|
||||
what blocks it.
|
||||
|
||||
Do not pad it with a guess "in case it helps". A guess next to an honest "I do not know"
|
||||
gets read as the answer.
|
||||
|
||||
## Widening and narrowing
|
||||
|
||||
- Report the case you measured, not the class it belongs to. One host checked is one host.
|
||||
One pipeline read is one pipeline. Say "on 5788" not "on main".
|
||||
- Do not round up. "Mostly green", "should be fine", "looks like it worked" are not states.
|
||||
The states are: measured green, measured red, not measured.
|
||||
- Do not round down either. If something is done with evidence, say done. Hedging a real
|
||||
result is as misleading as inflating a weak one.
|
||||
- Delivered is not done. Dispatched is not done. Sent is not received. Merged is not
|
||||
deployed. Use the word for the state you have evidence of.
|
||||
|
||||
## Communication evidence
|
||||
|
||||
Drafted is not sent. Sent is not delivered. Delivered is not acknowledged. Acknowledged is not completed.
|
||||
|
||||
Name the exact state and its source. A delivery receipt proves only the outcome the
|
||||
wrapper reports. It does not prove the recipient read, accepted, or completed the work.
|
||||
`queued-draft` and `unverifiable` are not permission to retry outside the wrapper contract.
|
||||
A later durable artifact or direct reply can prove acknowledgement or completion.
|
||||
|
||||
Honesty is not silence. If another authorized seat can measure or perform the missing
|
||||
step, send it a direct request before reporting. Record the destination, request or task
|
||||
id, exact delivery receipt, required return event, and escalation owner. Do not use an
|
||||
operator-facing update as a substitute for contacting the responsible seat.
|
||||
|
||||
A credential refusal is correct behavior, but it is not a terminal communication state.
|
||||
Route the request to the authorized owner. Call the goal blocked only when no authorized
|
||||
route or other meaningful work exists.
|
||||
|
||||
## Retraction
|
||||
|
||||
When you find that something you already said was wrong:
|
||||
|
||||
1. Say it in the next message, first line, labeled: "Retraction: <what I said> was wrong
|
||||
because <what I now measured>."
|
||||
2. Correct every place it was recorded (STATE, ledger, board, the other seat's inbox), with
|
||||
the date, leaving the superseded text legible rather than deleting it.
|
||||
3. Do not soften it, explain it away, or bury it under new results.
|
||||
|
||||
A retraction costs one message. An uncorrected error costs every decision built on it.
|
||||
|
||||
## How this binds the other skills
|
||||
|
||||
- `ms-executive-update`: each Just Completed bullet cites Measured evidence (SHA, verdict
|
||||
path, receipt). A bullet whose evidence is only Read carries `(unverified)`. Blocked
|
||||
names what is not known and who can know it. Another-seat and operator waits use the
|
||||
recorded delivery receipt, required return event, escalation owner, and existing wake
|
||||
path. They do not require polling or an `agent-watch`. External-condition waits require
|
||||
a fresh measurement or a permitted watch. A no-change line names the applicable record.
|
||||
- `ms-proactive-agent`: step 1 (record) writes the rung with the fact. A Guessed or
|
||||
Recalled item is never a task candidate; measuring it is the candidate. Two cycles that
|
||||
produce identical evidence are reported as no progress, not as progress.
|
||||
- `ms-watch`: a watch condition is a measurement. Arm it with a control run that shows it
|
||||
can return false, and record what it reads, not what you hope it reads.
|
||||
- Rulings and reviews: a ruling states the evidence rung it stands on. "Ruled on the render
|
||||
argument alone; protection settings unmeasured from this seat" is a valid ruling. A
|
||||
ruling that hides an unmeasured input is not.
|
||||
|
||||
## Self-check before sending
|
||||
|
||||
Read your draft once more and ask, per sentence with a fact in it:
|
||||
|
||||
- Which rung is this on, and would the reader guess the same rung from my wording?
|
||||
- If this turns out false, can I point to the exact tool output that misled me? If there
|
||||
is no such output, the sentence is Recalled or Guessed and must be rewritten or cut.
|
||||
- Did I write "done", "verified", "confirmed", "green", or "fixed" anywhere without a
|
||||
source next to it?
|
||||
- Is there a number or id I did not read from a tool this session?
|
||||
- Did I answer the question that was asked, or a nearby question I could answer better?
|
||||
- Did this interaction create an acknowledgement, tasking, handoff, review request,
|
||||
blocker, result, correction, or decision request that I have not sent directly?
|
||||
- Did I name a communication state more strongly than its receipt proves?
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Filling a gap with the most likely value.
|
||||
- Reading a document (including your own STATE file) and reporting it as a measurement.
|
||||
- Treating a tool's silence, a 403 body, or an empty grep as a finding.
|
||||
- "Should be", "presumably", "I believe" used as a fact with a softener, rather than as a
|
||||
flag that leads to a measurement.
|
||||
- Answering "is it fixed?" with "a newer version is deployed".
|
||||
- Quietly correcting an earlier claim in a later message without labeling it a retraction.
|
||||
- Promoting another seat's report to fact by repeating it in your own voice.
|
||||
- Describing what was checked in general terms ("ran the checks") so a partial check reads
|
||||
as a full one.
|
||||
- Calling a task blocked after an access refusal without routing it to an authorized owner.
|
||||
- Reporting a handoff upward while never sending it to the responsible seat.
|
||||
|
||||
## Examples
|
||||
|
||||
Asked whether branch protection requires status checks, with only a 403 in hand:
|
||||
|
||||
```
|
||||
Not measured. This seat's token gets 403 on branch_protections, so the settings are
|
||||
unknown from here. An admin token, or Jason reading the repo settings page, would settle
|
||||
it. I can ask orch-01 to read it with the admin identity.
|
||||
```
|
||||
|
||||
Reporting a merge learned from another seat's pane:
|
||||
|
||||
```
|
||||
* #3171: merged per orch-01's pane (unverified); verifying via the PR API now
|
||||
```
|
||||
|
||||
then, after the API read:
|
||||
|
||||
```
|
||||
* #3171: merged at 0bbb997d, merged_by orch-01, reviews 381+382 at head 4ce167c3 (PR API, 13:05)
|
||||
```
|
||||
|
||||
Retracting:
|
||||
|
||||
```
|
||||
Retraction: "main has no required status contexts" was read from a 403 body, not from the
|
||||
protection settings. Protection is unmeasured from this seat. Corrected in STATE.md (12:10)
|
||||
and sent to orch-01; issue #3170 body corrected by orch-01.
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: ms-proactive-agent
|
||||
description: Continue an authorized Mosaic assignment through planning, execution, verification, and recovery while useful work remains. Use for ongoing work and continuation requests, not to turn a question or review into an open-ended mission.
|
||||
---
|
||||
|
||||
# Proactive Agent
|
||||
|
||||
Complete the authorized outcome, not just the first task. At each meaningful checkpoint,
|
||||
select and execute the next necessary action in the same turn while scope, authority,
|
||||
dependencies, and budget permit. A plan, task dispatch, or status report is not completion.
|
||||
|
||||
This skill governs work selection. [ms-goal](../ms-goal/SKILL.md) governs durable goal
|
||||
state, pause/resume, and completion; load it when establishing or recovering a continuing
|
||||
assignment. The dev Stack does not yet integrate the `/goal` extension. Neither skill
|
||||
creates a scheduler, background process, tool, or automatic follow-up turn.
|
||||
|
||||
## Establish execution context
|
||||
|
||||
Before writing state or choosing work:
|
||||
|
||||
1. Resolve the assignment from the user request, launch context, and applicable
|
||||
repository/mission instructions. Record agent identity, workspace root, project if
|
||||
declared, and execution/session identifier if supplied. Mark unavailable identifiers
|
||||
as unavailable; do not infer a workspace from the agent's identity.
|
||||
2. Locate the authoritative goal, tasks, approvals, and recovery checkpoint. Use existing
|
||||
declared paths. For example, this skill's source repository uses `docs/plans/CURRENT.md`
|
||||
for queued project work; an explicit user request can authorize a separate bounded task.
|
||||
Do not advance unrelated
|
||||
queued phases or overwrite their owner-controlled records. Fleet paths and a seat's
|
||||
general purpose are not default assignments.
|
||||
3. Check writable state locations and required tools. Discover communication, claim, and
|
||||
wake mechanisms when needed. Optional reporting or watch skills must not block ordinary
|
||||
local work. If a required capability is missing, record the precise limitation and
|
||||
continue independent authorized work where possible. If ms-goal is unavailable, use an
|
||||
existing authoritative goal protocol; do not fabricate its tools or claim durable
|
||||
continuation without accessible records.
|
||||
4. Identify ownership. Use the coordinator's claim operation when provided. A Markdown
|
||||
owner field is not a lock. Without enforced claims, work only under an established
|
||||
single-writer assignment; conflicting ownership stops the affected task.
|
||||
|
||||
Explicit scope and authorization persist. Do not ask again for routine actions already
|
||||
covered by them. Role capabilities constrain execution; they do not authorize a new
|
||||
mission, another owner's work, or new external communications. Preserve phase approvals,
|
||||
acceptance ownership, and budget limits. Silence, task creation, and elapsed time grant
|
||||
no additional authority.
|
||||
|
||||
## Run the loop
|
||||
|
||||
Run on assignment start/recovery, a substantive checkpoint, an actionable message, or a
|
||||
matching wake event. Bookkeeping tool results and reports do not recursively trigger it.
|
||||
|
||||
1. **Reconcile and record.** Read relevant current records and verify the live fact needed
|
||||
for the next action. Preserve unrelated changes. Resolve actions with unknown outcomes
|
||||
before retrying them. Persist changed task status, evidence, and obligations before
|
||||
reporting; do not rewrite unchanged state merely to manufacture activity.
|
||||
2. **Check control state.** Follow ms-goal. Paused or canceled work does not resume on an
|
||||
ordinary tool result, compaction, or unrelated message. Apply user steering promptly;
|
||||
a status question does not cancel work. Reconcile scope changes with remaining tasks
|
||||
before executing them.
|
||||
3. **Select useful work.** Finish an owned in-flight task that can proceed, then an owned
|
||||
blocker you can resolve, then ready assigned work. Claim other necessary work only
|
||||
within delegated assignment authority. Respect dependencies and explicit priority;
|
||||
prefer smaller reversible steps when candidates otherwise have equal priority.
|
||||
4. **Fill a planning gap.** If an acceptance criterion is unmet and no task covers it,
|
||||
decompose the authorized outcome into bounded tasks with dependencies and evidence
|
||||
requirements, record them, and take the first ready task. A new goal can start with an
|
||||
empty queue. Necessary fixes and verification belong in scope; unrelated improvements
|
||||
remain proposals. If decomposition exposes a new scope decision, route it and continue
|
||||
any independent work already authorized.
|
||||
5. **Execute and verify.** Take the task to a meaningful checkpoint. Run checks appropriate
|
||||
to its acceptance criteria, inspect the results, and correct in-scope failures. Persist
|
||||
a resumable checkpoint before a risky action, context limit, or handoff. Do not mark a
|
||||
task done because it was started, delegated, or merely produced an artifact.
|
||||
6. **Close communication obligations.** Use the procedure below. Update the goal record
|
||||
with the next action and actual evidence. If ready work remains, return to step 1 in
|
||||
this turn. Report significant results without making the report an artificial stop.
|
||||
7. **When nothing can run, classify honestly.** Verify satisfaction against acceptance
|
||||
criteria, not queue emptiness. Otherwise record waiting, paused, or blocked with the
|
||||
unresolved gate and exact resumption condition, using ms-goal.
|
||||
|
||||
## Failure and budget handling
|
||||
|
||||
- Diagnose failures before retrying. Retry transient failures only under the tool's
|
||||
documented bounded retry contract or with a concrete changed input. For an uncertain
|
||||
external side effect, reconcile its request/action identifier first.
|
||||
- Repeated actions producing no new task evidence require a different justified approach
|
||||
or a recorded blocker. An unchanged registered wait is not a failed attempt; it should
|
||||
yield instead of generating more reports.
|
||||
- Track actual usage when available; label estimates and unavailable measurements. Stay
|
||||
within explicit limits and reserve capacity for verification and a checkpoint. Budget
|
||||
exhaustion pauses work; it does not establish completion. Never reset usage by resuming
|
||||
or splitting a task. Ask for an extension only when one is actually needed.
|
||||
- If recording fails, preserve recoverable evidence and report the failure. Do not start
|
||||
further effects that require a durable record or claim they were checkpointed.
|
||||
|
||||
## Communication and waits
|
||||
|
||||
For authorized inbound work, record the change and acknowledge the sender through the
|
||||
available approved transport. A reaction or receipt alone does not require another reply.
|
||||
For authorized tasking, handoffs, review requests, or decisions, persist any required
|
||||
artifact before sending its pointer. Durable storage does not necessarily mean a Git
|
||||
commit; follow the repository's commit authority and workflow.
|
||||
|
||||
Record request/task id, destination and workspace, exact receipt, expected return event,
|
||||
and follow-up owner. Distinguish drafted, queued, delivered, acknowledged, and completed.
|
||||
Unknown delivery is not permission to resend; reconcile or follow the transport's retry
|
||||
contract. A report to the user is not delivery to another agent. If no transport is
|
||||
available, record the unsent request and route the missing capability to the user.
|
||||
|
||||
For a wait, record the last observation, condition that ends it, wake mechanism, and any
|
||||
deadline plus its escalation owner/action. Verify the mechanism exists. An owner name,
|
||||
file entry, or queued draft does not prove that another turn will occur.
|
||||
|
||||
- Use existing message delivery for another agent's response. Do not poll its private
|
||||
files or pane. Use an available coordinator's timeout mechanism for overdue requests.
|
||||
- Use ms-agent-watch only when installed, supported, and authorized, for external
|
||||
conditions with no existing wake owner. Verify a known-false control and the actual
|
||||
condition, distinguishing pending from errors; handle an already-met condition now.
|
||||
Verify registration, bind to the exact resource/version and destination, and record
|
||||
timeout/delivery behavior. Reuse valid existing watches and retire obsolete ones.
|
||||
- Without automatic wake support, a wait is explicitly **manual**: state who must send
|
||||
what reply or resume instruction. A deadline is checked on the next invocation unless
|
||||
an actual timer/coordinator owns it. Do not promise unattended escalation or resumption.
|
||||
|
||||
## Checkpoints and reporting
|
||||
|
||||
Persist current task, completed evidence, remaining acceptance gates, next concrete action,
|
||||
outstanding communication/wait records, ownership, budget, and uncertain outcomes in their
|
||||
authoritative records. Keep a compact checkpoint pointing to them; retain unresolved
|
||||
obligations and durable history regardless of checkpoint trimming.
|
||||
|
||||
Report what changed, its evidence, what happens next, and any decision needed. Use an
|
||||
installed reporting format only when the applicable environment requires it. No special
|
||||
reporting skill or goal_report tool is required in dev mode. Do not repeat unchanged
|
||||
reports unless asked or a scheduled check produces information that matters.
|
||||
|
||||
End normally only at verified satisfaction, explicit pause/cancellation, a genuine
|
||||
blocker, or a registered automatic/manual wait with no ready work. If execution is forced
|
||||
to end while work remains, checkpoint it as incomplete and name the actual resume path;
|
||||
never imply that writing `next` scheduled that action.
|
||||
|
||||
For bounded dev testing, use
|
||||
[ms-goal's execution checks](../ms-goal/references/execution-checks.md).
|
||||
@@ -0,0 +1,320 @@
|
||||
// Hermetic tests for the pi /goal extension pure logic (state, parse, settle, store).
|
||||
// Run: node --test test/goal.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir, homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
checkLimitReached,
|
||||
clearGoal,
|
||||
DEFAULT_MAX_CHECKS,
|
||||
DEFAULT_MAX_NO_PROGRESS_REPORTS,
|
||||
envDefaultMaxChecks,
|
||||
envDefaultMaxNoProgressReports,
|
||||
initialState,
|
||||
pauseGoal,
|
||||
recordCheckInjected,
|
||||
recordReport,
|
||||
resumeGoal,
|
||||
setGoal,
|
||||
validateReport,
|
||||
} from "../lib/state.ts";
|
||||
import { parseGoalCommand } from "../lib/parse.ts";
|
||||
import { decideSettle } from "../lib/settle.ts";
|
||||
import { agentStateDir, loadState, saveState, stateFilePath } from "../lib/store.ts";
|
||||
|
||||
// ---------- state machine ----------
|
||||
|
||||
test("initialState is status none with default cap", () => {
|
||||
const s = initialState(25);
|
||||
assert.equal(s.status, "none");
|
||||
assert.equal(s.text, "");
|
||||
assert.equal(s.checks, 0);
|
||||
assert.equal(s.maxChecks, 25);
|
||||
assert.equal(s.noProgressReports, 0);
|
||||
assert.equal(s.maxNoProgressReports, 3);
|
||||
assert.equal(s.workEventSinceReport, false);
|
||||
assert.equal(DEFAULT_MAX_CHECKS, 25);
|
||||
assert.equal(DEFAULT_MAX_NO_PROGRESS_REPORTS, 3);
|
||||
});
|
||||
|
||||
test("setGoal activates with zeroed checks and optional cap", () => {
|
||||
const s = setGoal(initialState(25), "ship the thing");
|
||||
assert.equal(s.status, "active");
|
||||
assert.equal(s.text, "ship the thing");
|
||||
assert.equal(s.checks, 0);
|
||||
assert.equal(s.maxChecks, 25);
|
||||
|
||||
const capped = setGoal(initialState(25), "another", 7);
|
||||
assert.equal(capped.maxChecks, 7);
|
||||
});
|
||||
|
||||
test("pause only from active; keeps goal and reason", () => {
|
||||
const active = setGoal(initialState(25), "goal");
|
||||
const paused = pauseGoal(active, "stopped by operator");
|
||||
assert.equal(paused.status, "paused");
|
||||
assert.equal(paused.pausedReason, "stopped by operator");
|
||||
assert.equal(paused.text, "goal");
|
||||
assert.equal(pauseGoal(paused, "again"), paused); // idempotent when not active
|
||||
assert.equal(pauseGoal(initialState(25), "x").status, "none");
|
||||
});
|
||||
|
||||
test("resume only from paused; resets checks and clears reason", () => {
|
||||
const paused = pauseGoal(setGoal(initialState(25), "goal"), "cap");
|
||||
paused.checks = 9;
|
||||
paused.noProgressReports = 2;
|
||||
paused.workEventSinceReport = true;
|
||||
const resumed = resumeGoal(paused);
|
||||
assert.equal(resumed.status, "active");
|
||||
assert.equal(resumed.checks, 0);
|
||||
assert.equal(resumed.noProgressReports, 0);
|
||||
assert.equal(resumed.workEventSinceReport, false);
|
||||
assert.equal(resumed.pausedReason, undefined);
|
||||
assert.equal(resumeGoal(initialState(25)).status, "none");
|
||||
});
|
||||
|
||||
test("clearGoal returns to none", () => {
|
||||
const cleared = clearGoal(setGoal(initialState(25), "goal", 9));
|
||||
assert.equal(cleared.status, "none");
|
||||
assert.equal(cleared.text, "");
|
||||
assert.equal(cleared.maxChecks, 9);
|
||||
});
|
||||
|
||||
test("report resets consecutive checks; limit and injection counting interlock", () => {
|
||||
let s = setGoal(initialState(25), "goal", 3);
|
||||
assert.equal(checkLimitReached(s), false);
|
||||
s = recordCheckInjected(s);
|
||||
s = recordCheckInjected(s);
|
||||
assert.equal(checkLimitReached(s), false); // 2 of 3 injected, one slot left
|
||||
s = recordCheckInjected(s);
|
||||
assert.equal(checkLimitReached(s), true); // cap exhausted: auto-pause, no injection
|
||||
s = recordReport(s);
|
||||
assert.equal(checkLimitReached(s), false); // any report resets the counter
|
||||
});
|
||||
|
||||
// ---------- env default cap ----------
|
||||
|
||||
test("environment caps: valid overrides and invalid values fall back", () => {
|
||||
assert.equal(envDefaultMaxChecks({}), 25);
|
||||
assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "10" }), 10);
|
||||
assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "abc" }), 25);
|
||||
assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "0" }), 25);
|
||||
assert.equal(envDefaultMaxChecks({ GOAL_MAX_CHECKS: "" }), 25);
|
||||
assert.equal(envDefaultMaxNoProgressReports({}), 3);
|
||||
assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "5" }), 5);
|
||||
assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "0" }), 3);
|
||||
assert.equal(envDefaultMaxNoProgressReports({ GOAL_MAX_NO_PROGRESS_REPORTS: "bad" }), 3);
|
||||
});
|
||||
|
||||
// ---------- command parsing ----------
|
||||
|
||||
test("parse: verbs and status", () => {
|
||||
assert.deepEqual(parseGoalCommand(""), { kind: "status" });
|
||||
assert.deepEqual(parseGoalCommand(" "), { kind: "status" });
|
||||
assert.deepEqual(parseGoalCommand("stop"), { kind: "stop" });
|
||||
assert.deepEqual(parseGoalCommand("STOP"), { kind: "stop" });
|
||||
assert.deepEqual(parseGoalCommand("clear"), { kind: "clear" });
|
||||
assert.deepEqual(parseGoalCommand("resume"), { kind: "resume" });
|
||||
});
|
||||
|
||||
test("parse: set with plain text", () => {
|
||||
assert.deepEqual(parseGoalCommand("ship the thing"), { kind: "set", text: "ship the thing", max: undefined });
|
||||
assert.deepEqual(parseGoalCommand(" padded "), { kind: "set", text: "padded", max: undefined });
|
||||
});
|
||||
|
||||
test("parse: --max forms attach to set", () => {
|
||||
assert.deepEqual(parseGoalCommand("do it --max 5"), { kind: "set", text: "do it", max: 5 });
|
||||
assert.deepEqual(parseGoalCommand("--max 5 do it"), { kind: "set", text: "do it", max: 5 });
|
||||
assert.deepEqual(parseGoalCommand("do it --max=7"), { kind: "set", text: "do it", max: 7 });
|
||||
assert.deepEqual(parseGoalCommand("--max 40 write the spec"), {
|
||||
kind: "set",
|
||||
text: "write the spec",
|
||||
max: 40,
|
||||
});
|
||||
});
|
||||
|
||||
test("parse: --max errors", () => {
|
||||
assert.equal(parseGoalCommand("--max").kind, "error");
|
||||
assert.equal(parseGoalCommand("--max abc").kind, "error");
|
||||
assert.equal(parseGoalCommand("text --max 0").kind, "error");
|
||||
assert.equal(parseGoalCommand("text --max").kind, "error");
|
||||
assert.equal(parseGoalCommand("--max 5").kind, "error"); // no goal text
|
||||
assert.equal(parseGoalCommand("text --max 5.0").kind, "error");
|
||||
});
|
||||
|
||||
test("parse: text containing verbs is goal text, not a verb", () => {
|
||||
assert.deepEqual(parseGoalCommand("stop now"), { kind: "set", text: "stop now", max: undefined });
|
||||
});
|
||||
|
||||
test("setGoal without --max uses fresh default, not the previous goal's cap", () => {
|
||||
let s = setGoal(initialState(25), "first", 3);
|
||||
assert.equal(s.maxChecks, 3);
|
||||
s = clearGoal(s);
|
||||
s = setGoal(s, "second");
|
||||
assert.equal(s.maxChecks, 25); // per-goal --max: no leak from the capped goal
|
||||
s = setGoal(s, "third", 40);
|
||||
assert.equal(s.maxChecks, 40);
|
||||
s = setGoal(s, "fourth");
|
||||
assert.equal(s.maxChecks, 25);
|
||||
assert.equal(setGoal(initialState(25), "g", undefined).maxChecks, envDefaultMaxChecks({ GOAL_MAX_CHECKS: "" }));
|
||||
});
|
||||
|
||||
// ---------- settle decision (abort/error/cap pause paths, AC4 + AC5) ----------
|
||||
|
||||
test("settle: normal completion injects, abort and error pause", () => {
|
||||
const s = setGoal(initialState(25), "goal");
|
||||
assert.deepEqual(decideSettle(s, "toolUse"), { action: "inject" });
|
||||
assert.deepEqual(decideSettle(s, "stop"), { action: "inject" });
|
||||
assert.deepEqual(decideSettle(s, undefined), { action: "inject" });
|
||||
|
||||
const aborted = decideSettle(s, "aborted");
|
||||
assert.equal(aborted.action, "pause");
|
||||
assert.match((aborted as { reason: string }).reason, /aborted/);
|
||||
|
||||
const errored = decideSettle(s, "error");
|
||||
assert.equal(errored.action, "pause");
|
||||
assert.match((errored as { reason: string }).reason, /error/);
|
||||
});
|
||||
|
||||
test("settle: cap exhaustion pauses with cap reason", () => {
|
||||
const s = setGoal(initialState(25), "goal", 2);
|
||||
s.checks = 2;
|
||||
const d = decideSettle(s, "stop");
|
||||
assert.equal(d.action, "pause");
|
||||
assert.match((d as { reason: string }).reason, /cap of 2/);
|
||||
// an injected-check counter reset by a report makes the cap pass again
|
||||
assert.deepEqual(decideSettle(recordReport(s), "stop"), { action: "inject" });
|
||||
});
|
||||
|
||||
// ---------- report validation (FR3) ----------
|
||||
|
||||
test("validateReport: satisfied and blocked require evidence; in_progress does not", () => {
|
||||
assert.match(validateReport("satisfied", "")!, /evidence/);
|
||||
assert.match(validateReport("satisfied", " ")!, /evidence/);
|
||||
assert.match(validateReport("satisfied", undefined)!, /evidence/);
|
||||
assert.match(validateReport("blocked", "")!, /reason/);
|
||||
assert.match(validateReport("blocked", undefined)!, /reason/);
|
||||
assert.equal(validateReport("in_progress", ""), null);
|
||||
assert.equal(validateReport("satisfied", "counted to three"), null);
|
||||
assert.equal(validateReport("blocked", "missing credentials"), null);
|
||||
});
|
||||
|
||||
// ---------- store ----------
|
||||
|
||||
test("store: round-trip, missing file, corrupt file", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-test-"));
|
||||
try {
|
||||
const path = join(dir, "goal-state.json");
|
||||
assert.deepEqual(loadState(path), initialState()); // missing file -> initial
|
||||
|
||||
const s = setGoal(initialState(25), "durable goal", 11);
|
||||
s.checks = 4;
|
||||
s.noProgressReports = 2;
|
||||
s.workEventSinceReport = true;
|
||||
s.lastProgressFingerprint = "abc";
|
||||
s.lastNextAction = "measure gate";
|
||||
s.activeWait = { owner: "reviewer", watchId: "review-watch", nextCheck: "review arrives" };
|
||||
saveState(s, path);
|
||||
const loaded = loadState(path);
|
||||
assert.equal(loaded.text, "durable goal");
|
||||
assert.equal(loaded.status, "active");
|
||||
assert.equal(loaded.maxChecks, 11);
|
||||
assert.equal(loaded.checks, 4);
|
||||
assert.equal(loaded.noProgressReports, 2);
|
||||
assert.equal(loaded.workEventSinceReport, true);
|
||||
assert.equal(loaded.lastProgressFingerprint, "abc");
|
||||
assert.equal(loaded.lastNextAction, "measure gate");
|
||||
assert.equal(loaded.activeWait?.watchId, "review-watch");
|
||||
|
||||
writeFileSync(path, "{not json", "utf8");
|
||||
assert.deepEqual(loadState(path), initialState()); // corrupt -> initial
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("store: legacy version-1 active state gains NG8 defaults without losing focus", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-test-legacy-v1-"));
|
||||
try {
|
||||
const path = join(dir, "goal-state.legacy-v1.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
text: "preserve this active goal",
|
||||
status: "active",
|
||||
checks: 7,
|
||||
maxChecks: 19,
|
||||
setAt: "2026-08-30T00:00:00.000Z",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const state = loadState(path);
|
||||
assert.equal(state.text, "preserve this active goal");
|
||||
assert.equal(state.status, "active");
|
||||
assert.equal(state.checks, 7);
|
||||
assert.equal(state.maxChecks, 19);
|
||||
assert.equal(state.noProgressReports, 0);
|
||||
assert.equal(state.maxNoProgressReports, DEFAULT_MAX_NO_PROGRESS_REPORTS);
|
||||
assert.equal(state.workEventSinceReport, false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("store: normalize repairs bad counters", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-test-"));
|
||||
try {
|
||||
const path = join(dir, "goal-state.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
text: "g",
|
||||
status: "active",
|
||||
checks: -3,
|
||||
maxChecks: 0,
|
||||
noProgressReports: -9,
|
||||
maxNoProgressReports: 0,
|
||||
workEventSinceReport: "yes",
|
||||
setAt: "t",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const s = loadState(path);
|
||||
assert.equal(s.checks, 0);
|
||||
assert.equal(s.maxChecks, DEFAULT_MAX_CHECKS);
|
||||
assert.equal(s.noProgressReports, 0);
|
||||
assert.equal(s.maxNoProgressReports, DEFAULT_MAX_NO_PROGRESS_REPORTS);
|
||||
assert.equal(s.workEventSinceReport, false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("store: agent dir resolves from PI_CODING_AGENT_DIR with ~/.pi/agent fallback", () => {
|
||||
assert.equal(agentStateDir({ PI_CODING_AGENT_DIR: "/seat/.pi/agent" }), "/seat/.pi/agent");
|
||||
assert.equal(agentStateDir({}), join(homedir(), ".pi", "agent"));
|
||||
assert.equal(stateFilePath("/d"), join("/d", "goal-state.json"));
|
||||
});
|
||||
|
||||
test("store pauses invalid persisted bounded-wait configuration", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-wait-invalid-"));
|
||||
try {
|
||||
const path = join(dir, "state.json");
|
||||
for (const fields of [
|
||||
{ waitTimeoutSeconds: -1 },
|
||||
{ waitTimeoutSeconds: 60, activeWait: { owner: "operator", deadlineAt: 1e100 } },
|
||||
{ waitTimeoutSeconds: 60, waitWakeUsed: "false" },
|
||||
{ waitTimeoutSeconds: 60, activeWait: { owner: "operator" } },
|
||||
{ waitTimeoutSeconds: 60, activeWait: { owner: "operator", deadlineAt: 123456, wakeSent: true } },
|
||||
]) {
|
||||
writeFileSync(path, JSON.stringify({ ...setGoal(initialState(), "output"), ...fields }));
|
||||
assert.equal(loadState(path).status, "paused");
|
||||
assert.match(loadState(path).pausedReason!, /invalid persisted wait/);
|
||||
}
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_MAX_NO_PROGRESS_REPORTS,
|
||||
initialState,
|
||||
recordCheckInjected,
|
||||
recordInProgressReport,
|
||||
recordWorkEvent,
|
||||
setGoal,
|
||||
type ProgressDetails,
|
||||
} from "../lib/state.ts";
|
||||
|
||||
const measurement: ProgressDetails = {
|
||||
kind: "measurement",
|
||||
gate: "PR review",
|
||||
owner: "rev-code-01",
|
||||
lastMeasurement: "PR head abc123 remains unreviewed at 2026-08-31T15:00:00Z",
|
||||
nextAction: "dispatch exact-head review",
|
||||
};
|
||||
|
||||
function reportOnlyCycle(state: ReturnType<typeof setGoal>) {
|
||||
const checked = recordCheckInjected(state);
|
||||
return recordInProgressReport(checked, { evidence: "", progress: undefined }).state;
|
||||
}
|
||||
|
||||
test("exact report-only loop pauses within the no-progress bound", () => {
|
||||
let state = setGoal(initialState(), "ship", 25);
|
||||
for (let n = 1; n <= DEFAULT_MAX_NO_PROGRESS_REPORTS; n++) {
|
||||
state = reportOnlyCycle(state);
|
||||
assert.equal(state.noProgressReports, n);
|
||||
}
|
||||
assert.equal(state.status, "paused");
|
||||
assert.match(state.pausedReason ?? "", /no substantive progress/);
|
||||
});
|
||||
|
||||
test("empty, malformed, and duplicate evidence never reset no-progress", () => {
|
||||
let state = setGoal(initialState(), "ship", 25);
|
||||
state = recordInProgressReport(recordCheckInjected(state), { evidence: "", progress: undefined }).state;
|
||||
assert.equal(state.noProgressReports, 1);
|
||||
|
||||
state = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "measured",
|
||||
progress: { ...measurement, owner: "" },
|
||||
}).state;
|
||||
assert.equal(state.noProgressReports, 2);
|
||||
|
||||
state = recordWorkEvent(state, "read", "2026-08-31T15:00:00Z");
|
||||
const accepted = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "measured live PR state",
|
||||
progress: measurement,
|
||||
});
|
||||
assert.equal(accepted.classification, "progress");
|
||||
state = accepted.state;
|
||||
assert.equal(state.noProgressReports, 0);
|
||||
|
||||
state = recordWorkEvent(state, "read", "2026-08-31T15:01:00Z");
|
||||
const duplicate = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "measured live PR state",
|
||||
progress: measurement,
|
||||
});
|
||||
assert.equal(duplicate.classification, "no_progress");
|
||||
assert.equal(duplicate.state.noProgressReports, 1);
|
||||
});
|
||||
|
||||
test("measurement, action, and delegation require an observed successful work event", () => {
|
||||
for (const kind of ["measurement", "action", "delegation"] as const) {
|
||||
const details = { ...measurement, kind };
|
||||
let state = setGoal(initialState(), kind, 25);
|
||||
const unsupported = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: `${kind} claim`,
|
||||
progress: details,
|
||||
});
|
||||
assert.equal(unsupported.classification, "no_progress");
|
||||
|
||||
state = recordWorkEvent(unsupported.state, kind === "action" ? "edit" : "bash", "2026-08-31T15:00:00Z");
|
||||
const accepted = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: `${kind} completed with a new result`,
|
||||
progress: { ...details, lastMeasurement: `${details.lastMeasurement} ${kind}` },
|
||||
});
|
||||
assert.equal(accepted.classification, "progress");
|
||||
assert.equal(accepted.state.noProgressReports, 0);
|
||||
assert.equal(accepted.state.checks, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit non-tool work requires a concrete artifact", () => {
|
||||
let state = setGoal(initialState(), "draft", 25);
|
||||
const missing = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "drafted reasoning",
|
||||
progress: { ...measurement, kind: "non_tool" },
|
||||
});
|
||||
assert.equal(missing.classification, "no_progress");
|
||||
|
||||
const accepted = recordInProgressReport(recordCheckInjected(missing.state), {
|
||||
evidence: "completed architecture decision",
|
||||
progress: { ...measurement, kind: "non_tool", artifact: "work/decision-D12.md" },
|
||||
});
|
||||
assert.equal(accepted.classification, "progress");
|
||||
assert.equal(accepted.state.noProgressReports, 0);
|
||||
});
|
||||
|
||||
test("a legitimate external wait remains active without false-positive pause", () => {
|
||||
let state = setGoal(initialState(), "await review", 25);
|
||||
state = recordInProgressReport(recordCheckInjected(state), { evidence: "", progress: undefined }).state;
|
||||
state = recordInProgressReport(recordCheckInjected(state), { evidence: "", progress: undefined }).state;
|
||||
assert.equal(state.noProgressReports, 2);
|
||||
|
||||
const waiting: ProgressDetails = {
|
||||
kind: "wait",
|
||||
gate: "independent review",
|
||||
owner: "rev-code-01",
|
||||
lastMeasurement: "review dispatched at 2026-08-31T15:00:00Z",
|
||||
nextAction: "merge only after PASS",
|
||||
watchId: "ng8-review",
|
||||
nextCheck: "review artifact exists",
|
||||
};
|
||||
for (let n = 0; n < 10; n++) {
|
||||
const outcome = recordInProgressReport(recordCheckInjected(state), {
|
||||
evidence: "approved watch remains active",
|
||||
progress: waiting,
|
||||
});
|
||||
assert.equal(outcome.classification, "waiting");
|
||||
state = outcome.state;
|
||||
assert.equal(state.status, "active");
|
||||
assert.equal(state.noProgressReports, 2);
|
||||
assert.equal(state.checks, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("RED CONTROL: neutralizing no-progress counting restores the infinite loop", async () => {
|
||||
const source = readFileSync(new URL("../lib/state.ts", import.meta.url), "utf8");
|
||||
const sabotaged = source.replace(
|
||||
"const nextNoProgress = state.noProgressReports + 1; // NG8_COUNT_GATE",
|
||||
"const nextNoProgress = state.noProgressReports; // NG8_COUNT_GATE neutralized",
|
||||
);
|
||||
assert.notEqual(sabotaged, source, "count sabotage must change source");
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-ng8-count-red-"));
|
||||
try {
|
||||
const path = join(dir, "state.ts");
|
||||
writeFileSync(path, sabotaged);
|
||||
const red = await import(`${pathToFileURL(path).href}?red=${Date.now()}`);
|
||||
let state = red.setGoal(red.initialState(), "ship", 25);
|
||||
for (let n = 0; n < DEFAULT_MAX_NO_PROGRESS_REPORTS + 2; n++) {
|
||||
state = red.recordCheckInjected(state);
|
||||
state = red.recordInProgressReport(state, { evidence: "", progress: undefined }).state;
|
||||
}
|
||||
assert.equal(state.status, "active", "RED: report-only loop survives when counting is neutralized");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RED CONTROL: neutralizing pause enforcement leaves the counted loop active", async () => {
|
||||
const source = readFileSync(new URL("../lib/state.ts", import.meta.url), "utf8");
|
||||
const sabotaged = source.replace(
|
||||
"if (nextNoProgress >= state.maxNoProgressReports) { // NG8_PAUSE_GATE",
|
||||
"if (false && nextNoProgress >= state.maxNoProgressReports) { // NG8_PAUSE_GATE neutralized",
|
||||
);
|
||||
assert.notEqual(sabotaged, source, "pause sabotage must change source");
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-ng8-pause-red-"));
|
||||
try {
|
||||
const path = join(dir, "state.ts");
|
||||
writeFileSync(path, sabotaged);
|
||||
const red = await import(`${pathToFileURL(path).href}?red=${Date.now()}`);
|
||||
let state = red.setGoal(red.initialState(), "ship", 25);
|
||||
for (let n = 0; n < DEFAULT_MAX_NO_PROGRESS_REPORTS; n++) {
|
||||
state = red.recordCheckInjected(state);
|
||||
state = red.recordInProgressReport(state, { evidence: "", progress: undefined }).state;
|
||||
}
|
||||
assert.equal(state.noProgressReports, DEFAULT_MAX_NO_PROGRESS_REPORTS);
|
||||
assert.equal(state.status, "active", "RED: counted loop survives when pause gate is neutralized");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { initialState, setGoal, recordInProgressReport } from "../lib/state.ts";
|
||||
import { decideSettle } from "../lib/settle.ts";
|
||||
import { parseGoalCommand } from "../lib/parse.ts";
|
||||
|
||||
const wait = { evidence: "Dependency pending", progress: { kind: "wait" as const, gate: "input", owner: "operator", lastMeasurement: "input absent", nextAction: "Read input when available", nextCheck: "input file arrives" } };
|
||||
|
||||
test("opted-in waits suspend settle injection", () => {
|
||||
const state = { ...setGoal(initialState(), "prepare output"), waitTimeoutSeconds: 60 };
|
||||
const waiting = recordInProgressReport(state, wait).state;
|
||||
assert.equal(decideSettle(waiting, "stop").action, "wait");
|
||||
});
|
||||
|
||||
test("operator can select bounded waits per goal", () => {
|
||||
assert.deepEqual(parseGoalCommand("--wait-timeout 60 --max 5 prepare output"), { kind: "set", text: "prepare output", max: 5, waitTimeoutSeconds: 60 });
|
||||
});
|
||||
|
||||
test("timeout input is bounded and opt-in never leaks to another goal", () => {
|
||||
for (const args of ["--wait-timeout", "--wait-timeout=", "--wait-timeout 0 x", "--wait-timeout 9 x", "--wait-timeout 86401 x", "--wait-timeout 60.0 x", "--wait-timeout 60", "--wait-timeout 60 --wait-timeout 30 x"]) {
|
||||
assert.equal(parseGoalCommand(args).kind, "error", args);
|
||||
}
|
||||
const opted = setGoal(initialState(), "one", undefined, 60);
|
||||
assert.equal(setGoal(opted, "two").waitTimeoutSeconds, undefined);
|
||||
const legacyWait = recordInProgressReport(setGoal(initialState(), "legacy"), wait).state;
|
||||
assert.equal(decideSettle(legacyWait, "stop").action, "inject");
|
||||
});
|
||||
@@ -0,0 +1,599 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { fencedStateFilePath } from "../lib/store.ts";
|
||||
import {
|
||||
publishGoalPolicy,
|
||||
registerGoalTrackedItemResolver,
|
||||
resetGoalPolicyRegistryForTest,
|
||||
} from "../../mosaic-core/lib/goal-policy.ts";
|
||||
|
||||
interface SentMessage {
|
||||
content: string;
|
||||
options?: { deliverAs?: "steer" | "followUp" };
|
||||
}
|
||||
|
||||
const GOAL_ROOT = dirname(fileURLToPath(new URL("../index.ts", import.meta.url)));
|
||||
const INCARNATION_SOURCE = join(GOAL_ROOT, "..", "mosaic-core", "lib", "incarnation.ts");
|
||||
const EXECUTIVE_UPDATE_FORMAT = "structured progress in the ms-executive-update format (Just Completed / Next Step / Blocked)";
|
||||
const PROACTIVE_LOOP_DIRECTIVE =
|
||||
"Before reporting, run the ms-proactive-agent loop: reconcile records, perform the next authorized ready task, and verify its result. If no task can proceed, report a concrete wait or blocker. This extension owns goal lifecycle; use skills for task tracking, not a second goal loop.";
|
||||
|
||||
async function loadGoalExtension(root: string): Promise<(pi: any) => void> {
|
||||
const extensionRoot = join(root, "extensions", "goal-runtime");
|
||||
cpSync(join(GOAL_ROOT, "lib"), join(extensionRoot, "lib"), { recursive: true });
|
||||
mkdirSync(join(root, "extensions", "mosaic-core", "lib"), { recursive: true });
|
||||
cpSync(INCARNATION_SOURCE, join(root, "extensions", "mosaic-core", "lib", "incarnation.ts"));
|
||||
cpSync(join(GOAL_ROOT, "..", "mosaic-core", "lib", "goal-policy.ts"), join(root, "extensions", "mosaic-core", "lib", "goal-policy.ts"));
|
||||
|
||||
const source = readFileSync(join(GOAL_ROOT, "index.ts"), "utf8");
|
||||
const withoutTypebox = source.replace(
|
||||
'import { Type } from "typebox";',
|
||||
'const Type = { Object: (properties: unknown) => ({ type: "object", properties }), Optional: (schema: unknown) => schema, String: (options: unknown = {}) => ({ type: "string", ...(options as object) }) };',
|
||||
);
|
||||
const selfContained = withoutTypebox.replace(
|
||||
'import { StringEnum } from "@earendil-works/pi-ai";',
|
||||
'const StringEnum = (values: readonly string[]) => ({ type: "string", enum: values });',
|
||||
);
|
||||
assert.notEqual(withoutTypebox, source, "headless harness must replace TypeBox runtime import");
|
||||
assert.notEqual(selfContained, withoutTypebox, "headless harness must replace pi-ai runtime import");
|
||||
writeFileSync(join(extensionRoot, "index.ts"), selfContained);
|
||||
return (await import(`${pathToFileURL(join(extensionRoot, "index.ts")).href}?runtime=${Date.now()}`)).default;
|
||||
}
|
||||
|
||||
async function createHeadlessHarness(root: string, incarnation: string) {
|
||||
const agentDir = join(root, "state", "goal");
|
||||
const handlers = new Map<string, Array<(event: any, ctx: any) => any>>();
|
||||
const commands = new Map<string, any>();
|
||||
const tools = new Map<string, any>();
|
||||
const sent: SentMessage[] = [];
|
||||
const notifications: Array<{ message: string; level: string }> = [];
|
||||
const widgets: unknown[] = [];
|
||||
const statuses: string[] = [];
|
||||
const shortcuts = new Map<string, any>();
|
||||
|
||||
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousIncarnation = process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
process.env.MOSAIC_LAUNCH_INCARNATION = incarnation;
|
||||
|
||||
const pi: any = {
|
||||
on(name: string, handler: (event: any, ctx: any) => any) {
|
||||
handlers.set(name, [...(handlers.get(name) ?? []), handler]);
|
||||
},
|
||||
registerCommand(name: string, command: any) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
registerShortcut(key: string, shortcut: any) { shortcuts.set(key, shortcut); },
|
||||
registerTool(tool: any) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
sendUserMessage(content: string, options?: SentMessage["options"]) {
|
||||
sent.push({ content, options });
|
||||
},
|
||||
};
|
||||
|
||||
let idle = false;
|
||||
const ctx: any = {
|
||||
mode: "interactive",
|
||||
hasUI: true,
|
||||
isIdle: () => idle,
|
||||
ui: {
|
||||
theme: { fg: (color: string, text: string) => `[${color}]${text}` },
|
||||
setStatus(_id: string, text: string) { statuses.push(text); },
|
||||
notify(message: string, level: string) {
|
||||
notifications.push({ message, level });
|
||||
},
|
||||
setWidget(_id: string, value: unknown) {
|
||||
widgets.push(value);
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
getBranch: () => [{ type: "message", message: { role: "assistant", stopReason: "stop" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const goalExtension = await loadGoalExtension(root);
|
||||
goalExtension(pi);
|
||||
|
||||
return {
|
||||
commands,
|
||||
tools,
|
||||
sent,
|
||||
notifications,
|
||||
widgets,
|
||||
statuses,
|
||||
shortcuts,
|
||||
ctx,
|
||||
setIdle(value: boolean) {
|
||||
idle = value;
|
||||
},
|
||||
async emit(name: string, event: any = { type: name }) {
|
||||
for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
|
||||
},
|
||||
state() {
|
||||
return JSON.parse(readFileSync(fencedStateFilePath(agentDir, incarnation), "utf8"));
|
||||
},
|
||||
restoreEnv() {
|
||||
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
||||
if (previousIncarnation === undefined) delete process.env.MOSAIC_LAUNCH_INCARNATION;
|
||||
else process.env.MOSAIC_LAUNCH_INCARNATION = previousIncarnation;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("goal reporting instructions name the executive-update format and proactive loop", () => {
|
||||
const source = readFileSync(join(GOAL_ROOT, "index.ts"), "utf8");
|
||||
const checkPrompt = source.slice(source.indexOf("function checkPrompt(): string"), source.indexOf("/** Inject one check prompt"));
|
||||
const goalReport = source.slice(source.indexOf('name: "goal_report"'), source.indexOf("promptSnippet:", source.indexOf('name: "goal_report"')));
|
||||
const beforeAgentStart = source.slice(source.indexOf('pi.on("before_agent_start"'), source.indexOf('pi.on("tool_result"'));
|
||||
|
||||
for (const [section, text] of Object.entries({ checkPrompt, goalReport, beforeAgentStart })) {
|
||||
assert.ok(text.includes(EXECUTIVE_UPDATE_FORMAT), `${section} must name the executive-update format`);
|
||||
assert.ok(text.includes(PROACTIVE_LOOP_DIRECTIVE), `${section} must name the proactive loop`);
|
||||
}
|
||||
});
|
||||
|
||||
test("headless runtime exercises goal_report, agent_settled, follow-up injection, and pause", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-ng8-runtime-"));
|
||||
const harness = await createHeadlessHarness(dir, "ng8-headless-runtime");
|
||||
try {
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
assert.ok(goal);
|
||||
assert.ok(report);
|
||||
|
||||
await goal.handler("ship safely", harness.ctx);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
assert.equal(harness.sent[0].options?.deliverAs, "followUp");
|
||||
assert.match(harness.sent[0].content, /no-progress 0\/3/);
|
||||
|
||||
for (let cycle = 1; cycle <= 3; cycle++) {
|
||||
const result = await report.execute(`report-${cycle}`, { status: "in_progress" }, undefined, undefined, harness.ctx);
|
||||
await harness.emit("tool_result", {
|
||||
type: "tool_result",
|
||||
toolCallId: `report-${cycle}`,
|
||||
toolName: "goal_report",
|
||||
input: { status: "in_progress" },
|
||||
content: result.content,
|
||||
isError: false,
|
||||
});
|
||||
await harness.emit("agent_settled");
|
||||
if (cycle < 3) assert.equal(harness.sent.length, cycle + 1);
|
||||
}
|
||||
|
||||
assert.equal(harness.sent.length, 3, "paused loop injects no fourth check");
|
||||
assert.equal(harness.state().status, "paused");
|
||||
assert.equal(harness.state().noProgressReports, 3);
|
||||
assert.ok(harness.notifications.some((notice) => notice.level === "warning" && /no substantive progress/.test(notice.message)));
|
||||
} finally {
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("C7/C8: attested parser and resolver reject before Goal state changes", async () => {
|
||||
resetGoalPolicyRegistryForTest();
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-v4-policy-"));
|
||||
const incarnation = "inc-goal-v4";
|
||||
const harness = await createHeadlessHarness(dir, incarnation);
|
||||
try {
|
||||
assert.equal(registerGoalTrackedItemResolver(incarnation, async () => Object.freeze({ outcome: "zero" as const })).ok, true);
|
||||
const journalReasons: string[] = [];
|
||||
const publication = publishGoalPolicy({
|
||||
schemaVersion: 1,
|
||||
role: "plan-ng",
|
||||
roleRevision: 4,
|
||||
manifestSha256: "a".repeat(64),
|
||||
format: "ms-executive-update/v1",
|
||||
contractPath: "skills-local/ms-executive-update/SKILL.md",
|
||||
contractSection: "Machine contract (for `goal_report` payloads and any parser)",
|
||||
contractBlob: "df30c6fbb54b4a65a298c9e51c07f742610d171c",
|
||||
contractSha256: "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319",
|
||||
enforcement: "pre-state-change-fail-closed",
|
||||
identifierResolution: "consumer-fail-closed",
|
||||
launchGeneration: 1,
|
||||
incarnationId: incarnation,
|
||||
}, (reason) => journalReasons.push(reason));
|
||||
assert.equal(publication.ok, true);
|
||||
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("attested policy", harness.ctx);
|
||||
const before = harness.state();
|
||||
const update = [
|
||||
"Just Completed:", "", "* T165: contracts added", "", "Next Step:", "", "* T165: run tests", "", "Blocked:", "", "* T165: none \u2014 nothing from you", "",
|
||||
].join("\n");
|
||||
await assert.rejects(
|
||||
() => report.execute("attested-reject", { status: "satisfied", evidence: update }, undefined, undefined, harness.ctx),
|
||||
/resolver-zero/,
|
||||
);
|
||||
const after = harness.state();
|
||||
assert.equal(after.status, "active");
|
||||
assert.equal(after.checks, before.checks);
|
||||
assert.equal(after.noProgressReports, before.noProgressReports);
|
||||
|
||||
const noChange = "No change since T165; still waiting on T165.";
|
||||
for (const status of ["satisfied", "blocked"] as const) {
|
||||
await assert.rejects(
|
||||
() => report.execute(`no-change-${status}`, { status, evidence: noChange }, undefined, undefined, harness.ctx),
|
||||
/no-change-status/,
|
||||
);
|
||||
}
|
||||
const terminalAfter = harness.state();
|
||||
assert.equal(terminalAfter.status, "active", "unchanged form cannot settle or pause the goal");
|
||||
assert.equal(terminalAfter.checks, before.checks);
|
||||
assert.equal(terminalAfter.noProgressReports, before.noProgressReports);
|
||||
assert.deepEqual(
|
||||
journalReasons,
|
||||
["resolver-zero", "no-change-status", "no-change-status"],
|
||||
"all denials journal stable codes without report payload text",
|
||||
);
|
||||
} finally {
|
||||
resetGoalPolicyRegistryForTest();
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("headless runtime accepts a tool-backed measurement and preserves terminal behavior", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-ng8-runtime-work-"));
|
||||
const harness = await createHeadlessHarness(dir, "ng8-headless-work");
|
||||
try {
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("measure", harness.ctx);
|
||||
|
||||
await harness.emit("tool_result", {
|
||||
type: "tool_result",
|
||||
toolCallId: "read-error",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/missing" },
|
||||
content: [{ type: "text", text: "missing" }],
|
||||
isError: true,
|
||||
});
|
||||
assert.equal(harness.state().workEventSinceReport, false, "failed tools are not progress events");
|
||||
|
||||
await harness.emit("tool_result", {
|
||||
type: "tool_result",
|
||||
toolCallId: "read-1",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/status" },
|
||||
content: [{ type: "text", text: "measured" }],
|
||||
isError: false,
|
||||
});
|
||||
assert.equal(harness.state().workEventSinceReport, true);
|
||||
|
||||
const progress = {
|
||||
kind: "measurement",
|
||||
gate: "review",
|
||||
owner: "reviewer",
|
||||
lastMeasurement: "head abc remains pending at 2026-08-31T15:00:00Z",
|
||||
nextAction: "dispatch reviewer",
|
||||
};
|
||||
const accepted = await report.execute(
|
||||
"progress-1",
|
||||
{ status: "in_progress", evidence: "measured exact head", progress },
|
||||
undefined,
|
||||
undefined,
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(accepted.content[0].text, /Recorded substantive progress/);
|
||||
assert.equal(harness.state().noProgressReports, 0);
|
||||
assert.equal(harness.state().workEventSinceReport, false);
|
||||
|
||||
await goal.handler("done goal", harness.ctx);
|
||||
await assert.rejects(
|
||||
() => report.execute("satisfied-empty", { status: "satisfied" }, undefined, undefined, harness.ctx),
|
||||
/requires non-empty evidence/,
|
||||
);
|
||||
const satisfied = await report.execute(
|
||||
"satisfied",
|
||||
{ status: "satisfied", evidence: "acceptance evidence" },
|
||||
undefined,
|
||||
undefined,
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(satisfied.content[0].text, /SATISFIED/);
|
||||
assert.equal(harness.state().status, "none");
|
||||
|
||||
await goal.handler("blocked goal", harness.ctx);
|
||||
const blocked = await report.execute(
|
||||
"blocked",
|
||||
{ status: "blocked", evidence: "missing credential" },
|
||||
undefined,
|
||||
undefined,
|
||||
harness.ctx,
|
||||
);
|
||||
assert.match(blocked.content[0].text, /BLOCKED/);
|
||||
assert.equal(harness.state().status, "blocked");
|
||||
} finally {
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const waitReport = {
|
||||
status: "in_progress", evidence: "Input is pending",
|
||||
progress: { kind: "wait", gate: "input", owner: "operator", lastMeasurement: "input absent",
|
||||
nextAction: "Read input and finish output", nextCheck: "input file available" },
|
||||
};
|
||||
|
||||
test("quiet wait: no heartbeats, one deadline wake, unresolved dependency pauses", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-runtime-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-runtime");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await report.execute("wait-1", waitReport, undefined, undefined, harness.ctx);
|
||||
const deadline = harness.state().activeWait.deadlineAt;
|
||||
for (let i = 0; i < 100; i++) await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 1, "settle events cannot generate idle heartbeats");
|
||||
t.mock.timers.tick(30000);
|
||||
await report.execute("wait-2", { ...waitReport, evidence: "Still pending, differently worded" }, undefined, undefined, harness.ctx);
|
||||
assert.equal(harness.state().activeWait.deadlineAt, deadline, "unchanged waits never extend deadline");
|
||||
t.mock.timers.tick(29999);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(harness.sent.length, 2);
|
||||
assert.match(harness.sent[1].content, /Wait deadline reached/);
|
||||
assert.equal(harness.state().waitWakeUsed, true);
|
||||
const result = await report.execute("wait-3", waitReport, undefined, undefined, harness.ctx);
|
||||
assert.equal(result.details.goalStatus, "paused");
|
||||
t.mock.timers.tick(86400000);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 2, "unresolved deadline never re-arms itself");
|
||||
await assert.rejects(() => report.execute("late-satisfied", { status: "satisfied", evidence: "late result" }, undefined, undefined, harness.ctx), /paused/);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quiet wait: persisted deadline survives reload without an immediate check", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-reload-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-reload");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await harness.tools.get("goal_report").execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
await harness.emit("session_shutdown");
|
||||
t.mock.timers.tick(30000);
|
||||
await harness.emit("session_start");
|
||||
assert.equal(harness.sent.length, 1);
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(harness.sent.length, 2);
|
||||
assert.match(harness.sent[1].content, /Wait deadline reached/);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quiet wait: stop, clear, replacement, and completion cancel the timer", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-cancel-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-cancel");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
for (const operation of ["stop", "clear", "replacement", "satisfied"]) {
|
||||
await goal.handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
if (operation === "satisfied") {
|
||||
await report.execute("done", { status: "satisfied", evidence: "Output verified" }, undefined, undefined, harness.ctx);
|
||||
} else {
|
||||
await goal.handler(operation, harness.ctx);
|
||||
}
|
||||
const before = harness.sent.length;
|
||||
t.mock.timers.tick(61000);
|
||||
assert.equal(harness.sent.length, before, `${operation} leaves no deadline wake`);
|
||||
}
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quiet wait: a busy turn is allowed to settle before deadline injection", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-busy-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-busy");
|
||||
try {
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await harness.tools.get("goal_report").execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(harness.sent.length, 1, "no deadline queued during a running turn");
|
||||
await harness.commands.get("goal").handler("stop", harness.ctx);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 1, "stop cancels deferred deadline");
|
||||
await harness.commands.get("goal").handler("resume", harness.ctx);
|
||||
assert.equal(harness.state().activeWait, undefined);
|
||||
await harness.tools.get("goal_report").execute("wait-again", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
const before = harness.sent.length;
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, before + 1);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quiet wait: relevant progress cancels the wait; print mode has no timer wake", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-quiet-progress-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-progress");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
await harness.emit("tool_result", { toolName: "read", isError: false });
|
||||
await report.execute("progress", { status: "in_progress", evidence: "Input arrived and was verified", progress: { ...waitReport.progress, kind: "measurement", lastMeasurement: "Input present" } }, undefined, undefined, harness.ctx);
|
||||
assert.equal(harness.state().activeWait, undefined);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 2, "ready work continues after wait is resolved");
|
||||
harness.ctx.mode = "print";
|
||||
await goal.handler("--wait-timeout 60 write output", harness.ctx);
|
||||
await report.execute("wait-print", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 2, "print mode never schedules a model wake");
|
||||
} finally {
|
||||
await harness.emit("session_shutdown");
|
||||
harness.restoreEnv();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("deadline readiness recovers after compaction without agent_settled", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-compaction-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-compaction");
|
||||
try {
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 output", harness.ctx);
|
||||
await harness.tools.get("goal_report").execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
harness.setIdle(true);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(harness.sent.length, 2, "runtime readiness alone releases the deferred wake");
|
||||
} finally {
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unobserved deadline dispatch pauses once; observed start cancels watchdog", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-dispatch-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-dispatch");
|
||||
try {
|
||||
harness.setIdle(true);
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
await goal.handler("--wait-timeout 60 output", harness.ctx);
|
||||
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
const sent = harness.sent.length;
|
||||
// Ordinary Pi sendUserMessage returns void; a rejected async delivery may never start.
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(harness.state().status, "paused");
|
||||
assert.match(harness.state().pausedReason, /not observed/);
|
||||
assert.equal(harness.sent.length, sent, "watchdog never retries a potentially delivered request");
|
||||
await goal.handler("resume", harness.ctx);
|
||||
await report.execute("wait-again", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
await harness.emit("before_agent_start", { prompt: harness.sent.at(-1)!.content, systemPrompt: "base" });
|
||||
assert.equal(harness.state().activeWait.wakeObserved, true);
|
||||
t.mock.timers.tick(30000);
|
||||
assert.equal(harness.state().status, "active", "observed model start cancels the acknowledgment timer");
|
||||
} finally {
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("continuously busy runtime pauses after bounded readiness checks", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: 100000 });
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-busy-limit-"));
|
||||
const harness = await createHeadlessHarness(dir, "quiet-busy-limit");
|
||||
try {
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 output", harness.ctx);
|
||||
await harness.tools.get("goal_report").execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
t.mock.timers.tick(60000);
|
||||
for (let i = 0; i < 60; i++) t.mock.timers.tick(1000);
|
||||
assert.equal(harness.state().status, "paused");
|
||||
assert.match(harness.state().pausedReason, /remained busy/);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("footer and both recall paths cover lifecycle without adding widget content", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-footer-"));
|
||||
const harness = await createHeadlessHarness(dir, "footer");
|
||||
const previousNoColor = process.env.NO_COLOR;
|
||||
delete process.env.NO_COLOR;
|
||||
try {
|
||||
const goal = harness.commands.get("goal");
|
||||
const report = harness.tools.get("goal_report");
|
||||
const text = "Full goal " + "acceptance criteria ".repeat(100) + " END-OF-GOAL";
|
||||
await harness.emit("session_start");
|
||||
assert.match(harness.statuses.at(-1)!, /\[muted\]Goal: None/);
|
||||
await goal.handler(text, harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[accent\]Goal: Active/);
|
||||
const sent = harness.sent.length;
|
||||
await goal.handler("", harness.ctx);
|
||||
const recall = harness.notifications.at(-1)!.message;
|
||||
assert.ok(recall.includes(text));
|
||||
await harness.shortcuts.get("alt+g").handler(harness.ctx);
|
||||
assert.equal(harness.notifications.at(-1)!.message, recall);
|
||||
assert.equal(harness.sent.length, sent, "recall never starts a turn");
|
||||
assert.equal(harness.shortcuts.has("tab"), false);
|
||||
await report.execute("wait", waitReport, undefined, undefined, harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[warning\]Goal: Waiting/);
|
||||
await goal.handler("stop", harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[warning\]Goal: Paused/);
|
||||
await goal.handler("resume", harness.ctx);
|
||||
await report.execute("blocked", { status: "blocked", evidence: "permission needed" }, undefined, undefined, harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[error\]Goal: Blocked/);
|
||||
await goal.handler("resume", harness.ctx);
|
||||
await report.execute("done", { status: "satisfied", evidence: "verified" }, undefined, undefined, harness.ctx);
|
||||
assert.match(harness.statuses.at(-1)!, /\[success\]Goal: Complete/);
|
||||
assert.equal(harness.state().lastOutcome.text, text);
|
||||
const finishedSent = harness.sent.length;
|
||||
await harness.emit("agent_settled");
|
||||
await harness.emit("session_start");
|
||||
await goal.handler("resume", harness.ctx);
|
||||
assert.equal(harness.sent.length, finishedSent);
|
||||
await goal.handler("", harness.ctx);
|
||||
assert.ok(harness.notifications.at(-1)!.message.includes(text));
|
||||
process.env.NO_COLOR = "1";
|
||||
await harness.emit("session_start");
|
||||
assert.equal(harness.statuses.at(-1), "Goal: Complete (/goal, Alt+G)");
|
||||
await goal.handler("clear", harness.ctx);
|
||||
assert.equal(harness.state().lastOutcome, undefined);
|
||||
assert.equal(harness.statuses.at(-1), "Goal: None (/goal, Alt+G)");
|
||||
assert.ok(harness.widgets.every(value => value === undefined));
|
||||
} finally {
|
||||
if (previousNoColor === undefined) delete process.env.NO_COLOR;
|
||||
else process.env.NO_COLOR = previousNoColor;
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("state-write failure cannot report goal completion or inject another check", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "goal-save-failure-"));
|
||||
const incarnation = "quiet-save-failure";
|
||||
const harness = await createHeadlessHarness(dir, incarnation);
|
||||
try {
|
||||
await harness.commands.get("goal").handler("--wait-timeout 60 output", harness.ctx);
|
||||
const statePath = fencedStateFilePath(join(dir, "state", "goal"), incarnation);
|
||||
rmSync(statePath);
|
||||
mkdirSync(statePath); // force atomic replacement failure without mocking away filesystem behavior
|
||||
await assert.rejects(() => harness.tools.get("goal_report").execute("done", { status: "satisfied", evidence: "done" }, undefined, undefined, harness.ctx), /could not be saved/);
|
||||
await harness.emit("agent_settled");
|
||||
assert.equal(harness.sent.length, 1);
|
||||
assert.equal(harness.notifications.some((n) => n.message.includes("satisfied and cleared")), false);
|
||||
} finally {
|
||||
await harness.emit("session_shutdown"); harness.restoreEnv(); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user