mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-12 12:50:58 +08:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c86405841 | ||
|
|
ee1ab3ec26 | ||
|
|
ba2799a1d9 | ||
|
|
c6a4307892 | ||
|
|
2b620a0334 | ||
|
|
fc321a90fc | ||
|
|
9ee18c93f3 | ||
|
|
23b0104a1d | ||
|
|
65ecf204db | ||
|
|
294fe80564 | ||
|
|
1dc6146874 | ||
|
|
7eb8b1f46b | ||
|
|
c5782ee2aa | ||
|
|
e299e84c5e | ||
|
|
d56fc55242 | ||
|
|
4c6a7716b1 | ||
|
|
cac2077456 | ||
|
|
5566e035f5 | ||
|
|
48e2247c00 | ||
|
|
844099bdf0 | ||
|
|
754ef89f13 | ||
|
|
6cfeaf44ee | ||
|
|
3eaaec9f5a | ||
|
|
0a33ff2852 | ||
|
|
a44cd26c1d | ||
|
|
96399ec3e4 | ||
|
|
96290c553b | ||
|
|
2852432d44 | ||
|
|
9fbc156860 | ||
|
|
f87b8aac2f | ||
|
|
b32f4af11f | ||
|
|
db18317109 | ||
|
|
4db690a300 | ||
|
|
49eb68e813 | ||
|
|
18381bcda9 | ||
|
|
ead336c957 | ||
|
|
9ad4cee084 | ||
|
|
bf50d9bbc8 | ||
|
|
46cfd537f0 | ||
|
|
f09d443d88 | ||
|
|
4fce680f2d | ||
|
|
a45cc7d9c6 | ||
|
|
d0070d18b8 | ||
|
|
7f274a691c | ||
|
|
17a3b25a82 | ||
|
|
2362073d04 | ||
|
|
c1bf91f3db | ||
|
|
3dd0668963 | ||
|
|
389af540d1 | ||
|
|
fa7195eafb | ||
|
|
0d972e81c3 | ||
|
|
1426877817 | ||
|
|
6bb34f18cf | ||
|
|
36b0d98c2a | ||
|
|
b173d145db | ||
|
|
8de5c35248 | ||
|
|
a778480fec | ||
|
|
45d59e1edc | ||
|
|
d026e06b41 | ||
|
|
9ebcd3f4a6 | ||
|
|
4850b5195e | ||
|
|
e8bcba0fa7 | ||
|
|
0a349b056e | ||
|
|
3a1afd4c8e | ||
|
|
d5766c1a34 | ||
|
|
310ab730cc | ||
|
|
6892801fcc | ||
|
|
35713760ad | ||
|
|
144f4b95b4 | ||
|
|
87cf14a28c | ||
|
|
2adba3da45 | ||
|
|
842f1b85c7 | ||
|
|
e6af6db3d9 | ||
|
|
7c36c119c6 | ||
|
|
7e427d2ca6 |
@@ -0,0 +1,24 @@
|
||||
# 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` and `editor` protocol, 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 ./src/index.ts'
|
||||
```
|
||||
|
||||
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`, `Refine`, `Edit`, or `Cancel`.
|
||||
|
||||
## Intended supervision workflow
|
||||
|
||||
I already have pi-intercom-supervisor, but thought using pi-subagents could make it simpler. The idea is that the user makes a plan as in pi-goals, but on this branch, instead of a naive stateless subagent, we 1) fork, 2) compact, and 3) make it a supervisor with a prompt as in pi-intercom-supervisor. The supervisor is cheap because it sees only high-level material, which costs fewer tokens. It has good judgement because it sees a) compacted planning context, b) the plan, and c) summarised context (for example, my modified pi-vcc). This lets it operate read-only and steer the worker without losing track. It also compacts every 100k tokens to keep it cheap and high-level.
|
||||
|
||||
I am now thinking the subagent implementation may be too difficult. To keep the plan and forking, this branch of pi-goals could make another Pi session, perhaps using the fork explicitly, and use pi-intercom or pi-messenger to communicate with it. The user can switch to it, or Herdr could open it automatically.
|
||||
|
||||
-- wassname
|
||||
@@ -0,0 +1,3 @@
|
||||
# ARCHIVED
|
||||
|
||||
Superseded by [pi-goals](https://github.com/wassname/pi-goals).
|
||||
@@ -1,152 +1,70 @@
|
||||
# pi-goals
|
||||
|
||||
Plan mode for agreeing on goals before any code gets written. Each goal names the subtle failure
|
||||
mode that could fake a "done" and the discriminator that tells real success from it. Everything
|
||||
lives in one markdown file, `.pi/plan.md`, which the agent edits with its normal edit tool and which
|
||||
doubles as the task list. A goal is signed off only after a fresh read-only judge checks its
|
||||
evidence against the repo.
|
||||
Plan in one Pi session, then do the work there while a stronger visible Pi session supervises it.
|
||||
|
||||

|
||||
## How it works
|
||||
|
||||
The file has a fold at `## Log`. Above it is the working set: title, the human's own words, goals,
|
||||
discriminators. Below it is durable memory: the log, the learnings, and an unlimited unverified
|
||||
appendix. The working set is re-sent when the plan goes stale for a couple of turns; the whole file
|
||||
comes back at session start and after a compaction, which is when the settled context is gone.
|
||||
1. `/goals <objective>` creates `.pi/plan/<session_id>-vN.md` and enters read-only plan mode.
|
||||
2. Pi asks only material questions, writes the plan, and shows **Ready / Refine / Edit / Cancel**.
|
||||
3. **Ready** opens a second Herdr pane. The new Pi session explicitly forks the planning session and compacts that fork.
|
||||
4. The original session becomes the implementation worker. It keeps the full conversation and normal tools.
|
||||
5. The fork becomes a read-only supervisor. `pi-supervise` gives it compact worker views and carries its instructions to the worker through `pi-intercom`.
|
||||
6. The supervisor compacts again when its context reaches 100k tokens.
|
||||
7. The supervisor records a private approval only after it sees a stopped worker, no active work, a clean commit, evidence, and saved verification output. `CompleteGoal` checks that approval against the exact plan block and Git tree before it ticks `[x]`.
|
||||
|
||||
Design bet (v2): the plan file is for LLMs and the human, not for TypeScript. There is no parser and
|
||||
no schema. The format is a convention taught by a prompt; the judge, being a model, reads the file
|
||||
natively, finds the claimed goal itself (wording drift is fine), and validates the evidence in
|
||||
words. It cannot execute anything, so it never re-runs your `verify` command: you run it and save
|
||||
the output as evidence. This deleted ~800 lines of v1 (parser, exact-string goal matching, verify
|
||||
runner, JSON-stream judge transport, review menus) and with them the footguns they caused.
|
||||
|
||||
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).
|
||||
The two Pi sessions are visible. You can switch to the supervisor pane and talk to it directly.
|
||||
|
||||
## Install
|
||||
|
||||
This branch requires Herdr 0.7.5 or newer and these Pi packages:
|
||||
|
||||
```bash
|
||||
pi install npm:@wassname2/pi-goals
|
||||
pi install npm:@wassname2/pi-supervise
|
||||
pi install npm:pi-intercom
|
||||
```
|
||||
|
||||
Or for development:
|
||||
For a local checkout:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/wassname/pi-goals && cd pi-goals && npm install
|
||||
pi -e ./src/index.ts
|
||||
pi -e .
|
||||
```
|
||||
|
||||
## Use
|
||||
Run Pi from the Git repository that the plan will change. **Ready** fails if the current directory is not inside a Git repository; this prevents approval from checking the wrong repository.
|
||||
|
||||
```
|
||||
/goals CSV export for the report view
|
||||
## Commands
|
||||
|
||||
```text
|
||||
/goals <objective> create a new plan
|
||||
/goals model <model> select the visible supervisor model
|
||||
/goals model use Pi's current default model
|
||||
/goals clear close the supervisor pane and disconnect the plan
|
||||
```
|
||||
|
||||
`/goals` enters plan mode and starts a conversation; the objective is an optional seed. From there:
|
||||
`/goals clear` keeps the plan file. Starting another plan also keeps older versions.
|
||||
|
||||
1. Plan. The agent explores read-only (edit/write are blocked except on the plan file itself), asks
|
||||
about anything unclear, and drafts the goals into `.pi/plan.md`. The drafting rules are sent
|
||||
once, with your objective, not re-sent every turn.
|
||||
2. Review. Read the file; a menu asks Ready, open in `$EDITOR`, or keep planning. To revise, just
|
||||
reply. Plan mode ends when you pick Ready.
|
||||
3. Work. The agent ticks subtasks, appends to `## Log` and `## Learnings`, fills `evidence:`, and
|
||||
calls `CompleteGoal` when a discriminator is satisfied. If it leaves the plan untouched for two
|
||||
turns, the working set is sent back with a short upkeep reminder.
|
||||
## Plan format
|
||||
|
||||
Other commands: `/goals clear` empties the plan file; `/goals judge <model-ref>` picks a specific
|
||||
model for the sign-off judge (default: your current session model, else pi's default).
|
||||
A goal is a checkbox line whose text starts with `goal:`:
|
||||
|
||||
Coming from v1: a leftover `.pi/goals.md` is renamed to `.pi/plan.md` on session start.
|
||||
|
||||
## The plan.md format (a convention, not a schema)
|
||||
|
||||
```markdown
|
||||
# ship the cache layer
|
||||
|
||||
Latency target came from the SLO review; keep the existing client API.
|
||||
|
||||
## User voice
|
||||
|
||||
- > "keep the client API, I don't want to touch every call site"
|
||||
|
||||
## Goals
|
||||
|
||||
1. [/] goal: Implement cache layer
|
||||
- subtle failure mode: cache silently bypassed, latency ok by luck
|
||||
- discriminator: hit-rate > 0.8 in load-test.log (a bypass reads ~0)
|
||||
- verify: pytest tests/cache -q && python bench/p95.py --max-ms 50
|
||||
- tasks:
|
||||
1. [x] wire cache client
|
||||
2. [/] eviction policy
|
||||
- evidence:
|
||||
- > load-test.log: p95=41ms, hit-rate 0.93 (not bypassed)
|
||||
|
||||
## Future work / out of scope
|
||||
|
||||
## Log
|
||||
- 2026-06-15 14:02 cache client wired; eviction next
|
||||
|
||||
## Learnings
|
||||
- the client retries on 503, so a cache miss storm looks like latency, not errors
|
||||
|
||||
## Appendix (context, not approved)
|
||||
```md
|
||||
1. [ ] goal: Produce the report
|
||||
- subtle failure mode: the report exists but uses stale data
|
||||
- discriminator: the report cites the current input and the saved check confirms it
|
||||
- verify: `just verify`
|
||||
- evidence: (empty until sign-off)
|
||||
```
|
||||
|
||||
- A goal is a checkbox line beginning `goal:` (`[ ]` open, `[/]` active, `[x]` done, `[-]`
|
||||
cancelled). Indented checkbox lines under a goal are its subtasks. Those two patterns plus the
|
||||
`## Log` fold are the only things the extension itself reads; everything else is prose to it.
|
||||
- The `discriminator` is the success test, written while planning: the positive observation that the
|
||||
goal succeeded and that none of the `subtle failure mode`s could fake. `evidence` is the proof,
|
||||
filled at sign-off: each item pairs a durable artifact with a short read of it. Prefer committed
|
||||
artifacts (files, tests, diffs); `.pi/` is usually gitignored, so evidence there is judge-time
|
||||
proof only and won't survive in history.
|
||||
- `## User voice` holds the human's requirements word for word. A paraphrase drifts, and then the
|
||||
goals churn on the next reply.
|
||||
- `## Learnings` is what you now know, one line per gotcha, deduped. `## Log` is what happened, one
|
||||
line a turn. `## Appendix` is unlimited and unverified: alternatives, links, dead ends, and
|
||||
settled detail that is not part of the approved goals.
|
||||
- Small format deviations are fine; the file is read by the human and the judge, not a parser.
|
||||
- The agent prunes finished goals itself when the working set gets long (evidence survives in git
|
||||
history and `## Log`).
|
||||
The worker saves verification output in a nonempty repository file, adds that path to evidence, and commits it. The supervisor calls `ApproveGoal` with the inspected path; the worker then calls `CompleteGoal` with the exact goal text.
|
||||
|
||||
## Signing off a goal (`CompleteGoal`)
|
||||
|
||||
`CompleteGoal(goal)` is the one blessed tool. It spawns a strictly read-only `pi` subprocess (`-p
|
||||
--no-session --no-extensions`, tools `read,grep,find,ls` -- no bash, no edit/write) with the whole
|
||||
plan file and the claimed goal. The judge cannot execute anything, so it never re-runs your verify
|
||||
command (which may be a 10-hour training job); the agent runs `verify` itself and saves the output
|
||||
as evidence. The judge reviews evidence discipline in order -- anything here at all? each item
|
||||
quoted and attributed? provenance visible (how was this produced)? do the quotes match the cited
|
||||
files on disk? -- and only then the substance, returning `VERDICT: accept | reject` plus what's
|
||||
missing. It reads the live working tree, not HEAD: uncommitted work counts, and committing before
|
||||
sign-off is for durable evidence, not for the judge's visibility.
|
||||
|
||||
- accept: a sign-off line is appended to `## Log` and the goal is ticked `[x]` in the same write
|
||||
(exact goal-line match only; on wording drift the result asks the agent to tick it). The
|
||||
tool-written log line is the audit trail; a hand-tick without one shows in the diff.
|
||||
- every run saves the judge's full transcript to `.pi/judge/<stamp>.md`, referenced from the log
|
||||
line, so "what did the judge actually check?" stays answerable after the fact.
|
||||
- reject: the goal stays open and the agent gets the missing list.
|
||||
- judge ran but failed/errored/timed out, or returned no VERDICT line: accepted inconclusive,
|
||||
logged as such. There is no pre-emptive "no model" path -- a null judgeModel just omits
|
||||
`--model` so pi's configured default runs the judge, so inconclusive always means "ran but
|
||||
failed", never "couldn't start". The working agent is never blocked on judge infra.
|
||||
|
||||
## Prompts
|
||||
|
||||
All model-facing text lives in [`src/prompts.ts`](src/prompts.ts), in flow order.
|
||||
|
||||
## Develop
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pi -e ./src/index.ts # load locally
|
||||
npm test # vitest: judge argv invariants, appendLog, decideSignOff fail-forward
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## License
|
||||
`test/rpc-review.test.ts` runs the planning review flow through Pi's real RPC protocol with a local deterministic model. The Herdr launcher and visible supervisor bootstrap have focused tests; use a real Herdr session for the final two-pane check.
|
||||
|
||||
MIT
|
||||
-- PI[gpt-5.6-sol]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,31 @@
|
||||
I only have a file-read tool here, so I’ll 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 couldn’t 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. That’s 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. That’s 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.
|
||||
@@ -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 1–3 are from source inspection, not reproduced runs. Not approved as-is; defects 1 and 2 are worth fixing before merge.
|
||||
@@ -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.
|
||||
@@ -4,6 +4,9 @@ Working title. A pi extension: set up goals (with subtasks and evidence) through
|
||||
|
||||
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,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.
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Visible supervisor handover
|
||||
|
||||
## Objective
|
||||
|
||||
Replace pi-goals' nested pi-subagents worker with two visible Pi sessions:
|
||||
|
||||
1. The main session plans with the user, then becomes the implementation worker.
|
||||
2. On Ready, pi-goals explicitly forks the planning session into a Herdr pane.
|
||||
3. Only the fork is compacted. It becomes the stronger read-only supervisor.
|
||||
4. pi-supervise and pi-intercom connect the supervisor to the worker.
|
||||
5. The worker starts only after the real pi-supervise `pair`/`paired` acknowledgment.
|
||||
6. The supervisor retains the plan, compact planning context, and concise worker views. It can steer the worker and approve a completed goal.
|
||||
7. The supervisor compacts near 100k tokens.
|
||||
|
||||
Keep this minimal. Reuse pi-supervise's intercom protocol instead of building a second orchestration layer.
|
||||
|
||||
## User preferences
|
||||
|
||||
- The primary session must do the implementation. Other agents may test or review it, but must not own core development.
|
||||
- Avoid relaying implementation decisions through multiple agents.
|
||||
- Herdr should open the supervisor automatically and let the user switch to it.
|
||||
- Persist configurable models for three stages:
|
||||
- planning: strongest model, for example Fable 5.1 or Astra;
|
||||
- supervision: for example Sol or Opus;
|
||||
- implementation: for example Terra, Sonnet, Kimi K3, DeepSeek Pro, or GLM 5.3.
|
||||
- Validate model IDs through Pi. Do not hard-code a model list.
|
||||
- Switch the main session to the planning model when planning starts and to the worker model only after pairing succeeds. Launch the fork with the supervisor model.
|
||||
|
||||
## Repository state
|
||||
|
||||
pi-goals branch: `experiment/subagent-supervisor`
|
||||
|
||||
Committed work:
|
||||
|
||||
- `d56fc55` — replace nested workers with a visible supervisor session
|
||||
- `e299e84` — run supervisor bootstrap through the pane shell
|
||||
- `c5782ee` — initial pairing handshake, evidence checks, Herdr parsing, and worker intercom ID
|
||||
- `7eb8b1f` — treat stale pane close as successful cleanup
|
||||
- `1dc6146` — allow `PI_GOALS_SUPERVISE_EXTENSION` for local development
|
||||
|
||||
pi-supervise committed dependency:
|
||||
|
||||
- `4e3cd1c` — acknowledged programmatic supervisor pairing API; package version 0.0.4
|
||||
|
||||
Uncommitted pi-goals files:
|
||||
|
||||
- `src/intercom.ts`
|
||||
- `src/supervise.ts`
|
||||
- `test/intercom.test.ts` (new)
|
||||
|
||||
Uncommitted pi-supervise file:
|
||||
|
||||
- `src/index.ts`
|
||||
|
||||
Inspect these diffs before editing. They are a partial design-B refactor and have not passed the real workflow.
|
||||
|
||||
## Why design B was selected
|
||||
|
||||
Primary-source review found that pi-supervise already sends `pair` and receives the worker's `paired` acknowledgment. The custom `pi-goals/visible-supervisor/v1` intercom namespace duplicated that acknowledgment and introduced another registration and connection race.
|
||||
|
||||
Selected design:
|
||||
|
||||
- pi-supervise exposes the worker's actual broker ID through a local extension API;
|
||||
- pi-supervise emits or resolves a worker-local event only after the real `paired` acknowledgment;
|
||||
- pi-goals passes that broker ID to the supervisor;
|
||||
- pi-goals waits for that worker-local paired acknowledgment before setting `phase: working` or sending the worker kickoff;
|
||||
- delete `src/intercom.ts` and custom supervisor-ready messages if the partial diff has not already completed that deletion;
|
||||
- support either extension load order by using pi-intercom/pi-supervise registry-ready events idempotently.
|
||||
|
||||
Do not use pi-intercom `project-agent.ts` as another lifecycle. It opens a generic Pi pane and polls broker presence but does not supply the required fork, extensions, model, or pairing semantics.
|
||||
|
||||
## Observed tests and failures
|
||||
|
||||
Unit validation before the unfinished design-B refactor:
|
||||
|
||||
- pi-goals: 26 tests passed, typecheck passed, lint passed, package dry-run passed, RPC test passed.
|
||||
- pi-supervise: 97 tests passed and package dry-run passed.
|
||||
|
||||
Real Herdr observations:
|
||||
|
||||
1. The initial smoke loaded pi-supervise directly from source and did not exercise pi-goals' actual Ready command.
|
||||
2. A later actual `/goals` → Ready run failed before pane creation because pi-goals emitted `intercom:extension-register` before pi-intercom installed its listener.
|
||||
3. A local uncommitted registry-ready re-registration fix moved the real path farther: Ready created supervisor pane `w8:p1F` through `supervisorCommand`.
|
||||
4. That run then timed out waiting for the duplicate custom `supervisor-ready` message. This led to design B.
|
||||
5. The supervisor exited before its transcript was preserved. Do not infer that pi-supervise pairing succeeded.
|
||||
|
||||
The real end-to-end workflow has not passed.
|
||||
|
||||
## Next work
|
||||
|
||||
1. Read the uncommitted diffs in both repositories and finish or simplify design B.
|
||||
2. Add focused tests:
|
||||
- pi-supervise local API works whether pi-goals loads before or after pi-supervise;
|
||||
- no `phase: working` or kickoff before actual `paired`;
|
||||
- duplicate `paired` is idempotent.
|
||||
3. Run the actual pi-goals path, not a substitute command:
|
||||
- start worker with pi-goals and pi-intercom;
|
||||
- enter `/goals`, draft a plan, and select Ready;
|
||||
- use `PI_GOALS_SUPERVISE_EXTENSION=/home/code/.pi/agent/git/github.com/wassname/pi-supervise/src/index.ts` until 0.0.4 is published;
|
||||
- positively observe fork-only compaction, actual pairing acknowledgment, then worker kickoff;
|
||||
- preserve supervisor stdout/stderr and session JSONL before cleanup on every failure;
|
||||
- observe supervisor monitoring or steering;
|
||||
- complete real evidence at a clean commit, approve it, call CompleteGoal, and close the pane.
|
||||
4. Commit the lifecycle separately once the real path passes.
|
||||
5. Add the three persisted model settings in a separate commit.
|
||||
6. Run tests, typecheck, lint, package dry-runs, real RPC tests, and a fresh read-only review.
|
||||
|
||||
## Known packaging constraint
|
||||
|
||||
`src/herdr.ts` defaults to `npm:@wassname2/pi-supervise@0.0.4`. Version 0.0.4 is not publicly published. Do not publish without explicit editorial approval. Local testing must use `PI_GOALS_SUPERVISE_EXTENSION`.
|
||||
|
||||
## Important lifecycle bug discovered in this session
|
||||
|
||||
`/goals clear` cleared extension state but left the current model request under the previously injected coordinator system instruction. `/reload` did not remove it. A fresh ordinary Pi session is required for direct implementation. The redesign should avoid leaving a session unable to resume ordinary work after clear.
|
||||
|
||||
-- PI[gpt-5.6-sol]
|
||||
Generated
+97
-145
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"name": "@wassname2/pi-goals",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@wassname2/pi-goals",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.79.0",
|
||||
"@earendil-works/pi-tui": "*",
|
||||
"@sinclair/typebox": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"@earendil-works/pi-coding-agent": "^0.84.1",
|
||||
"@types/node": "^20.0.0",
|
||||
"typebox": "^1.3.7",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"typebox": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
@@ -89,9 +90,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -109,9 +107,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -129,9 +124,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -149,9 +141,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -196,20 +185,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent": {
|
||||
"version": "0.79.10",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.79.10.tgz",
|
||||
"integrity": "sha512-YxaRhmgyDTvLDdGVbe7YzTHV80oL5mX5odg6EhGHz3w5Wu1Ix8DCw7bhtiOBLGQNFRcknia0zPmVWIj30XP1EA==",
|
||||
"version": "0.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz",
|
||||
"integrity": "sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==",
|
||||
"hasShrinkwrap": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.79.10",
|
||||
"@earendil-works/pi-ai": "^0.79.10",
|
||||
"@earendil-works/pi-tui": "^0.79.10",
|
||||
"@earendil-works/pi-agent-core": "^0.84.1",
|
||||
"@earendil-works/pi-ai": "^0.84.1",
|
||||
"@earendil-works/pi-client": "^0.84.1",
|
||||
"@earendil-works/pi-protocol": "^0.84.1",
|
||||
"@earendil-works/pi-tui": "^0.84.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
"diff": "8.0.4",
|
||||
"glob": "13.0.6",
|
||||
"grok-mermaid": "0.2.2",
|
||||
"highlight.js": "10.7.3",
|
||||
"hosted-git-info": "9.0.3",
|
||||
"ignore": "7.0.5",
|
||||
@@ -217,8 +209,8 @@
|
||||
"minimatch": "10.2.5",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"semver": "7.8.0",
|
||||
"typebox": "1.1.38",
|
||||
"undici": "8.5.0",
|
||||
"typebox": "1.3.7",
|
||||
"undici": "8.9.0",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -667,13 +659,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.79.10",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.10.tgz",
|
||||
"version": "0.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.79.10",
|
||||
"@earendil-works/pi-ai": "^0.84.1",
|
||||
"@earendil-works/pi-telemetry": "^0.84.1",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
"typebox": "1.3.7",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -681,12 +675,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.79.10",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.10.tgz",
|
||||
"version": "0.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
|
||||
"@earendil-works/pi-telemetry": "^0.84.1",
|
||||
"@google/genai": "1.52.0",
|
||||
"@mistralai/mistralai": "2.2.6",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -695,18 +690,48 @@
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"openai": "6.26.0",
|
||||
"partial-json": "0.1.7",
|
||||
"typebox": "1.1.38"
|
||||
"typebox": "1.3.7"
|
||||
},
|
||||
"bin": {
|
||||
"pi-ai": "./dist/cli.js"
|
||||
"pi-ai": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": {
|
||||
"version": "0.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-protocol": "^0.84.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": {
|
||||
"version": "0.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"typebox": "1.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": {
|
||||
"version": "0.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.79.10",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.79.10.tgz",
|
||||
"version": "0.84.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
@@ -814,9 +839,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -833,9 +855,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -852,9 +871,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -871,9 +887,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -890,9 +903,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1230,15 +1240,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": {
|
||||
@@ -1484,6 +1494,15 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz",
|
||||
"integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||
@@ -1800,9 +1819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": {
|
||||
"version": "7.6.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
|
||||
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -1915,15 +1934,15 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": {
|
||||
"version": "1.1.38",
|
||||
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz",
|
||||
"integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==",
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz",
|
||||
"integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/undici": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz",
|
||||
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==",
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
|
||||
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -2028,19 +2047,6 @@
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.80.3",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.3.tgz",
|
||||
"integrity": "sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"marked": "18.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -2204,9 +2210,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2224,9 +2227,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2244,9 +2244,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2264,9 +2261,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2284,9 +2278,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2304,9 +2295,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2393,12 +2381,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.49",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
||||
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -2662,18 +2644,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -2817,9 +2787,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2841,9 +2808,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2865,9 +2829,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2889,9 +2850,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2957,22 +2915,10 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.5",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3030,9 +2976,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3050,7 +2996,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -3175,6 +3121,12 @@
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typebox": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz",
|
||||
"integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
+13
-9
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@wassname2/pi-goals",
|
||||
"version": "0.1.1",
|
||||
"description": "One .pi/plan.md: set goals in plan mode, work them, sign off only when a read-only judge checks the evidence.",
|
||||
"version": "0.2.2",
|
||||
"description": "Plan in one Pi session, then work under a visible forked supervisor.",
|
||||
"author": "wassname",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -18,12 +18,13 @@
|
||||
"proof",
|
||||
"uat",
|
||||
"evidence",
|
||||
"judge"
|
||||
"supervisor",
|
||||
"pi-intercom",
|
||||
"herdr"
|
||||
],
|
||||
"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",
|
||||
@@ -36,15 +37,18 @@
|
||||
"build": "tsc",
|
||||
"prepublishOnly": "npm run lint && npm run typecheck && npm run test",
|
||||
"test": "vitest run",
|
||||
"test:rpc": "vitest run test/rpc-review.test.ts",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check src/ test/",
|
||||
"lint:fix": "biome check --fix src/ test/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"@earendil-works/pi-coding-agent": "^0.84.1",
|
||||
"@types/node": "^20.0.0",
|
||||
"typebox": "^1.3.7",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pi": {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Goal steward validation
|
||||
|
||||
## Observations
|
||||
|
||||
- Unit, flow, type, and lint checks passed. [`20260905_validation.log`](20260905_validation.log) says:
|
||||
|
||||
> Test Files 8 passed (8)
|
||||
> Tests 36 passed (36)
|
||||
> Checked 12 files in 14ms. No fixes applied.
|
||||
|
||||
- A real Pi 0.85.0 process loaded pi-subagents 0.65.1, pi-goals, and a runtime `goal-steward` agent. It spawned one review and resumed that run for sign-off. [`20260905_steward-probe.json`](20260905_steward-probe.json) records two distinct run IDs:
|
||||
|
||||
> "runId": "4e9dc0c0-385b-4eb9-a060-ced7dc7cb6cc"
|
||||
|
||||
> "runId": "f6115c82-31de-499f-ab78-145dde0c51c0"
|
||||
|
||||
- The second review recalled a token that appeared only in the first review request. This is direct evidence that resume retained the steward conversation:
|
||||
|
||||
> "Persistence lineage token: amber-731."
|
||||
|
||||
- The sign-off review read `report.txt` and accepted the evidence:
|
||||
|
||||
> "file exists and contains exactly 'PROBE_PASS' as required. Failure mode (empty report) is ruled out."
|
||||
|
||||
## Test environment finding
|
||||
|
||||
The repository's older local Pi 0.84.1 install could not launch a pi-subagents background child because it did not include `@earendil-works/chord` and `@earendil-works/pi-server`. The successful probe used an isolated npm install of Pi 0.85.0. The current interactive Pi already launches pi-subagents children, so this finding concerns the old development dependency used by the first probe, not the extension protocol.
|
||||
|
||||
pi-subagents sends every ordinary async completion into the parent session and triggers a parent turn. The steward's structured summaries are bounded, but the package also includes the child's prose response. There is no public silent-completion option in pi-subagents 0.65.1. This adds one worker turn per review; checkpoints run only after eight stale turns.
|
||||
|
||||
— Pi/Codex
|
||||
@@ -0,0 +1,91 @@
|
||||
# Nested supervisor validation
|
||||
2026-09-05T19:31:55+08:00
|
||||
|
||||
$ npm test
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 test
|
||||
> vitest run
|
||||
|
||||
|
||||
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
|
||||
|
||||
|
||||
Test Files 9 passed (9)
|
||||
Tests 43 passed (43)
|
||||
Start at 19:31:56
|
||||
Duration 1.60s (transform 709ms, setup 0ms, import 1.64s, tests 1.76s, environment 1ms)
|
||||
|
||||
|
||||
$ npm run typecheck
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 typecheck
|
||||
> tsc --noEmit
|
||||
|
||||
|
||||
$ npm run lint
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 lint
|
||||
> biome check src/ test/
|
||||
|
||||
Checked 15 files in 29ms. No fixes applied.
|
||||
|
||||
$ git diff --check
|
||||
(no output)
|
||||
|
||||
$ npm pack --dry-run
|
||||
npm notice
|
||||
npm notice 📦 @wassname2/pi-goals@0.2.2
|
||||
npm notice Tarball Contents
|
||||
npm notice 5.8kB README.md
|
||||
npm notice 1.1kB agents/goal-worker.md
|
||||
npm notice 1.5kB package.json
|
||||
npm notice 4.0kB src/approval.ts
|
||||
npm notice 34.6kB src/index.ts
|
||||
npm notice 13.6kB src/prompts.ts
|
||||
npm notice 5.9kB src/supervisor-runtime.ts
|
||||
npm notice 7.3kB src/worker.ts
|
||||
npm notice Tarball Details
|
||||
npm notice name: @wassname2/pi-goals
|
||||
npm notice version: 0.2.2
|
||||
npm notice filename: wassname2-pi-goals-0.2.2.tgz
|
||||
npm notice package size: 23.1 kB
|
||||
npm notice unpacked size: 73.7 kB
|
||||
npm notice shasum: 579debe3de67b56116e51da6cac46c14511bdd07
|
||||
npm notice integrity: sha512-f5S39K2J3kjIX[...]cuwx2WFIeBBAQ==
|
||||
npm notice total files: 8
|
||||
npm notice
|
||||
wassname2-pi-goals-0.2.2.tgz
|
||||
|
||||
$ git diff --stat 2852432
|
||||
README.md | 13 +-
|
||||
agents/goal-worker.md | 2 +-
|
||||
.../20260905_nested-supervisor-validation.txt | 70 ++++------
|
||||
src/approval.ts | 27 +++-
|
||||
src/index.ts | 153 +++++++++++++++------
|
||||
src/prompts.ts | 9 +-
|
||||
src/supervisor-runtime.ts | 81 ++++++++---
|
||||
src/worker.ts | 36 +++--
|
||||
test/goals-flow.test.ts | 54 +++++++-
|
||||
test/prompts.test.ts | 2 +-
|
||||
test/supervisor-runtime.test.ts | 32 ++++-
|
||||
test/worker.test.ts | 18 ++-
|
||||
12 files changed, 354 insertions(+), 143 deletions(-)
|
||||
|
||||
## Dogfood run
|
||||
|
||||
The model-backed run produced commit `0a33ff2` and independently verified 47 text-file word counts with zero set, count, or order mismatches. Approval then deadlocked:
|
||||
|
||||
> Cannot approve while the retained worker is pending.
|
||||
|
||||
The worker process was terminal, but its model result was `Request was aborted`; the completion event did not clear retained state. A supervisor resume also failed because `subagent_supervisor` was unavailable in its strict tool list.
|
||||
|
||||
Usage from the run status files:
|
||||
|
||||
| agent | turns | new tokens | cached reads | reported cost |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| supervisor, including recovery | 42 | 169,288 | 2,670,336 | $2.35 |
|
||||
| worker | 17 | 67,803 | 812,544 | $0.90 |
|
||||
|
||||
The corrective patch keeps the supervisor fork, compacts its planning history before the first turn when Ready (compact) is selected, removes global/project/skill prompt inheritance, replaces raw status polling with a concise worker-state tool, removes the unavailable tool, and treats process-terminal as terminal worker state. Unit tests pass; a second model-backed run is still required.
|
||||
|
||||
-- PI[gpt-5.6-sol]
|
||||
@@ -0,0 +1,52 @@
|
||||
text/plain .gitignore
|
||||
text/plain AGENTS.md
|
||||
text/plain ARCHIVED.md
|
||||
text/plain README.md
|
||||
text/plain agents/pi-goals-worker-v1.md
|
||||
application/json biome.json
|
||||
text/plain docs/reviews/goals_menu2.md
|
||||
text/plain docs/reviews/goals_menu2_r2.md
|
||||
text/plain docs/reviews/pi-goals-grok-4-6-retry.md
|
||||
text/plain docs/reviews/pi-goals-kimi-k3.md
|
||||
text/plain docs/reviews/review.md
|
||||
text/plain docs/slop/audit/20260826_pi-plan-aligned-planning.md
|
||||
text/plain docs/slop/plans/20260706_plan-flow-and-judge-review.md
|
||||
text/plain docs/slop/plans/20260826_pi-plan-aligned-planning.md
|
||||
text/plain docs/spec/2026-06-15_pi-goals.md
|
||||
text/plain docs/spec/2026-06-29_complete-goal-fail-forward.md
|
||||
text/plain docs/spec/2026-08-14_per-session-plan.md
|
||||
image/png media/screenshot.png
|
||||
application/json package-lock.json
|
||||
application/json package.json
|
||||
text/x-shellscript scripts/check-judge-footprint.sh
|
||||
text/x-shellscript scripts/check-stale-fixmes.sh
|
||||
text/x-diff scripts/inconclusive-fail-forward.diff
|
||||
text/x-diff scripts/stale-fixme-removal.diff
|
||||
text/plain slop/audits/20260905_goal-steward-validation.md
|
||||
text/plain slop/audits/20260905_nested-supervisor-validation.txt
|
||||
text/plain slop/audits/20260905_pi-goals-file-types.txt
|
||||
text/plain slop/audits/20260905_pi-goals-line-count-table.md
|
||||
text/plain slop/audits/20260905_pi-goals-text-line-counts.txt
|
||||
application/json slop/audits/20260905_steward-probe.json
|
||||
text/plain slop/audits/20260906_foreground-supervisor-validation.txt
|
||||
text/plain slop/audits/20260906_nested-runtime-smoke.md
|
||||
text/plain slop/audits/20260906_nonchild-npm-test.txt
|
||||
text/plain slop/plans/20260905_goal-steward.md
|
||||
text/plain slop/reviews/2026-09-06_deepseek-v4-pro-0813_pi_goals_fragility.md
|
||||
text/plain slop/reviews/20260906_foreground-worker-review.md
|
||||
application/javascript src/approval.ts
|
||||
application/javascript src/index.ts
|
||||
application/javascript src/prompts.ts
|
||||
application/javascript src/supervisor-runtime.ts
|
||||
application/javascript src/worker.ts
|
||||
application/javascript test/append-log.test.ts
|
||||
application/javascript test/fixtures/offline-model.ts
|
||||
application/javascript test/fold.test.ts
|
||||
application/javascript test/goals-flow.test.ts
|
||||
application/javascript test/package-agent.test.ts
|
||||
application/javascript test/prompts.test.ts
|
||||
application/javascript test/rpc-review.test.ts
|
||||
application/javascript test/supervisor-runtime.test.ts
|
||||
application/javascript test/tick-goal.test.ts
|
||||
application/javascript test/worker.test.ts
|
||||
application/json tsconfig.json
|
||||
@@ -0,0 +1,67 @@
|
||||
# pi-goals tracked-text line counts
|
||||
|
||||
Scope: Git-tracked files at this repository snapshot. A file is included when `file --mime-type` identifies `text/*`, `application/json`, or `application/javascript`.
|
||||
|
||||
Excluded: `media/screenshot.png` is binary (`image/png`); `package-lock.json` is an npm-generated dependency lockfile. No other tracked files are excluded.
|
||||
|
||||
Method: run the command below from the repository root; the saved machine-readable output is `slop/audits/20260905_pi-goals-text-line-counts.txt`.
|
||||
|
||||
```sh
|
||||
git ls-files -z | while IFS= read -r -d '\0' f; do case "$f" in media/screenshot.png|package-lock.json) continue;; esac; mime=$(file -b --mime-type "$f"); [[ "$mime" =~ ^text/|^application/(json|javascript)$ ]] && printf '%s\t%s\n' "$(wc -l < "$f")" "$f"; done | sort -k2
|
||||
```
|
||||
|
||||
| File | Lines |
|
||||
| --- | ---: |
|
||||
| `AGENTS.md` | 24 |
|
||||
| `agents/pi-goals-worker-v1.md` | 22 |
|
||||
| `ARCHIVED.md` | 3 |
|
||||
| `biome.json` | 23 |
|
||||
| `docs/reviews/goals_menu2.md` | 65 |
|
||||
| `docs/reviews/goals_menu2_r2.md` | 21 |
|
||||
| `docs/reviews/pi-goals-grok-4-6-retry.md` | 30 |
|
||||
| `docs/reviews/pi-goals-kimi-k3.md` | 40 |
|
||||
| `docs/reviews/review.md` | 61 |
|
||||
| `docs/slop/audit/20260826_pi-plan-aligned-planning.md` | 25 |
|
||||
| `docs/slop/plans/20260706_plan-flow-and-judge-review.md` | 33 |
|
||||
| `docs/slop/plans/20260826_pi-plan-aligned-planning.md` | 53 |
|
||||
| `docs/spec/2026-06-15_pi-goals.md` | 275 |
|
||||
| `docs/spec/2026-06-29_complete-goal-fail-forward.md` | 71 |
|
||||
| `docs/spec/2026-08-14_per-session-plan.md` | 67 |
|
||||
| `.gitignore` | 6 |
|
||||
| `package.json` | 65 |
|
||||
| `README.md` | 139 |
|
||||
| `scripts/check-judge-footprint.sh` | 43 |
|
||||
| `scripts/check-stale-fixmes.sh` | 14 |
|
||||
| `scripts/inconclusive-fail-forward.diff` | 104 |
|
||||
| `scripts/stale-fixme-removal.diff` | 30 |
|
||||
| `slop/audits/20260905_goal-steward-validation.md` | 31 |
|
||||
| `slop/audits/20260905_nested-supervisor-validation.txt` | 91 |
|
||||
| `slop/audits/20260905_pi-goals-file-types.txt` | 52 |
|
||||
| `slop/audits/20260905_pi-goals-line-count-table.md` | 67 |
|
||||
| `slop/audits/20260905_pi-goals-text-line-counts.txt` | 50 |
|
||||
| `slop/audits/20260905_steward-probe.json` | 15 |
|
||||
| `slop/audits/20260906_foreground-supervisor-validation.txt` | 53 |
|
||||
| `slop/audits/20260906_nested-runtime-smoke.md` | 31 |
|
||||
| `slop/audits/20260906_nonchild-npm-test.txt` | 33 |
|
||||
| `slop/plans/20260905_goal-steward.md` | 37 |
|
||||
| `slop/reviews/2026-09-06_deepseek-v4-pro-0813_pi_goals_fragility.md` | 65 |
|
||||
| `slop/reviews/20260906_foreground-worker-review.md` | 20 |
|
||||
| `src/approval.ts` | 115 |
|
||||
| `src/index.ts` | 736 |
|
||||
| `src/prompts.ts` | 191 |
|
||||
| `src/supervisor-runtime.ts` | 179 |
|
||||
| `src/worker.ts` | 186 |
|
||||
| `test/append-log.test.ts` | 17 |
|
||||
| `test/fixtures/offline-model.ts` | 18 |
|
||||
| `test/fold.test.ts` | 63 |
|
||||
| `test/goals-flow.test.ts` | 596 |
|
||||
| `test/package-agent.test.ts` | 23 |
|
||||
| `test/prompts.test.ts` | 33 |
|
||||
| `test/rpc-review.test.ts` | 116 |
|
||||
| `test/supervisor-runtime.test.ts` | 153 |
|
||||
| `test/tick-goal.test.ts` | 32 |
|
||||
| `test/worker.test.ts` | 119 |
|
||||
| `tsconfig.json` | 15 |
|
||||
| **Total** | **4351** |
|
||||
|
||||
-- PI[gpt-5.6]
|
||||
@@ -0,0 +1,50 @@
|
||||
24 AGENTS.md
|
||||
22 agents/pi-goals-worker-v1.md
|
||||
3 ARCHIVED.md
|
||||
23 biome.json
|
||||
65 docs/reviews/goals_menu2.md
|
||||
21 docs/reviews/goals_menu2_r2.md
|
||||
30 docs/reviews/pi-goals-grok-4-6-retry.md
|
||||
40 docs/reviews/pi-goals-kimi-k3.md
|
||||
61 docs/reviews/review.md
|
||||
25 docs/slop/audit/20260826_pi-plan-aligned-planning.md
|
||||
33 docs/slop/plans/20260706_plan-flow-and-judge-review.md
|
||||
53 docs/slop/plans/20260826_pi-plan-aligned-planning.md
|
||||
275 docs/spec/2026-06-15_pi-goals.md
|
||||
71 docs/spec/2026-06-29_complete-goal-fail-forward.md
|
||||
67 docs/spec/2026-08-14_per-session-plan.md
|
||||
6 .gitignore
|
||||
65 package.json
|
||||
139 README.md
|
||||
43 scripts/check-judge-footprint.sh
|
||||
14 scripts/check-stale-fixmes.sh
|
||||
104 scripts/inconclusive-fail-forward.diff
|
||||
30 scripts/stale-fixme-removal.diff
|
||||
31 slop/audits/20260905_goal-steward-validation.md
|
||||
91 slop/audits/20260905_nested-supervisor-validation.txt
|
||||
52 slop/audits/20260905_pi-goals-file-types.txt
|
||||
67 slop/audits/20260905_pi-goals-line-count-table.md
|
||||
50 slop/audits/20260905_pi-goals-text-line-counts.txt
|
||||
15 slop/audits/20260905_steward-probe.json
|
||||
53 slop/audits/20260906_foreground-supervisor-validation.txt
|
||||
31 slop/audits/20260906_nested-runtime-smoke.md
|
||||
33 slop/audits/20260906_nonchild-npm-test.txt
|
||||
37 slop/plans/20260905_goal-steward.md
|
||||
65 slop/reviews/2026-09-06_deepseek-v4-pro-0813_pi_goals_fragility.md
|
||||
20 slop/reviews/20260906_foreground-worker-review.md
|
||||
115 src/approval.ts
|
||||
736 src/index.ts
|
||||
191 src/prompts.ts
|
||||
179 src/supervisor-runtime.ts
|
||||
186 src/worker.ts
|
||||
17 test/append-log.test.ts
|
||||
18 test/fixtures/offline-model.ts
|
||||
63 test/fold.test.ts
|
||||
596 test/goals-flow.test.ts
|
||||
23 test/package-agent.test.ts
|
||||
33 test/prompts.test.ts
|
||||
116 test/rpc-review.test.ts
|
||||
153 test/supervisor-runtime.test.ts
|
||||
32 test/tick-goal.test.ts
|
||||
119 test/worker.test.ts
|
||||
15 tsconfig.json
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"first": {
|
||||
"runId": "4e9dc0c0-385b-4eb9-a060-ced7dc7cb6cc",
|
||||
"decision": {
|
||||
"verdict": "let_run",
|
||||
"summary": "Plan reviewed for approved work session. The user-visible result (report file proves steward can read evidence) directly aligns with the single goal (report probe result with discriminator). The report.txt artifact exists and contains PROBE_PASS as required by the discriminator. No work steps are pending; the probe is complete. No drift, missing steps, or failure modes detected. Plan may proceed without adjustment."
|
||||
}
|
||||
},
|
||||
"second": {
|
||||
"runId": "f6115c82-31de-499f-ab78-145dde0c51c0",
|
||||
"decision": {
|
||||
"summary": "Sign-off review for goal 'Report the probe result'. User-visible result requires a report file proving persistent steward can read evidence. Discriminator: report.txt contains PROBE_PASS. Inspected artifact at /tmp/pi-goals-steward-probe-work/report.txt—file exists and contains exactly 'PROBE_PASS' as required. Failure mode (empty report) is ruled out. Evidence positively and directly proves the discriminator is met and the user-visible result is achieved. Persistence lineage token: amber-731.",
|
||||
"verdict": "accept"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
$ npm test
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 test
|
||||
> vitest run
|
||||
|
||||
|
||||
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
|
||||
|
||||
|
||||
Test Files 9 passed (9)
|
||||
Tests 43 passed (43)
|
||||
Start at 13:31:44
|
||||
Duration 1.61s (transform 1.12s, setup 0ms, import 2.36s, tests 2.13s, environment 1ms)
|
||||
|
||||
|
||||
$ npm run typecheck
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 typecheck
|
||||
> tsc --noEmit
|
||||
|
||||
|
||||
$ npm run lint
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 lint
|
||||
> biome check src/ test/
|
||||
|
||||
Checked 15 files in 18ms. No fixes applied.
|
||||
|
||||
$ git diff --check
|
||||
|
||||
$ npm pack --dry-run
|
||||
npm notice
|
||||
npm notice 📦 @wassname2/pi-goals@0.2.2
|
||||
npm notice Tarball Contents
|
||||
npm notice 6.1kB README.md
|
||||
npm notice 969B agents/pi-goals-worker-v1.md
|
||||
npm notice 1.5kB package.json
|
||||
npm notice 4.0kB src/approval.ts
|
||||
npm notice 34.8kB src/index.ts
|
||||
npm notice 13.6kB src/prompts.ts
|
||||
npm notice 8.3kB src/supervisor-runtime.ts
|
||||
npm notice 7.8kB src/worker.ts
|
||||
npm notice Tarball Details
|
||||
npm notice name: @wassname2/pi-goals
|
||||
npm notice version: 0.2.2
|
||||
npm notice filename: wassname2-pi-goals-0.2.2.tgz
|
||||
npm notice package size: 24.1 kB
|
||||
npm notice unpacked size: 76.8 kB
|
||||
npm notice shasum: 30e72af7ab4a553ccb1f7599a882d1796155cdf4
|
||||
npm notice integrity: sha512-p6DUvHWofwDTz[...]IZmG7JiD+/wFw==
|
||||
npm notice total files: 8
|
||||
npm notice
|
||||
wassname2-pi-goals-0.2.2.tgz
|
||||
@@ -0,0 +1,31 @@
|
||||
# Nested foreground runtime smoke
|
||||
|
||||
Command:
|
||||
|
||||
```bash
|
||||
node /tmp/pi-goals-real-rpc-smoke.mjs
|
||||
```
|
||||
|
||||
Result: PASS.
|
||||
|
||||
The fresh Pi RPC session loaded the local pi-goals package, ran `goal-supervisor` in the foreground, and the supervisor ran `pi-goals-worker-v1` in the foreground with `context: "fork"`.
|
||||
|
||||
Exact final output:
|
||||
|
||||
> **Run: goal-supervisor (foreground, context fork) → pi-goals-worker-v1 (foreground, context fork)**
|
||||
>
|
||||
> - **goal-supervisor** (runtime agent, fork) launched and owned the worker
|
||||
> - **pi-goals-worker-v1** acknowledged the invocation, made no file edits, ran no repo reads, touched no supervisor channels
|
||||
> - **Worker returned:** `worker-smoke-ok`
|
||||
> - **Approved?** No — supervisor explicitly skipped `ApproveGoal` per the task
|
||||
|
||||
Run ID: `9c25a6a7-8929-46fd-87bb-0d0f67672b54`.
|
||||
|
||||
Saved runtime artifacts:
|
||||
|
||||
- `/home/code/.pi/agent/sessions/--home-code-.pi-agent-git-github.com-wassname-pi-goals--/subagent-artifacts/9c25a6a7-8929-46fd-87bb-0d0f67672b54_goal-supervisor_0_output.md`
|
||||
- `/home/code/.pi/agent/sessions/--home-code-.pi-agent-git-github.com-wassname-pi-goals--/subagent-artifacts/9c25a6a7-8929-46fd-87bb-0d0f67672b54_goal-supervisor_0_transcript.jsonl`
|
||||
|
||||
This smoke tested nested discovery and foreground execution. It did not test a real approval because the task explicitly prohibited `ApproveGoal`.
|
||||
|
||||
-- PI[gpt-5.6-sol]
|
||||
@@ -0,0 +1,33 @@
|
||||
# npm test outside the subagent-child harness
|
||||
|
||||
Command run from `/home/code/.pi/agent/git/github.com/wassname/pi-goals`:
|
||||
|
||||
```sh
|
||||
env -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_EXTENSION_BINDINGS -u PI_SUBAGENT_PARENT_SESSION -u PI_SUBAGENTS_PI_CODING_AGENT_PACKAGE_ROOT npm test
|
||||
```
|
||||
|
||||
The cleared variables were the complete `PI_SUBAGENT_*` set inherited by this worker. `PI_SUBAGENT_CHILD=1` makes `isSupervisorProcess()` false in `src/index.ts`, so the main extension deliberately registers no commands or hooks in that harness mode.
|
||||
|
||||
Exact output:
|
||||
|
||||
```text
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 test
|
||||
> vitest run
|
||||
|
||||
|
||||
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
|
||||
|
||||
|
||||
Test Files 9 passed (9)
|
||||
Tests 43 passed (43)
|
||||
Start at 15:57:04
|
||||
Duration 1.27s (transform 407ms, setup 0ms, import 1.41s, tests 1.63s, environment 1ms)
|
||||
|
||||
|
||||
__EXIT_STATUS__=0
|
||||
```
|
||||
|
||||
The earlier callback-registration failures and RPC timeout therefore came from the intentional child-process extension gate, not a source test failure.
|
||||
|
||||
-- PI[gpt-5.6]
|
||||
@@ -0,0 +1,18 @@
|
||||
# visible-supervisor follow-up
|
||||
|
||||
## committed changes
|
||||
|
||||
- pi-goals `294fe80` removes the duplicate `pi-goals/visible-supervisor/v1` channel. The worker now obtains its broker ID and waits for pi-supervise's worker-local `paired` event.
|
||||
- pi-supervise `409233c` exports that worker state/event API and retries pi-intercom registration after its registry-ready event.
|
||||
|
||||
## observed Herdr run
|
||||
|
||||
A real `/goals` → Ready run created the fork pane. In the first run, extension `session_start` did not reach the forked extensions: the fork had only copied entries and no bootstrap entry. The supervisor therefore did not pair. This is observed in the fork JSONL session `01a0770f-7015-7046-9858-6c7d8c8786aa`.
|
||||
|
||||
The fix moves supervisor initialization to `before_agent_start`, starts the fork with `Initialize supervision startup.`, and loads pi-supervise before pi-goals. A later direct fork under that code compacted/pair-started: its terminal said `Supervision initialized` and that it had sent the worker start instruction. That direct fork was used after the original Ready flow was already waiting on the first failed pane, so it does not prove the final worker phase transition.
|
||||
|
||||
## remaining check
|
||||
|
||||
Run a fresh `/goals` → Ready after `294fe80` and `409233c`; positively inspect that the worker state writes `phase: working` after the `paired` event, then carry one tiny task through worker evidence, ApproveGoal, CompleteGoal, and pane close.
|
||||
|
||||
-- PI[gpt-5.6-sol]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Persistent goal steward
|
||||
|
||||
> "ideally the supervisor has the high level planning and goal context, doesn't get overloaded and have to compact, is cheap as it doesn't use many tokens (high level only)"
|
||||
>
|
||||
> "try again with more thought using pi-subagents much more to simplify out code and rely on that so our code is simple"
|
||||
|
||||
- [x] goal: A cheap read-only steward keeps the goal context across reviews
|
||||
- [x] register one `goal-steward` agent through the public pi-subagents event bus
|
||||
- [x] start it with fresh context at Ready and resume its latest saved run at checkpoints
|
||||
- [x] send the plan path and a bounded progress delta; require the steward to reread the plan
|
||||
- failure modes: every review starts fresh; the steward receives the full worker transcript; the steward can edit; reload loses its run
|
||||
- deliverable: tests show one spawn followed by resume, a saved latest run ID, read-only tools, bounded review prompts, and reload recovery
|
||||
- evidence: [`../audits/20260905_steward-probe.json`](../audits/20260905_steward-probe.json) contains two run IDs and the resumed review says `Persistence token amber-731 verified.`
|
||||
|
||||
- [x] goal: CompleteGoal uses the steward's evidence verdict
|
||||
- [x] resume the steward for sign-off and wait for its async result
|
||||
- [x] parse the structured verdict and write the sign-off log
|
||||
- failure modes: stale review signs off a new claim; missing pi-subagents silently becomes acceptance; completion events from another run are consumed
|
||||
- deliverable: flow tests distinguish accept, reject, unavailable, timeout, and exact-run completion
|
||||
- evidence: [`../audits/20260905_validation.log`](../audits/20260905_validation.log) says `Tests 36 passed (36)` and `Checked 12 files in 14ms. No fixes applied.`
|
||||
|
||||
## UAT / Verification
|
||||
|
||||
- [x] `npm test`, `npm run typecheck`, and `npm run lint` pass.
|
||||
- [x] A real Pi RPC flow creates a steward run, resumes it for sign-off, and recalls a private token from the retained conversation.
|
||||
- [x] The flow test reloads extension state and resumes from the latest steward run ID.
|
||||
|
||||
## Appendix (context, not approved)
|
||||
|
||||
Use pi-subagents 0.65.1 public RPC (`spawn`, `resume`) and `subagent:async-complete`. Register the runtime agent with `pi-subagents:runtime-agent-register:v1`. Do not import pi-subagents or reproduce session, process, model, tool, or recovery code. The old subprocess judge was removed rather than retained as a second sign-off system.
|
||||
|
||||
## Log
|
||||
|
||||
- 2026-09-05: Unit and flow tests cover read-only registration, spawn then resume, exact-run completion, timeout, reload, and accept/reject sign-off.
|
||||
- 2026-09-05: The Pi 0.85.0 + pi-subagents 0.65.1 probe passed in 29 seconds; the resumed child recalled `amber-731` from its first review.
|
||||
|
||||
— Pi/Codex
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
requested_model: deepseek/deepseek-v4-pro-0813
|
||||
mode: code review
|
||||
input: src/worker.ts, src/supervisor-runtime.ts
|
||||
trace: omitted from git (11 MB raw provider transcript)
|
||||
generated: 2026-09-06T04:44:52.809370+00:00
|
||||
---
|
||||
|
||||
# MoA fragility review
|
||||
|
||||
|
||||
Decision: reject the current fix and replace duplicate async lifecycle state with one synchronous worker tool.
|
||||
|
||||
Strongest objection: if a truly synchronous worker RPC is unavailable, this simplification blocks the intended parallel supervision model.
|
||||
|
||||
Next check: read the goal-worker tool implementation and the three failing test transcripts before deleting code.
|
||||
|
||||
Smallest recommended architecture:
|
||||
|
||||
The supervisor extension must not store worker lifecycle state. Lifecycle is owned by the subagent runtime. Move ownership into one tool boundary.
|
||||
|
||||
1. Delete NESTED_STATE persistence, event listeners, pending reconciliation, CheckWorkerState, and the replacement guard from supervisor-runtime.ts.
|
||||
2. Add a single supervisor tool:
|
||||
- RunGoalWorker: starts and awaits a goal-worker synchronously, using the aggregate output as a tool result.
|
||||
- Keep one in-memory boolean `workerRunning`, guarded at tool execute start, not relying on event ordering.
|
||||
3. If that synchronous tool cannot be supported:
|
||||
- StartGoalWorker returns a run ID as ordinary tool output.
|
||||
- WaitGoalWorker(runId) blocks on terminal status check.
|
||||
- ApproveGoal always calls bg_wait on the ID from StartGoalWorker or WaitGoalWorker; otherwise approval fails.
|
||||
|
||||
Because existing failure 2 came from the runtime blocking on a mismatched ID, the important property is:
|
||||
- an ID not produced by StartGoalWorker/WaitGoalWorker may not be used for bg_wait;
|
||||
- a failed wait must clear any in-process guard immediately;
|
||||
- an await cover failure must be treated as a terminal error, not as `pending`.
|
||||
|
||||
Exact deletions/changes:
|
||||
|
||||
In `src/supervisor-runtime.ts`:
|
||||
- Remove `NESTED_STATE`, `NestedState`, `nested`, `persist`, `targetRun`, `completeNested`, all `subagent:async-*`, process-terminal listeners, and `retainedRunState` reconciliation.
|
||||
- Remove `pi.events.on("tool_call")` blocks. Replace with allow/deny only: deny edit/write, allow read-only bash, allow RunGoalWorker, allow bg_wait, allow ApproveGoal, deny subagent action tools.
|
||||
- Replace CheckWorkerState with nothing. State inspection is only through normal async progress updates.
|
||||
- ApproveGoal asserts no active await cover currently exists from RunGoalWorker or WaitGoalWorker, processWorkState is idle, worktree is clean, and evidence inspection claims are backed by the actual tool result from RunGoalWorker.
|
||||
|
||||
In `src/worker.ts`:
|
||||
- Drop `retainedRunState` and any pending-closure logic.
|
||||
- Keep `asyncSnapshot` only for processWorkState, if needed.
|
||||
|
||||
Why this removes fragility:
|
||||
- Duplicate state is gone.
|
||||
- Lifecycle is only stored in the runtime’s tool execution stack.
|
||||
- Revival cannot resurrect a wrong worker ID unless a new tool starts it.
|
||||
- Race between event handler and spawn disappears because Start or Wait returns a result synchronously to the model.
|
||||
|
||||
Why this may be worse:
|
||||
- Synchronous wait loses the supervisor's ability to issue corrections inline during progress.
|
||||
- Parallel instrumented runs cannot be sustained within one tool without exposing `bg_wait` to the model.
|
||||
- If the model calls WaitGoalWorker with an incorrect ID, it will now fail directly, but the failure must not be caught and retried with a cached ID.
|
||||
|
||||
Acceptance test to catch all observed failures:
|
||||
- Send the supervisor script: `StartGoalWorker` → `WaitGoalWorker(id)` → `RunGoalWorker(correction)` → `ApproveGoal`, where a midway kill drops the terminal event and forces session revival, and then assert the code path stores no `NESTED_STATE`, does not even mention it in the extension memory, and either the worker returns a tool result or the revived session remains in the same `WaitGoalWorker` tool with no retry on an ID not yielded by that tool.
|
||||
|
||||
## Completion
|
||||
|
||||
- outcome: `completed_after_follow_up`
|
||||
- trace: omitted from git (11 MB raw provider transcript); this file preserves the complete review answer
|
||||
@@ -0,0 +1,20 @@
|
||||
## Review
|
||||
|
||||
No issues found.
|
||||
|
||||
- Correct: The packaged worker is discoverable in pi-subagents 0.65.1 child-safe fanout. `package.json` exposes `pi.subagents.agents`, which the installed discovery code consumes (`pi-subagents/src/agents/agents.ts:510-538,597-657`), while the child fanout executor uses normal `discoverAgents` (`pi-subagents/src/extension/fanout-child.ts:145-190`).
|
||||
- Correct: The supervisor gate requires the exact packaged agent, nonempty task, `async:false`, `context:"fork"`, and the configured model with no extra fields (`src/supervisor-runtime.ts:83-108`). The installed executor honors explicit foreground mode (`pi-subagents/src/runs/foreground/subagent-executor.ts:6511-6515,6917-6920`).
|
||||
- Correct: Foreground completion is tied to the real `tool_result`. `activeWorkerCalls` is removed only when that result arrives, successful completion is recorded, and approval requires a later turn (`src/supervisor-runtime.ts:75-115,132-138`). Same-message worker launch plus approval is independently rejected by inspecting the assistant message.
|
||||
- Correct: Stale local launch reservations self-heal: errors clear on `tool_result`, and `turn_start` clears any reservation for which no result hook arrived (`src/supervisor-runtime.ts:75-115`). The tests cover duplicate launch, failed-result recovery, and next-turn recovery (`test/supervisor-runtime.test.ts:57-76`).
|
||||
- Correct: `CompleteGoal` remains blocked while the retained supervisor is pending, while any subagent/process work is active or unknown, or until a matching approval checkpoint exists (`src/index.ts`, `CompleteGoal`). Foreground nested work therefore cannot race sign-off because its containing supervisor run remains pending.
|
||||
- Correct: `supervisor-runtime.ts` does not perform runtime-agent registration. The main extension exits in child processes through `isSupervisorProcess`, while installed pi-subagents itself is inert when `PI_SUBAGENT_CHILD=1` (`src/index.ts`, `isSupervisorProcess`; installed `pi-subagents/index.ts:3-8`).
|
||||
- Correct: The former nested async worker ID/pending lifecycle is absent. The remaining `workerRunId`/`workerPending` state belongs only to the retained supervisor lifecycle, matching the documented topology.
|
||||
|
||||
Residual risks:
|
||||
- `test/package-agent.test.ts` verifies packaging statically rather than launching the packaged worker through the real child-safe fanout runtime. The installed 0.65.1 source supports the configuration, but retaining an RPC integration check is advisable.
|
||||
- The focused approval tests mock Pi’s `tool_call`/`tool_result` ordering. A real RPC test remains the strongest guard against upstream lifecycle-event changes.
|
||||
- Tests were inspected but not executed in this review environment; the supervisor should run `npm test`, `npm run typecheck`, and `npm run lint`.
|
||||
|
||||
- Merge verdict: **OK with residual test-environment risks.**
|
||||
|
||||
-- PI[reviewer/gpt-5.6-sol]
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
|
||||
const GOAL_LINE = /^\s*(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*goal:\s*(.*)$/i;
|
||||
|
||||
export interface ApprovalRecord {
|
||||
version: 3;
|
||||
verdict: "accept";
|
||||
approvalId: string;
|
||||
goal: string;
|
||||
planPath: string;
|
||||
goalBlockHash: string;
|
||||
repoRoot: string;
|
||||
head: string;
|
||||
tree: string;
|
||||
cleanWorktree: true;
|
||||
inspected: { plan: true; repository: true; evidence: true; verifyOutput: true };
|
||||
verifyOutputPath: string;
|
||||
supervisor: { sessionId: string; runId: string | null };
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function command(repoRoot: string, args: string[]): string {
|
||||
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
export function repositoryState(cwd: string): { repoRoot: string; head: string; tree: string; cleanWorktree: boolean } {
|
||||
const repoRoot = command(cwd, ["rev-parse", "--show-toplevel"]);
|
||||
const head = command(repoRoot, ["rev-parse", "HEAD"]);
|
||||
const tree = command(repoRoot, ["rev-parse", "HEAD^{tree}"]);
|
||||
const prefix = relative(repoRoot, resolve(cwd)).replaceAll("\\", "/");
|
||||
const owned = prefix ? `${prefix}/.pi` : ".pi";
|
||||
const cleanWorktree = command(repoRoot, [
|
||||
"status", "--porcelain=v1", "--untracked-files=all", "--", ".",
|
||||
`:(exclude,glob)${owned}/plan/*.md`,
|
||||
`:(exclude,glob)${owned}/pi-goals/approvals/*`,
|
||||
]) === "";
|
||||
return { repoRoot, head, tree, cleanWorktree };
|
||||
}
|
||||
|
||||
export function goalBlock(plan: string, goal: string): string | null {
|
||||
const lines = plan.split("\n");
|
||||
const wanted = goal.trim().toLowerCase();
|
||||
const hits = lines.flatMap((line, index) => {
|
||||
const match = GOAL_LINE.exec(line);
|
||||
return match && (match[1] === " " || match[1] === "/") && match[2].trim().toLowerCase() === wanted ? [index] : [];
|
||||
});
|
||||
if (hits.length !== 1) return null;
|
||||
const start = hits[0];
|
||||
let end = lines.length;
|
||||
for (let index = start + 1; index < lines.length; index++) {
|
||||
if (GOAL_LINE.test(lines[index])) {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.slice(start, end).join("\n");
|
||||
}
|
||||
|
||||
export function hashGoalBlock(block: string): string {
|
||||
return createHash("sha256").update(block).digest("hex");
|
||||
}
|
||||
|
||||
export function verifyOutputPath(repoRoot: string, path: string): string | null {
|
||||
const resolved = resolve(repoRoot, path);
|
||||
const relativePath = relative(repoRoot, resolved).replaceAll("\\", "/");
|
||||
if (!relativePath || relativePath.startsWith("../") || relativePath === "..") return null;
|
||||
try {
|
||||
const output = statSync(resolved);
|
||||
if (!output.isFile() || output.size === 0) return null;
|
||||
command(repoRoot, ["ls-files", "--error-unmatch", "--", relativePath]);
|
||||
return relativePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function approvalPath(cwd: string, sessionId: string, goal: string): string {
|
||||
const goalId = createHash("sha256").update(goal.trim().toLowerCase()).digest("hex").slice(0, 16);
|
||||
return join(cwd, ".pi", "pi-goals", "approvals", `${sessionId}-${goalId}.json`);
|
||||
}
|
||||
|
||||
export function writeApproval(path: string, record: ApprovalRecord): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.${process.pid}.tmp`;
|
||||
try {
|
||||
writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`);
|
||||
renameSync(temporary, path);
|
||||
} finally {
|
||||
if (existsSync(temporary)) rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function readApproval(path: string): ApprovalRecord | null {
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as ApprovalRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function approvalMatches(record: ApprovalRecord | null, input: {
|
||||
approvalId: string;
|
||||
goal: string;
|
||||
planPath: string;
|
||||
goalBlockHash: string;
|
||||
repoRoot: string;
|
||||
head: string;
|
||||
tree: string;
|
||||
cleanWorktree: boolean;
|
||||
}): boolean {
|
||||
return record?.version === 3
|
||||
&& record.verdict === "accept"
|
||||
&& record.approvalId === input.approvalId
|
||||
&& record.goal === input.goal
|
||||
&& resolve(record.planPath) === resolve(input.planPath)
|
||||
&& record.goalBlockHash === input.goalBlockHash
|
||||
&& resolve(record.repoRoot) === resolve(input.repoRoot)
|
||||
&& record.head === input.head
|
||||
&& record.tree === input.tree
|
||||
&& record.cleanWorktree === true
|
||||
&& input.cleanWorktree
|
||||
&& record.inspected.plan === true
|
||||
&& record.inspected.repository === true
|
||||
&& record.inspected.evidence === true
|
||||
&& record.inspected.verifyOutput === true
|
||||
&& Boolean(record.verifyOutputPath);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface LaunchSupervisorInput {
|
||||
cwd: string;
|
||||
sourceSessionFile: string;
|
||||
workerSessionId: string;
|
||||
workerIntercomId: string;
|
||||
planPath: string;
|
||||
approvalId: string;
|
||||
extensionPath: string;
|
||||
superviseExtensionPath: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function findPaneId(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["pane_id", "paneId"]) {
|
||||
if (typeof record[key] === "string") return record[key];
|
||||
}
|
||||
for (const child of Object.values(record)) {
|
||||
const found = findPaneId(child);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function herdr(args: string[], json = true): Promise<unknown> {
|
||||
const bin = process.env.HERDR_BIN_PATH ?? "herdr";
|
||||
const { stdout } = await execFileAsync(bin, args, { encoding: "utf8", timeout: 15_000 });
|
||||
if (!json) return stdout.trim();
|
||||
return stdout.trim() ? JSON.parse(stdout) : {};
|
||||
}
|
||||
|
||||
function stalePaneError(error: unknown): boolean {
|
||||
const record = error as { stdout?: unknown; stderr?: unknown; message?: unknown };
|
||||
const text = [record.stdout, record.stderr, record.message].filter((value): value is string => typeof value === "string").join("\n");
|
||||
return /\b(?:NOT_FOUND|PANE_GONE|PANE_NOT_FOUND)\b/i.test(text);
|
||||
}
|
||||
|
||||
export function supervisorCommand(input: LaunchSupervisorInput): string {
|
||||
const env = [
|
||||
"PI_GOALS_ROLE=supervisor",
|
||||
`PI_GOALS_WORKER_ID=${input.workerSessionId}`,
|
||||
`PI_GOALS_WORKER_INTERCOM_ID=${input.workerIntercomId}`,
|
||||
`PI_GOALS_PLAN_PATH=${input.planPath}`,
|
||||
`PI_GOALS_APPROVAL_ID=${input.approvalId}`,
|
||||
`PI_GOALS_OWNER_SESSION_ID=${input.workerSessionId}`,
|
||||
];
|
||||
const args = [
|
||||
"pi",
|
||||
"--no-extensions",
|
||||
"-e", "npm:pi-intercom",
|
||||
"-e", process.env.PI_GOALS_SUPERVISE_EXTENSION ?? input.superviseExtensionPath ?? "npm:@wassname2/pi-supervise@0.0.4",
|
||||
"-e", input.extensionPath,
|
||||
"--fork", input.sourceSessionFile,
|
||||
"--name", `goals-supervisor-${input.workerSessionId.slice(0, 8)}`,
|
||||
];
|
||||
if (input.model) args.push("--model", input.model);
|
||||
return `env ${[...env, ...args].map(shellQuote).join(" ")}`;
|
||||
}
|
||||
|
||||
export async function openSupervisorPane(input: LaunchSupervisorInput): Promise<string> {
|
||||
if (process.env.HERDR_ENV !== "1") throw new Error("Ready needs a Herdr session so pi-goals can open the supervisor session.");
|
||||
await herdr(["--version"], false);
|
||||
const split = await herdr(["pane", "split", "--current", "--direction", "right", "--cwd", input.cwd, "--no-focus"]);
|
||||
const paneId = findPaneId(split);
|
||||
if (!paneId) throw new Error("Herdr did not return the new supervisor pane ID.");
|
||||
try {
|
||||
await herdr(["pane", "run", paneId, supervisorCommand(input)]);
|
||||
return paneId;
|
||||
} catch (error) {
|
||||
await closeSupervisorPane(paneId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeSupervisorPane(paneId: string): Promise<void> {
|
||||
try {
|
||||
await herdr(["pane", "close", paneId]);
|
||||
} catch (error) {
|
||||
if (stalePaneError(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+331
-339
@@ -1,68 +1,48 @@
|
||||
/**
|
||||
* pi-goals v2 — plan mode drafts goals into one .pi/plan.md, the agent works them with its normal
|
||||
* Edit tool, and a fresh read-only judge signs each goal off through the one blessed tool,
|
||||
* CompleteGoal.
|
||||
* PI: pi-goals owns one versioned plan per session. After Ready, the main session implements the
|
||||
* plan while a compacted, visible fork supervises it through pi-supervise.
|
||||
*
|
||||
* The v1 lesson: the parser existed so TypeScript could read plan.md, but almost every reader is a
|
||||
* model. So v2 has NO parser and no schema. The harness does exactly three things for a
|
||||
* cooperative-but-confused model:
|
||||
* 1. memory — a transient re-send of the plan, never persisted, on two triggers: the plan went
|
||||
* stale for STALE_TURNS turns (send the working set above ## Log), or the session
|
||||
* started / compacted (send the whole file, appendix included). v2 sent the whole
|
||||
* file every turn; pi-tasks tried that and deleted it as "wallpaper noise that
|
||||
* trains the model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149),
|
||||
* and the always-present CompleteGoal description carries the contract instead.
|
||||
* 2. format — a skeleton convention taught in planDrafting (prompts.ts), not validated
|
||||
* 3. eyes — CompleteGoal spawns a strictly read-only pi subprocess (--no-session, no bash)
|
||||
* that gets the whole plan file plus the claimed goal, finds the goal itself
|
||||
* (tolerates wording drift), checks the evidence (including the agent's saved
|
||||
* verify output) against the repo, and returns VERDICT: accept|reject
|
||||
* Each /goals call makes `.pi/plan/<session_id>-vN.md`. The selected version survives resume and
|
||||
* compaction. Old plans stay on disk but inactive. A session with no selected plan has no widget,
|
||||
* supervision, or CompleteGoal sign-off.
|
||||
*
|
||||
* The judge subsumes what v1 did in code: goal matching (no findGoal), evidence validation (a
|
||||
* placeholder gets rejected in words), and format reading. The extension's only
|
||||
* writes are the sign-off: append a log line to ## Log (the audit trail) and tick the goal [x] when
|
||||
* an exact goal line matches (on drift the agent ticks, and the result says so). A hand-tick
|
||||
* without a matching tool-written log line is visible in the diff either way.
|
||||
* TypeScript reads only goal checkbox lines for the widget. Models read the plan as prose. The
|
||||
* worker edits the project and records evidence. The supervisor inspects it and writes a private
|
||||
* approval checkpoint.
|
||||
*
|
||||
* Judge ran but failed/errored/timed out, or returned no VERDICT line => accepted_inconclusive: the
|
||||
* working agent is never blocked on judge infra; the log line says the judge ran but failed. There
|
||||
* is no pre-emptive "no model" path -- a null judgeModel just omits --model so pi's configured
|
||||
* default runs the judge, so inconclusive always means "ran but failed", never "couldn't start".
|
||||
*
|
||||
* All model-facing text lives in prompts.ts, in flow order.
|
||||
* -- Pi/Codex
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { completeGoalDescription, completeGoalParamDescription, judgeSystem, judgeUser, planDrafting, reminder, resync } from "./prompts.js";
|
||||
import { Type } from "typebox";
|
||||
import { approvalMatches, approvalPath, goalBlock, hashGoalBlock, readApproval, repositoryState } from "./approval.js";
|
||||
import { closeSupervisorPane, openSupervisorPane } from "./herdr.js";
|
||||
import { completeGoalDescription, completeGoalParamDescription, planDrafting, planningState, resync } from "./prompts.js";
|
||||
import { SUPERVISOR_STARTUP_TIMEOUT_MS, workerPiSupervise } from "./supervise.js";
|
||||
import { isVisibleSupervisor, registerVisibleSupervisor } from "./supervisor-session.js";
|
||||
|
||||
const STATE = "pi-goals-state";
|
||||
const STATUS_KEY = "pi-goals";
|
||||
const WIDGET_KEY = "pi-goals-widget";
|
||||
const PLAN_REL = ".pi/plan.md";
|
||||
// Judge toolset: strictly read-only, NO bash -- the judge can never execute or mutate anything, and
|
||||
// in particular never re-runs a verify command (which may be a 10-hour training job). The agent runs
|
||||
// verify itself and saves the output as evidence; the judge reads it. Names match pi's tool registry.
|
||||
const JUDGE_TOOLS = ["read", "grep", "find", "ls"];
|
||||
const JUDGE_BLOCKED_TOOLS = ["edit", "write"];
|
||||
const JUDGE_TIMEOUT_MS = 600_000;
|
||||
// Plan mode is read-only by convention AND a light gate: edit/write are blocked (except plan.md,
|
||||
// the deliverable). bash stays open — the prompt says don't mutate; guide, don't gate (spec D3).
|
||||
const PLANNING_CONTEXT = "pi-goals-planning-context";
|
||||
const PLAN_DIR = ".pi/plan";
|
||||
// For static text (the /goals description) where there is no ctx to resolve the session id.
|
||||
const PLAN_SHAPE = `${PLAN_DIR}/<session_id>-vN.md`;
|
||||
// Plan mode blocks edit/write except for its plan file. bash remains available for read-only inspection. -- Pi/Codex
|
||||
const PLAN_MODE_BLOCKED_TOOLS = ["edit", "write"];
|
||||
// Turns the plan may go untouched before it is re-sent. pi-tasks uses 4, or 2 while something is in
|
||||
// progress; here every goal is "in progress", so 2.
|
||||
const STALE_TURNS = 2;
|
||||
|
||||
// A checkbox line beginning "goal:", for the widget and the "any goals open?" reminder condition.
|
||||
// A checkbox line beginning "goal:", used by the widget and supervisor scheduling.
|
||||
// Everything else reads the file as prose.
|
||||
const GOAL_LINE = /^\s*(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*goal:\s*(.*)$/i;
|
||||
// An indented checkbox line that isn't a goal: a subtask. Only the widget reads these, so the human
|
||||
// sees the next action and not just the goal -- this file IS the task list.
|
||||
const SUBTASK_LINE = /^\s+(?:\d+\.|[-*])\s*\[([ xX/-])\]\s*(.*)$/;
|
||||
// The fold. Above it: the working set that gets re-sent. Below it: durable memory.
|
||||
// The fold separates current goals from the longer research record.
|
||||
const FOLD_LINE = /^##\s+Log\s*$/im;
|
||||
type GoalStatus = "open" | "active" | "done" | "cancelled";
|
||||
const CHAR_TO_STATUS: Record<string, GoalStatus> = { " ": "open", "/": "active", x: "done", "-": "cancelled" };
|
||||
@@ -76,8 +56,7 @@ function scanGoals(plan: string): Array<{ status: GoalStatus; subject: string; l
|
||||
return goals;
|
||||
}
|
||||
|
||||
/** The working set: everything above "## Log". Log, Learnings and Appendix below it are durable
|
||||
* memory -- unlimited, read on demand, pushed back only by a resync. Exported for the unit test. */
|
||||
/** Return the short current-goal section above "## Log". Exported for the unit test. */
|
||||
export function foldPlan(plan: string): string {
|
||||
const m = FOLD_LINE.exec(plan);
|
||||
return (m ? plan.slice(0, m.index) : plan).trimEnd();
|
||||
@@ -95,38 +74,124 @@ export function openSubtasks(plan: string, goalLine: number): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function nextPlanVersion(planNames: string[], sessionId: string): number {
|
||||
const prefix = `${sessionId}-v`;
|
||||
const versions = planNames.flatMap((name) => {
|
||||
if (!name.startsWith(prefix) || !name.endsWith(".md")) return [];
|
||||
const version = Number(name.slice(prefix.length, -".md".length));
|
||||
return Number.isInteger(version) && version > 0 ? [version] : [];
|
||||
});
|
||||
return Math.max(0, ...versions) + 1;
|
||||
}
|
||||
|
||||
type Phase = "planning" | "working" | null;
|
||||
|
||||
export function isMainSession(isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1"): boolean {
|
||||
return !isSubagentChild && !isVisibleSupervisor();
|
||||
}
|
||||
|
||||
interface PlanState {
|
||||
isPlanMode: boolean;
|
||||
/** Optional model ref for the sign-off judge; unset => current session model, else pi's default. */
|
||||
judgeModel: string | null;
|
||||
phase: Phase;
|
||||
supervisorModel: string | null;
|
||||
supervisorPaneId: string | null;
|
||||
approvalId: string | null;
|
||||
planVersion: number | null;
|
||||
}
|
||||
|
||||
export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
let state: PlanState = { isPlanMode: false, judgeModel: null };
|
||||
// Reminder cadence (pi-tasks style): the plan is re-sent only after it has gone untouched for
|
||||
// STALE_TURNS turns, and editing it resets the clock -- an agent that is maintaining the file
|
||||
// doesn't need to be told to. In-memory, like pi-tasks: a new session starts fresh.
|
||||
let turnsStale = 0;
|
||||
let lastSeenPlan = "";
|
||||
// Set on session start and after a compaction; drained by the next LLM call, which then carries
|
||||
// the WHOLE file (appendix included) instead of just the working set.
|
||||
if (isVisibleSupervisor()) {
|
||||
registerVisibleSupervisor(pi);
|
||||
return;
|
||||
}
|
||||
if (!isMainSession()) return;
|
||||
let state: PlanState = {
|
||||
phase: null,
|
||||
supervisorModel: null,
|
||||
supervisorPaneId: null,
|
||||
approvalId: null,
|
||||
planVersion: null,
|
||||
};
|
||||
let planningContextPending = false;
|
||||
let resyncReason: string | null = "New session.";
|
||||
|
||||
const planPath = (ctx: ExtensionContext) => join(ctx.cwd, ".pi", "plan.md");
|
||||
const readPlan = (ctx: ExtensionContext): string => (existsSync(planPath(ctx)) ? readFileSync(planPath(ctx), "utf-8") : "");
|
||||
const planRel = (ctx: ExtensionContext) => (state.planVersion === null ? PLAN_SHAPE : `${PLAN_DIR}/${ctx.sessionManager.getSessionId()}-v${state.planVersion}.md`);
|
||||
const planPath = (ctx: ExtensionContext) => {
|
||||
if (state.planVersion === null) throw new Error("No active plan version.");
|
||||
return join(ctx.cwd, planRel(ctx));
|
||||
};
|
||||
const readPlan = (ctx: ExtensionContext): string => (state.planVersion !== null && existsSync(planPath(ctx)) ? readFileSync(planPath(ctx), "utf-8") : "");
|
||||
const writePlan = (ctx: ExtensionContext, content: string): void => {
|
||||
mkdirSync(join(ctx.cwd, ".pi"), { recursive: true });
|
||||
mkdirSync(join(ctx.cwd, PLAN_DIR), { recursive: true });
|
||||
writeFileSync(planPath(ctx), content);
|
||||
};
|
||||
const nextVersion = (ctx: ExtensionContext): number =>
|
||||
nextPlanVersion(existsSync(join(ctx.cwd, PLAN_DIR)) ? readdirSync(join(ctx.cwd, PLAN_DIR)) : [], ctx.sessionManager.getSessionId());
|
||||
|
||||
function persist(): void {
|
||||
pi.appendEntry<PlanState>(STATE, state);
|
||||
}
|
||||
|
||||
function beginReview(ctx: ExtensionContext): void {
|
||||
for (const goal of scanGoals(readPlan(ctx))) {
|
||||
rmSync(approvalPath(ctx.cwd, ctx.sessionManager.getSessionId(), goal.subject), { force: true });
|
||||
}
|
||||
state = { ...state, approvalId: randomUUID() };
|
||||
persist();
|
||||
}
|
||||
|
||||
function loadedPiSuperviseExtensionPath(): string | null {
|
||||
const tool = pi.getAllTools().find((candidate) => candidate.name === "worker_view") as { sourceInfo?: { path?: unknown } } | undefined;
|
||||
return typeof tool?.sourceInfo?.path === "string" ? tool.sourceInfo.path : null;
|
||||
}
|
||||
|
||||
function repositoryRoot(cwd: string): string {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
async function startSupervisor(ctx: ExtensionContext): Promise<void> {
|
||||
repositoryRoot(ctx.cwd);
|
||||
const sourceSessionFile = ctx.sessionManager.getSessionFile();
|
||||
if (!sourceSessionFile) throw new Error("The current session is not persisted, so it cannot be forked.");
|
||||
const worker = await workerPiSupervise(pi);
|
||||
beginReview(ctx);
|
||||
let paneId: string | null = null;
|
||||
try {
|
||||
paneId = await openSupervisorPane({
|
||||
cwd: ctx.cwd,
|
||||
sourceSessionFile,
|
||||
workerSessionId: ctx.sessionManager.getSessionId(),
|
||||
workerIntercomId: worker.intercomId,
|
||||
planPath: planPath(ctx),
|
||||
approvalId: state.approvalId!,
|
||||
extensionPath: fileURLToPath(import.meta.url),
|
||||
superviseExtensionPath: loadedPiSuperviseExtensionPath(),
|
||||
model: state.supervisorModel,
|
||||
});
|
||||
await worker.waitForPair(SUPERVISOR_STARTUP_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
if (paneId) throw new Error(`Supervisor startup failed in Herdr pane ${paneId}; it remains open for inspection. ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
state = { ...state, supervisorPaneId: paneId };
|
||||
persist();
|
||||
}
|
||||
|
||||
async function stopSupervisor(): Promise<boolean> {
|
||||
if (!state.supervisorPaneId) return true;
|
||||
try {
|
||||
await closeSupervisorPane(state.supervisorPaneId);
|
||||
state = { ...state, supervisorPaneId: null };
|
||||
persist();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateWidget(ctx: ExtensionContext): void {
|
||||
if (state.isPlanMode) {
|
||||
if (state.phase === "planning") {
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("warning", "planning"));
|
||||
ctx.ui.setWidget(WIDGET_KEY, [`pi-goals: drafting goals in ${PLAN_REL}`]);
|
||||
ctx.ui.setWidget(WIDGET_KEY, ["pi-goals: drafting goals"]);
|
||||
return;
|
||||
}
|
||||
const goals = scanGoals(readPlan(ctx));
|
||||
@@ -136,41 +201,67 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
return;
|
||||
}
|
||||
const done = goals.filter((g) => g.status === "done").length;
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals`));
|
||||
const liveGoals = goals.filter((g) => g.status === "active" || g.status === "open");
|
||||
const stateLabel = liveGoals.length > 0 ? " · supervised" : " · complete";
|
||||
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `◷ ${done}/${goals.length} goals${stateLabel}`));
|
||||
const mark: Record<GoalStatus, string> = { done: "✔", active: "▸", open: "◻", cancelled: "✗" };
|
||||
// Only live goals get lines so finished work never pushes current work off screen. The active
|
||||
// goal also shows its open subtasks: this file is the task list, so the widget is the task list.
|
||||
// No path line: the session id makes it too long to be useful in the widget.
|
||||
const plan = readPlan(ctx);
|
||||
const lines = [ctx.ui.theme.fg("muted", PLAN_REL)];
|
||||
for (const g of goals.filter((g) => g.status === "active" || g.status === "open")) {
|
||||
lines.push(`${mark[g.status]} ${g.subject}`);
|
||||
const lines: string[] = liveGoals.length === 0 ? ["✔ complete"] : [];
|
||||
for (const g of liveGoals) {
|
||||
lines.push(`${mark[g.status]} ${g.status === "active" ? "supervising… " : ""}${g.subject}`);
|
||||
if (g.status === "active") lines.push(...openSubtasks(plan, g.line).slice(0, 3).map((s) => ctx.ui.theme.fg("muted", ` ◦ ${s}`)));
|
||||
}
|
||||
ctx.ui.setWidget(WIDGET_KEY, lines);
|
||||
}
|
||||
|
||||
// --- /goals: enter plan mode (or clear / set judge) --------------------------------------------
|
||||
// --- /goals: enter plan mode or configure supervision -- Pi/Codex -----------------------------
|
||||
|
||||
pi.registerCommand("goals", {
|
||||
description: `Plan mode: draft goals into ${PLAN_REL}, review, then work them. /goals <objective> | /goals clear | /goals judge <model>`,
|
||||
description: `Plan goals, then open a visible supervisor session. /goals <objective> | clear | model <supervisor>`,
|
||||
handler: async (args, ctx) => {
|
||||
const arg = args.trim();
|
||||
if (arg === "clear") {
|
||||
writePlan(ctx, "");
|
||||
state = { ...state, isPlanMode: false };
|
||||
if (state.planVersion === null) {
|
||||
ctx.ui.notify("No active plan to disconnect.", "info");
|
||||
return;
|
||||
}
|
||||
const currentPlan = planRel(ctx);
|
||||
if (!(await stopSupervisor())) {
|
||||
ctx.ui.notify("Could not close the visible supervisor; the plan remains connected.", "warning");
|
||||
return;
|
||||
}
|
||||
state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
ctx.ui.notify(`Cleared ${PLAN_REL}.`, "info");
|
||||
ctx.ui.notify(`Disconnected from ${currentPlan}; the file remains on disk.`, "info");
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith("judge")) {
|
||||
const ref = arg.slice("judge".length).trim();
|
||||
state = { ...state, judgeModel: ref || null };
|
||||
if (arg === "model" || arg.startsWith("model ")) {
|
||||
if (state.phase === "working") {
|
||||
ctx.ui.notify("Run /goals clear before changing the active supervisor model.", "warning");
|
||||
return;
|
||||
}
|
||||
if (!(await stopSupervisor())) {
|
||||
ctx.ui.notify("Could not close the visible supervisor; its model was not changed.", "warning");
|
||||
return;
|
||||
}
|
||||
const ref = arg.slice("model".length).trim();
|
||||
state = { ...state, supervisorModel: ref || null, supervisorPaneId: null, approvalId: null };
|
||||
persist();
|
||||
ctx.ui.notify(ref ? `Sign-off judge model set to ${ref}` : "Sign-off judge reset to the session model", "info");
|
||||
ctx.ui.notify(`Goal-supervisor model ${ref ? `set to ${ref}` : "reset to the current Pi default"}.`, "info");
|
||||
return;
|
||||
}
|
||||
state = { ...state, isPlanMode: true };
|
||||
if (!(await stopSupervisor())) {
|
||||
ctx.ui.notify("Could not close the visible supervisor; no new plan was started.", "warning");
|
||||
return;
|
||||
}
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx) };
|
||||
planningContextPending = true;
|
||||
resyncReason = null;
|
||||
writePlan(ctx, "");
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
// The drafting rules are sent ONCE, with the seed. v2 re-injected them every turn, which is
|
||||
@@ -185,113 +276,141 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
|
||||
// --- hooks --------------------------------------------------------------------------------------
|
||||
|
||||
/** What this LLM call should carry, if anything: a one-shot resync, or a staleness reminder. */
|
||||
/** Restore the complete plan once after session start or compaction. */
|
||||
function dueInjection(ctx: ExtensionContext, plan: string): string | null {
|
||||
const drainResync = (): string | null => {
|
||||
const why = resyncReason;
|
||||
resyncReason = null;
|
||||
return why;
|
||||
};
|
||||
if (state.isPlanMode) {
|
||||
const why = drainResync();
|
||||
return why ? `<system-reminder>\n${why} You are still in plan mode.\n\n${planDrafting}\n\nWrite the plan to ${planPath(ctx)}.\n</system-reminder>` : null;
|
||||
}
|
||||
if (!plan.trim()) return null;
|
||||
const why = drainResync();
|
||||
if (why) return resync(plan, PLAN_REL, why);
|
||||
if (turnsStale < STALE_TURNS) return null;
|
||||
const goals = scanGoals(plan);
|
||||
if (goals.length === 0) {
|
||||
// Non-empty plan but no recognizable goal line: the harness would go silently inert (no
|
||||
// widget, no injection, no reminders). Say so instead -- cooperative but confused.
|
||||
return `<system-reminder>\n${PLAN_REL} exists but has no goal line pi-goals recognizes. A goal is a checkbox list line starting "goal:", e.g. "1. [ ] goal: <imperative>" ([ ] open, [/] active, [x] done, [-] cancelled). Reformat it if it's meant to be the plan.\n</system-reminder>`;
|
||||
}
|
||||
if (!goals.some((g) => g.status === "active" || g.status === "open")) return null;
|
||||
return reminder(foldPlan(plan), PLAN_REL);
|
||||
if (state.phase === "planning" || !plan.trim() || !resyncReason) return null;
|
||||
const why = resyncReason;
|
||||
resyncReason = null;
|
||||
return resync(plan, planRel(ctx), why);
|
||||
}
|
||||
|
||||
// The one injection point: a transient user message on this LLM call only, never persisted. So
|
||||
// there are no stale copies to strip, and an untouched turn costs nothing.
|
||||
pi.on("context", async (event, ctx) => {
|
||||
const text = dueInjection(ctx, readPlan(ctx));
|
||||
if (!text) return;
|
||||
turnsStale = 0;
|
||||
return { messages: [...event.messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] };
|
||||
// The phase snapshot enters context only when planning starts or context was lost.
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
if (state.phase === "working") {
|
||||
return {
|
||||
systemPrompt: `${ctx.getSystemPrompt()}\n\nYou are the implementation worker for ${planRel(ctx)}. Keep the full conversation and do the work directly. A stronger read-only supervisor watches this session through pi-supervise and can steer you. Commit clean evidence before asking for sign-off. Stop when a goal appears complete so the supervisor can inspect a settled worker view. Call CompleteGoal only after the supervisor says it recorded approval. -- Pi/Codex`,
|
||||
};
|
||||
}
|
||||
if (!planningContextPending) return;
|
||||
planningContextPending = false;
|
||||
return { message: { customType: PLANNING_CONTEXT, content: planningState(planPath(ctx)), display: false } };
|
||||
});
|
||||
|
||||
// The staleness clock: editing the plan resets it, the way a task tool call resets pi-tasks'.
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
const plan = readPlan(ctx);
|
||||
if (plan === lastSeenPlan) {
|
||||
turnsStale++;
|
||||
return;
|
||||
// PI: Working turns never see an obsolete planning snapshot. Auto-compaction retries skip
|
||||
// before_agent_start, so context restores the planning snapshot exactly once in that path.
|
||||
pi.on("context", async (event, ctx) => {
|
||||
const messages = state.phase === "planning" ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT);
|
||||
const removedPlanningContext = messages.length !== event.messages.length;
|
||||
if (state.phase === "planning" && planningContextPending) {
|
||||
planningContextPending = false;
|
||||
return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text: planningState(planPath(ctx)) }], timestamp: Date.now() }] };
|
||||
}
|
||||
lastSeenPlan = plan;
|
||||
turnsStale = 0;
|
||||
const text = dueInjection(ctx, readPlan(ctx));
|
||||
if (!text) return removedPlanningContext ? { messages } : undefined;
|
||||
return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] };
|
||||
});
|
||||
|
||||
// PI: Human plan-mode replies are durable evidence of the interview, not model summaries.
|
||||
pi.on("input", async (event, ctx) => {
|
||||
if (state.phase === "planning" && event.source !== "extension") writePlan(ctx, appendInterview(readPlan(ctx), event.text));
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
updateWidget(ctx);
|
||||
});
|
||||
|
||||
// A compaction is exactly when the settled context is gone, so push the whole file back once.
|
||||
pi.on("session_compact", async () => {
|
||||
resyncReason = "The session was just compacted.";
|
||||
});
|
||||
|
||||
// Plan mode gate: block edit/write except on plan.md itself. bash stays open (guide, not gate).
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (!state.isPlanMode) return;
|
||||
if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) {
|
||||
const target = (event.input as { path?: string }).path;
|
||||
if (target && resolve(ctx.cwd, target) === resolve(planPath(ctx))) return;
|
||||
return { block: true, reason: `Plan mode is read-only: only ${PLAN_REL} may be written while drafting. Agree the goals first, then choose Ready.` };
|
||||
if (state.phase === "planning") {
|
||||
if (PLAN_MODE_BLOCKED_TOOLS.includes(event.toolName)) {
|
||||
const target = (event.input as { path?: string }).path;
|
||||
if (target && resolve(ctx.cwd, target) === resolve(planPath(ctx))) return;
|
||||
return { block: true, reason: `Planning is read-only: only ${planRel(ctx)} may be written. Agree the plan, then choose Ready.` };
|
||||
}
|
||||
if (event.toolName === "bash" && !isPlanningReadOnlyCommand(String((event.input as { command?: string }).command))) {
|
||||
return { block: true, reason: "Planning is read-only: inspect facts without writes or pipes, then put the change in the plan." };
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// After a plan-mode turn: if goals were drafted, offer Ready. The human reads the file and says
|
||||
// go, edits it in $EDITOR, or keeps talking to revise it (menu shape borrowed from pi-plan).
|
||||
pi.on("agent_end", async (_event, ctx) => {
|
||||
if (!state.isPlanMode || !ctx.hasUI) return;
|
||||
while (scanGoals(readPlan(ctx)).length > 0) {
|
||||
const choice = await ctx.ui.select(`Plan drafted in ${PLAN_REL}. Ready?`, [
|
||||
"Ready — start working the plan",
|
||||
"Open in $EDITOR — edit it myself",
|
||||
"Keep planning (reply to revise)",
|
||||
]);
|
||||
if (choice?.startsWith("Open")) {
|
||||
spawnSync(process.env.EDITOR || process.env.VISUAL || "vi", [planPath(ctx)], { stdio: "inherit" });
|
||||
// A compaction loses context, so restore either the planning snapshot or the working plan once.
|
||||
pi.on("session_compact", async () => {
|
||||
if (state.phase === "planning") planningContextPending = true;
|
||||
else resyncReason = "The session was just compacted.";
|
||||
});
|
||||
|
||||
// PI: Print after Pi settles. agent_end is still streaming, so its message queues behind the menu.
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
if (state.phase !== "planning" || !ctx.hasUI) return;
|
||||
let printed = "";
|
||||
while (true) {
|
||||
const plan = readPlan(ctx);
|
||||
if (scanGoals(plan).length === 0) {
|
||||
if (plan.trim()) ctx.ui.notify(`The plan has no goal line. Revise ${planRel(ctx)} to add one.`, "warning");
|
||||
return;
|
||||
}
|
||||
if (plan !== printed) {
|
||||
printed = plan;
|
||||
pi.sendMessage({ customType: "plan", content: plan, display: true });
|
||||
}
|
||||
const choice = await ctx.ui.select(`Plan drafted in ${planRel(ctx)}.`, ["Ready", "Refine", "Edit", "Cancel"]);
|
||||
if (choice === "Refine") {
|
||||
const notes = await ctx.ui.editor("What should change about the plan?", "");
|
||||
if (!notes?.trim()) continue;
|
||||
writePlan(ctx, appendInterview(plan, notes));
|
||||
planningContextPending = true;
|
||||
pi.sendUserMessage(`Revise the plan at ${planPath(ctx)} using these human notes:\n\n${notes}\n\nKeep the same goal structure.`, { deliverAs: "followUp" });
|
||||
return;
|
||||
}
|
||||
if (choice === "Edit") {
|
||||
const edited = await ctx.ui.editor("Edit the plan", plan);
|
||||
if (edited !== undefined && edited !== plan) writePlan(ctx, edited);
|
||||
continue;
|
||||
}
|
||||
if (!choice?.startsWith("Ready")) return;
|
||||
state = { ...state, isPlanMode: false };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
pi.sendUserMessage(
|
||||
`Work the goals in ${planPath(ctx)}. Pick an open goal, mark it active ([/]), work its subtasks, and when its discriminator is satisfied fill its evidence: list, then call CompleteGoal with the goal's text. Keep the plan file current as you go.`,
|
||||
{ deliverAs: "followUp" },
|
||||
);
|
||||
if (choice === "Cancel") {
|
||||
rmSync(planPath(ctx), { force: true });
|
||||
state = { ...state, phase: null, supervisorPaneId: null, approvalId: null, planVersion: null };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
ctx.ui.notify("Plan discarded.", "info");
|
||||
return;
|
||||
}
|
||||
if (choice !== "Ready") return;
|
||||
try {
|
||||
await startSupervisor(ctx);
|
||||
state = { ...state, phase: "working" };
|
||||
resyncReason = "The plan was approved.";
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
ctx.ui.notify(`Visible supervisor opened in Herdr pane ${state.supervisorPaneId}.`, "info");
|
||||
pi.sendUserMessage("The plan is approved. Begin implementation as the worker.");
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`Goal supervisor could not start: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null };
|
||||
persist();
|
||||
updateWidget(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
// v1 wrote .pi/goals.md; v2 reads .pi/plan.md. Rename so old goals aren't silently invisible
|
||||
// (dogfood finding). Claude: one-time migration, delete once v1 files are gone from the wild.
|
||||
const v1Path = join(ctx.cwd, ".pi", "goals.md");
|
||||
if (existsSync(v1Path) && !existsSync(planPath(ctx))) {
|
||||
renameSync(v1Path, planPath(ctx));
|
||||
ctx.ui.notify(`Renamed .pi/goals.md -> ${PLAN_REL} (v2 filename).`, "info");
|
||||
}
|
||||
const last = ctx.sessionManager
|
||||
.getEntries()
|
||||
.filter((e: { type?: string; customType?: string }) => e.type === "custom" && e.customType === STATE)
|
||||
.pop() as { data?: PlanState } | undefined;
|
||||
if (last?.data) state = { ...state, ...last.data };
|
||||
lastSeenPlan = readPlan(ctx);
|
||||
resyncReason = "New session.";
|
||||
state = {
|
||||
phase: last?.data?.phase ?? null,
|
||||
supervisorModel: last?.data?.supervisorModel ?? null,
|
||||
supervisorPaneId: last?.data?.supervisorPaneId ?? null,
|
||||
approvalId: last?.data?.approvalId ?? null,
|
||||
planVersion: last?.data?.planVersion ?? null,
|
||||
};
|
||||
planningContextPending = state.phase === "planning";
|
||||
resyncReason = state.phase === "working" ? "New session." : null;
|
||||
updateWidget(ctx);
|
||||
});
|
||||
|
||||
// --- the one blessed tool: CompleteGoal ---------------------------------------------------------
|
||||
|
||||
pi.registerTool({
|
||||
name: "CompleteGoal",
|
||||
label: "Goal signoff",
|
||||
@@ -299,48 +418,36 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
parameters: Type.Object({
|
||||
goal: Type.String({ description: completeGoalParamDescription }),
|
||||
}),
|
||||
async execute(_id, params, signal, onUpdate, ctx) {
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
if (state.phase !== "working") return result("Planning is not approved. Choose Ready before signing off a goal.", true);
|
||||
if (!state.approvalId) return result("Goal sign-off blocked: no current supervisor review.", true);
|
||||
const plan = readPlan(ctx);
|
||||
if (!plan.trim()) return result(`No plan file at ${PLAN_REL}.`, true);
|
||||
|
||||
const judgeModel = state.judgeModel ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null);
|
||||
onUpdate?.({ content: [{ type: "text", text: `Read-only judge (${judgeModel ?? "pi default"}) inspecting: ${params.goal}` }], details: {} });
|
||||
// decideSignOff runs the judge and derives the outcome + the one log line. judgeModel is never
|
||||
// checked pre-emptively: null just means pi's configured default runs (buildJudgeArgs omits
|
||||
// --model), so accepted_inconclusive always means "the judge ran but failed", never "no model".
|
||||
let judgeRaw: JudgeResult | null = null;
|
||||
const outcome = await decideSignOff({ goal: params.goal, plan, judgeModel }, signal, async (task) => {
|
||||
judgeRaw = await runJudge(task, judgeModel, ctx.cwd, signal);
|
||||
return judgeRaw;
|
||||
});
|
||||
// Persist the judge's full transcript so "did the judge really re-run verify?" is answerable
|
||||
// after the fact (dogfood finding: with only the one log line, an accept is unauditable).
|
||||
let transcriptNote = "";
|
||||
if (judgeRaw !== null) {
|
||||
const raw: JudgeResult = judgeRaw;
|
||||
mkdirSync(join(ctx.cwd, ".pi", "judge"), { recursive: true });
|
||||
const rel = `.pi/judge/${stamp().replace(/[: ]/g, "-")}.md`;
|
||||
writeFileSync(join(ctx.cwd, rel), `goal: ${params.goal}\nmodel: ${judgeModel ?? "pi default"}\nerror: ${raw.error ?? "none"}\n\n${raw.output}\n`);
|
||||
transcriptNote = ` (${rel})`;
|
||||
if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true);
|
||||
const block = goalBlock(plan, params.goal);
|
||||
if (!block) return result(`No unique open goal line matched "${params.goal}" in ${planRel(ctx)}.`, true);
|
||||
let repository: ReturnType<typeof repositoryState>;
|
||||
try {
|
||||
repository = repositoryState(ctx.cwd);
|
||||
} catch (error) {
|
||||
return result(`Goal sign-off could not inspect the repository: ${error instanceof Error ? error.message : String(error)}`, true);
|
||||
}
|
||||
if (outcome.logEntry) {
|
||||
// Sign-off write: tick the goal [x] (exact-subject match; dogfood showed agent bookkeeping
|
||||
// is the drift point) and append the audit log line, one write. On wording drift the tick
|
||||
// falls to the agent and the result says so -- both paths are explicit, never silent.
|
||||
let updated = readPlan(ctx);
|
||||
let tickNote = "";
|
||||
if (outcome.logEntry.startsWith("signed off")) {
|
||||
const ticked = tickGoal(updated, params.goal);
|
||||
updated = ticked ?? updated;
|
||||
tickNote = ticked
|
||||
? `\n\nGoal ticked [x] in ${PLAN_REL}.`
|
||||
: `\n\nNo exact goal line matched your wording -- tick it [x] in ${PLAN_REL} yourself.`;
|
||||
}
|
||||
writePlan(ctx, appendLog(updated, `${stamp()} ${outcome.logEntry}${transcriptNote}`));
|
||||
updateWidget(ctx);
|
||||
return result(outcome.resultText + tickNote, outcome.isError);
|
||||
}
|
||||
return result(outcome.resultText, outcome.isError);
|
||||
if (!repository.cleanWorktree) return result("Goal sign-off blocked: worktree is dirty.", true);
|
||||
const approval = readApproval(approvalPath(ctx.cwd, ctx.sessionManager.getSessionId(), params.goal));
|
||||
if (!approvalMatches(approval, {
|
||||
approvalId: state.approvalId,
|
||||
goal: params.goal,
|
||||
planPath: planPath(ctx),
|
||||
goalBlockHash: hashGoalBlock(block),
|
||||
repoRoot: repository.repoRoot,
|
||||
head: repository.head,
|
||||
tree: repository.tree,
|
||||
cleanWorktree: repository.cleanWorktree,
|
||||
})) return result("Goal sign-off blocked: no matching supervisor approval checkpoint. Request a fresh supervisor review.", true);
|
||||
const ticked = tickGoal(plan, params.goal);
|
||||
if (!ticked) return result(`No unique exact goal line matched "${params.goal}" in ${planRel(ctx)}.`, true);
|
||||
writePlan(ctx, appendLog(ticked, `${stamp()} mechanically signed off "${params.goal}" after matching supervisor approval`));
|
||||
updateWidget(ctx);
|
||||
return result(`Sign-off accepted. Goal ticked [x] in ${planRel(ctx)}.`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -351,6 +458,20 @@ function result(text: string, isError = false) {
|
||||
return { content: [{ type: "text" as const, text }], details: {}, isError };
|
||||
}
|
||||
|
||||
function mutatingReadCommand(part: string): boolean {
|
||||
return /(?:^|\s)--output(?:=|\s|$)|^find\b.*\s-(?:delete|exec|execdir|ok|okdir|fprint|fprintf|fls)(?:\s|$)/.test(part)
|
||||
|| (/^git\s+branch\b/.test(part) && !/^git\s+branch(?:\s+(?:--show-current|--list|-a|--all|-r|--remotes|-v|-vv))*$/.test(part));
|
||||
}
|
||||
|
||||
function isPlanningReadOnlyCommand(command: string): boolean {
|
||||
if (/[|><`$\n\r]/.test(command)) return false;
|
||||
return command.split(/&&|;/).every((raw) => {
|
||||
const part = raw.trim();
|
||||
return !mutatingReadCommand(part) && /^(?:cd\b|pwd|ls\b|git\s+(?:status|log|diff|show|branch)\b|rg\b|grep\b|find\b|head\b|tail\b|wc\b|stat\b|test\b)\b/.test(part);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** Local time, not UTC: agents freehand-stamp their manual ## Log lines from the local clock they
|
||||
* see, so a UTC tool stamp made the trail read as two different afternoons (dogfood finding). */
|
||||
function stamp(): string {
|
||||
@@ -359,87 +480,8 @@ function stamp(): string {
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function oneLine(s: string): string {
|
||||
return s.replace(/\s+/g, " ").trim().slice(0, 200);
|
||||
}
|
||||
|
||||
/** A judge run's result: stdout output, plus an error string when the subprocess failed/timed out. */
|
||||
export interface JudgeResult {
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Inputs to a sign-off decision. judgeModel is null when no explicit/session model is set. */
|
||||
export interface SignOffInput {
|
||||
goal: string;
|
||||
plan: string;
|
||||
judgeModel: string | null;
|
||||
}
|
||||
|
||||
/** The outcome of a sign-off: the reply text, whether it's a hard error, and the one ## Log line to
|
||||
* append (null when nothing should be written, e.g. aborted before any verdict). */
|
||||
export interface SignOffOutcome {
|
||||
resultText: string;
|
||||
isError: boolean;
|
||||
logEntry: string | null;
|
||||
}
|
||||
|
||||
/** Run the judge and decide accept / reject / accepted_inconclusive. Pure aside from the injected
|
||||
* judge runner, so the unit test can lock the fail-forward invariant: judgeModel is NEVER checked
|
||||
* here, 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 execute() wrapper does the plan-file write + widget.
|
||||
* Exported for the unit test that locks this invariant. */
|
||||
export async function decideSignOff(
|
||||
input: SignOffInput,
|
||||
signal: AbortSignal | undefined,
|
||||
runJudgeFn: (task: string) => Promise<JudgeResult>,
|
||||
): Promise<SignOffOutcome> {
|
||||
const task = judgeUser({ goal: input.goal, plan: input.plan, planPath: PLAN_REL });
|
||||
const judge = await runJudgeFn(task);
|
||||
|
||||
if (signal?.aborted) return { resultText: "Sign-off aborted.", isError: true, logEntry: null };
|
||||
|
||||
// Judge ran but failed/errored/timed out: fail forward, say so in the log.
|
||||
if (judge.error) {
|
||||
const partial = judge.output ? `\n\npartial judge output:\n${judge.output}` : "";
|
||||
return {
|
||||
resultText: `Judge ran but failed (${judge.error}). Accepted inconclusive — logged.${partial}`,
|
||||
isError: false,
|
||||
logEntry: `signed off "${input.goal}" (judge inconclusive: ran but failed: ${oneLine(judge.error)})`,
|
||||
};
|
||||
}
|
||||
|
||||
const verdictLine = judge.output.split("\n").find((l) => /^\s*VERDICT\s*:/i.test(l)) ?? "";
|
||||
const verdict = /^\s*VERDICT\s*:\s*(accept|reject)\s*$/i.exec(verdictLine)?.[1]?.toLowerCase();
|
||||
const reasoning = judge.output.length > 2000 ? `...\n${judge.output.slice(-2000)}` : judge.output;
|
||||
|
||||
if (verdict === "accept") {
|
||||
return {
|
||||
resultText: `Sign-off ACCEPTED (log line appended).\n\n--- judge ---\n${reasoning}`,
|
||||
isError: false,
|
||||
logEntry: `signed off "${input.goal}" (judge accept)`,
|
||||
};
|
||||
}
|
||||
if (verdict === "reject") {
|
||||
const missing = judge.output.match(/missing\s*:\s*([\s\S]*)$/i)?.[1].trim() || judge.output.slice(-500);
|
||||
return {
|
||||
resultText: `Sign-off REJECTED. Missing:\n${missing}\n\n--- judge ---\n${reasoning}`,
|
||||
isError: true,
|
||||
logEntry: `reject "${input.goal}": ${oneLine(missing)}`,
|
||||
};
|
||||
}
|
||||
// No VERDICT line: same fail-forward as a judge error -- the judge ran but didn't answer.
|
||||
return {
|
||||
resultText: `Judge returned no VERDICT line. Accepted inconclusive — logged.\n\n--- judge ---\n${reasoning || "(no output)"}`,
|
||||
isError: false,
|
||||
logEntry: `signed off "${input.goal}" (judge inconclusive: no VERDICT line)`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tick the goal line whose subject exactly matches `goal` (trimmed, case-insensitive) to [x].
|
||||
* Null when there is no unique exact match (wording drift / duplicates) -- the caller then asks the
|
||||
* agent to tick it itself. Reuses GOAL_LINE; deliberately NOT fuzzy, that's the judge's job. */
|
||||
* Null when there is no unique exact match. Reuses GOAL_LINE and is deliberately not fuzzy. */
|
||||
export function tickGoal(plan: string, goal: string): string | null {
|
||||
const lines = plan.split("\n");
|
||||
const want = goal.trim().toLowerCase();
|
||||
@@ -464,64 +506,14 @@ export function appendLog(text: string, entry: string): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** 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. `--no-extensions` keeps
|
||||
* the judge minimal and immune to a broken third-party extension taking down every sign-off.
|
||||
* Exported for the unit test that locks these invariants. */
|
||||
export function buildJudgeArgs(judgeModel: string | null): string[] {
|
||||
const args = ["-p", "--no-session", "--no-extensions"];
|
||||
if (judgeModel) args.push("--model", judgeModel);
|
||||
args.push("--tools", JUDGE_TOOLS.join(","), "--exclude-tools", JUDGE_BLOCKED_TOOLS.join(","), "--append-system-prompt", judgeSystem);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Locate the pi binary the same way the oracle extension does, so spawning works under bun or node. */
|
||||
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
||||
const script = process.argv[1];
|
||||
if (script && !script.startsWith("/$bunfs/root/") && existsSync(script)) return { command: process.execPath, args: [script, ...args] };
|
||||
const execName = basename(process.execPath).toLowerCase();
|
||||
if (!/^(node|bun)(\.exe)?$/.test(execName)) return { command: process.execPath, args };
|
||||
return { command: "pi", args };
|
||||
}
|
||||
|
||||
/** Spawn the read-only judge subprocess (plain `pi -p`: stdout is the final response text). */
|
||||
async function runJudge(
|
||||
task: string,
|
||||
judgeModel: string | null,
|
||||
cwd: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<JudgeResult> {
|
||||
const args = buildJudgeArgs(judgeModel);
|
||||
args.push(task);
|
||||
const inv = getPiInvocation(args);
|
||||
// Runs in-place against this checkout; pi --no-session does not clone into the parent
|
||||
// (proven by scripts/check-judge-footprint.sh).
|
||||
return new Promise((resolvePromise) => {
|
||||
let settled = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const done = (r: { output: string; error?: string }) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolvePromise(r);
|
||||
}
|
||||
};
|
||||
const proc = spawn(inv.command, inv.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], signal });
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill();
|
||||
done({ output: stdout.trim(), error: `judge timed out after ${JUDGE_TIMEOUT_MS / 1000}s` });
|
||||
}, JUDGE_TIMEOUT_MS);
|
||||
proc.stdout?.on("data", (d) => {
|
||||
stdout += d.toString();
|
||||
});
|
||||
proc.stderr?.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if ((code ?? 0) !== 0) done({ output: stdout.trim(), error: stderr.trim() || `judge subprocess exited ${code ?? 1}` });
|
||||
else done({ output: stdout.trim() });
|
||||
});
|
||||
proc.on("error", (e) => done({ output: stdout.trim(), error: `judge subprocess failed: ${e.message}` }));
|
||||
});
|
||||
/** PI: Preserve human plan-mode answers verbatim below the fold. */
|
||||
export function appendInterview(text: string, answer: string): string {
|
||||
const lines = text.split("\n");
|
||||
const header = lines.findIndex((l) => /^##\s+Interview\s*$/i.test(l));
|
||||
const entry = [`### ${stamp()}`, "", ...answer.split("\n").map((line) => `> ${line}`), ""];
|
||||
if (header === -1) return `${text.replace(/\n+$/, "")}\n\n## Interview\n\n${entry.join("\n")}`;
|
||||
let insertAt = header + 1;
|
||||
while (insertAt < lines.length && !/^##\s+/.test(lines[insertAt])) insertAt++;
|
||||
lines.splice(insertAt, 0, ...entry);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
+96
-128
@@ -1,78 +1,95 @@
|
||||
/**
|
||||
* pi-goals v2 — all model-facing text, in flow order.
|
||||
*
|
||||
* Design: plan.md is for LLMs and the human, not for TypeScript. There is no parser and no schema;
|
||||
* the skeleton below is a convention the drafting prompt teaches, the working agent maintains with
|
||||
* its normal Edit tool, and the judge reads natively. The harness does three things for a
|
||||
* cooperative-but-confused model: memory (a transient re-send of the plan when it goes stale),
|
||||
* format guidance (the skeleton), and fresh eyes (the read-only judge in CompleteGoal).
|
||||
* 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 main session implements it,
|
||||
* while a visible forked Pi session supervises through pi-supervise.
|
||||
*
|
||||
* 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.
|
||||
* THE FOLD: everything above "## Log" is the short current-goal section. Everything below it
|
||||
* (Log, Learnings, Appendix) is durable memory: unlimited, read on demand, and sent in full at
|
||||
* session start and after compaction.
|
||||
*
|
||||
* Flow:
|
||||
* SETUP (plan mode) 1. planDrafting — draft goals into plan.md (read-only phase), sent once
|
||||
* EXEC, on cadence 2. reminder — the folded plan + upkeep nudge when plan.md went stale
|
||||
* 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
|
||||
* SETUP (plan mode) 1. planDrafting — draft goals into the plan file (read-only), sent once
|
||||
* EXEC, after compact 2. resync — the WHOLE file back, once
|
||||
* SIGN-OFF, worker-side 3. completeGoal* — the one blessed tool's description
|
||||
* SUPERVISION supervisor-session.ts — visible read-only supervisor
|
||||
*
|
||||
* 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 (read-only: edit/write blocked except plan.md)
|
||||
* 1. planDrafting — SETUP, plan mode (read-only: edit/write blocked except the plan file)
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
export const planDrafting = `\
|
||||
You are in plan mode. The objective may arrive through conversation, not as one up-front command.
|
||||
Explore the repository read-only first: resolve discoverable facts by looking them up, and only ask
|
||||
the human when the answer is a genuine intent or preference choice. 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). When
|
||||
the objective is clear, draft the plan file and present it.
|
||||
You are in plan mode. You are making a short judgeable plan that captures the user's real goals, then tests it in conversation.
|
||||
|
||||
How this mode ends: after each of your turns the human gets a menu (Ready / open in $EDITOR / keep
|
||||
planning). Plan mode ends when they pick Ready. So close every draft with one line -- the plan is
|
||||
final, pick Ready to start or reply to revise -- and do not redraft in silence. When a new
|
||||
requirement arrives, fold it in, say what changed, and say the plan is final again. Detail that
|
||||
doesn't change a goal or a discriminator belongs in the appendix, not in the goals.
|
||||
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. Ask at least three short, concrete questions that test whether you
|
||||
understand the requested outcome, boundary, and how success will be judged. Inspect files or search the
|
||||
web before asking when either can answer a fact. If the human does not answer a question, record that
|
||||
point as unknown; do not silently replace it with an inference. Do not present the review menu with a
|
||||
placeholder goal such as "work out the thing", "improve it", or "investigate".
|
||||
3. For independent high-impact questions, build a decision tree and ask the whole frontier in one
|
||||
round. Ask only questions worth the human's time, where the answer materially reduces 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, or the unanswered
|
||||
unknown, 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.
|
||||
|
||||
How this mode ends: after each settled draft the human gets a menu (Ready / Refine / Edit / Cancel).
|
||||
Plan mode ends only when they pick Ready. Refine collects short revision notes. Edit opens the full
|
||||
plan. When a new requirement arrives, fold it in, say what changed, and present the plan again.
|
||||
Detail that doesn't change a goal or a discriminator belongs in the appendix, not in the goals.
|
||||
|
||||
Right-size it:
|
||||
- Default to ONE goal. Add another only when it's a genuinely separate checkpoint that can pass or
|
||||
fail on its own. Most objectives are 1-2 goals.
|
||||
- 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 supervisor 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 supervisor should 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.
|
||||
- Don't invent goals to look thorough. When in doubt, merge.
|
||||
- Everything above "## Log" is the part the model carries while it works. Keep it under 50 lines,
|
||||
reviewable in one pass. Everything below "## Log" is unlimited.
|
||||
- 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 supervisor 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.
|
||||
|
||||
Style: ASD-STE100 Simplified Technical English. Active voice, one idea per sentence, common words,
|
||||
the same word for the same thing, and define a new term at first use. This covers the context
|
||||
paragraph and the appendix too, not just the checklist. No all-caps headers and no bold spam; the
|
||||
checklist is already the structure.
|
||||
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.
|
||||
|
||||
If a loaded skill also gives you a plan format, this skeleton wins while you are in this mode. Take
|
||||
its style advice, not a second template, and keep one plan file.
|
||||
|
||||
Write the plan file in roughly this shape (it's a convention, not a schema -- the file is read
|
||||
directly by the human and a judge model, so clarity beats conformance; small deviations are fine):
|
||||
Write the plan file in roughly this shape -- the file is read directly by the human and the visible supervisor, 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 word for word>"
|
||||
- > "<the human's requirement, quoted in full word for word (with spelling fixes)>"
|
||||
|
||||
## Goals
|
||||
|
||||
1. [ ] goal: <one short imperative line>
|
||||
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>
|
||||
testable. The worker runs it and saves its output; the visible supervisor reads the evidence>
|
||||
- tasks:
|
||||
1. [ ] <subtask>
|
||||
- evidence: (empty until sign-off)
|
||||
@@ -82,9 +99,14 @@ directly by the human and a judge model, so clarity beats conformance; small dev
|
||||
<-- 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:
|
||||
@@ -97,11 +119,16 @@ Conventions:
|
||||
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.
|
||||
- evidence stays empty at planning; the worker fills it and the visible supervisor 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.
|
||||
usually gitignored, so files there prove things only at supervisor review 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.
|
||||
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
|
||||
@@ -112,48 +139,37 @@ Conventions:
|
||||
|
||||
When the goals are drafted, present them and say the plan is final. Do not begin execution.`;
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 3. reminder — EXEC. Transient, never persisted, and only when the plan went stale for a couple of
|
||||
* 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 reminder(foldedPlan: string, planRel: string): string {
|
||||
export function planningState(planPath: string): string {
|
||||
return `\
|
||||
<system-reminder>
|
||||
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
|
||||
- otherwise keep working toward the active goal; don't stop to ask unless genuinely blocked
|
||||
</system-reminder>`;
|
||||
[PLANNING MODE]
|
||||
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.`;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 3b. resync — EXEC, one-shot at session start and after a compaction: the WHOLE file back,
|
||||
* 2. 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 function resync(plan: string, planRel: string, why: string): string {
|
||||
return `\
|
||||
<system-reminder>
|
||||
${why} This is the whole plan file (${planRel}), appendix included, so you don't re-litigate what
|
||||
was already settled. Keep working the active goal; edit the file directly as you go.
|
||||
${why} This is the whole plan file (${planRel}), appendix included. You are the implementation worker.
|
||||
Keep the high-level goal and human intent stable and do the work directly. A visible read-only Pi
|
||||
session supervises you through pi-supervise. The human's latest message outranks the plan: if it
|
||||
changes scope, amend the plan rather than preserving an obsolete decision.
|
||||
|
||||
${plan}
|
||||
</system-reminder>`;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 4. completeGoal — SIGN-OFF, agent-side: the one blessed tool
|
||||
* 3. completeGoal — SIGN-OFF, agent-side: the one blessed tool
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
export const completeGoalDescription =
|
||||
"Sign off a goal once its discriminator is satisfied. First fill the goal's evidence: list in the " +
|
||||
@@ -161,62 +177,14 @@ export const completeGoalDescription =
|
||||
"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. " +
|
||||
"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.";
|
||||
"the goal names a verify: command, direct the worker to run it and save its output to a file cited " +
|
||||
"in the evidence. The supervisor may run an allowed read-only verification command, but must not " +
|
||||
"create the evidence file itself. The visible supervisor must reject a claimed pass with no saved " +
|
||||
"output. The read must show success POSITIVELY happened, not just that failures were avoided. The " +
|
||||
"supervisor records an approval checkpoint only after it inspected the current plan, repository, " +
|
||||
"evidence, verify output, and a stopped worker view with no active work. Then the worker calls this " +
|
||||
"tool with the exact goal text. This tool independently checks that checkpoint " +
|
||||
"against the exact current goal block, HEAD/tree, and clean worktree before it appends the sign-off to " +
|
||||
"## Log and ticks the goal [x]. If any check differs, it fails closed and requires a fresh supervisor review.";
|
||||
|
||||
export const completeGoalParamDescription = "The goal's text: the line after 'goal:' in the plan file.";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* 5. 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:
|
||||
|
||||
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.
|
||||
|
||||
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 judgeUser(p: { goal: string; plan: string; planPath: string }): string {
|
||||
return `\
|
||||
The working agent claims this goal is complete:
|
||||
|
||||
goal: ${p.goal}
|
||||
|
||||
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 its discriminator, subtle failure
|
||||
modes, verify command, and evidence list from the file itself.
|
||||
|
||||
--- plan file ---
|
||||
${p.plan}
|
||||
--- end plan file ---
|
||||
|
||||
Read the cited artifacts (you cannot execute anything), then give your VERDICT.`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const PAIR_EVENT = "pi-supervise:pair:v1";
|
||||
const WORKER_STATE_EVENT = "pi-supervise:worker-state:v1";
|
||||
const WORKER_PAIRED_EVENT = "pi-supervise:worker-paired:v1";
|
||||
const API_READY_EVENT = "pi-supervise:api-ready:v1";
|
||||
const TIMEOUT_MS = 15_000;
|
||||
export const SUPERVISOR_STARTUP_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
type Events = { emit(name: string, value: unknown): boolean; on(name: string, handler: (value: any) => void): void };
|
||||
|
||||
function wait<T>(start: (resolve: (value: T) => void, reject: (error: Error) => void) => void, message: string, timeoutMs = TIMEOUT_MS): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
start((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
||||
});
|
||||
}
|
||||
|
||||
export function pairWithPiSupervise(pi: ExtensionAPI, workerIntercomId: string, goal: string): Promise<void> {
|
||||
const events = (pi as unknown as { events: Events }).events;
|
||||
return wait((resolve, reject) => events.emit(PAIR_EVENT, { version: 1, workerIntercomId, goal, resolve, reject }), "pi-supervise did not accept the visible-supervisor pairing request.");
|
||||
}
|
||||
|
||||
export interface WorkerPiSupervise {
|
||||
intercomId: string;
|
||||
waitForPair(timeoutMs?: number): Promise<void>;
|
||||
}
|
||||
|
||||
export function workerPiSupervise(pi: ExtensionAPI, timeoutMs = TIMEOUT_MS): Promise<WorkerPiSupervise> {
|
||||
const events = (pi as unknown as { events: Events }).events;
|
||||
let paired = false;
|
||||
let resolvePair: (() => void) | undefined;
|
||||
events.on(WORKER_PAIRED_EVENT, () => {
|
||||
paired = true;
|
||||
resolvePair?.();
|
||||
});
|
||||
return wait((resolve, reject) => {
|
||||
let resolved = false;
|
||||
const request = () => events.emit(WORKER_STATE_EVENT, (state: { intercomId?: string; paired?: boolean }) => {
|
||||
if (resolved) return;
|
||||
if (!state.intercomId) return reject(new Error("pi-supervise returned no worker intercom ID."));
|
||||
if (state.paired) return reject(new Error("This worker is already paired with a supervisor. Stop that supervision before selecting Ready."));
|
||||
resolved = true;
|
||||
resolve({
|
||||
intercomId: state.intercomId,
|
||||
waitForPair: (pairTimeoutMs = timeoutMs) => paired ? Promise.resolve() : wait((pairResolve) => { resolvePair = pairResolve; }, "The visible supervisor did not pair with this worker.", pairTimeoutMs),
|
||||
});
|
||||
});
|
||||
events.on(API_READY_EVENT, request);
|
||||
request();
|
||||
}, "pi-supervise did not publish this worker's intercom state.", timeoutMs);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { approvalPath, goalBlock, hashGoalBlock, repositoryState, verifyOutputPath, writeApproval } from "./approval.js";
|
||||
import { pairWithPiSupervise } from "./supervise.js";
|
||||
|
||||
const BOOTSTRAPPED = "pi-goals-visible-supervisor-v1";
|
||||
const INITIAL_COMPACT_AT_TOKENS = 20_000;
|
||||
const COMPACT_AT_TOKENS = 100_000;
|
||||
|
||||
interface SupervisorConfig {
|
||||
workerSessionId: string;
|
||||
workerIntercomId: string;
|
||||
ownerSessionId: string;
|
||||
planPath: string;
|
||||
approvalId: string;
|
||||
}
|
||||
|
||||
function result(text: string, isError = false) {
|
||||
return { content: [{ type: "text" as const, text }], details: {}, isError };
|
||||
}
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required in a pi-goals supervisor session.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function config(): SupervisorConfig {
|
||||
return {
|
||||
workerSessionId: requiredEnv("PI_GOALS_WORKER_ID"),
|
||||
workerIntercomId: requiredEnv("PI_GOALS_WORKER_INTERCOM_ID"),
|
||||
ownerSessionId: requiredEnv("PI_GOALS_OWNER_SESSION_ID"),
|
||||
planPath: resolve(requiredEnv("PI_GOALS_PLAN_PATH")),
|
||||
approvalId: requiredEnv("PI_GOALS_APPROVAL_ID"),
|
||||
};
|
||||
}
|
||||
|
||||
function hasEvidenceEntry(block: string): boolean {
|
||||
const lines = block.split("\n");
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const evidence = /^\s*[-*]\s+evidence:\s*(.*)$/i.exec(lines[index]);
|
||||
if (!evidence) continue;
|
||||
if (evidence[1].trim() && !/^\(empty until sign-off\)$/i.test(evidence[1].trim())) return true;
|
||||
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
|
||||
for (let child = index + 1; child < lines.length; child++) {
|
||||
const childIndent = lines[child].match(/^\s*/)?.[0].length ?? 0;
|
||||
if (lines[child].trim() && childIndent <= indent) break;
|
||||
const entry = /^\s+[-*]\s+(.+?)\s*$/.exec(lines[child]);
|
||||
if (entry?.[1].trim()) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function latestWorkerView(ctx: ExtensionContext): string | null {
|
||||
for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
|
||||
const message = (entry as { type?: string; message?: { role?: string; content?: unknown[] } }).message;
|
||||
if ((entry as { type?: string }).type !== "message" || message?.role !== "user" || !Array.isArray(message.content)) continue;
|
||||
for (const part of message.content) {
|
||||
const text = (part as { type?: string; text?: string }).type === "text" ? (part as { text?: string }).text : undefined;
|
||||
if (text?.startsWith("The worker ")) return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function supervisorPrompt(settings: SupervisorConfig): string {
|
||||
return `You are the visible pi-goals supervisor for ${settings.planPath}. You are a stronger, read-only reviewer. The other Pi session is the implementation worker and keeps the full conversation. You keep the high-level intent from the compacted planning conversation and pi-supervise worker views. The complete plan at ${settings.planPath} is the source of truth; read it directly after every compaction.
|
||||
|
||||
Use pi-supervise to inspect and steer the worker. Give one concrete instruction when work is incomplete. Do not edit files. For each open goal, inspect its exact plan block, repository state, cited evidence, and a saved nonempty verification-output file. When its discriminator is positively satisfied and the worker view says no work is active, call ApproveGoal with that repository-relative path. Then call steer and tell the worker to call CompleteGoal with the exact goal text. Do not call done until every plan goal is [x]. -- PI[gpt-5.6-sol]`;
|
||||
}
|
||||
|
||||
export function isVisibleSupervisor(): boolean {
|
||||
return process.env.PI_GOALS_ROLE === "supervisor";
|
||||
}
|
||||
|
||||
export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
const settings = config();
|
||||
let compacting = false;
|
||||
let bootstrapping = false;
|
||||
|
||||
const bootstrap = async (ctx: ExtensionContext): Promise<void> => {
|
||||
if (bootstrapping) return;
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
if (entries.some((entry: { type?: string; customType?: string }) => entry.type === "custom" && entry.customType === BOOTSTRAPPED)) return;
|
||||
bootstrapping = true;
|
||||
try {
|
||||
await pairWithPiSupervise(pi, settings.workerIntercomId, settings.planPath);
|
||||
pi.appendEntry(BOOTSTRAPPED, { version: 1, workerSessionId: settings.workerSessionId, planPath: settings.planPath });
|
||||
pi.sendUserMessage("Supervision is paired. Inspect the worker and give its next concrete instruction.");
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`Supervisor startup failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrapAfterInitialCompaction = (ctx: ExtensionContext): void => {
|
||||
const tokens = ctx.getContextUsage()?.tokens;
|
||||
if (typeof tokens === "number" && tokens <= INITIAL_COMPACT_AT_TOKENS) {
|
||||
void bootstrap(ctx);
|
||||
return;
|
||||
}
|
||||
compacting = true;
|
||||
ctx.compact({
|
||||
customInstructions: `Preserve the user's high-level intent, decisions, unresolved risks, and the supervisor's remit. The canonical plan is ${settings.planPath}; it remains available directly and must not be replaced by this summary.`,
|
||||
onComplete: () => {
|
||||
compacting = false;
|
||||
ctx.ui.notify("Supervisor planning context compacted before work started.", "info");
|
||||
void bootstrap(ctx);
|
||||
},
|
||||
onError: (error) => {
|
||||
compacting = false;
|
||||
ctx.ui.notify(`Supervisor startup compaction failed: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
setImmediate(() => { bootstrapAfterInitialCompaction(ctx); });
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
return { systemPrompt: `${ctx.getSystemPrompt()}\n\n${supervisorPrompt(settings)}` };
|
||||
});
|
||||
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
if (compacting || (ctx.getContextUsage()?.tokens ?? 0) < COMPACT_AT_TOKENS) return;
|
||||
compacting = true;
|
||||
ctx.compact({
|
||||
customInstructions: `Keep the user's high-level intent, current plan state, unresolved risks, approval decisions, and the supervisor's own concise findings. Remove old worker views and implementation detail.`,
|
||||
onComplete: () => {
|
||||
compacting = false;
|
||||
ctx.ui.notify("Supervisor context compacted at 100k tokens.", "info");
|
||||
},
|
||||
onError: (error) => {
|
||||
compacting = false;
|
||||
ctx.ui.notify(`Supervisor compaction failed: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "ApproveGoal",
|
||||
label: "Approve goal",
|
||||
executionMode: "sequential",
|
||||
description: "Record approval after inspecting the current goal, repository, evidence, and a saved nonempty verification-output file, with a stopped worker view and no active work.",
|
||||
parameters: Type.Object({
|
||||
goal: Type.String({ description: "Exact text after goal: in the plan." }),
|
||||
verifyOutputPath: Type.String({ description: "Nonempty repository-relative file containing the verification output you inspected." }),
|
||||
}),
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
const view = latestWorkerView(ctx);
|
||||
if (!view?.startsWith("The worker stopped.")) return result("Cannot approve without a current stopped-worker view.", true);
|
||||
const pendingTool = view.match(/^tool calls with no result: (?!none$)(.+)$/m);
|
||||
const pendingChild = view.match(/^child pi processes still running: (?!none$)(.+)$/m);
|
||||
if (pendingTool || pendingChild) return result(`Cannot approve while work is active: ${(pendingTool ?? pendingChild)![1]}`, true);
|
||||
let plan: string;
|
||||
let repository: ReturnType<typeof repositoryState>;
|
||||
try {
|
||||
plan = readFileSync(settings.planPath, "utf8");
|
||||
repository = repositoryState(ctx.cwd);
|
||||
} catch (error) {
|
||||
return result(`Cannot inspect approval inputs: ${error instanceof Error ? error.message : String(error)}`, true);
|
||||
}
|
||||
if (!repository.cleanWorktree) return result("Cannot approve with a dirty worktree. Commit the worker changes first.", true);
|
||||
const block = goalBlock(plan, params.goal);
|
||||
if (!block) return result(`Cannot approve: no unique open goal matches "${params.goal}".`, true);
|
||||
if (!hasEvidenceEntry(block)) return result("Cannot approve without a nonblank evidence entry in the goal block.", true);
|
||||
const verifiedOutput = verifyOutputPath(repository.repoRoot, params.verifyOutputPath);
|
||||
if (!verifiedOutput) return result("Cannot approve without a nonempty repository-relative verification-output file.", true);
|
||||
const path = approvalPath(ctx.cwd, settings.ownerSessionId, params.goal);
|
||||
writeApproval(path, {
|
||||
version: 3,
|
||||
verdict: "accept",
|
||||
approvalId: settings.approvalId,
|
||||
goal: params.goal,
|
||||
planPath: settings.planPath,
|
||||
goalBlockHash: hashGoalBlock(block),
|
||||
repoRoot: repository.repoRoot,
|
||||
head: repository.head,
|
||||
tree: repository.tree,
|
||||
cleanWorktree: true,
|
||||
inspected: { plan: true, repository: true, evidence: true, verifyOutput: true },
|
||||
verifyOutputPath: verifiedOutput,
|
||||
supervisor: { sessionId: ctx.sessionManager.getSessionId(), runId: null },
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
return result(`Approval recorded for "${params.goal}". Now steer the worker to call CompleteGoal.`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { decideSignOff, type JudgeResult } from "../src/index.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.
|
||||
describe("decideSignOff (fail-forward invariant)", () => {
|
||||
it("proceeds to runJudge even when judgeModel is null (no pre-emptive 'no model' inconclusive)", async () => {
|
||||
const runJudge = vi.fn().mockResolvedValue({ output: "VERDICT: accept\nall good" });
|
||||
const out = await decideSignOff({ goal: "x", plan: "# plan\n1. [ ] goal: x\n", judgeModel: null }, undefined, runJudge);
|
||||
expect(runJudge).toHaveBeenCalledOnce(); // reached the judge -- no pre-emptive return on null model
|
||||
expect(out.isError).toBe(false);
|
||||
expect(out.logEntry).toContain("judge accept");
|
||||
});
|
||||
|
||||
it("a judge-subprocess error yields accepted_inconclusive with a 'ran but failed' reason", async () => {
|
||||
const runJudge = vi.fn().mockResolvedValue({ output: "", error: "judge subprocess exited 1" } satisfies JudgeResult);
|
||||
const out = await decideSignOff({ goal: "x", plan: "# plan\n", judgeModel: null }, undefined, runJudge);
|
||||
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({ goal: "x", plan: "# plan\n", judgeModel: null }, 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({ goal: "x", plan: "# plan\n", judgeModel: null }, 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({ goal: "x", plan: "# plan\n", 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("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({ goal: "x", plan: "# plan\n", judgeModel: null }, ctrl.signal, runJudge);
|
||||
expect(out.logEntry).toBeNull();
|
||||
expect(out.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
Vendored
+18
@@ -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 },
|
||||
}],
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -28,7 +28,7 @@ const plan = `# Plan
|
||||
## Appendix (context, not approved)
|
||||
${"filler line\n".repeat(200)}`;
|
||||
|
||||
describe("foldPlan (the working set is what gets re-sent; below ## Log is durable memory)", () => {
|
||||
describe("foldPlan (current goals are above ## Log; durable memory is below it)", () => {
|
||||
it("keeps the title, user voice and goals", () => {
|
||||
const folded = foldPlan(plan);
|
||||
expect(folded).toContain("keep it under 50 lines");
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
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 type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalPath, goalBlock, hashGoalBlock, repositoryState, writeApproval } from "../src/approval.js";
|
||||
|
||||
const openSupervisorPane = vi.fn(async () => "pane-2");
|
||||
const closeSupervisorPane = vi.fn(async () => undefined);
|
||||
vi.mock("../src/herdr.js", () => ({ openSupervisorPane, closeSupervisorPane }));
|
||||
const { default: piGoalsExtension, isMainSession } = await import("../src/index.js");
|
||||
|
||||
function setup(selectChoices: Array<string | undefined>, editorChoices: Array<string | undefined> = []) {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-flow-"));
|
||||
writeFileSync(join(cwd, ".gitignore"), ".pi/\n");
|
||||
writeFileSync(join(cwd, "verify.txt"), "PASS\n");
|
||||
execFileSync("git", ["init", "-q"], { cwd });
|
||||
execFileSync("git", ["add", ".gitignore", "verify.txt"], { cwd });
|
||||
execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-qm", "initial"], { cwd });
|
||||
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 }> = [];
|
||||
const messages: Array<{ content: string; display?: boolean }> = [];
|
||||
const notifications: string[] = [];
|
||||
const ctx = {
|
||||
cwd,
|
||||
hasUI: true,
|
||||
getSystemPrompt: () => "base prompt",
|
||||
sessionManager: {
|
||||
getSessionId: () => "session-a",
|
||||
getSessionFile: () => join(cwd, "session.jsonl"),
|
||||
getEntries: () => entries,
|
||||
},
|
||||
ui: {
|
||||
theme: { fg: (_kind: string, text: string) => text },
|
||||
setStatus: vi.fn(),
|
||||
setWidget: vi.fn(),
|
||||
notify: (text: string) => notifications.push(text),
|
||||
select: async () => selectChoices.shift(),
|
||||
editor: async () => editorChoices.shift(),
|
||||
},
|
||||
};
|
||||
const events = new EventEmitter();
|
||||
events.on("pi-supervise:worker-state:v1", (reply) => reply({ intercomId: "worker-intercom" }));
|
||||
openSupervisorPane.mockImplementation(async () => {
|
||||
queueMicrotask(() => events.emit("pi-supervise:worker-paired:v1", { supervisorIntercomId: "supervisor-intercom" }));
|
||||
return "pane-2";
|
||||
});
|
||||
const pi = {
|
||||
events,
|
||||
registerCommand: (name: string, command: any) => commands.set(name, command),
|
||||
on: (name: string, handler: any) => hooks.set(name, handler),
|
||||
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
|
||||
registerTool: (tool: any) => tools.set(tool.name, tool),
|
||||
getAllTools: () => [],
|
||||
sendMessage: (message: { content: string; display?: boolean }) => messages.push(message),
|
||||
sendUserMessage: (content: string) => messages.push({ content }),
|
||||
};
|
||||
piGoalsExtension(pi as unknown as ExtensionAPI);
|
||||
return { commands, ctx, cwd, entries, events, hooks, messages, notifications, tools };
|
||||
}
|
||||
|
||||
function writePlan(cwd: string, content: string): string {
|
||||
const path = join(cwd, ".pi/plan/session-a-v1.md");
|
||||
mkdirSync(join(cwd, ".pi/plan"), { recursive: true });
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
function approvedPlan(cwd: string): string {
|
||||
return writePlan(cwd, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: output exists\n - evidence:\n - `result.txt`: contains ok\n\n## Log\n");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
openSupervisorPane.mockClear();
|
||||
closeSupervisorPane.mockClear();
|
||||
});
|
||||
|
||||
describe("/goals flow", () => {
|
||||
it("preserves drafts, records the interview, and keeps planning read-only", async () => {
|
||||
const flow = setup(["Refine"], ["Keep two columns."]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("first objective", flow.ctx);
|
||||
const first = writePlan(flow.cwd, "# Plan\n\n## Goals\n\n1. [ ] goal: preserve this\n\n## Interview\n");
|
||||
await flow.hooks.get("input")({ text: "Preserve column order.", source: "interactive" }, flow.ctx);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(readFileSync(first, "utf8")).toContain("> Preserve column order.");
|
||||
expect(readFileSync(first, "utf8")).toContain("> Keep two columns.");
|
||||
expect(flow.messages.at(-1)?.content).toContain("Revise the plan at");
|
||||
expect((await flow.hooks.get("tool_call")({ toolName: "edit", input: { path: "README.md" } }, flow.ctx))?.block).toBe(true);
|
||||
|
||||
await flow.commands.get("goals").handler("second objective", flow.ctx);
|
||||
expect(readFileSync(first, "utf8")).toContain("preserve this");
|
||||
expect(flow.messages.at(-1)?.content).toContain("session-a-v2.md");
|
||||
} finally {
|
||||
rmSync(flow.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("forks a visible supervisor on Ready and keeps the main session as worker", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
const planPath = approvedPlan(flow.cwd);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(openSupervisorPane).toHaveBeenCalledWith(expect.objectContaining({
|
||||
cwd: flow.cwd,
|
||||
sourceSessionFile: join(flow.cwd, "session.jsonl"),
|
||||
workerSessionId: "session-a",
|
||||
workerIntercomId: "worker-intercom",
|
||||
planPath,
|
||||
}));
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", supervisorPaneId: "pane-2" });
|
||||
expect(flow.messages.at(-1)?.content).toBe("The plan is approved. Begin implementation as the worker.");
|
||||
const prompt = await flow.hooks.get("before_agent_start")({}, flow.ctx);
|
||||
expect(prompt.systemPrompt).toContain("implementation worker");
|
||||
expect(prompt.systemPrompt).toContain("stronger read-only supervisor");
|
||||
} finally {
|
||||
rmSync(flow.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns to planning when the worker is already paired", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
flow.events.removeAllListeners("pi-supervise:worker-state:v1");
|
||||
flow.events.on("pi-supervise:worker-state:v1", (reply) => reply({ intercomId: "worker-intercom", paired: true }));
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
approvedPlan(flow.cwd);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
expect(openSupervisorPane).not.toHaveBeenCalled();
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning", supervisorPaneId: null });
|
||||
expect(flow.notifications.at(-1)).toContain("already paired");
|
||||
} finally {
|
||||
rmSync(flow.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for the worker's real paired acknowledgement before beginning work", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
openSupervisorPane.mockImplementationOnce(async () => "pane-2");
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
approvedPlan(flow.cwd);
|
||||
const ready = flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "planning" });
|
||||
expect(flow.messages.some((message) => message.content === "The plan is approved. Begin implementation as the worker.")).toBe(false);
|
||||
flow.events.emit("pi-supervise:worker-paired:v1", { supervisorIntercomId: "supervisor-intercom" });
|
||||
await ready;
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: "working", supervisorPaneId: "pane-2" });
|
||||
} finally {
|
||||
rmSync(flow.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the supervisor on clear but keeps the plan file", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
const planPath = approvedPlan(flow.cwd);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
await flow.commands.get("goals").handler("clear", flow.ctx);
|
||||
expect(closeSupervisorPane).toHaveBeenCalledWith("pane-2");
|
||||
expect(readFileSync(planPath, "utf8")).toContain("make the file");
|
||||
expect(flow.entries.at(-1)?.data).toMatchObject({ phase: null, supervisorPaneId: null, planVersion: null });
|
||||
} finally {
|
||||
rmSync(flow.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts only an approval for the exact clean commit and goal block", async () => {
|
||||
const flow = setup(["Ready"]);
|
||||
try {
|
||||
await flow.commands.get("goals").handler("make the file", flow.ctx);
|
||||
const planPath = approvedPlan(flow.cwd);
|
||||
await flow.hooks.get("agent_settled")({}, flow.ctx);
|
||||
const goal = "make the file";
|
||||
const plan = readFileSync(planPath, "utf8");
|
||||
const block = goalBlock(plan, goal)!;
|
||||
const repository = repositoryState(flow.cwd);
|
||||
const approvalId = (flow.entries.at(-1)?.data as { approvalId: string }).approvalId;
|
||||
writeApproval(approvalPath(flow.cwd, "session-a", goal), {
|
||||
version: 3, verdict: "accept", approvalId, goal, planPath,
|
||||
goalBlockHash: hashGoalBlock(block), repoRoot: repository.repoRoot,
|
||||
head: repository.head, tree: repository.tree, cleanWorktree: true,
|
||||
inspected: { plan: true, repository: true, evidence: true, verifyOutput: true },
|
||||
verifyOutputPath: "verify.txt",
|
||||
supervisor: { sessionId: "supervisor", runId: null }, timestamp: new Date().toISOString(),
|
||||
});
|
||||
const signed = await flow.tools.get("CompleteGoal").execute("id", { goal }, undefined, undefined, flow.ctx);
|
||||
expect(signed.isError).toBe(false);
|
||||
expect(readFileSync(planPath, "utf8")).toContain("1. [x] goal: make the file");
|
||||
} finally {
|
||||
rmSync(flow.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("process role", () => {
|
||||
it("keeps subagent children and visible supervisors out of the worker extension", () => {
|
||||
expect(isMainSession(false)).toBe(true);
|
||||
expect(isMainSession(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { closeSupervisorPane, openSupervisorPane, supervisorCommand } from "../src/herdr.js";
|
||||
|
||||
function input() {
|
||||
return {
|
||||
cwd: "/repo",
|
||||
sourceSessionFile: "/sessions/worker.jsonl",
|
||||
workerSessionId: "worker-12345678",
|
||||
workerIntercomId: "intercom-12345678",
|
||||
planPath: "/repo/.pi/plan/worker-v1.md",
|
||||
approvalId: "approval-1",
|
||||
extensionPath: "/repo/src/index.ts",
|
||||
superviseExtensionPath: null,
|
||||
model: "provider/supervisor",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("supervisor pane command", () => {
|
||||
it("forks the planning session with an explicit supervisor role and model", () => {
|
||||
const command = supervisorCommand(input());
|
||||
expect(command).toContain("'PI_GOALS_ROLE=supervisor'");
|
||||
expect(command).toContain("'PI_GOALS_WORKER_INTERCOM_ID=intercom-12345678'");
|
||||
expect(command).toContain("'pi' '--no-extensions' '-e' 'npm:pi-intercom' '-e' 'npm:@wassname2/pi-supervise@0.0.4' '-e' '/repo/src/index.ts'");
|
||||
expect(command).toContain("'--fork' '/sessions/worker.jsonl'");
|
||||
expect(command).toContain("'--model' 'provider/supervisor'");
|
||||
expect(command).not.toContain("Initialize supervision startup.");
|
||||
expect(command).not.toContain("pi-subagents");
|
||||
});
|
||||
|
||||
it("uses the loaded pi-supervise extension before the npm fallback", () => {
|
||||
const loaded = { ...input(), superviseExtensionPath: "/repo/vendor/pi-supervise/src/index.ts" };
|
||||
expect(supervisorCommand(loaded)).toContain("'-e' '/repo/vendor/pi-supervise/src/index.ts'");
|
||||
vi.stubEnv("PI_GOALS_SUPERVISE_EXTENSION", "/repo/override/pi-supervise/src/index.ts");
|
||||
expect(supervisorCommand(loaded)).toContain("'-e' '/repo/override/pi-supervise/src/index.ts'");
|
||||
});
|
||||
|
||||
it("accepts Herdr's text version output and stale pane cleanup", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-herdr-"));
|
||||
const bin = join(cwd, "herdr");
|
||||
writeFileSync(bin, `#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then echo "herdr 0.8.2"; exit 0; fi
|
||||
if [ "$1" = "pane" ] && [ "$2" = "split" ]; then echo '{"pane_id":"new-pane"}'; exit 0; fi
|
||||
if [ "$1" = "pane" ] && [ "$2" = "run" ]; then if [ "$HERDR_SMOKE_RUN_FAIL" = "1" ]; then echo "run failed" >&2; exit 1; fi; echo '{}'; exit 0; fi
|
||||
if [ "$1" = "pane" ] && [ "$2" = "close" ]; then echo '{"error":{"code":"PANE_GONE"}}' >&2; exit 1; fi
|
||||
exit 2
|
||||
`);
|
||||
chmodSync(bin, 0o755);
|
||||
vi.stubEnv("HERDR_ENV", "1");
|
||||
vi.stubEnv("HERDR_BIN_PATH", bin);
|
||||
try {
|
||||
await expect(openSupervisorPane(input())).resolves.toBe("new-pane");
|
||||
await expect(closeSupervisorPane("new-pane")).resolves.toBeUndefined();
|
||||
vi.stubEnv("HERDR_SMOKE_RUN_FAIL", "1");
|
||||
await expect(openSupervisorPane(input())).rejects.toThrow("run failed");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
interface PackageManifest {
|
||||
files: string[];
|
||||
pi: { extensions: string[]; subagents?: unknown };
|
||||
}
|
||||
|
||||
describe("package manifest", () => {
|
||||
it("includes the extension without registering a packaged subagent", () => {
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")) as PackageManifest;
|
||||
expect(manifest.files).toEqual(["src", "README.md"]);
|
||||
expect(manifest.pi.extensions).toEqual(["./src/index.ts"]);
|
||||
expect(manifest.pi.subagents).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { completeGoalDescription, planDrafting, planningState, resync } 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 at least three short, concrete questions");
|
||||
expect(planDrafting).toContain("understand the requested outcome, boundary, and how success will be judged");
|
||||
expect(planDrafting).toContain("record that\npoint as unknown; do not silently replace it with an inference");
|
||||
expect(planDrafting).toContain("answer materially reduces uncertainty\nwhile discovering the right plan");
|
||||
expect(planDrafting).toContain("self-contained: state the relevant\ncontext, use the human's language and ASD-STE100");
|
||||
expect(planDrafting).toContain("placeholder goal such as \"work out the thing\"");
|
||||
expect(planDrafting).toContain("material user decisions remain open");
|
||||
});
|
||||
|
||||
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(resync("plan", ".pi/plan/test.md", "Compacted.")).toContain("amend the plan rather than preserving an obsolete decision");
|
||||
expect(resync("plan", ".pi/plan/test.md", "Compacted.")).toContain("implementation worker");
|
||||
expect(completeGoalDescription).toContain("visible supervisor");
|
||||
expect(completeGoalDescription).toContain("stopped worker view with no active work");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } 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("opens Refine's editor before it starts the revision turn", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-rpc-"));
|
||||
let requestCount = 0;
|
||||
let planPath = "";
|
||||
const server = createServer((_request, response) => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
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: "goals", message: "/goals work out the thing" });
|
||||
const review = await client.waitFor((message) => message.type === "extension_ui_request" && message.method === "select");
|
||||
client.send({ type: "extension_ui_response", id: review.id, value: "Refine" });
|
||||
const editor = await client.waitFor((message) => message.type === "extension_ui_request" && message.method === "editor");
|
||||
expect(requestCount).toBe(2);
|
||||
|
||||
const revisionStart = client.messages.length;
|
||||
client.send({ type: "extension_ui_response", id: editor.id, value: "Name the produced file." });
|
||||
await client.waitFor((message) => message.type === "agent_end", revisionStart);
|
||||
expect(requestCount).toBe(3);
|
||||
} finally {
|
||||
pi.kill();
|
||||
server.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workerPiSupervise } from "../src/supervise.js";
|
||||
|
||||
const API_READY = "pi-supervise:api-ready:v1";
|
||||
const WORKER_STATE = "pi-supervise:worker-state:v1";
|
||||
const WORKER_PAIRED = "pi-supervise:worker-paired:v1";
|
||||
|
||||
function pi(events: EventEmitter): ExtensionAPI {
|
||||
return { events } as unknown as ExtensionAPI;
|
||||
}
|
||||
|
||||
describe("pi-supervise worker API", () => {
|
||||
it("discovers pi-supervise when it loads after pi-goals", async () => {
|
||||
const events = new EventEmitter();
|
||||
const worker = workerPiSupervise(pi(events));
|
||||
events.on(WORKER_STATE, (reply) => reply({ intercomId: "worker-id", paired: false }));
|
||||
events.emit(API_READY);
|
||||
expect((await worker).intercomId).toBe("worker-id");
|
||||
});
|
||||
|
||||
it("discovers an already-loaded pi-supervise and accepts duplicate paired events once", async () => {
|
||||
const events = new EventEmitter();
|
||||
events.on(WORKER_STATE, (reply) => reply({ intercomId: "worker-id", paired: false }));
|
||||
const worker = await workerPiSupervise(pi(events));
|
||||
let acknowledgements = 0;
|
||||
const paired = worker.waitForPair().then(() => { acknowledgements += 1; });
|
||||
events.emit(WORKER_PAIRED, { supervisorIntercomId: "supervisor-id" });
|
||||
events.emit(WORKER_PAIRED, { supervisorIntercomId: "supervisor-id" });
|
||||
await paired;
|
||||
expect(acknowledgements).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects Ready when another supervisor already owns the worker", async () => {
|
||||
const events = new EventEmitter();
|
||||
events.on(WORKER_STATE, (reply) => reply({ intercomId: "worker-id", paired: true }));
|
||||
await expect(workerPiSupervise(pi(events))).rejects.toThrow("already paired");
|
||||
});
|
||||
|
||||
it("times out when the visible supervisor never pairs", async () => {
|
||||
const events = new EventEmitter();
|
||||
events.on(WORKER_STATE, (reply) => reply({ intercomId: "worker-id", paired: false }));
|
||||
const worker = await workerPiSupervise(pi(events), 1);
|
||||
await expect(worker.waitForPair()).rejects.toThrow("did not pair");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { approvalPath } from "../src/approval.js";
|
||||
import { registerVisibleSupervisor } from "../src/supervisor-session.js";
|
||||
|
||||
function setup(cwd: string, planPath: string, tokens: number | null = 10, onCompact: (options: any) => void = (options) => options.onComplete()) {
|
||||
vi.stubEnv("PI_GOALS_WORKER_ID", "worker-session");
|
||||
vi.stubEnv("PI_GOALS_WORKER_INTERCOM_ID", "worker-intercom");
|
||||
vi.stubEnv("PI_GOALS_OWNER_SESSION_ID", "worker-session");
|
||||
vi.stubEnv("PI_GOALS_PLAN_PATH", planPath);
|
||||
vi.stubEnv("PI_GOALS_APPROVAL_ID", "approval-1");
|
||||
const hooks = new Map<string, any>();
|
||||
const tools = new Map<string, any>();
|
||||
const entries: any[] = [];
|
||||
const paired: Array<{ workerIntercomId: string; goal: string }> = [];
|
||||
const messages: string[] = [];
|
||||
let branch: any[] = [];
|
||||
const ctx = {
|
||||
cwd,
|
||||
getSystemPrompt: () => "base",
|
||||
getContextUsage: () => tokens === null ? undefined : ({ tokens }),
|
||||
compact: vi.fn(onCompact),
|
||||
sessionManager: {
|
||||
getEntries: () => entries,
|
||||
getBranch: () => branch,
|
||||
getSessionId: () => "supervisor-session",
|
||||
},
|
||||
ui: { notify: vi.fn() },
|
||||
};
|
||||
const pi = {
|
||||
events: {
|
||||
on() {},
|
||||
emit(name: string, request: any) {
|
||||
if (name !== "pi-supervise:pair:v1") return;
|
||||
paired.push({ workerIntercomId: request.workerIntercomId, goal: request.goal });
|
||||
request.resolve();
|
||||
},
|
||||
},
|
||||
on: (name: string, handler: any) => hooks.set(name, handler),
|
||||
registerTool: (tool: any) => tools.set(tool.name, tool),
|
||||
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
|
||||
sendUserMessage: (message: string) => messages.push(message),
|
||||
};
|
||||
registerVisibleSupervisor(pi as unknown as ExtensionAPI);
|
||||
return { branch: (value: any[]) => { branch = value; }, ctx, entries, hooks, messages, paired, tools };
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("visible supervisor session", () => {
|
||||
it("pairs from session startup before asking the supervisor to work", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
|
||||
try {
|
||||
const runtime = setup(cwd, join(cwd, ".pi/plan/worker-v1.md"));
|
||||
await runtime.hooks.get("session_start")({}, runtime.ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(runtime.ctx.compact).not.toHaveBeenCalled();
|
||||
expect(runtime.entries.at(-1)).toMatchObject({ customType: "pi-goals-visible-supervisor-v1" });
|
||||
expect(runtime.paired).toEqual([{ workerIntercomId: "worker-intercom", goal: join(cwd, ".pi/plan/worker-v1.md") }]);
|
||||
expect(runtime.messages).toEqual(["Supervision is paired. Inspect the worker and give its next concrete instruction."]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("compacts a large planning fork before pairing", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
|
||||
try {
|
||||
let complete: (() => void) | undefined;
|
||||
const runtime = setup(cwd, join(cwd, ".pi/plan/worker-v1.md"), 20_001, (options) => { complete = options.onComplete; });
|
||||
await runtime.hooks.get("session_start")({}, runtime.ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(runtime.ctx.compact).toHaveBeenCalledOnce();
|
||||
expect(runtime.paired).toHaveLength(0);
|
||||
complete!();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(runtime.paired).toHaveLength(1);
|
||||
expect(runtime.messages).toEqual(["Supervision is paired. Inspect the worker and give its next concrete instruction."]);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not start work when initial compaction fails", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
|
||||
try {
|
||||
const runtime = setup(cwd, join(cwd, ".pi/plan/worker-v1.md"), null, (options) => options.onError(new Error("offline")));
|
||||
await runtime.hooks.get("session_start")({}, runtime.ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(runtime.ctx.compact).toHaveBeenCalledOnce();
|
||||
expect(runtime.paired).toHaveLength(0);
|
||||
expect(runtime.ctx.ui.notify).toHaveBeenCalledWith("Supervisor startup compaction failed: offline", "error");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pair twice across session startup and later turns", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
|
||||
try {
|
||||
const runtime = setup(cwd, join(cwd, ".pi/plan/worker-v1.md"));
|
||||
await runtime.hooks.get("session_start")({}, runtime.ctx);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await runtime.hooks.get("before_agent_start")({}, runtime.ctx);
|
||||
expect(runtime.paired).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("records approval only from a stopped view with evidence and no active work", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
|
||||
try {
|
||||
writeFileSync(join(cwd, ".gitignore"), ".pi/\n");
|
||||
writeFileSync(join(cwd, "verify.txt"), "PASS\n");
|
||||
execFileSync("git", ["init", "-q"], { cwd });
|
||||
execFileSync("git", ["add", ".gitignore", "verify.txt"], { cwd });
|
||||
execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-qm", "initial"], { cwd });
|
||||
const planPath = join(cwd, ".pi/plan/worker-v1.md");
|
||||
execFileSync("mkdir", ["-p", join(cwd, ".pi/plan")]);
|
||||
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: output exists\n - evidence:\n - `result.txt`: contains ok\n\n## Log\n");
|
||||
const runtime = setup(cwd, planPath);
|
||||
runtime.branch([{
|
||||
type: "message",
|
||||
message: { role: "user", content: [{ type: "text", text: "The worker stopped.\n\ntool calls with no result: none\nchild pi processes still running: none" }] },
|
||||
}]);
|
||||
const approved = await runtime.tools.get("ApproveGoal").execute("id", {
|
||||
goal: "make the file",
|
||||
verifyOutputPath: "verify.txt",
|
||||
}, undefined, undefined, runtime.ctx);
|
||||
expect(approved.isError).toBe(false);
|
||||
expect(existsSync(approvalPath(cwd, "worker-session", "make the file"))).toBe(true);
|
||||
const missingOutput = await runtime.tools.get("ApproveGoal").execute("id", {
|
||||
goal: "make the file", verifyOutputPath: "missing.txt",
|
||||
}, undefined, undefined, runtime.ctx);
|
||||
expect(missingOutput.isError).toBe(true);
|
||||
expect(missingOutput.content[0].text).toContain("verification-output");
|
||||
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - evidence:\n - \n - tasks:\n - write result.txt\n");
|
||||
const missingEvidence = await runtime.tools.get("ApproveGoal").execute("id", {
|
||||
goal: "make the file", verifyOutputPath: "verify.txt",
|
||||
}, undefined, undefined, runtime.ctx);
|
||||
expect(missingEvidence.isError).toBe(true);
|
||||
expect(missingEvidence.content[0].text).toContain("nonblank evidence entry");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects approval while the worker view has an unfinished tool call", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
|
||||
try {
|
||||
const planPath = join(cwd, "plan.md");
|
||||
writeFileSync(planPath, "1. [ ] goal: wait\n - evidence:\n - result\n");
|
||||
const runtime = setup(cwd, planPath);
|
||||
runtime.branch([{
|
||||
type: "message",
|
||||
message: { role: "user", content: [{ type: "text", text: "The worker stopped.\n\ntool calls with no result: bash\nchild pi processes still running: none" }] },
|
||||
}]);
|
||||
const rejected = await runtime.tools.get("ApproveGoal").execute("id", {
|
||||
goal: "wait", verifyOutputPath: "verify.txt",
|
||||
}, undefined, undefined, runtime.ctx);
|
||||
expect(rejected.isError).toBe(true);
|
||||
expect(rejected.content[0].text).toContain("bash");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user