mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-17 12:40:07 +08:00
Fix goal state recovery, approval invalidation and worker tracking
Unify Log boundaries, serialize completion edits, preserve immutable session state and solo recovery, and match stock worker execution events. Remove unused plan views and brittle prose checks; correct installation and context guidance. Co-Authored-By: Pi/OpenAI <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
@@ -128,14 +128,19 @@ Requires Herdr. Includes [edxeth/pi-subagents](https://github.com/edxeth/pi-suba
|
||||
pi install git:github.com/wassname/pi-goals
|
||||
```
|
||||
|
||||
Copy [`agents/goals-worker.md`](agents/goals-worker.md) into `~/.pi/agent/agents/`, then start a fresh Pi session.
|
||||
Copy [`agents/goals-worker.md`](agents/goals-worker.md) into `~/.pi/agent/agents/`, then start a fresh Pi session. Use one pi-goals installation and disable separately installed copies of its bundled companions; duplicate scheduler instances send duplicate prompts.
|
||||
|
||||
Or for development:
|
||||
The bundled pi-schedule-prompt 0.4.1 reads project schedules even when Pi project trust is declined. Until that upstream issue is fixed, use this bundle only in repositories you trust.
|
||||
|
||||
For development, register the checkout so workers also discover its extensions:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/wassname/pi-goals
|
||||
cd pi-goals && npm install
|
||||
pi -e .
|
||||
pi install .
|
||||
mkdir -p ~/.pi/agent/agents
|
||||
cp agents/goals-worker.md ~/.pi/agent/agents/
|
||||
pi
|
||||
```
|
||||
|
||||
## Use
|
||||
@@ -148,7 +153,7 @@ pi -e .
|
||||
|
||||
## Context delivery
|
||||
|
||||
Startup and successful compaction mark the plan for a fresh read at the next ordinary prompt (`before_agent_start`). Upkeep becomes due after each eight unchanged turns, but waits for that same prompt boundary. Supervisor upkeep cycles through curated nudges, advancing only when delivered; the editable hourly prompt is unchanged. A full plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule another refresh or consume pending upkeep. Missing plans are retried without discarding progress.
|
||||
Startup and successful compaction refresh the current plan above the Log at the next ordinary prompt (`before_agent_start`); history stays in the file for reading when needed. A Log heading at any Markdown heading level starts history. Other headings, such as Interview, do not end the current plan. Compaction uses Pi's configured threshold; this plugin does not set a separate 150k limit. Upkeep becomes due after each eight unchanged turns, but waits for that same prompt boundary. Supervisor upkeep cycles through curated nudges, advancing only when delivered; the editable hourly prompt is unchanged. A full plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule another refresh or consume pending upkeep. Missing plans are retried without discarding progress.
|
||||
|
||||
This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. They do **not** receive a newly read plan until ordinary prompt preparation. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed. Quit sends no model message.
|
||||
|
||||
@@ -159,7 +164,7 @@ You can read all the prompts in conversation order in [`src/prompts.ts`](src/pro
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
pi -e . # load locally; do not also load the installed copy
|
||||
pi # use the registered checkout above; do not add a duplicate -e
|
||||
npm test # all unit, flow, and Pi RPC tests
|
||||
npm run test:rpc # Pi RPC review flow with a local offline model
|
||||
npm run typecheck
|
||||
|
||||
+70
-49
@@ -2,10 +2,10 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, type FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { type ExtensionAPI, type ExtensionContext, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
||||
import { CronStorage } from "pi-schedule-prompt/src/storage.js";
|
||||
import { Type } from "typebox";
|
||||
import { foldPlan, GOAL_LINE } from "./plan.js";
|
||||
import { FOLD_LINE, foldPlan, GOAL_LINE, goalAcceptanceSignature } from "./plan.js";
|
||||
import { planViews } from "./plan-view.js";
|
||||
import {
|
||||
attachGoalPlanDescription,
|
||||
@@ -49,7 +49,8 @@ interface State {
|
||||
worker?: { id?: string; sessionFile: string };
|
||||
helpers: { id?: string; sessionFile: string }[];
|
||||
workerStopped?: boolean;
|
||||
signoffs: Record<string, { evidence: string[]; observation: string }>;
|
||||
pausedFrom?: "solo" | "supervising";
|
||||
signoffs: Record<string, { evidence: string[]; observation: string; signature: string }>;
|
||||
child?: boolean;
|
||||
}
|
||||
const initial = (): State => ({ mode: "chat", helpers: [], signoffs: {} });
|
||||
@@ -69,13 +70,13 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
let state = initial();
|
||||
let generation = 0;
|
||||
let workerRevision = 0;
|
||||
let pendingLaunches = 0;
|
||||
const pendingLaunches = new Map<string, { plan: string; generation: number; launches: { agent?: string; sessionFile?: string }[] }>();
|
||||
let notice = true;
|
||||
let planWatcher: FSWatcher | undefined;
|
||||
let planEditTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let planHash = "";
|
||||
const childEnvironment = process.env.PI_SUBAGENT_AGENT === WORKER;
|
||||
const save = () => pi.appendEntry(STATE, state);
|
||||
const save = () => pi.appendEntry(STATE, structuredClone(state));
|
||||
// Missing, empty and failed reads are unavailable snapshots, never an empty authoritative plan.
|
||||
const readPlan = () => {
|
||||
try {
|
||||
@@ -111,10 +112,10 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
const items = goals(snapshot.text);
|
||||
// Reopened/deleted/ambiguous goal identities lose their sign-off. Manual ticks remain claims.
|
||||
// Pi/OpenAI: approval belongs to the reviewed requirements, not only the title.
|
||||
for (const subject of Object.keys(state.signoffs)) {
|
||||
const matches = items.filter((g) => key(g.subject) === subject);
|
||||
if (matches.length !== 1 || matches[0].status !== "done") { delete state.signoffs[subject]; save(); }
|
||||
if (matches.length !== 1 || matches[0].status !== "done" || state.signoffs[subject].signature !== goalAcceptanceSignature(snapshot.text, subject)) { delete state.signoffs[subject]; save(); }
|
||||
}
|
||||
const accepted = items.filter((g) => g.status === "done" && state.signoffs[key(g.subject)]).length;
|
||||
ctx.ui.setStatus("goals", `goals: ${state.child ? "worker" : state.mode} | ${accepted}/${items.length} reviewed`);
|
||||
@@ -189,7 +190,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "nextTurn" });
|
||||
}
|
||||
async function confirmOwnership(ctx: ExtensionContext, target: string, text: string, solo = true): Promise<boolean> {
|
||||
if (pendingLaunches > 0) { ctx.ui.notify("A worker launch/resume is still pending; inspect its result before takeover.", "warning"); return false; }
|
||||
if (pendingLaunches.size > 0) { ctx.ui.notify("A worker launch/resume is still pending; inspect its result before takeover.", "warning"); return false; }
|
||||
const stamp = generation;
|
||||
const revision = workerRevision;
|
||||
const confirmation = solo ? "Worker confirmed stopped" : "Previous supervisor confirmed stopped";
|
||||
@@ -209,7 +210,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (state.mode !== "planning") { ctx.ui.notify("Ready applies to a draft; use status or resume.", "warning"); return; }
|
||||
const text = planText();
|
||||
const items = goals(text);
|
||||
if (!edit && (!items.length || new Set(items.map((g) => key(g.subject))).size !== items.length)) {
|
||||
if (!edit && (!items.length || items.some(g => !g.subject) || new Set(items.map((g) => key(g.subject))).size !== items.length)) {
|
||||
ctx.ui.notify("Write a plan with distinct '- [ ] goal: ...' subjects before Ready.", "warning"); return;
|
||||
}
|
||||
const stamp = generation;
|
||||
@@ -297,23 +298,32 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (launch && typeof launch.title === "string" && !launch.title.startsWith(prefix)) launch.title = prefix + launch.title;
|
||||
}
|
||||
}
|
||||
if (state.child || !["subagent", "subagent_resume"].includes(event.toolName)) return;
|
||||
if (state.child || (event.toolName !== "subagent" && event.toolName !== "subagent_resume")) return;
|
||||
// Solo means this chat took over implementation: no concurrent writer may be delegated.
|
||||
if (state.mode === "planning" || state.mode === "paused" || state.mode === "solo") return { block: true, reason: goalToolBlocked(state.mode) };
|
||||
if (state.plan) { pendingLaunches++; state.workerStopped = false; workerRevision++; save(); }
|
||||
if (state.plan) {
|
||||
const input = event.input as { agent?: string; sessionFile?: string; children?: { agent?: string; sessionFile?: string }[] };
|
||||
const launches = input.children ?? [input];
|
||||
pendingLaunches.set(event.toolCallId, { plan: state.plan, generation, launches: launches.map(launch => ({ agent: launch.agent, sessionFile: launch.sessionFile })) });
|
||||
state.workerStopped = false; workerRevision++; save();
|
||||
}
|
||||
});
|
||||
pi.on("tool_result", (event) => {
|
||||
if (state.child || !state.plan || !["subagent", "subagent_resume"].includes(event.toolName)) return;
|
||||
pendingLaunches = Math.max(0, pendingLaunches - 1);
|
||||
if (event.isError) return;
|
||||
const details = event.details as { id?: string; sessionFile?: string } | undefined;
|
||||
if (!details?.id || !details.sessionFile) return;
|
||||
const record = { id: details.id, sessionFile: details.sessionFile };
|
||||
if (state.worker?.sessionFile === record.sessionFile) state.worker = record;
|
||||
else if (!state.worker) state.worker = record;
|
||||
// Extra launches stay recorded as helpers; the implementation binding never moves silently.
|
||||
else state.helpers = [...(state.helpers ?? []).filter((h) => h.sessionFile !== record.sessionFile), record];
|
||||
state.workerStopped = false; workerRevision++; save();
|
||||
pi.on("tool_execution_end", (event) => {
|
||||
const pending = pendingLaunches.get(event.toolCallId);
|
||||
pendingLaunches.delete(event.toolCallId);
|
||||
if (!pending || state.child || pending.plan !== state.plan || pending.generation !== generation || event.isError) return;
|
||||
type ChildResult = { id?: string; sessionFile?: string; agent?: string };
|
||||
const details = (event.result as { details?: ChildResult & { children?: ChildResult[] } }).details;
|
||||
if (!details) return;
|
||||
for (const [index, child] of (details.children ?? [details]).entries()) {
|
||||
if (!child.id || !child.sessionFile) continue;
|
||||
const record = { id: child.id, sessionFile: child.sessionFile };
|
||||
const launch = pending.launches[index];
|
||||
const implementation = (child.agent ?? launch?.agent) === WORKER || launch?.sessionFile === state.worker?.sessionFile && Boolean(state.worker);
|
||||
if (state.worker?.sessionFile === record.sessionFile || !state.worker && implementation) state.worker = record;
|
||||
else state.helpers = [...state.helpers.filter(h => h.sessionFile !== record.sessionFile), record];
|
||||
}
|
||||
workerRevision++; save();
|
||||
});
|
||||
|
||||
pi.registerCommand("goals", {
|
||||
@@ -391,7 +401,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
const target = isAbsolute(raw) ? raw : resolve(ctx.cwd, raw);
|
||||
let text: string;
|
||||
try { text = readFileSync(target, "utf8"); } catch { ctx.ui.notify(`Cannot read plan at ${target}.`, "error"); return; }
|
||||
if (!goals(text).length) { ctx.ui.notify(`${target} has no '- [ ] goal:' lines; attach a judgeable plan.`, "warning"); return; }
|
||||
if (!goals(text).length || goals(text).some(g => !g.subject)) { ctx.ui.notify(`${target} has no '- [ ] goal:' lines with valid subjects; attach a judgeable plan.`, "warning"); return; }
|
||||
if (!solo && ((state.worker && !state.workerStopped) || state.mode === "supervising")) { ctx.ui.notify("Exit and resolve the existing worker before replacing the plan. The current plan is preserved.", "warning"); return; }
|
||||
const noted = /^-\s*worker session:\s*(\S+)/im.exec(foldPlan(text))?.[1];
|
||||
if (!(await confirmOwnership(ctx, target, text, solo))) return;
|
||||
@@ -412,13 +422,15 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
storage.removeJob(job.id); // Scheduler re-reads storage before firing; removed jobs cannot prompt.
|
||||
pi.events.emit("cron:change", { type: "remove", jobId: job.id });
|
||||
}
|
||||
state = initial(); generation++; workerRevision++; pendingLaunches = 0; pendingUpkeep = undefined; notice = true;
|
||||
state = initial(); generation++; workerRevision++; pendingLaunches.clear(); pendingUpkeep = undefined; notice = true;
|
||||
save(); refresh(ctx); watchPlan(ctx);
|
||||
ctx.ui.notify(`Goals cleared.${backup ? ` Plan backed up to ${backup}.` : ""}`, "info");
|
||||
return;
|
||||
}
|
||||
if (command === "stop") {
|
||||
if (state.mode === "planning") { ctx.ui.notify("A draft cannot pause; use /goals quit to back up and clear it.", "warning"); return; }
|
||||
if (state.mode !== "solo" && state.mode !== "supervising") return;
|
||||
state.pausedFrom = state.mode;
|
||||
state.mode = "paused"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
const pause = pauseExitNotice(state.worker, false);
|
||||
const requestCleanup = Boolean(state.worker) || hasScheduleTool();
|
||||
@@ -428,13 +440,14 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
}
|
||||
if (command === "resume") {
|
||||
if (state.mode !== "paused" || !state.plan) { ctx.ui.notify("Only a paused approved plan can resume. A draft needs Ready.", "warning"); return; }
|
||||
if (state.pausedFrom === "solo") { enterSolo(ctx); return; }
|
||||
if (!compatible()) { ctx.ui.notify("edxeth tools unavailable; plan remains paused.", "error"); return; }
|
||||
state.mode = "supervising"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
send(`${checkIn(ctx)}\n\n${resumeNotice(WORKER, state.plan, state.worker)}`);
|
||||
return;
|
||||
}
|
||||
if (command === "solo") {
|
||||
if (!state.plan || !goals(planText()).length) { ctx.ui.notify("Register a goal plan first.", "warning"); return; }
|
||||
if (!state.plan || !goals(planText()).length || goals(planText()).some(g => !g.subject)) { ctx.ui.notify("Register a goal plan first.", "warning"); return; }
|
||||
if (!(await confirmOwnership(ctx, state.plan, planText()))) return;
|
||||
enterSolo(ctx);
|
||||
return;
|
||||
@@ -460,7 +473,9 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
async execute(_id, params, _signal, _update, ctx) {
|
||||
if (!state.child) return result(messages.childAttachOnly);
|
||||
try {
|
||||
if (!isAbsolute(params.path) || !goals(readFileSync(params.path, "utf8")).length) return result(messages.invalidAttachment);
|
||||
if (!isAbsolute(params.path)) return result(messages.invalidAttachment);
|
||||
const items = goals(readFileSync(params.path, "utf8"));
|
||||
if (!items.length || items.some(g => !g.subject)) return result(messages.invalidAttachment);
|
||||
} catch { return result(messages.invalidAttachment); }
|
||||
state.plan = params.path; generation++; notice = true; save(); refresh(ctx);
|
||||
return result(childPlanAttached(params.path));
|
||||
@@ -469,29 +484,35 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "CompleteGoal", label: "Review goal evidence",
|
||||
description: completeGoalDescription,
|
||||
parameters: Type.Object({ goal: Type.String(), evidence: Type.Array(Type.String(), { minItems: 1 }), observation: Type.String({ minLength: 1 }) }),
|
||||
parameters: Type.Object({ goal: Type.String({ minLength: 1 }), evidence: Type.Array(Type.String(), { minItems: 1 }), observation: Type.String({ minLength: 1 }) }),
|
||||
async execute(_id, params, signal, _update, ctx) {
|
||||
if (state.child || !["supervising", "solo"].includes(state.mode)) return result(messages.completionUnavailable);
|
||||
if (signal?.aborted) return result(messages.cancelled);
|
||||
const snapshot = readPlan();
|
||||
if (snapshot.text === undefined) return result(snapshot.error!);
|
||||
const text = snapshot.text;
|
||||
const matches = goals(text).filter((g) => g.status !== "cancelled" && key(g.subject) === key(params.goal));
|
||||
if (matches.length !== 1 || !state.plan) return result(messages.uniqueGoal);
|
||||
const evidence = params.evidence.map((file) => isAbsolute(file) ? file : resolve(ctx.cwd, file));
|
||||
try { for (const file of evidence) if (!readFileSync(file).length) throw new Error(emptyEvidence(file)); }
|
||||
catch (error) { return result(evidenceUnavailable(error)); }
|
||||
const lines = text.split("\n");
|
||||
lines[matches[0].index] = lines[matches[0].index].replace(/\[[ xX/-]\]/, "[x]");
|
||||
let log = lines.findIndex(line => /^##\s+Log\s*$/i.test(line));
|
||||
if (log === -1) { lines.push("", "## Log"); log = lines.length - 1; }
|
||||
lines.splice(log + 1, 0, "", completionLog(params.goal, params.observation, evidence, state.mode === "solo"));
|
||||
writeFileSync(state.plan, `${lines.join("\n").trimEnd()}\n`);
|
||||
state.signoffs[key(matches[0].subject)] = { evidence, observation: params.observation };
|
||||
planHash = digest(planViews(planText()).notify);
|
||||
save(); refresh(ctx);
|
||||
const remaining = goals(planText()).some((goal) => goal.status !== "cancelled" && (goal.status !== "done" || !state.signoffs[key(goal.subject)]));
|
||||
return result(completionResult(matches[0].subject, ctx.sessionManager.getSessionId(), remaining, state.mode === "solo"));
|
||||
if (state.child || !state.plan || !["supervising", "solo"].includes(state.mode)) return result(messages.completionUnavailable);
|
||||
const path = state.plan;
|
||||
const stamp = generation;
|
||||
return withFileMutationQueue(path, async () => {
|
||||
if (stamp !== generation || path !== state.plan || state.child || !["supervising", "solo"].includes(state.mode)) return result(messages.completionUnavailable);
|
||||
if (signal?.aborted) return result(messages.cancelled);
|
||||
if (!params.goal.trim()) return result(messages.uniqueGoal);
|
||||
const snapshot = readPlan();
|
||||
if (snapshot.text === undefined) return result(snapshot.error!);
|
||||
const text = snapshot.text;
|
||||
const matches = goals(text).filter((g) => g.status !== "cancelled" && key(g.subject) === key(params.goal));
|
||||
if (matches.length !== 1 || !state.plan) return result(messages.uniqueGoal);
|
||||
const evidence = params.evidence.map((file) => isAbsolute(file) ? file : resolve(ctx.cwd, file));
|
||||
try { for (const file of evidence) if (!readFileSync(file).length) throw new Error(emptyEvidence(file)); }
|
||||
catch (error) { return result(evidenceUnavailable(error)); }
|
||||
const lines = text.split("\n");
|
||||
lines[matches[0].index] = lines[matches[0].index].replace(/\[[ xX/-]\]/, "[x]");
|
||||
let log = lines.findIndex(line => FOLD_LINE.test(line));
|
||||
if (log === -1) { lines.push("", "## Log"); log = lines.length - 1; }
|
||||
lines.splice(log + 1, 0, "", completionLog(params.goal, params.observation, evidence, state.mode === "solo"));
|
||||
writeFileSync(path, `${lines.join("\n").trimEnd()}\n`);
|
||||
state.signoffs[key(matches[0].subject)] = { evidence, observation: params.observation, signature: goalAcceptanceSignature(text, matches[0].subject)! };
|
||||
planHash = digest(planViews(planText()).notify);
|
||||
save(); refresh(ctx);
|
||||
const remaining = goals(planText()).some((goal) => goal.status !== "cancelled" && (goal.status !== "done" || !state.signoffs[key(goal.subject)]));
|
||||
return result(completionResult(matches[0].subject, ctx.sessionManager.getSessionId(), remaining, state.mode === "solo"));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+6
-32
@@ -1,33 +1,7 @@
|
||||
// Pi/OpenAI: Preserve plan wording; omit history and, in the short view, task/evidence details.
|
||||
// The notify view governs plan-change events: goals, tasks, evidence and inferences are
|
||||
// content worth a supervisor review; worker identity bookkeeping is not (field report,
|
||||
// LUCID3 supervisor 2026-09-10: two identical review events for a session-path edit).
|
||||
export function planViews(plan: string): { short: string; notify: string; long: string } {
|
||||
const long = plan.split(/^#{1,6}\s+(?:Log|Appendix|Appendices|Appendixes|Interview|Learnings|Papercuts)\b.*$/mi)[0].trim();
|
||||
const identity = /^-\s*(?:active worker|worker session|worker intercom session):/i;
|
||||
const notify = long.split("\n").filter((line) => !identity.test(line)).join("\n").trim();
|
||||
const kept: string[] = [];
|
||||
let omittedIndent: number | null = null;
|
||||
let omittedHeading: number | null = null;
|
||||
for (const line of long.split("\n")) {
|
||||
// Pi/OpenAI: Worker identity bookkeeping is not a change to agreed requirements.
|
||||
if (/^-\s*(?:active worker|worker session|worker intercom session):/i.test(line)) continue;
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(line);
|
||||
if (heading) {
|
||||
if (omittedHeading !== null && heading[1].length <= omittedHeading) omittedHeading = null;
|
||||
if (/^(?:Tasks?|Task list|Subtasks?|Evidence)\b/i.test(heading[2])) omittedHeading = heading[1].length;
|
||||
}
|
||||
if (omittedHeading !== null) continue;
|
||||
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
||||
if (omittedIndent !== null) {
|
||||
if (!line.trim() || indent > omittedIndent) continue;
|
||||
omittedIndent = null;
|
||||
}
|
||||
if (/^\s*[-*]\s+(?:tasks?|subtasks?|evidence):/i.test(line) || /^\s*(?:\d+[.)]|[-*])\s+\[[ x/~-]\]\s+(?!goal:)/i.test(line)) {
|
||||
omittedIndent = indent;
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
return { short: kept.join("\n").trim(), notify, long };
|
||||
// Pi/OpenAI: Review task/evidence changes, but omit history and worker identity bookkeeping.
|
||||
import { foldPlan } from "./plan.js";
|
||||
|
||||
export function planViews(plan: string): { notify: string } {
|
||||
const identity = /^[ \t]*[-*]\s*(?:active worker|worker session|worker intercom session):/i;
|
||||
return { notify: foldPlan(plan).split("\n").filter(line => !identity.test(line)).join("\n").trim() };
|
||||
}
|
||||
|
||||
+40
-2
@@ -1,8 +1,46 @@
|
||||
// Shared plan syntax: only the section above the Log contains current goals.
|
||||
// Pi/OpenAI: Log, at any heading level, is the single boundary between current work and history.
|
||||
export const GOAL_LINE = /^\s*(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*goal:\s*(.*)$/i;
|
||||
export const FOLD_LINE = /^##\s+Log\s*$/im;
|
||||
export const FOLD_LINE = /^#{1,6}[ \t]+Log[ \t]*\r?$/im;
|
||||
const identity = /^[ \t]*[-*]\s*(?:active worker|worker session|worker intercom session|preferred worker model):/i;
|
||||
|
||||
export function foldPlan(plan: string): string {
|
||||
const match = FOLD_LINE.exec(plan);
|
||||
return (match ? plan.slice(0, match.index) : plan).trimEnd();
|
||||
}
|
||||
|
||||
// Pi/OpenAI: Approval covers shared requirements and this goal, not checkbox/task/evidence maintenance.
|
||||
export function goalAcceptanceSignature(plan: string, goal: string): string | undefined {
|
||||
const lines = foldPlan(plan).split("\n");
|
||||
const goals = lines.flatMap((line, index) => {
|
||||
const match = GOAL_LINE.exec(line);
|
||||
return match ? [{ index, subject: match[2].trim().toLowerCase() }] : [];
|
||||
});
|
||||
const matches = goals.filter(item => item.subject === goal.trim().toLowerCase());
|
||||
if (matches.length !== 1) return undefined;
|
||||
const selected = matches[0];
|
||||
const end = goals.find(item => item.index > selected.index)?.index ?? lines.length;
|
||||
const content = [...lines.slice(0, goals[0].index), `goal: ${selected.subject}`, ...lines.slice(selected.index + 1, end)];
|
||||
const kept: string[] = [];
|
||||
let omittedIndent: number | undefined;
|
||||
let omittedHeading: number | undefined;
|
||||
for (const line of content) {
|
||||
if (identity.test(line)) continue;
|
||||
const heading = /^(#{1,6})\s+(.+)$/.exec(line);
|
||||
if (heading) {
|
||||
if (omittedHeading !== undefined && heading[1].length <= omittedHeading) omittedHeading = undefined;
|
||||
if (/^(?:Tasks?|Task list|Subtasks?|Evidence)\b/i.test(heading[2])) omittedHeading = heading[1].length;
|
||||
}
|
||||
if (omittedHeading !== undefined) continue;
|
||||
const indent = line.length - line.trimStart().length;
|
||||
if (omittedIndent !== undefined) {
|
||||
if (!line.trim() || indent > omittedIndent) continue;
|
||||
omittedIndent = undefined;
|
||||
}
|
||||
if (/^\s*[-*]\s+(?:tasks?|subtasks?|evidence):/i.test(line) || /^\s*(?:\d+[.)]|[-*])\s+\[[ xX/~-]\]/.test(line)) {
|
||||
omittedIndent = indent;
|
||||
continue;
|
||||
}
|
||||
if (line.trim()) kept.push(line.trim());
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
+13
-10
@@ -1,10 +1,12 @@
|
||||
// Pi/OpenAI: Planning, approval, supervision, reminders, completion and recovery.
|
||||
import { foldPlan } from "./plan.js";
|
||||
|
||||
export const planDrafting = `\
|
||||
You are in plan mode. Help the user express what they want this project to achieve in a short judgeable plan. Seek to understand their underlying goals, infer ordinary details, and use their applicable AGENTS.md instructions, relevant skills, and project context to interpret the request correctly. Do not silently substitute your own goals or expand the agreed scope.
|
||||
|
||||
1. Reduce technical uncertainty first. Use read-only repository tools or web search when either can
|
||||
resolve a fact. Do not write or run code in this phase (edit/write are blocked except for the plan
|
||||
file; don't mutate state via bash either).
|
||||
resolve a fact. Only edit the plan in this phase; do not implement or mutate project state via bash.
|
||||
This is an instruction, not a filesystem restriction.
|
||||
2. Before you draft a goal, identify its object, observable result, scope, and any decision that the
|
||||
human would need to approve later. Briefly reframe the request in your own words to check comprehension
|
||||
and make your understanding visible: the intended outcome, boundary, and success check. Invite correction,
|
||||
@@ -31,8 +33,8 @@ not replace, defer, or contradict it; ask the human if an inference would change
|
||||
5. When every goal has an object, observable result, settled scope, and required approval, draft the
|
||||
plan file and present it. It should be safe to work overnight and present the requested outcome.
|
||||
|
||||
How this mode ends: after each settled draft the human gets a menu (Ready / Refine / Edit / Cancel).
|
||||
Plan mode ends only when they pick Ready. Refine collects short revision notes. Edit opens the full
|
||||
How this mode ends: after each changed settled draft the human gets a menu (Ready / Discuss / Edit / Cancel).
|
||||
Plan mode ends only when they pick Ready. Discuss continues ordinary chat. Edit opens the full
|
||||
plan. When a new requirement arrives, fold it in, say what changed, and present the plan again.
|
||||
Detail that doesn't change a goal or a discriminator belongs in the appendix, not in the goals.
|
||||
|
||||
@@ -131,16 +133,16 @@ export function planningSeed(objective: string, planPath: string): string {
|
||||
return `Enter a planning conversation focused on the user's goals. ${objective ? `Initial idea: ${objective}.` : "Ask what the user wants to achieve; they do not need to supply a finished objective."} Read any existing plan at ${planPath} first, then discuss and draft it with the user. Do not infer approval to implement from starting this conversation. ${planning(planPath)}\n\n${planDrafting}`;
|
||||
}
|
||||
export const planDocument = (objective: string) => `# Goal plan\n\n## Objective\n${objective}\n\n## Goals\n\n## Log\n`;
|
||||
export const discuss = "Discuss the current draft in ordinary chat. Do not launch a worker or reopen the review menu until requested.";
|
||||
export const discuss = "Discuss the current draft in ordinary chat. Do not launch a worker. An unchanged draft does not reopen the review menu; a changed settled draft does.";
|
||||
|
||||
// Ready and explicit child attachment: stock lineage-only sessions do not inherit the shared plan.
|
||||
export const attachGoalPlanDescription = "Delegated goals-worker only: attach the absolute plan path explicitly supplied in your task. Read it without rewriting it. Restores the worker widget and plan context; grants no parent completion authority. No discovery or worker launch.";
|
||||
export const attachGoalPlanDescription = "Delegated goals-worker only: attach the absolute plan path explicitly supplied in your task. Read it without rewriting it. Restores plan context; grants no parent completion authority. No discovery or worker launch.";
|
||||
export const childPlanRole = "You are the delegated implementation worker. Maintain task ticks, evidence and Log entries for your delegated work in the supplied plan. Preserve agreed goals, requirements and discriminators; the supervisor owns goal-status changes and completion approval. Do not launch a second writer. Call AttachGoalPlan with the explicit plan path in your task before implementation (also after reconnect if unbound). Immediately report your actual Intercom UUID, saved-session path and current provider/model to the supplied supervisor ID. Identify unavailable fields as unknown; do not equate runtime IDs, session filenames and Intercom IDs. Send progress, completion and blocker reports there with artifact paths, then stay open for live messages. Do not exit or use caller_ping; unsent editor drafts are not visible in model context.";
|
||||
export function readyApproved(workerName: string, planPath: string, notedWorker: string | undefined, plan: string, supervisorId: string): string {
|
||||
const launch = notedWorker
|
||||
? `Inspect the recorded worker session ${notedWorker}; if still live, let it continue or message it. Only after confirming it stopped use subagent_resume with that sessionFile. Never restart completed work.`
|
||||
: `Delegate the first unfinished goal to agent '${workerName}' with subagent; provide name, title and a bounded task.`;
|
||||
return `Ready approved this plan: ${planPath}. Stay here as supervisor. ${launch} Include the absolute plan path, require AttachGoalPlan, and give the child supervisor Intercom session ${supervisorId}. The child sends its completion report there and stays open. Require an initial worker report with its actual Intercom UUID, saved-session path and current provider/model; the async launch may return only a runtime ID. Record each distinct identity in plan preferences, marking child-reported fields as such until verified. Do not start a second writer. Inspect actual outputs when the child reports.\n\n${plan}`;
|
||||
return `Ready approved this plan: ${planPath}. Stay here as supervisor. ${launch} Include the absolute plan path, require AttachGoalPlan, and first use intercom status/list to discover and confirm your own actual Intercom UUID, then give that address to the child. Your Pi session ID is ${supervisorId}; it is not necessarily your Intercom UUID. The child sends its completion report there and stays open. Require an initial worker report with its actual Intercom UUID, saved-session path and current provider/model; the async launch may return only a runtime ID. Record each distinct identity in plan preferences, marking child-reported fields as such until verified. Do not start a second writer. Inspect actual outputs when the child reports.\n\n${foldPlan(plan)}`;
|
||||
}
|
||||
|
||||
// Supervision and turn-event upkeep (not a scheduled wake-up).
|
||||
@@ -151,7 +153,7 @@ You can be playful: let the humor come from what actually happened. Avoid repeat
|
||||
You can speculate and brainstorm around uncertainty or unexpected results. Label guesses as guesses, consider alternative explanations, and look for a useful way to tell them apart. Keep exploration brief, open-minded and fun: take a step back, play with surprising ideas, question the current framing, and enjoy exploring the broader perspective while staying connected to the agreed goal.
|
||||
(b •_•)b -- wassname
|
||||
Take uncertainty as an invitation to investigate, not something to hide. Have room to play with ideas, question yourself and the worker, and appreciate a good surprise. Investigate surprising results, find mistaken assumptions, make complicated ideas simpler, and disagree usefully rather than agree politely. Keep the work moving without turning supervision into paperwork. A little affectionate teasing is welcome when it fits, and workers can push back too. Keep the humor friendly and the criticism specific. -- Pi/Astra
|
||||
Use stock subagent for launch and subagent_resume with the returned sessionFile only after confirming the worker stopped. A stored handle is not proof of liveness; missing runtime state is not proof it stopped. Use pi-intercom list/status to identify the actual live child session before live steering; receipt alone does not prove action. Give each worker your Intercom session ID ${supervisorId}; require its completion report through Intercom while its pane stays open. A recap alone sends no instruction. Record '- worker session:' and '- worker intercom session:' in plan preferences from actual launch results and received-message identity; never confuse the runtime ID with the Intercom ID. Ensure the child calls AttachGoalPlan with the supplied path. Inspect results before CompleteGoal, then continue only unfinished goals.
|
||||
Use stock subagent for launch and subagent_resume with the returned sessionFile only after confirming the worker stopped. A stored handle is not proof of liveness; missing runtime state is not proof it stopped. Use pi-intercom list/status to identify the actual live child session before live steering; receipt alone does not prove action. Your Pi session ID is ${supervisorId}, not necessarily your Intercom UUID. Use intercom status/list to discover and confirm your own actual Intercom UUID before supplying the worker's report address. Require its completion report through Intercom while its pane stays open. A recap alone sends no instruction. Record '- worker session:' and '- worker intercom session:' in plan preferences from actual launch results and received-message identity; never confuse the runtime ID with the Intercom ID. Ensure the child calls AttachGoalPlan with the supplied path. Inspect results before CompleteGoal, then continue only unfinished goals.
|
||||
Use the worker model requested in plan preferences, verify the resolved model, and report unavailable choices instead of silently substituting. Keep normal tools, not edxeth's restricted orchestrator mode. After reload or compaction reread the plan. Failed compaction, exhausted credits or lost connection do not erase progress: diagnose the actual error, restore an available authorized model/credits and resume the same saved session; never restart long work. Stock edxeth can crash the parent when a worker exits after parent reload: preserve drafts and stop workers before /reload. If it already happened, restart the saved parent session; do not repeat completed work.`;
|
||||
}
|
||||
// Pi/OpenAI: user nudges plus quotes/attributions from https://github.com/wassname/ml-debug/blob/main/fortune.txt.
|
||||
@@ -176,13 +178,14 @@ export const upkeepNudges = [
|
||||
"The unambiguously correct place to visualize your data is immediately before y_hat = model(x). This is the only source of truth. -- Andrej Karpathy",
|
||||
"Your misconfigured neural net will throw exceptions only if you're lucky; most of the time it will train but silently work a bit worse. -- Andrej Karpathy",
|
||||
"The first step to training a neural net is to not touch any neural net code at all and instead begin by thoroughly inspecting your data. -- Andrej Karpathy",
|
||||
"Write multiple competing hypotheses: consider the most likely failure but also some of: a subtle failure, a perverse failure, a possible bug, and an unknown. Put a rough credence on each. Finally write down what you expect to see differently for success vs each possibility and brainstorm the cheapest tests that may narrow them down. -- wassname",
|
||||
];
|
||||
export function upkeep(planPath: string, supervisorRound?: number): string {
|
||||
const nudge = supervisorRound === undefined ? "" : `${upkeepNudges[supervisorRound % upkeepNudges.length]}\n\n`;
|
||||
return `${nudge}Plan upkeep: update task ticks, evidence and Log in ${planPath} when you have new progress to record. Preserve agreed goals and discriminators. If already reviewing evidence, finish that review rather than repeat a status recap. This turn-event reminder does not resume paused work.`;
|
||||
}
|
||||
export function planContext(mode: string, path: string | undefined, text: string): string {
|
||||
return `Current goal mode: ${mode}. Earlier role messages are historical; this current role governs.\nPlan: ${path ?? "not attached"}\n${text}`;
|
||||
return `Current goal mode: ${mode}. Earlier role messages are historical; this current role governs.\nPlan: ${path ?? "not attached"}\n${foldPlan(text)}\n\nRead historical Log entries from the plan file when needed.`;
|
||||
}
|
||||
export function planChangedReview(planPath: string): string {
|
||||
return `${supervisorJob}\nPlan changed: ${planPath}. Read the current working set and inspect changed requirements, completion claims and evidence. Manual checkbox edits are claims, not proof. Do not weaken the agreed goal or start a duplicate writer.`;
|
||||
@@ -214,7 +217,7 @@ export const goalToolBlocked = (mode: string) => `Goals are ${mode}; no worker l
|
||||
export const emptyEvidence = (path: string) => `Empty evidence: ${path}`;
|
||||
export const evidenceUnavailable = (error: unknown) => `Evidence unavailable: ${String(error)}. No sign-off recorded.`;
|
||||
export const planUnavailable = (path: string | undefined, error: unknown) => `Goal plan ${path ?? "not attached"} unavailable: ${String(error)}. Do not implement or sign off until it is restored or explicitly attached. Retain all progress and signoffs; do not restart completed work.`;
|
||||
export const childPlanAttached = (path: string) => `Attached worker plan ${path}; widget and plan context restored without altering the file. Parent retains completion authority.`;
|
||||
export const childPlanAttached = (path: string) => `Attached worker plan ${path}; plan context restored without altering the file. Parent retains completion authority.`;
|
||||
export function completionLog(goal: string, observation: string, evidence: string[], solo: boolean): string {
|
||||
return `- ${solo ? "Solo self-verification" : "Parent review"}: ${JSON.stringify(goal)}; ${JSON.stringify(observation)}; evidence ${JSON.stringify(evidence)}`;
|
||||
}
|
||||
|
||||
+49
-3
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldPlan } from "../src/plan.js";
|
||||
import { foldPlan, goalAcceptanceSignature } from "../src/plan.js";
|
||||
|
||||
const plan = `# Plan
|
||||
|
||||
@@ -28,7 +28,7 @@ const plan = `# Plan
|
||||
## Appendix (context, not approved)
|
||||
${"filler line\n".repeat(200)}`;
|
||||
|
||||
describe("foldPlan (current goals are above ## Log; durable memory is below it)", () => {
|
||||
describe("foldPlan (current goals are above Log; durable memory is below it)", () => {
|
||||
it("keeps the title, user voice and goals", () => {
|
||||
const folded = foldPlan(plan);
|
||||
expect(folded).toContain("keep it under 50 lines");
|
||||
@@ -44,8 +44,54 @@ describe("foldPlan (current goals are above ## Log; durable memory is below it)"
|
||||
expect(folded.length).toBeLessThan(plan.length / 4);
|
||||
});
|
||||
|
||||
it("returns the whole plan when there is no ## Log yet (a fresh draft)", () => {
|
||||
it.each(["# Log", "## Log", "### Log", "###### Log", "### LOG\r"])("accepts the %s history boundary", heading => {
|
||||
expect(foldPlan(`- [ ] goal: current\n${heading}\n- [ ] goal: archived`)).toBe("- [ ] goal: current");
|
||||
});
|
||||
|
||||
it("returns the whole plan when there is no Log yet (a fresh draft)", () => {
|
||||
const draft = "# Plan\n\n## Goals\n\n1. [ ] goal: do the thing\n";
|
||||
expect(foldPlan(draft)).toBe(draft.trimEnd());
|
||||
});
|
||||
});
|
||||
|
||||
const acceptancePlan = `# Plan
|
||||
## User-visible result
|
||||
Produce a verified result.
|
||||
- preferred worker model: provider/model
|
||||
- worker session: /worker.jsonl
|
||||
## Goals
|
||||
1. [ ] goal: first
|
||||
- discriminator: exact bytes
|
||||
- tasks:
|
||||
- [ ] write output
|
||||
- evidence:
|
||||
- proof.log
|
||||
2. [ ] goal: second
|
||||
- discriminator: correct total
|
||||
## Log
|
||||
Old progress
|
||||
`;
|
||||
|
||||
it.each([
|
||||
["[ ] goal: first", "[x] goal: first"],
|
||||
["[ ] write output", "[x] write output"],
|
||||
["proof.log", "new-proof.log"],
|
||||
["goal: second", "goal: changed second"],
|
||||
["provider/model", "provider/other"],
|
||||
["/worker.jsonl", "/resumed.jsonl"],
|
||||
["Old progress", "More history"],
|
||||
])("approval ignores maintenance change %s", (before, after) => {
|
||||
expect(goalAcceptanceSignature(acceptancePlan.replace(before, after), "first")).toBe(goalAcceptanceSignature(acceptancePlan, "first"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["exact bytes", "a different acceptance criterion"],
|
||||
["Produce a verified result.", "Produce two verified results."],
|
||||
])("approval changes when requirement %s changes", (before, after) => {
|
||||
expect(goalAcceptanceSignature(acceptancePlan.replace(before, after), "first")).not.toBe(goalAcceptanceSignature(acceptancePlan, "first"));
|
||||
});
|
||||
|
||||
it("does not give a signature to missing or duplicate goals", () => {
|
||||
expect(goalAcceptanceSignature(acceptancePlan, "missing")).toBeUndefined();
|
||||
expect(goalAcceptanceSignature(acceptancePlan.replace("goal: second", "goal: first"), "first")).toBeUndefined();
|
||||
});
|
||||
|
||||
+178
-51
@@ -1,10 +1,11 @@
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { createEditTool, type ExtensionAPI, SessionManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import goalsExtension from "../src/index.js";
|
||||
import { scheduleCheckIn, upkeep, upkeepNudges } from "../src/prompts.js";
|
||||
import { upkeep, upkeepNudges } from "../src/prompts.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const shutdowns: Array<() => void> = [];
|
||||
@@ -27,7 +28,7 @@ function fixture(child = false) {
|
||||
} };
|
||||
const pi = {
|
||||
on: (event: string, hook: any) => hooks.set(event, hook),
|
||||
appendEntry: (customType: string, data: any) => entries.push({ type: "custom", customType, data: structuredClone(data) }),
|
||||
appendEntry: (customType: string, data: any) => entries.push({ type: "custom", customType, data }),
|
||||
registerCommand: (name: string, definition: any) => commands.set(name, definition),
|
||||
registerTool: (definition: any) => tools.set(definition.name, definition),
|
||||
sendMessage: (message: any, options: any) => messages.push({ message, options }),
|
||||
@@ -54,7 +55,13 @@ function fixture(child = false) {
|
||||
renameSync(tmp, path);
|
||||
await delay(25);
|
||||
};
|
||||
return { ctx, pi, hooks, tools, commands, messages, command, path, plan, draft, shutdown, changed, atomicWrite, entries };
|
||||
const start = (toolCallId: string, input: any = { agent: "goals-worker", title: "Implement" }, toolName = "subagent") => hooks.get("tool_call")({ toolCallId, toolName, input }, ctx);
|
||||
const finish = (toolCallId: string, details: any, toolName = "subagent", isError = false) => hooks.get("tool_execution_end")({ toolCallId, toolName, result: { content: [], details }, isError }, ctx);
|
||||
const launch = (details: any, agent = "goals-worker", toolName = "subagent") => {
|
||||
start(details.id, { agent, title: "Work", sessionFile: details.sessionFile }, toolName);
|
||||
finish(details.id, details, toolName);
|
||||
};
|
||||
return { ctx, pi, hooks, tools, commands, messages, command, path, plan, draft, shutdown, changed, atomicWrite, entries, start, finish, launch };
|
||||
}
|
||||
|
||||
it.each([
|
||||
@@ -117,7 +124,7 @@ it("edits even an empty draft directly without a model call", async () => {
|
||||
|
||||
it("clear backs up the plan, drops stale bindings and allows a separate new draft", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "stale", sessionFile: "/tmp/old-worker.jsonl" } });
|
||||
f.launch({ id: "stale", sessionFile: "/tmp/old-worker.jsonl" });
|
||||
const jobs = [
|
||||
{ id: "owned", name: "goals-copy-only", session: "copy-only", enabled: true },
|
||||
{ id: "older", name: "older-plan", session: "copy-only", enabled: true },
|
||||
@@ -211,7 +218,7 @@ it("rejects a plan changed while the human was reviewing it", async () => {
|
||||
|
||||
it("reloads a paused plan without launching, and retains the public worker session handle", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "child-1", sessionFile: "/tmp/child.jsonl" } });
|
||||
f.launch({ id: "child-1", sessionFile: "/tmp/child.jsonl" });
|
||||
await f.command("stop");
|
||||
expect(f.messages.at(-1).message.content).toContain("Remote stop is NOT yet confirmed");
|
||||
f.hooks.get("session_start")({}, f.ctx);
|
||||
@@ -336,16 +343,6 @@ it("tells the model to remove only its own job after the final review", async ()
|
||||
f.shutdown();
|
||||
});
|
||||
|
||||
it("retains human-edited and disabled owned schedules without overriding their controls", () => {
|
||||
const guidance = scheduleCheckIn("copy-only", ".pi/plan/copy-only-main.md");
|
||||
expect(guidance).toContain("List first");
|
||||
expect(guidance).toContain("enabled/disabled state unchanged");
|
||||
expect(guidance).toContain("never recreate, overwrite or re-enable");
|
||||
expect(guidance).toContain("no model override");
|
||||
expect(guidance).toContain("Do not reinstall a missing job from a scheduled check-in");
|
||||
expect(guidance).toContain("/schedule-prompts");
|
||||
});
|
||||
|
||||
it("restores context after compaction without reinstalling or overriding scheduler jobs", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("session_compact")();
|
||||
@@ -362,13 +359,15 @@ it("recovers from an unreadable plan after compaction instead of restarting work
|
||||
expect(result.systemPrompt).toContain("ENOENT");
|
||||
expect(result.systemPrompt).toContain("do not restart completed work");
|
||||
writeFileSync(f.path, f.plan);
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message.content).toContain(f.plan.trim());
|
||||
const restored = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message.content;
|
||||
expect(restored).toContain("- [ ] goal: first output");
|
||||
expect(restored).toContain(f.path);
|
||||
f.shutdown();
|
||||
});
|
||||
|
||||
it("requires confirmed worker stop before solo takeover and never lets two writers run together", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "child-1", sessionFile: "/tmp/child.jsonl" } });
|
||||
f.launch({ id: "child-1", sessionFile: "/tmp/child.jsonl" });
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Cancel");
|
||||
await f.command("solo");
|
||||
expect(f.entries.at(-1).data.mode).toBe("supervising"); // cancelled
|
||||
@@ -444,7 +443,7 @@ it("records the preferred worker model as a visible plan preference", async () =
|
||||
|
||||
it.each(["solo", "attach"])("%s takeover cannot bypass confirmation or survive a lifecycle change during the menu", async kind => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "child", sessionFile: "/tmp/prior.jsonl" } });
|
||||
f.launch({ id: "child", sessionFile: "/tmp/prior.jsonl" });
|
||||
let answer!: (choice: string) => void;
|
||||
f.ctx.ui.select.mockImplementationOnce(() => new Promise(resolve => { answer = resolve; }));
|
||||
const takeover = f.command(kind === "solo" ? "solo" : `attach ${f.path} solo`);
|
||||
@@ -469,14 +468,14 @@ it("attach solo requires stop confirmation for a noted worker even in a fresh se
|
||||
|
||||
it("retains the stopped session reference without permanently blocking another plan", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "child", sessionFile: "/tmp/prior.jsonl" } });
|
||||
f.launch({ id: "child", sessionFile: "/tmp/prior.jsonl" });
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
const other = join(f.ctx.cwd, "another.md"); writeFileSync(other, "- [ ] goal: next\n## Log\n");
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Previous supervisor confirmed stopped");
|
||||
await f.command(`attach ${other}`);
|
||||
expect(f.entries.at(-1).data).toMatchObject({ mode: "planning", plan: other, workerStopped: true, worker: { sessionFile: "/tmp/prior.jsonl" } });
|
||||
await f.command("ready");
|
||||
f.hooks.get("tool_call")({ toolName: "subagent_resume" });
|
||||
f.start("resume", { sessionFile: "/tmp/prior.jsonl" }, "subagent_resume");
|
||||
expect(f.entries.at(-1).data.workerStopped).toBe(false);
|
||||
await f.command("solo");
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("still pending"), "warning");
|
||||
@@ -511,7 +510,8 @@ it.each(["missing", "empty", "directory"])("%s plan snapshots never erase signof
|
||||
if (failure === "directory") rmSync(f.path, { recursive: true });
|
||||
writeFileSync(f.path, signed);
|
||||
const resync = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
expect(resync.message.content).toContain("Observed PASS");
|
||||
expect(resync.message.content).toContain("- [x] goal: first output");
|
||||
expect(readFileSync(f.path, "utf8")).toContain("Observed PASS");
|
||||
await delay(250);
|
||||
expect(f.entries.at(-1).data.signoffs["first output"]).toBeDefined();
|
||||
expect(f.changed()).toBe(0);
|
||||
@@ -650,40 +650,41 @@ it.each(["supervising", "solo"])("%s repeats upkeep every eight unchanged turns
|
||||
|
||||
it("extra subagent launches are recorded as helpers and never steal the implementation identity", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "impl", sessionFile: "/tmp/impl.jsonl" } });
|
||||
f.launch({ id: "impl", sessionFile: "/tmp/impl.jsonl" });
|
||||
expect(f.entries.at(-1).data).toMatchObject({ worker: { id: "impl", sessionFile: "/tmp/impl.jsonl" }, helpers: [] });
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "reviewer", sessionFile: "/tmp/review.jsonl" } });
|
||||
f.launch({ id: "reviewer", sessionFile: "/tmp/review.jsonl" }, "reviewer");
|
||||
expect(f.entries.at(-1).data).toMatchObject({ worker: { id: "impl" }, helpers: [{ id: "reviewer", sessionFile: "/tmp/review.jsonl" }] });
|
||||
// a repeated helper launch updates its record instead of duplicating it
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "reviewer-2", sessionFile: "/tmp/review.jsonl" } });
|
||||
f.launch({ id: "reviewer-2", sessionFile: "/tmp/review.jsonl" }, "reviewer");
|
||||
expect(f.entries.at(-1).data.helpers).toEqual([{ id: "reviewer-2", sessionFile: "/tmp/review.jsonl" }]);
|
||||
// resuming the worker keeps the binding and refreshes its id
|
||||
f.hooks.get("tool_result")({ toolName: "subagent_resume", details: { id: "impl-2", sessionFile: "/tmp/impl.jsonl" } });
|
||||
f.launch({ id: "impl-2", sessionFile: "/tmp/impl.jsonl" }, "goals-worker", "subagent_resume");
|
||||
expect(f.entries.at(-1).data).toMatchObject({ worker: { id: "impl-2", sessionFile: "/tmp/impl.jsonl" }, helpers: [{ id: "reviewer-2" }] });
|
||||
});
|
||||
|
||||
it("pending launch counter survives concurrent launches until every result lands", async () => {
|
||||
it("pending call IDs survive concurrent launches until every execution ends", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.hooks.get("tool_call")({ toolName: "subagent" });
|
||||
f.hooks.get("tool_call")({ toolName: "subagent" });
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "a", sessionFile: "/tmp/a.jsonl" } });
|
||||
f.start("call-a");
|
||||
f.start("call-b", { agent: "reviewer", title: "Review" });
|
||||
f.finish("call-a", { id: "a", sessionFile: "/tmp/a.jsonl" });
|
||||
f.finish("untracked", { id: "noise", sessionFile: "/tmp/noise.jsonl" });
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped");
|
||||
await f.command("solo");
|
||||
expect(f.entries.at(-1).data.mode).toBe("supervising"); // one launch still pending
|
||||
expect(f.ctx.notify ?? f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("still pending"), "warning");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "b", sessionFile: "/tmp/b.jsonl" } });
|
||||
f.finish("call-b", { id: "b", sessionFile: "/tmp/b.jsonl" });
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped");
|
||||
await f.command("solo");
|
||||
expect(f.entries.at(-1).data.mode).toBe("solo");
|
||||
expect(f.entries.at(-1).data).toMatchObject({ worker: { id: "a" }, helpers: [{ id: "b" }] });
|
||||
});
|
||||
|
||||
it("late worker results invalidate a takeover menu but do not disable plan watching", async () => {
|
||||
it("a launch started during takeover invalidates the menu without disabling plan watching", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
let answer!: (choice: string) => void;
|
||||
f.ctx.ui.select.mockImplementationOnce(() => new Promise(resolve => { answer = resolve; }));
|
||||
const solo = f.command("solo");
|
||||
f.hooks.get("tool_result")({ toolName: "subagent", details: { id: "late-child", sessionFile: "/tmp/late.jsonl" } });
|
||||
f.launch({ id: "late-child", sessionFile: "/tmp/late.jsonl" });
|
||||
answer("Worker confirmed stopped"); await solo;
|
||||
expect(f.entries.at(-1).data.mode).toBe("supervising");
|
||||
expect(f.entries.at(-1).data.workerStopped).toBe(false);
|
||||
@@ -725,25 +726,9 @@ it("does not approve cancelled goals or display current completion for an unavai
|
||||
expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", [expect.stringContaining("unavailable")]);
|
||||
});
|
||||
|
||||
it("uses scheduler storage for ownership and the real public user controls", () => {
|
||||
const prompt = scheduleCheckIn("session-1", "/plan.md");
|
||||
expect(prompt).toContain(".pi/schedule-prompts.json");
|
||||
expect(prompt).toContain("tool text does not expose binding");
|
||||
expect(prompt).toContain("Never use cleanup");
|
||||
expect(prompt).toContain("deletes disabled jobs");
|
||||
expect(prompt).toContain("schedule_prompt update");
|
||||
expect(prompt).not.toContain("with /schedule-prompts");
|
||||
});
|
||||
|
||||
it("keeps interactive workers open and supplies the supervisor identity for Intercom reports", async () => {
|
||||
it("keeps interactive workers open", () => {
|
||||
const agent = readFileSync(new URL("../agents/goals-worker.md", import.meta.url), "utf8");
|
||||
expect(agent).toContain("auto-exit: false");
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
expect(f.messages.at(-1).message.content).toContain("supervisor Intercom session copy-only");
|
||||
const role = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).systemPrompt;
|
||||
expect(role).toContain("your Intercom session ID copy-only");
|
||||
expect(role).toContain("stop workers before /reload");
|
||||
expect(role).not.toContain("Reports arrive automatically");
|
||||
});
|
||||
|
||||
it.each(["stop", "exit", "edit", "session_tree"])("discards pending upkeep after %s instead of reviving stale work", async change => {
|
||||
@@ -775,11 +760,153 @@ it("coalesces pending upkeep with a repaired post-compaction plan, retaining the
|
||||
writeFileSync(f.path, repaired);
|
||||
const ready = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
expect(ready.message).toMatchObject({ customType: "pi-goals-plan" });
|
||||
expect(ready.message.content).toContain(repaired);
|
||||
expect(ready.message.content).toContain("the human's latest exact result");
|
||||
expect(readFileSync(f.path, "utf8")).toBe(repaired);
|
||||
expect(ready.message.content).not.toContain("Plan upkeep:");
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
|
||||
});
|
||||
|
||||
it("real SessionManager preserves historical state and restores draft authority before Ready", async () => {
|
||||
const f = fixture();
|
||||
const session = SessionManager.inMemory(f.ctx.cwd);
|
||||
f.pi.appendEntry = (type: string, data: unknown) => { session.appendCustomEntry(type, data); return 0; };
|
||||
f.ctx.sessionManager.getBranch = () => session.getBranch();
|
||||
await f.draft();
|
||||
const planned = session.getLeafEntry() as any;
|
||||
await f.command("ready");
|
||||
expect(planned.data.mode).toBe("planning");
|
||||
expect((session.getLeafEntry() as any).data).not.toBe(planned.data);
|
||||
session.branch(planned.id);
|
||||
f.hooks.get("session_tree")({ newLeafId: planned.id }, f.ctx);
|
||||
expect(f.start("after-tree")?.block).toBe(true);
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).systemPrompt).toContain("Plan only in");
|
||||
});
|
||||
|
||||
it("serializes CompleteGoal after a real built-in edit without losing either successful update", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
writeFileSync(join(f.ctx.cwd, "proof.log"), "PASS\n");
|
||||
let reportRead!: () => void; const readStarted = new Promise<void>(resolve => { reportRead = resolve; });
|
||||
let release!: () => void; const continueRead = new Promise<void>(resolve => { release = resolve; });
|
||||
const edit = createEditTool(f.ctx.cwd, { operations: {
|
||||
access: path => access(path),
|
||||
readFile: async path => { const bytes = await readFile(path); reportRead(); await continueRead; return bytes; },
|
||||
writeFile: (path, text) => writeFile(path, text, "utf8"),
|
||||
} });
|
||||
const editing = edit.execute("edit", { path: f.path, edits: [{ oldText: "# Plan", newText: "# Plan with progress note" }] });
|
||||
await readStarted;
|
||||
const completing = f.tools.get("CompleteGoal").execute("complete", { goal: "first output", evidence: ["proof.log"], observation: "Read PASS" }, undefined, undefined, f.ctx);
|
||||
release();
|
||||
await editing; await completing;
|
||||
const text = readFileSync(f.path, "utf8");
|
||||
expect(text).toContain("# Plan with progress note");
|
||||
expect(text).toContain("- [x] goal: first output");
|
||||
expect(text).toContain("Parent review:");
|
||||
expect(f.entries.at(-1).data.signoffs["first output"]).toBeDefined();
|
||||
});
|
||||
|
||||
it.each(["pause", "replace", "tree", "cancel"])("rejects queued completion after %s while waiting for a file mutation", async change => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
writeFileSync(join(f.ctx.cwd, "proof.log"), "PASS\n");
|
||||
let entered!: () => void; const held = new Promise<void>(resolve => { entered = resolve; });
|
||||
let release!: () => void; const wait = new Promise<void>(resolve => { release = resolve; });
|
||||
const holding = withFileMutationQueue(f.path, async () => { entered(); await wait; });
|
||||
await held;
|
||||
const abort = new AbortController();
|
||||
const completing = f.tools.get("CompleteGoal").execute("complete", { goal: "first output", evidence: ["proof.log"], observation: "Read PASS" }, abort.signal, undefined, f.ctx);
|
||||
if (change === "pause") await f.command("stop");
|
||||
if (change === "replace") { await f.command("exit"); await f.command("new different output"); }
|
||||
if (change === "tree") f.hooks.get("session_tree")({}, f.ctx);
|
||||
if (change === "cancel") abort.abort();
|
||||
const current = f.entries.at(-1).data.plan;
|
||||
const before = readFileSync(current, "utf8");
|
||||
release(); await holding;
|
||||
const response = await completing;
|
||||
expect(response.content[0].text).not.toContain("Recorded parent judgment");
|
||||
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
|
||||
expect(readFileSync(current, "utf8")).toBe(before);
|
||||
expect(f.entries.at(-1).data.signoffs).toEqual({});
|
||||
});
|
||||
|
||||
it.each([true, false])("solo stop/reload/resume preserves ownership with companion tools=%s", async tools => {
|
||||
const f = fixture(); await f.draft();
|
||||
if (!tools) f.pi.getAllTools.mockReturnValue([]);
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
await f.command("stop"); await f.command("stop");
|
||||
f.hooks.get("session_start")({}, f.ctx);
|
||||
expect(f.entries.at(-1).data.pausedFrom).toBe("solo");
|
||||
await f.command("resume");
|
||||
expect(f.entries.at(-1).data.mode).toBe("solo");
|
||||
expect(f.start("forbidden")?.block).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects blank goal subjects on Ready and CompleteGoal", async () => {
|
||||
const f = fixture(); await f.draft();
|
||||
writeFileSync(f.path, "# Plan\n- [ ] goal: \n- [ ] goal: valid\n## Log\n");
|
||||
await f.command("ready");
|
||||
expect(f.entries.at(-1).data.mode).toBe("planning");
|
||||
writeFileSync(f.path, f.plan); await f.command("ready");
|
||||
writeFileSync(f.path, "# Plan\n- [ ] goal: \n## Log\n");
|
||||
writeFileSync(join(f.ctx.cwd, "proof.log"), "PASS\n");
|
||||
const before = readFileSync(f.path, "utf8");
|
||||
await f.tools.get("CompleteGoal").execute("blank", { goal: " ", evidence: ["proof.log"], observation: "Read PASS" }, undefined, undefined, f.ctx);
|
||||
expect(readFileSync(f.path, "utf8")).toBe(before);
|
||||
expect(f.entries.at(-1).data.signoffs).toEqual({});
|
||||
});
|
||||
|
||||
it.each(["denied", "cancelled"])("settles a %s preflight on execution_end without tool_result", async reason => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.start("refused");
|
||||
f.finish("refused", { error: reason }, "subagent", true);
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
expect(f.entries.at(-1).data.mode).toBe("solo");
|
||||
expect(f.entries.at(-1).data.worker).toBeUndefined();
|
||||
});
|
||||
|
||||
it("batch results and a reviewer arriving first do not take the implementation binding", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.launch({ id: "early", sessionFile: "/tmp/early.jsonl", agent: "reviewer" }, "reviewer");
|
||||
expect(f.entries.at(-1).data.worker).toBeUndefined();
|
||||
f.start("batch", { children: [{ agent: "reviewer", title: "Review" }, { agent: "goals-worker", title: "Implement" }] });
|
||||
f.finish("batch", { status: "started", children: [
|
||||
{ id: "review", sessionFile: "/tmp/review.jsonl", agent: "reviewer" },
|
||||
{ id: "impl", sessionFile: "/tmp/impl.jsonl", agent: "goals-worker" },
|
||||
] });
|
||||
expect(f.entries.at(-1).data.worker).toEqual({ id: "impl", sessionFile: "/tmp/impl.jsonl" });
|
||||
expect(f.entries.at(-1).data.helpers).toHaveLength(2);
|
||||
await f.command("stop");
|
||||
expect(f.messages.at(-1).message.content).toContain("impl");
|
||||
});
|
||||
|
||||
it.each(["replace", "tree", "pause"])("does not attach a launch result after %s changed its originating generation", async change => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
f.start("old");
|
||||
if (change === "replace") { await f.command("exit"); await f.command("new next output"); }
|
||||
if (change === "tree") f.hooks.get("session_tree")({}, f.ctx);
|
||||
if (change === "pause") await f.command("stop");
|
||||
f.finish("old", { id: "old-worker", sessionFile: "/tmp/old-worker.jsonl", agent: "goals-worker" });
|
||||
expect(f.entries.at(-1).data.worker).toBeUndefined();
|
||||
expect(f.entries.at(-1).data.helpers).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["criterion", false], ["scope", false], ["other goal", true], ["task", true], ["evidence", true], ["Log", true],
|
||||
])("%s edits retain signoff=%s according to reviewed acceptance", async (change, retained) => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
writeFileSync(f.path, f.plan.replace("# Plan", "# Plan\nShared scope: exact bytes").replace("goal: first output\n", "goal: first output\n - discriminator: original criterion\n - tasks:\n - [ ] original task\n - evidence: original evidence\n"));
|
||||
writeFileSync(join(f.ctx.cwd, "proof.log"), "PASS\n");
|
||||
await f.tools.get("CompleteGoal").execute("complete", { goal: "first output", evidence: ["proof.log"], observation: "Read PASS" }, undefined, undefined, f.ctx);
|
||||
const signed = readFileSync(f.path, "utf8");
|
||||
const replacements: Record<string, [string, string]> = {
|
||||
criterion: ["original criterion", "new criterion"], scope: ["exact bytes", "two files"], "other goal": ["second output", "new second output"],
|
||||
task: ["[ ] original task", "[x] maintained task"], evidence: ["original evidence", "additional evidence"], Log: ["## Log", "## Log\n- historical note"],
|
||||
};
|
||||
writeFileSync(f.path, signed.replace(...replacements[change as string]));
|
||||
f.hooks.get("agent_end")({}, f.ctx);
|
||||
expect(Boolean(f.entries.at(-1).data.signoffs["first output"])).toBe(retained);
|
||||
f.hooks.get("session_start")({}, f.ctx);
|
||||
expect(Boolean(f.entries.at(-1).data.signoffs["first output"])).toBe(retained);
|
||||
});
|
||||
|
||||
it("passive pause is visible immediately while its model notice waits safely for the next prompt", async () => {
|
||||
const f = fixture(); await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
|
||||
+13
-34
@@ -1,40 +1,19 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { planViews } from "../src/plan-view.js";
|
||||
|
||||
it("keeps outcome, preferences and discriminators without tasks or history", () => {
|
||||
const plan = "# Outcome\nBeat random, not just plot it.\n## User preferences\nKeep costs low.\n## Goals\n1. [ ] goal: repair\n - discriminator: beats random\n - subtle failure mode: plot exists but result fails\n - tasks:\n 1. [x] draw plot\n - evidence:\n - old output\n2. [ ] goal: confirm\n## Task list\n- [ ] run it\n## Appendix\nunapproved idea";
|
||||
const views = planViews(plan);
|
||||
for (const text of ["Beat random", "Keep costs low", "goal: repair", "discriminator: beats random", "subtle failure mode", "goal: confirm"]) expect(views.short).toContain(text);
|
||||
for (const text of ["draw plot", "old output", "run it", "unapproved idea"]) expect(views.short).not.toContain(text);
|
||||
expect(views.long).toContain("draw plot");
|
||||
expect(views.long).toContain("old output");
|
||||
expect(views.long).not.toContain("unapproved idea");
|
||||
it.each(["", " "])("ignores %sindented identity bookkeeping and Log edits, but reviews tasks and goals", indent => {
|
||||
const base = `# Plan\n- [ ] goal: result\n - tasks:\n - [ ] run it\n${indent}- worker session: /saved.jsonl\n## Log\nfirst entry`;
|
||||
const view = planViews(base).notify;
|
||||
expect(planViews(base.replace("/saved.jsonl", "/moved.jsonl")).notify).toBe(view);
|
||||
expect(planViews(base.replace("first entry", "second entry")).notify).toBe(view);
|
||||
expect(planViews(base.replace("- [ ] run it", "- [x] run it")).notify).not.toBe(view);
|
||||
expect(planViews(base.replace("[ ] goal: result", "[x] goal: result")).notify).not.toBe(view);
|
||||
});
|
||||
|
||||
it("omits only named worker identity fields from review while retaining them in full context", () => {
|
||||
const base = "# Plan\n- preferred worker model: provider/model\n- [ ] goal: result\n - discriminator: exact bytes";
|
||||
const metadata = "\n- Active worker: worker-1\n- worker session: /saved.jsonl\n- worker intercom session: uuid";
|
||||
expect(planViews(base + metadata).short).toBe(planViews(base).short);
|
||||
expect(planViews(base + metadata).long).toContain("/saved.jsonl");
|
||||
expect(planViews(base.replace("exact bytes", "approximate match")).short).not.toBe(planViews(base).short);
|
||||
expect(planViews(base.replace("[ ]", "[x]")).short).not.toBe(planViews(base).short);
|
||||
});
|
||||
|
||||
it("notifies on goal and task changes but not on identity bookkeeping or log edits", () => {
|
||||
const base = "# Plan\n- [ ] goal: result\n## Task list\n- [ ] run it\n- worker session: /saved.jsonl\n## Log\nfirst entry";
|
||||
const baseView = planViews(base).notify;
|
||||
// identity bookkeeping: silent
|
||||
expect(planViews(base.replace("/saved.jsonl", "/moved.jsonl")).notify).toBe(baseView);
|
||||
// log edits: silent
|
||||
expect(planViews(base.replace("first entry", "second entry")).notify).toBe(baseView);
|
||||
// worker ticking a task: review event (field catch, LUCID3 2026-09-10)
|
||||
expect(planViews(base.replace("- [ ] run it", "- [x] run it")).notify).not.toBe(baseView);
|
||||
// goal edits: review event
|
||||
expect(planViews(base.replace("[ ] goal: result", "[x] goal: result")).notify).not.toBe(baseView);
|
||||
});
|
||||
|
||||
it("stops at history and preserves a manual goal tick", () => {
|
||||
const view = planViews("# Plan\n1. [x] goal: result\n## Log\n1. [ ] goal: historical");
|
||||
expect(view.short).toContain("[x] goal: result");
|
||||
expect(view.long).not.toContain("historical");
|
||||
it("uses Log as the boundary even when Interview precedes Goals", () => {
|
||||
const plan = "# Plan\n## Interview\nOriginal discussion\n## Goals\n- [ ] goal: output\n### Log\n- [ ] goal: archived";
|
||||
const view = planViews(plan).notify;
|
||||
expect(view).toContain("goal: output");
|
||||
expect(view).not.toContain("archived");
|
||||
expect(planViews(plan.replace("goal: output", "goal: changed output")).notify).not.toBe(view);
|
||||
});
|
||||
|
||||
+13
-20
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planDrafting, upkeep, upkeepNudges } from "../src/prompts.js";
|
||||
import { expect, it } from "vitest";
|
||||
import { planContext, readyApproved, upkeep, upkeepNudges } from "../src/prompts.js";
|
||||
|
||||
it("cycles the curated supervisor nudges without changing the shared upkeep instructions", () => {
|
||||
const base = upkeep("/plan.md");
|
||||
@@ -7,24 +7,17 @@ it("cycles the curated supervisor nudges without changing the shared upkeep inst
|
||||
expect(new Set(variants).size).toBe(upkeepNudges.length);
|
||||
for (const text of variants) expect(text.endsWith(base)).toBe(true);
|
||||
expect(upkeep("/plan.md", upkeepNudges.length)).toBe(variants[0]);
|
||||
expect(base.startsWith("Plan upkeep:")).toBe(true);
|
||||
expect(base).toContain("/plan.md");
|
||||
});
|
||||
|
||||
describe("planning prompt", () => {
|
||||
it("requires fact finding or a focused question before a goal", () => {
|
||||
expect(planDrafting).toContain("Use read-only repository tools or web search when either can\nresolve a fact.");
|
||||
expect(planDrafting).toContain("Do not use a question quota");
|
||||
expect(planDrafting).toContain("Briefly reframe the request in your own words to check comprehension");
|
||||
expect(planDrafting).toContain("point as unknown; do not silently replace it with an inference or turn it into a new blocking decision");
|
||||
expect(planDrafting).toContain("answer materially reduces uncertainty\nwhile discovering the right plan");
|
||||
expect(planDrafting).toContain("self-contained: state the relevant\ncontext, use the human's language and ASD-STE100");
|
||||
expect(planDrafting).toContain("placeholder goal such as \"work out the thing\"");
|
||||
expect(planDrafting).toContain("Only withhold Ready for an unanswered choice that changes scope, spending, or the user-visible result");
|
||||
});
|
||||
|
||||
it("anchors work and sign-off to the user-visible result", () => {
|
||||
expect(planDrafting).toContain("## User-visible result");
|
||||
expect(planDrafting).toContain("Take it from the original request, not from your implementation plan");
|
||||
expect(planDrafting).toContain("Future work may not defer any artifact or action named there");
|
||||
});
|
||||
it.each(["## Log", "### Log"])("keeps all current requirements but omits %s history from refreshed and approved context", heading => {
|
||||
const requirements = "- > The human's full requested output and conditions.\n".repeat(80);
|
||||
const plan = `# Plan\n## User voice\n${requirements}\n- [ ] goal: verify output\n${heading}\nold progress report`;
|
||||
const refreshed = planContext("supervising", "/plan.md", plan);
|
||||
const approved = readyApproved("goals-worker", "/plan.md", undefined, plan, "pi-session");
|
||||
for (const text of [refreshed, approved]) {
|
||||
expect(text).toContain(requirements);
|
||||
expect(text).toContain("goal: verify output");
|
||||
expect(text).not.toContain("old progress report");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldPlan } from "../src/plan.js";
|
||||
|
||||
type RpcMessage = { type: string; id?: string; method?: string; [key: string]: unknown };
|
||||
type ModelRequest = { messages: Array<{ role: string; content: unknown }> };
|
||||
@@ -152,7 +153,7 @@ describe("RPC review flow", () => {
|
||||
const supervisor = requests.at(-1)!;
|
||||
expect(systemText(supervisor)).toContain("You are the goal supervisor in the main chat");
|
||||
expect(systemText(supervisor)).not.toContain("Plan only in");
|
||||
expect(JSON.stringify(supervisor.messages)).toContain(JSON.stringify(approvedPlan).slice(1, -1));
|
||||
expect(JSON.stringify(supervisor.messages)).toContain(JSON.stringify(foldPlan(approvedPlan)).slice(1, -1));
|
||||
expect(client.messages.filter(message => message.type === "tool_execution_start").map(message => message.toolName)).toEqual(["write"]);
|
||||
expect(client.messages.filter(message => message.type === "extension_error")).toEqual([]);
|
||||
console.log(`RPC ${choice}: visible automatic proposal; ${choice === "Edit" ? "editor saved exact plan without model call" : "discussion retained planning role without editor"}; Ready request used supervisor role; only write executed.`);
|
||||
|
||||
Reference in New Issue
Block a user