mirror of
https://github.com/wassname/pi-plan.git
synced 2026-08-20 12:40:22 +08:00
plan file is per session: .pi/plan/<session_id>.md
A subagent runs pi -p --no-session with extensions on, so it loaded pi-goals, got the parent's plan injected, and could sign off the parent's goals. Two windows on one checkout also stomped each other's file. The session id in the name fixes both, and doubles as the on switch: no /goals means no file at this session's path, so nothing fires. Drops the v1 goals.md rename, and /goals clear now deletes the file. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
+39
-32
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* pi-goals v2 — plan mode drafts goals into one .pi/plan.md, the agent works them with its normal
|
||||
* Edit tool, and a fresh read-only judge signs each goal off through the one blessed tool,
|
||||
* pi-goals v2 — plan mode drafts goals into .pi/plan/<session_id>.md, the agent works them with its
|
||||
* normal Edit tool, and a fresh read-only judge signs each goal off through the one blessed tool,
|
||||
* CompleteGoal.
|
||||
*
|
||||
* The v1 lesson: the parser existed so TypeScript could read plan.md, but almost every reader is a
|
||||
* One plan file per session, not per repo: two windows on one checkout, and any subagent (pi spawns
|
||||
* those with --no-session, extensions ON), each resolve a different path, so they can't read or
|
||||
* stomp each other's plan. The file name is also the arm switch: a session that never ran /goals has
|
||||
* no file at its path, so the widget, the injections and CompleteGoal all stay silent. The id is
|
||||
* stable exactly where it must be -- a resume reads it back from the session header, and a
|
||||
* compaction keeps it; only an explicit fork/new session gets a new one.
|
||||
*
|
||||
* The v1 lesson: the parser existed so TypeScript could read the plan, but almost every reader is a
|
||||
* model. So v2 has NO parser and no schema. The harness does exactly three things for a
|
||||
* cooperative-but-confused model:
|
||||
* 1. memory — a transient re-send of the plan, never persisted, on two triggers: the plan went
|
||||
@@ -33,7 +40,7 @@
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
@@ -42,15 +49,17 @@ import { completeGoalDescription, completeGoalParamDescription, judgeSystem, jud
|
||||
const STATE = "pi-goals-state";
|
||||
const STATUS_KEY = "pi-goals";
|
||||
const WIDGET_KEY = "pi-goals-widget";
|
||||
const PLAN_REL = ".pi/plan.md";
|
||||
const PLAN_DIR = ".pi/plan";
|
||||
// For static text (the /goals description) where there is no ctx to resolve the session id.
|
||||
const PLAN_SHAPE = `${PLAN_DIR}/<session_id>.md`;
|
||||
// Judge toolset: strictly read-only, NO bash -- the judge can never execute or mutate anything, and
|
||||
// in particular never re-runs a verify command (which may be a 10-hour training job). The agent runs
|
||||
// verify itself and saves the output as evidence; the judge reads it. Names match pi's tool registry.
|
||||
const JUDGE_TOOLS = ["read", "grep", "find", "ls"];
|
||||
const JUDGE_BLOCKED_TOOLS = ["edit", "write"];
|
||||
const JUDGE_TIMEOUT_MS = 600_000;
|
||||
// Plan mode is read-only by convention AND a light gate: edit/write are blocked (except plan.md,
|
||||
// the deliverable). bash stays open — the prompt says don't mutate; guide, don't gate (spec D3).
|
||||
// Plan mode is read-only by convention AND a light gate: edit/write are blocked (except the plan
|
||||
// file, the deliverable). bash stays open — the prompt says don't mutate; guide, not gate (spec D3).
|
||||
const PLAN_MODE_BLOCKED_TOOLS = ["edit", "write"];
|
||||
// Turns the plan may go untouched before it is re-sent. pi-tasks uses 4, or 2 while something is in
|
||||
// progress; here every goal is "in progress", so 2.
|
||||
@@ -112,10 +121,11 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
// the WHOLE file (appendix included) instead of just the working set.
|
||||
let resyncReason: string | null = "New session.";
|
||||
|
||||
const planPath = (ctx: ExtensionContext) => join(ctx.cwd, ".pi", "plan.md");
|
||||
const planRel = (ctx: ExtensionContext) => `${PLAN_DIR}/${ctx.sessionManager.getSessionId()}.md`;
|
||||
const planPath = (ctx: ExtensionContext) => join(ctx.cwd, planRel(ctx));
|
||||
const readPlan = (ctx: ExtensionContext): string => (existsSync(planPath(ctx)) ? readFileSync(planPath(ctx), "utf-8") : "");
|
||||
const writePlan = (ctx: ExtensionContext, content: string): void => {
|
||||
mkdirSync(join(ctx.cwd, ".pi"), { recursive: true });
|
||||
mkdirSync(join(ctx.cwd, PLAN_DIR), { recursive: true });
|
||||
writeFileSync(planPath(ctx), content);
|
||||
};
|
||||
|
||||
@@ -126,7 +136,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
function updateWidget(ctx: ExtensionContext): void {
|
||||
if (state.isPlanMode) {
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "planning"));
|
||||
ctx.ui.setWidget(WIDGET_KEY, [`pi-goals: drafting goals in ${PLAN_REL}`]);
|
||||
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: drafting goals"]);
|
||||
return;
|
||||
}
|
||||
const goals = scanGoals(readPlan(ctx));
|
||||
@@ -140,8 +150,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
const mark: Record<GoalStatus, string> = { done: "✔", active: "▸", open: "◻", cancelled: "✗" };
|
||||
// Only live goals get lines so finished work never pushes current work off screen. The active
|
||||
// goal also shows its open subtasks: this file is the task list, so the widget is the task list.
|
||||
// No path line: the session id makes it 47 chars, too long to be worth a widget row. The
|
||||
// human opens the file from the Ready menu, and every injected reminder still names it.
|
||||
const plan = readPlan(ctx);
|
||||
const lines = [ctx.ui.theme.fg("muted", PLAN_REL)];
|
||||
const lines: string[] = [];
|
||||
for (const g of goals.filter((g) => g.status === "active" || g.status === "open")) {
|
||||
lines.push(`${mark[g.status]} ${g.subject}`);
|
||||
if (g.status === "active") lines.push(...openSubtasks(plan, g.line).slice(0, 3).map((s) => ctx.ui.theme.fg("muted", ` ◦ ${s}`)));
|
||||
@@ -152,15 +164,15 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
// --- /goals: enter plan mode (or clear / set judge) --------------------------------------------
|
||||
|
||||
pi.registerCommand("goals", {
|
||||
description: `Plan mode: draft goals into ${PLAN_REL}, review, then work them. /goals <objective> | /goals clear | /goals judge <model>`,
|
||||
description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals <objective> | /goals clear | /goals judge <model>`,
|
||||
handler: async (args, ctx) => {
|
||||
const arg = args.trim();
|
||||
if (arg === "clear") {
|
||||
writePlan(ctx, "");
|
||||
rmSync(planPath(ctx), { force: true });
|
||||
state = { ...state, isPlanMode: false };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
ctx.ui.notify(`Cleared ${PLAN_REL}.`, "info");
|
||||
ctx.ui.notify(`Deleted ${planRel(ctx)}.`, "info");
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith("judge")) {
|
||||
@@ -198,16 +210,16 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
if (!plan.trim()) return null;
|
||||
const why = drainResync();
|
||||
if (why) return resync(plan, PLAN_REL, why);
|
||||
if (why) return resync(plan, planRel(ctx), why);
|
||||
if (turnsStale < STALE_TURNS) return null;
|
||||
const goals = scanGoals(plan);
|
||||
if (goals.length === 0) {
|
||||
// Non-empty plan but no recognizable goal line: the harness would go silently inert (no
|
||||
// widget, no injection, no reminders). Say so instead -- cooperative but confused.
|
||||
return `<system-reminder>\n${PLAN_REL} exists but has no goal line pi-goals recognizes. A goal is a checkbox list line starting "goal:", e.g. "1. [ ] goal: <imperative>" ([ ] open, [/] active, [x] done, [-] cancelled). Reformat it if it's meant to be the plan.\n</system-reminder>`;
|
||||
return `<system-reminder>\n${planRel(ctx)} exists but has no goal line pi-goals recognizes. A goal is a checkbox list line starting "goal:", e.g. "1. [ ] goal: <imperative>" ([ ] open, [/] active, [x] done, [-] cancelled). Reformat it if it's meant to be the plan.\n</system-reminder>`;
|
||||
}
|
||||
if (!goals.some((g) => g.status === "active" || g.status === "open")) return null;
|
||||
return reminder(foldPlan(plan), PLAN_REL);
|
||||
return reminder(foldPlan(plan), planRel(ctx));
|
||||
}
|
||||
|
||||
// The one injection point: a transient user message on this LLM call only, never persisted. So
|
||||
@@ -236,13 +248,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
resyncReason = "The session was just compacted.";
|
||||
});
|
||||
|
||||
// Plan mode gate: block edit/write except on plan.md itself. bash stays open (guide, not gate).
|
||||
// Plan mode gate: block edit/write except on the plan file itself. bash stays open (guide, not gate).
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (!state.isPlanMode) return;
|
||||
if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) {
|
||||
const target = (event.input as { path?: string }).path;
|
||||
if (target && resolve(ctx.cwd, target) === resolve(planPath(ctx))) return;
|
||||
return { block: true, reason: `Plan mode is read-only: only ${PLAN_REL} may be written while drafting. Agree the goals first, then choose Ready.` };
|
||||
return { block: true, reason: `Plan mode is read-only: only ${planRel(ctx)} may be written while drafting. Agree the goals first, then choose Ready.` };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -251,7 +263,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
pi.on("agent_end", async (_event, ctx) => {
|
||||
if (!state.isPlanMode || !ctx.hasUI) return;
|
||||
while (scanGoals(readPlan(ctx)).length > 0) {
|
||||
const choice = await ctx.ui.select(`Plan drafted in ${PLAN_REL}. Ready?`, [
|
||||
const choice = await ctx.ui.select(`Plan drafted in ${planRel(ctx)}. Ready?`, [
|
||||
"Ready — start working the plan",
|
||||
"Open in $EDITOR — edit it myself",
|
||||
"Keep planning (reply to revise)",
|
||||
@@ -273,13 +285,6 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
// v1 wrote .pi/goals.md; v2 reads .pi/plan.md. Rename so old goals aren't silently invisible
|
||||
// (dogfood finding). Claude: one-time migration, delete once v1 files are gone from the wild.
|
||||
const v1Path = join(ctx.cwd, ".pi", "goals.md");
|
||||
if (existsSync(v1Path) && !existsSync(planPath(ctx))) {
|
||||
renameSync(v1Path, planPath(ctx));
|
||||
ctx.ui.notify(`Renamed .pi/goals.md -> ${PLAN_REL} (v2 filename).`, "info");
|
||||
}
|
||||
const last = ctx.sessionManager
|
||||
.getEntries()
|
||||
.filter((e: { type?: string; customType?: string }) => e.type === "custom" && e.customType === STATE)
|
||||
@@ -301,7 +306,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}),
|
||||
async execute(_id, params, signal, onUpdate, ctx) {
|
||||
const plan = readPlan(ctx);
|
||||
if (!plan.trim()) return result(`No plan file at ${PLAN_REL}.`, true);
|
||||
if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true);
|
||||
|
||||
const judgeModel = state.judgeModel ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null);
|
||||
onUpdate?.({ content: [{ type: "text", text: `Read-only judge (${judgeModel ?? "pi default"}) inspecting: ${params.goal}` }], details: {} });
|
||||
@@ -309,7 +314,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
// checked pre-emptively: null just means pi's configured default runs (buildJudgeArgs omits
|
||||
// --model), so accepted_inconclusive always means "the judge ran but failed", never "no model".
|
||||
let judgeRaw: JudgeResult | null = null;
|
||||
const outcome = await decideSignOff({ goal: params.goal, plan, judgeModel }, signal, async (task) => {
|
||||
const outcome = await decideSignOff({ goal: params.goal, plan, planRel: planRel(ctx), judgeModel }, signal, async (task) => {
|
||||
judgeRaw = await runJudge(task, judgeModel, ctx.cwd, signal);
|
||||
return judgeRaw;
|
||||
});
|
||||
@@ -333,8 +338,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
const ticked = tickGoal(updated, params.goal);
|
||||
updated = ticked ?? updated;
|
||||
tickNote = ticked
|
||||
? `\n\nGoal ticked [x] in ${PLAN_REL}.`
|
||||
: `\n\nNo exact goal line matched your wording -- tick it [x] in ${PLAN_REL} yourself.`;
|
||||
? `\n\nGoal ticked [x] in ${planRel(ctx)}.`
|
||||
: `\n\nNo exact goal line matched your wording -- tick it [x] in ${planRel(ctx)} yourself.`;
|
||||
}
|
||||
writePlan(ctx, appendLog(updated, `${stamp()} ${outcome.logEntry}${transcriptNote}`));
|
||||
updateWidget(ctx);
|
||||
@@ -373,6 +378,8 @@ export interface JudgeResult {
|
||||
export interface SignOffInput {
|
||||
goal: string;
|
||||
plan: string;
|
||||
/** The session's plan file, relative to cwd; the judge prompt names it. */
|
||||
planRel: string;
|
||||
judgeModel: string | null;
|
||||
}
|
||||
|
||||
@@ -395,7 +402,7 @@ export async function decideSignOff(
|
||||
signal: AbortSignal | undefined,
|
||||
runJudgeFn: (task: string) => Promise<JudgeResult>,
|
||||
): Promise<SignOffOutcome> {
|
||||
const task = judgeUser({ goal: input.goal, plan: input.plan, planPath: PLAN_REL });
|
||||
const task = judgeUser({ goal: input.goal, plan: input.plan, planPath: input.planRel });
|
||||
const judge = await runJudgeFn(task);
|
||||
|
||||
if (signal?.aborted) return { resultText: "Sign-off aborted.", isError: true, logEntry: null };
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* pi-goals v2 — all model-facing text, in flow order.
|
||||
*
|
||||
* Design: plan.md is for LLMs and the human, not for TypeScript. There is no parser and no schema;
|
||||
* Design: the plan file is for LLMs and the human, not for TypeScript. No parser and no schema;
|
||||
* the skeleton below is a convention the drafting prompt teaches, the working agent maintains with
|
||||
* its normal Edit tool, and the judge reads natively. The harness does three things for a
|
||||
* cooperative-but-confused model: memory (a transient re-send of the plan when it goes stale),
|
||||
@@ -13,8 +13,8 @@
|
||||
* session start and after a compaction, which is where the settled context is actually needed.
|
||||
*
|
||||
* Flow:
|
||||
* SETUP (plan mode) 1. planDrafting — draft goals into plan.md (read-only phase), sent once
|
||||
* EXEC, on cadence 2. reminder — the folded plan + upkeep nudge when plan.md went stale
|
||||
* SETUP (plan mode) 1. planDrafting — draft goals into the plan file (read-only), sent once
|
||||
* EXEC, on cadence 2. reminder — the folded plan + upkeep nudge when it went stale
|
||||
* EXEC, after compact 3. resync — the WHOLE file back, once
|
||||
* SIGN-OFF, agent-side 4. completeGoal* — the one blessed tool's description
|
||||
* SIGN-OFF, judge-side 5. judgeSystem/judgeUser — the one rigorous check
|
||||
@@ -24,7 +24,7 @@
|
||||
*/
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 1. planDrafting — SETUP, plan mode (read-only: edit/write blocked except plan.md)
|
||||
* 1. planDrafting — SETUP, plan mode (read-only: edit/write blocked except the plan file)
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
export const planDrafting = `\
|
||||
You are in plan mode. The objective may arrive through conversation, not as one up-front command.
|
||||
|
||||
@@ -5,10 +5,12 @@ import { decideSignOff, type JudgeResult } from "../src/index.js";
|
||||
// model still reaches runJudge (pi's configured default runs it), and the only producers of
|
||||
// accepted_inconclusive are the judge-error and no-VERDICT paths -- i.e. "the judge ran but failed",
|
||||
// never "no model". The judge runner is injected so these tests never spawn a real subprocess.
|
||||
const input = { goal: "x", plan: "# plan\n", planRel: ".pi/plan/s1.md", judgeModel: null };
|
||||
|
||||
describe("decideSignOff (fail-forward invariant)", () => {
|
||||
it("proceeds to runJudge even when judgeModel is null (no pre-emptive 'no model' inconclusive)", async () => {
|
||||
const runJudge = vi.fn().mockResolvedValue({ output: "VERDICT: accept\nall good" });
|
||||
const out = await decideSignOff({ goal: "x", plan: "# plan\n1. [ ] goal: x\n", judgeModel: null }, undefined, runJudge);
|
||||
const out = await decideSignOff({ ...input, plan: "# plan\n1. [ ] goal: x\n" }, undefined, runJudge);
|
||||
expect(runJudge).toHaveBeenCalledOnce(); // reached the judge -- no pre-emptive return on null model
|
||||
expect(out.isError).toBe(false);
|
||||
expect(out.logEntry).toContain("judge accept");
|
||||
@@ -16,7 +18,7 @@ describe("decideSignOff (fail-forward invariant)", () => {
|
||||
|
||||
it("a judge-subprocess error yields accepted_inconclusive with a 'ran but failed' reason", async () => {
|
||||
const runJudge = vi.fn().mockResolvedValue({ output: "", error: "judge subprocess exited 1" } satisfies JudgeResult);
|
||||
const out = await decideSignOff({ goal: "x", plan: "# plan\n", judgeModel: null }, undefined, runJudge);
|
||||
const out = await decideSignOff(input, undefined, runJudge);
|
||||
expect(runJudge).toHaveBeenCalledOnce();
|
||||
expect(out.isError).toBe(false); // accepted inconclusive, not a hard error that blocks the agent
|
||||
expect(out.resultText.toLowerCase()).toContain("accepted inconclusive");
|
||||
@@ -27,7 +29,7 @@ describe("decideSignOff (fail-forward invariant)", () => {
|
||||
|
||||
it("a judge timeout is also accepted_inconclusive (ran but failed)", async () => {
|
||||
const runJudge = vi.fn().mockResolvedValue({ output: "partial", error: "judge timed out after 600s" });
|
||||
const out = await decideSignOff({ goal: "x", plan: "# plan\n", judgeModel: null }, undefined, runJudge);
|
||||
const out = await decideSignOff(input, undefined, runJudge);
|
||||
expect(out.isError).toBe(false);
|
||||
expect(out.resultText.toLowerCase()).toContain("accepted inconclusive");
|
||||
expect(out.logEntry).toContain("ran but failed");
|
||||
@@ -37,7 +39,7 @@ describe("decideSignOff (fail-forward invariant)", () => {
|
||||
|
||||
it("no VERDICT line is accepted_inconclusive too (judge ran but didn't answer)", async () => {
|
||||
const runJudge = vi.fn().mockResolvedValue({ output: "I looked but forgot the verdict line" });
|
||||
const out = await decideSignOff({ goal: "x", plan: "# plan\n", judgeModel: null }, undefined, runJudge);
|
||||
const out = await decideSignOff(input, undefined, runJudge);
|
||||
expect(out.isError).toBe(false);
|
||||
expect(out.resultText).toContain("no VERDICT line");
|
||||
expect(out.logEntry).toContain("no VERDICT line");
|
||||
@@ -45,7 +47,7 @@ describe("decideSignOff (fail-forward invariant)", () => {
|
||||
|
||||
it("rejects when the judge returns VERDICT: reject", async () => {
|
||||
const runJudge = vi.fn().mockResolvedValue({ output: "VERDICT: reject\nmissing: evidence, tests" });
|
||||
const out = await decideSignOff({ goal: "x", plan: "# plan\n", judgeModel: "openrouter/claude" }, undefined, runJudge);
|
||||
const out = await decideSignOff({ ...input, judgeModel: "openrouter/claude" }, undefined, runJudge);
|
||||
expect(out.isError).toBe(true);
|
||||
expect(out.resultText).toContain("REJECTED");
|
||||
expect(out.resultText).toContain("evidence, tests");
|
||||
@@ -56,7 +58,7 @@ describe("decideSignOff (fail-forward invariant)", () => {
|
||||
const runJudge = vi.fn().mockResolvedValue({ output: "VERDICT: accept" });
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const out = await decideSignOff({ goal: "x", plan: "# plan\n", judgeModel: null }, ctrl.signal, runJudge);
|
||||
const out = await decideSignOff(input, ctrl.signal, runJudge);
|
||||
expect(out.logEntry).toBeNull();
|
||||
expect(out.isError).toBe(true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user