@@ -0,0 +1,19 @@
|
||||
# Extensions
|
||||
|
||||
This directory holds canonical Pi extension source owned by stack-v2.
|
||||
|
||||
- `goal/` is the first accepted extension.
|
||||
- `mosaic-core/lib/` contains the supporting modules imported by goal. It has no extension entrypoint and does not activate Mosaic Core on its own.
|
||||
|
||||
Develop here, not under `.pi/extensions/`. Run:
|
||||
|
||||
```sh
|
||||
node --test extensions/goal/test/*.test.ts
|
||||
bash scripts/test-extension-package.sh
|
||||
scripts/sync-dev-extensions.sh --check
|
||||
python3 scripts/test-goal-native.py
|
||||
```
|
||||
|
||||
`scripts/sync-dev-extensions.sh` creates the native development installation under `.pi/extensions/` with ordinary files. It records installed hashes and refuses to overwrite local drift. `scripts/goal-dev.sh` synchronizes first, then loads only the generated goal entrypoint.
|
||||
|
||||
This is development packaging, not a published npm package or managed-runtime release. The Containerfile and runtime adapter still exclude these extensions. A later release increment must define its explicit artifact inventory, supported Pi version, image integration, policy gate, and rollback before activation.
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
// lib/adapter.ts — Pi adapter binding (PRD R3-R5; NG-2).
|
||||
//
|
||||
// Deliberately dependency-light (type-only imports + node builtins) so the
|
||||
// hermetic suite can import and exercise the REAL binding against a fake pi
|
||||
// — including the AC4 red control, which sabotages this file's enforcement
|
||||
// registration block and proves the same call then passes.
|
||||
//
|
||||
// The binding:
|
||||
// session_start (every reason: startup/new/resume/reload/fork — policy may
|
||||
// have changed on disk between sessions) ->
|
||||
// resolve seat role from the launcher-established agent
|
||||
// name -> load trusted manifest -> build application ->
|
||||
// setActiveTools(exact) -> VERIFY getActiveTools() equals
|
||||
// the exact set; any drift at any stage -> fail-closed.
|
||||
// tool_call -> decideToolCall; blocked calls return the stable code.
|
||||
|
||||
import { incarnationIdentity } from "./incarnation.ts";
|
||||
import {
|
||||
buildApplication,
|
||||
decideToolCall,
|
||||
failClosedState,
|
||||
statusSnapshot,
|
||||
STATUS_TOOL,
|
||||
PROPOSAL_TOOL,
|
||||
SAFE_READ_TOOLS,
|
||||
type EnforcementState,
|
||||
} from "./enforce.ts";
|
||||
import { loadTrustedManifest, seatRole, type LoadOutcome, type LoaderIO } from "./loader.ts";
|
||||
import { createJournal, type Journal, type JournalIO } from "./journal.ts";
|
||||
import { sanitizeProposal } from "./proposal.ts";
|
||||
import { publishGoalPolicy, verifyGoalPolicyPublication, type GoalPolicyAttestationV1 } from "./goal-policy.ts";
|
||||
import { readFileSync, lstatSync, realpathSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
export interface PiToolCallEvent {
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface PiLike {
|
||||
on(event: "session_start", handler: (event: { reason: string }) => void | Promise<void>): void;
|
||||
on(event: "tool_call", handler: (event: PiToolCallEvent) => unknown): void;
|
||||
getActiveTools(): string[];
|
||||
setActiveTools(names: string[]): void;
|
||||
registerTool(tool: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface AdapterOptions {
|
||||
env?: Record<string, string | undefined>;
|
||||
io?: LoaderIO;
|
||||
journalIO?: JournalIO;
|
||||
stateHome?: string;
|
||||
incarnationId?: string;
|
||||
launchGeneration?: number;
|
||||
}
|
||||
|
||||
function defaultIO(): LoaderIO {
|
||||
return {
|
||||
readFileSync(path: string): string | Uint8Array { return readFileSync(path); },
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
};
|
||||
}
|
||||
|
||||
function launchGeneration(env: Record<string, string | undefined>, supplied: number | undefined): number | undefined {
|
||||
if (supplied !== undefined) return supplied;
|
||||
const raw = env.MOSAIC_LAUNCH_GENERATION;
|
||||
if (!raw || !/^[1-9][0-9]*$/.test(raw)) return undefined;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) ? value : undefined;
|
||||
}
|
||||
|
||||
export function bindEnforcement(pi: PiLike, opts: AdapterOptions = {}): void {
|
||||
const env = opts.env ?? process.env;
|
||||
const io = opts.io ?? defaultIO();
|
||||
const incarnationId = opts.incarnationId ?? incarnationIdentity();
|
||||
const configuredLaunchGeneration = launchGeneration(env, opts.launchGeneration);
|
||||
let enforcement: EnforcementState = { state: "not-applied", incarnationId };
|
||||
let activeGoalPolicy: GoalPolicyAttestationV1 | undefined;
|
||||
// A version-4 role has exactly one trusted manifest/contract load per
|
||||
// incarnation. Reloads reuse this immutable result and never republish.
|
||||
let v4Load: Extract<LoadOutcome, { ok: true }> | undefined;
|
||||
let v4Published = false;
|
||||
|
||||
const brainHome = env.MOSAIC_BRAIN_HOME || `${homedir()}/.mosaic`;
|
||||
const journal: Journal = createJournal({
|
||||
incarnationId,
|
||||
stateHome: opts.stateHome ?? env.XDG_STATE_HOME,
|
||||
io: opts.journalIO,
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: STATUS_TOOL,
|
||||
label: "Mosaic policy status",
|
||||
description:
|
||||
"Read-only report of the applied mosaic-core role policy: role, revision, digest, " +
|
||||
"active tools, and enforcement state. Cannot modify anything.",
|
||||
parameters: { type: "object", properties: {}, additionalProperties: false },
|
||||
async execute() {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(statusSnapshot(enforcement, pi.getActiveTools())) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: PROPOSAL_TOOL,
|
||||
label: "Mosaic improvement proposal",
|
||||
description:
|
||||
"Record a structured improvement proposal for the mosaic-core role policy. Proposals " +
|
||||
"are appended to the per-incarnation journal for coordinator review; they never " +
|
||||
"modify manifests, settings, or active tools.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
target: { type: "string", description: "What the proposal concerns: a role, tool, or policy field" },
|
||||
summary: { type: "string", description: "One line, at most 500 characters" },
|
||||
motivation: { type: "string", description: "Why this improvement matters, at most 2000 characters" },
|
||||
evidence: { type: "array", items: { type: "string" }, description: "Optional evidence pointers, at most 8 items of 500 characters" },
|
||||
},
|
||||
required: ["target", "summary", "motivation"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(_id: string, params: unknown) {
|
||||
const verdict = sanitizeProposal(params);
|
||||
if (!verdict.ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: `proposal rejected (${verdict.reason}${verdict.detail ? `: ${verdict.detail}` : ""}) — recorded nothing` }],
|
||||
details: { rejected: verdict.reason },
|
||||
};
|
||||
}
|
||||
try {
|
||||
journal.append({ kind: "improvement-proposal", proposal: verdict.proposal });
|
||||
} catch {
|
||||
// Journal failure: the proposal is NOT recorded, nothing is applied,
|
||||
// and the caller sees a fixed message with no raw exception
|
||||
return {
|
||||
content: [{ type: "text", text: "proposal recording unavailable (journal failure); recorded nothing; nothing applied" }],
|
||||
details: { recorded: false, cause: "journal-failure" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `proposal recorded for coordinator review (target: ${verdict.proposal.target}); nothing was applied` }],
|
||||
details: { recorded: true },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// [NG-2 enforcement binding begin]
|
||||
// The enforcement surface under AC4's removal red control: exactly these
|
||||
// two registrations. Sabotage that deletes this block leaves the status
|
||||
// tool but no interception and no application — the same call then passes.
|
||||
pi.on("session_start", async () => {
|
||||
// Resolve the profile role before entering degraded mode. A known gate role
|
||||
// gets status only if its manifest, contract, resolver, or publication
|
||||
// fails; it must not inherit generic safe reads or proposal authority.
|
||||
const profileRole = seatRole(brainHome, env.MOSAIC_AGENT_NAME, io);
|
||||
let roleHint = profileRole ?? undefined;
|
||||
// NG4-RECON: enter TRUSTED FAIL-CLOSED BEFORE any platform call.
|
||||
// If setActiveTools or getActiveTools throws mid-reconciliation, the
|
||||
// previous applied policy does NOT remain authoritative (R5).
|
||||
enforcement = failClosedState(incarnationId, "reconciliation-in-progress", brainHome, roleHint);
|
||||
|
||||
try {
|
||||
let load: LoadOutcome;
|
||||
activeGoalPolicy = undefined;
|
||||
if (v4Load) {
|
||||
load = v4Load;
|
||||
roleHint = v4Load.manifest.role;
|
||||
} else {
|
||||
if (profileRole === null) {
|
||||
const stage = env.MOSAIC_AGENT_NAME ? "no-seat-profile" : "no-agent-name";
|
||||
load = { ok: false, failure: { stage } as LoadOutcome extends { ok: false; failure: infer F } ? F : never };
|
||||
} else {
|
||||
load = loadTrustedManifest(brainHome, profileRole, io, { incarnationId, launchGeneration: configuredLaunchGeneration });
|
||||
if (load.ok) roleHint = load.manifest.role;
|
||||
}
|
||||
if (load.ok && load.manifest.schemaVersion === 4) v4Load = load;
|
||||
}
|
||||
if (load.ok && load.manifest.schemaVersion === 4) {
|
||||
const policy = load.goalPolicy?.attestation;
|
||||
if (!policy) {
|
||||
load = { ok: false, failure: { stage: "goal-policy-publication" } };
|
||||
} else if (!v4Published) {
|
||||
const publication = publishGoalPolicy(policy, (reason) => {
|
||||
try {
|
||||
journal.append({ kind: "denial", tool: "goal_report", reason: `mosaic-core:goal-policy-denied:${reason}` });
|
||||
} catch {
|
||||
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
|
||||
}
|
||||
});
|
||||
if (!publication.ok) {
|
||||
load = { ok: false, failure: { stage: "goal-policy-publication" } };
|
||||
} else {
|
||||
v4Published = true;
|
||||
activeGoalPolicy = publication.attestation;
|
||||
}
|
||||
} else {
|
||||
activeGoalPolicy = policy;
|
||||
}
|
||||
}
|
||||
const application = buildApplication({
|
||||
load,
|
||||
activeTools: pi.getActiveTools(),
|
||||
incarnationId,
|
||||
roleHint,
|
||||
brainHome,
|
||||
});
|
||||
pi.setActiveTools(application.applyTools);
|
||||
const got = [...pi.getActiveTools()].sort();
|
||||
const want = [...application.applyTools].sort();
|
||||
if (JSON.stringify(got) !== JSON.stringify(want)) {
|
||||
enforcement = failClosedState(incarnationId, "postcondition-failed", brainHome, roleHint);
|
||||
// A mismatch is not merely a status value. Remove the broad/incorrect
|
||||
// active projection before returning; interception remains the final
|
||||
// denial if the platform rejects this recovery call too.
|
||||
try { pi.setActiveTools([...enforcement.allowed].sort()); } catch { /* interception governs */ }
|
||||
try { journal.append({ kind: "reconciliation", state: "fail-closed", cause: "postcondition-failed", removed: [], added: [] }); } catch (e) { console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)"); }
|
||||
return;
|
||||
}
|
||||
enforcement = application.state;
|
||||
try {
|
||||
journal.append({
|
||||
kind: "reconciliation",
|
||||
state: application.state.state,
|
||||
role: application.state.state === "applied" ? application.state.role : undefined,
|
||||
revision: application.state.state === "applied" ? application.state.revision : undefined,
|
||||
digest: application.state.state === "applied" ? application.state.digest : undefined,
|
||||
cause: application.state.state === "fail-closed" ? application.state.cause : undefined,
|
||||
removed: application.plan ? application.plan.toRemove : [],
|
||||
added: application.plan ? application.plan.toAdd : [],
|
||||
});
|
||||
} catch (e) { console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)"); }
|
||||
} catch (reconErr) {
|
||||
// Platform call threw mid-reconciliation: the provisional fail-closed
|
||||
// state GOVERNS — the old applied policy is dead.
|
||||
// NG4-CAUSE: STABLE cause only — never embed the raw exception.
|
||||
// status EXPOSES cause; an injected/platform error could carry
|
||||
// sensitive content into model-visible denial reasons.
|
||||
enforcement = failClosedState(incarnationId, "reconciliation-threw", brainHome, roleHint);
|
||||
try { pi.setActiveTools([...enforcement.allowed].sort()); } catch { /* best-effort; interception governs */ }
|
||||
try { journal.append({ kind: "reconciliation", state: "fail-closed", cause: "reconciliation-threw", removed: [], added: [] }); } catch { /* evidence never bypasses enforcement */ }
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event: PiToolCallEvent) => {
|
||||
// F7-(1): wire event.input through so the read-containment check sees
|
||||
// the path argument. F7-B precision: for read-family tools (read,
|
||||
// grep, find, ls) with an ABSENT path, Pi executes against the tool's
|
||||
// own cwd — an uncontrolled location. We MUTATE the input to pin the
|
||||
// path to the trusted brain home (Pi guarantees mutation affects
|
||||
// execution and is not revalidated). The test suite asserts the
|
||||
// executed input IS pinned, not just the decision result.
|
||||
// F7E: read.path is REQUIRED in Pi — absent/malformed input is DENIED
|
||||
// by the enforcement layer (no synthesis, no cwd ambiguity). Only ls
|
||||
// gets absent-path pinning (it lists a directory, not a file, and its
|
||||
// path is genuinely optional in Pi).
|
||||
if ((SAFE_READ_TOOLS as readonly string[]).includes(event.toolName)) {
|
||||
// (2) ls with UNDEFINED input: assign {path: brainHome} (safe dir listing)
|
||||
// FIX2-(1): synthesize ONLY when event.input is EXACTLY undefined.
|
||||
// null / 42 / [] are malformed and must reach stable DENIAL, not be
|
||||
// silently mutated into a valid input.
|
||||
if (event.toolName === "ls" && event.input === undefined) {
|
||||
event.input = { path: brainHome };
|
||||
}
|
||||
if (event.input && typeof event.input === "object") {
|
||||
const input = event.input as { path?: unknown };
|
||||
// ls with absent path in an existing object: pin to brainHome
|
||||
if (event.toolName === "ls" && (input.path === undefined || input.path === "")) {
|
||||
input.path = brainHome;
|
||||
}
|
||||
// (3) Resolve RELATIVE paths against brainHome BEFORE realpath
|
||||
if (typeof input.path === "string" && input.path !== "" && !input.path.startsWith("/")) {
|
||||
input.path = brainHome + "/" + input.path;
|
||||
}
|
||||
// F7C-(1): realpath existing targets — symlink escape resolution
|
||||
if (typeof input.path === "string" && input.path !== "") {
|
||||
try {
|
||||
input.path = realpathSync(input.path);
|
||||
} catch {
|
||||
// path doesn't exist yet — leave as-is; containment handles traversal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.toolName === "goal_report" && activeGoalPolicy) {
|
||||
const policy = verifyGoalPolicyPublication(activeGoalPolicy);
|
||||
if (!policy.ok) {
|
||||
const reason = `mosaic-core:goal-policy-denied:${policy.reason}`;
|
||||
try {
|
||||
journal.append({ kind: "denial", tool: event.toolName, reason });
|
||||
} catch {
|
||||
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
|
||||
}
|
||||
return { block: true, reason };
|
||||
}
|
||||
}
|
||||
const decision = decideToolCall(enforcement, event.toolName, event.input, brainHome);
|
||||
if (decision.block) {
|
||||
// PART B: evidence failure must NEVER bypass enforcement — the denial
|
||||
// returns even if the journal append throws (disk full, permission,
|
||||
// injected failure)
|
||||
try {
|
||||
journal.append({ kind: "denial", tool: event.toolName, reason: decision.reason });
|
||||
} catch (journalErr) {
|
||||
console.warn("[mosaic-core] journal append failed (enforcement unaffected; details suppressed)");
|
||||
}
|
||||
return { block: true, reason: decision.reason };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
// [NG-2 enforcement binding end]
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// lib/enforce.ts — pure enforcement state machine (PRD R3-R5, AC3/AC5/AC6).
|
||||
//
|
||||
// Two states, one decision function:
|
||||
// applied — a valid trusted manifest governs; allowed = the exact
|
||||
// manifest tool projection. Registered extension tools that
|
||||
// lack a bound capability remain unavailable.
|
||||
// fail-closed — load/validation/postcondition failed; allowed = SAFE_READ_TOOLS
|
||||
// + the status tool ONLY (AC5: safe reads and status survive;
|
||||
// every side-effecting tool blocks with a stable reason).
|
||||
//
|
||||
// Reason codes are STABLE STRINGS (AC3) — they appear in logs, journals, and
|
||||
// tests, so the format is part of the contract:
|
||||
// mosaic-core:tool-not-in-manifest:<tool>
|
||||
// mosaic-core:fail-closed:<cause>:<tool>
|
||||
// mosaic-core:not-applied:<tool>
|
||||
// mosaic-core:read-missing-path
|
||||
// mosaic-core:read-scope-unbound
|
||||
// mosaic-core:read-credential-denied:<canonical-path>
|
||||
// mosaic-core:read-outside-scope:<canonical-path>
|
||||
// mosaic-core:read-symlink-escape:<canonical-path>
|
||||
|
||||
import type { ReconcilePlan } from "./reconcile.ts";
|
||||
import { reconcileActiveTools } from "./reconcile.ts";
|
||||
import type { LoadOutcome } from "./loader.ts";
|
||||
|
||||
export const STATUS_TOOL = "mosaic_policy_status";
|
||||
export const PROPOSAL_TOOL = "mosaic_improvement_propose";
|
||||
export const GATE_MERGE_ROLE = "gate-merge-ng";
|
||||
/**
|
||||
* F7D (mercer): only DIRECTLY-CONSTRAINABLE read tools survive fail-closed.
|
||||
* `read` reads exactly one file (path argument IS the target); `ls` lists one
|
||||
* directory non-recursively. `grep` and `find` RECURSE below the path root —
|
||||
* they can enumerate and expose credential-shaped descendants
|
||||
* (brainHome/auth, fleet/agents/<seat>/secrets, junk) even when the root path
|
||||
* passes containment. gitignore is NOT an authorization boundary. Applied
|
||||
* manifests already bind only C3->read; exposing grep/find in degraded
|
||||
* state INCREASES authority. AC5 says safe reads, not every read-family tool.
|
||||
*/
|
||||
export const SAFE_READ_TOOLS = ["read", "ls"] as const;
|
||||
export const SYMBOLIC_BRAIN = "@brain";
|
||||
|
||||
export type EnforcementState =
|
||||
| {
|
||||
state: "applied";
|
||||
incarnationId: string;
|
||||
role: string;
|
||||
revision: number;
|
||||
digest: string;
|
||||
allowed: ReadonlySet<string>;
|
||||
/** C3 read-scope roots. Symbolic "@brain" or absolute. Empty = C3 unbound. */
|
||||
readScope: readonly string[];
|
||||
}
|
||||
| {
|
||||
state: "fail-closed";
|
||||
incarnationId: string;
|
||||
cause: string;
|
||||
allowed: ReadonlySet<string>;
|
||||
readScope: readonly string[];
|
||||
}
|
||||
| { state: "not-applied"; incarnationId: string };
|
||||
|
||||
export interface PolicyApplicationInput {
|
||||
load: LoadOutcome;
|
||||
activeTools: string[];
|
||||
incarnationId: string;
|
||||
/** Role resolved from the seat profile before a v4 load can fail. */
|
||||
roleHint?: string;
|
||||
/** The trusted brain home (for resolving the symbolic @brain root). */
|
||||
brainHome?: string;
|
||||
}
|
||||
|
||||
export interface PolicyApplication {
|
||||
state: EnforcementState;
|
||||
plan?: ReconcilePlan;
|
||||
/** the exact tool set setActiveTools must receive */
|
||||
applyTools: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A gate role's reviewed manifest explicitly forbids read, ls, and proposal.
|
||||
* On any gate load or reconciliation failure, retaining the generic safe-read
|
||||
* posture would broaden that role's active authority. Other roles retain the
|
||||
* established v3 degraded posture for compatibility.
|
||||
*/
|
||||
export function failClosedState(
|
||||
incarnationId: string,
|
||||
cause: string,
|
||||
brainHome?: string,
|
||||
roleHint?: string,
|
||||
): Extract<EnforcementState, { state: "fail-closed" }> {
|
||||
const gateRole = roleHint === GATE_MERGE_ROLE;
|
||||
const allowed = gateRole
|
||||
? new Set([STATUS_TOOL])
|
||||
: new Set([...SAFE_READ_TOOLS, STATUS_TOOL, PROPOSAL_TOOL]);
|
||||
return {
|
||||
state: "fail-closed",
|
||||
incarnationId,
|
||||
cause,
|
||||
allowed,
|
||||
readScope: gateRole ? [] : brainHome ? [brainHome] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApplication(input: PolicyApplicationInput): PolicyApplication {
|
||||
const { load, activeTools, incarnationId, brainHome, roleHint } = input;
|
||||
if (!load.ok) {
|
||||
const state = failClosedState(incarnationId, describeFailure(load.failure), brainHome, roleHint);
|
||||
return { state, applyTools: [...state.allowed].sort() };
|
||||
}
|
||||
const m = load.manifest;
|
||||
const allowed = new Set(m.tools);
|
||||
const plan = reconcileActiveTools(m.tools, activeTools);
|
||||
const c3 = m.capabilities.find((c) => c.id === "repo.file.read");
|
||||
const rawScope = c3 && c3.status === "bound" ? m.workspace.readRoots : [];
|
||||
const readScope = resolveReadScope(rawScope, brainHome);
|
||||
return {
|
||||
state: {
|
||||
state: "applied",
|
||||
incarnationId,
|
||||
role: m.role,
|
||||
revision: m.revision,
|
||||
digest: load.digest,
|
||||
allowed,
|
||||
readScope,
|
||||
},
|
||||
plan,
|
||||
applyTools: [...allowed].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(4): resolve the symbolic "@brain" root to the trusted brain home;
|
||||
* absolute roots pass through unchanged.
|
||||
*/
|
||||
export function resolveReadScope(roots: readonly string[], brainHome?: string): string[] {
|
||||
return roots.map((r) => (r === SYMBOLIC_BRAIN && brainHome ? brainHome : r));
|
||||
}
|
||||
|
||||
export function describeFailure(f: import("./loader.ts").LoadFailure): string {
|
||||
switch (f.stage) {
|
||||
case "no-agent-name":
|
||||
return "no-agent-name";
|
||||
case "no-seat-profile":
|
||||
return "no-seat-profile";
|
||||
case "no-role-in-profile":
|
||||
return "no-role-in-profile";
|
||||
case "manifest-unreadable":
|
||||
return "manifest-unreadable";
|
||||
case "manifest-symlink":
|
||||
return "manifest-symlink";
|
||||
case "invalid-json":
|
||||
return "invalid-json";
|
||||
case "schema":
|
||||
return `schema:${f.reason}`;
|
||||
case "role-mismatch":
|
||||
return `role-mismatch:${f.expected}!=${f.got}`;
|
||||
case "goal-policy-unreadable":
|
||||
case "goal-policy-root-symlink":
|
||||
case "goal-policy-parent-symlink":
|
||||
case "goal-policy-symlink":
|
||||
case "goal-policy-path-escape":
|
||||
case "goal-policy-non-file":
|
||||
case "goal-policy-invalid-utf8":
|
||||
case "goal-policy-digest":
|
||||
case "goal-policy-section":
|
||||
case "goal-policy-launch-generation":
|
||||
case "goal-policy-incarnation":
|
||||
case "goal-policy-publication":
|
||||
return f.stage;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
block: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(5): credential deny pattern — covers secrets/ and auth/ directories,
|
||||
* .token/.key/.pem/.env* file types, and any path containing "credential".
|
||||
* Applied AFTER canonical resolution so traversal cannot bypass it.
|
||||
*/
|
||||
const CREDENTIAL_PATH_RE = /(?:^|\/)(?:secrets?|auth)(?:\/|$)/i;
|
||||
const CREDENTIAL_FILE_RE = /\.(?:token|key|pem|env)(?:\.|$)|credential|fleet\/agents\/junk/i;
|
||||
const READ_TOOL_BINDING = "read"; // canonical Pi read tool
|
||||
|
||||
function isCredentialPath(canonical: string): boolean {
|
||||
return CREDENTIAL_PATH_RE.test(canonical) || CREDENTIAL_FILE_RE.test(canonical);
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(2): canonicalize a path — resolve `..` and `.` segments lexically,
|
||||
* resolve relative paths against the trusted root. The ADAPTER must
|
||||
* additionally realpath() existing targets to catch symlink escapes
|
||||
* before calling this function (see decideToolCallWithFs).
|
||||
*/
|
||||
export function canonicalizePath(rawPath: string, relativeRoot?: string): string {
|
||||
let p = rawPath;
|
||||
// resolve relative against the trusted root (never cwd)
|
||||
if (!p.startsWith("/") && relativeRoot) {
|
||||
p = relativeRoot + "/" + p;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const seg of p.split("/")) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") { parts.pop(); continue; }
|
||||
parts.push(seg);
|
||||
}
|
||||
return "/" + parts.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(2,5): read containment with credential denial AFTER canonical resolution.
|
||||
* readScope roots must themselves be canonical absolute paths.
|
||||
* Returns ok only if the canonical path falls under a declared root AND
|
||||
* does not match the credential deny pattern post-resolution.
|
||||
*/
|
||||
export function readPathAllowed(
|
||||
readScope: readonly string[],
|
||||
rawPath: string,
|
||||
relativeRoot?: string,
|
||||
): { ok: boolean; reason?: string } {
|
||||
if (readScope.length === 0) return { ok: false, reason: "mosaic-core:read-scope-unbound" };
|
||||
// F7-(2,3): missing, empty, or NON-STRING path is DENIED, not passed through
|
||||
if (typeof rawPath !== "string" || rawPath === "") {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
const canonical = canonicalizePath(rawPath, relativeRoot);
|
||||
// F7-(5): credential denial AFTER canonical resolution
|
||||
if (isCredentialPath(canonical)) {
|
||||
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
|
||||
}
|
||||
for (const root of readScope) {
|
||||
if (canonical === root || canonical.startsWith(root + "/")) {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* F7-(3): safe-read constraint — the SAFE_READ_TOOLS that survive
|
||||
* fail-closed must still respect the credential deny pattern.
|
||||
* Without this, a malformed manifest would EXPOSE broader reads than an
|
||||
* applied role. Safe reads get NO workspace scope (scope is empty in
|
||||
* fail-closed/not-applied), so read is denied; grep/glob/ls still have
|
||||
* credential-shaped path denial applied to their arguments.
|
||||
*/
|
||||
/**
|
||||
* F7-B (mercer NG4-F7B): all four Pi safe-read built-ins (read, grep, find,
|
||||
* ls) take an optional path argument; absent path defaults to the tool's
|
||||
* cwd. This function treats ABSENT path as the trusted root (in scope by
|
||||
* definition) and a PROVIDED path through canonical containment + credential
|
||||
* denial. In fail-closed, the scope IS the trusted brain home so reads
|
||||
* within the brain tree survive; reads outside are denied.
|
||||
*/
|
||||
export function safeReadAllowed(
|
||||
toolName: string,
|
||||
readScope: readonly string[],
|
||||
args?: unknown,
|
||||
trustedRoot?: string,
|
||||
): { ok: boolean; reason?: string } {
|
||||
if (toolName === STATUS_TOOL || toolName === PROPOSAL_TOOL) return { ok: true };
|
||||
|
||||
// F7F: tool-specific input shape — read REQUIRES a non-array object with
|
||||
// a nonempty string path; ls may accept undefined/{} but non-object/null/
|
||||
// array or non-string path DENIES. The pure decision must align with the
|
||||
// adapter (which synthesizes ls input ONLY when undefined).
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
|
||||
if (toolName === READ_TOOL_BINDING) {
|
||||
// read: args must be a non-array object with nonempty string path
|
||||
if (!isObject(args)) {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
const path = args.path;
|
||||
if (typeof path !== "string" || path === "") {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
// containment + credential denial
|
||||
const effectiveScope = readScope.length > 0 ? readScope : trustedRoot ? [trustedRoot] : [];
|
||||
const canonical = canonicalizePath(path, trustedRoot);
|
||||
if (isCredentialPath(canonical)) {
|
||||
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
|
||||
}
|
||||
if (effectiveScope.length === 0) {
|
||||
return { ok: false, reason: "mosaic-core:read-scope-unbound" };
|
||||
}
|
||||
for (const root of effectiveScope) {
|
||||
if (canonical === root || canonical.startsWith(root + "/")) return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
|
||||
}
|
||||
|
||||
// ls: undefined args OK (adapter synthesizes); non-object/null/array DENIES
|
||||
if (args !== undefined && !isObject(args)) {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
const path = isObject(args) ? args.path : undefined;
|
||||
if (path !== undefined && typeof path !== "string") {
|
||||
return { ok: false, reason: "mosaic-core:read-missing-path" };
|
||||
}
|
||||
// credential check on the resolved path (or brainHome for absent)
|
||||
const effectiveScope = readScope.length > 0 ? readScope : trustedRoot ? [trustedRoot] : [];
|
||||
const effectivePath = typeof path === "string" && path !== "" ? path : (trustedRoot ?? "/");
|
||||
const canonical = canonicalizePath(effectivePath, trustedRoot);
|
||||
if (isCredentialPath(canonical)) {
|
||||
return { ok: false, reason: `mosaic-core:read-credential-denied:${canonical}` };
|
||||
}
|
||||
if (effectiveScope.length === 0) {
|
||||
return { ok: false, reason: "mosaic-core:read-scope-unbound" };
|
||||
}
|
||||
for (const root of effectiveScope) {
|
||||
if (canonical === root || canonical.startsWith(root + "/")) return { ok: true };
|
||||
}
|
||||
return { ok: false, reason: `mosaic-core:read-outside-scope:${canonical}` };
|
||||
}
|
||||
|
||||
export function decideToolCall(state: EnforcementState, toolName: string, args?: unknown, relativeRoot?: string): Decision {
|
||||
switch (state.state) {
|
||||
case "not-applied":
|
||||
if (toolName === STATUS_TOOL || toolName === PROPOSAL_TOOL || (SAFE_READ_TOOLS as readonly string[]).includes(toolName)) {
|
||||
const safe = safeReadAllowed(toolName, [], args, relativeRoot);
|
||||
if (!safe.ok) return { block: true, reason: safe.reason };
|
||||
return { block: false };
|
||||
}
|
||||
return { block: true, reason: `mosaic-core:not-applied:${toolName}` };
|
||||
case "fail-closed":
|
||||
if (state.allowed.has(toolName)) {
|
||||
const safe = safeReadAllowed(toolName, state.readScope ?? [], args, relativeRoot);
|
||||
if (!safe.ok) return { block: true, reason: safe.reason };
|
||||
return { block: false };
|
||||
}
|
||||
return { block: true, reason: `mosaic-core:fail-closed:${state.cause}:${toolName}` };
|
||||
case "applied":
|
||||
if (state.allowed.has(toolName)) {
|
||||
// F7-(2): C3 read containment. Missing path DENIED. Relative paths
|
||||
// resolved against the trusted brain root. The adapter must
|
||||
// additionally realpath() existing targets before deciding.
|
||||
if (toolName === READ_TOOL_BINDING && state.readScope.length > 0) {
|
||||
const path = args && typeof args === "object" ? (args as { path?: unknown }).path : undefined;
|
||||
const scope = readPathAllowed(state.readScope, typeof path === "string" ? path : "", relativeRoot);
|
||||
if (!scope.ok) return { block: true, reason: scope.reason };
|
||||
}
|
||||
return { block: false };
|
||||
}
|
||||
return { block: true, reason: `mosaic-core:tool-not-in-manifest:${toolName}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function statusSnapshot(state: EnforcementState, activeTools: string[]): Record<string, unknown> {
|
||||
if (state.state === "applied") {
|
||||
return {
|
||||
state: state.state,
|
||||
role: state.role,
|
||||
revision: state.revision,
|
||||
digest: state.digest,
|
||||
incarnationId: state.incarnationId,
|
||||
activeTools: [...activeTools].sort(),
|
||||
};
|
||||
}
|
||||
if (state.state === "fail-closed") {
|
||||
return {
|
||||
state: state.state,
|
||||
cause: state.cause,
|
||||
incarnationId: state.incarnationId,
|
||||
activeTools: [...activeTools].sort(),
|
||||
};
|
||||
}
|
||||
return { state: state.state, incarnationId: state.incarnationId, activeTools: [...activeTools].sort() };
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// lib/gate-record.ts — T165 WP1 pure E1/E2 contracts.
|
||||
//
|
||||
// This module is deliberately isolated from brokers, adapters, storage, and
|
||||
// provider clients. It supplies closed record validation, RFC 8785 canonical
|
||||
// bytes, and digest/idempotency derivation for a later canonical writer.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const MG_CODES = ["MG01", "MG02", "MG03", "MG04", "MG05", "MG06", "MG07", "MG08"] as const;
|
||||
export type MgCode = typeof MG_CODES[number];
|
||||
export const FIXED_TARGET = {
|
||||
host: "git.mosaicstack.dev",
|
||||
repository: "mosaicstack/stack",
|
||||
pullRequest: 1491,
|
||||
baseBranch: "next",
|
||||
headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68",
|
||||
headTreeSha: "6697568b78c3a910e98f848a3aa4b054d80343d8",
|
||||
} as const;
|
||||
|
||||
/** Normative CDDL identifiers. Semantic constraints below make all maps closed. */
|
||||
export const GATE_RECORD_V1_CDDL = String.raw`GateRecordV1 = GateEvidenceBundleV1 / GateVerdictV1
|
||||
GateEvidenceBundleV1 = { schemaVersion: 1, recordType: "gate.merge.evidence", evidenceBundleId: uuid-v4, bundleSha256: sha256, gateProfile: "merge", requestedTransition: requested-transition, operationRequestId: uuid-v4, actorContext: actor-context, missionContext: mission-context, target: target, evidence: [8*64 evidence-item], securityReview: security-review, requiredContextSetSha256: sha256, observedAt: timestamp, expiresAt: timestamp, sensitivity: "internal" }
|
||||
GateVerdictV1 = { schemaVersion: 1, recordType: "gate.merge.verdict", verdictId: uuid-v4, transitionId: uuid-v4, idempotencyKey: sha256, operationRequestId: uuid-v4, actorContext: actor-context, missionContext: mission-context, authority: authority, transition: transition, target: target, evidenceBundleId: uuid-v4, evidenceBundleSha256: sha256, decision: decision, validity: validity, sensitivity: "internal", recordSha256: sha256 }
|
||||
requested-transition = { requestedFrom: "reviewed", requestedTo: "authorized-integration" }
|
||||
actor-context = { principalId: opaque-id, seatId: role-slug, incarnationId: uuid-v4, sessionId: opaque-id, roleId: "gate-merge-ng", roleRevision: 1, roleDigest: sha256, assignmentId: uuid-v4, assignmentRevision: positive-int, leaseId: uuid-v4, leaseRevision: positive-int, fencingToken: positive-int }
|
||||
mission-context = { missionId: uuid-v4, workUnitId: uuid-v4, workflowState: "reviewed", workflowRevision: positive-int }
|
||||
target = { host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, baseBranch: "next", baseHeadSha: git-sha, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68", headTreeSha: "6697568b78c3a910e98f848a3aa4b054d80343d8", diffSha256: sha256, changedPathsSha256: sha256 }
|
||||
evidence-item = { evidenceId: uuid-v4, criterionCode: criterion-code, class: evidence-class, evidenceKind: evidence-kind, producer: producer, capability: evidence-capability, source: evidence-source, targetBinding: target-binding, result: evidence-result, observedAt: timestamp, freshnessClass: freshness-class, expiresAt: timestamp / null, invalidationKeys: [1*32 invalidation-key] }
|
||||
producer = { producerId: opaque-id, principalId: opaque-id, roleId: role-slug, roleRevision: positive-int, roleDigest: sha256, relationship: "independent" / "system-observer", independentOf: [*16 opaque-id] }
|
||||
evidence-capability = { id: "repo.target.inspect" / "gate.evidence.observe", binding: "mosaic_gate_target_read" / "mosaic_gate_evidence_observe", backend: "mosaic-gate-broker" }
|
||||
evidence-source = { sourceType: "gitea-wrapper" / "woodpecker-wrapper" / "git-object" / "brain-artifact" / "coordinator-record" / "runtime-attestation" / "journal-record", locator: locator, sourceSha256: sha256, immutable: true }
|
||||
target-binding = { host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, baseHeadSha: git-sha, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68" }
|
||||
evidence-result = { outcome: "pass" / "fail" / "blocked", reasonCode: reason-code / null }
|
||||
security-review = { triggered: bool, triggerPolicyDigest: sha256, requiredEvidenceId: uuid-v4 / null }
|
||||
authority = { policyVersion: 4, policyDigest: sha256, roleBindingId: uuid-v4, roleId: "gate-merge-ng", roleRevision: 1, roleDigest: sha256, intentId: uuid-v4, decisionId: uuid-v4, grantId: uuid-v4, invocationId: uuid-v4, assignmentId: uuid-v4, assignmentRevision: positive-int, leaseId: uuid-v4, leaseRevision: positive-int, fencingToken: positive-int, leaseExpiresAt: timestamp }
|
||||
transition = { domain: "gate", objectId: "git.mosaicstack.dev/mosaicstack/stack#1491:merge", requestedFrom: "reviewed", requestedTo: "authorized-integration", expectedStateRevision: positive-int, previousState: "reviewed", previousRevision: positive-int, resultingState: "passed" / "failed" / "blocked", resultingRevision: positive-int }
|
||||
decision = { verdict: "PASS" / "FAIL" / "BLOCKED", criteria: [criterion-mg01, criterion-mg02, criterion-mg03, criterion-mg04, criterion-mg05, criterion-mg06, criterion-mg07, criterion-mg08], summary: summary, failedCriteria: [*8 criterion-code], blockedCriteria: [*8 criterion-code], reasonCodes: [*8 reason-code], authorizedNextOperation: authorized-next-operation / null }
|
||||
validity = { issuedAt: timestamp, expiresAt: timestamp, invalidationKeys: [1*32 invalidation-key] }
|
||||
criterion-mg01 = { code: "MG01", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg02 = { code: "MG02", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg03 = { code: "MG03", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg04 = { code: "MG04", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg05 = { code: "MG05", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg06 = { code: "MG06", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg07 = { code: "MG07", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
criterion-mg08 = { code: "MG08", outcome: criterion-outcome, reasonCode: reason-code / null, evidenceIds: [1*16 uuid-v4] }
|
||||
authorized-next-operation = { capability: "operation.merge", host: "git.mosaicstack.dev", repository: "mosaicstack/stack", pullRequest: 1491, headSha: "3ae1411d5f2c34174e8a0513b13b422e8c7e8c68", baseBranch: "next", strategy: "squash", operationGrantId: null }
|
||||
criterion-code = "MG01" / "MG02" / "MG03" / "MG04" / "MG05" / "MG06" / "MG07" / "MG08"
|
||||
criterion-outcome = "pass" / "fail" / "blocked"
|
||||
evidence-class = "observation" / "verification" / "review"
|
||||
evidence-kind = "pr-metadata" / "pr-diff" / "changed-paths" / "code-review" / "ci-contexts" / "merge-queue" / "security-trigger" / "security-review" / "repository-declaration" / "coordinator-authority" / "independence" / "runtime-attestation" / "journal-attestation"
|
||||
freshness-class = "pr-live" / "queue-live" / "ci-head" / "review-head" / "security-policy" / "authority-submit" / "runtime-incarnation"
|
||||
uuid-v4 = tstr .regexp "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
sha256 = tstr .regexp "^[0-9a-f]{64}$"
|
||||
git-sha = tstr .regexp "^[0-9a-f]{40}$"
|
||||
role-slug = tstr .regexp "^[a-z][a-z0-9-]{0,63}$"
|
||||
opaque-id = tstr .regexp "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
timestamp = tstr .regexp "^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9][.][0-9]{3}Z$"
|
||||
locator = tstr .size (1..1024)
|
||||
summary = tstr .size (1..500)
|
||||
positive-int = 1..9007199254740991
|
||||
reason-code = "TARGET_IDENTITY_MISMATCH" / "TARGET_HEAD_MISMATCH" / "TARGET_TREE_MISMATCH" / "TARGET_BASE_MISMATCH" / "TARGET_DIFF_MISMATCH" / "TARGET_CHANGED_PATHS_MISMATCH" / "TARGET_STATE_UNAVAILABLE" / "TARGET_DRIFT_REOBSERVE" / "REVIEW_REJECTED" / "REVIEW_TARGET_MISMATCH" / "REVIEW_INDEPENDENCE_VIOLATION" / "REVIEW_MISSING" / "REVIEW_STALE" / "REVIEW_INDEPENDENCE_UNAVAILABLE" / "SECURITY_REVIEW_REJECTED" / "SECURITY_POLICY_VIOLATION" / "SECURITY_REVIEW_MISSING" / "SECURITY_POLICY_UNAVAILABLE" / "SECURITY_EVIDENCE_STALE" / "CI_RED" / "CI_REQUIRED_CONTEXT_MISSING" / "CI_PENDING" / "CI_STATE_UNAVAILABLE" / "CI_EVIDENCE_STALE" / "QUEUE_BUSY" / "QUEUE_INDETERMINATE" / "QUEUE_EVIDENCE_STALE" / "PR_CLOSED" / "PR_DRAFT" / "PR_NOT_MERGEABLE" / "TRUNK_MISMATCH" / "MERGE_STRATEGY_MISMATCH" / "DECLARATION_INVALID" / "PR_STATE_UNAVAILABLE" / "DECLARATION_UNAVAILABLE" / "ASSIGNMENT_MISSING" / "AUTHORITY_EXPIRED" / "LEASE_EXPIRED" / "FENCE_STALE" / "WORKFLOW_REVISION_STALE" / "OPERATION_REQUEST_MISMATCH" / "AUTHORITY_BACKEND_UNAVAILABLE" / "GATE_CAPABILITY_UNAVAILABLE" / "BROKER_UNAVAILABLE" / "RUNTIME_ATTESTATION_UNAVAILABLE" / "RUNTIME_POLICY_VIOLATION" / "ROLE_RECONCILIATION_MISMATCH" / "MODEL_FLOOR_UNMET" / "JOURNAL_UNAVAILABLE" / "DENIED_PATH_CONTROL_FAILED" / "INDEPENDENCE_VIOLATION" / "INDEPENDENCE_UNAVAILABLE" / "EVIDENCE_MALFORMED" / "EVIDENCE_STALE" / "EVIDENCE_CONTRADICTORY"
|
||||
invalidation-key = { kind: "pr-head" / "head-tree" / "diff" / "changed-paths" / "base-branch" / "base-head" / "pr-state" / "mergeability" / "required-context-set" / "ci-state" / "queue-state" / "review-state" / "review-independence" / "security-policy" / "security-verdict" / "mission-revision" / "work-unit-revision" / "workflow-revision" / "operation-request" / "assignment-revision" / "lease-revision" / "fencing-token" / "incarnation" / "role-binding" / "active-tool-set" / "policy-set" / "broker-availability" / "expires-at", valueSha256: sha256 }`;
|
||||
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const GIT_SHA = /^[0-9a-f]{40}$/;
|
||||
const ROLE = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const OPAQUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const TIMESTAMP = /^[0-9]{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]{3}Z$/;
|
||||
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
||||
const EVIDENCE_KINDS = new Set(["pr-metadata", "pr-diff", "changed-paths", "code-review", "ci-contexts", "merge-queue", "security-trigger", "security-review", "repository-declaration", "coordinator-authority", "independence", "runtime-attestation", "journal-attestation"]);
|
||||
const SOURCE_TYPES = new Set(["gitea-wrapper", "woodpecker-wrapper", "git-object", "brain-artifact", "coordinator-record", "runtime-attestation", "journal-record"]);
|
||||
const INVALIDATION_KINDS = new Set(["pr-head", "head-tree", "diff", "changed-paths", "base-branch", "base-head", "pr-state", "mergeability", "required-context-set", "ci-state", "queue-state", "review-state", "review-independence", "security-policy", "security-verdict", "mission-revision", "work-unit-revision", "workflow-revision", "operation-request", "assignment-revision", "lease-revision", "fencing-token", "incarnation", "role-binding", "active-tool-set", "policy-set", "broker-availability", "expires-at"]);
|
||||
|
||||
const REASONS: Record<MgCode, { fail: readonly string[]; blocked: readonly string[] }> = {
|
||||
MG01: { fail: ["TARGET_IDENTITY_MISMATCH", "TARGET_HEAD_MISMATCH", "TARGET_TREE_MISMATCH", "TARGET_BASE_MISMATCH", "TARGET_DIFF_MISMATCH", "TARGET_CHANGED_PATHS_MISMATCH"], blocked: ["TARGET_STATE_UNAVAILABLE", "TARGET_DRIFT_REOBSERVE"] },
|
||||
MG02: { fail: ["REVIEW_REJECTED", "REVIEW_TARGET_MISMATCH", "REVIEW_INDEPENDENCE_VIOLATION"], blocked: ["REVIEW_MISSING", "REVIEW_STALE", "REVIEW_INDEPENDENCE_UNAVAILABLE"] },
|
||||
MG03: { fail: ["SECURITY_REVIEW_REJECTED", "SECURITY_POLICY_VIOLATION"], blocked: ["SECURITY_REVIEW_MISSING", "SECURITY_POLICY_UNAVAILABLE", "SECURITY_EVIDENCE_STALE"] },
|
||||
MG04: { fail: ["CI_RED", "CI_REQUIRED_CONTEXT_MISSING"], blocked: ["CI_PENDING", "CI_STATE_UNAVAILABLE", "CI_EVIDENCE_STALE"] },
|
||||
MG05: { fail: [], blocked: ["QUEUE_BUSY", "QUEUE_INDETERMINATE", "QUEUE_EVIDENCE_STALE"] },
|
||||
MG06: { fail: ["PR_CLOSED", "PR_DRAFT", "PR_NOT_MERGEABLE", "TRUNK_MISMATCH", "MERGE_STRATEGY_MISMATCH", "DECLARATION_INVALID"], blocked: ["PR_STATE_UNAVAILABLE", "DECLARATION_UNAVAILABLE"] },
|
||||
MG07: { fail: [], blocked: ["ASSIGNMENT_MISSING", "AUTHORITY_EXPIRED", "LEASE_EXPIRED", "FENCE_STALE", "WORKFLOW_REVISION_STALE", "OPERATION_REQUEST_MISMATCH", "AUTHORITY_BACKEND_UNAVAILABLE"] },
|
||||
MG08: { fail: ["RUNTIME_POLICY_VIOLATION", "ROLE_RECONCILIATION_MISMATCH", "MODEL_FLOOR_UNMET", "DENIED_PATH_CONTROL_FAILED", "INDEPENDENCE_VIOLATION"], blocked: ["GATE_CAPABILITY_UNAVAILABLE", "BROKER_UNAVAILABLE", "RUNTIME_ATTESTATION_UNAVAILABLE", "JOURNAL_UNAVAILABLE", "INDEPENDENCE_UNAVAILABLE"] },
|
||||
};
|
||||
const UNIVERSAL_BLOCKED = ["EVIDENCE_MALFORMED", "EVIDENCE_STALE", "EVIDENCE_CONTRADICTORY"] as const;
|
||||
|
||||
export type GateRecordValidation = Readonly<{ ok: true; canonical: string }> | Readonly<{ ok: false; reason: string }>;
|
||||
function fail(reason: string): GateRecordValidation { return Object.freeze({ ok: false, reason }); }
|
||||
function pass(value: unknown): GateRecordValidation { return Object.freeze({ ok: true, canonical: canonicalizeRfc8785(value) }); }
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
function closed(value: unknown, keys: readonly string[], label: string): Record<string, unknown> | undefined {
|
||||
if (!isObject(value)) return undefined;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) return undefined;
|
||||
return value;
|
||||
}
|
||||
function text(value: unknown, min = 1, max = 1024): value is string {
|
||||
return typeof value === "string" && Buffer.byteLength(value, "utf8") >= min && Buffer.byteLength(value, "utf8") <= max && value.normalize("NFC") === value && !CONTROL.test(value);
|
||||
}
|
||||
function sha(value: unknown): value is string { return typeof value === "string" && SHA256.test(value); }
|
||||
function uuid(value: unknown): value is string { return typeof value === "string" && UUID_V4.test(value); }
|
||||
function opaque(value: unknown): value is string { return typeof value === "string" && OPAQUE.test(value) && Buffer.byteLength(value, "utf8") <= 128; }
|
||||
function positive(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 1; }
|
||||
function timestamp(value: unknown): value is string { return typeof value === "string" && TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value; }
|
||||
function stringSet(value: unknown, max: number, predicate: (entry: string) => boolean, ordered = true): value is string[] {
|
||||
return Array.isArray(value) && value.length <= max && value.every((entry) => typeof entry === "string" && predicate(entry)) && new Set(value).size === value.length && (!ordered || value.every((entry, index) => index === 0 || value[index - 1] < entry));
|
||||
}
|
||||
function exactCodes(value: unknown): value is MgCode[] { return Array.isArray(value) && value.length === MG_CODES.length && value.every((code, index) => code === MG_CODES[index]); }
|
||||
function reasonAllowed(code: MgCode, outcome: string, reason: unknown): boolean {
|
||||
if (outcome === "pass") return reason === null;
|
||||
if (outcome !== "fail" && outcome !== "blocked") return false;
|
||||
if (typeof reason !== "string") return false;
|
||||
return outcome === "fail"
|
||||
? REASONS[code].fail.includes(reason)
|
||||
: REASONS[code].blocked.includes(reason) || (UNIVERSAL_BLOCKED as readonly string[]).includes(reason);
|
||||
}
|
||||
function noExtraStringControls(value: unknown): boolean {
|
||||
if (typeof value === "string") return text(value, 0, Number.MAX_SAFE_INTEGER);
|
||||
if (typeof value === "number") return Number.isSafeInteger(value) && !Object.is(value, -0);
|
||||
if (value === null || typeof value === "boolean") return true;
|
||||
if (Array.isArray(value)) return value.every(noExtraStringControls);
|
||||
return isObject(value) && Object.entries(value).every(([key, nested]) => text(key, 0, Number.MAX_SAFE_INTEGER) && noExtraStringControls(nested));
|
||||
}
|
||||
|
||||
/** RFC 8785 JSON Canonicalization Scheme bytes, after I-JSON validation. */
|
||||
export function canonicalizeRfc8785(value: unknown): string {
|
||||
if (!noExtraStringControls(value)) throw new Error("not I-JSON");
|
||||
const render = (current: unknown): string => {
|
||||
if (current === null || typeof current === "boolean") return String(current);
|
||||
if (typeof current === "number") return JSON.stringify(current);
|
||||
if (typeof current === "string") return JSON.stringify(current);
|
||||
if (Array.isArray(current)) return `[${current.map(render).join(",")}]`;
|
||||
const object = current as Record<string, unknown>;
|
||||
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${render(object[key])}`).join(",")}}`;
|
||||
};
|
||||
return render(value);
|
||||
}
|
||||
export function sha256Canonical(value: unknown): string { return createHash("sha256").update(canonicalizeRfc8785(value), "utf8").digest("hex"); }
|
||||
function omitTop(record: Record<string, unknown>, field: string): Record<string, unknown> {
|
||||
const copy = { ...record };
|
||||
delete copy[field];
|
||||
return copy;
|
||||
}
|
||||
|
||||
function validTarget(value: unknown): value is Record<string, unknown> {
|
||||
const target = closed(value, ["host", "repository", "pullRequest", "baseBranch", "baseHeadSha", "headSha", "headTreeSha", "diffSha256", "changedPathsSha256"], "target");
|
||||
return !!target && target.host === FIXED_TARGET.host && target.repository === FIXED_TARGET.repository && target.pullRequest === FIXED_TARGET.pullRequest && target.baseBranch === FIXED_TARGET.baseBranch && typeof target.baseHeadSha === "string" && GIT_SHA.test(target.baseHeadSha) && target.headSha === FIXED_TARGET.headSha && target.headTreeSha === FIXED_TARGET.headTreeSha && sha(target.diffSha256) && sha(target.changedPathsSha256);
|
||||
}
|
||||
function validTargetBinding(value: unknown): boolean {
|
||||
const binding = closed(value, ["host", "repository", "pullRequest", "baseHeadSha", "headSha"], "targetBinding");
|
||||
return !!binding && binding.host === FIXED_TARGET.host && binding.repository === FIXED_TARGET.repository && binding.pullRequest === FIXED_TARGET.pullRequest && typeof binding.baseHeadSha === "string" && GIT_SHA.test(binding.baseHeadSha) && binding.headSha === FIXED_TARGET.headSha;
|
||||
}
|
||||
function validActor(value: unknown): value is Record<string, unknown> {
|
||||
const actor = closed(value, ["principalId", "seatId", "incarnationId", "sessionId", "roleId", "roleRevision", "roleDigest", "assignmentId", "assignmentRevision", "leaseId", "leaseRevision", "fencingToken"], "actor");
|
||||
return !!actor && opaque(actor.principalId) && typeof actor.seatId === "string" && ROLE.test(actor.seatId) && uuid(actor.incarnationId) && opaque(actor.sessionId) && actor.roleId === "gate-merge-ng" && actor.roleRevision === 1 && sha(actor.roleDigest) && uuid(actor.assignmentId) && positive(actor.assignmentRevision) && uuid(actor.leaseId) && positive(actor.leaseRevision) && positive(actor.fencingToken);
|
||||
}
|
||||
function validMission(value: unknown): value is Record<string, unknown> {
|
||||
const mission = closed(value, ["missionId", "workUnitId", "workflowState", "workflowRevision"], "mission");
|
||||
return !!mission && uuid(mission.missionId) && uuid(mission.workUnitId) && mission.workflowState === "reviewed" && positive(mission.workflowRevision);
|
||||
}
|
||||
function validInvalidationKeys(value: unknown): boolean {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 32) return false;
|
||||
let previous = "";
|
||||
const seen = new Set<string>();
|
||||
for (const entry of value) {
|
||||
const key = closed(entry, ["kind", "valueSha256"], "invalidation");
|
||||
if (!key || typeof key.kind !== "string" || !INVALIDATION_KINDS.has(key.kind) || !sha(key.valueSha256)) return false;
|
||||
const order = `${key.kind}\u0000${key.valueSha256}`;
|
||||
if (order <= previous || seen.has(order)) return false;
|
||||
previous = order;
|
||||
seen.add(order);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function validProducer(value: unknown): boolean {
|
||||
const producer = closed(value, ["producerId", "principalId", "roleId", "roleRevision", "roleDigest", "relationship", "independentOf"], "producer");
|
||||
if (!producer || !opaque(producer.producerId) || !opaque(producer.principalId) || typeof producer.roleId !== "string" || !ROLE.test(producer.roleId) || !positive(producer.roleRevision) || !sha(producer.roleDigest) || (producer.relationship !== "independent" && producer.relationship !== "system-observer") || !stringSet(producer.independentOf, 16, opaque)) return false;
|
||||
return !producer.independentOf.includes(producer.principalId);
|
||||
}
|
||||
function validCapability(value: unknown): boolean {
|
||||
const capability = closed(value, ["id", "binding", "backend"], "capability");
|
||||
if (!capability || capability.backend !== "mosaic-gate-broker") return false;
|
||||
return (capability.id === "repo.target.inspect" && capability.binding === "mosaic_gate_target_read") || (capability.id === "gate.evidence.observe" && capability.binding === "mosaic_gate_evidence_observe");
|
||||
}
|
||||
function validSource(value: unknown): boolean {
|
||||
const source = closed(value, ["sourceType", "locator", "sourceSha256", "immutable"], "source");
|
||||
return !!source && typeof source.sourceType === "string" && SOURCE_TYPES.has(source.sourceType) && text(source.locator, 1, 1024) && sha(source.sourceSha256) && source.immutable === true;
|
||||
}
|
||||
function expectedFreshness(kind: string): { freshness: string; class: string } | undefined {
|
||||
if (["pr-metadata", "pr-diff", "changed-paths", "repository-declaration"].includes(kind)) return { freshness: "pr-live", class: "observation" };
|
||||
if (kind === "code-review") return { freshness: "review-head", class: "review" };
|
||||
if (kind === "ci-contexts") return { freshness: "ci-head", class: "observation" };
|
||||
if (kind === "merge-queue") return { freshness: "queue-live", class: "observation" };
|
||||
if (kind === "security-trigger") return { freshness: "security-policy", class: "observation" };
|
||||
if (kind === "security-review") return { freshness: "security-policy", class: "review" };
|
||||
if (kind === "coordinator-authority") return { freshness: "authority-submit", class: "observation" };
|
||||
if (["independence", "runtime-attestation", "journal-attestation"].includes(kind)) return { freshness: "runtime-incarnation", class: "verification" };
|
||||
return undefined;
|
||||
}
|
||||
function validEvidenceItem(value: unknown): value is Record<string, unknown> {
|
||||
const item = closed(value, ["evidenceId", "criterionCode", "class", "evidenceKind", "producer", "capability", "source", "targetBinding", "result", "observedAt", "freshnessClass", "expiresAt", "invalidationKeys"], "evidence");
|
||||
if (!item || !uuid(item.evidenceId) || typeof item.criterionCode !== "string" || !MG_CODES.includes(item.criterionCode as MgCode) || typeof item.class !== "string" || typeof item.evidenceKind !== "string" || !EVIDENCE_KINDS.has(item.evidenceKind) || !validProducer(item.producer) || !validCapability(item.capability) || !validSource(item.source) || !validTargetBinding(item.targetBinding) || !timestamp(item.observedAt) || !validInvalidationKeys(item.invalidationKeys)) return false;
|
||||
const result = closed(item.result, ["outcome", "reasonCode"], "result");
|
||||
const fresh = expectedFreshness(item.evidenceKind);
|
||||
if (!result || !fresh || item.class !== fresh.class || item.freshnessClass !== fresh.freshness || !reasonAllowed(item.criterionCode as MgCode, String(result.outcome), result.reasonCode)) return false;
|
||||
if (item.expiresAt !== null && !timestamp(item.expiresAt)) return false;
|
||||
if (item.expiresAt === null && item.freshnessClass !== "review-head" && item.freshnessClass !== "runtime-incarnation") return false;
|
||||
return true;
|
||||
}
|
||||
type CriterionDerivation = Readonly<{
|
||||
outcome: "pass" | "fail" | "blocked";
|
||||
reasonCode: string | null;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* E1 is the only source of E2 criterion outcomes. The first applicable reason
|
||||
* follows the CDDL's left-to-right priority after all evidence for that
|
||||
* criterion has been considered. A caller cannot choose a weaker reason or a
|
||||
* PASS result over a negative observation.
|
||||
*/
|
||||
function deriveCriterion(code: MgCode, items: readonly Record<string, unknown>[]): CriterionDerivation | undefined {
|
||||
const outcomes = items.map((item) => (item.result as Record<string, unknown>).outcome);
|
||||
const outcome = outcomes.includes("fail")
|
||||
? "fail"
|
||||
: outcomes.includes("blocked")
|
||||
? "blocked"
|
||||
: outcomes.every((entry) => entry === "pass")
|
||||
? "pass"
|
||||
: undefined;
|
||||
if (!outcome) return undefined;
|
||||
if (outcome === "pass") return Object.freeze({ outcome, reasonCode: null });
|
||||
const priority = outcome === "fail"
|
||||
? REASONS[code].fail
|
||||
: [...REASONS[code].blocked, ...UNIVERSAL_BLOCKED];
|
||||
for (const reasonCode of priority) {
|
||||
if (items.some((item) => (item.result as Record<string, unknown>).reasonCode === reasonCode)) {
|
||||
return Object.freeze({ outcome, reasonCode });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateGateEvidenceBundle(value: unknown): GateRecordValidation {
|
||||
const bundle = closed(value, ["schemaVersion", "recordType", "evidenceBundleId", "bundleSha256", "gateProfile", "requestedTransition", "operationRequestId", "actorContext", "missionContext", "target", "evidence", "securityReview", "requiredContextSetSha256", "observedAt", "expiresAt", "sensitivity"], "bundle");
|
||||
if (!bundle) return fail("E1:closed-map");
|
||||
if (bundle.schemaVersion !== 1 || bundle.recordType !== "gate.merge.evidence" || !uuid(bundle.evidenceBundleId) || !sha(bundle.bundleSha256) || bundle.gateProfile !== "merge" || !uuid(bundle.operationRequestId) || !validActor(bundle.actorContext) || !validMission(bundle.missionContext) || !validTarget(bundle.target) || !sha(bundle.requiredContextSetSha256) || !timestamp(bundle.observedAt) || !timestamp(bundle.expiresAt) || bundle.sensitivity !== "internal") return fail("E1:scalar");
|
||||
if (!closed(bundle.requestedTransition, ["requestedFrom", "requestedTo"], "requestedTransition") || (bundle.requestedTransition as Record<string, unknown>).requestedFrom !== "reviewed" || (bundle.requestedTransition as Record<string, unknown>).requestedTo !== "authorized-integration") return fail("E1:transition");
|
||||
if (!Array.isArray(bundle.evidence) || bundle.evidence.length < 8 || bundle.evidence.length > 64) return fail("E1:evidence-count");
|
||||
const ids = new Set<string>();
|
||||
const byCriterion = new Map<MgCode, Record<string, unknown>[]>();
|
||||
let prior = "";
|
||||
for (const item of bundle.evidence) {
|
||||
if (!validEvidenceItem(item)) return fail("E1:evidence-item");
|
||||
const evidence = item as Record<string, unknown>;
|
||||
const id = evidence.evidenceId as string;
|
||||
const criterion = evidence.criterionCode as MgCode;
|
||||
const order = `${criterion}\u0000${evidence.evidenceKind as string}\u0000${id}`;
|
||||
if (ids.has(id) || order <= prior) return fail("E1:evidence-order");
|
||||
const targetBinding = evidence.targetBinding as Record<string, unknown>;
|
||||
if (targetBinding.baseHeadSha !== (bundle.target as Record<string, unknown>).baseHeadSha) return fail("E1:target-binding");
|
||||
ids.add(id); prior = order;
|
||||
byCriterion.set(criterion, [...(byCriterion.get(criterion) ?? []), evidence]);
|
||||
}
|
||||
if (!MG_CODES.every((code) => (byCriterion.get(code)?.length ?? 0) >= 1)) return fail("E1:criterion-coverage");
|
||||
const security = closed(bundle.securityReview, ["triggered", "triggerPolicyDigest", "requiredEvidenceId"], "security");
|
||||
if (!security || typeof security.triggered !== "boolean" || !sha(security.triggerPolicyDigest) || (security.triggered ? !uuid(security.requiredEvidenceId) || !ids.has(security.requiredEvidenceId as string) : security.requiredEvidenceId !== null)) return fail("E1:security");
|
||||
if (security.triggered) {
|
||||
const match = bundle.evidence.find((item) => (item as Record<string, unknown>).evidenceId === security.requiredEvidenceId) as Record<string, unknown> | undefined;
|
||||
if (!match || match.criterionCode !== "MG03" || match.evidenceKind !== "security-review") return fail("E1:security-reference");
|
||||
}
|
||||
const observed = bundle.evidence.map((item) => (item as Record<string, unknown>).observedAt as string).sort().at(-1);
|
||||
if (bundle.observedAt !== observed) return fail("E1:observed-at");
|
||||
const expiries = bundle.evidence.map((item) => (item as Record<string, unknown>).expiresAt).filter((entry): entry is string => typeof entry === "string").sort();
|
||||
if (expiries.length > 0 && bundle.expiresAt !== expiries[0]) return fail("E1:expires-at");
|
||||
if (bundle.bundleSha256 !== sha256Canonical(omitTop(bundle, "bundleSha256"))) return fail("E1:bundle-digest");
|
||||
return pass(bundle);
|
||||
}
|
||||
|
||||
function validAuthority(value: unknown, actor: Record<string, unknown>): boolean {
|
||||
const authority = closed(value, ["policyVersion", "policyDigest", "roleBindingId", "roleId", "roleRevision", "roleDigest", "intentId", "decisionId", "grantId", "invocationId", "assignmentId", "assignmentRevision", "leaseId", "leaseRevision", "fencingToken", "leaseExpiresAt"], "authority");
|
||||
return !!authority && authority.policyVersion === 4 && sha(authority.policyDigest) && uuid(authority.roleBindingId) && authority.roleId === "gate-merge-ng" && authority.roleRevision === 1 && authority.roleDigest === actor.roleDigest && uuid(authority.intentId) && uuid(authority.decisionId) && uuid(authority.grantId) && uuid(authority.invocationId) && authority.assignmentId === actor.assignmentId && authority.assignmentRevision === actor.assignmentRevision && authority.leaseId === actor.leaseId && authority.leaseRevision === actor.leaseRevision && authority.fencingToken === actor.fencingToken && timestamp(authority.leaseExpiresAt);
|
||||
}
|
||||
function validTransition(value: unknown): value is Record<string, unknown> {
|
||||
const transition = closed(value, ["domain", "objectId", "requestedFrom", "requestedTo", "expectedStateRevision", "previousState", "previousRevision", "resultingState", "resultingRevision"], "transition");
|
||||
return !!transition && transition.domain === "gate" && transition.objectId === "git.mosaicstack.dev/mosaicstack/stack#1491:merge" && transition.requestedFrom === "reviewed" && transition.requestedTo === "authorized-integration" && positive(transition.expectedStateRevision) && transition.previousState === "reviewed" && transition.previousRevision === transition.expectedStateRevision && ["passed", "failed", "blocked"].includes(String(transition.resultingState)) && transition.resultingRevision === (transition.expectedStateRevision as number) + 1;
|
||||
}
|
||||
function validAuthorizedOperation(value: unknown): boolean {
|
||||
return !!closed(value, ["capability", "host", "repository", "pullRequest", "headSha", "baseBranch", "strategy", "operationGrantId"], "operation") && exactFixed(value, { capability: "operation.merge", host: FIXED_TARGET.host, repository: FIXED_TARGET.repository, pullRequest: FIXED_TARGET.pullRequest, headSha: FIXED_TARGET.headSha, baseBranch: FIXED_TARGET.baseBranch, strategy: "squash", operationGrantId: null });
|
||||
}
|
||||
function exactFixed(value: unknown, expected: Record<string, unknown>): boolean {
|
||||
const obj = closed(value, Object.keys(expected), "fixed");
|
||||
return !!obj && Object.keys(expected).every((key) => obj[key] === expected[key]);
|
||||
}
|
||||
function derivedIdempotency(verdict: Record<string, unknown>): string {
|
||||
const actor = verdict.actorContext as Record<string, unknown>;
|
||||
const target = verdict.target as Record<string, unknown>;
|
||||
const transition = verdict.transition as Record<string, unknown>;
|
||||
const source = {
|
||||
assignmentId: actor.assignmentId,
|
||||
assignmentRevision: actor.assignmentRevision,
|
||||
baseHeadSha: target.baseHeadSha,
|
||||
expectedStateRevision: transition.expectedStateRevision,
|
||||
headSha: target.headSha,
|
||||
operationRequestId: verdict.operationRequestId,
|
||||
requestedFrom: "reviewed",
|
||||
requestedTo: "authorized-integration",
|
||||
transitionId: verdict.transitionId,
|
||||
};
|
||||
return createHash("sha256").update("mosaic-gate-verdict-v1\n", "utf8").update(canonicalizeRfc8785(source), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function validDecision(
|
||||
value: unknown,
|
||||
transition: Record<string, unknown>,
|
||||
evidenceById: ReadonlyMap<string, Record<string, unknown>>,
|
||||
): { ok: true } | { ok: false } {
|
||||
const decision = closed(value, ["verdict", "criteria", "summary", "failedCriteria", "blockedCriteria", "reasonCodes", "authorizedNextOperation"], "decision");
|
||||
if (!decision || !["PASS", "FAIL", "BLOCKED"].includes(String(decision.verdict)) || !text(decision.summary, 1, 500) || !Array.isArray(decision.criteria) || decision.criteria.length !== MG_CODES.length || !Array.isArray(decision.failedCriteria) || !Array.isArray(decision.blockedCriteria) || !stringSet(decision.reasonCodes, 8, (code) => typeof code === "string")) return { ok: false };
|
||||
|
||||
const expectedFailed: MgCode[] = [];
|
||||
const expectedBlocked: MgCode[] = [];
|
||||
const expectedReasonCodes: string[] = [];
|
||||
const referencedIds = new Set<string>();
|
||||
|
||||
for (let index = 0; index < MG_CODES.length; index++) {
|
||||
const criterion = closed(decision.criteria[index], ["code", "outcome", "reasonCode", "evidenceIds"], "criterion");
|
||||
const code = MG_CODES[index];
|
||||
if (!criterion || criterion.code !== code || !["pass", "fail", "blocked"].includes(String(criterion.outcome)) || !Array.isArray(criterion.evidenceIds) || criterion.evidenceIds.length < 1 || criterion.evidenceIds.length > 16 || !stringSet(criterion.evidenceIds, 16, uuid)) return { ok: false };
|
||||
|
||||
const evidence: Record<string, unknown>[] = [];
|
||||
for (const id of criterion.evidenceIds) {
|
||||
if (referencedIds.has(id)) return { ok: false };
|
||||
const item = evidenceById.get(id);
|
||||
if (!item || item.criterionCode !== code) return { ok: false };
|
||||
referencedIds.add(id);
|
||||
evidence.push(item);
|
||||
}
|
||||
|
||||
const derived = deriveCriterion(code, evidence);
|
||||
if (!derived || criterion.outcome !== derived.outcome || criterion.reasonCode !== derived.reasonCode) return { ok: false };
|
||||
if (derived.outcome === "fail") expectedFailed.push(code);
|
||||
if (derived.outcome === "blocked") expectedBlocked.push(code);
|
||||
if (derived.reasonCode !== null) expectedReasonCodes.push(derived.reasonCode);
|
||||
}
|
||||
|
||||
if (referencedIds.size !== evidenceById.size) return { ok: false };
|
||||
if (!sameOrderedCodes(decision.failedCriteria, expectedFailed) || !sameOrderedCodes(decision.blockedCriteria, expectedBlocked)) return { ok: false };
|
||||
const expectedReasons = [...new Set(expectedReasonCodes)].sort();
|
||||
if (JSON.stringify(decision.reasonCodes) !== JSON.stringify(expectedReasons)) return { ok: false };
|
||||
const derivedVerdict = expectedFailed.length > 0 ? "FAIL" : expectedBlocked.length > 0 ? "BLOCKED" : "PASS";
|
||||
const expectedState = derivedVerdict === "PASS" ? "passed" : derivedVerdict === "FAIL" ? "failed" : "blocked";
|
||||
if (decision.verdict !== derivedVerdict || transition.resultingState !== expectedState) return { ok: false };
|
||||
if ((derivedVerdict === "PASS" && !validAuthorizedOperation(decision.authorizedNextOperation)) || (derivedVerdict !== "PASS" && decision.authorizedNextOperation !== null)) return { ok: false };
|
||||
return { ok: true };
|
||||
}
|
||||
function sameOrderedCodes(value: unknown, expected: MgCode[]): boolean { return Array.isArray(value) && value.length === expected.length && value.every((code, index) => code === expected[index]); }
|
||||
function validValidity(value: unknown): value is Record<string, unknown> {
|
||||
const validity = closed(value, ["issuedAt", "expiresAt", "invalidationKeys"], "validity");
|
||||
return !!validity && timestamp(validity.issuedAt) && timestamp(validity.expiresAt) && validInvalidationKeys(validity.invalidationKeys) && validity.issuedAt <= validity.expiresAt;
|
||||
}
|
||||
function sameCanonical(left: unknown, right: unknown): boolean {
|
||||
try {
|
||||
return canonicalizeRfc8785(left) === canonicalizeRfc8785(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* E2 is valid only at the broker's supplied submit time. Requiring the caller
|
||||
* to supply that time prevents an offline E2 shape check from masquerading as
|
||||
* an operation-authorizing validation.
|
||||
*/
|
||||
function validityBoundAtSubmit(
|
||||
validity: Record<string, unknown>,
|
||||
bundle: Record<string, unknown>,
|
||||
authority: Record<string, unknown>,
|
||||
submittedAt: unknown,
|
||||
): boolean {
|
||||
if (!timestamp(submittedAt)) return false;
|
||||
const issuedAt = validity.issuedAt as string;
|
||||
const expiresAt = validity.expiresAt as string;
|
||||
const evidenceObservedAt = bundle.observedAt as string;
|
||||
const evidenceExpiresAt = bundle.expiresAt as string;
|
||||
const leaseExpiresAt = authority.leaseExpiresAt as string;
|
||||
return issuedAt === submittedAt
|
||||
&& evidenceObservedAt <= submittedAt
|
||||
&& expiresAt <= evidenceExpiresAt
|
||||
&& expiresAt <= leaseExpiresAt
|
||||
&& submittedAt <= expiresAt
|
||||
&& submittedAt <= evidenceExpiresAt
|
||||
&& submittedAt <= leaseExpiresAt;
|
||||
}
|
||||
|
||||
export function validateGateVerdict(value: unknown, bundle?: unknown, submittedAt?: unknown): GateRecordValidation {
|
||||
const verdict = closed(value, ["schemaVersion", "recordType", "verdictId", "transitionId", "idempotencyKey", "operationRequestId", "actorContext", "missionContext", "authority", "transition", "target", "evidenceBundleId", "evidenceBundleSha256", "decision", "validity", "sensitivity", "recordSha256"], "verdict");
|
||||
if (!verdict) return fail("E2:closed-map");
|
||||
if (verdict.schemaVersion !== 1 || verdict.recordType !== "gate.merge.verdict" || !uuid(verdict.verdictId) || !uuid(verdict.transitionId) || !sha(verdict.idempotencyKey) || !uuid(verdict.operationRequestId) || !validActor(verdict.actorContext) || !validMission(verdict.missionContext) || !validTarget(verdict.target) || !uuid(verdict.evidenceBundleId) || !sha(verdict.evidenceBundleSha256) || !validValidity(verdict.validity) || verdict.sensitivity !== "internal" || !sha(verdict.recordSha256)) return fail("E2:scalar");
|
||||
const actor = verdict.actorContext as Record<string, unknown>;
|
||||
if (!validAuthority(verdict.authority, actor) || !validTransition(verdict.transition)) return fail("E2:authority-transition");
|
||||
const authority = verdict.authority as Record<string, unknown>;
|
||||
if (verdict.transitionId !== authority.invocationId || verdict.idempotencyKey !== derivedIdempotency(verdict)) return fail("E2:idempotency");
|
||||
if (bundle === undefined) return fail("E2:bundle-required");
|
||||
|
||||
const bundleCheck = validateGateEvidenceBundle(bundle);
|
||||
if (!bundleCheck.ok) return fail("E2:bundle-invalid");
|
||||
const evidence = bundle as Record<string, unknown>;
|
||||
if (
|
||||
verdict.evidenceBundleId !== evidence.evidenceBundleId
|
||||
|| verdict.evidenceBundleSha256 !== evidence.bundleSha256
|
||||
|| verdict.operationRequestId !== evidence.operationRequestId
|
||||
|| !sameCanonical(verdict.actorContext, evidence.actorContext)
|
||||
|| !sameCanonical(verdict.missionContext, evidence.missionContext)
|
||||
|| !sameCanonical(verdict.target, evidence.target)
|
||||
) return fail("E2:bundle-binding");
|
||||
if (!validityBoundAtSubmit(verdict.validity, evidence, authority, submittedAt)) return fail("E2:validity");
|
||||
|
||||
const evidenceById = new Map<string, Record<string, unknown>>((evidence.evidence as unknown[]).map((item) => [(item as Record<string, unknown>).evidenceId as string, item as Record<string, unknown>]));
|
||||
const decision = validDecision(verdict.decision, verdict.transition as Record<string, unknown>, evidenceById);
|
||||
if (!decision.ok) return fail("E2:decision");
|
||||
try {
|
||||
if (verdict.recordSha256 !== sha256Canonical(omitTop(verdict, "recordSha256"))) return fail("E2:record-digest");
|
||||
} catch {
|
||||
return fail("E2:record-digest");
|
||||
}
|
||||
return pass(verdict);
|
||||
}
|
||||
|
||||
export function validateGateRecord(value: unknown): GateRecordValidation {
|
||||
if (!isObject(value)) return fail("record:not-object");
|
||||
if (value.recordType === "gate.merge.evidence") return validateGateEvidenceBundle(value);
|
||||
if (value.recordType === "gate.merge.verdict") return validateGateVerdict(value);
|
||||
return fail("record:unknown-type");
|
||||
}
|
||||
|
||||
/** Parse untrusted JSON with duplicate-key, I-JSON, and closed-schema checks. */
|
||||
export function parseGateRecordJson(raw: string): GateRecordValidation {
|
||||
try {
|
||||
assertNoDuplicateJsonKeys(raw);
|
||||
return validateGateRecord(JSON.parse(raw));
|
||||
} catch {
|
||||
return fail("record:invalid-json");
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoDuplicateJsonKeys(raw: string): void {
|
||||
let index = 0;
|
||||
const whitespace = (): void => { while (/\s/.test(raw[index] ?? "")) index++; };
|
||||
const string = (): string => {
|
||||
const start = index;
|
||||
if (raw[index++] !== '"') throw new Error("string");
|
||||
while (index < raw.length) {
|
||||
const char = raw[index++];
|
||||
if (char === '"') return JSON.parse(raw.slice(start, index));
|
||||
if (char === "\\") { const escaped = raw[index++]; if (escaped === "u") index += 4; else if (escaped === undefined) throw new Error("escape"); }
|
||||
else if (char < " ") throw new Error("control");
|
||||
}
|
||||
throw new Error("unterminated");
|
||||
};
|
||||
const value = (): void => {
|
||||
whitespace(); const char = raw[index];
|
||||
if (char === "{") { index++; whitespace(); const keys = new Set<string>(); if (raw[index] === "}") { index++; return; } while (true) { whitespace(); const key = string(); if (keys.has(key)) throw new Error("duplicate"); keys.add(key); whitespace(); if (raw[index++] !== ":") throw new Error("colon"); value(); whitespace(); if (raw[index] === "}") { index++; return; } if (raw[index++] !== ",") throw new Error("comma"); } }
|
||||
if (char === "[") { index++; whitespace(); if (raw[index] === "]") { index++; return; } while (true) { value(); whitespace(); if (raw[index] === "]") { index++; return; } if (raw[index++] !== ",") throw new Error("comma"); } }
|
||||
if (char === '"') { string(); return; }
|
||||
const token = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(raw.slice(index));
|
||||
if (!token) throw new Error("value");
|
||||
index += token[0].length;
|
||||
};
|
||||
value(); whitespace(); if (index !== raw.length) throw new Error("trailing");
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// lib/goal-policy.ts — process-local, one-writer Goal policy bridge (T165 WP1).
|
||||
//
|
||||
// Goal registers the read-only consumer before Mosaic Core starts a session.
|
||||
// A trusted launcher or role adapter registers exactly one resolver, then
|
||||
// Mosaic Core publishes one validated attestation for the incarnation. This
|
||||
// module has no filesystem, network, provider, or model-visible surface.
|
||||
|
||||
export const GOAL_REPORT_FORMAT = "ms-executive-update/v1";
|
||||
export const GOAL_REPORT_CONTRACT_PATH = "skills-local/ms-executive-update/SKILL.md";
|
||||
export const GOAL_REPORT_CONTRACT_SECTION = "Machine contract (for `goal_report` payloads and any parser)";
|
||||
export const GOAL_REPORT_CONTRACT_BLOB = "df30c6fbb54b4a65a298c9e51c07f742610d171c";
|
||||
export const GOAL_REPORT_CONTRACT_SHA256 = "bbea48a46b1f8da7bc759f86856fb52830b7dde456b826317163c6dc6ccab319";
|
||||
export const GOAL_POLICY_ENFORCEMENT = "pre-state-change-fail-closed";
|
||||
export const GOAL_POLICY_IDENTIFIER_RESOLUTION = "consumer-fail-closed";
|
||||
|
||||
export type GoalPolicyRole = "gate-merge-ng" | "plan-ng" | "review-ng";
|
||||
export type GoalPolicyRoleRevision = 3 | 4;
|
||||
|
||||
export type GoalPolicyAttestationV1 = Readonly<{
|
||||
schemaVersion: 1;
|
||||
role: GoalPolicyRole;
|
||||
roleRevision: GoalPolicyRoleRevision;
|
||||
manifestSha256: string;
|
||||
format: typeof GOAL_REPORT_FORMAT;
|
||||
contractPath: typeof GOAL_REPORT_CONTRACT_PATH;
|
||||
contractSection: typeof GOAL_REPORT_CONTRACT_SECTION;
|
||||
contractBlob: typeof GOAL_REPORT_CONTRACT_BLOB;
|
||||
contractSha256: typeof GOAL_REPORT_CONTRACT_SHA256;
|
||||
enforcement: typeof GOAL_POLICY_ENFORCEMENT;
|
||||
identifierResolution: typeof GOAL_POLICY_IDENTIFIER_RESOLUTION;
|
||||
launchGeneration: number;
|
||||
incarnationId: string;
|
||||
}>;
|
||||
|
||||
export type GoalItemResolutionV1 =
|
||||
| Readonly<{
|
||||
outcome: "resolved";
|
||||
objectId: string;
|
||||
objectSha256: string;
|
||||
changedSincePreviousAcceptedReport: boolean;
|
||||
completionEvidenceId: string | null;
|
||||
}>
|
||||
| Readonly<{ outcome: "zero" | "multiple" | "unavailable" | "stale" }>;
|
||||
|
||||
export type GoalTrackedItem = Readonly<{
|
||||
token: string;
|
||||
section: "Just Completed" | "Next Step" | "Blocked";
|
||||
}>;
|
||||
|
||||
export type GoalTrackedItemResolverV1 = (parsedItem: GoalTrackedItem) => Promise<GoalItemResolutionV1>;
|
||||
|
||||
export type GoalPolicyReason =
|
||||
| "consumer-unavailable"
|
||||
| "resolver-unavailable"
|
||||
| "resolver-already-registered"
|
||||
| "already-published"
|
||||
| "malformed-attestation"
|
||||
| "attestation-mismatch"
|
||||
| "incarnation-mismatch";
|
||||
|
||||
export interface GoalPolicyPublication {
|
||||
attestation: GoalPolicyAttestationV1;
|
||||
resolver: GoalTrackedItemResolverV1;
|
||||
/** Trusted Core journal bridge. Receives a stable code only, never payload text. */
|
||||
onDenial?: (reason: string) => void;
|
||||
}
|
||||
|
||||
interface GoalPolicySlot {
|
||||
consumerRegistered: boolean;
|
||||
resolver?: GoalTrackedItemResolverV1;
|
||||
publication?: GoalPolicyPublication;
|
||||
}
|
||||
|
||||
const REGISTRY_KEY = Symbol.for("mosaic.goal-policy.v1.registry");
|
||||
type GlobalWithGoalPolicy = typeof globalThis & { [REGISTRY_KEY]?: Map<string, GoalPolicySlot> };
|
||||
|
||||
function registry(): Map<string, GoalPolicySlot> {
|
||||
const global = globalThis as GlobalWithGoalPolicy;
|
||||
if (!global[REGISTRY_KEY]) {
|
||||
Object.defineProperty(global, REGISTRY_KEY, {
|
||||
value: new Map<string, GoalPolicySlot>(),
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
return global[REGISTRY_KEY]!;
|
||||
}
|
||||
|
||||
function utf8Length(value: string): number {
|
||||
return Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
|
||||
export function isOpaqueGoalPolicyId(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) && utf8Length(value) >= 1 && utf8Length(value) <= 128;
|
||||
}
|
||||
|
||||
export function isLowerSha256(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function roleRevisionMatches(role: GoalPolicyRole, revision: GoalPolicyRoleRevision): boolean {
|
||||
return (role === "gate-merge-ng" && revision === 3) || ((role === "plan-ng" || role === "review-ng") && revision === 4);
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
export function validateGoalPolicyAttestation(value: unknown): value is GoalPolicyAttestationV1 {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const attestation = value as Record<string, unknown>;
|
||||
if (!hasExactKeys(attestation, [
|
||||
"schemaVersion", "role", "roleRevision", "manifestSha256", "format", "contractPath",
|
||||
"contractSection", "contractBlob", "contractSha256", "enforcement", "identifierResolution",
|
||||
"launchGeneration", "incarnationId",
|
||||
])) return false;
|
||||
if (attestation.schemaVersion !== 1) return false;
|
||||
if (attestation.role !== "gate-merge-ng" && attestation.role !== "plan-ng" && attestation.role !== "review-ng") return false;
|
||||
if (attestation.roleRevision !== 3 && attestation.roleRevision !== 4) return false;
|
||||
if (!roleRevisionMatches(attestation.role, attestation.roleRevision)) return false;
|
||||
if (!isLowerSha256(attestation.manifestSha256)) return false;
|
||||
if (attestation.format !== GOAL_REPORT_FORMAT) return false;
|
||||
if (attestation.contractPath !== GOAL_REPORT_CONTRACT_PATH) return false;
|
||||
if (attestation.contractSection !== GOAL_REPORT_CONTRACT_SECTION) return false;
|
||||
if (attestation.contractBlob !== GOAL_REPORT_CONTRACT_BLOB) return false;
|
||||
if (attestation.contractSha256 !== GOAL_REPORT_CONTRACT_SHA256) return false;
|
||||
if (attestation.enforcement !== GOAL_POLICY_ENFORCEMENT) return false;
|
||||
if (attestation.identifierResolution !== GOAL_POLICY_IDENTIFIER_RESOLUTION) return false;
|
||||
if (typeof attestation.launchGeneration !== "number" || !Number.isSafeInteger(attestation.launchGeneration) || attestation.launchGeneration < 1) return false;
|
||||
return isOpaqueGoalPolicyId(attestation.incarnationId);
|
||||
}
|
||||
|
||||
function freezeAttestation(value: GoalPolicyAttestationV1): GoalPolicyAttestationV1 {
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
/** Goal calls this during extension initialization, before Mosaic Core publishes. */
|
||||
export function registerGoalPolicyConsumer(incarnationId: string): { ok: true } | { ok: false; reason: "incarnation-mismatch" } {
|
||||
if (!isOpaqueGoalPolicyId(incarnationId)) return { ok: false, reason: "incarnation-mismatch" };
|
||||
const slots = registry();
|
||||
const slot = slots.get(incarnationId) ?? { consumerRegistered: false };
|
||||
slot.consumerRegistered = true;
|
||||
slots.set(incarnationId, slot);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* A trusted launcher or role adapter supplies a resolver before Core publishes.
|
||||
* WP1 deliberately supplies no fallback resolver: missing resolver authority
|
||||
* keeps a version-4 role fail-closed until the later broker/adapter work exists.
|
||||
*/
|
||||
export function registerGoalTrackedItemResolver(
|
||||
incarnationId: string,
|
||||
resolver: GoalTrackedItemResolverV1,
|
||||
): { ok: true } | { ok: false; reason: GoalPolicyReason } {
|
||||
if (!isOpaqueGoalPolicyId(incarnationId)) return { ok: false, reason: "incarnation-mismatch" };
|
||||
if (typeof resolver !== "function") return { ok: false, reason: "resolver-unavailable" };
|
||||
const slot = registry().get(incarnationId);
|
||||
if (!slot?.consumerRegistered) return { ok: false, reason: "consumer-unavailable" };
|
||||
if (slot.resolver) return { ok: false, reason: "resolver-already-registered" };
|
||||
slot.resolver = resolver;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Mosaic Core is the only policy publisher and may publish once per incarnation. */
|
||||
export function publishGoalPolicy(
|
||||
value: GoalPolicyAttestationV1,
|
||||
onDenial?: (reason: string) => void,
|
||||
): { ok: true; attestation: GoalPolicyAttestationV1 } | { ok: false; reason: GoalPolicyReason } {
|
||||
if (!validateGoalPolicyAttestation(value)) return { ok: false, reason: "malformed-attestation" };
|
||||
const slot = registry().get(value.incarnationId);
|
||||
if (!slot?.consumerRegistered) return { ok: false, reason: "consumer-unavailable" };
|
||||
if (!slot.resolver) return { ok: false, reason: "resolver-unavailable" };
|
||||
if (slot.publication) return { ok: false, reason: "already-published" };
|
||||
const attestation = freezeAttestation(value);
|
||||
slot.publication = Object.freeze({ attestation, resolver: slot.resolver, onDenial });
|
||||
return { ok: true, attestation };
|
||||
}
|
||||
|
||||
/** Read-only consumer view. No caller can replace a published pair. */
|
||||
export function goalPolicyPublication(incarnationId: string): GoalPolicyPublication | undefined {
|
||||
return registry().get(incarnationId)?.publication;
|
||||
}
|
||||
|
||||
/** Goal records a stable, payload-free denial through the Core-owned journal bridge. */
|
||||
export function recordGoalPolicyDenial(incarnationId: string, reason: string): void {
|
||||
try {
|
||||
registry().get(incarnationId)?.publication?.onDenial?.(reason);
|
||||
} catch {
|
||||
// A journal outage cannot turn a fail-closed report denial into an allow.
|
||||
}
|
||||
}
|
||||
|
||||
/** Core pre-dispatch check: attestation must still be the exact published pair. */
|
||||
export function verifyGoalPolicyPublication(
|
||||
expected: GoalPolicyAttestationV1,
|
||||
): { ok: true } | { ok: false; reason: GoalPolicyReason } {
|
||||
if (!validateGoalPolicyAttestation(expected)) return { ok: false, reason: "malformed-attestation" };
|
||||
const published = goalPolicyPublication(expected.incarnationId);
|
||||
if (!published) return { ok: false, reason: "resolver-unavailable" };
|
||||
const actual = published.attestation;
|
||||
const same = JSON.stringify(actual) === JSON.stringify(expected);
|
||||
return same ? { ok: true } : { ok: false, reason: "attestation-mismatch" };
|
||||
}
|
||||
|
||||
/** Validate the closed resolver result before Goal uses it. */
|
||||
export function validateGoalItemResolution(value: unknown): value is GoalItemResolutionV1 {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value) || !Object.isFrozen(value)) return false;
|
||||
const result = value as Record<string, unknown>;
|
||||
if (result.outcome === "zero" || result.outcome === "multiple" || result.outcome === "unavailable" || result.outcome === "stale") {
|
||||
return hasExactKeys(result, ["outcome"]);
|
||||
}
|
||||
if (result.outcome !== "resolved" || !hasExactKeys(result, [
|
||||
"outcome", "objectId", "objectSha256", "changedSincePreviousAcceptedReport", "completionEvidenceId",
|
||||
])) return false;
|
||||
return isOpaqueGoalPolicyId(result.objectId)
|
||||
&& isLowerSha256(result.objectSha256)
|
||||
&& typeof result.changedSincePreviousAcceptedReport === "boolean"
|
||||
&& (result.completionEvidenceId === null || isOpaqueGoalPolicyId(result.completionEvidenceId));
|
||||
}
|
||||
|
||||
/** Test-only registry reset. Production code has no reset or replacement path. */
|
||||
export function resetGoalPolicyRegistryForTest(): void {
|
||||
registry().clear();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// lib/incarnation.ts — process-launch incarnation identity (DESIGN HAZARD).
|
||||
//
|
||||
// Mercer-measured hazard, carried by the NG-1 tasking: Pi documents
|
||||
// PI_SESSION_ID as SESSION identity, not incarnation, and Mercer's
|
||||
// environment carries no incarnation variable. Per L2-D01/D05 we therefore
|
||||
// do NOT relabel PI_SESSION_ID as an incarnation — the R6 journal keys on
|
||||
// the identity minted here.
|
||||
//
|
||||
// Resolution order (pure function of process state + injectable rng):
|
||||
// 1. Launcher claim: MOSAIC_LAUNCH_INCARNATION, VALIDATED (NG-7 F2): the
|
||||
// claim becomes a FILENAME COMPONENT in the journal and goal-state
|
||||
// paths, so anything outside ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ is
|
||||
// IGNORED (no path separators, no leading dot, no traversal shape) and
|
||||
// resolution falls through — an invalid claim must never reach a path.
|
||||
// 2. Process-global: globalThis.__mosaicCoreIncarnation — set on first
|
||||
// mint. globalThis is per-process, so the identity survives extension
|
||||
// /reload (module re-import) and session switches WITHIN the process.
|
||||
// Deliberately NOT exported via process.env: child shells must not
|
||||
// inherit a forgeable-looking claim.
|
||||
// 3. Mint: rng() — DEFAULTS to crypto.randomUUID (NG-7 F1: the first cut
|
||||
// required an injectable rng with no default, and both production
|
||||
// call sites invoked incarnationIdentity() with no argument — a live
|
||||
// startup throw the test suites never caught because every arm
|
||||
// injected an rng or an explicit incarnationId).
|
||||
//
|
||||
// This function never reads PI_SESSION_ID. There is no code path here that
|
||||
// could conflate session identity with launch identity.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const LAUNCH_CLAIM_ENV = "MOSAIC_LAUNCH_INCARNATION";
|
||||
const GLOBAL_SLOT = "__mosaicCoreIncarnation";
|
||||
|
||||
/** A claim is usable only if it is a safe single path component. */
|
||||
export const CLAIM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
type Rng = () => string;
|
||||
|
||||
export function validClaim(claim: string | undefined): claim is string {
|
||||
return typeof claim === "string" && CLAIM_RE.test(claim);
|
||||
}
|
||||
|
||||
export function incarnationIdentity(rng: Rng = () => randomUUID()): string {
|
||||
const claim = process.env[LAUNCH_CLAIM_ENV];
|
||||
if (validClaim(claim)) {
|
||||
return claim;
|
||||
}
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const existing = g[GLOBAL_SLOT];
|
||||
if (typeof existing === "string" && existing.length > 0) {
|
||||
return existing;
|
||||
}
|
||||
const minted = rng();
|
||||
g[GLOBAL_SLOT] = minted;
|
||||
return minted;
|
||||
}
|
||||
|
||||
/** Test/adapter helper: clear the process-global slot (never touches claims). */
|
||||
export function resetIncarnationGlobal(): void {
|
||||
delete (globalThis as Record<string, unknown>)[GLOBAL_SLOT];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// lib/journal.ts — per-incarnation append-only JSONL journal (PRD R6, NG-3).
|
||||
//
|
||||
// One file per incarnation identity (the NG-1 process-launch identity —
|
||||
// NEVER PI_SESSION_ID) under the XDG state home:
|
||||
// ${XDG_STATE_HOME:-~/.local/state}/mosaic-core/<incarnationId>.jsonl
|
||||
//
|
||||
// Append-only is structural: the only write primitive is appendFileSync of a
|
||||
// newline-terminated JSON line. No read-modify-write, no truncation, no
|
||||
// rewrite — a journal that can be edited is a journal that cannot prove
|
||||
// anything. Event payloads are pre-sanitized by their producers (the denial
|
||||
// event carries tool+reason only; proposals pass the proposal sanitizer).
|
||||
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type JournalKind = "reconciliation" | "denial" | "improvement-proposal";
|
||||
|
||||
export interface JournalEvent {
|
||||
ts: string;
|
||||
incarnationId: string;
|
||||
kind: JournalKind;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
export interface JournalIO {
|
||||
mkdirSync(path: string, opts: { recursive: boolean }): void;
|
||||
appendFileSync(path: string, data: string): void;
|
||||
}
|
||||
|
||||
function defaultIO(): JournalIO {
|
||||
return { mkdirSync, appendFileSync };
|
||||
}
|
||||
|
||||
export function journalDir(stateHome: string | undefined, home: string = homedir()): string {
|
||||
return join(stateHome ?? join(home, ".local", "state"), "mosaic-core");
|
||||
}
|
||||
|
||||
export interface Journal {
|
||||
path: string;
|
||||
append(event: Omit<JournalEvent, "ts" | "incarnationId">): void;
|
||||
}
|
||||
|
||||
export function createJournal(opts: {
|
||||
incarnationId: string;
|
||||
stateHome?: string;
|
||||
io?: JournalIO;
|
||||
now?: () => string;
|
||||
}): Journal {
|
||||
const io = opts.io ?? defaultIO();
|
||||
const now = opts.now ?? (() => new Date().toISOString());
|
||||
const dir = journalDir(opts.stateHome);
|
||||
const path = join(dir, `${opts.incarnationId}.jsonl`);
|
||||
io.mkdirSync(dir, { recursive: true });
|
||||
return {
|
||||
path,
|
||||
append(event) {
|
||||
const line = `${JSON.stringify({ ts: now(), incarnationId: opts.incarnationId, ...event })}\n`;
|
||||
io.appendFileSync(path, line);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// lib/loader.ts — trusted-path role-manifest and v4 Goal-policy loading.
|
||||
//
|
||||
// Version-3 loading retains its established manifest path and digest behavior.
|
||||
// Version-4 adds one trusted, non-symlinked contract read at session start and
|
||||
// emits an immutable policy attestation. There is no cwd-relative path,
|
||||
// environment override, second manifest reader, or policy fallback.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { validateManifest, type RoleManifest, type RoleManifestV4 } from "./policy.ts";
|
||||
import {
|
||||
GOAL_POLICY_ENFORCEMENT,
|
||||
GOAL_POLICY_IDENTIFIER_RESOLUTION,
|
||||
GOAL_REPORT_CONTRACT_BLOB,
|
||||
GOAL_REPORT_CONTRACT_PATH,
|
||||
GOAL_REPORT_CONTRACT_SECTION,
|
||||
GOAL_REPORT_CONTRACT_SHA256,
|
||||
GOAL_REPORT_FORMAT,
|
||||
isOpaqueGoalPolicyId,
|
||||
type GoalPolicyAttestationV1,
|
||||
} from "./goal-policy.ts";
|
||||
|
||||
export const MANIFEST_BASENAME = "mosaic-core.manifest.json";
|
||||
|
||||
export type LoadFailure =
|
||||
| { stage: "no-agent-name" }
|
||||
| { stage: "no-seat-profile" }
|
||||
| { stage: "no-role-in-profile" }
|
||||
| { stage: "manifest-unreadable"; detail: string }
|
||||
| { stage: "manifest-symlink" }
|
||||
| { stage: "invalid-json"; detail: string }
|
||||
| { stage: "schema"; reason: string; detail?: string }
|
||||
| { stage: "role-mismatch"; expected: string; got: string }
|
||||
| { stage: "goal-policy-unreadable" }
|
||||
| { stage: "goal-policy-root-symlink" }
|
||||
| { stage: "goal-policy-parent-symlink" }
|
||||
| { stage: "goal-policy-symlink" }
|
||||
| { stage: "goal-policy-path-escape" }
|
||||
| { stage: "goal-policy-non-file" }
|
||||
| { stage: "goal-policy-invalid-utf8" }
|
||||
| { stage: "goal-policy-digest" }
|
||||
| { stage: "goal-policy-section" }
|
||||
| { stage: "goal-policy-launch-generation" }
|
||||
| { stage: "goal-policy-incarnation" }
|
||||
| { stage: "goal-policy-publication" };
|
||||
|
||||
export interface TrustedGoalPolicy {
|
||||
attestation: GoalPolicyAttestationV1;
|
||||
}
|
||||
|
||||
export type LoadOutcome =
|
||||
| { ok: true; manifest: RoleManifest; digest: string; path: string; goalPolicy?: TrustedGoalPolicy }
|
||||
| { ok: false; failure: LoadFailure };
|
||||
|
||||
export interface LoaderIO {
|
||||
readFileSync(path: string): string | Uint8Array;
|
||||
lstatSync(path: string): { isSymbolicLink(): boolean; isFile(): boolean };
|
||||
/** Required for v4 contract containment. Missing capability fails v4 closed. */
|
||||
realpathSync?(path: string): string;
|
||||
}
|
||||
|
||||
export interface LoadContext {
|
||||
incarnationId?: string;
|
||||
launchGeneration?: number;
|
||||
}
|
||||
|
||||
export function manifestPath(brainHome: string, role: string): string {
|
||||
return `${brainHome}/fleet/roles/${role}/${MANIFEST_BASENAME}`;
|
||||
}
|
||||
|
||||
export function seatRole(brainHome: string, agentName: string | undefined, io: LoaderIO): string | null {
|
||||
if (!agentName || !/^[a-z][a-z0-9-]*$/.test(agentName)) return null;
|
||||
let profileRaw: string;
|
||||
try {
|
||||
profileRaw = decodeUtf8(io.readFileSync(`${brainHome}/fleet/agents/${agentName}/profile.json`));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const profile = JSON.parse(profileRaw) as Record<string, unknown>;
|
||||
const role = profile.role;
|
||||
if (typeof role === "string" && /^[a-z][a-z0-9-]*$/.test(role)) return role;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Trusted manifest loader. v4 context is mandatory only for a v4 manifest. */
|
||||
export function loadTrustedManifest(brainHome: string, role: string, io: LoaderIO, context: LoadContext = {}): LoadOutcome {
|
||||
const path = manifestPath(brainHome, role);
|
||||
let st: ReturnType<LoaderIO["lstatSync"]>;
|
||||
try {
|
||||
st = io.lstatSync(path);
|
||||
} catch (error) {
|
||||
return { ok: false, failure: { stage: "manifest-unreadable", detail: String(error) } };
|
||||
}
|
||||
if (st.isSymbolicLink()) return { ok: false, failure: { stage: "manifest-symlink" } };
|
||||
|
||||
let rawBytes: Uint8Array;
|
||||
let raw: string;
|
||||
try {
|
||||
rawBytes = toBytes(io.readFileSync(path));
|
||||
raw = decodeUtf8(rawBytes);
|
||||
} catch (error) {
|
||||
return { ok: false, failure: { stage: "manifest-unreadable", detail: String(error) } };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
return { ok: false, failure: { stage: "invalid-json", detail: String(error) } };
|
||||
}
|
||||
const verdict = validateManifest(parsed);
|
||||
if (!verdict.ok) {
|
||||
return { ok: false, failure: { stage: "schema", reason: verdict.reason ?? "invalid", detail: verdict.detail } };
|
||||
}
|
||||
if (verdict.manifest!.role !== role) {
|
||||
return { ok: false, failure: { stage: "role-mismatch", expected: role, got: verdict.manifest!.role } };
|
||||
}
|
||||
|
||||
const digest = sha256(rawBytes);
|
||||
if (verdict.manifest!.schemaVersion !== 4) {
|
||||
return { ok: true, manifest: verdict.manifest!, digest, path };
|
||||
}
|
||||
|
||||
const goalPolicy = loadGoalPolicy(brainHome, verdict.manifest!, digest, io, context);
|
||||
if (!goalPolicy.ok) return goalPolicy;
|
||||
return { ok: true, manifest: verdict.manifest!, digest, path, goalPolicy: goalPolicy.goalPolicy };
|
||||
}
|
||||
|
||||
function loadGoalPolicy(
|
||||
brainHome: string,
|
||||
manifest: RoleManifestV4,
|
||||
manifestSha256: string,
|
||||
io: LoaderIO,
|
||||
context: LoadContext,
|
||||
): { ok: true; goalPolicy: TrustedGoalPolicy } | { ok: false; failure: LoadFailure } {
|
||||
const policy = manifest.goalReportPolicy;
|
||||
// validateManifest already pins every field. Repeat explicit constants here
|
||||
// so a future policy-validator regression cannot silently change the loader.
|
||||
if (
|
||||
policy.format !== GOAL_REPORT_FORMAT
|
||||
|| policy.contractPath !== GOAL_REPORT_CONTRACT_PATH
|
||||
|| policy.contractSection !== GOAL_REPORT_CONTRACT_SECTION
|
||||
|| policy.contractBlob !== GOAL_REPORT_CONTRACT_BLOB
|
||||
|| policy.contractSha256 !== GOAL_REPORT_CONTRACT_SHA256
|
||||
|| policy.enforcement !== GOAL_POLICY_ENFORCEMENT
|
||||
|| policy.identifierResolution !== GOAL_POLICY_IDENTIFIER_RESOLUTION
|
||||
) {
|
||||
return { ok: false, failure: { stage: "goal-policy-digest" } };
|
||||
}
|
||||
const trustedPath = trustedContractPath(brainHome, policy.contractPath, io);
|
||||
if (!trustedPath.ok) return { ok: false, failure: { stage: trustedPath.stage } };
|
||||
const contractPath = trustedPath.path;
|
||||
|
||||
let stat: ReturnType<LoaderIO["lstatSync"]>;
|
||||
try {
|
||||
stat = io.lstatSync(contractPath);
|
||||
} catch {
|
||||
return { ok: false, failure: { stage: "goal-policy-unreadable" } };
|
||||
}
|
||||
if (stat.isSymbolicLink()) return { ok: false, failure: { stage: "goal-policy-symlink" } };
|
||||
if (!stat.isFile()) return { ok: false, failure: { stage: "goal-policy-non-file" } };
|
||||
|
||||
let bytes: Uint8Array;
|
||||
let source: string;
|
||||
try {
|
||||
bytes = toBytes(io.readFileSync(contractPath));
|
||||
source = decodeUtf8(bytes);
|
||||
} catch {
|
||||
return { ok: false, failure: { stage: "goal-policy-invalid-utf8" } };
|
||||
}
|
||||
if (sha256(bytes) !== policy.contractSha256 || gitBlobSha1(bytes) !== policy.contractBlob) {
|
||||
return { ok: false, failure: { stage: "goal-policy-digest" } };
|
||||
}
|
||||
const heading = `## ${policy.contractSection}`;
|
||||
const first = source.indexOf(heading);
|
||||
if (first < 0 || source.indexOf(heading, first + heading.length) !== -1) {
|
||||
return { ok: false, failure: { stage: "goal-policy-section" } };
|
||||
}
|
||||
|
||||
const generation = context.launchGeneration;
|
||||
if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 1) {
|
||||
return { ok: false, failure: { stage: "goal-policy-launch-generation" } };
|
||||
}
|
||||
if (!isOpaqueGoalPolicyId(context.incarnationId)) {
|
||||
return { ok: false, failure: { stage: "goal-policy-incarnation" } };
|
||||
}
|
||||
|
||||
const attestation: GoalPolicyAttestationV1 = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
role: manifest.role,
|
||||
roleRevision: manifest.revision,
|
||||
manifestSha256,
|
||||
format: policy.format,
|
||||
contractPath: policy.contractPath,
|
||||
contractSection: policy.contractSection,
|
||||
contractBlob: policy.contractBlob,
|
||||
contractSha256: policy.contractSha256,
|
||||
enforcement: policy.enforcement,
|
||||
identifierResolution: policy.identifierResolution,
|
||||
launchGeneration: generation,
|
||||
incarnationId: context.incarnationId,
|
||||
});
|
||||
return { ok: true, goalPolicy: { attestation } };
|
||||
}
|
||||
|
||||
type TrustedContractPath =
|
||||
| Readonly<{ ok: true; path: string }>
|
||||
| Readonly<{
|
||||
ok: false;
|
||||
stage: "goal-policy-unreadable" | "goal-policy-root-symlink" | "goal-policy-parent-symlink" | "goal-policy-symlink" | "goal-policy-path-escape";
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Resolve each path component without allowing a contract parent to redirect
|
||||
* a trusted-root read. The leaf check alone is insufficient because
|
||||
* `skills-local` itself can be a symlink to matching bytes outside the brain.
|
||||
*/
|
||||
function trustedContractPath(brainHome: string, relative: string, io: LoaderIO): TrustedContractPath {
|
||||
if (relative !== GOAL_REPORT_CONTRACT_PATH || relative.startsWith("/") || relative.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
|
||||
}
|
||||
const root = brainHome.replace(/\/+$/, "");
|
||||
if (!root.startsWith("/") || root === "" || !io.realpathSync) {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
|
||||
}
|
||||
|
||||
try {
|
||||
const rootStat = io.lstatSync(root);
|
||||
if (rootStat.isSymbolicLink()) return Object.freeze({ ok: false, stage: "goal-policy-root-symlink" });
|
||||
const canonicalRoot = io.realpathSync(root).replace(/\/+$/, "");
|
||||
if (!canonicalRoot.startsWith("/") || canonicalRoot === "") {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
|
||||
}
|
||||
|
||||
const parts = relative.split("/");
|
||||
let path = root;
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
path = `${path}/${parts[index]}`;
|
||||
const stat = io.lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
return Object.freeze({ ok: false, stage: index === parts.length - 1 ? "goal-policy-symlink" : "goal-policy-parent-symlink" });
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalPath = io.realpathSync(path);
|
||||
if (canonicalPath !== canonicalRoot && !canonicalPath.startsWith(`${canonicalRoot}/`)) {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-path-escape" });
|
||||
}
|
||||
return Object.freeze({ ok: true, path });
|
||||
} catch {
|
||||
return Object.freeze({ ok: false, stage: "goal-policy-unreadable" });
|
||||
}
|
||||
}
|
||||
|
||||
function toBytes(value: string | Uint8Array): Uint8Array {
|
||||
return typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value);
|
||||
}
|
||||
|
||||
function decodeUtf8(value: string | Uint8Array): string {
|
||||
if (typeof value === "string") return value;
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(value);
|
||||
}
|
||||
|
||||
export function sha256(value: string | Uint8Array): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function gitBlobSha1(bytes: Uint8Array): string {
|
||||
const header = Buffer.from(`blob ${bytes.byteLength}\0`, "utf8");
|
||||
return createHash("sha1").update(header).update(bytes).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
// lib/policy.ts — pure role-manifest schema validation (PRD R2, AC1; NG-4 v3).
|
||||
//
|
||||
// NG-1 keeps PURE validation only: no filesystem load, no trusted-path
|
||||
// resolution (NG-2 owns those). The schema is deliberately strict —
|
||||
// everything not named here is rejected, because a manifest that silently
|
||||
// tolerates extra fields is a manifest whose future drift cannot be caught.
|
||||
//
|
||||
// NG-4 (velma B2, memo 9c426fa9): the manifest separates CAPABILITIES from
|
||||
// tool bindings. Capabilities are the authority; Pi tools are their bound
|
||||
// projections. The strict cross-check: the tools array must be EXACTLY the
|
||||
// set of Pi bindings of the bound capabilities — neither more (an
|
||||
// unbound-to-capability tool is undeclared authority) nor less.
|
||||
//
|
||||
// NG-4 remediation F6: the manifest carries the FULL authority set —
|
||||
// schemaVersion, extensions.load order, workspace.readRoots, credentials
|
||||
// (deny-all), evidence permissions (journal-only), and digest/integrity
|
||||
// rules. Settings are generated projections; they never become authority.
|
||||
|
||||
export interface CapabilityBinding {
|
||||
id: string;
|
||||
effect: CapabilityEffect;
|
||||
binding?: string;
|
||||
status: CapabilityStatus;
|
||||
}
|
||||
|
||||
export interface RoleManifestV3 {
|
||||
schemaVersion: 3;
|
||||
role: string;
|
||||
revision: number;
|
||||
capabilities: CapabilityBinding[];
|
||||
tools: string[];
|
||||
extensions: { load: string[] };
|
||||
workspace: { readRoots: string[] };
|
||||
credentials: Record<string, unknown>;
|
||||
evidence: { journal: true; journalEvents: string[]; external: "denied" };
|
||||
digestRules: { algorithm: "sha256"; scope: "manifest-bytes"; postcondition: "active-set-exact" };
|
||||
forbiddenTools: string[];
|
||||
shell: { mode: "denied" };
|
||||
}
|
||||
|
||||
export interface GoalReportPolicy {
|
||||
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";
|
||||
}
|
||||
|
||||
export interface RoleManifestV4 {
|
||||
schemaVersion: 4;
|
||||
role: "gate-merge-ng" | "plan-ng" | "review-ng";
|
||||
revision: 3 | 4;
|
||||
capabilities: CapabilityBinding[];
|
||||
tools: string[];
|
||||
extensions: { load: string[] };
|
||||
workspace: { readRoots: string[] };
|
||||
credentials: Record<string, unknown>;
|
||||
evidence: Record<string, unknown>;
|
||||
digestRules: { algorithm: "sha256"; scope: "manifest-bytes"; postcondition: "active-set-exact" };
|
||||
forbiddenTools: string[];
|
||||
shell: { mode: "denied" };
|
||||
goalReportPolicy: GoalReportPolicy;
|
||||
targetPolicy?: { kind: "pull-request"; hosts: string[]; repositories: string[]; pinSource: "assignment" };
|
||||
}
|
||||
|
||||
export type RoleManifest = RoleManifestV3 | RoleManifestV4;
|
||||
|
||||
export type CapabilityEffect = "observe" | "propose" | "transition" | "communicate";
|
||||
export type CapabilityStatus = "bound" | "unbound";
|
||||
|
||||
/** Closed capability ids. C1-C8 remain version-3 only; C9-C11 are version-4 gate authority. */
|
||||
const V3_CAPABILITY_IDS = new Set([
|
||||
"policy.status", // C1
|
||||
"improvement.propose", // C2
|
||||
"repo.file.read", // C3
|
||||
"goal.report", // C4
|
||||
"reason.structured", // C5
|
||||
"coord.route_implementation", // C6
|
||||
"coord.request", // C7
|
||||
"comms.send", // C8
|
||||
]);
|
||||
export const CAPABILITY_IDS = new Set([
|
||||
...V3_CAPABILITY_IDS,
|
||||
"repo.target.inspect", // C9
|
||||
"gate.evidence.observe", // C10
|
||||
"gate.verdict", // C11
|
||||
]);
|
||||
|
||||
export const CAPABILITY_EFFECTS = new Set<CapabilityEffect>(["observe", "propose", "transition", "communicate"]);
|
||||
|
||||
/** Fixed normative id-to-binding maps. Version 3 never learns C9-C11. */
|
||||
const V3_NORMATIVE_BINDINGS: Record<string, { binding: string; status: "bound" } | { status: "unbound" }> = {
|
||||
"policy.status": { binding: "mosaic_policy_status", status: "bound" },
|
||||
"improvement.propose": { binding: "mosaic_improvement_propose", status: "bound" },
|
||||
"repo.file.read": { binding: "read", status: "bound" },
|
||||
"goal.report": { binding: "goal_report", status: "bound" },
|
||||
"reason.structured": { status: "unbound" },
|
||||
"coord.route_implementation": { status: "unbound" },
|
||||
"coord.request": { status: "unbound" },
|
||||
"comms.send": { status: "unbound" },
|
||||
};
|
||||
export const NORMATIVE_BINDINGS: Record<string, { binding: string; status: "bound" } | { status: "unbound" }> = {
|
||||
...V3_NORMATIVE_BINDINGS,
|
||||
"repo.target.inspect": { binding: "mosaic_gate_target_read", status: "bound" },
|
||||
"gate.evidence.observe": { binding: "mosaic_gate_evidence_observe", status: "bound" },
|
||||
"gate.verdict": { binding: "mosaic_gate_verdict", status: "bound" },
|
||||
};
|
||||
|
||||
/**
|
||||
* NG4-CRIT: the MANDATORY reviewed forbidden raw-tool set. The manifest's
|
||||
* forbiddenTools must CONTAIN this minimum set (it may add more, never
|
||||
* remove). This prevents the caller from deleting entries to smuggle a
|
||||
* forbidden tool back into scope.
|
||||
*/
|
||||
export const MANDATORY_FORBIDDEN = ["bash", "edit", "write", "powershell", "tmux", "send_message", "git"];
|
||||
export const CAPABILITY_STATUSES = new Set<CapabilityStatus>(["bound", "unbound"]);
|
||||
|
||||
export type PolicyRejection =
|
||||
| "not-an-object"
|
||||
| "unknown-field"
|
||||
| "missing-field"
|
||||
| "invalid-role"
|
||||
| "invalid-revision"
|
||||
| "invalid-schema-version"
|
||||
| "invalid-tools"
|
||||
| "duplicate-tool"
|
||||
| "unsafe-empty-policy"
|
||||
| "invalid-capabilities"
|
||||
| "duplicate-capability"
|
||||
| "unknown-capability"
|
||||
| "invalid-capability-effect"
|
||||
| "invalid-capability-status"
|
||||
| "bound-without-binding"
|
||||
| "unbound-with-binding"
|
||||
| "invalid-capability-binding"
|
||||
| "tools-bindings-mismatch"
|
||||
| "invalid-extensions"
|
||||
| "invalid-workspace"
|
||||
| "invalid-credentials"
|
||||
| "invalid-evidence"
|
||||
| "invalid-digest-rules"
|
||||
| "missing-capability"
|
||||
| "invalid-forbidden-tools"
|
||||
| "invalid-shell"
|
||||
| "forbidden-tool-overlap"
|
||||
| "invalid-journal-events"
|
||||
| "invalid-role-class"
|
||||
| "invalid-goal-report-policy"
|
||||
| "invalid-target-policy"
|
||||
| "version4-exact-set-mismatch";
|
||||
|
||||
export interface PolicyResult {
|
||||
ok: boolean;
|
||||
manifest?: RoleManifest;
|
||||
reason?: PolicyRejection;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const V3_ALLOWED_FIELDS = new Set([
|
||||
"schemaVersion", "role", "revision", "tools", "capabilities",
|
||||
"extensions", "workspace", "credentials", "evidence", "digestRules",
|
||||
"forbiddenTools", "shell",
|
||||
]);
|
||||
const ROLE_RE = /^[a-z][a-z0-9-]*$/;
|
||||
const TOOL_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
const EXT_RE = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export function validateManifest(input: unknown): PolicyResult {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return { ok: false, reason: "not-an-object", detail: "manifest must be a JSON object" };
|
||||
}
|
||||
const obj = input as Record<string, unknown>;
|
||||
|
||||
// Version 3 remains on its original closed validator. Version 4 is a
|
||||
// separate role-class contract and never widens the v3 capability map.
|
||||
if (obj.schemaVersion === 4) return validateV4Manifest(obj);
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!V3_ALLOWED_FIELDS.has(key)) {
|
||||
return { ok: false, reason: "unknown-field", detail: `unexpected field "${key}"` };
|
||||
}
|
||||
}
|
||||
for (const field of V3_ALLOWED_FIELDS) {
|
||||
if (!(field in obj)) {
|
||||
return { ok: false, reason: "missing-field", detail: `missing field "${field}"` };
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.schemaVersion !== 3) {
|
||||
return { ok: false, reason: "invalid-schema-version", detail: "schemaVersion must be 3" };
|
||||
}
|
||||
|
||||
const role = obj.role;
|
||||
if (typeof role !== "string" || !ROLE_RE.test(role)) {
|
||||
return { ok: false, reason: "invalid-role", detail: `role must match ${ROLE_RE}` };
|
||||
}
|
||||
|
||||
const revision = obj.revision;
|
||||
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
|
||||
return { ok: false, reason: "invalid-revision", detail: "revision must be a positive integer" };
|
||||
}
|
||||
|
||||
// ---- capabilities: the authority layer ------------------------------------
|
||||
const rawCaps = obj.capabilities;
|
||||
if (!Array.isArray(rawCaps) || rawCaps.length === 0 || rawCaps.some((c) => typeof c !== "object" || c === null || Array.isArray(c))) {
|
||||
return { ok: false, reason: "invalid-capabilities", detail: "capabilities must be a non-empty array of objects" };
|
||||
}
|
||||
const seenCaps = new Set<string>();
|
||||
const boundTools: string[] = [];
|
||||
const capabilities: CapabilityBinding[] = [];
|
||||
for (const raw of rawCaps) {
|
||||
const c = raw as Record<string, unknown>;
|
||||
for (const key of Object.keys(c)) {
|
||||
if (!["id", "effect", "binding", "status"].includes(key)) {
|
||||
return { ok: false, reason: "invalid-capabilities", detail: `unknown capability field "${key}"` };
|
||||
}
|
||||
}
|
||||
if (typeof c.id !== "string" || !V3_CAPABILITY_IDS.has(c.id)) {
|
||||
return { ok: false, reason: "unknown-capability", detail: `capability id "${String(c.id)}" is outside the reviewed C1-C8 map` };
|
||||
}
|
||||
if (seenCaps.has(c.id)) {
|
||||
return { ok: false, reason: "duplicate-capability", detail: `duplicate capability "${c.id}"` };
|
||||
}
|
||||
seenCaps.add(c.id);
|
||||
if (typeof c.effect !== "string" || !CAPABILITY_EFFECTS.has(c.effect as CapabilityEffect)) {
|
||||
return { ok: false, reason: "invalid-capability-effect", detail: `capability "${c.id}"` };
|
||||
}
|
||||
// F6H-(5): enforce the normative id->effect mapping from the C1-C8 map
|
||||
const NORMATIVE_EFFECT: Record<string, CapabilityEffect> = {
|
||||
"policy.status": "observe",
|
||||
"improvement.propose": "propose",
|
||||
"repo.file.read": "observe",
|
||||
"goal.report": "transition",
|
||||
"reason.structured": "observe",
|
||||
"coord.route_implementation": "transition",
|
||||
"coord.request": "transition",
|
||||
"comms.send": "communicate",
|
||||
};
|
||||
if (c.effect !== NORMATIVE_EFFECT[c.id]) {
|
||||
return { ok: false, reason: "invalid-capability-effect", detail: `capability "${c.id}" must have effect "${NORMATIVE_EFFECT[c.id]}" (normative C-map), got "${c.effect}"` };
|
||||
}
|
||||
if (typeof c.status !== "string" || !CAPABILITY_STATUSES.has(c.status as CapabilityStatus)) {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}"` };
|
||||
}
|
||||
// NG4-CRIT: enforce the NORMATIVE binding (or mandatory unbound status)
|
||||
const normative = V3_NORMATIVE_BINDINGS[c.id];
|
||||
if (normative.status === "unbound" && c.status === "bound") {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}" must be UNBOUND in this slice (no backend)` };
|
||||
}
|
||||
if (c.status === "bound") {
|
||||
if (normative.status !== "bound") {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${c.id}" cannot be bound in this slice` };
|
||||
}
|
||||
if (c.binding !== normative.binding) {
|
||||
return { ok: false, reason: "invalid-capability-binding", detail: `capability "${c.id}" must bind exactly "${normative.binding}" (normative C-map), got "${c.binding}"` };
|
||||
}
|
||||
if (typeof c.binding !== "string" || !TOOL_RE.test(c.binding)) {
|
||||
return { ok: false, reason: "bound-without-binding", detail: `capability "${c.id}" is bound but has no valid tool binding` };
|
||||
}
|
||||
boundTools.push(c.binding);
|
||||
} else if (c.binding !== undefined) {
|
||||
return { ok: false, reason: "unbound-with-binding", detail: `capability "${c.id}" is unbound but declares a binding` };
|
||||
}
|
||||
capabilities.push({
|
||||
id: c.id,
|
||||
effect: c.effect as CapabilityEffect,
|
||||
binding: c.binding as string | undefined,
|
||||
status: c.status as CapabilityStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- tools: the PROJECTION of bound capabilities, exactly ------------------
|
||||
const tools = obj.tools;
|
||||
if (!Array.isArray(tools) || tools.some((t) => typeof t !== "string" || !TOOL_RE.test(t))) {
|
||||
return { ok: false, reason: "invalid-tools", detail: `every tool must match ${TOOL_RE}` };
|
||||
}
|
||||
if (tools.length === 0) {
|
||||
return { ok: false, reason: "unsafe-empty-policy", detail: "tools must not be empty" };
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const t of tools) {
|
||||
if (seen.has(t)) {
|
||||
return { ok: false, reason: "duplicate-tool", detail: `duplicate tool "${t}"` };
|
||||
}
|
||||
seen.add(t);
|
||||
}
|
||||
const expected = [...new Set(boundTools)].sort();
|
||||
const declared = [...new Set(tools)].sort();
|
||||
if (JSON.stringify(expected) !== JSON.stringify(declared)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "tools-bindings-mismatch",
|
||||
detail: `tools must be exactly the bound capabilities' bindings (expected [${expected.join(", ")}])`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- NG4-CRIT3: C1-C4 MUST be present and bound (canary contract) -------------
|
||||
const REQUIRED_BOUND = ["policy.status", "improvement.propose", "repo.file.read", "goal.report"];
|
||||
for (const reqId of REQUIRED_BOUND) {
|
||||
const cap = capabilities.find((c) => c.id === reqId);
|
||||
if (!cap) {
|
||||
return { ok: false, reason: "missing-capability", detail: `canary roles must declare capability "${reqId}" (C1-C4 are mandatory)` };
|
||||
}
|
||||
if (cap.status !== "bound") {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${reqId}" must be BOUND (C1-C4 are mandatory for canary roles)` };
|
||||
}
|
||||
}
|
||||
// C5-C8: if present, must be unbound (first slice — no backend)
|
||||
for (const cap of capabilities) {
|
||||
if (V3_NORMATIVE_BINDINGS[cap.id]?.status === "unbound" && cap.status === "bound") {
|
||||
return { ok: false, reason: "invalid-capability-status", detail: `capability "${cap.id}" must be UNBOUND in this slice` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- extensions: load order authority ----------------------------------------
|
||||
const exts = obj.extensions;
|
||||
if (typeof exts !== "object" || exts === null || Array.isArray(exts) || !Array.isArray((exts as Record<string, unknown>).load)) {
|
||||
return { ok: false, reason: "invalid-extensions", detail: "extensions.load must be an ordered array" };
|
||||
}
|
||||
// F6H-(2): reject unknown nested keys in extensions
|
||||
for (const ek of Object.keys(exts)) {
|
||||
if (ek !== "load") {
|
||||
return { ok: false, reason: "invalid-extensions", detail: `unknown extensions field "${ek}"` };
|
||||
}
|
||||
}
|
||||
const loadOrder = (exts as { load: unknown[] }).load;
|
||||
if (loadOrder.some((e) => typeof e !== "string" || !EXT_RE.test(e))) {
|
||||
return { ok: false, reason: "invalid-extensions", detail: "extension names must be slugs" };
|
||||
}
|
||||
if (new Set(loadOrder).size !== loadOrder.length) {
|
||||
return { ok: false, reason: "invalid-extensions", detail: "duplicate extension in load order" };
|
||||
}
|
||||
// F6H-(3): the reviewed required load order is exact — goal FIRST (registers
|
||||
// goal_report), mosaic-core LAST (reconciles the complete registry).
|
||||
// Anything else is unreviewed authority.
|
||||
const REQUIRED_LOAD = ["goal", "mosaic-core"];
|
||||
if (JSON.stringify(loadOrder) !== JSON.stringify(REQUIRED_LOAD)) {
|
||||
return { ok: false, reason: "invalid-extensions", detail: `extensions.load must be exactly [${REQUIRED_LOAD.join(", ")}] — goal first (registers goal_report), mosaic-core last (reconciles the complete registry)` };
|
||||
}
|
||||
|
||||
// ---- workspace: read-scope roots (symbolic @brain or absolute) ---------------
|
||||
const ws = obj.workspace;
|
||||
if (typeof ws !== "object" || ws === null || Array.isArray(ws) || !Array.isArray((ws as Record<string, unknown>).readRoots)) {
|
||||
return { ok: false, reason: "invalid-workspace", detail: "workspace.readRoots must be an array" };
|
||||
}
|
||||
// F6H-(2): reject unknown nested keys in workspace
|
||||
for (const wk of Object.keys(ws)) {
|
||||
if (wk !== "readRoots") {
|
||||
return { ok: false, reason: "invalid-workspace", detail: `unknown workspace field "${wk}"` };
|
||||
}
|
||||
}
|
||||
const readRoots = (ws as { readRoots: unknown[] }).readRoots;
|
||||
if (readRoots.some((r) => typeof r !== "string" || !(r.startsWith("/") || r === "@brain"))) {
|
||||
return { ok: false, reason: "invalid-workspace", detail: "readRoots must be absolute paths or the symbolic @brain root" };
|
||||
}
|
||||
|
||||
// ---- credentials: deny-all, EXACT (F6) ----------------------------------------
|
||||
const creds = obj.credentials;
|
||||
if (typeof creds !== "object" || creds === null || Array.isArray(creds)) {
|
||||
return { ok: false, reason: "invalid-credentials", detail: "credentials must be an object" };
|
||||
}
|
||||
const credKeys = Object.keys(creds);
|
||||
if (credKeys.some((k) => !["store", "helper", "providerTokens"].includes(k))) {
|
||||
return { ok: false, reason: "invalid-credentials", detail: "unknown credentials field" };
|
||||
}
|
||||
if (creds.store !== "none" || creds.helper !== "none") {
|
||||
return { ok: false, reason: "invalid-credentials", detail: "canary roles carry no credential store or helper" };
|
||||
}
|
||||
if (creds.providerTokens !== "denied") {
|
||||
return { ok: false, reason: "invalid-credentials", detail: 'providerTokens must be exactly "denied" (mandatory deny-all)' };
|
||||
}
|
||||
|
||||
// ---- evidence: journal-only (F6) ----------------------------------------------
|
||||
const ev = obj.evidence;
|
||||
if (typeof ev !== "object" || ev === null || Array.isArray(ev)) {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "evidence must be an object" };
|
||||
}
|
||||
const evKeys = Object.keys(ev);
|
||||
if (evKeys.some((k) => !["journal", "journalEvents", "external"].includes(k))) {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "unknown evidence field" };
|
||||
}
|
||||
if (ev.journal !== true) {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "canary roles must journal (the only evidence sink)" };
|
||||
}
|
||||
if (ev.external !== "denied") {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "external evidence must be denied for canary roles" };
|
||||
}
|
||||
const REVIEWED_EVENTS = ["reconciliation", "denial", "improvement-proposal"];
|
||||
if (!Array.isArray(ev.journalEvents) || ev.journalEvents.length !== REVIEWED_EVENTS.length) {
|
||||
return { ok: false, reason: "invalid-journal-events", detail: `journalEvents must be exactly the reviewed set [${REVIEWED_EVENTS.join(", ")}]` };
|
||||
}
|
||||
const evSeen = new Set<string>();
|
||||
for (const e of ev.journalEvents) {
|
||||
if (typeof e !== "string" || !REVIEWED_EVENTS.includes(e)) {
|
||||
return { ok: false, reason: "invalid-journal-events", detail: `unknown journal event "${String(e)}"` };
|
||||
}
|
||||
if (evSeen.has(e)) {
|
||||
return { ok: false, reason: "invalid-journal-events", detail: `duplicate journal event "${e}"` };
|
||||
}
|
||||
evSeen.add(e);
|
||||
}
|
||||
|
||||
// ---- digest/integrity rules: explicit and closed (F6) --------------------------
|
||||
const dig = obj.digestRules;
|
||||
if (typeof dig !== "object" || dig === null || Array.isArray(dig)) {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "digestRules must be an object" };
|
||||
}
|
||||
const digKeys = Object.keys(dig);
|
||||
if (digKeys.some((k) => !["algorithm", "scope", "postcondition"].includes(k))) {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "unknown digestRules field" };
|
||||
}
|
||||
if (dig.algorithm !== "sha256") {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "digest algorithm must be sha256" };
|
||||
}
|
||||
if (dig.scope !== "manifest-bytes") {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "digest scope must be manifest-bytes" };
|
||||
}
|
||||
if (dig.postcondition !== "active-set-exact") {
|
||||
return { ok: false, reason: "invalid-digest-rules", detail: "postcondition must be active-set-exact" };
|
||||
}
|
||||
|
||||
// ---- forbiddenTools (auth): the reviewed absent set — these tools must
|
||||
// never appear in the bound tools; overlap is a typed rejection
|
||||
const forbidden = obj.forbiddenTools;
|
||||
if (!Array.isArray(forbidden) || forbidden.length === 0 || forbidden.some((t) => typeof t !== "string" || !TOOL_RE.test(t))) {
|
||||
return { ok: false, reason: "invalid-forbidden-tools", detail: "forbiddenTools must be a non-empty array of valid tool names" };
|
||||
}
|
||||
const forbiddenSet = new Set(forbidden as string[]);
|
||||
if (forbiddenSet.size !== (forbidden as string[]).length) {
|
||||
return { ok: false, reason: "invalid-forbidden-tools", detail: "duplicate entry in forbiddenTools" };
|
||||
}
|
||||
// NG4-CRIT: the manifest MUST contain the mandatory reviewed forbidden set
|
||||
// (it may add more, never remove)
|
||||
for (const m of MANDATORY_FORBIDDEN) {
|
||||
if (!forbiddenSet.has(m)) {
|
||||
return { ok: false, reason: "invalid-forbidden-tools", detail: `mandatory forbidden tool "${m}" is absent from forbiddenTools (cannot be removed)` };
|
||||
}
|
||||
}
|
||||
for (const t of tools) {
|
||||
if (forbiddenSet.has(t)) {
|
||||
return { ok: false, reason: "forbidden-tool-overlap", detail: `tool "${t}" is both bound and forbidden` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shell policy (auth): canary roles deny all shell access
|
||||
const shell = obj.shell;
|
||||
if (typeof shell !== "object" || shell === null || Array.isArray(shell)) {
|
||||
return { ok: false, reason: "invalid-shell", detail: "shell must be an object" };
|
||||
}
|
||||
const shellKeys = Object.keys(shell);
|
||||
if (shellKeys.some((k) => !["mode"].includes(k))) {
|
||||
return { ok: false, reason: "invalid-shell", detail: "unknown shell field" };
|
||||
}
|
||||
if (shell.mode !== "denied") {
|
||||
return { ok: false, reason: "invalid-shell", detail: "canary roles deny all shell access (shell.mode must be \"denied\")" };
|
||||
}
|
||||
|
||||
// ---- C3 scope (F7): bound read capability requires declared scope ---------------
|
||||
const c3 = capabilities.find((c) => c.id === "repo.file.read");
|
||||
if (c3 && c3.status === "bound" && readRoots.length === 0) {
|
||||
return { ok: false, reason: "invalid-workspace", detail: "C3 repo.file.read is bound but workspace.readRoots is empty — declare the read scope or leave C3 unbound" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
manifest: {
|
||||
schemaVersion: 3,
|
||||
role,
|
||||
revision,
|
||||
capabilities,
|
||||
tools: [...tools],
|
||||
extensions: { load: [...loadOrder] as string[] },
|
||||
workspace: { readRoots: [...readRoots] as string[] },
|
||||
credentials: { ...(creds as Record<string, unknown>) },
|
||||
evidence: { journal: true, journalEvents: [...(ev as { journalEvents: string[] }).journalEvents], external: "denied" },
|
||||
digestRules: { algorithm: "sha256", scope: "manifest-bytes", postcondition: "active-set-exact" },
|
||||
forbiddenTools: [...forbidden] as string[],
|
||||
shell: { mode: "denied" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- schema version 4: closed, role-class-specific authority -----------------
|
||||
|
||||
type V4Role = "gate-merge-ng" | "plan-ng" | "review-ng";
|
||||
interface V4RoleSpec {
|
||||
revision: 3 | 4;
|
||||
capabilities: ReadonlyArray<Readonly<{ id: string; effect: CapabilityEffect; binding?: string; status: CapabilityStatus }>>;
|
||||
tools: readonly string[];
|
||||
extensions: readonly string[];
|
||||
readRoots: readonly string[];
|
||||
credentials: Record<string, unknown>;
|
||||
evidence: Record<string, unknown>;
|
||||
forbiddenTools: readonly string[];
|
||||
targetPolicy?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const GOAL_POLICY_FIELDS = [
|
||||
"format", "contractPath", "contractSection", "contractBlob", "contractSha256", "enforcement", "identifierResolution",
|
||||
] as const;
|
||||
const GOAL_POLICY_VALUES: GoalReportPolicy = {
|
||||
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",
|
||||
};
|
||||
|
||||
const V4_COMMON_POLICY = { algorithm: "sha256", scope: "manifest-bytes", postcondition: "active-set-exact" } as const;
|
||||
const PLAN_REVIEW_CAPABILITIES = [
|
||||
{ id: "policy.status", effect: "observe", binding: "mosaic_policy_status", status: "bound" },
|
||||
{ id: "improvement.propose", effect: "propose", binding: "mosaic_improvement_propose", status: "bound" },
|
||||
{ id: "repo.file.read", effect: "observe", binding: "read", status: "bound" },
|
||||
{ id: "goal.report", effect: "transition", binding: "goal_report", status: "bound" },
|
||||
{ id: "coord.route_implementation", effect: "transition", status: "unbound" },
|
||||
{ id: "comms.send", effect: "communicate", status: "unbound" },
|
||||
] as const;
|
||||
const GATE_CAPABILITIES = [
|
||||
{ id: "policy.status", effect: "observe", binding: "mosaic_policy_status", status: "bound" },
|
||||
{ id: "goal.report", effect: "transition", binding: "goal_report", status: "bound" },
|
||||
{ id: "repo.target.inspect", effect: "observe", binding: "mosaic_gate_target_read", status: "bound" },
|
||||
{ id: "gate.evidence.observe", effect: "observe", binding: "mosaic_gate_evidence_observe", status: "bound" },
|
||||
{ id: "gate.verdict", effect: "transition", binding: "mosaic_gate_verdict", status: "bound" },
|
||||
] as const;
|
||||
const PLAN_REVIEW_FORBIDDEN = ["bash", "edit", "git", "powershell", "send_message", "tmux", "write"] as const;
|
||||
const GATE_FORBIDDEN = [
|
||||
"bash", "browser", "edit", "fetch", "find", "git", "grep", "http", "ls", "mosaic_improvement_propose",
|
||||
"powershell", "pr_merge", "read", "send_message", "tmux", "web", "write",
|
||||
] as const;
|
||||
|
||||
const V4_SPECS: Record<V4Role, V4RoleSpec> = {
|
||||
"gate-merge-ng": {
|
||||
revision: 3,
|
||||
capabilities: GATE_CAPABILITIES,
|
||||
tools: ["goal_report", "mosaic_gate_evidence_observe", "mosaic_gate_target_read", "mosaic_gate_verdict", "mosaic_policy_status"],
|
||||
extensions: ["goal", "gate-merge", "mosaic-core"],
|
||||
readRoots: [],
|
||||
credentials: { seatStore: "none", helper: "none", providerTokens: "denied", brokerChannel: "launcher-inherited-attested-fd" },
|
||||
evidence: { localJournal: true, journalEvents: ["reconciliation", "denial", "gate-observation", "gate-verdict-attempt"], canonicalWriter: "mosaic-gate-broker", external: "gate-evidence-and-verdict-only" },
|
||||
forbiddenTools: GATE_FORBIDDEN,
|
||||
targetPolicy: { kind: "pull-request", hosts: ["git.mosaicstack.dev"], repositories: ["mosaicstack/stack"], pinSource: "assignment" },
|
||||
},
|
||||
"plan-ng": {
|
||||
revision: 4,
|
||||
capabilities: PLAN_REVIEW_CAPABILITIES,
|
||||
tools: ["goal_report", "mosaic_improvement_propose", "mosaic_policy_status", "read"],
|
||||
extensions: ["goal", "mosaic-core"],
|
||||
readRoots: ["@brain"],
|
||||
credentials: { store: "none", helper: "none", providerTokens: "denied" },
|
||||
evidence: { journal: true, journalEvents: ["reconciliation", "denial", "improvement-proposal"], external: "denied" },
|
||||
forbiddenTools: PLAN_REVIEW_FORBIDDEN,
|
||||
},
|
||||
"review-ng": {
|
||||
revision: 4,
|
||||
capabilities: PLAN_REVIEW_CAPABILITIES,
|
||||
tools: ["goal_report", "mosaic_improvement_propose", "mosaic_policy_status", "read"],
|
||||
extensions: ["goal", "mosaic-core"],
|
||||
readRoots: ["@brain"],
|
||||
credentials: { store: "none", helper: "none", providerTokens: "denied" },
|
||||
evidence: { journal: true, journalEvents: ["reconciliation", "denial", "improvement-proposal"], external: "denied" },
|
||||
forbiddenTools: PLAN_REVIEW_FORBIDDEN,
|
||||
},
|
||||
};
|
||||
|
||||
function exactObject(value: unknown, expected: Record<string, unknown>): boolean {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const expectedKeys = Object.keys(expected).sort();
|
||||
if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) return false;
|
||||
return expectedKeys.every((key) => exactValue(obj[key], expected[key]));
|
||||
}
|
||||
|
||||
function exactValue(actual: unknown, expected: unknown): boolean {
|
||||
if (Array.isArray(expected)) {
|
||||
return Array.isArray(actual) && actual.length === expected.length && actual.every((entry, index) => exactValue(entry, expected[index]));
|
||||
}
|
||||
if (typeof expected === "object" && expected !== null) return exactObject(actual, expected as Record<string, unknown>);
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
function sameStringSet(value: unknown, expected: readonly string[]): boolean {
|
||||
return Array.isArray(value)
|
||||
&& value.every((entry) => typeof entry === "string")
|
||||
&& new Set(value).size === value.length
|
||||
&& value.length === expected.length
|
||||
&& [...value].sort().every((entry, index) => entry === [...expected].sort()[index]);
|
||||
}
|
||||
|
||||
function exactCapabilitySet(value: unknown, expected: V4RoleSpec["capabilities"]): boolean {
|
||||
if (!Array.isArray(value) || value.length !== expected.length) return false;
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
for (const raw of value) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return false;
|
||||
const cap = raw as Record<string, unknown>;
|
||||
if (typeof cap.id !== "string" || byId.has(cap.id)) return false;
|
||||
byId.set(cap.id, cap);
|
||||
}
|
||||
return expected.every((definition) => {
|
||||
const cap = byId.get(definition.id);
|
||||
if (!cap) return false;
|
||||
const expectedKeys = definition.status === "bound" ? ["id", "effect", "binding", "status"] : ["id", "effect", "status"];
|
||||
if (Object.keys(cap).length !== expectedKeys.length || Object.keys(cap).some((key) => !expectedKeys.includes(key))) return false;
|
||||
const normative = NORMATIVE_BINDINGS[definition.id];
|
||||
return !!normative
|
||||
&& definition.effect === cap.effect
|
||||
&& definition.status === cap.status
|
||||
&& cap.effect === (definition.effect as string)
|
||||
&& cap.status === definition.status
|
||||
&& (definition.status === "unbound" || ("binding" in definition && cap.binding === definition.binding && normative.status === "bound" && normative.binding === definition.binding));
|
||||
});
|
||||
}
|
||||
|
||||
function validateV4Manifest(obj: Record<string, unknown>): PolicyResult {
|
||||
const role = obj.role;
|
||||
if (role !== "gate-merge-ng" && role !== "plan-ng" && role !== "review-ng") {
|
||||
return { ok: false, reason: "invalid-role-class", detail: "schema version 4 supports only gate-merge-ng, plan-ng, and review-ng" };
|
||||
}
|
||||
const spec = V4_SPECS[role];
|
||||
const rootFields = [
|
||||
"schemaVersion", "role", "revision", "capabilities", "tools", "extensions", "workspace", "credentials", "evidence",
|
||||
"digestRules", "forbiddenTools", "shell", "goalReportPolicy", ...(spec.targetPolicy ? ["targetPolicy"] : []),
|
||||
];
|
||||
if (Object.keys(obj).length !== rootFields.length || Object.keys(obj).some((key) => !rootFields.includes(key))) {
|
||||
return { ok: false, reason: "unknown-field", detail: "schema version 4 manifest fields must be exact" };
|
||||
}
|
||||
if (obj.schemaVersion !== 4) return { ok: false, reason: "invalid-schema-version", detail: "schemaVersion must be 4" };
|
||||
if (obj.revision !== spec.revision) return { ok: false, reason: "invalid-revision", detail: `${role} must have revision ${spec.revision}` };
|
||||
if (!exactCapabilitySet(obj.capabilities, spec.capabilities)) return { ok: false, reason: "version4-exact-set-mismatch", detail: "capabilities must be the exact reviewed role-class set" };
|
||||
if (!sameStringSet(obj.tools, spec.tools)) return { ok: false, reason: "tools-bindings-mismatch", detail: "tools must be the exact reviewed bound capability projection" };
|
||||
if (!exactObject(obj.extensions, { load: spec.extensions })) return { ok: false, reason: "invalid-extensions", detail: "extensions.load must be the exact reviewed order" };
|
||||
if (!exactObject(obj.workspace, { readRoots: spec.readRoots })) return { ok: false, reason: "invalid-workspace", detail: "workspace.readRoots must be exact for the role class" };
|
||||
if (!exactObject(obj.credentials, spec.credentials)) return { ok: false, reason: "invalid-credentials", detail: "credentials policy must be exact" };
|
||||
if (!exactObject(obj.evidence, spec.evidence)) return { ok: false, reason: "invalid-evidence", detail: "evidence policy must be exact" };
|
||||
if (!exactObject(obj.digestRules, V4_COMMON_POLICY)) return { ok: false, reason: "invalid-digest-rules", detail: "digest rules must be exact" };
|
||||
if (!sameStringSet(obj.forbiddenTools, spec.forbiddenTools)) return { ok: false, reason: "invalid-forbidden-tools", detail: "forbidden tools must be the exact reviewed set" };
|
||||
if (!exactObject(obj.shell, { mode: "denied" })) return { ok: false, reason: "invalid-shell", detail: "shell.mode must be denied" };
|
||||
if (!exactObject(obj.goalReportPolicy, GOAL_POLICY_VALUES)) return { ok: false, reason: "invalid-goal-report-policy", detail: "goalReportPolicy must pin the reviewed contract exactly" };
|
||||
if (spec.targetPolicy) {
|
||||
if (!exactObject(obj.targetPolicy, spec.targetPolicy)) return { ok: false, reason: "invalid-target-policy", detail: "targetPolicy must pin the reviewed assignment scope" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
manifest: {
|
||||
schemaVersion: 4,
|
||||
role,
|
||||
revision: spec.revision,
|
||||
capabilities: (obj.capabilities as CapabilityBinding[]).map((capability) => ({ ...capability })),
|
||||
tools: [...obj.tools as string[]],
|
||||
extensions: { load: [...(obj.extensions as { load: string[] }).load] },
|
||||
workspace: { readRoots: [...(obj.workspace as { readRoots: string[] }).readRoots] },
|
||||
credentials: { ...(obj.credentials as Record<string, unknown>) },
|
||||
evidence: { ...(obj.evidence as Record<string, unknown>) },
|
||||
digestRules: { ...V4_COMMON_POLICY },
|
||||
forbiddenTools: [...obj.forbiddenTools as string[]],
|
||||
shell: { mode: "denied" },
|
||||
goalReportPolicy: { ...GOAL_POLICY_VALUES },
|
||||
...(spec.targetPolicy ? { targetPolicy: { ...(spec.targetPolicy as { kind: "pull-request"; hosts: string[]; repositories: string[]; pinSource: "assignment" }) } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// lib/proposal.ts — structured improvement-proposal sanitization (PRD R7, AC7).
|
||||
//
|
||||
// An improvement proposal is RECORDED, never applied: the only consumer is
|
||||
// the append-only journal. The sanitizer is the boundary that makes "record
|
||||
// structured proposals" safe to expose to the model — strict shape, bounded
|
||||
// text, normalized control characters, unknown fields rejected (same
|
||||
// strictness doctrine as the manifest validator: tolerating extra fields
|
||||
// means being unable to catch drift).
|
||||
|
||||
export interface SanitizedProposal {
|
||||
target: string; // what the proposal concerns: role slug, tool name, or policy field
|
||||
summary: string; // <= 500 chars, single line
|
||||
motivation: string; // <= 2000 chars, newlines normalized to spaces
|
||||
evidence?: string[]; // 0..8 items, each <= 500 chars
|
||||
}
|
||||
|
||||
export type ProposalRejection =
|
||||
| "not-an-object"
|
||||
| "unknown-field"
|
||||
| "missing-field"
|
||||
| "invalid-target"
|
||||
| "oversized"
|
||||
| "invalid-evidence";
|
||||
|
||||
export interface ProposalResult {
|
||||
ok: boolean;
|
||||
proposal?: SanitizedProposal;
|
||||
reason?: ProposalRejection;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const FIELDS = new Set(["target", "summary", "motivation", "evidence"]);
|
||||
const SLUG_RE = /^[a-z][a-z0-9_.:-]*$/i;
|
||||
const LIMITS = { summary: 500, motivation: 2000, evidenceItems: 8, evidenceItem: 500 };
|
||||
|
||||
function normalize(s: unknown): string | null {
|
||||
if (typeof s !== "string") return null;
|
||||
// collapse all line-breaking and control characters to single spaces so a
|
||||
// proposal can never smuggle structure past the JSONL line boundary
|
||||
return s.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function sanitizeProposal(input: unknown): ProposalResult {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
return { ok: false, reason: "not-an-object" };
|
||||
}
|
||||
const obj = input as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!FIELDS.has(key)) return { ok: false, reason: "unknown-field", detail: `unexpected field "${key}"` };
|
||||
}
|
||||
for (const field of ["target", "summary", "motivation"]) {
|
||||
if (!(field in obj)) return { ok: false, reason: "missing-field", detail: `missing field "${field}"` };
|
||||
}
|
||||
|
||||
const target = normalize(obj.target);
|
||||
if (target === null || target === "" || !SLUG_RE.test(target) || target.length > 100) {
|
||||
return { ok: false, reason: "invalid-target" };
|
||||
}
|
||||
|
||||
const summary = normalize(obj.summary);
|
||||
if (summary === null || summary === "") return { ok: false, reason: "invalid-target", detail: "summary empty" };
|
||||
if (summary.length > LIMITS.summary) return { ok: false, reason: "oversized", detail: "summary" };
|
||||
|
||||
const motivation = normalize(obj.motivation);
|
||||
if (motivation === null || motivation === "") {
|
||||
return { ok: false, reason: "invalid-target", detail: "motivation empty" };
|
||||
}
|
||||
if (motivation.length > LIMITS.motivation) return { ok: false, reason: "oversized", detail: "motivation" };
|
||||
|
||||
let evidence: string[] | undefined;
|
||||
if ("evidence" in obj) {
|
||||
const raw = obj.evidence;
|
||||
if (!Array.isArray(raw) || raw.length > LIMITS.evidenceItems) {
|
||||
return { ok: false, reason: "invalid-evidence", detail: "evidence must be an array of at most 8 items" };
|
||||
}
|
||||
const items: string[] = [];
|
||||
for (const item of raw) {
|
||||
const n = normalize(item);
|
||||
if (n === null || n === "") return { ok: false, reason: "invalid-evidence" };
|
||||
if (n.length > LIMITS.evidenceItem) return { ok: false, reason: "oversized", detail: "evidence item" };
|
||||
items.push(n);
|
||||
}
|
||||
evidence = items;
|
||||
}
|
||||
|
||||
return { ok: true, proposal: { target, summary, motivation, evidence } };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// lib/reconcile.ts — pure exact active-tool reconciliation (PRD R3 core, AC2).
|
||||
//
|
||||
// The reconciliation output is the COMPLETE target state: applying it makes
|
||||
// the active tool set EXACTLY the manifest's set — no more (an injected
|
||||
// third-party tool is removed), no less (a manifest tool missing from the
|
||||
// active set is listed for addition). NG-2 binds this to setActiveTools();
|
||||
// this module never touches Pi state.
|
||||
|
||||
export interface ReconcilePlan {
|
||||
/** the exact target active set (sorted manifest tools) */
|
||||
exact: string[];
|
||||
/** active tools absent from the manifest — must be removed */
|
||||
toRemove: string[];
|
||||
/** manifest tools absent from the active set — must be added */
|
||||
toAdd: string[];
|
||||
/** true when the active set already equals the manifest set */
|
||||
unchanged: boolean;
|
||||
}
|
||||
|
||||
export function reconcileActiveTools(manifestTools: string[], activeTools: string[]): ReconcilePlan {
|
||||
const target = [...new Set(manifestTools)].sort();
|
||||
const active = [...new Set(activeTools)].sort();
|
||||
const inManifest = new Set(target);
|
||||
const inActive = new Set(active);
|
||||
const toRemove = active.filter((t) => !inManifest.has(t));
|
||||
const toAdd = target.filter((t) => !inActive.has(t));
|
||||
return { exact: target, toRemove, toAdd, unchanged: toRemove.length === 0 && toAdd.length === 0 };
|
||||
}
|
||||
Reference in New Issue
Block a user