/** * Built-in workflows shipped with rpiv-pi. Each workflow's `stages` * insertion order IS its linear stage order — `Object.keys(stages)` gives * the natural read order for previews and traversal alike. * * Route edges use `gate(...)` from `@juicesharp/rpiv-workflow`, which * attaches `.targets` metadata so reachability checks and graph * introspectors can enumerate possible branches without probing. * * These workflows name skills bundled by rpiv-pi (research, design, plan, * implement, validate, code-review, revise, commit). Installing * rpiv-workflow without rpiv-pi means these workflows aren't loaded — * users author their own over their own skills. */ import { readFileSync } from "node:fs"; import { basename, isAbsolute, join } from "node:path"; import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; import { type Artifact, acts, defineWorkflow, eq, fanout, gate, gitCommitOutcome, gt, handleToString, iterate, type Output, type PromptFn, produces, type RunView, type Unit, type Workflow, } from "@juicesharp/rpiv-workflow/registration"; import { StagePreflightError } from "@juicesharp/rpiv-workflow/runner"; // The code-review stage's output schema is no longer declared here — every // code-review stage sources it from the skill's contract `produces.data` // (`blockers_count` required), validated by the runtime output loop via // `effectiveOutputSchema`. One source of truth, in the skill, not copy-pasted // per workflow. Every workflow — build/arch/polish AND vet — routes on the // same numeric gate: `gate("blockers_count", { : gt(0), commit: eq(0) }, "commit")`. /** * A plan's structured `phases:` frontmatter array — the machine-readable phase * enumeration a plan-producing skill (`blueprint`, `plan`) derives from its * `## Phase N:` body headings — is what drives `implement` fanout. The * convention lives here; rpiv-workflow knows nothing about phases. * * Cap: a plan declaring more than 32 phases throws. The rpiv-pi planning skills * cap around 8 phases in practice; 32 leaves headroom for stretch plans without * letting a pathological (or hostile) plan drive an unbounded fanout loop. */ const MAX_PHASES = 32; /** * `## Phase N:` headings — the source of truth a plan's `phases:` frontmatter * array is derived from. Used to verify that derived array, not to enumerate * (enumeration reads the typed `phases:` array). */ const PLAN_PHASE_RE = /^## Phase (\d+):/gm; /** * One parsed entry of a plan's `phases:` array. `entry` carries the whole raw * frontmatter object, so a consumer can read fields beyond `{ n, title }` * without this parser knowing about them. */ interface PhaseRecord { entry: Record; /** From `entry.n`, falling back to the 1-based array position. */ n: number; /** From `entry.title`, or "" when absent. */ title: string; /** 0-based position in the array. */ index: number; /** Total phases in this plan. */ total: number; } /** Read an artifact file, resolving a workflow-relative path against `cwd`. */ const readArtifactFile = (path: string, cwd: string): string => readFileSync(isAbsolute(path) ? path : join(cwd, path), "utf-8"); /** Build the halting `StagePreflightError` shape every phase fanout/iterate guard `throw`s. */ const haltPreflight = (who: string, summary: string, detail: string): StagePreflightError => new StagePreflightError("halt", who, summary, detail, true); /** * Parse a plan's `phases:` frontmatter into records, derive-checked against the * body's `## Phase N:` headings — the source of truth both the single-plan * (`FRONTMATTER_PHASE_FANOUT`) and multi-plan (`PLANS_PHASE_FANOUT`) fanouts * share. A length mismatch means the producer's rebuild step was skipped or the * array went stale; throw rather than dispatch a wrong unit list. `who`/`path` * shape the diagnostic. */ const planPhaseRecords = (content: string, who: string, path: string): readonly PhaseRecord[] => { const { frontmatter } = parseFrontmatter(content); const fm = frontmatter as Record; const raw = fm.phases; const phases = Array.isArray(raw) ? raw : []; const headingCount = [...content.matchAll(PLAN_PHASE_RE)].length; if (phases.length !== headingCount) { throw haltPreflight( who, `${who}: plan ${path} has mismatched phases`, `${who}: plan ${path} frontmatter phases (${phases.length}) ≠ '## Phase N:' headings (${headingCount}) — the derived array is stale against the body`, ); } // The REQUIRED scalar `phase_count` must equal the derived phase count — it // drives the fanout unit count. Fire only when the file declares // plan-ness (has phases OR a phase_count) so a genuinely empty / non-plan file // still degrades to [] (the existing "neither phases nor headings" path); a plan // that declares phases but omits phase_count THROWS (the field is contract-required). if ((phases.length > 0 || fm.phase_count !== undefined) && fm.phase_count !== phases.length) { throw haltPreflight( who, `${who}: plan ${path} has invalid phase_count`, `${who}: plan ${path} frontmatter phase_count (${String(fm.phase_count)}) ≠ phases length (${phases.length}) — rebuild phase_count from the '## Phase N:' headings`, ); } return phases.map((entry, index) => { const e = (entry ?? {}) as Record; return { entry: e, n: typeof e.n === "number" ? e.n : index + 1, title: typeof e.title === "string" ? e.title : "", index, total: phases.length, }; }); }; /** Latest `fs`-handle artifact most recently published under `name` (undefined if none). */ const latestFsArtifact = (state: RunView, name: string): Artifact | undefined => state.named[name]?.at(-1)?.artifacts.find((a) => a.handle.kind === "fs"); /** * Fan `implement` out over the structured `phases:` frontmatter array of the * latest plan published to the named `"plans"` channel. Sourcing from the named * channel (not the rolling primary) makes the stage's `reads: ["plans"]` * declaration semantically honest. Used by every workflow whose `implement` * inherits one plan (ship/build/arch/vet); polish's accumulating multi-plan * variant is `PLANS_PHASE_FANOUT`. */ const FRONTMATTER_PHASE_FANOUT = fanout({ source: "plans", unit: { by: "frontmatter-array", pattern: "phases" }, max: MAX_PHASES, units: ({ state, cwd }) => { const plan = latestFsArtifact(state, "plans"); if (plan?.handle.kind !== "fs") return []; const path = plan.handle.path; let content: string; try { content = readArtifactFile(path, cwd); } catch (err) { throw haltPreflight( "FRONTMATTER_PHASE_FANOUT", `FRONTMATTER_PHASE_FANOUT: plan file not found`, `FRONTMATTER_PHASE_FANOUT: could not read ${path} — ${err instanceof Error ? err.message : String(err)}`, ); } const records = planPhaseRecords(content, "FRONTMATTER_PHASE_FANOUT", path); if (records.length > MAX_PHASES) { throw haltPreflight( "FRONTMATTER_PHASE_FANOUT", `FRONTMATTER_PHASE_FANOUT: plan ${path} exceeds phase limit`, `FRONTMATTER_PHASE_FANOUT: plan ${path} declares ${records.length} phases — exceeds MAX_PHASES (${MAX_PHASES}); split into smaller plans`, ); } const promptPath = handleToString(plan.handle); return records.map((r) => ({ prompt: `${promptPath} Phase ${r.n}: ${r.title}`.trimEnd(), label: `phase ${r.index + 1}/${r.total}`, })); }, }); // =========================================================================== // ship — blueprint → implement → validate → commit // =========================================================================== const shipWorkflow = defineWorkflow({ name: "ship", description: "Fast path with no research or review. Best when the change is small and the approach is obvious. Chain: blueprint → implement → validate → commit.", start: "blueprint", stages: { blueprint: produces(), implement: acts({ loop: FRONTMATTER_PHASE_FANOUT, reads: ["plans"] }), validate: produces(), commit: acts({ outcome: gitCommitOutcome }), }, edges: { blueprint: "implement", implement: "validate", validate: "commit", commit: "stop", }, }); // =========================================================================== // build — research → blueprint → implement → validate → code-review → // (revise → implement → loop) | commit // Loops until code-review reports zero blockers, bounded by the // runner's maxBackwardJumps (default 2 → up to 3 review iterations). // =========================================================================== const buildWorkflow = defineWorkflow({ name: "build", description: "Research-backed feature work with a review loop. Best for medium changes where you want a second pass before committing. Chain: research → blueprint → implement → validate → code-review → (revise loop) → commit.", start: "research", stages: { research: produces(), blueprint: produces(), implement: acts({ loop: FRONTMATTER_PHASE_FANOUT, reads: ["plans"] }), validate: produces(), "code-review": produces(), revise: produces({ reads: ["plans", "reviews"] }), commit: acts({ outcome: gitCommitOutcome }), }, edges: { research: "blueprint", blueprint: "implement", implement: "validate", validate: "code-review", "code-review": gate("blockers_count", { revise: gt(0), commit: eq(0) }, "commit"), // Backward edge: revise → implement re-enters the implement/validate/ // code-review cycle. Bounded by the runner's default maxBackwardJumps // (2), permitting at most 3 review iterations before the guard halts. revise: "implement", commit: "stop", }, }); // =========================================================================== // arch — research → design → plan → implement → validate → code-review → // (design → loop) | commit // Loops the full design/plan/implement/validate/review chain until // code-review reports zero blockers, bounded by the runner's // maxBackwardJumps (default 2 → up to 3 review iterations). // =========================================================================== const archWorkflow = defineWorkflow({ name: "arch", description: "Design-led pipeline for complex changes touching many files or layers. Best when the approach itself needs to be worked out before planning. Chain: research → design → plan → implement → validate → code-review → (design loop) → commit.", start: "research", stages: { research: produces(), design: produces(), plan: produces(), implement: acts({ loop: FRONTMATTER_PHASE_FANOUT, reads: ["plans"] }), validate: produces(), "code-review": produces(), commit: acts({ outcome: gitCommitOutcome }), }, edges: { research: "design", design: "plan", plan: "implement", implement: "validate", validate: "code-review", // Backward edge: code-review → design re-enters the full // design/plan/implement/validate/review cycle. Bounded by the // runner's default maxBackwardJumps (2), permitting at most 3 // review iterations before the guard halts. "code-review": gate("blockers_count", { design: gt(0), commit: eq(0) }, "commit"), commit: "stop", }, }); // =========================================================================== // vet — code-review → (blueprint → implement → validate → loop) | commit // Examine existing changes; if not approved, blueprint a fix plan, // implement it, validate, and re-review. Loops until approved. // =========================================================================== const vetWorkflow = defineWorkflow({ name: "vet", description: "Examine existing changes for approval; loop a fix cycle if not approved. Best when a diff already exists (yours or a teammate's) and you want a structured review with optional repair. Chain: code-review → (blueprint → implement → validate → loop) → commit.", start: "code-review", stages: { "code-review": produces(), blueprint: produces(), implement: acts({ loop: FRONTMATTER_PHASE_FANOUT, reads: ["plans"] }), validate: produces(), commit: acts({ outcome: gitCommitOutcome }), }, edges: { // Same numeric gate as build/arch/polish: zero remaining blockers → // commit; any blockers → loop a fix pass through blueprint. The // `blockers_count` field is sourced + validated from the code-review // contract (`produces.data`, required), so a missing field fails // output validation rather than silently routing. "code-review": gate("blockers_count", { blueprint: gt(0), commit: eq(0) }, "commit"), blueprint: "implement", implement: "validate", // Backward edge: validate → code-review creates the review-fix loop. // Bounded by the runner's default maxBackwardJumps (2), permitting at // most 3 review iterations (initial + 2 retries) before the guard halts. validate: "code-review", commit: "stop", }, }); // =========================================================================== // polish — architecture-review → blueprint (iterate, per review phase) → // implement → validate → code-review → (blueprint loop) | commit // For a large architecture review that can't be planned in one pass: // plan each review phase sequentially, each plan building on the // ones before it, then implement/validate/review the lot. // =========================================================================== /** * `### Phase N — name` headings — the source of truth the review's `phases:` * frontmatter array is derived from. Used to verify that derived array, not to * enumerate (enumeration reads the typed `phases:` array). */ const REVIEW_PHASE_RE = /^### Phase (\d+) — (.+)$/gm; /** Number of structured `phases` in the latest architecture review's frontmatter (0 if none). */ const reviewPhaseCount = (state: RunView, cwd: string): number => { const review = latestFsArtifact(state, "architecture-reviews"); if (review?.handle.kind !== "fs") return 0; const { frontmatter } = parseFrontmatter(readArtifactFile(review.handle.path, cwd)); const raw = (frontmatter as Record).phases; return Array.isArray(raw) ? raw.length : 0; }; /** * The plans from the most recent blueprint pass. blueprint's iterate stage * pushes one `Output` per review phase into `state.named["plans"]`; on a * corrective loop it re-plans every phase, so keep only the last `phaseCount` * (the review's phase count) and drop the stale generation. Shared by the * implement fanout and the validate prompt so both see the same plan set. */ const latestPlans = (state: RunView, cwd: string): readonly Output[] => { const plans = state.named.plans ?? []; const phaseCount = reviewPhaseCount(state, cwd); return phaseCount > 0 && plans.length > phaseCount ? plans.slice(-phaseCount) : plans; }; /** Phase number for a `phases:` entry, falling back to its 1-based position. */ const phaseNum = (entry: unknown, index: number): number => { const n = (entry as { n?: unknown } | undefined)?.n; return typeof n === "number" ? n : index + 1; }; /** `depends_on` phase numbers an entry declares (empty when absent). */ const phaseDeps = (entry: unknown): number[] => { const raw = (entry as { depends_on?: unknown } | undefined)?.depends_on; return Array.isArray(raw) ? raw.filter((d): d is number => typeof d === "number") : []; }; /** * Per-review-phase blueprint generator (the `iterate` dual of * FRONTMATTER_PHASE_FANOUT). One blueprint pass per review phase, enumerating the * review's structured `phases:` array (derived by architecture-review from its * `### Phase N — name` headings). blueprint writes its own natural plan file; the * `plans` collector captures whatever path it announces. * * Each phase reads only the plans of the phases it `depends_on` (vs. every prior * plan) — accurate context, and the seam a future scheduler could parallelize on. * `blast_radius`/`effort` tag the label. Absent `depends_on` falls back to all * prior plans. * * Guards (first call): the array's length must equal the `### Phase N — name` * heading count (stale derive), and every `depends_on` must reference an earlier * phase (exists, no self/forward/cyclic edge against body order). */ const REVIEW_PHASE_ITERATE = iterate({ source: "architecture-reviews", unit: { by: "frontmatter-array", pattern: "phases" }, max: MAX_PHASES, next: ({ artifact, state, accumulated, cwd }) => { // Source the review from the named registry — robust to corrective re-entry, // where the rolling primary is the latest code-review doc, not the review. const review = latestFsArtifact(state, "architecture-reviews") ?? artifact; if (review?.handle.kind !== "fs") return null; const reviewPath = review.handle.path; // captured: narrowing is lost inside nested closures below const content = readArtifactFile(reviewPath, cwd); const { frontmatter } = parseFrontmatter(content); const raw = (frontmatter as Record).phases; const phases = Array.isArray(raw) ? raw : []; const i = accumulated.length; if (i === 0) { const headingCount = [...content.matchAll(REVIEW_PHASE_RE)].length; if (phases.length !== headingCount) { throw haltPreflight( "REVIEW_PHASE_ITERATE", `REVIEW_PHASE_ITERATE: review ${reviewPath} has mismatched phases`, `REVIEW_PHASE_ITERATE: review ${reviewPath} frontmatter phases (${phases.length}) ≠ '### Phase N —' headings (${headingCount}) — the derived array is stale against the body`, ); } const indexByN = new Map(phases.map((e, idx) => [phaseNum(e, idx), idx])); phases.forEach((e, idx) => { for (const d of phaseDeps(e)) { const di = indexByN.get(d); if (di === undefined) throw haltPreflight( "REVIEW_PHASE_ITERATE", `REVIEW_PHASE_ITERATE: review ${reviewPath} has invalid depends_on`, `REVIEW_PHASE_ITERATE: review ${reviewPath} phase ${phaseNum(e, idx)} depends_on ${d}, which is not a declared phase`, ); if (di >= idx) throw haltPreflight( "REVIEW_PHASE_ITERATE", `REVIEW_PHASE_ITERATE: review ${reviewPath} has cyclic dependency`, `REVIEW_PHASE_ITERATE: review ${reviewPath} phase ${phaseNum(e, idx)} depends_on ${d}, which is not an earlier phase (self/forward/cyclic dependency)`, ); } }); } if (i >= phases.length) return null; // every phase planned → terminate const entry = (phases[i] ?? {}) as { title?: unknown; blast_radius?: unknown; effort?: unknown }; const n = phaseNum(entry, i); const title = typeof entry.title === "string" ? entry.title : ""; // accumulated[j] is phase j's output — map each prior phase number to its plans. const priorByN = new Map(); accumulated.forEach((o, j) => { const paths = o.artifacts.filter((a) => a.handle.kind === "fs").map((a) => handleToString(a.handle)); if (paths.length) priorByN.set(phaseNum(phases[j], j), paths); }); const deps = phaseDeps(phases[i]); const prior = deps.length ? deps.flatMap((d) => priorByN.get(d) ?? []) : [...priorByN.values()].flat(); // On a corrective pass the latest code-review is in `reviews`; fold its blockers in. const feedback = latestFsArtifact(state, "reviews"); let prompt = `${handleToString(review.handle)} Implement Phase ${n}: ${title}`; if (prior.length) prompt += `\nPrior phase plans (read first; build on them, don't duplicate): ${prior.join(", ")}`; if (feedback?.handle.kind === "fs") prompt += `\nAddress the blockers in the latest code review: ${handleToString(feedback.handle)}`; const tags = [entry.effort, entry.blast_radius].filter((t): t is string => typeof t === "string"); let label = `phase ${i + 1}/${phases.length} — ${title}`; if (tags.length) label += ` [${tags.join(", ")}]`; return { prompt, label, id: `phase-${n}` }; }, }); /** * Fan implement out over the `phases:` array of EVERY plan in the latest * blueprint pass (see `latestPlans` for the corrective-loop dedup), so blueprint * keeps its natural timestamped filenames. The single-plan * `FRONTMATTER_PHASE_FANOUT` is the same over one inherited plan; both share * `planPhaseRecords`. MAX_PHASES is enforced on the aggregate unit count, since * polish fans one implement pass over the whole plan set. */ const PLANS_PHASE_FANOUT = fanout({ source: "plans", unit: { by: "frontmatter-array", pattern: "phases" }, max: MAX_PHASES, units: ({ state, cwd }) => { const units: Unit[] = []; for (const out of latestPlans(state, cwd)) { for (const a of out.artifacts) { if (a.handle.kind !== "fs") continue; const path = a.handle.path; const content = readArtifactFile(path, cwd); const promptPath = handleToString(a.handle); for (const r of planPhaseRecords(content, "PLANS_PHASE_FANOUT", path)) { units.push({ prompt: `${promptPath} Phase ${r.n}: ${r.title}`.trimEnd(), label: `${basename(path)} P${r.n}`, }); } } } if (units.length > MAX_PHASES) { throw haltPreflight( "PLANS_PHASE_FANOUT", `PLANS_PHASE_FANOUT: phase limit exceeded`, `PLANS_PHASE_FANOUT: ${units.length} phases exceeds MAX_PHASES (${MAX_PHASES})`, ); } return units; }, }); /** * Hand the single validate session EVERY plan from the latest blueprint pass * (`latestPlans`). The runner's default rolling-primary — and a plain * `reads: ["plans"]`, which only reads `.at(-1)` — would point validate at the * LAST plan alone, leaving earlier phases unvalidated. A `prompt` stage owns * its whole message, so the `/skill:validate` prefix is explicit. */ const VALIDATE_PLANS_PROMPT: PromptFn = ({ state, cwd }) => { const paths = latestPlans(state, cwd) .flatMap((o) => o.artifacts) .filter((a) => a.handle.kind === "fs") .map((a) => handleToString(a.handle)); return `/skill:validate ${paths.join(" ")}`; }; const polishWorkflow = defineWorkflow({ name: "polish", description: "Architecture-review-driven polish: review → per-phase blueprint (sequential, accumulating) → implement → validate → code-review → commit. Best when a large architecture review can't be planned in one pass and each phase's plan must build on the ones before it.", start: "architecture-review", stages: { "architecture-review": produces(), blueprint: produces({ loop: REVIEW_PHASE_ITERATE }), implement: acts({ loop: PLANS_PHASE_FANOUT, reads: ["plans"] }), validate: produces({ prompt: VALIDATE_PLANS_PROMPT }), "code-review": produces(), commit: acts({ outcome: gitCommitOutcome }), }, edges: { "architecture-review": "blueprint", blueprint: "implement", implement: "validate", validate: "code-review", // Backward edge: code-review → blueprint re-plans (implement needs a plan). // The iterate stage re-runs over every review phase; bounded by the // runner's default maxBackwardJumps (2 → up to 3 review iterations). "code-review": gate("blockers_count", { blueprint: gt(0), commit: eq(0) }, "commit"), commit: "stop", }, }); // =========================================================================== // pr-triage — pr-triage → security-gate → stop // Read-only front door for an incoming GitHub PR. The `pr-triage` skill // fetches the PR thread, assesses the diff against whatever standard the repo // actually carries, writes a triage artifact, and recommends a triage // disposition (Review / Request changes / Hold / Decline) as a plain next // action. The `security-gate` script stage reads the skill's // `security_flag` and HALTS the run on a BLOCK (≥ 2) via `haltPreflight` — the // "security gate first, before any checkout" posture — while SAFE/REVIEW (< 2) // fall through to `stop` carrying the verdict. // // It terminates rather than dispatching a follow-up workflow: triage gates entry // to review, it does not merge. The disposition is a plain action; `vet` (the // review stage) is offered as optional sugar under Review. Only the security gate // is enforced by the graph. A linear guard stage (not a `gate(...)` // edge) keeps the halt off the data-routing path — the throw lives inside the // script, read from the prior stage's output. // =========================================================================== /** BLOCK tier the pr-triage `security_flag` contract field emits (0 SAFE · 1 REVIEW · 2 BLOCK). */ const PR_TRIAGE_BLOCK = 2; const prTriageWorkflow = defineWorkflow({ name: "pr-triage", description: "Read-only triage of a GitHub PR before any review effort. Fetches the PR thread, assesses the diff against the repo's own standards, writes a triage artifact, and recommends a triage disposition (Review / Request changes / Hold / Decline). A security BLOCK halts the run before any checkout. Best as the entry point for an incoming PR. Chain: pr-triage → security-gate → stop.", start: "pr-triage", stages: { "pr-triage": produces(), // Skillless guard: read the triage skill's `security_flag` from the prior // stage's output and halt on BLOCK. A script stage (not a skill) — no LLM, // no session — so the gate is free. On SAFE/REVIEW it is a no-op side effect // and the chain advances to `stop`. "security-gate": acts.script({ run: ({ input }) => { const flag = Number((input?.data as { security_flag?: unknown } | undefined)?.security_flag); if (Number.isNaN(flag) || flag >= PR_TRIAGE_BLOCK) { throw haltPreflight( "pr-triage", "pr-triage: security BLOCK — do not proceed", `pr-triage: security_flag=${flag} (BLOCK) — the PR diff carries a high-confidence security risk. Resolve it before any checkout or review; see the triage artifact for the traced finding.`, ); } }, }), }, edges: { "pr-triage": "security-gate", "security-gate": "stop", }, }); // =========================================================================== // Exports // =========================================================================== export const builtInWorkflows: readonly Workflow[] = [ shipWorkflow, buildWorkflow, archWorkflow, vetWorkflow, polishWorkflow, prTriageWorkflow, ];