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:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user