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.
246 lines
13 KiB
TypeScript
246 lines
13 KiB
TypeScript
/**
|
||
* task — `pi-task` as a first-class tool, so the isolated rung of the context
|
||
* ladder competes with `fork` on equal footing.
|
||
*
|
||
* WHY. `fork` (L4: child inherits the ENTIRE parent branch) is a tool and is
|
||
* therefore in the model's face every turn; `pi-task` (L0–L2: child sees only
|
||
* an immutable spec) was a CLI reached through bash after remembering that it
|
||
* exists and hand-writing a JSON file. Models choose among the affordances in
|
||
* front of them, so the prose rule "prefer pi-task for work with prohibitions
|
||
* or write boundaries" lost to the tool list — repeatedly, and by the agent
|
||
* that had just read the rule. This registers the same CLI as `task`, with the
|
||
* decision rule in the description AND in `promptGuidelines` (which pi appends
|
||
* to the system prompt, so it survives compaction; the skill text does not).
|
||
*
|
||
* WHAT IT WRAPS. `/opt/pi-toolkit/bin/pi-task run <spec.json>` (pi-toolkit).
|
||
* The wrapper adds nothing to the contract; it just builds the spec from flat
|
||
* parameters, validates the two things the CLI cannot catch until the run is
|
||
* over (write_allowed ⊆ roots; no writable root nested in a watched one — both
|
||
* make a violation certain, i.e. a guaranteed false FAIL), serialises tasks
|
||
* whose roots overlap (parallel siblings see each other's writes as violations;
|
||
* measured 2026-09-17), and returns the CLI's own parent-facing report plus the
|
||
* parsed result.json as details.
|
||
*
|
||
* WHAT IT DOES NOT FIX. Isolation removes the *narrative* failures (parent
|
||
* voice, invented continuity, ignored prohibitions). It does not remove
|
||
* confabulation: the envelope proves shape, not truth. Spot-check the evidence
|
||
* pointers — the report says so at the top of every result.
|
||
*
|
||
* Companion: `fork-gate.ts` blocks forks whose brief needs this tool.
|
||
*/
|
||
|
||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||
import { homedir, tmpdir } from "node:os";
|
||
import path from "node:path";
|
||
import { StringEnum } from "@earendil-works/pi-ai";
|
||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||
import { Type } from "typebox";
|
||
|
||
// ── Locating the CLI ─────────────────────────────────────────────────────────
|
||
|
||
function findPiTask(): string {
|
||
const env = process.env.PI_TASK_BIN;
|
||
if (env && existsSync(env)) return env;
|
||
const home = homedir();
|
||
const candidates = [
|
||
"/opt/pi-toolkit/bin/pi-task",
|
||
path.join(home, "src/pi-toolkit/bin/pi-task"),
|
||
path.join(home, "src/src_local/pi-toolkit/bin/pi-task"),
|
||
"/workspace/pi-toolkit/bin/pi-task",
|
||
];
|
||
for (const c of candidates) if (existsSync(c)) return c;
|
||
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
||
const c = path.join(dir, "pi-task");
|
||
if (dir && existsSync(c)) return c;
|
||
}
|
||
throw new Error(
|
||
"pi-task CLI not found (looked at $PI_TASK_BIN, /opt/pi-toolkit/bin, ~/src/pi-toolkit/bin, /workspace/pi-toolkit/bin, $PATH). " +
|
||
"Install pi-toolkit or set PI_TASK_BIN.",
|
||
);
|
||
}
|
||
|
||
// ── Root overlap (exported for test/task.test.mjs) ───────────────────────────
|
||
|
||
/** True when one path is the other or lies inside it. */
|
||
export function rootsOverlap(a: string, b: string): boolean {
|
||
const na = path.resolve(a);
|
||
const nb = path.resolve(b);
|
||
return na === nb || na.startsWith(nb + path.sep) || nb.startsWith(na + path.sep);
|
||
}
|
||
|
||
export function anyOverlap(as: string[], bs: string[]): boolean {
|
||
return as.some((a) => bs.some((b) => rootsOverlap(a, b)));
|
||
}
|
||
|
||
/**
|
||
* The two spec errors that make a boundary violation CERTAIN, checked before
|
||
* spending a model run on them. Mirrors pi-task's own check: a delta line is
|
||
* keyed by the root it was observed in, and is allowed only if that exact root
|
||
* string is in write_allowed. So a writable subdirectory of a watched repo root
|
||
* trips the repo root's porcelain diff every time.
|
||
*/
|
||
export function validateRoots(roots: string[], writeAllowed: string[] | undefined, readOnly: boolean): string[] {
|
||
const problems: string[] = [];
|
||
if (roots.length === 0) problems.push("roots must name at least one absolute path to watch");
|
||
for (const r of roots) {
|
||
if (!path.isAbsolute(r)) problems.push(`root is not absolute: ${r}`);
|
||
else if (!existsSync(r)) problems.push(`root does not exist: ${r}`);
|
||
}
|
||
if (readOnly) {
|
||
if (writeAllowed && writeAllowed.length) problems.push("read_only=true but write_allowed is set — pick one");
|
||
return problems;
|
||
}
|
||
if (!writeAllowed || writeAllowed.length === 0) {
|
||
problems.push(
|
||
"read_only=false requires write_allowed (an exact subset of roots). Without it every root is writable and a violation is undetectable.",
|
||
);
|
||
return problems;
|
||
}
|
||
for (const w of writeAllowed) {
|
||
if (!roots.includes(w)) {
|
||
problems.push(`write_allowed entry is not one of roots (must match a root string exactly): ${w}`);
|
||
continue;
|
||
}
|
||
for (const r of roots) {
|
||
if (r !== w && !writeAllowed.includes(r) && rootsOverlap(w, r)) {
|
||
problems.push(
|
||
`writable root ${w} is nested in (or contains) watched-only root ${r}: any write would register as a violation there. ` +
|
||
"List the writable part as its own root and drop the enclosing one.",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
return problems;
|
||
}
|
||
|
||
// ── Extension ────────────────────────────────────────────────────────────────
|
||
|
||
const ID_RE = /^[a-z0-9][a-z0-9-]{1,48}$/;
|
||
const MAX_REPORT_BYTES = 24_000;
|
||
|
||
export default function (pi: ExtensionAPI) {
|
||
/** Tasks in flight, in registration order; a newcomer waits for earlier overlapping ones. */
|
||
const running: { id: string; roots: string[]; done: Promise<void> }[] = [];
|
||
|
||
pi.registerTool({
|
||
name: "task",
|
||
label: "Task (pi-task, isolated child)",
|
||
description:
|
||
"Run a delegated task in an ISOLATED child agent (pi-task; context ladder L0–L2). Unlike `fork`, the child sees ONLY this spec — " +
|
||
"not this conversation — so prohibitions and boundaries in the brief are actually obeyed. Returns a machine-checked PASS/FAIL: " +
|
||
"the envelope must parse; every root is diffed before/after and a change outside write_allowed FAILS the task; cost and wall-clock " +
|
||
"budgets apply; a full audit dir is kept under ~/.pi/agent/pi-task/. Use `task` for any delegated work that changes files, any " +
|
||
"brief containing do-not/only/never, or whenever you want a checkable result rather than prose. Use `fork` only for read-only " +
|
||
"exploration that needs this conversation's context, or N parallel opinions on one question. Roots are watched individually: " +
|
||
"write_allowed must be an exact subset of roots, and a writable root must not sit inside another listed root (list the writable " +
|
||
"part on its own). Tasks whose roots overlap are run one at a time automatically. Spot-check the evidence pointers in the result.",
|
||
promptSnippet:
|
||
"Delegate work to an isolated child agent with a PASS/FAIL envelope and write-boundary diff (prefer over fork for anything that writes or has prohibitions)",
|
||
promptGuidelines: [
|
||
"Use task, not fork, when a delegated brief will change files, contains a prohibition (do not / only / never), or when a machine-checked PASS/FAIL is wanted; fork inherits this entire conversation and does not reliably obey such briefs.",
|
||
"Treat a task result's envelope as shape, not truth: spot-check its evidence pointers before building on them.",
|
||
],
|
||
parameters: Type.Object({
|
||
id: Type.String({ description: "Short slug (a-z, 0-9, -). Used in the session id and the audit dir name." }),
|
||
goal: Type.String({ description: "Verbatim task statement. The child has NO other context — say everything." }),
|
||
deliverable: Type.String({ description: "The exact shape of answer wanted (e.g. 'a table of file:line pointers', 'the edited files plus a one-line summary')." }),
|
||
effort: Type.Optional(
|
||
StringEnum(["fast", "balanced", "deep"] as const, {
|
||
description: "Model tier from settings pi-fork.effortProfiles: fast=mechanical/lookups, balanced=default, deep=architecture/security/ambiguity.",
|
||
}),
|
||
),
|
||
read_only: Type.Optional(Type.Boolean({ description: "Default true. Any change under roots then FAILS the task." })),
|
||
roots: Type.Array(Type.String(), {
|
||
description: "Absolute paths WATCHED for changes (git porcelain --ignored, or a file manifest for non-git dirs). Each is diffed on its own.",
|
||
}),
|
||
write_allowed: Type.Optional(
|
||
Type.Array(Type.String(), {
|
||
description: "Exact subset of roots the child may CHANGE. Required when read_only=false. Must not be nested inside a watched-only root.",
|
||
}),
|
||
),
|
||
facts: Type.Optional(Type.Array(Type.String(), { description: "Verified facts the child may rely on without re-deriving (L2 context)." })),
|
||
files: Type.Optional(Type.Array(Type.String(), { description: "Absolute paths the child should read itself (L1 context)." })),
|
||
commands: Type.Optional(Type.Array(Type.String(), { description: "Exact commands the child may run." })),
|
||
wall_s: Type.Optional(Type.Integer({ description: "Wall-clock budget in seconds (default 600). The child is killed at this point and the task FAILS." })),
|
||
usd: Type.Optional(Type.Number({ description: "Cost budget in USD (default 1.0). Exceeding it FAILS the task after the fact." })),
|
||
}),
|
||
|
||
async execute(_toolCallId, params, signal, onUpdate) {
|
||
const bin = findPiTask();
|
||
if (!ID_RE.test(params.id)) throw new Error(`id must match ${ID_RE} (got ${JSON.stringify(params.id)})`);
|
||
const readOnly = params.read_only ?? true;
|
||
const roots = params.roots.map((r) => path.resolve(r));
|
||
const writeAllowed = params.write_allowed?.map((r) => path.resolve(r));
|
||
const problems = validateRoots(roots, writeAllowed, readOnly);
|
||
if (problems.length) throw new Error(`task spec rejected before launch:\n- ${problems.join("\n- ")}`);
|
||
|
||
const wall = params.wall_s ?? 600;
|
||
const spec = {
|
||
id: params.id,
|
||
goal: params.goal,
|
||
deliverable: params.deliverable,
|
||
effort: params.effort ?? "balanced",
|
||
read_only: readOnly,
|
||
roots,
|
||
...(writeAllowed ? { write_allowed: writeAllowed } : {}),
|
||
context: {
|
||
...(params.facts?.length ? { facts: params.facts } : {}),
|
||
...(params.files?.length ? { files: params.files } : {}),
|
||
...(params.commands?.length ? { commands: params.commands } : {}),
|
||
},
|
||
budget: { wall_s: wall, usd: params.usd ?? 1.0 },
|
||
};
|
||
|
||
// Serialise against earlier in-flight tasks whose roots overlap ours.
|
||
const blockers = running.filter((t) => anyOverlap(t.roots, roots));
|
||
let release!: () => void;
|
||
const me = { id: params.id, roots, done: new Promise<void>((res) => (release = res)) };
|
||
running.push(me);
|
||
try {
|
||
if (blockers.length) {
|
||
onUpdate?.({
|
||
content: [{ type: "text", text: `waiting for overlapping task(s) ${blockers.map((b) => b.id).join(", ")} — overlapping roots run sequentially` }],
|
||
});
|
||
await Promise.all(blockers.map((b) => b.done));
|
||
}
|
||
if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled before launch" }], details: { cancelled: true } };
|
||
|
||
const dir = mkdtempSync(path.join(tmpdir(), "pi-task-spec-"));
|
||
const specPath = path.join(dir, `${params.id}.json`);
|
||
writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
||
onUpdate?.({ content: [{ type: "text", text: `pi-task run ${params.id} (${spec.effort}, ${readOnly ? "read-only" : `writes: ${writeAllowed!.join(", ")}`}, ≤${wall}s)` }] });
|
||
let res: { stdout: string; stderr: string; code: number | null; killed?: boolean };
|
||
try {
|
||
res = await pi.exec(bin, ["run", specPath], { signal, timeout: (wall + 90) * 1000 });
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
|
||
// 0 = PASS, 1 = FAIL verdict — both are results. Anything else is the CLI refusing.
|
||
if (res.code !== 0 && res.code !== 1) {
|
||
throw new Error(
|
||
`pi-task did not produce a verdict (exit ${res.code}${res.killed ? ", killed" : ""}):\n${(res.stderr || res.stdout).slice(-4000)}`,
|
||
);
|
||
}
|
||
const auditDir = /^\s*audit:\s*(\S+)\s*$/m.exec(res.stdout)?.[1];
|
||
let result: Record<string, unknown> | undefined;
|
||
if (auditDir && existsSync(path.join(auditDir, "result.json"))) {
|
||
try {
|
||
result = JSON.parse(readFileSync(path.join(auditDir, "result.json"), "utf8"));
|
||
} catch {}
|
||
}
|
||
let report = res.stdout.trimEnd();
|
||
if (report.length > MAX_REPORT_BYTES) report = `${report.slice(0, MAX_REPORT_BYTES)}\n… (report truncated; full result at ${auditDir ?? "audit dir"}/result.json)`;
|
||
return {
|
||
content: [{ type: "text", text: report }],
|
||
details: { verdict: res.code === 0 ? "PASS" : "FAIL", audit_dir: auditDir, result },
|
||
};
|
||
} finally {
|
||
release();
|
||
const i = running.indexOf(me);
|
||
if (i >= 0) running.splice(i, 1);
|
||
}
|
||
},
|
||
});
|
||
}
|