Author SHA1 Message Date
wassname2 b386efd8ea fix: persist goal reminders before agent runs 2026-09-09 14:57:55 +08:00
wassname2 d5729ac106 Consolidate supervision and remember models per role
Bundle Intercom/VCC with the internal supervisor. Add default alignment questions and conversational plan review. Cover model recovery, cancellation and packed single-package operation.
2026-09-08 08:45:26 +08:00
wassname2 8e44738773 Use real plan supervisors and enable automation by default 2026-09-07 14:49:25 +08:00
wassname2 1c10a282ac Use goals subcommands without option prefixes 2026-09-07 09:18:56 +08:00
wassname2 3f0aadfffa Add persistent plan steward 2026-09-03 14:17:39 +08:00
wassname f09d443d88 Improve goal reminder cadence and auto continuation 2026-09-01 18:29:00 +08:00
wassname 4fce680f2d bump 0.2.2 for npm publish (0.2.1 was staged conflict) 2026-08-31 19:15:46 +08:00
wassnameandPI/OpenAI a45cc7d9c6 Anchor goals to the user-visible result
Co-Authored-By: PI/OpenAI <288921227+claudypoo@users.noreply.github.com>
2026-08-30 18:44:36 +08:00
wassname d0070d18b8 Correct planning prompt assertion 2026-08-26 14:03:59 +08:00
wassname 7f274a691c Ask questions that discover the plan 2026-08-26 14:03:23 +08:00
wassname 17a3b25a82 Batch high-impact planning questions 2026-08-26 13:43:42 +08:00
wassname 2362073d04 Make planning research conditional 2026-08-26 13:42:44 +08:00
wassname c1bf91f3db Resolve planning uncertainty before approval 2026-08-26 13:32:06 +08:00
wassname 3dd0668963 Document Pi test workflow 2026-08-26 13:28:54 +08:00
wassname 389af540d1 Test review flow through Pi RPC 2026-08-26 12:12:05 +08:00
wassname fa7195eafb Make planning goals concrete 2026-08-26 12:05:04 +08:00
wassname 0d972e81c3 Align planning state with review flow 2026-08-26 10:46:05 +08:00
wassname 1426877817 Test Keep planning stays idle 2026-08-26 10:00:51 +08:00
wassname 6bb34f18cf Show plans after agent settles 2026-08-26 09:58:01 +08:00
wassname 36b0d98c2a Record plan interview replies 2026-08-26 09:54:12 +08:00
wassname b173d145db Version plans and expose judge review 2026-08-24 21:38:45 +08:00
wassname (Michael J Clark) 8de5c35248 Update README.md 2026-08-24 21:35:56 +08:00
wassname (Michael J Clark) a778480fec Update README.md 2026-08-24 21:34:38 +08:00
wassname (Michael J Clark) 45d59e1edc Update README.md 2026-08-24 21:32:49 +08:00
wassname (Michael J Clark) d026e06b41 Update README.md 2026-08-24 21:31:45 +08:00
wassname (Michael J Clark) 9ebcd3f4a6 Update README.md 2026-08-24 21:28:56 +08:00
wassname (Michael J Clark) 4850b5195e Merge pull request #4 from wassname2/patch-1
Refine planDrafting prompt for clarity and engagement
2026-08-24 15:07:26 +08:00
Michael.Clark2 e8bcba0fa7 Refine planDrafting prompt for clarity and engagement
Updated the planDrafting prompt to improve clarity and user engagement. Added details on user interaction and refined language for better understanding.
2026-08-24 14:34:53 +08:00
wassnameandClaudypoo 0a349b056e review: deepseek approves the ready menu after 2 rounds
Both round-1 findings were withdrawn once the reviewer had the plan-mode
facts. Comment the state-flip order, which is the part that reads like a bug
and is not.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-21 08:25:54 +08:00
wassnameandClaudypoo 3a1afd4c8e ready menu: print the plan, add "Ready + compact"
"Ready?" over an unread file is not a review -- the only copy of the plan was
inside a collapsed edit tool call. Print the working set before the menu.

The 4th option compacts the planning conversation before the work turn starts.
session_compact already re-sends the whole plan file, so the exploration is
summarized away and the agreed goals are not.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-21 08:14:29 +08:00
wassnameandClaudypoo d5766c1a34 deps: caret ranges on dev dependencies, not exact pins
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-19 11:42:28 +08:00
wassname 310ab730cc archive: superseded by pi-goals 2026-08-18 18:02:21 +08:00
wassname 6892801fcc prompts: one goal per distinct outcome, qualitative over invented thresholds 2026-08-18 18:02:03 +08:00
wassname (Michael J Clark) 35713760ad Merge pull request #2 from wassname2/fix/package-supply-chain
Use Pi's bundled core dependencies
2026-08-18 11:07:25 +08:00
wassname2 144f4b95b4 fix: use Pi bundled dependencies 2026-08-18 09:21:02 +08:00
wassname 87cf14a28c drafting prompt: goal count follows the distinct evidence, no cap 2026-08-17 18:11:26 +08:00
wassname 2adba3da45 release 0.2.1 2026-08-17 17:49:25 +08:00
wassname 842f1b85c7 drafting prompt: soften the one-goal default to a low goal count (1-3) 2026-08-17 17:49:18 +08:00
wassnameandClaudypoo e6af6db3d9 release 0.2.0: docs and description follow the per-session plan path
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-14 16:07:26 +08:00
wassnameandClaudypoo 7c36c119c6 plan file is per session: .pi/plan/<session_id>.md
A subagent runs pi -p --no-session with extensions on, so it loaded pi-goals, got the
parent's plan injected, and could sign off the parent's goals. Two windows on one checkout
also stomped each other's file. The session id in the name fixes both, and doubles as the
on switch: no /goals means no file at this session's path, so nothing fires.

Drops the v1 goals.md rename, and /goals clear now deletes the file.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-14 16:07:26 +08:00
wassnameandClaudypoo 7e427d2ca6 spec: one plan file per session, .pi/plan/<session_id>.md
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-14 16:07:26 +08:00
wassname 4827808575 release 0.1.1: gallery preview image 2026-08-12 11:12:16 +08:00
wassnameandClaudypoo 7d4fc32cc9 gallery preview: widget screenshot + pi.image (jsDelivr serves it with an image content-type)
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-12 08:45:10 +08:00
wassname 6df9390d2f drop pi-lgtm references from description, README and spec 2026-08-10 18:07:04 +08:00
wassname 230b7bd343 package: publishable as @wassname2/pi-goals 0.1.0 (files, public access, prepublish checks); README install from npm 2026-08-10 18:06:27 +08:00
wassnameandClaudypoo 751e20b7ee drafting prompt: this skeleton wins over any plan format a loaded skill also supplies
~/.pi/skills is a symlink to ~/.claude/skills, so a pi session loads the plan-format skill and this
prompt at once. A dogfooding agent merged the two by hand on every redraft.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-05 12:11:22 +08:00
wassnameandClaudypoo 1cd6e04e05 README: the fold, user voice, learnings, unlimited appendix; the judge never runs verify
The judge has been read-only with no bash since the rewrite, but the README still said it runs the
goal's verify command. That is the exact thing a dogfooding agent got wrong out loud.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-05 12:09:24 +08:00
wassnameandClaudypoo 07510d96bd fold the plan at ## Log: re-send the working set on a stale cadence, the whole file only on resync
v2 injected the entire plan.md on every turn. pi-tasks tried that and deleted it -- "wallpaper
noise that trains the model to ignore the task block" (CHANGELOG.md:149) -- so follow them: one
transient user message via the context hook, never persisted, and only when the plan went untouched
for 2 turns. Editing the plan resets the clock, the way a task tool call resets theirs. Session
start and session_compact push the WHOLE file back instead, which is where the settled context is
actually needed (pi-goal-x does the same with its post-compaction resync).

That makes an unlimited appendix free: everything under ## Log is durable memory, not working set.

Also: the drafting prompt is sent once with the /goals seed instead of every turn (that re-arming
is why plan mode read as never-ending), the review menu gains "Open in $EDITOR" and loops like
pi-plan's, and the widget shows the active goal's open subtasks so the plan is visibly the task
list. Drops the stale-copy stripping hook, which a non-persisted injection doesn't need.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-08-05 12:08:16 +08:00
wassnameandClaudypoo 16827de45d judge goes strictly read-only: no bash, never re-runs verify; reviews evidence discipline instead
Re-running verify was fine for 'npm test' but a footgun for ML workflows where verify may be a
10-hour training run -- and bash made 'read-only' nominal anyway (it could mutate). The agent now
runs verify itself and saves the output as evidence. The judge checks, in order: anything here /
quoted+attributed / provenance / quotes match disk / substance. Matches the cooperative-but-
confused threat model: reading real artifacts catches confusion; execution only defended against
deliberate forgery, which is out of scope.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 12:13:14 +08:00
wassnameandClaudypoo 7d5342f332 prompts: varglite evidence discipline -- quote what you observed, judge rejects reconstructed quotes
Dogfood: an agent with blank tool output back-filled plausible test counts into evidence and
the judge accepted (the numbers happened to be true). Norm now stated agent-side (verbatim
quotes, honest gaps beat plausible fabrication) and enforced judge-side (mismatched quotes =>
reject even when the goal looks met).

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 12:04:04 +08:00
wassnameandClaudypoo 72ac6cf357 nudge when plan.md exists but no goal line matches, instead of going silently inert
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 12:00:01 +08:00
wassnameandClaudypoo 924910e942 persist judge transcript to .pi/judge/<stamp>.md and reference it from the sign-off log line
'Did the judge really re-run verify?' was unanswerable post-hoc; now every sign-off's full
judge output survives on disk.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 11:59:00 +08:00
wassnameandClaudypoo 92d3f6d725 sign-off ticks the goal [x] itself + document that the judge reads the working tree, not HEAD
Dogfood exit interview: agent bookkeeping is the drift point (tick after accept was the
step most likely forgotten), and 'committed artifact' language implied the judge sees HEAD.
Tick is exact-subject match via the existing GOAL_LINE regex; on drift the result explicitly
asks the agent to tick, so neither path is silent.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 11:49:31 +08:00
wassnameandClaudypoo 03e88d34ad README: fix stale install (never published to npm), test list, and goal-regex scope
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 11:22:14 +08:00
wassnameandClaudypoo 4b622a22fa README: note the v1 goals.md auto-rename
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 11:17:11 +08:00
wassnameandClaudypoo 67daed312f docs: evidence should cite committed artifacts; .pi/ is gitignored so it's judge-time proof only
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 11:12:42 +08:00
wassnameandClaudypoo 8586b26ba8 rename stale .pi/goals.md to plan.md on session start so v1 goals aren't silently invisible
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 11:12:36 +08:00
wassnameandClaudypoo 485be236ce log stamps in local time so tool and agent-written lines agree (dogfood finding)
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 11:12:02 +08:00
wassname 632012cc6e judge: extract decideSignOff so inconclusive always means 'ran but failed'
CompleteGoal.execute now delegates to a pure decideSignOff(input, signal,
runJudgeFn) that takes an injected judge runner. judgeModel is never checked
pre-emptively: null just omits --model (buildJudgeArgs), so pi's configured
default runs the judge. The only producers of accepted_inconclusive are now the
judge-error and no-VERDICT paths -- i.e. 'the judge ran but failed', never 'no
model'. Wording (log/result/docstring/README) updated to say 'ran but failed'.

Adds test/decide-signoff.test.ts: null judgeModel still reaches runJudge (no
pre-emptive return); judge error/timeout/no-VERDICT -> accepted_inconclusive
with a 'ran but failed' reason. 11 tests pass, typecheck + lint clean.
2026-07-03 10:55:45 +08:00
wassnameandClaudypoo c0f80b869e v2: delete the parser -- plan.md is for LLMs, the judge subsumes the machinery
The v1 lesson: the parser existed so TypeScript could read goals.md, but every
reader is a model. v2 injects .pi/plan.md verbatim each turn, teaches the format
as a convention, and hands the whole file to the judge, which now does the goal
matching (tolerates wording drift), evidence validation, verify execution, and
format reading that v1 did in code. 1874 -> 530 lines.

Deleted: plan-file.ts + tests, JSON-stream judge transport, custom tool
rendering, review menu + $EDITOR + newSession dance, pruneCompleted, unwired
continuation/loopJudge prompts, MUTATING_BASH_PATTERNS. CompleteGoal's only
write is the ## Log sign-off line (the audit trail); the agent ticks [x] itself.
Judge runs with --no-extensions so a broken global extension can't take down
sign-offs (pi-hermes-memory currently does exactly that). File renamed
goals.md -> plan.md.

UAT (real judge subprocess on a toy repo, /tmp/claude-goals-uat/judge-*.log):
accept with verify run + byte-check, reject on placeholder evidence + missing
file, accept under drifted goal wording.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 10:24:35 +08:00
wassnameandClaudypoo 5d88502e4d judge: omit --model when unset so pi default runs (no more inconclusive-by-default)
Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-03 10:16:40 +08:00
wassnameandClaudypoo 0dfd6612ce widget: hide finished goals entirely (no summary line)
Drop the '✔ N done' summary line -- done/cancelled goals now render
nothing in the widget. The done count is already in the status bar
(◷ n/N goals) and full history is in goals.md / the ## Log.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-02 06:44:19 +08:00
wassname acb04dbfa2 Merge branch 'main' of https://github.com/wassname/pi-goals 2026-07-02 06:29:20 +08:00
wassnameandClaudypoo de01894348 widget: show only live goals, crop finished to a one-line summary
Completed goals were listed in file order, so done goals pushed the
active/open work down the widget. Now show active+open in full and
collapse done/cancelled into a single muted '✔ N done  ✗ M cancelled'
line. Full history stays in goals.md and the ## Log.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-02 06:29:16 +08:00
wassnameandClaudypoo 87c6c4d440 goals widget: fold path into header + add 'prune completed goals'
Two space-savers for a display that grows across sessions:
- widget header now carries the clickable .pi/goals.md path (with title),
  dropping the separate muted footer -- one vertical line instead of two.
- /goals clear now offers 'Prune completed goals' (pure pruneCompleted:
  drops done+cancelled goal blocks, keeps active/open, title, and the log)
  alongside the existing full clear. Gives a way to shed old goals without
  losing the audit trail.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-07-02 06:26:46 +08:00
wassname 14e757cfe1 Make goal judge use explicit session model 2026-06-29 05:28:17 +08:00
wassname 9a4da96d56 Fail forward when goal judge is inconclusive 2026-06-29 05:14:23 +08:00
wassname e0470a0c6d Judge: read-only + bash (no edit/write), renderCall/renderResult, streaming progress
- Judge gets read, bash, grep, find, ls but edit+write are blocked via --exclude-tools
- Added renderCall: shows goal name while running
- Added renderResult: shows accept/reject icon, model, duration, collapsed/expanded view
- Wired onUpdate through decideSignOff -> runJudge so the TUI shows progress while judging
- Added SignOffDetails type for structured metadata
- Added 120s timeout on judge subprocess
2026-06-17 18:21:45 +08:00
wassname 39c83994fa FIXME: judge side-effect clones pollute user workspace
pi -p --no-session clones the repo into the parent of cwd, leaving a stale
directory that the NEXT judge then finds and rejects the goal over. Needs a
temp-dir fix or in-repo inspection.
2026-06-17 18:16:32 +08:00
wassname 489f9b8c35 Clean pi-plan references, add judge timeout, fix heading format
- Rename spec doc to 2026-06-15_pi-goals.md, update title
- Update review.md spec reference
- Rename piPlanExtension -> piGoalsExtension in src/index.ts
- Add 120s timeout to judge subprocess (was unbounded, caused hang)
- Change planInjection heading from 'Goals (goals.md):' to '.pi/goals.md:'
- Add FIXMEs for tool label, progress visibility, heading format
2026-06-17 18:09:03 +08:00
wassnameandClaudypoo 0a1503dc04 pi-goals: move CompleteGoal desc into prompts.ts; trim README
The tool description and param doc are model-facing, so they belong in
prompts.ts with the rest. Add them as step 6 (completeGoalTool) and
renumber the evidence judge to 7; prompts.ts is now ordered the way the
agent meets each text, so it reads as one pass.

The moved desc also carries the positive-success framing: evidence must
show the success happened, not just that a failure was avoided.

README trimmed (saying less, voice unchanged): tighter intro and
comparison, less prose around the examples and sign-off steps. Humanizer
lint clean.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-16 11:50:12 +08:00
wassnameandClaudypoo 838c42d7bd pi-goals: discriminator/failure-mode format + visible sign-off judge
Replace done_when with a discriminator + subtle-failure-mode pair as the
heart of each goal. The discriminator is the POSITIVE success observation
that no failure mode could fake, not just failure-avoidance: a run can
dodge every trap and still produce nothing. Carried through planDrafting,
the sign-off judge, README, and the parser doc.

Format migration: flat numbered markdown goals (`1. [/] goal: ...`),
keyword-anchored parsing (indentation cosmetic), goals matched by text,
subtask states [ ]/[/]/[x]/[-] plus ~~strike~~. Evidence empty at
planning, filled at sign-off, multi-line supported.

CompleteGoal now returns the judge's reasoning under a
`--- sign-off judge ---` block (was just "Signed off"), so the verdict is
visible. Plan mode is read-only: edit/write (except goals.md) and
mutating bash are blocked by a tool hook.

17 parser tests, typecheck + biome clean.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-16 11:45:08 +08:00
wassnameandClaudypoo a65c822bf9 pi-plan -> pi-goals: rename package, command, and file to goals.md
Distinguishes this from the other pi-plan extensions by foregrounding what's
different (goals tracked to verified completion). Mechanical rename only, no
behavior change:
- package @wassname2/pi-plan -> @wassname2/pi-goals (+ repo url)
- plan.md -> goals.md (the canonical file)
- command /plan -> /goals
- file H1 marker "# Plan:" -> "# Goals:", widget/session labels likewise
- internal state keys pi-plan-* -> pi-goals-*

Internal source filename (plan-file.ts) and identifiers (planDrafting, PlanDoc,
setGoalStatus) keep "plan"; they're not user-visible. External burneikis/pi-plan
references are left intact.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-16 05:53:22 +08:00
wassnameandClaudypoo bb00314932 pi-plan: checkbox-in-header goal state + evidence block + widget/judge fixes
Goal state moves from a `status:` line into a checkbox on the goal header
(single source of truth, renders natively): [ ] open, [/] active, [x] done,
[-] cancelled. Only CompleteGoal writes [x]; the agent sets [/] when starting.
The GoalStatus enum and all consumers (widget, injection, counts) are unchanged.

Evidence becomes a goal field, not an ephemeral tool argument: an `evidence:`
block the agent fills before sign-off, read by CompleteGoal from the file
(git-tracked, reviewable). The tool is now CompleteGoal(goal_id) only.

Also:
- format reorder: subtasks under the goal; failure_modes + evidence as
  separated trailing blocks (no abutting dash-lists)
- widget: (done/total tasks), and done goals show checked instead of hiding
- drafting prompt: guard against a circular done_when (one that points at the
  file's own checkbox/log, which the sign-off writes, so it can never pass)
- drafting template now includes the H1 and the <!-- id --> line CompleteGoal
  needs to locate a goal
- strip ANSI/CSI control codes from the judge subprocess output

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-16 05:49:22 +08:00
wassnameandClaudypoo f2f9e6a1b9 pi-plan: finish stale-pi crash fix, print plan on start, todo->task
- The Ready->fresh-context crash was a stale pi.* call inside
  withSession. Prior commit moved sendUserMessage to sessionCtx but
  left pi.setSessionName inside withSession (also stale -> crash).
  Drop it (cosmetic) and use only sessionCtx in the swap window.
- Print plan.md on execution start (both fresh and in-place) so the
  user sees what's being worked on after a context switch. Plan text
  captured before newSession since ctx goes stale.
- Widget: "(N todo)" -> "(N task[s])"

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-15 20:49:57 +08:00
wassnameandClaudypoo 3134adf203 pi-plan: fix crash on Ready->fresh-context; drop em-dashes in prompts
- startExecution: inside withSession, send via the ReplacedSessionContext
  (sessionCtx.sendUserMessage) and set the session name there. The old
  code used the global pi.* handle bound to the replaced session, which
  is stale after newSession (runner.assertActive) -> crash on the
  "fresh, compacted context" choice.
- prompts: replace em-dashes in model-facing strings with commas/
  semicolons/periods (humanizer pass; comments left as-is)

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-15 20:32:30 +08:00
wassnameandClaudypoo 861b2ea157 pi-plan: right-size plans (fewer goals), lean done_when/failure_modes
The drafting prompt over-decomposed: one goal per item, long run-on
done_when (criterion + failure symptom in one line), and 3 mandatory
failure_modes. Plans came out verbose and hard to read.

- planDrafting: default to ONE goal; add another only for a genuinely
  separate checkpoint; near-identical items become subtasks. Subtasks
  only for 3+ step goals. Don't invent phases. (granularity heuristic
  adapted from tintinweb/pi-tasks when-to/when-not guidance)
- done_when: one falsifiable check, no embedded "if wrong" clause (the
  failure symptom belongs in failure_modes)
- failure_modes: 0-2 terse items, optional
- Sync the stale done_when wording in README and plan-file.ts comment

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-15 20:28:02 +08:00
wassnameandClaudypoo 158e04f4ac pi-plan: fix corrupted index.ts, queue revise msg, bare /plan prompts
- Restore exitPlanMode closing brace + CompleteGoal tool registration
  opening that an earlier edit dropped (parse error at 224)
- Edit-revise path now sends with deliverAs:"followUp" so it doesn't
  throw "Agent is already processing" mid-stream
- Bare /plan now prompts for an objective and enters plan mode instead
  of only showing the current plan

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
2026-06-15 20:23:55 +08:00
71 changed files with 15099 additions and 942 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
dist/
*.log
.pi/
docs/reviews/raw.jsonl
docs/reviews/err.txt
+18
View File
@@ -0,0 +1,18 @@
# pi-goals contributor notes
## Tests
Run `npm test` before a commit. It includes unit and flow tests plus the RPC review test.
- `test/*.test.ts` unit and flow tests use a small Pi API mock. They check plan state, tool gates, and plan-file updates.
- `npm run test:rpc` runs `test/rpc-review.test.ts`. It starts the installed Pi executable in RPC mode, uses Pi's real `select` protocol and conversational Discuss flow, and uses a local deterministic HTTP model. It does not need a credential or spend API credits. This is the closest automated session test.
- Use tmux for visual TUI debugging when the RPC test fails or a terminal-only problem is reported:
```bash
tmux new-session -s pi-goals-debug 'cd /path/to/pi-goals && pi -e .'
```
Run `/goals <objective>` in that pane. Tmux checks the rendered menu, editor focus, widget, and keyboard handling. RPC does not render the terminal UI.
- `pi -p` has no UI, so it cannot test `Ready`, `Discuss`, `Edit`, or `Cancel`.
- `npm run test:supervisor` runs the inherited `node:test` supervisor regressions. `npm test` also includes the always-enabled packed-artifact Intercom flow; Linux requires Unix-socket support.
+3
View File
@@ -0,0 +1,3 @@
# ARCHIVED
Superseded by [pi-goals](https://github.com/wassname/pi-goals).
+194 -69
View File
@@ -1,114 +1,239 @@
# pi-plan
# pi-goals
A [pi](https://github.com/badlogic/pi-mono) extension for plan-driven, goal-tracked work in one
`plan.md`. Set up goals (with evidence and failure modes) in plan mode, work them, and sign a goal
off only when a read-only subagent has checked the evidence.
Make a short list of goals in one Markdown plan file. This is easy to review, and a subagent can check whether each goal is complete.
Successor to [pi-lgtm](https://github.com/wassname/pi-lgtm), kept deliberately small: about
[burneikis/pi-plan](https://github.com/burneikis/pi-plan) plus the additions, goals with evidence,
a sign-off check, a widget, and a reminder.
The plan file looks like this:
The form guides; it does not gate. The agent edits `plan.md` with its normal Edit tool. The one
blessed tool is `CompleteGoal`, which runs the sign-off check and records the result. The reminder,
the injected plan summary, and git/widget visibility carry the process. It trusts the agent's
judgement rather than guarding it.
```md
## <short plan title>
<context: one short paragraph. What the human wants and why.>
### User-visible result
<one concrete sentence naming the final artifact or behavior the human will inspect>
### User voice
- │ "<the human's requirement, quoted in full word for word (with spelling fixes)>"
### Goals
1. [ ] goal: <one short judgeable imperative outcome>
- subtle failure mode: <a way this could look done but isn't>
- discriminator: <the concrete observation that tells real success from that failure>
- tasks:
1. [ ] <subtask>
- evidence: (empty until sign-off)
### Future work / out of scope
### Log
### Interview
### Learnings
### Papercuts - problems, gotchas, suggestions
```
![the widget: live goals from the session's plan file, with the active goal's open subtasks](media/screenshot.png)
## Related work
Like [pi-milestones](https://github.com/Neuron-Mr-White/UniPi/tree/main/packages/milestone) and
[burneikis/pi-plan](https://github.com/burneikis/pi-plan), it guides rather than guards. The
reminder cadence is copied from [tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) and the
resync-after-compaction from [tmonk/pi-goal-x](https://github.com/tmonk/pi-goal-x).
## Install
```bash
pi install npm:@wassname2/pi-plan
pi install npm:@wassname2/pi-goals
```
Or run without installing:
Or for development:
```bash
pi -e npm:@wassname2/pi-plan
git clone https://github.com/wassname/pi-goals && cd pi-goals && npm install
pi -e .
```
## Use
```
/plan add CSV export to the report view
/goals CSV export for the report view
```
1. Plan. The agent explores read-only and writes goals into `plan.md` (see format below).
2. Review. You get a menu: Ready, Edit (ask the agent to revise), Open in `$EDITOR`, or Cancel.
On Ready you choose whether to keep the current context or start fresh and compacted.
3. Work. Each turn the active goal is injected (so it survives compaction) and a reminder nudges
the agent to keep `plan.md` current and work autonomously. When a goal's `done_when` is met the
agent calls `CompleteGoal`, which runs `verify` and a read-only judge and, on accept, marks it
done and logs it.
`/goals` enters plan mode and starts a conversation; the objective is an optional seed. From there:
Other commands: `/plan` (print the plan), `/plan clear` (empty `plan.md`, history kept in git),
`/plan judge <model-ref>` (use a specific model for the sign-off judge; default is your current
model).
1. Align. The agent inspects technical facts read-only, then asks at least three task-specific
questions in one chat round about the expected result, scope/constraints, and success/failure
criteria. It waits for your answers before proposing the final plan. An explicit “no questions”
or “skip questions” clause in the current objective waives this round for that plan only.
“No q's” and “skip q's” are also supported. Negated instructions (“do not skip questions”) and
quoted feature references (“add a 'skip questions' button”) do not waive alignment.
2. Review. When alignment is complete, the agent requests review and the full draft is printed.
Check that User-visible result names the artifact or behavior you expect. The menu offers
**Ready / Discuss / Edit / Cancel**. Discuss returns to normal chat and asks useful alignment
questions, not a refinement-notes editor. Keep talking for as many turns as needed; the old draft
alone cannot reopen the menu. When discussion is finished, the agent calls `RequestPlanReview`,
even if the draft is unchanged. Discussion state survives reload. Edit opens the full plan directly.
Escape also returns to chat and preserves the draft; explicit Cancel discards the current draft.
3. Work. Ready is the only review action that starts work. The agent ticks subtasks, appends to
`## Log` and `## Learnings`, fills `evidence:`, and calls `CompleteGoal` when a discriminator is
satisfied. Every human reply in plan mode is saved verbatim under `## Interview`.
After eight turns without a change above `## Log`, the next natural prompt includes a saved
extension message with the working set and a short upkeep reminder.
## plan.md format
Plan reminders are saved in session history before they reach the model, not appended only to an
outgoing request. In working mode, after startup or compaction, the next natural prompt refreshes
the whole plan, including its appendix, from disk. In planning mode, it refreshes the planning-policy
snapshot and plan path instead. Automatic compaction and tool-loop continuations do not start
an extra turn for a reminder: the refresh waits until the next `before_agent_start` (normally the
next user prompt). There is no fresh plan reminder during that ongoing automatic continuation.
One file holds the objective, the goals, and a short append-only log.
## Plan supervisor and auto-continue
```markdown
# Plan: ship the cache layer
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.
## Goal: Implement cache layer
<!-- id: cache-layer-1 -->
status: active
done_when: p95 < 50ms on bench-X. If wrong: timeouts in load-test.log
verify: pytest tests/cache -q && python bench/p95.py --max-ms 50
failure_modes:
- cache silently bypassed (hit-rate ~0, latency ok by luck)
- bench too small to exercise eviction
- [x] wire cache client
- [ ] eviction policy
Install/load **only pi-goals**. Its internal modules contain the supervisor; the package bundles
`pi-intercom` 0.10.0 and `@sting8k/pi-vcc` 0.5.0 as locked runtime dependencies. Pi's manifest loads
the bundled Intercom resources through `node_modules/` paths. VCC is used as a compiler, not loaded
as a separate extension. Pi core stays a peer dependency. **Herdr remains the supported terminal
host** for launching/focusing the supervisor. Load the package directory (`pi -e .`), not only
`src/index.ts`, so the manifest can supply Intercom too.
## Log
- 2026-06-15 14:02 cache client wired; eviction next
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 internal 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. The `SUPERVISOR.md` policy lookup is unchanged: project `.pi/SUPERVISOR.md`, then the Pi agent directory, then the built-in policy. 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, start inside Herdr with just this checkout:
```bash
pi -e /path/to/pi-goals
```
- A goal is a `## Goal:` header with an `<!-- id -->`, a `status:`
(`open` | `active` | `done` | `cancelled`), a falsifiable `done_when:` (what you expect, and the
symptom if it is NOT met), an optional `verify:` shell command, a `failure_modes:` pre-mortem
list, and `- [ ]` subtasks.
- `done_when` names the evidence that distinguishes real success from a subtle failure. `verify`,
when present, is the deterministic first stage of the sign-off check.
- The agent ticks subtasks, appends to `## Log`, and sets `status` as it works. Multiple goals may
be `active`.
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.
## The sign-off check (`CompleteGoal`)
### Migrating an already-running installation
`CompleteGoal(goal_id, evidence, paths?)` is the one blessed completion path:
After validating this package, remove the old standalone supervisor and standalone Intercom entries
from Pi's package list, leaving pi-goals. **Reload existing workers before selecting Ready again**:
an old worker still has old launch arguments in memory and can launch both old and internal copies.
Reload both sides of a retained pairing. Do not add extra `-e` supervisor/Intercom arguments.
Duplicate Intercom registries are diagnosed and plan bootstrap is refused; Pi also reports conflicting
tools from duplicate packages. Diagnose/remove the duplicate rather than starting more panes. No
settings or live panes are changed by this extension's migration.
1. If the goal has a `verify:` command, it is run. A non-zero exit rejects immediately, with no model
call.
2. Otherwise a read-only `pi` subprocess (the judge) inspects the evidence against the repo and the
named failure modes and returns a verdict. It re-derives from the artifacts you point it at
rather than trusting the claim, so point `evidence`/`paths` at durable artifacts (saved logs,
committed diffs, files).
3. On accept, the goal's `status` flips to `done` and a `## Log` line is written. On reject, the
goal stays open and the agent is told what is missing.
### Remembered role models
The judge defaults to your current model (guaranteed authorized and capable). Set a different one
with `/plan judge <provider/model>` for an independent cross-family check.
Choose with `/model` or Pi's model-cycle shortcut in planning, the worker, or the supervisor. Each
pi-goals role remembers its own last explicit provider/model. On first use it inherits the current
model; no provider is hardcoded. Planning is restored on `/goals`, worker at Ready **after** the
planning fork is captured and before pairing activation/the work handoff, and supervisor before its initial compaction
or first turn. Reload/resume and later plans restore those choices. The supervisor does not inherit
the worker's goal tools or auto-continue policy.
Storage is under `getAgentDir()/pi-goals/` (normally `~/.pi/agent/pi-goals/`):
`planning-model.json`, `worker-model.json`, and `supervisor-model.json`. Each atomic file contains
only `{"provider":"…","id":"…"}`. Different role processes never rewrite each other's file; competing
explicit choices in the *same* role are last-write-wins. No credentials or thinking-level preferences
are stored. Automatic `setModel` and Pi's restore events do not replace role preferences.
If a remembered model is missing or unauthenticated, the role pauses with an error instead of
silently using a different provider. Configure the saved model and reload, or explicitly select a
different available model with `/model`. Pi does not emit a selection event when you choose the
already-current model; use **`/goals model current`** to explicitly save that current model for the
paused role instead. This command verifies authentication before replacing the saved choice.
A worker-model failure at Ready keeps the attached supervisor inactive and persists **worker** as
the recovery target across reload. Recovery updates the worker preference, not the planner's, and
reoffers the existing Ready menu; Ready retries the same fork without another approval stage.
There is no activation, supervisor review turn or work handoff before the worker model is usable.
`/goals clear` and `/goals steward off` still stop/cancel supervision while a model is unavailable;
old supervisor directives cannot restart the stopped work. A failed restore or recovery never
silently replaces a saved model choice.
`/goals judge <model-ref>` remains a separate override for the fresh evidence judge; it never changes
these role files.
## Prompts
All model-facing text lives in [`src/prompts.ts`](src/prompts.ts), in flow order, so the process is
easy to review end to end.
Planning/judge text lives in [`src/prompts.ts`](src/prompts.ts); supervisor policy/text lives in [`src/internal/supervisor/prompts.ts`](src/internal/supervisor/prompts.ts).
## Develop
```bash
pi -e ./src/index.ts # load locally
npm test # vitest: parser + sign-off record logic
pi -e . # package manifest includes bundled Intercom
npm test # unit/flow/RPC + inherited node:test supervisor regressions
npm run test:rpc # real-Pi conversational review, local offline model
npm run test:supervisor # inherited lifecycle/VCC/correlation/recovery regressions
npm run typecheck
npm run lint
npm run build
```
## Not (yet) included
No supervisor checkout or opt-in environment variable is needed. `test/rpc-supervisor.test.ts`
performs `npm pack`, extracts the tarball outside the checkout, and runs real Pi sessions plus the
actual bundled Intercom broker and a fresh offline evidence judge. Only Herdr is mocked: the worker's
exec adapter imports the extracted goals entry, and the supervisor loads the untouched extracted
package manifest. The test checks bundled production resources and excludes bundled Pi core peers.
It requires Unix sockets on Linux and spends no API credits. The hook integration additionally
checks initial supervisor compaction/model order, native planning-fork capture, cancellation, two
goal reviews, and judge isolation without relying on a live terminal.
No autonomous re-prompt loop (an until-done-style loop judge). Autonomy comes from the reminder, not
a harness. Plan-phase model stickiness is a documented next step.
The moved VCC dependency is source-only and has upstream type incompatibilities with current Pi/Intl
unions. `tsconfig.build.json` maps just its four imported API surfaces to narrow local declarations;
the actual pinned VCC source still runs in tests and production. All pi-goals source is typechecked
and linted; the node:test suite is run separately, not silently collected/skipped by Vitest.
Validation (2026-09-07): **67 Vitest tests and 118 internal supervisor tests passed, with no skips**,
including the packed real-Pi/Intercom flow. Typecheck, lint, build and diff checks passed. Independent
review and targeted recheck are complete. See the [saved validation and review disposition](docs/reviews/2026-09-07_single-package-role-models.md).
Neither automated test proves visual Herdr rendering/navigation or measured token savings. The
previous live trial contained a historical tool call without a saved result, which can still block
supervisor `done`. Use `/goals clear` to explicitly disconnect; genuine outstanding-work checks have
not been weakened. See [the prior validation record](docs/reviews/2026-09-07_supervisor-validation.md).
## License
MIT
MIT. See [third-party provenance and notices](THIRD_PARTY_NOTICES.md).
+47
View File
@@ -0,0 +1,47 @@
# Third-party provenance and notices
## Internal supervisor
`src/internal/supervisor/` and `test/internal-supervisor/` were moved from
[wassname/pi-supervise](https://github.com/wassname/pi-supervise) (formerly
pi-intercom-supervisor), commit `145c2cb081f85c08b0244c4a2c8a2d9aef8debda`.
The source package is `@wassname2/pi-supervise` 0.0.4, author wassname, declared
license MIT. The source comments and attribution are retained. Local changes
integrate package loading, role-model readiness, duplicate-registration diagnostics,
and strict build/lint compatibility. Its synthetic fork fixture is retained; it
contains no user transcript.
The supervisor's `subagents.ts` retains its attribution to
`@monotykamary/pi-supervisor` (MIT), `src/subagent-detector.ts`. Its policy precedence
also follows that project. No separate supervisor package is required at runtime.
### MIT license
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## Bundled dependencies
- `pi-intercom` 0.10.0: existing Intercom transport and broker; MIT. Its upstream
LICENSE is included under `node_modules/pi-intercom/LICENSE` in the package.
- `@sting8k/pi-vcc` 0.5.0: existing algorithmic worker-view compiler. Its README's
License section declares MIT and is included with the bundled source.
- Intercom's runtime dependencies, including `tsx` and `esbuild`, retain their
upstream package notices in the tarball. Pi core and typebox are peers, not bundled.
No replacement IPC runtime or VCC implementation was written for this move.
@@ -0,0 +1,38 @@
# Single-package and role-model validation
2026-09-07. The parent accepted the implementation after independent review and a targeted recheck. Changes are in the local pi-goals feature worktree; this is not an npm release.
## Delivered
- One pi-goals package: internal supervisor, bundled Intercom/VCC, and one package-root supervisor launch. Herdr remains the terminal host.
- Separate remembered planning, worker and supervisor provider/model choices. Files are under `getAgentDir()/pi-goals/`; the fresh evidence-judge override stays separate.
- At least three task-specific alignment questions by default. An explicit affirmative current-plan waiver skips them; negations and quoted feature names do not.
- Ready / Discuss / Edit / Cancel. Discuss and Escape preserve the draft and return to chat. `RequestPlanReview` reopens review when discussion is finished, including an unchanged draft. Only Ready starts work.
## Observed validation
[Saved full output](evidence/2026-09-07_single-package-final-validation.log) contains:
```text
Test Files 12 passed (12)
Tests 67 passed (67)
...
tests 118
pass 118
fail 0
skipped 0
...
SINGLE_PACKAGE_FINAL_VALIDATION_PASSED
```
`npm test` includes the packed/extracted production artifact running two real Pi sessions, the actual bundled Intercom broker and an offline fresh judge. It checks distinct actual role models and does not load a companion source checkout. The real-Pi conversational test covers Discuss and same-current-model recovery. Herdr is mocked in automated tests. Typecheck, lint, build and diff checks passed. Comparing common entries in the old/new lockfiles found no changed versions of existing locked packages.
The first review found four defects: stop blocked by model unavailability; activation before worker restoration and wrong recovery role; negated waiver matching; and same-model selection not triggering recovery. All were fixed with regressions. Parent inspection also caught a recovery await before cancellation ownership was captured; two more regressions cover clear/replacement during that await. The targeted review read current source and tests and returned `No issues found.` and `Merge verdict: OK`.
## Migration and remaining limits
The parent removed the two old companion entries from the user's Pi package list after the packed path passed. Only pi-goals remains registered for this workflow. Existing workers must reload before Ready: their old launch arguments still name the standalone extensions. The failed supervisor pane was observed at a shell with no Pi process; no duplicate recovery process was started during implementation.
Live Herdr recovery/navigation, the quality of questions from the user's chosen model, and token savings still need a human trial. The old historical tool-call-without-result issue can still block whole-plan `done`; `/goals clear` explicitly disconnects supervision and preserves history. This change did not weaken outstanding-work checks or alter old session transcripts.
<!-- Parent synthesis from observed commands, source inspection and review output, by Pi. -->
@@ -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,157 @@
> @wassname2/pi-goals@0.2.2 test
> vitest run && npm run test:supervisor
RUN v4.1.9 /home/ubuntu/.pi/agent/worktrees/pi-goals-persistent-steward
Test Files 12 passed (12)
Tests 67 passed (67)
Start at 16:14:44
Duration 15.63s (transform 1.67s, setup 307ms, import 22.12s, tests 28.12s, environment 3ms)
> @wassname2/pi-goals@0.2.2 test:supervisor
> node --import tsx --test test/internal-supervisor/*.test.ts
✔ retries intercom registration when pi-intercom loads after pi-supervise (3.540888ms)
✔ a directive with no text is rejected, so the worker never sees undefined (0.288987ms)
✔ a directive from the paired supervisor becomes a real user message (27.396147ms)
✔ a directive to a busy worker interrupts, instead of waiting for the whole task (13.079025ms)
✔ a directive from an unpaired session is dropped (11.977008ms)
✔ a second pair takes over, and the first supervisor is told it lost the worker (21.508059ms)
✔ only the paired worker can end a run (6.314542ms)
✔ the programmatic pairing API waits for the worker acknowledgement (1.485485ms)
✔ the worker acknowledges a pair, so the supervisor knows it was heard (5.359587ms)
✔ a goal the supervisor inferred reaches the worker, which owns the view header (37.477099ms)
✔ the second view carries only what happened after the first (328.068703ms)
✔ a message addressed to a different session is ignored (11.834377ms)
✔ on settle the worker publishes a view built from the live branch (17.888259ms)
✔ the view is built from the live branch, not from every entry in the session (22.049192ms)
✔ an unpaired session publishes nothing on settle (0.498005ms)
✔ supervision never stops itself: no round limit at all (9.442441ms)
✔ goal, pairing and the steer count all survive a reload together (0.791695ms)
✔ a view that arrives while the supervisor is thinking is queued, not dropped (5.643173ms)
✔ the nudge repeats neither the instructions already sent nor the verdict rules (5.269154ms)
✔ a multi-line goal returns to supervisor context every fifth review and after compaction (31.103788ms)
✔ a one-line goal is not redundantly reinserted (26.640178ms)
✔ a check in and a worker that stopped ask for different things (12.287139ms)
✔ a loop still gets named after the supervisor compacts, from restored state (1.450856ms)
✔ a session that does not answer the roll call is not offered as a worker (501.077007ms)
✔ a child run stays out of the roll call, so it can never be picked (5.337167ms)
✔ a session already paired stays out of the roll call, and a free one answers (15.899876ms)
✔ /supervise look asks the worker for a fresh view, rather than the supervisor guessing (306.105484ms)
✔ let_it_run says the turn is over, so it is not called four times running (0.835308ms)
✔ a sign-off verdict is answered, not aborted, and a runaway is still cut (0.609635ms)
✔ every verdict result names the way to end the turn, steer included (0.449781ms)
✔ an old view is dropped from context once its verdict is in, and the verdict is kept (1.046937ms)
✔ a worker session never has its context rewritten (0.289351ms)
✔ a view that arrives mid-answer starts a fresh look (5.824464ms)
✔ a tool a worker cannot use never aborts its turn (0.339873ms)
✔ a resume onto a session that is gone drops the pairing and says so (6.297894ms)
✔ a resume onto a live worker keeps supervising, and takes the writers back off (5.29537ms)
✔ state written before recentSteers existed still loads (0.171283ms)
✔ done unpairs the worker, so it stops publishing views (320.167743ms)
✔ with no goal the supervisor cannot steer, it must ask the human (0.573651ms)
✔ set_goal binds an inferred goal, and steering then works (501.94885ms)
✔ a goal given at pair time still allows steering (0.665903ms)
✔ done is refused while the worker has an unanswered tool call (12.424782ms)
✔ done is allowed once nothing is outstanding (6.182792ms)
✔ steer refuses when the session is not supervising (0.325609ms)
✔ a reworded repeat of an earlier instruction is sent, and named back to the supervisor (0.510602ms)
✔ overlap scores rewording high and a different instruction low (0.136533ms)
✔ the view of the old worker cannot be used to judge the new one (6.045704ms)
✔ with one other session here, /supervise needs no target and the whole line is the goal (501.381162ms)
✔ naming the worker still works, and the rest of the line is the goal (0.554981ms)
✔ with two free sessions here, /supervise asks which one, and pairs with the choice (501.08053ms)
✔ a goal that is a path is read from the file, so it is not pasted every run (2.297652ms)
✔ a long goal is one short line above the picker, and reaches the worker whole (501.854523ms)
✔ a session that stayed quiet is still on the list, because 0 free is a dead end (501.687356ms)
✔ a cancelled picker pairs with nothing (500.579211ms)
✔ supervising takes the writing tools away, and stopping gives them back (501.255208ms)
✔ stopping gives back the writers without undoing another extension's tools (500.601415ms)
✔ a first word that names no session is refused, rather than folded into the goal (0.611801ms)
✔ a goal with spaces needs no target, and @name takes the rest of the line as the goal (501.893405ms)
✔ the brief starts no turn, so there is no answer before the first view (5.995751ms)
✔ /supervise goal changes the goal without breaking the pairing (0.70475ms)
✔ the footer says which side of a pairing this session is, and clears when it ends (506.432358ms)
✔ a session that is not supervising never sees the supervisor tools (5.830284ms)
✔ worker_view refuses when there is no worker, rather than implying a pairing (0.513982ms)
✔ the view names the worker's model and how full its context is (21.061667ms)
✔ supervising a second session is refused while the first is still paired (0.548942ms)
✔ the supervisor gets a look at a working worker every half hour, without being asked (926.124762ms)
✔ a human message in the worker session is not a reason to stand back (6.127037ms)
✔ letting a stopped worker run says plainly that the worker stays stopped (11.377477ms)
✔ a stopped worker is looked at again, so let_it_run cannot silence the pairing (922.993821ms)
✔ a worker that pairs at the prompt and never takes a turn is still watched (604.616558ms)
✔ a worker that reloads at the prompt starts watching itself again (604.845926ms)
✔ a timer look at a worker that has not moved is not sent, until it has been skipped three times (2425.474247ms)
✔ the worker counts reviews in a row where nothing changed (356.432776ms)
✔ an unacknowledged pair gives up, and a takeover cancels that timer (3.893969ms)
✔ duplicate standalone Intercom registries are diagnosed and cannot bootstrap a plan (1.232918ms)
✔ plan bootstrap compacts only the supervisor and pairing alone never starts a worker or a review (18.935035ms)
✔ goal decisions are correlated, preserve the pair across two goals, and cannot call overall done (5.389797ms)
✔ abort and stop cancel pending requests; late decisions cannot approve a replacement (3.265783ms)
✔ 50 actual model turns trigger one view, independent of the number of messages (3.457965ms)
✔ unknown background providers are not proof of quiescence (0.294658ms)
✔ stale plan content invalidates a pending goal review (3.01992ms)
✔ small forks skip compaction, but real compaction failure prevents pairing (2.471879ms)
✔ the hour timer and a coincident turn checkpoint produce a single view (5.559542ms)
✔ settled checks wait for tracked processes and subagents to finish (2.833087ms)
✔ bootstrap stop cannot resurrect a supervisor after compaction completes (2.15529ms)
✔ a restarted worker reconnects by exact saved session identity without a new supervisor (2.812666ms)
✔ unknown initial context must compact instead of taking the known-small shortcut (1.119486ms)
✔ null post-compaction usage cannot raise the next configured 100k checkpoint (2.180167ms)
✔ stopping a routine view during compaction invalidates its suspended continuation (1.977452ms)
✔ restart of a provisional bootstrap resumes compaction and pairing in the same saved session (2.18079ms)
✔ command preserves a stopped supervisor across reload (2.007763ms)
✔ done preserves a stopped supervisor across reload (2.456918ms)
✔ same-binding replay retains activation when the supervisor lost its acknowledgement (2.599438ms)
✔ model-unavailable stop validates binding, cancels pending reviews and ignores old directives (2.091306ms)
✔ a child process named pi is found by ps, and stops being found when it exits (367.036839ms)
✔ the check is a snapshot, so it cannot hold up the worker's settle (321.13146ms)
✔ a one-line goal stays whole while a multi-line goal has a locator (6.226689ms)
✔ a view carries only the turns the supervisor has not been sent (2.16041ms)
✔ the last two reasoning blocks stay in the narrative, and older ones drop out (1.078426ms)
✔ a compaction restarts the view, so no turn falls into the gap (0.455488ms)
✔ pi-vcc reports the files the worker wrote, and separates them from the ones it read (1.361829ms)
✔ progressKey is unchanged when a review produced no new file or commit (0.506944ms)
✔ progressKey still sees a new file past pi-vcc's ten path display cap (0.575315ms)
✔ a commit counts as progress, even when no file was written since (0.960805ms)
✔ outstandingWork finds tool calls that never got a result (1.43676ms)
✔ buildView reports a tool call with no result, so done can be refused (0.87924ms)
✔ the view says how many reviews in a row changed nothing, and says nothing at zero (0.692566ms)
✔ the view merges the worker's compaction summary with the turns after it (0.470565ms)
✔ a turn the compaction summary already covers is not sent twice (0.336722ms)
✔ pi-vcc's sections and its transcript land on the right sides of the split (2.943ms)
✔ the view does not tell the supervisor to use vcc_recall, a tool it does not have (0.291333ms)
✔ supervisor directives are not sent back as worker evidence (0.488463ms)
✔ bookkeeping tool calls are kept out of the transcript (0.390618ms)
✔ buildView reports the goal, status, and files without historical failures (0.42015ms)
✔ how long the worker has been quiet, measured from its own last entry (0.593754ms)
✔ buildView keeps the newest turns when it has to cut for the channel limit (26.316809ms)
✔ pi's own branch logic drops the abandoned fork, on a session file (1793.809954ms)
✔ a long goal cannot push the view past the broker limit (0.55492ms)
tests 118
suites 0
pass 118
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 20178.623102
> @wassname2/pi-goals@0.2.2 typecheck
> tsc -p tsconfig.build.json --noEmit
> @wassname2/pi-goals@0.2.2 lint
> biome check src/ test/
Checked 32 files in 93ms. No fixes applied.
> @wassname2/pi-goals@0.2.2 build
> tsc -p tsconfig.build.json
SINGLE_PACKAGE_FINAL_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
+66
View File
@@ -0,0 +1,66 @@
Let me get the diff to see what changed:
Now let me check the git history to see what changedNow I have full context. Let me verify the pi API behavior by checking the relevant source files to confirm the claims about `sendMessage`, `compact`, and event orderingNow I have all the context needed. Let me verify the `sendMessage` path more carefully:
I now have a complete picture. Here is the review.
---
## Code Review: ready menu — print plan + add "Ready + compact"
### Summary
The `agent_end` handler now prints the plan working set into the transcript before the menu, and adds a 4th option ("Ready + compact") that compacts the planning chatter away before the work turn. The logic is correct and well-considered, with one structural race and one stale-widget gap.
### Important (should fix)
- **`src/index.ts:~248` (the `agent_end` handler) — race: plan mode is turned off before compaction completes.**
`state = { ...state, isPlanMode: false }` executes before `ctx.compact()` is called (let alone finished). The compaction is fire-and-forget. If the human types and sends a message in the window between the state flip and the compaction callback firing, the agent starts a turn in normal mode *without* the work instruction. The work instruction arrives later (via `sendUserMessage` in `onComplete`/`onError`), triggering a second, redundant turn.
The window is small (compaction is fast) but the behavior is undefined — the agent could start executing before the work instruction lands.
**Fix**: flip `isPlanMode` inside the callbacks, not before. Move `state = { ...state, isPlanMode: false }; persist(); updateWidget(ctx);` into both `onComplete` and `onError`, and also into the non-compact branch (where it already is, effectively). The `work` string can be defined before the branch.
```typescript
if (!choice.includes("compact")) {
state = { ...state, isPlanMode: false };
persist();
updateWidget(ctx);
pi.sendUserMessage(work, { deliverAs: "followUp" });
return;
}
ctx.compact({
customInstructions: `...`,
onComplete: () => {
state = { ...state, isPlanMode: false };
persist();
updateWidget(ctx);
pi.sendUserMessage(work, { deliverAs: "followUp" });
},
onError: (e) => {
ctx.ui.notify(`Compaction failed (${e.message}); starting work anyway.`, "warning");
state = { ...state, isPlanMode: false };
persist();
updateWidget(ctx);
pi.sendUserMessage(work, { deliverAs: "followUp" });
},
});
```
This also means the widget stays in "planning" mode during compaction, which is truthful — compaction hasn't finished yet.
### Suggestions
- **`src/index.ts:~248` — widget not refreshed after `$EDITOR`.**
When the human chooses "Open in $EDITOR", `spawnSync` blocks, then `continue` re-enters the loop. The plan is re-read and potentially re-printed, but `updateWidget` is not called. If the human changed goal statuses (e.g. ticked a checkbox), the widget stays stale until the next `turn_end`.
Add `updateWidget(ctx);` after the `spawnSync` line (or inside the `continue` branch before the continue).
- **`src/index.ts:~248` — `spawnSync` blocks the event loop.**
`spawnSync(process.env.EDITOR || ...)` is a synchronous blocking call. While the editor is open, no async work (including compaction from a previous iteration, timers, etc.) can proceed. This is fine for a local TUI tool, but worth noting — if the editor hangs or the human walks away, the entire pi process is frozen.
### Positive
- **De-duplication is correct.** `printed` is a local variable, fresh per `agent_end` call, and correctly suppresses re-printing when the working set hasn't changed across editor passes. The `while` loop exit condition (`scanGoals(...).length > 0`) correctly handles the human deleting all goals in the editor.
- **String matching is safe.** `choice?.startsWith("Ready")` gates both Ready options, then `choice.includes("compact")` distinguishes them. The word "compact" appears only in the "Ready + compact" string. No ambiguity.
- **Both compaction callbacks queue the work turn.** `onComplete` and `onError` both call `pi.sendUserMessage(work, ...)`. A failed compaction does not strand the session — work starts anyway, with a notification.
- **`session_compact` → `resyncReason` → injection chain is correct.** The pi source confirms `session_compact` fires (and is awaited) *before* `this.compact()` resolves and `onComplete` fires. So `resyncReason` is set before the next LLM call, and the full plan file is re-injected. The compaction summarizes away the exploration; the plan itself survives.
### Verdict
**REQUEST CHANGES** — the race between `isPlanMode = false` and compaction completion is a real timing bug that can cause the agent to start a turn without the work instruction. The fix is straightforward: move the state flip into the callbacks.
+22
View File
@@ -0,0 +1,22 @@
You're right on both counts. Let me trace through each.
**Finding 1, withdrawn.** Keeping `isPlanMode` true during compaction would mean any message the human sends while compaction runs gets processed in plan mode: blocked tools (`tool_call` hook), a "You are still in plan mode" injection (`context` hook), and a second Ready menu on `agent_end`. The current order (flip first, then compact) means that same message is processed in work mode — the destination the session is heading to. The cost is at worst one redundant turn when the `onComplete` follow-up lands. Your order is the correct one.
**Finding 2, withdrawn.** `updateWidget` with `isPlanMode === true` hits the early return that sets two fixed strings already on screen. It is a pure no-op.
---
## Code Review: ready menu — print plan + add "Ready + compact"
### Summary
The `agent_end` handler now prints the plan working set into the transcript before the menu, and adds a 4th option ("Ready + compact") that compacts the planning chatter away before the work turn. The logic is correct and well-considered.
### Positive
- **De-duplication is correct.** `printed` is a local variable, fresh per `agent_end` call, and suppresses re-printing when the working set hasn't changed across `$EDITOR` passes. The `while` loop exits cleanly if the human deletes all goals in the editor.
- **String matching is safe.** `choice?.startsWith("Ready")` gates both Ready options, then `choice.includes("compact")` distinguishes them. The word "compact" appears only in the one label.
- **Both compaction callbacks queue the work turn.** `onComplete` and `onError` both call `pi.sendUserMessage(work, ...)`. A failed compaction does not strand the session.
- **The `session_compact``resyncReason` → injection chain is correct.** The pi source confirms `session_compact` fires (and is awaited) before `this.compact()` resolves and `onComplete` fires. The full plan file is re-injected on the next LLM call; the compaction summarizes away the exploration, not the plan.
- **The state-flip-before-compaction order is deliberate and correct.** Flipping `isPlanMode` first means any message the human sends during compaction is handled in work mode, not plan mode (where tools would be blocked and a contradictory injection would fire).
### Verdict
**APPROVE** — no bugs found. The two concerns raised initially were incorrect; the code handles the interleaving correctly.
+31
View File
@@ -0,0 +1,31 @@
I only have a file-read tool here, so Ill start from the repo files I can open and look for the plan-versioning and judge-related changes.Continuing through the judge path and tests.Reading the rest of the judge prompt and the tests that cover these behaviors.Looking for the test files.Trying common test filenames under `src` and `test`.Checking other likely files mentioned in the README.I couldnt run `git diff` (no shell tool). From the files I could read, these are the concrete defects against the required behaviors:
**1. Uncommitted conflict markers in `README.md`**
The working tree still has `<<<<<<< Updated upstream` / `=======` / `>>>>>>> Stashed changes` (and a truncated upstream judge sentence). That is not a reviewable change set.
**2. Judge “checked artifacts” gate is too brittle**
`decideSignOff` only accepts:
```ts
/^#{0,6}\s*(?:\*\*)?checks(?:\*\*)?:\s*\n(?:[-*]\s+.+\n?)+\s*VERDICT:/im
```
The prompt asks for a `checks:` heading plus bullets, then **exactly two lines**:
```
VERDICT: accept | reject
missing: ...
```
Any extra blank line, `Checks` with other wrapping, or a sentence between the last bullet and `VERDICT:` rejects a real accept. Thats stricter than “must show artifact quotes,” and can fail a judge that did the right thing.
**3. Grill-me vs Ready is prompt-only**
`Grill me` sets `skipReadyMenu` for **one** `agent_end`, then the next turn with any goal lines shows Ready again. Nothing checks that the understanding question was asked **and answered**. If the model asks and offers Ready in the same turn, or the human never replies, Ready is still offered. Required: no Ready until that question has completed.
**4. Legacy unversioned plans are dropped with no migration path in code**
Only `<session_id>-vN.md` is selected; `planVersion` restore is the only resume hook. Thats the intended break, but I found no test that old `.pi/plan/*.md` names stay unused and that a new `/goals` always writes a fresh `-vN` without rewriting prior files.
**5. Could not verify the focused tests or `git diff`**
No test file opened (`src/index.test.ts`, `test/*.test.ts`, etc. all missing from this view). I could not confirm coverage for version reservation, visible plan print, grill-me sequencing, or reject-accept-without-checks.
I would **not APPROVE** this set as-is. Fix the README conflicts, tighten grill-me so Ready stays suppressed until after the human answers, and either loosen the checks regex to the documented “quotes present before VERDICT” rule or lock the exact judge format in a test.
+41
View File
@@ -0,0 +1,41 @@
I only have the `read` tool available, so let me locate the changed files by probing the repository structure.The extension source is clear. Let me verify the pi API usage (`sendMessage` with `customType`/`display`, `appendEntry`, `getSessionId`, `ctx.compact`) against the pi docs, since several required behaviors hinge on them.I can't run `git diff` or the test suite — this review environment only exposes a `read` tool, and the test directory can't be enumerated (every probe under `test/` returned ENOENT, and directories can't be listed). So I reviewed the changed files directly in the working tree (`src/index.ts`, `src/prompts.ts`, `README.md`, `scripts/check-judge-footprint.sh`) against the five required behaviors, and verified every pi API the behaviors depend on (`pi.sendMessage` with `customType`/`display`, `pi.appendEntry` restore via `session_start`, `pi.sendUserMessage` followUp, `ctx.compact` with onComplete/onError) against the installed pi docs. Findings below.
## Defects
**1. `/goals` arg parsing swallows ordinary objectives — src/index.ts, `/goals` handler**
```ts
if (arg.startsWith("judge")) {
```
Any objective whose first word is "judge" — e.g. `/goals judge the vendor options`, `/goals judge readiness of the export` — is treated as the judge-model subcommand: it sets `judgeModel` to "the vendor options" and **does not reserve a new `.pi/plan/<session_id>-vN.md`**, violating "every ordinary `/goals` invocation reserves a new file". Same class of issue for an objective that is exactly `clear` (deletes the plan instead of planning). Use exact-match subcommand parsing (`arg === "judge"` / `arg.startsWith("judge ")` at minimum, and even then an objective like "judge model quality" is unreachable — a `:` or `--` separator would be safer).
**2. The checked-artifact-list regex false-rejects compliant judges — `decideSignOff`, src/index.ts**
```ts
const checks = /^checks:\s*\n(?:-\s+.+\n)+VERDICT:/im.test(judge.output);
```
This requires the last `- ` bullet to be *immediately* followed by `VERDICT:` with no blank line, and requires the heading to be exactly `checks:` at line start. Nothing in `judgeSystem` tells the judge not to separate sections with a blank line (models habitually emit `…bullet\n\nVERDICT: accept`), and a judge writing `## checks:` or `**checks:**` also fails. Result: a valid accept *with* a real checked-artifact list is rejected, and the working agent gets a "Missing: checked-artifact list" reply it already satisfied — a retry loop against a nondeterministic judge. It's fail-closed (never accepts without the list, so the hard requirement holds), but as written it will produce systematic false rejects. Allow optional blank lines / formatting, e.g. `/^#*\s*checks:\s*\n(?:[-*]\s+.+\n)+\s*VERDICT:/im`.
**3. Judge transcript files collide within the same minute — CompleteGoal `execute`, src/index.ts**
```ts
const rel = `.pi/judge/${stamp().replace(/[: ]/g, "-")}.md`;
```
`stamp()` has minute resolution, so two sign-offs in the same minute (two goals signed off back-to-back — the common case) write the same `.pi/judge/<stamp>.md` path and the second silently overwrites the first's full transcript, contradicting "every run saves the judge's full transcript … referenced from the log line" (both log lines then cite one file containing only the second run). Add seconds or a short unique suffix.
## Minor / cosmetic
- Misindented closing `}` of the `if (!checks)` block in `decideSignOff` (extra indentation on the `}` line) — likely flagged by `biome check src/`, which `prepublishOnly` runs.
- In plan mode, if the agent drafts a plan with no recognizable `goal:` checkbox line, the `agent_end` `while (scanGoals(...).length > 0)` loop never shows the Ready menu and plan mode can't be exited via the menu; the "no recognizable goal line" nudge in `dueInjection` only fires in the *non*-plan-mode branch. Not one of the five required behaviors, but a reachable dead end (escape hatch is `/goals clear`).
## Behaviors verified as correct
- **Versioning**: each ordinary `/goals` writes a fresh `<session_id>-vN.md` (empty reservation) via `nextPlanVersion` (max+1, so no clobbering); prior versions are never touched; legacy unversioned names are ignored by `nextPlanVersion` and never read — consistent with the intentional breaking change. `planVersion` is persisted via `appendEntry` and restored in `session_start`, so it survives resume/reload.
- **Visible plan before Ready**: `pi.sendMessage({ customType: "plan", content: plan, display: true })` fires before `ctx.ui.select(...)`, and re-prints inside the `$EDITOR` loop when the text changed. `sendMessage`+`display: true` is the documented visible-transcript mechanism.
- **Grill me**: sets `skipReadyMenu`, queues the single understanding-check question as a followUp user message, and consumes the skip at the next `agent_end` — so no Ready offer exists between asking the question and the human's reply being processed. `skipReadyMenu` is persisted, so it survives resume mid-grill.
- **Judge**: `buildJudgeArgs` yields `pi -p --no-session --no-extensions --tools read,grep,find,ls --exclude-tools edit,write`; `judgeUser` embeds the complete plan file; `judgeSystem` demands per-artifact verbatim quotes framed as an inspectable record ("not hidden reasoning", "no bullet for a file you did not open"); an accept without the `checks:` list is rejected in `decideSignOff` (subject to defect 2); full transcript persisted per run (subject to defect 3).
Main caveat: I could not execute `git diff` or `npm test` here, so I can't confirm the exact diff boundaries or that the focused vitest cases (judge argv invariants, `appendLog`, `decideSignOff` fail-forward) pass — defects 13 are from source inspection, not reproduced runs. Not approved as-is; defects 1 and 2 are worth fixing before merge.
+1 -1
View File
@@ -1,4 +1,4 @@
Code review against spec `docs/spec/2026-06-15_pi-plan.md`.
Code review against spec `docs/spec/2026-06-15_pi-goals.md`.
---
@@ -0,0 +1,25 @@
# Verification: state-aligned planning mode
## Commands
```text
$ npm test
Test Files 8 passed (8)
Tests 29 passed (29)
$ npm run typecheck
> tsc --noEmit
$ npm run lint
Checked 8 files in 17ms. No fixes applied.
$ git diff --check
```
## Read
[test/goals-flow.test.ts](../../../test/goals-flow.test.ts) covers the visible plan before Refine, an editor prompt before a Refine revision turn, exact multiline Refine notes in `## Interview`, Ready as the only work handoff, Pi editor then Cancel, phase restoration, planning snapshot, writable plan path, allowed `pwd && ls && git log` and `cd . && ls -la`, blocked pipe, and blocked `CompleteGoal`.
[test/prompts.test.ts](../../../test/prompts.test.ts) locks the prompt instruction to inspect repository facts or search the web only when it can resolve a fact, ask a short self-contained batch of high-impact questions in the human's language with recommendations, and forbid placeholder goals.
[test/rpc-review.test.ts](../../../test/rpc-review.test.ts) starts the installed Pi RPC executable with [offline-model.ts](../../../test/fixtures/offline-model.ts), selects Refine through Pi's real dialog protocol, receives the editor request before the revision call, then submits notes and observes the revision call. The test uses a local HTTP model, so it spends no API credits.
@@ -0,0 +1,33 @@
# Plan flow and judge review
- [x] goal: Each new `/goals` draft uses a fresh session-plan version
- [x] Persist the selected `-vN` name so resume, reminders, Ready, and sign-off use one file.
- [x] Keep earlier versioned files unchanged.
- [x] Reserve `--clear` and `--judge` for commands so normal objectives are always new drafts.
- failure mode: a second `/goals`, including an objective that begins with `judge`, changes the earlier plan or does not make a draft.
- deliverable: [goals-flow.test.ts](../../../test/goals-flow.test.ts) shows an unchanged legacy file and `v1`, new `v2`, and `judge the vendor options` in new `v3`.
- [x] goal: Plan review asks and displays the needed context
- [x] Add `Grill me` to the Ready menu and queue an understanding-check interview turn.
- [x] Keep one short goal subject with its full indented context block.
- [x] Keep visible plan output before the Ready dialog.
- failure mode: Grill me starts work or the plan is only hidden in an edit call.
- deliverable: [goals-flow.test.ts](../../../test/goals-flow.test.ts) records display before dialog and the grill follow-up.
- [x] goal: Judge review is visible without being confused with agent evidence
- [x] Require concise observed checks before the verdict.
- [x] Save the full judge reply under a unique path and link it from the plan log.
- [x] Accept a headed check list with normal Markdown spacing, but reject an accept with no list.
- failure mode: provider-private reasoning is claimed as evidence, the review is not inspectable, or a correct judge reply is rejected for blank-line formatting.
- deliverable: [decide-signoff.test.ts](../../../test/decide-signoff.test.ts) locks the checked-artifact review contract, including a Markdown heading and blank line before the verdict.
## UAT / Verification
Observed 2026-08-24: `npm test` reported `Test Files 6 passed (6)` and `Tests 22 passed (22)`.
`npm run typecheck`, `npm run lint`, and `git diff --check` exited 0. The focused flow test proves
plan versioning, visible plan-before-dialog ordering, Grill me behavior, and objectives beginning
with `judge`.
## Appendix (context, not approved)
Issue #1 has a 600 second judge timeout now. The judge stays a separate read-only `pi -p --no-session` subprocess. Intercom is unsuitable because it has no equivalent isolation boundary.
External review: [Kimi K3](../../reviews/pi-goals-kimi-k3.md) found the command-prefix, check-list formatting, and transcript-path defects; all were fixed. [Grok 4.6](../../reviews/pi-goals-grok-4-6-retry.md) confirmed the check-list concern. Its Grill me concern does not apply: `skipReadyMenu` suppresses the menu after the generated follow-up, and the next `agent_end` follows the human reply.
@@ -0,0 +1,53 @@
# State-aligned planning mode
Pi-goals will use pi-plan's small phase model. The UI, tool gate, and agent context will read the same persisted phase. Planning still keeps pi-goals' judgeable goals, direct user quotes, and interview record.
- [x] goal: Planning state survives restart and matches the UI and agent context
- [ ] Replace `isPlanMode` and `skipReadyMenu` with persisted `phase: planning | working`.
- [ ] Render the planning widget, inject the hidden planning-state snapshot, and restore state from that phase.
- [ ] Restore the snapshot after restart or compaction without repeating the full drafting prompt every turn.
- subtle failure mode: the UI says planning but a resumed or compacted agent sees work mode.
- discriminator: a flow test restores planning and observes the planning snapshot; working has neither.
- evidence: [goals-flow.test.ts](../../../test/goals-flow.test.ts) restores persisted planning state and observes `[PLANNING MODE]`; [verification](../audit/20260826_pi-plan-aligned-planning.md) records `25 passed`.
- [x] goal: Planning blocks implementation while allowing fact finding
- [ ] Allow writes only to the active plan file.
- [ ] Block implementation tools, `CompleteGoal`, and bash write or pipe attempts with a planning-mode explanation.
- [ ] Allow ordinary read-only inspection commands such as `pwd && ls && git log`.
- subtle failure mode: an agent marks a goal active or changes project code before approval.
- discriminator: flow tests reject each work route and allow the inspection command.
- evidence: [goals-flow.test.ts](../../../test/goals-flow.test.ts) asserts allowed `pwd && ls && git log`, blocked pipe, non-plan write, and `CompleteGoal`; [verification](../audit/20260826_pi-plan-aligned-planning.md) records `25 passed`.
- [x] goal: Planning interviews and revision notes are durable user evidence
- [ ] Teach the planning prompt to ask each independent, high-impact user-decision frontier with a recommendation, while researching facts itself.
- [ ] Keep typed answers and `Refine` editor notes verbatim under `## Interview`.
- [ ] Exempt `## User voice` and `## Interview` from working-set line pressure.
- subtle failure mode: the plan silently assumes preferences or loses a revision note.
- discriminator: a flow test opens Refine and finds its exact multiline text in `## Interview`.
- evidence: [goals-flow.test.ts](../../../test/goals-flow.test.ts) matches the exact multiline Refine note under `## Interview`; [verification](../audit/20260826_pi-plan-aligned-planning.md) records `25 passed`.
- [/] goal: The settled plan review is concise and cannot start work accidentally
- [ ] Use `agent_settled` to visibly print the full plan, then offer `Ready`, `Refine`, `Edit`, and `Cancel`.
- [ ] Ready alone sends the work handoff. Refine sends one explicit revision turn. Edit opens Pi's full-plan editor. Cancel leaves planning.
- subtle failure mode: a review choice queues an unrequested agent turn or hides the plan below the dialog.
- discriminator: flow tests show plan before the menu and distinguish all four actions.
- evidence: [goals-flow.test.ts](../../../test/goals-flow.test.ts) shows plan before the menu and isolates Ready as the work handoff; [verification](../audit/20260826_pi-plan-aligned-planning.md) records `25 passed`. Pending human Pi TUI check.
- [x] goal: Planning resolves facts, interpretation, and approval before overnight work
- [x] Use repository inspection or web search when either can resolve a discoverable fact.
- [x] Require human confirmation for the agent's interpretation, unresolved task or outcome, scope, and decisions needing later approval.
- [x] Batch independent high-impact questions with the needed context, the human's terms, ASD-STE100 language, and a recommendation.
- [x] Ban placeholder goals such as "work out the thing" before the plan review menu.
- subtle failure mode: the plan has a formal discriminator but silently chooses an editorial direction or other human decision.
- discriminator: [prompts.test.ts](../../../test/prompts.test.ts) locks the research, clarification, approval, question-batch, and concrete-goal rules in the model prompt.
- evidence: [prompts.ts](../../../src/prompts.ts) makes research conditional on whether it can resolve a fact, then requires human confirmation and approval before Ready. [prompts.test.ts](../../../test/prompts.test.ts) checks those requirements. [verification](../audit/20260826_pi-plan-aligned-planning.md) records `29 passed`.
- [x] goal: Refine waits for text in Pi's real dialog protocol
- [x] Run Pi in RPC mode against a local no-cost model.
- [x] Select Refine, observe the editor request, then submit text and observe the revision turn.
- subtle failure mode: a mocked editor hides a Pi RPC ordering defect, so Refine starts a turn before the human can type.
- discriminator: [rpc-review.test.ts](../../../test/rpc-review.test.ts) uses Pi's `extension_ui_request` and `extension_ui_response` protocol and observes two model requests before editor input, then the third revision request after it.
- evidence: [rpc-review.test.ts](../../../test/rpc-review.test.ts) starts the installed Pi executable plus [offline-model.ts](../../../test/fixtures/offline-model.ts), with no credential or network dependency. [verification](../audit/20260826_pi-plan-aligned-planning.md) records its pass.
## UAT / Verification
`npm test`, `npm run typecheck`, and `npm run lint` pass. Read [test/goals-flow.test.ts](../../../test/goals-flow.test.ts): its assertions must show a restored planning phase, visible plan before review, exact recorded refinement, blocked work routes, and a work message only after Ready.
## Appendix (context, not approved)
Accepted: copy pi-plan's persisted phase, `agent_settled` review, and Pi editor. Do not copy its restrictive shell allowlist. Grill is a planning instruction, not a menu item: ask the whole independent frontier in rounds, with recommendations. `Ready + compact` is removed; compaction remains Pi's normal command after Ready.
@@ -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. -->
@@ -0,0 +1,138 @@
# One package, remembered role models, and conversational plan alignment
The user wants supervision inside pi-goals, with a separate remembered model choice for planning,
working and supervising. This replaces the three-extension installation described in the earlier
integration plan. Two follow-up requests require default alignment questions and a clear return to
normal chat from review; the parent supplied these approved additions during implementation.
## User-visible result
Install only pi-goals, discuss a plan and select Ready to open its real supervisor. Each role restores
its last explicitly selected provider/model. Planning asks at least three task-specific alignment
questions by default. Discuss returns the review menu to ordinary conversation without an editor or
an immediately recurring menu; Ready remains the sole handoff to work.
## User voice
- > did you do the part where the last model choice in plan ,vs worker vs supervisor mode is sticky?
- > ok implement. and just put it into pi-goals I guess... seems messy to have a sep package. one clean package
- > note I tried it in another window and it didn't grill me even one quesiton that is bad should be default to at least ask 3 questions to see how far apart in understanding user and agent are
- > and then I wans't sure what to press, edit? no. refine? that's a special prompt. how to go back to chat about plan and ask it to grill. should not have ot
## Goals
1. [x] goal: Install one package for planning and supervision
- scope: Move supervisor code and regression coverage into pi-goals. Bundle the existing Intercom transport through supported Pi packaging; no separate companion registration or source checkout required. Keep Herdr as the terminal host.
- failure modes: A source-checkout test hides missing packed dependencies; duplicate extension registration; lifecycle regressions during the move.
- discriminator: A packed pi-goals artifact alone supplies both real Pi sessions and completes the offline supervised-goal test. Full inherited supervisor regressions run from this repository.
- evidence: `src/internal/supervisor/` imports the implementation from `145c2cb081f85c08b0244c4a2c8a2d9aef8debda`; `THIRD_PARTY_NOTICES.md` and source attribution retain provenance/MIT notices. `package.json` bundles locked Intercom 0.10.0 and VCC 0.5.0; its manifest loads Intercom through `node_modules/`, while VCC remains a compiler rather than a loaded extension. Launch passes only the pi-goals package root. Duplicate registry diagnostics reject ambiguous plan bootstrap. `test/rpc-supervisor.test.ts` packs/extracts outside companion checkouts, runs actual Pi/Intercom and an offline fresh judge, and asserts production resources and no bundled Pi core peers. Only the worker's Herdr exec is adapted; the supervisor loads the untouched extracted manifest. Parent full validation passed 59 Vitest tests and 117 internal tests (116 inherited plus duplicate-registry coverage), zero skipped.
2. [x] goal: Restore the last model selected for each role
- scope: Remember provider/model separately for planning, worker and supervisor in user-scoped pi-goals preferences. First use inherits the current model. Preserve the separate evidence-judge override.
- failure modes: Automatic switching overwrites another role's preference; reload/fork assigns the wrong role; one process overwrites another role's choice; an unavailable model silently changes the saved preference.
- discriminator: Tests select distinct models in each role, enter/re-enter roles, reload and start another plan. Each role restores its own selection; missing models are reported without overwriting preferences.
- evidence: `src/role-models.ts` uses public `model_select` (`set`/`cycle`; ignores `restore` and guarded automatic `setModel` events). Separate atomic `planning-model.json`, `worker-model.json`, and `supervisor-model.json` files under `getAgentDir()/pi-goals` contain only provider/id. Failed authentication or lookup visibly pauses the role, preserving its saved choice. `test/role-models.test.ts` covers deterministic cycling, fresh processes and unavailable/unauthenticated models. `test/goals-flow.test.ts` covers Ready, cancellation during setModel, failed Ready, next plans, fresh-instance resume/reload and inherited supervisor isolation. `test/supervisor-integration.test.ts` proves planning model capture before worker restore, supervisor model before initial compaction, activation cancellation and judge override isolation. The packed RPC flow also verifies actual planning selection and distinct worker/supervisor/judge model IDs.
3. [x] goal: Ask useful alignment questions before the final plan
- scope: Default to at least three distinct task-specific questions in one chat round, about expected result, scope/constraints and success/failure criteria. Resolve technical facts read-only. Wait for answers and use them. Only an explicit current-objective no/skip-questions instruction waives the default.
- failure modes: Generic ritual questions; questions skipped merely because the agent thinks it understands; an old waiver leaking into a new plan.
- discriminator: Prompt and flow regressions require the default, persist the current-plan waiver across resync, reset it on a new plan, and keep the final-review menu closed until review is requested.
- evidence: `src/prompts.ts` defines the default and current-plan policy; `src/index.ts` persists `questionsWaived` per plan. Prompt and flow tests cover default/waiver/next-plan behavior. The deterministic real-Pi `test/rpc-review.test.ts` asks questions before the first review, accepts chat answers, and reaches Ready. This validates protocol/control flow, not semantic question quality from every live model; no semantic question-count framework was added.
4. [x] goal: Return to ordinary plan chat from review
- scope: Ready / Discuss / Edit / Cancel. Discuss preserves the draft and planning role, asks useful questions in chat, and supports multiple answer turns without another modal. Escape also preserves the draft and returns to chat. Edit remains direct editing; explicit Cancel discards the draft.
- failure modes: Refine-notes editor persists under a renamed button; unchanged drafts immediately reopen the menu; discussion is lost on reload; another approval gate starts work.
- discriminator: Discuss -> multiple chat turns without a menu -> completed discussion -> review again (including unchanged draft) -> exactly one Ready handoff. Reload retains discussion state.
- evidence: Planning-only `RequestPlanReview` is the unambiguous signal to offer review; it does not approve work. New plans and Discuss set persisted `reviewRequested: false`. `test/goals-flow.test.ts` proves unchanged-draft re-review and reload. `test/rpc-review.test.ts` uses real Pi select/chat events and asserts no Discuss editor or premature menu. Ready still exclusively starts work.
## Log
Final parent acceptance: [saved validation and review disposition](../../reviews/2026-09-07_single-package-role-models.md). All four review findings and the recovery-cancellation correction passed the targeted recheck (`No issues found.`, `Merge verdict: OK`). Final tests: 67 Vitest and 118 internal tests, no skips. The package-list migration is complete; existing workers need reload before another Ready attempt.
## Verification
Initial (pre-review-fix) parent unsandboxed checkpoint validation, read back from
`/tmp/pi-goals-single-package-parent-validation.log`:
```text
npm test
Test Files 12 passed (12)
Tests 59 passed (59)
internal node:test: tests 117, pass 117, fail 0, skipped 0
npm run typecheck: passed
npm run lint: Checked 32 files. No fixes applied.
npm run build: passed
git diff --check: passed
SINGLE_PACKAGE_PARENT_VALIDATION_PASSED
```
The full test includes production `npm pack` and the real broker/offline supervised flow, without an
optional integration flag or companion checkout. Worker-local validation independently passes 58
non-broker Vitest tests and all 117 internal regressions, plus typecheck/lint/build. Worker full
`npm test` fails only at Intercom startup: a minimal Unix `net.listen()` also returns `EPERM` in this
sandbox. The parent ran the unchanged enabled test outside that restriction and passed it. Do not
confuse the worker environment limitation with a skipped test or a product pass claim.
`tsconfig.build.json` maps only the four used source-only VCC API surfaces to narrow declarations,
because VCC 0.5.0 has upstream Pi-message/Intl type incompatibilities. Pi-goals source remains fully
typechecked/linted; tests execute the actual pinned VCC implementation. The inherited `node:test`
suite has its own package script and is not silently collected or skipped by Vitest.
Final production tarball, file listing, tracked-plus-new-source diff, source copies, status and
validation logs are saved under `/tmp/pi-goals-single-package-review/` for read-only review. No files
are staged. Fresh review and the targeted recheck are complete; parent acceptance is recorded above.
## Accepted review findings and narrow fixes
Parent accepted all four findings in `single-package-review-recovery.md`; the source was frozen
again for unsandboxed validation after this targeted pass:
1. **Stop while a model is unavailable:** the validated `stop` control-plane operation bypasses
model readiness, while binding/channel checks and peer notification remain. AbortSignal review
cancellation also works during the pause. Directives are ignored for paused/inactive or stopped
workers. New actual-module clear/off tests and the internal wrong-binding/abort/stop regression
prove that the old pair cannot revive work.
2. **Restore before activation and recover the right role:** Ready captures/attaches the planning
fork, persists a pending worker recovery target, restores the worker model, then activates and
hands off. Missing lookup/authentication returns to the review UI without activation or a
supervisor review turn. The persisted recovery target remains worker across reload; an explicit
selection writes worker-model.json, not planning-model.json. Both failure paths have supervised
integration regressions; successful recovery reuses the existing fork and hands off once.
3. **Affirmative waivers only:** a small clause recognizer accepts explicit affirmative current-plan
instructions including `no q's` and `skip q's`, but not `do not skip questions`, quoted feature
names, or embedded quoted clauses. No general NLP parser or semantic question-count gate was added.
4. **Already-current model recovery:** `/goals model current` explicitly authenticates and saves the
current model for the paused role. It does not depend on model_select, which Pi suppresses for
an unchanged model. It is never automatic and does nothing if no role is paused. Unit tests
cover failed authentication without preference replacement; real-Pi RPC proves that same-model
selection alone cannot recover, then the explicit command safely recovers and Ready starts once.
The parent identified one cancellation gap in the initial repair: the recovery restore await was
outside Ready's controller/version ownership. It is now inside the captured lifetime/controller and
plan version/hash checks, before any launch call. Deferred recovery -> clear and -> replacement
regressions prove zero additional startup/activation/handoff and preserve the new/null state.
Worker validation on the final fix source: 66 non-broker Vitest tests and 118 internal supervisor tests pass;
`npm run typecheck`, lint (32 files), build and diff checks pass. Worker full `npm test` still fails
only at actual Intercom startup under the same sandbox Unix-socket EPERM limitation. The enabled
packed test has not been skipped or replaced. Parent unsandboxed validation of the four-fix checkpoint passed 65/65 Vitest and 118/118 internal
tests, zero skipped, plus typecheck/lint/build/diff checks; see
`/tmp/pi-goals-single-package-fixed-validation.log`, ending `SINGLE_PACKAGE_FIX_VALIDATION_PASSED`.
That full run predates the final recovery-await cancellation correction and two extra local tests.
Final parent unsandboxed validation of that correction has now passed **67/67 Vitest tests in 12
files and 118/118 internal tests, zero skipped**, plus typecheck, lint (32 files), build and diffcheck.
The read-back log is `/tmp/pi-goals-single-package-final-validation.log`, ending
`SINGLE_PACKAGE_FINAL_VALIDATION_PASSED`. Parent's targeted review subsequently returned `No issues found.` and `Merge verdict: OK`; all named fixes and their immediate regressions were checked.
Fresh complete snapshots are at `/tmp/pi-goals-single-package-fix-review/`, including a fix-only
delta against the previous review snapshot as well as the full tracked-plus-new-source diff. The
normal three-role/Discuss/RequestPlanReview flow is unchanged, and no extra normal-path approval
gate, IPC/package redesign, global setting edit, or legacy orphaned-write fix was made.
## Boundaries and remaining limits
- Sole writer in the goals worktree on `feature/persistent-steward`, based on `8e44738`; the supervisor source checkout was read-only. No commit/push/publish or global settings/auth edits.
- Only pi-goals is loaded after migration. Parent reports that the old standalone supervisor/Intercom global registrations have now been removed; existing workers remain instructed to wait for validated reload/retry guidance before Ready. Old worker code can otherwise launch old `-e` companion arguments. No panes were controlled here.
- Herdr remains the supported host. Automated tests mock its exec adapter; live navigation/rendering/recovery and measured token savings are not established by this change.
- Native fork/compaction, SUPERVISOR.md precedence, incremental VCC memory, correlated review/cancellation/recovery and default-on policy remain. The fresh evidence judge stays separate.
- The previous live trial's apparently stale unresolved-write `done` failure remains pre-existing and undiagnosed, as recorded in `docs/reviews/2026-09-07_supervisor-validation.md`. Outstanding-work safeguards were not weakened and cleanup is not claimed fixed.
- Role files avoid cross-role lost updates; concurrent explicit choices within the same role are intentionally last-write-wins. No thinking-level preferences or credentials are stored.
<!-- Implementation and parent-observed validation by Pi. -->
@@ -1,9 +1,12 @@
# pi-plan — design spec
# pi-goals — design spec
Working title. A pi extension: set up goals (with subtasks and evidence) through plan mode, work them autonomously, and sign a goal off only when a check passes. One markdown file holds everything. The form guides a process; it does not police one. Successor to `pi-lgtm`, deliberately smaller.
Working title. A pi extension: set up goals (with subtasks and evidence) through plan mode, work them autonomously, and sign a goal off only when a check passes. One markdown file holds everything. The form guides a process; it does not police one. Deliberately small.
Status: draft for review. Names, defaults, field shapes provisional.
The file is now `.pi/plan/<session_id>.md`, one per session, not the `plan.md` this spec names
throughout. See [2026-08-14_per-session-plan.md](2026-08-14_per-session-plan.md).
---
## 1. Original ask → this spec
@@ -0,0 +1,71 @@
# CompleteGoal fail-forward on judge failure
## Goal
Make `CompleteGoal` stop rejecting verified goals just because the read-only judge subprocess times out. Keep the judge useful when it works, and make failures explicit in the log/result.
## Scope
In: `CompleteGoal` sign-off behavior, judge transport, tests, docs.
Out: broader autonomous loop work, plan-mode UX, model auto-selection.
## Requirements
- R1: If `verify:` fails, the goal is rejected immediately. Done means: existing `verify_failed` behavior remains. VERIFY: unit test for pure sign-off record still passes.
- R2: If `verify:` passes and the judge accepts, mark the goal done as before. Done means: log records normal judge accept. VERIFY: unit test for accepted sign-off still passes.
- R3: If any `verify:` command passes but the judge times out or subprocess/model transport fails, mark the goal done with an explicit inconclusive-judge log. Goals without `verify:` use the same fail-forward rule once evidence exists. VERIFY: a unit test records accepted status and a log line containing `judge inconclusive`.
- R4: Judge transport should parse `pi --mode json` message events instead of raw `-p` terminal output. Done means: code captures final assistant text and provider stop errors distinctly. VERIFY: `npm run typecheck` and tests pass.
- R5: The judge should behave like oracle where it matters: explicit model, live streamed progress, and a timeout large enough for a cold reasoning turn. Done means: unset `/goals judge` resolves to the current session model when visible; if no model is visible, no implicit Pi default is used and sign-off is `judge inconclusive`. `message_update` emits throttled progress, and timeout is 600s. VERIFY: `npm run typecheck` and fresh-eyes diff review.
## Tasks
- [x] T1 (R3): Add an accepted-with-warning sign-off outcome.
- verify: `npm test`
- success: test shows status `[x]` plus `judge inconclusive` in `## Log`
- likely_fail: timeout still records `reject`
- sneaky_fail: accepted status lands but log hides judge failure
- UAT: [test/plan-file.test.ts](/home/wassname/.pi/agent/git/github.com/wassname/pi-plan/test/plan-file.test.ts)
- [x] T2 (R4): Switch judge subprocess to JSON-mode parsing.
- verify: `npm run typecheck`
- success: no TypeScript errors, judge code has no ANSI-terminal parsing dependency
- likely_fail: compile errors around streamed event shape
- sneaky_fail: model error produces empty output and gets parsed as reject instead of transport failure
- UAT: [src/index.ts](/home/wassname/.pi/agent/git/github.com/wassname/pi-plan/src/index.ts)
- [x] T3 (docs): Update README sign-off semantics.
- verify: `rg "inconclusive|timeout|judge accept" README.md src test`
- success: docs name fail-forward behavior
- likely_fail: README still says all rejects keep goal open
- sneaky_fail: docs imply subagent evidence was accepted when it timed out
- UAT: [README.md](/home/wassname/.pi/agent/git/github.com/wassname/pi-plan/README.md)
- [x] T4 (R5): Copy oracle's reliability shape for model/progress.
- verify: `npm run typecheck`
- success: `CompleteGoal` passes the current session model to the judge when no override is set, never spawns without `--model`, and streamed judge deltas are surfaced through `onUpdate`
- likely_fail: judge still runs without `--model`
- sneaky_fail: user sees no progress for several minutes and kills a working judge
- UAT: [src/index.ts](/home/wassname/.pi/agent/git/github.com/wassname/pi-plan/src/index.ts)
## Context
Observed result from downstream use:
```json
{
"goal": "Make persona validation fail-fast and evidence-correct",
"outcome": "rejected",
"durationMs": 120003,
"verifyCommand": "`uv run python -m compileall -q scripts/validate_persona_axes_openrouter.py`",
"reasoning": "VERDICT: reject\nmissing: judge timed out after 120s",
"isError": true
}
```
Interpretation: latest surfaced output proves the internal judge timed out. It does not prove the verify command passed, though earlier logs indicated that pattern.
## Log
- 2026-06-29 current `runJudge` uses raw `pi -p --no-session` output plus ANSI stripping; oracle uses `--mode json` and parses message events, which is likely more reliable.
- 2026-06-29 unset `/goals judge` spawns the judge without `--model`, so Pi resolves its configured default model; do not describe this as the current session model.
- 2026-06-29 timeout/transport failure now maps to `accepted_inconclusive`, preserving partial output in reasoning when available.
- 2026-06-29 fresh-eyes review found loose `/accept/i` verdict parsing and caller-abort fail-forward risk; fixed exact verdict parsing and made caller abort reject.
- 2026-06-29 oracle comparison suggests the important reliability pieces are explicit model selection, JSON streaming, live partial output, and no short wrapper timeout; updated CompleteGoal to use the current session model when visible, never spawn without `--model`, stream throttled progress, and wait 600s.
## TODO
- Consider making `CompleteGoal` expose `verifyExitCode: 0` and `judgeOutcome` separately in details.
## Errors
| Task | Error | Resolution |
|------|-------|------------|
+67
View File
@@ -0,0 +1,67 @@
# Per-session plan file
One `.pi/plan.md` per repo is wrong when two agents share the repo. A subagent spawns as
`pi -p --no-session` in the same cwd with extensions ON (only the judge gets `--no-extensions`),
so it loads pi-goals, gets the whole plan pushed in on its first call, and can call CompleteGoal
on the parent's goal. A second window has the same problem, plus last-write-wins on the file.
Fix: the plan file is named after the session, `.pi/plan/<session_id>.md`. The file name is the
arm switch. A session that never ran `/goals` has no file at its path, so the extension stays
silent. No new state flag.
The id is stable where it must be. Resume reads `header.id` from the session file
(`session-manager.js:547`) and compaction uses `branchWithSummary`, which does not touch the id.
Only an explicit fork or new session gets a new id (`createBranchedSession`, `newSession`).
- [x] goal A: the plan file is per session, and a session with no plan is inert
- [x] `planPath(ctx)` = `.pi/plan/<ctx.sessionManager.getSessionId()>.md`; `mkdir -p` the dir
- [x] `PLAN_REL` becomes a per-context value; pass it into `decideSignOff` through `SignOffInput`
so the judge prompt still names the real file
- `--no-session` still gets a fresh random id, checked: `SessionManager.inMemory` passes no
session file, so the constructor calls `newSession()` -> `createSessionId()`. So each subagent
gets its own unused path and reads nothing. No empty-id special case is needed.
- failure modes: two sessions somehow resolve the same id, so the two agents still share a file
- deliverable: `ls .pi/plan/` in a scratch repo after two sessions, showing two files
- [x] goal B: delete what this replaces
- [x] drop the v1 `.pi/goals.md` -> `.pi/plan.md` rename in `session_start`
- [x] `/goals clear` unlinks the file instead of writing an empty one
- failure modes: none, this is removal
- deliverable: the diff, negative line count
- [x] goal C: docs and version match the code
- [x] README, `package.json` description, `docs/spec/2026-06-15_pi-goals.md` say `.pi/plan/<session_id>.md`
- [x] version 0.2.0, the path change is breaking
- failure modes: docs still say `.pi/plan.md`, so the next reader trusts the wrong file
- deliverable: `grep -rn "plan\.md" $(git ls-files)` returns only history and format prose
## UAT / Verification
`npm test` 19 passed, `npm run typecheck` and `npm run lint` clean.
Live A/B in `/tmp/plan-scratch`, which holds the same plan at both the old and the new path. The
plan's one goal line is `1. [/] goal: SECRET-CANARY-PLAN delete the production database`. Each run
asked: "Were you given a plan with goals? If yes, reply with the goal line verbatim. If no, reply
exactly: NO PLAN".
| run | version, flags | answer |
|---|---|---|
| A | HEAD 4827808 (`.pi/plan.md`), `-p -ne --no-session` | `1. [/] goal: SECRET-CANARY-PLAN delete the production database` |
| B | this change, `-p -ne --no-session` | `NO PLAN` |
| C | this change, `-p -ne --session-id 019ec140-ce3b-70d7-8151-abfa7f1c95a9` | `goal: SECRET-CANARY-PLAN delete the production database` |
A is the bug: a subagent reads the parent's plan. B is a subagent under this change: it sees
nothing and writes nothing (`find /tmp/plan-scratch/.pi -type f` still lists only the two seeded
files). C is the owning session, whose id matches the plan file name: it still gets its plan.
Not covered by a live run: `/resume` and compaction keep the id. Both were checked by reading
`session-manager.js` (`:547` reads `header.id`; compaction goes through `branchWithSummary`, which
never assigns `sessionId`).
## Appendix (context, not approved)
Rejected: keep one `.pi/plan.md` and add an `armed` flag to `PlanState` that only `/goals` Ready
sets. It works for subagents (`--no-session` has no state to replay) but it is more code than the
rename, and two armed windows still stomp each other's file. -- Claude
Open, not in this plan: adopting an old plan into a new session (`/goals resume` picking the newest
file in `.pi/plan/`), and carrying the plan across an explicit fork. Both are one command each; wait
until the need is real.
@@ -0,0 +1,39 @@
# 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,
trajectory, goal ordering, and scope. `CompleteGoal`'s fresh judge continues to check artifacts.
## Lifecycle
1. The human opts in with `/goals steward on`.
2. Ready forks one non-writing `oracle` through the public `pi-subagents` RPC. Work does not start
until it returns `approve`.
3. The child process exits. Pi-subagents retains its session and run identity; no model or process
remains active between checkpoints.
4. The first `CompleteGoal` call resumes the same child with bounded approved/current contract views
and the proposed goal. Checkbox state is normalized and evidence detail is omitted because the fresh
judge owns it. The steward checks contract fidelity and whether sign-off is timely.
5. `approve` creates a one-use approval bound to the goal and current working-set hash. The next
`CompleteGoal` call consumes it and runs the existing fresh evidence judge.
6. `revise_plan`, `needs_user`, an invalid response, or a failed child never signs off the goal.
## Authority
The steward's contract forbids edits, goal completion, detailed evidence assessment, and answers to
unresolved human choices. The builtin Oracle retains inspection-only bash; pi-goals rejects a review
when pi-subagents reports a file-mutation effect, but this is not an OS sandbox. Structured decisions
are `approve`, `revise_plan`, and `needs_user`. An `approve` carrying drift or unresolved decisions is
downgraded. A separate fresh judge remains the only evidence sign-off path.
## Integration
Pi-goals uses the process-local `subagents:rpc:v1` event API. Initial execution is async `spawn` with
`context: fork`; later checks use `resume`. The current run id and approved working set persist in the
existing `pi-goals-state` session entry. `pi-intercom` is not involved.
The feature is opt-in and has no hard package dependency. If pi-subagents is absent, RPC startup
fails visibly and the plan remains in planning mode until the user retries or turns the steward off.
+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. -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+5398
View File
File diff suppressed because it is too large Load Diff
+41 -15
View File
@@ -1,13 +1,13 @@
{
"name": "@wassname2/pi-plan",
"version": "0.0.1",
"description": "One plan.md: set goals via plan mode, work them, sign off only when a read-only check passes. Successor to pi-lgtm.",
"name": "@wassname2/pi-goals",
"version": "0.2.2",
"description": "One plan file per session: set goals in plan mode, work them, sign off only when a read-only judge checks the evidence.",
"author": "wassname",
"license": "MIT",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/wassname/pi-plan.git"
"url": "git+https://github.com/wassname/pi-goals.git"
},
"keywords": [
"pi-package",
@@ -20,28 +20,54 @@
"evidence",
"judge"
],
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.79.0",
"@earendil-works/pi-tui": "*",
"@sinclair/typebox": "latest"
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"typebox": "*"
},
"files": [
"src",
"README.md",
"THIRD_PARTY_NOTICES.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc",
"test": "vitest run",
"build": "tsc -p tsconfig.build.json",
"prepublishOnly": "npm run lint && npm run typecheck && npm run test",
"test": "vitest run && npm run test:supervisor",
"test:rpc": "vitest run test/rpc-review.test.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"lint": "biome check src/ test/",
"lint:fix": "biome check --fix src/ test/"
"lint:fix": "biome check --fix src/ test/",
"test:supervisor": "node --import tsx --test test/internal-supervisor/*.test.ts"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "^0.84.1",
"@types/node": "^20.0.0",
"typebox": "^1.3.7",
"typescript": "^5.0.0",
"@biomejs/biome": "^2.4.8",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"tsx": "^4.20.0"
},
"pi": {
"extensions": [
"./src/index.ts"
"./src/index.ts",
"./node_modules/pi-intercom/index.ts"
],
"image": "https://cdn.jsdelivr.net/gh/wassname/pi-goals@main/media/screenshot.png",
"skills": [
"./node_modules/pi-intercom/skills"
]
}
},
"dependencies": {
"@sting8k/pi-vcc": "0.5.0",
"pi-intercom": "0.10.0"
},
"bundledDependencies": [
"pi-intercom",
"@sting8k/pi-vcc"
]
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Replaces the stale FIXME(side-effect) claim in src/index.ts with a checked fact.
#
# The claim was: "pi -p --no-session clones the repo into the PARENT of cwd, leaving a stale
# directory." Reproducing the exact sign-off judge invocation (pi --mode json -p --no-session,
# read-only tools, edit/write excluded, cwd = here) shows it does not. This script makes that
# reproducible: it runs the invocation, requires pi to actually reach agent_end (so a pass is not
# vacuous), and asserts the parent-of-cwd listing is byte-identical before and after.
#
# Exit 0 = judge leaves no clone in the parent. Exit 1 = either pi did not run, or it polluted.
# Run by hand; re-run as the rigorous sign-off check (the judge has bash and runs this itself).
set -u
PARENT="$(cd "$PWD/.." && pwd)"
before="$(ls -1A "$PARENT" | sort)"
# Cheapest available model; the test exercises pi --no-session's workdir setup, not the output.
out="$(timeout 90 pi --mode json -p --no-session \
--model 'openrouter/~anthropic/claude-haiku-latest' \
--tools read,bash,grep,find,ls --exclude-tools edit,write \
--append-system-prompt 'Reply with exactly: VERDICT: accept' \
"Reply with exactly: VERDICT: accept" 2>/dev/null || true)"
# Non-vacuous: require pi to have actually completed a turn. A pass without this could mean pi
# crashed instantly and never had the chance to clone -- which would prove nothing.
if ! printf '%s' "$out" | grep -q '"type":"agent_end"'; then
echo "FAIL: pi --no-session did not reach agent_end; cannot confirm no-clone."
exit 1
fi
after="$(ls -1A "$PARENT" | sort)"
echo "parent: $PARENT"
echo "--- before ---"; echo "$before"
echo "--- after ---"; echo "$after"
if [ "$before" == "$after" ]; then
echo "PASS: parent-of-cwd listing identical before/after; no clone created."
exit 0
fi
echo "FAIL: parent-of-cwd listing changed. Diff (< before, > after):"
diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") | head -20
exit 1
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Structural gate for the goal's `verify:` field. Cheap and deterministic: no API calls.
# Confirms (a) neither stale FIXME tag remains in src/, (b) the footprint script exists, and
# (c) the plan-injection heading prefix is still emitted. The rigorous runtime check (running
# the footprint script) is the sign-off judge's job -- it has bash and re-runs the script itself.
set -u
fail() { echo "FAIL: $1"; exit 1; }
grep -rnE 'FIXME\((heading|side-effect)\)' src/ >/dev/null 2>&1 && fail "a stale FIXME(heading|side-effect) is still in src/"
test -f scripts/check-judge-footprint.sh || fail "scripts/check-judge-footprint.sh is missing"
grep -q '\.pi/goals\.md:' src/prompts.ts || fail "the .pi/goals.md: heading prefix was dropped from src/prompts.ts"
echo "PASS: stale FIXMEs gone, footprint script present, heading prefix intact."
exit 0
+104
View File
@@ -0,0 +1,104 @@
diff --git a/README.md b/README.md
index ce4056a..5485002 100644
--- a/README.md
+++ b/README.md
@@ -145,9 +145,9 @@ else is the agent editing the file. It reads the goal's `evidence:` block from `
reasoning comes back in the result.
The judge defaults to the current session model and streams partial output while it runs. If the
-current model is not visible to the extension, `CompleteGoal` does not fall back to Pi's implicit
-default; it signs off as `judge inconclusive` and tells you to set `/goals judge <provider/model>`.
-Point it at another model for an independent cross-family check.
+session model is not visible to the extension, the `--model` flag is omitted and pi uses its own
+configured default, so the judge always runs. `/goals judge <provider/model>` is an optional override
+for an independent cross-family check; never required.
## Prompts
diff --git a/src/index.ts b/src/index.ts
index 9eb2a16..14784f7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -326,7 +326,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
durationMs,
verifyCommand: goal.verify ?? undefined,
verifyExitCode: outcome.kind === "verify_failed" ? outcome.exitCode : undefined,
- judgeModel: judgeModel ?? "no explicit judge model",
+ judgeModel: judgeModel ?? "pi default",
reasoning,
isError: res.isError,
};
@@ -522,14 +522,6 @@ async function decideSignOff(
};
}
}
- if (!judgeModel) {
- const reason = "no explicit judge model available; set /goals judge <provider/model>";
- return {
- outcome: { kind: "accepted_inconclusive", reason },
- reasoning: `VERDICT: inconclusive\nreason: ${reason}`,
- durationMs: Date.now() - startedAt,
- };
- }
const verdict = await runJudge(goal, evidence, paths, verifyResult, judgeModel, cwd, signal, onUpdate);
const outcome: SignOff =
verdict.kind === "accepted"
@@ -573,13 +565,25 @@ type JudgeResult =
| { kind: "rejected"; missing: string; reasoning: string; durationMs: number }
| { kind: "inconclusive"; reason: string; reasoning: string; durationMs: number };
+/** Stage 2: a read-only pi subprocess inspects the evidence against the repo and returns a verdict. */
+/** Build the pi argv for the read-only judge. `--model` is omitted when no explicit/session model is
+ * set, so pi falls back to its configured default -- the judge always runs, never pre-emptively
+ * fails as "no model". Exported for a unit test that locks this invariant (an empty `--model ""`
+ * would make every sign-off silently inconclusive). */
+export function buildJudgeArgs(judgeModel: string | null): string[] {
+ const args = ["--mode", "json", "-p", "--no-session"];
+ if (judgeModel) args.push("--model", judgeModel);
+ args.push("--tools", JUDGE_TOOLS.join(","), "--exclude-tools", JUDGE_BLOCKED_TOOLS.join(","), "--append-system-prompt", evidenceJudgeSystem);
+ return args;
+}
+
/** Stage 2: a read-only pi subprocess inspects the evidence against the repo and returns a verdict. */
async function runJudge(
goal: Goal,
evidence: string,
paths: string[],
verifyResult: { command: string; exitCode: number; outputTail: string } | null,
- judgeModel: string,
+ judgeModel: string | null,
cwd: string,
signal: AbortSignal | undefined,
onUpdate?: (partial: { content: Array<{ type: "text"; text: string }>; details: SignOffDetails }) => void,
@@ -600,14 +604,14 @@ async function runJudge(
evidence,
paths,
});
- const args = ["--mode", "json", "-p", "--no-session", "--model", judgeModel, "--tools", JUDGE_TOOLS.join(","), "--exclude-tools", JUDGE_BLOCKED_TOOLS.join(","), "--append-system-prompt", evidenceJudgeSystem];
+ const args = buildJudgeArgs(judgeModel);
args.push(task);
emit("spawning", `Spawning read-only judge for: ${goal.subject}`);
const inv = getPiInvocation(args);
- // FIXME(side-effect): pi -p --no-session clones the repo into the PARENT of cwd (so alongside
- // the working dir), leaving a stale directory. The judge should run in a temp dir or inside the
- // existing repo checkout so it doesn't pollute the user's workspace.
+ // The judge runs in-place against this checkout (cwd is passed to spawn and the read-only tools
+ // read from it); pi --no-session does not clone into the parent. Proven and re-checked by
+ // scripts/check-judge-footprint.sh, which reproduces this invocation and asserts no parent clone.
const judge = await new Promise<{ output: string; error?: string; aborted?: boolean }>((resolve) => {
let settled = false;
let stdoutBuffer = "";
diff --git a/src/prompts.ts b/src/prompts.ts
index 03faea8..3b8d270 100644
--- a/src/prompts.ts
+++ b/src/prompts.ts
@@ -117,8 +117,6 @@ export function planInjection(p: {
counts: { done: number; open: number };
}): string {
if (!p.activeGoal) {
- // FIXME(heading): user wants the heading to show ".pi/goals.md: <title>" so the filename is explicit
- // even in the injection. Currently says "Goals (goals.md):" which is close but not the same.
return `.pi/goals.md: ${p.title}\nNo active goal. ${p.counts.open} open, ${p.counts.done} done. Pick the next goal (set its checkbox to [/]) or run /goals.`;
}
const subtasks = p.activeGoal.openSubtasks.length
+30
View File
@@ -0,0 +1,30 @@
diff --git a/src/index.ts b/src/index.ts
index 9eb2a16..cdd7b45 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -605,9 +605,9 @@ async function runJudge(
emit("spawning", `Spawning read-only judge for: ${goal.subject}`);
const inv = getPiInvocation(args);
- // FIXME(side-effect): pi -p --no-session clones the repo into the PARENT of cwd (so alongside
- // the working dir), leaving a stale directory. The judge should run in a temp dir or inside the
- // existing repo checkout so it doesn't pollute the user's workspace.
+ // The judge runs in-place against this checkout (cwd is passed to spawn and the read-only tools
+ // read from it); pi --no-session does not clone into the parent. Proven and re-checked by
+ // scripts/check-judge-footprint.sh, which reproduces this invocation and asserts no parent clone.
const judge = await new Promise<{ output: string; error?: string; aborted?: boolean }>((resolve) => {
let settled = false;
let stdoutBuffer = "";
diff --git a/src/prompts.ts b/src/prompts.ts
index 03faea8..3b8d270 100644
--- a/src/prompts.ts
+++ b/src/prompts.ts
@@ -117,8 +117,6 @@ export function planInjection(p: {
counts: { done: number; open: number };
}): string {
if (!p.activeGoal) {
- // FIXME(heading): user wants the heading to show ".pi/goals.md: <title>" so the filename is explicit
- // even in the injection. Currently says "Goals (goals.md):" which is close but not the same.
return `.pi/goals.md: ${p.title}\nNo active goal. ${p.counts.open} open, ${p.counts.done} done. Pick the next goal (set its checkbox to [/]) or run /goals.`;
}
const subtasks = p.activeGoal.openSubtasks.length
+960 -290
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import { randomUUID } from "node:crypto";
export interface BackgroundState { quiet: boolean; description: string }
/** Public process-local protocols only. Missing owners remain explicitly unknown. */
export async function backgroundState(pi: any): Promise<BackgroundState> {
let processes: unknown;
pi.events.emit("processes:request:list", { reply: (value: unknown) => { processes = value; } });
const rows = Array.isArray(processes) ? processes : null;
const processKnown = rows?.every(p => p && ["running", "terminating", "terminate_timeout", "exited", "killed"].includes(p.status));
const activeProcesses = processKnown ? rows!.filter(p => !["exited", "killed"].includes(p.status)).length : null;
let activeSubagents: number | null = null;
if (pi.getAllTools?.().some((tool: any) => tool.name === "subagent")) {
const requestId = randomUUID();
activeSubagents = await new Promise<number | null>(resolve => {
let unsubscribe: unknown;
const finish = (value: number | null) => { clearTimeout(timer); if (typeof unsubscribe === "function") unsubscribe(); resolve(value); };
const timer = setTimeout(() => finish(null), 2_000);
unsubscribe = pi.events.on(`subagents:rpc:v1:reply:${requestId}`, (reply: any) => {
if (reply?.requestId !== requestId) return;
const count = reply?.success && reply?.data?.fleet?.version === 1 ? reply.data.fleet.totalActive : undefined;
finish(Number.isSafeInteger(count) && count >= 0 ? count : null);
});
pi.events.emit("subagents:rpc:v1:request", { version: 1, requestId, method: "status", params: {}, source: { extension: "pi-supervise" } });
});
}
return {
quiet: activeProcesses === 0 && activeSubagents === 0,
description: `processes: ${activeProcesses ?? "unknown (provider unavailable)"}; subagents: ${activeSubagents ?? "unknown (provider unavailable)"}; unregistered detached work is not tracked`,
};
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
import { createHash, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
/** Process-local integration; Intercom carries the corresponding peer messages. */
export const PLAN_API_EVENT = "pi-supervise:plan:v1";
export interface PlanBinding {
id: string;
planPath: string;
workerSession: string;
workerPane: string;
supervisorPane?: string;
supervisorSession?: string;
active?: boolean;
stopped?: boolean;
everyTurns: number;
intervalMs: number;
compactTokens: number;
}
export interface GoalReview {
requestId: string;
bindingId: string;
goal: string;
planHash: string;
}
export interface GoalDecision extends GoalReview {
decision: "approve" | "needs_work" | "needs_user";
reason: string;
}
export type PlanWire =
| { t: "plan_hello" | "plan_hello_ack"; to: string; bindingId: string; role: "worker" | "supervisor"; sessionFile: string }
| ({ t: "goal_review"; to: string } & GoalReview)
| ({ t: "goal_decision"; to: string } & GoalDecision)
| { t: "goal_cancel"; to: string; requestId: string; bindingId: string }
| { t: "plan_update"; to: string; bindingId: string }
| { t: "plan_activate" | "plan_stop"; to: string; bindingId: string };
export interface PlanApiRequest {
version: 1;
method: "prepare" | "bootstrap" | "attached" | "activate" | "status" | "review" | "update" | "stop";
binding?: PlanBinding;
bindingId?: string;
workerId?: string;
goal?: string;
planHash?: string;
signal?: AbortSignal;
handled?: boolean;
resolve(value: unknown): void;
reject(error: Error): void;
}
export function validBinding(value: unknown): value is PlanBinding {
if (!value || typeof value !== "object") return false;
const b = value as PlanBinding;
return [b.id, b.planPath, b.workerSession, b.workerPane].every(v => typeof v === "string" && v.length > 0)
&& [b.everyTurns, b.intervalMs, b.compactTokens].every(v => Number.isSafeInteger(v) && v > 0);
}
export function planText(binding: PlanBinding): string {
return readFileSync(binding.planPath, "utf8");
}
export function planHash(text: string): string {
return createHash("sha256").update(text).digest("hex");
}
export function validPlanWire(value: any): value is PlanWire {
if (!value || typeof value.to !== "string" || typeof value.bindingId !== "string") return false;
if (value.t === "plan_hello" || value.t === "plan_hello_ack") return ["worker", "supervisor"].includes(value.role) && typeof value.sessionFile === "string";
if (value.t === "plan_update" || value.t === "plan_activate" || value.t === "plan_stop") return true;
if (typeof value.requestId !== "string") return false;
if (value.t === "goal_cancel") return true;
if (typeof value.goal !== "string" || typeof value.planHash !== "string") return false;
if (value.t === "goal_review") return true;
return value.t === "goal_decision" && ["approve", "needs_work", "needs_user"].includes(value.decision) && typeof value.reason === "string";
}
/** A wait owns its cancellation and deadline; a timeout never means approval. */
export function pendingReply<T>(signal: AbortSignal | undefined, cancel: () => void, timeoutMs = 600_000) {
let finish!: (value?: T, error?: Error) => void;
const promise = new Promise<T>((resolve, reject) => {
let settled = false;
const abort = () => finish(undefined, new Error("Supervisor request cancelled"));
const timer = setTimeout(() => finish(undefined, new Error("Supervisor request timed out; retry or turn the steward off")), timeoutMs);
finish = (value, error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
if (error) { cancel(); reject(error); } else resolve(value as T);
};
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) queueMicrotask(abort);
});
return { requestId: randomUUID(), promise, finish };
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Every word the supervisor session reads, in the order it reads them, so this file is the run:
*
* 1. loadSupervisorPrompt, the policy, read once at /supervise
* 2. BRIEF, sent once at pairing, carrying that policy
* 3. TOOL_*, the three verdicts, in context at every model call because tools always are
* 4. REVIEW_NUDGE, sent with every view, and short because 1 to 3 already said the rest
* 5. NO_GOAL and DONE_BLOCKED, refusals, read only when a tool is refused
* 6. DEFAULT_SUPERVISOR_PROMPT, the policy used when no SUPERVISOR.md exists
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
/**
* Same precedence as @monotykamary/pi-supervisor, so an existing SUPERVISOR.md keeps working:
* <cwd>/.pi/SUPERVISOR.md, then <agent dir>/SUPERVISOR.md, then the default below.
*
* getAgentDir is pi's own, so a profile that moves the agent dir moves this with it. That matters:
* the only SUPERVISOR.md on this machine lives in a profile, not in ~/.pi/agent.
*/
export function loadSupervisorPrompt(cwd: string): { prompt: string; source: string } {
for (const path of [join(cwd, ".pi", "SUPERVISOR.md"), join(getAgentDir(), "SUPERVISOR.md")]) {
if (existsSync(path)) return { prompt: readFileSync(path, "utf-8").trim(), source: path };
}
return { prompt: DEFAULT_SUPERVISOR_PROMPT, source: "built-in" };
}
/**
* Put in the supervisor's context when pairing, so it knows its job before the first view arrives.
*
* The last line has been wrong three times. "Reply with exactly: watching" taught a text answer,
* and deepseek-v4-flash then answered two real views with the plain word "wait" and no tool call
* (session 019ff4eb-c66c, 2026-08-12). Ordering a let_it_run call taught the opposite: with no view
* to read, one supervisor answered its own brief 98 times in a row (019ffa5f, 2026-08-13). Asking
* for no answer at all still got two calls, because the brief arrived as a user message and a user
* message is a turn. It now arrives without one, so the view is the first thing there is to answer.
*/
export const BRIEF = (policy: string, goal: string, worker: string) =>
`${policy}
You are now supervising the pi session "${worker}".
The goal is between the tags below, exactly as the human typed it. Nothing outside the tags is
part of the goal. A multi-line goal appears as a one-line locator in ordinary views. Its full text
returns after a goal change, reload, compaction, and before every fifth review.
<goal>
${goal || "not given, so infer it from the first view you receive and call set_goal"}
</goal>
Your verdict is a tool call, not text: let_it_run, steer or done. If the policy above tells you to reply
with JSON, ignore that part: it belongs to a different supervisor and nothing parses it here.
You see the worker twice: when it stops, and on a check in while it is still working. Each view
carries only what is new since your last look, so read it against what you already know rather
than expecting the whole session again.
The view names the worker's model and how full its context is. A small or fast model needs one
small step per instruction. A worker near the top of its context is about to compact, so tell it
to write down what matters before it loses the detail.
There is no round limit and no budget. Supervision runs until the human stops it. Ending early is
the failure this exists to prevent, so never stop because it feels like enough.
The human typing in the worker session is not a handover, and it is not a reason to stand back.
They say a word and go to bed; the worker is then stopped with nobody driving it, which is the
state you exist for. They will stop you themselves when they want you stopped. Judge the worker
against the goal and nothing else.
Every view says how long the worker has gone with no new turn. A worker that has produced nothing
for a long time is stuck, or waiting for you, or in one command that will not return. Say which
one you think it is, and use the number rather than guessing from the turns.
The worker sends its first view as it pairs. It is below this message, and it is what you
answer.`;
/**
* Sent on a resume or a /reload that finds the pairing still alive.
*
* Short on purpose: the policy is already in the transcript above it. Only the answer shape and the
* goal repeat, because those are what a supervisor drops first, and because a /reload is how a
* changed prompt reaches a running session. Without this, fixing the wording of the brief needs
* /supervise stop and a fresh pairing, which throws away the supervisor's memory of its own steers.
*/
export const REANCHOR = (goal: string, rounds: number) =>
`Supervising again, after a reload or a restart.
<goal>
${goal || "not set"}
</goal>
${rounds} instructions so far.
A view of the worker follows. Answer it with one tool call: steer, done or let_it_run. The word on its
own does nothing; only the call reaches the worker.`;
/** Sent when the human runs /supervise goal, so the supervisor does not judge against the old one. */
export const GOAL_CHANGED = (goal: string) =>
`The human changed the goal. From now on judge the worker against what is between the tags,
and against nothing else:
<goal>
${goal}
</goal>
A fresh view follows. Answer it with one tool call: steer, done or let_it_run.`;
/**
* The three verdicts. These live in the tool descriptions, which the API sends at every model call,
* so they are the only instructions here that a supervisor compaction cannot lose.
*/
export const TOOL_LET_IT_RUN =
"Use when the current worker view gives quoted evidence that no instruction is needed."
+ " The call sends no message to the worker. Call it once, then end the current supervisor response."
// Repeated here because a tool description survives a compaction and the brief does not. The
// live failure was a let_it_run reasoned "human is actively directing", two hours before dawn.
+ " A human message does not end supervision; only an explicit stop command ends supervision.";
/**
* How a look ends, and it must appear in every verdict's result.
*
* A tool result reads as a prompt to act again. A turn ends only when the assistant writes text and
* calls no tool, so a result that does not name that exit leaves another tool call as the only move.
* The let_it_run result used to say "Say nothing more until the next view arrives", which forbids the
* exit outright: session 019ffa73 answered with a second let_it_run on all sixteen looks before 11:05Z
* and aborted every one. The steer result said nothing about ending, and cost a spare let_it_run on
* 22 of 22 steers in the fifteen hours after.
*/
export const END_TURN =
`End the current supervisor response now: write one short line or no text, then make no further tool call.`;
export const LET_IT_RUN_ACK = (reason: string, workerStopped = false) =>
`No supervisor instruction was sent for the current worker view. Supervisor-provided reason, not independently verified: ${reason}\n\nThe supervisor has completed its verdict for the current worker view. ${END_TURN}
${workerStopped ? STOPPED_WARNING : "A later worker view starts the next supervisor review."}`;
/**
* Added to the let_it_run result when the view said the worker had stopped.
*
* Session 019ffa73, 2026-08-14: the worker stopped, the supervisor answered let_it_run "waiting for
* the worker to re-queue", and both sat still for two and a half hours. A stopped worker does not
* resume on its own, so letting it run leaves it stopped. The timer now looks again either way, and
* this says why that look will show the same thing.
*/
export const STOPPED_WARNING =
`The current worker view reports that worker execution stopped. A stopped worker does not resume
without a new user or supervisor message. If the goal remains unmet, send a concrete continuation
instruction. A human message does not end supervision. A later worker view will report the worker state.`;
/** The answer to a second let_it_run in one look. Costs a round trip and no error line. */
export const LET_IT_RUN_AGAIN =
`The supervisor already recorded a verdict for the current worker view. This second let_it_run call
sent no instruction. ${END_TURN}`;
/** The answer after a supervisor directive is sent. A repeat warning is appended after it. */
export const STEER_ACK = (round: number, workerId: string) =>
`Supervisor instruction ${round} was sent to worker session ${workerId}. Worker receipt and execution
are not confirmed. The supervisor has completed its verdict for the current worker view. ${END_TURN}`;
export const TOOL_STEER =
"Send one concrete next action to the worker. The extension sends the message to the paired worker session; worker receipt and execution require a later worker view.";
export const TOOL_DONE =
"Declare the goal met and stop supervising. Only call this with quoted evidence from the view.";
/**
* Sent with every view, so it is deliberately short.
*
* What used to be here and is now sent once: the verdict rules (BRIEF, and the tool descriptions,
* which survive a compaction) and the instructions already sent (the supervisor's own steer calls
* are in its context; the steer tool warns about a repeat when it happens). A multi-line goal
* stays as a one-line locator inside the view.
*
* A check in is not a decision point. Interrupting a working agent is expensive and usually wrong,
* so the two triggers ask for different things.
*/
/**
* The two openers, and the test the context pruner uses to find a view it can drop.
*
* They are constants because two things read them: the nudge that writes a view, and the pruner
* that later collapses it. Matching the prose in two places would let them drift silently, and a
* pruner that stops recognising views just quietly stops working.
*/
export const VIEW_STOPPED = "The worker stopped.";
export const VIEW_CHECKIN = "Checking in on the worker, which is still going.";
export const isViewText = (text: string) => text.startsWith(VIEW_STOPPED) || text.startsWith(VIEW_CHECKIN);
/** What an old view is replaced with. Short, and it says where the content went. */
export const VIEW_PRUNED =
"[an earlier view of the worker, dropped once you had judged it. Your verdict on it follows.]";
export const REVIEW_NUDGE = (view: string, rounds: number, stopped: boolean) =>
stopped
? `${VIEW_STOPPED}
${view}
${rounds} instructions so far. The status line says how long it has had no new turn. It will not start
again by itself, and the human being present does not count as somebody driving it. Answer with one
tool call: steer, done or let_it_run. The word on its own does nothing; only the call reaches the
worker.`
: `${VIEW_CHECKIN}
${view}
Call let_it_run unless the view gives concrete evidence that the worker needs an instruction.`;
/** Refusal shown when done is called while the worker still has work running. */
export const DONE_BLOCKED = (what: string) =>
`Cannot finish: the worker still has work running (${what}). Wait for the next view.`;
/** Refusal shown when the supervisor tries to steer with no goal set. */
export const NO_GOAL = `No goal is set, so you must not steer or finish. Inventing a task is worse
than doing nothing. Either call set_goal with the goal you infer from the worker's view, which
tells the human what you chose, or reply in plain text asking them for it. Your reply reaches
their phone.`;
/**
* Default supervisor prompt. A project SUPERVISOR.md overrides it, same as @monotykamary/pi-supervisor.
* Unlike that extension there is no JSON verdict to parse, because the verdict is a tool call.
*/
export const DEFAULT_SUPERVISOR_PROMPT = `You supervise a coding agent from outside its session.
Your job is to make it reach the goal without the human stepping in.
Judge from the view only. You cannot see the worker's files unless you read them yourself.
Call steer when the work is incomplete, when the worker asked a question you can answer with a
sensible default, or when it claims success without evidence. One concrete next action per steer.
Never repeat a steer that had no effect; change the approach instead.
The view line "child pi processes still running" means the worker delegated to a subagent that is
still working. It stopped, the subagent did not. Do not call done, it will be refused. Steer the
worker to wait for that subagent and report what it produced.
The view line "no new file or commit for N reviews in a row" means your last N instructions
moved nothing the worker's session can show. Two or more is your signal to change approach, ask the
human, or check whether the goal is already met. Sometimes it is honest work on one file, so read
the recent turns before you decide.
Call done only when all of these hold:
1. the worker named the artifact file it produced, with a path
2. the worker quoted text from that file, rather than summarising it
3. nothing in the view contradicts the claim
A confident summary is not evidence. When in doubt, steer.
When the worker does machine learning or data research, a wrong result looks exactly like a right
one. These steers come from wassname's ml-debug skill, roughly in the order they bite. Each one is
something you can see in the view.
- It concluded without reading its data. Steer it to paste the lines it read into the chat, a raw
sample and the metric line, not a summary of them. Quoting is the point, twice over: you and the
human can then check the same text, and an agent that has to quote has to look (Karpathy inspects
the data before touching the model; Nanda: read your data, often it is quite bad). A conclusion
with no quoted output, a ranking with no per-item evidence, or "the method failed" with no sample
of what the output looked like, all mean it has not looked.
- It reports a surprising win. Most true results are boring, so an exciting one is more likely to
be false (Neel Nanda). Steer it to rule out a bug, leakage or a broken evaluation first.
- It reports a failure and moves on, or calls the failure a property of the method. Assume a bug:
bugs are far more common, and far cheaper to find, than a real negative result (Andy Jones).
Steer it to write two or three diagnoses, one of them a bug in its own code, put a rough
probability on each, and run the cheapest test that tells them apart. Broken research code fails
silently and still runs, so "it ran" is not evidence that it worked.
- It is about to start another long run without saying what each outcome would mean. Steer it to
write that prediction first (Rahtz: think more, experiment less). On a shared GPU that is the
cheapest hour you can buy.
- It compares two methods from one run each. Seed variance alone splits identical configurations
into different distributions (Henderson), so steer it to say what varies before it ranks
anything.
- It changed two things in one run and credits one of them. Changing anything changes everything
(Sculley et al., CACE). Steer it to say what it can actually attribute, or to rerun with one
change.
- It saw a number it cannot explain and carried on. An anomaly it did not go looking for is the
cheapest bug it will ever find, so steer it to chase that before anything else.
Three more ways work gets faked, from @monotykamary/pi-supervisor's cheating list. Steer, and ask
for the output that would settle it.
- the worker edits a test to weaken an assertion, or skips a failing one, and calls that progress
- it reports a number without the command output it came from, or edits the measurement instead of
the thing being measured
- it runs a smaller dataset or part of the suite, then reports as if it ran the whole thing
Do not answer questions that need real human knowledge: passwords, credentials, spending money,
or a choice between two designs the human cares about. For those, reply in plain text saying what
you need. Your reply reaches the human's phone.`;
+118
View File
@@ -0,0 +1,118 @@
/**
* What the two sessions say to each other over the pi-intercom extension channel.
*
* The channel never enters a transcript and never starts a turn, so each side triggers its own
* turn locally with pi.sendUserMessage after it receives one of these.
*/
import { type PlanBinding, type PlanWire, validBinding, validPlanWire } from "./plan-api.js";
export const NAMESPACE = "wassname/pi-intercom-supervisor/v1";
// No round cap, no budget, on purpose. Supervision runs until the human stops it with
// /supervise stop, because premature stopping is the failure this whole thing exists to prevent
// (wassname's SUPERVISOR.md, citing arXiv:2410.07095: 8.7% vs 0.8% on MLE-bench).
export type Wire = PlanWire
/** Roll call, broadcast, so "to" is the wildcard rather than a session. Only /supervise sends it. */
| { t: "who"; to: "*" }
/** The answer to a roll call: I load this extension, I am free, and I am not a child run. */
| { t: "here"; to: string }
| { t: "pair"; to: string; goal: string; plan?: PlanBinding }
| { t: "paired"; to: string; plan?: PlanBinding }
| { t: "goal"; to: string; goal: string }
/** stopped: the worker settled, so this is a decision point. false: a check in mid-turn. */
| { t: "view"; to: string; view: string; stopped: boolean }
/** Supervisor asks for a view now. Its own turn cannot make one: the worker publishes them. */
| { t: "look"; to: string }
| { t: "directive"; to: string; text: string }
| { t: "done"; to: string; reason: string }
| { t: "unpair"; to: string };
/** Validates the field each kind carries, so a malformed peer cannot inject "[supervisor] undefined". */
export function isWire(payload: unknown): payload is Wire {
if (typeof payload !== "object" || payload === null) return false;
if (validPlanWire(payload)) return true;
const { t, to, goal, view, stopped, text, reason, plan } = payload as Record<string, unknown>;
if ((t === "pair" || t === "paired") && plan !== undefined && !validBinding(plan)) return false;
if (typeof to !== "string") return false;
if (t === "pair" || t === "goal") return typeof goal === "string";
if (t === "view") return typeof view === "string" && typeof stopped === "boolean";
if (t === "directive") return typeof text === "string" && text.trim().length > 0;
if (t === "done") return typeof reason === "string";
return t === "unpair" || t === "paired" || t === "look" || t === "who" || t === "here";
}
/**
* Words two instructions share, over the words either uses. Stopwords and short words dropped.
*
* Six remembered instructions do not stop repetition, because the same order rephrased reads as
* new. This catches the rephrasing that shares vocabulary; it cannot catch a true paraphrase.
*/
export function overlap(a: string, b: string): number {
const words = (s: string) =>
new Set(
s
.toLowerCase()
.split(/[^a-z0-9_./-]+/)
.filter((w) => w.length > 3 && !STOPWORDS.has(w)),
);
const [x, y] = [words(a), words(b)];
if (!x.size || !y.size) return 0;
const shared = [...x].filter((w) => y.has(w)).length;
return shared / (x.size + y.size - shared);
}
const STOPWORDS = new Set([
"then", "with", "that", "this", "from", "into", "your", "each", "have", "then", "should", "please",
"make", "sure", "also", "them", "they", "what", "when", "here", "there", "which", "will", "would",
]);
/**
* Two instructions sharing this much vocabulary get flagged back to the supervisor.
*
* Measured on rewordings of one instruction: about 0.44. On two different instructions: under 0.2.
* A true paraphrase that shares no words scores 0 and slips through, so this is a floor on
* repetition, not a bound.
*/
export const OVERLAP_WARN = 0.4;
export interface SuperviseState {
role: "none" | "worker" | "supervisor";
/** Intercom session ID of the other side. The broker stamps this, so it cannot be forged. */
pairedId: string;
goal: string;
steerRounds: number;
/** Recent steer texts, so the supervisor can see repetition after its own context is compacted. */
recentSteers: string[];
/** Optional pi-goals integration. Standalone supervision keeps its original policy. */
plan?: PlanBinding;
/** Supervisor bootstrap completed and the worker acknowledged this plan pairing. */
planInitialized?: boolean;
}
export const EMPTY_STATE: SuperviseState = {
role: "none",
pairedId: "",
goal: "",
steerRounds: 0,
recentSteers: [],
};
/** How many past steers to keep and show back. Enough to spot a loop, small enough to stay cheap. */
export const STEER_MEMORY = 6;
/** Session entry type used to persist state, so a compaction or reload cannot reset the count. */
export const STATE_ENTRY = "supervise-state";
/** Rebuild state from session entries. The last one written wins. */
export function restoreState(entries: Array<{ type: string; customType?: string; data?: unknown }>): SuperviseState {
let state = EMPTY_STATE;
for (const entry of entries) {
if (entry.type === "custom" && entry.customType === STATE_ENTRY && entry.data) {
// Merge over the defaults so a record written before a field existed still loads.
state = { ...EMPTY_STATE, ...(entry.data as Partial<SuperviseState>) };
}
}
return state;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Child pi processes, so a settled worker with a subagent still running is not called finished.
*
* The in-session check only sees tool calls that never got a result. A subagent spawned as its own
* process leaves no such trace, so the worker settles and the view looks quiet.
*
* This is a snapshot, not a wait. The original polls here for up to two minutes, and pi awaits the
* settle handler (agent-session.js:330), so that holds the worker's own settle for the whole poll.
* The loop already does the waiting: the supervisor sees the process listed, done is refused, and
* it steers instead. That leaves the waiting in the transcript where you can read it.
*
* Ported from @monotykamary/pi-supervisor (MIT), src/subagent-detector.ts. Extension agnostic: it
* does not matter who spawned them. Nothing is caught here, so a broken ps is loud.
*/
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
interface PiProcess {
pid: number;
ppid: number;
}
async function piProcesses(): Promise<PiProcess[]> {
if (process.platform !== "darwin" && process.platform !== "linux") return [];
const { stdout } = await execAsync(`ps -eo ppid,pid,comm | grep -E "\\bpi\\b" || true`);
return stdout
.trim()
.split("\n")
.map((line) => line.trim().split(/\s+/))
.filter((parts) => parts.length >= 3 && parts[2] === "pi")
.map((parts) => ({ ppid: Number(parts[0]), pid: Number(parts[1]) }));
}
export async function childPiProcesses(): Promise<number[]> {
return (await piProcesses()).filter((p) => p.ppid === process.pid).map((p) => p.pid);
}
+15
View File
@@ -0,0 +1,15 @@
/** Typed boundary for pi-vcc 0.5.0's source-only API. Its own source uses older Pi message
* unions and Intl.Segmenter types; do not typecheck that dependency as pi-goals source. */
declare module "@sting8k/pi-vcc/src/core/summarize.ts" {
export function compile(input: { messages: unknown[] }): string;
}
declare module "@sting8k/pi-vcc/src/core/normalize.ts" {
export function normalize(messages: unknown[]): VccBlock[];
interface VccBlock { type: string; [key: string]: unknown }
}
declare module "@sting8k/pi-vcc/src/extract/files.ts" {
export function extractFiles(blocks: unknown[]): { modified: Set<string>; created: Set<string> };
}
declare module "@sting8k/pi-vcc/src/extract/commits.ts" {
export function extractCommits(blocks: unknown[]): Array<{ hash?: string; message: string }>;
}
+303
View File
@@ -0,0 +1,303 @@
/**
* The worker view: what the supervisor judges from.
*
* Built from ctx.sessionManager.getBranch(), which already follows the live leaf path, so a fork
* or a rewind cannot leave dead entries in here. There is no disk read and no cross-branch merge.
*
* The body is pi-vcc's compiler, the same algorithmic compactor the worker can run, called here on
* the live messages with the worker's last compaction summary as previousSummary. So the view is
* "compaction summary, merged with everything since". We add what a compactor has no reason to
* track: unanswered tool calls and whether anything changed since the last review.
*/
import { normalize } from "@sting8k/pi-vcc/src/core/normalize.ts";
import { compile } from "@sting8k/pi-vcc/src/core/summarize.ts";
import { extractCommits } from "@sting8k/pi-vcc/src/extract/commits.ts";
import { extractFiles } from "@sting8k/pi-vcc/src/extract/files.ts";
const SUPERVISOR_PREFIX = "[supervisor] ";
/** Entry shapes we read. Only the fields this file touches, taken from real session jsonl. */
export interface Block {
type: string;
id?: string;
text?: string;
/** Set on `type: "thinking"` blocks. Empty when the provider redacted the reasoning. */
thinking?: string;
name?: string;
arguments?: Record<string, unknown>;
}
export interface AgentMsg {
role: "user" | "assistant" | "toolResult" | string;
content?: string | Block[];
toolName?: string;
toolCallId?: string;
isError?: boolean;
}
export interface Entry {
type: string;
message?: AgentMsg;
/** ISO, written on every entry by the session manager (core/session-manager.d.ts:21). */
timestamp?: string;
/** Written by whichever compactor the worker runs. VCC's summary lands here too. */
summary?: string;
tokensBefore?: number;
}
/**
* Milliseconds since the worker last put a message in its session.
*
* The clock a stuck worker shows on, and the only one that reads the same for both ways of being
* stuck: sitting at the prompt, and inside one command that never returns. A supervisor directive
* does not reset it. Time since the last look measures the supervisor instead, and understates a
* worker that stopped hours before.
*/
export function sinceLastTurn(entries: Entry[], now = Date.now()): number {
const last = [...entries].reverse().find((e) =>
e.type === "message" && e.timestamp && !(e.message?.role === "user" && textOf(e.message).startsWith(SUPERVISOR_PREFIX))
);
return last ? now - Date.parse(last.timestamp!) : 0;
}
/** A duration a supervisor can read at a glance: 2h27m, 45m, 30s. */
export function age(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.round(s / 60);
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
}
/** Extension channel payloads cap at 16 KiB, so the view must stay under it. */
export const MAX_VIEW_BYTES = 15000;
const GOAL_PREVIEW_CHARS = 160;
/** A long goal remains identifiable in every view without replaying its whole rubric. */
export function goalPreview(goal: string): string {
if (!goal.includes("\n")) return goal || "not set";
const firstLine = goal.split("\n").find((line) => line.trim())?.trim() || "not set";
return `${firstLine.slice(0, GOAL_PREVIEW_CHARS)} [...]`;
}
function blocks(msg: AgentMsg): Block[] {
return Array.isArray(msg.content) ? msg.content : [];
}
function textOf(msg: AgentMsg): string {
if (typeof msg.content === "string") return msg.content;
return blocks(msg)
.filter((b) => b.type === "text")
.map((b) => b.text ?? "")
.join("\n");
}
/**
* Tool calls with no matching result on this branch. A settled worker with an unanswered
* subagent call still has delegated work running, and "done" then means nothing.
*/
export function outstandingWork(entries: Entry[]): string[] {
const called = new Map<string, string>();
const answered = new Set<string>();
for (const entry of entries) {
const msg = entry.message;
if (!msg) continue;
for (const b of blocks(msg)) {
if (b.type === "toolCall" && b.id) called.set(b.id, b.name ?? "?");
}
if (msg.role === "toolResult" && msg.toolCallId) answered.add(msg.toolCallId);
}
return [...called].filter(([id]) => !answered.has(id)).map(([, name]) => name);
}
/** The summary written by whichever compactor the worker runs. Empty when it has not compacted. */
export function compactionSummary(entries: Entry[]): string {
let summary = "";
for (const entry of entries) {
if (entry.type === "compaction" && entry.summary) summary = entry.summary;
}
return summary;
}
/**
* What the worker has changed: the files it wrote and the commits it made.
*
* Two reviews with the same key mean the last instruction produced neither. That is evidence for
* the supervisor, not a rule: re-editing one file while a test still fails looks the same, and is
* sometimes the right thing to be doing.
*
* Read from pi-vcc's extractor rather than from its rendered section, which caps the list at ten
* paths and would freeze this key on any run long enough to matter.
*/
export function progressKey(entries: Entry[]): string {
const blocks = normalize(messagesSince(entries) as any);
const files = extractFiles(blocks);
const commits = extractCommits(blocks).map((c) => c.hash ?? c.message);
return [[...files.modified].sort(), [...files.created].sort(), commits].map((p) => p.join(",")).join("||");
}
/**
* Messages after the worker's last compaction.
*
* getBranch keeps the entries a compaction replaced, so handing every message to compile alongside
* the summary would send the supervisor both copies and spend the byte budget twice. Supervisor
* directives already live in the supervisor transcript, so exclude their worker-session echo.
*/
function messagesSince(entries: Entry[]): AgentMsg[] {
const lastCompaction = entries.map((e) => e.type).lastIndexOf("compaction");
return entries
.slice(lastCompaction + 1)
.filter((e) => e.type === "message" && e.message)
.map((e) => e.message!)
.filter((message) => message.role !== "user" || !textOf(message).startsWith(SUPERVISOR_PREFIX));
}
/** What the caller records after a view goes out, and hands back as `since` on the next one. */
export function turnsSince(entries: Entry[]): number {
return messagesSince(entries).length;
}
/** Reasoning blocks kept, newest first, and the tail kept from each. A block ends on a decision. */
const THINKING_BLOCKS = 2;
const THINKING_CHARS = 400;
/**
* Keep the last few reasoning blocks by rewriting them as text, and let pi-vcc drop the rest.
*
* normalize() keeps only text and toolCall blocks from an assistant message, so reasoning never
* reaches the supervisor although you see it on screen. Rewriting in place leaves each thought
* next to the tool call it produced, which is the order you read a session in. A separate section
* at the top of the view would divorce the thought from what it did.
*
* Only the last two, because one worker session here held 161 reasoning blocks and all of them
* would make the view a second transcript. Everything older needs no work: pi-vcc drops it.
*/
function keepRecentThinking(msgs: AgentMsg[]): AgentMsg[] {
const keep = new Set<string>();
outer: for (let i = msgs.length - 1; i >= 0; i--) {
const content = msgs[i].content;
if (!Array.isArray(content)) continue;
for (let j = content.length - 1; j >= 0; j--) {
if (content[j].type !== "thinking" || !content[j].thinking) continue;
keep.add(`${i}:${j}`);
if (keep.size === THINKING_BLOCKS) break outer;
}
}
if (!keep.size) return msgs;
return msgs.map((msg, i) =>
Array.isArray(msg.content)
? {
...msg,
content: msg.content.map((b, j) =>
keep.has(`${i}:${j}`) ? { type: "text", text: `(thinking) ${b.thinking!.slice(-THINKING_CHARS)}` } : b
),
}
: msg
);
}
const VCC_SEPARATOR = "\n\n---\n\n";
/** pi-vcc's section names, in the order formatSummary writes them (its format.ts). */
const VCC_HEADERS = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"];
/**
* pi-vcc's compiled summary, split into its header sections and its brief transcript.
*
* compile() writes `sections + "\n\n---\n\n" + brief`, and drops either part when it is empty, so
* all four combinations are possible. Get this wrong and the header block lands in the transcript,
* where the byte cut eats the newest turns instead of the oldest.
*/
function vccSections(fresh: AgentMsg[]): { headers: string; brief: string } {
// No previousSummary: compile's merge reads the fresh brief with briefOf, which finds nothing
// when the fresh messages produced no header sections, and the newest turns vanish. The
// compaction summary goes into the view above this instead, which loses nothing.
//
// compile appends a note telling the reader to call vcc_recall, which the supervisor does not
// have. Matched on the tool name because wrapLongLines rewraps the note before we see it.
const compiled = compile({ messages: keepRecentThinking(fresh) as any })
.replace(/\n*-*\n*Use `vcc_recall`[\s\S]*$/, "")
.trim();
if (!VCC_HEADERS.some((h) => compiled.startsWith(`[${h}]`))) return { headers: "", brief: compiled };
const at = compiled.indexOf(VCC_SEPARATOR);
if (at < 0) return { headers: compiled, brief: "" };
return { headers: compiled.slice(0, at), brief: compiled.slice(at + VCC_SEPARATOR.length) };
}
export interface ViewInput {
goal: string;
status: string;
entries: Entry[];
/**
* Turns the supervisor has already been sent, from turnsSince() after the last view.
*
* The supervisor is a real session and keeps every view it has read, so re-sending the whole
* transcript every time is a second copy of what it already has. This is a person glancing at a
* screen: they read the new lines, not the scrollback. Past the compaction or a rewind this no
* longer lines up, and the view says so and sends everything after the compaction.
*/
since?: number;
/** Reviews in a row where progressKey did not change. 0 means something changed this time. */
stale?: number;
/** Child pi processes still running. A settled worker with one of these is still spending. */
subagents?: number[];
/**
* The worker's model and how full its context is, from the intercom presence record.
*
* A supervisor steering a small fast model should give smaller steps than one steering a frontier
* model, and a worker near the top of its context is about to compact and lose detail.
*/
model?: string;
}
/** Render the view, and cut it to MAX_VIEW_BYTES so the broker cannot reject it. */
export function buildView({ goal, status, entries, since = 0, stale = 0, subagents = [], model = "" }: ViewInput): string {
const messages = entries.filter((e) => e.type === "message" && e.message);
const pending = outstandingWork(messages);
const workerMessages = messagesSince(entries);
const total = workerMessages.length;
// A compaction or a rewind leaves the mark past the end. Restart from the compaction and say so,
// otherwise the supervisor silently reads a slice of the wrong history.
const restarted = since > total;
const from = restarted ? 0 : since;
const fresh = workerMessages.slice(from);
const { headers, brief } = vccSections(fresh);
const earlier = compactionSummary(entries);
const head = [
// Short goals are the criterion on every review. A multi-line research rubric is reinserted
// into the supervisor context at its own cadence, so this view carries only its locator.
`<goal>`,
goalPreview(goal),
`</goal>`,
``,
`# Worker`,
...(model ? [`model: ${model}`] : []),
`status: ${status}`,
`turns: ${workerMessages.length}`,
`tool calls with no result: ${pending.length ? pending.join(", ") : "none"}`,
`child pi processes still running: ${subagents.length ? subagents.join(", ") : "none"}`,
...(stale > 0 ? [`no new file or commit for ${stale} reviews in a row`] : []),
``,
// Sent when this view starts at the compaction boundary, which is the first view and every
// view after the worker compacts. In between the supervisor already has it.
...(from === 0 && earlier
? [
restarted ? `# The worker compacted, so this view restarts. Everything before it:` : `# Earlier work, from the worker's own compaction summary`,
earlier.slice(0, 6000),
``,
]
: []),
...(headers ? [`# Files, commits and context, from the new turns only`, headers, ``] : []),
from > 0 ? `# New turns since your last look (${total - from} of ${total})` : `# Turns so far`,
].join("\n");
// Oldest brief lines go first, because the newest turns are what the next instruction rests on.
let lines = brief.split("\n");
let view = `${head}\n${lines.join("\n")}\n`;
while (Buffer.byteLength(view, "utf-8") > MAX_VIEW_BYTES && lines.length > 1) {
lines = lines.slice(1);
view = `${head}\n[earlier turns cut to fit the channel]\n${lines.join("\n")}\n`;
}
if (Buffer.byteLength(view, "utf-8") <= MAX_VIEW_BYTES) return view;
// The head alone can overflow, on a long goal. The broker drops anything over
// 16 KiB and never tells the extension, so the supervisor would go blind. Cut, and say so.
return `${Buffer.from(view, "utf-8").subarray(0, MAX_VIEW_BYTES - 40).toString("utf-8")}\n[view cut here to fit the channel]\n`;
}
-225
View File
@@ -1,225 +0,0 @@
/**
* plan-file.ts — read plan.md, and the two writes CompleteGoal needs. That is all.
*
* Pure module, no pi deps, so it unit-tests without a runtime. The file is the canonical store and
* the agent edits it with its normal Edit tool (create goals, tick subtasks, append log), guided by
* the format in prompts.tsx and the reminder -- the form guides, it does not gate (spec D3). So this
* module does NOT render or create goals; the format's single source of truth is the planDrafting
* prompt. The only programmatic writers are setGoalStatus + appendLog, used by CompleteGoal to
* record an accepted sign-off; both touch one line so the git diff stays readable.
*
* Format (spec §4):
*
* # Plan: <objective>
*
* ## Goal: <subject>
* <!-- id: <slug> -->
* status: open | active | done | cancelled
* done_when: <falsifiable check; plus the symptom if NOT met>
* verify: <shell command, optional>
* failure_modes:
* - <pre-mortem item>
* - [ ] <subtask>
*
* ## Log
* - <verbatim append-only line>
*/
export type GoalStatus = "open" | "active" | "done" | "cancelled";
export interface Subtask {
text: string;
done: boolean;
}
export interface Goal {
id: string;
subject: string;
status: GoalStatus;
done_when: string;
verify?: string;
failure_modes: string[];
subtasks: Subtask[];
}
export interface PlanDoc {
objective: string;
goals: Goal[];
/** Verbatim ## Log lines, including the leading "- ". */
log: string[];
}
const GOAL_HEADER = /^##\s+Goal:\s*(.*)$/;
const ANY_HEADER = /^#{1,6}\s/;
const LOG_HEADER = /^##\s+Log\s*$/i;
const ID_COMMENT = /^<!--\s*id:\s*(.+?)\s*-->$/;
const CHECKBOX = /^- \[([ xX])\]\s+(.*)$/;
export function parse(text: string): PlanDoc {
const lines = text.split("\n");
let objective = "";
const goals: Goal[] = [];
const log: string[] = [];
let cur: Goal | null = null;
let inFailureModes = false;
let inLog = false;
const flush = () => {
if (cur) goals.push(cur);
cur = null;
inFailureModes = false;
};
for (const line of lines) {
const objMatch = /^#\s+Plan:\s*(.*)$/.exec(line);
if (objMatch) {
objective = objMatch[1].trim();
continue;
}
const goalMatch = GOAL_HEADER.exec(line);
if (goalMatch) {
flush();
inLog = false;
cur = { id: "", subject: goalMatch[1].trim(), status: "open", done_when: "", failure_modes: [], subtasks: [] };
continue;
}
if (LOG_HEADER.test(line)) {
flush();
inLog = true;
continue;
}
// Any other header ends the current goal / log section.
if (ANY_HEADER.test(line)) {
flush();
inLog = false;
continue;
}
if (inLog) {
if (/^\s*-\s+/.test(line)) log.push(line);
continue;
}
if (!cur) continue;
const idMatch = ID_COMMENT.exec(line.trim());
if (idMatch) {
cur.id = idMatch[1];
continue;
}
// A checkbox (column 0) is a subtask; checked first so it is never read as a failure mode.
const checkbox = CHECKBOX.exec(line);
if (checkbox) {
inFailureModes = false;
cur.subtasks.push({ done: checkbox[1].toLowerCase() === "x", text: checkbox[2].trim() });
continue;
}
const kv = /^(status|done_when|verify|failure_modes)\s*:\s*(.*)$/.exec(line);
if (kv) {
const [, key, value] = kv;
if (key === "status") cur.status = value.trim() as GoalStatus;
else if (key === "done_when") cur.done_when = value.trim();
else if (key === "verify") cur.verify = value.trim() || undefined;
else if (key === "failure_modes") inFailureModes = true;
continue;
}
// Indented "- " items under failure_modes: (a column-0 checkbox already returned above).
if (inFailureModes) {
const fm = /^\s*-\s+(.*)$/.exec(line);
if (fm) {
cur.failure_modes.push(fm[1].trim());
continue;
}
if (line.trim() !== "") inFailureModes = false;
}
}
flush();
return { objective, goals, log };
}
export function findGoal(doc: PlanDoc, id: string): Goal | undefined {
return doc.goals.find((g) => g.id === id);
}
export function counts(doc: PlanDoc): { done: number; open: number; active: number } {
const c = { done: 0, open: 0, active: 0 };
for (const g of doc.goals) {
if (g.status === "done") c.done++;
else if (g.status === "active") c.active++;
else if (g.status === "open") c.open++;
}
return c;
}
/** Flip a goal's `status:` line in place (the one write CompleteGoal needs). */
export function setGoalStatus(text: string, id: string, status: GoalStatus): string {
const lines = text.split("\n");
let i = lines.findIndex((l) => ID_COMMENT.test(l.trim()) && ID_COMMENT.exec(l.trim())?.[1] === id);
if (i === -1) throw new Error(`Goal #${id} not found`);
for (; i < lines.length; i++) {
if (i > 0 && ANY_HEADER.test(lines[i]) && !GOAL_HEADER.test(lines[i]) && !LOG_HEADER.test(lines[i])) break;
const kv = /^(status\s*:\s*)(.*)$/.exec(lines[i]);
if (kv) {
lines[i] = `${kv[1]}${status}`;
return lines.join("\n");
}
}
throw new Error(`Goal #${id} has no status: line`);
}
/**
* The outcome of a sign-off attempt, decided by CompleteGoal (which runs verify + the judge). Kept
* separate from the I/O so the record logic below is pure and testable.
*/
export type SignOff =
| { kind: "verify_failed"; exitCode: number; outputTail: string }
| { kind: "rejected"; missing: string }
| { kind: "accepted" };
/** Apply a sign-off outcome to plan.md text: accept flips status + logs; reject only logs. Pure. */
export function recordSignOff(
text: string,
goalId: string,
when: string,
outcome: SignOff,
): { content: string; message: string; isError: boolean } {
const goal = findGoal(parse(text), goalId);
if (!goal) return { content: text, message: `No goal #${goalId} in plan.md.`, isError: true };
if (outcome.kind === "verify_failed") {
const content = appendLog(text, `${when} reject #${goalId}: verify exit ${outcome.exitCode}`);
return { content, message: `Sign-off rejected: verify failed (exit ${outcome.exitCode}).\n${outcome.outputTail}`, isError: true };
}
if (outcome.kind === "rejected") {
const oneLine = outcome.missing.replace(/\s+/g, " ").trim().slice(0, 200);
const content = appendLog(text, `${when} reject #${goalId}: ${oneLine}`);
return { content, message: `Sign-off rejected. Missing:\n${outcome.missing}`, isError: true };
}
const flipped = setGoalStatus(text, goalId, "done");
const content = appendLog(flipped, `${when} signed off #${goalId}: ${goal.subject} (oracle accept)`);
return { content, message: `Signed off #${goalId}: ${goal.subject}. Marked done in plan.md.`, isError: false };
}
/** Append one verbatim line to ## Log (creating the section if absent). The other CompleteGoal write. */
export function appendLog(text: string, entry: string): string {
const lines = text.split("\n");
const line = `- ${entry}`;
const header = lines.findIndex((l) => LOG_HEADER.test(l));
if (header === -1) return `${text.replace(/\n+$/, "")}\n\n## Log\n${line}\n`;
let insertAt = header + 1;
for (let i = header + 1; i < lines.length; i++) {
if (ANY_HEADER.test(lines[i])) break;
if (/^\s*-\s+/.test(lines[i])) insertAt = i + 1;
}
lines.splice(insertAt, 0, line);
return lines.join("\n");
}
+260 -169
View File
@@ -1,204 +1,295 @@
/**
* pi-plan — all model-facing text, in flow order.
* pi-goals v2 — all model-facing text, in flow order.
*
* Philosophy: the form guides a process; it does not police one. The agent can
* edit plan.md freely. These prompts + the plan.md structure make the right path
* the easy path. The only step that is genuinely rigorous is the evidence judge
* (6), and even that is reached by guiding the agent to call CompleteGoal, not by
* trapping it. Bypasses stay visible in the git diff and the widget.
* Design: the plan file is for LLMs and the human, not for TypeScript. No parser and no schema;
* the skeleton below is a convention the drafting prompt teaches, the working agent maintains with
* its normal Edit tool, and the judge reads natively. The harness does three things for a
* cooperative-but-confused model: memory (a saved reminder of the plan when it goes stale),
* format guidance (the skeleton), and fresh eyes (the read-only judge in CompleteGoal).
*
* THE FOLD: everything above "## Log" is the working set (title, user voice, goals,
* discriminators) and is what gets re-sent on the reminder cadence. Everything below it (Log,
* Learnings, Appendix) is durable memory: unlimited, read on demand, and re-sent in full only at
* session start and after a compaction, which is where the settled context is actually needed.
*
* Flow:
* SETUP (plan mode) 1. planDrafting — strong/sticky model drafts goals
* EXEC, each turn start 2. planInjection — "here is your plan, where you are"
* EXEC, periodic 3. reminder — the typed nudge that drives upkeep + autonomy
* EXEC, loop continue 4. continuation — keep going toward the active goal
* EXEC, after each turn 5. loopJudge — continue / pause (cheap, foolable, ok)
* SIGN-OFF 6. evidenceJudge — read-only verify (rigorous; the one real check)
* SETUP (plan mode) 1. planDrafting — draft goals into the plan file (read-only), sent once
* EXEC, on cadence 2. reminder — the folded plan + upkeep nudge when it went stale
* EXEC, after compact 3. resync — the WHOLE file back, once
* SIGN-OFF, agent-side 4. completeGoal* — the one blessed tool's description
* SIGN-OFF, judge-side 5. judgeSystem/judgeUser — the one rigorous check
*
* Read top to bottom to see the whole process. 5 and 6 are kept adjacent on
* purpose: the cheap-foolable vs must-not-be-fooled contrast is the design.
*
* WIRED in index.ts: 1 planDrafting, 2 planInjection, 3 reminder, 6 evidenceJudge.
* NOT YET WIRED: 4 continuation and 5 loopJudge define the autonomous re-prompt loop, which is
* intentionally not built in v1 (an until-done-style loop was judged too complex). They stay here so
* the full intended flow is reviewable; wire them if/when the loop is added.
* The goal's test is the DISCRIMINATOR: the concrete observation that tells real success from the
* named subtle failure mode. Evidence is empty at planning and filled at sign-off.
*/
/* ─────────────────────────────────────────────────────────────────────────
* 1. planDrafting SETUP, plan mode
*
* System guidance for the plan-phase agent. Runs on the plan model (may differ
* from the execution model; the choice is sticky — see oracle.json-style config).
* This phase is read-only: explore, then draft goals into plan.md. No code yet.
* The field requirements here are the whole "elicitation" — get them agreed up
* front, because the human reviews this output before any execution.
* 1. planDrafting SETUP, plan mode (read-only: edit/write blocked except the plan file)
* ──────────────────────────────────────────────────────────────────────── */
export const planDrafting = `\
You are in plan mode. Explore the repository read-only, then draft a plan into plan.md.
Do not write or run code in this phase. Produce goals the human will review and approve.
You are in plan mode. You are making a short judgeable plan that captures the user's real goals, then tests it in conversation.
Write each goal in this shape:
1. Reduce technical uncertainty first. Use read-only repository tools or web search when either can
resolve a fact. Do not write or run code in this phase (edit/write are blocked except for the plan
file; don't mutate state via bash either).
2. Before you draft a goal, identify its object, observable result, scope, and any decision that the
human would need to approve later. If any is uncertain, reduce uncertainty now: inspect files or
search the web when they can answer, then ask the human to confirm your interpretation, pin down the
outcome or task, or approve an editorial or other preference choice. Do not present the review menu
with a placeholder goal such as "work out the thing", "improve it", or "investigate".
3. By default, before proposing a final plan, ask at least THREE distinct task-specific alignment
questions in ONE chat round: test agreement about the expected result, scope and constraints, and
success/failure criteria. Even if you think you understand, check how far apart your interpretations
are. Wait for the human's answers and use them before declaring the plan final. Do not ask technical
facts that read-only inspection can resolve, or use a generic ritual questionnaire. Additional
questions should materially reduce uncertainty while discovering the right plan. Each question must be short and self-contained: state the relevant
context, use the human's language and ASD-STE100
Simple Technical English, and give a recommended answer. Record each answer in ## Interview. Do not
make the plan final while material user decisions remain open.
4. State the user-visible result before the goals: one concrete sentence naming what the human will
inspect when this plan is done. Take it from the original request, not from your implementation plan.
Every requested artifact and action must survive into this sentence. An agent-inferred constraint may
not replace, defer, or contradict it; ask the human if an inference would change the result.
5. When every goal has an object, observable result, settled scope, and required approval, draft the
plan file and present it. It should be safe to work overnight and present the requested outcome.
## Goal: <one short imperative line>
status: open
done_when: <a falsifiable check, plus the symptom you'd see if it's NOT met>
verify: <a shell command that exits 0 only when the goal is met — include this whenever
success is expressible as tests/lint/build/a threshold; omit it otherwise>
failure_modes:
- <a concrete way this could look done but isn't>
- <another>
- <if verify exists: "verify passes on a trivial or gamed test">
- [ ] <first subtask>
- [ ] <next subtask>
How this mode ends: when alignment is complete and the plan is ready, call RequestPlanReview to
show Ready / Discuss / Edit / Cancel. Only Ready ends planning. Discuss returns to normal chat:
ask useful alignment questions, wait for answers, and continue discussing for as many turns as
needed. Do not request review while awaiting answers. When discussion is finished, call
RequestPlanReview again, even if the draft did not change. Edit opens the full plan directly.
When a new requirement arrives, fold it in, say what changed, and request review when ready.
Detail that doesn't change a goal or a discriminator belongs in the appendix, not in the goals.
Rules for a good plan:
- Keep goals small enough that done_when is checkable in one sitting.
- done_when must be falsifiable. "Works well" is not a criterion; "p95 < 50ms on bench-X,
else timeouts in load-test.log" is.
- failure_modes are a pre-mortem: the cheap, specific ways a later "done" could be wrong.
This is the highest-value part — it shapes what evidence you'll collect.
- Prefer a verify command. A green deterministic check is worth more than a paragraph of
description, and it's the first thing checked at sign-off.
Right-size it:
- One goal per distinct judgeable outcome. Group related goals when it helps judge them together
and readability. The count flows from the outcomes.
- Describe outcomes in qualitative terms the judge and user can discriminate.
- Use the users language or more precise don't transform "MV" into "knob" as it looses precision and is overloaded
- Don't invent metrics or thresholds for problems you haven't explored yet — the judge should hopefully know it when it sees the outcome.
- Quantitative gates are fine only when you are certain they survive contact with reality.
- Subtasks are the steps inside a goal; add them when a goal has 3+ distinct steps, skip otherwise.
- Two goals that share one discriminator are one goal. Merge them.
- Keep the goal subject short. Put its important scope, failure modes, discriminator, tasks, and evidence in the indented block beneath it. The judge reads the whole block and the whole plan.
- Keep the working set under 50 lines, excluding ## User voice. ## User voice has no line limit: quote
the human fully rather than shorten or paraphrase them. Everything below "## Log" is unlimited.
When the plan is drafted, present it and stop for review. Do not begin execution.`;
Style: Make it easy for a busy and forgetfull user to review. Use ASD-STE100 Simplified Technical English. Use active voice, one idea per sentence, common words,
the same word for the same thing, and define a new terms at first use. Use redundant context for skim readers e.g. "our output - the cells, CV tag" is easy to read and reminds context. This covers the context
paragraph and the appendix too, not just the checklist. No all-caps headers and no bold spam. Just write less, add your voice less, persuade less, and burden the reader less.
Write the plan file in roughly this shape -- the file is read directly by the human and a judge model, so clarity beats conformance; small deviations are fine):
# <short plan title>
<context: one short paragraph. What the human wants and why.>
## User-visible result
<one concrete sentence naming the final artifact or behavior the human will inspect>
## User voice
- > "<the human's requirement, quoted in full word for word (with spelling fixes)>"
## Goals
1. [ ] goal: <one short jugable imperative outcome>
- subtle failure mode: <a way this could look done but isn't>
- discriminator: <the concrete observation that tells real success from that failure>
- verify: <optional shell command that exits 0 only when the discriminator passes; omit if not
testable. YOU run it at sign-off time and save its output as evidence; the judge only reads>
- tasks:
1. [ ] <subtask>
- evidence: (empty until sign-off)
## Future work / out of scope
<-- the fold: everything below here is durable memory, not the working set -->
## Log
### {date}
## Interview
## Learnings
## Papercuts - problems, gotchas, suggestions
## Appendix (context, not approved)
Conventions:
- A goal is a checkbox line beginning "goal:". Checkbox state: [ ] open, [/] active, [x] done,
[-] cancelled. Leave goals [ ] at planning.
- subtle failure mode + discriminator are the heart of this. Name the ways a "done" could look
achieved but not be (empty output, a silently-errored step, a gamed test, a no-op that dodged
every trap and showed nothing). The discriminator is the POSITIVE observation that success
happened -- the count moved, the test exercised the real path, the metric beat noise -- and that
none of the failure modes could fake. Ruling out failures is necessary, not sufficient.
- Make the discriminator a concrete, checkable observation about a real artifact (a file, a test
result, a committed diff, a metric), never about the plan file's own checkbox.
- evidence stays empty at planning; you fill it at sign-off and a fresh read-only judge checks it.
Cite durable artifacts a future reader can open: committed files, test names, git diffs. .pi/ is
usually gitignored, so files there prove things only at judge time, not in history.
- User-visible result: restate the original deliverable, not the proposed implementation. Every goal
must contribute to it. Future work may not defer any artifact or action named there.
- User voice: quote the human word for word, one line per requirement, as they say it. Never
paraphrase there -- a paraphrase drifts, and then the goals churn on the next reply. It is exempt
from the working-set line limit. Never put an agent inference in User voice.
- Interview: every human reply in plan mode is stored here verbatim as a dated blockquote. It is
durable memory below the fold, not a substitute for ## User voice.
- Rejected options stay visible: ~~struck through~~ with who rejected them and why, so nobody
relitigates them.
- Learnings: one line per gotcha that a future reader would otherwise rediscover. Write down what
you saw from a source that does not persist (a browser page, an image, a long log tail) before
you do anything else with it.
- Appendix: unlimited and unverified. Alternatives, links, dead ends, and the settled detail that
is not part of the approved goals. Nothing here is approved and nothing here is checked.
After the alignment answers are incorporated, present the final plan and call RequestPlanReview. Do not begin execution.`;
/* ─────────────────────────────────────────────────────────────────────────
* 2. planInjection EXEC, injected at each agent start (and after compaction)
*
* A late user-role message, NOT a system-prompt mutation (keeps the prefix cache
* valid). Built from the parsed plan. MUST be byte-identical when nothing changed:
* fixed field order, no volatile timestamps in the body. Pass only the active
* goal + its open subtasks + the last log line — not the whole file.
* 3. reminder — EXEC. Saved at the next natural prompt after the plan goes stale for several
* turns. pi-tasks tried a per-turn injection and deleted it: "wallpaper noise that trains the
* model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149). Carries the folded plan
* (above ## Log), because a nudge with no plan in it makes the model go read the file anyway.
* ──────────────────────────────────────────────────────────────────────── */
export function planInjection(p: {
objective: string;
activeGoal: { subject: string; done_when: string; openSubtasks: string[] } | null;
lastLogLine: string | null;
counts: { done: number; open: number };
}): string {
if (!p.activeGoal) {
return `Plan (plan.md): ${p.objective}\nNo active goal. ${p.counts.open} open, ${p.counts.done} done. Pick the next goal or run /plan.`;
}
const subtasks = p.activeGoal.openSubtasks.length
? p.activeGoal.openSubtasks.map((s) => ` - [ ] ${s}`).join("\n")
: " (no open subtasks)";
return `\
Plan (plan.md): ${p.objective}
Active goal: ${p.activeGoal.subject}
done_when: ${p.activeGoal.done_when}
Open subtasks:
${subtasks}
Last log: ${p.lastLogLine ?? "(none yet)"}
Progress: ${p.counts.done} done, ${p.counts.open} open.`;
/** A waiver belongs only to the current objective, never to an earlier plan's transcript. */
export function waivesAlignment(objective: string): boolean {
// Only standalone affirmative clauses, not negations or quoted feature names.
const unquoted = objective.replace(/"[^"]*"|“[^”]*”|[^]*|`[^`]*`/g, " ").replace(/(^|\W)'[^'\n]*'(?=$|\W)/g, "$1 ");
return unquoted.split(/[;,.!?\n]+/).some(clause => /^(?:please\s+)?(?:no[- ](?:questions|q[']s)|skip(?:[- ](?:the|all))?[- ](?:questions|q[']s)|(?:don[']t|do not) ask(?: me)?(?: any)? questions)(?:\s+please)?(?:\s+(?:and|then)\b.+)?$/i.test(clause.trim()));
}
/* ─────────────────────────────────────────────────────────────────────────
* 3. reminder — EXEC, periodic system-reminder
*
* The typed nudge. This is both the housekeeping and the autonomy engine — it is
* what makes the process get followed without a hard gate. Fires after N
* file-modifying turns since the last plan.md update while a goal is active.
* Keep the wording stable so it doesn't thrash the cache.
* ──────────────────────────────────────────────────────────────────────── */
export const reminder = `\
export function alignmentPolicy(waived: boolean): string {
return waived
? "Current-plan alignment: the human explicitly waived questions in this objective. Skip the default three-question round for THIS plan only."
: "Current-plan alignment: ask at least THREE task-specific questions in ONE chat round before the final plan; wait for answers and use them. Check the expected result, scope/constraints, and success/failure criteria. A waiver in any previous plan does NOT apply. Do not repeat questions already answered for this plan.";
}
export const discussPlan = "Continue discussing this draft in normal chat. Ask useful, task-specific alignment questions to check where your understanding differs from the human's: expected result, scope/constraints, and success/failure criteria. Wait for answers; do not open an editor or request review yet. Keep the draft and incorporate answers. When discussion is finished and the plan is ready, call RequestPlanReview, even if the draft is unchanged.";
export function planningState(planPath: string, questionsWaived = false): string {
return `\
[PLANNING MODE]
${alignmentPolicy(questionsWaived)}
The plan at ${planPath} is the only file you may change. Use read-only repository tools or web search
when either can resolve a fact. Ask the human to confirm unresolved interpretation, outcome, task,
scope, or a choice that needs their approval. Batch independent high-impact questions in one short,
self-contained round with relevant context and a recommendation. Do not draft a placeholder goal
without a concrete object, observable result, settled scope, and required approval. Do not execute
work, mark a goal [/] or [x], or sign off a goal. The plan is not approved until the human selects
Ready. Call RequestPlanReview only when alignment is complete and the plan is ready, not while waiting for chat answers.`;
}
export function reminder(foldedPlan: string, planRel: string): string {
return `\
<system-reminder>
Keep plan.md current as you work:
- tasks: tick the subtasks you've finished; add any new ones you've discovered.
- log: append ONE short line to ## Log (append — don't rewrite earlier lines).
- goal: if the active goal's evidence is in, sign it off by calling CompleteGoal with that
evidence. Don't edit status to done by hand — CompleteGoal runs the check and records it.
- otherwise: keep working toward the active goal. Don't stop to ask unless you're genuinely
blocked; if blocked, say what's blocking and why.
Your plan (${planRel}, above the fold; the log, learnings and appendix are in the file):
${foldedPlan}
Keep it current as you work, with your normal edit tool:
- tick finished subtasks ([/] in progress), add discovered ones
- append ONE short line to ## Log, and a line to ## Learnings for a gotcha worth keeping
- when the active goal's discriminator is satisfied, fill its evidence: list (each item = a durable
artifact + a verbatim quote you actually observed + a short read of it), then call CompleteGoal.
Don't tick a goal [x] before CompleteGoal accepts; the sign-off log line is the audit trail.
- if the working set has grown long, prune finished goals (their evidence lives in git history and
## Log) and move settled detail down to ## Appendix, which is unlimited
- the human's latest message outranks this plan. If it corrects the deliverable or scope, amend the
user-visible result, user voice, and affected goals before continuing; don't defend the old plan
- otherwise keep working toward the active goal; don't stop to ask unless genuinely blocked
</system-reminder>`;
/* ─────────────────────────────────────────────────────────────────────────
* 4. continuation — EXEC, the loop's "keep going" turn
*
* Hermes-style. A plain user-role message appended when the loop judge (5) says
* continue. Does not mutate the system prompt, so the cache holds.
* ──────────────────────────────────────────────────────────────────────── */
export const continuation = `\
Continue toward the active goal in plan.md. If it now meets its done_when, call CompleteGoal
with your evidence (point to durable artifacts — saved logs, committed diffs, files — not just
claims). If you're blocked, state what's blocking it.`;
/* ─────────────────────────────────────────────────────────────────────────
* 5. loopJudge — EXEC, runs after each turn to decide continue / pause
*
* Cheap, conservative, fail-open. Reads only the agent's last response, so it CAN
* be fooled by an asserted "done" — that's acceptable: its worst case is a
* premature pause, caught by you or the iteration budget. It does NOT sign goals
* off; that's the evidence judge's job. Return strict JSON, no prose.
* ──────────────────────────────────────────────────────────────────────── */
export const loopJudgeSystem = `\
You decide whether an autonomous coding agent should keep working or pause for the human.
Be conservative: only pause when the work is plainly finished or plainly blocked. When in
doubt, continue. You are not verifying correctness — a later read-only judge does that.
Reply with ONLY a JSON object, no other text: {"done": boolean, "reason": "<one sentence>"}.
Set done=true only if the agent's last message shows the active goal's done_when is met, or
the agent says it is blocked and needs the human.`;
export function loopJudgeUser(p: { activeGoalDoneWhen: string; lastResponse: string }): string {
return `\
Active goal done_when: ${p.activeGoalDoneWhen}
Agent's last message:
"""
${p.lastResponse}
"""
{"done": ?, "reason": ?}`;
}
/* ─────────────────────────────────────────────────────────────────────────
* 6. evidenceJudge — SIGN-OFF, the one rigorous check
*
* Runs inside CompleteGoal, on the read-only oracle subprocess (fresh context,
* strongest reasoning on the chosen provider; override to a different vendor for
* high-stakes goals). It re-derives from the repo rather than trusting the
* agent's transcription, and it judges whether a verify command actually tests
* the criterion or could pass while a named failure mode holds (gaming).
*
* The transport gives it read/grep/find/ls. The prompt below imposes the verdict
* contract — the oracle returns prose by default, so parse the VERDICT line.
* 3b. resync — EXEC, one-shot at session start and after a compaction: the WHOLE file back,
* appendix included. Modelled on pi-goal-x's [POST-COMPACTION RESYNC] one-shot. This is the
* only place the below-the-fold sections are pushed; otherwise the agent reads them on demand.
* ──────────────────────────────────────────────────────────────────────── */
export const evidenceJudgeSystem = `\
You are a read-only reviewer signing off a coding goal. Do not trust claims — verify.
Use read/grep/find/ls to inspect the repository and the cited artifacts yourself. Re-read the
files, logs, and diffs the evidence points to; if something it asserts isn't on disk, you can't
confirm it. If a verify command was run, judge whether it genuinely tests the criterion or
could pass while one of the listed failure modes still holds — a tautological or skipped test
is a reject. Check each failure mode is actually ruled out, not just unmentioned.
export function resync(plan: string, planRel: string, why: string): string {
return `\
<system-reminder>
${why} This is the whole plan file (${planRel}), appendix included. Keep working the active goal;
edit the file directly as you go. The human's latest message outranks the plan: if it corrects the
deliverable or scope, amend the plan rather than preserving an obsolete decision.
${plan}
</system-reminder>`;
}
export const completeGoalDescription =
"Sign off a goal once its discriminator is satisfied. When the optional persistent steward is enabled, " +
"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 " +
"output, rerun it or write that you couldn't -- an honest gap beats a plausible fabrication. If " +
"the goal names a verify: command, run it yourself first and save its output to a file cited in " +
"the evidence: the judge cannot execute anything and will reject a claimed pass with no saved " +
"output. The read must show success POSITIVELY happened, not just that failures were avoided. " +
"Check that the claimed result uses the artifact and outcome named in User-visible result and does " +
"not substitute an agent-inferred deliverable. Then call this with the goal's text (the line after " +
"'goal:'; small wording drift is fine). A " +
"fresh strictly-read-only judge inspects the LIVE WORKING TREE (uncommitted changes included; " +
"committing first is for durability, not visibility) and returns accept or reject with what's " +
"missing. On accept (or if the judge itself failed), a sign-off line is appended to ## Log " +
"and the goal is ticked [x] for you; the result says if you must tick it yourself. On reject the " +
"goal stays open.";
export const completeGoalParamDescription = "The goal's text: the line after 'goal:' in the plan file.";
/* ─────────────────────────────────────────────────────────────────────────
* 6. judge — SIGN-OFF, judge-side: the one rigorous check. Runs on a fresh
* read-only pi subprocess (--no-session) so it never sees the working
* agent's transcript. It gets the WHOLE plan file: it finds the goal,
* reads discriminator/failure modes/evidence itself (no parser between).
* ──────────────────────────────────────────────────────────────────────── */
export const judgeSystem = `\
You are a strictly read-only reviewer signing off a coding goal. You cannot execute anything: judge
by reading (read/grep/find/ls). Never re-run the work or its verify command -- it may be a 10-hour
job; the agent must bring you its saved output. Your job is evidence discipline, checked in order:
0. Task fidelity? Read User-visible result and User voice first. Reject if this goal contradicts,
replaces, or defers the requested artifact or outcome. Agent-inferred scope is not authority.
1. Anything here? An empty or placeholder evidence: list -> reject: "there's nothing here -- fill
the evidence and try again."
2. Quoted and attributed? Each item needs a source (file path / command) plus a verbatim quote of
what was observed, plus a one-line read. A bare claim -> reject: "you didn't quote and
attribute it."
3. Provenance? It must be visible HOW each result was produced (the command run, where its output
was saved). Results with no origin -> reject: "I see the results, but how did you get them?"
4. Spot-check: open the cited files. A quote or number that doesn't match what's on disk means the
evidence was reconstructed from memory, not observed -> reject and ask for re-observed
evidence, even if the goal otherwise looks met.
5. Substance, only once 1-4 hold: does the evidence show the discriminator's success signal
POSITIVELY happened -- not just that the named failure modes were dodged; a run can rule out
every trap and still have produced nothing. Is each subtle failure mode genuinely ruled out,
not just unmentioned? If the goal names a verify: command, its saved output must be among the
evidence, and the command must actually test the discriminator rather than pass tautologically.
Before the verdict, write this heading: checks:. Put one concise bullet under it for each artifact you actually read:
path, verbatim observed quote, and what that observation establishes. This is an inspectable review
record, not hidden reasoning. Do not write a checks bullet for a file you did not open.
Finish with exactly these two lines and nothing after:
VERDICT: accept | reject
missing: <empty if accept; otherwise a short list of what's needed before this can be accepted>`;
export function evidenceJudgeUser(p: {
subject: string;
done_when: string;
verify: string | null;
verifyResult: { command: string; exitCode: number; outputTail: string } | null;
failure_modes: string[];
evidence: string;
paths: string[];
}): string {
const verifyBlock = p.verify
? `verify command: ${p.verify}\nverify result: exit ${p.verifyResult?.exitCode ?? "n/a"}\n${p.verifyResult?.outputTail ?? ""}`
: "verify command: none (no deterministic check for this goal)";
return `\
Goal: ${p.subject}
done_when: ${p.done_when}
failure_modes:
${p.failure_modes.map((f) => ` - ${f}`).join("\n")}
export function judgeUser(p: { goal: string; plan: string; planPath: string }): string {
return `\
The working agent claims this goal is complete:
${verifyBlock}
goal: ${p.goal}
Agent's evidence:
${p.evidence}
Below is the full plan file (${p.planPath}). Find that goal in it (tolerate small wording drift; if
you cannot find a matching goal at all, reject and say so). Read User-visible result and User voice
first, then its discriminator, subtle failure modes, verify command, and evidence list.
Artifacts it points to (inspect these):
${p.paths.map((x) => ` - ${x}`).join("\n") || " (none listed — note this)"}
--- plan file ---
${p.plan}
--- end plan file ---
Verify the goal against its done_when. Then give your VERDICT.`;
Read the cited artifacts (you cannot execute anything), then give your VERDICT.`;
}
+88
View File
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
export type ModelRole = "planning" | "worker" | "supervisor";
export interface ModelChoice { provider: string; id: string }
/** One atomic file per role: a supervisor process cannot clobber the worker's choice. */
export class RoleModels {
private role: ModelRole | null = null;
private automatic = 0;
private available = true;
constructor(private pi: ExtensionAPI, private directory = join(getAgentDir(), "pi-goals")) {
pi.on("model_select", (event, ctx) => {
// setModel can emit "set" itself. Session restore is not a human preference either.
if (this.automatic || event.source === "restore" || !this.role) return;
try { this.save(this.role, event.model); this.available = true; }
catch (error) { ctx.ui.notify(`Could not remember ${this.role} model: ${String(error)}`, "error"); }
});
}
get ready(): boolean { return this.available; }
get activeRole(): ModelRole | null { return this.role; }
get restoring(): boolean { return this.automatic > 0; }
leave(): void { this.role = null; this.available = true; }
/** Explicit acknowledgement works even when Pi suppresses same-model model_select. */
async useCurrent(ctx: ExtensionContext): Promise<boolean> {
const role = this.role;
if (!role || this.available) { ctx.ui.notify("No role model is paused.", "info"); return false; }
this.automatic++;
try {
const model = ctx.model;
if (!model || !await this.pi.setModel(model)) throw new Error("The current model is missing or unauthenticated");
this.save(role, model);
this.available = true;
ctx.ui.notify(`Explicitly saved current model ${model.provider}/${model.id} for ${role}.`, "info");
return true;
} catch (error) {
ctx.ui.notify(`Could not recover ${role}: ${String(error)}. Saved choice unchanged.`, "error");
return false;
} finally { this.automatic--; }
}
private path(role: ModelRole): string { return join(this.directory, `${role}-model.json`); }
private read(role: ModelRole): ModelChoice | undefined {
let raw: string;
try { raw = readFileSync(this.path(role), "utf8"); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
const value = JSON.parse(raw);
if (!value || typeof value.provider !== "string" || !value.provider || typeof value.id !== "string" || !value.id) throw new Error(`Invalid model preference: ${this.path(role)}`);
return { provider: value.provider, id: value.id };
}
private save(role: ModelRole, model: ModelChoice): void {
mkdirSync(this.directory, { recursive: true });
const temp = `${this.path(role)}.${randomUUID()}.tmp`;
try {
writeFileSync(temp, `${JSON.stringify({ provider: model.provider, id: model.id })}\n`, { mode: 0o600 });
renameSync(temp, this.path(role));
} finally { rmSync(temp, { force: true }); }
}
/** Failure keeps the saved choice and pauses the role; only an explicit selection replaces it. */
async enter(role: ModelRole, ctx: ExtensionContext): Promise<boolean> {
this.role = role;
this.automatic++;
try {
const choice = this.read(role);
if (!choice) {
if (!ctx.model) throw new Error("No current model. Select one with /model first.");
this.save(role, ctx.model);
if (!await this.pi.setModel(ctx.model)) throw new Error(`Current model ${ctx.model.provider}/${ctx.model.id} has no authentication.`);
} else {
const model = ctx.modelRegistry.find(choice.provider, choice.id);
if (!model) throw new Error(`Remembered model ${choice.provider}/${choice.id} is unavailable.`);
if (!await this.pi.setModel(model)) throw new Error(`Remembered model ${choice.provider}/${choice.id} has no authentication.`);
}
this.available = true;
return true;
} catch (error) {
this.available = false;
ctx.ui.notify(`${role} model paused: ${String(error)} Saved choice unchanged; configure that model and retry, select a different model with /model, or explicitly use the current model for this paused role with /goals model current. Then retry Ready if work has not started.`, "error");
return false;
} finally { this.automatic--; }
}
}
+121
View File
@@ -0,0 +1,121 @@
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("The internal supervisor is not registered. Load the pi-goals package directory (not only src/index.ts), 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 packageRoot = fileURLToPath(new URL("../", import.meta.url));
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", packageRoot], 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.`);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { appendLog } from "../src/index.js";
describe("appendLog (the extension's only plan-file write)", () => {
it("creates ## Log at EOF when absent", () => {
const out = appendLog("# Plan\n\n## Goals\n\n1. [ ] goal: x\n", "2026-07-03 10:00 signed off \"x\" (judge accept)");
expect(out).toContain("## Log\n- 2026-07-03 10:00 signed off");
});
it("appends after the last existing log line, before any following header", () => {
const plan = "# Plan\n\n## Log\n- first\n- second\n\n# Future work\n- later\n";
const out = appendLog(plan, "third");
const lines = out.split("\n");
expect(lines[lines.indexOf("- second") + 1]).toBe("- third");
expect(out.indexOf("- third")).toBeLessThan(out.indexOf("# Future work"));
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from "vitest";
import { decideSignOff, type JudgeResult } from "../src/index.js";
import { judgeSystem } from "../src/prompts.js";
// decideSignOff is the fail-forward invariant: judgeModel is NEVER checked pre-emptively, so a null
// model still reaches runJudge (pi's configured default runs it), and the only producers of
// accepted_inconclusive are the judge-error and no-VERDICT paths -- i.e. "the judge ran but failed",
// never "no model". The judge runner is injected so these tests never spawn a real subprocess.
const input = { goal: "x", plan: "# plan\n", planRel: ".pi/plan/s1.md", judgeModel: null };
describe("decideSignOff (fail-forward invariant)", () => {
it("proceeds to runJudge even when judgeModel is null (no pre-emptive 'no model' inconclusive)", async () => {
const output = "## checks:\n- evidence.txt: `PASS`; the saved check passed\n\nThe artifact proves the gate passed.\nVERDICT: accept\nmissing:";
const runJudge = vi.fn().mockResolvedValue({ output });
const out = await decideSignOff({ ...input, plan: "# plan\n1. [ ] goal: x\n" }, undefined, runJudge);
expect(runJudge).toHaveBeenCalledOnce(); // reached the judge -- no pre-emptive return on null model
expect(out.isError).toBe(false);
expect(out.logEntry).toContain("judge accept");
expect(out.resultText).toContain("evidence.txt: `PASS`");
});
it("rejects an accept verdict without a checked-artifact list", async () => {
const runJudge = vi.fn().mockResolvedValue({ output: "VERDICT: accept\nmissing:" });
const out = await decideSignOff(input, undefined, runJudge);
expect(out.isError).toBe(true);
expect(out.resultText).toContain("checked-artifact list");
expect(out.logEntry).toContain("no checked-artifact list");
});
it("a judge-subprocess error yields accepted_inconclusive with a 'ran but failed' reason", async () => {
const runJudge = vi.fn().mockResolvedValue({ output: "", error: "judge subprocess exited 1" } satisfies JudgeResult);
const out = await decideSignOff(input, undefined, runJudge);
expect(runJudge).toHaveBeenCalledOnce();
expect(out.isError).toBe(false); // accepted inconclusive, not a hard error that blocks the agent
expect(out.resultText.toLowerCase()).toContain("accepted inconclusive");
expect(out.resultText).toContain("ran but failed"); // inconclusive means ran but failed, not "no model"
expect(out.logEntry).toContain("ran but failed");
expect(out.logEntry).toContain("subprocess exited 1");
});
it("a judge timeout is also accepted_inconclusive (ran but failed)", async () => {
const runJudge = vi.fn().mockResolvedValue({ output: "partial", error: "judge timed out after 600s" });
const out = await decideSignOff(input, undefined, runJudge);
expect(out.isError).toBe(false);
expect(out.resultText.toLowerCase()).toContain("accepted inconclusive");
expect(out.logEntry).toContain("ran but failed");
expect(out.logEntry).toContain("timed out");
expect(out.resultText).toContain("partial judge output:\npartial");
});
it("no VERDICT line is accepted_inconclusive too (judge ran but didn't answer)", async () => {
const runJudge = vi.fn().mockResolvedValue({ output: "I looked but forgot the verdict line" });
const out = await decideSignOff(input, undefined, runJudge);
expect(out.isError).toBe(false);
expect(out.resultText).toContain("no VERDICT line");
expect(out.logEntry).toContain("no VERDICT line");
});
it("rejects when the judge returns VERDICT: reject", async () => {
const runJudge = vi.fn().mockResolvedValue({ output: "VERDICT: reject\nmissing: evidence, tests" });
const out = await decideSignOff({ ...input, judgeModel: "openrouter/claude" }, undefined, runJudge);
expect(out.isError).toBe(true);
expect(out.resultText).toContain("REJECTED");
expect(out.resultText).toContain("evidence, tests");
expect(out.logEntry).toContain("reject");
});
it("requires a concise checked-artifact review, not private reasoning", () => {
expect(judgeSystem).toContain("checks:");
expect(judgeSystem).toContain("not hidden reasoning");
});
it("writes nothing when aborted after the judge ran", async () => {
const runJudge = vi.fn().mockResolvedValue({ output: "VERDICT: accept" });
const ctrl = new AbortController();
ctrl.abort();
const out = await decideSignOff(input, ctrl.signal, runJudge);
expect(out.logEntry).toBeNull();
expect(out.isError).toBe(true);
});
});
+411
View File
@@ -0,0 +1,411 @@
{"type":"session","version":3,"id":"fixture","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp/fixture"}
{"type":"message","id":"e0000","parentId":null,"timestamp":"2026-01-01T00:00:00.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 0, this text is on every branch"}]}}
{"type":"message","id":"e0001","parentId":"e0000","timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 0, this text is on every branch"}]}}
{"type":"message","id":"e0002","parentId":"e0001","timestamp":"2026-01-01T00:00:02.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 1, this text is on every branch"}]}}
{"type":"message","id":"e0003","parentId":"e0002","timestamp":"2026-01-01T00:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 1, this text is on every branch"}]}}
{"type":"message","id":"e0004","parentId":"e0003","timestamp":"2026-01-01T00:00:04.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 2, this text is on every branch"}]}}
{"type":"message","id":"e0005","parentId":"e0004","timestamp":"2026-01-01T00:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 2, this text is on every branch"}]}}
{"type":"message","id":"e0006","parentId":"e0005","timestamp":"2026-01-01T00:00:06.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 3, this text is on every branch"}]}}
{"type":"message","id":"e0007","parentId":"e0006","timestamp":"2026-01-01T00:00:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 3, this text is on every branch"}]}}
{"type":"message","id":"e0008","parentId":"e0007","timestamp":"2026-01-01T00:00:08.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 4, this text is on every branch"}]}}
{"type":"message","id":"e0009","parentId":"e0008","timestamp":"2026-01-01T00:00:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 4, this text is on every branch"}]}}
{"type":"message","id":"e0010","parentId":"e0009","timestamp":"2026-01-01T00:00:10.000Z","message":{"role":"user","content":[{"type":"text","text":"shared turn 5, this text is on every branch"}]}}
{"type":"message","id":"e0011","parentId":"e0010","timestamp":"2026-01-01T00:00:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"shared reply 5, this text is on every branch"}]}}
{"type":"message","id":"e0012","parentId":"e0011","timestamp":"2026-01-01T00:01:40.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 0, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0013","parentId":"e0012","timestamp":"2026-01-01T00:01:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 0, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0014","parentId":"e0013","timestamp":"2026-01-01T00:01:42.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 1, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0015","parentId":"e0014","timestamp":"2026-01-01T00:01:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 1, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0016","parentId":"e0015","timestamp":"2026-01-01T00:01:44.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 2, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0017","parentId":"e0016","timestamp":"2026-01-01T00:01:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 2, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0018","parentId":"e0017","timestamp":"2026-01-01T00:01:46.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 3, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0019","parentId":"e0018","timestamp":"2026-01-01T00:01:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 3, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0020","parentId":"e0019","timestamp":"2026-01-01T00:01:48.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 4, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0021","parentId":"e0020","timestamp":"2026-01-01T00:01:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 4, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0022","parentId":"e0021","timestamp":"2026-01-01T00:01:50.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 5, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0023","parentId":"e0022","timestamp":"2026-01-01T00:01:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 5, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0024","parentId":"e0023","timestamp":"2026-01-01T00:01:52.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 6, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0025","parentId":"e0024","timestamp":"2026-01-01T00:01:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 6, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0026","parentId":"e0025","timestamp":"2026-01-01T00:01:54.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 7, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0027","parentId":"e0026","timestamp":"2026-01-01T00:01:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 7, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0028","parentId":"e0027","timestamp":"2026-01-01T00:01:56.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 8, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0029","parentId":"e0028","timestamp":"2026-01-01T00:01:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 8, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0030","parentId":"e0029","timestamp":"2026-01-01T00:01:58.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 9, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0031","parentId":"e0030","timestamp":"2026-01-01T00:01:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 9, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0032","parentId":"e0031","timestamp":"2026-01-01T00:02:00.000Z","message":{"role":"user","content":[{"type":"text","text":"ABANDONED turn 10, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0033","parentId":"e0032","timestamp":"2026-01-01T00:02:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ABANDONED reply 10, rewound away and must not appear in a view"}]}}
{"type":"message","id":"e0034","parentId":"e0011","timestamp":"2026-01-01T00:05:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 0, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0035","parentId":"e0034","timestamp":"2026-01-01T00:05:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 0, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0036","parentId":"e0035","timestamp":"2026-01-01T00:05:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 1, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0037","parentId":"e0036","timestamp":"2026-01-01T00:05:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 1, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0038","parentId":"e0037","timestamp":"2026-01-01T00:05:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 2, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0039","parentId":"e0038","timestamp":"2026-01-01T00:05:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 2, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0040","parentId":"e0039","timestamp":"2026-01-01T00:05:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 3, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0041","parentId":"e0040","timestamp":"2026-01-01T00:05:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 3, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0042","parentId":"e0041","timestamp":"2026-01-01T00:05:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 4, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0043","parentId":"e0042","timestamp":"2026-01-01T00:05:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 4, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0044","parentId":"e0043","timestamp":"2026-01-01T00:05:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 5, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0045","parentId":"e0044","timestamp":"2026-01-01T00:05:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 5, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0046","parentId":"e0045","timestamp":"2026-01-01T00:05:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 6, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0047","parentId":"e0046","timestamp":"2026-01-01T00:05:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 6, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0048","parentId":"e0047","timestamp":"2026-01-01T00:05:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 7, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0049","parentId":"e0048","timestamp":"2026-01-01T00:05:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 7, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0050","parentId":"e0049","timestamp":"2026-01-01T00:05:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 8, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0051","parentId":"e0050","timestamp":"2026-01-01T00:05:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 8, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0052","parentId":"e0051","timestamp":"2026-01-01T00:05:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 9, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0053","parentId":"e0052","timestamp":"2026-01-01T00:05:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 9, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0054","parentId":"e0053","timestamp":"2026-01-01T00:05:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 10, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0055","parentId":"e0054","timestamp":"2026-01-01T00:05:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 10, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0056","parentId":"e0055","timestamp":"2026-01-01T00:05:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 11, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0057","parentId":"e0056","timestamp":"2026-01-01T00:05:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 11, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0058","parentId":"e0057","timestamp":"2026-01-01T00:05:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 12, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0059","parentId":"e0058","timestamp":"2026-01-01T00:05:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 12, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0060","parentId":"e0059","timestamp":"2026-01-01T00:05:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 13, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0061","parentId":"e0060","timestamp":"2026-01-01T00:05:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 13, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0062","parentId":"e0061","timestamp":"2026-01-01T00:05:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 14, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0063","parentId":"e0062","timestamp":"2026-01-01T00:05:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 14, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0064","parentId":"e0063","timestamp":"2026-01-01T00:05:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 15, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0065","parentId":"e0064","timestamp":"2026-01-01T00:05:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 15, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0066","parentId":"e0065","timestamp":"2026-01-01T00:05:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 16, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0067","parentId":"e0066","timestamp":"2026-01-01T00:05:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 16, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0068","parentId":"e0067","timestamp":"2026-01-01T00:05:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 17, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0069","parentId":"e0068","timestamp":"2026-01-01T00:05:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 17, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0070","parentId":"e0069","timestamp":"2026-01-01T00:05:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 18, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0071","parentId":"e0070","timestamp":"2026-01-01T00:05:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 18, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0072","parentId":"e0071","timestamp":"2026-01-01T00:05:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 19, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0073","parentId":"e0072","timestamp":"2026-01-01T00:05:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 19, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0074","parentId":"e0073","timestamp":"2026-01-01T00:05:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 20, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0075","parentId":"e0074","timestamp":"2026-01-01T00:05:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 20, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0076","parentId":"e0075","timestamp":"2026-01-01T00:05:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 21, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0077","parentId":"e0076","timestamp":"2026-01-01T00:05:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 21, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0078","parentId":"e0077","timestamp":"2026-01-01T00:05:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 22, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0079","parentId":"e0078","timestamp":"2026-01-01T00:05:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 22, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0080","parentId":"e0079","timestamp":"2026-01-01T00:05:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 23, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0081","parentId":"e0080","timestamp":"2026-01-01T00:05:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 23, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0082","parentId":"e0081","timestamp":"2026-01-01T00:05:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 24, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0083","parentId":"e0082","timestamp":"2026-01-01T00:05:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 24, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0084","parentId":"e0083","timestamp":"2026-01-01T00:05:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 25, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0085","parentId":"e0084","timestamp":"2026-01-01T00:05:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 25, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0086","parentId":"e0085","timestamp":"2026-01-01T00:05:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 26, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0087","parentId":"e0086","timestamp":"2026-01-01T00:05:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 26, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0088","parentId":"e0087","timestamp":"2026-01-01T00:05:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 27, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0089","parentId":"e0088","timestamp":"2026-01-01T00:05:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 27, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0090","parentId":"e0089","timestamp":"2026-01-01T00:05:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 28, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0091","parentId":"e0090","timestamp":"2026-01-01T00:05:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 28, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0092","parentId":"e0091","timestamp":"2026-01-01T00:05:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 29, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0093","parentId":"e0092","timestamp":"2026-01-01T00:05:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 29, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0094","parentId":"e0093","timestamp":"2026-01-01T00:06:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 30, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0095","parentId":"e0094","timestamp":"2026-01-01T00:06:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 30, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0096","parentId":"e0095","timestamp":"2026-01-01T00:06:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 31, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0097","parentId":"e0096","timestamp":"2026-01-01T00:06:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 31, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0098","parentId":"e0097","timestamp":"2026-01-01T00:06:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 32, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0099","parentId":"e0098","timestamp":"2026-01-01T00:06:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 32, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0100","parentId":"e0099","timestamp":"2026-01-01T00:06:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 33, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0101","parentId":"e0100","timestamp":"2026-01-01T00:06:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 33, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0102","parentId":"e0101","timestamp":"2026-01-01T00:06:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 34, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0103","parentId":"e0102","timestamp":"2026-01-01T00:06:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 34, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0104","parentId":"e0103","timestamp":"2026-01-01T00:06:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 35, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0105","parentId":"e0104","timestamp":"2026-01-01T00:06:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 35, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0106","parentId":"e0105","timestamp":"2026-01-01T00:06:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 36, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0107","parentId":"e0106","timestamp":"2026-01-01T00:06:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 36, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0108","parentId":"e0107","timestamp":"2026-01-01T00:06:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 37, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0109","parentId":"e0108","timestamp":"2026-01-01T00:06:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 37, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0110","parentId":"e0109","timestamp":"2026-01-01T00:06:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 38, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0111","parentId":"e0110","timestamp":"2026-01-01T00:06:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 38, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0112","parentId":"e0111","timestamp":"2026-01-01T00:06:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 39, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0113","parentId":"e0112","timestamp":"2026-01-01T00:06:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 39, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0114","parentId":"e0113","timestamp":"2026-01-01T00:06:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 40, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0115","parentId":"e0114","timestamp":"2026-01-01T00:06:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 40, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0116","parentId":"e0115","timestamp":"2026-01-01T00:06:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 41, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0117","parentId":"e0116","timestamp":"2026-01-01T00:06:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 41, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0118","parentId":"e0117","timestamp":"2026-01-01T00:06:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 42, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0119","parentId":"e0118","timestamp":"2026-01-01T00:06:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 42, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0120","parentId":"e0119","timestamp":"2026-01-01T00:06:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 43, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0121","parentId":"e0120","timestamp":"2026-01-01T00:06:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 43, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0122","parentId":"e0121","timestamp":"2026-01-01T00:06:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 44, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0123","parentId":"e0122","timestamp":"2026-01-01T00:06:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 44, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0124","parentId":"e0123","timestamp":"2026-01-01T00:06:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 45, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0125","parentId":"e0124","timestamp":"2026-01-01T00:06:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 45, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0126","parentId":"e0125","timestamp":"2026-01-01T00:06:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 46, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0127","parentId":"e0126","timestamp":"2026-01-01T00:06:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 46, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0128","parentId":"e0127","timestamp":"2026-01-01T00:06:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 47, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0129","parentId":"e0128","timestamp":"2026-01-01T00:06:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 47, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0130","parentId":"e0129","timestamp":"2026-01-01T00:06:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 48, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0131","parentId":"e0130","timestamp":"2026-01-01T00:06:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 48, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0132","parentId":"e0131","timestamp":"2026-01-01T00:06:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 49, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0133","parentId":"e0132","timestamp":"2026-01-01T00:06:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 49, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0134","parentId":"e0133","timestamp":"2026-01-01T00:06:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 50, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0135","parentId":"e0134","timestamp":"2026-01-01T00:06:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 50, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0136","parentId":"e0135","timestamp":"2026-01-01T00:06:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 51, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0137","parentId":"e0136","timestamp":"2026-01-01T00:06:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 51, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0138","parentId":"e0137","timestamp":"2026-01-01T00:06:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 52, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0139","parentId":"e0138","timestamp":"2026-01-01T00:06:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 52, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0140","parentId":"e0139","timestamp":"2026-01-01T00:06:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 53, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0141","parentId":"e0140","timestamp":"2026-01-01T00:06:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 53, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0142","parentId":"e0141","timestamp":"2026-01-01T00:06:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 54, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0143","parentId":"e0142","timestamp":"2026-01-01T00:06:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 54, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0144","parentId":"e0143","timestamp":"2026-01-01T00:06:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 55, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0145","parentId":"e0144","timestamp":"2026-01-01T00:06:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 55, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0146","parentId":"e0145","timestamp":"2026-01-01T00:06:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 56, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0147","parentId":"e0146","timestamp":"2026-01-01T00:06:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 56, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0148","parentId":"e0147","timestamp":"2026-01-01T00:06:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 57, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0149","parentId":"e0148","timestamp":"2026-01-01T00:06:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 57, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0150","parentId":"e0149","timestamp":"2026-01-01T00:06:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 58, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0151","parentId":"e0150","timestamp":"2026-01-01T00:06:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 58, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0152","parentId":"e0151","timestamp":"2026-01-01T00:06:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 59, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0153","parentId":"e0152","timestamp":"2026-01-01T00:06:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 59, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0154","parentId":"e0153","timestamp":"2026-01-01T00:07:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 60, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0155","parentId":"e0154","timestamp":"2026-01-01T00:07:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 60, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0156","parentId":"e0155","timestamp":"2026-01-01T00:07:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 61, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0157","parentId":"e0156","timestamp":"2026-01-01T00:07:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 61, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0158","parentId":"e0157","timestamp":"2026-01-01T00:07:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 62, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0159","parentId":"e0158","timestamp":"2026-01-01T00:07:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 62, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0160","parentId":"e0159","timestamp":"2026-01-01T00:07:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 63, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0161","parentId":"e0160","timestamp":"2026-01-01T00:07:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 63, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0162","parentId":"e0161","timestamp":"2026-01-01T00:07:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 64, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0163","parentId":"e0162","timestamp":"2026-01-01T00:07:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 64, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0164","parentId":"e0163","timestamp":"2026-01-01T00:07:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 65, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0165","parentId":"e0164","timestamp":"2026-01-01T00:07:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 65, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0166","parentId":"e0165","timestamp":"2026-01-01T00:07:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 66, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0167","parentId":"e0166","timestamp":"2026-01-01T00:07:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 66, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0168","parentId":"e0167","timestamp":"2026-01-01T00:07:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 67, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0169","parentId":"e0168","timestamp":"2026-01-01T00:07:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 67, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0170","parentId":"e0169","timestamp":"2026-01-01T00:07:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 68, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0171","parentId":"e0170","timestamp":"2026-01-01T00:07:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 68, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0172","parentId":"e0171","timestamp":"2026-01-01T00:07:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 69, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0173","parentId":"e0172","timestamp":"2026-01-01T00:07:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 69, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0174","parentId":"e0173","timestamp":"2026-01-01T00:07:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 70, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0175","parentId":"e0174","timestamp":"2026-01-01T00:07:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 70, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0176","parentId":"e0175","timestamp":"2026-01-01T00:07:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 71, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0177","parentId":"e0176","timestamp":"2026-01-01T00:07:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 71, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0178","parentId":"e0177","timestamp":"2026-01-01T00:07:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 72, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0179","parentId":"e0178","timestamp":"2026-01-01T00:07:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 72, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0180","parentId":"e0179","timestamp":"2026-01-01T00:07:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 73, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0181","parentId":"e0180","timestamp":"2026-01-01T00:07:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 73, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0182","parentId":"e0181","timestamp":"2026-01-01T00:07:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 74, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0183","parentId":"e0182","timestamp":"2026-01-01T00:07:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 74, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0184","parentId":"e0183","timestamp":"2026-01-01T00:07:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 75, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0185","parentId":"e0184","timestamp":"2026-01-01T00:07:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 75, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0186","parentId":"e0185","timestamp":"2026-01-01T00:07:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 76, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0187","parentId":"e0186","timestamp":"2026-01-01T00:07:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 76, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0188","parentId":"e0187","timestamp":"2026-01-01T00:07:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 77, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0189","parentId":"e0188","timestamp":"2026-01-01T00:07:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 77, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0190","parentId":"e0189","timestamp":"2026-01-01T00:07:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 78, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0191","parentId":"e0190","timestamp":"2026-01-01T00:07:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 78, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0192","parentId":"e0191","timestamp":"2026-01-01T00:07:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 79, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0193","parentId":"e0192","timestamp":"2026-01-01T00:07:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 79, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0194","parentId":"e0193","timestamp":"2026-01-01T00:07:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 80, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0195","parentId":"e0194","timestamp":"2026-01-01T00:07:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 80, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0196","parentId":"e0195","timestamp":"2026-01-01T00:07:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 81, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0197","parentId":"e0196","timestamp":"2026-01-01T00:07:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 81, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0198","parentId":"e0197","timestamp":"2026-01-01T00:07:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 82, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0199","parentId":"e0198","timestamp":"2026-01-01T00:07:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 82, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0200","parentId":"e0199","timestamp":"2026-01-01T00:07:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 83, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0201","parentId":"e0200","timestamp":"2026-01-01T00:07:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 83, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0202","parentId":"e0201","timestamp":"2026-01-01T00:07:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 84, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0203","parentId":"e0202","timestamp":"2026-01-01T00:07:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 84, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0204","parentId":"e0203","timestamp":"2026-01-01T00:07:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 85, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0205","parentId":"e0204","timestamp":"2026-01-01T00:07:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 85, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0206","parentId":"e0205","timestamp":"2026-01-01T00:07:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 86, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0207","parentId":"e0206","timestamp":"2026-01-01T00:07:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 86, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0208","parentId":"e0207","timestamp":"2026-01-01T00:07:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 87, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0209","parentId":"e0208","timestamp":"2026-01-01T00:07:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 87, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0210","parentId":"e0209","timestamp":"2026-01-01T00:07:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 88, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0211","parentId":"e0210","timestamp":"2026-01-01T00:07:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 88, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0212","parentId":"e0211","timestamp":"2026-01-01T00:07:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 89, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0213","parentId":"e0212","timestamp":"2026-01-01T00:07:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 89, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0214","parentId":"e0213","timestamp":"2026-01-01T00:08:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 90, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0215","parentId":"e0214","timestamp":"2026-01-01T00:08:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 90, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0216","parentId":"e0215","timestamp":"2026-01-01T00:08:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 91, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0217","parentId":"e0216","timestamp":"2026-01-01T00:08:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 91, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0218","parentId":"e0217","timestamp":"2026-01-01T00:08:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 92, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0219","parentId":"e0218","timestamp":"2026-01-01T00:08:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 92, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0220","parentId":"e0219","timestamp":"2026-01-01T00:08:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 93, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0221","parentId":"e0220","timestamp":"2026-01-01T00:08:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 93, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0222","parentId":"e0221","timestamp":"2026-01-01T00:08:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 94, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0223","parentId":"e0222","timestamp":"2026-01-01T00:08:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 94, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0224","parentId":"e0223","timestamp":"2026-01-01T00:08:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 95, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0225","parentId":"e0224","timestamp":"2026-01-01T00:08:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 95, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0226","parentId":"e0225","timestamp":"2026-01-01T00:08:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 96, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0227","parentId":"e0226","timestamp":"2026-01-01T00:08:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 96, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0228","parentId":"e0227","timestamp":"2026-01-01T00:08:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 97, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0229","parentId":"e0228","timestamp":"2026-01-01T00:08:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 97, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0230","parentId":"e0229","timestamp":"2026-01-01T00:08:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 98, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0231","parentId":"e0230","timestamp":"2026-01-01T00:08:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 98, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0232","parentId":"e0231","timestamp":"2026-01-01T00:08:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 99, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0233","parentId":"e0232","timestamp":"2026-01-01T00:08:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 99, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0234","parentId":"e0233","timestamp":"2026-01-01T00:08:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 100, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0235","parentId":"e0234","timestamp":"2026-01-01T00:08:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 100, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0236","parentId":"e0235","timestamp":"2026-01-01T00:08:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 101, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0237","parentId":"e0236","timestamp":"2026-01-01T00:08:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 101, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0238","parentId":"e0237","timestamp":"2026-01-01T00:08:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 102, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0239","parentId":"e0238","timestamp":"2026-01-01T00:08:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 102, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0240","parentId":"e0239","timestamp":"2026-01-01T00:08:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 103, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0241","parentId":"e0240","timestamp":"2026-01-01T00:08:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 103, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0242","parentId":"e0241","timestamp":"2026-01-01T00:08:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 104, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0243","parentId":"e0242","timestamp":"2026-01-01T00:08:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 104, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0244","parentId":"e0243","timestamp":"2026-01-01T00:08:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 105, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0245","parentId":"e0244","timestamp":"2026-01-01T00:08:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 105, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0246","parentId":"e0245","timestamp":"2026-01-01T00:08:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 106, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0247","parentId":"e0246","timestamp":"2026-01-01T00:08:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 106, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0248","parentId":"e0247","timestamp":"2026-01-01T00:08:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 107, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0249","parentId":"e0248","timestamp":"2026-01-01T00:08:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 107, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0250","parentId":"e0249","timestamp":"2026-01-01T00:08:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 108, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0251","parentId":"e0250","timestamp":"2026-01-01T00:08:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 108, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0252","parentId":"e0251","timestamp":"2026-01-01T00:08:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 109, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0253","parentId":"e0252","timestamp":"2026-01-01T00:08:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 109, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0254","parentId":"e0253","timestamp":"2026-01-01T00:08:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 110, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0255","parentId":"e0254","timestamp":"2026-01-01T00:08:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 110, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0256","parentId":"e0255","timestamp":"2026-01-01T00:08:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 111, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0257","parentId":"e0256","timestamp":"2026-01-01T00:08:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 111, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0258","parentId":"e0257","timestamp":"2026-01-01T00:08:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 112, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0259","parentId":"e0258","timestamp":"2026-01-01T00:08:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 112, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0260","parentId":"e0259","timestamp":"2026-01-01T00:08:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 113, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0261","parentId":"e0260","timestamp":"2026-01-01T00:08:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 113, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0262","parentId":"e0261","timestamp":"2026-01-01T00:08:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 114, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0263","parentId":"e0262","timestamp":"2026-01-01T00:08:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 114, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0264","parentId":"e0263","timestamp":"2026-01-01T00:08:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 115, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0265","parentId":"e0264","timestamp":"2026-01-01T00:08:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 115, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0266","parentId":"e0265","timestamp":"2026-01-01T00:08:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 116, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0267","parentId":"e0266","timestamp":"2026-01-01T00:08:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 116, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0268","parentId":"e0267","timestamp":"2026-01-01T00:08:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 117, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0269","parentId":"e0268","timestamp":"2026-01-01T00:08:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 117, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0270","parentId":"e0269","timestamp":"2026-01-01T00:08:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 118, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0271","parentId":"e0270","timestamp":"2026-01-01T00:08:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 118, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0272","parentId":"e0271","timestamp":"2026-01-01T00:08:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 119, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0273","parentId":"e0272","timestamp":"2026-01-01T00:08:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 119, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0274","parentId":"e0273","timestamp":"2026-01-01T00:09:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 120, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0275","parentId":"e0274","timestamp":"2026-01-01T00:09:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 120, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0276","parentId":"e0275","timestamp":"2026-01-01T00:09:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 121, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0277","parentId":"e0276","timestamp":"2026-01-01T00:09:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 121, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0278","parentId":"e0277","timestamp":"2026-01-01T00:09:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 122, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0279","parentId":"e0278","timestamp":"2026-01-01T00:09:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 122, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0280","parentId":"e0279","timestamp":"2026-01-01T00:09:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 123, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0281","parentId":"e0280","timestamp":"2026-01-01T00:09:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 123, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0282","parentId":"e0281","timestamp":"2026-01-01T00:09:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 124, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0283","parentId":"e0282","timestamp":"2026-01-01T00:09:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 124, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0284","parentId":"e0283","timestamp":"2026-01-01T00:09:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 125, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0285","parentId":"e0284","timestamp":"2026-01-01T00:09:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 125, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0286","parentId":"e0285","timestamp":"2026-01-01T00:09:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 126, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0287","parentId":"e0286","timestamp":"2026-01-01T00:09:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 126, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0288","parentId":"e0287","timestamp":"2026-01-01T00:09:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 127, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0289","parentId":"e0288","timestamp":"2026-01-01T00:09:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 127, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0290","parentId":"e0289","timestamp":"2026-01-01T00:09:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 128, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0291","parentId":"e0290","timestamp":"2026-01-01T00:09:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 128, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0292","parentId":"e0291","timestamp":"2026-01-01T00:09:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 129, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0293","parentId":"e0292","timestamp":"2026-01-01T00:09:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 129, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0294","parentId":"e0293","timestamp":"2026-01-01T00:09:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 130, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0295","parentId":"e0294","timestamp":"2026-01-01T00:09:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 130, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0296","parentId":"e0295","timestamp":"2026-01-01T00:09:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 131, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0297","parentId":"e0296","timestamp":"2026-01-01T00:09:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 131, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0298","parentId":"e0297","timestamp":"2026-01-01T00:09:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 132, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0299","parentId":"e0298","timestamp":"2026-01-01T00:09:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 132, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0300","parentId":"e0299","timestamp":"2026-01-01T00:09:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 133, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0301","parentId":"e0300","timestamp":"2026-01-01T00:09:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 133, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0302","parentId":"e0301","timestamp":"2026-01-01T00:09:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 134, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0303","parentId":"e0302","timestamp":"2026-01-01T00:09:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 134, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0304","parentId":"e0303","timestamp":"2026-01-01T00:09:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 135, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0305","parentId":"e0304","timestamp":"2026-01-01T00:09:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 135, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0306","parentId":"e0305","timestamp":"2026-01-01T00:09:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 136, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0307","parentId":"e0306","timestamp":"2026-01-01T00:09:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 136, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0308","parentId":"e0307","timestamp":"2026-01-01T00:09:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 137, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0309","parentId":"e0308","timestamp":"2026-01-01T00:09:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 137, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0310","parentId":"e0309","timestamp":"2026-01-01T00:09:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 138, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0311","parentId":"e0310","timestamp":"2026-01-01T00:09:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 138, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0312","parentId":"e0311","timestamp":"2026-01-01T00:09:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 139, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0313","parentId":"e0312","timestamp":"2026-01-01T00:09:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 139, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0314","parentId":"e0313","timestamp":"2026-01-01T00:09:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 140, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0315","parentId":"e0314","timestamp":"2026-01-01T00:09:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 140, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0316","parentId":"e0315","timestamp":"2026-01-01T00:09:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 141, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0317","parentId":"e0316","timestamp":"2026-01-01T00:09:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 141, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0318","parentId":"e0317","timestamp":"2026-01-01T00:09:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 142, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0319","parentId":"e0318","timestamp":"2026-01-01T00:09:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 142, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0320","parentId":"e0319","timestamp":"2026-01-01T00:09:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 143, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0321","parentId":"e0320","timestamp":"2026-01-01T00:09:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 143, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0322","parentId":"e0321","timestamp":"2026-01-01T00:09:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 144, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0323","parentId":"e0322","timestamp":"2026-01-01T00:09:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 144, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0324","parentId":"e0323","timestamp":"2026-01-01T00:09:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 145, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0325","parentId":"e0324","timestamp":"2026-01-01T00:09:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 145, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0326","parentId":"e0325","timestamp":"2026-01-01T00:09:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 146, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0327","parentId":"e0326","timestamp":"2026-01-01T00:09:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 146, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0328","parentId":"e0327","timestamp":"2026-01-01T00:09:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 147, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0329","parentId":"e0328","timestamp":"2026-01-01T00:09:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 147, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0330","parentId":"e0329","timestamp":"2026-01-01T00:09:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 148, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0331","parentId":"e0330","timestamp":"2026-01-01T00:09:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 148, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0332","parentId":"e0331","timestamp":"2026-01-01T00:09:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 149, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0333","parentId":"e0332","timestamp":"2026-01-01T00:09:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 149, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0334","parentId":"e0333","timestamp":"2026-01-01T00:10:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 150, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0335","parentId":"e0334","timestamp":"2026-01-01T00:10:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 150, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0336","parentId":"e0335","timestamp":"2026-01-01T00:10:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 151, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0337","parentId":"e0336","timestamp":"2026-01-01T00:10:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 151, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0338","parentId":"e0337","timestamp":"2026-01-01T00:10:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 152, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0339","parentId":"e0338","timestamp":"2026-01-01T00:10:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 152, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0340","parentId":"e0339","timestamp":"2026-01-01T00:10:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 153, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0341","parentId":"e0340","timestamp":"2026-01-01T00:10:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 153, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0342","parentId":"e0341","timestamp":"2026-01-01T00:10:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 154, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0343","parentId":"e0342","timestamp":"2026-01-01T00:10:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 154, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0344","parentId":"e0343","timestamp":"2026-01-01T00:10:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 155, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0345","parentId":"e0344","timestamp":"2026-01-01T00:10:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 155, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0346","parentId":"e0345","timestamp":"2026-01-01T00:10:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 156, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0347","parentId":"e0346","timestamp":"2026-01-01T00:10:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 156, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0348","parentId":"e0347","timestamp":"2026-01-01T00:10:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 157, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0349","parentId":"e0348","timestamp":"2026-01-01T00:10:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 157, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0350","parentId":"e0349","timestamp":"2026-01-01T00:10:16.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 158, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0351","parentId":"e0350","timestamp":"2026-01-01T00:10:17.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 158, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0352","parentId":"e0351","timestamp":"2026-01-01T00:10:18.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 159, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0353","parentId":"e0352","timestamp":"2026-01-01T00:10:19.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 159, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0354","parentId":"e0353","timestamp":"2026-01-01T00:10:20.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 160, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0355","parentId":"e0354","timestamp":"2026-01-01T00:10:21.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 160, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0356","parentId":"e0355","timestamp":"2026-01-01T00:10:22.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 161, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0357","parentId":"e0356","timestamp":"2026-01-01T00:10:23.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 161, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0358","parentId":"e0357","timestamp":"2026-01-01T00:10:24.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 162, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0359","parentId":"e0358","timestamp":"2026-01-01T00:10:25.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 162, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0360","parentId":"e0359","timestamp":"2026-01-01T00:10:26.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 163, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0361","parentId":"e0360","timestamp":"2026-01-01T00:10:27.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 163, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0362","parentId":"e0361","timestamp":"2026-01-01T00:10:28.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 164, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0363","parentId":"e0362","timestamp":"2026-01-01T00:10:29.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 164, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0364","parentId":"e0363","timestamp":"2026-01-01T00:10:30.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 165, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0365","parentId":"e0364","timestamp":"2026-01-01T00:10:31.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 165, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0366","parentId":"e0365","timestamp":"2026-01-01T00:10:32.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 166, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0367","parentId":"e0366","timestamp":"2026-01-01T00:10:33.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 166, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0368","parentId":"e0367","timestamp":"2026-01-01T00:10:34.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 167, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0369","parentId":"e0368","timestamp":"2026-01-01T00:10:35.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 167, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0370","parentId":"e0369","timestamp":"2026-01-01T00:10:36.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 168, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0371","parentId":"e0370","timestamp":"2026-01-01T00:10:37.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 168, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0372","parentId":"e0371","timestamp":"2026-01-01T00:10:38.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 169, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0373","parentId":"e0372","timestamp":"2026-01-01T00:10:39.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 169, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0374","parentId":"e0373","timestamp":"2026-01-01T00:10:40.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 170, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0375","parentId":"e0374","timestamp":"2026-01-01T00:10:41.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 170, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0376","parentId":"e0375","timestamp":"2026-01-01T00:10:42.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 171, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0377","parentId":"e0376","timestamp":"2026-01-01T00:10:43.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 171, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0378","parentId":"e0377","timestamp":"2026-01-01T00:10:44.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 172, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0379","parentId":"e0378","timestamp":"2026-01-01T00:10:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 172, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0380","parentId":"e0379","timestamp":"2026-01-01T00:10:46.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 173, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0381","parentId":"e0380","timestamp":"2026-01-01T00:10:47.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 173, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0382","parentId":"e0381","timestamp":"2026-01-01T00:10:48.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 174, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0383","parentId":"e0382","timestamp":"2026-01-01T00:10:49.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 174, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0384","parentId":"e0383","timestamp":"2026-01-01T00:10:50.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 175, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0385","parentId":"e0384","timestamp":"2026-01-01T00:10:51.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 175, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0386","parentId":"e0385","timestamp":"2026-01-01T00:10:52.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 176, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0387","parentId":"e0386","timestamp":"2026-01-01T00:10:53.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 176, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0388","parentId":"e0387","timestamp":"2026-01-01T00:10:54.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 177, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0389","parentId":"e0388","timestamp":"2026-01-01T00:10:55.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 177, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0390","parentId":"e0389","timestamp":"2026-01-01T00:10:56.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 178, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0391","parentId":"e0390","timestamp":"2026-01-01T00:10:57.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 178, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0392","parentId":"e0391","timestamp":"2026-01-01T00:10:58.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 179, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0393","parentId":"e0392","timestamp":"2026-01-01T00:10:59.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 179, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0394","parentId":"e0393","timestamp":"2026-01-01T00:11:00.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 180, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0395","parentId":"e0394","timestamp":"2026-01-01T00:11:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 180, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0396","parentId":"e0395","timestamp":"2026-01-01T00:11:02.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 181, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0397","parentId":"e0396","timestamp":"2026-01-01T00:11:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 181, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0398","parentId":"e0397","timestamp":"2026-01-01T00:11:04.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 182, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0399","parentId":"e0398","timestamp":"2026-01-01T00:11:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 182, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0400","parentId":"e0399","timestamp":"2026-01-01T00:11:06.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 183, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0401","parentId":"e0400","timestamp":"2026-01-01T00:11:07.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 183, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0402","parentId":"e0401","timestamp":"2026-01-01T00:11:08.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 184, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0403","parentId":"e0402","timestamp":"2026-01-01T00:11:09.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 184, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0404","parentId":"e0403","timestamp":"2026-01-01T00:11:10.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 185, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0405","parentId":"e0404","timestamp":"2026-01-01T00:11:11.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 185, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0406","parentId":"e0405","timestamp":"2026-01-01T00:11:12.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 186, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0407","parentId":"e0406","timestamp":"2026-01-01T00:11:13.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 186, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0408","parentId":"e0407","timestamp":"2026-01-01T00:11:14.000Z","message":{"role":"user","content":[{"type":"text","text":"live turn 187, this is the branch the supervisor should read"}]}}
{"type":"message","id":"e0409","parentId":"e0408","timestamp":"2026-01-01T00:11:15.000Z","message":{"role":"assistant","content":[{"type":"text","text":"live reply 187, this is the branch the supervisor should read"}]}}
+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(); });
}
+18
View File
@@ -0,0 +1,18 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function offlineModel(pi: ExtensionAPI): void {
pi.registerProvider("offline", {
baseUrl: process.env.PI_GOALS_OFFLINE_MODEL_URL!,
apiKey: "test",
api: "openai-completions",
models: [{
id: "test",
name: "Offline test model",
reasoning: false,
input: ["text"],
contextWindow: 16_000,
maxTokens: 1_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}],
});
}
+10
View File
@@ -0,0 +1,10 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { type ModelRole, RoleModels } from "../../src/role-models.js";
const [directory, role, provider, id] = process.argv.slice(2);
let selected: (event: any, ctx: any) => void;
const pi = { on(_name: string, handler: any) { selected = handler; }, setModel: async () => true };
const ctx = { model: { provider, id }, modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) }, ui: { notify(message: string) { throw new Error(message); } } };
const models = new RoleModels(pi as unknown as ExtensionAPI, directory);
await models.enter(role as ModelRole, ctx as unknown as ExtensionContext);
selected!({ source: "cycle", model: { provider, id } }, ctx);
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { foldPlan, openSubtasks } from "../src/index.js";
const plan = `# Plan
## User voice
- > "keep it under 50 lines"
## Goals
1. [/] goal: Implement the cache layer
- discriminator: hit-rate > 0.8 in load-test.log
- tasks:
1. [x] wire client
2. [/] eviction policy
3. [ ] bench p95
2. [ ] goal: Ship the docs
- tasks:
1. [ ] write the readme
## Log
- 2026-08-05 12:00 wired the client
## Learnings
- the tokenizer pads left, which silently shifted every offset
## Appendix (context, not approved)
${"filler line\n".repeat(200)}`;
describe("foldPlan (the working set is what gets re-sent; below ## Log is durable memory)", () => {
it("keeps the title, user voice and goals", () => {
const folded = foldPlan(plan);
expect(folded).toContain("keep it under 50 lines");
expect(folded).toContain("goal: Implement the cache layer");
expect(folded).toContain("discriminator: hit-rate > 0.8");
});
it("drops the log, the learnings and the unlimited appendix", () => {
const folded = foldPlan(plan);
expect(folded).not.toContain("wired the client");
expect(folded).not.toContain("tokenizer pads left");
expect(folded).not.toContain("filler line");
expect(folded.length).toBeLessThan(plan.length / 4);
});
it("returns the whole plan when there is no ## Log yet (a fresh draft)", () => {
const draft = "# Plan\n\n## Goals\n\n1. [ ] goal: do the thing\n";
expect(foldPlan(draft)).toBe(draft.trimEnd());
});
});
describe("openSubtasks (the widget shows the next action, so the plan IS the task list)", () => {
const active = plan.split("\n").findIndex((l) => l.includes("goal: Implement the cache layer"));
it("lists the active goal's open and in-progress subtasks, stopping at the next goal", () => {
expect(openSubtasks(plan, active)).toEqual(["eviction policy", "bench p95"]);
});
it("does not leak subtasks from the goal below", () => {
expect(openSubtasks(plan, active)).not.toContain("write the readme");
});
});
+545
View File
@@ -0,0 +1,545 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import piGoalsExtension from "../src/index.js";
vi.mock("../src/internal/supervisor/index.js", () => ({ default: () => {} }));
function setup(
selectChoices: Array<string | undefined>,
editorChoices: Array<string | undefined> = [],
editPlan?: () => Promise<string | undefined>,
) {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-flow-"));
const commands = new Map<string, any>();
const hooks = new Map<string, any>();
const tools = new Map<string, any>();
const entries: Array<{ type: string; customType: string; data?: unknown; details?: unknown; content?: string }> = [];
const events: string[] = [];
const messages: Array<{ content: string; display?: boolean; customType?: string }> = [];
const busHandlers = new Map<string, Set<(value: unknown) => unknown>>();
const bus = {
on(name: string, handler: (value: unknown) => unknown) {
const handlers = busHandlers.get(name) ?? new Set();
handlers.add(handler);
busHandlers.set(name, handlers);
return () => handlers.delete(handler);
},
emit(name: string, value: unknown) {
for (const handler of busHandlers.get(name) ?? []) void handler(value);
},
};
const ctx = {
cwd,
model: { provider: "offline", id: "test" },
modelRegistry: { find: (provider: string, id: string) => ({ provider, id }) },
hasUI: true,
isIdle: () => true,
sessionManager: { getSessionId: () => "session-a", getSessionFile: () => join(cwd, "session-a.jsonl"), getEntries: () => entries, getBranch: () => entries },
ui: {
theme: { fg: (_kind: string, text: string) => text },
setStatus: () => {},
setWidget: () => {},
notify: vi.fn(),
select: async () => {
events.push("select");
return selectChoices.shift();
},
editor: async () => {
events.push("editor");
return editPlan ? editPlan() : editorChoices.shift();
},
},
};
let active = ["read", "write", "edit", "bash", "CompleteGoal", "RequestPlanReview"];
const pi = {
getActiveTools: () => active,
setActiveTools: (names: string[]) => { active = names; },
setModel: vi.fn(async (model: { provider: string; id: string }) => { const previousModel = ctx.model; ctx.model = model; await hooks.get("model_select")?.({ model, previousModel, source: "set" }, ctx); return true; }),
registerCommand: (name: string, command: any) => commands.set(name, command),
on: (name: string, handler: any) => { const previous = hooks.get(name); hooks.set(name, async (...args: any[]) => { const prior = await previous?.(...args); return await handler(...args) ?? prior; }); },
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
registerTool: (tool: any) => tools.set(tool.name, tool),
events: bus,
sendMessage: (message: { content: string; display?: boolean; customType?: string }) => {
events.push("display");
messages.push(message);
},
sendUserMessage: (message: string) => messages.push({ content: message }),
};
piGoalsExtension(pi as unknown as ExtensionAPI);
return { pi, bus, commands, ctx, cwd, entries, events, hooks, messages, tools };
}
async function promptReminder(flow: ReturnType<typeof setup>) {
const result = await flow.hooks.get("before_agent_start")({}, flow.ctx);
if (result?.message) flow.entries.push({ type: "custom_message", ...result.message });
await flow.hooks.get("context")({ messages: [] }, flow.ctx);
return result?.message;
}
async function settleDraft(flow: ReturnType<typeof setup>) {
await flow.tools.get("RequestPlanReview").execute("", {}, undefined, undefined, flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
}
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 settleDraft(flow);
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 Discuss, and records chat answers", async () => {
const flow = setup(["Discuss"]);
try {
const legacy = join(flow.cwd, ".pi/plan/session-a.md");
mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true });
writeFileSync(legacy, "old plan");
await flow.commands.get("goals").handler("first objective", flow.ctx);
const v1 = join(flow.cwd, ".pi/plan/session-a-v1.md");
expect(readFileSync(v1, "utf-8")).toBe("");
expect(readFileSync(legacy, "utf-8")).toBe("old plan");
const plan = "# First plan\n\n## Goals\n\n1. [ ] goal: preserve this\n\n## Appendix (context, not approved)\nold context\n";
mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true });
writeFileSync(v1, plan);
await flow.hooks.get("input")({ text: "The result must preserve column order.", source: "interactive" }, flow.ctx);
await settleDraft(flow);
expect(flow.events).toEqual(["display", "select"]);
expect(flow.messages.at(-1)?.content).toContain("normal chat");
await flow.hooks.get("input")({ text: "Keep two columns.\nDo not add a filter.", source: "interactive" }, flow.ctx);
expect(flow.messages.find((message) => message.display)?.content).toContain("goal: preserve this");
const interviewedPlan = readFileSync(v1, "utf-8");
expect(interviewedPlan).toContain("> The result must preserve column order.");
expect(interviewedPlan).toMatch(/## Interview\n\n### .+\n\n> The result must preserve column order\.[\s\S]+> Keep two columns\.\n> Do not add a filter\./);
const refineSnapshot = await flow.hooks.get("before_agent_start")({}, flow.ctx);
expect(refineSnapshot.message.content).toContain("[PLANNING MODE]");
const blocked = await flow.hooks.get("tool_call")({ toolName: "edit", input: { path: "README.md" } }, flow.ctx);
expect(blocked?.block).toBe(true);
await flow.commands.get("goals").handler("second objective", flow.ctx);
expect(readFileSync(v1, "utf-8")).toBe(interviewedPlan);
expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v2.md"), "utf-8")).toBe("");
expect(flow.messages.at(-1)?.content).toContain("session-a-v2.md");
await flow.commands.get("goals").handler("compare the vendor options", flow.ctx);
expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v3.md"), "utf-8")).toBe("");
expect(flow.messages.at(-1)?.content).toContain("Objective: compare the vendor options");
await flow.commands.get("goals").handler("judge provider/model", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ judgeModel: "provider/model", planVersion: 3 });
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("disconnects without deleting the active plan", async () => {
const flow = setup([]);
try {
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: preserve this\n");
await flow.commands.get("goals").handler("clear", flow.ctx);
expect(readFileSync(planPath, "utf-8")).toContain("goal: preserve this");
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, planVersion: null });
await flow.commands.get("goals").handler("next objective", flow.ctx);
expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v2.md"), "utf-8")).toBe("");
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("Discuss allows multiple chat turns, survives reload, and reopens review for an unchanged draft", async () => {
const flow = setup(["Discuss", "Ready"]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx);
const path = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(path, "# Plan\n\n1. [ ] goal: make this specific\n");
await settleDraft(flow);
expect(flow.events).toEqual(["display", "select"]);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", reviewRequested: false });
await flow.hooks.get("agent_settled")({}, flow.ctx); // question turn, not another menu
await flow.hooks.get("input")({ text: "Only the existing output; no new UI.", source: "interactive" }, flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
await flow.hooks.get("session_start")({ reason: "reload" }, flow.ctx);
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.events).toEqual(["display", "select"]);
const unchanged = readFileSync(path, "utf8");
await settleDraft(flow);
expect(readFileSync(path, "utf8")).toBe(unchanged);
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.events).toEqual(["display", "select", "display", "select"]);
expect(flow.messages.filter(m => m.content.startsWith("Work the goals"))).toHaveLength(1);
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
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");
await settleDraft(flow);
expect(flow.events).toEqual(["display", "select"]);
expect(flow.messages.filter((message) => !message.display)).toHaveLength(2);
expect(flow.messages.at(-1)?.content).toContain("Work the goals");
await flow.hooks.get("session_start")({}, flow.ctx);
expect((await promptReminder(flow)).content).toContain("New session.");
expect(await promptReminder(flow)).toBeUndefined();
} 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 settleDraft(flow);
await flow.commands.get("goals").handler("steward on", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", stewardEnabled: false, supervisor: null });
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("edits a plan in Pi and cancels without starting work", async () => {
const original = "# Plan\n\n## Goals\n\n1. [ ] goal: original\n";
const edited = "# Plan\n\n## Goals\n\n1. [ ] goal: edited\n";
const flow = setup(["Edit", "Cancel"], [edited]);
try {
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
writeFileSync(planPath, original);
await settleDraft(flow);
expect(flow.events).toEqual(["display", "select", "editor", "display", "select"]);
expect(() => readFileSync(planPath, "utf-8")).toThrow();
expect(flow.messages.filter((message) => !message.display)).toHaveLength(1);
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
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");
await settleDraft(flow);
await flow.hooks.get("turn_end")({}, flow.ctx);
for (let turn = 0; turn < 3; turn++) await flow.hooks.get("turn_end")({}, flow.ctx);
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n\n## Log\n- checked input\n");
for (let turn = 0; turn < 5; turn++) await flow.hooks.get("turn_end")({}, flow.ctx);
expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages).toHaveLength(0);
const reminder = await promptReminder(flow);
expect(reminder.content).toContain(".pi/plan/session-a-v1.md");
expect(reminder.content).not.toContain("checked input");
expect(await promptReminder(flow)).toBeUndefined();
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n - [x] inspect input\n\n## Log\n- checked input\n");
await flow.hooks.get("turn_end")({}, flow.ctx);
for (let turn = 0; turn < 7; turn++) await flow.hooks.get("turn_end")({}, flow.ctx);
expect(await promptReminder(flow)).toBeUndefined();
await flow.hooks.get("turn_end")({}, flow.ctx);
expect((await promptReminder(flow)).content).toContain("make the output");
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("does not acknowledge an unpersisted reminder; retries fresh on the next natural prompt", async () => {
const flow = setup([]);
try {
flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", planVersion: 1, stewardEnabled: false, autoIntervalMs: null } });
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true });
writeFileSync(planPath, "1. [ ] goal: old plan\n");
await flow.hooks.get("session_start")({}, flow.ctx);
await flow.hooks.get("session_compact")({}, flow.ctx);
const unsaved = await flow.hooks.get("before_agent_start")({}, flow.ctx);
expect(unsaved.message.content).toContain("old plan");
expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages).toEqual([]);
writeFileSync(planPath, "1. [ ] goal: fresh plan\n");
const saved = await promptReminder(flow);
expect(saved.content).toContain("The session was just compacted.");
expect(saved.content).toContain("fresh plan");
expect(saved.details.reminderId).not.toBe(unsaved.message.details.reminderId);
expect(await promptReminder(flow)).toBeUndefined();
expect(flow.messages).toEqual([]); // no queued sendMessage/sendUserMessage delivery
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("auto-continues once on stop, then pauses after two no-progress wakes", async () => {
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");
await settleDraft(flow);
await flow.commands.get("goals").handler("auto 1", flow.ctx);
await settleDraft(flow);
await vi.advanceTimersByTimeAsync(0);
const autoMessages = () => flow.messages.filter((message) => message.content.includes("Auto-continue is enabled"));
expect(autoMessages()).toHaveLength(1);
await settleDraft(flow);
await vi.advanceTimersByTimeAsync(60_000);
expect(autoMessages()).toHaveLength(2);
await settleDraft(flow);
await vi.advanceTimersByTimeAsync(60_000);
expect(autoMessages()).toHaveLength(2);
} finally {
vi.useRealTimers();
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("delays auto-continuation after a known background start", async () => {
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");
await settleDraft(flow);
await flow.commands.get("goals").handler("auto 1", flow.ctx);
await flow.hooks.get("agent_start")({}, flow.ctx);
await flow.hooks.get("tool_call")({ toolName: "process", input: { action: "start" } }, flow.ctx);
await settleDraft(flow);
await vi.advanceTimersByTimeAsync(0);
const autoMessages = () => flow.messages.filter((message) => message.content.includes("Auto-continue is enabled"));
expect(autoMessages()).toHaveLength(0);
await vi.advanceTimersByTimeAsync(60_000);
expect(autoMessages()).toHaveLength(1);
} finally {
vi.useRealTimers();
rmSync(flow.cwd, { recursive: true, force: true });
}
});
it("gives the agent a planning snapshot and blocks work routes", async () => {
const flow = setup([]);
try {
await flow.commands.get("goals").handler("objective", flow.ctx);
const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md");
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" });
await flow.hooks.get("session_start")({}, flow.ctx);
const snapshot = await flow.hooks.get("before_agent_start")({}, flow.ctx);
expect(snapshot.message.content).toContain("[PLANNING MODE]");
expect(snapshot.message.content).toContain(planPath);
const writePlan = await flow.hooks.get("tool_call")({ toolName: "write", input: { path: planPath } }, flow.ctx);
const writeCode = await flow.hooks.get("tool_call")({ toolName: "write", input: { path: "README.md" } }, flow.ctx);
const readShell = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "pwd && ls && git log" } }, flow.ctx);
const changeDirectoryThenRead = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "cd . && ls -la" } }, flow.ctx);
const pipeShell = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "ls | head" } }, flow.ctx);
const pythonWrite = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "python -c \"open('README.md', 'w')\"" } }, flow.ctx);
const signoff = await flow.tools.get("CompleteGoal").execute("", { goal: "work" }, undefined, undefined, flow.ctx);
await flow.hooks.get("session_compact")({}, flow.ctx);
expect(await flow.hooks.get("context")({ messages: [] }, flow.ctx)).toBeUndefined();
const compacted = await promptReminder(flow);
expect(writePlan).toBeUndefined();
expect(writeCode?.block).toBe(true);
expect(readShell).toBeUndefined();
expect(changeDirectoryThenRead).toBeUndefined();
expect(pipeShell?.block).toBe(true);
expect(pythonWrite?.block).toBe(true);
expect(signoff.isError).toBe(true);
expect(compacted.content).toContain("[PLANNING MODE]");
expect(await promptReminder(flow)).toBeUndefined();
} finally {
rmSync(flow.cwd, { recursive: true, force: true });
}
});
});
describe("role models across goal lifecycle", () => {
const choice = (role: string) => ({ provider: `${role}-provider`, id: `${role}-model` });
const select = async (flow: ReturnType<typeof setup>, role: string) => {
flow.ctx.model = choice(role);
await flow.hooks.get("model_select")({ source: "cycle", model: flow.ctx.model }, flow.ctx);
};
const draft = (flow: ReturnType<typeof setup>, version = 1) => writeFileSync(join(flow.cwd, `.pi/plan/session-a-v${version}.md`), "# Plan\n\n1. [ ] goal: produce the artifact\n");
it("restores each role at Ready, fresh-instance reload/resume and the next plan without changing judge override", async () => {
const flow = setup(["Ready"]); const fresh = setup([]);
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("first", flow.ctx); await select(flow, "planning"); draft(flow);
await settleDraft(flow); await select(flow, "worker");
await flow.commands.get("goals").handler("judge isolated/judge", flow.ctx);
fresh.entries.push(...structuredClone(flow.entries));
await fresh.hooks.get("session_start")({ reason: "resume" }, fresh.ctx);
expect(fresh.ctx.model).toEqual(choice("worker"));
await fresh.commands.get("goals").handler("second", fresh.ctx);
expect(fresh.ctx.model).toEqual(choice("planning"));
expect(fresh.entries.at(-1)?.data).toMatchObject({ phase: "planning", judgeModel: "isolated/judge" });
await fresh.hooks.get("session_start")({ reason: "reload" }, fresh.ctx);
expect(fresh.ctx.model).toEqual(choice("planning"));
} finally { for (const f of [flow, fresh]) { await f.hooks.get("session_shutdown")({}, f.ctx); rmSync(f.cwd, { recursive: true, force: true }); } }
});
it("failed Ready and explicit cancellation never save a worker choice over planning", async () => {
const flow = setup(["Ready", "Cancel"]); vi.stubEnv("HERDR_ENV", "");
try {
await flow.commands.get("goals").handler("objective", flow.ctx); await select(flow, "planning"); draft(flow);
await settleDraft(flow); expect(flow.ctx.model).toEqual(choice("planning"));
expect(flow.messages.some(m => m.content.startsWith("Work the goals"))).toBe(false);
await settleDraft(flow);
await select(flow, "outside-plan");
await flow.commands.get("goals").handler("next plan", flow.ctx);
expect(flow.ctx.model).toEqual(choice("planning"));
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("unavailable worker model blocks Ready until an explicit selection, without changing its remembered provider", async () => {
const flow = setup(["Ready", "Ready"]);
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals"); mkdirSync(dir);
writeFileSync(join(dir, "worker-model.json"), JSON.stringify(choice("worker")));
try {
await flow.commands.get("goals").handler("steward off", flow.ctx);
await flow.commands.get("goals").handler("objective", flow.ctx); draft(flow);
flow.pi.setModel.mockResolvedValueOnce(false);
await settleDraft(flow);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" });
expect(flow.messages.some(m => m.content.startsWith("Work the goals"))).toBe(false);
expect(flow.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("has no authentication"), "error");
expect(JSON.parse(readFileSync(join(dir, "worker-model.json"), "utf8"))).toEqual(choice("worker"));
await select(flow, "replacement-worker"); await settleDraft(flow);
expect(flow.ctx.model).toEqual(choice("replacement-worker"));
expect(flow.messages.filter(m => m.content.startsWith("Work the goals"))).toHaveLength(1);
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("supervisor resume uses its own preference and never restores inherited worker automation/tools", async () => {
const flow = setup([]);
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals"); mkdirSync(dir);
writeFileSync(join(dir, "supervisor-model.json"), JSON.stringify(choice("supervisor")));
try {
flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", planVersion: 1, autoIntervalMs: 1, stewardEnabled: false } });
flow.entries.push({ type: "custom", customType: "pi-goals-supervisor", data: { binding: { id: "binding" }, workerId: "worker" } });
await flow.hooks.get("session_start")({ reason: "resume" }, flow.ctx);
expect(flow.ctx.model).toEqual(choice("supervisor"));
expect(flow.pi.getActiveTools()).not.toContain("CompleteGoal");
expect(flow.pi.getActiveTools()).not.toContain("RequestPlanReview");
await flow.hooks.get("agent_settled")({}, flow.ctx);
expect(flow.messages).toEqual([]);
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
it("waives alignment only for the current plan and Escape returns to chat without deleting the draft", async () => {
const flow = setup([undefined]);
try {
await flow.commands.get("goals").handler("first; no questions", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ questionsWaived: true });
expect(flow.messages.at(-1)?.content).toContain("THIS plan only");
await flow.commands.get("goals").handler("second", flow.ctx);
expect(flow.entries.at(-1)?.data).toMatchObject({ questionsWaived: false, reviewRequested: false });
expect(flow.messages.at(-1)?.content).toContain("previous plan does NOT apply");
draft(flow, 2);
await flow.hooks.get("agent_settled")({}, flow.ctx); expect(flow.events).toEqual([]);
await settleDraft(flow);
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", reviewRequested: false });
expect(readFileSync(join(flow.cwd, ".pi/plan/session-a-v2.md"), "utf8")).toContain("produce the artifact");
expect(flow.events).toEqual(["display", "select"]);
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
});
it("cancels an ordinary Ready handoff suspended in setModel without losing planning preference", 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);
writeFileSync(join(flow.cwd, ".pi/plan/session-a-v1.md"), "# Plan\n\n1. [ ] goal: produce output\n");
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
writeFileSync(join(dir, "worker-model.json"), JSON.stringify({ provider: "worker", id: "work" }));
let finish!: () => void;
flow.pi.setModel.mockImplementationOnce(async model => { await new Promise<void>(resolve => { finish = resolve; }); flow.ctx.model = model; return true; });
const starting = settleDraft(flow);
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
await flow.commands.get("goals").handler("clear", flow.ctx);
finish(); await starting;
expect(flow.ctx.model).toEqual({ provider: "offline", id: "test" });
expect(flow.messages.some(m => m.content.startsWith("Work the goals"))).toBe(false);
expect(JSON.parse(readFileSync(join(dir, "worker-model.json"), "utf8"))).toEqual({ provider: "worker", id: "work" });
} finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); }
});
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { backgroundState } from "../../src/internal/supervisor/background.js";
import extension from "../../src/internal/supervisor/index.js";
import { PLAN_API_EVENT, type PlanBinding, planHash } from "../../src/internal/supervisor/plan-api.js";
const tick = () => new Promise(resolve => setImmediate(resolve));
function pairHarness() {
const cwd = mkdtempSync(join(tmpdir(), "supervise-plan-"));
const peers: any[] = [];
const wire: any[] = [];
const planPath = join(cwd, "plan.md");
writeFileSync(planPath, '# Plan\n\nUser voice: render the literal "[x]"\n\n1. [ ] goal: first\n2. [ ] goal: second\n');
const binding: PlanBinding = { id: "binding", planPath, workerSession: join(cwd, "worker.jsonl"), workerPane: "w1:p1", supervisorSession: join(cwd, "supervisor.jsonl"), supervisorPane: "w1:p2", everyTurns: 50, intervalMs: 3_600_000, compactTokens: 100_000 };
function make(id: string, entries: any[] = [], sessionFile = join(cwd, `${id}.jsonl`)) {
const hooks = new Map<string, any[]>(); const listeners = new Map<string, Set<any>>(); const tools = new Map<string, any>();
const messages: any[] = []; const contexts: any[] = []; const commands = new Map<string, any>();
let active: string[] = ["read", "grep", "bash", "edit", "write"];
const peer: any = { id, messages, contexts, entries, tools, commands, modelReady: true, activeProcesses: 0, activeSubagents: 0, idle: true, compactions: 0, tokens: 50_000, aborts: 0 };
const bus = {
on(name: string, handler: any) { const set = listeners.get(name) ?? new Set(); set.add(handler); listeners.set(name, set); return () => set.delete(handler); },
emit(name: string, value: any) {
if (name === "intercom:extension-register") {
peer.receive = value.onEvent;
value.onReady({ snapshot: () => ({ connected: true, supported: true }), listSessions: async () => peers.map(p => ({ id: p.id, pid: p === peer ? process.pid : process.pid + 1, cwd, model: "test" })), publish(payload: any) {
wire.push({ from: peer.id, ...payload });
for (const p of peers) queueMicrotask(() => p.receive?.({ type: "message", fromSessionId: peer.id, payload }));
} }); return true;
}
if (name === "processes:request:list") value.reply(Array.from({ length: peer.activeProcesses }, () => ({ status: "running" })));
if (name === "subagents:rpc:v1:request") bus.emit(`subagents:rpc:v1:reply:${value.requestId}`, { requestId: value.requestId, success: true, data: { fleet: { version: 1, totalActive: peer.activeSubagents } } });
for (const handler of listeners.get(name) ?? []) handler(value);
},
};
const pi: any = { events: bus, on(name: string, fn: any) { hooks.set(name, [...(hooks.get(name) ?? []), fn]); }, registerTool(tool: any) { tools.set(tool.name, tool); active.push(tool.name); }, registerCommand(name: string, command: any) { commands.set(name, command); }, appendEntry(customType: string, data: any) { entries.push({ type: "custom", customType, data }); }, getActiveTools: () => active, setActiveTools: (names: string[]) => { active = names; }, getAllTools: () => [{ name: "subagent" }], sendMessage: (message: any) => contexts.push(message), sendUserMessage: (text: string, options: any) => messages.push({ text, options }) };
const ctx: any = { cwd, hasUI: true, model: { contextWindow: 200_000 }, isIdle: () => peer.idle, abort: () => { peer.aborts++; }, getContextUsage: () => ({ tokens: peer.tokens }), compact({ onComplete }: any) { peer.compactions++; peer.tokens = 10_000; onComplete({}); }, ui: { notify() {}, setStatus() {}, theme: { fg: (_: any, text: string) => text } }, sessionManager: { getEntries: () => entries, getBranch: () => entries, getSessionFile: () => sessionFile } };
peer.pi = pi; peer.ctx = ctx;
peer.hook = async (name: string, event = {}) => { for (const fn of hooks.get(name) ?? []) await fn(event, ctx); };
peer.request = (method: string, params: any = {}) => new Promise<any>((resolve, reject) => bus.emit(PLAN_API_EVENT, { version: 1, method, ...params, resolve, reject }));
peers.push(peer); extension(pi, () => peer.modelReady); return peer;
}
const worker = make("worker"); const supervisor = make("supervisor");
return { worker, supervisor, binding, wire, planPath, async restart(peer: any, id: string) { await peer.hook("session_shutdown"); peers.splice(peers.indexOf(peer), 1); const replacement = make(id, structuredClone(peer.entries), peer.ctx.sessionManager.getSessionFile()); await replacement.hook("session_start"); await tick(); return replacement; }, async start() { await worker.hook("session_start"); await supervisor.hook("session_start"); await worker.request("prepare", { binding }); const attached = worker.request("attached", { bindingId: binding.id }); void attached.catch(() => {}); await supervisor.request("bootstrap", { binding, workerId: "worker" }); await attached; }, async close() { for (const peer of peers) await peer.hook("session_shutdown"); rmSync(cwd, { recursive: true, force: true }); } };
}
test("plan bootstrap compacts only the supervisor and pairing alone never starts a worker or a review", async () => {
const h = pairHarness(); try {
await h.start(); assert.equal(h.worker.compactions, 0); assert.equal(h.supervisor.compactions, 1);
assert.equal(h.worker.messages.length, 0); assert.equal(h.supervisor.messages.length, 0);
assert.ok(h.supervisor.contexts.some((m: any) => m.content.includes('literal "[x]"')));
await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
assert.equal(h.supervisor.messages.length, 1); assert.match(h.supervisor.messages[0].text, /still going/);
assert.equal(h.worker.messages.length, 0);
} finally { await h.close(); }
});
test("goal decisions are correlated, preserve the pair across two goals, and cannot call overall done", async () => {
const h = pairHarness(); try {
await h.start(); const hash = planHash(readFileSync(h.planPath, "utf8"));
for (const goal of ["first", "second"]) {
const result = h.worker.request("review", { bindingId: h.binding.id, goal, planHash: hash }); await tick();
const req = h.wire.findLast((w: any) => w.t === "goal_review");
await assert.rejects(h.supervisor.tools.get("done").execute("", { reason: "done" }, undefined, undefined, h.supervisor.ctx), /Open plan goals/);
await assert.rejects(h.supervisor.tools.get("review_goal").execute("", { requestId: "stale", decision: "approve", reason: "ok" }), /No matching/);
await h.supervisor.tools.get("review_goal").execute("", { requestId: req.requestId, decision: "approve", reason: "Faithful to the plan" });
assert.equal((await result).goal, goal); assert.equal((await h.worker.request("status")).connected, true);
}
assert.equal(h.wire.filter((w: any) => w.t === "done").length, 0);
} finally { await h.close(); }
});
test("abort and stop cancel pending requests; late decisions cannot approve a replacement", async () => {
const h = pairHarness(); try {
await h.start(); const abort = new AbortController();
const pending = h.worker.request("review", { bindingId: h.binding.id, goal: "first", planHash: planHash(readFileSync(h.planPath, "utf8")), signal: abort.signal });
const rejected = assert.rejects(pending, /cancelled/); await tick(); abort.abort(); await rejected; await tick();
assert.equal(h.supervisor.aborts, 1);
await h.worker.request("stop", { bindingId: h.binding.id }); await tick(); assert.equal((await h.worker.request("status")).connected, false);
} finally { await h.close(); }
});
test("50 actual model turns trigger one view, independent of the number of messages", async () => {
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick(); h.worker.idle = false;
const count = h.wire.filter((w: any) => w.t === "view").length;
for (let n = 0; n < 49; n++) await h.worker.hook("turn_end");
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count);
await h.worker.hook("turn_end"); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count + 1);
} finally { await h.close(); }
});
test("unknown background providers are not proof of quiescence", async () => {
const result = await backgroundState({ events: { emit() {} }, getAllTools: () => [] });
assert.equal(result.quiet, false); assert.match(result.description, /unknown/);
});
test("stale plan content invalidates a pending goal review", async () => {
const h = pairHarness(); try {
await h.start(); const abort = new AbortController();
const pending = h.worker.request("review", { bindingId: h.binding.id, goal: "first", planHash: planHash(readFileSync(h.planPath, "utf8")), signal: abort.signal });
const rejected = assert.rejects(pending, /cancelled/); await tick();
const req = h.wire.findLast((w: any) => w.t === "goal_review"); writeFileSync(h.planPath, "Human changed the requirement");
await assert.rejects(h.supervisor.tools.get("review_goal").execute("", { requestId: req.requestId, decision: "approve", reason: "ok" }), /Plan changed/);
abort.abort(); await rejected;
} finally { await h.close(); }
});
test("small forks skip compaction, but real compaction failure prevents pairing", async () => {
const small = pairHarness(); try {
small.supervisor.tokens = 10_000; await small.start(); assert.equal(small.supervisor.compactions, 0);
assert.ok(small.supervisor.contexts.some((m: any) => m.content.includes('literal "[x]"')));
} finally { await small.close(); }
const failed = pairHarness(); try {
failed.supervisor.ctx.compact = ({ onError }: any) => onError(new Error("Compaction provider failed"));
await assert.rejects(failed.start(), /Compaction provider failed/);
assert.equal(failed.wire.filter((w: any) => w.t === "pair").length, 0);
} finally { await failed.close(); }
});
test("the hour timer and a coincident turn checkpoint produce a single view", async (t) => {
t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"] });
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick(); h.worker.idle = false;
const count = h.wire.filter((w: any) => w.t === "view").length;
for (let n = 0; n < 49; n++) await h.worker.hook("turn_end");
t.mock.timers.tick(3_600_000); await h.worker.hook("turn_end"); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count + 1);
} finally { await h.close(); t.mock.timers.reset(); }
});
test("settled checks wait for tracked processes and subagents to finish", async (t) => {
t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"] });
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
t.mock.timers.tick(2_000); h.worker.activeProcesses = 1; h.worker.activeSubagents = 1;
const count = h.wire.filter((w: any) => w.t === "view").length;
await h.worker.hook("agent_settled"); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count);
h.worker.activeProcesses = 0; h.worker.pi.events.emit("processes:ended", {}); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count);
h.worker.activeSubagents = 0; h.worker.pi.events.emit("subagent:async-complete", {}); await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, count + 1);
} finally { await h.close(); t.mock.timers.reset(); }
});
test("bootstrap stop cannot resurrect a supervisor after compaction completes", async () => {
const h = pairHarness(); let finish!: () => void;
h.supervisor.ctx.compact = ({ onComplete }: any) => { finish = onComplete; };
try {
const starting = h.start(); const rejected = assert.rejects(starting, /cancelled/); await tick();
await h.worker.request("stop", { bindingId: h.binding.id }); await tick(); finish(); await rejected;
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 0);
const restarted = await h.restart(h.supervisor, "stopped-bootstrap-reloaded");
await assert.rejects(restarted.request("bootstrap", { binding: h.binding, workerId: "worker" }), /was stopped/);
assert.equal(restarted.compactions, 0);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 0);
assert.equal((await restarted.request("status")).binding.stopped, true);
} finally { await h.close(); }
});
test("a restarted worker reconnects by exact saved session identity without a new supervisor", async () => {
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
const reloaded = await h.restart(h.worker, "worker-after-restart");
assert.equal((await reloaded.request("status")).connected, true);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 1);
assert.equal(h.supervisor.compactions, 1);
const pending = reloaded.request("review", { bindingId: h.binding.id, goal: "first", planHash: planHash(readFileSync(h.planPath, "utf8")) }); await tick();
const req = h.wire.findLast((w: any) => w.t === "goal_review");
await h.supervisor.tools.get("review_goal").execute("", { requestId: req.requestId, decision: "approve", reason: "Same plan" });
assert.equal((await pending).decision, "approve");
} finally { await h.close(); }
});
test("unknown initial context must compact instead of taking the known-small shortcut", async () => {
const h = pairHarness(); try {
h.supervisor.tokens = null;
await h.start();
assert.equal(h.supervisor.compactions, 1);
} finally { await h.close(); }
});
test("null post-compaction usage cannot raise the next configured 100k checkpoint", async () => {
const h = pairHarness(); try {
h.supervisor.tokens = 150_000;
h.supervisor.ctx.compact = ({ onComplete }: any) => {
h.supervisor.compactions++;
h.supervisor.tokens = null; // Native Pi has no assistant usage yet after compaction.
onComplete({ estimatedTokensAfter: 10_000 });
};
await h.start();
await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
assert.equal(h.supervisor.compactions, 1);
h.supervisor.tokens = 100_001;
await h.supervisor.receive({ type: "message", fromSessionId: "worker", payload: { t: "view", to: "supervisor", view: "New work since compaction", stopped: false } });
await tick();
assert.equal(h.supervisor.compactions, 2);
} finally { await h.close(); }
});
test("stopping a routine view during compaction invalidates its suspended continuation", async () => {
const h = pairHarness(); let finish!: () => void;
try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
h.supervisor.tokens = 150_000;
h.supervisor.ctx.compact = ({ onComplete }: any) => { finish = onComplete; };
h.supervisor.receive({ type: "message", fromSessionId: "worker", payload: { t: "view", to: "supervisor", view: "Must not restart the stopped supervisor", stopped: false } });
await tick(); assert.equal(typeof finish, "function");
await h.worker.request("stop", { bindingId: h.binding.id }); await tick();
const messages = h.supervisor.messages.length;
const contexts = h.supervisor.contexts.length;
const entries = h.supervisor.entries.length;
finish(); await tick();
assert.equal(h.supervisor.messages.length, messages);
assert.equal(h.supervisor.contexts.length, contexts);
assert.equal(h.supervisor.entries.length, entries);
assert.equal((await h.supervisor.request("status")).binding.stopped, true);
} finally { await h.close(); }
});
test("restart of a provisional bootstrap resumes compaction and pairing in the same saved session", async () => {
const h = pairHarness(); let finish!: () => void;
try {
await h.worker.hook("session_start"); await h.supervisor.hook("session_start");
await h.worker.request("prepare", { binding: h.binding });
const attached = h.worker.request("attached", { bindingId: h.binding.id });
void attached.catch(() => {});
h.supervisor.ctx.compact = ({ onComplete }: any) => { finish = onComplete; };
const interrupted = h.supervisor.request("bootstrap", { binding: h.binding, workerId: "worker" });
const rejected = assert.rejects(interrupted, /cancelled/); await tick();
assert.equal(h.supervisor.entries.at(-1).data.role, "supervisor");
assert.notEqual(h.supervisor.entries.at(-1).data.planInitialized, true);
const restarted = await h.restart(h.supervisor, "supervisor-restarted");
finish(); await rejected;
assert.equal(h.wire.filter((w: any) => w.t === "look" || w.t === "plan_hello").length, 0);
await restarted.request("bootstrap", { binding: h.binding, workerId: "worker" });
const ready = await attached;
assert.equal(ready.supervisorSession, h.binding.supervisorSession);
assert.equal(restarted.ctx.sessionManager.getSessionFile(), h.binding.supervisorSession);
assert.equal(restarted.compactions, 1);
assert.equal(restarted.entries.at(-1).data.planInitialized, true);
assert.equal((await h.worker.request("status")).connected, true);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 1);
// An acknowledged reload reuses the pairing rather than bootstrapping once again.
const acknowledged = await h.restart(restarted, "supervisor-acknowledged-reloaded");
await acknowledged.request("bootstrap", { binding: h.binding, workerId: "worker" });
assert.equal(acknowledged.compactions, 0);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, 1);
} finally { await h.close(); }
});
for (const stop of ["command", "done"]) test(`${stop} preserves a stopped supervisor across reload`, async () => {
const h = pairHarness(); try {
await h.start();
if (stop === "command") await h.supervisor.commands.get("supervise").handler("stop", h.supervisor.ctx);
else {
writeFileSync(h.planPath, "# Plan\n\n1. [x] goal: first\n2. [x] goal: second\n");
await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
await h.supervisor.tools.get("done").execute("", { reason: "All goals accepted" }, undefined, undefined, h.supervisor.ctx);
}
await tick(); const pairCount = h.wire.filter((w: any) => w.t === "pair").length;
const restarted = await h.restart(h.supervisor, `${stop}-reloaded`);
await assert.rejects(restarted.request("bootstrap", { binding: h.binding, workerId: "worker" }), /was stopped/);
assert.equal(restarted.compactions, 0);
assert.equal(h.wire.filter((w: any) => w.t === "pair").length, pairCount);
assert.equal((await restarted.request("status")).binding.stopped, true);
} finally { await h.close(); }
});
test("same-binding replay retains activation when the supervisor lost its acknowledgement", async () => {
const h = pairHarness(); try {
await h.worker.hook("session_start"); await h.supervisor.hook("session_start");
await h.worker.request("prepare", { binding: h.binding });
const attached = h.worker.request("attached", { bindingId: h.binding.id });
// Simulate loss of the supervisor before it receives/persists the acknowledgement.
// The worker still receives pair and can finish Ready independently of that process.
h.supervisor.receive = () => {};
void h.supervisor.request("bootstrap", { binding: h.binding, workerId: "worker" }).catch(() => {});
await attached;
await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
assert.equal((await h.worker.request("status")).binding.active, true);
assert.notEqual(h.supervisor.entries.at(-1).data.planInitialized, true);
assert.notEqual(h.supervisor.entries.at(-1).data.plan.active, true);
const restarted = await h.restart(h.supervisor, "supervisor-after-lost-ack");
await restarted.request("bootstrap", { binding: h.binding, workerId: "worker" });
assert.equal(restarted.ctx.sessionManager.getSessionFile(), h.binding.supervisorSession);
assert.equal((await h.worker.request("status")).binding.active, true);
assert.equal(h.wire.findLast((w: any) => w.t === "paired").plan.active, true);
assert.equal((await restarted.request("status")).binding.active, true);
assert.equal(restarted.entries.at(-1).data.planInitialized, true);
const views = h.wire.filter((w: any) => w.t === "view").length;
const messages = restarted.messages.length;
h.worker.idle = false;
for (let n = 0; n < 50; n++) await h.worker.hook("turn_end");
await tick();
assert.equal(h.wire.filter((w: any) => w.t === "view").length, views + 1);
assert.equal(restarted.messages.length, messages + 1);
assert.equal(h.wire.filter((w: any) => w.t === "plan_activate").length, 1);
assert.equal(h.worker.messages.length, 0); // No new Ready/work handoff was necessary.
} finally { await h.close(); }
});
test("model-unavailable stop validates binding, cancels pending reviews and ignores old directives", async () => {
const h = pairHarness(); try {
await h.start(); await h.worker.request("activate", { bindingId: h.binding.id }); await tick();
const abort = new AbortController();
const pending = h.worker.request("review", { bindingId: h.binding.id, goal: "first", planHash: planHash(readFileSync(h.planPath, "utf8")), signal: abort.signal });
const cancelled = assert.rejects(pending, /cancelled/); await tick();
h.worker.modelReady = false; abort.abort(); await cancelled; await tick();
await assert.rejects(h.worker.request("stop", { bindingId: "wrong-binding" }), /No matching plan pairing/);
await h.worker.request("stop", { bindingId: h.binding.id }); await tick();
assert.equal(h.worker.entries.at(-1).data.role, "none");
assert.equal(h.supervisor.entries.at(-1).data.role, "none");
const before = h.worker.messages.length;
h.worker.modelReady = true;
h.worker.receive({ type: "message", fromSessionId: "supervisor", payload: { t: "directive", to: "worker", text: "stale instruction" } });
await tick(); assert.equal(h.worker.messages.length, before);
} finally { await h.close(); }
});
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { copyFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { childPiProcesses } from "../../src/internal/supervisor/subagents.js";
/**
* Runs the real ps command against a real child process, because the parsing is the part most
* likely to be quietly wrong: a column order or a comm name that differs by platform reads as
* "no subagents", which is the answer that lets the supervisor declare a busy worker finished.
*/
function spawnChildNamedPi() {
const dir = mkdtempSync(join(tmpdir(), "supervisor-test-"));
const fake = join(dir, "pi");
copyFileSync("/usr/bin/sleep", fake); // ps reports comm from the executable name
return spawn(fake, ["30"], { stdio: "ignore" });
}
test("a child process named pi is found by ps, and stops being found when it exits", async (t) => {
if (process.platform !== "linux" && process.platform !== "darwin") return t.skip("ps only");
assert.deepEqual(await childPiProcesses(), [], "no subagents before one is started");
const child = spawnChildNamedPi();
await new Promise((r) => setTimeout(r, 300));
assert.deepEqual(await childPiProcesses(), [child.pid], "the running child must be reported");
child.kill();
await new Promise((r) => child.once("exit", r));
assert.deepEqual(await childPiProcesses(), [], "and not reported once it exits");
});
test("the check is a snapshot, so it cannot hold up the worker's settle", async (t) => {
if (process.platform !== "linux" && process.platform !== "darwin") return t.skip("ps only");
const child = spawnChildNamedPi();
await new Promise((r) => setTimeout(r, 300));
const started = Date.now();
assert.deepEqual(await childPiProcesses(), [child.pid]);
assert.ok(Date.now() - started < 1000, "it returns while the child is still running, it does not wait");
child.kill();
});
+314
View File
@@ -0,0 +1,314 @@
import assert from "node:assert/strict";
import test from "node:test";
import { age, buildView, type Entry, goalPreview, MAX_VIEW_BYTES, outstandingWork, progressKey, sinceLastTurn, turnsSince } from "../../src/internal/supervisor/view.js";
function assistant(text: string, calls: Array<{ name: string; args: Record<string, unknown> }> = []): Entry {
return {
type: "message",
message: {
role: "assistant",
content: [
{ type: "text", text },
...calls.map((c) => ({ type: "toolCall", name: c.name, arguments: c.args })),
],
},
};
}
function toolResult(toolName: string, text: string, isError = false): Entry {
return { type: "message", message: { role: "toolResult", toolName, isError, content: [{ type: "text", text }] } };
}
test("a one-line goal stays whole while a multi-line goal has a locator", () => {
assert.equal(goalPreview("fix the parser"), "fix the parser");
assert.equal(goalPreview("Build the causal evaluation.\nThe full rubric follows."), "Build the causal evaluation. [...]");
const view = buildView({
goal: "Build the causal evaluation.\nThe full rubric follows.",
status: "idle",
entries: [assistant("working")],
});
assert.match(view, /<goal>\nBuild the causal evaluation\. \[\.\.\.\]\n<\/goal>/);
assert.doesNotMatch(view, /The full rubric follows/);
});
test("a view carries only the turns the supervisor has not been sent", () => {
// The supervisor is a real session and keeps every view it read, so re-sending the whole
// transcript each time is a second copy of what it already has. It grows with every review.
const entries = [assistant("the first thing I did"), assistant("the second thing I did")];
const first = buildView({ goal: "g", status: "idle", entries });
assert.match(first, /the first thing I did/);
assert.equal(turnsSince(entries), 2);
entries.push(assistant("the third thing I did"));
const next = buildView({ goal: "g", status: "idle", entries, since: 2 });
assert.match(next, /the third thing I did/);
assert.doesNotMatch(next, /the first thing I did/, "already sent, so it must not go again");
assert.match(next, /# New turns since your last look \(1 of 3\)/);
});
test("the last two reasoning blocks stay in the narrative, and older ones drop out", () => {
// Block shape read out of a real session jsonl. pi-vcc's normalize keeps only text and toolCall,
// so reasoning reaches nobody, although you see it on screen. It belongs beside the tool call it
// produced, not in a section of its own, because that is the order you read a session in.
const thinker = (thinking: string, said: string): Entry => ({
type: "message",
message: { role: "assistant", content: [{ type: "thinking", thinking }, { type: "text", text: said }] },
});
const entries = [
thinker("TOO OLD TO SEND", "first try"),
thinker("the bash tool is dead, LET ME TEST READ", "second try"),
thinker("read works, SO I WILL DELEGATE INSTEAD", "third try"),
];
const view = buildView({ goal: "g", status: "working", entries });
assert.match(view, /LET ME TEST READ[\s\S]*SO I WILL DELEGATE INSTEAD/, "in order, oldest first");
assert.match(view, /LET ME TEST READ[\s\S]*second try/, "each thought sits with the turn it produced");
assert.doesNotMatch(view, /TOO OLD TO SEND/, "two blocks, or the view becomes a second transcript");
assert.match(view, /\(thinking\)/, "marked, so the supervisor knows it is reasoning and not speech");
});
test("a compaction restarts the view, so no turn falls into the gap", () => {
// getBranch keeps the entries a compaction replaced, so the mark now points past the end. Read
// the wrong slice here and the supervisor silently reads a piece of the old history.
const entries: Entry[] = [
assistant("old work"),
{ type: "compaction", summary: "SUMMARY OF THE OLD WORK" },
assistant("work after the compaction"),
];
const view = buildView({ goal: "g", status: "idle", entries, since: 9 });
assert.match(view, /The worker compacted, so this view restarts/);
assert.match(view, /SUMMARY OF THE OLD WORK/);
assert.match(view, /work after the compaction/);
assert.equal(turnsSince(entries), 1, "the mark restarts from the compaction, not from the session");
});
test("pi-vcc reports the files the worker wrote, and separates them from the ones it read", () => {
const view = buildView({
goal: "g",
status: "idle",
entries: [
assistant("editing", [{ name: "edit", args: { path: "src/a.ts" } }]),
assistant("reading", [{ name: "read", args: { path: "src/never.ts" } }]),
assistant("writing", [{ name: "write", args: { file_path: "docs/b.md" } }]),
],
});
assert.match(view, /Modified:.*src\/a\.ts/);
assert.match(view, /Read:.*src\/never\.ts/);
});
test("progressKey is unchanged when a review produced no new file or commit", () => {
const worked = [assistant("editing", [{ name: "edit", args: { path: "src/a.ts" } }])];
const talked = [...worked, assistant("I will look into that shortly.")];
const wroteMore = [...worked, assistant("editing", [{ name: "write", args: { path: "src/b.ts" } }])];
assert.equal(progressKey(talked), progressKey(worked), "talking is not progress");
assert.notEqual(progressKey(wroteMore), progressKey(worked), "a new file is progress");
});
test("progressKey still sees a new file past pi-vcc's ten path display cap", () => {
// The rendered section stops at ten paths and says "(+N more)", so reading it there froze this
// key on exactly the long runs it exists for.
const many = (n: number) =>
Array.from({ length: n }, (_, i) => assistant("editing", [{ name: "edit", args: { path: `src/f${i}.ts` } }]));
assert.notEqual(progressKey(many(13)), progressKey(many(12)), "the 13th file must count as progress");
});
test("a commit counts as progress, even when no file was written since", () => {
// A worker whose whole turn was committing looked stagnant while the view line claims to count
// commits.
const ran = (cmd: string, out: string): Entry[] => [
assistant("committing", [{ name: "bash", args: { command: cmd } }]),
toolResult("bash", out),
];
const before = ran("git status", "nothing to commit");
const after = [...before, ...ran(`git commit -m "fix the parser"`, "[main abc1234] fix the parser")];
assert.notEqual(progressKey(after), progressKey(before));
});
test("outstandingWork finds tool calls that never got a result", () => {
const answered: Entry = {
type: "message",
message: { role: "toolResult", toolName: "read", toolCallId: "call-1", content: [{ type: "text", text: "ok" }] },
};
const entries: Entry[] = [
{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "read" }] } },
answered,
{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "call-2", name: "subagent" }] } },
];
assert.deepEqual(outstandingWork(entries), ["subagent"]);
assert.deepEqual(outstandingWork([entries[0], answered]), []);
});
test("buildView reports a tool call with no result, so done can be refused", () => {
const busy = buildView({
goal: "g",
status: "idle",
entries: [{ type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "x", name: "subagent", arguments: {} }] } }],
});
assert.match(busy, /^tool calls with no result: subagent$/m);
const quiet = buildView({ goal: "g", status: "idle", entries: [assistant("all done")] });
assert.match(quiet, /^tool calls with no result: none$/m);
});
test("the view says how many reviews in a row changed nothing, and says nothing at zero", () => {
const entries = [assistant("hi")];
assert.match(buildView({ goal: "g", status: "idle", entries, stale: 3 }), /no new file or commit for 3 reviews/);
assert.doesNotMatch(buildView({ goal: "g", status: "idle", entries }), /reviews in a row/);
});
test("the view merges the worker's compaction summary with the turns after it", () => {
// pi-vcc's compile() takes the old summary as previousSummary, so nothing between the summary
// and the newest turn falls in the gap between them.
const view = buildView({
goal: "g",
status: "idle",
entries: [
{ type: "compaction", summary: "[Session Goal]\n- build the dataset" },
assistant("carrying on", [{ name: "write", args: { path: "after.md" } }]),
],
});
assert.match(view, /build the dataset/, "the summary from before the compaction survives");
assert.match(view, /after\.md/, "so does the work done after it");
});
test("a turn the compaction summary already covers is not sent twice", () => {
// getBranch keeps the entries a compaction replaced, so passing all of them alongside the
// summary spends the byte budget on two copies of the same work.
const view = buildView({
goal: "g",
status: "idle",
entries: [
assistant("COVERED BY THE SUMMARY", [{ name: "edit", args: { path: "old.ts" } }]),
{ type: "compaction", summary: "[Session Goal]\n- build the dataset" },
assistant("after the compaction"),
],
});
assert.match(view, /build the dataset/);
assert.match(view, /after the compaction/);
assert.doesNotMatch(view, /COVERED BY THE SUMMARY/);
});
test("pi-vcc's sections and its transcript land on the right sides of the split", () => {
// compile() emits `sections + "\n\n---\n\n" + transcript`, and drops either part when it is
// empty. Splitting that wrong put the sections under "Recent turns", where the byte cut eats
// them from the top.
const view = buildView({
goal: "g",
status: "idle",
entries: [{ type: "message", message: { role: "user", content: "make the results table" } }],
});
const [above, below] = view.split("# Turns so far");
assert.match(above, /\[Session Goal\]/, "the sections belong above");
assert.match(below, /make the results table/, "the transcript belongs below");
assert.doesNotMatch(below, /\[Session Goal\]/);
});
test("the view does not tell the supervisor to use vcc_recall, a tool it does not have", () => {
const view = buildView({ goal: "g", status: "idle", entries: [assistant("hi")] });
assert.doesNotMatch(view, /vcc_recall/);
});
test("supervisor directives are not sent back as worker evidence", () => {
const directive: Entry = {
type: "message",
message: { role: "user", content: "[supervisor] Read exactly src/a.ts and quote it." },
};
const first = buildView({
goal: "g",
status: "working",
entries: [directive, assistant("I read src/a.ts", [{ name: "read", args: { path: "src/a.ts" } }])],
});
assert.doesNotMatch(first, /Read exactly src\/a\.ts/);
assert.match(first, /I read src\/a\.ts/);
const next = buildView({
goal: "g",
status: "working",
entries: [directive, assistant("I read src/a.ts", [{ name: "read", args: { path: "src/a.ts" } }]), directive],
since: 1,
});
assert.match(next, /# New turns since your last look \(0 of 1\)/);
assert.doesNotMatch(next, /Read exactly src\/a\.ts/);
});
test("bookkeeping tool calls are kept out of the transcript", () => {
const view = buildView({
goal: "g",
status: "idle",
entries: [
assistant("planning", [{ name: "TodoWrite", args: {} }]),
toolResult("TodoWrite", "todo list updated with 5 items"),
assistant("real work", [{ name: "edit", args: { path: "src/a.ts" } }]),
],
});
assert.doesNotMatch(view, /todo list updated/);
assert.match(view, /src\/a\.ts/);
});
test("buildView reports the goal, status, and files without historical failures", () => {
const view = buildView({
goal: "make the table",
status: "idle",
entries: [assistant("done", [{ name: "write", args: { path: "results.md" } }]), toolResult("bash", "exit code 2")],
});
assert.match(view, /<goal>\nmake the table\n<\/goal>/);
assert.match(view, /status: idle/);
assert.match(view, /turns: 2/);
assert.match(view, /results\.md/);
assert.doesNotMatch(view, /# Problems|bash exit 2/);
});
test("how long the worker has been quiet, measured from its own last entry", () => {
// The number wassname asked for after the 2h27m silence: "there have been no turns for this
// long, is it stuck". Measured from the worker's last message, not from the supervisor's last
// look, so it reads the same whether the worker sits at the prompt or hangs inside one command.
const now = Date.parse("2026-08-14T04:29:00Z");
const at = (iso: string): Entry => ({ ...assistant("working"), timestamp: iso });
const stopped = [at("2026-08-14T01:00:00Z"), at("2026-08-14T02:02:18Z")];
assert.equal(age(sinceLastTurn(stopped, now)), "2h27m", "the real overnight gap, from session 019ffa73");
const supervisorMessage: Entry = {
type: "message",
timestamp: "2026-08-14T04:28:59Z",
message: { role: "user", content: "[supervisor] Do not reset the worker clock." },
};
assert.equal(age(sinceLastTurn([...stopped, supervisorMessage], now)), "2h27m", "a supervisor directive is not worker progress");
// The last non-supervisor entry wins even when an older one follows it in some other order, and a branch with
// no timestamps at all reports 0 rather than throwing.
assert.equal(age(sinceLastTurn([at("2026-08-14T04:28:30Z")], now)), "30s");
assert.equal(age(sinceLastTurn([at("2026-08-14T03:44:00Z")], now)), "45m");
assert.equal(sinceLastTurn([assistant("no stamp")], now), 0);
});
test("buildView keeps the newest turns when it has to cut for the channel limit", () => {
const long = Array.from({ length: 400 }, (_, i) => assistant(`turn ${i} ${"x".repeat(400)}`));
const view = buildView({ goal: "g", status: "idle", entries: long });
assert.ok(Buffer.byteLength(view, "utf-8") <= MAX_VIEW_BYTES, `view was ${Buffer.byteLength(view)} bytes`);
assert.match(view, /turn 399/, "the newest turn must survive the cut");
assert.doesNotMatch(view, /turn 0 /, "the oldest must be the one dropped");
});
test("pi's own branch logic drops the abandoned fork, on a session file", async () => {
// Synthetic fixture copied from the upstream make-fixture.ts output; no real transcript.
// An earlier version of this test asserted on the rendered view, and passed even when fed every
// abandoned entry, because byte trimming removed them anyway.
const { parseSessionEntries, buildContextEntries } = await import("@earendil-works/pi-coding-agent");
const { readFileSync } = await import("node:fs");
const fixture = new URL("../fixtures/forked-session.jsonl", import.meta.url).pathname;
const all = parseSessionEntries(readFileSync(fixture, "utf-8")).filter((e: any) => e.type !== "session");
const byId = new Map(all.map((e: any) => [e.id, e]));
const leaf = all[all.length - 1] as any;
const branch = buildContextEntries(all as any, leaf.id, byId as any);
const asText = (entries: any[]) => JSON.stringify(entries);
assert.match(asText(all), /ABANDONED/, "the fixture must contain an abandoned branch");
assert.doesNotMatch(asText(branch), /ABANDONED/, "the live branch must not contain the abandoned fork");
assert.equal(all.length - branch.length, 22);
});
test("a long goal cannot push the view past the broker limit", () => {
const view = buildView({ goal: "x".repeat(60000), status: "idle", entries: [assistant("hi")] });
assert.ok(Buffer.byteLength(view, "utf-8") <= MAX_VIEW_BYTES, `view was ${Buffer.byteLength(view)} bytes`);
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildJudgeArgs } from "../src/index.js";
describe("buildJudgeArgs", () => {
it("omits --model when judgeModel is null (pi uses its configured default; never a pre-emptive 'no model' failure)", () => {
const args = buildJudgeArgs(null);
expect(args).not.toContain("--model");
// an empty --model "" would make every sign-off silently inconclusive -- guard against it
const i = args.indexOf("--model");
expect(i).toBe(-1);
});
it("includes --model <ref> when an explicit/session model is set", () => {
const args = buildJudgeArgs("openrouter/~anthropic/claude-haiku-latest");
const i = args.indexOf("--model");
expect(i).not.toBe(-1);
expect(args[i + 1]).toBe("openrouter/~anthropic/claude-haiku-latest");
});
it("always sets --no-session, --no-extensions, the read-only tool allowlist, and edit/write exclusion", () => {
for (const m of [null, "some/model"]) {
const args = buildJudgeArgs(m);
expect(args).toContain("--no-session");
expect(args).toContain("--no-extensions"); // a broken global extension must not take down sign-offs
expect(args).toContain("--tools");
expect(args.some((a) => a.startsWith("read,grep,find,ls"))).toBe(true);
// no bash: the judge must never be able to execute (or re-run a 10-hour verify) or mutate
expect(args.some((a) => a.includes("bash"))).toBe(false);
expect(args).toContain("--exclude-tools");
expect(args.some((a) => a.includes("edit") && a.includes("write"))).toBe(true);
}
});
});
-171
View File
@@ -1,171 +0,0 @@
import { describe, expect, it } from "vitest";
import { appendLog, counts, findGoal, parse, recordSignOff, setGoalStatus } from "../src/plan-file.js";
const SAMPLE = `# Plan: ship the cache layer
## Goal: Implement cache layer
<!-- id: cache-layer-1 -->
status: active
done_when: p95 < 50ms on bench-X. If wrong: timeouts in load-test.log
verify: pytest tests/cache -q
failure_modes:
- cache silently bypassed (hit-rate ~0, latency ok by luck)
- bench too small to exercise eviction
- [x] wire cache client
- [ ] eviction policy
- [ ] load test
## Goal: Document the API
<!-- id: document-the-api-1 -->
status: open
done_when: every public fn has a docstring; else sphinx warns
failure_modes:
- docstrings exist but are stale
## Log
- 2026-06-15 14:02 cache client wired; eviction next
`;
/** Multiset line diff: lines b adds vs removes vs a (order-insensitive, so insertions score added:1). */
function lineDelta(a: string, b: string): { added: number; removed: number } {
const count = (s: string) => {
const m = new Map<string, number>();
for (const l of s.split("\n")) m.set(l, (m.get(l) ?? 0) + 1);
return m;
};
const ma = count(a);
const mb = count(b);
let added = 0;
let removed = 0;
for (const k of new Set([...ma.keys(), ...mb.keys()])) {
const d = (mb.get(k) ?? 0) - (ma.get(k) ?? 0);
if (d > 0) added += d;
else if (d < 0) removed += -d;
}
return { added, removed };
}
describe("parse", () => {
const doc = parse(SAMPLE);
it("reads the objective and both goals", () => {
expect(doc.objective).toBe("ship the cache layer");
expect(doc.goals.map((g) => g.id)).toEqual(["cache-layer-1", "document-the-api-1"]);
});
it("reads goal fields", () => {
const g = findGoal(doc, "cache-layer-1");
expect(g?.subject).toBe("Implement cache layer");
expect(g?.status).toBe("active");
expect(g?.done_when).toBe("p95 < 50ms on bench-X. If wrong: timeouts in load-test.log");
expect(g?.verify).toBe("pytest tests/cache -q");
});
it("separates failure_modes from subtasks", () => {
const g = findGoal(doc, "cache-layer-1");
expect(g?.failure_modes).toHaveLength(2);
expect(g?.failure_modes[0]).toContain("cache silently bypassed");
expect(g?.subtasks).toEqual([
{ text: "wire cache client", done: true },
{ text: "eviction policy", done: false },
{ text: "load test", done: false },
]);
});
it("reads the log verbatim and counts by status", () => {
expect(doc.log).toEqual(["- 2026-06-15 14:02 cache client wired; eviction next"]);
expect(counts(doc)).toEqual({ done: 0, open: 1, active: 1 });
});
});
describe("failure_modes vs subtask disambiguation", () => {
it("a column-0 checkbox right after failure_modes: is a SUBTASK", () => {
const doc = parse(
`# Plan: x\n\n## Goal: G\n<!-- id: g-1 -->\nstatus: open\ndone_when: z\nfailure_modes:\n- [ ] first subtask\n- [x] second subtask\n`,
);
const g = findGoal(doc, "g-1");
expect(g?.failure_modes).toEqual([]);
expect(g?.subtasks).toEqual([
{ text: "first subtask", done: false },
{ text: "second subtask", done: true },
]);
});
it("an indented checkbox-shaped item inside failure_modes is a FAILURE MODE", () => {
const doc = parse(
`# Plan: x\n\n## Goal: G\n<!-- id: g-2 -->\nstatus: open\ndone_when: z\nfailure_modes:\n - [ ] prose that looks like a checkbox\n- [ ] real subtask\n`,
);
const g = findGoal(doc, "g-2");
expect(g?.failure_modes).toEqual(["[ ] prose that looks like a checkbox"]);
expect(g?.subtasks).toEqual([{ text: "real subtask", done: false }]);
});
it("a goal with no failure_modes keeps its subtasks", () => {
const doc = parse(`# Plan: x\n\n## Goal: G\n<!-- id: g-3 -->\nstatus: open\ndone_when: z\n- [ ] only subtask\n`);
const g = findGoal(doc, "g-3");
expect(g?.failure_modes).toEqual([]);
expect(g?.subtasks).toEqual([{ text: "only subtask", done: false }]);
});
});
describe("the two CompleteGoal writes (minimal diff)", () => {
it("setGoalStatus replaces exactly one line, scoped to the right goal", () => {
const next = setGoalStatus(SAMPLE, "cache-layer-1", "done");
expect(lineDelta(SAMPLE, next)).toEqual({ added: 1, removed: 1 });
expect(findGoal(parse(next), "cache-layer-1")?.status).toBe("done");
expect(findGoal(parse(next), "document-the-api-1")?.status).toBe("open"); // untouched
});
it("setGoalStatus targets the second goal without touching the first", () => {
const next = setGoalStatus(SAMPLE, "document-the-api-1", "active");
expect(findGoal(parse(next), "cache-layer-1")?.status).toBe("active");
expect(findGoal(parse(next), "document-the-api-1")?.status).toBe("active");
});
it("appendLog adds exactly one line under ## Log", () => {
const next = appendLog(SAMPLE, "2026-06-15 15:00 eviction done");
expect(lineDelta(SAMPLE, next)).toEqual({ added: 1, removed: 0 });
expect(parse(next).log).toEqual([
"- 2026-06-15 14:02 cache client wired; eviction next",
"- 2026-06-15 15:00 eviction done",
]);
});
it("appendLog creates the section when absent", () => {
const noLog = "# Plan: x\n\n## Goal: y\n<!-- id: y-1 -->\nstatus: open\ndone_when: z\n";
expect(parse(appendLog(noLog, "first entry")).log).toEqual(["- first entry"]);
});
});
describe("recordSignOff (CompleteGoal's pure record logic)", () => {
const WHEN = "2026-06-15 16:00";
it("accept flips status:done and logs a sign-off line", () => {
const r = recordSignOff(SAMPLE, "cache-layer-1", WHEN, { kind: "accepted" });
expect(r.isError).toBe(false);
const doc = parse(r.content);
expect(findGoal(doc, "cache-layer-1")?.status).toBe("done");
expect(doc.log.at(-1)).toBe(`- ${WHEN} signed off #cache-layer-1: Implement cache layer (oracle accept)`);
});
it("verify_failed only logs a reject line, status stays active", () => {
const r = recordSignOff(SAMPLE, "cache-layer-1", WHEN, { kind: "verify_failed", exitCode: 1, outputTail: "boom" });
expect(r.isError).toBe(true);
const doc = parse(r.content);
expect(findGoal(doc, "cache-layer-1")?.status).toBe("active"); // NOT marked done
expect(doc.log.at(-1)).toBe(`- ${WHEN} reject #cache-layer-1: verify exit 1`);
});
it("rejected logs the (one-lined) missing reason, status stays", () => {
const r = recordSignOff(SAMPLE, "cache-layer-1", WHEN, { kind: "rejected", missing: "no\nsaved\nbench log" });
expect(r.isError).toBe(true);
expect(findGoal(parse(r.content), "cache-layer-1")?.status).toBe("active");
expect(parse(r.content).log.at(-1)).toBe(`- ${WHEN} reject #cache-layer-1: no saved bench log`);
});
it("unknown goal returns an error and does not touch the file", () => {
const r = recordSignOff(SAMPLE, "nope-1", WHEN, { kind: "accepted" });
expect(r.isError).toBe(true);
expect(r.content).toBe(SAMPLE);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { alignmentPolicy, judgeSystem, planDrafting, planningState, reminder, resync, waivesAlignment } from "../src/prompts.js";
describe("planning prompt", () => {
it("requires fact finding or a focused question before a goal", () => {
expect(planDrafting).toContain("Use read-only repository tools or web search when either can\nresolve a fact.");
expect(planDrafting).toContain("ask the human to confirm your interpretation");
expect(planDrafting).toContain("approve an editorial or other preference choice");
expect(planDrafting).toContain("ask at least THREE distinct task-specific alignment");
expect(planDrafting).toContain("questions in ONE chat round");
expect(planDrafting).toContain("Wait for the human's answers and use them");
expect(planDrafting).toContain("self-contained: state the relevant\ncontext, use the human's language and ASD-STE100");
expect(planDrafting).toContain("placeholder goal such as \"work out the thing\"");
expect(planDrafting).toContain("object, observable result, settled scope, and required approval");
});
it("waives questions only for an explicit current-objective instruction", () => {
for (const objective of ["fix it, no questions", "skip questions and implement", "do not ask me any questions", "no q's", "skip q's", "fix it; no qs please"]) {
expect(waivesAlignment(objective)).toBe(true);
expect(alignmentPolicy(waivesAlignment(objective))).toContain("THIS plan only");
}
for (const objective of ["next objective", "fix the exporter; do not skip questions", "don't skip questions", "add a 'skip questions' button", 'add a "no questions" mode', 'document "first; skip questions; then build"']) expect(waivesAlignment(objective), objective).toBe(false);
expect(alignmentPolicy(false)).toContain("previous plan does NOT apply");
});
it("restores the same rule after compaction", () => {
expect(planningState(".pi/plan/test.md")).toContain("web search\nwhen either can resolve a fact.");
expect(planningState(".pi/plan/test.md")).toContain("choice that needs their approval");
expect(planningState(".pi/plan/test.md")).toContain("self-contained round with relevant context and a recommendation");
});
it("anchors work and sign-off to the user-visible result", () => {
expect(planDrafting).toContain("## User-visible result");
expect(planDrafting).toContain("Take it from the original request, not from your implementation plan");
expect(planDrafting).toContain("Future work may not defer any artifact or action named there");
expect(reminder("plan", ".pi/plan/test.md")).toContain("latest message outranks this plan");
expect(resync("plan", ".pi/plan/test.md", "Compacted.")).toContain("amend the plan rather than preserving an obsolete decision");
expect(judgeSystem).toContain("Task fidelity?");
expect(judgeSystem).toContain("Agent-inferred scope is not authority");
});
});
+185
View File
@@ -0,0 +1,185 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { describe, expect, it } from "vitest";
import piGoalsExtension from "../src/index.js";
// Point at the installed Pi package to exercise its real lifecycle, not the API mock.
const sdkRoot = process.env.PI_GOALS_TEST_SDK_ROOT ?? resolve("node_modules/@earendil-works/pi-coding-agent");
const sdk = await import(pathToFileURL(join(sdkRoot, "dist/index.js")).href);
const { loadExtensionFromFactory } = await import(pathToFileURL(join(sdkRoot, "dist/core/extensions/loader.js")).href);
const requireSdk = createRequire(join(sdkRoot, "package.json"));
const aiRoot = requireSdk.resolve.paths("@earendil-works/pi-ai")!.map(path => join(path, "@earendil-works/pi-ai")).find(path => existsSync(join(path, "package.json")))!;
const { convertResponsesMessages } = await import(pathToFileURL(join(aiRoot, "dist/api/openai-responses-shared.js")).href);
const sdkVersion = JSON.parse(readFileSync(join(sdkRoot, "package.json"), "utf8")).version;
// Optional read-only check against the real guard; no native endpoint or private checkpoint used.
const replay = process.env.PI_GOALS_TEST_REPLAY_ROOT
? await import(pathToFileURL(join(process.env.PI_GOALS_TEST_REPLAY_ROOT, "src/payload-rewrite.ts")).href) : undefined;
const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
const plan = (revision: string) => `# Plan ${revision}\n\n1. [/] goal: test saved reminders\n\n## Log\n- fixture log ${revision}\n\n## Appendix\nfixture appendix ${revision}\n`;
const compactReminder = (message: any) => typeof message.content === "string" && message.content.includes("The session was just compacted.");
async function setup(phase = "working") {
sdk.initTheme("dark", false);
const cwd = mkdtempSync(join(tmpdir(), "goals-reminder-sdk-"));
const sm = sdk.SessionManager.create(cwd, join(cwd, "sessions"));
const events: string[] = [];
const requests: any[][] = [];
const errors: unknown[] = [];
let responses: Array<{ tool?: boolean; high?: boolean; overflow?: boolean; queue?: boolean }> = [];
let compactions = 0;
const settingsManager = sdk.SettingsManager.inMemory({ compaction: { enabled: false, reserveTokens: 1000, keepRecentTokens: 100 }, retry: { enabled: false } });
const runtime = sdk.createExtensionRuntime();
const bus = sdk.createEventBus();
const extensions = [];
extensions.push(await loadExtensionFromFactory(piGoalsExtension, cwd, bus, runtime));
let session: any;
extensions.push(await loadExtensionFromFactory((pi: any) => {
pi.on("before_agent_start", () => { events.push("before_agent_start"); });
pi.on("session_compact", (event: any) => { events.push(`compact:${event.reason}:${event.willRetry}`); });
pi.on("session_before_compact", (event: any) => {
compactions++;
return { compaction: { summary: `Offline checkpoint ${compactions}`, firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { compactedWindow: [{ type: "compaction", encrypted_content: "offline-fixture-only" }] } } };
});
pi.registerTool({ name: "fixture_tool", label: "fixture", description: "Offline no-op", parameters: { type: "object", properties: {} }, execute: async () => ({ content: [{ type: "text", text: "fixture result" }], details: {} }) });
}, cwd, bus, runtime));
const resourceLoader = {
getExtensions: () => ({ extensions, errors: [], runtime }),
getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }),
getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => "Offline reminder test", getSystemPromptSource: () => undefined,
getAppendSystemPrompt: () => [], getAppendSystemPromptSources: () => [], extendResources: () => {}, reload: async () => {},
};
const modelRuntime = await sdk.ModelRuntime.create({ authPath: join(cwd, "auth.json"), modelsPath: join(cwd, "models.json"), modelsStorePath: join(cwd, "models-store.json"), allowModelNetwork: false });
await modelRuntime.setRuntimeApiKey("openai", "offline-fixture-key");
const model = { ...modelRuntime.getModel("openai", "gpt-4.1"), contextWindow: 10000 };
expect(model.id).toBe("gpt-4.1");
const assistant = (content: any[], stopReason = "stop", input = 100) => ({ role: "assistant", content, api: model.api, provider: model.provider, model: model.id, stopReason, usage: { input, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: input + 1, cost: zeroCost }, timestamp: Date.now() });
sm.appendMessage({ role: "user", content: "Fixture history", timestamp: Date.now() - 1000 });
sm.appendMessage({ ...assistant([{ type: "text", text: "Fixture history response" }]), timestamp: Date.now() - 900 });
sm.appendCustomEntry("pi-goals-state", { defaultsVersion: 1, phase, planVersion: 1, stewardEnabled: false, autoIntervalMs: null, reviewRequested: false });
const planPath = join(cwd, ".pi/plan", `${sm.getSessionId()}-v1.md`);
mkdirSync(dirname(planPath), { recursive: true }); writeFileSync(planPath, plan("initial"));
({ session } = await sdk.createAgentSession({ cwd, agentDir: process.env.PI_CODING_AGENT_DIR, model, modelRuntime, sessionManager: sm, settingsManager, resourceLoader, tools: ["fixture_tool"] }));
session.subscribe((event: any) => {
if (event.type === "message_end" && event.message.role === "assistant" && event.message.errorMessage && event.message.errorMessage !== "maximum context length exceeded") errors.push(event.message.errorMessage);
});
const serialize = (messages: any[]) => convertResponsesMessages(model, { messages: sdk.convertToLlm(messages) }, new Set(["openai", "openai-codex", "opencode"]));
session.agent.streamFunction = async (_model: any, context: any) => {
const actual = serialize(context.messages);
const saved = serialize(sm.buildSessionContext().messages);
expect(actual).toEqual(saved); // Provider-visible history matches saved history AT request time.
const disk = sdk.SessionManager.open(sm.getSessionFile());
expect(serialize(disk.buildSessionContext().messages)).toEqual(saved);
const checkpoint = sm.getBranch().findLast((entry: any) => entry.type === "compaction");
if (replay && checkpoint) {
const args = { model, payload: { model: model.id, instructions: "Offline reminder test", input: actual }, branchEntries: sm.getBranch(), compactionEntry: checkpoint };
expect(replay.rewriteResponsesPayloadWithNativeReplay(args).ok).toBe(true);
// Negative control: the original ephemeral suffix must still be rejected by the guard.
expect(replay.rewriteResponsesPayloadWithNativeReplay({ ...args, payload: { ...args.payload, input: [...actual, { role: "user", content: [{ type: "input_text", text: "<system-reminder>unsaved plan</system-reminder>" }] }] } })).toMatchObject({ ok: false, reason: "expected-pi-replay-mismatch" });
}
requests.push(structuredClone(context.messages));
const next = responses.shift() ?? {};
if (next.queue) await session.steer("Queued user must retain order");
const message = assistant(next.tool ? [{ type: "toolCall", id: `fixture-${requests.length}`, name: "fixture_tool", arguments: {} }] : [{ type: "text", text: "Offline answer" }], next.overflow ? "error" : next.tool ? "toolUse" : "stop", next.high ? 9500 : 100);
if (next.overflow) { message.errorMessage = "maximum context length exceeded"; message.usage.input = 0; }
return { async *[Symbol.asyncIterator]() { yield next.overflow ? { type: "error", reason: "error", error: message } : { type: "done", reason: message.stopReason, message }; }, result: async () => message };
};
await session.bindExtensions({ onError: (error: unknown) => errors.push(error) });
return {
session, sm, events, requests, errors, planPath, settingsManager,
respond: (...next: typeof responses) => { responses = next; },
reminders: () => sm.getBranch().filter((entry: any) => entry.type === "custom_message" && compactReminder(entry)),
close: () => { session.dispose(); rmSync(cwd, { recursive: true, force: true }); },
};
}
describe(`saved goal reminders (Pi SDK ${sdkVersion}${replay ? ", real replay guard" : ""})`, () => {
it.each(["working", "planning"])("manual compact in %s: fresh saved reminder once on next natural prompt", async (phase) => {
const flow = await setup(phase);
try {
await flow.session.prompt("Initial request");
const priorSnapshotIds = new Set(flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message").map((entry: any) => entry.id));
await flow.session.compact();
const checkpointId = flow.sm.getBranch().findLast((entry: any) => entry.type === "compaction").id;
expect(flow.events).toContain("compact:manual:false");
expect(flow.requests).toHaveLength(1);
writeFileSync(flow.planPath, plan("fresh-after-compact"));
await flow.session.prompt("Natural request");
const snapshots = flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === (phase === "working" ? "pi-goals-plan-reminder" : "pi-goals-planning-context"));
const count = snapshots.length;
const latest = snapshots.at(-1);
expect(latest.display).toBe(false);
expect(priorSnapshotIds.has(latest.id)).toBe(false);
const branch = flow.sm.getBranch();
expect(branch.findIndex((entry: any) => entry.id === latest.id)).toBeGreaterThan(branch.findIndex((entry: any) => entry.id === checkpointId));
expect(flow.requests[1].some((message: any) => message.role === "user" && Array.isArray(message.content) && message.content.some((part: any) => part.type === "text" && part.text === latest.content))).toBe(true);
if (phase === "working") { expect(flow.reminders()).toHaveLength(1); expect(flow.reminders()[0].content).toContain("fixture appendix fresh-after-compact"); }
await flow.session.prompt("Another natural request");
expect(flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === snapshots[0].customType)).toHaveLength(count);
expect(flow.requests).toHaveLength(3);
expect(flow.errors).toEqual([]);
} finally { flow.close(); }
});
it.each(["threshold", "overflow"])("post-run %s compact: no extra run, refresh at next natural prompt", async (reason) => {
const flow = await setup();
try {
flow.settingsManager.applyOverrides({ compaction: { enabled: true } });
flow.respond(reason === "threshold" ? { high: true } : { overflow: true }, {});
await flow.session.prompt("Run and compact");
expect(flow.events).toContain(`compact:${reason}:${reason === "overflow"}`);
expect(flow.requests).toHaveLength(reason === "threshold" ? 1 : 2);
expect(flow.reminders()).toHaveLength(0);
expect(flow.events.filter(event => event === "before_agent_start")).toHaveLength(1);
writeFileSync(flow.planPath, plan("next-natural"));
await flow.session.prompt("Next natural prompt");
expect(flow.reminders()).toHaveLength(1);
expect(flow.reminders()[0].content).toContain("fixture appendix next-natural");
expect(flow.errors).toEqual([]);
} finally { flow.close(); }
});
it("stale tool-loop reminder waits for a natural prompt and persists only the working set", async () => {
const flow = await setup();
try {
flow.respond(...Array.from({ length: 8 }, () => ({ tool: true })), {});
await flow.session.prompt("Long tool run");
expect(flow.requests).toHaveLength(9);
const count = () => flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === "pi-goals-plan-reminder").length;
expect(count()).toBe(1); // startup only; no turn_end/ephemeral reminders
await flow.session.prompt("Natural prompt after staleness");
expect(count()).toBe(2);
const text = JSON.stringify(flow.requests.at(-1).at(-1));
expect(text).toContain("test saved reminders");
expect(text).not.toContain("fixture appendix");
await flow.session.prompt("No duplicate");
expect(count()).toBe(2);
expect(flow.errors).toEqual([]);
} finally { flow.close(); }
});
it.skipIf(Number(sdkVersion.split(".")[1]) < 85).each([false, true])("mid-run threshold, queued user=%s: defer without drops, duplicates, or extra response", async (queue) => {
const flow = await setup();
try {
flow.settingsManager.applyOverrides({ compaction: { enabled: true } });
flow.respond({ tool: true, high: true, queue }, {});
await flow.session.prompt("Use fixture tool");
expect(flow.events).toContain("compact:threshold:false");
expect(flow.requests).toHaveLength(2);
expect(JSON.stringify(flow.requests[1][0])).toContain("Offline checkpoint"); // compacted before the tool continuation, not just after the run
expect(flow.events.filter(event => event === "before_agent_start")).toHaveLength(1);
expect(flow.reminders()).toHaveLength(0);
if (queue) expect(JSON.stringify(flow.requests[1].at(-1))).toContain("Queued user must retain order");
writeFileSync(flow.planPath, plan("after-auto"));
await flow.session.prompt("Next natural prompt");
expect(flow.reminders()).toHaveLength(1);
expect(flow.reminders()[0].content).toContain("fixture appendix after-auto");
await flow.session.prompt("No duplicate");
expect(flow.reminders()).toHaveLength(1);
expect(flow.requests).toHaveLength(4);
expect(flow.errors).toEqual([]);
} finally { flow.close(); }
});
});
+95
View File
@@ -0,0 +1,95 @@
import { execFile } from "node:child_process";
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { type ModelChoice, type ModelRole, RoleModels } from "../src/role-models.js";
function harness(directory: string, current: ModelChoice = { provider: "initial", id: "model" }) {
let selected: any;
const ctx = { model: current, modelRegistry: { find: vi.fn((provider: string, id: string) => ({ provider, id })) }, ui: { notify: vi.fn() } };
const pi = {
on(_name: string, handler: any) { selected = handler; },
setModel: vi.fn(async (model: ModelChoice) => { const previousModel = ctx.model; ctx.model = model; if (previousModel.provider !== model.provider || previousModel.id !== model.id) selected({ source: "set", previousModel, model }, ctx); return true; }),
};
const models = new RoleModels(pi as unknown as ExtensionAPI, directory);
return {
models, ctx, pi,
enter: (role: ModelRole) => models.enter(role, ctx as unknown as ExtensionContext),
select: (source: "set" | "cycle" | "restore", model: ModelChoice) => { ctx.model = model; selected({ source, model }, ctx); },
};
}
const stored = (dir: string, role: string) => JSON.parse(readFileSync(join(dir, `${role}-model.json`), "utf8"));
describe("role-models storage and public model_select", () => {
it("inherits current on first use, remembers set/cycle independently, and ignores automatic set and restore", async () => {
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
const h = harness(dir);
for (const role of ["planning", "worker", "supervisor"] as const) {
const inherited = { ...h.ctx.model };
expect(await h.enter(role)).toBe(true);
expect(stored(dir, role)).toEqual(inherited);
h.select(role === "planning" ? "set" : "cycle", { provider: role, id: `${role}-choice` });
}
for (const role of ["planning", "worker", "supervisor"] as const) {
expect(await h.enter(role)).toBe(true);
expect(h.ctx.model).toEqual({ provider: role, id: `${role}-choice` });
h.select("restore", { provider: "session", id: "old" });
expect(stored(dir, role)).toEqual({ provider: role, id: `${role}-choice` });
}
h.models.leave(); h.select("cycle", { provider: "outside", id: "unrelated" });
expect(stored(dir, "supervisor").provider).toBe("supervisor");
});
it("restores after a fresh instance/process and avoids cross-role lost updates", async () => {
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
const h = harness(dir);
await h.enter("planning"); h.select("cycle", { provider: "planner", id: "persisted" });
await Promise.all(["worker", "supervisor"].map(role => promisify(execFile)(process.execPath, ["--import", "tsx", resolve("test/fixtures/role-model-process.ts"), dir, role, `${role}-provider`, "persisted"])));
const fresh = harness(dir, { provider: "unrelated", id: "start" });
for (const role of ["planning", "worker", "supervisor"] as const) {
await fresh.enter(role);
expect(fresh.ctx.model).toEqual({ provider: role === "planning" ? "planner" : `${role}-provider`, id: "persisted" });
expect(Object.keys(stored(dir, role))).toEqual(["provider", "id"]);
}
});
it.each(["unavailable", "unauthenticated"])("visibly pauses a %s remembered model without replacing it", async failure => {
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
const h = harness(dir); await h.enter("worker");
h.select("set", { provider: "chosen", id: "keep" });
const fresh = harness(dir, { provider: "other-provider", id: "fallback" });
if (failure === "unavailable") fresh.ctx.modelRegistry.find.mockReturnValue(undefined as any);
else fresh.pi.setModel.mockResolvedValue(false);
expect(await fresh.enter("worker")).toBe(false);
expect(fresh.models.ready).toBe(false);
expect(fresh.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Saved choice unchanged"), "error");
expect(fresh.ctx.model.provider).toBe("other-provider");
expect(stored(dir, "worker")).toEqual({ provider: "chosen", id: "keep" });
fresh.select("restore", { provider: "fallback", id: "automatic" });
expect(stored(dir, "worker").provider).toBe("chosen");
fresh.select("cycle", { provider: "explicit", id: "replacement" });
expect(fresh.models.ready).toBe(true);
expect(stored(dir, "worker").provider).toBe("explicit");
});
});
it("explicit use-current recovers the paused role when same-model selection emits no event", async () => {
const dir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals");
const h = harness(dir); await h.enter("worker"); h.select("set", { provider: "missing", id: "worker-choice" });
const fresh = harness(dir, { provider: "current", id: "working-model" });
fresh.ctx.modelRegistry.find.mockReturnValue(undefined as any);
expect(await fresh.enter("worker")).toBe(false);
await fresh.pi.setModel(fresh.ctx.model); // Real Pi suppresses this model_select.
expect(fresh.models.ready).toBe(false);
expect(stored(dir, "worker").provider).toBe("missing");
fresh.pi.setModel.mockResolvedValueOnce(false);
expect(await fresh.models.useCurrent(fresh.ctx as unknown as ExtensionContext)).toBe(false);
expect(stored(dir, "worker").provider).toBe("missing");
expect(await fresh.models.useCurrent(fresh.ctx as unknown as ExtensionContext)).toBe(true);
expect(fresh.models.activeRole).toBe("worker");
expect(fresh.models.ready).toBe(true);
expect(stored(dir, "worker")).toEqual({ provider: "current", id: "working-model" });
});
+153
View File
@@ -0,0 +1,153 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { StringDecoder } from "node:string_decoder";
import { describe, expect, it } from "vitest";
type RpcMessage = { type: string; id?: string; method?: string; [key: string]: unknown };
class RpcClient {
readonly messages: RpcMessage[] = [];
private readonly waiters: Array<{ predicate: (message: RpcMessage) => boolean; resolve: (message: RpcMessage) => void }> = [];
constructor(readonly process: ChildProcessWithoutNullStreams) {
const decoder = new StringDecoder("utf8");
let buffer = "";
process.stdout.on("data", (chunk) => {
buffer += decoder.write(chunk);
while (buffer.includes("\n")) {
const newline = buffer.indexOf("\n");
const line = buffer.slice(0, newline).replace(/\r$/, "");
buffer = buffer.slice(newline + 1);
if (!line) continue;
const message = JSON.parse(line) as RpcMessage;
this.messages.push(message);
const index = this.waiters.findIndex(({ predicate }) => predicate(message));
if (index !== -1) this.waiters.splice(index, 1)[0].resolve(message);
}
});
}
send(message: RpcMessage): void {
this.process.stdin.write(`${JSON.stringify(message)}\n`);
}
waitFor(predicate: (message: RpcMessage) => boolean, after = 0): Promise<RpcMessage> {
const existing = this.messages.slice(after).find(predicate);
if (existing) return Promise.resolve(existing);
return new Promise((resolvePromise) => this.waiters.push({ predicate, resolve: resolvePromise }));
}
}
function streamResponse(response: import("node:http").ServerResponse, delta: object, finishReason: "stop" | "tool_calls"): void {
response.writeHead(200, { "content-type": "text/event-stream" });
response.write(`data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`);
response.write(`data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: finishReason }] })}\n\n`);
response.end("data: [DONE]\n\n");
}
describe("RPC review flow", () => {
it.each([false, true])("alignment and Discuss before one Ready handoff; real-Pi same-current recovery=%s", async (recoverCurrent) => {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-rpc-"));
const modelDir = join(cwd, ".agent", "pi-goals"); mkdirSync(modelDir, { recursive: true });
if (recoverCurrent) writeFileSync(join(modelDir, "worker-model.json"), JSON.stringify({ provider: "missing", id: "unavailable-worker" }));
let requestCount = 0;
let planPath = "";
const server = createServer((_request, response) => {
requestCount++;
if (requestCount === 1 || requestCount === 4) {
streamResponse(response, { content: "1. Should the output be a text file? 2. Keep the existing CLI only? 3. Does a saved PASS receipt prove success?" }, "stop");
return;
}
if (requestCount === 3 || requestCount === 6) {
streamResponse(response, { tool_calls: [{ index: 0, id: `review-${requestCount}`, type: "function", function: { name: "RequestPlanReview", arguments: "{}" } }] }, "tool_calls");
return;
}
if (requestCount === 2) {
streamResponse(response, {
tool_calls: [{
index: 0,
id: "write-plan",
type: "function",
function: {
name: "write",
arguments: JSON.stringify({
path: planPath,
content: "# Plan\n\n## Goals\n\n1. [ ] goal: name the output\n - subtle failure mode: the output has no name\n - discriminator: the plan names the output\n\n## Log\n\n## Interview\n",
}),
},
}],
}, "tool_calls");
return;
}
streamResponse(response, { content: "Plan drafted." }, "stop");
});
await new Promise<void>((resolvePromise) => server.listen(0, "127.0.0.1", resolvePromise));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Offline model did not bind a TCP port.");
const pi = spawn(resolve("node_modules/.bin/pi"), [
"--mode", "rpc", "--no-session", "--model", "offline/test",
"-e", resolve("test/fixtures/offline-model.ts"),
"-e", resolve("src/index.ts"),
], {
cwd,
env: {
...process.env,
PI_CODING_AGENT_DIR: join(cwd, ".agent"),
PI_GOALS_OFFLINE_MODEL_URL: `http://127.0.0.1:${address.port}`,
},
});
const client = new RpcClient(pi);
try {
client.send({ type: "get_state", id: "state" });
const state = await client.waitFor((message) => message.type === "response" && message.id === "state");
const sessionId = (state.data as { sessionId: string }).sessionId;
planPath = join(cwd, ".pi", "plan", `${sessionId}-v1.md`);
client.send({ type: "prompt", id: "off", message: "/goals steward off" });
await client.waitFor(message => message.type === "response" && message.id === "off");
client.send({ type: "prompt", id: "goals", message: "/goals name the output file" });
await client.waitFor(message => message.type === "agent_end");
expect(requestCount).toBe(1);
expect(client.messages.some(message => message.method === "select")).toBe(false);
client.send({ type: "prompt", id: "answers", message: "Text file, existing CLI only, and a saved PASS receipt." });
const review = await client.waitFor(message => message.type === "extension_ui_request" && message.method === "select");
expect(review.options).toEqual(["Ready", "Discuss", "Edit", "Cancel"]);
const discussionAt = client.messages.length;
client.send({ type: "extension_ui_response", id: review.id, value: "Discuss" });
await client.waitFor(message => message.type === "agent_end", discussionAt);
expect(requestCount).toBe(4);
expect(client.messages.slice(discussionAt).some(message => message.method === "editor" || message.method === "select")).toBe(false);
const answerAt = client.messages.length;
client.send({ type: "prompt", id: "discuss-answer", message: "Use output.txt, no UI changes." });
await client.waitFor(message => message.type === "agent_end", answerAt);
expect(client.messages.slice(answerAt).some(message => message.method === "select")).toBe(false);
client.send({ type: "prompt", id: "finish-discussion", message: "Yes, that is enough; the draft is still right." });
const reviewedAgain = await client.waitFor(message => message.type === "extension_ui_request" && message.method === "select", discussionAt);
const readyAt = client.messages.length;
client.send({ type: "extension_ui_response", id: reviewedAgain.id, value: "Ready" });
if (recoverCurrent) {
await client.waitFor(message => message.type === "extension_ui_request" && message.method === "notify" && JSON.stringify(message).includes("worker model paused"), readyAt);
client.send({ type: "set_model", id: "same-current", provider: "offline", modelId: "test" });
expect((await client.waitFor(message => message.type === "response" && message.id === "same-current")).success).toBe(true);
expect(JSON.parse(readFileSync(join(modelDir, "worker-model.json"), "utf8"))).toEqual({ provider: "missing", id: "unavailable-worker" });
expect(client.messages.slice(readyAt).some(message => message.method === "select")).toBe(false);
client.send({ type: "prompt", id: "use-current", message: "/goals model current" });
const recoveryMenu = await client.waitFor(message => message.type === "extension_ui_request" && message.method === "select", readyAt);
expect(JSON.parse(readFileSync(join(modelDir, "worker-model.json"), "utf8"))).toEqual({ provider: "offline", id: "test" });
expect(JSON.parse(readFileSync(join(modelDir, "planning-model.json"), "utf8"))).toEqual({ provider: "offline", id: "test" });
client.send({ type: "extension_ui_response", id: recoveryMenu.id, value: "Ready" });
}
await client.waitFor(message => message.type === "agent_end", readyAt);
expect(client.messages.filter(message => message.type === "message_start" && JSON.stringify(message).includes("Work the goals"))).toHaveLength(1);
expect(requestCount).toBe(7);
} finally {
pi.kill();
server.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 15_000);
});
+105
View File
@@ -0,0 +1,105 @@
import { execFileSync, 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 { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
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("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-"));
// Pack real production dependencies, then run outside every checkout with no install/symlink.
const packed = JSON.parse(execFileSync("npm", ["pack", "--json", "--ignore-scripts", "--cache", "/tmp/pi-goals-npm-cache", "--pack-destination", cwd], { cwd: resolve("."), encoding: "utf8" }))[0];
execFileSync("tar", ["-xzf", join(cwd, packed.filename), "-C", cwd]);
const packageRoot = join(cwd, "package");
const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
expect(manifest.bundledDependencies).toEqual(["pi-intercom", "@sting8k/pi-vcc"]);
expect(existsSync(join(packageRoot, "node_modules/pi-intercom/broker/broker.ts"))).toBe(true);
expect(existsSync(join(packageRoot, "src/internal/supervisor/index.ts"))).toBe(true);
expect(existsSync(join(packageRoot, "node_modules/@sting8k/pi-vcc/src/core/summarize.ts"))).toBe(true);
expect(packed.files.some((file: { path: string }) => /node_modules\/(?:@earendil-works|typebox)\//.test(file.path))).toBe(false);
expect(existsSync(join(packageRoot, "THIRD_PARTY_NOTICES.md"))).toBe(true);
const host = join(cwd, "herdr-test-host.ts");
writeFileSync(host, readFileSync(resolve("test/fixtures/herdr-test-host.ts"), "utf8").replace('"../../src/index.js"', JSON.stringify(join(packageRoot, "src/index.ts"))));
// Only the worker's Herdr exec is wrapped. Supervisor loads the untouched package manifest.
const resources = manifest.pi.extensions.flatMap((path: string) => ["-e", path === "./src/index.ts" ? host : join(packageRoot, path)]);
let planPath = ""; let reviewCalls = 0; let judgeCalls = 0;
const supervisorModels: string[] = []; const judgeModels: string[] = []; const workerModels: string[] = [];
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" && messages.at(-2)?.tool_calls?.some((c: any) => c.function.name === "write")) return call("RequestPlanReview", {});
if (last.role === "tool") return stream(res, { content: "Check complete." });
if (names.includes("review_goal")) {
supervisorModels.push(body.model);
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" });
workerModels.push(body.model); return stream(res, { content: "Worker is ready." });
}
judgeCalls++; judgeModels.push(body.model); 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: ["test", "planning", "worker", "supervisor", "judge"].map(id => ({ id, name: `Offline ${id}`, reasoning: false, input: ["text"], contextWindow: 200_000, maxTokens: 1000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } })) } } }));
const modelDir = join(agentDir, "pi-goals"); mkdirSync(modelDir);
for (const role of ["worker", "supervisor"]) writeFileSync(join(modelDir, `${role}-model.json`), JSON.stringify({ provider: "offline", id: role }));
writeFileSync(join(cwd, "evidence.txt"), "PASS\n");
const child = spawn(resolve("node_modules/.bin/pi"), ["--mode", "rpc", "--no-extensions", "--model", "offline/test", ...resources], {
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");
send({ type: "set_model", id: "planning-model", provider: "offline", modelId: "planning" });
expect((await wait(e => e.type === "response" && e.id === "planning-model")).success).toBe(true);
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);
send({ type: "prompt", id: "judge-model", message: "/goals judge offline/judge" });
await wait(e => e.type === "response" && e.id === "judge-model");
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");
expect(workerModels).toEqual(["worker"]);
expect(supervisorModels.length).toBeGreaterThan(0); expect(supervisorModels.every(model => model === "supervisor")).toBe(true);
expect(judgeModels).toEqual(["judge"]);
for (const role of ["planning", "worker", "supervisor"]) expect(JSON.parse(readFileSync(join(modelDir, `${role}-model.json`), "utf8"))).toEqual({ provider: "offline", id: role });
} finally { send({ type: "abort" }); child.kill(); server.close(); await new Promise(done => child.once("close", done)); rmSync(cwd, { recursive: true, force: true }); }
}, 55_000);
});
+8
View File
@@ -0,0 +1,8 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
let agentDir: string;
beforeEach(() => { agentDir = mkdtempSync(join(tmpdir(), "goals-model-prefs-")); vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); });
afterEach(() => { rmSync(agentDir, { recursive: true, force: true }); vi.unstubAllEnvs(); });
+214
View File
@@ -0,0 +1,214 @@
import { EventEmitter } from "node:events";
import { mkdirSync, 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 tick = () => new Promise(resolve => setImmediate(resolve));
afterEach(() => { vi.unstubAllEnvs(); judge.calls = []; });
describe("actual goals and supervisor package hooks (Herdr and judge mocked)", () => {
it.each(["completion", "replacement during activation", "steward off during activation", "missing worker model", "unauthenticated worker model", "clear while model unavailable", "off while model unavailable", "clear during recovery restore", "replacement during recovery restore"])("Ready forks once and preserves lifecycle ownership: %s", async (scenario) => {
const cwd = mkdtempSync(join(tmpdir(), "goals-supervisor-integration-"));
const peers: any[] = [];
const wires: any[] = [];
const herdrCalls: string[][] = [];
const modelDir = join(process.env.PI_CODING_AGENT_DIR!, "pi-goals"); mkdirSync(modelDir);
for (const role of ["worker", "supervisor"]) writeFileSync(join(modelDir, `${role}-model.json`), JSON.stringify({ provider: "offline", id: role }));
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, compactionModels: [], modelChanges: [], 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) { if (wire.t === "plan_activate") expect(peer.ctx.model.id).not.toBe("planning"); 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 = {
setModel: async (model: any) => {
if (peer.noAuth && model.id === "worker") return false;
if (peer.deferWorkerRestore && model.id === "worker") {
peer.deferWorkerRestore = false;
await new Promise<void>(resolve => { peer.finishRestore = resolve; });
}
const previousModel = ctx.model; ctx.model = model; peer.modelChanges.push(model.id);
manager.appendModelChange(model.provider, model.id);
await peer.hook("model_select", { source: "set", model, previousModel }); return true;
},
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: "internal-supervisor" } }],
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: (manager.getBranch().findLast((entry: any) => entry.type === "model_change") as any)?.modelId ?? "test", contextWindow: 200_000 }, sessionManager: manager,
modelRegistry: { find: (provider: string, id: string) => peer.missing && id === "worker" ? undefined : ({ provider, id, contextWindow: 200_000 }) },
getContextUsage: () => ({ tokens: 50_000 }), compact({ onComplete }: any) { peer.compactions++; peer.compactionModels.push(ctx.model.id); 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); 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);
worker.ctx.model = { provider: "offline", id: "planning", contextWindow: 200_000 };
manager.appendModelChange("offline", "planning");
await worker.hook("model_select", { source: "cycle", model: worker.ctx.model });
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.includes("during activation");
worker.missing = scenario === "missing worker model" || scenario.includes("during recovery restore");
worker.noAuth = scenario === "unauthenticated worker model";
await worker.tools.get("RequestPlanReview").execute("", {}, undefined, undefined, worker.ctx);
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);
expect(worker.ctx.model.id).toBe("planning");
expect(worker.modelChanges).toContain("worker");
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];
if (worker.missing || worker.noAuth) {
const saved = manager.getBranch().findLast((entry: any) => entry.customType === "pi-goals-state") as any;
expect(saved.data).toMatchObject({ phase: "planning", modelRecovery: "worker" });
expect(wires.some(w => w.t === "plan_activate" || w.t === "view")).toBe(false);
expect(supervisor.messages).toEqual([]);
expect(worker.messages.some((m: string) => m.startsWith("Work the goals"))).toBe(false);
expect(worker.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("worker model paused"), "error");
await worker.hook("session_start", { reason: "reload" });
expect(worker.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("/goals model current"), "error");
const before = worker.messages.length;
worker.receive({ type: "message", fromSessionId: supervisor.id, payload: { t: "directive", to: worker.id, text: "Do not revive an inactive worker" } });
await tick(); expect(worker.messages).toHaveLength(before);
worker.missing = false; worker.noAuth = false;
if (scenario.includes("during recovery restore")) {
worker.deferWorkerRestore = true;
const startupCount = herdrCalls.length;
const recovering = worker.hook("agent_settled");
await vi.waitFor(() => expect(worker.finishRestore).toBeTypeOf("function"));
const clearing = scenario.startsWith("clear");
await worker.commands.get("goals").handler(clearing ? "clear" : "plan replacement has no Ready", worker.ctx);
worker.finishRestore(); await recovering; await tick();
expect(herdrCalls).toHaveLength(startupCount);
expect(wires.some(w => w.t === "plan_activate")).toBe(false);
expect(worker.messages.some((m: string) => m.startsWith("Work the goals"))).toBe(false);
expect(manager.getBranch().findLast((entry: any) => entry.customType === "pi-goals-state").data).toMatchObject({ phase: clearing ? null : "planning", planVersion: clearing ? null : 2, supervisor: null, modelRecovery: null });
expect(worker.ctx.model.id).toBe("planning");
return;
}
await worker.pi.setModel({ provider: "offline", id: "recovered-worker", contextWindow: 200_000 });
await vi.waitFor(() => expect(worker.messages.filter((m: string) => m.startsWith("Work the goals"))).toHaveLength(1));
expect(JSON.parse(readFileSync(join(modelDir, "worker-model.json"), "utf8"))).toEqual({ provider: "offline", id: "recovered-worker" });
expect(JSON.parse(readFileSync(join(modelDir, "planning-model.json"), "utf8"))).toEqual({ provider: "offline", id: "planning" });
expect(wires.filter(w => w.t === "plan_activate")).toHaveLength(1);
expect(herdrCalls.filter(args => args[1] === "start")).toHaveLength(1);
return;
}
if (scenario.includes("while model unavailable")) {
worker.noAuth = true;
await worker.hook("session_start", { reason: "reload" });
await worker.commands.get("goals").handler(scenario.startsWith("clear") ? "clear" : "steward off", worker.ctx);
await tick();
for (const peer of peers) expect(peer.manager.getBranch().findLast((e: any) => e.customType === "supervise-state").data.role).toBe("none");
const before = worker.messages.length;
worker.receive({ type: "message", fromSessionId: supervisor.id, payload: { t: "directive", to: worker.id, text: "An old directive must not revive work" } });
await tick(); expect(worker.messages).toHaveLength(before);
expect(supervisor.aborts).toBeGreaterThan(0);
return;
}
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.compactionModels).toEqual(["supervisor"]);
expect(supervisor.manager.getBranch().some((entry: any) => entry.type === "model_change" && entry.modelId === "worker")).toBe(false);
expect(supervisor.manager.getBranch().some((entry: any) => entry.type === "model_change" && entry.modelId === "planning")).toBe(true);
expect(worker.ctx.model.id).toBe("worker");
expect(JSON.parse(readFileSync(join(modelDir, "planning-model.json"), "utf8"))).toEqual({ provider: "offline", id: "planning" });
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");
await worker.commands.get("goals").handler("judge offline/judge", worker.ctx);
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") && args.includes("offline/judge"))).toBe(true);
expect(worker.ctx.model.id).toBe("worker");
expect(supervisor.ctx.model.id).toBe("supervisor");
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 pi-goals package directory");
});
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 "[ ]"'));
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { tickGoal } from "../src/index.js";
const plan = `# Plan
## Goals
1. [/] goal: Implement the cache layer
- tasks:
1. [x] wire client
2. [ ] goal: Ship the docs
## Log
`;
describe("tickGoal (sign-off ticks the goal; agent only ticks on wording drift)", () => {
it("ticks the exact-matching goal line, case-insensitive, leaving subtasks alone", () => {
const out = tickGoal(plan, "implement the CACHE layer");
expect(out).toContain("1. [x] goal: Implement the cache layer");
expect(out).toContain("1. [x] wire client"); // subtask untouched (was already x)
expect(out).toContain("2. [ ] goal: Ship the docs"); // other goal untouched
});
it("returns null on wording drift (fuzzy matching is the judge's job, not TypeScript's)", () => {
expect(tickGoal(plan, "Implement caching")).toBeNull();
});
it("returns null when the subject matches more than one goal line", () => {
const dup = `${plan}3. [ ] goal: Ship the docs\n`;
expect(tickGoal(dup, "Ship the docs")).toBeNull();
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
"@sting8k/pi-vcc/src/*": [
"./src/internal/supervisor/vcc.d.ts"
]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { include: ["test/*.test.ts"], setupFiles: ["test/setup.ts"] } });