import * as path from "node:path"; import { DEFAULT_CONCURRENCY, DEFAULT_EVALUATE_ITERATIONS, DEFAULT_LOOP_ITERATIONS, DEFAULT_TIMEOUT_MS, MAX_EVALUATE_ITERATIONS, MAX_GRAPH_NODES, MAX_LOOP_ITERATIONS, MAX_PARALLEL_TASKS, flowError, type FlowDiscovery, type FlowError } from "./types.ts"; import { safePath } from "./sanitize.ts"; // The workflow phase predicates live in validate-workflow.ts; re-exported here // so this module stays the one seam callers validate a call through. export { isWorkflowWorkPhase, workflowHeadlessApprovalRefusal, workflowPhasesRefusal } from "./validate-workflow.ts"; export function parseToolsOverride(tools: string | undefined, fallback: string[] | undefined): string[] | undefined { if (!tools) return fallback; if (tools.trim().toLowerCase() === "default") return undefined; if (tools.trim().toLowerCase() === "none") return []; return tools .split(",") .map((tool) => tool.trim()) .filter(Boolean); } export function appendReturnRequirements(task: string, requirements: string | undefined, requireEvidence: boolean | undefined): string { const sections: string[] = []; if (requirements?.trim()) sections.push(requirements.trim()); if (requireEvidence) { sections.push("Ground every load-bearing claim in concrete evidence: file:line references, command output, citations, or explicit gaps when evidence is unavailable."); } if (sections.length === 0) return task; return [task, "\n## Return requirements", ...sections.map((section) => `- ${section}`)].join("\n"); } /** Compatibility alias for the original helper name; prefer appendReturnRequirements. */ export const appendReturnContract = appendReturnRequirements; export function effectiveTools(discovery: FlowDiscovery, ref: { agent: string; tools?: string }): string[] | undefined | null { const agent = discovery.agents.find((candidate) => candidate.name === ref.agent); if (!agent) return null; return parseToolsOverride(ref.tools, agent.tools); } const MUTATING_TOOLS = ["bash", "edit", "write"]; export function canMutateWorkspace(discovery: FlowDiscovery, ref: { agent: string; tools?: string }): boolean { const tools = effectiveTools(discovery, ref); if (tools === null) return false; // Undefined means "pi defaults", which include bash/edit/write in the coding agent. if (tools === undefined) return true; return tools.some((tool) => MUTATING_TOOLS.includes(tool.toLowerCase())); } /** * Why this ref counts as write-capable, stated in terms of its effective tools. * The refusal message must let a reader see that the toolset — not the agent's * name or prompt — is what classified it, so a name-only retry is visibly futile. */ export function writeCapabilityAttribution(discovery: FlowDiscovery, ref: { agent: string; tools?: string }): string { const tools = effectiveTools(discovery, ref); // Covers both an omitted tools field and an explicit tools:"default". if (tools === undefined) return `${ref.agent} (effective tools are pi defaults, which include ${MUTATING_TOOLS.join("/")})`; const mutating = (tools ?? []).filter((tool) => MUTATING_TOOLS.includes(tool.toLowerCase())); if (mutating.length === 0) { if ((tools ?? []).some((tool) => tool.toLowerCase() === "bash-ro")) return `${ref.agent} (not write-capable: bash-ro is bash under a child-enforced read-only allowlist)`; return `${ref.agent} (not write-capable by its effective tools)`; } return `${ref.agent} (effective tools include ${mutating.join("/")})`; } /** * The spawn gate's predicate: a call that spawns children must justify the * delegation with a non-empty `why`, or the tool refuses it (WHY_REQUIRED) * before any child starts. The selection eval imports this same function to * score admissibility, so the scored rule cannot drift from the enforced one. */ export function spawnJustificationMissing(why: unknown): boolean { return typeof why !== "string" || why.trim().length === 0; } /** * The surfaces that return before the spawn gate — this list must name exactly * the params the flow tool answers ahead of its WHY_REQUIRED check, so the * selection eval exempts the same calls the tool does. Adding a pre-gate * surface to the tool means adding it here too. */ export function nonSpawningFlowCall(params: { list?: unknown; showConfig?: unknown }): boolean { return Boolean(params?.list || params?.showConfig); } export function resolvedCwd(defaultCwd: string, cwd?: string): string { return path.resolve(defaultCwd, cwd ?? defaultCwd); } export function sharedWriteCwdError(discovery: FlowDiscovery, defaultCwd: string, refs: Array<{ agent: string; cwd?: string; tools?: string }>): FlowError | null { const byCwd = new Map(); for (const ref of refs) { const cwd = resolvedCwd(defaultCwd, ref.cwd); byCwd.set(cwd, [...(byCwd.get(cwd) ?? []), writeCapabilityAttribution(discovery, ref)]); } for (const [cwd, attributions] of byCwd) { if (attributions.length > 1) { return flowError( "SHARED_WRITE_CWD", "Multiple write-capable flow agents would share one working directory.", `These agents would run concurrently in ${safePath(cwd)}, which risks conflicting edits in the same checkout: ${attributions.join("; ")}. The effective toolset is what classifies a role as write-capable, so switching to a different agent name changes nothing unless its tools change too.`, `Serialize with concurrency:1, use agents whose effective tools exclude ${MUTATING_TOOLS.join("/")} (bash-ro, read-only-allowlisted bash, stays admissible), or give each writer a distinct cwd/worktree. Pass allowSharedWriteCwd:true only as a last resort, when concurrent writes in one shared checkout are actually intended.`, ); } } return null; } export function validateSharedWriteCwd( discovery: FlowDiscovery, defaultCwd: string, refs: Array<{ agent: string; cwd?: string; tools?: string }>, allowSharedWriteCwd: boolean | undefined, concurrency: number, ): FlowError | null { if (allowSharedWriteCwd) return null; if (concurrency <= 1) return null; const mutating = refs.filter((ref) => canMutateWorkspace(discovery, ref)); if (mutating.length <= 1) return null; return sharedWriteCwdError(discovery, defaultCwd, mutating); } /** A present-but-non-array dependsOn is schema-refused before the handler runs; totality over raw model args means treating it as no deps, never iterating a non-iterable. Exported for graph's plan declaration (modes/graph.ts). */ export function graphDependsOn(node: any): string[] { return Array.isArray(node?.dependsOn) ? node.dependsOn : []; } /** * handleGraph refuses GRAPH_INVALID for every structural defect before its * guard — node count outside 1..MAX_GRAPH_NODES, a node missing string * id/agent/task, a duplicated id, a non-array dependsOn, or a dependsOn * naming no node. A graph failing any of those yields null (the admissibility * mirror and the cycle check stay silent behind that earlier refusal, and * never iterate a hostile-length list); a structurally valid one yields its * nodes. Exported for graph's plan declaration (modes/graph.ts), so the plan * and this refusal read one structural predicate. */ export function validGraphNodes(params: Record): any[] | null { const nodes = params.graph?.nodes; if (!Array.isArray(nodes) || nodes.length === 0 || nodes.length > MAX_GRAPH_NODES) return null; const ids = new Set(); for (const node of nodes) { if (typeof node?.id !== "string" || !node.id || typeof node.agent !== "string" || !node.agent || typeof node.task !== "string" || !node.task || ids.has(node.id)) return null; if (node.dependsOn !== undefined && !Array.isArray(node.dependsOn)) return null; ids.add(node.id); } for (const node of nodes) { for (const dep of graphDependsOn(node)) { if (typeof dep !== "string" || !ids.has(dep)) return null; } } return nodes; } /** * A structurally valid graph with no dependency-free node deadlocks * immediately: handleGraph computes an empty first wave and refuses * GRAPH_CYCLE before any child spawns. The selection eval scores that * refusal so an all-cyclic graph cannot terminate the harness into credit * for delegation that never occurred. */ export function graphCycleRefusal(params: Record): FlowError | null { if (params?.graph === undefined) return null; const nodes = validGraphNodes(params); if (!nodes) return null; if (nodes.some((node) => graphDependsOn(node).length === 0)) return null; return flowError( "GRAPH_CYCLE", "Graph has a cycle or unsatisfied dependency.", "No graph node is dependency-free, so no first wave can ever become runnable.", "Remove cycles and ensure every dependsOn chain eventually reaches a dependency-free node.", ); } /** * handleMonitor's pre-probe validation, as one call-level predicate: a * missing probe command, or a match trigger without a compilable pattern, is * refused MONITOR_INVALID before the probe command ever runs — so the * selection eval can score the refusal and safely let it play out. The * trigger normalization mirrors the handler exactly: anything other than * "failure" or "match" falls back to "success", which needs no pattern. */ export function monitorInvalidRefusal(params: Record): FlowError | null { if (params?.monitor === undefined) return null; const spec = params.monitor ?? {}; const invalid = (message: string, cause: string, fix: string): FlowError => flowError("MONITOR_INVALID", message, cause, fix); if (typeof spec.command !== "string" || !spec.command.trim()) { return invalid("Monitor mode requires a probe command.", "No deterministic observation source was configured.", "Provide monitor.command and a bounded trigger policy."); } const trigger = ["failure", "match"].includes(spec.trigger) ? spec.trigger : "success"; if (trigger === "match") { try { if (!spec.pattern) throw new Error("pattern is required for a match trigger"); new RegExp(spec.pattern, "i"); } catch (cause) { return invalid("Monitor match trigger has an invalid pattern.", cause instanceof Error ? cause.message : String(cause), "Provide a valid JavaScript regular expression in monitor.pattern."); } } return null; } /** * The fan-out bounds handlers enforce before any spawn, as one call-level * predicate: parallel refuses more than MAX_PARALLEL_TASKS tasks * (modes/parallel.ts) and vote refuses more than MAX_PARALLEL_TASKS voters, * explicit or replicated (modes/vote.ts), both with TOO_MANY_TASKS and both * before their shared-write guard runs. The wave mirror above stays silent * for these so the refusal is never mislabeled as the guard; this predicate * is how the admissibility seam scores the refusal itself. */ export function preSpawnFanoutRefusal(params: Record): FlowError | null { const tooMany = (count: number, what: string): FlowError => flowError( "TOO_MANY_TASKS", `Too many ${what} (${count}).`, `At most ${MAX_PARALLEL_TASKS} concurrent children are allowed to prevent runaway subprocess fanout.`, `Use ${MAX_PARALLEL_TASKS} or fewer ${what}.`, ); if (Array.isArray(params?.tasks) && params.tasks.length > MAX_PARALLEL_TASKS) return tooMany(params.tasks.length, "flow tasks"); if (params?.vote !== undefined) { const spec = params.vote ?? {}; if (Array.isArray(spec.voters) && spec.voters.length > MAX_PARALLEL_TASKS) return tooMany(spec.voters.length, "voters"); if (typeof spec.agent === "string" && spec.agent && Number.isFinite(spec.count) && Math.floor(spec.count) > MAX_PARALLEL_TASKS) { return tooMany(Math.floor(spec.count), "voters"); } } return null; } export function validateConcurrency(value: number | undefined): FlowError | null { if (value === undefined) return null; if (!Number.isInteger(value)) { return flowError( "INVALID_CONCURRENCY", `Invalid concurrency: ${value}.`, "Concurrency must be an integer so queueing is deterministic.", `Use an integer from 1 to ${MAX_PARALLEL_TASKS}, or omit concurrency to use ${DEFAULT_CONCURRENCY}.`, ); } if (value < 1 || value > MAX_PARALLEL_TASKS) { return flowError( "INVALID_CONCURRENCY", `Invalid concurrency: ${value}.`, `Concurrency must be between 1 and ${MAX_PARALLEL_TASKS}.`, `Use an integer from 1 to ${MAX_PARALLEL_TASKS}, or omit concurrency to use ${DEFAULT_CONCURRENCY}.`, ); } return null; } export function normalizeTimeout(timeoutMs: number | undefined): number { if (timeoutMs === undefined) return DEFAULT_TIMEOUT_MS; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return DEFAULT_TIMEOUT_MS; return Math.floor(timeoutMs); } export function clampIterations(value: number | undefined): number { if (value === undefined || !Number.isFinite(value)) return DEFAULT_EVALUATE_ITERATIONS; return Math.max(1, Math.min(MAX_EVALUATE_ITERATIONS, Math.floor(value))); } export function clampLoopIterations(value: number | undefined): number { if (value === undefined || !Number.isFinite(value)) return DEFAULT_LOOP_ITERATIONS; return Math.max(1, Math.min(MAX_LOOP_ITERATIONS, Math.floor(value))); } /** Current flow nesting depth from PI_FLOWS_DEPTH, clamped to a non-negative integer so hostile or garbage env values cannot disable the depth guard. */ export function currentFlowDepth(): number { const raw = Number(process.env.PI_FLOWS_DEPTH); return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0; }