/** * 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="", deliverable="",', ' 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=[""], files=["/abs/path/to/read"])', "", " CLI form if the tool is absent: /opt/pi-toolkit/bin/pi-task run (`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 }; }); }