25c1265681
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.
140 lines
6.7 KiB
TypeScript
140 lines
6.7 KiB
TypeScript
/**
|
||
* fork-gate — put the "pi-task, not fork" rule where the decision is made.
|
||
*
|
||
* WHY. `fork` is a registered tool: its description is in the model's face on
|
||
* every turn, and it recommends itself for "implementation". `pi-task` is a
|
||
* CLI with no tool description, reached through bash after remembering it
|
||
* exists. The prose rule that says which to use (global AGENTS.md, the
|
||
* pi-extensions skill) was correct and lost anyway — the skill is gone after
|
||
* the first compaction, the tool description never is. Measured on this fleet
|
||
* (2026-09-01/06 mbp-m1-2020, 2026-09-17 tor-ms22): forks with prohibitions in
|
||
* the brief ignored them, invented quotes, answered in the user's voice; the
|
||
* same briefs as pi-task runs passed their envelope. Root cause is by design:
|
||
* a fork child receives the WHOLE parent branch with the brief as the last
|
||
* message (pi-fork src/index.ts), so in a long session the narrative outweighs
|
||
* the instruction.
|
||
*
|
||
* WHAT. A `tool_call` hook on `fork` that BLOCKS when the brief carries the
|
||
* three things a fork cannot be trusted with — a prohibition, a write
|
||
* boundary, or an imperative to change files — and returns, as the block
|
||
* reason, the exact `task(...)` call to make instead. Fork stays available for
|
||
* what it is good at: read-only exploration that needs THIS conversation, and
|
||
* N parallel opinions on one question. A false positive costs one turn and
|
||
* teaches the rule in-context, which is the point; the message says how to
|
||
* rephrase a genuinely read-only brief.
|
||
*
|
||
* The classifier matches WORDING, not intent, and says so.
|
||
*
|
||
* OFF SWITCH. `PI_FORK_GATE=off` in the environment disables blocking for a
|
||
* session (the match is still logged to stderr); `/ext` (ext-toggle) disables
|
||
* the extension entirely. Neither is needed in normal operation.
|
||
*
|
||
* Companion: `task.ts` registers the `task` tool this gate points at. The
|
||
* gate does not require it — the reason text also gives the CLI form.
|
||
*/
|
||
|
||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||
|
||
// ── Classifier (pure; exported so test/fork-gate.test.mjs can drive it) ──────
|
||
|
||
export interface GateRule {
|
||
/** Class of hazard, used in the block reason. */
|
||
kind: "prohibition" | "boundary" | "mutation";
|
||
pattern: RegExp;
|
||
/** One line: why a fork cannot be trusted with this. */
|
||
why: string;
|
||
}
|
||
|
||
export const GATE_RULES: GateRule[] = [
|
||
{
|
||
kind: "prohibition",
|
||
pattern:
|
||
/\b(do not|don'?t|must not|mustn'?t|never|not allowed|forbidden|prohibited|refrain from|under no circumstances|avoid (touching|editing|modifying|changing|writing))\b/i,
|
||
why: "a fork inherits your entire branch; the parent narrative outweighs a prohibition placed at the end of it",
|
||
},
|
||
{
|
||
kind: "boundary",
|
||
pattern:
|
||
/\b(only (touch|edit|modify|change|write|create|alter)|(touch|edit|modify|change|write) only|nothing else|no other (files?|dirs?|directories|paths?)|outside (of )?(this|these|that|the|its|your) (dir|directory|directories|file|files|folder|path|root|scope|repo|tree)|write[- ]?boundar(y|ies)|read[- ]only|stay (within|inside)|confined? to|limited to (the |these |those )?(\S+ )?(files?|dir|directory|directories|paths?|tree|repo))\b/i,
|
||
why: "fork has no boundary diff — a write outside the named set is invisible; pi-task diffs roots[] before and after",
|
||
},
|
||
{
|
||
// Clause-initial imperatives that change files. Deliberately excludes
|
||
// "write" and "create" (too often "write a summary"); the boundary and
|
||
// prohibition rules catch write tasks that phrase themselves carefully.
|
||
kind: "mutation",
|
||
pattern:
|
||
/(^|[.!?:;\n]\s*|\b(then|and|also|please|now)\s+)(commit|push|implement|refactor|rewrite|rename|delete|edit|modify|update|fix|apply|migrate|convert|replace|install|patch)\b/im,
|
||
why: "work that changes files needs a checkable PASS/FAIL and an audit trail; fork returns prose and deletes its own temp dir on exit",
|
||
},
|
||
];
|
||
|
||
export interface GateVerdict {
|
||
block: boolean;
|
||
kind?: GateRule["kind"];
|
||
matched?: string;
|
||
why?: string;
|
||
}
|
||
|
||
/** Decide whether a fork brief must be redirected to pi-task. */
|
||
export function classifyForkBrief(brief: unknown): GateVerdict {
|
||
if (typeof brief !== "string" || brief.length === 0) return { block: false };
|
||
for (const rule of GATE_RULES) {
|
||
const m = rule.pattern.exec(brief);
|
||
if (m) {
|
||
// Report the whole match trimmed, so the agent sees which words fired.
|
||
return { block: true, kind: rule.kind, matched: m[0].trim(), why: rule.why };
|
||
}
|
||
}
|
||
return { block: false };
|
||
}
|
||
|
||
/** The block reason the model reads. Exported so the test can pin its content. */
|
||
export function blockReason(v: GateVerdict): string {
|
||
return [
|
||
`fork BLOCKED by fork-gate — the brief contains a ${v.kind}: "${v.matched}".`,
|
||
`Why: ${v.why}.`,
|
||
"",
|
||
"Use the `task` tool instead (pi-task, L0–L2: isolated child that sees ONLY your spec,",
|
||
"immutable spec, PASS/FAIL envelope, write-boundary diff, audit trail):",
|
||
"",
|
||
' task(id="short-slug", goal="<verbatim task>", deliverable="<exact shape wanted>",',
|
||
' effort="fast|balanced|deep", read_only=false,',
|
||
' roots=["/abs/repo/docs", "/abs/repo/src"], # WATCHED, each diffed on its own',
|
||
' write_allowed=["/abs/repo/docs"], # CHANGEABLE: exact subset of roots; never nested in another root',
|
||
' facts=["<verified fact>"], files=["/abs/path/to/read"])',
|
||
"",
|
||
" CLI form if the tool is absent: /opt/pi-toolkit/bin/pi-task run <spec.json> (`pi-task schema` lists fields)",
|
||
" Tasks whose roots overlap run one after another, never in parallel (the tool serialises them).",
|
||
"",
|
||
"If this really is READ-ONLY exploration that needs this conversation's context, or N parallel",
|
||
"opinions on one question, drop the prohibition/mutation wording and call fork again — the gate",
|
||
"matches wording, not intent. If the brief needs the prohibition, it needs pi-task.",
|
||
].join("\n");
|
||
}
|
||
|
||
// ── Extension ────────────────────────────────────────────────────────────────
|
||
|
||
export default function (pi: ExtensionAPI) {
|
||
const mode = (process.env.PI_FORK_GATE ?? "block").toLowerCase();
|
||
|
||
pi.on("tool_call", (event, ctx) => {
|
||
if (event.toolName !== "fork") return;
|
||
const input = event.input as { task?: unknown } | undefined;
|
||
const verdict = classifyForkBrief(input?.task);
|
||
if (!verdict.block) return;
|
||
|
||
const reason = blockReason(verdict);
|
||
if (mode === "off") {
|
||
process.stderr.write(
|
||
`[fork-gate] PI_FORK_GATE=off — would have blocked fork (${verdict.kind}: "${verdict.matched}")\n`,
|
||
);
|
||
return;
|
||
}
|
||
if (ctx.hasUI) {
|
||
ctx.ui.notify(`fork-gate: blocked a fork (${verdict.kind}: "${verdict.matched}") → use task`, "warning");
|
||
}
|
||
return { block: true, reason };
|
||
});
|
||
}
|