task tool + fork-gate: put the "pi-task, not fork" rule where the decision is made

The rule was correct and written down twice (global AGENTS.md, this skill)
and was still violated by agents that had just read it: on 2026-09-17 all
five fork briefs in one session carried "do not", one returned confident
verbatim quotes that did not exist, and four had disjoint write boundaries
fork cannot enforce (re-run as pi-task, they passed). Three mechanisms,
none of them wording:

  1. fork is a TOOL — its self-recommending description ("implementation,
     testing, review…") is in the model's face every turn; pi-task was a CLI
     to be remembered and reached through bash with a hand-written JSON.
  2. The skill is gone after the first compaction; the tool list never is.
     The asymmetry widens in exactly the long sessions where fork is worst.
  3. Friction: one string vs a spec file + bash + reading result.json.

extensions/task.ts registers pi-task as the `task` tool. Flat parameters
build the spec; the decision rule sits in the description and in
promptGuidelines (appended to the system prompt, so compaction cannot remove
it). Before spending a model run it rejects the two spec errors that make a
boundary violation certain — write_allowed not an exact subset of roots, and
a writable root nested in a watched-only root (the parent's porcelain would
change every time; pi-task keys deltas by root string) — and it serialises
sibling tasks whose roots overlap (parallel siblings saw each other's writes
as violations, 2026-09-17). Returns the CLI's own parent-facing report; a
FAIL verdict is a result, only a CLI refusal is an error.

extensions/fork-gate.ts is a tool_call hook that BLOCKS a fork whose brief
contains a prohibition, a write boundary, or a clause-initial file-changing
imperative, and returns as the reason the exact task(...) to make instead.
Wording, not intent — the message says so and how to rephrase a genuinely
read-only brief. PI_FORK_GATE=off logs instead; /ext disables.

Evidence: test/fork-gate.test.mjs is two-sided (15 must-block incl. the real
shapes, 10 must-pass incl. "Write a summary…", "Report which files were
modified…", "Give me an update…"); the classifier redirects 5/5 of the real
briefs from the motivating session. test/task.test.mjs pins root overlap and
the pre-launch validation. Live in `pi -p`: the fork was intercepted before
any child spawned (no /tmp/pi-fork-* dir) and the model received the
redirect; task returned PASS with an evidence pointer and audit dir, a
budget-overrun returned a FAIL result (isError=false), and a nested-root spec
was rejected with no audit dir created.

skill/SKILL.md: Part 1 now opens with "decide the rung before the brief"
(the table, the three-question pre-flight, the roots contract, overlap,
what isolation does not fix); the ladder section and quick reference no
longer say pi-task "will never appear in your tool list". package.json gains
"type": "module" and a test script.
This commit is contained in:
2026-09-19 16:41:23 +02:00
parent 2610545c83
commit 25c1265681
8 changed files with 699 additions and 22 deletions
+57
View File
@@ -222,6 +222,63 @@ A footer line shows pending changes (e.g. `pending: notify→off, foo→on`) so
3. In a running pi session, `/reload` is enough; no restart needed
4. (or, with `ext-toggle` installed: `/ext` to disable noisy ones at runtime)
### `task.ts`
Registers the [`pi-task`](https://gitea.jordbo.se/joakimp/pi-toolkit) runner as
the `task` tool: a delegated task runs in an **isolated** child agent that sees
only the spec (context ladder L0–L2), returns a machine-checked PASS/FAIL
envelope, has every `roots[]` entry diffed before and after (a change outside
`write_allowed` FAILS), and leaves an audit dir under `~/.pi/agent/pi-task/`.
Compare `fork`, whose child inherits the *entire* parent branch (L4) and returns
prose.
Why a tool and not just the CLI: `fork` is a tool with a self-recommending
description in the model's face every turn; the CLI had to be remembered, and the
prose rule that said "use pi-task for briefs with prohibitions" lost to the tool
list for months. The rule now lives in the tool description and in
`promptGuidelines`, which pi appends to the system prompt — the one place
compaction cannot remove it from.
Beyond wrapping the CLI the tool:
- rejects, **before** a model run, the two spec errors that make a boundary
violation certain — `write_allowed` not an exact subset of `roots`, and a
writable root nested inside a watched-only root (the parent's porcelain would
change every time);
- serialises sibling `task` calls whose roots overlap (parallel siblings see each
other's writes as violations — measured 2026-09-17);
- returns the CLI's parent-facing report as the tool result (verdict, problems,
deliverable, evidence pointers, audit dir) and the parsed `result.json` as
`details`. A FAIL verdict is a *result*; only the CLI refusing to run is an
error.
Finds the runner at `$PI_TASK_BIN`, `/opt/pi-toolkit/bin/pi-task`,
`~/src/pi-toolkit/bin/pi-task`, `~/src/src_local/pi-toolkit/bin/pi-task`,
`/workspace/pi-toolkit/bin/pi-task`, then `$PATH`. Effort tiers resolve through
`pi-fork.effortProfiles` in `settings.json`, same as `fork`.
### `fork-gate.ts`
A `tool_call` hook that **blocks** a `fork` whose brief contains a prohibition
(*do not / must not / never / only …*), a write boundary (*only touch, nothing
else, read-only, stay within …*) or a clause-initial file-changing imperative
(*Edit …, Commit …, Fix …, Implement …*), and returns — as the block reason the
model reads — the `task(...)` call to make instead, plus the CLI fallback.
Measured motivation: on 2026-09-17 all five fork briefs in one session carried
"do not"; one returned confident verbatim quotes that did not exist in the
source, and the four migration briefs had disjoint write boundaries that fork
cannot enforce (they were re-run as pi-task and passed). Running the shipped
classifier over those five real briefs redirects 5/5.
The gate matches **wording, not intent**, and says so: a genuinely read-only
exploration brief that trips it is rephrased without the prohibition; one that
cannot be rephrased needed `task`. Two-sided tests in
`test/fork-gate.test.mjs` pin both the must-block and must-pass sets (the latter
includes "Write a summary of…", "Report which files were modified…", "Give me an
update on…" — verbs a naive list would misfire on).
`PI_FORK_GATE=off` makes it log to stderr instead of blocking; `/ext` disables it.
### `todo.ts`
Gives the agent a `todo` tool (actions: `list` / `add` / `toggle` / `clear`) so it can externalize a multi-step plan and tick items off as it works. Also registers `/todos` so you can inspect the current list at any time.