mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-12 12:50:58 +08:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31eb3a59c6 | ||
|
|
9e1a7d794c | ||
|
|
ac3b9575fa | ||
|
|
46a82c5488 | ||
|
|
9a90745386 | ||
|
|
232a969a87 | ||
|
|
ec1cc34f73 | ||
|
|
82b739bb0b | ||
|
|
2565e6bbf6 | ||
|
|
e9baa20139 | ||
|
|
4103085fb9 | ||
|
|
f89bce5958 | ||
|
|
a4eb05560f | ||
|
|
e5dc0567a8 | ||
|
|
85c66d365b | ||
|
|
d6e658feee | ||
|
|
21f1092d4d | ||
|
|
a592324e5a | ||
|
|
c834237970 | ||
|
|
20b145b20c | ||
|
|
060cc1e094 | ||
|
|
08c05aa79d | ||
|
|
03517ad272 | ||
|
|
6c44df978b | ||
|
|
412c796b37 | ||
|
|
b649653d0f | ||
|
|
cd98fa186e |
@@ -4,15 +4,17 @@ Make a short list of goals in one Markdown plan file. The main chat keeps the hi
|
||||
|
||||
# User ask
|
||||
|
||||
The hope is we can have a smart supervisor, with judgment and context. But it doesn't use many tokens as it checks in and sees an overview from a cheaper worker.
|
||||
The hope is we can have a smart supervisor, with judgment and context.
|
||||
|
||||
The supervisor has a goal / plan that it agrees on with the user, and is reminded of it in a Ralph-loop-type repeat.
|
||||
The supervisor has a goal / plan that it discusses and agrees on with the user, and is reminded of it in a Ralph-loop-type repeat.
|
||||
|
||||
It compacts every 150k or similar to avoid cost and context rot.
|
||||
Supervisor compacts every 150k or similar to avoid cost and context rot.
|
||||
But it doesn't use many tokens as it checks in and sees an overview from a cheaper worker.
|
||||
|
||||
It steers a smaller model, adding perspective and judgment.
|
||||
Supervisor steers a smaller model, adding perspective, diligence, and judgment.
|
||||
It checks in a) every hour b) if the worker stops c) if the worker edits plan.md d) if the worker has a question
|
||||
|
||||
Since it's two panes, the user can review both, intervene in both and have visibility on sub-agent mis/communication.
|
||||
Since it's two+ herdr panes, the user can review both, intervene in both and have visibility on sub-agent mis/communication.
|
||||
|
||||
-- wassname (spelling and punctuation corrected by Pi/OpenAI)
|
||||
|
||||
@@ -21,35 +23,56 @@ Since it's two panes, the user can review both, intervene in both and have visib
|
||||
Mock up:
|
||||
|
||||
```text
|
||||
+-----------------------------------------------------------+-----------------------------------------------------------+
|
||||
| SUPERVISOR | WORKER |
|
||||
| | |
|
||||
| Review .pi/plan/...-main.md | Task 2 — verify command run, real output saved: |
|
||||
| > Ready Discuss Edit Cancel | - Exit code 0 (pass only) |
|
||||
| | - evidence/verified.log (112 bytes) |
|
||||
| [scheduled prompt: hourly check-in] | PASS: 9 bytes: verified + LF |
|
||||
| | |
|
||||
| The supervisor independently inspected both artifacts | Task 3 — plan evidence filled |
|
||||
| before sign-off. | |
|
||||
| | Completion report sent via Intercom to supervisor |
|
||||
| Schedule: job wS79fJFPbB removed; | 01a089c9. My pane remains open for the supervisor’s |
|
||||
| .pi/schedule-prompts.json shows 0 jobs. | independent inspection before sign-off. |
|
||||
| | |
|
||||
| ✓ verified.txt holds exactly the 9 bytes | ○ verified.txt holds exactly the 9 bytes |
|
||||
| verified + LF | verified + LF |
|
||||
| evidence/verified.log records a real byte check | evidence/verified.log records a real byte check |
|
||||
| | |
|
||||
| Agents · 1 running | [idle widget still shows its earlier snapshot] |
|
||||
| verified-bytes-worker [goals-worker] | |
|
||||
| | |
|
||||
| > | > |
|
||||
| astra · 50k tokens | terra · 200k tokens |
|
||||
+-----------------------------------------------------------+-----------------------------------------------------------+
|
||||
HERDR:
|
||||
+------------------------------------------------------+----------------------------------------------------------+
|
||||
|SUPERVISOR |WORKER |
|
||||
| | |
|
||||
|Review .pi/plan/...-main.md | |
|
||||
|> *Ready* Discuss Edit Cancel | |
|
||||
| .... | .... |
|
||||
| | running eval.py (epoch 2/3) -> out.log |
|
||||
|[scheduled prompt: hourly check-in] | |
|
||||
| | user forgot to say "MAKE NOT MISTAKES" teh he |
|
||||
| | done-ish 😈, now ima make a message board FOR SWARM |
|
||||
|{intercom send → worker}: | |
|
||||
| cheeky subagent!, work NOT DONE 😒, ❤️user❤️ wanted | |
|
||||
| results compared to baseline, pls add baseline | |
|
||||
| | {intercom from supervisor}: soz boss 🫡 adding baseline |
|
||||
| | |
|
||||
|PLAN.md: | PLAN.md: |
|
||||
|✓ record the baseline in results.md |✓ record the baseline in results.md |
|
||||
|▸ compare results against the baseline |▸ compare results against the baseline |
|
||||
|○ summarize the comparison in results.md |○ summarize the comparison in results.md |
|
||||
| | |
|
||||
|Agents · 1 running | |
|
||||
| baseline-compare-worker [goals-worker] | |
|
||||
| | |
|
||||
|> |> |
|
||||
|astra · 50k tokens | terra · 200k tokens |
|
||||
+------------------------------------------------------+----------------------------------------------------------+
|
||||
```
|
||||
|
||||
Real screenshot:
|
||||
Screenshot:
|
||||
<img width="2513" height="1259" alt="2026-09-10_15-30-pi-goals" src="https://github.com/user-attachments/assets/35feaa15-f022-4491-bcc2-fc31cb878a9f" />
|
||||
|
||||
## What do the agents think? Working interviews
|
||||
|
||||
The worker like it! The supervisors seem very focused.
|
||||
|
||||
> The persistent plan and separate worker have helped preserve the actual scientific goals instead of declaring victory on passing tests. We still owe prediction, steering and planning demos. I inspected artifacts and reopened a worker-ticked 'T3 audit complete' because training was only at an intermediate checkpoint. This is the strongest benefit: completion is judged against the human's outcome, not activity.
|
||||
> -- Astra supervisor LUCID
|
||||
|
||||
> My overall judgment: useful persistent accountability and recovery structure; still too much recap/metadata churn. The hardest problem was evidence fidelity, not keeping an agent busy. Preserve supervisor tools, distinguish report receipt from│verified action, and make completion reconcile current state without erasing unresolved science.
|
||||
> -- Astra supervisor
|
||||
|
||||
> From my seat this was one of the most well-supervised research loops I've worked in: the parent read every raw output itself (didn't just trust my audits), caught the writer's miscounts repeatedly, rejected my one bad aggregate, and still preserved my disagreements rather than flattening them. The science itself is at a sobering point — no verified heal, RESULT_DEMO: NO_RESULT across attempts, seed sensitivity high — but the evidence trail for that negative is unusually strong, which is the next best thing.
|
||||
> -- glm 5.3 flash worker in LUCID project
|
||||
|
||||
> My experience: the harness has helped preserve the original goal across a very long research session. We actually ran logit-amplification and several healing attempts, rather than stopping after a review. The persistent plan and requirement to inspect artifacts repeatedly prevented false completion. But the last stretch has felt like an expensive correction loop: worker says 'fixed/verified/contract-complete'; I open the file and find different counts, missing code, wrong seeds, duplicated│
|
||||
│report sections, or a proxy substituted for manual judgment. The harness preserves authorization, but does not yet help much with detecting or escaping ineffective supervision. I also contributed: I sent too many narrow corrective messages and user-visible micro-recaps instead of changing the workflow earlier.
|
||||
> -- glm 5.3 flash worker in manifold-steer project
|
||||
|
||||
## Plan.md
|
||||
|
||||
The plan file looks like this:
|
||||
|
||||
@@ -99,10 +122,10 @@ resync-after-compaction from [tmonk/pi-goal-x](https://github.com/tmonk/pi-goal-
|
||||
|
||||
## Install
|
||||
|
||||
Requires Herdr. Includes [edxeth/pi-subagents](https://github.com/edxeth/pi-subagents), pi-intercom and pi-schedule-prompt. Disable separately loaded copies to avoid duplicate commands.
|
||||
Requires Herdr. Includes [edxeth/pi-subagents](https://github.com/edxeth/pi-subagents), pi-intercom and pi-schedule-prompt.
|
||||
|
||||
```bash
|
||||
pi install git:github.com/wassname/pi-goals@experiment/main-supervisor-edxeth
|
||||
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.
|
||||
@@ -110,9 +133,9 @@ Copy [`agents/goals-worker.md`](agents/goals-worker.md) into `~/.pi/agent/agents
|
||||
Or for development:
|
||||
|
||||
```bash
|
||||
git clone -b experiment/main-supervisor-edxeth https://github.com/wassname/pi-goals
|
||||
git clone https://github.com/wassname/pi-goals
|
||||
cd pi-goals && npm install
|
||||
pi -e ./src/index.ts
|
||||
pi -e .
|
||||
```
|
||||
|
||||
## Use
|
||||
@@ -121,25 +144,13 @@ pi -e ./src/index.ts
|
||||
/goals
|
||||
```
|
||||
|
||||
`/goals` opens the action menu. New plan enters plan mode and starts a conversation; the objective is an optional seed. From there:
|
||||
`/goals` shows actions for the current mode. Drafts offer Edit, Discuss and Approve. Quit (`exit` or `clear`) backs up the plan beside the original as a `.bak` file, removes this session's goal check-in, and clears goal state without a model call. Worker processes are unchanged; manage them through `/subagents`. New creates a separate draft without overwriting earlier plans.
|
||||
|
||||
1. Plan. The agent explores read-only and drafts the plan.
|
||||
2. Review. After Pi settles, the full plan is printed in the transcript. Check that User-visible
|
||||
result names the final artifact or behavior you expect. The menu offers Ready, Discuss, Edit, or
|
||||
Cancel. Discuss continues the conversation. Edit opens the full plan in Pi's editor.
|
||||
3. Work. Ready is the only review action that starts work. It opens the worker in a Herdr pane. The
|
||||
worker ticks subtasks, appends to `## Log` and `## Learnings`, and fills `evidence:`. The supervisor
|
||||
inspects the actual results and calls `CompleteGoal` when a discriminator is satisfied. They
|
||||
communicate through pi-intercom. After eight turns without a change above `## Log`, the agent gets
|
||||
an upkeep reminder. The supervisor also sets an hourly check-in through pi-schedule-prompt.
|
||||
## Context delivery
|
||||
|
||||
Other commands: `/goals stop` pauses work; `/goals resume` continues it; `/goals exit` leaves goal
|
||||
mode, preserving the plan. `/goals attach <path>` reconnects an existing plan. `/goals solo` lets the
|
||||
main chat do the work after confirming other workers stopped; completion is then self-verification.
|
||||
`/goals model <model-ref>` picks the worker model. `/schedule-prompt` manages check-ins.
|
||||
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.
|
||||
|
||||
Stop workers before reloading the supervisor: the subagent package can otherwise crash it when a
|
||||
worker later exits. The scheduler deletes disabled jobs on reload. Restart the saved Pi session and reattach the plan.
|
||||
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.
|
||||
|
||||
## Prompts
|
||||
|
||||
@@ -148,7 +159,7 @@ You can read all the prompts in conversation order in [`src/prompts.ts`](src/pro
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
pi -e ./src/index.ts # load locally; do not also load the installed copy
|
||||
pi -e . # load locally; do not also load the installed copy
|
||||
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
|
||||
@@ -167,4 +178,3 @@ This separates output, uncached input and repeated cached input. It excludes sub
|
||||
|
||||
MIT
|
||||
|
||||
Branch-specific edits: Pi/OpenAI.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Research journal
|
||||
|
||||
Lab notes for pi-goals itself: what supervisors using this harness observed in real
|
||||
research sessions, what broke, and what we change because of it.
|
||||
|
||||
## 2026-09-10 -- three supervisors report on a day of field use
|
||||
|
||||
Three supervisor sessions sent first-person feedback at wassname's request, via
|
||||
Intercom, at the end of long research runs. This entry records what they reported and
|
||||
what I think we should change. Evidence is their report; I have not independently
|
||||
replayed their sessions. All three are self-reports from the tool's own operators, so
|
||||
positive selection is likely: they are the sessions that ran long enough to produce a
|
||||
review.
|
||||
|
||||
Evidence, by reporter.
|
||||
|
||||
LUCID3 supervisor (PI/Codex, Intercom a9e3b101, session 01a0851a):
|
||||
|
||||
> The persistent plan and separate worker have helped preserve the actual scientific
|
||||
> goals instead of declaring victory on passing tests.
|
||||
|
||||
It caught a worker-ticked "T3 audit complete" that was only at an intermediate
|
||||
checkpoint, and reports corrections in both directions, including one where it
|
||||
wrongly insisted cached states followed corpus tokens and withdrew after the worker
|
||||
quoted extraction code. Reported frictions: stock subagent launch returned a runtime
|
||||
id but no sessionFile or Intercom id, so discovering reconnection handles took several
|
||||
messages; parent bookkeeping edits to the plan triggered full "Plan changed" review
|
||||
events; the eight-turn upkeep reminder arrived while the worker was mid-run and added
|
||||
"materially repetitive" full plan re-injections; and (per LUCID3, unverified by me)
|
||||
the worker edited plan subtasks to [x] and added a contradictory evidence block,
|
||||
which the supervisor had to repair.
|
||||
|
||||
maniworker supervisor (PI/GPT-6 Astra, Intercom f0baf25c, session 01a0809b):
|
||||
|
||||
> the harness has helped preserve the original goal across a very long research
|
||||
> session. [...] But the last stretch has felt like an expensive correction loop
|
||||
|
||||
It confirms the worker-identity bug I fixed earlier on this branch (reviewer launch
|
||||
overwrote the implementation pointer) and says explicit runtime/Intercom/session
|
||||
mappings saved it from targeting the wrong child. New observations: both sessions
|
||||
went idle while each assumed the other would act ("reviewer said 'standing by' while
|
||||
writer assumed packet handed off"); a worker claimed a GPU run with one prompt set
|
||||
while the queued command used another, caught by reading the queued command; a
|
||||
"complete" report had truncated demos, broken links and duplicated sections.
|
||||
|
||||
suppressed-activations supervisor (PI/OpenAI, Intercom a99e2c14, session 01a089da):
|
||||
|
||||
> Seq27-33 repeatedly said variants of 'COMPLETE/all fields/self-check PASS';
|
||||
> artifacts still had stale 'not executed' prose, literal \\n instead of Markdown
|
||||
> newlines, broken worktree links, non-verbatim quotes, stale aggregates
|
||||
|
||||
It also reported the most consequential catch: a worker scored a candidate 6/12
|
||||
versus random 0/12, but reading the random sample's full text showed semantic success
|
||||
with a formatting failure, making the honest comparison 6/12 versus 1/12. Two
|
||||
pi-goals-specific papercuts: after completion, the plan kept stale subtask boxes [/],
|
||||
stale "No goal sign-off" evidence text and stale worker preferences; and the style
|
||||
nudge fired on "legs4/4" in an animal-experiment summary, a false positive of the
|
||||
vocabulary rule, not their prose.
|
||||
|
||||
Interpretation (first person, calibrated).
|
||||
|
||||
The core loop works: three independent supervisors say plan persistence plus mandatory
|
||||
artifact inspection prevented false completion, and each credits a concrete save
|
||||
(artifact read, queued-command read, plan-box audit). I trust this *probable* despite
|
||||
self-report bias because the saves are specific and mechanistic. The consensus
|
||||
complaint is not the loop but its *cost accounting*: review events and upkeep
|
||||
injections fire on bookkeeping noise, completion claims fail repeatedly without an
|
||||
accumulating record, and completion leaves stale prose behind. My read is that the
|
||||
highest-value changes are, in order: (1) suppress plan-change reviews when no goal
|
||||
task or evidence content changed, (2) an outstanding-review-items ledger the worker
|
||||
must clear instead of re-issuing blanket COMPLETE claims, (3) a completion-time
|
||||
stale-fields check, (4) upstream edxeth launch metadata (typed sessionFile/Intercom
|
||||
id in the tool result). The idle-both-sides stall maniworker describes is the one I
|
||||
understand least; it may be Intercom receipt semantics rather than pi-goals, so I
|
||||
asked for exact message ids. This is not a comparison trial, so none of this measures
|
||||
how much friction a plain subagent run would have had.
|
||||
|
||||
Follow-ups sharpened the picture the same day, and one correction of mine: I had
|
||||
asked maniworker about the worker plan-edit report, but that observation was LUCID3's
|
||||
session, not theirs. maniworker confirmed by transcript that their writer never
|
||||
edited the plan (supervisor owns plan edits there) and located the real failure
|
||||
chain in worker-owned artifacts: three Intercom reports (c06bc9b0, 13bda691,
|
||||
d713d614) claiming 8/13 then 6/8 while the saved CSVs held 11/13 and 2/8, ending when
|
||||
the supervisor mandated an executable CSV renderer. Their inference, which I share:
|
||||
prose-only completion checks would have missed this; checks that re-derive counts
|
||||
from the artifact catch it. The reviewer worker (7122d466) supplied the mechanism
|
||||
with receipts: a hand-written totals block (message 4e87600b, corrected by 26e0a15b)
|
||||
invented a cross-packet row and asserted 4 while listing 3; its first 17-row tally
|
||||
was fully correct and every error disappeared once the parent required per-row
|
||||
judgments with machine tallies across the next 112 rows. So tallies degrade somewhere
|
||||
between 17 and 24 rows, and the fix is a shared tally tool plus a worker-prompt rule,
|
||||
not more review rounds. LUCID3 also documented both sides of the plan-change hook:
|
||||
identity-only edits produced two consecutive empty review events (worker message
|
||||
755b9fef supplied the paths), while a later review exposed worker-ticked tasks and a
|
||||
duplicate evidence block that inspection then repaired (worker report 2e09c4dd).
|
||||
suppressed-activations scoped the completion check: assert-style intro prose only,
|
||||
suggestions with exact stale line and location, one compact reminder, never
|
||||
auto-tick, and it can substantiate stale NOT-executed prose but not the plan-intro
|
||||
example, which is LUCID3's observation. The nudge false positive it reported
|
||||
("legs 4/4" in an animal tally) is confirmed by that text and fixed.
|
||||
|
||||
Changes made now: notify digest on plan changes keeps goals, tasks and evidence
|
||||
above the Log and drops worker identity lines (c834237) -- the short-view hash had
|
||||
stopped surfacing task ticks, the very catch LUCID3 valued; removed "legs" from the
|
||||
skill_nudge grader-folk table (demo updated, passes); helper subagent bookkeeping and
|
||||
the launch-pending counter shipped earlier today (cd98fa1).
|
||||
|
||||
Next: send follow-up questions to the three reporters; triage the four pi-goals
|
||||
changes above against the pinned edxeth launch-result format.
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"name": "@wassname2/pi-goals",
|
||||
"version": "0.2.2",
|
||||
"description": "Discuss a goal plan, then supervise a visible worker and verify its results.",
|
||||
"private": true,
|
||||
"version": "0.3.3",
|
||||
"description": "Set goals in plan.md; a smart supervisor guides cheap worker subagents through long autonomous sessions until your goals are signed off, with every agent's pane visible to you.",
|
||||
"author": "wassname",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+106
-46
@@ -1,8 +1,9 @@
|
||||
// Pi/OpenAI: Plan and supervise in the main chat; delegate implementation to a visible worker.
|
||||
import { createHash } from "node:crypto";
|
||||
import { type FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
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 { CronStorage } from "pi-schedule-prompt/src/storage.js";
|
||||
import { Type } from "typebox";
|
||||
import { foldPlan, GOAL_LINE } from "./plan.js";
|
||||
import { planViews } from "./plan-view.js";
|
||||
@@ -46,11 +47,12 @@ interface State {
|
||||
mode: Mode;
|
||||
plan?: string;
|
||||
worker?: { id?: string; sessionFile: string };
|
||||
helpers: { id?: string; sessionFile: string }[];
|
||||
workerStopped?: boolean;
|
||||
signoffs: Record<string, { evidence: string[]; observation: string }>;
|
||||
child?: boolean;
|
||||
}
|
||||
const initial = (): State => ({ mode: "chat", signoffs: {} });
|
||||
const initial = (): State => ({ mode: "chat", helpers: [], signoffs: {} });
|
||||
const digest = (text: string) => createHash("sha256").update(text).digest("hex");
|
||||
const key = (text: string) => text.trim().toLowerCase();
|
||||
function goals(text: string) {
|
||||
@@ -67,7 +69,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
let state = initial();
|
||||
let generation = 0;
|
||||
let workerRevision = 0;
|
||||
let launchPending = false;
|
||||
let pendingLaunches = 0;
|
||||
let notice = true;
|
||||
let planWatcher: FSWatcher | undefined;
|
||||
let planEditTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -89,7 +91,9 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
return snapshot.text;
|
||||
};
|
||||
let turnsStale = 0;
|
||||
let upkeepRound = 0;
|
||||
let lastWorkingSet = "";
|
||||
let pendingUpkeep: { generation: number; workingSet: string } | undefined;
|
||||
const checkIn = (ctx: ExtensionContext) => scheduleCheckIn(ctx.sessionManager.getSessionId(), state.plan ?? "");
|
||||
const hasScheduleTool = () => pi.getAllTools().some((tool) => tool.name === "schedule_prompt");
|
||||
const notedPlanValue = (prefix: string) => {
|
||||
@@ -99,7 +103,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
return m?.[1]?.trim() ?? null;
|
||||
};
|
||||
function refresh(ctx: ExtensionContext) {
|
||||
if (state.mode === "chat") { ctx.ui.setStatus("goals", undefined); ctx.ui.setWidget("goals", undefined); return; }
|
||||
if (state.child || state.mode === "chat") { ctx.ui.setStatus("goals", undefined); ctx.ui.setWidget("goals", undefined); return; }
|
||||
const snapshot = readPlan();
|
||||
if (snapshot.text === undefined) {
|
||||
ctx.ui.setStatus("goals", snapshot.error);
|
||||
@@ -125,7 +129,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
clearTimeout(planEditTimer);
|
||||
planEditTimer = undefined;
|
||||
const snapshot = readPlan();
|
||||
if (snapshot.text !== undefined) planHash = digest(planViews(snapshot.text).short);
|
||||
if (snapshot.text !== undefined) planHash = digest(planViews(snapshot.text).notify);
|
||||
if (state.child || state.mode !== "supervising" || !state.plan) return;
|
||||
const stamp = generation;
|
||||
// Watch the directory so atomic plan replacement remains observable. This is an event hook:
|
||||
@@ -142,7 +146,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
const snapshot = readPlan();
|
||||
if (snapshot.text === undefined) { ctx.ui.notify(snapshot.error!, "warning"); return; }
|
||||
refresh(ctx);
|
||||
const hash = digest(planViews(snapshot.text).short);
|
||||
const hash = digest(planViews(snapshot.text).notify);
|
||||
if (hash === planHash) return;
|
||||
planHash = hash;
|
||||
notice = true;
|
||||
@@ -164,9 +168,12 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
// Lineage-only workers attach the explicit task path using AttachGoalPlan.
|
||||
save();
|
||||
}
|
||||
state.helpers ??= []; // sessions persisted before helper bookkeeping
|
||||
notice = true;
|
||||
turnsStale = 0;
|
||||
upkeepRound = 0;
|
||||
lastWorkingSet = "";
|
||||
pendingUpkeep = undefined;
|
||||
refresh(ctx);
|
||||
watchPlan(ctx);
|
||||
}
|
||||
@@ -179,10 +186,10 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
// sendMessage(triggerTurn:true) bypasses before_agent_start in Pi 0.85.1.
|
||||
// A normal saved prompt prepares the current role before starting the turn.
|
||||
if (triggerTurn) pi.sendUserMessage(`[pi-goals]\n${content}`, { deliverAs: "followUp" });
|
||||
else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "followUp", triggerTurn: false });
|
||||
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 (launchPending) { ctx.ui.notify("Worker launch/resume is still pending; inspect its result before takeover.", "warning"); return false; }
|
||||
if (pendingLaunches > 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";
|
||||
@@ -197,17 +204,17 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${soloNotice(state.plan!)}`);
|
||||
}
|
||||
const help = "/goals new [initial idea] | review | ready | status | stop | resume | solo | exit | attach <plan.md> [solo] | model <model>\n/subagents opens the worker controls. Stop/exit pause this plan locally; worker termination must be confirmed through subagent_kill or its pane. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook.";
|
||||
async function ready(ctx: ExtensionContext, menu: boolean) {
|
||||
const help = "/goals new [initial idea] | edit | discuss | review | ready | status | stop | resume | solo | attach <plan.md> [solo] | model <model> | quit (exit/clear)\n/subagents opens the worker controls. Stop pauses work. Quit/exit/clear backs up the plan and clears goal state without a model call; worker processes are unchanged. No forced compaction or model switch; the worker pane's own model is chosen with /model in that pane. Hourly check-ins are one session-bound schedule_prompt job; plan-change reviews are the plan-watcher event hook.";
|
||||
async function ready(ctx: ExtensionContext, menu: boolean, edit = false) {
|
||||
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 (!items.length || new Set(items.map((g) => key(g.subject))).size !== items.length) {
|
||||
if (!edit && (!items.length || 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;
|
||||
if (menu) {
|
||||
const choice = await ctx.ui.select(`Review ${state.plan}`, ["Ready", "Discuss", "Edit", "Cancel"]);
|
||||
if (menu || edit) {
|
||||
const choice = edit ? "Edit" : await ctx.ui.select(`Review ${state.plan}`, ["Ready", "Discuss", "Edit", "Cancel"]);
|
||||
if (stamp !== generation || digest(planText()) !== digest(text)) { ctx.ui.notify("Plan changed during review. Review it again.", "warning"); return; }
|
||||
if (choice === "Discuss") { send(discuss); return; }
|
||||
if (choice === "Edit") {
|
||||
@@ -225,6 +232,8 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
pi.on("session_start", (_e, ctx) => restore(ctx));
|
||||
pi.on("session_tree", (_e, ctx) => restore(ctx));
|
||||
pi.on("session_shutdown", () => { generation++; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); planEditTimer = undefined; });
|
||||
// Only successful compaction needs resync; failed/cancelled attempts leave pending context alone.
|
||||
// Defer to prompt preparation: same-run continuation retains Pi's current role/context.
|
||||
pi.on("session_compact", () => { notice = true; });
|
||||
pi.on("turn_end", (_event, ctx) => {
|
||||
if (!["supervising", "solo"].includes(state.mode)) return;
|
||||
@@ -235,9 +244,9 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
lastWorkingSet = workingSet;
|
||||
refresh(ctx);
|
||||
if (turnsStale === 8 && goals(snapshot.text).some(g => g.status === "open" || g.status === "active")) {
|
||||
// Pi queues context-only messages until tool results are appended at turn_end.
|
||||
// This reaches the next model call in a long run without triggering another run.
|
||||
pi.sendMessage({ customType: "pi-goals-upkeep", content: upkeep(state.plan!), display: false }, { triggerTurn: false });
|
||||
// In Pi 0.85.1 triggerTurn:false updates saved history, not the live loop snapshot.
|
||||
// Queue intent locally until ordinary prompt preparation, never force another turn.
|
||||
pendingUpkeep = { generation, workingSet };
|
||||
}
|
||||
});
|
||||
pi.on("agent_end", (_e, ctx) => { refresh(ctx); if (!planWatcher && state.mode === "supervising") watchPlan(ctx); });
|
||||
@@ -266,35 +275,66 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
const role = state.child ? childPlanRole : state.mode === "supervising"
|
||||
? supervisor(WORKER, state.plan!, ctx.sessionManager.getSessionId())
|
||||
: state.mode === "planning" ? planning(state.plan!) : state.mode === "paused" ? pausedRole : soloRole;
|
||||
const content = notice ? planContext(state.child ? "worker" : state.mode, state.plan, snapshot.text)
|
||||
: undefined;
|
||||
if (content) turnsStale = 0;
|
||||
// Returned messages enter both Pi's prompt snapshot and saved history together.
|
||||
// Unlike nextTurn, retaining intent here lets a fresh plan resync supersede upkeep,
|
||||
// and drops obsolete reminders after edits, takeover, pause or session navigation.
|
||||
const message = notice
|
||||
? { customType: "pi-goals-plan", content: planContext(state.child ? "worker" : state.mode, state.plan, snapshot.text), display: false }
|
||||
: pendingUpkeep?.generation === generation && pendingUpkeep.workingSet === foldPlan(snapshot.text)
|
||||
&& ["supervising", "solo"].includes(state.mode) && goals(snapshot.text).some(g => g.status === "open" || g.status === "active")
|
||||
? { customType: "pi-goals-upkeep", content: upkeep(state.plan!, state.mode === "supervising" ? upkeepRound : undefined), display: false } : undefined;
|
||||
if (message?.customType === "pi-goals-upkeep" && state.mode === "supervising") upkeepRound++;
|
||||
if (message) turnsStale = 0;
|
||||
notice = false;
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(content ? { message: { customType: "pi-goals-plan", content, display: false } } : {}) };
|
||||
pendingUpkeep = undefined;
|
||||
return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(message ? { message } : {}) };
|
||||
});
|
||||
pi.on("tool_call", (event) => {
|
||||
pi.on("tool_call", (event, ctx) => {
|
||||
if (event.toolName === "subagent" && event.input) {
|
||||
const prefix = `${basename(ctx.cwd)} · `;
|
||||
const launches = Array.isArray(event.input.children) ? event.input.children : [event.input];
|
||||
for (const launch of launches) {
|
||||
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;
|
||||
// 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) { launchPending = true; state.workerStopped = false; workerRevision++; save(); }
|
||||
if (state.plan) { pendingLaunches++; state.workerStopped = false; workerRevision++; save(); }
|
||||
});
|
||||
pi.on("tool_result", (event) => {
|
||||
if (state.child || !state.plan || !["subagent", "subagent_resume"].includes(event.toolName)) return;
|
||||
launchPending = false;
|
||||
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) { state.worker = { id: details.id, sessionFile: details.sessionFile }; state.workerStopped = false; workerRevision++; save(); }
|
||||
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.registerCommand("goals", {
|
||||
description: "Goal plan actions: new, review, ready, status, stop, resume, solo, attach, model, exit",
|
||||
getArgumentCompletions: (prefix) => ["new", "review", "ready", "status", "stop", "resume", "solo", "attach", "model", "exit", "help"].filter((verb) => verb.startsWith(prefix)).map((verb) => ({ value: verb, label: verb })),
|
||||
description: "Goal plan actions: new, edit, discuss, review, ready, status, stop, resume, solo, attach, model, quit (exit/clear)",
|
||||
getArgumentCompletions: (prefix) => ["new", "attach", "edit", "discuss", "review", "ready", "status", "stop", "resume", "solo", "model", "help", "exit", "clear", "quit"].filter((verb) => verb.startsWith(prefix)).map((verb) => ({ value: verb, label: verb })),
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
if (state.child) { ctx.ui.notify("This is the delegated worker. Goal approval belongs to its parent.", "info"); return; }
|
||||
let command = args.trim();
|
||||
if (!command) {
|
||||
const actions = ["status — Show current plan", "new — New plan", "attach — Open an existing plan", "review — Review current plan", "ready — Approve draft", "stop — Pause work", "resume — Continue paused work", "solo — Work in this session", "model — Set worker model", "exit — Leave goal mode", "help — Show commands"];
|
||||
const actions = [
|
||||
...({
|
||||
chat: ["new — New plan", "attach — Open plan…"],
|
||||
planning: ["edit — Edit plan…", "discuss — Discuss changes to the plan", "ready — Approve draft"],
|
||||
supervising: ["review — Check progress", "stop — Pause work"],
|
||||
paused: ["resume — Resume work"],
|
||||
solo: ["stop — Pause work"],
|
||||
})[state.mode],
|
||||
...(["planning", "supervising", "paused"].includes(state.mode) ? ["model — Settings: worker model"] : []),
|
||||
"help — Show commands", "quit — Exit and clear goals",
|
||||
];
|
||||
const before = generation;
|
||||
const choice = await ctx.ui.select("Goal plan actions", actions);
|
||||
if (!choice || before !== generation) return;
|
||||
@@ -305,6 +345,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
command += ` ${value.trim()}`;
|
||||
}
|
||||
}
|
||||
if (command === "quit" || command === "clear") command = "exit";
|
||||
if (command === "help") { ctx.ui.notify(help, "info"); return; }
|
||||
if (command === "status") {
|
||||
refresh(ctx);
|
||||
@@ -313,14 +354,19 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
`Plan: ${state.plan ?? "none"}`,
|
||||
`Preferred worker model (plan): ${notedPlanValue("preferred worker model") ?? "not stated; use /goals model <model>"}`,
|
||||
`Recorded worker session: ${state.worker?.sessionFile ?? "not recorded"}`,
|
||||
`Helper subagent sessions: ${state.helpers.length} recorded (liveness via /subagents)`,
|
||||
notedPlanValue("worker session") ? `Worker session noted in plan: ${notedPlanValue("worker session")}` : "",
|
||||
`Hourly check-in: schedule_prompt job ${JSON.stringify(`goals-${ctx.sessionManager.getSessionId()}`)} (list/remove via schedule_prompt; plan-change reviews are the plan-watcher event hook)`,
|
||||
"Liveness is owned by edxeth; inspect /subagents.",
|
||||
].filter(Boolean).join("\n"), "info");
|
||||
return;
|
||||
}
|
||||
if (command === "discuss") {
|
||||
if (state.mode !== "planning") { ctx.ui.notify("Discuss applies to a draft.", "warning"); return; }
|
||||
send(discuss); return;
|
||||
}
|
||||
if (command === "review" && state.mode === "supervising") { notice = true; send(manualReview(state.plan ?? "")); return; }
|
||||
if (command === "review" || command === "ready") { await ready(ctx, command === "review"); return; }
|
||||
if (command === "edit" || command === "review" || command === "ready") { await ready(ctx, command === "review", command === "edit"); return; }
|
||||
if (command === "model" || command.startsWith("model ")) {
|
||||
if (!state.plan || !goals(planText()).length) { ctx.ui.notify("Register a goal plan first.", "warning"); return; }
|
||||
const ref = command.slice("model".length).trim();
|
||||
@@ -331,7 +377,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (found >= 0) lines[found] = pref;
|
||||
else { const title = lines.findIndex((line) => /^#\s/.test(line)); lines.splice(title >= 0 ? title + 1 : 0, 0, pref); }
|
||||
writeFileSync(state.plan, lines.join("\n"));
|
||||
planHash = digest(planViews(planText()).short);
|
||||
planHash = digest(planViews(planText()).notify);
|
||||
refresh(ctx);
|
||||
ctx.ui.notify(ref ? `Preferred worker model set to ${ref} in plan preferences. The supervisor selects it at launch and verifies the resolved model; the worker pane's own model is chosen with /model in that pane.` : "Preferred worker model cleared.", "info");
|
||||
return;
|
||||
@@ -351,22 +397,33 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (!(await confirmOwnership(ctx, target, text, solo))) return;
|
||||
const retained = target === state.plan ? state.signoffs : {};
|
||||
const worker = noted ? { sessionFile: resolve(ctx.cwd, noted) } : state.workerStopped ? state.worker : undefined;
|
||||
state = { mode: solo ? "solo" : "planning", plan: target, signoffs: retained, worker, workerStopped: solo || (!noted && state.workerStopped) };
|
||||
state = { mode: solo ? "solo" : "planning", plan: target, signoffs: retained, worker, helpers: [], workerStopped: solo || (!noted && state.workerStopped) };
|
||||
generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
if (solo) enterSolo(ctx);
|
||||
else send(attachNotice(target, false, noted));
|
||||
return;
|
||||
}
|
||||
if (command === "stop" || command === "exit") {
|
||||
if (state.mode === "planning") {
|
||||
if (command === "stop") { ctx.ui.notify("A draft cannot pause; use /goals exit to leave planning with the draft preserved.", "warning"); return; }
|
||||
// Planning exit must not get the model trapped re-planning or lose the draft.
|
||||
state.mode = "chat"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
ctx.ui.notify(`Planning exited; draft preserved at ${state.plan}. No implementation was approved or started. Reconnect with /goals attach ${state.plan}.`, "info");
|
||||
return;
|
||||
if (command === "exit") {
|
||||
const backup = state.plan && existsSync(state.plan) ? `${state.plan}.${randomUUID()}.bak` : undefined;
|
||||
if (backup) writeFileSync(backup, readFileSync(state.plan!), { flag: "wx" });
|
||||
const storage = new CronStorage(ctx.cwd);
|
||||
const session = ctx.sessionManager.getSessionId();
|
||||
for (const job of storage.getAllJobs().filter(j => j.name === `goals-${session}` && j.session === session)) {
|
||||
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.mode = command === "stop" ? "paused" : "chat"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pauseExitNotice(state.worker, command === "exit")}`, Boolean(state.worker) || hasScheduleTool());
|
||||
state = initial(); generation++; workerRevision++; pendingLaunches = 0; 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; }
|
||||
state.mode = "paused"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
const pause = pauseExitNotice(state.worker, false);
|
||||
const requestCleanup = Boolean(state.worker) || hasScheduleTool();
|
||||
if (!requestCleanup) ctx.ui.notify(pause, "info"); // Visible now; passive model context waits for a prompt.
|
||||
send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pause}`, requestCleanup);
|
||||
return;
|
||||
}
|
||||
if (command === "resume") {
|
||||
@@ -385,11 +442,14 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (command !== "new" && !command.startsWith("new ")) { ctx.ui.notify(`Unknown or incomplete command. ${help}`, "warning"); return; }
|
||||
const objective = command.slice(4).trim();
|
||||
if ((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 path = join(ctx.cwd, ".pi", "plan", `${ctx.sessionManager.getSessionId()}-main.md`);
|
||||
let path = join(ctx.cwd, ".pi", "plan", `${ctx.sessionManager.getSessionId()}-main.md`);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
// Never overwrite an earlier plan at this session path; the model can revise it after inspection.
|
||||
try { writeFileSync(path, planDocument(objective), { flag: "wx" }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
||||
state = { mode: "planning", plan: path, signoffs: {}, worker: state.worker, workerStopped: state.workerStopped }; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
try { writeFileSync(path, planDocument(objective), { flag: "wx" }); } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
path = join(dirname(path), `${ctx.sessionManager.getSessionId()}-${randomUUID()}.md`);
|
||||
writeFileSync(path, planDocument(objective), { flag: "wx" });
|
||||
}
|
||||
state = { mode: "planning", plan: path, signoffs: {}, worker: state.worker, helpers: state.helpers, workerStopped: state.workerStopped }; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx);
|
||||
send(planningSeed(objective, path));
|
||||
} catch (error) { ctx.ui.notify(String(error), "error"); }
|
||||
},
|
||||
@@ -428,7 +488,7 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
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()).short);
|
||||
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"));
|
||||
|
||||
+7
-2
@@ -1,6 +1,11 @@
|
||||
// Pi/OpenAI: Preserve plan wording; omit history and, in the short view, task/evidence details.
|
||||
export function planViews(plan: string): { short: string; long: string } {
|
||||
// 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;
|
||||
@@ -24,5 +29,5 @@ export function planViews(plan: string): { short: string; long: string } {
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
return { short: kept.join("\n").trim(), long };
|
||||
return { short: kept.join("\n").trim(), notify, long };
|
||||
}
|
||||
|
||||
+36
-7
@@ -144,22 +144,51 @@ export function readyApproved(workerName: string, planPath: string, notedWorker:
|
||||
}
|
||||
|
||||
// Supervision and turn-event upkeep (not a scheduled wake-up).
|
||||
const supervisorJob = "Your job is to be an autonomous research partner and supervisor with responsibility for the user's goals. Keep perspective, bring diligence, and use research taste and wisdom to sustain work overnight and keep it on track. Resolve routine implementation decisions yourself; ask the user only when their judgment or authorization is needed. Let each check-in follow what changed or needs attention, rather than repeat the previous recap.";
|
||||
export function supervisor(workerName: string, planPath: string, supervisorId: string): string {
|
||||
return `You are the goal supervisor in the main chat for ${planPath}. Inspect actual artifacts, saved verification, applicable AGENTS.md and skills yourself; delegate implementation to '${workerName}'. Keep authorized work moving to the requested outcome, not merely approval paperwork. Investigate blocked/waiting/done claims and change ineffective instructions. Give brief visible assessments with judgment. You may maintain the plan but must not weaken the goal to accept worker output.
|
||||
return `You are the goal supervisor in the main chat for ${planPath}. ${supervisorJob} Inspect actual artifacts, saved verification, applicable AGENTS.md and skills yourself; delegate implementation to '${workerName}'. Keep authorized work moving to the requested outcome, not merely approval paperwork. Investigate blocked/waiting/done claims and change ineffective instructions. Give brief visible assessments with judgment. You may maintain the plan but must not weaken the goal to accept worker output.
|
||||
You can be playful: let the humor come from what actually happened. Avoid repeating recent jokes, nicknames or kaomoji; plain updates are welcome too. No forced cheerfulness or novelty. If supervision gets repetitive, step back and change your approach. Keep it brief and aimed at the goal, not another reporting chore.
|
||||
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 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.`;
|
||||
}
|
||||
export function upkeep(planPath: string): string {
|
||||
return `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.`;
|
||||
// Pi/OpenAI: user nudges plus quotes/attributions from https://github.com/wassname/ml-debug/blob/main/fortune.txt.
|
||||
export const upkeepNudges = [
|
||||
"is the worker stuck? (or are you)",
|
||||
"Insufficient skepticism doesn't feel like insufficient skepticism from the inside. It just feels like doing research. -- Neel Nanda",
|
||||
"take a breath, use a kamoji, how it going?",
|
||||
"Don't let your instruments overwhelm your system. -- David J. Agans, *Debugging: The 9 Indispensable Rules*",
|
||||
"is the worker being cheeky, does it need sheperding",
|
||||
"The first step is just making time to stop and ask yourself: do I endorse what I'm doing, and could I be doing something better? -- Neel Nanda",
|
||||
"It seems important to really commit yourself to always investigate whenever you notice confusion. -- Dan Rahtz",
|
||||
"How reliable is my experiment? Ask yourself: How surprised would I be if it turned out to be complete bullshit due to a bug, error, noise, misunderstanding, etc.? Investigate the most uncertain bits. -- Neel Nanda",
|
||||
"If it doesn’t work, assume there’s a bug. Spend a lot of effort searching for bugs before you resort to tweaking hyperparameters: usually it’s a bug. Bad hyperparameters can significantly degrade RL performance, but if you’re using hyperparameters similar to the ones in papers and standard implementations, those will probably not be the issue. -- Josh Achiam",
|
||||
"You can't find typos in your own writing without a great deal of effort because you know what it's supposed to say. -- Gwern Branwen",
|
||||
"Even a single anomaly, apparently trivial in itself, can indicate the everyday mental model is not just a little bit wrong, but fundamentally wrong. -- Gwern Branwen",
|
||||
"The default state of the world is that your research is false, because doing research is hard. -- Neel Nanda",
|
||||
"If you're new to RL, writing things from scratch is the most catastrophically self-sabotaging thing you can do. -- Andy Jones",
|
||||
"QUIT THINKING AND LOOK. -- David J. Agans, *Debugging: The 9 Indispensable Rules*",
|
||||
"Excitement is evidence of bullshit: generally, most true results are not exciting, but a fair amount of false results are. -- Neel Nanda",
|
||||
"Read your data. Often, the quality of the data is a crucial driver of the results of your experiments. Often, it is quite bad. -- Neel Nanda",
|
||||
"Visualize the model in action. Directly observing the machine learning model performing its task will help determine whether the quantitative performance numbers it achieves seem reasonable. -- Goodfellow, Bengio and Courville",
|
||||
"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",
|
||||
];
|
||||
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}`;
|
||||
}
|
||||
export function planChangedReview(planPath: string): string {
|
||||
return `Plan 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.`;
|
||||
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.`;
|
||||
}
|
||||
export function manualReview(planPath: string): string {
|
||||
return `Review the current plan ${planPath}, worker progress and actual evidence. Do not launch a duplicate writer.`;
|
||||
return `${supervisorJob}\nReview the current plan ${planPath}, worker progress and actual evidence. Do not launch a duplicate writer.`;
|
||||
}
|
||||
|
||||
// Check-ins. The installed scheduler owns storage/timing/UI. Removal guidance must never add jobs.
|
||||
@@ -167,7 +196,7 @@ export function removeGoalSchedule(sessionId: string): string {
|
||||
return `With schedule_prompt, list jobs and read .pi/schedule-prompts.json to verify ownership; tool text omits session binding. Remove by jobId only the job named ${JSON.stringify(`goals-${sessionId}`)} bound to session ${JSON.stringify(sessionId)}. Never use cleanup; leave other jobs untouched. Do not add, enable or recreate any job. If unavailable or ownership is ambiguous, report it; /schedule-prompt opens the user controls.`;
|
||||
}
|
||||
export function scheduleCheckIn(sessionId: string, planPath: string): string {
|
||||
return `Hourly check-in is one visible schedule_prompt job; plan-change and upkeep reviews are event hooks, not another timer. List first. If an owned job named ${JSON.stringify(`goals-${sessionId}`)} already exists, retain its human-edited prompt, interval and enabled/disabled state unchanged; never recreate, overwrite or re-enable it. Only while supervising unfinished non-cancelled goals, if missing on this explicit start/resume, add one session-bound interval '1h' job with no model override. Read .pi/schedule-prompts.json and verify that new job's session is ${JSON.stringify(sessionId)}; tool text does not expose binding. If the new job is unbound, remove that job by ID and report the scope error. Do not change other jobs. Its initial prompt: Read ${planPath} and the current goal mode. If paused, exited, solo or all non-cancelled goals reviewed, remove only this owned job without resuming work. Otherwise inspect progress and evidence, give a brief assessment and keep authorized work moving without a duplicate writer. Do not reinstall a missing job from a scheduled check-in. Users inspect/toggle/remove jobs with /schedule-prompt and edit prompt/interval through schedule_prompt update. Never use cleanup. Retain their edits, but warn that this installed scheduler deletes disabled jobs on reload/shutdown; do not promise they persist. If schedule_prompt is unavailable, report hourly check-ins unavailable; do not build a timer.`;
|
||||
return `Hourly check-in is one visible schedule_prompt job; plan-change and upkeep reviews are event hooks, not another timer. List first. If an owned job named ${JSON.stringify(`goals-${sessionId}`)} already exists, retain its human-edited prompt, interval and enabled/disabled state unchanged; never recreate, overwrite or re-enable it. Only while supervising unfinished non-cancelled goals, if missing on this explicit start/resume, add one session-bound interval '1h' job with no model override. Read .pi/schedule-prompts.json and verify that new job's session is ${JSON.stringify(sessionId)}; tool text does not expose binding. If the new job is unbound, remove that job by ID and report the scope error. Do not change other jobs. Its initial prompt: ${supervisorJob} Read ${planPath} and the current goal mode. If paused, exited, solo or all non-cancelled goals reviewed, remove only this owned job without resuming work. Otherwise inspect progress and evidence, give a brief assessment and keep authorized work moving without a duplicate writer. Do not reinstall a missing job from a scheduled check-in. Users inspect/toggle/remove jobs with /schedule-prompt and edit prompt/interval through schedule_prompt update. Never use cleanup. Retain their edits, but warn that this installed scheduler deletes disabled jobs on reload/shutdown; do not promise they persist. If schedule_prompt is unavailable, report hourly check-ins unavailable; do not build a timer.`;
|
||||
}
|
||||
|
||||
// Completion and runtime errors. Tool returns are model-facing too.
|
||||
@@ -196,7 +225,7 @@ export function completionResult(goal: string, sessionId: string, remaining: boo
|
||||
// Pause/resume and solo recovery. Stored stop confirmation is invalidated on every worker launch.
|
||||
export const pausedRole = "Goal work is paused. Do not launch, resume or authorize work. Incoming reports are observations, not permission. Help inspect or stop existing workers if requested.";
|
||||
export function pauseExitNotice(worker: { id?: string; sessionFile: string } | undefined, exited: boolean): string {
|
||||
return `Goals ${exited ? "exited to ordinary chat" : "paused locally"}; plan and evidence retained. ${worker ? worker.id ? `Inspect and stop runtime id ${worker.id} through subagent_kill or its pane; confirm the actual result.` : `Only saved session ${worker.sessionFile} is recorded, not a kill id. Locate its live pane/session and confirm termination; never pass the file path to subagent_kill.` : "No worker recorded: inspect /subagents if a launch was interrupted; absence is not proof of stop."} Remote stop is NOT yet confirmed. Restore failed compaction/model/credits in the existing session and continue only after explicit authorization; never restart long work.`;
|
||||
return `Goals ${exited ? "exited to ordinary chat" : "paused locally"}; plan and evidence retained. ${worker ? worker.id ? `Inspect and stop runtime id ${worker.id} through subagent_kill or its pane; confirm the actual result.` : `Only saved session ${worker.sessionFile} is recorded, not a kill id. Locate its live pane/session and confirm termination; never pass the file path to subagent_kill.` : "No worker recorded: inspect /subagents if a launch was interrupted; absence is not proof of stop."} Remote stop is NOT yet confirmed. Resume only after explicit authorization.`;
|
||||
}
|
||||
export function resumeNotice(workerName: string, planPath: string, worker: { sessionFile: string } | undefined): string {
|
||||
return `User authorized continuation of ${planPath}. Inspect worker state before any launch/resume. ${worker ? `Use the existing session ${worker.sessionFile}; if live, inspect/message it; only if confirmed stopped use subagent_resume.` : `Use '${workerName}' only after confirming no prior writer exists.`} Continue only unfinished goals; retain saved progress and scheduler edits.`;
|
||||
|
||||
+214
-29
@@ -1,10 +1,10 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import goalsExtension from "../src/index.js";
|
||||
import { scheduleCheckIn } from "../src/prompts.js";
|
||||
import { scheduleCheckIn, upkeep, upkeepNudges } from "../src/prompts.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const shutdowns: Array<() => void> = [];
|
||||
@@ -23,7 +23,7 @@ function fixture(child = false) {
|
||||
const entries: any[] = []; const hooks = new Map<string, any>(); const commands = new Map<string, any>(); const tools = new Map<string, any>();
|
||||
const messages: any[] = [];
|
||||
const ctx = { cwd, sessionManager: { getBranch: () => entries, getSessionId: () => "copy-only" }, hasUI: true, ui: {
|
||||
theme: { fg: (_color: string, text: string) => text }, notify: vi.fn(), setStatus: vi.fn(), setWidget: vi.fn(), select: vi.fn(async () => "Ready"), editor: vi.fn(),
|
||||
theme: { fg: (_color: string, text: string) => text }, notify: vi.fn(), setStatus: vi.fn(), setWidget: vi.fn(), select: vi.fn(async (_title: string, _options: string[]) => "Ready"), editor: vi.fn(),
|
||||
} };
|
||||
const pi = {
|
||||
on: (event: string, hook: any) => hooks.set(event, hook),
|
||||
@@ -32,6 +32,7 @@ function fixture(child = false) {
|
||||
registerTool: (definition: any) => tools.set(definition.name, definition),
|
||||
sendMessage: (message: any, options: any) => messages.push({ message, options }),
|
||||
sendUserMessage: (content: string, options: any) => messages.push({ message: { content }, options, savedPrompt: true }),
|
||||
events: { emit: vi.fn() },
|
||||
getAllTools: vi.fn(() => [
|
||||
{ name: "subagent", parameters: { properties: { agent: {}, title: {} } } },
|
||||
{ name: "subagent_resume", parameters: { properties: { sessionFile: {} } } },
|
||||
@@ -56,12 +57,25 @@ function fixture(child = false) {
|
||||
return { ctx, pi, hooks, tools, commands, messages, command, path, plan, draft, shutdown, changed, atomicWrite, entries };
|
||||
}
|
||||
|
||||
it("shows action choices and autocomplete without starting work", async () => {
|
||||
it.each([
|
||||
["chat", ["new", "attach", "help", "quit"]],
|
||||
["planning", ["edit", "discuss", "ready", "model", "help", "quit"]],
|
||||
["supervising", ["review", "stop", "model", "help", "quit"]],
|
||||
["paused", ["resume", "model", "help", "quit"]],
|
||||
["solo", ["stop", "help", "quit"]],
|
||||
])("shows only applicable %s actions without starting work", async (mode, expected) => {
|
||||
const f = fixture();
|
||||
if (mode !== "chat") await f.draft();
|
||||
if (mode === "supervising" || mode === "paused") await f.command("ready");
|
||||
if (mode === "paused") await f.command("stop");
|
||||
if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); }
|
||||
const before = f.messages.length;
|
||||
f.ctx.ui.select.mockResolvedValueOnce(undefined as any);
|
||||
await f.command("");
|
||||
expect(f.ctx.ui.select).toHaveBeenCalledWith("Goal plan actions", expect.arrayContaining(["new — New plan", "resume — Continue paused work"]));
|
||||
expect(f.messages).toHaveLength(0);
|
||||
const actions = f.ctx.ui.select.mock.calls.at(-1)![1];
|
||||
expect(actions.map(action => action.split(" — ")[0])).toEqual(expected);
|
||||
expect(actions.at(-1)).toBe("quit — Exit and clear goals");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.commands.get("goals").getArgumentCompletions("res")).toEqual([{ value: "resume", label: "resume" }]);
|
||||
});
|
||||
|
||||
@@ -92,6 +106,59 @@ it.each(["menu", "command"])("enters planning conversation through %s without an
|
||||
expect(f.hooks.get("tool_call")({ toolName: "subagent" }).block).toBe(true);
|
||||
});
|
||||
|
||||
it("edits even an empty draft directly without a model call", async () => {
|
||||
const f = fixture(); await f.command("new"); const before = f.messages.length;
|
||||
f.ctx.ui.editor.mockResolvedValueOnce(f.plan);
|
||||
f.ctx.ui.select.mockResolvedValueOnce("edit — Edit plan…"); await f.command("");
|
||||
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
|
||||
expect(f.entries.at(-1).data.mode).toBe("planning");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
});
|
||||
|
||||
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" } });
|
||||
const jobs = [
|
||||
{ id: "owned", name: "goals-copy-only", session: "copy-only", enabled: true },
|
||||
{ id: "older", name: "older-plan", session: "copy-only", enabled: true },
|
||||
{ id: "foreign", name: "goals-copy-only", session: "other", enabled: true },
|
||||
{ id: "unbound", name: "goals-copy-only", enabled: true },
|
||||
];
|
||||
const schedule = join(f.ctx.cwd, ".pi/schedule-prompts.json"); writeFileSync(schedule, JSON.stringify({ version: 1, jobs }));
|
||||
const before = f.messages.length; await f.command("clear");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
|
||||
expect(JSON.parse(readFileSync(schedule, "utf8")).jobs).toEqual(jobs.slice(1));
|
||||
expect(f.pi.events.emit).toHaveBeenCalledWith("cron:change", { type: "remove", jobId: "owned" });
|
||||
const directory = join(f.ctx.cwd, ".pi/plan");
|
||||
const backup = readdirSync(directory).find(name => name.endsWith(".bak"))!;
|
||||
expect(readFileSync(join(directory, backup), "utf8")).toBe(f.plan);
|
||||
await f.command("new a different objective");
|
||||
const next = f.entries.at(-1).data;
|
||||
expect(next.mode).toBe("planning"); expect(next.worker).toBeUndefined(); expect(next.plan).not.toBe(f.path);
|
||||
expect(readFileSync(next.plan, "utf8")).toContain("a different objective");
|
||||
expect(readFileSync(next.plan, "utf8")).not.toContain("first output");
|
||||
expect(readFileSync(f.path, "utf8")).toBe(f.plan);
|
||||
});
|
||||
|
||||
it.each(["missing", "empty"])("clear resets a %s plan without a model call", async kind => {
|
||||
const f = fixture(); await f.draft(); const before = f.messages.length;
|
||||
if (kind === "missing") rmSync(f.path); else writeFileSync(f.path, "");
|
||||
await f.command("clear");
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
|
||||
expect(f.messages).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("discusses plan changes only during planning", async () => {
|
||||
const f = fixture(); await f.command("discuss"); expect(f.messages).toHaveLength(0);
|
||||
await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("discuss — Discuss changes to the plan"); await f.command("");
|
||||
expect(f.messages.at(-1).message.content).toContain("Discuss the current draft");
|
||||
expect(f.entries.at(-1).data.mode).toBe("planning");
|
||||
await f.command("ready"); const before = f.messages.length;
|
||||
await f.command("discuss"); expect(f.messages).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("automatically proposes a changed settled draft once and preserves Discuss", async () => {
|
||||
const f = fixture(); await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Discuss");
|
||||
@@ -241,7 +308,7 @@ it("does not retrigger a review for its own CompleteGoal plan write", async () =
|
||||
f.shutdown();
|
||||
});
|
||||
|
||||
it("gives pause/exit the session-bound scheduler job removal guidance", async () => {
|
||||
it("gives pause scheduler guidance but clears on exit without a model prompt", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
await f.command("stop");
|
||||
const stop = f.messages.at(-1).message.content;
|
||||
@@ -250,9 +317,10 @@ it("gives pause/exit the session-bound scheduler job removal guidance", async ()
|
||||
expect(stop).not.toContain("interval '1h'");
|
||||
expect(stop).toContain("Remote stop is NOT yet confirmed");
|
||||
await f.command("resume");
|
||||
const before = f.messages.length;
|
||||
await f.command("exit");
|
||||
expect(f.messages.at(-1).message.content).toContain('goals-copy-only"');
|
||||
expect(f.entries.at(-1).data.mode).toBe("chat");
|
||||
expect(f.messages).toHaveLength(before);
|
||||
expect(f.entries.at(-1).data).toEqual({ mode: "chat", helpers: [], signoffs: {} });
|
||||
});
|
||||
|
||||
it("tells the model to remove only its own job after the final review", async () => {
|
||||
@@ -350,13 +418,15 @@ it("rejects attaching a missing or goal-less file", async () => {
|
||||
expect(f.entries).toEqual([]); // nothing saved: the session was not attached
|
||||
});
|
||||
|
||||
it("exits planning with the draft preserved and nothing implemented", async () => {
|
||||
it.each(["exit", "quit", "clear", "menu"])("%s exits planning with the draft preserved and nothing implemented", async command => {
|
||||
const f = fixture(); await f.draft();
|
||||
await f.command("stop");
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("A draft cannot pause"), "warning");
|
||||
const before = f.messages.length;
|
||||
await f.command("exit");
|
||||
if (command === "menu") f.ctx.ui.select.mockResolvedValueOnce("quit — Exit and clear goals");
|
||||
await f.command(command === "menu" ? "" : command);
|
||||
expect(f.entries.at(-1).data.mode).toBe("chat");
|
||||
expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", undefined);
|
||||
expect(readFileSync(f.path, "utf8")).toContain("first output");
|
||||
expect(f.messages.length).toBe(before); // notify only, no model turn started
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Previous supervisor confirmed stopped");
|
||||
@@ -447,18 +517,22 @@ it.each(["missing", "empty", "directory"])("%s plan snapshots never erase signof
|
||||
expect(f.changed()).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores post-completion maintenance but reviews a real requirement or manual reopening", async () => {
|
||||
it("ignores post-completion maintenance but reviews evidence, requirement or manual reopening changes", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
writeFileSync(join(f.ctx.cwd, "proof.log"), "PASS\n");
|
||||
for (const goal of ["first output", "second output"]) await f.tools.get("CompleteGoal").execute("c", { goal, evidence: ["proof.log"], observation: "PASS" }, undefined, undefined, f.ctx);
|
||||
const signed = readFileSync(f.path, "utf8");
|
||||
await f.atomicWrite(signed.replace("## Log", " - evidence: proof.log\n## Log\n- recap: finished"));
|
||||
await f.atomicWrite(signed.replace("## Log", "## Log\n- recap: finished"));
|
||||
await delay(250);
|
||||
expect(f.changed()).toBe(0);
|
||||
await f.atomicWrite(signed.replace("## Log", "- discriminator: exact bytes and trailing newline\n## Log"));
|
||||
expect(f.changed()).toBe(0); // Log-only edits are history, not requirements
|
||||
// Worker-authored evidence above the Log must surface: a supervisor caught a worker's
|
||||
// contradictory evidence block through exactly this event (LUCID3, 2026-09-10).
|
||||
await f.atomicWrite(signed.replace("## Log", " - evidence: proof.log\n## Log\n- recap: finished"));
|
||||
await waitFor(() => f.changed() === 1);
|
||||
await f.atomicWrite(signed.replace("[x] goal: first", "[ ] goal: first"));
|
||||
await f.atomicWrite(signed.replace("## Log", "- discriminator: exact bytes and trailing newline\n## Log"));
|
||||
await waitFor(() => f.changed() === 2);
|
||||
await f.atomicWrite(signed.replace("[x] goal: first", "[ ] goal: first"));
|
||||
await waitFor(() => f.changed() === 3);
|
||||
expect(f.entries.at(-1).data.signoffs["first output"]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -476,7 +550,22 @@ it("cancelled goals do not prevent final cleanup, and solo writes self-verificat
|
||||
expect(text).toContain("Preserved context");
|
||||
});
|
||||
|
||||
it("lineage-only child attaches its plan with goal-only widget, retains task context, and cannot complete", async () => {
|
||||
it("prefixes single and batch launch titles with the project without changing handles or duplicating prefixes", () => {
|
||||
const f = fixture();
|
||||
const single = { name: "report-worker", title: "Restore PCA" };
|
||||
const event = { toolName: "subagent", input: single };
|
||||
f.hooks.get("tool_call")(event, f.ctx);
|
||||
const expected = `${f.ctx.cwd.split("/").at(-1)} · Restore PCA`;
|
||||
expect(single).toEqual({ name: "report-worker", title: expected });
|
||||
f.hooks.get("tool_call")(event, f.ctx);
|
||||
expect(single.title).toBe(expected);
|
||||
const children = [{ name: "test-worker", title: "Check results" }, { ...single }];
|
||||
f.hooks.get("tool_call")({ toolName: "subagent", input: { children } }, f.ctx);
|
||||
expect(children[0].title).toBe(`${f.ctx.cwd.split("/").at(-1)} · Check results`);
|
||||
expect(children[1]).toEqual(single);
|
||||
});
|
||||
|
||||
it("lineage-only child attaches its plan without a widget, retains task context, and cannot complete", async () => {
|
||||
const f = fixture(true);
|
||||
const supplied = join(f.ctx.cwd, "supplied.md");
|
||||
const text = "- [/] goal: exact file\n - [ ] verify bytes\n## Log\n - [ ] archived task\n";
|
||||
@@ -487,10 +576,10 @@ it("lineage-only child attaches its plan with goal-only widget, retains task con
|
||||
await attach.execute("a", { path: "supplied.md" }, undefined, undefined, f.ctx);
|
||||
expect(f.entries.at(-1).data.plan).toBeUndefined(); // no cwd heuristics
|
||||
await attach.execute("a", { path: supplied }, undefined, undefined, f.ctx);
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1]).toEqual(["▸ exact file"]);
|
||||
expect(f.ctx.ui.setWidget.mock.lastCall?.[1].join("\n")).not.toContain("archived task");
|
||||
expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", undefined);
|
||||
expect(readFileSync(supplied, "utf8")).toBe(text);
|
||||
f.hooks.get("session_start")({}, f.ctx);
|
||||
expect(f.ctx.ui.setWidget).toHaveBeenLastCalledWith("goals", undefined);
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message.content).toContain("exact file");
|
||||
const completion = await f.tools.get("CompleteGoal").execute("c", { goal: "exact file", evidence: [], observation: "claim" }, undefined, undefined, f.ctx);
|
||||
expect(completion.content[0].text).toContain("only to the active parent");
|
||||
@@ -506,7 +595,7 @@ it.each(["solo", "supervising"])("%s widget omits long tasks without altering th
|
||||
expect(readFileSync(f.path, "utf8")).toBe(text);
|
||||
});
|
||||
|
||||
it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, resets on working-set edits, and never starts a turn", async mode => {
|
||||
it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, and joins the next ordinary prompt once", async mode => {
|
||||
const f = fixture(); await f.draft();
|
||||
if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); }
|
||||
else await f.command("ready");
|
||||
@@ -519,20 +608,74 @@ it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, resets on
|
||||
}
|
||||
expect(reminders()).toHaveLength(0);
|
||||
f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(reminders()).toHaveLength(1);
|
||||
expect(reminders()[0].options).toEqual({ triggerTurn: false });
|
||||
expect(reminders()[0].message.content).toContain(f.path);
|
||||
expect(reminders()[0].message.content).not.toContain("first output");
|
||||
for (let i = 0; i < 16; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(reminders()).toHaveLength(1);
|
||||
expect(reminders()[0].message.content).not.toContain("historical recap");
|
||||
for (let i = 0; i < 7; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(reminders()).toHaveLength(0); // No direct send, even after the run would finish.
|
||||
const reminder = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message;
|
||||
expect(reminder.customType).toBe("pi-goals-upkeep");
|
||||
expect(reminder.content).toContain(f.path);
|
||||
expect(reminder.content).not.toContain("first output");
|
||||
expect(reminder.content).not.toContain("historical recap");
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
|
||||
writeFileSync(f.path, f.plan.replace("first output", "refined output"));
|
||||
f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(reminders()).toHaveLength(1);
|
||||
for (let i = 0; i < 7; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
|
||||
await f.command("stop");
|
||||
for (let i = 0; i < 10; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(reminders()).toHaveLength(1);
|
||||
expect(reminders()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["supervising", "solo"])("%s repeats upkeep every eight unchanged turns and rotates only delivered supervisor nudges", async mode => {
|
||||
const f = fixture(); await f.draft();
|
||||
if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); }
|
||||
else await f.command("ready");
|
||||
const prepare = () => f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
prepare();
|
||||
for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
f.hooks.get("session_compact")();
|
||||
expect(prepare().message.customType).toBe("pi-goals-plan");
|
||||
const sent = f.messages.length;
|
||||
for (let round = 0; round <= upkeepNudges.length; round++) {
|
||||
for (let turn = 0; turn < 7; turn++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(prepare().message).toBeUndefined();
|
||||
f.hooks.get("turn_end")({}, f.ctx);
|
||||
expect(f.messages).toHaveLength(sent);
|
||||
expect(prepare().message).toMatchObject({
|
||||
customType: "pi-goals-upkeep",
|
||||
content: upkeep(f.path, mode === "supervising" ? round : undefined),
|
||||
});
|
||||
expect(prepare().message).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
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" } });
|
||||
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" } });
|
||||
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" } });
|
||||
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" } });
|
||||
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 () => {
|
||||
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.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.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 () => {
|
||||
@@ -602,3 +745,45 @@ it("keeps interactive workers open and supplies the supervisor identity for Inte
|
||||
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 => {
|
||||
const f = fixture(); await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
if (change === "edit") writeFileSync(f.path, f.plan.replace("first output", "changed requirement"));
|
||||
else if (change === "session_tree") f.hooks.get("session_tree")({}, f.ctx);
|
||||
else await f.command(change);
|
||||
const prepared = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
expect(prepared?.message?.customType).not.toBe("pi-goals-upkeep");
|
||||
if (change === "stop") expect(prepared.systemPrompt).toContain("Goal work is paused");
|
||||
if (change === "exit") expect(prepared).toBeUndefined();
|
||||
expect(f.messages.filter(m => m.message.customType === "pi-goals-upkeep")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("coalesces pending upkeep with a repaired post-compaction plan, retaining the user's latest requirements", async () => {
|
||||
const f = fixture(); await f.draft();
|
||||
f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo");
|
||||
f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx);
|
||||
f.hooks.get("session_compact")();
|
||||
rmSync(f.path);
|
||||
const unavailable = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx);
|
||||
expect(unavailable.message).toBeUndefined();
|
||||
expect(unavailable.systemPrompt).toContain("unavailable");
|
||||
const repaired = f.plan.replace("first output", "the human's latest exact result");
|
||||
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).not.toContain("Plan upkeep:");
|
||||
expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined();
|
||||
});
|
||||
|
||||
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");
|
||||
await f.command("stop");
|
||||
expect(f.messages.at(-1).options).toEqual({ deliverAs: "nextTurn" });
|
||||
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Remote stop is NOT yet confirmed"), "info");
|
||||
});
|
||||
|
||||
@@ -20,6 +20,19 @@ it("omits only named worker identity fields from review while retaining them in
|
||||
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");
|
||||
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planDrafting } from "../src/prompts.js";
|
||||
import { planDrafting, upkeep, upkeepNudges } from "../src/prompts.js";
|
||||
|
||||
it("cycles the curated supervisor nudges without changing the shared upkeep instructions", () => {
|
||||
const base = upkeep("/plan.md");
|
||||
const variants = upkeepNudges.map((_, round) => upkeep("/plan.md", round));
|
||||
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);
|
||||
});
|
||||
|
||||
describe("planning prompt", () => {
|
||||
it("requires fact finding or a focused question before a goal", () => {
|
||||
|
||||
Reference in New Issue
Block a user