/* * `rotate` — handover-to-self across a context clear (Phase 5 Task 5). * * THE PACKET IS A SNAPSHOT, AND A CONTEXT RESET IS PRECISELY WHEN NOBODY CAN * CHECK IT. After `/clear` the agent has no memory to contradict the packet * with: whatever it says becomes the world. So the packet is written from * TOOLS AND `gh`, never from chat memory — memory is the one source that * cannot be re-derived after the reset it is meant to survive — and on the * far side it is RECONCILED against live state before any work is done. * * A packet that is merely READ on resume is a stale world restored with * confidence. That is the failure this verb exists to prevent, so `missionHint` * is named a HINT in the type and treated as one in code: it is the only field * that cannot be verified, and it never gets to assert anything. */ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { z } from "zod"; import { ROOT } from "../store.js"; /* * 5.4 — JOB IDS ARE AN ALLOWLIST. * * `archive-done` is deliberately ABSENT and stays absent: it is an arithmetic * job over DONE.md that belongs to Groundwork and an existing QUEUE item, and * a rotation is the worst possible moment to run one. Rotation exists to carry * state ACROSS a reset intact; a verb that also rewrites the records it is * carrying cannot be checked afterwards by the agent that ran it. */ export const ROTATE_JOBS = ["reseed-only", "phase-boundary"] as const; export type RotateJob = (typeof ROTATE_JOBS)[number]; export type OpenPr = { n: number; headRefOid: string }; export type RotatePacket = { agentId: string; job: RotateJob; rooms: string[]; name: string; /** A HINT, never an assertion — the one field nothing can verify. */ missionHint: string; atSha: string; openPrs: OpenPr[]; at: string; }; const packetFile = (agentId: string) => path.join(ROOT, "rotate", `${agentId}.json`); export type RepoFacts = { dirty: string; sha: string; openPrs: OpenPr[] }; const realFacts = (repo: string): RepoFacts => { const git = (args: string[]) => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); const out = execFileSync("gh", ["pr", "list", "--state", "open", "--json", "number,headRefOid"], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); return { dirty: git(["status", "--porcelain"]), sha: git(["rev-parse", "HEAD"]), openPrs: (JSON.parse(out) as { number: number; headRefOid: string }[]).map((p) => ({ n: p.number, headRefOid: p.headRefOid })), }; }; export const rotateSchema = { agentId: z.string().min(1), job: z.string().min(1), repo: z.string().optional(), rooms: z.array(z.string()).optional(), missionHint: z.string().optional(), write: z.boolean().optional(), }; export async function rotateTool( args: { agentId: string; job: string; repo?: string; rooms?: string[]; missionHint?: string; write?: boolean }, facts: (repo: string) => RepoFacts = realFacts, ) { const repo = args.repo ?? process.cwd(); // 5.4 — unknown job refused BY NAME, and `archive-done` gets its own reason // so the refusal reads as a decision rather than a typo. if (!(ROTATE_JOBS as readonly string[]).includes(args.job)) { const extra = args.job === "archive-done" ? ` 'archive-done' is deliberately not a rotation job: it rewrites the records the rotation is carrying, and a reset is the one moment nobody can check the result. It belongs to Groundwork and its existing QUEUE item.` : ""; return { ok: false as const, error: `'${args.job}' is not a rotate job. Allowed: ${ROTATE_JOBS.join(", ")}.${extra}` }; } let f: RepoFacts; try { f = facts(repo); } catch (e) { return { ok: false as const, error: `could not read live state (${String((e as Error).message).split("\n")[0]}) — a packet built from anything but live state is chat memory with a filename.` }; } // 5.2 — MID-SLICE DIRTY REFUSES THE CLEAR. // // Uncommitted work is the one thing a packet cannot carry: it is not in the // repo, not on a branch, and not in any tool's answer, so after `/clear` no // reconciliation can discover it ever existed. It does not get lost loudly — // it gets lost silently, which is why this is a refusal and not a warning. if (f.dirty) { const n = f.dirty.split("\n").filter(Boolean).length; return { ok: false as const, error: `${n} uncommitted change(s) — refusing to rotate mid-slice. Uncommitted work is the one thing a packet cannot carry: after the clear, nothing can discover it existed. Commit it or stash it deliberately, then rotate.`, dirty: f.dirty.split("\n").filter(Boolean), }; } const packet: RotatePacket = { agentId: args.agentId, job: args.job as RotateJob, rooms: args.rooms ?? [], name: args.agentId, missionHint: args.missionHint ?? "", atSha: f.sha, openPrs: f.openPrs, at: new Date().toISOString(), }; if (!args.write) return { ok: true as const, written: false as const, packet, note: `packet built from live state; pass write:true to persist it before /clear.` }; mkdirSync(path.dirname(packetFile(args.agentId)), { recursive: true }); writeFileSync(packetFile(args.agentId), `${JSON.stringify(packet, null, 2)}\n`); return { ok: true as const, written: true as const, packet, path: packetFile(args.agentId) }; } /* ── 5.3 / 5.5 — reconcile before working, or refuse to work ───────────────── */ export type Divergence = { field: string; packet: string; live: string; note: string }; export const rotateReconcileSchema = { agentId: z.string().min(1), repo: z.string().optional() }; export async function rotateReconcileTool( args: { agentId: string; repo?: string }, facts: (repo: string) => RepoFacts = realFacts, ) { const repo = args.repo ?? process.cwd(); const f0 = packetFile(args.agentId); if (!existsSync(f0)) return { ok: false as const, error: `no rotate packet for '${args.agentId}'. A reseeded agent with no packet has nothing to reconcile against and must not infer its state — ask for a GO.` }; let packet: RotatePacket; try { packet = JSON.parse(readFileSync(f0, "utf8")) as RotatePacket; } catch (e) { return { ok: false as const, error: `packet for '${args.agentId}' is unreadable (${String((e as Error).message).split("\n")[0]}) — an unparseable packet is not an empty one; do not proceed as if there were no prior state.` }; } let f: RepoFacts; try { f = facts(repo); } catch (e) { // NOT RECONCILED IS NOT RECONCILED-CLEAN. return { ok: false as const, error: `could not read live state to reconcile (${String((e as Error).message).split("\n")[0]}) — NOT checked, which is not the same as checked and matching.`, packet }; } const live = new Map(f.openPrs.map((p) => [p.n, p.headRefOid])); const divergences: Divergence[] = []; // 5.5 — THE PACKET'S OPEN PRs ARE CLAIMS, NOT FACTS. // // A PR that merged during the reset is the dangerous direction: the packet // says "open", the agent resumes and keeps working a branch that is already // in main, and every one of its next steps is coherent and wrong. for (const p of packet.openPrs ?? []) { if (!live.has(p.n)) divergences.push({ field: `pr#${p.n}`, packet: "open", live: "not open", note: `#${p.n} is no longer open — it merged or closed during the reset. Do NOT resume work on it as open.` }); else if (live.get(p.n) !== p.headRefOid) divergences.push({ field: `pr#${p.n}`, packet: p.headRefOid, live: String(live.get(p.n)), note: `#${p.n} advanced during the reset — the packet's head is stale; re-read before acting.` }); } if (packet.atSha && f.sha !== packet.atSha) divergences.push({ field: "atSha", packet: packet.atSha, live: f.sha, note: `the repo moved during the reset; anything the packet said about the tree may be stale.` }); // The verdict names its POPULATION, and `missionHint` is excluded from it on // purpose: it is unverifiable, so counting it as "reconciled" would be a // clean report over something never checked. const verdict = { reconciled: (packet.openPrs?.length ?? 0) + (packet.atSha ? 1 : 0), divergences, unverifiable: ["missionHint"], missionHint: packet.missionHint, }; if (divergences.length) return { ok: false as const, error: `${divergences.length} divergence(s) between packet and live state — REFUSING to report ready. A packet read without reconciliation is a stale world restored with confidence.`, verdict, packet }; return { ok: true as const, ready: true as const, verdict, packet, note: `packet matches live state. 'missionHint' is a HINT and was NOT verified — treat it as a prompt, never as an instruction you have confirmed.` }; }