Use real plan supervisors and enable automation by default

This commit is contained in:
wassname2
2026-09-07 14:49:25 +08:00
parent 1c10a282ac
commit 8e44738773
18 changed files with 1127 additions and 772 deletions
+66 -19
View File
@@ -77,26 +77,66 @@ pi -e ./src/index.ts
After eight turns without a change above `## Log`, the working set is sent back with a short upkeep
reminder.
Optional persistent perspective requires the separate `pi-subagents` package (`pi install
npm:pi-subagents`, then restart or reload Pi). `/goals steward on` forks one non-writing Oracle
when Ready is selected. Work waits for its plan decision. The child process exits after the
review, while its session is retained. The first `CompleteGoal` call resumes that same session for a
trajectory and scope check; after approval, a second call runs the normal fresh evidence judge. The
steward cannot complete goals or make unresolved human decisions. `/goals steward off` disables it.
Between reviews there is no running child process or model call. The retained session receives a
bounded contract view: evidence detail stays with the fresh judge, approved checkbox state is
normalized, and current goal status remains visible. The Oracle's profile includes inspection-only
bash by contract; pi-goals rejects its decision if pi-subagents reports a file-mutation effect. This
is not an OS sandbox. If pi-subagents is absent, Ready stays in planning after a visible RPC timeout;
install it, retry Ready, or use `/goals steward off`. The integration is process-local and does not
require `pi-intercom`.
## Plan supervisor and auto-continue
Other commands: `/goals clear` disconnects this session from its active plan, preserving the
versioned file on disk; `/goals auto [minutes|off]` continues active goals after the agent settles
and then on that interval. It pauses after two automatic wakes with no working-plan change;
`/goals judge <model-ref>` picks a sign-off judge model (default: your current session model, else
pi's default); `/goals steward [on|off|status]` controls the optional persistent plan steward. The
old `--clear`, `--auto`, and `--judge` forms remain compatibility aliases but are not required.
Steward supervision and 60-minute auto-continue are enabled by default. A real supervisor starts at
Ready. Auto-continue is the fallback when stewardship is off; it does not run a competing timer while
supervision is enabled. Use `/goals steward off` or `/goals auto off` to opt out. Explicit preferences
survive clear and reload. Cleared legacy sessions adopt the new defaults on reload; active legacy
plans retain their settings so supervision is not attached midway through work.
This branch requires the matching
plan-aware `pi-intercom-supervisor` branch, `pi-intercom`, and Pi running inside Herdr. Both extensions
must be loaded in the worker; pi-goals passes their resolved paths to the supervisor.
Ready is the human's plan approval. Pi-goals forks the planning session, initializes the supervisor
with the original plan and supervisor policy, and waits for acknowledged pairing before starting the
worker. The initial supervisor view can steer; it is not another mandatory plan-approval gate.
The supervisor fork is compacted unless its known context is already at most 20k tokens. Only the
supervisor is compacted. Its normal context policy checks near 100k tokens, subject to its model limit.
The existing supervisor provides incremental VCC views and retains its decisions. The plan-aware
policy checks every 50 model turns or 60 minutes, or when the worker settles with no tracked
background work. Process/subagent providers that cannot answer are reported as unknown; they do not
prove the worker is finished. Standalone supervisor settings are unchanged. Auto-continue is
suspended while the steward is enabled so there is only one continuation policy.
One `CompleteGoal` call asks this supervisor about direction and scope, then runs the normal fresh
read-only evidence judge. Approving one goal does not finish supervision. Cancelled, stale or
mismatched replies do not sign off goals. `/goals steward off` ends this plan's supervision and
cancels pending goal requests; it does not close the human's terminal pane.
Navigation: `/goals supervisor` focuses the supervisor, `/goals worker` returns to the worker, and
`/goals zoom` toggles supervisor zoom. These use the real Pi panes, not a Fleet inspector. If the
recorded pane is unavailable, its location/liveness is unknown. Locate the existing session first;
only after confirming it is no longer running, reopen the saved `pi --session` path shown in the
error. Pi-goals never starts a duplicate merely because a pane ID is missing.
After completion, keep the plan as a record. Ordinary auto-continue stops when no open goals remain.
The supervisor's `done` ends the pairing and its watch timer; it leaves the terminal and saved session
available for inspection. `/goals clear` is the manual way to disconnect. A later `/goals plan …`
creates a new plan version and starts a new supervisor fork at Ready rather than reusing the completed
plan's pairing. You can close an old supervisor pane after supervision has ended.
Other commands: `/goals clear` disconnects this session, preserving its plan file;
`/goals auto [minutes|off]` controls ordinary auto-continue; `/goals judge <model-ref>` overrides the
fresh judge's model; `/goals steward status` reports supervision. Use `/goals plan <objective>` for
objectives beginning with reserved command words, such as `/goals plan judge the vendor options`.
The old `--clear`, `--auto`, and `--judge` forms remain compatibility aliases.
For a local trial, load the two feature checkouts explicitly in a Herdr-managed Pi session (replace
paths as needed; this does not change global package settings):
```bash
pi -e /path/to/pi-intercom-supervisor/src/index.ts \
-e /path/to/pi-intercom-supervisor/node_modules/pi-intercom/index.ts \
-e /path/to/pi-goals/src/index.ts
```
Then draft a plan and select Ready; no enable command is needed. Initialization failure stays in
planning and names the unavailable component; resolve it in the supervisor pane, or turn the steward
off and retry Ready. Sessions saved with the older checkpoint-only steward need a new Ready handoff;
old pi-subagents reviewer runs are not reused as supervisor sessions.
## Prompts
@@ -110,8 +150,15 @@ 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
npm run lint
# Cross-package tests require the updated supervisor checkout:
PI_GOALS_SUPERVISOR_SOURCE=/path/to/pi-intercom-supervisor/src/index.ts npm test
```
The cross-package hook test uses actual extension code and a native persisted fork, with Herdr and
the evidence judge mocked. `test/rpc-supervisor.test.ts` starts two real Pi RPC sessions, the actual
Intercom broker and a fresh judge against a local offline model, with Herdr alone mocked. It requires
Unix sockets. Neither test proves visual pane navigation; live Herdr UAT is still required.
## License
MIT
@@ -0,0 +1,69 @@
# Supervisor integration options
2026-09-07. Source-based recommendation, not implemented or tested as a live two-pane session. User intent: [Plan-aware persistent supervisor](../spec/2026-09-07_plan-supervisor.md).
## Recommendation
Compose `pi-goals` with `pi-intercom-supervisor` and its existing Intercom channel. Run the supervisor as a second real Pi session in a Herdr split. Use native Pi fork/resume and Herdr launch/focus facilities; keep a small adapter in pi-goals for the plan handoff and navigation.
This reuses the supervisor's policy, incremental views, decisions, and steering. It avoids both copying that runtime into pi-goals and placing a headless subagent behind a second UI. Both sessions remain ordinary Pi sessions that the human can inspect and address.
Herdr owns the terminals. Pi owns each session's history and compaction. The supervisor package owns the supervision relationship and observation cadence. Pi-goals owns the plan and individual goal-completion requests. Intercom transports messages. Pi-subagents can continue running the worker's delegated jobs without owning the supervisor.
## Existing examples and their fit
| Pattern | Fit for this task | Boundary |
|---|---|---|
| Intercom planner/worker + existing supervisor + native Herdr split | Recommended | New fork/bootstrap, plan-signoff, and cadence hooks still needed |
| Intercom `openProjectPaneIfMissing` | Useful launch/discovery example | Starts plain Pi; no explicit fork/session argument; cwd selection is insufficient to identify this supervisor |
| Pi-subagents `project.open` | Real visible Pi session | One binding per canonical cwd; options do not select a session file or supervisor role |
| Pi-subagents retained child + Fleet/Herdr inspector | Retains child context and managed lifecycle | Inspector reads artifacts and offers control; it is not the child's interactive Pi session |
| Messenger Crew/lobby + overlay | Useful for multi-worker task orchestration | Own task state and headless `--mode json --no-session -p` workers; overlay is chat/progress, not two real Pi terminals |
Intercom's README describes its planner/worker example as “one holds the big picture, the other does the hands-on work.” Its extension-channel example is the right transport pattern for non-conversational views and correlated goal requests: channel traffic alone starts no model turn. The supervisor already uses this channel and starts its own review when a view arrives.
The supervisor's `pi-supervise:pair:v1` event accepts an explicit worker Intercom identity and goal, and resolves on worker acknowledgement. It is emitted inside the supervisor process, not across the event bus from the worker. Its companion worker-state and paired events expose the identities needed by a launcher.
## Human interaction
Default to a named worker/supervisor split so both real sessions are visible. Focus either pane to type there; zoom temporarily when more width is needed. Offer direct pi-goals actions for opening/focusing the supervisor and returning to the worker. Exact slash spelling is still a proposal.
Installed help confirms this launch shape. These commands were NOT run:
```bash
herdr pane split --current --direction right --cwd "$PROJECT" --no-focus
# Take SUPERVISOR_PANE from the returned pane.pane_id.
herdr agent start goals-supervisor --kind pi --pane "$SUPERVISOR_PANE" -- --session "$FORK_SESSION_FILE"
herdr agent focus "$SUPERVISOR_PANE"
herdr agent focus "$WORKER_PANE"
herdr pane zoom --pane "$SUPERVISOR_PANE" --toggle
```
`--session` here opens an already-created distinct fork, never the worker's live file. Pi also supports `--fork <path|id>` to create the separate session at launch, so a native CLI fork followed by supervisor-side initialization may eliminate custom fork-file construction. Herdr `agent start` waits for the expected interactive agent to be detected and ready; its pane must initially be at a shell prompt. Use a unique name per pairing and retain the returned pane ID for control.
A separate Herdr tab is an alternative for switching; the split satisfies seeing both at once. Outside Herdr, an ordinary second terminal remains usable, but automatic terminal creation is a separate portability question. Do not build an additional fallback process manager before that need is established.
## Minimal integration work still required
1. **Bootstrap:** fork at the plan handoff, establish a supervisor-only role, compact the fork, provide the worker's explicit plan path and full plan, then pair. Do not restore the fork's inherited pi-goals activation as another worker, and do not truncate the plan into the routine 15 KB view. No supervisor judgement should run before initialization finishes.
2. **One review policy:** expose the proposed 50-model-turn / 60-minute / settled-and-no-background-work triggers in the supervisor package. Replace its stock 30-minute policy rather than running a second timer beside it. Count model turns, not transcript messages. Track the worker's registered processes/subagents and react when they finish; arbitrary unregistered detached work remains a visibility limitation.
3. **Per-goal request/reply:** add correlated goal-signoff and plan-update hooks. Stock `done` ends the entire pairing. A goal decision should return to pi-goals, which owns checkbox updates and the existing fresh evidence judge. A stale or duplicate reply must not approve another goal or plan version. The current two-call CompleteGoal protocol is not a user requirement.
4. **Recovery and navigation:** retain session/plan/pane identity; reconcile live Intercom identity and pairing on restart. Focus or resume the same supervisor instead of creating another. Do not open one session file in two live Pi processes. Preserve the original supervisor's decisions and reestablish policy/plan after compaction.
Keep VCC's incremental views and the supervisor's recent-view pruning initially. Use native compaction for the fork and later supervisor history. Measure token totals, cache reads, compaction cost, and whether useful interventions occur. The supervisor README's reported cost saving is not proof of an equivalent token reduction.
## Evidence and limits
Inspected Pi 0.84.4 and Herdr `0.8.2-preview.2026-09-06-9e9bc8a14466` CLI help. Herdr is installed, but this chat is not a Herdr-managed pane. No panes, agents, packages, or settings were started/changed for the research. Only documentation changed in this branch.
Source anchors:
- [Supervisor pairing and runtime](https://github.com/wassname/pi-intercom-supervisor/blob/409233cd4fb89e9b8c4a027affdedac5c9a8fddc/src/index.ts): pairing at 749794; recent-view pruning at 535556; settled reviews at 710742. [Policy](https://github.com/wassname/pi-intercom-supervisor/blob/409233cd4fb89e9b8c4a027affdedac5c9a8fddc/src/prompts.ts) and [wire protocol](https://github.com/wassname/pi-intercom-supervisor/blob/409233cd4fb89e9b8c4a027affdedac5c9a8fddc/src/protocol.ts) show the reusable prompt and missing per-goal request.
- [Intercom 0.13.0 README](https://github.com/nicobailon/pi-intercom/blob/199279ae861bf53ce014809fb2a03337538ae13e/README.md): Planner-Worker Coordination, Extension channels. [Project launcher](https://github.com/nicobailon/pi-intercom/blob/199279ae861bf53ce014809fb2a03337538ae13e/project-agent.ts) accepts cwd/focus but not a selected session. Inspected the published npm tarball as well. The supervisor README's old `tintinweb/pi-intercom` link returned 404; current Intercom source is under `nicobailon`.
- [Pi-subagents current integration docs](https://github.com/nicobailon/pi-subagents/blob/54df154d1891db6b1a3e755539683335b9f9dbd3/docs/extension-api.md): Inspector panes, Project panes, Background-work provider API. Also inspected installed 0.60.0 source: `project-panes.ts:102107,177179,409412,546564` and `focus.ts:3255`. Registry latest was 0.66.0; no upgrade was performed. Current source still distinguishes project sessions from headless-run inspectors.
- [Messenger 0.15.2 lobby launch](https://github.com/nicobailon/pi-messenger/blob/09937ed647a1b07a3b595bf75943feacb80ff123/crew/lobby.ts): `spawnLobbyWorker` uses print/JSON/no-session mode. [Overlay actions](https://github.com/nicobailon/pi-messenger/blob/09937ed647a1b07a3b595bf75943feacb80ff123/overlay-actions.ts) send messages and manipulate Crew tasks.
- Installed Pi SDK `docs/sdk.md`, `docs/session-format.md`, and `examples/sdk/11-sessions.ts`: session persistence, native branching, compaction, and CLI resume. Installed `herdr pane split --help`, `herdr agent start --help`, `herdr agent focus --help`, and `herdr pane zoom --help`: real interactive launch and navigation.
This is a fit comparison of the requested packages and their actual examples, not a package security audit or a measured reliability ranking. End-to-end fork/compact/pair, reload, background completion, sign-off, and visible switching remain to be tested.
<!-- Research and synthesis by Pi; recommendation pending user approval. -->
@@ -0,0 +1,80 @@
# Plan supervisor: implementation and trial status
2026-09-07. Feature-branch implementation, reviewed fixes and local trial. One live supervised goal completed successfully. Full navigation/reload UAT and token-saving measurements remain open. The user authorized registering the two companion packages in Pi settings and committing/pushing both feature branches. No npm release is part of this change.
## What changed
Pi-goals now forks a real supervisor session and starts it through native Herdr commands. The existing supervisor package supplies its policy, incremental VCC worker views and retained judgement. Ready waits for initialization and acknowledged pairing, then starts work; it is not a second plan-approval gate.
Routine checks use the 50-model-turn / 60-minute / settled-with-no-tracked-work policy. One CompleteGoal call requests a correlated supervisor decision before running the separate fresh evidence judge. Direct supervisor/worker focus and zoom commands replace Fleet navigation for this workflow. Small measured forks can skip compaction; larger/unknown forks use native compaction. Missing-pane recovery is deliberately manual.
## Parent-observed automated validation
[Saved default-on validation](evidence/2026-09-07_default-on-validation.log) records `Tests 48 passed (48)` across 11 files, plus typecheck, lint, build and diff checks. Both the actual-package hook test and real-Pi/Intercom test now initialize supervision without an explicit enable command. New regressions cover default-on migration, explicit-off persistence and preserving active legacy plans.
[Earlier full saved output](evidence/2026-09-07_supervisor-validation.log) covers all seven lifecycle review fixes, before the default-on change. Relevant excerpts:
```text
GOALS: enabled suite after R6/R7
Test Files 11 passed (11)
Tests 45 passed (45)
...
Checked 16 files in 56ms. No fixes applied.
...
tests 116
pass 116
fail 0
skipped 0
...
POST-R6-R7 PARENT VALIDATION PASSED
```
The goals suite ran with `PI_GOALS_SUPERVISOR_SOURCE` pointing to the matching supervisor branch. It included the actual two-Pi RPC / Intercom-broker / fresh-offline-judge test, with Herdr mocked. The hook integration additionally exercises two goals with actual package code and a persisted native fork, but mocks transport, Herdr and the judge. Neither is visual TUI proof. Typecheck, lint, build and both diff checks also passed. The supervisor suite ran through its literal `npm test`, not only the worker's alternate runner.
Reproduce from the goals worktree:
```bash
PI_GOALS_SUPERVISOR_SOURCE=/home/ubuntu/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration/src/index.ts npm test
npm run typecheck && npm run lint && npm run build && git diff --check
cd /home/ubuntu/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration
npm test && git diff --check
```
## Review disposition
All seven implementation findings were accepted and fixed:
- R1: preserve unknown context usage and remove the stale pre-compaction token floor.
- R2: cancel stale Ready handoffs after awaited activation and plan replacement.
- R3: invalidate suspended view/compaction continuations after stop or shutdown.
- R4: persist acknowledged initialization separately from provisional bootstrap state.
- R5: preserve explicit-stop state across cleanup and reload.
- R6: preserve and acknowledge worker activation during same-binding bootstrap replay.
- R7: keep the plan in starting until activation succeeds; steward-off returns it to planning.
The independent final review verified the original R1R5 scenarios and found R6/R7. Its verdict was BLOCK for R6. The parent then authorized the two narrow fixes, inspected their source and regression tests, and ran the final suites above. No fourth independent review was launched: the three-round cap was reached. R6/R7 therefore have parent review and regression evidence, not a subsequent independent approval.
The orchestration script failed after the first fix worker because its progress object included an undefined optional output reference. Completed code/results were retained; only the unlaunched final reviewer was recovered. This did not constitute a code/test failure.
## Local trial
The user has registered all three local packages in Pi settings, so ordinary Pi startup now loads them. For a temporary trial elsewhere, start inside Herdr with the matching goals extension already loaded and pass the companions explicitly:
```bash
base="$HOME/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration"
pi -e "$base/src/index.ts" -e "$base/node_modules/pi-intercom/index.ts"
```
Then use `/goals plan <objective>` and Ready. Steward and 60-minute fallback auto-continue now default to on; explicit off preferences persist. `/goals supervisor`, `/goals worker`, and `/goals zoom` operate on the recorded real panes. `/goals steward off` stops the relationship, not the terminal pane.
Observe both panes, switch and zoom, reload/compact the supervisor, then complete two goals. If a pane ID is lost, locate the existing supervisor before reopening its saved session; a missing pane ID is not evidence that its process exited. Unknown background providers are not evidence that all work finished. Automatic phase-model switching was not implemented.
## Live trial and remaining cleanup issue
The user completed one real Herdr-supervised file-table goal. The supervisor delivered a direction message, and CompleteGoal ultimately accepted the evidence after corrections to the saved command/transcript attribution. This establishes a live pairing, steering and goal-sign-off path; it does not establish two-goal persistence or focus/zoom/reload behavior.
After completion, the supervisor reported that its `done` call was blocked by `Cannot finish: the worker still has work running (write).` Its worker view still reported an unresolved write while the worker was settled and tracked processes/subagents were zero. The origin of this apparently stale tool state has not been diagnosed. Automatic whole-plan cleanup is therefore not verified. `/goals clear` explicitly disconnects the pairing and stops its watch timer while preserving the plan; the supervisor pane/session can remain as history. A new plan creates a new version and pairing.
The managed pi-goals checkout's pre-existing `package-lock.json` modification was left untouched. Development dependencies were installed only in the new supervisor feature checkout. The later user-approved settings change registers the existing local supervisor and Intercom copies; it does not upgrade or download packages.
<!-- Final implementation synthesis and observed validation by Pi. -->
@@ -0,0 +1,27 @@
> @wassname2/pi-goals@0.2.2 test
> vitest run
RUN v4.1.9 /home/ubuntu/.pi/agent/worktrees/pi-goals-persistent-steward
Test Files 11 passed (11)
Tests 48 passed (48)
Start at 14:06:36
Duration 6.78s (transform 1.61s, setup 0ms, import 16.58s, tests 11.71s, environment 2ms)
> @wassname2/pi-goals@0.2.2 typecheck
> tsc --noEmit
> @wassname2/pi-goals@0.2.2 lint
> biome check src/ test/
Checked 16 files in 36ms. No fixes applied.
> @wassname2/pi-goals@0.2.2 build
> tsc
DEFAULT_ON_VALIDATION_PASSED
@@ -0,0 +1,167 @@
GOALS: enabled suite after R6/R7
> @wassname2/pi-goals@0.2.2 test
> vitest run
RUN v4.1.9 /home/ubuntu/.pi/agent/worktrees/pi-goals-persistent-steward
Test Files 11 passed (11)
Tests 45 passed (45)
Start at 12:09:35
Duration 6.14s (transform 1.20s, setup 0ms, import 14.85s, tests 10.77s, environment 2ms)
GOALS: typecheck
> @wassname2/pi-goals@0.2.2 typecheck
> tsc --noEmit
GOALS: lint
> @wassname2/pi-goals@0.2.2 lint
> biome check src/ test/
Checked 16 files in 56ms. No fixes applied.
GOALS: build
> @wassname2/pi-goals@0.2.2 build
> tsc
SUPERVISOR: literal npm test after R6/R7
> @wassname2/pi-supervise@0.0.4 pretest
> tsx scripts/make-fixture.ts
wrote /home/ubuntu/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration/test/forked-session.jsonl
entries (excluding the session header): 410
abandoned entries: 22
> @wassname2/pi-supervise@0.0.4 test
> tsx --test src/*.test.ts
✔ retries intercom registration when pi-intercom loads after pi-supervise (3.429966ms)
✔ a directive with no text is rejected, so the worker never sees undefined (0.247018ms)
✔ a directive from the paired supervisor becomes a real user message (29.622902ms)
✔ a directive to a busy worker interrupts, instead of waiting for the whole task (13.977563ms)
✔ a directive from an unpaired session is dropped (13.157035ms)
✔ a second pair takes over, and the first supervisor is told it lost the worker (27.141375ms)
✔ only the paired worker can end a run (7.283707ms)
✔ the programmatic pairing API waits for the worker acknowledgement (1.436818ms)
✔ the worker acknowledges a pair, so the supervisor knows it was heard (5.54306ms)
✔ a goal the supervisor inferred reaches the worker, which owns the view header (44.194636ms)
✔ the second view carries only what happened after the first (330.585574ms)
✔ a message addressed to a different session is ignored (10.170908ms)
✔ on settle the worker publishes a view built from the live branch (21.893806ms)
✔ the view is built from the live branch, not from every entry in the session (17.260824ms)
✔ an unpaired session publishes nothing on settle (0.379273ms)
✔ supervision never stops itself: no round limit at all (4.651789ms)
✔ goal, pairing and the steer count all survive a reload together (0.521505ms)
✔ a view that arrives while the supervisor is thinking is queued, not dropped (5.970369ms)
✔ the nudge repeats neither the instructions already sent nor the verdict rules (6.195585ms)
✔ a multi-line goal returns to supervisor context every fifth review and after compaction (32.097966ms)
✔ a one-line goal is not redundantly reinserted (26.327091ms)
✔ a check in and a worker that stopped ask for different things (10.092757ms)
✔ a loop still gets named after the supervisor compacts, from restored state (0.953582ms)
✔ a session that does not answer the roll call is not offered as a worker (502.245612ms)
✔ a child run stays out of the roll call, so it can never be picked (6.095977ms)
✔ a session already paired stays out of the roll call, and a free one answers (16.179354ms)
✔ /supervise look asks the worker for a fresh view, rather than the supervisor guessing (308.118194ms)
✔ let_it_run says the turn is over, so it is not called four times running (0.768638ms)
✔ a sign-off verdict is answered, not aborted, and a runaway is still cut (0.57058ms)
✔ every verdict result names the way to end the turn, steer included (0.39166ms)
✔ an old view is dropped from context once its verdict is in, and the verdict is kept (1.05293ms)
✔ a worker session never has its context rewritten (0.295565ms)
✔ a view that arrives mid-answer starts a fresh look (5.950038ms)
✔ a tool a worker cannot use never aborts its turn (0.408612ms)
✔ a resume onto a session that is gone drops the pairing and says so (5.502016ms)
✔ a resume onto a live worker keeps supervising, and takes the writers back off (6.297883ms)
✔ state written before recentSteers existed still loads (0.188312ms)
✔ done unpairs the worker, so it stops publishing views (321.527785ms)
✔ with no goal the supervisor cannot steer, it must ask the human (0.563499ms)
✔ set_goal binds an inferred goal, and steering then works (501.01427ms)
✔ a goal given at pair time still allows steering (0.523019ms)
✔ done is refused while the worker has an unanswered tool call (11.351204ms)
✔ done is allowed once nothing is outstanding (5.223176ms)
✔ steer refuses when the session is not supervising (0.364627ms)
✔ a reworded repeat of an earlier instruction is sent, and named back to the supervisor (0.483156ms)
✔ overlap scores rewording high and a different instruction low (0.114745ms)
✔ the view of the old worker cannot be used to judge the new one (6.06669ms)
✔ with one other session here, /supervise needs no target and the whole line is the goal (501.412838ms)
✔ naming the worker still works, and the rest of the line is the goal (0.519157ms)
✔ with two free sessions here, /supervise asks which one, and pairs with the choice (501.863353ms)
✔ a goal that is a path is read from the file, so it is not pasted every run (2.227961ms)
✔ a long goal is one short line above the picker, and reaches the worker whole (501.010774ms)
✔ a session that stayed quiet is still on the list, because 0 free is a dead end (501.565666ms)
✔ a cancelled picker pairs with nothing (501.796819ms)
✔ supervising takes the writing tools away, and stopping gives them back (501.55056ms)
✔ stopping gives back the writers without undoing another extension's tools (501.583537ms)
✔ a first word that names no session is refused, rather than folded into the goal (0.653782ms)
✔ a goal with spaces needs no target, and @name takes the rest of the line as the goal (501.035374ms)
✔ the brief starts no turn, so there is no answer before the first view (6.002338ms)
✔ /supervise goal changes the goal without breaking the pairing (0.74172ms)
✔ the footer says which side of a pairing this session is, and clears when it ends (505.756191ms)
✔ a session that is not supervising never sees the supervisor tools (5.764996ms)
✔ worker_view refuses when there is no worker, rather than implying a pairing (0.508317ms)
✔ the view names the worker's model and how full its context is (18.254298ms)
✔ supervising a second session is refused while the first is still paired (0.498834ms)
✔ the supervisor gets a look at a working worker every half hour, without being asked (920.025754ms)
✔ a human message in the worker session is not a reason to stand back (6.229147ms)
✔ letting a stopped worker run says plainly that the worker stays stopped (10.401535ms)
✔ a stopped worker is looked at again, so let_it_run cannot silence the pairing (924.087031ms)
✔ a worker that pairs at the prompt and never takes a turn is still watched (604.883737ms)
✔ a worker that reloads at the prompt starts watching itself again (604.981832ms)
✔ a timer look at a worker that has not moved is not sent, until it has been skipped three times (2426.519261ms)
✔ the worker counts reviews in a row where nothing changed (356.420327ms)
✔ an unacknowledged pair gives up, and a takeover cancels that timer (4.220555ms)
✔ plan bootstrap compacts only the supervisor and pairing alone never starts a worker or a review (23.339027ms)
✔ goal decisions are correlated, preserve the pair across two goals, and cannot call overall done (6.488073ms)
✔ abort and stop cancel pending requests; late decisions cannot approve a replacement (4.950084ms)
✔ 50 actual model turns trigger one view, independent of the number of messages (4.336451ms)
✔ unknown background providers are not proof of quiescence (0.39308ms)
✔ stale plan content invalidates a pending goal review (3.928135ms)
✔ small forks skip compaction, but real compaction failure prevents pairing (2.247287ms)
✔ the hour timer and a coincident turn checkpoint produce a single view (3.504665ms)
✔ settled checks wait for tracked processes and subagents to finish (2.813837ms)
✔ bootstrap stop cannot resurrect a supervisor after compaction completes (1.747705ms)
✔ a restarted worker reconnects by exact saved session identity without a new supervisor (2.135761ms)
✔ unknown initial context must compact instead of taking the known-small shortcut (0.924763ms)
✔ null post-compaction usage cannot raise the next configured 100k checkpoint (1.994091ms)
✔ stopping a routine view during compaction invalidates its suspended continuation (1.671618ms)
✔ restart of a provisional bootstrap resumes compaction and pairing in the same saved session (2.221047ms)
✔ command preserves a stopped supervisor across reload (1.905196ms)
✔ done preserves a stopped supervisor across reload (2.421426ms)
✔ same-binding replay retains activation when the supervisor lost its acknowledgement (2.604633ms)
✔ a child process named pi is found by ps, and stops being found when it exits (374.81872ms)
✔ the check is a snapshot, so it cannot hold up the worker's settle (319.675518ms)
✔ a one-line goal stays whole while a multi-line goal has a locator (8.167075ms)
✔ a view carries only the turns the supervisor has not been sent (2.090138ms)
✔ the last two reasoning blocks stay in the narrative, and older ones drop out (1.13405ms)
✔ a compaction restarts the view, so no turn falls into the gap (0.563394ms)
✔ pi-vcc reports the files the worker wrote, and separates them from the ones it read (1.400011ms)
✔ progressKey is unchanged when a review produced no new file or commit (0.525086ms)
✔ progressKey still sees a new file past pi-vcc's ten path display cap (0.725928ms)
✔ a commit counts as progress, even when no file was written since (0.874443ms)
✔ outstandingWork finds tool calls that never got a result (1.469005ms)
✔ buildView reports a tool call with no result, so done can be refused (0.885647ms)
✔ the view says how many reviews in a row changed nothing, and says nothing at zero (0.644311ms)
✔ the view merges the worker's compaction summary with the turns after it (0.44949ms)
✔ a turn the compaction summary already covers is not sent twice (0.364824ms)
✔ pi-vcc's sections and its transcript land on the right sides of the split (2.422505ms)
✔ the view does not tell the supervisor to use vcc_recall, a tool it does not have (0.315271ms)
✔ supervisor directives are not sent back as worker evidence (0.494375ms)
✔ bookkeeping tool calls are kept out of the transcript (0.412053ms)
✔ buildView reports the goal, status, and files without historical failures (0.403255ms)
✔ how long the worker has been quiet, measured from its own last entry (0.460999ms)
✔ buildView keeps the newest turns when it has to cut for the channel limit (33.622661ms)
✔ pi's own branch logic drops the abandoned fork, on a session file (1712.237778ms)
✔ a long goal cannot push the view past the broker limit (0.489445ms)
tests 116
suites 0
pass 116
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 19908.802286
POST-R6-R7 PARENT VALIDATION PASSED
@@ -0,0 +1,44 @@
# Plan-aware supervisor integration
Approved direction: compose the existing supervisor with native Pi/Herdr sessions. See [intent](../../spec/2026-09-07_plan-supervisor.md) and [final validation](../../reviews/2026-09-07_supervisor-validation.md).
- [ ] goal: Ready creates a real, plan-aware supervisor that the user can open beside the worker
- [x] Replace the checkpoint reviewer with native fork/bootstrap, acknowledged pairing and a Herdr launch/focus adapter.
- [x] Compact only the supervisor; supply the explicit worker plan and isolate the supervisor role.
- [x] Observe live supervisor steering and accepted sign-off for one goal.
- [ ] Verify navigation, reload, two-goal persistence and automatic whole-plan cleanup in Herdr.
- failure modes: dashboard mistaken for a session; inherited worker automation; duplicate startup.
- deliverable: branch implementation and trial route; live visual acceptance remains open.
- evidence: [final log](../../reviews/evidence/2026-09-07_supervisor-validation.log) shows 45 enabled goals tests passing, including two real Pi processes and actual Intercom; Herdr is mocked.
- [x] goal: One supervisor checks progress at the agreed cadence without following every worker turn
- [x] Use one 50-model-turn / 60-minute / settled-and-no-tracked-work policy, VCC views, retained verdicts and supervisor-only compaction.
- failure modes: duplicate timers; message count substituted for model turns; unknown work treated as finished.
- deliverable: supervisor branch with deterministic cadence, background-state and compaction coverage.
- evidence: [final log](../../reviews/evidence/2026-09-07_supervisor-validation.log) includes passing 50-turn, hour/turn coincidence, unknown-provider, background-completion and nullable-compaction regressions. Token savings are not yet measured.
- [x] goal: Goal sign-off asks that supervisor and still checks evidence independently
- [x] Correlate goal requests and replies; preserve pairing between goals; keep the worker and fresh judge as the completion path.
- [x] Cover stale replies, cancellation, interrupted bootstrap, lost acknowledgement and explicit stop/reload.
- failure modes: one goal ends all supervision; an old response approves another goal; disabled-only tests.
- deliverable: single-call sign-off with actual-package and real-Pi integration tests.
- evidence: [final log](../../reviews/evidence/2026-09-07_supervisor-validation.log) records 45 goals and 116 supervisor tests passing without skips; [report](../../reviews/2026-09-07_supervisor-validation.md) distinguishes the real and mocked boundaries.
- [x] goal: The two branches are reviewable and ready for a user trial
- [x] Complete the single-writer implementation, independent review, accepted R1R7 fixes and final parent diff/test checks.
- failure modes: mocks hide the package boundary; undisclosed updates; unrelated settings/lockfiles change.
- deliverable: [review disposition, saved validation and trial commands](../../reviews/2026-09-07_supervisor-validation.md).
- evidence: final log ends `POST-R6-R7 PARENT VALIDATION PASSED`; typecheck/lint/build and both diff checks passed. R6/R7 were parent-reviewed after the independent three-round cap, not independently re-reviewed.
## UAT / Verification
- Success: Ready initializes and pairs; goal review reaches the correct supervisor and fresh judge. Automated enabled-path evidence is saved above.
- Likely failure: unavailable Intercom/Herdr/API produces an actionable error, with no false approval; covered in tests.
- Sneaky failure: pending work, stale replies or restarted identities cause a false finish; targeted lifecycle regressions pass.
- Still required: see/focus/zoom both real Herdr sessions, reload/compact the supervisor and finish two goals. Measure supervisor token use and usefulness on real work.
## Constraints and state
- Goals: `/home/ubuntu/.pi/agent/worktrees/pi-goals-persistent-steward`, branch `feature/persistent-steward`.
- Supervisor: `/home/ubuntu/.pi/agent/worktrees/pi-intercom-supervisor-goals-integration`, branch `feature/pi-goals-integration`.
- State: automated default-on validation passes 48 goals tests; one live supervised goal accepted. Full Herdr UAT remains open, including an apparently stale unresolved-write cleanup blocker. User authorized companion-package settings and feature-branch commit/push; no npm release.
- Manual lost-pane recovery remains explicit. The managed pi-goals lockfile, APC files and personal journals were not edited. No council or automatic phase-model switching.
<!-- Plan and final evidence read by Pi. -->
@@ -1,5 +1,7 @@
# Persistent plan steward
Historical snapshot of the replaced checkpoint-review implementation. The user's clarified objective and proposed real-session supervisor workflow are captured in [Plan-aware persistent supervisor (2026-09-07)](2026-09-07_plan-supervisor.md); the current branch implements that newer composition, with validation limitations recorded in its plan.
## Purpose
Add judgement across a plan without weakening the fresh evidence check. The steward checks intent,
+73
View File
@@ -0,0 +1,73 @@
# Plan-aware persistent supervisor
Status: user-confirmed intent and package composition, approved 2026-09-07. The composition is implemented in the two feature worktrees. [Final validation and trial instructions](../reviews/2026-09-07_supervisor-validation.md) record passing enabled suites and real Pi/Intercom integration. Live Herdr UAT and token-saving measurements remain open in [the branch plan](../slop/plans/20260907_plan-supervisor-integration.md).
This updates the direction of [the persistent-steward branch](2026-09-03_persistent-plan-steward.md). That earlier document is a historical snapshot of the replaced checkpoint reviewer.
## User's goal
> the idea is that the supervisor agent can use less tokens, and keep track of the overall goals, keeping the work on trck, and using it's judgment and context to steer or sign off.
The worker carries detailed investigation and implementation context. The supervisor retains the overall goals, user intent, decisions, and enough progress information to judge the direction of the work. It spends its tokens on supervision rather than following every working turn.
The intended result is lower supervisory token use while keeping work on track. A succession of approval gates is not the objective. The supervisor should remember earlier reasoning and its own interventions, recognize ineffective approaches, and steer or sign off using that context.
## Proposed workflow
The user proposed:
> make a plan
> fork this agent and compact
> so supervisor has: compacted state, plan, and supervisor prompt
1. Draft the plan with the user.
2. At the planning-to-working handoff, create a separate supervisor session from the planning session's fork. Compact it unless measured context is already at most 20k tokens; unknown size does not justify skipping. The original session remains the worker.
3. Give the supervisor the compacted planning context, actual plan, and supervisor policy from `pi-intercom-supervisor` (including the existing `SUPERVISOR.md` customization route).
4. Send incremental worker updates. Retain supervisor context between checks; inactivity need not consume model tokens.
5. Let the user open either real Pi session or see both together without manually starting and pairing the second session.
The plan and supervisor policy remain available directly after compaction. Summarization should not become the only surviving copy of the user's goals.
## Two check interfaces
Routine supervision and per-goal sign-off ask different questions.
Routine checks are proposed after 50 completed worker model turns, after 60 minutes, or when the worker settles with no outstanding processes or subagents. These are candidate defaults, not measured optimal settings. Overlapping triggers should produce one check. A busy worker making progress may need no instruction; a stopped worker with unfinished work may need a continuation or a human decision.
A goal-completion attempt is a separate explicit request identifying the goal and its evidence. Approving one goal must not end supervision of the remaining plan. The existing fresh evidence judge remains in the branch; removing it has not been approved.
Use registered background-work state where available. The existing supervisor's child-process snapshot is not a complete test for outstanding work. The idle supervisor itself must not prevent the worker from ever being considered settled.
## Context and token use
- Consider native compaction for the initial supervisor fork and later supervisor-history compaction.
- Compare native summary generation with the existing VCC compiler for incremental worker views. Reporting should not require compacting the worker each time.
- Preserve user decisions and supervisory conclusions; avoid repeatedly sending the whole worker transcript.
- Reuse the existing supervisor's retention approach where useful: recent views remain detailed, older views give way to the supervisor's verdicts.
- The user suggested supervisor compaction around 100k tokens to reduce context rot and cost. Interpret this as current context size, subject to the model's limit, rather than cumulative billed tokens. Exact policy remains to be tested.
- Planning/working model persistence was considered earlier; its settings and switching behaviour are not settled.
## Preferences
Use the simplest robust composition of existing packages and supported APIs. Candidates include `pi-intercom-supervisor`, `pi-intercom`, `pi-subagents`, `pi-messenger`, and Herdr. Mentioning a package does not approve adding it as a dependency or merging its whole codebase.
Reuse the supervisor prompt and working supervision behaviour where possible. Avoid an additional orchestration framework, council, or growing collection of mechanical review gates. Routine implementation judgement belongs to the supervisor; material choices requiring the user's knowledge or preferences still come back to the user.
Keep `/goals` subcommands without required `--` prefixes. The feature should be testable in an ordinary Pi session through this branch.
Make worker/supervisor navigation direct. The user finds Fleet hard to parse. A named supervisor pane, easy switching, and a side-by-side view are preferable to requiring navigation through a fleet dashboard. A headless-run inspector is not the same thing as the real supervisor session.
## Approved implementation qualifications
Compose the existing supervisor through narrow APIs, using native Pi sessions and Herdr panes.
Human Ready is the approval: wait for successful bootstrap and pairing, then start work once. The
initial supervisor view may steer but is not a second mandatory plan-approval gate.
A demonstrably small fork (at most 20k tokens) skips the initial compaction call and reports that fact.
Unknown size does not justify skipping; other compaction failures remain visible.
If a recorded pane is missing, locate the supervisor before reopening its saved session. Missing pane
identity is not proof of process exit. Automatic recreation or an additional confirmation UI is
deferred for the branch trial, as approved by the parent during implementation.
<!-- Written by Pi from the user's conversation; integration proposals are not implementation receipts. -->
+135 -267
View File
@@ -38,7 +38,6 @@
*/
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -52,20 +51,8 @@ import {
planningState,
reminder,
resync,
reviewingState,
stewardPlanReview,
stewardSignoffReview,
} from "./prompts.js";
import {
rpcRunId,
rpcText,
STEWARD_OUTPUT_SCHEMA,
type StewardDecision,
SUBAGENT_ASYNC_COMPLETE_EVENT,
stewardCompletion,
stewardContract,
subagentRpc,
} from "./steward.js";
import { focusSupervisor, initializeSupervisor, planHash, type SupervisorBinding, type SupervisorDecision, startSupervisor, supervisorBootstrap, supervisorRequest } from "./supervisor.js";
const STATE = "pi-goals-state";
const STATUS_KEY = "pi-goals";
@@ -139,49 +126,38 @@ export function nextPlanVersion(planNames: string[], sessionId: string): number
return Math.max(0, ...versions) + 1;
}
type Phase = "planning" | "reviewing" | "working" | null;
type StewardReview = {
kind: "plan" | "signoff";
runId: string;
goal?: string;
/** Full plan hash for plan review; folded working-set hash for sign-off review. */
snapshotHash: string;
};
type StewardApproval = { goal: string; workingSetHash: string };
type Phase = "planning" | "starting" | "working" | null;
interface PlanState {
/** Distinguishes explicit preferences from the old opt-in defaults. */
defaultsVersion: 1;
phase: Phase;
/** Optional model ref for the sign-off judge; unset => current session model, else pi's default. */
judgeModel: string | null;
planVersion: number | null;
/** User-enabled interval for continuing active goals after the agent settles. */
/** Interval for continuing active goals when supervision is disabled. */
autoIntervalMs: number | null;
autoPaused: boolean;
/** Opt-in persistent, forked plan steward supplied by pi-subagents. */
/** Real supervisor session, enabled by default and paired through pi-intercom-supervisor. */
stewardEnabled: boolean;
stewardRunId: string | null;
stewardReview: StewardReview | null;
stewardApproval: StewardApproval | null;
/** Immutable working set captured when the steward approved work to start. */
approvedPlan: string | null;
supervisor: SupervisorBinding | null;
}
export default function piGoalsExtension(pi: ExtensionAPI): void {
let state: PlanState = {
defaultsVersion: 1,
phase: null,
judgeModel: null,
planVersion: null,
autoIntervalMs: null,
autoIntervalMs: AUTO_DEFAULT_INTERVAL_MS,
autoPaused: false,
stewardEnabled: false,
stewardRunId: null,
stewardReview: null,
stewardApproval: null,
approvedPlan: null,
stewardEnabled: true,
supervisor: null,
};
let planningContextPending = false;
let liveContext: ExtensionContext | null = null;
let stewardRecoveryFrom: string | null = null;
let supervisorOnly = false;
let operation: AbortController | null = null;
const lifetime = new AbortController();
// The reminder sees only the working set. A repeated Log line must not look like progress.
let turnsStale = 0;
let lastSeenWorkingSet = "";
@@ -212,14 +188,6 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
pi.appendEntry<PlanState>(STATE, state);
}
function contentHash(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
function workingSetHash(plan: string): string {
return contentHash(foldPlan(plan));
}
function workMessage(ctx: ExtensionContext): string {
return `Work the goals in ${planPath(ctx)}. Pick an open goal, mark it active ([/]), work its subtasks, and when its discriminator is satisfied fill its evidence: list, then call CompleteGoal with the goal's text. Keep the plan file current as you go.`;
}
@@ -234,6 +202,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
function scheduleAutoContinue(ctx: ExtensionContext, delayMs = state.autoIntervalMs): void {
if (state.stewardEnabled || supervisorOnly) { clearAutoTimer(); return; }
clearAutoTimer();
if (delayMs === null || state.phase !== "working" || state.autoIntervalMs === null || state.autoPaused || !activeGoals(ctx)) return;
autoTimer = setTimeout(() => {
@@ -284,9 +253,9 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: drafting goals"]);
return;
}
if (state.phase === "reviewing") {
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "steward review"));
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: forked steward reviewing the plan"]);
if (state.phase === "starting") {
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "starting supervisor"));
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: starting the supervisor session"]);
return;
}
const goals = scanGoals(readPlan(ctx));
@@ -297,7 +266,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
const done = goals.filter((g) => g.status === "done").length;
const auto = state.autoPaused ? " · waiting for user" : state.autoIntervalMs === null ? "" : ` · auto ${state.autoIntervalMs / 60_000}m`;
const steward = state.stewardEnabled ? state.stewardReview ? " · steward reviewing" : " · steward" : "";
const steward = state.stewardEnabled ? " · supervisor" : "";
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `${done}/${goals.length} goals${auto}${steward}`));
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
@@ -313,194 +282,89 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
ctx.ui.setWidget(WIDGET_KEY, lines);
}
function stewardMessage(decision: StewardDecision): string {
const drift = decision.contractDrift.length ? `\nContract drift:\n- ${decision.contractDrift.join("\n- ")}` : "";
const unresolved = decision.unresolvedDecisions.length ? `\nNeeds human decision:\n- ${decision.unresolvedDecisions.join("\n- ")}` : "";
return `Persistent plan steward: ${decision.decision}\n${decision.reason}\nNext: ${decision.nextAction}${drift}${unresolved}`;
async function stopSupervisor(ctx: ExtensionContext): Promise<void> {
operation?.abort();
operation = null;
if (state.supervisor) {
try { await supervisorRequest(pi, "stop", { bindingId: state.supervisor.id }); }
catch (error) { ctx.ui.notify(`Could not reach the supervisor to stop it: ${String(error)}. Check its pane.`, "warning"); }
}
state = { ...state, supervisor: null };
}
async function startPlanSteward(ctx: ExtensionContext): Promise<void> {
const plan = readPlan(ctx);
const workingSet = stewardContract(foldPlan(plan));
const hash = contentHash(plan);
async function startPlanSupervisor(ctx: ExtensionContext): Promise<void> {
if (operation) return;
const controller = new AbortController();
operation = controller;
const signal = AbortSignal.any([controller.signal, lifetime.signal]);
const version = state.planVersion;
const approvedDraft = planHash(readPlan(ctx));
const handoff = workMessage(ctx);
state = { ...state, phase: "starting" };
persist(); updateWidget(ctx);
try {
const prompt = stewardPlanReview(workingSet, planRel(ctx));
const data = state.stewardRunId
? await subagentRpc(pi, "resume", { id: state.stewardRunId, message: prompt })
: await subagentRpc(pi, "spawn", {
agent: "oracle",
task: prompt,
context: "fork",
async: true,
mission: false,
outputSchema: STEWARD_OUTPUT_SCHEMA,
});
const runId = rpcRunId(data);
if (!runId) throw new Error("pi-subagents spawn reply contained no run id");
state = { ...state, phase: "reviewing", stewardReview: { kind: "plan", runId, snapshotHash: hash }, stewardApproval: null };
planningContextPending = true;
persist();
updateWidget(ctx);
ctx.ui.notify("Forked plan steward is reviewing the approved draft. Work will start after its decision.", "info");
const binding = await startSupervisor(pi, ctx, planPath(ctx), state.supervisor, supervisor => {
if (signal.aborted) return;
state = { ...state, supervisor }; persist();
}, signal);
if (signal.aborted || state.planVersion !== version || !state.stewardEnabled) return;
if (planHash(readPlan(ctx)) !== approvedDraft) { await supervisorRequest(pi, "stop", { bindingId: binding.id }); state = { ...state, supervisor: null }; throw new Error("The plan changed during initialization; select Ready again"); }
state = { ...state, supervisor: binding };
persist(); updateWidget(ctx);
await supervisorRequest(pi, "activate", { bindingId: binding.id }, signal);
if (signal.aborted || state.planVersion !== version || state.supervisor?.id !== binding.id || !state.stewardEnabled) return;
if (planHash(readPlan(ctx)) !== approvedDraft) { await supervisorRequest(pi, "stop", { bindingId: binding.id }); state = { ...state, supervisor: null }; throw new Error("The plan changed during activation; select Ready again"); }
state = { ...state, phase: "working" };
persist(); updateWidget(ctx);
pi.sendUserMessage(handoff, { deliverAs: "followUp" });
} catch (error) {
state = { ...state, phase: "planning", stewardRunId: null, stewardReview: null };
persist();
updateWidget(ctx);
ctx.ui.notify(`Could not start the plan steward: ${error instanceof Error ? error.message : String(error)}`, "error");
}
if (signal.aborted) return;
state = { ...state, phase: "planning" }; persist(); updateWidget(ctx);
ctx.ui.notify(`Could not initialize the supervisor: ${String(error)}. Use /goals supervisor to inspect startup, or /goals steward off and retry Ready.`, "error");
} finally { if (operation === controller) operation = null; }
}
async function startSignoffSteward(ctx: ExtensionContext, goal: string): Promise<string> {
if (!state.stewardRunId || !state.approvedPlan) return "Persistent steward has no retained approved-plan session. Select Ready again or disable the steward.";
const currentPlan = stewardContract(foldPlan(readPlan(ctx)), { preserveGoalStatus: true });
const hash = workingSetHash(readPlan(ctx));
try {
const data = await subagentRpc(pi, "resume", {
id: state.stewardRunId,
message: stewardSignoffReview({
approvedPlan: state.approvedPlan,
currentPlan,
planPath: planRel(ctx),
goal,
}),
});
const runId = rpcRunId(data);
if (!runId) throw new Error("pi-subagents resume reply contained no run id");
state = { ...state, stewardReview: { kind: "signoff", runId, goal, snapshotHash: hash }, stewardApproval: null };
persist();
updateWidget(ctx);
return `Sign-off paused while the persistent steward reviews trajectory and scope (run ${runId.slice(0, 8)}). Its child process exits after the review; the retained session will be resumed at the next checkpoint.`;
} catch (error) {
return `Could not resume the persistent steward: ${error instanceof Error ? error.message : String(error)}`;
}
}
async function reconcilePendingSteward(ctx: ExtensionContext): Promise<void> {
const pending = state.stewardReview;
if (!pending) {
if (state.phase === "reviewing") {
state = { ...state, phase: "planning" };
persist();
}
return;
}
try {
const status = await subagentRpc(pi, "status", { id: pending.runId });
if (!/\b(?:complete|failed|paused|stopped)\b/i.test(rpcText(status))) return;
if (state.stewardReview?.runId !== pending.runId) return;
const plan = readPlan(ctx);
const message = pending.kind === "plan"
? stewardPlanReview(stewardContract(foldPlan(plan)), planRel(ctx))
: stewardSignoffReview({
approvedPlan: state.approvedPlan ?? "(approved plan unavailable)",
currentPlan: stewardContract(foldPlan(plan), { preserveGoalStatus: true }),
planPath: planRel(ctx),
goal: pending.goal ?? "(goal unavailable)",
});
stewardRecoveryFrom = pending.runId;
const resumed = await subagentRpc(pi, "resume", { id: pending.runId, message });
const runId = rpcRunId(resumed);
if (!runId) throw new Error("pi-subagents resume reply contained no run id");
if (state.stewardReview?.runId !== pending.runId) return;
state = { ...state, stewardReview: { ...pending, runId } };
persist();
ctx.ui.notify("Recovered the pending persistent steward review after session restart.", "info");
} catch (error) {
ctx.ui.notify(`Could not reconcile the pending steward review: ${error instanceof Error ? error.message : String(error)}`, "warning");
} finally {
stewardRecoveryFrom = null;
}
}
pi.events.on(SUBAGENT_ASYNC_COMPLETE_EVENT, async (payload: unknown) => {
const ctx = liveContext;
const pending = state.stewardReview;
const completion = stewardCompletion(payload);
if (completion?.runId === stewardRecoveryFrom) return;
if (!ctx || !pending || !completion || completion.runId !== pending.runId) return;
state = { ...state, stewardRunId: completion.runId, stewardReview: null };
if (completion.error || !completion.decision) {
if (pending.kind === "plan") state = { ...state, phase: "planning" };
persist();
updateWidget(ctx);
pi.sendMessage({
customType: "pi-goals-steward",
content: `Persistent plan steward failed: ${completion.error ?? "no decision"}. The plan or goal remains unapproved; retry or use /goals steward off.`,
display: true,
}, { triggerTurn: true });
return;
}
const decision = completion.decision;
if (pending.kind === "plan") {
const current = readPlan(ctx);
if (contentHash(current) !== pending.snapshotHash) {
state = { ...state, phase: "planning" };
persist();
updateWidget(ctx);
pi.sendMessage({ customType: "pi-goals-steward", content: "The plan changed while the steward reviewed it. Review the current draft and select Ready again.", display: true }, { triggerTurn: true });
return;
}
if (decision.decision === "approve") {
state = { ...state, phase: "working", approvedPlan: stewardContract(foldPlan(current)) };
persist();
updateWidget(ctx);
pi.sendMessage({ customType: "pi-goals-steward", content: stewardMessage(decision), display: true });
pi.sendUserMessage(workMessage(ctx), { deliverAs: "followUp" });
return;
}
state = { ...state, phase: "planning", approvedPlan: null };
persist();
planningContextPending = true;
updateWidget(ctx);
pi.sendMessage({ customType: "pi-goals-steward", content: stewardMessage(decision), display: true }, { triggerTurn: true });
return;
}
if (decision.decision === "approve" && pending.goal) {
state = { ...state, stewardApproval: { goal: pending.goal, workingSetHash: pending.snapshotHash } };
persist();
updateWidget(ctx);
pi.sendMessage({
customType: "pi-goals-steward",
content: `${stewardMessage(decision)}\n\nTrajectory review passed. Call CompleteGoal again for the fresh evidence review.`,
display: true,
}, { triggerTurn: true });
return;
}
persist();
updateWidget(ctx);
pi.sendMessage({ customType: "pi-goals-steward", content: stewardMessage(decision), display: true }, { triggerTurn: true });
});
// --- /goals: enter plan mode (or clear / set judge / set steward) -------------------------------
pi.registerCommand("goals", {
description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals <objective> | /goals clear | /goals auto [minutes|off] | /goals judge <model> | /goals steward [on|off|status]`,
description: `Plan mode: draft goals into ${PLAN_SHAPE}, review, then work them. /goals plan <objective> | /goals supervisor | /goals worker | /goals zoom | /goals <objective> | /goals clear | /goals auto [minutes|off] | /goals judge <model> | /goals steward [on|off|status]`,
handler: async (args, ctx) => {
const arg = args.trim();
if (arg === "clear" || arg === "--clear") {
if (supervisorOnly) {
const bootstrap = supervisorBootstrap(ctx)!;
if (["worker", "supervisor", "zoom"].includes(args.trim())) await focusSupervisor(pi, bootstrap.binding, args.trim() as "worker" | "supervisor" | "zoom");
else ctx.ui.notify("This is the supervisor session. Use /goals worker to return to the plan's worker.", "info");
return;
}
const explicitPlan = args.trim() === "plan" || args.trim().startsWith("plan ");
const arg = explicitPlan ? args.trim().slice(4).trim() : args.trim();
if (!explicitPlan && ["supervisor", "worker", "zoom"].includes(arg)) {
try {
if (!state.supervisor) throw new Error("Select Ready with the steward enabled first");
await focusSupervisor(pi, state.supervisor, arg as "supervisor" | "worker" | "zoom");
} catch (error) { ctx.ui.notify(String(error), "warning"); }
return;
}
if (!explicitPlan && (arg === "clear" || arg === "--clear")) {
if (state.planVersion === null) {
ctx.ui.notify("No active plan to disconnect.", "info");
return;
}
const currentPlan = planRel(ctx);
await stopSupervisor(ctx);
clearAutoTimer();
state = {
...state,
phase: null,
planVersion: null,
autoIntervalMs: null,
autoPaused: false,
stewardRunId: null,
stewardReview: null,
stewardApproval: null,
approvedPlan: null,
supervisor: null,
};
persist();
updateWidget(ctx);
ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info");
return;
}
if (arg === "auto" || arg.startsWith("auto ") || arg === "--auto" || arg.startsWith("--auto ")) {
if (!explicitPlan && (arg === "auto" || arg.startsWith("auto ") || arg === "--auto" || arg.startsWith("--auto "))) {
const command = arg.startsWith("--") ? "--auto" : "auto";
const value = arg.slice(command.length).trim();
if (value === "off") {
@@ -530,32 +394,30 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
ctx.ui.notify(`Goal auto-continue enabled every ${minutes}m.`, "info");
return;
}
if (arg === "steward" || arg.startsWith("steward ")) {
if (!explicitPlan && (arg === "steward" || arg.startsWith("steward "))) {
const value = arg.slice("steward".length).trim() || "status";
if (value === "status") {
const status = state.stewardEnabled
? state.stewardReview ? `enabled; ${state.stewardReview.kind} review running` : state.stewardRunId ? "enabled; retained steward ready" : "enabled; starts when Ready is selected"
: "disabled";
ctx.ui.notify(`Persistent plan steward: ${status}.`, "info");
try {
const status = await supervisorRequest<{ connected: boolean }>(pi, "status");
ctx.ui.notify(`Plan supervisor: ${!state.stewardEnabled ? "disabled" : status.connected ? "connected" : "enabled, not connected; starts at Ready"}.`, "info");
} catch (error) { ctx.ui.notify(`Plan supervisor: ${state.stewardEnabled ? "enabled" : "disabled"}; ${String(error)}`, "warning"); }
return;
}
if (value !== "on" && value !== "off") {
ctx.ui.notify("Use /goals steward on, off, or status.", "warning");
return;
}
if (value === "on" && state.phase === "working" && !state.stewardRunId) {
ctx.ui.notify("Enable the persistent steward before selecting Ready so it can approve the plan baseline.", "warning");
if (value === "on" && state.phase === "working" && !state.supervisor) {
ctx.ui.notify("Enable the persistent steward before selecting Ready so it can retain the planning context.", "warning");
return;
}
if (value === "off") await stopSupervisor(ctx);
state = {
...state,
stewardEnabled: value === "on",
...(value === "off" ? {
phase: state.phase === "reviewing" ? "planning" : state.phase,
stewardRunId: null,
stewardReview: null,
stewardApproval: null,
approvedPlan: null,
phase: state.phase === "starting" ? "planning" : state.phase,
supervisor: null,
} : {}),
};
persist();
@@ -563,7 +425,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
ctx.ui.notify(`Persistent plan steward ${value === "on" ? "enabled" : "disabled"}.`, "info");
return;
}
if (arg === "judge" || arg.startsWith("judge ") || arg === "--judge" || arg.startsWith("--judge ")) {
if (!explicitPlan && (arg === "judge" || arg.startsWith("judge ") || arg === "--judge" || arg.startsWith("--judge "))) {
const command = arg.startsWith("--") ? "--judge" : "judge";
const ref = arg.slice(command.length).trim();
state = { ...state, judgeModel: ref || null };
@@ -571,14 +433,12 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
ctx.ui.notify(ref ? `Sign-off judge model set to ${ref}` : "Sign-off judge reset to the session model", "info");
return;
}
await stopSupervisor(ctx);
state = {
...state,
phase: "planning",
planVersion: nextVersion(ctx),
stewardRunId: null,
stewardReview: null,
stewardApproval: null,
approvedPlan: null,
supervisor: null,
};
planningContextPending = true;
resyncReason = null;
@@ -604,7 +464,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
resyncReason = null;
return why;
};
if (state.phase === "planning" || state.phase === "reviewing") return null;
if (state.phase === "planning" || state.phase === "starting") return null;
if (!plan.trim()) return null;
const why = drainResync();
if (why) return resync(plan, planRel(ctx), why);
@@ -621,20 +481,20 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
// The phase snapshot enters context only when planning starts or context was lost.
pi.on("before_agent_start", async (_event, ctx) => {
if ((state.phase !== "planning" && state.phase !== "reviewing") || !planningContextPending) return;
if ((state.phase !== "planning" && state.phase !== "starting") || !planningContextPending) return;
planningContextPending = false;
const content = state.phase === "reviewing" ? reviewingState(planPath(ctx)) : planningState(planPath(ctx));
const content = planningState(planPath(ctx));
return { message: { customType: PLANNING_CONTEXT, content, display: false } };
});
// PI: Working turns never see an obsolete planning snapshot. Auto-compaction retries skip
// before_agent_start, so context restores the planning snapshot exactly once in that path.
pi.on("context", async (event, ctx) => {
const inPlanGate = state.phase === "planning" || state.phase === "reviewing";
const inPlanGate = state.phase === "planning" || state.phase === "starting";
const messages = inPlanGate ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT);
if (inPlanGate && planningContextPending) {
planningContextPending = false;
const text = state.phase === "reviewing" ? reviewingState(planPath(ctx)) : planningState(planPath(ctx));
const text = planningState(planPath(ctx));
return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] };
}
const text = dueInjection(ctx, readPlan(ctx));
@@ -654,7 +514,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
updateWidget(ctx);
}
}
if ((state.phase === "planning" || state.phase === "reviewing") && event.source !== "extension") writePlan(ctx, appendInterview(readPlan(ctx), event.text));
if ((state.phase === "planning" || state.phase === "starting") && event.source !== "extension") writePlan(ctx, appendInterview(readPlan(ctx), event.text));
});
// The staleness clock sees only the working set. Log updates are durable evidence, not progress.
@@ -665,6 +525,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
return;
}
lastSeenWorkingSet = workingSet;
if (state.supervisor && state.phase === "working") void supervisorRequest(pi, "update", { bindingId: state.supervisor.id }).catch((error: Error) => ctx.ui.notify(error.message, "warning"));
turnsStale = 0;
updateWidget(ctx);
});
@@ -677,7 +538,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
if (state.phase === "working" && (event.toolName === "subagent" || (event.toolName === "process" && (event.input as { action?: string }).action === "start"))) {
runStartedBackgroundWork = true;
}
if (state.phase !== "planning" && state.phase !== "reviewing") return;
if (state.phase !== "planning" && state.phase !== "starting") 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;
@@ -690,7 +551,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
// A compaction loses context, so restore either the planning snapshot or the working plan once.
pi.on("session_compact", async () => {
if (state.phase === "planning" || state.phase === "reviewing") planningContextPending = true;
if (state.phase === "planning" || state.phase === "starting") planningContextPending = true;
else resyncReason = "The session was just compacted.";
});
@@ -727,15 +588,13 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
continue;
}
if (choice === "Cancel") {
await stopSupervisor(ctx);
rmSync(planPath(ctx), { force: true });
state = {
...state,
phase: null,
planVersion: null,
stewardRunId: null,
stewardReview: null,
stewardApproval: null,
approvedPlan: null,
supervisor: null,
};
persist();
updateWidget(ctx);
@@ -744,10 +603,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
}
if (choice !== "Ready") return;
if (state.stewardEnabled) {
await startPlanSteward(ctx);
await startPlanSupervisor(ctx);
return;
}
state = { ...state, phase: "working", approvedPlan: foldPlan(plan) };
state = { ...state, phase: "working" };
persist();
updateWidget(ctx);
pi.sendUserMessage(workMessage(ctx), { deliverAs: "followUp" });
@@ -756,34 +615,41 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
});
pi.on("session_start", async (_event, ctx) => {
liveContext = ctx;
const bootstrap = supervisorBootstrap(ctx);
if (bootstrap) {
supervisorOnly = true;
pi.setActiveTools(pi.getActiveTools().filter(tool => tool !== "CompleteGoal"));
initializeSupervisor(pi, ctx, bootstrap, lifetime.signal);
return;
}
const last = ctx.sessionManager
.getEntries()
.filter((e: { type?: string; customType?: string }) => e.type === "custom" && e.customType === STATE)
.pop() as { data?: PlanState } | undefined;
// Upgrade cleared/unused legacy sessions, but never attach supervision mid-plan.
const saved = last?.data;
const useNewDefaults = saved?.defaultsVersion !== 1 && saved?.planVersion == null;
state = {
phase: last?.data?.phase ?? null,
defaultsVersion: 1,
phase: last?.data?.phase === "working" ? "working" : last?.data?.phase ? "planning" : null,
judgeModel: last?.data?.judgeModel ?? null,
planVersion: last?.data?.planVersion ?? null,
autoIntervalMs: last?.data?.autoIntervalMs ?? null,
autoPaused: last?.data?.autoPaused ?? false,
stewardEnabled: last?.data?.stewardEnabled ?? false,
stewardRunId: last?.data?.stewardRunId ?? null,
stewardReview: last?.data?.stewardReview ?? null,
stewardApproval: last?.data?.stewardApproval ?? null,
approvedPlan: last?.data?.approvedPlan ?? null,
autoIntervalMs: useNewDefaults || saved?.autoIntervalMs === undefined ? AUTO_DEFAULT_INTERVAL_MS : saved.autoIntervalMs,
autoPaused: useNewDefaults ? false : saved?.autoPaused ?? false,
stewardEnabled: useNewDefaults ? true : saved?.stewardEnabled ?? true,
supervisor: last?.data?.supervisor ?? null,
};
await reconcilePendingSteward(ctx);
lastSeenWorkingSet = foldPlan(readPlan(ctx));
autoLastWorkingSet = lastSeenWorkingSet;
planningContextPending = state.phase === "planning" || state.phase === "reviewing";
planningContextPending = state.phase === "planning" || state.phase === "starting";
resyncReason = state.phase === "working" ? "New session." : null;
updateWidget(ctx);
scheduleAutoContinue(ctx);
});
pi.on("session_shutdown", async () => {
liveContext = null;
lifetime.abort();
operation?.abort();
clearAutoTimer();
});
@@ -797,25 +663,26 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
goal: Type.String({ description: completeGoalParamDescription }),
}),
async execute(_id, params, signal, onUpdate, ctx) {
if (state.phase === "planning" || state.phase === "reviewing") return result("Planning is not approved. Wait for the steward or choose Ready before signing off a goal.", true);
if (state.phase === "planning" || state.phase === "starting") return result("Planning is not approved. Wait for the steward or choose Ready before signing off a goal.", true);
const plan = readPlan(ctx);
if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true);
if (supervisorOnly) throw new Error("Only the worker can complete its plan goals");
if (state.stewardEnabled) {
const hash = workingSetHash(plan);
const approved = state.stewardApproval;
const approvalMatches = approved
&& approved.goal.trim().toLowerCase() === params.goal.trim().toLowerCase()
&& approved.workingSetHash === hash;
if (!approvalMatches) {
if (state.stewardReview) return result("Sign-off is already paused for a persistent steward review. Wait for its decision.");
const message = await startSignoffSteward(ctx, params.goal);
return result(message, message.startsWith("Could not") || message.startsWith("Persistent steward has no"));
}
state = { ...state, stewardApproval: null };
persist();
if (!state.supervisor) return result("No supervisor is paired. Retry Ready or use /goals steward off.", true);
const bindingId = state.supervisor.id;
const hash = planHash(plan);
try {
onUpdate?.({ content: [{ type: "text", text: "Supervisor checking trajectory and scope…" }], details: {} });
const decision = await supervisorRequest<SupervisorDecision>(pi, "review", { bindingId, goal: params.goal, planHash: hash }, AbortSignal.any([lifetime.signal, ...(signal ? [signal] : [])]));
if (state.supervisor?.id !== bindingId || planHash(readPlan(ctx)) !== hash || decision.bindingId !== bindingId || decision.goal !== params.goal || decision.planHash !== hash) return result("Plan or pairing changed during goal review; retry.", true);
if (decision.decision !== "approve") return result(`Supervisor: ${decision.decision}. ${decision.reason}`, true);
} catch (error) { return result(`Supervisor review failed: ${String(error)}`, true); }
}
const reviewedPlanHash = planHash(plan);
const reviewedVersion = state.planVersion;
const reviewedPairing = state.supervisor?.id;
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: {} });
// decideSignOff runs the judge and derives the outcome + the one log line. judgeModel is never
@@ -836,6 +703,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
writeFileSync(join(ctx.cwd, rel), `goal: ${params.goal}\nmodel: ${judgeModel ?? "pi default"}\nerror: ${raw.error ?? "none"}\n\n${raw.output}\n`);
transcriptNote = ` (${rel})`;
}
if (state.planVersion !== reviewedVersion || state.supervisor?.id !== reviewedPairing || planHash(readPlan(ctx)) !== reviewedPlanHash) return result("The plan or supervisor changed during evidence review; no goal was signed off. Retry.", true);
if (outcome.logEntry) {
// Sign-off write: tick the goal [x] (exact-subject match; dogfood showed agent bookkeeping
// is the drift point) and append the audit log line, one write. On wording drift the tick
+1 -73
View File
@@ -160,15 +160,6 @@ work, mark a goal [/] or [x], or sign off a goal. The plan is not approved until
Ready.`;
}
export function reviewingState(planPath: string): string {
return `\
[PLAN STEWARD REVIEW]
A forked read-only steward is reviewing the plan at ${planPath}. Work has not started. Do not edit
project files, call CompleteGoal, or treat the plan as approved. You may inspect facts and update only
the plan if the human supplies a correction; that invalidates the pending review and requires Ready
again.`;
}
export function reminder(foldedPlan: string, planRel: string): string {
return `\
<system-reminder>
@@ -206,72 +197,9 @@ ${plan}
</system-reminder>`;
}
/* ─────────────────────────────────────────────────────────────────────────
* 4. persistent steward — a forked, read-only perspective kept between checkpoints
* ──────────────────────────────────────────────────────────────────────── */
export function stewardPlanReview(plan: string, planPath: string): string {
return `\
You are the persistent plan steward for one pi-goals plan. You are a read-only adviser, not the
worker, user, evidence judge, or final decision-maker. This review happens after the human selected
Ready but before work starts; it may be a resumed review of a revised draft.
Judge whether the written plan preserves the user's requested result and is safe to execute. Look
for invented scope, hidden user decisions, goals that depend on later goals, overlapping
criteria, impossible ordering, and discriminators that can pass without the user-visible result.
Do not request more detail merely for audit neatness. Do not assess implementation evidence yet.
A product, scientific, editorial, scope, or authority choice that the human did not settle belongs
in unresolvedDecisions; never choose a sensible default on the human's behalf.
Return approve only when work may start without revising the plan or asking the human. Return
revise_plan when the agent can repair the written plan without a new human decision. Return
needs_user when the human owns a material unresolved choice. Keep reason and nextAction concise.
Plan path: ${planPath}
--- proposed plan working set ---
${plan}
--- end proposed plan working set ---`;
}
export function stewardSignoffReview(p: {
approvedPlan: string;
currentPlan: string;
planPath: string;
goal: string;
}): string {
return `\
Resume your role as the persistent plan steward. This is a trajectory review before a separate
fresh evidence judge checks artifacts. Do not duplicate the evidence audit and do not mark the
goal complete.
Decide whether signing off this goal now remains faithful to the human-approved result and the
approved plan. Check for changed meaning, scope substitution, a prerequisite allocated to another
goal, an inferred human decision, and work that optimizes judge acceptance instead of the requested
artifact or behavior. Routine task/evidence/checkbox progress is not contract drift.
Return approve only when this is the right goal and interpretation to send to the evidence judge.
Return revise_plan when the agent should repair goal ordering, wording, or scope first. Return
needs_user when a material decision belongs to the human. Keep reason and nextAction concise.
Plan path: ${p.planPath}
Goal proposed for sign-off: ${p.goal}
--- human-approved plan working set ---
${p.approvedPlan}
--- end approved plan working set ---
--- current plan working set ---
${p.currentPlan}
--- end current plan working set ---`;
}
/* ─────────────────────────────────────────────────────────────────────────
* 5. completeGoal — SIGN-OFF, agent-side: the one blessed tool
* ──────────────────────────────────────────────────────────────────────── */
export const completeGoalDescription =
"Sign off a goal once its discriminator is satisfied. When the optional persistent steward is enabled, " +
"the first call pauses sign-off for a retained trajectory review; call CompleteGoal again only after that " +
"review approves. First fill the goal's evidence: list in the " +
"this call asks the retained supervisor about direction and scope before running the fresh judge. First fill the goal's evidence: list in the " +
"plan file: each item pairs a durable artifact with a short read of it (a quoted+linked log, a " +
"table plus how to read it, a metric plus what it shows -- not a bare claim). Quote verbatim from " +
"output you actually observed; never reconstruct numbers from memory. If you couldn't see an " +
-156
View File
@@ -1,156 +0,0 @@
import { randomUUID } from "node:crypto";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export const SUBAGENT_RPC_REQUEST_EVENT = "subagents:rpc:v1:request";
export const SUBAGENT_RPC_REPLY_PREFIX = "subagents:rpc:v1:reply:";
export const SUBAGENT_ASYNC_COMPLETE_EVENT = "subagent:async-complete";
export type StewardDecisionName = "approve" | "revise_plan" | "needs_user";
export interface StewardDecision {
decision: StewardDecisionName;
reason: string;
nextAction: string;
contractDrift: string[];
unresolvedDecisions: string[];
}
export function stewardContract(plan: string, options: { preserveGoalStatus?: boolean } = {}): string {
const output: string[] = [];
let evidenceIndent: number | null = null;
for (const line of plan.split("\n")) {
const indent = line.match(/^\s*/)?.[0].length ?? 0;
if (evidenceIndent !== null) {
if (!line.trim()) continue;
if (!line.startsWith("#") && indent > evidenceIndent) continue;
evidenceIndent = null;
}
if (/^\s*-\s*evidence\s*:/i.test(line)) {
output.push(line.replace(/:.*/, ": (checked separately by the fresh evidence judge)"));
evidenceIndent = indent;
continue;
}
const goalLine = /^\s*(?:\d+\.|[-*])\s*\[[ xX/-]\]\s*goal:/i.test(line);
output.push(goalLine && options.preserveGoalStatus ? line : line.replace(/\[[ xX/-]\]/g, "[ ]"));
}
return output.join("\n").trimEnd();
}
export const STEWARD_OUTPUT_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["decision", "reason", "nextAction", "contractDrift", "unresolvedDecisions"],
properties: {
decision: { enum: ["approve", "revise_plan", "needs_user"] },
reason: { type: "string" },
nextAction: { type: "string" },
contractDrift: { type: "array", items: { type: "string" } },
unresolvedDecisions: { type: "array", items: { type: "string" } },
},
} as const;
interface RpcReply {
version: 1;
requestId: string;
success: boolean;
data?: unknown;
error?: { code?: string; message?: string };
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export function rpcText(data: unknown): string {
const top = record(data);
return typeof top?.text === "string" ? top.text : "";
}
export function rpcRunId(data: unknown): string | null {
const top = record(data);
const details = record(top?.details);
for (const value of [details?.runId, top?.runId, top?.id]) {
if (typeof value === "string" && value.trim()) return value;
}
return null;
}
export function parseStewardDecision(value: unknown): StewardDecision | null {
const input = record(value);
if (!input) return null;
if (input.decision !== "approve" && input.decision !== "revise_plan" && input.decision !== "needs_user") return null;
if (typeof input.reason !== "string" || typeof input.nextAction !== "string") return null;
if (!Array.isArray(input.contractDrift) || !input.contractDrift.every((item) => typeof item === "string")) return null;
if (!Array.isArray(input.unresolvedDecisions) || !input.unresolvedDecisions.every((item) => typeof item === "string")) return null;
const contractDrift = input.contractDrift as string[];
const unresolvedDecisions = input.unresolvedDecisions as string[];
const decision = input.decision === "approve" && unresolvedDecisions.length
? "needs_user"
: input.decision === "approve" && contractDrift.length
? "revise_plan"
: input.decision;
return {
decision,
reason: input.reason,
nextAction: input.nextAction,
contractDrift,
unresolvedDecisions,
};
}
export function stewardCompletion(payload: unknown): { runId: string; decision: StewardDecision | null; error: string | null } | null {
const input = record(payload);
const runId = typeof input?.runId === "string" ? input.runId : typeof input?.id === "string" ? input.id : null;
if (!runId) return null;
const results = Array.isArray(input?.results) ? input.results : [];
const first = record(results[0]);
const effects = record(first?.effects);
const fileMutation = record(effects?.fileMutation);
const mutationObserved = fileMutation?.status === "observed" || fileMutation?.attempted === true;
const decision = parseStewardDecision(first?.structuredOutput);
const error = mutationObserved
? "steward attempted or produced a file mutation"
: typeof first?.error === "string"
? first.error
: input?.success === false
? typeof input?.summary === "string" ? input.summary : "steward subagent failed"
: decision ? null : "steward returned no valid structured decision";
return { runId, decision, error };
}
export async function subagentRpc(
pi: ExtensionAPI,
method: "spawn" | "resume" | "status",
params: Record<string, unknown>,
timeoutMs = 5_000,
): Promise<unknown> {
const requestId = randomUUID();
const replyEvent = `${SUBAGENT_RPC_REPLY_PREFIX}${requestId}`;
return new Promise((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout>;
const unsubscribe = pi.events.on(replyEvent, (value: unknown) => {
if (settled) return;
const reply = record(value) as RpcReply | null;
if (!reply || reply.requestId !== requestId) return;
settled = true;
clearTimeout(timer);
if (typeof unsubscribe === "function") unsubscribe();
if (reply.success) resolve(reply.data);
else reject(new Error(reply.error?.message ?? `pi-subagents ${method} failed`));
});
timer = setTimeout(() => {
if (settled) return;
settled = true;
if (typeof unsubscribe === "function") unsubscribe();
reject(new Error(`pi-subagents ${method} RPC did not reply within ${timeoutMs}ms`));
}, timeoutMs);
pi.events.emit(SUBAGENT_RPC_REQUEST_EVENT, {
version: 1,
requestId,
method,
params,
source: { extension: "pi-goals" },
});
});
}
+123
View File
@@ -0,0 +1,123 @@
import { createHash, randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { type ExtensionAPI, type ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
export const SUPERVISOR_ROLE = "pi-goals-supervisor";
const PLAN_API = "pi-supervise:plan:v1";
export interface SupervisorBinding {
id: string;
planPath: string;
workerSession: string;
workerPane: string;
supervisorPane?: string;
supervisorSession?: string;
active?: boolean;
everyTurns: number;
intervalMs: number;
compactTokens: number;
}
interface Bootstrap { binding: SupervisorBinding; workerId: string }
interface SupervisorStatus { connected: boolean; binding?: SupervisorBinding; workerId: string; role?: string }
export interface SupervisorDecision { bindingId: string; goal: string; planHash: string; decision: "approve" | "needs_work" | "needs_user"; reason: string }
export function planHash(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
/** The owner claims synchronously; its promise includes peer acknowledgement or review. */
export function supervisorRequest<T>(pi: ExtensionAPI, method: string, params: Record<string, unknown> = {}, signal?: AbortSignal): Promise<T> {
return new Promise((resolve, reject) => {
const request = { version: 1, method, ...params, signal, handled: false, resolve, reject };
pi.events.emit(PLAN_API, request);
if (!request.handled) reject(new Error("Load the plan-aware pi-intercom-supervisor and pi-intercom packages in both sessions, then reload Pi."));
});
}
async function herdr(pi: ExtensionAPI, args: string[], signal?: AbortSignal): Promise<Record<string, any>> {
if (process.env.HERDR_ENV !== "1") throw new Error("Start Pi inside Herdr to launch or focus the supervisor. No pane was created.");
const result = await pi.exec("herdr", args, { timeout: 45_000, signal });
if (result.code !== 0) throw new Error(`Herdr: ${result.stderr || result.stdout}`);
const parsed = JSON.parse(result.stdout);
if (parsed.error) throw new Error(`Herdr: ${parsed.error.message}`);
return parsed.result ?? parsed;
}
export async function focusSupervisor(pi: ExtensionAPI, binding: SupervisorBinding, target: "supervisor" | "worker" | "zoom"): Promise<void> {
const pane = target === "worker" ? binding.workerPane : binding.supervisorPane;
if (!pane) throw new Error("No supervisor pane is recorded. Select Ready to start it.");
try { await herdr(pi, target === "zoom" ? ["pane", "zoom", "--pane", pane, "--toggle"] : ["agent", "focus", pane]); }
catch (error) { throw new Error(`${String(error)}. Session location/liveness is unknown. Locate the existing supervisor first; only after confirming it is no longer running, reopen pi --session ${JSON.stringify(binding.supervisorSession)}.`); }
}
export function supervisorBootstrap(ctx: ExtensionContext): Bootstrap | undefined {
const entry = ctx.sessionManager.getBranch().filter(e => e.type === "custom" && e.customType === SUPERVISOR_ROLE).at(-1);
return entry?.type === "custom" ? entry.data as Bootstrap : undefined;
}
export function initializeSupervisor(pi: ExtensionAPI, ctx: ExtensionContext, bootstrap: Bootstrap, signal?: AbortSignal): void {
// session_start handlers are ordered. Let all packages initialize before requesting their API.
setImmediate(() => {
if (signal?.aborted) return;
void supervisorRequest(pi, "bootstrap", bootstrap as unknown as Record<string, unknown>, signal).catch((error: Error) => {
if (!signal?.aborted) ctx.ui.notify(`Supervisor initialization failed: ${error.message}`, "error");
});
});
}
export async function startSupervisor(
pi: ExtensionAPI, ctx: ExtensionContext, planPath: string, existing: SupervisorBinding | null,
save: (binding: SupervisorBinding) => void, signal: AbortSignal,
): Promise<SupervisorBinding> {
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) throw new Error("Start Pi inside Herdr before enabling the steward at Ready.");
const status = await supervisorRequest<SupervisorStatus>(pi, "status", {}, signal);
signal.throwIfAborted();
if (existing && status.connected && status.binding?.id === existing.id) return status.binding;
if ((!existing && status.role && status.role !== "none") || (status.binding && status.binding.id !== existing?.id)) throw new Error("This session already has another supervision relationship. Stop it explicitly before Ready.");
const parent = ctx.sessionManager.getSessionFile();
const leaf = ctx.sessionManager.getLeafId();
if (!parent || !leaf) throw new Error("The planning session must be persisted before creating its supervisor fork.");
const supervisorSource = pi.getCommands().find(command => command.name === "supervise")?.sourceInfo?.path;
const intercomSource = pi.getAllTools().find(tool => tool.name === "intercom")?.sourceInfo?.path;
if (!supervisorSource || !intercomSource) throw new Error("Cannot resolve the loaded supervisor and Intercom extensions; load both before Ready.");
let binding = existing ?? {
id: randomUUID(), planPath, workerSession: parent, workerPane: process.env.HERDR_PANE_ID,
everyTurns: 50, intervalMs: 60 * 60_000, compactTokens: 100_000,
};
if (!existing) {
save(binding);
await supervisorRequest(pi, "prepare", { binding }, signal);
signal.throwIfAborted();
}
if (!binding.supervisorSession) {
const fork = SessionManager.open(parent);
const sessionFile = fork.createBranchedSession(leaf);
if (!sessionFile) throw new Error("Could not persist the supervisor fork");
binding = { ...binding, supervisorSession: sessionFile };
fork.appendCustomEntry(SUPERVISOR_ROLE, { binding, workerId: status.workerId });
fork.appendSessionInfo(`Supervisor ${binding.id.slice(0, 8)}`);
save(binding);
}
if (binding.supervisorPane) {
// An existing occupant is not permission to start another process on the same session file.
await focusSupervisor(pi, binding, "supervisor");
signal.throwIfAborted();
return await supervisorRequest<SupervisorBinding>(pi, "attached", { bindingId: binding.id }, signal);
}
const split = await herdr(pi, ["pane", "split", "--current", "--direction", "right", "--cwd", ctx.cwd, "--no-focus"], signal);
const pane = split.pane?.pane_id;
if (typeof pane !== "string") throw new Error("Herdr split did not return a pane ID");
binding = { ...binding, supervisorPane: pane };
save(binding);
signal.throwIfAborted();
// Keep bootstrap and worker binding identical, including the returned pane identity.
SessionManager.open(binding.supervisorSession!).appendCustomEntry(SUPERVISOR_ROLE, { binding, workerId: status.workerId });
const waiting = supervisorRequest<SupervisorBinding>(pi, "attached", { bindingId: binding.id }, signal);
void waiting.catch(() => {});
try {
await herdr(pi, ["agent", "start", `supervisor-${binding.id.slice(0, 8)}`, "--kind", "pi", "--pane", pane, "--", "--session", binding.supervisorSession!,
"-e", supervisorSource, "-e", intercomSource, "-e", fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./index.ts" : "./index.js", import.meta.url))], signal);
return await waiting;
} catch (error) {
throw new Error(`Supervisor startup incomplete: ${String(error)}. Inspect the recorded pane, resolve startup, reload it, then retry Ready.`);
}
}
+26
View File
@@ -0,0 +1,26 @@
/** Test-only Herdr adapter. Starts a real supervisor Pi in RPC mode, never a live pane. */
import { spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import goals from "../../src/index.js";
export default function testHost(pi: ExtensionAPI): void {
const children: ReturnType<typeof spawn>[] = [];
goals({ ...pi, exec: async (command, args, options) => {
if (command !== "herdr") return pi.exec(command, args, options);
if (args[0] === "pane") return { code: 0, stdout: JSON.stringify({ result: { pane: { pane_id: "test-supervisor-pane" } } }), stderr: "", killed: false };
if (args[1] === "start") {
const native = args.slice(args.indexOf("--") + 1);
const child = spawn(process.execPath, [process.argv[1], "--mode", "rpc", "--no-extensions", ...native, "--model", "offline/test"], {
cwd: process.cwd(), env: process.env, stdio: ["pipe", "pipe", "pipe"],
});
children.push(child);
const log = join(process.cwd(), "supervisor-rpc.jsonl");
child.stdout?.on("data", chunk => appendFileSync(log, chunk));
child.stderr?.on("data", chunk => appendFileSync(join(process.cwd(), "supervisor-stderr.log"), chunk));
}
return { code: 0, stdout: "{}", stderr: "", killed: false };
} });
pi.on("session_shutdown", () => { for (const child of children) child.kill(); });
}
+67 -193
View File
@@ -33,7 +33,7 @@ function setup(
cwd,
hasUI: true,
isIdle: () => true,
sessionManager: { getSessionId: () => "session-a", getSessionFile: () => join(cwd, "session-a.jsonl"), getEntries: () => entries },
sessionManager: { getSessionId: () => "session-a", getSessionFile: () => join(cwd, "session-a.jsonl"), getEntries: () => entries, getBranch: () => entries },
ui: {
theme: { fg: (_kind: string, text: string) => text },
setStatus: () => {},
@@ -66,6 +66,66 @@ function setup(
}
describe("/goals draft flow", () => {
it("enables steward and hourly auto by default in new and cleared legacy sessions", async () => {
for (const legacy of [false, true]) {
const flow = setup([]);
try {
if (legacy) flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: null, planVersion: null, stewardEnabled: false, autoIntervalMs: null } });
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ defaultsVersion: 1, stewardEnabled: true, autoIntervalMs: 3_600_000 });
await flow.commands.get("goals").handler("clear", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, stewardEnabled: true, autoIntervalMs: 3_600_000 });
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
}
});
it("preserves explicit off preferences across reload and a new plan", async () => {
const flow = setup([]);
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("auto off", flow.ctx);
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ defaultsVersion: 1, stewardEnabled: false, autoIntervalMs: null });
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("does not enable automation midway through a legacy working plan", async () => {
const flow = setup([]);
try {
flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", planVersion: 1, stewardEnabled: false, autoIntervalMs: null } });
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("judge", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardEnabled: false, autoIntervalMs: null });
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("uses the plan escape for objectives beginning with reserved command words", async () => {
const flow = setup([]);
try {
for (const objective of ["judge the vendor options", "auto generate captions", "steward the migration", "clear"]) {
await flow.commands.get("goals").handler(`plan ${objective}`, flow.ctx);
expect(flow.messages.at(-1)?.content).toContain(`Objective: ${objective}`);
}
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", planVersion: 4, judgeModel: null });
} finally { rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("keeps an enabled plan in planning when a real supervisor cannot be launched", async () => {
const flow = setup(["Ready"]);
vi.stubEnv("HERDR_ENV", "");
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n1. [ ] goal: make the file\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", stewardEnabled: true });
expect(flow.messages.some(message => message.content.startsWith("Work the goals"))).toBe(false);
} finally { vi.unstubAllEnvs(); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("preserves prior drafts, displays the plan before Refine, and records editor notes", async () => {
const flow = setup(["Refine"], ["Keep two columns.\nDo not add a filter."]);
try {
@@ -154,6 +214,7 @@ describe("/goals draft flow", () => {
it("starts work only when the human chooses Ready", async () => {
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: work on this\n");
@@ -170,207 +231,17 @@ describe("/goals draft flow", () => {
}
});
it("forks a persistent steward at Ready and resumes it before sign-off", async () => {
const flow = setup(["Ready"]);
flow.bus.on("subagents:rpc:v1:request", (value: unknown) => {
const request = value as { requestId: string; method: string; params: Record<string, unknown> };
const runId = request.method === "spawn" ? "plan-review-run" : "signoff-review-run";
flow.bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, {
version: 1,
requestId: request.requestId,
success: true,
data: { details: { runId } },
});
});
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("steward on", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## User-visible result\n\nA file exists.\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: the file can be read\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({
phase: "reviewing",
stewardReview: { kind: "plan", runId: "plan-review-run" },
});
expect(flow.messages.some((message) => message.content.includes("Work the goals"))).toBe(false);
const blockedDuringReview = await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "README.md" } }, flow.ctx);
expect(blockedDuringReview?.block).toBe(true);
const reviewContext = await flow.hooks.get("before_agent_start")({}, flow.ctx);
expect(reviewContext.message.content).toContain("[PLAN STEWARD REVIEW]");
flow.bus.emit("subagent:async-complete", {
runId: "plan-review-run",
success: true,
results: [{ structuredOutput: {
decision: "approve",
reason: "The goals preserve the requested result.",
nextAction: "Start work.",
contractDrift: [],
unresolvedDecisions: [],
} }],
});
await new Promise((resolve) => setImmediate(resolve));
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardRunId: "plan-review-run" });
expect(flow.messages.some((message) => message.content.includes("Work the goals"))).toBe(true);
const firstSignoff = await flow.tools.get("CompleteGoal").execute("", { goal: "make the file" }, undefined, undefined, flow.ctx);
expect(firstSignoff.isError).toBe(false);
expect(firstSignoff.content[0].text).toContain("Sign-off paused");
expect(flow.entries.at(-1)?.data).toMatchObject({
stewardReview: { kind: "signoff", runId: "signoff-review-run", goal: "make the file" },
});
flow.bus.emit("subagent:async-complete", {
runId: "signoff-review-run",
success: true,
results: [{ structuredOutput: {
decision: "approve",
reason: "The sign-off remains in scope.",
nextAction: "Run the fresh evidence review.",
contractDrift: [],
unresolvedDecisions: [],
} }],
});
await new Promise((resolve) => setImmediate(resolve));
expect(flow.entries.at(-1)?.data).toMatchObject({
stewardRunId: "signoff-review-run",
stewardApproval: { goal: "make the file" },
});
expect(flow.messages.at(-1)?.content).toContain("Call CompleteGoal again");
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("invalidates a plan approval when the human corrects it during review", async () => {
const flow = setup(["Ready"]);
flow.bus.on("subagents:rpc:v1:request", (value: unknown) => {
const request = value as { requestId: string };
flow.bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, {
version: 1, requestId: request.requestId, success: true, data: { details: { runId: "review-run" } },
});
});
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("steward on", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n\n## Log\n\n## Interview\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
await flow.hooks.get("input")({ text: "The file must be CSV.", source: "interactive" }, flow.ctx);
flow.bus.emit("subagent:async-complete", {
runId: "review-run",
success: true,
results: [{ structuredOutput: {
decision: "approve", reason: "The old plan was sound.", nextAction: "Start.", contractDrift: [], unresolvedDecisions: [],
} }],
});
await new Promise((resolve) => setImmediate(resolve));
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" });
expect(flow.messages.some((message) => message.content.includes("Work the goals"))).toBe(false);
expect(flow.messages.at(-1)?.content).toContain("plan changed while the steward reviewed it");
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("reconciles a completed pending steward review after session restart", async () => {
const flow = setup([]);
const methods: string[] = [];
flow.bus.on("subagents:rpc:v1:request", (value: unknown) => {
const request = value as { requestId: string; method: string };
methods.push(request.method);
if (request.method === "resume") {
flow.bus.emit("subagent:async-complete", {
runId: "lost-review",
success: true,
results: [{ structuredOutput: {
decision: "approve", reason: "Stale completion.", nextAction: "Start.", contractDrift: [], unresolvedDecisions: [],
} }],
});
}
flow.bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, {
version: 1,
requestId: request.requestId,
success: true,
data: request.method === "status" ? { text: "State: complete" } : { details: { runId: "recovered-review" } },
});
});
try {
mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true });
writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n");
flow.entries.push({
type: "custom",
customType: "pi-goals-state",
data: {
phase: "reviewing", judgeModel: null, planVersion: 1, autoIntervalMs: null, autoPaused: false,
stewardEnabled: true, stewardRunId: "lost-review", approvedPlan: null, stewardApproval: null,
stewardReview: { kind: "plan", runId: "lost-review", snapshotHash: "old" },
},
});
await flow.hooks.get("session_start")({}, flow.ctx);
expect(methods).toEqual(["status", "resume"]);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "reviewing", stewardReview: { runId: "recovered-review" } });
expect(flow.messages.some((message) => message.content.includes("Work the goals"))).toBe(false);
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("resumes the same steward after it requests a plan revision", async () => {
const flow = setup(["Ready", "Ready"]);
const methods: string[] = [];
flow.bus.on("subagents:rpc:v1:request", (value: unknown) => {
const request = value as { requestId: string; method: string };
methods.push(request.method);
flow.bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, {
version: 1,
requestId: request.requestId,
success: true,
data: { details: { runId: methods.length === 1 ? "first-review" : "revised-review" } },
});
});
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("steward on", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make it better\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
flow.bus.emit("subagent:async-complete", {
runId: "first-review",
success: true,
results: [{ structuredOutput: {
decision: "revise_plan",
reason: "The result is not observable.",
nextAction: "Name the artifact.",
contractDrift: [],
unresolvedDecisions: [],
} }],
});
await new Promise((resolve) => setImmediate(resolve));
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", stewardRunId: "first-review" });
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: create report.html\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(methods).toEqual(["spawn", "resume"]);
expect(flow.entries.at(-1)?.data).toMatchObject({ stewardReview: { runId: "revised-review" } });
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("refuses to enable a steward after an unreviewed plan is already working", async () => {
const flow = setup(["Ready"]);
try {
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n");
await flow.hooks.get("agent_settled")({}, flow.ctx);
await flow.commands.get("goals").handler("steward on", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardEnabled: false, stewardRunId: null });
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardEnabled: false, supervisor: null });
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
@@ -398,6 +269,7 @@ describe("/goals draft flow", () => {
it("reminds every eight unchanged working-set turns, ignoring log-only edits", async () => {
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n\n## Log\n");
@@ -426,6 +298,7 @@ describe("/goals draft flow", () => {
vi.useFakeTimers();
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n");
@@ -453,6 +326,7 @@ describe("/goals draft flow", () => {
vi.useFakeTimers();
const flow = setup(["Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n");
+78
View File
@@ -0,0 +1,78 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const source = process.env.PI_GOALS_SUPERVISOR_SOURCE;
const intercom = source ? resolve(dirname(source), "../node_modules/pi-intercom/index.ts") : "";
const stream = (res: import("node:http").ServerResponse, delta: object, finish = "stop") => {
res.writeHead(200, { "content-type": "text/event-stream" });
for (const data of [{ choices: [{ index: 0, delta, finish_reason: null }] }, { choices: [{ index: 0, delta: {}, finish_reason: finish }] }]) res.write(`data: ${JSON.stringify(data)}\n\n`);
res.end("data: [DONE]\n\n");
};
describe.skipIf(!source || !existsSync(intercom))("two real Pi sessions with the actual Intercom broker (Herdr mocked)", () => {
it("forks, pairs, reviews a goal and invokes a fresh offline evidence judge", async () => {
const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-rpc-"));
let planPath = ""; let reviewCalls = 0; let judgeCalls = 0;
const server = createServer((req, res) => {
let raw = ""; req.on("data", chunk => { raw += chunk; }); req.on("end", () => {
const body = JSON.parse(raw); const messages = body.messages; const last = messages.at(-1); const text = typeof last.content === "string" ? last.content : JSON.stringify(last.content);
const names = (body.tools ?? []).map((tool: any) => tool.function.name);
const call = (name: string, args: object) => stream(res, { tool_calls: [{ index: 0, id: `call-${Date.now()}`, type: "function", function: { name, arguments: JSON.stringify(args) } }] }, "tool_calls");
if (last.role === "tool") return stream(res, { content: "Check complete." });
if (names.includes("review_goal")) {
const request = text.match(/Goal sign-off request ([^ .]+)\./);
if (request) { reviewCalls++; return call("review_goal", { requestId: request[1], decision: "approve", reason: "This goal remains faithful to the plan." }); }
return call("let_it_run", { reason: "Ready selected; worker starting" });
}
if (text.includes("intercom status")) return call("intercom", { action: "status" });
if (names.includes("CompleteGoal")) {
if (text.includes("sign off first")) return call("CompleteGoal", { goal: "first" });
if (text.includes("We're in plan mode.") || text.includes("[PLANNING MODE]")) return call("write", { path: planPath, content: "# Plan\n\n## User-visible result\n\nTwo text files.\n\n## Goals\n\n1. [ ] goal: first\n - evidence: evidence.txt says PASS\n2. [ ] goal: second\n\n## Log\n" });
return stream(res, { content: "Worker is ready." });
}
judgeCalls++; stream(res, { content: "## checks:\n- evidence.txt: `PASS`; the saved receipt passed\n\nVERDICT: accept\nmissing:" });
});
});
await new Promise<void>(done => server.listen(0, "127.0.0.1", done));
const address = server.address(); if (!address || typeof address === "string") throw new Error("Offline HTTP server did not start");
const agentDir = join(cwd, ".agent"); mkdirSync(agentDir);
writeFileSync(join(agentDir, "models.json"), JSON.stringify({ providers: { offline: { baseUrl: `http://127.0.0.1:${address.port}`, apiKey: "test", api: "openai-completions", models: [{ id: "test", name: "Offline", reasoning: false, input: ["text"], contextWindow: 200_000, maxTokens: 1000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] } } }));
writeFileSync(join(cwd, "evidence.txt"), "PASS\n");
const child = spawn(resolve("node_modules/.bin/pi"), ["--mode", "rpc", "--no-extensions", "--model", "offline/test", "-e", source!, "-e", intercom, "-e", resolve("test/fixtures/herdr-test-host.ts")], {
cwd, env: { ...process.env, PI_CODING_AGENT_DIR: agentDir, PI_INTERCOM_SCOPE_ID: `test-${Date.now()}`, HERDR_ENV: "1", HERDR_PANE_ID: "test-worker-pane", PI_SUPERVISOR_DEBUG: "1" }, stdio: ["pipe", "pipe", "pipe"],
});
const events: any[] = []; let buffer = ""; let stderr = "";
const waits = new Set<(event: any) => void>();
child.stdout.on("data", chunk => { buffer += chunk; while (buffer.includes("\n")) { const at = buffer.indexOf("\n"); const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); if (!line) continue; const event = JSON.parse(line); events.push(event); for (const fn of waits) fn(event); } });
child.stderr.on("data", chunk => { stderr += chunk; });
const send = (message: object) => child.stdin.write(`${JSON.stringify(message)}\n`);
const wait = (predicate: (event: any) => boolean, from = 0) => new Promise<any>((done, reject) => {
const found = events.slice(from).find(predicate); if (found) return done(found);
const timeout = setTimeout(() => { waits.delete(listener); reject(new Error(`RPC timeout; stderr: ${stderr}\nsupervisor: ${existsSync(join(cwd, "supervisor-stderr.log")) ? readFileSync(join(cwd, "supervisor-stderr.log"), "utf8") : "not started"}\nevents: ${JSON.stringify(events.filter(e => e.method === "notify" || e.type === "extension_error")) + JSON.stringify(events.slice(-2))}`)); }, 20_000);
const listener = (event: any) => { if (predicate(event)) { clearTimeout(timeout); waits.delete(listener); done(event); } }; waits.add(listener);
});
try {
send({ type: "get_state", id: "state" }); const state = await wait(e => e.type === "response" && e.id === "state");
planPath = join(cwd, ".pi/plan", `${state.data.sessionId}-v1.md`);
const diagnosticAt = events.length;
send({ type: "prompt", id: "diagnostic", message: "intercom status" });
const diagnostic = await wait(e => e.type === "tool_execution_end" && e.toolName === "intercom", diagnosticAt);
expect(JSON.stringify(diagnostic), "Actual Intercom must connect before testing goals").toContain("Connected: Yes");
await wait(e => e.type === "agent_end", diagnosticAt);
send({ type: "prompt", id: "plan", message: "/goals plan create the outputs" });
const menu = await wait(e => e.type === "extension_ui_request" && e.method === "select"); const readyAt = events.length;
send({ type: "extension_ui_response", id: menu.id, value: "Ready" });
await wait(e => e.type === "message_start" && JSON.stringify(e.message).includes("Work the goals"), readyAt);
await wait(e => e.type === "agent_end", readyAt);
const signoffAt = events.length;
send({ type: "prompt", id: "signoff", message: "sign off first" });
await wait(e => e.type === "tool_execution_end" && e.toolName === "CompleteGoal", signoffAt);
expect(readFileSync(planPath, "utf8")).toContain("[x] goal: first"); expect(reviewCalls).toBe(1); expect(judgeCalls).toBe(1);
expect(readFileSync(planPath, "utf8")).toContain("[ ] goal: second");
} finally { send({ type: "abort" }); child.kill(); server.close(); await new Promise(done => child.once("close", done)); rmSync(cwd, { recursive: true, force: true }); }
}, 55_000);
});
-64
View File
@@ -1,64 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseStewardDecision, rpcRunId, stewardCompletion, stewardContract } from "../src/steward.js";
const decision = {
decision: "approve",
reason: "The goal remains faithful.",
nextAction: "Send it to the evidence judge.",
contractDrift: [],
unresolvedDecisions: [],
};
describe("persistent steward protocol", () => {
it("extracts run ids from pi-subagents RPC replies", () => {
expect(rpcRunId({ details: { runId: "run-1" } })).toBe("run-1");
expect(rpcRunId({ runId: "run-2" })).toBe("run-2");
expect(rpcRunId({ details: {} })).toBeNull();
});
it("accepts only the bounded structured decision", () => {
expect(parseStewardDecision(decision)).toEqual(decision);
expect(parseStewardDecision({ ...decision, decision: "done" })).toBeNull();
expect(parseStewardDecision({ ...decision, unresolvedDecisions: "none" })).toBeNull();
expect(parseStewardDecision({ ...decision, unresolvedDecisions: ["Choose the published scope."] })?.decision).toBe("needs_user");
expect(parseStewardDecision({ ...decision, contractDrift: ["The output changed."] })?.decision).toBe("revise_plan");
});
it("keeps the approved contract compact across progress and evidence growth", () => {
const plan = `1. [/] goal: make the file
- discriminator: the file can be read
- tasks:
1. [x] write it
- evidence:
- huge quoted log
- another artifact
2. [ ] goal: publish it`;
expect(stewardContract(plan)).toBe(`1. [ ] goal: make the file
- discriminator: the file can be read
- tasks:
1. [ ] write it
- evidence: (checked separately by the fresh evidence judge)
2. [ ] goal: publish it`);
expect(stewardContract(plan, { preserveGoalStatus: true })).toContain("1. [/] goal: make the file");
});
it("correlates a structured completion by run id", () => {
expect(stewardCompletion({
runId: "run-3",
success: true,
results: [{ structuredOutput: decision }],
})).toEqual({ runId: "run-3", decision, error: null });
expect(stewardCompletion({
runId: "run-4",
success: false,
summary: "child failed",
results: [{}],
})).toEqual({ runId: "run-4", decision: null, error: "child failed" });
expect(stewardCompletion({
runId: "run-5",
success: true,
results: [{ structuredOutput: decision, effects: { fileMutation: { status: "observed", attempted: true } } }],
})?.error).toBe("steward attempted or produced a file mutation");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { EventEmitter } from "node:events";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import goals from "../src/index.js";
import { SUPERVISOR_ROLE } from "../src/supervisor.js";
const judge = vi.hoisted(() => ({ calls: [] as string[][] }));
vi.mock("node:child_process", async (original) => {
const actual = await original<typeof import("node:child_process")>();
return { ...actual, spawn: (_command: string, args: string[]) => {
judge.calls.push(args);
const process = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), kill() {} });
queueMicrotask(() => { process.stdout.emit("data", "## checks:\n- evidence.txt: `PASS`; the saved check passed\n\nVERDICT: accept\nmissing:"); process.emit("close", 0); });
return process;
} };
});
const supervisorSource = process.env.PI_GOALS_SUPERVISOR_SOURCE;
const tick = () => new Promise(resolve => setImmediate(resolve));
afterEach(() => { vi.unstubAllEnvs(); judge.calls = []; });
describe.skipIf(!supervisorSource)("actual goals and supervisor package hooks (Herdr and judge mocked)", () => {
it.each(["completion", "replacement during activation", "steward off during activation"])("Ready forks once and preserves lifecycle ownership: %s", async (scenario) => {
const supervise = (await import(/* @vite-ignore */ supervisorSource!)).default;
const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-integration-"));
const peers: any[] = [];
const wires: any[] = [];
const herdrCalls: string[][] = [];
vi.stubEnv("HERDR_ENV", "1"); vi.stubEnv("HERDR_PANE_ID", "w1:p1");
function make(id: string, manager: SessionManager) {
const hooks = new Map<string, any[]>(); const commands = new Map<string, any>(); const tools = new Map<string, any>(); const listeners = new Map<string, Set<any>>();
const messages: string[] = []; const contexts: any[] = []; let active = ["read", "grep", "bash", "write", "edit"];
const peer: any = { id, manager, messages, contexts, commands, tools, compactions: 0, aborts: 0 };
const bus = {
on(name: string, fn: any) { const list = listeners.get(name) ?? new Set(); list.add(fn); listeners.set(name, list); return () => list.delete(fn); },
emit(name: string, payload: any) {
if (name === "intercom:extension-register") {
peer.receive = payload.onEvent;
payload.onReady({ snapshot: () => ({ connected: true, supported: true }), listSessions: async () => peers.map(p => ({ id: p.id, pid: p === peer ? process.pid : process.pid + 1, cwd, model: "offline/test" })), publish(wire: any) { wires.push(wire); for (const p of peers) queueMicrotask(() => p.receive({ type: "message", fromSessionId: peer.id, payload: wire })); } });
return true;
}
if (name === "processes:request:list") payload.reply([]);
if (name === "subagents:rpc:v1:request") {
const reply = () => bus.emit(`subagents:rpc:v1:reply:${payload.requestId}`, { requestId: payload.requestId, success: true, data: { fleet: { version: 1, totalActive: 0 } } });
if (peer.delayBackground) { peer.finishBackground = reply; return; }
reply();
}
for (const fn of listeners.get(name) ?? []) fn(payload);
},
};
const pi: any = {
events: bus, on(name: string, fn: any) { hooks.set(name, [...(hooks.get(name) ?? []), fn]); },
registerCommand(name: string, command: any) { commands.set(name, command); }, registerTool(tool: any) { tools.set(tool.name, tool); active.push(tool.name); },
appendEntry: (name: string, data: any) => manager.appendCustomEntry(name, data),
getActiveTools: () => active, setActiveTools: (names: string[]) => { active = names; },
getCommands: () => [{ name: "supervise", sourceInfo: { path: supervisorSource } }],
getAllTools: () => [{ name: "subagent" }, { name: "intercom", sourceInfo: { path: "intercom-test-only" } }],
sendUserMessage: (text: string) => messages.push(text), sendMessage: (message: any) => contexts.push(message),
exec: async (command: string, args: string[]) => {
expect(command).toBe("herdr"); herdrCalls.push(args);
if (args[0] === "pane") return { code: 0, stdout: JSON.stringify({ result: { pane: { pane_id: "w1:p2" } } }), stderr: "" };
if (args[1] === "start") {
const sessionFile = args[args.indexOf("--session") + 1];
const supervisor = make("supervisor", SessionManager.open(sessionFile));
await supervisor.hook("session_start"); await tick();
}
return { code: 0, stdout: "{}", stderr: "" };
},
};
const ctx: any = { cwd, hasUI: true, isIdle: () => true, model: { provider: "offline", id: "test", contextWindow: 200_000 }, sessionManager: manager,
getContextUsage: () => ({ tokens: 50_000 }), compact({ onComplete }: any) { peer.compactions++; onComplete({}); }, abort() { peer.aborts++; },
ui: { theme: { fg: (_: string, text: string) => text }, setWidget() {}, setStatus() {}, notify: vi.fn(), select: async () => "Ready" },
};
peer.pi = pi; peer.ctx = ctx; peer.hook = async (name: string, event = {}) => { for (const fn of hooks.get(name) ?? []) await fn(event, ctx); };
peers.push(peer); supervise(pi); goals(pi); return peer;
}
const manager = SessionManager.create(cwd, join(cwd, "sessions"));
manager.appendMessage({ role: "user", content: "Keep the literal [x] and produce two files", timestamp: Date.now() });
manager.appendMessage({ role: "assistant", content: [{ type: "text", text: "Plan drafted" }], api: "openai-completions", provider: "offline", model: "test", stopReason: "stop", timestamp: Date.now(), usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } });
const worker = make("worker", manager);
try {
await worker.hook("session_start");
await worker.commands.get("goals").handler("plan judge the vendor options", worker.ctx);
const path = join(cwd, ".pi", "plan", `${manager.getSessionId()}-v1.md`);
writeFileSync(path, '# Plan\n\nUser voice: render "[x]" literally\n\n1. [ ] goal: first\n2. [ ] goal: second\n\n## Log\n');
worker.delayBackground = scenario !== "completion";
const starting = worker.hook("agent_settled");
if (worker.delayBackground) {
await vi.waitFor(() => expect(worker.finishBackground).toBeTypeOf("function"));
expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(0);
const stopping = scenario === "steward off during activation";
await worker.commands.get("goals").handler(stopping ? "steward off" : "plan a replacement that has not received Ready", worker.ctx);
worker.finishBackground(); await starting; await tick();
expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(0);
const saved = manager.getBranch().findLast((entry: any) => entry.customType === "pi-goals-state") as any;
expect(saved.data).toMatchObject({ phase: "planning", planVersion: stopping ? 1 : 2, supervisor: null });
if (stopping) {
expect(saved.data.stewardEnabled).toBe(false);
worker.delayBackground = false;
await worker.hook("agent_settled"); // Ready is offered again; ordinary work can now start.
expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(1);
const retried = manager.getBranch().findLast((entry: any) => entry.customType === "pi-goals-state") as any;
expect(retried.data).toMatchObject({ phase: "working", planVersion: 1 });
}
expect(herdrCalls.filter(args => args[1] === "start")).toHaveLength(1);
return;
}
await starting; await tick();
const supervisor = peers[1];
expect(supervisor).toBeDefined(); expect(supervisor.manager.getSessionFile()).not.toBe(manager.getSessionFile());
expect(supervisor.manager.getBranch().some((e: any) => e.customType === SUPERVISOR_ROLE)).toBe(true);
expect(supervisor.compactions).toBe(1); expect(worker.compactions).toBe(0);
expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(1);
expect(supervisor.pi.getActiveTools()).not.toContain("CompleteGoal");
for (const goal of ["first", "second"]) {
const completion = worker.tools.get("CompleteGoal").execute("", { goal }, undefined, undefined, worker.ctx);
await tick(); const request = wires.findLast((w: any) => w.t === "goal_review");
expect(request.goal).toBe(goal);
await supervisor.tools.get("review_goal").execute("", { requestId: request.requestId, decision: "approve", reason: "Within the requested scope" });
const completed = await completion;
expect(completed.isError, JSON.stringify(completed)).toBe(false);
expect(readFileSync(path, "utf8")).toContain(`[x] goal: ${goal}`);
}
expect(judge.calls).toHaveLength(2); expect(judge.calls.every(args => args.includes("--no-extensions"))).toBe(true);
expect(wires.some(w => w.t === "done")).toBe(false);
await worker.commands.get("goals").handler("supervisor", worker.ctx);
expect(herdrCalls.at(-1)).toEqual(["agent", "focus", "w1:p2"]);
await supervisor.commands.get("goals").handler("worker", supervisor.ctx);
expect(herdrCalls.at(-1)).toEqual(["agent", "focus", "w1:p1"]);
const pending = worker.tools.get("CompleteGoal").execute("", { goal: "first" }, undefined, undefined, worker.ctx);
await tick(); await worker.commands.get("goals").handler("steward off", worker.ctx);
expect((await pending).isError).toBe(true); await tick();
expect(judge.calls).toHaveLength(2); expect(supervisor.aborts).toBeGreaterThan(0);
} finally { for (const peer of peers) await peer.hook("session_shutdown"); rmSync(cwd, { recursive: true, force: true }); }
}, 15_000);
});
+30
View File
@@ -0,0 +1,30 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { focusSupervisor, planHash, type SupervisorBinding, startSupervisor, supervisorRequest } from "../src/supervisor.js";
const binding: SupervisorBinding = { id: "pair", workerSession: "/worker.jsonl", supervisorSession: "/supervisor.jsonl", planPath: "/plan.md", workerPane: "w1:p1", supervisorPane: "w1:p2", everyTurns: 50, intervalMs: 3_600_000, compactTokens: 100_000 };
afterEach(() => vi.unstubAllEnvs());
describe("supervisor adapter", () => {
it("fails visibly when the supervisor API is absent", async () => {
const pi = { events: { emit() {} } } as unknown as ExtensionAPI;
await expect(supervisorRequest(pi, "status")).rejects.toThrow("Load the plan-aware");
});
it("does not operate on a live Herdr session from outside Herdr", async () => {
vi.stubEnv("HERDR_ENV", "");
const exec = vi.fn();
await expect(focusSupervisor({ exec } as unknown as ExtensionAPI, binding, "supervisor")).rejects.toThrow("Start Pi inside Herdr");
expect(exec).not.toHaveBeenCalled();
});
it("does not spawn a duplicate when the recorded pane is unavailable", async () => {
vi.stubEnv("HERDR_ENV", "1"); vi.stubEnv("HERDR_PANE_ID", "w1:p1");
const exec = vi.fn(async () => ({ code: 1, stdout: "", stderr: '{"error":{"code":"pane_not_found","message":"Pane unavailable"}}' }));
const pi = { exec, events: { emit(_name: string, request: any) { request.handled = true; request.resolve({ connected: false, binding, workerId: "worker" }); } }, getCommands: () => [{ name: "supervise", sourceInfo: { path: "/supervise.ts" } }], getAllTools: () => [{ name: "intercom", sourceInfo: { path: "/intercom.ts" } }] } as unknown as ExtensionAPI;
const ctx = { cwd: "/project", sessionManager: { getSessionFile: () => "/worker.jsonl", getLeafId: () => "leaf" } } as unknown as ExtensionContext;
await expect(startSupervisor(pi, ctx, binding.planPath, binding, vi.fn(), new AbortController().signal)).rejects.toThrow("/supervisor.jsonl");
expect(exec.mock.calls).toHaveLength(1);
expect(exec.mock.calls[0]).toEqual(["herdr", ["agent", "focus", "w1:p2"], expect.anything()]);
});
it("hashes quoted requirements without normalizing their literal checkbox syntax", () => {
expect(planHash('Render "[x]"')).not.toBe(planHash('Render "[ ]"'));
});
});